NavidromeApp/Shared/Storage/LibraryCache.swift

151 lines
5.3 KiB
Swift

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?
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)
// 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
private func cacheURL(for key: String) -> URL {
cacheDir.appendingPathComponent("\(key).json")
}
func save<T: Encodable>(_ data: T, key: String) {
if let encoded = try? JSONEncoder().encode(data) {
try? encoded.write(to: cacheURL(for: key), options: .atomic)
}
}
func load<T: Decodable>(_ 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<String> {
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) }
}
}
}