Files
yattee/Yattee/Data/DataManager+WatchHistory.swift
Arkadiusz Fal e5cea1b6c8 Fix a crash opening Search when a video is in history under several sources
watchEntriesMap() built its dictionary with uniqueKeysWithValues, but the
same video ID can be recorded under more than one source scope, which
trapped on the duplicate key (3 crash logs from build 270, all in
SearchView.loadWatchEntries). Keep the most recent entry instead.

The subscription metadata map in Manage Channels had the same shape (a
channel subscribed under several account scopes), as did the server
history hydration map; both now tolerate duplicates.
2026-10-14 09:24:56 +02:00

449 lines
18 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)
// The same video ID can be recorded under several source scopes; the
// history is most recent first, so the first entry is the one to keep.
return Dictionary(entries.map { ($0.videoID, $0) }, uniquingKeysWith: { first, _ in first })
}
/// 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
}
}