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.
190 lines
7.1 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|