Files
yattee/Yattee/Data/DataManager+Subscriptions.swift
Arkadiusz Fal 4d8ba7e028 Invalidate the subscription feed when subscriptions change (#968)
Subscribing or unsubscribing, locally or via iCloud, only posted a
notification, so the cached feed stayed valid and Yattee Server never
received the new channel list until the cache expired. Invalidate the
feed cache on every subscription add and removal, and skip the disk
write when it is already stale.
2026-10-19 22:54:55 +02:00

342 lines
12 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)
// The stateless feed must be rebuilt from the new channel list (#968)
SubscriptionFeedCache.shared.invalidate()
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)
}
SubscriptionFeedCache.shared.invalidate()
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)
}
}
/// 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
}
/// 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
}
}