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.
301 lines
9.3 KiB
Swift
301 lines
9.3 KiB
Swift
//
|
|
// Bookmark.swift
|
|
// Yattee
|
|
//
|
|
// SwiftData model for bookmarked/favorited videos.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
/// Represents a bookmarked video for later viewing.
|
|
@Model
|
|
final class Bookmark {
|
|
// MARK: - Video Identity
|
|
|
|
/// The video ID string (YouTube ID or PeerTube UUID).
|
|
var videoID: String = ""
|
|
|
|
/// The content source raw value for encoding ("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 for re-extraction.
|
|
var externalURLString: String?
|
|
|
|
// MARK: - Video Metadata (cached for offline display)
|
|
|
|
/// 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
|
|
|
|
/// View count if available.
|
|
var viewCount: Int?
|
|
|
|
/// When the video was published.
|
|
var publishedAt: Date?
|
|
|
|
/// Human-readable published date from the API.
|
|
var publishedText: String?
|
|
|
|
// MARK: - Bookmark Metadata
|
|
|
|
/// When this bookmark was created.
|
|
var createdAt: Date = Date()
|
|
|
|
/// Optional user note/comment.
|
|
var note: String?
|
|
|
|
/// When the note was last modified.
|
|
var noteModifiedAt: Date?
|
|
|
|
/// User-defined tags for categorizing the bookmark.
|
|
var tags: [String] = []
|
|
|
|
/// When the tags were last modified.
|
|
var tagsModifiedAt: Date?
|
|
|
|
/// Sort order for manual ordering.
|
|
var sortOrder: Int = 0
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
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,
|
|
viewCount: Int? = nil,
|
|
publishedAt: Date? = nil,
|
|
publishedText: String? = nil,
|
|
note: String? = nil,
|
|
noteModifiedAt: Date? = nil,
|
|
tags: [String] = [],
|
|
tagsModifiedAt: Date? = nil,
|
|
sortOrder: Int = 0
|
|
) {
|
|
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.viewCount = viewCount
|
|
self.publishedAt = publishedAt
|
|
self.publishedText = publishedText
|
|
self.note = note
|
|
self.noteModifiedAt = noteModifiedAt
|
|
self.tags = tags
|
|
self.tagsModifiedAt = tagsModifiedAt
|
|
self.sortOrder = sortOrder
|
|
self.createdAt = Date()
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// The content source for this bookmark.
|
|
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)
|
|
}
|
|
|
|
/// The thumbnail URL if available.
|
|
var thumbnailURL: URL? {
|
|
thumbnailURLString.flatMap { URL(string: $0) }
|
|
}
|
|
|
|
/// Formatted duration string.
|
|
var formattedDuration: String {
|
|
guard !isLive else { return String(localized: "video.badge.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)
|
|
}
|
|
}
|
|
|
|
/// Formatted view count string.
|
|
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
|
|
}
|
|
|
|
/// Converts this bookmark to a Video model for playback.
|
|
func toVideo() -> Video {
|
|
let videoIDObj: VideoID
|
|
switch contentSource {
|
|
case .global(let provider):
|
|
videoIDObj = VideoID(source: .global(provider: provider), videoID: videoID)
|
|
case .federated(let provider, let instance):
|
|
videoIDObj = VideoID(source: .federated(provider: provider, instance: instance), videoID: videoID, uuid: peertubeUUID)
|
|
case .extracted(let extractor, let originalURL):
|
|
videoIDObj = VideoID(source: .extracted(extractor: extractor, originalURL: originalURL), videoID: videoID)
|
|
}
|
|
|
|
let author = Author(
|
|
id: authorID,
|
|
name: authorName,
|
|
thumbnailURL: nil,
|
|
subscriberCount: nil
|
|
)
|
|
|
|
return Video(
|
|
id: videoIDObj,
|
|
title: title,
|
|
description: nil,
|
|
author: author,
|
|
duration: duration,
|
|
publishedAt: publishedAt,
|
|
publishedText: publishedText,
|
|
viewCount: viewCount,
|
|
likeCount: nil,
|
|
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
|
|
isLive: isLive,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Factory Methods
|
|
|
|
extension Bookmark {
|
|
/// Creates a Bookmark from a Video model.
|
|
static func from(video: Video, tags: [String] = [], tagsModifiedAt: Date? = nil, sortOrder: Int = 0) -> Bookmark {
|
|
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 Bookmark(
|
|
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,
|
|
viewCount: video.viewCount,
|
|
publishedAt: video.publishedAt,
|
|
publishedText: video.publishedText,
|
|
tags: tags,
|
|
tagsModifiedAt: tagsModifiedAt,
|
|
sortOrder: sortOrder
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Support
|
|
|
|
extension Bookmark {
|
|
/// A sample bookmark for SwiftUI previews.
|
|
static var preview: Bookmark {
|
|
Bookmark(
|
|
videoID: "dQw4w9WgXcQ",
|
|
sourceRawValue: "global",
|
|
globalProvider: "youtube",
|
|
title: "Sample Video Title",
|
|
authorName: "Sample Channel",
|
|
authorID: "UC123",
|
|
duration: 212,
|
|
thumbnailURLString: "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
|
|
isLive: false,
|
|
viewCount: 1_234_567,
|
|
publishedAt: Date().addingTimeInterval(-86400 * 3),
|
|
publishedText: "3 days ago",
|
|
note: "Great video about SwiftUI patterns and best practices",
|
|
tags: ["Swift", "iOS", "Tutorial", "SwiftUI", "Xcode"]
|
|
)
|
|
}
|
|
}
|