mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
Persistence models (local playlists, watch history, bookmarks, downloads) store only the best advertised thumbnail URL - usually maxresdefault.jpg, which doesn't exist for many older videos and 404s. Live API results survive this because views fall back through the full quality chain, but toVideo() rebuilt videos with that single dead URL, leaving placeholder covers. Reconstruct the quality fallback chain from the stored YouTube-style URL at read time (Thumbnail.fallbackChain), and rewrite playlist list covers to the always-available hqdefault variant (Thumbnail.reliableURL), since those views render a single URL without fallback. Also expand the fabricated maxres URL in PipedAPI into a full chain.
437 lines
15 KiB
Swift
437 lines
15 KiB
Swift
//
|
|
// Video.swift
|
|
// Yattee
|
|
//
|
|
// Core video model representing a video from any source.
|
|
//
|
|
|
|
@preconcurrency import Foundation
|
|
|
|
/// Represents a video from any content source.
|
|
struct Video: Identifiable, Codable, Sendable {
|
|
/// Unique identifier combining source and video ID.
|
|
let id: VideoID
|
|
|
|
/// The video title.
|
|
let title: String
|
|
|
|
/// The video description (may be truncated in list views).
|
|
let description: String?
|
|
|
|
/// The channel/author information.
|
|
let author: Author
|
|
|
|
/// Duration in seconds.
|
|
let duration: TimeInterval
|
|
|
|
/// When the video was published.
|
|
let publishedAt: Date?
|
|
|
|
/// Human-readable published date from the API.
|
|
let publishedText: String?
|
|
|
|
/// View count if available.
|
|
let viewCount: Int?
|
|
|
|
/// Like count if available.
|
|
let likeCount: Int?
|
|
|
|
/// Available thumbnails.
|
|
let thumbnails: [Thumbnail]
|
|
|
|
/// Whether this is a live stream.
|
|
let isLive: Bool
|
|
|
|
/// Whether this is an upcoming premiere/stream.
|
|
let isUpcoming: Bool
|
|
|
|
/// Scheduled start time for upcoming content.
|
|
let scheduledStartTime: Date?
|
|
|
|
/// Related/recommended videos (populated from video details API).
|
|
let relatedVideos: [Video]?
|
|
|
|
// MARK: - Explicit Initializer
|
|
|
|
init(
|
|
id: VideoID,
|
|
title: String,
|
|
description: String?,
|
|
author: Author,
|
|
duration: TimeInterval,
|
|
publishedAt: Date?,
|
|
publishedText: String?,
|
|
viewCount: Int?,
|
|
likeCount: Int?,
|
|
thumbnails: [Thumbnail],
|
|
isLive: Bool,
|
|
isUpcoming: Bool,
|
|
scheduledStartTime: Date?,
|
|
relatedVideos: [Video]? = nil
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.description = description
|
|
self.author = author
|
|
self.duration = duration
|
|
self.publishedAt = publishedAt
|
|
self.publishedText = publishedText
|
|
self.viewCount = viewCount
|
|
self.likeCount = likeCount
|
|
self.thumbnails = thumbnails
|
|
self.isLive = isLive
|
|
self.isUpcoming = isUpcoming
|
|
self.scheduledStartTime = scheduledStartTime
|
|
self.relatedVideos = relatedVideos
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var bestThumbnail: Thumbnail? {
|
|
thumbnails.sorted { $0.quality > $1.quality }.first
|
|
}
|
|
|
|
/// Thumbnail URLs ordered best-quality-first.
|
|
///
|
|
/// Used as a fallback chain: backends (and YouTube's CDN) often advertise
|
|
/// `maxres`/`sddefault` variants that don't actually exist for a given video
|
|
/// and 404. Consumers try these in order and drop to the next when one fails.
|
|
var thumbnailURLsByQuality: [URL] {
|
|
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
|
|
}
|
|
|
|
var formattedDuration: String {
|
|
guard !isLive else { return "LIVE" }
|
|
guard duration > 0 else { return "" }
|
|
|
|
let hours = Int(duration) / 3600
|
|
let minutes = (Int(duration) % 3600) / 60
|
|
let seconds = Int(duration) % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
var formattedViewCount: String? {
|
|
guard let viewCount else { return nil }
|
|
return CountFormatter.compact(viewCount)
|
|
}
|
|
|
|
/// Formatted published date, preferring parsed Date over API-provided text.
|
|
var formattedPublishedDate: String? {
|
|
if let publishedAt {
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .full
|
|
return formatter.localizedString(for: publishedAt, relativeTo: Date())
|
|
}
|
|
return publishedText
|
|
}
|
|
|
|
/// URL for sharing this video.
|
|
var shareURL: URL {
|
|
switch id.source {
|
|
case .global(let provider):
|
|
if provider == ContentSource.youtubeProvider {
|
|
return URL(string: "https://www.youtube.com/watch?v=\(id.videoID)")!
|
|
}
|
|
// Future: handle other global providers
|
|
return URL(string: "https://www.youtube.com/watch?v=\(id.videoID)")!
|
|
case .federated(let provider, let instance):
|
|
if provider == ContentSource.peertubeProvider {
|
|
return URL(string: "\(instance.absoluteString)/w/\(id.videoID)")!
|
|
}
|
|
// Future: handle other federated providers
|
|
return instance.appendingPathComponent("videos/\(id.videoID)")
|
|
case .extracted(_, let originalURL):
|
|
return originalURL
|
|
}
|
|
}
|
|
|
|
/// The ContentSource for the author's channel.
|
|
/// For federated content, the author may be on a different instance than the video.
|
|
var authorSource: ContentSource {
|
|
switch id.source {
|
|
case .global:
|
|
return id.source
|
|
case .federated(let provider, let videoInstance):
|
|
// For PeerTube, the author might be on a different federated instance
|
|
if let authorInstance = author.instance, authorInstance.host != videoInstance.host {
|
|
return .federated(provider: provider, instance: authorInstance)
|
|
}
|
|
return id.source
|
|
case .extracted:
|
|
return id.source
|
|
}
|
|
}
|
|
|
|
/// Whether this video supports comments via the API.
|
|
/// Currently only YouTube videos support comments through Invidious.
|
|
var supportsComments: Bool {
|
|
if case .global(let provider) = id.source {
|
|
return provider == ContentSource.youtubeProvider
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Whether this video supports detailed statistics (views, likes, date) via API.
|
|
/// Extracted videos (yt-dlp sources) don't have API stats available.
|
|
var supportsAPIStats: Bool {
|
|
switch id.source {
|
|
case .global, .federated:
|
|
return true
|
|
case .extracted:
|
|
return false
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Video ID
|
|
|
|
/// Unique identifier for a video, combining source and video ID.
|
|
struct VideoID: Codable, Hashable, Sendable {
|
|
/// The content source.
|
|
let source: ContentSource
|
|
|
|
/// The video ID within that source.
|
|
let videoID: String
|
|
|
|
/// For PeerTube, videos can be identified by UUID as well.
|
|
let uuid: String?
|
|
|
|
init(source: ContentSource, videoID: String, uuid: String? = nil) {
|
|
self.source = source
|
|
self.videoID = videoID
|
|
self.uuid = uuid
|
|
}
|
|
|
|
/// Creates a global video ID (e.g., YouTube).
|
|
static func global(_ videoID: String, provider: String = ContentSource.youtubeProvider) -> VideoID {
|
|
VideoID(source: .global(provider: provider), videoID: videoID)
|
|
}
|
|
|
|
/// Creates a federated video ID (e.g., PeerTube).
|
|
static func federated(_ videoID: String, provider: String = ContentSource.peertubeProvider, instance: URL, uuid: String? = nil) -> VideoID {
|
|
VideoID(source: .federated(provider: provider, instance: instance), videoID: videoID, uuid: uuid)
|
|
}
|
|
|
|
/// Creates an extracted video ID for sites supported by yt-dlp.
|
|
static func extracted(_ videoID: String, extractor: String, originalURL: URL) -> VideoID {
|
|
VideoID(source: .extracted(extractor: extractor, originalURL: originalURL), videoID: videoID)
|
|
}
|
|
}
|
|
|
|
extension VideoID: Identifiable {
|
|
var id: String {
|
|
switch source {
|
|
case .global(let provider):
|
|
return "global:\(provider):\(videoID)"
|
|
case .federated(let provider, let instance):
|
|
return "federated:\(provider):\(instance.host ?? ""):\(videoID)"
|
|
case .extracted(let extractor, _):
|
|
return "extracted:\(extractor):\(videoID)"
|
|
}
|
|
}
|
|
|
|
/// Whether this video is from an SMB media source.
|
|
var isSMBSource: Bool {
|
|
if case .extracted(let extractor, _) = source {
|
|
return extractor == MediaFile.smbProvider
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Author
|
|
|
|
/// Represents a video author/channel.
|
|
struct Author: Codable, Hashable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let thumbnailURL: URL?
|
|
let subscriberCount: Int?
|
|
|
|
/// For PeerTube, the instance where this author is hosted.
|
|
let instance: URL?
|
|
|
|
/// For external sources, the channel/author URL for navigation.
|
|
let url: URL?
|
|
|
|
/// Whether real channel info was returned from the server (vs fallback to extractor name).
|
|
let hasRealChannelInfo: Bool
|
|
|
|
init(id: String, name: String, thumbnailURL: URL? = nil, subscriberCount: Int? = nil, instance: URL? = nil, url: URL? = nil, hasRealChannelInfo: Bool = true) {
|
|
self.id = id
|
|
self.name = name
|
|
self.thumbnailURL = thumbnailURL
|
|
self.subscriberCount = subscriberCount
|
|
self.instance = instance
|
|
self.url = url
|
|
self.hasRealChannelInfo = hasRealChannelInfo
|
|
}
|
|
|
|
var formattedSubscriberCount: String? {
|
|
guard let subscriberCount else { return nil }
|
|
return CountFormatter.compact(subscriberCount) + " " + String(localized: "channel.subscribers")
|
|
}
|
|
}
|
|
|
|
// MARK: - Thumbnail
|
|
|
|
/// Represents a video thumbnail.
|
|
struct Thumbnail: Codable, Hashable, Sendable {
|
|
let url: URL
|
|
let quality: Quality
|
|
let width: Int?
|
|
let height: Int?
|
|
|
|
enum Quality: String, Codable, Comparable, Sendable {
|
|
case `default`
|
|
case medium
|
|
case high
|
|
case standard
|
|
case maxres
|
|
|
|
static func < (lhs: Quality, rhs: Quality) -> Bool {
|
|
let order: [Quality] = [.default, .medium, .high, .standard, .maxres]
|
|
guard let lIndex = order.firstIndex(of: lhs),
|
|
let rIndex = order.firstIndex(of: rhs) else {
|
|
return false
|
|
}
|
|
return lIndex < rIndex
|
|
}
|
|
}
|
|
|
|
init(url: URL, quality: Quality = .default, width: Int? = nil, height: Int? = nil) {
|
|
self.url = url
|
|
self.quality = quality
|
|
self.width = width
|
|
self.height = height
|
|
}
|
|
|
|
/// Expands a single stored YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`)
|
|
/// into a best-first fallback chain. Persistence models (playlists, watch history,
|
|
/// bookmarks) store only the best advertised URL — usually `maxresdefault.jpg`,
|
|
/// which doesn't exist for many older videos and 404s. Reconstructing the
|
|
/// lower-quality variants lets thumbnail views fall back the same way they do
|
|
/// for live API results. Non-matching URLs get a single-entry chain.
|
|
/// Rewrites a YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`) to the
|
|
/// `hqdefault.jpg` variant, which exists for effectively every video (unlike
|
|
/// `maxresdefault`/`sddefault`, which 404 for many older uploads). Use where a
|
|
/// single URL is displayed without fallback, e.g. playlist covers. Non-matching
|
|
/// URLs are returned unchanged.
|
|
static func reliableURL(for url: URL?) -> URL? {
|
|
guard let url else { return nil }
|
|
let variants = ["maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg", "default.jpg"]
|
|
let path = url.path
|
|
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
|
|
let match = variants.first(where: { path.hasSuffix($0) }),
|
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return url
|
|
}
|
|
components.path = String(path.dropLast(match.count)) + "hqdefault.jpg"
|
|
return components.url ?? url
|
|
}
|
|
|
|
static func fallbackChain(for url: URL?) -> [Thumbnail] {
|
|
guard let url else { return [] }
|
|
let variants: [(suffix: String, quality: Quality)] = [
|
|
("maxresdefault.jpg", .maxres),
|
|
("sddefault.jpg", .standard),
|
|
("hqdefault.jpg", .high),
|
|
("mqdefault.jpg", .medium),
|
|
("default.jpg", .default),
|
|
]
|
|
let path = url.path
|
|
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
|
|
let current = variants.first(where: { path.hasSuffix($0.suffix) }),
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return [Thumbnail(url: url, quality: .medium)]
|
|
}
|
|
let basePath = String(path.dropLast(current.suffix.count))
|
|
return variants.compactMap { variant in
|
|
guard variant.quality <= current.quality else { return nil }
|
|
var variantComponents = components
|
|
variantComponents.path = basePath + variant.suffix
|
|
guard let variantURL = variantComponents.url else { return nil }
|
|
return Thumbnail(url: variantURL, quality: variant.quality)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Support
|
|
|
|
extension Video {
|
|
/// A sample video for SwiftUI previews.
|
|
static var preview: Video {
|
|
Video(
|
|
id: .global("dQw4w9WgXcQ"),
|
|
title: "Sample Video Title",
|
|
description: "This is a sample video description for preview purposes.",
|
|
author: Author(id: "UC123", name: "Sample Channel"),
|
|
duration: 212,
|
|
publishedAt: Date().addingTimeInterval(-86400 * 3),
|
|
publishedText: "3 days ago",
|
|
viewCount: 1_234_567,
|
|
likeCount: 50_000,
|
|
thumbnails: [
|
|
Thumbnail(
|
|
url: URL(string: "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg")!,
|
|
quality: .maxres,
|
|
width: 1280,
|
|
height: 720
|
|
)
|
|
],
|
|
isLive: false,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
|
|
/// A sample live video for SwiftUI previews.
|
|
static var livePreview: Video {
|
|
Video(
|
|
id: .global("live123"),
|
|
title: "Live Stream Example",
|
|
description: "This is a live stream.",
|
|
author: Author(id: "UC456", name: "Live Channel"),
|
|
duration: 0,
|
|
publishedAt: Date(),
|
|
publishedText: "Started streaming",
|
|
viewCount: 5_432,
|
|
likeCount: nil,
|
|
thumbnails: [
|
|
Thumbnail(
|
|
url: URL(string: "https://i.ytimg.com/vi/live123/maxresdefault.jpg")!,
|
|
quality: .maxres
|
|
)
|
|
],
|
|
isLive: true,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Equatable & Hashable
|
|
|
|
extension Video: Equatable {
|
|
/// Custom Equatable implementation that only compares by ID for performance.
|
|
/// Two videos with the same ID are considered equal during SwiftUI diffing.
|
|
/// This avoids expensive comparisons of all properties (including nested Author and [Thumbnail]).
|
|
static func == (lhs: Video, rhs: Video) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
|
|
extension Video: Hashable {
|
|
/// Custom Hashable implementation consistent with Equatable.
|
|
/// Only hashes the ID since equality is determined by ID alone.
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
}
|
|
}
|