import Foundation import Combine /// Manages multiple Navidrome server connections with automatic failover. /// When one server is unreachable (e.g. public domain on local WiFi), /// it automatically tries the next server with the same credentials. class ServerManager: ObservableObject { static let shared = ServerManager() @Published var servers: [ServerConfig] = [] @Published var activeServer: ServerConfig? @Published var connectionState: ConnectionState = .disconnected @Published var lastFailoverMessage: String? private let storageKey = "navidrome_servers" private let activeServerKey = "navidrome_active_server" private var failoverTask: Task? enum ConnectionState: Equatable { case disconnected case connecting case connected case error(String) } let client = SubsonicClient() private init() { loadServers() } // MARK: - Server CRUD func addServer(_ server: ServerConfig) { var srv = server srv.url = srv.url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) servers.append(srv) saveServers() } func updateServer(_ server: ServerConfig) { if let idx = servers.firstIndex(where: { $0.id == server.id }) { servers[idx] = server saveServers() if activeServer?.id == server.id { activeServer = server client.currentServer = server } } } func removeServer(at offsets: IndexSet) { let removedIds = offsets.map { servers[$0].id } servers.remove(atOffsets: offsets) if let active = activeServer, removedIds.contains(active.id) { activeServer = servers.first client.currentServer = activeServer } saveServers() } func removeServer(_ server: ServerConfig) { servers.removeAll { $0.id == server.id } if activeServer?.id == server.id { activeServer = servers.first client.currentServer = activeServer } saveServers() } // MARK: - Connection with Auto-Failover /// Connects to a specific server. On failure, tries other servers /// with the same username (likely the same Navidrome instance). func connect(to server: ServerConfig) async -> Bool { await MainActor.run { connectionState = .connecting lastFailoverMessage = nil } // Try the requested server first if await tryConnect(server) { return true } // Failed — try other servers with same username (same Navidrome instance) let alternatives = servers.filter { $0.id != server.id && $0.username == server.username } for alt in alternatives { await MainActor.run { lastFailoverMessage = "Trying \(alt.name)..." } if await tryConnect(alt) { await MainActor.run { lastFailoverMessage = "Connected via \(alt.name)" } return true } } // Also try servers with different usernames as last resort let others = servers.filter { $0.id != server.id && $0.username != server.username } for other in others { await MainActor.run { lastFailoverMessage = "Trying \(other.name)..." } if await tryConnect(other) { await MainActor.run { lastFailoverMessage = "Connected via \(other.name)" } return true } } // All failed await MainActor.run { connectionState = .error("All servers unreachable") lastFailoverMessage = nil } return false } /// Low-level connect attempt with short timeout private func tryConnect(_ server: ServerConfig) async -> Bool { client.currentServer = server do { let ok = try await withTimeout(seconds: 5) { try await self.client.ping(server: server) } if ok { await MainActor.run { self.activeServer = server self.connectionState = .connected self.client.isAuthenticated = true self.saveActiveServer() } return true } } catch { // Connection failed — will try next } return false } /// Timeout wrapper for network calls private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw URLError(.timedOut) } let result = try await group.next()! group.cancelAll() return result } } func connectToActive() async { guard let server = activeServer else { // No active server — try all if let first = servers.first { _ = await connect(to: first) } return } _ = await connect(to: server) } /// Called when an API request fails — triggers background failover func handleConnectionFailure() { guard failoverTask == nil else { return } failoverTask = Task { if let active = activeServer { _ = await connect(to: active) } failoverTask = nil } } func switchServer(_ server: ServerConfig) async -> Bool { return await connect(to: server) } func disconnect() { connectionState = .disconnected client.isAuthenticated = false } // MARK: - Persistence private func saveServers() { if let data = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(data, forKey: storageKey) } syncToWatch() } private func saveActiveServer() { if let data = try? JSONEncoder().encode(activeServer) { UserDefaults.standard.set(data, forKey: activeServerKey) } } private func loadServers() { if let data = UserDefaults.standard.data(forKey: storageKey), let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) { servers = decoded } if let data = UserDefaults.standard.data(forKey: activeServerKey), let decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) { activeServer = decoded client.currentServer = decoded } else { activeServer = servers.first client.currentServer = servers.first } } private func syncToWatch() { #if os(iOS) WatchConnectivityManager.shared.sendServersToWatch(servers) #endif } }