Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

300
Yattee/Data/Bookmark.swift Normal file
View File

@@ -0,0 +1,300 @@
//
// 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"]
)
}
}

View File

@@ -0,0 +1,61 @@
//
// ChannelNotificationSettings.swift
// Yattee
//
// SwiftData model for per-channel notification preferences.
// These settings are synced via iCloud independently of the subscription source.
//
import Foundation
import SwiftData
/// Stores notification preferences for a channel.
/// This is separate from subscriptions so notification settings persist
/// across subscription account changes (local vs Invidious).
@Model
final class ChannelNotificationSettings {
// MARK: - Properties
/// The channel ID this setting applies to.
@Attribute(.unique) var channelID: String = ""
/// Whether notifications are enabled for this channel.
var notificationsEnabled: Bool = false
/// When this setting was last updated.
var updatedAt: Date = Date()
/// Source type: "global", "federated", or "extracted".
var sourceRawValue: String = "global"
/// Instance URL for federated sources.
var instanceURLString: String?
/// Provider name for global sources (e.g. "youtube").
var globalProvider: String?
// MARK: - Initialization
init(
channelID: String,
notificationsEnabled: Bool = false,
sourceRawValue: String = "global",
instanceURLString: String? = nil,
globalProvider: String? = nil
) {
self.channelID = channelID
self.notificationsEnabled = notificationsEnabled
self.updatedAt = Date()
self.sourceRawValue = sourceRawValue
self.instanceURLString = instanceURLString
self.globalProvider = globalProvider
}
// MARK: - Methods
/// Updates the notifications enabled state and timestamp.
func setNotificationsEnabled(_ enabled: Bool) {
notificationsEnabled = enabled
updatedAt = Date()
}
}

View File

@@ -0,0 +1,95 @@
//
// DataExportStructures.swift
// Yattee
//
// Codable structures for exporting data to iCloud sync.
//
import Foundation
// MARK: - Subscription Export
/// Codable struct for exporting Subscription to iCloud.
struct SubscriptionExport: Codable {
let channelID: String
let sourceRawValue: String
let instanceURLString: String?
let name: String
let channelDescription: String?
let subscriberCount: Int?
let avatarURLString: String?
let bannerURLString: String?
let isVerified: Bool
let subscribedAt: Date
let lastUpdatedAt: Date
init(from subscription: Subscription) {
self.channelID = subscription.channelID
self.sourceRawValue = subscription.sourceRawValue
self.instanceURLString = subscription.instanceURLString
self.name = subscription.name
self.channelDescription = subscription.channelDescription
self.subscriberCount = subscription.subscriberCount
self.avatarURLString = subscription.avatarURLString
self.bannerURLString = subscription.bannerURLString
self.isVerified = subscription.isVerified
self.subscribedAt = subscription.subscribedAt
self.lastUpdatedAt = subscription.lastUpdatedAt
}
}
// MARK: - Media Source Export
/// Codable struct for exporting MediaSource (WebDAV and SMB) to iCloud.
/// Note: Local folder sources are never synced as they are device-specific.
struct MediaSourceExport: Codable {
let id: UUID
let name: String
let type: String // "webdav" or "smb"
let urlString: String
let username: String?
let isEnabled: Bool
let dateAdded: Date
let allowInvalidCertificates: Bool
// SMB-specific fields
let smbWorkgroup: String?
let smbProtocolVersion: Int32?
init(from source: MediaSource) {
self.id = source.id
self.name = source.name
self.type = source.type.rawValue
self.urlString = source.url.absoluteString
self.username = source.username
self.isEnabled = source.isEnabled
self.dateAdded = source.dateAdded
self.allowInvalidCertificates = source.allowInvalidCertificates
self.smbWorkgroup = source.smbWorkgroup
self.smbProtocolVersion = source.smbProtocolVersion?.rawValue
}
/// Converts back to a MediaSource. Creates WebDAV or SMB based on type.
func toMediaSource() -> MediaSource? {
guard let url = URL(string: urlString) else { return nil }
// Determine the source type (default to webdav for backward compatibility)
let sourceType = MediaSourceType(rawValue: type) ?? .webdav
// Only allow network source types (webdav and smb)
guard sourceType == .webdav || sourceType == .smb else { return nil }
return MediaSource(
id: id,
name: name,
type: sourceType,
url: url,
isEnabled: isEnabled,
dateAdded: dateAdded,
username: username,
allowInvalidCertificates: allowInvalidCertificates,
smbWorkgroup: smbWorkgroup,
smbProtocolVersion: smbProtocolVersion.flatMap { SMBProtocol(rawValue: $0) }
)
}
}

View File

@@ -0,0 +1,181 @@
//
// DataManager+Bookmarks.swift
// Yattee
//
// Bookmark operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Bookmarks
/// Adds a video to bookmarks.
func addBookmark(for video: Video) {
// Check if already bookmarked
let videoID = video.id.videoID
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
return
}
// Get max sort order
let allBookmarks = try modelContext.fetch(FetchDescriptor<Bookmark>())
let maxOrder = allBookmarks.map(\.sortOrder).max() ?? -1
let bookmark = Bookmark.from(video: video, sortOrder: maxOrder + 1)
modelContext.insert(bookmark)
save()
// Update cache immediately for fast lookup
cachedBookmarkedVideoIDs.insert(videoID)
// Queue for CloudKit sync
cloudKitSync?.queueBookmarkSave(bookmark)
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
} catch {
LoggingService.shared.logCloudKitError("Failed to add bookmark", error: error)
}
}
/// Removes a video from bookmarks.
func removeBookmark(for videoID: String) {
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let bookmarks = try modelContext.fetch(descriptor)
guard !bookmarks.isEmpty else { return }
// Capture scopes before deleting
let scopes = bookmarks.map {
SourceScope.from(
sourceRawValue: $0.sourceRawValue,
globalProvider: $0.globalProvider,
instanceURLString: $0.instanceURLString,
externalExtractor: $0.externalExtractor
)
}
bookmarks.forEach { modelContext.delete($0) }
save()
// Update cache immediately for fast lookup
cachedBookmarkedVideoIDs.remove(videoID)
// Queue scoped CloudKit deletions
for scope in scopes {
cloudKitSync?.queueBookmarkDelete(videoID: videoID, scope: scope)
}
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
} catch {
LoggingService.shared.logCloudKitError("Failed to remove bookmark", error: error)
}
}
/// Checks if a video is bookmarked using cached Set for O(1) lookup.
func isBookmarked(videoID: String) -> Bool {
cachedBookmarkedVideoIDs.contains(videoID)
}
/// Gets all bookmarks, most recent first.
func bookmarks(limit: Int = 100) -> [Bookmark] {
var descriptor = FetchDescriptor<Bookmark>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = limit
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch bookmarks", error: error)
return []
}
}
/// Gets the total count of bookmarks.
func bookmarksCount() -> Int {
let descriptor = FetchDescriptor<Bookmark>()
do {
return try modelContext.fetchCount(descriptor)
} catch {
return 0
}
}
/// Gets a bookmark for a specific video ID.
func bookmark(for videoID: String) -> Bookmark? {
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a bookmark into the database.
/// Used by CloudKitSyncEngine for applying remote bookmarks.
func insertBookmark(_ bookmark: Bookmark) {
// Check for duplicates
let videoID = bookmark.videoID
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
modelContext.insert(bookmark)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(bookmark)
save()
}
}
/// Updates bookmark tags and note for a video.
func updateBookmark(videoID: String, tags: [String], note: String?) {
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
guard let bookmark = try modelContext.fetch(descriptor).first else {
LoggingService.shared.logCloudKitError("Failed to update bookmark: not found", error: nil)
return
}
let now = Date()
// Update tags and timestamp if changed
if bookmark.tags != tags {
bookmark.tags = tags
bookmark.tagsModifiedAt = now
}
// Update note and timestamp if changed
if bookmark.note != note {
bookmark.note = note
bookmark.noteModifiedAt = now
}
save()
// Queue for CloudKit sync
cloudKitSync?.queueBookmarkSave(bookmark)
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
} catch {
LoggingService.shared.logCloudKitError("Failed to update bookmark", error: error)
}
}
}

View File

