feat(ui): proxy audio generation and streaming through SvelteKit, fix hardcoded scraper URL in AudioPlayer
This commit is contained in:
@@ -55,11 +55,11 @@
|
|||||||
status = 'generating';
|
status = 'generating';
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8080/ui/audio/${slug}/${chapter}`, {
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ voice, speed })
|
body: JSON.stringify({ voice, speed })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Generation failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Generation failed: ${res.status}`);
|
||||||
// After generation, fetch the presigned URL
|
// After generation, fetch the presigned URL
|
||||||
await loadAudio();
|
await loadAudio();
|
||||||
|
|||||||
92
ui/src/routes/api/audio/[slug]/[n]/+server.ts
Normal file
92
ui/src/routes/api/audio/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/audio/[slug]/[n]
|
||||||
|
* Proxies the audio generation request to the scraper's /api/audio endpoint.
|
||||||
|
* Keeps the scraper URL server-side — the browser never needs to know it.
|
||||||
|
*
|
||||||
|
* Body: { voice?: string, speed?: number }
|
||||||
|
* Response: { url: string, filename: string }
|
||||||
|
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...&speed=...
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
|
const { slug, n } = params;
|
||||||
|
const chapter = parseInt(n, 10);
|
||||||
|
if (!slug || !chapter || chapter < 1) {
|
||||||
|
error(400, 'Invalid slug or chapter number');
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { voice?: string; speed?: number } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
// empty body is fine — scraper will use defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scraperRes.ok) {
|
||||||
|
const text = await scraperRes.text().catch(() => '');
|
||||||
|
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await scraperRes.json()) as { url: string; filename: string };
|
||||||
|
|
||||||
|
// Rewrite the proxy URL from the scraper's /ui/audio-proxy/... to our own
|
||||||
|
// /api/audio/[slug]/[n]?voice=...&speed=... so the browser never calls the scraper directly.
|
||||||
|
const voice = body.voice ?? '';
|
||||||
|
const speed = body.speed ?? 1.0;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (voice) qs.set('voice', voice);
|
||||||
|
qs.set('speed', String(speed));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
|
||||||
|
filename: data.filename
|
||||||
|
}),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audio/[slug]/[n]?voice=...&speed=...
|
||||||
|
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
|
||||||
|
* This is the URL the browser's <audio> element uses as its src.
|
||||||
|
*/
|
||||||
|
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 speed = url.searchParams.get('speed') ?? '1';
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (voice) qs.set('voice', voice);
|
||||||
|
qs.set('speed', speed);
|
||||||
|
|
||||||
|
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
|
||||||
|
|
||||||
|
if (!scraperRes.ok) {
|
||||||
|
error(scraperRes.status as Parameters<typeof error>[0], 'Audio not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the audio body through — preserve Content-Type and Content-Length.
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', scraperRes.headers.get('Content-Type') ?? 'audio/mpeg');
|
||||||
|
headers.set('Cache-Control', 'public, max-age=3600');
|
||||||
|
const cl = scraperRes.headers.get('Content-Length');
|
||||||
|
if (cl) headers.set('Content-Length', cl);
|
||||||
|
|
||||||
|
return new Response(scraperRes.body, { headers });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user