mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Ten review findings in the remote-playlist work, all of them the same shape: something acted on what it remembered instead of what the server says now. - A confirmed-gone playlist is forgotten whole — snapshot, list entry, and both ordering fences — so a refresh that started before the delete can no longer republish it, and a list refetch that fails offline no longer leaves it on screen. - `.accountsDidChange` discards account-scoped content when the account it belonged to is no longer the effective one, as the status notification already did. - A tab that stops being offered no longer stays selected: the browse view moves to the first available one, which reloads by itself. - A Handoff account id is accepted only when that account is on the instance the playlist lives on. - Composite writes moved up into `AccountService`, over a transport whose every method is exactly one request: create-with-privacy, rename, and set-description. The credential is rechecked between each pair, so a sign-out between the two halves of a create or an edit can no longer send the second half under the previous user's secret. - Editing a playlist no longer forces it private. Invidious serialises visibility as `isListed` — `privacy.public?`, so unlisted and private are indistinguishable — which the snapshot now carries; the picker starts there, and sends nothing at all unless it was moved. Invidious keeps the stored visibility when the PATCH omits it (measured), so an untouched picker leaves it exactly as it was. - Tapping Edit on a server-side playlist awaits an authenticated refetch first and populates the form from it; a failed refetch shows an error and no sheet. - Playlist rows are keyed by row rather than by video id, which two rows of the same video share. - The add-to-playlist sheet offers only accounts whose instance is enabled and that hold a credential, alongside the write capability.
396 lines
16 KiB
Swift
396 lines
16 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.
|
|
///
|
|
/// 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)
|
|
}
|
|
}
|