@@ -0,0 +1,182 @@
//
// DataManager+ChannelNotificationSettings.swift
// Yattee
//
// Channel notification settings operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Channel Notification Settings
/// Sets notification preferences for a channel.
/// Creates the settings record if it doesn't exist.
/// - Parameters:
/// - enabled: Whether notifications should be enabled.
/// - channelID: The channel ID to set preferences for.
func setNotificationsEnabled(_ enabled: Bool, for channelID: String) {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
let settings: ChannelNotificationSettings
if let existingSettings = existing.first {
// Update existing record
existingSettings.setNotificationsEnabled(enabled)
settings = existingSettings
} else {
// Derive source info from the matching subscription
let sub = subscription(for: channelID)
settings = ChannelNotificationSettings(
channelID: channelID,
notificationsEnabled: enabled,
sourceRawValue: sub?.sourceRawValue ?? "global",
instanceURLString: sub?.instanceURLString,
globalProvider: sub?.providerName
)
modelContext.insert(settings)
}
save()
// Queue for CloudKit sync
cloudKitSync?.queueChannelNotificationSettingsSave(settings)
} catch {
LoggingService.shared.logCloudKitError("Failed to set notification settings", error: error)
}
}
/// Gets notification preferences for a channel.
/// - Parameter channelID: The channel ID to get preferences for.
/// - Returns: Whether notifications are enabled. Defaults to false if no settings exist.
func notificationsEnabled(for channelID: String) -> Bool {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let results = try modelContext.fetch(descriptor)
return results.first?.notificationsEnabled ?? false
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch notification settings", error: error)
return false
}
}
/// Gets the notification settings record for a channel.
/// - Parameter channelID: The channel ID to get settings for.
/// - Returns: The settings record, or nil if none exists.
func channelNotificationSettings(for channelID: String) -> ChannelNotificationSettings? {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
return try modelContext.fetch(descriptor).first
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch notification settings", error: error)
return nil
}
}
/// Gets all channel IDs with notifications enabled.
/// Used for background refresh to determine which channels to check.
/// - Returns: Array of channel IDs with notifications enabled.
func channelIDsWithNotificationsEnabled() -> [String] {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.notificationsEnabled == true }
)
do {
let results = try modelContext.fetch(descriptor)
return results.map { $0.channelID }
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch channels with notifications enabled", error: error)
return []
}
}
/// Gets all channel notification settings.
/// - Returns: Array of all notification settings records.
func allChannelNotificationSettings() -> [ChannelNotificationSettings] {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
sortBy: [SortDescriptor(\.channelID)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch all notification settings", error: error)
return []
}
}
/// Deletes notification settings for a channel.
/// - Parameter channelID: The channel ID to delete settings for.
func deleteNotificationSettings(for channelID: String) {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let results = try modelContext.fetch(descriptor)
guard !results.isEmpty else { return }
// Capture scopes before deleting
let scopes = results.map {
SourceScope.from(
sourceRawValue: $0.sourceRawValue,
globalProvider: $0.globalProvider,
instanceURLString: $0.instanceURLString,
externalExtractor: nil
)
}
for settings in results {
modelContext.delete(settings)
}
save()
// Queue scoped CloudKit deletions
for scope in scopes {
cloudKitSync?.queueChannelNotificationSettingsDelete(channelID: channelID, scope: scope)
}
} catch {
LoggingService.shared.logCloudKitError("Failed to delete notification settings", error: error)
}
}
/// Inserts or updates notification settings from CloudKit sync.
/// - Parameter settings: The notification settings to upsert.
func upsertChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
let channelID = settings.channelID
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
if let existingSettings = existing.first {
// Update if incoming is newer
if settings.updatedAt > existingSettings.updatedAt {
existingSettings.notificationsEnabled = settings.notificationsEnabled
existingSettings.updatedAt = settings.updatedAt
existingSettings.sourceRawValue = settings.sourceRawValue
existingSettings.instanceURLString = settings.instanceURLString
existingSettings.globalProvider = settings.globalProvider
}
} else {
// Insert new record
modelContext.insert(settings)
}
save()
} catch {
LoggingService.shared.logCloudKitError("Failed to upsert notification settings", error: error)
}
}
}

View File

@@ -0,0 +1,300 @@
//
// DataManager+Maintenance.swift
// Yattee
//
// Media source cleanup and deduplication operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Media Source Cleanup
/// Removes all watch history entries for videos from a specific media source.
func removeHistoryForMediaSource(sourceID: UUID) {
let prefix = sourceID.uuidString + ":"
let descriptor = FetchDescriptor<WatchEntry>()
do {
let allEntries = try modelContext.fetch(descriptor)
let toDelete = allEntries.filter { $0.videoID.hasPrefix(prefix) }
guard !toDelete.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = toDelete.map { entry in
(entry.videoID, SourceScope.from(
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString,
externalExtractor: entry.externalExtractor
))
}
toDelete.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
}
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
LoggingService.shared.debug("Removed \(toDelete.count) history entries for media source \(sourceID)", category: .general)
} catch {
LoggingService.shared.logCloudKitError("Failed to remove history for media source", error: error)
}
}
/// Removes all bookmarks for videos from a specific media source.
func removeBookmarksForMediaSource(sourceID: UUID) {
let prefix = sourceID.uuidString + ":"
let descriptor = FetchDescriptor<Bookmark>()
do {
let allBookmarks = try modelContext.fetch(descriptor)
let toDelete = allBookmarks.filter { $0.videoID.hasPrefix(prefix) }
guard !toDelete.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = toDelete.map { bookmark in
(bookmark.videoID, SourceScope.from(
sourceRawValue: bookmark.sourceRawValue,
globalProvider: bookmark.globalProvider,
instanceURLString: bookmark.instanceURLString,
externalExtractor: bookmark.externalExtractor
))
}
toDelete.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueBookmarkDelete(videoID: info.videoID, scope: info.scope)
}
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
LoggingService.shared.debug("Removed \(toDelete.count) bookmarks for media source \(sourceID)", category: .general)
} catch {
LoggingService.shared.logCloudKitError("Failed to remove bookmarks for media source", error: error)
}
}
/// Removes all playlist items for videos from a specific media source.
func removePlaylistItemsForMediaSource(sourceID: UUID) {
let prefix = sourceID.uuidString + ":"
let descriptor = FetchDescriptor<LocalPlaylistItem>()
do {
let allItems = try modelContext.fetch(descriptor)
let toDelete = allItems.filter { $0.videoID.hasPrefix(prefix) }
guard !toDelete.isEmpty else { return }
// Capture IDs before deleting
let itemIDs = toDelete.map { $0.id }
toDelete.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for itemID in itemIDs {
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
}
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
LoggingService.shared.debug("Removed \(toDelete.count) playlist items for media source \(sourceID)", category: .general)
} catch {
LoggingService.shared.logCloudKitError("Failed to remove playlist items for media source", error: error)
}
}
/// Removes all data associated with a media source (history, bookmarks, playlist items).
func removeAllDataForMediaSource(sourceID: UUID) {
removeHistoryForMediaSource(sourceID: sourceID)
removeBookmarksForMediaSource(sourceID: sourceID)
removePlaylistItemsForMediaSource(sourceID: sourceID)
}
// MARK: - Deduplication
/// Results from a deduplication operation.
struct DeduplicationResult {
var subscriptionsRemoved: Int = 0
var bookmarksRemoved: Int = 0
var historyEntriesRemoved: Int = 0
var playlistsRemoved: Int = 0
var playlistItemsRemoved: Int = 0
var totalRemoved: Int {
subscriptionsRemoved + bookmarksRemoved + historyEntriesRemoved + playlistsRemoved + playlistItemsRemoved
}
var summary: String {
var parts: [String] = []
if subscriptionsRemoved > 0 { parts.append("\(subscriptionsRemoved) subscriptions") }
if bookmarksRemoved > 0 { parts.append("\(bookmarksRemoved) bookmarks") }
if historyEntriesRemoved > 0 { parts.append("\(historyEntriesRemoved) history entries") }
if playlistsRemoved > 0 { parts.append("\(playlistsRemoved) playlists") }
if playlistItemsRemoved > 0 { parts.append("\(playlistItemsRemoved) playlist items") }
return parts.isEmpty ? "No duplicates found" : "Removed: " + parts.joined(separator: ", ")
}
}
/// Removes all duplicate entries from subscriptions, bookmarks, history, and playlists.
func deduplicateAllData() -> DeduplicationResult {
var result = DeduplicationResult()
result.subscriptionsRemoved = deduplicateSubscriptions()
result.bookmarksRemoved = deduplicateBookmarks()
result.historyEntriesRemoved = deduplicateWatchHistory()
let (playlists, items) = deduplicatePlaylists()
result.playlistsRemoved = playlists
result.playlistItemsRemoved = items
LoggingService.shared.logCloudKit("Deduplication completed: \(result.summary)")
return result
}
/// Removes duplicate subscriptions, keeping the oldest one.
func deduplicateSubscriptions() -> Int {
let allSubscriptions = subscriptions()
var seenChannelIDs = Set<String>()
var duplicates: [Subscription] = []
// Sort by subscribedAt to keep the oldest
let sorted = allSubscriptions.sorted { $0.subscribedAt < $1.subscribedAt }
for subscription in sorted {
if seenChannelIDs.contains(subscription.channelID) {
duplicates.append(subscription)
LoggingService.shared.logCloudKit("Found duplicate subscription: \(subscription.name) (\(subscription.channelID))")
} else {
seenChannelIDs.insert(subscription.channelID)
}
}
for duplicate in duplicates {
modelContext.delete(duplicate)
}
if !duplicates.isEmpty {
save()
SubscriptionFeedCache.shared.invalidate()
}
return duplicates.count
}
/// Removes duplicate bookmarks, keeping the oldest one.
func deduplicateBookmarks() -> Int {
let allBookmarks = bookmarks(limit: 10000)
var seenVideoIDs = Set<String>()
var duplicates: [Bookmark] = []
// Sort by createdAt to keep the oldest
let sorted = allBookmarks.sorted { $0.createdAt < $1.createdAt }
for bookmark in sorted {
if seenVideoIDs.contains(bookmark.videoID) {
duplicates.append(bookmark)
LoggingService.shared.logCloudKit("Found duplicate bookmark: \(bookmark.title) (\(bookmark.videoID))")
} else {
seenVideoIDs.insert(bookmark.videoID)
}
}
for duplicate in duplicates {
modelContext.delete(duplicate)
}
if !duplicates.isEmpty {
save()
}
return duplicates.count
}
/// Removes duplicate watch history entries, keeping the one with most progress.
func deduplicateWatchHistory() -> Int {
let allEntries = watchHistory(limit: 10000)
var bestByVideoID = [String: WatchEntry]()
var duplicates: [WatchEntry] = []
for entry in allEntries {
if let existing = bestByVideoID[entry.videoID] {
// Keep the one with more progress, or mark finished if either is
if entry.watchedSeconds > existing.watchedSeconds {
duplicates.append(existing)
bestByVideoID[entry.videoID] = entry
LoggingService.shared.logCloudKit("Found duplicate history (keeping newer progress): \(entry.title) (\(entry.videoID))")
} else {
duplicates.append(entry)
LoggingService.shared.logCloudKit("Found duplicate history (keeping existing progress): \(entry.title) (\(entry.videoID))")
}
} else {
bestByVideoID[entry.videoID] = entry
}
}
for duplicate in duplicates {
modelContext.delete(duplicate)
}
if !duplicates.isEmpty {
save()
}
return duplicates.count
}
/// Removes duplicate playlists and playlist items.
func deduplicatePlaylists() -> (playlists: Int, items: Int) {
let allPlaylists = playlists()
var seenPlaylistIDs = Set<UUID>()
var duplicatePlaylists: [LocalPlaylist] = []
var totalDuplicateItems = 0
// Sort by createdAt to keep the oldest
let sorted = allPlaylists.sorted { $0.createdAt < $1.createdAt }
for playlist in sorted {
if seenPlaylistIDs.contains(playlist.id) {
duplicatePlaylists.append(playlist)
LoggingService.shared.logCloudKit("Found duplicate playlist: \(playlist.title) (\(playlist.id))")
} else {
seenPlaylistIDs.insert(playlist.id)
// Also deduplicate items within the playlist
var seenItemVideoIDs = Set<String>()
var duplicateItems: [LocalPlaylistItem] = []
let sortedItems = playlist.sortedItems
for item in sortedItems {
if seenItemVideoIDs.contains(item.videoID) {
duplicateItems.append(item)
LoggingService.shared.logCloudKit("Found duplicate playlist item: \(item.title) in playlist \(playlist.title)")
} else {
seenItemVideoIDs.insert(item.videoID)
}
}
for duplicateItem in duplicateItems {
modelContext.delete(duplicateItem)
}
totalDuplicateItems += duplicateItems.count
}
}
for duplicate in duplicatePlaylists {
modelContext.delete(duplicate)
}
if !duplicatePlaylists.isEmpty || totalDuplicateItems > 0 {
save()
}
return (duplicatePlaylists.count, totalDuplicateItems)
}
}

