mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
247 lines
9.9 KiB
Swift
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"
|
|
}
|