Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,139 @@
//
// InvidiousSubscriptionProvider.swift
// Yattee
//
// Invidious subscription provider that manages subscriptions on an Invidious instance.
// Uses in-memory cache only - does NOT persist to SwiftData to avoid corrupting local subscriptions.
//
import Foundation
/// Invidious subscription provider that syncs with an Invidious instance account.
/// Subscriptions are managed on the Invidious server and kept in memory only.
/// This provider does NOT write to SwiftData to preserve local subscriptions.
@MainActor
final class InvidiousSubscriptionProvider: SubscriptionProvider {
// MARK: - Properties
let accountType: SubscriptionAccountType = .invidious
private let invidiousAPI: InvidiousAPI
private let credentialsManager: InvidiousCredentialsManager
private let instancesManager: InstancesManager
private let settingsManager: SettingsManager
/// In-memory cache of subscribed channel IDs for fast lookup.
private var cachedChannelIDs: Set<String> = []
/// In-memory cache of full channel data for display.
private var cachedChannels: [Channel] = []
/// Whether the cache has been populated from the server.
private var cachePopulated = false
// MARK: - Initialization
init(
invidiousAPI: InvidiousAPI,
credentialsManager: InvidiousCredentialsManager,
instancesManager: InstancesManager,
settingsManager: SettingsManager
) {
self.invidiousAPI = invidiousAPI
self.credentialsManager = credentialsManager
self.instancesManager = instancesManager
self.settingsManager = settingsManager
}
// MARK: - SubscriptionProvider
func fetchSubscriptions() async throws -> [Channel] {
let (instance, sid) = try await getAuthenticatedInstance()
// Fetch subscriptions from Invidious
let subscriptions = try await invidiousAPI.subscriptions(instance: instance, sid: sid)
// Update in-memory cache
cachedChannelIDs = Set(subscriptions.map(\.authorId))
cachedChannels = subscriptions.map { $0.toChannel(baseURL: instance.url) }
cachePopulated = true
LoggingService.shared.info("Fetched \(subscriptions.count) Invidious subscriptions", category: .api)
return cachedChannels
}
func subscribe(to channel: Channel) async throws {
let (instance, sid) = try await getAuthenticatedInstance()
// Subscribe on Invidious server
try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid)
// Update in-memory cache only
cachedChannelIDs.insert(channel.id.channelID)
if !cachedChannels.contains(where: { $0.id.channelID == channel.id.channelID }) {
cachedChannels.append(channel)
}
LoggingService.shared.info("Subscribed to \(channel.name) on Invidious", category: .api)
}
func unsubscribe(from channelID: String) async throws {
let (instance, sid) = try await getAuthenticatedInstance()
// Unsubscribe on Invidious server
try await invidiousAPI.unsubscribe(from: channelID, instance: instance, sid: sid)
// Update in-memory cache only
cachedChannelIDs.remove(channelID)
cachedChannels.removeAll { $0.id.channelID == channelID }
LoggingService.shared.info("Unsubscribed from \(channelID) on Invidious", category: .api)
}
func isSubscribed(to channelID: String) async -> Bool {
// If cache isn't populated yet, try to refresh it
if !cachePopulated {
do {
_ = try await fetchSubscriptions()
} catch {
// Can't determine subscription status without server connection
return false
}
}
return cachedChannelIDs.contains(channelID)
}
func refreshCache() async throws {
// Simply fetch subscriptions again - this updates in-memory cache
_ = try await fetchSubscriptions()
}
// MARK: - Private Helpers
/// Gets the authenticated Invidious instance and session ID from account settings.
private func getAuthenticatedInstance() async throws -> (Instance, String) {
// Get the instance ID from subscription account settings
let account = settingsManager.subscriptionAccount
let instance: Instance?
if let instanceID = account.instanceID {
// Use the specific instance from account settings
instance = instancesManager.instances.first { $0.id == instanceID && $0.isEnabled }
} else {
// Fallback to first enabled Invidious instance
instance = instancesManager.instances.first { $0.type == .invidious && $0.isEnabled }
}
guard let instance else {
throw SubscriptionProviderError.instanceNotConfigured
}
// Get session ID
guard let sid = credentialsManager.sid(for: instance) else {
throw SubscriptionProviderError.notAuthenticated
}
return (instance, sid)
}
}

View File

