Files
yattee/Yattee/Data/DataManager+WatchHistory.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

447 lines
17 KiB
Swift

//
// 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.
///
/// - Parameter isLive: Set by the player when the active stream is live but the
/// `Video` metadata does not say so (some sources hardcode `isLive: false`).
/// Live views are still recorded in history, but without a resume position.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
save()
} else {
let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
save()
// Notify HomeView when a new entry is inserted (not on every progress update)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
}
// 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, isLive: Bool = false) {
// Find existing entry or create new one
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
do {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
entry = newEntry
}
save()
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
} 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
}
/// Gets all watch entries 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 watchEntries(forVideoID videoID: String) -> [WatchEntry] {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Gets every watch entry, for lookups that cannot go through the
/// video-ID index (CloudKit record names that carry a digest of the ID).
func allWatchEntries() -> [WatchEntry] {
(try? modelContext.fetch(FetchDescriptor<WatchEntry>())) ?? []
}
/// Deletes a single watch entry without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteWatchEntry(_ entry: WatchEntry) {
modelContext.delete(entry)
save()
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Inserts a watch entry into the database.
/// Used by CloudKitSyncEngine for applying remote watch history.
func insertWatchEntry(_ watchEntry: WatchEntry) {
// Check for duplicates within the same source scope - the same
// video ID can legitimately exist under different sources
let videoID = watchEntry.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
let scopeSuffix = watchEntry.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(watchEntry)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(watchEntry)
save()
}
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// 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)
}
}
}
// MARK: - Source Scope
private extension WatchEntry {
/// 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
}
}