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

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: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [],
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"]
)
}
}