View File

@@ -0,0 +1,165 @@
//
// DataManager+Playlists.swift
// Yattee
//
// Local playlist operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Local Playlists
/// Creates a new local playlist.
func createPlaylist(title: String, description: String? = nil) -> LocalPlaylist {
let playlist = LocalPlaylist(title: title, description: description)
modelContext.insert(playlist)
save()
// Queue for CloudKit sync
cloudKitSync?.queuePlaylistSave(playlist)
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
return playlist
}
/// Updates a playlist's title and description.
func updatePlaylist(_ playlist: LocalPlaylist, title: String, description: String?) {
playlist.title = title
playlist.playlistDescription = description
playlist.updatedAt = Date()
save()
// Queue for CloudKit sync
cloudKitSync?.queuePlaylistSave(playlist)
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
}
/// Deletes a local playlist.
func deletePlaylist(_ playlist: LocalPlaylist) {
let playlistID = playlist.id
modelContext.delete(playlist)
save()
// Queue for CloudKit deletion
cloudKitSync?.queuePlaylistDelete(playlistID: playlistID)
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
}
/// Gets all local playlists.
func playlists() -> [LocalPlaylist] {
let descriptor = FetchDescriptor<LocalPlaylist>(
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch playlists", error: error)
return []
}
}
/// Gets a playlist by its ID.
func playlist(forID id: UUID) -> LocalPlaylist? {
let descriptor = FetchDescriptor<LocalPlaylist>(
predicate: #Predicate { $0.id == id }
)
return try? modelContext.fetch(descriptor).first
}
/// Gets a playlist item by its ID.
func playlistItem(forID id: UUID) -> LocalPlaylistItem? {
let descriptor = FetchDescriptor<LocalPlaylistItem>(
predicate: #Predicate { $0.id == id }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a playlist into the database.
/// Used by CloudKitSyncEngine for applying remote playlists.
func insertPlaylist(_ playlist: LocalPlaylist) {
// Check for duplicates
let id = playlist.id
let descriptor = FetchDescriptor<LocalPlaylist>(
predicate: #Predicate { $0.id == id }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
modelContext.insert(playlist)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(playlist)
save()
}
}
/// Inserts a playlist item into the database.
/// Used by CloudKitSyncEngine for applying remote playlist items.
func insertPlaylistItem(_ item: LocalPlaylistItem) {
// Check for duplicates
let id = item.id
let descriptor = FetchDescriptor<LocalPlaylistItem>(
predicate: #Predicate { $0.id == id }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
modelContext.insert(item)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(item)
save()
}
}
/// Deletes a playlist item.
/// Used by CloudKitSyncEngine for applying remote deletions.
func deletePlaylistItem(_ item: LocalPlaylistItem) {
modelContext.delete(item)
save()
}
/// Adds a video to a playlist.
func addToPlaylist(_ video: Video, playlist: LocalPlaylist) {
guard !playlist.contains(videoID: video.id.videoID) else {
return
}
playlist.addVideo(video)
save()
// Queue for CloudKit sync (will sync playlist and all items)
cloudKitSync?.queuePlaylistSave(playlist)
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
}
/// Removes a video from a playlist.
func removeVideoFromPlaylist(at index: Int, playlist: LocalPlaylist) {
guard index < playlist.sortedItems.count else { return }
let item = playlist.sortedItems[index]
let itemID = item.id
// Remove from playlist
playlist.removeVideo(at: index)
save()
// Queue updated playlist for CloudKit sync
cloudKitSync?.queuePlaylistSave(playlist)
// Also delete the orphaned item from CloudKit
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
}
}

View File

@@ -0,0 +1,482 @@
//
// DataManager+Recents.swift
// Yattee
//
// Search history, recent channels, and recent playlists operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Search History
/// Adds a search query to history. If query already exists (case-insensitive),
/// updates its timestamp and moves to top. Enforces user-configured limit.
func addSearchQuery(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
let lowercased = trimmed.lowercased()
// Check for existing query (case-insensitive)
let fetchDescriptor = FetchDescriptor<SearchHistory>()
let savedEntry: SearchHistory
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first(where: {
$0.query.lowercased() == lowercased
}) {
// Update timestamp to move to top
existing.searchedAt = Date()
savedEntry = existing
} else {
// Create new entry
let newHistory = SearchHistory(query: trimmed, searchedAt: Date())
modelContext.insert(newHistory)
savedEntry = newHistory
}
// Enforce limit
enforceSearchHistoryLimit()
save()
// Queue for CloudKit sync
cloudKitSync?.queueSearchHistorySave(savedEntry)
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
}
/// Fetches search history ordered by most recent first.
func fetchSearchHistory(limit: Int) -> [SearchHistory] {
// Process pending changes to ensure we fetch fresh data
modelContext.processPendingChanges()
var fetchDescriptor = FetchDescriptor<SearchHistory>(
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
)
fetchDescriptor.fetchLimit = limit
return (try? modelContext.fetch(fetchDescriptor)) ?? []
}
/// Deletes a specific search history entry.
func deleteSearchQuery(_ history: SearchHistory) {
let historyID = history.id
modelContext.delete(history)
save()
// Queue for CloudKit sync
cloudKitSync?.queueSearchHistoryDelete(id: historyID)
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
}
/// Clears all search history.
func clearSearchHistory() {
let fetchDescriptor = FetchDescriptor<SearchHistory>()
if let allHistory = try? modelContext.fetch(fetchDescriptor) {
guard !allHistory.isEmpty else { return }
// Capture IDs before deleting
let ids = allHistory.map { $0.id }
allHistory.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for id in ids {
cloudKitSync?.queueSearchHistoryDelete(id: id)
}
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
}
}
/// Enforces the user-configured search history limit by deleting oldest entries.
func enforceSearchHistoryLimit() {
// Get limit from settings (injected or default to 25)
let limit = settingsManager?.searchHistoryLimit ?? 25
guard limit > 0 else { return }
let fetchDescriptor = FetchDescriptor<SearchHistory>(
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
)
guard let allHistory = try? modelContext.fetch(fetchDescriptor),
allHistory.count > limit else { return }
// Delete oldest entries beyond limit
let toDelete = allHistory.dropFirst(limit)
// Capture IDs before deleting
let ids = toDelete.map { $0.id }
toDelete.forEach { modelContext.delete($0) }
// Queue CloudKit deletions
for id in ids {
cloudKitSync?.queueSearchHistoryDelete(id: id)
}
}
/// Gets all search history (for CloudKit sync).
func allSearchHistory() -> [SearchHistory] {
let descriptor = FetchDescriptor<SearchHistory>(
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch search history", error: error)
return []
}
}
/// Gets a search history entry by ID (for CloudKit sync).
func searchHistoryEntry(forID id: UUID) -> SearchHistory? {
let descriptor = FetchDescriptor<SearchHistory>(
predicate: #Predicate { $0.id == id }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a search history entry (for CloudKit sync).
func insertSearchHistory(_ searchHistory: SearchHistory) {
// Check for duplicates by ID
if searchHistoryEntry(forID: searchHistory.id) == nil {
modelContext.insert(searchHistory)
save()
}
}
// MARK: - Recent Channels
/// Adds a channel to recent history. If channel already exists,
/// updates its timestamp and moves to top. Enforces same limit as search history.
func addRecentChannel(_ channel: Channel) {
let channelID = channel.id.channelID
// Check for existing entry
let fetchDescriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
let savedEntry: RecentChannel
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first {
// Update timestamp to move to top
existing.visitedAt = Date()
// Also update metadata in case channel info changed
existing.name = channel.name
existing.thumbnailURLString = channel.thumbnailURL?.absoluteString
existing.subscriberCount = channel.subscriberCount
existing.isVerified = channel.isVerified
savedEntry = existing
} else {
// Create new entry
let recentChannel = RecentChannel.from(channel: channel)
modelContext.insert(recentChannel)
savedEntry = recentChannel
}
// Enforce limit
enforceRecentChannelsLimit()
save()
// Queue for CloudKit sync
cloudKitSync?.queueRecentChannelSave(savedEntry)
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
}
/// Fetches recent channels ordered by most recent first.
func fetchRecentChannels(limit: Int) -> [RecentChannel] {
// Process pending changes to ensure we fetch fresh data
modelContext.processPendingChanges()
var fetchDescriptor = FetchDescriptor<RecentChannel>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
fetchDescriptor.fetchLimit = limit
return (try? modelContext.fetch(fetchDescriptor)) ?? []
}
/// Deletes a specific recent channel entry.
func deleteRecentChannel(_ channel: RecentChannel) {
let channelID = channel.channelID
let scope = SourceScope.from(
sourceRawValue: channel.sourceRawValue,
globalProvider: nil,
instanceURLString: channel.instanceURLString,
externalExtractor: nil
)
modelContext.delete(channel)
save()
// Queue scoped CloudKit deletion
cloudKitSync?.queueRecentChannelDelete(channelID: channelID, scope: scope)
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
}
/// Clears all recent channels.
func clearRecentChannels() {
let fetchDescriptor = FetchDescriptor<RecentChannel>()
if let allChannels = try? modelContext.fetch(fetchDescriptor) {
guard !allChannels.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(channelID: String, scope: SourceScope)] = allChannels.map { channel in
(channel.channelID, SourceScope.from(
sourceRawValue: channel.sourceRawValue,
globalProvider: nil,
instanceURLString: channel.instanceURLString,
externalExtractor: nil
))
}
allChannels.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueRecentChannelDelete(channelID: info.channelID, scope: info.scope)
}
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
}
}
/// Enforces the user-configured limit by deleting oldest entries.
func enforceRecentChannelsLimit() {
let limit = settingsManager?.searchHistoryLimit ?? 25
guard limit > 0 else { return }
let fetchDescriptor = FetchDescriptor<RecentChannel>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
guard let allChannels = try? modelContext.fetch(fetchDescriptor),
allChannels.count > limit else { return }
// Delete oldest entries beyond limit
let toDelete = allChannels.dropFirst(limit)
// Capture IDs and scopes before deleting
let deleteInfo: [(channelID: String, scope: SourceScope)] = toDelete.map { channel in
(channel.channelID, SourceScope.from(
sourceRawValue: channel.sourceRawValue,
globalProvider: nil,
instanceURLString: channel.instanceURLString,
externalExtractor: nil
))
}
toDelete.forEach { modelContext.delete($0) }
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueRecentChannelDelete(channelID: info.channelID, scope: info.scope)
}
}
/// Gets all recent channels (for CloudKit sync).
func allRecentChannels() -> [RecentChannel] {
let descriptor = FetchDescriptor<RecentChannel>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch recent channels", error: error)
return []
}
}
/// Gets a recent channel by channelID (for CloudKit sync).
func recentChannelEntry(forChannelID channelID: String) -> RecentChannel? {
let descriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a recent channel (for CloudKit sync).
func insertRecentChannel(_ recentChannel: RecentChannel) {
// Check for duplicates by channelID
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
modelContext.insert(recentChannel)
save()
}
}
// MARK: - Recent Playlists
/// Adds a playlist to recent history (remote playlists only).
/// If playlist already exists, updates its timestamp and moves to top.
func addRecentPlaylist(_ playlist: Playlist) {
// Skip local playlists
guard let recentPlaylist = RecentPlaylist.from(playlist: playlist) else {
return
}
let playlistID = playlist.id.playlistID
// Check for existing entry
let fetchDescriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
let savedEntry: RecentPlaylist
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first {
// Update timestamp to move to top
existing.visitedAt = Date()
// Also update metadata
existing.title = playlist.title
existing.authorName = playlist.authorName
existing.videoCount = playlist.videoCount
existing.thumbnailURLString = playlist.thumbnailURL?.absoluteString
savedEntry = existing
} else {
// Create new entry
modelContext.insert(recentPlaylist)
savedEntry = recentPlaylist
}
// Enforce limit
enforceRecentPlaylistsLimit()
save()
// Queue for CloudKit sync
cloudKitSync?.queueRecentPlaylistSave(savedEntry)
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
}
/// Fetches recent playlists ordered by most recent first.
func fetchRecentPlaylists(limit: Int) -> [RecentPlaylist] {
// Process pending changes to ensure we fetch fresh data
modelContext.processPendingChanges()
var fetchDescriptor = FetchDescriptor<RecentPlaylist>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
fetchDescriptor.fetchLimit = limit
return (try? modelContext.fetch(fetchDescriptor)) ?? []
}
/// Deletes a specific recent playlist entry.
func deleteRecentPlaylist(_ playlist: RecentPlaylist) {
let playlistID = playlist.playlistID
let scope = SourceScope.from(
sourceRawValue: playlist.sourceRawValue,
globalProvider: nil,
instanceURLString: playlist.instanceURLString,
externalExtractor: nil
)
modelContext.delete(playlist)
save()
// Queue scoped CloudKit deletion
cloudKitSync?.queueRecentPlaylistDelete(playlistID: playlistID, scope: scope)
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
}
/// Clears all recent playlists.
func clearRecentPlaylists() {
let fetchDescriptor = FetchDescriptor<RecentPlaylist>()
if let allPlaylists = try? modelContext.fetch(fetchDescriptor) {
guard !allPlaylists.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(playlistID: String, scope: SourceScope)] = allPlaylists.map { playlist in
(playlist.playlistID, SourceScope.from(
sourceRawValue: playlist.sourceRawValue,
globalProvider: nil,
instanceURLString: playlist.instanceURLString,
externalExtractor: nil
))
}
allPlaylists.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueRecentPlaylistDelete(playlistID: info.playlistID, scope: info.scope)
}
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
}
}
/// Enforces the user-configured limit by deleting oldest entries.
func enforceRecentPlaylistsLimit() {
let limit = settingsManager?.searchHistoryLimit ?? 25
guard limit > 0 else { return }
let fetchDescriptor = FetchDescriptor<RecentPlaylist>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
guard let allPlaylists = try? modelContext.fetch(fetchDescriptor),
allPlaylists.count > limit else { return }
// Delete oldest entries beyond limit
let toDelete = allPlaylists.dropFirst(limit)
// Capture IDs and scopes before deleting
let deleteInfo: [(playlistID: String, scope: SourceScope)] = toDelete.map { playlist in
(playlist.playlistID, SourceScope.from(
sourceRawValue: playlist.sourceRawValue,
globalProvider: nil,
instanceURLString: playlist.instanceURLString,
externalExtractor: nil
))
}
toDelete.forEach { modelContext.delete($0) }
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueRecentPlaylistDelete(playlistID: info.playlistID, scope: info.scope)
}
}
/// Gets all recent playlists (for CloudKit sync).
func allRecentPlaylists() -> [RecentPlaylist] {
let descriptor = FetchDescriptor<RecentPlaylist>(
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch recent playlists", error: error)
return []
}
}
/// Gets a recent playlist by playlistID (for CloudKit sync).
func recentPlaylistEntry(forPlaylistID playlistID: String) -> RecentPlaylist? {
let descriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a recent playlist (for CloudKit sync).
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
// Check for duplicates by playlistID
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
modelContext.insert(recentPlaylist)
save()
}
}
}

