Files
yattee/Yattee/Services/AccountService.swift
Arkadiusz Fal cb858edd34 Serialize playlist mutations and re-resolve on credential change
Two changes to a server-side playlist could overlap: the second planned its
write from the snapshot the first had already invalidated, and on Piped —
where a row is a position — its preflight then passed over whatever had
shifted into the slot. Mutations now hold a per-playlist queue from the write
to the end of the refetch that reconciles it. Creation queues on the account's
list instead: it depends on no playlist's contents, and one queue per account
would only make unrelated changes wait.

The playlist detail view kept the authenticated snapshot in its own state, so
signing in as somebody else — or a change of Apple account — left the previous
login's rows, title and destructive affordances on screen. It now discards them
the moment either lands and loads again, which re-resolves ownership and falls
back to the public endpoint.

Also documents two residuals rather than pretending to close them: equal rows
are indistinguishable to a positional API, so verify-before-remove cannot
protect a particular occurrence of a repeated video; and the credential recheck
is a fence, not a lock, because Keychain state cannot be made atomic with a
remote send.
2026-09-03 20:43:21 +02:00

416 lines
17 KiB
Swift

//
// 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.
///
/// **Every method here is exactly one request to the server.** Nothing composes
/// two of them, because the credential can be replaced between any two requests
/// and only this service can tell: a composite that awaited its own first call
/// would send the second with a secret that has since changed hands. Backends
/// that need several writes to change one playlist are sequenced above, with a
/// recheck between each pair.
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,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
) async throws -> Playlist
func renamePlaylist(
id: String,
title: String,
privacy: PlaylistPrivacy?,
instance: Instance,
credential: String
) async throws
func setPlaylistDescription(
id: String,
description: String,
title: String,
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.
///
/// Two requests when a description was asked for, because no backend takes
/// one at creation — and the credential is checked again in between, since
/// it can be replaced while the first one is out. The second request's
/// failure is the caller's to see: a playlist that exists with the wrong
/// description is not a silent success.
///
/// - 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)
try requireStillCurrent(credential, for: account)
let playlist = try await tracking(account, credential: credential) {
try await transport.createPlaylist(
title: title,
privacy: privacy,
instance: instance,
credential: credential
)
}
if let description, !description.isEmpty {
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.setPlaylistDescription(
id: playlist.id.playlistID,
description: description,
title: title,
instance: instance,
credential: credential
)
}
}
return playlist
}
/// Edits a playlist on the account's instance.
///
/// Two requests, no transaction, when the description is part of the edit:
/// Piped stores the name and the description behind separate endpoints, and
/// Invidious refuses to take a description without a title. The rename goes
/// first so a failure of the second leaves the visible half applied, and the
/// caller's refetch shows exactly what landed — with the credential checked
/// again in between, because it can be replaced while the rename is out.
///
/// - Parameters:
/// - description: `nil` leaves whatever the playlist has.
/// - privacy: `nil` leaves whatever visibility the playlist has; ignored
/// entirely on backends without one.
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 requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.renamePlaylist(
id: id,
title: title,
privacy: privacy,
instance: instance,
credential: credential
)
}
if let description {
try requireStillCurrent(credential, for: account)
try await tracking(account, credential: credential) {
try await transport.setPlaylistDescription(
id: id,
description: description,
title: title,
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 requireStillCurrent(credential, for: account)
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 requireStillCurrent(credential, for: account)
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.
///
/// Two rows of the same video are one row to a positional API, so the check
/// is weaker still where a playlist repeats one: it can say the position
/// holds that video, never that it holds *this* occurrence of it. In
/// `[A, A, B]`, another client removing the first `A` leaves the second in
/// its place and the check passes over the survivor. Nothing Piped exposes
/// tells two equal rows apart — there is no token for the row, only its
/// index — so no stronger comparison exists to make here; a fingerprint of
/// the neighbours would refuse legitimate removals without ever settling
/// the duplicate case. The refetch afterwards is what shows the result.
///
/// The preflight is also where the credential can change out from under the
/// removal — it is the one place here that awaits a server before writing —
/// so the credential is read again before the removal is sent.
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 requireStillCurrent(credential, for: account)
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<AccountCapabilities, Bool>
) 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)
}
/// Refuses to send a write with a credential that is no longer the
/// account's.
///
/// Resolving a credential and sending the request are not the same instant:
/// a sign-out and a sign-in as somebody else can both happen in between —
/// Piped's removals in particular await a whole playlist fetch first. A
/// stale *read* is merely wasted; a stale *write* changes the previous
/// user's data, so every mutation checks again immediately before it goes.
///
/// It is a fence, not a lock, and the last of it is accepted rather than
/// closed: between this check and the bytes leaving the device there is at
/// least an actor hop, and a sign-out landing inside it still sends the old
/// secret. Keychain state cannot be made atomic with a remote send —
/// cancelling in-flight work or handing the transport a generation would
/// move the window, not remove it — so the recheck is the practical fence,
/// and what follows the send is what covers the remainder: an outcome is
/// recorded only against the secret the request actually used, so a late
/// answer never lands on a credential that did not earn it.
private func requireStillCurrent(_ credential: String, for account: Account) throws {
guard credentialReader(account) == credential else {
throw AccountServiceError.notAuthenticated
}
}
/// 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<T>(
_ 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)
}
}