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
This commit is contained in:
Arkadiusz Fal
2026-07-28 18:54:38 +02:00
parent 155a5a48a0
commit cd01c08b5a
10 changed files with 339 additions and 27 deletions

View File

@@ -2097,9 +2097,13 @@ struct ChannelView: View {
isLoading = true
errorMessage = nil
// Load subscription state
// Load subscription state: optimistic from the local store, then
// corrected by the active provider (server accounts differ from local)
subscription = appEnvironment.dataManager.subscription(for: channelID)
isSubscribed = subscription != nil
Task {
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: channelID)
}
// Load cached header data for immediate display
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
@@ -2212,6 +2216,9 @@ struct ChannelView: View {
// Check subscription status using extracted channel ID
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
isSubscribed = subscription != nil
Task {
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: fetchedChannel.id.channelID)
}
}
} catch let error as APIError {
await MainActor.run {
@@ -2247,6 +2254,11 @@ struct ChannelView: View {
let effectiveChannelID = channel?.id.channelID ?? channelID
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
isSubscribed = subscription != nil
Task {
if let service = appEnvironment?.subscriptionService {
isSubscribed = await service.isSubscribed(to: effectiveChannelID)
}
}
}
private func loadMoreVideos() async {

View File

@@ -1473,7 +1473,22 @@ struct HomeView: View {
}
private func loadChannelsData() {
channelsCount = dataManager?.subscriptions().count ?? 0
guard let service = appEnvironment?.subscriptionService else {
channelsCount = 0
return
}
// Local accounts always have a count; server accounts only after their
// in-memory cache is populated fetch it once in the background otherwise.
if let cached = service.cachedSubscriptionCount {
channelsCount = cached
} else {
Task {
if let channels = try? await service.fetchSubscriptions() {
channelsCount = channels.count
}
}
}
}
private func loadRemoteDevicesData() {

View File

@@ -35,7 +35,13 @@ struct SubscriptionsSettingsView: View {
// Export state
@State private var selectedExportFormat: SubscriptionExportFormat = .json
@State private var exportFile: ExportFile?
@State private var isExporting = false
// Account subscription count (provider-scoped, fetched for server accounts)
@State private var accountSubscriptionCount: Int?
// Local data deletion state
@State private var showingDeleteLocalConfirmation = false
private var dataManager: DataManager? {
appEnvironment?.dataManager
@@ -49,7 +55,17 @@ struct SubscriptionsSettingsView: View {
appEnvironment?.subscriptionAccountValidator
}
private var subscriptionService: SubscriptionService? {
appEnvironment?.subscriptionService
}
/// Subscription count of the active account (local store or server).
private var subscriptionCount: Int {
accountSubscriptionCount ?? subscriptionService?.cachedSubscriptionCount ?? 0
}
/// Count of subscriptions in the local store, shown in the local-data section.
private var localSubscriptionCount: Int {
dataManager?.subscriptionCount ?? 0
}
@@ -63,8 +79,12 @@ struct SubscriptionsSettingsView: View {
if validator?.hasAvailableAccounts == true {
importSection
exportSection
localDataSection
}
}
.task(id: currentAccount) {
await refreshAccountSubscriptionCount()
}
.navigationTitle(String(localized: "settings.subscriptions.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
@@ -111,6 +131,17 @@ struct SubscriptionsSettingsView: View {
} message: {
Text(String(localized: "settings.subscriptions.account.switch.message"))
}
.confirmationDialog(
String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"),
isPresented: $showingDeleteLocalConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "settings.subscriptions.localData.delete.confirmation.action"), role: .destructive) {
deleteLocalSubscriptions()
}
} message: {
Text(String(localized: "settings.subscriptions.localData.delete.confirmation.message"))
}
.presentationCompactAdaptation(.sheet)
}
@@ -225,9 +256,15 @@ struct SubscriptionsSettingsView: View {
Button {
exportSubscriptions()
} label: {
Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up")
HStack {
Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up")
Spacer()
if isExporting {
ProgressView()
}
}
}
.disabled(subscriptionCount == 0)
.disabled(subscriptionCount == 0 || isExporting)
} header: {
Text(String(localized: "settings.subscriptions.export.title"))
} footer: {
@@ -235,6 +272,27 @@ struct SubscriptionsSettingsView: View {
}
}
// MARK: - Local Data Section
/// Lets the user delete locally stored subscriptions while a server account
/// is active. Hidden in local mode, where the account IS the local store.
@ViewBuilder
private var localDataSection: some View {
if currentAccount.type != .local && localSubscriptionCount > 0 {
Section {
Button(role: .destructive) {
showingDeleteLocalConfirmation = true
} label: {
Label(String(localized: "settings.subscriptions.localData.delete.button"), systemImage: "trash")
}
} header: {
Text(String(localized: "settings.subscriptions.localData.title"))
} footer: {
Text(String(localized: "settings.subscriptions.localData.footer \(localSubscriptionCount)"))
}
}
}
// MARK: - Actions
private func showImportPicker() {
@@ -281,12 +339,12 @@ struct SubscriptionsSettingsView: View {
// Parse subscriptions
let parseResult = try SubscriptionImportExport.parseAuto(data)
// Import to database
guard let dataManager else {
// Import into the active account (local store or server)
guard let subscriptionService else {
throw SubscriptionImportError.invalidData
}
let importStats = dataManager.importSubscriptionsFromExternal(parseResult.channels)
let importStats = await subscriptionService.importSubscriptions(parseResult.channels)
await MainActor.run {
isImporting = false
@@ -294,6 +352,7 @@ struct SubscriptionsSettingsView: View {
showingImportResult = true
LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped")
}
await refreshAccountSubscriptionCount()
} catch {
await MainActor.run {
isImporting = false
@@ -306,9 +365,36 @@ struct SubscriptionsSettingsView: View {
}
private func exportSubscriptions() {
guard let dataManager else { return }
isExporting = true
Task {
await performExport()
isExporting = false
}
}
let subscriptions = dataManager.allSubscriptions
/// Exports subscriptions of the active account: the local store in local
/// mode, or the server's list for Invidious/Piped accounts.
private func performExport() async {
let subscriptions: [Subscription]
if currentAccount.type == .local {
guard let dataManager else { return }
subscriptions = dataManager.allSubscriptions
} else {
guard let subscriptionService else { return }
do {
let channels = try await subscriptionService.fetchSubscriptions()
accountSubscriptionCount = channels.count
// Detached model instances used purely as export carriers
subscriptions = channels.map { Subscription.from(channel: $0) }
} catch {
LoggingService.shared.logSubscriptionsError("Failed to fetch account subscriptions for export", error: error)
appEnvironment?.toastManager.showError(
String(localized: "settings.subscriptions.export.error.title"),
subtitle: error.localizedDescription
)
return
}
}
let data: Data?
switch selectedExportFormat {
@@ -339,6 +425,28 @@ struct SubscriptionsSettingsView: View {
#endif
}
/// Refreshes the account-scoped subscription count shown in the export footer.
private func refreshAccountSubscriptionCount() async {
guard let subscriptionService else { return }
if let cached = subscriptionService.cachedSubscriptionCount {
accountSubscriptionCount = cached
}
if subscriptionService.currentAccountType != .local {
if let channels = try? await subscriptionService.fetchSubscriptions() {
accountSubscriptionCount = channels.count
}
}
}
private func deleteLocalSubscriptions() {
dataManager?.deleteAllSubscriptions()
appEnvironment?.toastManager.showSuccess(String(localized: "settings.subscriptions.localData.deleted.title"))
Task {
await refreshAccountSubscriptionCount()
}
}
#if os(macOS)
private func showMacOSSavePanel(data: Data, filename: String) {
let panel = NSSavePanel()