mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 00:47:17 +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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user