Files
yattee/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift
Arkadiusz Fal cd01c08b5a Fix #960: scope subscription counts, import/export to active account
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
2026-08-03 23:34:06 +02:00

144 lines
5.0 KiB
Swift

//
// PipedSubscriptionProvider.swift
// Yattee
//
// Piped subscription provider that manages subscriptions on a Piped instance.
// Uses in-memory cache only - does NOT persist to SwiftData to avoid corrupting local subscriptions.
//
import Foundation
/// Piped subscription provider that syncs with a Piped instance account.
/// Subscriptions are managed on the Piped server and kept in memory only.
/// This provider does NOT write to SwiftData to preserve local subscriptions.
@MainActor
final class PipedSubscriptionProvider: SubscriptionProvider {
// MARK: - Properties
let accountType: SubscriptionAccountType = .piped
private let pipedAPI: PipedAPI
private let credentialsManager: PipedCredentialsManager
private let instancesManager: InstancesManager
private let settingsManager: SettingsManager
/// In-memory cache of subscribed channel IDs for fast lookup.
private var cachedChannelIDs: Set<String> = []
/// In-memory cache of full channel data for display.
private var cachedChannels: [Channel] = []
/// Whether the cache has been populated from the server.
private var cachePopulated = false
// MARK: - Initialization
init(
pipedAPI: PipedAPI,
credentialsManager: PipedCredentialsManager,
instancesManager: InstancesManager,
settingsManager: SettingsManager
) {
self.pipedAPI = pipedAPI
self.credentialsManager = credentialsManager
self.instancesManager = instancesManager
self.settingsManager = settingsManager
}
// MARK: - SubscriptionProvider
func fetchSubscriptions() async throws -> [Channel] {
let (instance, authToken) = try await getAuthenticatedInstance()
// Fetch subscriptions from Piped
let subscriptions = try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
// Update in-memory cache
cachedChannelIDs = Set(subscriptions.map(\.channelId))
cachedChannels = subscriptions.map { $0.toChannel() }
cachePopulated = true
LoggingService.shared.info("Fetched \(subscriptions.count) Piped subscriptions", category: .api)
return cachedChannels
}
func subscribe(to channel: Channel) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// Subscribe on Piped server
try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken)
// Update in-memory cache only
cachedChannelIDs.insert(channel.id.channelID)
if !cachedChannels.contains(where: { $0.id.channelID == channel.id.channelID }) {
cachedChannels.append(channel)
}
LoggingService.shared.info("Subscribed to \(channel.name) on Piped", category: .api)
}
func unsubscribe(from channelID: String) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// Unsubscribe on Piped server
try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken)
// Update in-memory cache only
cachedChannelIDs.remove(channelID)
cachedChannels.removeAll { $0.id.channelID == channelID }
LoggingService.shared.info("Unsubscribed from \(channelID) on Piped", category: .api)
}
func isSubscribed(to channelID: String) async -> Bool {
// If cache isn't populated yet, try to refresh it
if !cachePopulated {
do {
_ = try await fetchSubscriptions()
} catch {
// Can't determine subscription status without server connection
return false
}
}
return cachedChannelIDs.contains(channelID)
}
func refreshCache() async throws {
// Simply fetch subscriptions again - this updates in-memory cache
_ = try await fetchSubscriptions()
}
var cachedSubscriptionCount: Int? {
cachePopulated ? cachedChannels.count : nil
}
// MARK: - Private Helpers
/// Gets the authenticated Piped instance and auth token from account settings.
private func getAuthenticatedInstance() async throws -> (Instance, String) {
// Get the instance ID from subscription account settings
let account = settingsManager.subscriptionAccount
let instance: Instance?
if let instanceID = account.instanceID {
// Use the specific instance from account settings
instance = instancesManager.instances.first { $0.id == instanceID && $0.isEnabled }
} else {
// Fallback to first enabled Piped instance
instance = instancesManager.instances.first { $0.type == .piped && $0.isEnabled }
}
guard let instance else {
throw SubscriptionProviderError.instanceNotConfigured
}
// Get auth token
guard let authToken = credentialsManager.credential(for: instance) else {
throw SubscriptionProviderError.notAuthenticated
}
return (instance, authToken)
}
}