Files
yattee/Yattee/Core/InstancesManager.swift
2026-02-08 18:33:56 +01:00

389 lines
13 KiB
Swift

//
// InstancesManager.swift
// Yattee
//
// Manages configured backend instances with iCloud sync.
//
import Foundation
import SwiftUI
/// Status of an instance's connectivity and authentication.
enum InstanceStatus: Equatable {
/// Instance is online and working.
case online
/// Instance is offline or unreachable.
case offline
/// Instance requires authentication but credentials are not provided.
case authRequired
/// Instance authentication failed (wrong credentials).
case authFailed
}
/// Manages the list of configured backend instances with iCloud sync.
@MainActor
@Observable
final class InstancesManager {
// MARK: - Storage
private let localDefaults = UserDefaults.standard
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
private let instancesKey = "configuredInstances"
private let activeInstanceKey = "activeInstanceID"
// MARK: - Dependencies
private weak var settingsManager: SettingsManager?
// MARK: - State
private(set) var instances: [Instance] = []
private(set) var activeInstanceID: UUID?
/// Current status of each instance, keyed by instance ID.
private(set) var instanceStatuses: [UUID: InstanceStatus] = [:]
// MARK: - Initialization
init(settingsManager: SettingsManager? = nil) {
self.settingsManager = settingsManager
loadInstances()
loadActiveInstance()
// Listen for external changes from iCloud
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousStore,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleiCloudChange()
}
}
}
/// Sets the settings manager reference for checking iCloud sync status.
func setSettingsManager(_ manager: SettingsManager) {
self.settingsManager = manager
}
/// Whether iCloud sync is currently enabled.
private var iCloudSyncEnabled: Bool {
settingsManager?.iCloudSyncEnabled ?? false
}
/// Whether instance sync is enabled (requires both master toggle and category toggle).
private var instanceSyncEnabled: Bool {
iCloudSyncEnabled && (settingsManager?.syncInstances ?? true)
}
/// Handles external iCloud changes by replacing local data with iCloud data.
private func handleiCloudChange() {
// Only process iCloud changes if instance sync is enabled
guard instanceSyncEnabled else { return }
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
return
}
// Replace local instances with iCloud data
instances = iCloudInstances
// Save to local defaults for offline access
localDefaults.set(iCloudData, forKey: instancesKey)
// Update sync time
settingsManager?.updateLastSyncTime()
}
/// Syncs local data to iCloud (called when enabling iCloud sync).
/// Only syncs if instance sync is enabled.
func syncToiCloud() {
guard instanceSyncEnabled else { return }
guard let data = try? JSONEncoder().encode(instances) else { return }
ubiquitousStore.set(data, forKey: instancesKey)
ubiquitousStore.synchronize()
settingsManager?.updateLastSyncTime()
}
/// Replaces local data with iCloud data (called when enabling iCloud sync).
/// Only replaces if instance sync is enabled.
func replaceWithiCloudData() {
guard instanceSyncEnabled else { return }
ubiquitousStore.synchronize()
guard let iCloudData = ubiquitousStore.data(forKey: instancesKey),
let iCloudInstances = try? JSONDecoder().decode([Instance].self, from: iCloudData) else {
// No iCloud data exists, sync local data to iCloud
syncToiCloud()
return
}
// Replace local with iCloud data
instances = iCloudInstances
localDefaults.set(iCloudData, forKey: instancesKey)
settingsManager?.updateLastSyncTime()
}
// MARK: - Public Methods
func add(_ instance: Instance) {
instances.append(instance)
saveInstances()
}
func remove(_ instance: Instance) {
instances.removeAll { $0.id == instance.id }
// Clear active instance if it was the removed one
if activeInstanceID == instance.id {
activeInstanceID = nil
localDefaults.removeObject(forKey: activeInstanceKey)
}
saveInstances()
}
func update(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
instances[index] = instance
saveInstances()
}
}
/// Alias for add method to maintain consistency.
func addInstance(_ instance: Instance) {
add(instance)
}
/// Toggles the enabled state of an instance.
func toggleEnabled(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
var updated = instances[index]
updated.isEnabled.toggle()
instances[index] = updated
saveInstances()
}
}
/// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)
LoggingService.shared.debug("[InstancesManager] Current instances: \(instances.map { $0.displayName })", category: .general)
guard let index = instances.firstIndex(where: { $0.id == instance.id }) else {
LoggingService.shared.debug("[InstancesManager] Instance not found in list", category: .general)
return
}
if index == 0 {
LoggingService.shared.debug("[InstancesManager] Instance already at index 0, skipping", category: .general)
return
}
LoggingService.shared.debug("[InstancesManager] Moving instance from index \(index) to 0", category: .general)
// Move to front
let removed = instances.remove(at: index)
instances.insert(removed, at: 0)
saveInstances()
LoggingService.shared.debug("[InstancesManager] After move: \(instances.map { $0.displayName })", category: .general)
}
// MARK: - Computed Properties
var enabledInstances: [Instance] {
instances.filter(\.isEnabled)
}
var youtubeInstances: [Instance] {
instances.filter(\.isYouTubeInstance)
}
var peertubeInstances: [Instance] {
instances.filter(\.isPeerTubeInstance)
}
var yatteeServerInstances: [Instance] {
instances.filter(\.isYatteeServerInstance)
}
var hasYouTubeInstances: Bool {
instances.contains { $0.isYouTubeInstance }
}
var hasPeerTubeInstances: Bool {
instances.contains { $0.isPeerTubeInstance }
}
var hasYatteeServerInstances: Bool {
instances.contains { $0.isYatteeServerInstance }
}
var invidiousPipedInstances: [Instance] {
instances.filter { $0.type == .invidious || $0.type == .piped }
}
var hasInvidiousPipedInstances: Bool {
instances.contains { $0.type == .invidious || $0.type == .piped }
}
var enabledYatteeServerInstances: [Instance] {
yatteeServerInstances.filter(\.isEnabled)
}
/// Selects an enabled instance appropriate for the given video's content source.
/// - For PeerTube videos: prefers the exact instance, falls back to any PeerTube instance
/// - For YouTube/extracted content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
func instance(for video: Video) -> Instance? {
instance(for: video.id.source)
}
/// Selects an enabled instance appropriate for the given content source.
/// - For PeerTube content: prefers the exact instance, falls back to any PeerTube instance
/// - For extracted content: requires Yattee Server (only backend with yt-dlp)
/// - For YouTube content: uses YouTube-capable instance (Invidious, Piped, Yattee Server)
func instance(for contentSource: ContentSource) -> Instance? {
switch contentSource {
case .federated(let provider, let instanceURL) where provider == ContentSource.peertubeProvider:
// PeerTube content - prefer the exact instance, fall back to any PeerTube instance
return enabledInstances.first { $0.url.host == instanceURL.host }
?? enabledInstances.first(where: \.isPeerTubeInstance)
case .extracted:
// Extracted content requires Yattee Server (yt-dlp)
return yatteeServerInstances.first
case .global, .federated:
// YouTube content - prefer Yattee Server, fall back to other YouTube-capable instances
return enabledYatteeServerInstances.first
?? enabledInstances.first(where: \.isYouTubeInstance)
}
}
/// Disables all Yattee Server instances except the specified one.
func disableOtherYatteeServerInstances(except instanceID: UUID) {
for instance in enabledYatteeServerInstances where instance.id != instanceID {
var updated = instance
updated.isEnabled = false
update(updated)
}
}
// MARK: - Instance Status
/// Returns the current status of an instance.
func status(for instance: Instance) -> InstanceStatus {
instanceStatuses[instance.id] ?? .online
}
/// Updates the status of an instance.
func updateStatus(_ status: InstanceStatus, for instance: Instance) {
instanceStatuses[instance.id] = status
}
/// Updates status based on an API error.
func updateStatusFromError(_ error: Error, for instance: Instance) {
if let apiError = error as? APIError {
switch apiError {
case .unauthorized:
updateStatus(.authFailed, for: instance)
case .noConnection, .timeout:
updateStatus(.offline, for: instance)
default:
// Don't change status for other errors
break
}
} else {
// Generic network errors
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
updateStatus(.offline, for: instance)
}
}
}
/// Clears the status of an instance (resets to online).
func clearStatus(for instance: Instance) {
instanceStatuses.removeValue(forKey: instance.id)
}
/// Instances that have auth issues (need attention).
var instancesWithAuthIssues: [Instance] {
instances.filter { instanceStatuses[$0.id] == .authFailed || instanceStatuses[$0.id] == .authRequired }
}
/// The currently active instance for browsing content.
/// Falls back to the first enabled instance if no active instance is set.
var activeInstance: Instance? {
if let id = activeInstanceID,
let instance = enabledInstances.first(where: { $0.id == id }) {
return instance
}
return enabledInstances.first
}
/// Sets the given instance as the active instance for browsing.
func setActive(_ instance: Instance) {
guard enabledInstances.contains(where: { $0.id == instance.id }) else { return }
activeInstanceID = instance.id
saveActiveInstance()
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
}
/// Clears the active instance, falling back to the first enabled instance.
func clearActiveInstance() {
activeInstanceID = nil
localDefaults.removeObject(forKey: activeInstanceKey)
NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil)
}
// MARK: - Private Methods
private func loadInstances() {
// Only load from local defaults - never automatically pull from iCloud
// User must explicitly enable iCloud sync to get iCloud data
if let data = localDefaults.data(forKey: instancesKey),
let decoded = try? JSONDecoder().decode([Instance].self, from: data) {
instances = decoded
}
}
private func saveInstances() {
guard let data = try? JSONEncoder().encode(instances) else { return }
// Always write to local storage
localDefaults.set(data, forKey: instancesKey)
// Only write to iCloud if instance sync is enabled
if instanceSyncEnabled {
ubiquitousStore.set(data, forKey: instancesKey)
settingsManager?.updateLastSyncTime()
}
NotificationCenter.default.post(name: .instancesDidChange, object: nil)
}
private func loadActiveInstance() {
if let idString = localDefaults.string(forKey: activeInstanceKey),
let uuid = UUID(uuidString: idString) {
activeInstanceID = uuid
}
}
private func saveActiveInstance() {
if let id = activeInstanceID {
localDefaults.set(id.uuidString, forKey: activeInstanceKey)
} else {
localDefaults.removeObject(forKey: activeInstanceKey)
}
}
}
// MARK: - Notifications
extension Notification.Name {
static let instancesDidChange = Notification.Name("stream.yattee.instancesDidChange")
static let activeInstanceDidChange = Notification.Name("stream.yattee.activeInstanceDidChange")
}