mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
When an Invidious/Piped account is active, several views read the local SwiftData subscription store instead of the account, so counts contradicted each other (Home tile said 10 while the Channels list showed the server's 2): - Home Channels tile now uses the provider's count via new SubscriptionService.cachedSubscriptionCount (fetched once in the background when the server cache isn't populated yet) - Export footer and export content use the active account's list; export runs async with a spinner and surfaces fetch errors as a toast - CSV/OPML import routes through SubscriptionService.importSubscriptions, subscribing on the server for server accounts instead of silently writing to the invisible local store - ChannelView subscribe-state is corrected via the provider after the optimistic local-store read; unused *Sync write helpers removed - Server-account subscribe/unsubscribe/import now post subscriptionsDidChange so other views refresh - New "Delete Local Subscription Data" section in Subscriptions settings (visible with a server account) clears the local store and queues CloudKit deletions so iCloud doesn't restore it
323 lines
11 KiB
Swift
323 lines
11 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
|
|
}
|
|
|
|
/// 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.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)
|
|
postChangeNotificationForServerAccount()
|
|
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: - 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 currentAccountType != .local 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 currentAccountType != .local else { return }
|
|
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
|
}
|
|
}
|