View File

@@ -0,0 +1,396 @@
//
// DataManager+Subscriptions.swift
// Yattee
//
// Subscription operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Subscriptions
/// Subscribes to a channel.
/// - Parameter channel: The channel to subscribe to.
func subscribe(to channel: Channel) {
let channelID = channel.id.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
return
}
let subscription = Subscription.from(channel: channel)
modelContext.insert(subscription)
save()
// Queue for CloudKit sync
cloudKitSync?.queueSubscriptionSave(subscription)
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
}
}
/// Subscribes to a channel from an Author.
/// - Parameters:
/// - author: The author/channel to subscribe to.
/// - source: The content source (YouTube or PeerTube).
func subscribe(to author: Author, source: ContentSource) {
let channelID = author.id
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
return
}
let sourceRaw: String
var instanceURL: String?
switch source {
case .global:
sourceRaw = "global"
case .federated(_, let instance):
sourceRaw = "federated"
instanceURL = instance.absoluteString
case .extracted:
// Extracted sources don't support subscriptions
return
}
let subscription = Subscription(
channelID: channelID,
sourceRawValue: sourceRaw,
instanceURLString: instanceURL,
name: author.name,
subscriberCount: author.subscriberCount,
avatarURLString: author.thumbnailURL?.absoluteString
)
modelContext.insert(subscription)
save()
// Queue for CloudKit sync
cloudKitSync?.queueSubscriptionSave(subscription)
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
}
}
/// Unsubscribes from a channel.
func unsubscribe(from channelID: String) {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let subscriptions = try modelContext.fetch(descriptor)
guard !subscriptions.isEmpty else { return }
// Capture scopes before deleting
let scopes = subscriptions.map {
SourceScope.from(
sourceRawValue: $0.sourceRawValue,
globalProvider: $0.providerName,
instanceURLString: $0.instanceURLString,
externalExtractor: nil
)
}
subscriptions.forEach { modelContext.delete($0) }
save()
// Queue scoped CloudKit deletions
for scope in scopes {
cloudKitSync?.queueSubscriptionDelete(channelID: channelID, scope: scope)
}
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to unsubscribe", error: error)
}
}
/// Bulk adds subscriptions from channel data (for testing).
func bulkAddSubscriptions(_ channels: [(id: String, name: String)]) {
var addedCount = 0
for channel in channels {
let channelID = channel.id
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
continue
}
let subscription = Subscription(
channelID: channel.id,
sourceRawValue: "youtube",
instanceURLString: nil,
name: channel.name
)
modelContext.insert(subscription)
addedCount += 1
} catch {
continue
}
}
if addedCount > 0 {
save()
SubscriptionFeedCache.shared.invalidate()
LoggingService.shared.info("Bulk added \(addedCount) subscriptions", category: .general)
}
}
/// Checks if subscribed to a channel.
func isSubscribed(to channelID: String) -> Bool {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let count = try modelContext.fetchCount(descriptor)
return count > 0
} catch {
return false
}
}
/// Gets a subscription by channel ID.
func subscription(for channelID: String) -> Subscription? {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
return try modelContext.fetch(descriptor).first
} catch {
return nil
}
}
/// Gets all subscriptions.
func subscriptions() -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
sortBy: [SortDescriptor(\.name)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch subscriptions", error: error)
return []
}
}
/// Inserts a subscription into the database.
/// Used by SubscriptionService for caching server subscriptions locally.
func insertSubscription(_ subscription: Subscription) {
// Check for duplicates
let channelID = subscription.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
modelContext.insert(subscription)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(subscription)
save()
}
}
/// Deletes a subscription from the database.
/// Used by SubscriptionService for removing stale cached subscriptions.
func deleteSubscription(_ subscription: Subscription) {
modelContext.delete(subscription)
// Note: caller is responsible for calling save() after batch operations
}
/// Removes subscriptions matching the given channel IDs.
func removeSubscriptions(matching channelIDs: Set<String>) {
let allSubscriptions = subscriptions()
var removedCount = 0
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
for subscription in allSubscriptions {
if channelIDs.contains(subscription.channelID) {
// Capture scope before deleting
let scope = SourceScope.from(
sourceRawValue: subscription.sourceRawValue,
globalProvider: subscription.providerName,
instanceURLString: subscription.instanceURLString,
externalExtractor: nil
)
deleteInfo.append((subscription.channelID, scope))
modelContext.delete(subscription)
removedCount += 1
}
}
if removedCount > 0 {
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
}
SubscriptionFeedCache.shared.invalidate()
LoggingService.shared.info("Removed \(removedCount) test subscriptions", category: .general)
}
}
/// Returns the total count of subscriptions.
var subscriptionCount: Int {
let descriptor = FetchDescriptor<Subscription>()
do {
return try modelContext.fetchCount(descriptor)
} catch {
return 0
}
}
/// Returns all subscriptions.
var allSubscriptions: [Subscription] {
subscriptions()
}
/// Updates lastVideoPublishedAt for subscriptions based on feed videos.
func updateLastVideoPublishedDates(from videos: [Video]) {
var latestByChannel: [String: Date] = [:]
for video in videos {
guard let publishedAt = video.publishedAt else { continue }
let channelID = video.author.id
if let existing = latestByChannel[channelID] {
if publishedAt > existing { latestByChannel[channelID] = publishedAt }
} else {
latestByChannel[channelID] = publishedAt
}
}
guard !latestByChannel.isEmpty else { return }
let allSubscriptions = subscriptions()
var updated = false
for subscription in allSubscriptions {
if let latestDate = latestByChannel[subscription.channelID],
subscription.lastVideoPublishedAt == nil || latestDate > subscription.lastVideoPublishedAt! {
subscription.lastVideoPublishedAt = latestDate
updated = true
}
}
if updated {
save()
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
}
}
/// Imports subscriptions from external sources (YouTube CSV, OPML).
/// Skips existing subscriptions and returns import statistics.
/// - Parameter channels: Array of tuples containing channel ID and name
/// - Returns: Tuple with count of imported and skipped subscriptions
func importSubscriptionsFromExternal(_ channels: [(channelID: String, name: String)]) -> (imported: Int, skipped: Int) {
var imported = 0
var skipped = 0
var addedSubscriptions: [Subscription] = []
for channel in channels {
// Skip if already subscribed
if isSubscribed(to: channel.channelID) {
skipped += 1
continue
}
// Create new subscription
let subscription = Subscription(
channelID: channel.channelID,
sourceRawValue: "global",
instanceURLString: nil,
name: channel.name
)
subscription.providerName = ContentSource.youtubeProvider
modelContext.insert(subscription)
addedSubscriptions.append(subscription)
imported += 1
}
if imported > 0 {
save()
SubscriptionFeedCache.shared.invalidate()
// Queue imported subscriptions for CloudKit sync
for subscription in addedSubscriptions {
cloudKitSync?.queueSubscriptionSave(subscription)
}
// Post notification for UI updates
let change = SubscriptionChange(addedSubscriptions: addedSubscriptions, removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
LoggingService.shared.info("Imported \(imported) subscriptions from external source", category: .general)
}
return (imported, skipped)
}
/// Updates subscription metadata from fresh channel data.
func updateSubscription(for channelID: String, with channel: Channel) {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let subscriptions = try modelContext.fetch(descriptor)
if let subscription = subscriptions.first {
subscription.update(from: channel)
save()
}
} catch {
LoggingService.shared.logCloudKitError("Failed to update subscription", error: error)
}
}
}

