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

@@ -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' }
});
};