mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
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
462 lines
16 KiB
Swift
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
|
|
}
|
|
}
|