mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Recomputing statuses reads the Keychain, which cannot know that a server rejected a credential or failed to answer — and it ran in reaction to the same notification those verdicts were announced with, so it erased them as they were recorded. Status changes now have their own notification. With that separation `unreachable` no longer has to be preserved to survive: it is transient, and preserving it stopped every read from reaching the server, so nothing would ever discover that the network had come back. A fetch already running when its provider is discarded now has its result dropped. Signing in again — or switching Apple account — changes neither the context nor the account ID, so the previous login's channels would otherwise arrive as the current account's. Caches with no recorded owner are discarded rather than adopted. The first launch after upgrading finds feeds written before anyone recorded whose they were, and the Apple account may have changed since. The legacy validator's auto-correction moves the data context with it, and Manage Channels reloads on the context rather than the legacy value — it could list one account's channels while unsubscribes went to another.
414 lines
14 KiB
Swift
414 lines
14 KiB
Swift
//
|
|
// 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?
|
|
private weak var contextValidator: DataContextValidator?
|
|
|
|
// 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 setContextValidator(_ validator: DataContextValidator) {
|
|
contextValidator = validator
|
|
}
|
|
|
|
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
|
|
providerGeneration += 1
|
|
}
|
|
|
|
/// Which generation of logins the cached providers belong to. Bumped
|
|
/// whenever one is discarded, so a fetch that outlived its provider can be
|
|
/// told from a current one.
|
|
private var providerGeneration = 0
|
|
|
|
/// The provider for **writes**.
|
|
///
|
|
/// Always the raw selected context, never the read fallback: a subscribe
|
|
/// must fail visibly when the account is unavailable rather than silently
|
|
/// landing in local data the user did not choose.
|
|
var currentProvider: (any SubscriptionProvider)? {
|
|
provider(for: currentContext)
|
|
}
|
|
|
|
/// The provider for **reads**.
|
|
///
|
|
/// Uses the validator's effective context, which falls back to local while
|
|
/// the selected account is waiting on its instance or credential. The
|
|
/// fallback is not persisted, so the user's choice takes effect again on
|
|
/// its own once the account resolves.
|
|
var readProvider: (any SubscriptionProvider)? {
|
|
provider(for: contextValidator?.effectiveContext ?? 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? {
|
|
readProvider?.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 = readProvider 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 = readProvider else {
|
|
throw SubscriptionError.providerNotAvailable
|
|
}
|
|
|
|
isLoading = true
|
|
lastError = nil
|
|
|
|
// Dropping a provider from the cache does not stop a fetch it has
|
|
// already started: the running task holds it, and the result would
|
|
// arrive afterwards as the current account's subscriptions. Signing in
|
|
// again or switching Apple account changes neither the context nor the
|
|
// account ID, so nothing else downstream would notice.
|
|
let generation = providerGeneration
|
|
|
|
do {
|
|
let channels = try await provider.fetchSubscriptions()
|
|
isLoading = false
|
|
|
|
guard generation == providerGeneration else {
|
|
LoggingService.shared.info(
|
|
"Discarding subscriptions fetched for a login that has since been replaced",
|
|
category: .general
|
|
)
|
|
throw SubscriptionError.providerNotAvailable
|
|
}
|
|
|
|
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 (contextValidator?.effectiveContext ?? 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 = readProvider 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)
|
|
}
|
|
}
|