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.
This commit is contained in:
Arkadiusz Fal
2026-07-30 23:59:57 +02:00
parent 8faf20c9e9
commit aa6e7fbce9
16 changed files with 392 additions and 28 deletions

View File

@@ -13,8 +13,13 @@ extension DataManager {
/// Records or updates watch progress locally without triggering iCloud sync. /// Records or updates watch progress locally without triggering iCloud sync.
/// Use this for frequent updates during playback to avoid unnecessary sync overhead. /// 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 videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>( let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID } predicate: #Predicate { $0.videoID == videoID }
) )
@@ -22,14 +27,25 @@ extension DataManager {
do { do {
let existing = try modelContext.fetch(descriptor) let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first { 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) existingEntry.updateProgress(seconds: seconds, duration: duration)
}
save() save()
} else { } else {
let newEntry = WatchEntry.from(video: video) let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 { if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration newEntry.duration = duration
} }
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry) modelContext.insert(newEntry)
save() save()
// Notify HomeView when a new entry is inserted (not on every progress update) // Notify HomeView when a new entry is inserted (not on every progress update)
@@ -43,9 +59,10 @@ extension DataManager {
/// Records or updates watch progress for a video and queues for iCloud sync. /// 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. /// 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 // Find existing entry or create new one
let videoID = video.id.videoID let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>( let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID } predicate: #Predicate { $0.videoID == videoID }
) )
@@ -54,14 +71,25 @@ extension DataManager {
let existing = try modelContext.fetch(descriptor) let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry let entry: WatchEntry
if let existingEntry = existing.first { 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) existingEntry.updateProgress(seconds: seconds, duration: duration)
}
entry = existingEntry entry = existingEntry
} else { } else {
let newEntry = WatchEntry.from(video: video) let newEntry = WatchEntry.from(video: video)
if !isLiveContent {
newEntry.watchedSeconds = seconds newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 { if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration newEntry.duration = duration
} }
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry) modelContext.insert(newEntry)
entry = newEntry entry = newEntry
} }

View File

@@ -51,6 +51,10 @@ final class WatchEntry {
/// Thumbnail URL string. /// Thumbnail URL string.
var thumbnailURLString: 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 // MARK: - Watch Progress
/// Last watched position in seconds. /// Last watched position in seconds.
@@ -86,7 +90,8 @@ final class WatchEntry {
duration: TimeInterval, duration: TimeInterval,
thumbnailURLString: String? = nil, thumbnailURLString: String? = nil,
watchedSeconds: TimeInterval = 0, watchedSeconds: TimeInterval = 0,
isFinished: Bool = false isFinished: Bool = false,
isLive: Bool = false
) { ) {
self.videoID = videoID self.videoID = videoID
self.sourceRawValue = sourceRawValue self.sourceRawValue = sourceRawValue
@@ -102,6 +107,7 @@ final class WatchEntry {
self.thumbnailURLString = thumbnailURLString self.thumbnailURLString = thumbnailURLString
self.watchedSeconds = watchedSeconds self.watchedSeconds = watchedSeconds
self.isFinished = isFinished self.isFinished = isFinished
self.isLive = isLive
self.createdAt = Date() self.createdAt = Date()
self.updatedAt = Date() self.updatedAt = Date()
} }
@@ -136,8 +142,10 @@ final class WatchEntry {
} }
/// Watch progress as a percentage (0.0 to 1.0). /// 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 { var progress: Double {
guard duration > 0 else { return 0 } guard !isLive, duration > 0 else { return 0 }
return min(watchedSeconds / duration, 1.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. /// Marks the video as finished.
func markAsFinished() { func markAsFinished() {
isFinished = true isFinished = true
@@ -220,7 +242,7 @@ extension WatchEntry {
viewCount: nil, viewCount: nil,
likeCount: nil, likeCount: nil,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false, isLive: isLive,
isUpcoming: false, isUpcoming: false,
scheduledStartTime: nil scheduledStartTime: nil
) )
@@ -260,8 +282,11 @@ extension WatchEntry {
title: video.title, title: video.title,
authorName: video.author.name, authorName: video.author.name,
authorID: video.author.id, authorID: video.author.id,
duration: video.duration, // Live videos report no usable duration - Invidious sends 0 and Piped
thumbnailURLString: video.bestThumbnail?.url.absoluteString // 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
) )
} }
} }

