diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index f48619f4..31ca3328 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -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. diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index 9458810e..89589d48 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -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 diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index de9b8203..fa26360a 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -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. diff --git a/Yattee/Services/AccountService.swift b/Yattee/Services/AccountService.swift index 37c96ce3..5c020f99 100644 --- a/Yattee/Services/AccountService.swift +++ b/Yattee/Services/AccountService.swift @@ -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 diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index a47997a3..f9f67a42 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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) } diff --git a/Yattee/Services/WatchHistorySyncService.swift b/Yattee/Services/WatchHistorySyncService.swift new file mode 100644 index 00000000..293b7efb --- /dev/null +++ b/Yattee/Services/WatchHistorySyncService.swift @@ -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)] = [:] + + 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 + } + } +} diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index af03a048..abdb218c 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -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 } diff --git a/YatteeTests/AccountServiceTests.swift b/YatteeTests/AccountServiceTests.swift index 857f98ec..4237b73e 100644 --- a/YatteeTests/AccountServiceTests.swift +++ b/YatteeTests/AccountServiceTests.swift @@ -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] = [] + + 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 diff --git a/YatteeTests/WatchHistorySyncTests.swift b/YatteeTests/WatchHistorySyncTests.swift new file mode 100644 index 00000000..1aa8aa6a --- /dev/null +++ b/YatteeTests/WatchHistorySyncTests.swift @@ -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"]) + } +}