Mirror a watch into the account it was watched under

This commit is contained in:
Arkadiusz Fal
2026-09-06 11:06:15 +02:00
parent 3dc129dcfa
commit 7bcc711c40
9 changed files with 1044 additions and 4 deletions

View File

@@ -60,6 +60,7 @@ final class AppEnvironment {
let accountService: AccountService
let remotePlaylistsCache: RemotePlaylistsCache
let dataContextValidator: DataContextValidator
let watchHistorySync: WatchHistorySyncService
let playerControlsLayoutService: PlayerControlsLayoutService
let legacyMigrationService: LegacyDataMigrationService
let sourcesSettings: SourcesSettings
@@ -277,7 +278,8 @@ final class AppEnvironment {
default: return nil
}
},
transport: contentSvc
transport: contentSvc,
historyTransport: contentSvc
)
self.accountService = accountSvc
self.remotePlaylistsCache = RemotePlaylistsCache(accountService: accountSvc)
@@ -437,6 +439,17 @@ final class AppEnvironment {
toastManager: toast
)
// Mirrors what is played here into the active account's server-side
// history. Reads the *effective* context, so it has to be built after
// the validator that decides it.
let watchHistory = WatchHistorySyncService(
accountsManager: accounts,
accountService: accountSvc,
contextValidator: self.dataContextValidator
)
self.watchHistorySync = watchHistory
player.setWatchHistorySyncService(watchHistory)
// Initialize Player Controls Layout Service
let layoutService = PlayerControlsLayoutService()
self.playerControlsLayoutService = layoutService
@@ -796,6 +809,7 @@ final class AppEnvironment {
func discardAccountData(for accountID: UUID) {
subscriptionService.discardProvider(for: accountID)
remotePlaylistsCache.clear(accountID: accountID)
watchHistorySync.cancelPending(for: accountID)
}
/// Drops everything cached for whichever account was being shown.

View File

@@ -494,6 +494,59 @@ actor ContentService: ContentServiceProtocol {
}
}
// MARK: - Watch History
/// One page of the credential owner's server-side watch history.
///
/// Invidious is the only backend with a history endpoint at all; the
/// capability gate above this refuses the others long before here.
func watchHistory(
instance: Instance,
credential: String,
page: Int,
maxResults: Int
) async throws -> [String] {
switch instance.type {
case .invidious:
return try await invidiousAPI(for: instance).watchHistory(
instance: instance,
sid: credential,
page: page,
maxResults: maxResults
)
default:
throw APIError.notSupported
}
}
/// Records a video as watched in the credential owner's history.
func markWatched(videoID: String, instance: Instance, credential: String) async throws {
switch instance.type {
case .invidious:
try await invidiousAPI(for: instance).markWatched(
videoID: videoID,
instance: instance,
sid: credential
)
default:
throw APIError.notSupported
}
}
/// Removes one video from the credential owner's history.
func removeWatched(videoID: String, instance: Instance, credential: String) async throws {
switch instance.type {
case .invidious:
try await invidiousAPI(for: instance).removeWatched(
videoID: videoID,
instance: instance,
sid: credential
)
default:
throw APIError.notSupported
}
}
// MARK: - Authentication
/// Signs in to an instance and returns the credential its backend issues —
@@ -658,6 +711,8 @@ actor ContentService: ContentServiceProtocol {
/// that satisfy it; the protocol exists so tests can script a server.
extension ContentService: RemotePlaylistTransport {}
extension ContentService: WatchHistoryTransport {}
// MARK: - Feed Page
/// One page of an authenticated feed, with whatever the backend says about

View File

@@ -820,6 +820,72 @@ actor InvidiousAPI: InstanceAPI {
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
// MARK: - Watch History
/// One page of the user's server-side watch history, newest first.
///
/// The server answers a bare array of video IDs — no titles, no durations,
/// nothing else — so a caller that wants to show them has to hydrate each
/// one itself. Past the end the array is empty, which is the only way to
/// tell there is no further page.
/// - Parameters:
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - page: 1-based page number
/// - maxResults: How many IDs the page may hold
/// - Returns: Video IDs, newest first
func watchHistory(
instance: Instance,
sid: String,
page: Int,
maxResults: Int
) async throws -> [String] {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history",
queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "max_results", value: String(maxResults))
],
headers: authHeaders(sid: sid)
)
let response: [String] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response
}
/// Records a video as watched in the user's server-side history.
///
/// Answers 204 with no body, and repeating it is not an error: the server
/// moves an ID it already holds back to the front, which is what a re-watch
/// should do to the ordering.
/// - Parameters:
/// - videoID: The YouTube video ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
func markWatched(videoID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history/\(videoID)",
method: .post,
headers: authHeaders(sid: sid)
)
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
/// Removes one video from the user's server-side history.
///
/// Idempotent — an ID the history never held answers 204 just the same.
/// - Parameters:
/// - videoID: The YouTube video ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
func removeWatched(videoID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/history/\(videoID)",
method: .delete,
headers: authHeaders(sid: sid)
)
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
}
/// The headers every authenticated write carries. `Content-Type` is set for
/// all of them, including the bodiless ones, so a proxy in front of the
/// instance cannot guess something else.

