mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
397 lines
13 KiB
Swift
397 lines
13 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.
|
|
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
|
|
let channelID = subscription.channelID
|
|
let descriptor = FetchDescriptor<Subscription>(
|
|
predicate: #Predicate { $0.channelID == channelID }
|
|
)
|
|
|
|
do {
|
|
let existing = try modelContext.fetch(descriptor)
|
|
if existing.isEmpty {
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|