@@ -0,0 +1,66 @@
//
// LocalSubscriptionProvider.swift
// Yattee
//
// Local subscription provider using SwiftData with iCloud sync.
// This is the default provider when using "Yattee (iCloud)" subscription account.
//
import Foundation
/// Local subscription provider that stores subscriptions in SwiftData.
/// Syncs with iCloud via CloudKitSyncEngine.
@MainActor
final class LocalSubscriptionProvider: SubscriptionProvider {
// MARK: - Properties
let accountType: SubscriptionAccountType = .local
private let dataManager: DataManager
// MARK: - Initialization
init(dataManager: DataManager) {
self.dataManager = dataManager
}
// MARK: - SubscriptionProvider
func fetchSubscriptions() async throws -> [Channel] {
// Convert Subscription models to Channel models
dataManager.subscriptions().map { subscription in
Channel(
id: ChannelID(source: subscription.contentSource, channelID: subscription.channelID),
name: subscription.name,
description: subscription.channelDescription,
subscriberCount: subscription.subscriberCount,
thumbnailURL: subscription.avatarURL,
bannerURL: subscription.bannerURL,
isVerified: subscription.isVerified
)
}
}
func subscribe(to channel: Channel) async throws {
// Check if already subscribed
if dataManager.isSubscribed(to: channel.id.channelID) {
throw SubscriptionProviderError.alreadySubscribed
}
dataManager.subscribe(to: channel)
}
func unsubscribe(from channelID: String) async throws {
// Check if subscribed
guard dataManager.isSubscribed(to: channelID) else {
throw SubscriptionProviderError.notSubscribed
}
dataManager.unsubscribe(from: channelID)
}
func isSubscribed(to channelID: String) async -> Bool {
dataManager.isSubscribed(to: channelID)
}
func refreshCache() async throws {
// Local provider doesn't need cache refresh - data is already local
}
}

View File

@@ -0,0 +1,139 @@
//
// PipedSubscriptionProvider.swift
// Yattee
//
// Piped subscription provider that manages subscriptions on a Piped instance.
// Uses in-memory cache only - does NOT persist to SwiftData to avoid corrupting local subscriptions.
//
import Foundation
/// Piped subscription provider that syncs with a Piped instance account.
/// Subscriptions are managed on the Piped server and kept in memory only.
/// This provider does NOT write to SwiftData to preserve local subscriptions.
@MainActor
final class PipedSubscriptionProvider: SubscriptionProvider {
// MARK: - Properties
let accountType: SubscriptionAccountType = .piped
private let pipedAPI: PipedAPI
private let credentialsManager: PipedCredentialsManager
private let instancesManager: InstancesManager
private let settingsManager: SettingsManager
/// In-memory cache of subscribed channel IDs for fast lookup.
private var cachedChannelIDs: Set<String> = []
/// In-memory cache of full channel data for display.
private var cachedChannels: [Channel] = []
/// Whether the cache has been populated from the server.
private var cachePopulated = false
// MARK: - Initialization
init(
pipedAPI: PipedAPI,
credentialsManager: PipedCredentialsManager,
instancesManager: InstancesManager,
settingsManager: SettingsManager
) {
self.pipedAPI = pipedAPI
self.credentialsManager = credentialsManager
self.instancesManager = instancesManager
self.settingsManager = settingsManager
}
// MARK: - SubscriptionProvider
func fetchSubscriptions() async throws -> [Channel] {
let (instance, authToken) = try await getAuthenticatedInstance()
// Fetch subscriptions from Piped
let subscriptions = try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
// Update in-memory cache
cachedChannelIDs = Set(subscriptions.map(\.channelId))
cachedChannels = subscriptions.map { $0.toChannel() }
cachePopulated = true
LoggingService.shared.info("Fetched \(subscriptions.count) Piped subscriptions", category: .api)
return cachedChannels
}
func subscribe(to channel: Channel) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// Subscribe on Piped server
try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken)
// Update in-memory cache only
cachedChannelIDs.insert(channel.id.channelID)
if !cachedChannels.contains(where: { $0.id.channelID == channel.id.channelID }) {
cachedChannels.append(channel)
}
LoggingService.shared.info("Subscribed to \(channel.name) on Piped", category: .api)
}
func unsubscribe(from channelID: String) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// Unsubscribe on Piped server
try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken)
// Update in-memory cache only
cachedChannelIDs.remove(channelID)
cachedChannels.removeAll { $0.id.channelID == channelID }
LoggingService.shared.info("Unsubscribed from \(channelID) on Piped", category: .api)
}
func isSubscribed(to channelID: String) async -> Bool {
// If cache isn't populated yet, try to refresh it
if !cachePopulated {
do {
_ = try await fetchSubscriptions()
} catch {
// Can't determine subscription status without server connection
return false
}
}
return cachedChannelIDs.contains(channelID)
}
func refreshCache() async throws {
// Simply fetch subscriptions again - this updates in-memory cache
_ = try await fetchSubscriptions()
}
// MARK: - Private Helpers
/// Gets the authenticated Piped instance and auth token from account settings.
private func getAuthenticatedInstance() async throws -> (Instance, String) {
// Get the instance ID from subscription account settings
let account = settingsManager.subscriptionAccount
let instance: Instance?
if let instanceID = account.instanceID {
// Use the specific instance from account settings
instance = instancesManager.instances.first { $0.id == instanceID && $0.isEnabled }
} else {
// Fallback to first enabled Piped instance
instance = instancesManager.instances.first { $0.type == .piped && $0.isEnabled }
}
guard let instance else {
throw SubscriptionProviderError.instanceNotConfigured
}
// Get auth token
guard let authToken = credentialsManager.credential(for: instance) else {
throw SubscriptionProviderError.notAuthenticated
}
return (instance, authToken)
}
}

