NavidromeApp/Shared/Storage/LibraryCache.swift

185 lines
6.9 KiB
Swift
Raw Normal View History

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)
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
// 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
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)
}
}
2026-04-11 02:23:03 -07:00
func remove(key: String) {
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_") {
try? FileManager.default.removeItem(at: url)
}
}
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")
}
updates to companion from ios to server Models.swift — CompanionSong, CompanionAlbum, CompanionArtist, CompanionLibraryResponse. Each has a toSong()/toAlbum() converter. Song.id = navidrome_id (streaming still works), Song.coverArt = "companion:{id}" (routes cover art to Companion), Album.id = "companion:{name}|{artist}" (detected by AlbumDetailView). CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs, fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated where needed. coverArtURL/artistPhotoURL are nonisolated so views can call them synchronously. Push handler now fires companionCoverArtUpdated and companionArtistPhotoUpdated events and clears ImageCache immediately on receipt. LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums, cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence of companion data between launches. SyncEngine.swift — syncCompanion() runs after every Navidrome sync (bootstrap and delta) when Companion is enabled. It fetches albums from /library/albums, converts them to Album objects, and overwrites "all_albums" cache — so MyMusicView’s album grid immediately shows Companion-sorted data. companionLibraryChanged notification triggers reactive UI updates. AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to CompanionAPIService().coverArtURL(companionId:) instead of Navidrome. All existing non-companion cover art paths are unchanged. AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"), parses "companion:{name}|{artist}", calls /library/songs?album=...&album_artist=... on Companion, and builds a synthetic AlbumWithSongs locally. Caches results so the next tap is instant. Falls back to the existing Navidrome path for all non-companion album IDs.
2026-04-10 11:14:41 -07:00
// 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")
}
/// Cache companion songs (all songs, used for search + album detail).
func cacheCompanionAlbumSongs(_ songs: [CompanionSong], albumId: String) {
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
save(songs, key: safeKey)
}
func loadCompanionAlbumSongs(albumId: String) -> [CompanionSong]? {
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
return load([CompanionSong].self, key: safeKey)
}
/// Whether the library data came from Companion API (affects sort order, cover art).
var hasCompanionLibrary: Bool {
loadCompanionAlbums() != nil
}
// 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) }
}
}
}