mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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.
142 lines
4.9 KiB
Swift
142 lines
4.9 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|