mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
This commit is contained in:
@@ -57,6 +57,8 @@ final class AppEnvironment {
|
||||
let invidiousAPI: InvidiousAPI
|
||||
let pipedAPI: PipedAPI
|
||||
let credentialProbeService: CredentialProbeService
|
||||
let accountService: AccountService
|
||||
let remotePlaylistsCache: RemotePlaylistsCache
|
||||
let subscriptionAccountValidator: SubscriptionAccountValidator
|
||||
let dataContextValidator: DataContextValidator
|
||||
let playerControlsLayoutService: PlayerControlsLayoutService
|
||||
@@ -154,6 +156,16 @@ final class AppEnvironment {
|
||||
await client.setRandomizeUserAgentPerRequest(settings.randomizeUserAgentPerRequest)
|
||||
}
|
||||
|
||||
// Every per-instance client is minted by a factory rather than shared,
|
||||
// and until this is recorded those clients send no User-Agent at all —
|
||||
// which instances that whitelist the `Yattee/<version>` prefix answer
|
||||
// with a 401 the shared client never earns. Set before anything that
|
||||
// builds a factory, `ContentService` included.
|
||||
UserAgentConfiguration.shared.update(
|
||||
userAgent: settings.customUserAgent,
|
||||
randomizePerRequest: settings.randomizeUserAgentPerRequest
|
||||
)
|
||||
|
||||
let instances = instancesManager ?? InstancesManager(settingsManager: settings)
|
||||
instances.setSettingsManager(settings)
|
||||
self.instancesManager = instances
|
||||
@@ -253,6 +265,24 @@ final class AppEnvironment {
|
||||
probeService?.probeSuspectedRejection(for: account)
|
||||
}
|
||||
|
||||
// Server-side playlists, addressed by account. The same credential
|
||||
// lookup the probe service uses — one account, one secret, whichever
|
||||
// backend it belongs to — so neither can read a login the other cannot.
|
||||
let accountSvc = AccountService(
|
||||
accountsManager: accounts,
|
||||
credentialReader: { [weak accounts, weak invidiousCreds, weak pipedCreds] account in
|
||||
guard let type = accounts?.instance(for: account)?.type else { return nil }
|
||||
switch type {
|
||||
case .invidious: return invidiousCreds?.credential(for: account)
|
||||
case .piped: return pipedCreds?.credential(for: account)
|
||||
default: return nil
|
||||
}
|
||||
},
|
||||
transport: contentSvc
|
||||
)
|
||||
self.accountService = accountSvc
|
||||
self.remotePlaylistsCache = RemotePlaylistsCache(accountService: accountSvc)
|
||||
|
||||
// Initialize SubscriptionService with all required dependencies
|
||||
self.subscriptionService = SubscriptionService(
|
||||
dataManager: dm,
|
||||
@@ -487,8 +517,9 @@ final class AppEnvironment {
|
||||
// needs the instance, and its ownership check is bound to one.
|
||||
// The provider holds this account's subscriptions in memory and may
|
||||
// have a fetch in flight; without dropping it, the deleted
|
||||
// account's channels can still arrive and be shown.
|
||||
self.subscriptionService.discardProvider(for: accountID)
|
||||
// account's channels can still arrive and be shown. Its playlists
|
||||
// are held the same way, and go the same way.
|
||||
self.discardAccountData(for: accountID)
|
||||
|
||||
guard let account, let credentials = self.accountCredentialsManager(for: account) else {
|
||||
self.invidiousCredentialsManager.deleteCredential(forAccountID: accountID)
|
||||
@@ -647,6 +678,9 @@ final class AppEnvironment {
|
||||
func updateUserAgent() {
|
||||
let userAgent = settingsManager.customUserAgent
|
||||
let randomizePerRequest = settingsManager.randomizeUserAgentPerRequest
|
||||
// Clients built from here on; the ones already handed out are updated
|
||||
// through their own setters, and only the shared one is long-lived.
|
||||
UserAgentConfiguration.shared.update(userAgent: userAgent, randomizePerRequest: randomizePerRequest)
|
||||
Task {
|
||||
await httpClient.setUserAgent(userAgent)
|
||||
await httpClient.setRandomizeUserAgentPerRequest(randomizePerRequest)
|
||||
@@ -763,7 +797,7 @@ final class AppEnvironment {
|
||||
|
||||
/// Drops everything cached under an account's previous credential.
|
||||
private func invalidateCaches(for account: Account) {
|
||||
subscriptionService.discardProvider(for: account.id)
|
||||
discardAccountData(for: account.id)
|
||||
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
|
||||
|
||||
// Reached by both ways a credential is replaced — the user signing in
|
||||
@@ -784,6 +818,17 @@ final class AppEnvironment {
|
||||
NotificationCenter.default.post(name: .accountCredentialDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Drops the in-memory data an account left behind, and disowns whatever is
|
||||
/// still being fetched for it.
|
||||
///
|
||||
/// One call for every way an account stops being usable — signed out,
|
||||
/// deleted here, deleted on another device, or its instance removed — so a
|
||||
/// new place that has to be cleared is added once rather than four times.
|
||||
func discardAccountData(for accountID: UUID) {
|
||||
subscriptionService.discardProvider(for: accountID)
|
||||
remotePlaylistsCache.clear(accountID: accountID)
|
||||
}
|
||||
|
||||
/// Drops everything cached for whichever account was being shown.
|
||||
///
|
||||
/// Neither cache is keyed by account: the subscriptions feed is a single
|
||||
@@ -792,6 +837,10 @@ final class AppEnvironment {
|
||||
private func clearAccountScopedCaches() {
|
||||
SubscriptionFeedCache.shared.handleAccountChange()
|
||||
HomeInstanceCache.shared.clearAllFeeds()
|
||||
// Keyed by account, so nothing here is shared between two logins — but
|
||||
// an Apple account switch replaces the whole account list, and results
|
||||
// still in flight for the previous one have to be disowned.
|
||||
remotePlaylistsCache.clearAll()
|
||||
}
|
||||
|
||||
private static let cacheOwnerKey = "accountScopedCacheOwner"
|
||||
@@ -865,7 +914,7 @@ final class AppEnvironment {
|
||||
let credentials = accountCredentialsManager(for: account)
|
||||
credentials?.deleteCredential(for: account)
|
||||
credentials?.deleteLegacyCredentialIfOwned(for: account)
|
||||
subscriptionService.discardProvider(for: account.id)
|
||||
discardAccountData(for: account.id)
|
||||
accountsManager.removeAccount(account.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts.playlists.error.entryMismatch" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "This playlist changed on the server. Refresh and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts.playlists.error.instanceNotConfigured" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "This account's instance is unavailable."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts.playlists.error.notAuthenticated" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sign in to this account to manage its playlists."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts.playlists.error.notSupported" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "This instance does not support playlists."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts.signIn.identityChanged" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@@ -73,3 +73,14 @@ struct RemotePlaylistSnapshot: Hashable, Sendable {
|
||||
self.entries = entries
|
||||
}
|
||||
}
|
||||
|
||||
/// Who may see a server-side playlist.
|
||||
///
|
||||
/// The raw values are the words Invidious accepts. Piped has no visibility
|
||||
/// concept at all — its capability row says so, and the write layer drops the
|
||||
/// value rather than inventing one.
|
||||
enum PlaylistPrivacy: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case `public`
|
||||
case unlisted
|
||||
case `private`
|
||||
}
|
||||
|
||||
@@ -243,6 +243,232 @@ actor ContentService: ContentServiceProtocol {
|
||||
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
|
||||
}
|
||||
|
||||
// MARK: - Authenticated Playlists
|
||||
|
||||
/// Fetches the playlists the credential's owner has on the instance.
|
||||
func userPlaylists(instance: Instance, credential: String) async throws -> [Playlist] {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).userPlaylists(instance: instance, sid: credential)
|
||||
case .piped:
|
||||
return try await pipedAPI(for: instance).userPlaylists(instance: instance, authToken: credential)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one of the credential owner's playlists along with the per-entry
|
||||
/// identities its rows can be removed by.
|
||||
func userPlaylistSnapshot(
|
||||
id: String,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws -> RemotePlaylistSnapshot {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).userPlaylistSnapshot(
|
||||
id: id,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
case .piped:
|
||||
return try await pipedAPI(for: instance).userPlaylistSnapshot(
|
||||
id: id,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a playlist for the credential's owner.
|
||||
///
|
||||
/// The returned playlist is the little the server says about what it just
|
||||
/// made — an id and a title. Nothing else is invented here: callers refetch
|
||||
/// the list, which is the only description that is actually the server's.
|
||||
/// - Parameters:
|
||||
/// - privacy: Ignored by backends without a visibility concept (Piped),
|
||||
/// and defaulted to private on those that have one when not given.
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws -> Playlist {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let api = await invidiousAPI(for: instance)
|
||||
let playlist = try await api.createPlaylist(
|
||||
title: title,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
// Creation takes no description, so a wanted one is a second call.
|
||||
// Its failure is the caller's to see: a playlist that exists with
|
||||
// the wrong description is not a silent success.
|
||||
if let description, !description.isEmpty {
|
||||
try await api.updatePlaylist(
|
||||
id: playlist.id.playlistID,
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
}
|
||||
return playlist
|
||||
|
||||
case .piped:
|
||||
let api = await pipedAPI(for: instance)
|
||||
let playlistID = try await api.createPlaylist(
|
||||
name: title,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
if let description, !description.isEmpty {
|
||||
try await api.updatePlaylistDescription(
|
||||
id: playlistID,
|
||||
description: description,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
}
|
||||
return Playlist(id: .global(playlistID), title: title, description: description)
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits one of the credential owner's playlists.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - description: `nil` leaves whatever the playlist has.
|
||||
/// - privacy: `nil` keeps the private default on backends that have
|
||||
/// visibility; ignored entirely on those that do not.
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).updatePlaylist(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy ?? .private,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
|
||||
case .piped:
|
||||
// Two calls, no transaction: Piped edits the name and the
|
||||
// description through separate endpoints. The rename goes first so
|
||||
// a failure of the second leaves the visible half applied, and the
|
||||
// caller's refetch shows exactly what landed.
|
||||
let api = await pipedAPI(for: instance)
|
||||
try await api.renamePlaylist(
|
||||
id: id,
|
||||
newName: title,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
if let description {
|
||||
try await api.updatePlaylistDescription(
|
||||
id: id,
|
||||
description: description,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one of the credential owner's playlists.
|
||||
func deletePlaylist(id: String, instance: Instance, credential: String) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).deletePlaylist(id: id, instance: instance, sid: credential)
|
||||
case .piped:
|
||||
try await pipedAPI(for: instance).deletePlaylist(id: id, instance: instance, authToken: credential)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a video to one of the credential owner's playlists.
|
||||
func addVideoToPlaylist(
|
||||
playlistID: String,
|
||||
videoID: String,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
try await invidiousAPI(for: instance).addVideoToPlaylist(
|
||||
playlistID: playlistID,
|
||||
videoID: videoID,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
case .piped:
|
||||
try await pipedAPI(for: instance).addToPlaylist(
|
||||
id: playlistID,
|
||||
videoID: videoID,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes one row from one of the credential owner's playlists.
|
||||
///
|
||||
/// The entry id says which backend minted it, and it has to be the one this
|
||||
/// instance runs: an Invidious `indexId` means nothing to Piped, and a
|
||||
/// Piped position sent to Invidious would be a *different row* rather than
|
||||
/// an error, so a mismatch is refused rather than translated.
|
||||
func removeEntry(
|
||||
_ entryID: RemotePlaylistEntryID,
|
||||
instance: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
switch (entryID, instance.type) {
|
||||
case (.invidious(let playlistID, let indexID), .invidious):
|
||||
try await invidiousAPI(for: instance).removeVideoFromPlaylist(
|
||||
playlistID: playlistID,
|
||||
indexID: indexID,
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
|
||||
case (.piped(let playlistID, let position), .piped):
|
||||
try await pipedAPI(for: instance).removeFromPlaylist(
|
||||
id: playlistID,
|
||||
index: position,
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
|
||||
case (_, .invidious), (_, .piped):
|
||||
throw APIError.invalidRequest
|
||||
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
|
||||
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
|
||||
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
@@ -362,6 +588,11 @@ actor ContentService: ContentServiceProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// The real transport behind ``AccountService``. Declared here rather than on
|
||||
/// the protocol's own file so the conformance is checked next to the methods
|
||||
/// that satisfy it; the protocol exists so tests can script a server.
|
||||
extension ContentService: RemotePlaylistTransport {}
|
||||
|
||||
// MARK: - Search Result
|
||||
|
||||
enum OrderedSearchItem: Sendable {
|
||||
|
||||
@@ -648,6 +648,168 @@ actor InvidiousAPI: InstanceAPI {
|
||||
customHeaders: ["Cookie": "SID=\(sid)"]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playlist Management
|
||||
|
||||
/// Creates a playlist owned by the logged-in user.
|
||||
///
|
||||
/// Invidious answers 201 with `{"title", "playlistId"}` — not the whole
|
||||
/// playlist — so the returned value carries the new id and the title that
|
||||
/// was asked for, and nothing else. Callers that need the rest refetch.
|
||||
/// - Parameters:
|
||||
/// - title: The playlist title
|
||||
/// - privacy: Who may see it. Required by the server, which rejects the
|
||||
/// request outright when it is missing.
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
/// - Returns: The created playlist, with no videos
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
privacy: PlaylistPrivacy,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws -> Playlist {
|
||||
struct CreateRequest: Encodable, Sendable {
|
||||
let title: String
|
||||
let privacy: String
|
||||
}
|
||||
|
||||
struct CreateResponse: Decodable, Sendable {
|
||||
let playlistId: String
|
||||
let title: String?
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(CreateRequest(title: title, privacy: privacy.rawValue))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists",
|
||||
method: .post,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
let response: CreateResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return Playlist(id: .global(response.playlistId), title: response.title ?? title)
|
||||
}
|
||||
|
||||
/// Edits a playlist's title, description or privacy.
|
||||
///
|
||||
/// Title and privacy are sent even when unchanged: Invidious reads a missing
|
||||
/// field as "leave it alone" for the description but validates the other two
|
||||
/// on every request, so a partial body is rejected rather than merged.
|
||||
/// Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - title: The title to store
|
||||
/// - description: The description to store, or `nil` to leave it alone
|
||||
/// - privacy: The visibility to store
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
struct UpdateRequest: Encodable, Sendable {
|
||||
let title: String
|
||||
let privacy: String
|
||||
/// Omitted from the JSON when nil, which is how the server is told
|
||||
/// to keep the description it already has.
|
||||
let description: String?
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(
|
||||
UpdateRequest(title: title, privacy: privacy.rawValue, description: description)
|
||||
)
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(id)",
|
||||
method: .patch,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Deletes one of the user's playlists. Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func deletePlaylist(id: String, instance: Instance, sid: String) async throws {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(id)",
|
||||
method: .delete,
|
||||
headers: authHeaders(sid: sid)
|
||||
)
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Appends a video to one of the user's playlists.
|
||||
///
|
||||
/// The 201 response carries the row the server minted, including the
|
||||
/// `indexId` it can later be removed by. It is deliberately not decoded:
|
||||
/// the whole playlist is refetched after every mutation, and a row parsed
|
||||
/// from a response is one more thing that can disagree with the server.
|
||||
/// - Parameters:
|
||||
/// - playlistID: The playlist to add to
|
||||
/// - videoID: The video to add
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func addVideoToPlaylist(
|
||||
playlistID: String,
|
||||
videoID: String,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
struct AddRequest: Encodable, Sendable {
|
||||
let videoId: String
|
||||
}
|
||||
|
||||
let body = try JSONEncoder().encode(AddRequest(videoId: videoID))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(playlistID)/videos",
|
||||
method: .post,
|
||||
headers: authHeaders(sid: sid),
|
||||
body: body
|
||||
)
|
||||
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Removes one row from one of the user's playlists.
|
||||
///
|
||||
/// Addressed by `indexId`, the per-entry token the fetch handed out — never
|
||||
/// by position. The server names the same row by it across refetches, so a
|
||||
/// removal cannot land on whatever moved into that position meanwhile.
|
||||
/// Answers 204 with no body.
|
||||
/// - Parameters:
|
||||
/// - playlistID: The playlist to remove from
|
||||
/// - indexID: The entry's `indexId` from ``userPlaylistSnapshot(id:instance:sid:)``
|
||||
/// - instance: The Invidious instance
|
||||
/// - sid: The session ID from login
|
||||
func removeVideoFromPlaylist(
|
||||
playlistID: String,
|
||||
indexID: String,
|
||||
instance: Instance,
|
||||
sid: String
|
||||
) async throws {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/api/v1/auth/playlists/\(playlistID)/videos/\(indexID)",
|
||||
method: .delete,
|
||||
headers: authHeaders(sid: sid)
|
||||
)
|
||||
try await httpClient.sendRequest(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// The headers every authenticated write carries. `Content-Type` is set for
|
||||
/// all of them, including the bodiless ones, so a proxy in front of the
|
||||
/// instance cannot guess something else.
|
||||
private nonisolated func authHeaders(sid: String) -> [String: String] {
|
||||
["Cookie": "SID=\(sid)", "Content-Type": "application/json"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Proxy
|
||||
|
||||
@@ -487,6 +487,177 @@ actor PipedAPI: InstanceAPI {
|
||||
instanceURL: instanceURL
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Playlist Management
|
||||
|
||||
/// Creates a playlist owned by the logged-in user.
|
||||
///
|
||||
/// Piped answers with `{"playlistId"}` alone — there is no privacy setting
|
||||
/// to pass and no playlist body to read back.
|
||||
/// - Parameters:
|
||||
/// - name: The playlist name
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: The new playlist's ID
|
||||
func createPlaylist(name: String, instance: Instance, authToken: String) async throws -> String {
|
||||
struct CreateRequest: Encodable, Sendable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct CreateResponse: Decodable, Sendable {
|
||||
let playlistId: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/create",
|
||||
authToken: authToken,
|
||||
body: CreateRequest(name: name)
|
||||
)
|
||||
|
||||
let response: CreateResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.playlistId
|
||||
}
|
||||
|
||||
/// Renames one of the user's playlists.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - newName: The name to store
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func renamePlaylist(id: String, newName: String, instance: Instance, authToken: String) async throws {
|
||||
struct RenameRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let newName: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/rename",
|
||||
authToken: authToken,
|
||||
body: RenameRequest(playlistId: id, newName: newName)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Replaces the description of one of the user's playlists.
|
||||
///
|
||||
/// PATCH, unlike every other playlist write here. Upstream shipped this as
|
||||
/// `POST /user/playlists/description/change` and replaced it with
|
||||
/// `PATCH /user/playlists/description` the same afternoon, so no instance
|
||||
/// worth supporting is still on the POST spelling.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - description: The description to store
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func updatePlaylistDescription(
|
||||
id: String,
|
||||
description: String,
|
||||
instance: Instance,
|
||||
authToken: String
|
||||
) async throws {
|
||||
struct DescriptionRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/description",
|
||||
method: .patch,
|
||||
authToken: authToken,
|
||||
body: DescriptionRequest(playlistId: id, description: description)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Deletes one of the user's playlists.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func deletePlaylist(id: String, instance: Instance, authToken: String) async throws {
|
||||
struct DeleteRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/delete",
|
||||
authToken: authToken,
|
||||
body: DeleteRequest(playlistId: id)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Appends a video to one of the user's playlists.
|
||||
///
|
||||
/// Sent as `videoIds`, the plural form. Upstream reads both spellings — the
|
||||
/// singular `videoId` is kept only for backwards compatibility, and the
|
||||
/// plural has been accepted since the batching change — so the plural is
|
||||
/// the one that will still be there.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist to add to
|
||||
/// - videoID: The video to add
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func addToPlaylist(id: String, videoID: String, instance: Instance, authToken: String) async throws {
|
||||
struct AddRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let videoIds: [String]
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/add",
|
||||
authToken: authToken,
|
||||
body: AddRequest(playlistId: id, videoIds: [videoID])
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Removes the row at a position from one of the user's playlists.
|
||||
///
|
||||
/// Positional, because Piped offers nothing else: the index is only
|
||||
/// meaningful against the fetch it came from, and whoever calls this owes
|
||||
/// the check that the position still holds what it did. That check is
|
||||
/// deliberately not here — this layer is stateless and has no snapshot to
|
||||
/// compare against.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist to remove from
|
||||
/// - index: Zero-based position of the row to remove
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func removeFromPlaylist(id: String, index: Int, instance: Instance, authToken: String) async throws {
|
||||
struct RemoveRequest: Encodable, Sendable {
|
||||
let playlistId: String
|
||||
let index: Int
|
||||
}
|
||||
|
||||
let endpoint = try writeEndpoint(
|
||||
path: "/user/playlists/remove",
|
||||
authToken: authToken,
|
||||
body: RemoveRequest(playlistId: id, index: index)
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Builds an authenticated JSON write request. Piped wants the token bare in
|
||||
/// `Authorization` — no `Bearer` — on every one of these.
|
||||
private nonisolated func writeEndpoint(
|
||||
path: String,
|
||||
method: HTTPMethod = .post,
|
||||
authToken: String,
|
||||
body: some Encodable
|
||||
) throws -> GenericEndpoint {
|
||||
GenericEndpoint(
|
||||
path: path,
|
||||
method: method,
|
||||
headers: ["Authorization": authToken, "Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(body)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Piped Authentication Response Models
|
||||
|
||||
315
Yattee/Services/AccountService.swift
Normal file
315
Yattee/Services/AccountService.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,23 @@ actor HTTPClient {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
|
||||
/// - Parameters:
|
||||
/// - userAgent: the User-Agent to send from the very first request. The
|
||||
/// setter below cannot serve that purpose for a client built inside a
|
||||
/// synchronous factory: awaiting it there is impossible, and scheduling
|
||||
/// it races the first request out of the door.
|
||||
/// - randomizeUserAgentPerRequest: whether to mint a fresh random
|
||||
/// User-Agent per request instead, ignoring `userAgent`.
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
decoder: JSONDecoder = .init(),
|
||||
userAgent: String? = nil,
|
||||
randomizeUserAgentPerRequest: Bool = false
|
||||
) {
|
||||
self.session = session
|
||||
self.decoder = decoder
|
||||
self.userAgent = userAgent
|
||||
self.randomizeUserAgentPerRequest = randomizeUserAgentPerRequest
|
||||
|
||||
Self.configureForAPIs(self.decoder)
|
||||
}
|
||||
|
||||
@@ -7,34 +7,82 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The User-Agent every client a factory mints should identify itself with.
|
||||
///
|
||||
/// Held apart from the factories so there is one answer for the whole process.
|
||||
/// A per-instance client is built deep inside `ContentService`, from a factory
|
||||
/// nobody outside it can reach, and the app's User-Agent has to reach it too:
|
||||
/// some instances only answer requests whose User-Agent starts with
|
||||
/// `Yattee/<version>`, and a client that sends none gets a 401 the shared
|
||||
/// client never sees.
|
||||
final class UserAgentConfiguration: @unchecked Sendable {
|
||||
static let shared = UserAgentConfiguration()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var userAgent: String?
|
||||
private var randomizePerRequest = false
|
||||
|
||||
/// Records the User-Agent settings to apply from now on. Clients already
|
||||
/// built keep what they were given — they have their own setters.
|
||||
func update(userAgent: String?, randomizePerRequest: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.userAgent = userAgent
|
||||
self.randomizePerRequest = randomizePerRequest
|
||||
}
|
||||
|
||||
/// The settings as they stand.
|
||||
func current() -> (userAgent: String?, randomizePerRequest: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (userAgent, randomizePerRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating HTTPClient instances based on instance SSL settings.
|
||||
final class HTTPClientFactory: Sendable {
|
||||
private let sessionFactory: URLSessionFactory
|
||||
private let userAgentConfiguration: UserAgentConfiguration
|
||||
|
||||
init(sessionFactory: URLSessionFactory = .shared) {
|
||||
init(
|
||||
sessionFactory: URLSessionFactory = .shared,
|
||||
userAgentConfiguration: UserAgentConfiguration = .shared
|
||||
) {
|
||||
self.sessionFactory = sessionFactory
|
||||
self.userAgentConfiguration = userAgentConfiguration
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient configured for the given instance's SSL requirements.
|
||||
/// - Parameter instance: The instance to create a client for.
|
||||
/// - Returns: An HTTPClient with appropriate SSL settings.
|
||||
func createClient(for instance: Instance) -> HTTPClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: instance.allowInvalidCertificates)
|
||||
return HTTPClient(session: session)
|
||||
makeClient(session: sessionFactory.session(allowInvalidCertificates: instance.allowInvalidCertificates))
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient with explicit SSL settings.
|
||||
/// - Parameter allowInvalidCertificates: Whether to bypass SSL certificate validation.
|
||||
/// - Returns: An HTTPClient with the specified SSL settings.
|
||||
func createClient(allowInvalidCertificates: Bool) -> HTTPClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates)
|
||||
return HTTPClient(session: session)
|
||||
makeClient(session: sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates))
|
||||
}
|
||||
|
||||
/// Creates an HTTPClient with low network priority for background/prefetch work.
|
||||
/// - Returns: An HTTPClient configured with `.background` network service type.
|
||||
func createLowPriorityClient() -> HTTPClient {
|
||||
let session = sessionFactory.lowPrioritySession()
|
||||
return HTTPClient(session: session)
|
||||
makeClient(session: sessionFactory.lowPrioritySession())
|
||||
}
|
||||
|
||||
/// Builds the client with the current User-Agent baked in.
|
||||
///
|
||||
/// Passed through the initializer rather than set afterwards: these clients
|
||||
/// are handed straight to an API that sends its first request immediately,
|
||||
/// and a `Task` to configure them would sometimes lose that race.
|
||||
private func makeClient(session: URLSession) -> HTTPClient {
|
||||
let configuration = userAgentConfiguration.current()
|
||||
return HTTPClient(
|
||||
session: session,
|
||||
userAgent: configuration.userAgent,
|
||||
randomizeUserAgentPerRequest: configuration.randomizePerRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
352
Yattee/Services/RemotePlaylistsCache.swift
Normal file
352
Yattee/Services/RemotePlaylistsCache.swift
Normal file
@@ -0,0 +1,352 @@
|
||||
//
|
||||
// RemotePlaylistsCache.swift
|
||||
// Yattee
|
||||
//
|
||||
// What the server said about an account's playlists, the last time it was
|
||||
// asked. In memory, for this run of the app, and nowhere else.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Holds server-side playlists for display, and routes every change to them.
|
||||
///
|
||||
/// **Never persisted, and never synced.** Two reasons, both fatal to the idea
|
||||
/// of writing this to disk or into CloudKit: the data belongs to a login rather
|
||||
/// than to this device, and Piped's rows are addressed by *position*, so a
|
||||
/// stored entry id would be aimed at whatever occupies that slot the next time
|
||||
/// the app opens. Offline the last fetch is shown and writes fail fast.
|
||||
///
|
||||
/// Mutations go through here rather than around it, so the refetch that
|
||||
/// reconciles them cannot be forgotten: every change refetches what it touched
|
||||
/// — on failure as much as on success, because a failed write may still have
|
||||
/// landed — and the UI only ever shows what came back from the server. Nothing
|
||||
/// is edited optimistically.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RemotePlaylistsCache {
|
||||
private let accountService: AccountService
|
||||
|
||||
init(accountService: AccountService) {
|
||||
self.accountService = accountService
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
/// Last-fetched playlist lists, by account.
|
||||
private var lists: [UUID: [Playlist]] = [:]
|
||||
|
||||
/// When each account's list was last fetched.
|
||||
private var listFetchDates: [UUID: Date] = [:]
|
||||
|
||||
/// Last-fetched playlist snapshots, by account and playlist id.
|
||||
private var snapshots: [UUID: [String: RemotePlaylistSnapshot]] = [:]
|
||||
|
||||
/// When each snapshot was last fetched.
|
||||
private var snapshotFetchDates: [UUID: [String: Date]] = [:]
|
||||
|
||||
/// Which generation of an account's data the state belongs to.
|
||||
///
|
||||
/// Clearing a cache never cancels the request that was filling it: a fetch
|
||||
/// started before a sign-out returns afterwards, and would repopulate the
|
||||
/// account it was cleared for. Every fetch captures this at the start and
|
||||
/// drops its result if it has moved.
|
||||
private var generations: [UUID: Int] = [:]
|
||||
|
||||
/// In-flight list fetches, so concurrent refreshes of the same account cost
|
||||
/// one round trip and all see the same answer.
|
||||
private var listFetches: [UUID: Task<Void, Error>] = [:]
|
||||
|
||||
/// In-flight snapshot fetches, keyed by account and playlist.
|
||||
private var snapshotFetches: [SnapshotKey: Task<Void, Error>] = [:]
|
||||
|
||||
/// Ordering for results, so an older fetch can never overwrite a newer one.
|
||||
///
|
||||
/// A refresh already in flight when a mutation happens describes the
|
||||
/// playlist *before* the change; the reconciling refetch starts later and
|
||||
/// must win however the two happen to return.
|
||||
private var sequence = 0
|
||||
private var appliedListSequence: [UUID: Int] = [:]
|
||||
private var appliedSnapshotSequence: [SnapshotKey: Int] = [:]
|
||||
|
||||
private struct SnapshotKey: Hashable {
|
||||
let accountID: UUID
|
||||
let playlistID: String
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// The account's playlists as last fetched, or `nil` if they never were.
|
||||
func playlists(for accountID: UUID) -> [Playlist]? {
|
||||
lists[accountID]
|
||||
}
|
||||
|
||||
/// When the account's list was last fetched.
|
||||
func listFetchDate(for accountID: UUID) -> Date? {
|
||||
listFetchDates[accountID]
|
||||
}
|
||||
|
||||
/// One playlist as last fetched, or `nil` if it never was.
|
||||
func snapshot(id: String, for accountID: UUID) -> RemotePlaylistSnapshot? {
|
||||
snapshots[accountID]?[id]
|
||||
}
|
||||
|
||||
/// When that snapshot was last fetched.
|
||||
func snapshotFetchDate(id: String, for accountID: UUID) -> Date? {
|
||||
snapshotFetchDates[accountID]?[id]
|
||||
}
|
||||
|
||||
// MARK: - Refreshing
|
||||
|
||||
/// Refetches the account's playlist list, joining a refresh already running.
|
||||
func refreshList(for account: Account) async throws {
|
||||
if let inFlight = listFetches[account.id] {
|
||||
return try await inFlight.value
|
||||
}
|
||||
try await fetchList(for: account)
|
||||
}
|
||||
|
||||
/// Refetches one playlist, joining a refresh already running.
|
||||
func refreshSnapshot(id: String, for account: Account) async throws {
|
||||
let key = SnapshotKey(accountID: account.id, playlistID: id)
|
||||
if let inFlight = snapshotFetches[key] {
|
||||
return try await inFlight.value
|
||||
}
|
||||
try await fetchSnapshot(id: id, for: account)
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Creates a playlist and republishes the account's list from the server.
|
||||
@discardableResult
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
for account: Account
|
||||
) async throws -> Playlist {
|
||||
try await reconcilingList(for: account) {
|
||||
try await accountService.createPlaylist(
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy,
|
||||
for: account
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits a playlist and republishes both the list and the playlist itself.
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
for account: Account
|
||||
) async throws {
|
||||
try await reconciling(playlist: id, andList: true, for: account) {
|
||||
try await accountService.updatePlaylist(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
privacy: privacy,
|
||||
for: account
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a playlist and republishes the account's list.
|
||||
func deletePlaylist(id: String, for account: Account) async throws {
|
||||
do {
|
||||
try await accountService.deletePlaylist(id: id, for: account)
|
||||
forgetSnapshot(id: id, for: account.id)
|
||||
await reconcileList(for: account)
|
||||
} catch {
|
||||
// The delete may have landed even though the answer did not, so the
|
||||
// snapshot is refetched rather than assumed: whichever way it went,
|
||||
// the server's answer replaces it.
|
||||
await reconcileSnapshot(id: id, for: account)
|
||||
await reconcileList(for: account)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a video and republishes the playlist it was added to.
|
||||
func addVideo(videoID: String, toPlaylist id: String, for account: Account) async throws {
|
||||
try await reconciling(playlist: id, andList: false, for: account) {
|
||||
try await accountService.addVideo(videoID: videoID, toPlaylist: id, for: account)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a row and republishes the playlist it was removed from.
|
||||
func removeEntry(
|
||||
_ entry: RemotePlaylistEntry,
|
||||
fromPlaylist id: String,
|
||||
for account: Account
|
||||
) async throws {
|
||||
try await reconciling(playlist: id, andList: false, for: account) {
|
||||
try await accountService.removeEntry(entry, fromPlaylist: id, for: account)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clearing
|
||||
|
||||
/// Drops everything known about one account, and disowns its in-flight
|
||||
/// fetches so none of them can put it back.
|
||||
func clear(accountID: UUID) {
|
||||
generations[accountID, default: 0] += 1
|
||||
|
||||
lists[accountID] = nil
|
||||
listFetchDates[accountID] = nil
|
||||
snapshots[accountID] = nil
|
||||
snapshotFetchDates[accountID] = nil
|
||||
|
||||
listFetches.removeValue(forKey: accountID)?.cancel()
|
||||
for key in snapshotFetches.keys where key.accountID == accountID {
|
||||
snapshotFetches.removeValue(forKey: key)?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops everything, for a change of Apple account or of identity scope.
|
||||
func clearAll() {
|
||||
for accountID in Set(lists.keys)
|
||||
.union(snapshots.keys)
|
||||
.union(listFetches.keys)
|
||||
.union(snapshotFetches.keys.map(\.accountID))
|
||||
.union(generations.keys) {
|
||||
clear(accountID: accountID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetching
|
||||
|
||||
private func fetchList(for account: Account) async throws {
|
||||
let accountID = account.id
|
||||
let generation = generations[accountID, default: 0]
|
||||
let sequence = nextSequence()
|
||||
|
||||
let task = Task<Void, Error> { [weak self] in
|
||||
guard let self else { return }
|
||||
let playlists = try await self.accountService.userPlaylists(for: account)
|
||||
|
||||
guard self.isCurrent(generation, for: accountID),
|
||||
sequence > self.appliedListSequence[accountID, default: 0]
|
||||
else { return }
|
||||
|
||||
self.appliedListSequence[accountID] = sequence
|
||||
self.lists[accountID] = playlists
|
||||
self.listFetchDates[accountID] = Date()
|
||||
}
|
||||
|
||||
listFetches[accountID] = task
|
||||
defer {
|
||||
if listFetches[accountID] == task {
|
||||
listFetches[accountID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
try await task.value
|
||||
}
|
||||
|
||||
private func fetchSnapshot(id: String, for account: Account) async throws {
|
||||
let accountID = account.id
|
||||
let key = SnapshotKey(accountID: accountID, playlistID: id)
|
||||
let generation = generations[accountID, default: 0]
|
||||
let sequence = nextSequence()
|
||||
|
||||
let task = Task<Void, Error> { [weak self] in
|
||||
guard let self else { return }
|
||||
let snapshot = try await self.accountService.playlistSnapshot(id: id, for: account)
|
||||
|
||||
guard self.isCurrent(generation, for: accountID),
|
||||
sequence > self.appliedSnapshotSequence[key, default: 0]
|
||||
else { return }
|
||||
|
||||
self.appliedSnapshotSequence[key] = sequence
|
||||
self.snapshots[accountID, default: [:]][id] = snapshot
|
||||
self.snapshotFetchDates[accountID, default: [:]][id] = Date()
|
||||
}
|
||||
|
||||
snapshotFetches[key] = task
|
||||
defer {
|
||||
if snapshotFetches[key] == task {
|
||||
snapshotFetches[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
try await task.value
|
||||
}
|
||||
|
||||
// MARK: - Reconciliation
|
||||
|
||||
/// Runs a change and refetches the playlist it touched — and the list too,
|
||||
/// when the change is one the list shows — however the change turned out.
|
||||
private func reconciling<T>(
|
||||
playlist id: String,
|
||||
andList refreshesList: Bool,
|
||||
for account: Account,
|
||||
_ mutation: () async throws -> T
|
||||
) async throws -> T {
|
||||
do {
|
||||
let value = try await mutation()
|
||||
await reconcileSnapshot(id: id, for: account)
|
||||
if refreshesList {
|
||||
await reconcileList(for: account)
|
||||
}
|
||||
return value
|
||||
} catch {
|
||||
// A write that reported failure may still have been applied — a
|
||||
// connection dropped after the server acted looks exactly like one
|
||||
// dropped before. The refetch is what settles it.
|
||||
await reconcileSnapshot(id: id, for: account)
|
||||
if refreshesList {
|
||||
await reconcileList(for: account)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcilingList<T>(
|
||||
for account: Account,
|
||||
_ mutation: () async throws -> T
|
||||
) async throws -> T {
|
||||
do {
|
||||
let value = try await mutation()
|
||||
await reconcileList(for: account)
|
||||
return value
|
||||
} catch {
|
||||
await reconcileList(for: account)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Refetches after a change, always as a new request: joining one that
|
||||
/// started earlier would republish the playlist as it was *before*.
|
||||
///
|
||||
/// Its own failure is swallowed. The mutation's outcome is what the caller
|
||||
/// is told about; a reconciling refetch that could not reach the server
|
||||
/// leaves the previous data on screen, which is what every other read does
|
||||
/// offline.
|
||||
private func reconcileList(for account: Account) async {
|
||||
try? await fetchList(for: account)
|
||||
}
|
||||
|
||||
private func reconcileSnapshot(id: String, for account: Account) async {
|
||||
try? await fetchSnapshot(id: id, for: account)
|
||||
}
|
||||
|
||||
private func forgetSnapshot(id: String, for accountID: UUID) {
|
||||
snapshots[accountID]?[id] = nil
|
||||
snapshotFetchDates[accountID]?[id] = nil
|
||||
}
|
||||
|
||||
// MARK: - Fences
|
||||
|
||||
private func isCurrent(_ generation: Int, for accountID: UUID) -> Bool {
|
||||
generations[accountID, default: 0] == generation
|
||||
}
|
||||
|
||||
private func nextSequence() -> Int {
|
||||
sequence += 1
|
||||
return sequence
|
||||
}
|
||||
}
|
||||
@@ -175,9 +175,9 @@ struct InstanceAccountsSection: View {
|
||||
appEnvironment.accountCredentialsManager(for: account)?.deleteCredential(for: account)
|
||||
appEnvironment.accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account)
|
||||
appEnvironment.accountsManager.removeAccount(account.id)
|
||||
// Drop the provider too, or its in-memory subscription cache would keep
|
||||
// answering for an account that no longer exists.
|
||||
appEnvironment.subscriptionService.discardProvider(for: account.id)
|
||||
// Drop what the account had in memory too, or its subscriptions and
|
||||
// playlists would keep answering for an account that no longer exists.
|
||||
appEnvironment.discardAccountData(for: account.id)
|
||||
|
||||
if appEnvironment.accountsManager.accounts(forInstance: instance.id).isEmpty {
|
||||
appEnvironment.instancesManager.setUsesAccountLogin(false, for: instance)
|
||||
|
||||
518
YatteeTests/AccountServiceTests.swift
Normal file
518
YatteeTests/AccountServiceTests.swift
Normal file
@@ -0,0 +1,518 @@
|
||||
//
|
||||
// AccountServiceTests.swift
|
||||
// YatteeTests
|
||||
//
|
||||
// What an account is allowed to do, whose credential it did it with, and what
|
||||
// the cache does with an answer that arrived too late to matter.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Yattee
|
||||
|
||||
// MARK: - Scripted transport
|
||||
|
||||
/// A ``RemotePlaylistTransport`` that answers from a script instead of a server.
|
||||
///
|
||||
/// Every call is recorded, so a test can assert what the service decided to
|
||||
/// send — the privacy it dropped, the token it removed by — and any call can be
|
||||
/// parked mid-flight, which is where the credential-change and generation rules
|
||||
/// actually live.
|
||||
private actor ScriptedPlaylistTransport: RemotePlaylistTransport {
|
||||
enum Call: Equatable, Sendable {
|
||||
case userPlaylists(credential: String)
|
||||
case snapshot(id: String, credential: String)
|
||||
case create(title: String, description: String?, privacy: PlaylistPrivacy?, credential: String)
|
||||
case update(id: String, title: String, description: String?, privacy: PlaylistPrivacy?, credential: String)
|
||||
case delete(id: String, credential: String)
|
||||
case addVideo(playlistID: String, videoID: String, credential: String)
|
||||
case remove(entryID: RemotePlaylistEntryID, credential: String)
|
||||
}
|
||||
|
||||
private(set) var calls: [Call] = []
|
||||
|
||||
/// Thrown by the next call of any kind, once set.
|
||||
var nextError: Error?
|
||||
|
||||
/// What ``userPlaylistSnapshot(id:instance:credential:)`` answers.
|
||||
var snapshot = RemotePlaylistSnapshot(playlist: Playlist(id: .global("pl"), title: "Playlist"), entries: [])
|
||||
|
||||
/// What ``userPlaylists(instance:credential:)`` answers.
|
||||
var playlists: [Playlist] = []
|
||||
|
||||
/// While true, every call suspends until `release()`.
|
||||
private var holdsCallsOpen = false
|
||||
private var parked: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func setError(_ error: Error?) { nextError = error }
|
||||
func setSnapshot(_ snapshot: RemotePlaylistSnapshot) { self.snapshot = snapshot }
|
||||
func setPlaylists(_ playlists: [Playlist]) { self.playlists = playlists }
|
||||
func holdCalls() { holdsCallsOpen = true }
|
||||
|
||||
func release() {
|
||||
holdsCallsOpen = false
|
||||
let waiting = parked
|
||||
parked = []
|
||||
waiting.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
/// Waits until at least `count` calls have been entered.
|
||||
func waitForCalls(_ count: Int) async throws {
|
||||
for _ in 0 ..< 400 {
|
||||
if calls.count >= count { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
Issue.record("The transport was never entered \(count) time(s)")
|
||||
}
|
||||
|
||||
var callCount: Int { calls.count }
|
||||
|
||||
private func record(_ call: Call) async throws {
|
||||
calls.append(call)
|
||||
|
||||
if holdsCallsOpen {
|
||||
await withCheckedContinuation { parked.append($0) }
|
||||
}
|
||||
|
||||
if let error = nextError {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func userPlaylists(instance _: Instance, credential: String) async throws -> [Playlist] {
|
||||
try await record(.userPlaylists(credential: credential))
|
||||
return playlists
|
||||
}
|
||||
|
||||
func userPlaylistSnapshot(
|
||||
id: String,
|
||||
instance _: Instance,
|
||||
credential: String
|
||||
) async throws -> RemotePlaylistSnapshot {
|
||||
try await record(.snapshot(id: id, credential: credential))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func createPlaylist(
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance _: Instance,
|
||||
credential: String
|
||||
) async throws -> Playlist {
|
||||
try await record(
|
||||
.create(title: title, description: description, privacy: privacy, credential: credential)
|
||||
)
|
||||
return Playlist(id: .global("new-playlist"), title: title, description: description)
|
||||
}
|
||||
|
||||
func updatePlaylist(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
privacy: PlaylistPrivacy?,
|
||||
instance _: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
try await record(
|
||||
.update(id: id, title: title, description: description, privacy: privacy, credential: credential)
|
||||
)
|
||||
}
|
||||
|
||||
func deletePlaylist(id: String, instance _: Instance, credential: String) async throws {
|
||||
try await record(.delete(id: id, credential: credential))
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
playlistID: String,
|
||||
videoID: String,
|
||||
instance _: Instance,
|
||||
credential: String
|
||||
) async throws {
|
||||
try await record(.addVideo(playlistID: playlistID, videoID: videoID, credential: credential))
|
||||
}
|
||||
|
||||
func removeEntry(_ entryID: RemotePlaylistEntryID, instance _: Instance, credential: String) async throws {
|
||||
try await record(.remove(entryID: entryID, credential: credential))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
/// An account on an instance, a scripted transport, and the service between
|
||||
/// them. The credential lives in a mutable box so a test can replace it while a
|
||||
/// call is still out.
|
||||
@MainActor
|
||||
private final class AccountServiceHarness {
|
||||
let instances: InstancesManager
|
||||
private(set) var instance: Instance
|
||||
let accounts: AccountsManager
|
||||
let account: Account
|
||||
let transport = ScriptedPlaylistTransport()
|
||||
private(set) var service: AccountService!
|
||||
|
||||
/// What the credential reader reports right now.
|
||||
var credential: String? = "secret-original"
|
||||
|
||||
init(type: InstanceType = .invidious, isEnabled: Bool = true) {
|
||||
instances = InstancesManager()
|
||||
instance = Instance(
|
||||
type: type,
|
||||
url: URL(string: "https://accounts-\(UUID().uuidString).example.com")!,
|
||||
isEnabled: isEnabled
|
||||
)
|
||||
instances.add(instance)
|
||||
|
||||
accounts = AccountsManager(
|
||||
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
|
||||
instancesManager: instances,
|
||||
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!,
|
||||
unreachableRetryInterval: 3600
|
||||
)
|
||||
account = accounts.addAccount(instanceID: instance.id)
|
||||
accounts.setStatus(.signedIn, for: account.id)
|
||||
|
||||
service = AccountService(
|
||||
accountsManager: accounts,
|
||||
credentialReader: { [weak self] _ in self?.credential },
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
/// Instances live in the shared defaults, so each harness takes its own
|
||||
/// back out again.
|
||||
func tearDown() {
|
||||
instances.remove(instance)
|
||||
}
|
||||
|
||||
var status: AccountStatus {
|
||||
accounts.status(for: account.id)
|
||||
}
|
||||
|
||||
func makeCache() -> RemotePlaylistsCache {
|
||||
RemotePlaylistsCache(accountService: service)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeVideo(_ id: String) -> Video {
|
||||
Video(
|
||||
id: .global(id),
|
||||
title: "Video \(id)",
|
||||
description: nil,
|
||||
author: Author(id: "UCtest", name: "Test Channel"),
|
||||
duration: 60,
|
||||
publishedAt: nil,
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: [],
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSnapshot(
|
||||
playlistID: String,
|
||||
videoIDs: [String],
|
||||
kind: InstanceType
|
||||
) -> RemotePlaylistSnapshot {
|
||||
let videos = videoIDs.map(makeVideo)
|
||||
let entries = videos.enumerated().map { offset, video in
|
||||
RemotePlaylistEntry(
|
||||
id: kind == .invidious
|
||||
? .invidious(playlistID: playlistID, indexID: "TOKEN-\(video.id.videoID)")
|
||||
: .piped(playlistID: playlistID, position: offset),
|
||||
video: video,
|
||||
position: offset
|
||||
)
|
||||
}
|
||||
|
||||
return RemotePlaylistSnapshot(
|
||||
playlist: Playlist(id: .global(playlistID), title: "Playlist", videos: videos),
|
||||
entries: entries
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Suite("Account service", .serialized)
|
||||
@MainActor
|
||||
struct AccountServiceTests {
|
||||
// MARK: - Capability gating
|
||||
|
||||
@Test("A backend without playlist support is refused before anything is sent")
|
||||
func unsupportedBackendIsRefused() async throws {
|
||||
let harness = AccountServiceHarness(type: .peertube)
|
||||
defer { harness.tearDown() }
|
||||
|
||||
await #expect(throws: AccountServiceError.notSupported) {
|
||||
_ = try await harness.service.userPlaylists(for: harness.account)
|
||||
}
|
||||
|
||||
#expect(await harness.transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("A backend without playlist privacy never receives one")
|
||||
func privacyIsDroppedWhereUnsupported() async throws {
|
||||
let harness = AccountServiceHarness(type: .piped)
|
||||
defer { harness.tearDown() }
|
||||
|
||||
_ = try await harness.service.createPlaylist(
|
||||
title: "Watch later",
|
||||
description: nil,
|
||||
privacy: .private,
|
||||
for: harness.account
|
||||
)
|
||||
|
||||
#expect(await harness.transport.calls == [
|
||||
.create(title: "Watch later", description: nil, privacy: nil, credential: "secret-original")
|
||||
])
|
||||
}
|
||||
|
||||
@Test("A backend with playlist privacy receives the one that was asked for")
|
||||
func privacyIsPassedWhereSupported() async throws {
|
||||
let harness = AccountServiceHarness(type: .invidious)
|
||||
defer { harness.tearDown() }
|
||||
|
||||
_ = try await harness.service.createPlaylist(
|
||||
title: "Watch later",
|
||||
description: "Later",
|
||||
privacy: .unlisted,
|
||||
for: harness.account
|
||||
)
|
||||
|
||||
#expect(await harness.transport.calls == [
|
||||
.create(title: "Watch later", description: "Later", privacy: .unlisted, credential: "secret-original")
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Resolution
|
||||
|
||||
@Test("An account with no readable credential is not sent anywhere")
|
||||
func missingCredentialIsRefused() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
harness.credential = nil
|
||||
|
||||
await #expect(throws: AccountServiceError.notAuthenticated) {
|
||||
_ = try await harness.service.userPlaylists(for: harness.account)
|
||||
}
|
||||
|
||||
#expect(await harness.transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("An account on a disabled instance is not sent anywhere")
|
||||
func disabledInstanceIsRefused() async throws {
|
||||
let harness = AccountServiceHarness(isEnabled: false)
|
||||
defer { harness.tearDown() }
|
||||
|
||||
await #expect(throws: AccountServiceError.instanceNotConfigured) {
|
||||
_ = try await harness.service.userPlaylists(for: harness.account)
|
||||
}
|
||||
|
||||
#expect(await harness.transport.callCount == 0)
|
||||
}
|
||||
|
||||
// MARK: - Outcome tracking
|
||||
|
||||
@Test("A successful call reports the account as signed in")
|
||||
func successRecordsSignedIn() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
harness.accounts.setStatus(.credentialMissing, for: harness.account.id)
|
||||
|
||||
_ = try await harness.service.userPlaylists(for: harness.account)
|
||||
|
||||
#expect(harness.status == .signedIn)
|
||||
}
|
||||
|
||||
@Test("A server that cannot be reached is recorded as such")
|
||||
func failureRecordsTheError() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
await harness.transport.setError(APIError.timeout)
|
||||
|
||||
await #expect(throws: APIError.timeout) {
|
||||
_ = try await harness.service.userPlaylists(for: harness.account)
|
||||
}
|
||||
|
||||
#expect(harness.status == .unreachable)
|
||||
}
|
||||
|
||||
@Test("An answer about a credential that has since been replaced records nothing")
|
||||
func staleCredentialRecordsNothing() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
harness.accounts.setStatus(.credentialRejected, for: harness.account.id)
|
||||
await harness.transport.holdCalls()
|
||||
|
||||
let call = Task { [service = harness.service!, account = harness.account] in
|
||||
try await service.userPlaylists(for: account)
|
||||
}
|
||||
|
||||
try await harness.transport.waitForCalls(1)
|
||||
|
||||
// The user signs in again while the request is still out. Its answer
|
||||
// describes a secret nobody holds any more.
|
||||
harness.credential = "secret-replacement"
|
||||
await harness.transport.release()
|
||||
_ = try await call.value
|
||||
|
||||
#expect(harness.status == .credentialRejected)
|
||||
}
|
||||
|
||||
// MARK: - Removal
|
||||
|
||||
@Test("Invidious removes by the server's own token")
|
||||
func invidiousRemovesByIndexID() async throws {
|
||||
let harness = AccountServiceHarness(type: .invidious)
|
||||
defer { harness.tearDown() }
|
||||
let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .invidious)
|
||||
|
||||
try await harness.service.removeEntry(
|
||||
snapshot.entries[1],
|
||||
fromPlaylist: "pl",
|
||||
for: harness.account
|
||||
)
|
||||
|
||||
#expect(await harness.transport.calls == [
|
||||
.remove(entryID: .invidious(playlistID: "pl", indexID: "TOKEN-bbb"), credential: "secret-original")
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Piped removes by position once the position still holds the same video")
|
||||
func pipedRemovesAfterVerifying() async throws {
|
||||
let harness = AccountServiceHarness(type: .piped)
|
||||
defer { harness.tearDown() }
|
||||
let snapshot = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped)
|
||||
await harness.transport.setSnapshot(snapshot)
|
||||
|
||||
try await harness.service.removeEntry(
|
||||
snapshot.entries[1],
|
||||
fromPlaylist: "pl",
|
||||
for: harness.account
|
||||
)
|
||||
|
||||
#expect(await harness.transport.calls == [
|
||||
.snapshot(id: "pl", credential: "secret-original"),
|
||||
.remove(entryID: .piped(playlistID: "pl", position: 1), credential: "secret-original")
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Piped removes nothing when the position now holds a different video")
|
||||
func pipedRefusesToRemoveAMovedRow() async throws {
|
||||
let harness = AccountServiceHarness(type: .piped)
|
||||
defer { harness.tearDown() }
|
||||
let stale = makeSnapshot(playlistID: "pl", videoIDs: ["aaa", "bbb"], kind: .piped)
|
||||
// The row above was removed elsewhere, so position 1 is now a different
|
||||
// video entirely — removing by that position would delete the wrong one.
|
||||
await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["bbb", "ccc"], kind: .piped))
|
||||
|
||||
await #expect(throws: AccountServiceError.entryMismatch) {
|
||||
try await harness.service.removeEntry(stale.entries[1], fromPlaylist: "pl", for: harness.account)
|
||||
}
|
||||
|
||||
#expect(await harness.transport.calls == [.snapshot(id: "pl", credential: "secret-original")])
|
||||
}
|
||||
|
||||
@Test("An entry minted by another playlist is refused")
|
||||
func entryFromAnotherPlaylistIsRefused() async throws {
|
||||
let harness = AccountServiceHarness(type: .invidious)
|
||||
defer { harness.tearDown() }
|
||||
let other = makeSnapshot(playlistID: "other", videoIDs: ["aaa"], kind: .invidious)
|
||||
|
||||
await #expect(throws: AccountServiceError.entryMismatch) {
|
||||
try await harness.service.removeEntry(other.entries[0], fromPlaylist: "pl", for: harness.account)
|
||||
}
|
||||
|
||||
#expect(await harness.transport.callCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
@Suite("Remote playlists cache", .serialized)
|
||||
@MainActor
|
||||
struct RemotePlaylistsCacheTests {
|
||||
@Test("A result that arrives after the account was cleared is dropped")
|
||||
func generationFenceDropsStaleResults() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
let cache = harness.makeCache()
|
||||
await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")])
|
||||
await harness.transport.holdCalls()
|
||||
|
||||
let refresh = Task { [account = harness.account] in
|
||||
try await cache.refreshList(for: account)
|
||||
}
|
||||
try await harness.transport.waitForCalls(1)
|
||||
|
||||
// Signed out while the fetch is still out: the request is not cancelled
|
||||
// by clearing, so its answer has to be refused on arrival.
|
||||
cache.clear(accountID: harness.account.id)
|
||||
await harness.transport.release()
|
||||
_ = try? await refresh.value
|
||||
|
||||
#expect(cache.playlists(for: harness.account.id) == nil)
|
||||
}
|
||||
|
||||
@Test("Creating a playlist republishes the list from the server")
|
||||
func createRefreshesTheList() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
let cache = harness.makeCache()
|
||||
await harness.transport.setPlaylists([Playlist(id: .global("new-playlist"), title: "Watch later")])
|
||||
|
||||
let created = try await cache.createPlaylist(
|
||||
title: "Watch later",
|
||||
description: nil,
|
||||
privacy: nil,
|
||||
for: harness.account
|
||||
)
|
||||
|
||||
#expect(created.title == "Watch later")
|
||||
#expect(cache.playlists(for: harness.account.id)?.map(\.title) == ["Watch later"])
|
||||
}
|
||||
|
||||
@Test("A failed mutation still refetches what it may have changed")
|
||||
func failedMutationStillReconciles() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
let cache = harness.makeCache()
|
||||
await harness.transport.setSnapshot(makeSnapshot(playlistID: "pl", videoIDs: ["aaa"], kind: .invidious))
|
||||
|
||||
// The write reports failure, which is not the same as not having landed.
|
||||
await harness.transport.setError(APIError.httpError(statusCode: 500, message: nil))
|
||||
|
||||
await #expect(throws: APIError.self) {
|
||||
try await cache.addVideo(videoID: "bbb", toPlaylist: "pl", for: harness.account)
|
||||
}
|
||||
|
||||
let calls = await harness.transport.calls
|
||||
#expect(calls.contains(.addVideo(playlistID: "pl", videoID: "bbb", credential: "secret-original")))
|
||||
#expect(calls.contains(.snapshot(id: "pl", credential: "secret-original")))
|
||||
}
|
||||
|
||||
@Test("Concurrent refreshes of the same list cost one round trip")
|
||||
func concurrentRefreshesAreCoalesced() async throws {
|
||||
let harness = AccountServiceHarness()
|
||||
defer { harness.tearDown() }
|
||||
let cache = harness.makeCache()
|
||||
await harness.transport.setPlaylists([Playlist(id: .global("pl"), title: "Playlist")])
|
||||
await harness.transport.holdCalls()
|
||||
|
||||
let first = Task { [account = harness.account] in try await cache.refreshList(for: account) }
|
||||
try await harness.transport.waitForCalls(1)
|
||||
let second = Task { [account = harness.account] in try await cache.refreshList(for: account) }
|
||||
|
||||
// Long enough for a second request to have been made, had one been.
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
await harness.transport.release()
|
||||
try await first.value
|
||||
try await second.value
|
||||
|
||||
#expect(await harness.transport.callCount == 1)
|
||||
#expect(cache.playlists(for: harness.account.id)?.count == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user