Files
yattee/Yattee/Models/Video.swift
2026-02-08 18:33:56 +01:00

379 lines
12 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
}
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
}
}
// 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)
}
}