diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index 1518e68..a5b00e4 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -241,11 +241,42 @@ actor APIClient { // MARK: - Audio - func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioGenerateResponse { + func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { struct Body: Encodable { let voice: String; let speed: Double } return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) } + /// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed. + /// Returns the presigned/proxy URL on success, throws on failure or cancellation. + func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { + let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" + struct StatusResponse: Decodable { + let status: String + let url: String? + let error: String? + } + while true { + try Task.checkCancellation() + let r: StatusResponse = try await fetch(path) + switch r.status { + case "done": + guard let url = r.url, !url.isEmpty else { + throw URLError(.badServerResponse) + } + return url + case "failed": + throw NSError( + domain: "AudioGeneration", + code: 0, + userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"] + ) + default: + // pending / generating / idle — keep polling + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s + } + } + } + func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { struct Response: Decodable { let url: String } let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") @@ -406,11 +437,22 @@ struct SearchResponse: Decodable { } } -struct AudioGenerateResponse: Decodable { - // The Go API is synchronous: it blocks until generation completes and - // returns {"url": "...", "filename": "..."} on success. - let url: String - let filename: String +/// Returned by POST /api/audio/{slug}/{n}. +/// - 202 Accepted: job enqueued → poll via pollAudioStatus() +/// - 200 OK: audio already cached → url is ready to play +struct AudioTriggerResponse: Decodable { + let jobId: String? // present on 202 + let status: String? // present on 202: "pending" | "generating" + let url: String? // present on 200: proxy URL ready to play + let filename: String? // present on 200 + + enum CodingKeys: String, CodingKey { + case jobId = "job_id" + case status, url, filename + } + + /// True when the server accepted the request and created an async job. + var isAsync: Bool { jobId != nil } } struct ProgressEntry: Decodable, Identifiable { diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index 3c18d04..df34be2 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -165,14 +165,28 @@ final class AudioPlayerService: ObservableObject { return } - // Slow path: trigger TTS generation. Go API blocks until done. + // Slow path: trigger TTS generation (async — returns 202 immediately). status = .generating - generationProgress = 50 - let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) - audioURL = response.url + generationProgress = 10 + let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) + + let playableURL: String + if trigger.isAsync { + // 202 Accepted: poll until done. + generationProgress = 30 + playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice) + } else { + // 200: already cached URL returned inline. + guard let url = trigger.url, !url.isEmpty else { + throw URLError(.badServerResponse) + } + playableURL = url + } + + audioURL = playableURL status = .ready generationProgress = 100 - await playURL(response.url) + await playURL(playableURL) await prefetchNext() } catch is CancellationError { // Cancelled — no-op @@ -197,9 +211,16 @@ final class AudioPlayerService: ObservableObject { nextPrefetchStatus = .prefetched return } - // Slow path: trigger generation so it's ready for next time. - let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) - nextAudioURL = response.url + // Slow path: trigger generation; poll until done (background — won't block playback). + let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) + let url: String + if trigger.isAsync { + url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) + } else { + guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } + url = u + } + nextAudioURL = url nextPrefetchStatus = .prefetched } catch { nextPrefetchStatus = .failed diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 0fdb006..c1f070b 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -398,23 +398,23 @@ {@const chapterUrl = data.inLib ? `/books/${data.book.slug}/chapters/${chapter.number}` : `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`} -