View File

@@ -66,6 +66,25 @@ protocol RemotePlaylistTransport: Sendable {
func removeEntry(_ entryID: RemotePlaylistEntryID, instance: Instance, credential: String) async throws
}
/// The authenticated watch-history surface ``AccountService`` talks to.
///
/// Same contract as ``RemotePlaylistTransport``: stateless, credential-carrying,
/// and **every method here is exactly one request to the server** — the
/// credential can be replaced between any two of them, and only the service
/// above can tell.
protocol WatchHistoryTransport: Sendable {
func watchHistory(
instance: Instance,
credential: String,
page: Int,
maxResults: Int
) async throws -> [String]
func markWatched(videoID: String, instance: Instance, credential: String) async throws
func removeWatched(videoID: String, instance: Instance, credential: String) async throws
}
/// Why an account could not do what was asked of it.
enum AccountServiceError: Error, LocalizedError, Equatable, Sendable {
/// No credential is readable for this account right now.
@@ -113,15 +132,18 @@ final class AccountService {
private let accountsManager: AccountsManager
private let credentialReader: CredentialReader
private let transport: any RemotePlaylistTransport
private let historyTransport: any WatchHistoryTransport
init(
accountsManager: AccountsManager,
credentialReader: @escaping CredentialReader,
transport: any RemotePlaylistTransport
transport: any RemotePlaylistTransport,
historyTransport: any WatchHistoryTransport
) {
self.accountsManager = accountsManager
self.credentialReader = credentialReader
self.transport = transport
self.historyTransport = historyTransport
}
// MARK: - Reads
@@ -318,6 +340,61 @@ final class AccountService {
}
}
// MARK: - Watch history
/// One page of the account's server-side watch history, newest first.
///
/// Video IDs only — the server stores nothing else — so whatever shows them
/// hydrates them from local history or a video fetch.
/// - Parameters:
/// - page: 1-based page number
/// - maxResults: How many IDs the page may hold
func watchHistory(page: Int, maxResults: Int, for account: Account) async throws -> [String] {
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
return try await tracking(account, credential: credential) {
try await historyTransport.watchHistory(
instance: instance,
credential: credential,
page: page,
maxResults: maxResults
)
}
}
/// Records a video as watched in the account's server-side history.
///
/// Repeating it is not an error: the server moves the video back to the
/// front of the history, which is what a re-watch should do.
func markWatched(videoID: String, for account: Account) async throws {
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await historyTransport.markWatched(
videoID: videoID,
instance: instance,
credential: credential
)
}
}
/// Removes one video from the account's server-side history.
///
/// The server's history only; the local ``WatchEntry`` is a separate store
/// with its own view, and nothing here touches it.
func removeFromWatchHistory(videoID: String, for account: Account) async throws {
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await historyTransport.removeWatched(
videoID: videoID,
instance: instance,
credential: credential
)
}
}
// MARK: - Resolution
/// The instance and credential to talk to on this account's behalf, once

