Files
yattee/Yattee/Data/DataManager+WatchHistory.swift
Arkadiusz Fal aa6e7fbce9 Stop tracking resume progress for live streams
With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.

Live views are still recorded in history, but carry no resume position:

- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
  stores no position and clears state a drifting HLS duration may have
  left behind. progress is always 0 and toVideo() propagates the flag so
  history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
  storage.
- DataManager routes live saves through recordLiveWatch() and clears the
  flag on the non-live path (before updateProgress, so the auto-finish
  check is not suppressed by the hard-0 live progress), letting an entry
  heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
  and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
  bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
  live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
  resolver carries it with watchedSeconds/isFinished and the sync engine
  applies it when merging into an existing entry. It is read leniently,
  so the schema version stays at 2 and older clients keep parsing these
  records.
2026-08-03 23:34:06 +02:00

441 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)) ?? []
}
/// 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
}
}