Files
yattee/Yattee/Data/DataManager+Subscriptions.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

462 lines
16 KiB
Swift

//
// DataManager+Subscriptions.swift
// Yattee
//
// Subscription operations for DataManager.
//
import Foundation
import SwiftData
extension DataManager {
// MARK: - Subscriptions
/// Subscribes to a channel.
/// - Parameter channel: The channel to subscribe to.
func subscribe(to channel: Channel) {
let channelID = channel.id.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
return
}
let subscription = Subscription.from(channel: channel)
modelContext.insert(subscription)
save()
// Queue for CloudKit sync
cloudKitSync?.queueSubscriptionSave(subscription)
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
}
}
/// Subscribes to a channel from an Author.
/// - Parameters:
/// - author: The author/channel to subscribe to.
/// - source: The content source (YouTube or PeerTube).
func subscribe(to author: Author, source: ContentSource) {
let channelID = author.id
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
return
}
let sourceRaw: String
var instanceURL: String?
switch source {
case .global:
sourceRaw = "global"
case .federated(_, let instance):
sourceRaw = "federated"
instanceURL = instance.absoluteString
case .extracted:
// Extracted sources don't support subscriptions
return
}
let subscription = Subscription(
channelID: channelID,
sourceRawValue: sourceRaw,
instanceURLString: instanceURL,
name: author.name,
subscriberCount: author.subscriberCount,
avatarURLString: author.thumbnailURL?.absoluteString
)
modelContext.insert(subscription)
save()
// Queue for CloudKit sync
cloudKitSync?.queueSubscriptionSave(subscription)
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
}
}
/// Unsubscribes from a channel.
func unsubscribe(from channelID: String) {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let subscriptions = try modelContext.fetch(descriptor)
guard !subscriptions.isEmpty else { return }
// Capture scopes before deleting
let scopes = subscriptions.map {
SourceScope.from(
sourceRawValue: $0.sourceRawValue,
globalProvider: $0.providerName,
instanceURLString: $0.instanceURLString,
externalExtractor: nil
)
}
subscriptions.forEach { modelContext.delete($0) }
save()
// Queue scoped CloudKit deletions
for scope in scopes {
cloudKitSync?.queueSubscriptionDelete(channelID: channelID, scope: scope)
}
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
} catch {
LoggingService.shared.logCloudKitError("Failed to unsubscribe", error: error)
}
}
/// Bulk adds subscriptions from channel data (for testing).
func bulkAddSubscriptions(_ channels: [(id: String, name: String)]) {
var addedCount = 0
for channel in channels {
let channelID = channel.id
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let existing = try modelContext.fetch(descriptor)
guard existing.isEmpty else {
continue
}
let subscription = Subscription(
channelID: channel.id,
sourceRawValue: "youtube",
instanceURLString: nil,
name: channel.name
)
modelContext.insert(subscription)
addedCount += 1
} catch {
continue
}
}
if addedCount > 0 {
save()
SubscriptionFeedCache.shared.invalidate()
LoggingService.shared.info("Bulk added \(addedCount) subscriptions", category: .general)
}
}
/// Checks if subscribed to a channel.
func isSubscribed(to channelID: String) -> Bool {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let count = try modelContext.fetchCount(descriptor)
return count > 0
} catch {
return false
}
}
/// Gets a subscription by channel ID.
func subscription(for channelID: String) -> Subscription? {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
return try modelContext.fetch(descriptor).first
} catch {
return nil
}
}
/// Gets all subscriptions matching a channel ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func subscriptions(forChannelID channelID: String) -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Gets all subscriptions.
func subscriptions() -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
sortBy: [SortDescriptor(\.name)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
LoggingService.shared.logCloudKitError("Failed to fetch subscriptions", error: error)
return []
}
}
/// Inserts a subscription into the database.
/// Used by SubscriptionService for caching server subscriptions locally.
func insertSubscription(_ subscription: Subscription) {
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
let channelID = subscription.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
let scopeSuffix = subscription.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(subscription)
save()
}
} catch {
// Insert anyway if we can't check
modelContext.insert(subscription)
save()
}
}
/// Deletes a subscription from the database.
/// Used by SubscriptionService for removing stale cached subscriptions.
func deleteSubscription(_ subscription: Subscription) {
modelContext.delete(subscription)
// Note: caller is responsible for calling save() after batch operations
}
/// Removes subscriptions matching the given channel IDs.
func removeSubscriptions(matching channelIDs: Set<String>) {
let allSubscriptions = subscriptions()
var removedCount = 0
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
for subscription in allSubscriptions {
if channelIDs.contains(subscription.channelID) {
// Capture scope before deleting
let scope = SourceScope.from(
sourceRawValue: subscription.sourceRawValue,
globalProvider: subscription.providerName,
instanceURLString: subscription.instanceURLString,
externalExtractor: nil
)
deleteInfo.append((subscription.channelID, scope))
modelContext.delete(subscription)
removedCount += 1
}
}
if removedCount > 0 {
save()
// Queue CloudKit deletions
for info in deleteInfo {
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
}
SubscriptionFeedCache.shared.invalidate()
LoggingService.shared.info("Removed \(removedCount) test subscriptions", category: .general)
}
}
/// 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.
var subscriptionCount: Int {
let descriptor = FetchDescriptor<Subscription>()
do {
return try modelContext.fetchCount(descriptor)
} catch {
return 0
}
}
/// Returns all subscriptions.
var allSubscriptions: [Subscription] {
subscriptions()
}
/// Updates lastVideoPublishedAt for subscriptions based on feed videos.
func updateLastVideoPublishedDates(from videos: [Video]) {
var latestByChannel: [String: Date] = [:]
for video in videos {
guard let publishedAt = video.publishedAt else { continue }
let channelID = video.author.id
if let existing = latestByChannel[channelID] {
if publishedAt > existing { latestByChannel[channelID] = publishedAt }
} else {
latestByChannel[channelID] = publishedAt
}
}
guard !latestByChannel.isEmpty else { return }
let allSubscriptions = subscriptions()
var updated = false
for subscription in allSubscriptions {
if let latestDate = latestByChannel[subscription.channelID],
subscription.lastVideoPublishedAt == nil || latestDate > subscription.lastVideoPublishedAt! {
subscription.lastVideoPublishedAt = latestDate
updated = true
}
}
if updated {
save()
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
}
}
/// Imports subscriptions from external sources (YouTube CSV, OPML).
/// Skips existing subscriptions and returns import statistics.
/// - Parameter channels: Array of tuples containing channel ID and name
/// - Returns: Tuple with count of imported and skipped subscriptions
func importSubscriptionsFromExternal(_ channels: [(channelID: String, name: String)]) -> (imported: Int, skipped: Int) {
var imported = 0
var skipped = 0
var addedSubscriptions: [Subscription] = []
for channel in channels {
// Skip if already subscribed
if isSubscribed(to: channel.channelID) {
skipped += 1
continue
}
// Create new subscription
let subscription = Subscription(
channelID: channel.channelID,
sourceRawValue: "global",
instanceURLString: nil,
name: channel.name
)
subscription.providerName = ContentSource.youtubeProvider
modelContext.insert(subscription)
addedSubscriptions.append(subscription)
imported += 1
}
if imported > 0 {
save()
SubscriptionFeedCache.shared.invalidate()
// Queue imported subscriptions for CloudKit sync
for subscription in addedSubscriptions {
cloudKitSync?.queueSubscriptionSave(subscription)
}
// Post notification for UI updates
let change = SubscriptionChange(addedSubscriptions: addedSubscriptions, removedChannelIDs: [])
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
LoggingService.shared.info("Imported \(imported) subscriptions from external source", category: .general)
}
return (imported, skipped)
}
/// Updates subscription metadata from fresh channel data.
func updateSubscription(for channelID: String, with channel: Channel) {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
do {
let subscriptions = try modelContext.fetch(descriptor)
if let subscription = subscriptions.first {
subscription.update(from: channel)
save()
}
} catch {
LoggingService.shared.logCloudKitError("Failed to update subscription", error: error)
}
}
}
// MARK: - Source Scope
private extension Subscription {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: providerName,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}