Files
yattee/Yattee/Data/DataManager+Bookmarks.swift
Arkadiusz Fal 53fda7574c Fix a crash when syncing watch entries whose video ID exceeds the CloudKit record name limit
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.
2026-09-20 23:59:05 +02:00

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
}
}