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.
447 lines
17 KiB
Swift
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
|
|
}
|
|
}
|