View File

@@ -128,6 +128,7 @@ final class PlayerService {
private weak var queueManager: QueueManager?
private weak var playerControlsLayoutService: PlayerControlsLayoutService?
private weak var handoffManager: HandoffManager?
private weak var watchHistorySyncService: WatchHistorySyncService?
// MARK: - Private State
@@ -263,6 +264,14 @@ final class PlayerService {
saveProgressAndSync()
}
// Mirror this watch into the active account's server history. Only for
// a genuinely new video: an audio-mode toggle or a quality switch
// reloads the same one, and starting a session for those would post it
// to the server a second time.
if isNewVideo {
watchHistorySyncService?.beginSession(video: video)
}
// Reset flag for the new video
videoEndedNaturally = false
@@ -672,6 +681,7 @@ final class PlayerService {
currentPlayTask = nil
saveProgressAndSync()
watchHistorySyncService?.endSession()
cleanup()
// Clear Handoff activity
@@ -2698,6 +2708,11 @@ final class PlayerService {
self.handoffManager = manager
}
/// Sets the service that mirrors playback into an account's server history.
func setWatchHistorySyncService(_ service: WatchHistorySyncService) {
self.watchHistorySyncService = service
}
#if os(iOS)
/// Called when the player sheet appears.
func playerSheetDidAppear() {
@@ -2829,6 +2844,7 @@ final class PlayerService {
// 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)
watchHistorySyncService?.recordProgress(videoID: video.id.videoID)
// Update Handoff activity with current playback time
handoffManager?.updatePlaybackTime(state.currentTime)
@@ -2855,6 +2871,7 @@ final class PlayerService {
// This is the only save for a naturally finished video — play() skips
// saveProgressAndSync() when videoEndedNaturally is set.
dataManager.updateWatchProgress(for: video, seconds: completedDuration, duration: completedDuration)
watchHistorySyncService?.recordProgress(videoID: video.id.videoID)
// Update Handoff activity with completed time
handoffManager?.updatePlaybackTime(completedDuration)
@@ -2871,6 +2888,7 @@ final class PlayerService {
// Save and queue for iCloud sync (used when video closes/switches)
dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration, isLive: state.isLive)
watchHistorySyncService?.recordProgress(videoID: video.id.videoID)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
}

View File

@@ -0,0 +1,191 @@
//
// 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 for a video that is about to play.
///
/// Called only for a genuinely new video. Reloads of the same one — the
/// audio-mode toggle, a quality switch, a stream retry — must not reach
/// here, or each of them would post again.
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)
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 {
guard attempt == 0 else {
LoggingService.shared.debug(
"Watch history mirror dropped for \(videoID): \(error)",
category: .api
)
return
}
}
}
}
// 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
}
}
}

View File

