mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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:
@@ -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<WatchEntry>(
|
||||
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<WatchEntry>(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -2270,6 +2271,13 @@ struct VideoInfoView: View {
|
||||
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
|
||||
|
||||
141
YatteeTests/CloudKitSyncTests.swift
Normal file
141
YatteeTests/CloudKitSyncTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user