193 lines
7.5 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|