127 lines
4.2 KiB
Swift
127 lines
4.2 KiB
Swift
|
|
import Foundation
|
||
|
|
import Network
|
||
|
|
|
||
|
|
/// Caches library data (albums, artists, playlists, songs) to disk for offline browsing.
|
||
|
|
/// Also monitors network connectivity.
|
||
|
|
class LibraryCache: ObservableObject {
|
||
|
|
static let shared = LibraryCache()
|
||
|
|
|
||
|
|
@Published var isOffline = false
|
||
|
|
|
||
|
|
private let fileManager = FileManager.default
|
||
|
|
private let cacheDir: URL
|
||
|
|
private let monitor = NWPathMonitor()
|
||
|
|
private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network")
|
||
|
|
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
|
||
|
|
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: - 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) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|