mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
378
Yattee/Models/Video.swift
Normal file
378
Yattee/Models/Video.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user