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

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