@@ -305,6 +305,9 @@ struct YatteeApp: App {
appEnvironment.playerService.syncWatchProgress()
Task {
await appEnvironment.cloudKitSync.flushPendingChanges()
// The progress save above is what enqueues the server-history
// post, so the flush is created after it.
await appEnvironment.watchHistorySync.flushPending()
}
}
}
@@ -369,6 +372,10 @@ struct YatteeApp: App {
appEnvironment.dataManager.save()
Task {
await appEnvironment.cloudKitSync.flushPendingChanges()
// The progress save above is what enqueues the server-history
// post, so the flush is created after it — and both finish
// before the assertion is given up.
await appEnvironment.watchHistorySync.flushPending()
if backgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
@@ -381,6 +388,9 @@ struct YatteeApp: App {
#else
Task {
await appEnvironment.cloudKitSync.flushPendingChanges()
// The progress save above is what enqueues the server-history
// post, so the flush is created after it.
await appEnvironment.watchHistorySync.flushPending()
}
#endif
}

View File

@@ -18,7 +18,9 @@ import Foundation
/// send — the privacy it dropped, the token it removed by — and any call can be
/// parked mid-flight, which is where the credential-change and generation rules
/// actually live.
private actor ScriptedPlaylistTransport: RemotePlaylistTransport {
/// Shared with `WatchHistorySyncTests`, whose service needs a playlist half it
/// never reaches.
actor ScriptedPlaylistTransport: RemotePlaylistTransport {
enum Call: Equatable, Sendable {
case userPlaylists(credential: String)
case snapshot(id: String, credential: String)
@@ -160,6 +162,96 @@ private actor ScriptedPlaylistTransport: RemotePlaylistTransport {
}
}
// MARK: - Scripted history transport
/// A ``WatchHistoryTransport`` that answers from a script instead of a server.
///
/// Same shape as ``ScriptedPlaylistTransport`` — every call recorded, any call
/// parkable — because the same questions are asked of it: which credential went
/// out, and what happened while one was still in flight. Shared with
/// `WatchHistorySyncTests`.
actor ScriptedHistoryTransport: WatchHistoryTransport {
enum Call: Equatable, Sendable {
case history(page: Int, maxResults: Int, credential: String)
case markWatched(videoID: String, credential: String)
case removeWatched(videoID: String, credential: String)
}
private(set) var calls: [Call] = []
/// Thrown by every call, once set.
var nextError: Error?
/// What ``watchHistory(instance:credential:page:maxResults:)`` answers.
var history: [String] = []
/// While true, every call suspends until `release()`.
private var holdsCallsOpen = false
private var parked: [CheckedContinuation<Void, Never>] = []
func setError(_ error: Error?) { nextError = error }
func setHistory(_ history: [String]) { self.history = history }
func holdCalls() { holdsCallsOpen = true }
func release() {
holdsCallsOpen = false
let waiting = parked
parked = []
waiting.forEach { $0.resume() }
}
/// Waits until at least `count` calls have been entered.
func waitForCalls(_ count: Int) async throws {
for _ in 0 ..< 400 {
if calls.count >= count { return }
try await Task.sleep(for: .milliseconds(5))
}
Issue.record("The history transport was never entered \(count) time(s)")
}
/// Waits for the calls that should *not* happen to have had their chance.
func settle() async throws {
try await Task.sleep(for: .milliseconds(50))
}
var callCount: Int { calls.count }
var markedVideoIDs: [String] {
calls.compactMap {
if case .markWatched(let videoID, _) = $0 { return videoID }
return nil
}
}
private func record(_ call: Call) async throws {
calls.append(call)
if holdsCallsOpen {
await withCheckedContinuation { parked.append($0) }
}
if let nextError { throw nextError }
}
func watchHistory(
instance _: Instance,
credential: String,
page: Int,
maxResults: Int
) async throws -> [String] {
try await record(.history(page: page, maxResults: maxResults, credential: credential))
return history
}
func markWatched(videoID: String, instance _: Instance, credential: String) async throws {
try await record(.markWatched(videoID: videoID, credential: credential))
}
func removeWatched(videoID: String, instance _: Instance, credential: String) async throws {
try await record(.removeWatched(videoID: videoID, credential: credential))
}
}
// MARK: - Harness
/// An account on an instance, a scripted transport, and the service between
@@ -172,6 +264,7 @@ private final class AccountServiceHarness {
let accounts: AccountsManager
let account: Account
let transport = ScriptedPlaylistTransport()
let historyTransport = ScriptedHistoryTransport()
private(set) var service: AccountService!
/// What the credential reader reports right now.
@@ -204,7 +297,8 @@ private final class AccountServiceHarness {
service = AccountService(
accountsManager: accounts,
credentialReader: { [weak self] _ in self?.readCredential() },
transport: transport
transport: transport,
historyTransport: historyTransport
)
}
@@ -637,6 +731,97 @@ struct AccountServiceTests {
#expect(await harness.transport.callCount == 0)
}
// MARK: - Watch history
@Test("A backend without a history endpoint is refused before anything is sent")
func historyIsRefusedWhereUnsupported() async throws {
let harness = AccountServiceHarness(type: .piped)
defer { harness.tearDown() }
await #expect(throws: AccountServiceError.notSupported) {
_ = try await harness.service.watchHistory(page: 1, maxResults: 60, for: harness.account)
}
await #expect(throws: AccountServiceError.notSupported) {
try await harness.service.markWatched(videoID: "aaa", for: harness.account)
}
await #expect(throws: AccountServiceError.notSupported) {
try await harness.service.removeFromWatchHistory(videoID: "aaa", for: harness.account)
}
#expect(await harness.historyTransport.callCount == 0)
}
@Test("History without a credential is refused before anything is sent")
func historyIsRefusedWithoutACredential() async throws {
let harness = AccountServiceHarness()
defer { harness.tearDown() }
harness.credential = nil
await #expect(throws: AccountServiceError.notAuthenticated) {
_ = try await harness.service.watchHistory(page: 1, maxResults: 60, for: harness.account)
}
await #expect(throws: AccountServiceError.notAuthenticated) {
try await harness.service.markWatched(videoID: "aaa", for: harness.account)
}
#expect(await harness.historyTransport.callCount == 0)
}
@Test("A history page is asked for with the paging the caller chose")
func historyPageCarriesItsPaging() async throws {
let harness = AccountServiceHarness()
defer { harness.tearDown() }
await harness.historyTransport.setHistory(["aaa", "bbb"])
let ids = try await harness.service.watchHistory(page: 2, maxResults: 60, for: harness.account)
#expect(ids == ["aaa", "bbb"])
#expect(await harness.historyTransport.calls == [
.history(page: 2, maxResults: 60, credential: "secret-original")
])
}
@Test("A credential replaced between resolving and marking stops the write")
func markingStopsWhenTheCredentialChanges() async throws {
let harness = AccountServiceHarness()
defer { harness.tearDown() }
harness.credentialSwapOnNextRead = "secret-replacement"
await #expect(throws: AccountServiceError.notAuthenticated) {
try await harness.service.markWatched(videoID: "aaa", for: harness.account)
}
#expect(await harness.historyTransport.callCount == 0)
}
@Test("A removal from the server's history goes out on its own")
func removalFromHistoryIsOneRequest() async throws {
let harness = AccountServiceHarness()
defer { harness.tearDown() }
try await harness.service.removeFromWatchHistory(videoID: "aaa", for: harness.account)
#expect(await harness.historyTransport.calls == [
.removeWatched(videoID: "aaa", credential: "secret-original")
])
}
@Test("A history call reports what it learned about the login")
func historyRecordsItsOutcome() async throws {
let harness = AccountServiceHarness()
defer { harness.tearDown() }
harness.accounts.setStatus(.credentialMissing, for: harness.account.id)
try await harness.service.markWatched(videoID: "aaa", for: harness.account)
#expect(harness.status == .signedIn)
await harness.historyTransport.setError(APIError.timeout)
await #expect(throws: APIError.timeout) {
try await harness.service.markWatched(videoID: "bbb", for: harness.account)
}
#expect(harness.status == .unreachable)
}
}
// MARK: - Cache

