mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
342 lines
12 KiB
Swift
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
|
|
}
|
|
}
|