diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 7535cfbf..47cb7051 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -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/` 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) } diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index b5117595..261dad22 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Models/RemotePlaylistEntry.swift b/Yattee/Models/RemotePlaylistEntry.swift index 4e288f5e..60a58475 100644 --- a/Yattee/Models/RemotePlaylistEntry.swift +++ b/Yattee/Models/RemotePlaylistEntry.swift @@ -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` +} diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index a163839e..708bf639 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -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 { diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 31ceb1f0..382408d5 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -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 diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index b3edb720..f491281b 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -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 diff --git a/Yattee/Services/AccountService.swift b/Yattee/Services/AccountService.swift new file mode 100644 index 00000000..7c23e216 --- /dev/null +++ b/Yattee/Services/AccountService.swift @@ -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 + ) 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( + _ 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) + } +} diff --git a/Yattee/Services/Networking/HTTPClient.swift b/Yattee/Services/Networking/HTTPClient.swift index dba3525e..af4e6ec9 100644 --- a/Yattee/Services/Networking/HTTPClient.swift +++ b/Yattee/Services/Networking/HTTPClient.swift @@ -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) } diff --git a/Yattee/Services/Networking/HTTPClientFactory.swift b/Yattee/Services/Networking/HTTPClientFactory.swift index 9fb41a5a..3d5f34b9 100644 --- a/Yattee/Services/Networking/HTTPClientFactory.swift +++ b/Yattee/Services/Networking/HTTPClientFactory.swift @@ -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/`, 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 + ) } } diff --git a/Yattee/Services/RemotePlaylistsCache.swift b/Yattee/Services/RemotePlaylistsCache.swift new file mode 100644 index 00000000..95fd5375 --- /dev/null +++ b/Yattee/Services/RemotePlaylistsCache.swift @@ -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] = [:] + + /// In-flight snapshot fetches, keyed by account and playlist. + private var snapshotFetches: [SnapshotKey: Task] = [:] + + /// 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 { [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 { [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( + 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( + 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 + } +} diff --git a/Yattee/Views/Settings/InstanceAccountsSection.swift b/Yattee/Views/Settings/InstanceAccountsSection.swift index dffe763a..b427ee43 100644 --- a/Yattee/Views/Settings/InstanceAccountsSection.swift +++ b/Yattee/Views/Settings/InstanceAccountsSection.swift @@ -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) diff --git a/YatteeTests/AccountServiceTests.swift b/YatteeTests/AccountServiceTests.swift new file mode 100644 index 00000000..135c1312 --- /dev/null +++ b/YatteeTests/AccountServiceTests.swift @@ -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] = [] + + 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) + } +}