Files
yattee/Yattee/Services/WatchHistorySyncService.swift
2026-09-06 22:21:15 +02:00

247 lines
9.9 KiB
Swift

//
// 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<Void, Never>)] = [:]
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"
}