// // SubscriptionService.swift // Yattee // // Service for managing subscriptions through the selected provider. // Supports local (iCloud) and Invidious account subscriptions. // import Foundation /// Errors that can occur during subscription operations. enum SubscriptionError: LocalizedError, Sendable { case alreadySubscribed case notSubscribed case providerNotAvailable case operationFailed(String) var errorDescription: String? { switch self { case .alreadySubscribed: return String(localized: "subscription.error.alreadySubscribed") case .notSubscribed: return String(localized: "subscription.error.notSubscribed") case .providerNotAvailable: return String(localized: "subscription.error.providerNotAvailable") case .operationFailed(let message): return message } } } /// Service that manages subscriptions through the selected subscription provider. /// The provider is determined by the user's subscription account setting. @MainActor @Observable final class SubscriptionService { // MARK: - Dependencies private let dataManager: DataManager private let settingsManager: SettingsManager private let instancesManager: InstancesManager private let invidiousCredentialsManager: InvidiousCredentialsManager private let pipedCredentialsManager: PipedCredentialsManager private let invidiousAPI: InvidiousAPI private let pipedAPI: PipedAPI // MARK: - Providers private var localProvider: LocalSubscriptionProvider? /// One provider per account, built on demand and kept so its in-memory /// subscription cache survives context switches. private var accountProviders: [UUID: any SubscriptionProvider] = [:] private weak var accountsManager: AccountsManager? // MARK: - State /// Whether a subscription operation is in progress. private(set) var isLoading = false /// The last error that occurred. private(set) var lastError: Error? // MARK: - Initialization init( dataManager: DataManager, settingsManager: SettingsManager, instancesManager: InstancesManager, invidiousCredentialsManager: InvidiousCredentialsManager, pipedCredentialsManager: PipedCredentialsManager, invidiousAPI: InvidiousAPI, pipedAPI: PipedAPI ) { self.dataManager = dataManager self.settingsManager = settingsManager self.instancesManager = instancesManager self.invidiousCredentialsManager = invidiousCredentialsManager self.pipedCredentialsManager = pipedCredentialsManager self.invidiousAPI = invidiousAPI self.pipedAPI = pipedAPI // Account providers are built lazily: which accounts exist is not known // until AccountsManager has loaded and iCloud has delivered instances. self.localProvider = LocalSubscriptionProvider(dataManager: dataManager) } func setAccountsManager(_ manager: AccountsManager) { accountsManager = manager // Providers cache another identity's subscriptions in memory, so they // cannot outlive a change of Apple Account. NotificationCenter.default.addObserver( forName: .credentialIdentityScopeDidChange, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.accountProviders.removeAll() } } } // MARK: - Current Provider /// The provider for a context, built on first use. /// /// Returns nil for an account whose instance or backend cannot supply /// subscriptions — a caller that gets nil must surface that rather than /// quietly falling back to local data the user did not select. func provider(for context: DataContext) -> (any SubscriptionProvider)? { switch context { case .local: return localProvider case let .account(id): if let existing = accountProviders[id] { return existing } guard let accountsManager, let account = accountsManager.account(id: id), let instance = accountsManager.instance(for: account), instance.type.accountCapabilities.supportsSubscriptions else { return nil } let provider: (any SubscriptionProvider)? switch instance.type { case .invidious: provider = InvidiousSubscriptionProvider( account: account, invidiousAPI: invidiousAPI, credentialsManager: invidiousCredentialsManager, accountsManager: accountsManager, settingsManager: settingsManager ) case .piped: provider = PipedSubscriptionProvider( account: account, pipedAPI: pipedAPI, credentialsManager: pipedCredentialsManager, accountsManager: accountsManager, settingsManager: settingsManager ) default: provider = nil } accountProviders[id] = provider return provider } } /// Drops a removed account's provider so its cached subscriptions cannot be /// served after the account is gone. func discardProvider(for accountID: UUID) { accountProviders[accountID] = nil } /// Returns the current subscription provider for the selected context. var currentProvider: (any SubscriptionProvider)? { provider(for: currentContext) } /// The selected context, or `.local` before accounts have loaded. var currentContext: DataContext { accountsManager?.currentContext ?? .local } /// Subscription count for the active account, when known without a network fetch. /// Always available for local accounts; nil for server accounts until their cache is populated. var cachedSubscriptionCount: Int? { currentProvider?.cachedSubscriptionCount } // MARK: - Subscribe /// Subscribes to a channel using the current provider. /// - Parameter channel: The channel to subscribe to. /// - Throws: SubscriptionProviderError if the operation fails. func subscribe(to channel: Channel) async throws { guard let provider = currentProvider else { throw SubscriptionError.providerNotAvailable } isLoading = true lastError = nil do { try await provider.subscribe(to: channel) postChangeNotificationForServerAccount() LoggingService.shared.info( "Subscribed to \(channel.name) via \(provider.context)", category: .general ) } catch { lastError = error LoggingService.shared.error( "Failed to subscribe to \(channel.name): \(error.localizedDescription)", category: .general ) throw error } isLoading = false } /// Subscribes to a channel from an Author. /// - Parameters: /// - author: The author/channel to subscribe to. /// - source: The content source. func subscribe(to author: Author, source: ContentSource) async throws { let channel = Channel( id: ChannelID(source: source, channelID: author.id), name: author.name, subscriberCount: author.subscriberCount, thumbnailURL: author.thumbnailURL ) try await subscribe(to: channel) } // MARK: - Unsubscribe /// Unsubscribes from a channel using the current provider. /// - Parameter channelID: The channel ID to unsubscribe from. /// - Throws: SubscriptionProviderError if the operation fails. func unsubscribe(from channelID: String) async throws { guard let provider = currentProvider else { throw SubscriptionError.providerNotAvailable } isLoading = true lastError = nil do { try await provider.unsubscribe(from: channelID) postChangeNotificationForServerAccount() LoggingService.shared.info( "Unsubscribed from \(channelID) via \(provider.context)", category: .general ) } catch { lastError = error LoggingService.shared.error( "Failed to unsubscribe from \(channelID): \(error.localizedDescription)", category: .general ) throw error } isLoading = false } // MARK: - Query /// 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 { guard let provider = currentProvider else { return false } return await provider.isSubscribed(to: channelID) } /// Fetches all subscriptions from the current provider. /// - Returns: Array of subscribed channels. func fetchSubscriptions() async throws -> [Channel] { guard let provider = currentProvider else { throw SubscriptionError.providerNotAvailable } isLoading = true lastError = nil do { let channels = try await provider.fetchSubscriptions() isLoading = false return channels } catch { lastError = error isLoading = false throw error } } /// Synchronously fetches subscriptions for local provider. /// Returns nil if the current provider is not local (requires async fetch). func fetchSubscriptionsSync() -> [Channel]? { guard currentContext.isLocal else { return nil } return dataManager.subscriptions().map { subscription in Channel( id: ChannelID(source: subscription.contentSource, channelID: subscription.channelID), name: subscription.name, description: subscription.channelDescription, subscriberCount: subscription.subscriberCount, thumbnailURL: subscription.avatarURL, bannerURL: subscription.bannerURL, isVerified: subscription.isVerified ) } } /// Refreshes the subscription cache from the current provider. func refreshCache() async throws { guard let provider = currentProvider else { throw SubscriptionError.providerNotAvailable } isLoading = true lastError = nil do { try await provider.refreshCache() isLoading = false } catch { lastError = error isLoading = false throw error } } // MARK: - Import /// Imports parsed external subscriptions (YouTube CSV / OPML) into the active account. /// Local accounts write to SwiftData; server accounts subscribe on the server, /// one channel at a time. Channels that are already subscribed are skipped. func importSubscriptions(_ channels: [(channelID: String, name: String)]) async -> (imported: Int, skipped: Int) { guard !currentContext.isLocal else { return dataManager.importSubscriptionsFromExternal(channels) } guard let provider = currentProvider else { return (0, channels.count) } // Populate the cache so already-subscribed channels can be skipped try? await provider.refreshCache() var imported = 0 var skipped = 0 for entry in channels { if await provider.isSubscribed(to: entry.channelID) { skipped += 1 continue } do { try await provider.subscribe(to: Channel(id: .global(entry.channelID), name: entry.name)) imported += 1 } catch { skipped += 1 LoggingService.shared.error( "Failed to import subscription \(entry.channelID): \(error.localizedDescription)", category: .general ) } } if imported > 0 { postChangeNotificationForServerAccount() } return (imported, skipped) } // MARK: - Private Helpers /// Posts a subscriptions-changed notification for server accounts. /// Local accounts already post it from DataManager with a change payload. private func postChangeNotificationForServerAccount() { guard !currentContext.isLocal else { return } NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil) } }