// // AccountService.swift // Yattee // // The account-first facade for server-side playlists: everything a view needs // to read or change a playlist that lives on an instance, addressed by the // account it belongs to rather than by an instance and a secret. // import Foundation /// The authenticated playlist surface ``AccountService`` talks to. /// /// Exists so the service can be exercised without a server: `ContentService` /// conforms to it, and tests script it. Deliberately stateless and /// credential-carrying — resolving *which* credential is this service's job, /// and the transport must never guess. protocol RemotePlaylistTransport: Sendable { func userPlaylists(instance: Instance, credential: String) async throws -> [Playlist] func userPlaylistSnapshot( id: String, instance: Instance, credential: String ) async throws -> RemotePlaylistSnapshot func createPlaylist( title: String, description: String?, privacy: PlaylistPrivacy?, instance: Instance, credential: String ) async throws -> Playlist func updatePlaylist( id: String, title: String, description: String?, privacy: PlaylistPrivacy?, instance: Instance, credential: String ) async throws func deletePlaylist(id: String, instance: Instance, credential: String) async throws func addVideoToPlaylist( playlistID: String, videoID: String, instance: Instance, credential: String ) async throws func removeEntry(_ entryID: RemotePlaylistEntryID, 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. case notAuthenticated /// The account's instance is unknown or switched off. case instanceNotConfigured /// The backend has no such feature. case notSupported /// The row that was going to be removed is no longer the row it was. case entryMismatch var errorDescription: String? { switch self { case .notAuthenticated: return String(localized: "accounts.playlists.error.notAuthenticated") case .instanceNotConfigured: return String(localized: "accounts.playlists.error.instanceNotConfigured") case .notSupported: return String(localized: "accounts.playlists.error.notSupported") case .entryMismatch: return String(localized: "accounts.playlists.error.entryMismatch") } } } /// Reads and changes the playlists an account has on its instance. /// /// Views hand it an ``Account`` and nothing else. It resolves the instance and /// the credential, refuses what the backend cannot do, and records what every /// answer implies about the login — which is what eventually marks an account /// as rejected, and what stops it being marked by somebody else's failure. /// /// Nothing is cached here. ``RemotePlaylistsCache`` is what holds results, and /// it is the layer that refetches after a change. @MainActor final class AccountService { /// The credential an account currently holds, or `nil` when there is none /// to be read — including while the identity scope is unresolved, which is /// "cannot tell yet" rather than "signed out". typealias CredentialReader = @MainActor (Account) -> String? private let accountsManager: AccountsManager private let credentialReader: CredentialReader private let transport: any RemotePlaylistTransport init( accountsManager: AccountsManager, credentialReader: @escaping CredentialReader, transport: any RemotePlaylistTransport ) { self.accountsManager = accountsManager self.credentialReader = credentialReader self.transport = transport } // MARK: - Reads /// The playlists this account has on its instance. func userPlaylists(for account: Account) async throws -> [Playlist] { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistReads) return try await tracking(account, credential: credential) { try await transport.userPlaylists(instance: instance, credential: credential) } } /// One playlist, together with the identities its rows can be removed by. func playlistSnapshot(id: String, for account: Account) async throws -> RemotePlaylistSnapshot { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistReads) return try await tracking(account, credential: credential) { try await transport.userPlaylistSnapshot(id: id, instance: instance, credential: credential) } } // MARK: - Writes /// Creates a playlist on the account's instance. /// /// - Parameter privacy: dropped on backends without a visibility concept, /// rather than refused: the playlist is still perfectly creatable there, /// and failing the whole call over a setting the server does not have /// would be a worse answer than making it. func createPlaylist( title: String, description: String?, privacy: PlaylistPrivacy?, for account: Account ) async throws -> Playlist { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistWrites) let privacy = supportedPrivacy(privacy, for: account) return try await tracking(account, credential: credential) { try await transport.createPlaylist( title: title, description: description, privacy: privacy, instance: instance, credential: credential ) } } /// Edits a playlist on the account's instance. func updatePlaylist( id: String, title: String, description: String?, privacy: PlaylistPrivacy?, for account: Account ) async throws { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistWrites) let privacy = supportedPrivacy(privacy, for: account) try await tracking(account, credential: credential) { try await transport.updatePlaylist( id: id, title: title, description: description, privacy: privacy, instance: instance, credential: credential ) } } /// Deletes a playlist from the account's instance. func deletePlaylist(id: String, for account: Account) async throws { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistWrites) try await tracking(account, credential: credential) { try await transport.deletePlaylist(id: id, instance: instance, credential: credential) } } /// Appends a video to one of the account's playlists. func addVideo(videoID: String, toPlaylist id: String, for account: Account) async throws { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistWrites) try await tracking(account, credential: credential) { try await transport.addVideoToPlaylist( playlistID: id, videoID: videoID, instance: instance, credential: credential ) } } /// Removes one row from one of the account's playlists. /// /// A Piped entry names a *position*, and the playlist may have moved since /// the snapshot that minted it — so the playlist is refetched and the /// position checked to still hold the same video before anything is /// removed. That narrows the window; it cannot close it. Between the check /// and the removal the playlist can still change, and only the caller's /// refetch afterwards can show what actually happened. Invidious needs none /// of this: its `indexId` names the row itself, whatever moved around it. func removeEntry( _ entry: RemotePlaylistEntry, fromPlaylist id: String, for account: Account ) async throws { let (instance, credential) = try resolve(account, requiring: \.supportsPlaylistWrites) switch entry.id { case .invidious(let playlistID, _): guard playlistID == id else { throw AccountServiceError.entryMismatch } case .piped(let playlistID, let position): guard playlistID == id else { throw AccountServiceError.entryMismatch } let snapshot = try await tracking(account, credential: credential) { try await transport.userPlaylistSnapshot(id: id, instance: instance, credential: credential) } guard let current = snapshot.entries.first(where: { $0.position == position }), current.video.id.videoID == entry.video.id.videoID else { throw AccountServiceError.entryMismatch } } try await tracking(account, credential: credential) { try await transport.removeEntry(entry.id, instance: instance, credential: credential) } } // MARK: - Resolution /// The instance and credential to talk to on this account's behalf, once /// the backend has been confirmed to support the operation. private func resolve( _ account: Account, requiring capability: KeyPath ) throws -> (Instance, String) { // The account names its instance directly — never "some enabled // instance of the right type", which could send one account's request // to a server it has no credential for. guard let instance = accountsManager.instance(for: account), instance.isEnabled else { throw AccountServiceError.instanceNotConfigured } // Capabilities are the one place that decides what a backend can do; no // caller here switches on the instance type to find out. guard let capabilities = accountsManager.capabilities(for: account) else { throw AccountServiceError.instanceNotConfigured } guard capabilities[keyPath: capability] else { throw AccountServiceError.notSupported } guard let credential = credentialReader(account) else { throw AccountServiceError.notAuthenticated } return (instance, credential) } /// The privacy to send, or `nil` where the backend has no such setting. private func supportedPrivacy(_ privacy: PlaylistPrivacy?, for account: Account) -> PlaylistPrivacy? { guard accountsManager.capabilities(for: account)?.supportsPlaylistPrivacy == true else { return nil } return privacy } // MARK: - Outcome tracking /// Runs a server call and records what it implies about the credential. private func tracking( _ account: Account, credential: String, _ operation: () async throws -> T ) async throws -> T { do { let value = try await operation() recordOutcome(nil, for: account, credential: credential) return value } catch { recordOutcome(error, for: account, credential: credential) throw error } } /// Records an outcome against the account, but only for the secret the /// request actually used. /// /// Signing in again — or a switch of Apple account — replaces the /// credential under the same key while a request is still out, and a late /// 401 would otherwise reject the replacement, or a late success clear a /// rejection it never earned. private func recordOutcome(_ error: Error?, for account: Account, credential: String) { guard let current = accountsManager.account(id: account.id), credentialReader(current) == credential else { return } accountsManager.recordServerOutcome(error, for: current) } }