233 lines
7.5 KiB
Swift
233 lines
7.5 KiB
Swift
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?
|
|
/// Set to true the first time a server ping succeeds. Used by LibraryCache.isServerAvailable
|
|
/// to avoid greying out songs on first launch while the initial connection is in-flight.
|
|
private(set) var hasEverConnected: Bool = false
|
|
|
|
private let storageKey = "navidrome_servers"
|
|
private let activeServerKey = "navidrome_active_server"
|
|
private var failoverTask: Task<Void, Never>?
|
|
|
|
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.hasEverConnected = true
|
|
self.client.isAuthenticated = true
|
|
self.saveActiveServer()
|
|
}
|
|
return true
|
|
}
|
|
} catch {
|
|
// Connection failed — will try next
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Timeout wrapper for network calls
|
|
private func withTimeout<T>(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
|
|
}
|
|
}
|