301 lines
12 KiB
Swift
301 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
|
|
/// Single reusable client for all downloads — avoids allocating a new URLSession
|
|
/// (with its own connection pool and TLS state) per song. Downloads are sequential
|
|
/// so there's no concurrency concern; we just set currentServer before each one.
|
|
private let downloadClient = SubsonicClient()
|
|
|
|
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 = downloadClient
|
|
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))")
|
|
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)
|
|
}
|
|
}
|