// // WatchHistorySyncService.swift // Yattee // // Mirrors what is being watched here into the active account's server-side // history — best-effort, and never in playback's way. // import Foundation /// Posts each played video to the account's server history once. /// /// The local ``WatchEntry`` remains the sole source of truth for resume and /// progress; this is a mirror on top of it, so nothing here can fail in a way /// playback has to notice. A post that does not land is dropped after one /// retry and never mentioned again. /// /// Two rules shape everything else: /// /// - **The account is captured when playback starts.** Switching context /// mid-video must not redirect the post to whoever is selected by the time /// the progress save happens — the video was watched under the account that /// was active when it began. /// - **De-duplication is per playback session, not per app run.** One POST for /// one watch; playing the same video again is a new session and posts again, /// which is what moves it back to the front of the server's history. @MainActor final class WatchHistorySyncService { /// One playback of one video, under the account that was active when it /// started. private struct Session { let videoID: String let accountID: UUID var posted: Bool } private let accountsManager: AccountsManager private let accountService: AccountService private let contextValidator: DataContextValidator private var currentSession: Session? /// Posts still in flight, so background suspension can wait for them and an /// account that goes away can disown them. private var pendingTasks: [UUID: (accountID: UUID, task: Task)] = [:] init( accountsManager: AccountsManager, accountService: AccountService, contextValidator: DataContextValidator ) { self.accountsManager = accountsManager self.accountService = accountService self.contextValidator = contextValidator } // MARK: - Session lifecycle /// Starts a mirroring session unless this video is already the one being /// mirrored. /// /// What playback calls on every load, because a load is not by itself a new /// watch: an audio-mode toggle, a quality switch or a stream retry all come /// through the same path with the same video, and starting a session for /// each of them would post the video again every time. A session already /// standing for this video is left exactly as it is — including its /// `posted` flag, which is what makes the reload free. func beginSessionIfNeeded(video: Video) { if let currentSession, currentSession.videoID == video.id.videoID { return } beginSession(video: video) } /// Starts a mirroring session for a video, replacing whatever was running. /// /// For the moments that are a new watch of a video already on screen — /// replaying one that finished, repeat-one coming round again — where the /// point is precisely to post it a second time. func beginSession(video: Video) { currentSession = nil // Only YouTube content has an ID the Invidious history endpoint can // take. A PeerTube video, a file from a media source or anything // yt-dlp extracted carries an ID that means nothing there, and the // server answers 400 to it. This is the same predicate `supportsComments` // uses to decide whether a video exists on the backend at all. guard case .global(let provider) = video.id.source, provider == ContentSource.youtubeProvider else { return } // The *effective* context, not the stored one: while the selected // account is unusable the app reads local data, and a video watched in // that state was not watched under the account. guard case .account(let accountID) = contextValidator.effectiveContext, contextValidator.allowsServerMutations, let account = accountsManager.account(id: accountID), accountsManager.capabilities(for: account)?.supportsWatchHistory == true else { return } currentSession = Session(videoID: video.id.videoID, accountID: accountID, posted: false) } /// Ends the current session without posting anything further. func endSession() { currentSession = nil } /// Mirrors the session's video the first time its progress is saved. /// /// Called from the progress-saving paths, after their incognito and /// save-watch-history guards — so the same two settings that stop a local /// entry being written stop the server one too. func recordProgress(videoID: String) { guard var session = currentSession, session.videoID == videoID, !session.posted else { return } // Marked before the request leaves, not after it answers: progress is // saved every few seconds, and a second save landing while the first // post is still out would otherwise send it twice. session.posted = true currentSession = session let taskID = UUID() let accountID = session.accountID let task = Task { [weak self] in await self?.post(videoID: videoID, accountID: accountID) self?.pendingTasks[taskID] = nil } pendingTasks[taskID] = (accountID: accountID, task: task) } // MARK: - Posting /// Sends the post, once more if the first attempt failed, then gives up. private func post(videoID: String, accountID: UUID) async { for attempt in 0 ... 1 { if Task.isCancelled { return } // Resolved at post time rather than captured: the account may have // been deleted while the video played, and there is nothing left to // mirror to. guard let account = accountsManager.account(id: accountID) else { return } do { try await accountService.markWatched(videoID: videoID, for: account) announceUpdate(videoID: videoID, accountID: accountID) return } catch is CancellationError { // The account was discarded or the app is going away. Not a // failure to retry. return } catch let error as AccountServiceError { // Signed out, instance switched off, backend cannot do it — // none of which a second attempt would resolve. LoggingService.shared.debug( "Watch history mirror skipped for \(videoID): \(error)", category: .api ) return } catch { // Only what a second attempt could plausibly answer differently: // a timeout, a dropped connection, a rate limit, a server that // fell over. A 400 for an ID the server will never accept is // just as wrong the second time. let isRetryable = (error as? APIError)?.isRetryable == true guard attempt == 0, isRetryable else { LoggingService.shared.debug( "Watch history mirror dropped for \(videoID): \(error)", category: .api ) return } } } } /// Tells whoever is showing this account's server history that a video just /// reached the front of it. /// /// The view has no other way to know: the local write it also does is not /// news about the server, and the account's history is only ever read by /// asking for it. private func announceUpdate(videoID: String, accountID: UUID) { NotificationCenter.default.post( name: .serverWatchHistoryDidUpdate, object: nil, userInfo: [ ServerWatchHistoryUpdate.accountIDKey: accountID, ServerWatchHistoryUpdate.videoIDKey: videoID ] ) } // MARK: - Draining /// Waits for every post still in flight. /// /// Called when the app goes to background, inside the assertion that keeps /// it alive, so a video watched right up to suspension still reaches the /// server. /// Drains one at a time rather than awaiting a snapshot, so a post enqueued /// while the flush is already running is waited for too — and so each turn /// of the loop removes an entry, whatever the task itself did. func flushPending() async { while let (taskID, entry) = pendingTasks.first { await entry.task.value pendingTasks[taskID] = nil } } /// Disowns whatever is still being posted for an account, and forgets a /// session that belongs to it. /// /// Reached when an account is signed out, deleted, or its credential /// replaced — after any of which the post would either fail or, worse, /// land under somebody else's login. func cancelPending(for accountID: UUID) { for (taskID, entry) in pendingTasks where entry.accountID == accountID { entry.task.cancel() pendingTasks[taskID] = nil } if currentSession?.accountID == accountID { currentSession = nil } } } // MARK: - Announcement extension Notification.Name { /// A video was recorded in an account's server-side history. The userInfo /// carries which account and which video, so a list showing another /// account's history can ignore it. static let serverWatchHistoryDidUpdate = Notification.Name("stream.yattee.serverWatchHistoryDidUpdate") } /// The userInfo keys of ``Notification/Name/serverWatchHistoryDidUpdate``. enum ServerWatchHistoryUpdate { /// `UUID` — the account whose history gained the video. static let accountIDKey = "accountID" /// `String` — the video that was recorded. static let videoIDKey = "videoID" }