diff --git a/Yattee/Data/DataManager+WatchHistory.swift b/Yattee/Data/DataManager+WatchHistory.swift index 0d4c194e..16233536 100644 --- a/Yattee/Data/DataManager+WatchHistory.swift +++ b/Yattee/Data/DataManager+WatchHistory.swift @@ -13,8 +13,13 @@ extension DataManager { /// 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) { + /// + /// - 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( predicate: #Predicate { $0.videoID == videoID } ) @@ -22,13 +27,24 @@ extension DataManager { do { let existing = try modelContext.fetch(descriptor) if let existingEntry = existing.first { - existingEntry.updateProgress(seconds: seconds, duration: duration) + 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) - newEntry.watchedSeconds = seconds - if let duration, duration > 0, newEntry.duration == 0 { - newEntry.duration = duration + if !isLiveContent { + newEntry.watchedSeconds = seconds + if let duration, duration > 0, newEntry.duration == 0 { + newEntry.duration = duration + } + } else { + newEntry.isLive = true } modelContext.insert(newEntry) save() @@ -43,9 +59,10 @@ extension DataManager { /// 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) { + 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( predicate: #Predicate { $0.videoID == videoID } ) @@ -54,13 +71,24 @@ extension DataManager { let existing = try modelContext.fetch(descriptor) let entry: WatchEntry if let existingEntry = existing.first { - existingEntry.updateProgress(seconds: seconds, duration: duration) + 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) - newEntry.watchedSeconds = seconds - if let duration, duration > 0, newEntry.duration == 0 { - newEntry.duration = duration + 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 diff --git a/Yattee/Data/WatchEntry.swift b/Yattee/Data/WatchEntry.swift index 64156186..2bd2e05b 100644 --- a/Yattee/Data/WatchEntry.swift +++ b/Yattee/Data/WatchEntry.swift @@ -51,6 +51,10 @@ final class WatchEntry { /// Thumbnail URL string. var thumbnailURLString: String? + /// Whether this entry was recorded for a live stream. + /// Live entries never carry a resume position - see `recordLiveWatch()`. + var isLive: Bool = false + // MARK: - Watch Progress /// Last watched position in seconds. @@ -86,7 +90,8 @@ final class WatchEntry { duration: TimeInterval, thumbnailURLString: String? = nil, watchedSeconds: TimeInterval = 0, - isFinished: Bool = false + isFinished: Bool = false, + isLive: Bool = false ) { self.videoID = videoID self.sourceRawValue = sourceRawValue @@ -102,6 +107,7 @@ final class WatchEntry { self.thumbnailURLString = thumbnailURLString self.watchedSeconds = watchedSeconds self.isFinished = isFinished + self.isLive = isLive self.createdAt = Date() self.updatedAt = Date() } @@ -136,8 +142,10 @@ final class WatchEntry { } /// Watch progress as a percentage (0.0 to 1.0). + /// Always 0 for live streams - their playback position is relative to the + /// live edge, so it does not describe progress through a fixed timeline. var progress: Double { - guard duration > 0 else { return 0 } + guard !isLive, duration > 0 else { return 0 } return min(watchedSeconds / duration, 1.0) } @@ -188,6 +196,20 @@ final class WatchEntry { } } + /// Records a live-stream view without storing a resume position. + /// Live playback positions are relative to the live edge, so keeping them + /// would make the app offer to "continue" from a meaningless timestamp. + /// Also clears state a previous non-live save may have left behind, which + /// heals entries wrongly marked finished by a drifting HLS duration. + func recordLiveWatch() { + isLive = true + watchedSeconds = 0 + duration = 0 + isFinished = false + finishedAt = nil + updatedAt = Date() + } + /// Marks the video as finished. func markAsFinished() { isFinished = true @@ -220,7 +242,7 @@ extension WatchEntry { viewCount: nil, likeCount: nil, thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], - isLive: false, + isLive: isLive, isUpcoming: false, scheduledStartTime: nil ) @@ -260,8 +282,11 @@ extension WatchEntry { title: video.title, authorName: video.author.name, authorID: video.author.id, - duration: video.duration, - thumbnailURLString: video.bestThumbnail?.url.absoluteString + // Live videos report no usable duration - Invidious sends 0 and Piped + // uses -1 as its live sentinel, which must never reach storage. + duration: video.isLive ? 0 : max(0, video.duration), + thumbnailURLString: video.bestThumbnail?.url.absoluteString, + isLive: video.isLive ) } } diff --git a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift index 3989eef3..73bdaf39 100644 --- a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift +++ b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift @@ -82,10 +82,12 @@ actor CloudKitConflictResolver { resolved["watchedSeconds"] = localSeconds as CKRecordValue resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue resolved["finishedAt"] = local["finishedAt"] + resolved["isLive"] = local["isLive"] } else { resolved["watchedSeconds"] = serverSeconds as CKRecordValue resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue resolved["finishedAt"] = server["finishedAt"] + resolved["isLive"] = server["isLive"] } // Use most recent updatedAt diff --git a/Yattee/Services/CloudKit/CloudKitRecordMapper.swift b/Yattee/Services/CloudKit/CloudKitRecordMapper.swift index 7b69f826..69941f91 100644 --- a/Yattee/Services/CloudKit/CloudKitRecordMapper.swift +++ b/Yattee/Services/CloudKit/CloudKitRecordMapper.swift @@ -153,6 +153,7 @@ final class CloudKitRecordMapper { record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue record["finishedAt"] = watchEntry.finishedAt as CKRecordValue? + record["isLive"] = (watchEntry.isLive ? 1 : 0) as CKRecordValue // Timestamps record["createdAt"] = watchEntry.createdAt as CKRecordValue @@ -229,6 +230,10 @@ final class CloudKitRecordMapper { watchEntry.updatedAt = updatedAt watchEntry.finishedAt = record["finishedAt"] as? Date + // Optional field - records written before this field existed default to false, + // so it is read leniently rather than bumping the schema version. + watchEntry.isLive = (record["isLive"] as? Int64) == 1 + return watchEntry } diff --git a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift index 878ff468..8487b30c 100644 --- a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift +++ b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift @@ -2152,6 +2152,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { existing.watchedSeconds = resolvedEntry.watchedSeconds existing.isFinished = resolvedEntry.isFinished existing.finishedAt = resolvedEntry.finishedAt + existing.isLive = resolvedEntry.isLive existing.updatedAt = resolvedEntry.updatedAt // Update metadata if newer diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index f79c6e73..1f3e239a 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -420,7 +420,11 @@ final class PlayerService { let savedProgress = dataManager.watchProgress(for: video.id.videoID) LoggingService.shared.logPlayer("Replay check: savedProgress=\(savedProgress ?? -1), startTime=\(startTime ?? -1), duration=\(video.duration), threshold=\(completionThreshold)") - if let startTime { + if video.isLive || selectedStream.isLive { + // Live playback positions are relative to the live edge, so a stored + // or passed position means nothing - always start at the live edge. + seekTime = 0 + } else if let startTime { // Explicit startTime provided - use it (0 means play from beginning, >0 means resume) // For quality switching with startTime > 0, honor the time unless video was completed if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold { @@ -1028,6 +1032,9 @@ final class PlayerService { /// - video: The video to open /// - startTime: Optional start time in seconds (used for continue watching) func openVideo(_ video: Video, startTime: TimeInterval? = nil) { + // Live streams have no meaningful resume position - drop any passed one + // so callers with a stale watch entry cannot seek into the live window. + let startTime = video.isLive ? nil : startTime // Check if MPV PiP is active - if so, don't expand the player #if os(iOS) || os(macOS) @@ -2797,8 +2804,9 @@ final class PlayerService { guard let video = state.currentVideo, state.currentTime > 0 else { return } - // Save locally only during playback - no iCloud sync overhead - dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration) + // Save locally only during playback - no iCloud sync overhead. + // Live streams still get a history entry, but no resume position. + dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive) // Update Handoff activity with current playback time handoffManager?.updatePlaybackTime(state.currentTime) @@ -2811,6 +2819,10 @@ final class PlayerService { settingsManager?.saveWatchHistory != false, let video = state.currentVideo else { return } + // A live stream never "completes" - saveProgress() has already recorded + // its history entry, and writing a duration here would fabricate progress. + guard !state.isLive else { return } + // Use video.duration (API-reported) to match WatchEntry.duration stored value. // This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration. // Fall back to state.duration (MPV-reported) if video.duration is not available. @@ -2836,7 +2848,7 @@ final class PlayerService { state.currentTime > 0 else { return } // Save and queue for iCloud sync (used when video closes/switches) - dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration) + dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive) NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil) } diff --git a/Yattee/Services/TopShelfSnapshotWriter.swift b/Yattee/Services/TopShelfSnapshotWriter.swift index 057de365..14175c55 100644 --- a/Yattee/Services/TopShelfSnapshotWriter.swift +++ b/Yattee/Services/TopShelfSnapshotWriter.swift @@ -54,7 +54,7 @@ enum TopShelfSnapshotWriter { guard let dataManager else { return } let history = dataManager.watchHistory(limit: 50) let items = history - .filter { !$0.isFinished && $0.watchedSeconds > 10 } + .filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 } .prefix(TopShelfSnapshot.maxItems) .compactMap(Self.makeItem(from:)) TopShelfSnapshot.write(Array(items), section: .continueWatching) diff --git a/Yattee/Views/Components/TappableVideoModifier.swift b/Yattee/Views/Components/TappableVideoModifier.swift index 32aaabbc..dcb89fed 100644 --- a/Yattee/Views/Components/TappableVideoModifier.swift +++ b/Yattee/Views/Components/TappableVideoModifier.swift @@ -170,6 +170,13 @@ struct TappableVideoModifier: ViewModifier { private func playVideoAndQueueRest() { guard let env = appEnvironment else { return } + // Live streams have no fixed timeline, so a resume position is meaningless - + // never ask, always join at the live edge. + guard !video.isLive else { + playVideoWithStartTime(0) + return + } + // Determine the saved progress: prefer explicitly passed startTime, then query database // This handles cases where startTime is passed from views like Continue Watching/History // that already have the watch position, avoiding issues with data not being synced yet diff --git a/Yattee/Views/Components/VideoThumbnailView.swift b/Yattee/Views/Components/VideoThumbnailView.swift index 198f56df..3528868c 100644 --- a/Yattee/Views/Components/VideoThumbnailView.swift +++ b/Yattee/Views/Components/VideoThumbnailView.swift @@ -105,7 +105,8 @@ struct VideoThumbnailView: View { @ViewBuilder private var watchProgressBar: some View { - if let progress = watchProgress, progress > 0 && progress < 1 { + // Live streams have no fixed timeline, so a progress bar is meaningless + if !isLive, let progress = watchProgress, progress > 0 && progress < 1 { GeometryReader { geo in Rectangle() .fill(.red) diff --git a/Yattee/Views/Home/ContinueWatchingGridCard.swift b/Yattee/Views/Home/ContinueWatchingGridCard.swift index 07ac89ad..03808ba3 100644 --- a/Yattee/Views/Home/ContinueWatchingGridCard.swift +++ b/Yattee/Views/Home/ContinueWatchingGridCard.swift @@ -51,8 +51,9 @@ struct ContinueWatchingGridCard: View { VideoCardView( video: video, watchProgress: entry.progress, - customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)"), - customDuration: entry.remainingTime + customMetadata: entry.isFinished || entry.isLive ? nil : String(localized: "home.history.remaining \(entry.remainingTime)"), + // Live streams have no remaining time - fall back to the LIVE badge + customDuration: entry.isLive ? nil : entry.remainingTime ) } } diff --git a/Yattee/Views/Home/ContinueWatchingView.swift b/Yattee/Views/Home/ContinueWatchingView.swift index cfca4b7b..7cb449e5 100644 --- a/Yattee/Views/Home/ContinueWatchingView.swift +++ b/Yattee/Views/Home/ContinueWatchingView.swift @@ -33,7 +33,7 @@ struct ContinueWatchingView: View { /// Filtered to only show in-progress videos. private var inProgressEntries: [WatchEntry] { - watchHistory.filter { !$0.isFinished && $0.watchedSeconds > 10 } + watchHistory.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 } } // Grid layout configuration @@ -237,7 +237,7 @@ struct ContinueWatchingView: View { video: entry.toVideo(), style: rowStyle, watchProgress: entry.progress, - customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)") + customMetadata: entry.isFinished || entry.isLive ? nil : String(localized: "home.history.remaining \(entry.remainingTime)") ) .tappableVideo( entry.toVideo(), diff --git a/Yattee/Views/Home/HistoryListView.swift b/Yattee/Views/Home/HistoryListView.swift index 92ad9306..b75bbbf2 100644 --- a/Yattee/Views/Home/HistoryListView.swift +++ b/Yattee/Views/Home/HistoryListView.swift @@ -305,7 +305,7 @@ struct HistoryListView: View { video: video, style: rowStyle, watchProgress: watchProgress(for: entry), - customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)") + customMetadata: entry.isFinished || entry.isLive ? nil : String(localized: "home.history.remaining \(entry.remainingTime)") ) .tappableVideo( video, diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 2bf1023b..4d15995e 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -1463,7 +1463,7 @@ struct HomeView: View { private func loadContinueWatchingData() { let allHistory = dataManager?.watchHistory(limit: 100) ?? [] // Filter to in-progress only (same logic as ContinueWatchingView) - recentContinueWatching = allHistory.filter { !$0.isFinished && $0.watchedSeconds > 10 } + recentContinueWatching = allHistory.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 } continueWatchingCount = recentContinueWatching.count } diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 4ffc1e81..3e021b22 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -232,6 +232,7 @@ struct VideoInfoView: View { /// and resume setting is continueWatching or ask. private var playButtonLabel: String { guard let video = displayedVideo, + !video.isLive, let savedProgress = dataManager?.watchProgress(for: video.id.videoID), savedProgress >= 5, video.duration > 0, @@ -2269,7 +2270,14 @@ struct VideoInfoView: View { /// Play the video, respecting the user's resume action setting for partially watched videos. private func playVideo() { guard let video = displayedVideo, let env = appEnvironment else { return } - + + // Live streams have no fixed timeline, so a resume position is meaningless - + // never ask, always join at the live edge. + guard !video.isLive else { + playVideoWithStartTime(0) + return + } + // Get saved watch progress from database let savedProgress = env.dataManager.watchProgress(for: video.id.videoID) let videoDuration = video.duration diff --git a/YatteeTests/CloudKitSyncTests.swift b/YatteeTests/CloudKitSyncTests.swift new file mode 100644 index 00000000..cd32ca72 --- /dev/null +++ b/YatteeTests/CloudKitSyncTests.swift @@ -0,0 +1,141 @@ +// +// CloudKitSyncTests.swift +// YatteeTests +// +// Tests for CloudKit record mapping and conflict resolution. +// + +import CloudKit +import Foundation +import Testing +@testable import Yattee + +@Suite("CloudKit Sync") +struct CloudKitSyncTests { + + @MainActor + private static func makeMapper() -> CloudKitRecordMapper { + CloudKitRecordMapper(zone: CKRecordZone(zoneName: RecordType.zoneName)) + } + + @MainActor + private static func makeEntry( + videoID: String = "video1", + watchedSeconds: TimeInterval = 0, + duration: TimeInterval = 0, + isLive: Bool = false, + updatedAt: Date = Date(timeIntervalSince1970: 1_000) + ) -> WatchEntry { + let entry = WatchEntry( + videoID: videoID, + sourceRawValue: "global", + title: "Video", + authorName: "Channel", + authorID: "ch1", + duration: duration, + watchedSeconds: watchedSeconds, + isLive: isLive + ) + entry.updatedAt = updatedAt + return entry + } + + // MARK: - Record Mapper + + @Suite("Record Mapper") + struct RecordMapperTests { + @Test("isLive survives the record round-trip") + @MainActor + func liveFlagRoundTrip() throws { + let mapper = CloudKitSyncTests.makeMapper() + let record = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(isLive: true)) + + #expect(record["isLive"] as? Int64 == 1) + + let decoded = try mapper.toWatchEntry(from: record) + #expect(decoded.isLive) + } + + @Test("Records from older clients without isLive read as not live") + @MainActor + func missingLiveFlagReadsFalse() throws { + let mapper = CloudKitSyncTests.makeMapper() + let record = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry(isLive: true)) + // Simulate a record written before the field existed + record["isLive"] = nil + + let decoded = try mapper.toWatchEntry(from: record) + #expect(!decoded.isLive) + } + } + + // MARK: - Conflict Resolution + + @Suite("Watch Entry Conflicts") + struct WatchEntryConflictTests { + @Test("Newer local live watch keeps its isLive flag over an old server record") + @MainActor + func localLiveWinsOverStaleServer() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + isLive: true, + updatedAt: Date(timeIntervalSince1970: 2_000) + )) + let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + watchedSeconds: 1_234, + duration: 3_600, + updatedAt: Date(timeIntervalSince1970: 1_000) + )) + // Old clients never wrote the field at all + server["isLive"] = nil + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server) + + #expect(resolved["isLive"] as? Int64 == 1) + #expect(resolved["watchedSeconds"] as? Double == 0) + } + + @Test("Newer local VOD heal clears a stale live flag on the server") + @MainActor + func localVODHealClearsServerLiveFlag() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + watchedSeconds: 300, + duration: 1_200, + updatedAt: Date(timeIntervalSince1970: 2_000) + )) + let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + isLive: true, + updatedAt: Date(timeIntervalSince1970: 1_000) + )) + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server) + + #expect(resolved["isLive"] as? Int64 == 0) + #expect(resolved["watchedSeconds"] as? Double == 300) + } + + @Test("Newer server record keeps its isLive flag") + @MainActor + func newerServerLiveFlagWins() async throws { + let mapper = CloudKitSyncTests.makeMapper() + let local = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + watchedSeconds: 500, + duration: 3_600, + updatedAt: Date(timeIntervalSince1970: 1_000) + )) + let server = mapper.toCKRecord(watchEntry: CloudKitSyncTests.makeEntry( + isLive: true, + updatedAt: Date(timeIntervalSince1970: 2_000) + )) + + let resolver = CloudKitConflictResolver() + let resolved = await resolver.resolveWatchEntryConflict(local: local, server: server) + + #expect(resolved["isLive"] as? Int64 == 1) + #expect(resolved["watchedSeconds"] as? Double == 0) + } + } +} diff --git a/YatteeTests/DataTests.swift b/YatteeTests/DataTests.swift index a3f8aab0..df40507b 100644 --- a/YatteeTests/DataTests.swift +++ b/YatteeTests/DataTests.swift @@ -710,6 +710,139 @@ struct DataTests { #expect(progress == 300) } + @Test("Live stream is recorded in history without a resume position") + @MainActor + func liveStreamStoresNoProgress() async throws { + let manager = try DataManager(inMemory: true) + + let live = Video( + id: .global("liveStream1"), + title: "Live Stream", + description: nil, + author: Author(id: "ch1", name: "Channel"), + duration: 0, + publishedAt: nil, + publishedText: nil, + viewCount: nil, + likeCount: nil, + thumbnails: [], + isLive: true, + isUpcoming: false, + scheduledStartTime: nil + ) + + manager.updateWatchProgress(for: live, seconds: 1800, duration: 1800) + + // The entry exists so the stream shows up in History... + let entry = try #require(manager.watchEntry(for: "liveStream1")) + #expect(entry.isLive) + // ...but carries nothing that could be offered as a resume point + #expect(entry.watchedSeconds == 0) + #expect(entry.duration == 0) + #expect(entry.progress == 0) + #expect(!entry.isFinished) + #expect(entry.toVideo().isLive) + } + + @Test("Piped live sentinel duration never reaches storage") + @MainActor + func liveStreamNegativeDurationSentinel() async throws { + let manager = try DataManager(inMemory: true) + + // Piped encodes "live" as duration == -1 and passes it straight into Video + let live = Video( + id: .global("liveStream2"), + title: "Piped Live", + description: nil, + author: Author(id: "ch1", name: "Channel"), + duration: -1, + publishedAt: nil, + publishedText: nil, + viewCount: nil, + likeCount: nil, + thumbnails: [], + isLive: true, + isUpcoming: false, + scheduledStartTime: nil + ) + + manager.updateWatchProgressLocal(for: live, seconds: 120, duration: 120) + + let entry = try #require(manager.watchEntry(for: "liveStream2")) + #expect(entry.duration == 0) + #expect(entry.remainingTime == "0:00") + } + + @Test("Entry heals once a former live stream becomes a VOD") + @MainActor + func liveEntryBecomesVOD() async throws { + let manager = try DataManager(inMemory: true) + + let makeVideo: (Bool, TimeInterval) -> Video = { isLive, duration in + Video( + id: .global("liveThenVOD"), + title: "Stream", + description: nil, + author: Author(id: "ch1", name: "Channel"), + duration: duration, + publishedAt: nil, + publishedText: nil, + viewCount: nil, + likeCount: nil, + thumbnails: [], + isLive: isLive, + isUpcoming: false, + scheduledStartTime: nil + ) + } + + manager.updateWatchProgress(for: makeVideo(true, 0), seconds: 900, duration: 900) + #expect(manager.watchProgress(for: "liveThenVOD") == 0) + + // Same video watched later as a regular recording + manager.updateWatchProgress(for: makeVideo(false, 1200), seconds: 300, duration: 1200) + + let entry = try #require(manager.watchEntry(for: "liveThenVOD")) + #expect(!entry.isLive) + #expect(entry.watchedSeconds == 300) + #expect(entry.duration == 1200) + #expect(entry.progress == 0.25) + } + + @Test("Healing save past 90% marks a former live entry finished") + @MainActor + func liveEntryHealAutoFinishes() async throws { + let manager = try DataManager(inMemory: true) + + let makeVideo: (Bool, TimeInterval) -> Video = { isLive, duration in + Video( + id: .global("liveThenFinished"), + title: "Stream", + description: nil, + author: Author(id: "ch1", name: "Channel"), + duration: duration, + publishedAt: nil, + publishedText: nil, + viewCount: nil, + likeCount: nil, + thumbnails: [], + isLive: isLive, + isUpcoming: false, + scheduledStartTime: nil + ) + } + + manager.updateWatchProgress(for: makeVideo(true, 0), seconds: 900, duration: 900) + + // First VOD save lands past the 90% threshold - the auto-finish check + // must see the cleared isLive flag, not the hard-0 live progress + manager.updateWatchProgress(for: makeVideo(false, 1000), seconds: 950, duration: 1000) + + let entry = try #require(manager.watchEntry(for: "liveThenFinished")) + #expect(!entry.isLive) + #expect(entry.isFinished) + } + @Test("Bookmark toggle") @MainActor func bookmarkToggle() async throws {