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) } }