import Foundation import Observation struct DownloadedTrack: Codable, Hashable { let ino: String let filename: String let localPath: String // relative to AppPaths.downloadsDirectory let durationSeconds: Double enum CodingKeys: String, CodingKey { case ino, filename, localPath, durationSeconds } init(ino: String, filename: String, localPath: String, durationSeconds: Double) { self.ino = ino self.filename = filename self.localPath = localPath self.durationSeconds = durationSeconds } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) ino = try c.decode(String.self, forKey: .ino) filename = try c.decode(String.self, forKey: .filename) localPath = try c.decode(String.self, forKey: .localPath) durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0 } } struct DownloadedItem: Codable, Hashable { let itemId: String var episodeId: String? let title: String let author: String let durationSeconds: Double let tracks: [DownloadedTrack] enum CodingKeys: String, CodingKey { case itemId, episodeId, title, author, durationSeconds, tracks } init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) { self.itemId = itemId self.episodeId = episodeId self.title = title self.author = author self.durationSeconds = durationSeconds self.tracks = tracks } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) itemId = try c.decode(String.self, forKey: .itemId) episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId) title = try c.decode(String.self, forKey: .title) author = try c.decode(String.self, forKey: .author) durationSeconds = try c.decode(Double.self, forKey: .durationSeconds) tracks = try c.decode([DownloadedTrack].self, forKey: .tracks) } var downloadKey: String { if let episodeId { return "\(itemId)|\(episodeId)" } return itemId } } @Observable @MainActor final class DownloadManager { private let client: ABSClient /// Keyed by downloadKey (itemId or "itemId|episodeId"). private(set) var states: [String: DownloadState] = [:] private(set) var downloadedItems: [String: DownloadedItem] = [:] private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } private var activeTasks: [String: Task] = [:] init(client: ABSClient) { self.client = client try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true) loadIndex() } func state(for downloadKey: String) -> DownloadState { states[downloadKey] ?? .notDownloaded } func isDownloaded(downloadKey: String) -> Bool { if case .downloaded = state(for: downloadKey) { return true } return false } func localTrackURLs(for downloadKey: String) -> [URL]? { guard let item = downloadedItems[downloadKey] else { return nil } return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) } } /// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile). /// For a podcast episode pass the synthetic LibraryItem that the AppState builds /// (item.id == podcastItemId, item.episodeId == episodeId, audioFiles == [episode.audioFile]). func startDownload(item: LibraryItem) { let key = item.downloadKey guard activeTasks[key] == nil else { return } states[key] = .downloading(progress: 0) let task = Task { @MainActor [weak self] in guard let self else { return } var workItem = item // Books may arrive with empty audioFiles (list endpoint omits them). // Episodes always arrive populated, since AppState builds the synthetic item. if !workItem.isPodcast && workItem.audioFiles.isEmpty { do { workItem = try await self.client.fetchItemDetail(itemId: item.id) } catch { self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)") self.activeTasks[key] = nil return } } if workItem.audioFiles.isEmpty { self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.") self.activeTasks[key] = nil return } await self.performDownload(workItem: workItem, downloadKey: key) self.activeTasks[key] = nil } activeTasks[key] = task } func cancel(downloadKey: String) { activeTasks[downloadKey]?.cancel() activeTasks[downloadKey] = nil states[downloadKey] = .notDownloaded } func delete(downloadKey: String) { cancel(downloadKey: downloadKey) if let item = downloadedItems[downloadKey] { let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId) try? FileManager.default.removeItem(at: dir) // If this was an episode and the podcast's parent directory is now empty, clean it up too. if item.episodeId != nil { let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId) if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty { try? FileManager.default.removeItem(at: parent) } } } downloadedItems.removeValue(forKey: downloadKey) states[downloadKey] = .notDownloaded persistIndex() } private func directoryURL(itemId: String, episodeId: String?) -> URL { var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true) if let episodeId { dir = dir.appendingPathComponent(episodeId, isDirectory: true) } return dir } private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String { if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" } return "\(itemId)/\(fileName)" } private func performDownload(workItem: LibraryItem, downloadKey: String) async { let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) do { try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) } catch { states[downloadKey] = .failed(message: error.localizedDescription) return } var tracks: [DownloadedTrack] = [] let total = max(workItem.audioFiles.count, 1) for (idx, file) in workItem.audioFiles.enumerated() { if Task.isCancelled { states[downloadKey] = .notDownloaded return } guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue } var request = URLRequest(url: url) for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } do { let (tempURL, response) = try await URLSession.shared.download(for: request) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { states[downloadKey] = .failed(message: "HTTP \(http.statusCode) bei Datei \(file.filename)") try? FileManager.default.removeItem(at: tempURL) return } let ext = file.ext.isEmpty ? "mp3" : file.ext let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)" let dest = itemDir.appendingPathComponent(destName) try? FileManager.default.removeItem(at: dest) try FileManager.default.moveItem(at: tempURL, to: dest) tracks.append(DownloadedTrack( ino: file.ino, filename: file.filename, localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName), durationSeconds: file.durationSeconds )) states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total)) } catch { states[downloadKey] = .failed(message: error.localizedDescription) return } } let downloaded = DownloadedItem( itemId: workItem.id, episodeId: workItem.episodeId, title: workItem.title, author: workItem.author, durationSeconds: workItem.durationSeconds, tracks: tracks ) downloadedItems[downloadKey] = downloaded states[downloadKey] = .downloaded persistIndex() } private func loadIndex() { guard let data = try? Data(contentsOf: indexFile), let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return } // Re-key by current downloadKey (handles both legacy itemId-only keys and new composite keys). var rekeyed: [String: DownloadedItem] = [:] for (_, item) in decoded { if item.tracks.isEmpty { continue } rekeyed[item.downloadKey] = item } downloadedItems = rekeyed for k in rekeyed.keys { states[k] = .downloaded } // Clean up phantom folders. for (oldKey, item) in decoded where item.tracks.isEmpty { let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey) try? FileManager.default.removeItem(at: dir) } if rekeyed.count != decoded.count { persistIndex() } } private func persistIndex() { do { let data = try JSONEncoder().encode(downloadedItems) try data.write(to: indexFile, options: .atomic) } catch { // non-fatal } } }