View File

@@ -0,0 +1,424 @@
//
// WatchHistorySyncTests.swift
// YatteeTests
//
// Which account a watch is mirrored to, how many times it is sent, and what
// happens to a post nobody is waiting for.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Harness
/// An account whose instance keeps history, the scripted transport behind it,
/// and the sync service on top.
///
/// The context validator is the real one: what suppresses mirroring is the
/// *effective* context, and that is computed from the account's status and its
/// instance — both of which a test can move directly.
@MainActor
private final class WatchHistorySyncHarness {
let instances: InstancesManager
private(set) var instance: Instance
let accounts: AccountsManager
let account: Account
let playlistTransport = ScriptedPlaylistTransport()
let transport = ScriptedHistoryTransport()
let validator: DataContextValidator
private(set) var service: WatchHistorySyncService!
/// What the credential reader reports for each account right now. Keyed by
/// account so a post can be shown to have gone out under the login that was
/// selected when playback started, and not the one selected since.
var credentials: [UUID: String] = [:]
init(type: InstanceType = .invidious) {
instances = InstancesManager()
instance = Instance(
type: type,
url: URL(string: "https://history-\(UUID().uuidString).example.com")!,
isEnabled: true
)
instances.add(instance)
accounts = AccountsManager(
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
instancesManager: instances,
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!,
unreachableRetryInterval: 3600
)
account = accounts.addAccount(instanceID: instance.id)
accounts.setStatus(.signedIn, for: account.id)
accounts.setContext(.account(account.id))
credentials[account.id] = "secret-original"
validator = DataContextValidator(
accountsManager: accounts,
instancesManager: instances,
toastManager: ToastManager()
)
let accountService = AccountService(
accountsManager: accounts,
credentialReader: { [weak self] account in self?.credentials[account.id] },
transport: playlistTransport,
historyTransport: transport
)
service = WatchHistorySyncService(
accountsManager: accounts,
accountService: accountService,
contextValidator: validator
)
}
/// Instances live in the shared defaults, so each harness takes its own
/// back out again.
func tearDown() {
instances.remove(instance)
}
/// A second account on its own instance, for the context switches.
@discardableResult
func addSecondAccount(type: InstanceType = .invidious) -> (Instance, Account) {
let other = Instance(
type: type,
url: URL(string: "https://history-other-\(UUID().uuidString).example.com")!,
isEnabled: true
)
instances.add(other)
let otherAccount = accounts.addAccount(instanceID: other.id)
accounts.setStatus(.signedIn, for: otherAccount.id)
credentials[otherAccount.id] = "secret-other"
return (other, otherAccount)
}
}
/// A flag a test can look at from outside the task that sets it.
@MainActor
private final class Flag {
var isSet = false
}
private func makeVideo(_ id: String) -> Video {
Video(
id: .global(id),
title: "Video \(id)",
description: nil,
author: Author(id: "UCtest", name: "Test Channel"),
duration: 60,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// A file from a media source: an ID no YouTube-backed history can take.
private func makeExtractedVideo(_ id: String) -> Video {
Video(
id: .extracted(id, extractor: "generic", originalURL: URL(string: "https://example.com/\(id).mp4")!),
title: "File \(id)",
description: nil,
author: Author(id: "local", name: "Files"),
duration: 60,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
// MARK: - Tests
@Suite("Watch history mirroring", .serialized)
@MainActor
struct WatchHistorySyncTests {
// MARK: - What is mirrored, and to whom
@Test("A watch is posted once its progress is first saved")
func progressPostsTheWatch() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.calls == [
.markWatched(videoID: "aaa", credential: "secret-original")
])
}
@Test("A context switched mid-video does not redirect the post")
func theAccountIsCapturedWhenPlaybackStarts() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
let (_, other) = harness.addSecondAccount()
harness.service.beginSession(video: makeVideo("aaa"))
// The user switches accounts while the video is playing. The video was
// still watched under the one that was selected when it started.
harness.accounts.setContext(.account(other.id))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.calls == [
.markWatched(videoID: "aaa", credential: "secret-original")
])
}
@Test("Repeated progress saves in one session cost one post")
func oneSessionPostsOnce() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
harness.service.recordProgress(videoID: "aaa")
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa"])
}
@Test("Playing the same video again is a new watch and posts again")
func aNewSessionForTheSameVideoPostsAgain() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
harness.service.endSession()
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
}
@Test("Progress for a video the session is not about is ignored")
func progressForAnotherVideoIsIgnored() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "bbb")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
@Test("Progress after the session ended is ignored")
func progressAfterTheSessionEndedIsIgnored() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.endSession()
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
// MARK: - What is never mirrored
@Test("A video that is not YouTube content is never posted")
func nonYouTubeContentIsNeverPosted() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeExtractedVideo("file"))
harness.service.recordProgress(videoID: "file")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
@Test("Local data is nobody's history")
func localContextIsNeverPosted() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.accounts.setContext(.local)
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
@Test("A backend without a history endpoint is never posted to")
func anAccountWithoutHistorySupportIsNeverPosted() async throws {
let harness = WatchHistorySyncHarness(type: .piped)
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
@Test("A context that is only falling back to local is not mirrored either")
func anUnavailableContextIsNeverPosted() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
// The selection is kept, but nothing may be sent under it — and the
// reads behind the watch came from local data, not the account.
harness.accounts.setStatus(.credentialMissing, for: harness.account.id)
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
// MARK: - Failure
@Test("A failed post is tried once more and then dropped")
func aFailedPostIsRetriedExactlyOnce() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
await harness.transport.setError(APIError.timeout)
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 2)
}
@Test("A post that could not be resolved at all is not retried")
func aResolutionFailureIsNotRetried() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
// Signed out between the session starting and the progress save: the
// account has no credential to send, and asking again would not change
// that.
harness.credentials[harness.account.id] = nil
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
@Test("An account deleted while the video played has nothing to mirror to")
func aDeletedAccountDropsThePost() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.accounts.removeAccount(harness.account.id)
harness.service.recordProgress(videoID: "aaa")
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 0)
}
// MARK: - Draining and disowning
@Test("A flush returns only once the post it is waiting for has landed")
func flushWaitsForAPostInFlight() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
await harness.transport.holdCalls()
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.waitForCalls(1)
let finished = Flag()
let flush = Task { [service = harness.service!] in
await service.flushPending()
finished.isSet = true
}
// Long enough for the flush to have returned, had it not been waiting.
try await Task.sleep(for: .milliseconds(50))
#expect(finished.isSet == false)
await harness.transport.release()
await flush.value
#expect(finished.isSet)
}
@Test("A flush with nothing pending returns immediately")
func flushWithNothingPendingReturns() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
await harness.service.flushPending()
#expect(await harness.transport.callCount == 0)
}
@Test("An account that goes away takes its unsent post with it")
func cancellingPreventsThePost() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.cancelPending(for: harness.account.id)
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.settle()
// The session went with it, so the progress save has nothing to post.
#expect(await harness.transport.callCount == 0)
}
@Test("An account that goes away disowns the post already in flight")
func cancellingKillsAPostInFlight() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
await harness.transport.holdCalls()
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
try await harness.transport.waitForCalls(1)
harness.service.cancelPending(for: harness.account.id)
await harness.transport.release()
// Nothing is left to wait for, and the cancelled attempt is never
// repeated.
await harness.service.flushPending()
try await harness.transport.settle()
#expect(await harness.transport.callCount == 1)
}
@Test("Another account's trouble leaves this one's post alone")
func cancellingAnotherAccountLeavesThePost() async throws {
let harness = WatchHistorySyncHarness()
defer { harness.tearDown() }
let (_, other) = harness.addSecondAccount()
harness.service.beginSession(video: makeVideo("aaa"))
harness.service.recordProgress(videoID: "aaa")
harness.service.cancelPending(for: other.id)
await harness.service.flushPending()
#expect(await harness.transport.markedVideoIDs == ["aaa"])
}
}