View File

@@ -0,0 +1,373 @@
//
// DataManager+WatchHistory.swift
// Yattee
//
// Watch history operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Watch History
/// Records or updates watch progress locally without triggering iCloud sync.
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
let videoID = video.id.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
modelContext.insert(newEntry)
}
save()
// Note: No CloudKit queueing - use updateWatchProgress() when sync is needed
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress locally", error: error)
}
}
/// Records or updates watch progress for a video and queues for iCloud sync.
/// Use this when video closes or switches to sync the final progress.
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
// Find existing entry or create new one
let videoID = video.id.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
modelContext.insert(newEntry)
entry = newEntry
}
save()
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
}
}
/// Gets the watch progress for a video.
func watchProgress(for videoID: String) -> TimeInterval? {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let entries = try modelContext.fetch(descriptor)
return entries.first?.watchedSeconds
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch watch progress", error: error)
return nil
}
}
/// Gets all watch history entries, most recent first.
func watchHistory(limit: Int = 100) -> [WatchEntry] {
var descriptor = FetchDescriptor<WatchEntry>(
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
)
descriptor.fetchLimit = limit
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch watch history", error: error)
return []
}
}
/// Returns a dictionary of video ID to WatchEntry for efficient bulk lookup.
func watchEntriesMap() -> [String: WatchEntry] {
let entries = watchHistory(limit: 10000)
return Dictionary(uniqueKeysWithValues: entries.map { ($0.videoID, $0) })
}
/// Gets the total count of watch history entries.
func watchHistoryCount() -> Int {
let descriptor = FetchDescriptor<WatchEntry>()
do {
return try modelContext.fetchCount(descriptor)
} catch {
return 0
}
}
/// Gets the watch entry for a specific video ID.
func watchEntry(for videoID: String) -> WatchEntry? {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
return try? modelContext.fetch(descriptor).first
}
/// Inserts a watch entry into the database.
/// Used by CloudKitSyncEngine for applying remote watch history.
func insertWatchEntry(_ watchEntry: WatchEntry) {
// Check for duplicates
let videoID = watchEntry.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
modelContext.insert(watchEntry)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(watchEntry)
save()
}
}
/// Clears all watch history.
func clearWatchHistory() {
let descriptor = FetchDescriptor<WatchEntry>()
do {
let entries = try modelContext.fetch(descriptor)
guard !entries.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
(entry.videoID, SourceScope.from(
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString,
externalExtractor: entry.externalExtractor
))
}
entries.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
}
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
LoggingService.shared.logCloudKit("Watch history cleared, queued \(deleteInfo.count) CloudKit deletions")
} catch {
LoggingService.shared.logCloudKitError("Failed to clear watch history", error: error)
}
}
/// Clears watch history entries updated after a given date.
/// Used for time-based clearing (e.g., "clear last hour").
func clearWatchHistory(since date: Date) {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.updatedAt >= date }
)
do {
let entries = try modelContext.fetch(descriptor)
guard !entries.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
(entry.videoID, SourceScope.from(
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString,
externalExtractor: entry.externalExtractor
))
}
entries.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
}
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
LoggingService.shared.logCloudKit("Watch history cleared since \(date), queued \(deleteInfo.count) CloudKit deletions")
} catch {
LoggingService.shared.logCloudKitError("Failed to clear watch history since date", error: error)
}
}
/// Clears watch history entries older than a given date.
/// Used for auto-cleanup of old history.
func clearWatchHistory(olderThan date: Date) {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.updatedAt < date }
)
do {
let entries = try modelContext.fetch(descriptor)
guard !entries.isEmpty else { return }
// Capture IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
(entry.videoID, SourceScope.from(
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString,
externalExtractor: entry.externalExtractor
))
}
entries.forEach { modelContext.delete($0) }
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
}
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
LoggingService.shared.logCloudKit("Watch history cleared older than \(date), queued \(deleteInfo.count) CloudKit deletions")
} catch {
LoggingService.shared.logCloudKitError("Failed to clear old watch history", error: error)
}
}
/// Removes a specific watch entry.
func removeFromHistory(videoID: String) {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let entries = try modelContext.fetch(descriptor)
guard !entries.isEmpty else { return }
// Capture scopes before deleting
let scopes = entries.map {
SourceScope.from(
sourceRawValue: $0.sourceRawValue,
globalProvider: $0.globalProvider,
instanceURLString: $0.instanceURLString,
externalExtractor: $0.externalExtractor
)
}
entries.forEach { modelContext.delete($0) }
save()
// Queue scoped CloudKit deletions
for scope in scopes {
cloudKitSync?.queueWatchEntryDelete(videoID: videoID, scope: scope)
}
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
} catch {
LoggingService.shared.logCloudKitError("Failed to remove from history", error: error)
}
}
/// Marks a video as watched by creating or updating a WatchEntry with isFinished = true.
func markAsWatched(video: Video) {
let videoID = video.id.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
existingEntry.isFinished = true
existingEntry.updatedAt = Date()
// Set watchedSeconds to duration for 100% progress
if existingEntry.duration > 0 {
existingEntry.watchedSeconds = existingEntry.duration
} else if video.duration > 0 {
existingEntry.duration = video.duration
existingEntry.watchedSeconds = video.duration
}
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.isFinished = true
// Set watchedSeconds to duration for 100% progress
if video.duration > 0 {
newEntry.duration = video.duration
newEntry.watchedSeconds = video.duration
}
modelContext.insert(newEntry)
entry = newEntry
}
save()
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
} catch {
LoggingService.shared.logCloudKitError("Failed to mark as watched", error: error)
}
}
/// Marks a video as unwatched by removing the watch history entry entirely.
func markAsUnwatched(videoID: String) {
removeFromHistory(videoID: videoID)
}
/// Clears all in-progress (not finished, watched > 10 seconds) watch entries.
func clearInProgressHistory() {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { !$0.isFinished && $0.watchedSeconds > 10 }
)
do {
let entries = try modelContext.fetch(descriptor)
guard !entries.isEmpty else { return }
// Capture video IDs and scopes before deleting
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
(entry.videoID, SourceScope.from(
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString,
externalExtractor: entry.externalExtractor
))
}
// Delete all entries
entries.forEach { modelContext.delete($0) }
save()
// Queue scoped CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
}
// Post single notification
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
LoggingService.shared.logCloudKit("Cleared \(entries.count) in-progress watch entries")
} catch {
LoggingService.shared.logCloudKitError("Failed to clear in-progress history", error: error)
}
}
}

