import Foundation import Observation /// Runs podcast generation (generate → poll → download) **off** the player, so a /// new episode can be produced in the background while another one keeps playing. /// The finished audio is written to the on-disk cache and recorded in /// `PodcastIndex`; the optional `onReady` callback lets a caller start playback /// only when nothing else is playing. @Observable @MainActor final class PodcastGenerationManager { struct Job: Identifiable { let id: String // articleUrl var articleUrl: String var title: String var progress: Double // 0…1 var label: String var failed: Bool = false var error: String? } private(set) var jobs: [Job] = [] private var tasks: [String: Task] = [:] private let claude = ClaudeService() /// Jobs still working (not failed). Drives the "Generating…" banner. var activeJobs: [Job] { jobs.filter { !$0.failed } } var hasActive: Bool { !activeJobs.isEmpty } func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil } func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } } /// Enqueue a background generation for `articleUrl`. Idempotent per URL. /// If audio is already cached, `onReady` fires immediately and nothing runs. /// `onReady` is invoked on the main actor once audio is available. func enqueue(articleUrl: String, title: String, parentBookmarkUrl: String? = nil, onReady: ((URL, String) -> Void)? = nil) { let cached = ClaudeService.cachedPodcastURL(for: articleUrl) if FileManager.default.fileExists(atPath: cached.path) { onReady?(cached, title) return } if tasks[articleUrl] != nil { return } // already in flight jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title, progress: 0, label: "Starting…"), at: 0) Analytics.track("podcast.bg_started", ["url": articleUrl]) let task = Task { [weak self] in guard let self else { return } do { let jobId = try await claude.generatePodcast(url: articleUrl) while !Task.isCancelled { let status = try await claude.podcastStatus(jobId: jobId) update(articleUrl) { $0.progress = Double(status.progress) / 100 $0.label = PodcastPlayerViewModel.statusLabel(status.status) } if status.isDone { update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 } let url = try await claude.downloadPodcastAudio( jobId: jobId, articleUrl: articleUrl, title: status.title, parentBookmarkUrl: parentBookmarkUrl) let finalTitle = status.title ?? title finish(articleUrl) Analytics.track("podcast.bg_completed", ["url": articleUrl]) onReady?(url, finalTitle) return } if status.isFailed { fail(articleUrl, status.error ?? "Generation failed") return } try await Task.sleep(for: .seconds(3)) } } catch is CancellationError { finish(articleUrl) } catch { fail(articleUrl, error.localizedDescription) } } tasks[articleUrl] = task } /// Cancel an in-flight job and drop it from the list. func cancel(_ articleUrl: String) { tasks[articleUrl]?.cancel() tasks[articleUrl] = nil jobs.removeAll { $0.articleUrl == articleUrl } } func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } } // MARK: - Mutations private func update(_ url: String, _ mutate: (inout Job) -> Void) { guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return } mutate(&jobs[i]) } private func finish(_ url: String) { tasks[url] = nil jobs.removeAll { $0.articleUrl == url } } private func fail(_ url: String, _ message: String) { tasks[url] = nil update(url) { $0.failed = true; $0.error = message; $0.label = message } Analytics.track("podcast.bg_failed", ["url": url, "error": message]) // Failed jobs linger briefly so the user sees them, then self-clear. Task { [weak self] in try? await Task.sleep(for: .seconds(8)) self?.dismiss(url) } } }