Add async audio generation: job tracking in PocketBase + UI polling
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
This commit is contained in:
Admin
2026-03-07 20:12:08 +05:00
parent 88644341d8
commit 89f0dfb113
9 changed files with 540 additions and 83 deletions

View File

@@ -331,6 +331,51 @@
return data.url;
}
type AudioStatusResponse =
| { status: 'done'; url: string; filename: string }
| { status: 'pending' | 'generating'; job_id: string }
| { status: 'idle' }
| { status: 'failed'; error?: string };
/**
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
* until status is "done" or "failed" (or the caller cancels via signal).
*
* Returns the final status response, or throws on network error / cancellation.
*/
async function pollAudioStatus(
targetSlug: string,
targetChapter: number,
targetVoice: string,
intervalMs = 2000,
signal?: AbortSignal
): Promise<AudioStatusResponse> {
const qs = new URLSearchParams();
if (targetVoice) qs.set('voice', targetVoice);
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
while (true) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
const data = (await res.json()) as AudioStatusResponse;
if (data.status === 'done' || data.status === 'failed') {
return data;
}
// Still pending/generating — wait then retry.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, intervalMs);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
}
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
async function prefetchNext() {
@@ -354,7 +399,7 @@
return;
}
// Slow path: trigger Kokoro generation in background
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -362,13 +407,31 @@
});
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
stopNextProgress();
audioStore.nextProgress = 100;
audioStore.nextAudioUrl = cached.url;
audioStore.nextStatus = 'prefetched';
return;
}
// 202: poll until done.
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
const url2 = await tryPresign(slug, nextChapter, voice);
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
}
audioStore.nextAudioUrl = url2;
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, nextChapter, voice));
if (!doneUrl) throw new Error('Prefetch: audio done but no URL available');
audioStore.nextAudioUrl = doneUrl;
audioStore.nextStatus = 'prefetched';
} catch {
stopNextProgress();
@@ -447,7 +510,7 @@
return;
}
// Slow path: trigger Kokoro generation.
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
audioStore.status = 'generating';
startProgress();
@@ -458,11 +521,32 @@
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
await finishProgress();
audioStore.audioUrl = cached.url;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// 202: poll until done.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
await finishProgress();
const url2 = await tryPresign(slug, chapter, voice);
if (!url2) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = url2;
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, chapter, voice));
if (!doneUrl) throw new Error('Audio generated but no URL available');
audioStore.audioUrl = doneUrl;
audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background.

View File

@@ -11,8 +11,12 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
* Keeps the scraper URL server-side — the browser never needs to know it.
*
* Body: { voice?: string }
* Response: { url: string, filename: string }
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...
*
* Responses:
* 200 { url: string, filename: string } — audio already cached; url is a
* relative path to GET /api/audio/[slug]/[n]?voice=...
* 202 { job_id: string, status: "pending"|"generating" } — generation
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
*/
export const POST: RequestHandler = async ({ params, request }) => {
const { slug, n } = params;
@@ -40,18 +44,28 @@ export const POST: RequestHandler = async ({ params, request }) => {
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
}
const data = (await scraperRes.json()) as { url: string; filename: string };
const data = (await scraperRes.json()) as
| { url: string; filename: string }
| { job_id: string; status: string };
// The scraper returns a proxy URL pointing to /api/audio-proxy/... — we rewrite
// it to our own /api/audio/[slug]/[n]?... so the browser never calls the scraper directly.
const voice = body.voice ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
// 202 Accepted: generation enqueued — return job_id + status for polling.
if (scraperRes.status === 202 || 'job_id' in data) {
return new Response(JSON.stringify(data), {
status: 202,
headers: { 'Content-Type': 'application/json' }
});
}
// 200: audio was already cached — rewrite the proxy URL through our own handler.
const cached = data as { url: string; filename: string };
return new Response(
JSON.stringify({
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
filename: data.filename
filename: cached.filename
}),
{ headers: { 'Content-Type': 'application/json' } }
);

View File

@@ -0,0 +1,67 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
/**
* GET /api/audio/status/[slug]/[n]?voice=...
* Proxies the audio generation status check to the scraper's
* GET /api/audio/status/{slug}/{n} endpoint.
*
* Possible responses from scraper (passed through as-is):
* {"status":"done","url":"/api/audio-proxy/...","filename":"..."}
* {"status":"pending"|"generating","job_id":"..."}
* {"status":"idle"}
* {"status":"failed","error":"..."}
*
* When status is "done" the scraper returns a proxy URL pointing to its own
* /api/audio-proxy/... — we rewrite this to our own
* /api/audio/[slug]/[n]?voice=... so the browser never calls the scraper.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
const voice = url.searchParams.get('voice') ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
const scraperRes = await fetch(
`${SCRAPER_URL}/api/audio/status/${slug}/${chapter}?${qs.toString()}`
);
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper audio status check failed', {
slug,
chapter,
status: scraperRes.status,
body: text
});
error(scraperRes.status as Parameters<typeof error>[0], text || 'Status check failed');
}
const data = (await scraperRes.json()) as {
status: string;
job_id?: string;
url?: string;
filename?: string;
error?: string;
};
// Rewrite the proxy URL if the audio is done so it routes through us.
if (data.status === 'done' && data.url) {
const rewrittenQs = new URLSearchParams();
if (voice) rewrittenQs.set('voice', voice);
data.url = `/api/audio/${slug}/${chapter}?${rewrittenQs.toString()}`;
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};