NavidromeApp/Shared/Storage/ServerManager.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

229 lines
7.2 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?
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.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
}
}