Files
yattee/Yattee/Services/AccountService.swift
Arkadiusz Fal b2741bbb32 Let an account change the playlists it owns
Reading a server-side playlist was all the app could do: the write half of
both APIs was missing, and the only thing that knew which credential to use
was the subscription layer. So playlists arrive here as an account concern —
hand AccountService an account and it finds the instance, reads the secret,
refuses what the backend cannot do, and records what the answer implies
about the login.

Capabilities decide, never the instance type: a privacy setting sent to a
backend that has none is dropped rather than refused, because the playlist
is still perfectly creatable there. Every call captures the credential it
used and re-reads it before recording an outcome, so a late 401 cannot
reject the login the user just fixed, nor a late success clear a rejection
it never earned.

Removal is where the backends stop being alike. Invidious names the row
itself with an indexId that survives refetches, so it removes directly.
Piped removes by position, and a position is only true of the fetch that
minted it — so the playlist is refetched and the position checked to still
hold the same video before anything is deleted. That narrows the window; it
cannot close it, and only the refetch afterwards shows what really happened.

The cache exists so that refetch cannot be forgotten. Every mutation goes
through it and republishes what it touched — on failure as much as on
success, because a write that reported failure may still have landed — and
nothing is ever edited optimistically. It is in memory alone: the data
belongs to a login rather than to this device, and a stored Piped entry id
would be aimed at whatever occupies that slot next launch. A per-account
generation fences results that come back after the account was cleared,
since clearing has never cancelled the request that was filling it.

Along the way, clients minted per instance sent no User-Agent at all —
instances that whitelist the Yattee prefix answered them with a 401 the
shared client never earned. The factory now bakes the app's User-Agent in
at construction, where no request can outrun it.
2026-09-02 23:29:13 +02:00

316 lines
12 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.
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<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)
}
/// 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)
}
}