NavidromeApp/Shared/Storage/LibraryCache.swift
Dallas Groot 8aa47319f2 quick fix
2026-04-12 13:21:20 -07:00

193 lines
7.5 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?
/// 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<NSString, CacheEntry>()
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<T: Encodable>(_ 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<T: Decodable>(_ 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) }
}
}
}