Files
linkding-ios/Marks/Services/PodcastGenerationManager.swift
2026-07-02 01:11:03 -05:00

128 lines
5.1 KiB
Swift

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 // 01
var label: String
var failed: Bool = false
var error: String?
}
private(set) var jobs: [Job] = []
private var tasks: [String: Task<Void, Never>] = [:]
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,
sourceText: String? = nil,
sourceKind: 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,
title: title,
sourceText: sourceText,
sourceKind: sourceKind
)
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)
PodcastLibrary.shared.reload() // new episode refresh list + badge
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)
}
}
}