mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
CloudKit record names carry a source scope suffix so the same channel or video ID can exist under different sources, but the apply path matched local entities by bare ID: incoming records could merge into an entity from a different scope, and a remote deletion of one scope deleted every local entity with that ID and cascaded scoped deletions for all other scopes back to CloudKit. - Match local entities on the full mapper-derived record name when merging incoming records, materializing records for upload, and applying remote deletions. - Remote deletions now remove only the matching entity and no longer queue CloudKit deletions (no echo, no cross-scope cascade). - Insert dedupe for CloudKit-applied records is scope-aware, so a same-ID record from a different source inserts instead of being silently dropped. Remotely added bookmarks also update the fast-lookup cache immediately. - When conflict resolution during a fetch keeps newer local data, the merge result is queued for upload; previously the server (and other devices) kept the stale version unless the entity was edited again. Strict timestamp comparisons prevent re-upload ping-pong between devices.
413 lines
16 KiB
Swift
413 lines
16 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.
|
|
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)
|
|
save()
|
|
} 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()
|
|
// 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) {
|
|
// 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)
|
|
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)) ?? []
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|