View File

@@ -82,10 +82,12 @@ actor CloudKitConflictResolver {
resolved["watchedSeconds"] = localSeconds as CKRecordValue resolved["watchedSeconds"] = localSeconds as CKRecordValue
resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = local["finishedAt"] resolved["finishedAt"] = local["finishedAt"]
resolved["isLive"] = local["isLive"]
} else { } else {
resolved["watchedSeconds"] = serverSeconds as CKRecordValue resolved["watchedSeconds"] = serverSeconds as CKRecordValue
resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue
resolved["finishedAt"] = server["finishedAt"] resolved["finishedAt"] = server["finishedAt"]
resolved["isLive"] = server["isLive"]
} }
// Use most recent updatedAt // Use most recent updatedAt

View File

@@ -153,6 +153,7 @@ final class CloudKitRecordMapper {
record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue
record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue
record["finishedAt"] = watchEntry.finishedAt as CKRecordValue? record["finishedAt"] = watchEntry.finishedAt as CKRecordValue?
record["isLive"] = (watchEntry.isLive ? 1 : 0) as CKRecordValue
// Timestamps // Timestamps
record["createdAt"] = watchEntry.createdAt as CKRecordValue record["createdAt"] = watchEntry.createdAt as CKRecordValue
@@ -229,6 +230,10 @@ final class CloudKitRecordMapper {
watchEntry.updatedAt = updatedAt watchEntry.updatedAt = updatedAt
watchEntry.finishedAt = record["finishedAt"] as? Date 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 return watchEntry
} }

View File

@@ -2152,6 +2152,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
existing.watchedSeconds = resolvedEntry.watchedSeconds existing.watchedSeconds = resolvedEntry.watchedSeconds
existing.isFinished = resolvedEntry.isFinished existing.isFinished = resolvedEntry.isFinished
existing.finishedAt = resolvedEntry.finishedAt existing.finishedAt = resolvedEntry.finishedAt
existing.isLive = resolvedEntry.isLive
existing.updatedAt = resolvedEntry.updatedAt existing.updatedAt = resolvedEntry.updatedAt
// Update metadata if newer // Update metadata if newer

View File

