Files
yattee/Yattee/Services/SubscriptionService.swift
2026-02-08 18:33:56 +01:00

289 lines
9.5 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?
private var invidiousProvider: InvidiousSubscriptionProvider?
private var pipedProvider: PipedSubscriptionProvider?
// 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
// Initialize providers
self.localProvider = LocalSubscriptionProvider(dataManager: dataManager)
self.invidiousProvider = InvidiousSubscriptionProvider(
invidiousAPI: invidiousAPI,
credentialsManager: invidiousCredentialsManager,
instancesManager: instancesManager,
settingsManager: settingsManager
)
self.pipedProvider = PipedSubscriptionProvider(
pipedAPI: pipedAPI,
credentialsManager: pipedCredentialsManager,
instancesManager: instancesManager,
settingsManager: settingsManager
)
}
// MARK: - Current Provider
/// Returns the current subscription provider based on the user's account setting.
var currentProvider: (any SubscriptionProvider)? {
let account = settingsManager.subscriptionAccount
switch account.type {
case .local:
return localProvider
case .invidious:
return invidiousProvider
case .piped:
return pipedProvider
}
}
/// The current subscription account type.
var currentAccountType: SubscriptionAccountType {
settingsManager.subscriptionAccount.type
}
// 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)
LoggingService.shared.info(
"Subscribed to \(channel.name) via \(provider.accountType)",
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)
LoggingService.shared.info(
"Unsubscribed from \(channelID) via \(provider.accountType)",
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 currentAccountType == .local 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: - Synchronous Helpers (for backwards compatibility)
/// Synchronously checks if subscribed to a channel.
/// Uses cached data from DataManager for instant response.
/// - Parameter channelID: The channel ID to check.
/// - Returns: `true` if subscribed (based on local cache), `false` otherwise.
func isSubscribedSync(to channelID: String) -> Bool {
dataManager.isSubscribed(to: channelID)
}
/// Synchronously subscribes to a channel (local provider only).
/// For Invidious provider, this will only update local cache.
/// - Parameter channel: The channel to subscribe to.
func subscribeSync(to channel: Channel) {
dataManager.subscribe(to: channel)
}
/// Synchronously unsubscribes from a channel (local provider only).
/// For Invidious provider, this will only update local cache.
/// - Parameter channelID: The channel ID to unsubscribe from.
func unsubscribeSync(from channelID: String) {
dataManager.unsubscribe(from: channelID)
}
}