View File

@@ -0,0 +1,226 @@
//
// SubscriptionAccountValidator.swift
// Yattee
//
// Validates subscription account selection and handles auto-correction
// when the selected account becomes unavailable.
//
import Foundation
/// Validates subscription account selection and provides available accounts for the picker.
/// Automatically corrects to a valid account if the current selection becomes invalid.
@MainActor
@Observable
final class SubscriptionAccountValidator {
// MARK: - Dependencies
private let settingsManager: SettingsManager
private let instancesManager: InstancesManager
private let invidiousCredentialsManager: InvidiousCredentialsManager
private let pipedCredentialsManager: PipedCredentialsManager
private let toastManager: ToastManager
private let feedCache: SubscriptionFeedCache
// MARK: - Observation State
private var observationTask: Task<Void, Never>?
// MARK: - Initialization
init(
settingsManager: SettingsManager,
instancesManager: InstancesManager,
invidiousCredentialsManager: InvidiousCredentialsManager,
pipedCredentialsManager: PipedCredentialsManager,
toastManager: ToastManager,
feedCache: SubscriptionFeedCache
) {
self.settingsManager = settingsManager
self.instancesManager = instancesManager
self.invidiousCredentialsManager = invidiousCredentialsManager
self.pipedCredentialsManager = pipedCredentialsManager
self.toastManager = toastManager
self.feedCache = feedCache
startObserving()
}
/// Stops observation. Call before releasing the validator.
func stopObserving() {
observationTask?.cancel()
observationTask = nil
}
// MARK: - Available Accounts
/// Available subscription accounts based on current configuration.
/// Only includes accounts that are properly configured and authenticated.
var availableAccounts: [SubscriptionAccount] {
var accounts: [SubscriptionAccount] = []
// Add local if Yattee Server is configured and enabled
let hasYatteeServer = instancesManager.instances.contains {
$0.type == .yatteeServer && $0.isEnabled
}
if hasYatteeServer {
accounts.append(.local)
}
// Add logged-in and enabled Invidious instances
for instance in instancesManager.instances {
if instance.type == .invidious &&
instance.isEnabled &&
invidiousCredentialsManager.isLoggedIn(for: instance) {
accounts.append(.invidious(instance.id))
}
}
// Add logged-in and enabled Piped instances
for instance in instancesManager.instances {
if instance.type == .piped &&
instance.isEnabled &&
pipedCredentialsManager.isLoggedIn(for: instance) {
accounts.append(.piped(instance.id))
}
}
return accounts
}
/// Whether any subscription accounts are available.
var hasAvailableAccounts: Bool {
!availableAccounts.isEmpty
}
// MARK: - Validation
/// Whether the current subscription account is valid.
var isCurrentAccountValid: Bool {
let current = settingsManager.subscriptionAccount
return availableAccounts.contains(current)
}
/// Validates the current account and auto-corrects if invalid.
/// Call this on app launch and when configuration changes.
func validateAndCorrectIfNeeded() {
guard !isCurrentAccountValid else {
LoggingService.shared.debug(
"Current subscription account is valid: \(settingsManager.subscriptionAccount.type)",
category: .general
)
return
}
let previousAccount = settingsManager.subscriptionAccount
// Find first available account to switch to
if let newAccount = availableAccounts.first {
settingsManager.subscriptionAccount = newAccount
feedCache.handleAccountChange()
let newName = displayName(for: newAccount)
LoggingService.shared.info(
"Auto-corrected subscription account from \(previousAccount.type) to \(newAccount.type)",
category: .general
)
toastManager.showInfo(
String(localized: "subscriptions.accountSwitched.title"),
subtitle: newName
)
} else {
// No valid accounts available - set to local anyway
// The feed will show an appropriate error state
settingsManager.subscriptionAccount = .local
feedCache.handleAccountChange()
LoggingService.shared.warning(
"No valid subscription accounts available, defaulting to local",
category: .general
)
}
}
// MARK: - Display Names
/// Returns a display name for the given subscription account.
func displayName(for account: SubscriptionAccount) -> String {
switch account.type {
case .local:
return String(localized: "subscriptions.account.local")
case .invidious:
guard let instanceID = account.instanceID,
let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else {
return String(localized: "subscriptions.account.invidious")
}
// Use instance name if available, otherwise use hostname
let instanceName: String
if let name = instance.name, !name.isEmpty {
instanceName = name
} else {
instanceName = instance.url.host ?? "Invidious"
}
return String(localized: "subscriptions.account.invidiousInstance \(instanceName)")
case .piped:
guard let instanceID = account.instanceID,
let instance = instancesManager.instances.first(where: { $0.id == instanceID }) else {
return String(localized: "subscriptions.account.piped")
}
// Use instance name if available, otherwise use hostname
let instanceName: String
if let name = instance.name, !name.isEmpty {
instanceName = name
} else {
instanceName = instance.url.host ?? "Piped"
}
return String(localized: "subscriptions.account.pipedInstance \(instanceName)")
}
}
/// Returns the instance for an Invidious or Piped account, if available.
func instance(for account: SubscriptionAccount) -> Instance? {
guard account.type == .invidious || account.type == .piped,
let instanceID = account.instanceID else {
return nil
}
return instancesManager.instances.first { $0.id == instanceID }
}
// MARK: - Observation
/// Starts observing changes to instances and credentials.
private func startObserving() {
observationTask?.cancel()
observationTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
// Wait for changes using withObservationTracking
await withCheckedContinuation { continuation in
withObservationTracking {
// Access the observed properties to register for tracking
_ = self.instancesManager.instances
_ = self.invidiousCredentialsManager.loggedInInstanceIDs
_ = self.pipedCredentialsManager.loggedInInstanceIDs
} onChange: {
// Resume when a change is detected
continuation.resume()
}
}
guard !Task.isCancelled else { break }
// Small delay to batch rapid changes
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { break }
// Validate after changes
self.validateAndCorrectIfNeeded()
}
}
}
}