@@ -420,7 +420,11 @@ final class PlayerService {
let savedProgress = dataManager.watchProgress(for: video.id.videoID) let savedProgress = dataManager.watchProgress(for: video.id.videoID)
LoggingService.shared.logPlayer("Replay check: savedProgress=\(savedProgress ?? -1), startTime=\(startTime ?? -1), duration=\(video.duration), threshold=\(completionThreshold)") 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) // 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 // For quality switching with startTime > 0, honor the time unless video was completed
if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold { if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold {
@@ -1028,6 +1032,9 @@ final class PlayerService {
/// - video: The video to open /// - video: The video to open
/// - startTime: Optional start time in seconds (used for continue watching) /// - startTime: Optional start time in seconds (used for continue watching)
func openVideo(_ video: Video, startTime: TimeInterval? = nil) { 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 // Check if MPV PiP is active - if so, don't expand the player
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS)
@@ -2797,8 +2804,9 @@ final class PlayerService {
guard let video = state.currentVideo, guard let video = state.currentVideo,
state.currentTime > 0 else { return } state.currentTime > 0 else { return }
// Save locally only during playback - no iCloud sync overhead // Save locally only during playback - no iCloud sync overhead.
dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration) // 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 // Update Handoff activity with current playback time
handoffManager?.updatePlaybackTime(state.currentTime) handoffManager?.updatePlaybackTime(state.currentTime)
@@ -2811,6 +2819,10 @@ final class PlayerService {
settingsManager?.saveWatchHistory != false, settingsManager?.saveWatchHistory != false,
let video = state.currentVideo else { return } 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. // Use video.duration (API-reported) to match WatchEntry.duration stored value.
// This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration. // This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration.
// Fall back to state.duration (MPV-reported) if video.duration is not available. // 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 } state.currentTime > 0 else { return }
// Save and queue for iCloud sync (used when video closes/switches) // 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) NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
} }

View File

@@ -54,7 +54,7 @@ enum TopShelfSnapshotWriter {
guard let dataManager else { return } guard let dataManager else { return }
let history = dataManager.watchHistory(limit: 50) let history = dataManager.watchHistory(limit: 50)
let items = history let items = history
.filter { !$0.isFinished && $0.watchedSeconds > 10 } .filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
.prefix(TopShelfSnapshot.maxItems) .prefix(TopShelfSnapshot.maxItems)
.compactMap(Self.makeItem(from:)) .compactMap(Self.makeItem(from:))
TopShelfSnapshot.write(Array(items), section: .continueWatching) TopShelfSnapshot.write(Array(items), section: .continueWatching)

View File

@@ -170,6 +170,13 @@ struct TappableVideoModifier: ViewModifier {
private func playVideoAndQueueRest() { private func playVideoAndQueueRest() {
guard let env = appEnvironment else { return } 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 // Determine the saved progress: prefer explicitly passed startTime, then query database
// This handles cases where startTime is passed from views like Continue Watching/History // 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 // that already have the watch position, avoiding issues with data not being synced yet

View File

@@ -105,7 +105,8 @@ struct VideoThumbnailView: View {
@ViewBuilder @ViewBuilder
private var watchProgressBar: some View { 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 GeometryReader { geo in
Rectangle() Rectangle()
.fill(.red) .fill(.red)

View File

@@ -51,8 +51,9 @@ struct ContinueWatchingGridCard: View {
VideoCardView( VideoCardView(
video: video, video: video,
watchProgress: entry.progress, 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)"),
customDuration: entry.remainingTime // Live streams have no remaining time - fall back to the LIVE badge
customDuration: entry.isLive ? nil : entry.remainingTime
) )
} }
} }

View File

@@ -33,7 +33,7 @@ struct ContinueWatchingView: View {
/// Filtered to only show in-progress videos. /// Filtered to only show in-progress videos.
private var inProgressEntries: [WatchEntry] { private var inProgressEntries: [WatchEntry] {
watchHistory.filter { !$0.isFinished && $0.watchedSeconds > 10 } watchHistory.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
} }
// Grid layout configuration // Grid layout configuration
@@ -237,7 +237,7 @@ struct ContinueWatchingView: View {
video: entry.toVideo(), video: entry.toVideo(),
style: rowStyle, style: rowStyle,
watchProgress: entry.progress, 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( .tappableVideo(
entry.toVideo(), entry.toVideo(),

View File

@@ -305,7 +305,7 @@ struct HistoryListView: View {
video: video, video: video,
style: rowStyle, style: rowStyle,
watchProgress: watchProgress(for: entry), 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( .tappableVideo(
video, video,

View File

@@ -1463,7 +1463,7 @@ struct HomeView: View {
private func loadContinueWatchingData() { private func loadContinueWatchingData() {
let allHistory = dataManager?.watchHistory(limit: 100) ?? [] let allHistory = dataManager?.watchHistory(limit: 100) ?? []
// Filter to in-progress only (same logic as ContinueWatchingView) // 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 continueWatchingCount = recentContinueWatching.count
} }

View File

@@ -232,6 +232,7 @@ struct VideoInfoView: View {
/// and resume setting is continueWatching or ask. /// and resume setting is continueWatching or ask.
private var playButtonLabel: String { private var playButtonLabel: String {
guard let video = displayedVideo, guard let video = displayedVideo,
!video.isLive,
let savedProgress = dataManager?.watchProgress(for: video.id.videoID), let savedProgress = dataManager?.watchProgress(for: video.id.videoID),
savedProgress >= 5, savedProgress >= 5,
video.duration > 0, video.duration > 0,
@@ -2270,6 +2271,13 @@ struct VideoInfoView: View {
private func playVideo() { private func playVideo() {
guard let video = displayedVideo, let env = appEnvironment else { return } 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 // Get saved watch progress from database
let savedProgress = env.dataManager.watchProgress(for: video.id.videoID) let savedProgress = env.dataManager.watchProgress(for: video.id.videoID)
let videoDuration = video.duration let videoDuration = video.duration

View File

@@ -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)
}
}
}

View File

@@ -710,6 +710,139 @@ struct DataTests {
#expect(progress == 300) #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") @Test("Bookmark toggle")
@MainActor @MainActor
func bookmarkToggle() async throws { func bookmarkToggle() async throws {