Files
yattee/Yattee/Services/AccountService.swift
Arkadiusz Fal 24b43ac866 Aim a removal at the row the server counts
Remote playlist entries carried the display row number as their server
position, so a row the app could not parse shifted every removal after it
by one, and rows were paired back to entries by video id, which cannot
tell two copies of the same video apart.

- Piped entry positions are now the raw stream index; entries carry a
  separate displayRow for the row they are drawn as, filled in by both
  snapshot builders and used by the detail view's row map
- an emptied description clears a server-side playlist instead of being
  read as "leave it alone"
- a delete that reported failure forgets a playlist the refetch 404s on,
  and drops it from the account's cached list
- the browse view discards account-scoped content when a status change
  moves which account the instance resolves to
- every mutation re-reads the credential immediately before it is sent,
  which matters most after Piped's verify-before-remove fetch
- Handoff carries the owning account id, so a Piped playlist keeps its
  ownership on the receiving device
2026-09-02 23:56:59 +02:00

339 lines
13 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)
try requireStillCurrent(credential, 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 requireStillCurrent(credential, 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 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.
///
/// 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.
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)
}
}