mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
449 lines
18 KiB
Swift
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
|
|
}
|
|
}
|