View File

@@ -0,0 +1,151 @@
//
// DataManager.swift
// Yattee
//
// Central manager for all local data operations using SwiftData.
//
import Foundation
import SwiftData
/// Manages all local data persistence using SwiftData with CloudKit sync.
@MainActor
@Observable
final class DataManager {
// MARK: - Properties
let modelContainer: ModelContainer
let modelContext: ModelContext
/// Weak reference to settings manager for accessing search history limit.
weak var settingsManager: SettingsManager?
/// Weak reference to CloudKit sync engine.
weak var cloudKitSync: CloudKitSyncEngine?
/// Whether CloudKit sync is currently enabled for this instance.
private(set) var isCloudKitEnabled: Bool = false
/// Cached set of bookmarked video IDs for fast O(1) lookup.
/// Updated when bookmarks are added/removed locally or via CloudKit sync.
var cachedBookmarkedVideoIDs: Set<String> = []
/// Shared schema for all data models.
static let schema = Schema([
WatchEntry.self,
Bookmark.self,
LocalPlaylist.self,
LocalPlaylistItem.self,
Subscription.self,
SearchHistory.self,
RecentChannel.self,
RecentPlaylist.self,
ChannelNotificationSettings.self
])
// MARK: - Initialization
init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws {
let configuration: ModelConfiguration
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
// the same data is created on multiple devices (each device generates different IDs).
//
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
// This approach:
// 1. Uses business identifiers (channelID, videoID) for deduplication
// 2. Gives explicit control over sync timing and conflict resolution
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
if inMemory {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
} else {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
}
self.modelContainer = try ModelContainer(
for: Self.schema,
configurations: [configuration]
)
self.modelContext = modelContainer.mainContext
self.modelContext.autosaveEnabled = true
self.isCloudKitEnabled = false
LoggingService.shared.logCloudKit("SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)")
// Initialize bookmark cache
refreshBookmarkCache()
// Listen for bookmark changes from CloudKit sync to refresh cache
NotificationCenter.default.addObserver(
forName: .bookmarksDidChange,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor [weak self] in
self?.refreshBookmarkCache()
}
}
}
/// Refreshes the cached set of bookmarked video IDs.
/// Called at init and when bookmarks change via CloudKit sync.
private func refreshBookmarkCache() {
let descriptor = FetchDescriptor<Bookmark>()
do {
let bookmarks = try modelContext.fetch(descriptor)
cachedBookmarkedVideoIDs = Set(bookmarks.map { $0.videoID })
} catch {
cachedBookmarkedVideoIDs = []
LoggingService.shared.logCloudKitError("Failed to refresh bookmark cache", error: error)
}
}
/// Creates an in-memory DataManager for previews and testing.
static func preview() throws -> DataManager {
try DataManager(inMemory: true)
}
// MARK: - Utilities
/// Forces a save of any pending changes.
func save() {
do {
try modelContext.save()
} catch {
LoggingService.shared.logCloudKitError("Failed to save data", error: error)
}
}
// MARK: - Subscription Updates
/// Updates subscriber count and verified status for a subscription by channel ID.
/// Used to populate cached metadata from Yattee Server.
func updateSubscriberCount(for channelID: String, count: Int, isVerified: Bool?) {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
if let subscription = try modelContext.fetch(descriptor).first {
subscription.subscriberCount = count
if let verified = isVerified {
subscription.isVerified = verified
}
subscription.lastUpdatedAt = Date()
save()
}
} catch {
LoggingService.shared.logCloudKitError("Failed to update subscriber count for \(channelID)", error: error)
}
}
}

