import Foundation import Combine // MARK: - AudioDownloadService // Manages offline TTS audio downloads with progress tracking. // Uses a background URLSession so downloads survive app suspension. // Keys use "::" separator (slugs contain hyphens). @MainActor final class AudioDownloadService: NSObject, ObservableObject { static let shared = AudioDownloadService() // MARK: - Published state @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" @Published var downloadedChapters: Set = [] // key: "slug::chapter::voice" // MARK: - Private private var session: URLSession! private var activeTasks: [String: URLSessionDownloadTask] = [:] private let fileManager = FileManager.default private let metadataKey = "v2.downloadedChapters" // MARK: - Init private override init() { super.init() let config = URLSessionConfiguration.background( withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads") config.isDiscretionary = false config.sessionSendsLaunchEvents = true session = URLSession(configuration: config, delegate: self, delegateQueue: nil) loadMetadata() } // MARK: - Public API func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice)) } func localURL(slug: String, chapter: Int, voice: String) -> URL? { guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } return audioFileURL(slug: slug, chapter: chapter, voice: voice) } func download(slug: String, chapter: Int, voice: String) async throws { let key = makeKey(slug: slug, chapter: chapter, voice: voice) guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return } let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) guard let url = URL(string: urlString) else { throw URLError(.badURL) } let task = session.downloadTask(with: url) task.taskDescription = key activeTasks[key] = task downloads[key] = DownloadProgress( slug: slug, chapter: chapter, voice: voice, progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading) task.resume() } func cancelDownload(slug: String, chapter: Int, voice: String) { let key = makeKey(slug: slug, chapter: chapter, voice: voice) activeTasks[key]?.cancel() activeTasks.removeValue(forKey: key) downloads.removeValue(forKey: key) } func deleteDownload(slug: String, chapter: Int, voice: String) throws { let key = makeKey(slug: slug, chapter: chapter, voice: voice) let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) if fileManager.fileExists(atPath: fileURL.path) { try fileManager.removeItem(at: fileURL) } downloadedChapters.remove(key) downloads.removeValue(forKey: key) saveMetadata() } func deleteAllDownloads() throws { if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { let audioDir = docs.appendingPathComponent("audio") if fileManager.fileExists(atPath: audioDir.path) { try fileManager.removeItem(at: audioDir) } } downloadedChapters.removeAll() downloads.removeAll() activeTasks.values.forEach { $0.cancel() } activeTasks.removeAll() saveMetadata() } func totalStorageUsed() -> Int64 { guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 } let audioDir = docs.appendingPathComponent("audio") guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } var total: Int64 = 0 for case let url as URL in enumerator { if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { total += Int64(size) } } return total } func offlineBookSlugs() -> [String] { Array(Set(downloadedChapters.compactMap { key -> String? in let parts = key.split(separator: "::") return parts.count == 3 ? String(parts[0]) : nil })).sorted() } func downloadedChapterCount(for slug: String) -> Int { downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count } // MARK: - Key / path helpers func makeKey(slug: String, chapter: Int, voice: String) -> String { "\(slug)::\(chapter)::\(voice)" } nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return docs .appendingPathComponent("audio") .appendingPathComponent(slug) .appendingPathComponent("\(chapter)-\(voice).mp3") } // MARK: - Persistence private func loadMetadata() { if let data = UserDefaults.standard.data(forKey: metadataKey), let decoded = try? JSONDecoder().decode(Set.self, from: data) { downloadedChapters = decoded } } private func saveMetadata() { if let encoded = try? JSONEncoder().encode(downloadedChapters) { UserDefaults.standard.set(encoded, forKey: metadataKey) } } } // MARK: - URLSessionDownloadDelegate extension AudioDownloadService: URLSessionDownloadDelegate { nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let key = downloadTask.taskDescription else { return } let parts = key.split(separator: "::") guard parts.count == 3, let chapter = Int(parts[1]) else { return } let slug = String(parts[0]) let voice = String(parts[2]) let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice) do { let dir = dest.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } try FileManager.default.moveItem(at: location, to: dest) Task { @MainActor in self.downloadedChapters.insert(key) self.downloads.removeValue(forKey: key) self.activeTasks.removeValue(forKey: key) self.saveMetadata() } } catch { Task { @MainActor in self.downloads[key]?.status = .failed(error.localizedDescription) self.activeTasks.removeValue(forKey: key) } } } nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard let key = downloadTask.taskDescription else { return } let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 Task { @MainActor in if var p = self.downloads[key] { p.downloadedBytes = totalBytesWritten p.totalBytes = totalBytesExpectedToWrite p.progress = progress self.downloads[key] = p } } } nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let key = task.taskDescription, let error else { return } let nsErr = error as NSError guard nsErr.code != NSURLErrorCancelled else { return } Task { @MainActor in self.downloads[key]?.status = .failed(error.localizedDescription) self.activeTasks.removeValue(forKey: key) } } } // MARK: - Supporting types struct DownloadProgress: Equatable { let slug: String let chapter: Int let voice: String var progress: Double var totalBytes: Int64 var downloadedBytes: Int64 var status: DownloadStatus } enum DownloadStatus: Equatable { case downloading case completed case failed(String) }