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.
176 lines
6.5 KiB
Swift
176 lines
6.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?
|
|
|
|
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 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) }
|
|
}
|
|
}
|
|
}
|