NavidromeApp/Shared/Storage/LibraryCache.swift
Dallas Groot f761f65e87 edit-metadata endpoint
iOS — 6 fixes across 5 files:
Models.swift — Song, Album, AlbumWithSongs all now have albumArtist:
String?. CompanionSong.toSong() passes albumArtist.
CompanionAlbum.toAlbum() passes albumArtist to the new field.
TrackEditorView.swift — Album Artist field now initialises from
song.albumArtist ?? song.artist instead of just song.artist.
fetchCompanionDetails no longer requires companion: prefix — it
fetches for all songs using the album name, decodes properly using
CompanionLibraryResponse, and matches by relative_path.
BatchAlbumEditorSheet.swift — initialises from album.albumArtist ??
album.artist.
MultiAlbumEditorSheet.swift — pre-fills from first.albumArtist ??
first.artist.
AlbumDetailView.swift — buildAlbumWithSongs now passes albumArtist
from the companion songs.
Companion — 2 fixes:
apply_tags — now does full FLAC cleanup (removes all Picard legacy
variants) before writing, same as apply_tags_dict already did.
edit-metadata endpoint — no longer calls restructure_file. File
renaming only happens via /bulk-fix. This was the root cause of the
500 errors on compilation tracks with disc numbers.
2026-04-11 00:50:37 -07:00

190 lines
7.1 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 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")
}
// 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) }
}
}
}