View File

@@ -0,0 +1,79 @@
//
// SubscriptionProvider.swift
// Yattee
//
// Protocol defining subscription management operations.
// Implementations handle different subscription sources (local, Invidious, Piped).
//
import Foundation
/// Protocol for subscription management providers.
/// Each provider implementation handles a specific subscription source.
@MainActor
protocol SubscriptionProvider: Sendable {
/// The type of subscription account this provider handles.
var accountType: SubscriptionAccountType { get }
/// Fetches all subscriptions from the provider.
/// - Returns: Array of channels the user is subscribed to.
func fetchSubscriptions() async throws -> [Channel]
/// Subscribes to a channel.
/// - Parameter channel: The channel to subscribe to.
func subscribe(to channel: Channel) async throws
/// Unsubscribes from a channel.
/// - Parameter channelID: The channel ID to unsubscribe from.
func unsubscribe(from channelID: String) async throws
/// Checks if subscribed to a channel.
/// - Parameter channelID: The channel ID to check.
/// - Returns: `true` if subscribed, `false` otherwise.
func isSubscribed(to channelID: String) async -> Bool
/// Refreshes the local cache of subscriptions from the remote source.
/// For local provider, this is a no-op.
func refreshCache() async throws
}
// MARK: - Default Implementations
extension SubscriptionProvider {
/// Default implementation for providers that don't need cache refresh.
func refreshCache() async throws {
// No-op by default
}
}
// MARK: - Subscription Provider Error
/// Errors that can occur during subscription provider operations.
enum SubscriptionProviderError: Error, LocalizedError, Equatable, Sendable {
case notAuthenticated
case networkError(String)
case channelNotFound
case alreadySubscribed
case notSubscribed
case instanceNotConfigured
case operationFailed(String)
var errorDescription: String? {
switch self {
case .notAuthenticated:
return String(localized: "subscription.error.notAuthenticated")
case .networkError(let message):
return String(localized: "subscription.error.network \(message)")
case .channelNotFound:
return String(localized: "subscription.error.channelNotFound")
case .alreadySubscribed:
return String(localized: "subscription.error.alreadySubscribed")
case .notSubscribed:
return String(localized: "subscription.error.notSubscribed")
case .instanceNotConfigured:
return String(localized: "subscription.error.instanceNotConfigured")
case .operationFailed(let message):
return String(localized: "subscription.error.operationFailed \(message)")
}
}
}