import Foundation import Network import Combine /// Caches library data (albums, artists, playlists, songs) to disk for offline browsing. /// Also monitors network connectivity and server reachability. class LibraryCache: ObservableObject { static let shared = LibraryCache() @Published var isOffline = false /// True when a non-downloaded song can be streamed. /// Combines NWPathMonitor (network) with ServerManager.connectionState (server). var isServerAvailable: Bool { guard !isOffline else { return false } switch ServerManager.shared.connectionState { case .connected: return true case .connecting: return true // If we've never connected yet (cold launch), treat as available optimistically // to avoid songs flashing grey while the initial ping is in-flight. // Once we get .error or explicitly .disconnected, grey out immediately. case .disconnected: return !ServerManager.shared.hasEverConnected case .error: return false } } private let fileManager = FileManager.default private let cacheDir: URL private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network") private var serverStateCancellable: AnyCancellable? /// In-memory cache — eliminates repeated Data(contentsOf:) + JSONDecoder on view loads. /// NSCache auto-evicts under memory pressure, so this is free memory-wise. private let memoryCache = NSCache() private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! cacheDir = caches.appendingPathComponent("LibraryCache", isDirectory: true) try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true) // Allow background file access — prevents -54 sandbox errors during background sync try? (cacheDir as NSURL).setResourceValue( URLFileProtection.completeUntilFirstUserAuthentication, forKey: .fileProtectionKey ) // Monitor network status monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isOffline = (path.status != .satisfied) } } monitor.start(queue: monitorQueue) // Re-publish when server connection state changes so views observing // LibraryCache also react to server becoming reachable/unreachable serverStateCancellable = ServerManager.shared.$connectionState .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.objectWillChange.send() } } deinit { monitor.cancel() } // MARK: - Generic Cache Read/Write /// Wraps raw encoded Data for storage in NSCache (requires reference type). private class CacheEntry { let data: Data; init(_ data: Data) { self.data = data } } private func cacheURL(for key: String) -> URL { cacheDir.appendingPathComponent("\(key).json") } func save(_ data: T, key: String) { if let encoded = try? JSONEncoder().encode(data) { // Write-through: memory + disk memoryCache.setObject(CacheEntry(encoded), forKey: key as NSString) try? encoded.write(to: cacheURL(for: key), options: .atomic) } } func remove(key: String) { memoryCache.removeObject(forKey: key as NSString) try? FileManager.default.removeItem(at: cacheURL(for: key)) } /// Remove all album detail caches (keyed as "album_ALBUMID") so stale /// song paths from before a file restructure aren't served to the Companion. func removeAlbumDetails() { guard let files = try? FileManager.default.contentsOfDirectory( at: cacheDir, includingPropertiesForKeys: nil) else { return } for url in files where url.lastPathComponent.hasPrefix("album_") { // Strip ".json" to get the cache key, evict from memory let key = url.deletingPathExtension().lastPathComponent memoryCache.removeObject(forKey: key as NSString) try? FileManager.default.removeItem(at: url) } } func load(_ type: T.Type, key: String) -> T? { // 1. Memory hit — zero I/O (still needs decode from cached Data) if let entry = memoryCache.object(forKey: key as NSString) { return try? JSONDecoder().decode(type, from: entry.data) } // 2. Disk fallback — promote to memory let url = cacheURL(for: key) guard let data = try? Data(contentsOf: url) else { return nil } memoryCache.setObject(CacheEntry(data), forKey: key as NSString) return try? JSONDecoder().decode(type, from: data) } // MARK: - Typed Helpers func cacheAlbums(_ albums: [Album], key: String = "recent_albums") { save(albums, key: key) } func loadAlbums(key: String = "recent_albums") -> [Album]? { load([Album].self, key: key) } func cacheArtists(_ artists: [ArtistIndex]) { save(artists, key: "artists") } func loadArtists() -> [ArtistIndex]? { load([ArtistIndex].self, key: "artists") } func cachePlaylists(_ playlists: [Playlist]) { save(playlists, key: "playlists") } func loadPlaylists() -> [Playlist]? { load([Playlist].self, key: "playlists") } func cacheAlbumDetail(_ album: AlbumWithSongs) { save(album, key: "album_\(album.id)") } func loadAlbumDetail(id: String) -> AlbumWithSongs? { load(AlbumWithSongs.self, key: "album_\(id)") } func cacheArtistDetail(_ artist: ArtistWithAlbums) { save(artist, key: "artist_\(artist.id)") } func loadArtistDetail(id: String) -> ArtistWithAlbums? { load(ArtistWithAlbums.self, key: "artist_\(id)") } func cacheRadioStations(_ stations: [RadioStation]) { save(stations, key: "radio_stations") } func loadRadioStations() -> [RadioStation]? { load([RadioStation].self, key: "radio_stations") } // MARK: - Companion Library Cache /// Cache companion albums (authoritative sorted list from Companion API). func cacheCompanionAlbums(_ albums: [CompanionAlbum]) { save(albums, key: "companion_albums") } func loadCompanionAlbums() -> [CompanionAlbum]? { load([CompanionAlbum].self, key: "companion_albums") } /// Filesystem-safe cache key for a companion album ID. private func companionAlbumKey(_ albumId: String) -> String { let safe = albumId.replacingOccurrences(of: ":", with: "_") .replacingOccurrences(of: "|", with: "_") .replacingOccurrences(of: " ", with: "_") .replacingOccurrences(of: "/", with: "_") return "companion_album_\(safe)" } /// Cache companion songs (all songs, used for search + album detail). func cacheCompanionAlbumSongs(_ songs: [CompanionSong], albumId: String) { save(songs, key: companionAlbumKey(albumId)) } func loadCompanionAlbumSongs(albumId: String) -> [CompanionSong]? { load([CompanionSong].self, key: companionAlbumKey(albumId)) } // MARK: - Clear func clearAll() { memoryCache.removeAllObjects() if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) { for file in files { try? fileManager.removeItem(at: file) } } } }