mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Extracted media-source video IDs are full file paths, so closing a long-named WebDAV video built a watch record name longer than 255 characters and CKRecord.ID threw a fatal CKException. Scoped record names now fall back to a SHA-256 digest of the ID when the verbatim form would exceed the limit. The sync engine's local lookups for materializing pending saves and applying remote deletions recognise the digest form and match it against all entities of that type instead of the indexed ID lookup. Short IDs keep their existing names, so records already in iCloud are unaffected.
232 lines
7.8 KiB
Swift
232 lines
7.8 KiB
Swift
//
|
|
// 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
|
|
}
|
|
|
|
/// Gets all bookmarks matching a video ID. The same ID can exist
|
|
/// under multiple source scopes; callers that care about a specific
|
|
/// source must pick the matching entity themselves.
|
|
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
|
|
let descriptor = FetchDescriptor<Bookmark>(
|
|
predicate: #Predicate { $0.videoID == videoID }
|
|
)
|
|
return (try? modelContext.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
/// Gets every bookmark, for lookups that cannot go through the
|
|
/// video-ID index (CloudKit record names that carry a digest of the ID).
|
|
func allBookmarks() -> [Bookmark] {
|
|
(try? modelContext.fetch(FetchDescriptor<Bookmark>())) ?? []
|
|
}
|
|
|
|
/// Deletes a single bookmark without queueing a CloudKit deletion.
|
|
/// Used by CloudKitSyncEngine when applying remote deletions.
|
|
func deleteBookmark(_ bookmark: Bookmark) {
|
|
let videoID = bookmark.videoID
|
|
modelContext.delete(bookmark)
|
|
save()
|
|
|
|
// Keep the fast-lookup cache accurate; another scope may still
|
|
// have a bookmark with this video ID
|
|
if self.bookmark(for: videoID) == nil {
|
|
cachedBookmarkedVideoIDs.remove(videoID)
|
|
}
|
|
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
|
}
|
|
|
|
/// Inserts a bookmark into the database.
|
|
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
|
func insertBookmark(_ bookmark: Bookmark) {
|
|
// Check for duplicates within the same source scope - the same
|
|
// video ID can legitimately exist under different sources
|
|
let videoID = bookmark.videoID
|
|
let descriptor = FetchDescriptor<Bookmark>(
|
|
predicate: #Predicate { $0.videoID == videoID }
|
|
)
|
|
let scopeSuffix = bookmark.sourceScopeSuffix
|
|
|
|
do {
|
|
let existing = try modelContext.fetch(descriptor)
|
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
|
modelContext.insert(bookmark)
|
|
save()
|
|
cachedBookmarkedVideoIDs.insert(videoID)
|
|
}
|
|
} catch {
|
|
// Insert anyway if we can't check
|
|
modelContext.insert(bookmark)
|
|
save()
|
|
cachedBookmarkedVideoIDs.insert(videoID)
|
|
}
|
|
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Source Scope
|
|
|
|
private extension Bookmark {
|
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
|
var sourceScopeSuffix: String {
|
|
SourceScope.from(
|
|
sourceRawValue: sourceRawValue,
|
|
globalProvider: globalProvider,
|
|
instanceURLString: instanceURLString,
|
|
externalExtractor: externalExtractor
|
|
).recordNameSuffix
|
|
}
|
|
}
|