mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
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:
@@ -291,6 +291,45 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes all locally stored subscriptions, including their iCloud copies.
|
||||||
|
/// Server-account subscriptions (Invidious/Piped) are unaffected.
|
||||||
|
func deleteAllSubscriptions() {
|
||||||
|
let allSubscriptions = subscriptions()
|
||||||
|
guard !allSubscriptions.isEmpty else { return }
|
||||||
|
|
||||||
|
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
|
||||||
|
for subscription in allSubscriptions {
|
||||||
|
let scope = SourceScope.from(
|
||||||
|
sourceRawValue: subscription.sourceRawValue,
|
||||||
|
globalProvider: subscription.providerName,
|
||||||
|
instanceURLString: subscription.instanceURLString,
|
||||||
|
externalExtractor: nil
|
||||||
|
)
|
||||||
|
deleteInfo.append((subscription.channelID, scope))
|
||||||
|
modelContext.delete(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
save()
|
||||||
|
|
||||||
|
for info in deleteInfo {
|
||||||
|
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionFeedCache.shared.invalidate()
|
||||||
|
|
||||||
|
let change = SubscriptionChange(
|
||||||
|
addedSubscriptions: [],
|
||||||
|
removedChannelIDs: deleteInfo.map(\.channelID)
|
||||||
|
)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .subscriptionsDidChange,
|
||||||
|
object: nil,
|
||||||
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||||
|
)
|
||||||
|
|
||||||
|
LoggingService.shared.info("Deleted all \(deleteInfo.count) local subscriptions", category: .general)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the total count of subscriptions.
|
/// Returns the total count of subscriptions.
|
||||||
var subscriptionCount: Int {
|
var subscriptionCount: Int {
|
||||||
let descriptor = FetchDescriptor<Subscription>()
|
let descriptor = FetchDescriptor<Subscription>()
|
||||||
|
|||||||
@@ -14788,6 +14788,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.subscriptions.export.error.title" : {
|
||||||
|
"comment" : "Title for export error toast",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Export Failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.subscriptions.export.footer %lld" : {
|
"settings.subscriptions.export.footer %lld" : {
|
||||||
"comment" : "Footer showing subscription count for export",
|
"comment" : "Footer showing subscription count for export",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -14898,6 +14909,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.subscriptions.localData.delete.button" : {
|
||||||
|
"comment" : "Button to delete locally stored subscriptions",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete Local Subscription Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.delete.confirmation.action" : {
|
||||||
|
"comment" : "Confirmation action for deleting local subscriptions",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.delete.confirmation.message" : {
|
||||||
|
"comment" : "Message explaining what deleting local subscriptions does",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Removes subscriptions stored on this device and synced with iCloud. Subscriptions on your Invidious or Piped account are not affected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.delete.confirmation.title %lld" : {
|
||||||
|
"comment" : "Confirmation title for deleting local subscriptions",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete %lld local subscriptions?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.deleted.title" : {
|
||||||
|
"comment" : "Toast shown after local subscriptions were deleted",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Local Subscriptions Deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.footer %lld" : {
|
||||||
|
"comment" : "Footer showing how many subscriptions are stored locally",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%lld subscriptions are stored locally on this device. They are not used while a server account is selected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.subscriptions.localData.title" : {
|
||||||
|
"comment" : "Header for local subscription data section",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Local Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.subscriptions.title" : {
|
"settings.subscriptions.title" : {
|
||||||
"comment" : "Title for subscriptions settings section",
|
"comment" : "Title for subscriptions settings section",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ final class SubscriptionService {
|
|||||||
settingsManager.subscriptionAccount.type
|
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
|
// MARK: - Subscribe
|
||||||
|
|
||||||
/// Subscribes to a channel using the current provider.
|
/// Subscribes to a channel using the current provider.
|
||||||
@@ -129,6 +135,7 @@ final class SubscriptionService {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await provider.subscribe(to: channel)
|
try await provider.subscribe(to: channel)
|
||||||
|
postChangeNotificationForServerAccount()
|
||||||
LoggingService.shared.info(
|
LoggingService.shared.info(
|
||||||
"Subscribed to \(channel.name) via \(provider.accountType)",
|
"Subscribed to \(channel.name) via \(provider.accountType)",
|
||||||
category: .general
|
category: .general
|
||||||
@@ -174,6 +181,7 @@ final class SubscriptionService {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await provider.unsubscribe(from: channelID)
|
try await provider.unsubscribe(from: channelID)
|
||||||
|
postChangeNotificationForServerAccount()
|
||||||
LoggingService.shared.info(
|
LoggingService.shared.info(
|
||||||
"Unsubscribed from \(channelID) via \(provider.accountType)",
|
"Unsubscribed from \(channelID) via \(provider.accountType)",
|
||||||
category: .general
|
category: .general
|
||||||
@@ -262,27 +270,53 @@ final class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Synchronous Helpers (for backwards compatibility)
|
// MARK: - Import
|
||||||
|
|
||||||
/// Synchronously checks if subscribed to a channel.
|
/// Imports parsed external subscriptions (YouTube CSV / OPML) into the active account.
|
||||||
/// Uses cached data from DataManager for instant response.
|
/// Local accounts write to SwiftData; server accounts subscribe on the server,
|
||||||
/// - Parameter channelID: The channel ID to check.
|
/// one channel at a time. Channels that are already subscribed are skipped.
|
||||||
/// - Returns: `true` if subscribed (based on local cache), `false` otherwise.
|
func importSubscriptions(_ channels: [(channelID: String, name: String)]) async -> (imported: Int, skipped: Int) {
|
||||||
func isSubscribedSync(to channelID: String) -> Bool {
|
guard currentAccountType != .local else {
|
||||||
dataManager.isSubscribed(to: channelID)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously subscribes to a channel (local provider only).
|
// MARK: - Private Helpers
|
||||||
/// 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).
|
/// Posts a subscriptions-changed notification for server accounts.
|
||||||
/// For Invidious provider, this will only update local cache.
|
/// Local accounts already post it from DataManager with a change payload.
|
||||||
/// - Parameter channelID: The channel ID to unsubscribe from.
|
private func postChangeNotificationForServerAccount() {
|
||||||
func unsubscribeSync(from channelID: String) {
|
guard currentAccountType != .local else { return }
|
||||||
dataManager.unsubscribe(from: channelID)
|
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
|
|||||||
_ = try await fetchSubscriptions()
|
_ = try await fetchSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cachedSubscriptionCount: Int? {
|
||||||
|
cachePopulated ? cachedChannels.count : nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
/// Gets the authenticated Invidious instance and session ID from account settings.
|
/// Gets the authenticated Invidious instance and session ID from account settings.
|
||||||
|
|||||||
@@ -63,4 +63,8 @@ final class LocalSubscriptionProvider: SubscriptionProvider {
|
|||||||
func refreshCache() async throws {
|
func refreshCache() async throws {
|
||||||
// Local provider doesn't need cache refresh - data is already local
|
// Local provider doesn't need cache refresh - data is already local
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cachedSubscriptionCount: Int? {
|
||||||
|
dataManager.subscriptionCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
|
|||||||
_ = try await fetchSubscriptions()
|
_ = try await fetchSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cachedSubscriptionCount: Int? {
|
||||||
|
cachePopulated ? cachedChannels.count : nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
/// Gets the authenticated Piped instance and auth token from account settings.
|
/// Gets the authenticated Piped instance and auth token from account settings.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ protocol SubscriptionProvider: Sendable {
|
|||||||
/// - Returns: `true` if subscribed, `false` otherwise.
|
/// - Returns: `true` if subscribed, `false` otherwise.
|
||||||
func isSubscribed(to channelID: String) async -> Bool
|
func isSubscribed(to channelID: String) async -> Bool
|
||||||
|
|
||||||
|
/// The subscription count known without a network fetch.
|
||||||
|
/// Returns nil when the provider hasn't populated its cache yet.
|
||||||
|
var cachedSubscriptionCount: Int? { get }
|
||||||
|
|
||||||
/// Refreshes the local cache of subscriptions from the remote source.
|
/// Refreshes the local cache of subscriptions from the remote source.
|
||||||
/// For local provider, this is a no-op.
|
/// For local provider, this is a no-op.
|
||||||
func refreshCache() async throws
|
func refreshCache() async throws
|
||||||
|
|||||||
@@ -2097,9 +2097,13 @@ struct ChannelView: View {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
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)
|
subscription = appEnvironment.dataManager.subscription(for: channelID)
|
||||||
isSubscribed = subscription != nil
|
isSubscribed = subscription != nil
|
||||||
|
Task {
|
||||||
|
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: channelID)
|
||||||
|
}
|
||||||
|
|
||||||
// Load cached header data for immediate display
|
// Load cached header data for immediate display
|
||||||
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
|
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
|
||||||
@@ -2212,6 +2216,9 @@ struct ChannelView: View {
|
|||||||
// Check subscription status using extracted channel ID
|
// Check subscription status using extracted channel ID
|
||||||
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
|
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
|
||||||
isSubscribed = subscription != nil
|
isSubscribed = subscription != nil
|
||||||
|
Task {
|
||||||
|
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: fetchedChannel.id.channelID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch let error as APIError {
|
} catch let error as APIError {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -2247,6 +2254,11 @@ struct ChannelView: View {
|
|||||||
let effectiveChannelID = channel?.id.channelID ?? channelID
|
let effectiveChannelID = channel?.id.channelID ?? channelID
|
||||||
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
|
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
|
||||||
isSubscribed = subscription != nil
|
isSubscribed = subscription != nil
|
||||||
|
Task {
|
||||||
|
if let service = appEnvironment?.subscriptionService {
|
||||||
|
isSubscribed = await service.isSubscribed(to: effectiveChannelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMoreVideos() async {
|
private func loadMoreVideos() async {
|
||||||
|
|||||||
@@ -1473,7 +1473,22 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadChannelsData() {
|
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() {
|
private func loadRemoteDevicesData() {
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ struct SubscriptionsSettingsView: View {
|
|||||||
// Export state
|
// Export state
|
||||||
@State private var selectedExportFormat: SubscriptionExportFormat = .json
|
@State private var selectedExportFormat: SubscriptionExportFormat = .json
|
||||||
@State private var exportFile: ExportFile?
|
@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? {
|
private var dataManager: DataManager? {
|
||||||
appEnvironment?.dataManager
|
appEnvironment?.dataManager
|
||||||
@@ -49,7 +55,17 @@ struct SubscriptionsSettingsView: View {
|
|||||||
appEnvironment?.subscriptionAccountValidator
|
appEnvironment?.subscriptionAccountValidator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var subscriptionService: SubscriptionService? {
|
||||||
|
appEnvironment?.subscriptionService
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscription count of the active account (local store or server).
|
||||||
private var subscriptionCount: Int {
|
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
|
dataManager?.subscriptionCount ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +79,12 @@ struct SubscriptionsSettingsView: View {
|
|||||||
if validator?.hasAvailableAccounts == true {
|
if validator?.hasAvailableAccounts == true {
|
||||||
importSection
|
importSection
|
||||||
exportSection
|
exportSection
|
||||||
|
localDataSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: currentAccount) {
|
||||||
|
await refreshAccountSubscriptionCount()
|
||||||
|
}
|
||||||
.navigationTitle(String(localized: "settings.subscriptions.title"))
|
.navigationTitle(String(localized: "settings.subscriptions.title"))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -111,6 +131,17 @@ struct SubscriptionsSettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "settings.subscriptions.account.switch.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)
|
.presentationCompactAdaptation(.sheet)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,9 +256,15 @@ struct SubscriptionsSettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
exportSubscriptions()
|
exportSubscriptions()
|
||||||
} label: {
|
} 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: {
|
} header: {
|
||||||
Text(String(localized: "settings.subscriptions.export.title"))
|
Text(String(localized: "settings.subscriptions.export.title"))
|
||||||
} footer: {
|
} 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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func showImportPicker() {
|
private func showImportPicker() {
|
||||||
@@ -281,12 +339,12 @@ struct SubscriptionsSettingsView: View {
|
|||||||
// Parse subscriptions
|
// Parse subscriptions
|
||||||
let parseResult = try SubscriptionImportExport.parseAuto(data)
|
let parseResult = try SubscriptionImportExport.parseAuto(data)
|
||||||
|
|
||||||
// Import to database
|
// Import into the active account (local store or server)
|
||||||
guard let dataManager else {
|
guard let subscriptionService else {
|
||||||
throw SubscriptionImportError.invalidData
|
throw SubscriptionImportError.invalidData
|
||||||
}
|
}
|
||||||
|
|
||||||
let importStats = dataManager.importSubscriptionsFromExternal(parseResult.channels)
|
let importStats = await subscriptionService.importSubscriptions(parseResult.channels)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isImporting = false
|
isImporting = false
|
||||||
@@ -294,6 +352,7 @@ struct SubscriptionsSettingsView: View {
|
|||||||
showingImportResult = true
|
showingImportResult = true
|
||||||
LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped")
|
LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped")
|
||||||
}
|
}
|
||||||
|
await refreshAccountSubscriptionCount()
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isImporting = false
|
isImporting = false
|
||||||
@@ -306,9 +365,36 @@ struct SubscriptionsSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func exportSubscriptions() {
|
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?
|
let data: Data?
|
||||||
switch selectedExportFormat {
|
switch selectedExportFormat {
|
||||||
@@ -339,6 +425,28 @@ struct SubscriptionsSettingsView: View {
|
|||||||
#endif
|
#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)
|
#if os(macOS)
|
||||||
private func showMacOSSavePanel(data: Data, filename: String) {
|
private func showMacOSSavePanel(data: Data, filename: String) {
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
|
|||||||
Reference in New Issue
Block a user