2026-03-28 13:49:47 -07:00
|
|
|
import Foundation
|
|
|
|
|
import Network
|
2026-04-04 16:12:28 -07:00
|
|
|
import Combine
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
/// Caches library data (albums, artists, playlists, songs) to disk for offline browsing.
|
2026-04-04 16:12:28 -07:00
|
|
|
/// Also monitors network connectivity and server reachability.
|
2026-03-28 13:49:47 -07:00
|
|
|
class LibraryCache: ObservableObject {
|
|
|
|
|
static let shared = LibraryCache()
|
|
|
|
|
|
|
|
|
|
@Published var isOffline = false
|
|
|
|
|
|
2026-04-04 16:12:28 -07:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
private let fileManager = FileManager.default
|
|
|
|
|
private let cacheDir: URL
|
|
|
|
|
private let monitor = NWPathMonitor()
|
|
|
|
|
private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network")
|
2026-04-04 16:12:28 -07:00
|
|
|
private var serverStateCancellable: AnyCancellable?
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
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
|
2026-03-28 13:49:47 -07:00
|
|
|
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)
|
2026-04-04 16:12:28 -07:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
// 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) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|