Files
yattee/Yattee/Services/Subscriptions/SubscriptionProvider.swift
Arkadiusz Fal 9065234606 Key subscription providers by account rather than backend type
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.

Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.

Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.

Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
2026-08-24 23:56:40 +02:00

88 lines
3.1 KiB
Swift

//
// SubscriptionProvider.swift
// Yattee
//
// Protocol defining subscription management operations.
// Implementations handle different subscription sources (local, Invidious, Piped).
//
import Foundation
/// Protocol for subscription management providers.
/// Each provider implementation handles a specific subscription source.
@MainActor
protocol SubscriptionProvider: Sendable {
/// The data context this provider serves.
///
/// A context rather than a backend type: an instance can hold several
/// accounts, so "which Invidious" is no longer enough to identify a
/// provider — each account gets its own, with its own cache.
var context: DataContext { get }
/// Fetches all subscriptions from the provider.
/// - Returns: Array of channels the user is subscribed to.
func fetchSubscriptions() async throws -> [Channel]
/// Subscribes to a channel.
/// - Parameter channel: The channel to subscribe to.
func subscribe(to channel: Channel) async throws
/// Unsubscribes from a channel.
/// - Parameter channelID: The channel ID to unsubscribe from.
func unsubscribe(from channelID: String) async throws
/// Checks if subscribed to a channel.
/// - Parameter channelID: The channel ID to check.
/// - Returns: `true` if subscribed, `false` otherwise.
func isSubscribed(to channelID: String) async -> Bool
/// The subscription count known without a network fetch.
/// Returns nil when the provider hasn't populated its cache yet.
var cachedSubscriptionCount: Int? { get }
/// Refreshes the local cache of subscriptions from the remote source.
/// For local provider, this is a no-op.
func refreshCache() async throws
}
// MARK: - Default Implementations
extension SubscriptionProvider {
/// Default implementation for providers that don't need cache refresh.
func refreshCache() async throws {
// No-op by default
}
}
// MARK: - Subscription Provider Error
/// Errors that can occur during subscription provider operations.
enum SubscriptionProviderError: Error, LocalizedError, Equatable, Sendable {
case notAuthenticated
case networkError(String)
case channelNotFound
case alreadySubscribed
case notSubscribed
case instanceNotConfigured
case operationFailed(String)
var errorDescription: String? {
switch self {
case .notAuthenticated:
return String(localized: "subscription.error.notAuthenticated")
case .networkError(let message):
return String(localized: "subscription.error.network \(message)")
case .channelNotFound:
return String(localized: "subscription.error.channelNotFound")
case .alreadySubscribed:
return String(localized: "subscription.error.alreadySubscribed")
case .notSubscribed:
return String(localized: "subscription.error.notSubscribed")
case .instanceNotConfigured:
return String(localized: "subscription.error.instanceNotConfigured")
case .operationFailed(let message):
return String(localized: "subscription.error.operationFailed \(message)")
}
}
}