View File

@@ -0,0 +1,47 @@
//
// DataManagerNotifications.swift
// Yattee
//
// Notification definitions for data changes.
//
import Foundation
// MARK: - Notification Names
extension Notification.Name {
/// Posted when subscriptions are modified (subscribe or unsubscribe).
static let subscriptionsDidChange = Notification.Name("subscriptionsDidChange")
/// Posted when bookmarks are modified (add or remove).
static let bookmarksDidChange = Notification.Name("bookmarksDidChange")
/// Posted when watch history is modified (add, update, or remove).
static let watchHistoryDidChange = Notification.Name("watchHistoryDidChange")
/// Posted when playlists are modified (create, update, or delete).
static let playlistsDidChange = Notification.Name("playlistsDidChange")
/// Posted when media sources are modified (add, update, or delete).
static let mediaSourcesDidChange = Notification.Name("mediaSourcesDidChange")
/// Posted when search history is modified (add or delete).
static let searchHistoryDidChange = Notification.Name("searchHistoryDidChange")
/// Posted when recent channels are modified (add or delete).
static let recentChannelsDidChange = Notification.Name("recentChannelsDidChange")
/// Posted when recent playlists are modified (add or delete).
static let recentPlaylistsDidChange = Notification.Name("recentPlaylistsDidChange")
/// Posted when video details (likes, views, etc.) are loaded or updated.
static let videoDetailsDidLoad = Notification.Name("videoDetailsDidLoad")
/// Posted when sidebar settings (max channels, sort order, etc.) are modified.
static let sidebarSettingsDidChange = Notification.Name("sidebarSettingsDidChange")
}
// MARK: - Subscription Change
/// Describes what changed in subscriptions for incremental feed updates.
struct SubscriptionChange {
let addedSubscriptions: [Subscription]
let removedChannelIDs: [String]
static let userInfoKey = "subscriptionChange"
var isEmpty: Bool {
addedSubscriptions.isEmpty && removedChannelIDs.isEmpty
}
}

View File

@@ -0,0 +1,299 @@
//
// 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.
var thumbnailURL: URL? {
(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: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
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
)
}
}

View File

@@ -0,0 +1,69 @@
//
// RecentChannel.swift
// Yattee
//
// SwiftData model for recent channel visits.
//
import Foundation
import SwiftData
@Model
final class RecentChannel {
var id: UUID
var channelID: String
var sourceRawValue: String
var instanceURLString: String?
var name: String
var thumbnailURLString: String?
var subscriberCount: Int?
var isVerified: Bool
var visitedAt: Date
init(
id: UUID = UUID(),
channelID: String,
sourceRawValue: String,
instanceURLString: String? = nil,
name: String,
thumbnailURLString: String? = nil,
subscriberCount: Int? = nil,
isVerified: Bool = false,
visitedAt: Date = Date()
) {
self.id = id
self.channelID = channelID
self.sourceRawValue = sourceRawValue
self.instanceURLString = instanceURLString
self.name = name
self.thumbnailURLString = thumbnailURLString
self.subscriberCount = subscriberCount
self.isVerified = isVerified
self.visitedAt = visitedAt
}
/// Creates a RecentChannel from a Channel model
static func from(channel: Channel) -> RecentChannel {
let (sourceRaw, instanceURL) = extractSourceInfo(from: channel.id.source)
return RecentChannel(
channelID: channel.id.channelID,
sourceRawValue: sourceRaw,
instanceURLString: instanceURL,
name: channel.name,
thumbnailURLString: channel.thumbnailURL?.absoluteString,
subscriberCount: channel.subscriberCount,
isVerified: channel.isVerified
)
}
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
switch source {
case .global:
return ("global", nil)
case .federated(_, let instance):
return ("federated", instance.absoluteString)
case .extracted:
return ("extracted", nil)
}
}
}

View File

@@ -0,0 +1,74 @@
//
// RecentPlaylist.swift
// Yattee
//
// SwiftData model for recent playlist visits (remote playlists only).
//
import Foundation
import SwiftData
@Model
final class RecentPlaylist {
var id: UUID
var playlistID: String
var sourceRawValue: String
var instanceURLString: String?
var title: String
var authorName: String
var videoCount: Int
var thumbnailURLString: String?
var visitedAt: Date
init(
id: UUID = UUID(),
playlistID: String,
sourceRawValue: String,
instanceURLString: String? = nil,
title: String,
authorName: String = "",
videoCount: Int = 0,
thumbnailURLString: String? = nil,
visitedAt: Date = Date()
) {
self.id = id
self.playlistID = playlistID
self.sourceRawValue = sourceRawValue
self.instanceURLString = instanceURLString
self.title = title
self.authorName = authorName
self.videoCount = videoCount
self.thumbnailURLString = thumbnailURLString
self.visitedAt = visitedAt
}
/// Creates a RecentPlaylist from a Playlist model
/// Returns nil for local playlists (we only track remote ones)
static func from(playlist: Playlist) -> RecentPlaylist? {
guard !playlist.isLocal, let source = playlist.id.source else {
return nil
}
let (sourceRaw, instanceURL) = extractSourceInfo(from: source)
return RecentPlaylist(
playlistID: playlist.id.playlistID,
sourceRawValue: sourceRaw,
instanceURLString: instanceURL,
title: playlist.title,
authorName: playlist.authorName,
videoCount: playlist.videoCount,
thumbnailURLString: playlist.thumbnailURL?.absoluteString
)
}
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
switch source {
case .global:
return ("global", nil)
case .federated(_, let instance):
return ("federated", instance.absoluteString)
case .extracted:
return ("extracted", nil)
}
}
}

