NavidromeApp/Shared/Storage/OfflineManager.swift
Dallas Groot fc69d8a3cf CPU: Remove @Published from AudioPlayer time properties
Replace @Published var currentTime/duration with plain vars and drive
progress bars via TimelineView(.periodic) instead of SwiftUI
observation. This stops objectWillChange from firing 20x/second on
AudioPlayer, eliminating continuous body re-evaluation on
NowPlayingView, MiniPlayerBar, and MyMusicView regardless of
visualizer state.
2026-04-11 16:44:56 -07:00

297 lines
12 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)
DebugLogger.shared.log("Auto-analyzed: \(song.title)", category: "Vis")
} catch {
DebugLogger.shared.log("Auto-analyze failed for \(song.title): \(error.localizedDescription)", category: "Vis")
}
}
}
#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
}
/// Full-song overload with title/artist/duration fallback.
///
/// When a Companion API tag edit triggers a Navidrome restructure, the file
/// moves to a new path and Navidrome assigns a new ID. The catalog still has
/// the old ID so a bare ID lookup returns nil. This method falls back to
/// matching by title + artist + duration (±2s) so previously downloaded
/// songs remain playable offline even after their IDs change.
func localURL(for song: Song) -> URL? {
// 1. Exact ID match fast path, covers the common case
if let url = localURL(for: song.id) { return url }
// 2. Fuzzy fallback title + artist + duration (±2s)
// Handles ID changes caused by Companion API restructuring.
let targetTitle = song.title.lowercased().trimmingCharacters(in: .whitespaces)
let targetArtist = (song.artist ?? "").lowercased().trimmingCharacters(in: .whitespaces)
let targetDur = Double(song.duration ?? 0)
for ds in downloadedSongs {
let dsTitle = ds.song.title.lowercased().trimmingCharacters(in: .whitespaces)
let dsArtist = (ds.song.artist ?? "").lowercased().trimmingCharacters(in: .whitespaces)
let dsDur = Double(ds.song.duration ?? 0)
guard dsTitle == targetTitle, dsArtist == targetArtist else { continue }
guard targetDur == 0 || abs(dsDur - targetDur) <= 2 else { continue }
let filename = (ds.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: url.path) {
print("[OfflineManager] ID changed: \(ds.id)\(song.id) (\(song.title))", flush: true)
return url
}
}
return nil
}
/// Returns true if this song has a matching offline file, checking both
/// the stored ID and the title/artist/duration fallback.
func isSongAvailableOffline(_ song: Song) -> Bool {
localURL(for: song) != 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)
}
}