NavidromeApp/Shared/Storage/OfflineManager.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

256 lines
9.4 KiB
Swift

import Foundation
import Combine
/// Manages downloading songs for offline playback on both iOS and watchOS
class OfflineManager: ObservableObject {
static let shared = OfflineManager()
@Published var downloads: [String: DownloadState] = [:] // songId -> state
@Published var downloadedSongs: [DownloadedSong] = []
@Published var totalDownloadSize: Int64 = 0
@Published var isDownloading = false
private let fileManager = FileManager.default
private let catalogKey = "offline_catalog"
private var downloadQueue: [(Song, ServerConfig)] = []
private var isProcessingQueue = false
enum DownloadState: Equatable {
case queued
case downloading(progress: Double)
case completed(localPath: String)
case failed(error: String)
}
private init() {
loadCatalog()
}
// MARK: - Download Directory
private var downloadDirectory: URL {
let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
let dir = paths[0].appendingPathComponent("OfflineMusic", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
// Allow background access for audio playback
try? (dir as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
}
return dir
}
// MARK: - Download Songs
func downloadSong(_ song: Song, server: ServerConfig) {
guard downloads[song.id] == nil || {
if case .failed = downloads[song.id] { return true }
return false
}() else { return }
downloads[song.id] = .queued
downloadQueue.append((song, server))
processQueue()
}
func downloadAlbum(_ album: AlbumWithSongs, server: ServerConfig) {
guard let songs = album.song else { return }
for song in songs {
downloadSong(song, server: server)
}
}
func downloadPlaylist(_ playlist: PlaylistWithSongs, server: ServerConfig) {
guard let songs = playlist.entry else { return }
for song in songs {
downloadSong(song, server: server)
}
}
private func processQueue() {
guard !isProcessingQueue, !downloadQueue.isEmpty else { return }
isProcessingQueue = true
isDownloading = true
Task {
while !downloadQueue.isEmpty {
let (song, server) = downloadQueue.removeFirst()
await performDownload(song: song, server: server)
}
await MainActor.run {
self.isProcessingQueue = false
self.isDownloading = false
}
}
}
private func performDownload(song: Song, server: ServerConfig) async {
await MainActor.run {
downloads[song.id] = .downloading(progress: 0)
}
let client = SubsonicClient()
client.currentServer = server
do {
let data: Data
let ext: String
#if os(iOS)
let quality = StreamingQuality.shared
if quality.cacheFormat != "raw" {
// Stream with transcoding using cache format settings
guard let streamURL = client.streamURL(
songId: song.id,
format: quality.cacheFormat,
maxBitRate: quality.downloadBitRate
) else {
throw NSError(domain: "Download", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not build stream URL"])
}
let (streamData, _) = try await URLSession.shared.data(from: streamURL)
data = streamData
ext = quality.cacheFormat == "ogg" ? "ogg" : quality.cacheFormat
} else {
let (rawData, _) = try await client.downloadSong(id: song.id)
data = rawData
ext = song.suffix ?? "mp3"
}
#else
// watchOS always downloads original
let (rawData, _) = try await client.downloadSong(id: song.id)
data = rawData
ext = song.suffix ?? "mp3"
#endif
let filename = "\(song.id).\(ext)"
let localURL = downloadDirectory.appendingPathComponent(filename)
try data.write(to: localURL)
// Also download cover art if available
if let coverArtId = song.coverArt {
if let artURL = client.coverArtURL(id: coverArtId, size: 600) {
if let (artData, _) = try? await URLSession.shared.data(from: artURL) {
let artPath = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
try? artData.write(to: artPath)
}
}
}
let downloaded = DownloadedSong(
id: song.id,
serverId: server.id,
song: song,
localPath: localURL.path,
downloadDate: Date(),
fileSize: Int64(data.count)
)
await MainActor.run {
self.downloads[song.id] = .completed(localPath: localURL.path)
self.downloadedSongs.append(downloaded)
self.totalDownloadSize += Int64(data.count)
self.saveCatalog()
}
// Auto-analyze for visualizer cache
#if os(iOS)
Task.detached(priority: .background) {
let storage = VisualizerStorageManager.shared
let hasCache = await storage.hasCache(for: song.id)
if !hasCache {
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(url: localURL)
try await storage.saveCache(frames: frames, for: song.id)
print("[Vis] Auto-analyzed: \(song.title)")
} catch {
print("[Vis] Auto-analyze failed for \(song.title): \(error.localizedDescription)")
}
}
}
#endif
} catch {
await MainActor.run {
self.downloads[song.id] = .failed(error: error.localizedDescription)
}
}
}
// MARK: - Remove Downloads
func removeSong(_ songId: String) {
if let idx = downloadedSongs.firstIndex(where: { $0.id == songId }) {
let song = downloadedSongs[idx]
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
totalDownloadSize -= song.fileSize
downloadedSongs.remove(at: idx)
downloads.removeValue(forKey: songId)
saveCatalog()
}
}
func removeAll() {
for song in downloadedSongs {
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
}
downloadedSongs.removeAll()
downloads.removeAll()
totalDownloadSize = 0
saveCatalog()
}
func isSongDownloaded(_ songId: String) -> Bool {
return downloadedSongs.contains { $0.id == songId }
}
func localURL(for songId: String) -> URL? {
guard let song = downloadedSongs.first(where: { $0.id == songId }) else { return nil }
// Reconstruct from filename stored absolute paths break when sandbox UUID changes
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil
}
func coverArtLocalURL(for coverArtId: String) -> URL? {
let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
return fileManager.fileExists(atPath: path.path) ? path : nil
}
// MARK: - Persistence
private func saveCatalog() {
if let data = try? JSONEncoder().encode(downloadedSongs) {
UserDefaults.standard.set(data, forKey: catalogKey)
}
}
private func loadCatalog() {
if let data = UserDefaults.standard.data(forKey: catalogKey),
let decoded = try? JSONDecoder().decode([DownloadedSong].self, from: data) {
downloadedSongs = decoded
totalDownloadSize = decoded.reduce(0) { $0 + $1.fileSize }
for song in decoded {
// Reconstruct path from filename absolute paths break across app launches
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: url.path) {
downloads[song.id] = .completed(localPath: url.path)
}
}
}
}
// MARK: - Storage Info
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: totalDownloadSize, countStyle: .file)
}
}