View File

@@ -0,0 +1,22 @@
//
// SearchHistory.swift
// Yattee
//
// SwiftData model for search history.
//
import Foundation
import SwiftData
@Model
final class SearchHistory {
var id: UUID
var query: String
var searchedAt: Date
init(id: UUID = UUID(), query: String, searchedAt: Date = Date()) {
self.id = id
self.query = query
self.searchedAt = searchedAt
}
}

View File

@@ -0,0 +1,205 @@
//
// Subscription.swift
// Yattee
//
// SwiftData model for channel subscriptions.
//
import Foundation
import SwiftData
/// Represents a subscribed channel.
@Model
final class Subscription {
// MARK: - Channel Identity
/// The channel ID string.
var channelID: String = ""
/// The content source raw value.
var sourceRawValue: String = "youtube"
/// For PeerTube: the instance URL string.
var instanceURLString: String?
// MARK: - Channel Metadata
/// The channel name.
var name: String = ""
/// Channel description.
var channelDescription: String?
/// Subscriber count (if known).
var subscriberCount: Int?
/// Avatar/thumbnail URL string.
var avatarURLString: String?
/// Banner URL string.
var bannerURLString: String?
/// Whether the channel is verified.
var isVerified: Bool = false
// MARK: - Subscription Metadata
/// When the subscription was created.
var subscribedAt: Date = Date()
/// When channel info was last updated.
var lastUpdatedAt: Date = Date()
/// When the channel's most recent video was published (for sorting).
var lastVideoPublishedAt: Date?
// MARK: - Server Sync (Yattee Server)
/// The server's subscription ID (for deletion via server API).
var serverSubscriptionID: Int?
/// The provider name (e.g., "youtube", "peertube") for server sync.
/// Used as the `site` field in server API calls.
var providerName: String?
/// The channel URL for external/extracted sources (required for feed fetching).
var channelURLString: String?
// MARK: - Initialization
init(
channelID: String,
sourceRawValue: String,
instanceURLString: String? = nil,
name: String,
channelDescription: String? = nil,
subscriberCount: Int? = nil,
avatarURLString: String? = nil,
bannerURLString: String? = nil,
isVerified: Bool = false,
channelURLString: String? = nil
) {
self.channelID = channelID
self.sourceRawValue = sourceRawValue
self.instanceURLString = instanceURLString
self.name = name
self.channelDescription = channelDescription
self.subscriberCount = subscriberCount
self.avatarURLString = avatarURLString
self.bannerURLString = bannerURLString
self.isVerified = isVerified
self.channelURLString = channelURLString
self.subscribedAt = Date()
self.lastUpdatedAt = Date()
}
// MARK: - Computed Properties
/// The content source for this subscription.
var contentSource: ContentSource {
let provider = providerName ?? ContentSource.youtubeProvider
if sourceRawValue == "global" {
return .global(provider: provider)
} else if sourceRawValue == "federated",
let urlString = instanceURLString,
let url = URL(string: urlString) {
return .federated(provider: providerName ?? ContentSource.peertubeProvider, instance: url)
}
return .global(provider: provider)
}
/// The site value for server API calls (same as provider).
var site: String {
providerName ?? contentSource.provider
}
/// The avatar URL if available.
var avatarURL: URL? {
avatarURLString.flatMap { URL(string: $0) }
}
/// The banner URL if available.
var bannerURL: URL? {
bannerURLString.flatMap { URL(string: $0) }
}
/// Formatted subscriber count.
var formattedSubscriberCount: String? {
guard let count = subscriberCount else { return nil }
return CountFormatter.compact(count)
}
// MARK: - Methods
/// Updates the channel metadata from fresh data.
/// Uses a merge strategy: only updates optional fields if the new value is non-nil,
/// preventing nil values from overwriting valid cached data.
func update(from channel: Channel) {
name = channel.name
isVerified = channel.isVerified
lastUpdatedAt = Date()
// Only update optional fields if new value is non-nil
if let desc = channel.description {
channelDescription = desc
}
if let count = channel.subscriberCount {
subscriberCount = count
}
if let thumb = channel.thumbnailURL {
avatarURLString = thumb.absoluteString
}
if let banner = channel.bannerURL {
bannerURLString = banner.absoluteString
}
}
}
// MARK: - Factory Methods
extension Subscription {
/// Creates a Subscription from a Channel model.
static func from(channel: Channel) -> Subscription {
let sourceRaw: String
var instanceURL: String?
var channelURL: String?
let provider = channel.id.source.provider
switch channel.id.source {
case .global(let prov):
sourceRaw = "global"
// Construct YouTube channel URL
if prov == ContentSource.youtubeProvider {
if channel.id.channelID.hasPrefix("@") {
channelURL = "https://www.youtube.com/\(channel.id.channelID)"
} else {
channelURL = "https://www.youtube.com/channel/\(channel.id.channelID)"
}
}
case .federated(_, let instance):
sourceRaw = "federated"
instanceURL = instance.absoluteString
// Construct PeerTube channel URL
channelURL = instance.appendingPathComponent("video-channels/\(channel.id.channelID)").absoluteString
case .extracted(_, let originalURL):
sourceRaw = "extracted"
channelURL = originalURL.absoluteString
}
let subscription = Subscription(
channelID: channel.id.channelID,
sourceRawValue: sourceRaw,
instanceURLString: instanceURL,
name: channel.name,
channelDescription: channel.description,
subscriberCount: channel.subscriberCount,
avatarURLString: channel.thumbnailURL?.absoluteString,
bannerURLString: channel.bannerURL?.absoluteString,
isVerified: channel.isVerified,
channelURLString: channelURL
)
subscription.providerName = provider
return subscription
}
}

View File

@@ -0,0 +1,267 @@
//
// WatchEntry.swift
// Yattee
//
// SwiftData model for tracking video watch history.
//
import Foundation
import SwiftData
/// Represents a watched video entry in the user's history.
@Model
final class WatchEntry {
// 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 at time of watching.
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?
// MARK: - Watch Progress
/// Last watched position in seconds.
var watchedSeconds: TimeInterval = 0
/// Whether the video has been fully watched (>90% or manually marked).
var isFinished: Bool = false
/// When the video was marked as finished.
var finishedAt: Date?
// MARK: - Timestamps
/// When this entry was first created.
var createdAt: Date = Date()
/// When this entry was last updated.
var updatedAt: Date = Date()
// 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,
watchedSeconds: TimeInterval = 0,
isFinished: Bool = false
) {
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.watchedSeconds = watchedSeconds
self.isFinished = isFinished
self.createdAt = Date()
self.updatedAt = Date()
}
// MARK: - Computed Properties
/// The content source for this entry.
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 full VideoID for this entry, matching what VideoRowView uses for zoom transitions.
var videoIdentifier: VideoID {
VideoID(source: contentSource, videoID: videoID, uuid: peertubeUUID)
}
/// The thumbnail URL if available.
var thumbnailURL: URL? {
thumbnailURLString.flatMap { URL(string: $0) }
}
/// Watch progress as a percentage (0.0 to 1.0).
var progress: Double {
guard duration > 0 else { return 0 }
return min(watchedSeconds / duration, 1.0)
}
/// Formatted total duration.
var formattedDuration: String {
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 remaining time.
var remainingTime: String {
let remaining = max(0, duration - watchedSeconds)
let hours = Int(remaining) / 3600
let minutes = (Int(remaining) % 3600) / 60
let seconds = Int(remaining) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
// MARK: - Methods
/// Updates the watch progress.
func updateProgress(seconds: TimeInterval, duration: TimeInterval? = nil) {
watchedSeconds = seconds
updatedAt = Date()
// Update duration if it was 0 and a valid duration is now known
if self.duration == 0, let newDuration = duration, newDuration > 0 {
self.duration = newDuration
}
// Mark as finished if watched more than 90%
if progress >= 0.9 && !isFinished {
isFinished = true
finishedAt = Date()
}
}
/// Marks the video as finished.
func markAsFinished() {
isFinished = true
finishedAt = Date()
updatedAt = Date()
}
/// Resets the watch progress.
func resetProgress() {
watchedSeconds = 0
isFinished = false
finishedAt = nil
updatedAt = Date()
}
}
// MARK: - Conversion Methods
extension WatchEntry {
/// Converts this WatchEntry 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: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Creates a WatchEntry from a Video model.
static func from(video: Video) -> WatchEntry {
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 WatchEntry(
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
)
}
}