mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
493 lines
20 KiB
Swift
493 lines
20 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
|
|
}
|
|
|
|
/// The authenticated watch-history surface ``AccountService`` talks to.
|
|
///
|
|
/// Same contract as ``RemotePlaylistTransport``: stateless, credential-carrying,
|
|
/// and **every method here is exactly one request to the server** — the
|
|
/// credential can be replaced between any two of them, and only the service
|
|
/// above can tell.
|
|
protocol WatchHistoryTransport: Sendable {
|
|
func watchHistory(
|
|
instance: Instance,
|
|
credential: String,
|
|
page: Int,
|
|
maxResults: Int
|
|
) async throws -> [String]
|
|
|
|
func markWatched(videoID: String, instance: Instance, credential: String) async throws
|
|
|
|
func removeWatched(videoID: String, 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
|
|
private let historyTransport: any WatchHistoryTransport
|
|
|
|
init(
|
|
accountsManager: AccountsManager,
|
|
credentialReader: @escaping CredentialReader,
|
|
transport: any RemotePlaylistTransport,
|
|
historyTransport: any WatchHistoryTransport
|
|
) {
|
|
self.accountsManager = accountsManager
|
|
self.credentialReader = credentialReader
|
|
self.transport = transport
|
|
self.historyTransport = historyTransport
|
|
}
|
|
|
|
// 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: - Watch history
|
|
|
|
/// One page of the account's server-side watch history, newest first.
|
|
///
|
|
/// Video IDs only — the server stores nothing else — so whatever shows them
|
|
/// hydrates them from local history or a video fetch.
|
|
/// - Parameters:
|
|
/// - page: 1-based page number
|
|
/// - maxResults: How many IDs the page may hold
|
|
func watchHistory(page: Int, maxResults: Int, for account: Account) async throws -> [String] {
|
|
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
|
|
return try await tracking(account, credential: credential) {
|
|
try await historyTransport.watchHistory(
|
|
instance: instance,
|
|
credential: credential,
|
|
page: page,
|
|
maxResults: maxResults
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Records a video as watched in the account's server-side history.
|
|
///
|
|
/// Repeating it is not an error: the server moves the video back to the
|
|
/// front of the history, which is what a re-watch should do.
|
|
func markWatched(videoID: String, for account: Account) async throws {
|
|
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
|
|
|
|
try requireStillCurrent(credential, for: account)
|
|
try await tracking(account, credential: credential) {
|
|
try await historyTransport.markWatched(
|
|
videoID: videoID,
|
|
instance: instance,
|
|
credential: credential
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Removes one video from the account's server-side history.
|
|
///
|
|
/// The server's history only; the local ``WatchEntry`` is a separate store
|
|
/// with its own view, and nothing here touches it.
|
|
func removeFromWatchHistory(videoID: String, for account: Account) async throws {
|
|
let (instance, credential) = try resolve(account, requiring: \.supportsWatchHistory)
|
|
|
|
try requireStillCurrent(credential, for: account)
|
|
try await tracking(account, credential: credential) {
|
|
try await historyTransport.removeWatched(
|
|
videoID: videoID,
|
|
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)
|
|
}
|
|
}
|