mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Mirror a watch into the account it was watched under
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
191
Yattee/Services/WatchHistorySyncService.swift
Normal file
191
Yattee/Services/WatchHistorySyncService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
424
YatteeTests/WatchHistorySyncTests.swift
Normal file
424
YatteeTests/WatchHistorySyncTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user