import Foundation import Network /// Caches library data (albums, artists, playlists, songs) to disk for offline browsing. /// Also monitors network connectivity. class LibraryCache: ObservableObject { static let shared = LibraryCache() @Published var isOffline = false private let fileManager = FileManager.default private let cacheDir: URL private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network") 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 access — prevents -54 sandbox errors try? (cacheDir as NSURL).setResourceValue( URLFileProtection.completeUntilFirstUserAuthentication, forKey: .fileProtectionKey ) // Set file protection to allow background access — prevents -54 sandbox errors 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) } deinit { monitor.cancel() } // MARK: - Generic Cache Read/Write private func cacheURL(for key: String) -> URL { cacheDir.appendingPathComponent("\(key).json") } func save(_ data: T, key: String) { if let encoded = try? JSONEncoder().encode(data) { try? encoded.write(to: cacheURL(for: key), options: .atomic) } } func load(_ type: T.Type, key: String) -> T? { let url = cacheURL(for: key) guard let data = try? Data(contentsOf: url) else { return nil } 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: - Download Status Check /// Returns set of song IDs that are downloaded for offline playback func downloadedSongIds() -> Set { let key = "offline_catalog" guard let data = UserDefaults.standard.data(forKey: key), let songs = try? JSONDecoder().decode([DownloadedSong].self, from: data) else { return [] } return Set(songs.map { $0.id }) } // MARK: - Clear func clearAll() { if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) { for file in files { try? fileManager.removeItem(at: file) } } } }