mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 15:21:29 +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.
306 lines
8.8 KiB
Swift
306 lines
8.8 KiB
Swift
//
|
|
// LocalPlaylist.swift
|
|
// Yattee
|
|
//
|
|
// SwiftData model for user-created local playlists.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
/// Represents a user-created local playlist.
|
|
@Model
|
|
final class LocalPlaylist {
|
|
/// Unique identifier for the playlist.
|
|
var id: UUID = UUID()
|
|
|
|
/// The playlist title.
|
|
var title: String = ""
|
|
|
|
/// Optional description.
|
|
var playlistDescription: String?
|
|
|
|
/// When the playlist was created.
|
|
var createdAt: Date = Date()
|
|
|
|
/// When the playlist was last modified.
|
|
var updatedAt: Date = Date()
|
|
|
|
/// Whether this playlist is a placeholder awaiting sync.
|
|
/// Placeholder playlists are created when playlist items arrive before their parent playlist.
|
|
var isPlaceholder: Bool = false
|
|
|
|
/// The videos in this playlist (ordered). Optional for CloudKit compatibility.
|
|
@Relationship(deleteRule: .cascade, inverse: \LocalPlaylistItem.playlist)
|
|
var items: [LocalPlaylistItem]? = []
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
title: String,
|
|
description: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.playlistDescription = description
|
|
self.createdAt = Date()
|
|
self.updatedAt = Date()
|
|
self.items = []
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Number of videos in the playlist.
|
|
var videoCount: Int {
|
|
(items ?? []).count
|
|
}
|
|
|
|
/// Total duration of all videos.
|
|
var totalDuration: TimeInterval {
|
|
(items ?? []).reduce(0) { $0 + $1.duration }
|
|
}
|
|
|
|
/// Formatted total duration.
|
|
var formattedTotalDuration: String {
|
|
let total = Int(totalDuration)
|
|
let hours = total / 3600
|
|
let minutes = (total % 3600) / 60
|
|
|
|
if hours > 0 {
|
|
return "\(hours)h \(minutes)m"
|
|
} else {
|
|
return "\(minutes) min"
|
|
}
|
|
}
|
|
|
|
/// The first video's thumbnail URL for display.
|
|
///
|
|
/// Rewritten to the always-available `hqdefault` variant because the stored
|
|
/// URL is the best advertised quality (often `maxresdefault`), which 404s
|
|
/// for many older videos and would leave the cover blank.
|
|
var thumbnailURL: URL? {
|
|
Thumbnail.reliableURL(
|
|
for: (items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
|
.first?
|
|
.thumbnailURL
|
|
)
|
|
}
|
|
|
|
/// Sorted items by order.
|
|
var sortedItems: [LocalPlaylistItem] {
|
|
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
/// Adds a video to the playlist.
|
|
func addVideo(_ video: Video) {
|
|
let maxOrder = (items ?? []).map(\.sortOrder).max() ?? -1
|
|
let item = LocalPlaylistItem.from(video: video, sortOrder: maxOrder + 1)
|
|
item.playlist = self
|
|
if items == nil {
|
|
items = []
|
|
}
|
|
items?.append(item)
|
|
updatedAt = Date()
|
|
}
|
|
|
|
/// Removes a video from the playlist.
|
|
func removeVideo(at index: Int) {
|
|
guard index < sortedItems.count else { return }
|
|
let item = sortedItems[index]
|
|
items?.removeAll { $0.id == item.id }
|
|
updatedAt = Date()
|
|
}
|
|
|
|
/// Checks if a video is already in the playlist.
|
|
func contains(videoID: String) -> Bool {
|
|
(items ?? []).contains { $0.videoID == videoID }
|
|
}
|
|
}
|
|
|
|
/// Represents a video item within a local playlist.
|
|
@Model
|
|
final class LocalPlaylistItem {
|
|
/// Unique identifier.
|
|
var id: UUID = UUID()
|
|
|
|
/// The parent playlist.
|
|
var playlist: LocalPlaylist?
|
|
|
|
/// Sort order within the playlist.
|
|
var sortOrder: Int = 0
|
|
|
|
// MARK: - Video Identity
|
|
|
|
/// The video ID string.
|
|
var videoID: String = ""
|
|
|
|
/// The content source raw value ("global", "federated", "extracted").
|
|
var sourceRawValue: String = "global"
|
|
|
|
/// For global sources: the provider name (e.g., "youtube", "dailymotion").
|
|
var globalProvider: String?
|
|
|
|
/// For PeerTube: the instance URL string.
|
|
var instanceURLString: String?
|
|
|
|
/// For PeerTube: the UUID.
|
|
var peertubeUUID: String?
|
|
|
|
/// For external sources: the extractor name (e.g., "vimeo", "twitter").
|
|
var externalExtractor: String?
|
|
|
|
/// For external sources: the original URL string.
|
|
var externalURLString: String?
|
|
|
|
// MARK: - Video Metadata
|
|
|
|
/// The video title.
|
|
var title: String = ""
|
|
|
|
/// The channel/author name.
|
|
var authorName: String = ""
|
|
|
|
/// The channel/author ID.
|
|
var authorID: String = ""
|
|
|
|
/// Video duration in seconds.
|
|
var duration: TimeInterval = 0
|
|
|
|
/// Thumbnail URL string.
|
|
var thumbnailURLString: String?
|
|
|
|
/// Whether this is a live stream.
|
|
var isLive: Bool = false
|
|
|
|
/// When this item was added.
|
|
var addedAt: Date = Date()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
sortOrder: Int,
|
|
videoID: String,
|
|
sourceRawValue: String,
|
|
globalProvider: String? = nil,
|
|
instanceURLString: String? = nil,
|
|
peertubeUUID: String? = nil,
|
|
externalExtractor: String? = nil,
|
|
externalURLString: String? = nil,
|
|
title: String,
|
|
authorName: String,
|
|
authorID: String,
|
|
duration: TimeInterval,
|
|
thumbnailURLString: String? = nil,
|
|
isLive: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.sortOrder = sortOrder
|
|
self.videoID = videoID
|
|
self.sourceRawValue = sourceRawValue
|
|
self.globalProvider = globalProvider
|
|
self.instanceURLString = instanceURLString
|
|
self.peertubeUUID = peertubeUUID
|
|
self.externalExtractor = externalExtractor
|
|
self.externalURLString = externalURLString
|
|
self.title = title
|
|
self.authorName = authorName
|
|
self.authorID = authorID
|
|
self.duration = duration
|
|
self.thumbnailURLString = thumbnailURLString
|
|
self.isLive = isLive
|
|
self.addedAt = Date()
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// The thumbnail URL if available.
|
|
var thumbnailURL: URL? {
|
|
thumbnailURLString.flatMap { URL(string: $0) }
|
|
}
|
|
|
|
/// The content source for this item.
|
|
var contentSource: ContentSource {
|
|
if sourceRawValue == "global" {
|
|
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
|
} else if sourceRawValue == "federated",
|
|
let urlString = instanceURLString,
|
|
let url = URL(string: urlString) {
|
|
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
|
} else if sourceRawValue == "extracted",
|
|
let extractor = externalExtractor,
|
|
let urlString = externalURLString,
|
|
let url = URL(string: urlString) {
|
|
return .extracted(extractor: extractor, originalURL: url)
|
|
}
|
|
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversion Methods
|
|
|
|
extension LocalPlaylistItem {
|
|
/// Converts this LocalPlaylistItem back to a Video model for playback or display.
|
|
func toVideo() -> Video {
|
|
Video(
|
|
id: VideoID(source: contentSource, videoID: videoID),
|
|
title: title,
|
|
description: nil,
|
|
author: Author(id: authorID, name: authorName),
|
|
duration: duration,
|
|
publishedAt: nil,
|
|
publishedText: nil,
|
|
viewCount: nil,
|
|
likeCount: nil,
|
|
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
|
isLive: isLive,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
|
|
/// Creates a LocalPlaylistItem from a Video model.
|
|
static func from(video: Video, sortOrder: Int) -> LocalPlaylistItem {
|
|
let sourceRaw: String
|
|
var provider: String?
|
|
var instanceURL: String?
|
|
var uuid: String?
|
|
var extractor: String?
|
|
var externalURL: String?
|
|
|
|
switch video.id.source {
|
|
case .global(let prov):
|
|
sourceRaw = "global"
|
|
provider = prov
|
|
case .federated(_, let instance):
|
|
sourceRaw = "federated"
|
|
instanceURL = instance.absoluteString
|
|
uuid = video.id.uuid
|
|
case .extracted(let ext, let originalURL):
|
|
sourceRaw = "extracted"
|
|
extractor = ext
|
|
externalURL = originalURL.absoluteString
|
|
}
|
|
|
|
return LocalPlaylistItem(
|
|
sortOrder: sortOrder,
|
|
videoID: video.id.videoID,
|
|
sourceRawValue: sourceRaw,
|
|
globalProvider: provider,
|
|
instanceURLString: instanceURL,
|
|
peertubeUUID: uuid,
|
|
externalExtractor: extractor,
|
|
externalURLString: externalURL,
|
|
title: video.title,
|
|
authorName: video.author.name,
|
|
authorID: video.author.id,
|
|
duration: video.duration,
|
|
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
|
|
isLive: video.isLive
|
|
)
|
|
}
|
|
}
|