- Add @sentry/sveltekit: server-side init in hooks.server.ts, client-side in hooks.client.ts; opt-in via PUBLIC_GLITCHTIP_DSN env var; handleError wired up - Add Umami analytics: script tag in +layout.svelte (opt-in via PUBLIC_UMAMI_WEBSITE_ID); track book_view, chapter_read, audio_played events via window.umami?.track - Presign audio endpoint now auto-triggers Kokoro TTS generation when audio is missing (404 → POST /api/audio → return 202); AudioPlayer handles 202 status - Fix listAudioCache: was querying non-existent audio_cache collection; now projects from audio_jobs where status='done' - Add Feedback nav link (desktop + mobile + footer) pointing to feedback.libnovel.cc - Admin scrape page: add full catalogue scrape button; fix retryTask for catalogue kind; show retry button for all failed/cancelled tasks regardless of kind
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
import { json, error } from '@sveltejs/kit';
|
|
import type { RequestHandler } from './$types';
|
|
import { presignAudio } from '$lib/server/minio';
|
|
import { log } from '$lib/server/logger';
|
|
import * as cache from '$lib/server/presignCache';
|
|
import { backendFetch } from '$lib/server/scraper';
|
|
|
|
/**
|
|
* GET /api/presign/audio?slug=...&n=...&voice=...
|
|
*
|
|
* Returns a presigned MinIO URL for the audio file so the client can stream
|
|
* it directly without going through the server.
|
|
*
|
|
* When the audio has not been generated yet, this endpoint automatically
|
|
* enqueues a TTS generation job and returns 202 Accepted with the job status,
|
|
* so callers can poll GET /api/audio/status/{slug}/{n}?voice=... until done,
|
|
* then call this endpoint again to get the URL.
|
|
*
|
|
* Responses:
|
|
* 200 { url: string } — audio ready, stream from MinIO
|
|
* 202 { task_id: string, status: string } — TTS enqueued, poll for completion
|
|
* 202 { status: "pending"|"generating" } — TTS already in progress
|
|
*
|
|
* Results are cached in-process for 50 minutes (MinIO URLs are valid 1 hour)
|
|
* to avoid a backend + MinIO round-trip on every "Play" click.
|
|
*/
|
|
export const GET: RequestHandler = async ({ url }) => {
|
|
const slug = url.searchParams.get('slug');
|
|
// Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param
|
|
const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10);
|
|
const voice = url.searchParams.get('voice') ?? '';
|
|
|
|
if (!slug || !n || n < 1) {
|
|
error(400, 'Missing slug or n');
|
|
}
|
|
|
|
const cacheKey = cache.audioKey(slug, n, voice);
|
|
|
|
// Fast path: return cached URL if still valid.
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) {
|
|
return json({ url: cached });
|
|
}
|
|
|
|
// Slow path: call backend → MinIO presign.
|
|
try {
|
|
const presignedUrl = await presignAudio(slug, n, voice || undefined);
|
|
await cache.set(cacheKey, presignedUrl);
|
|
return json({ url: presignedUrl });
|
|
} catch (e) {
|
|
const status = (e as { status?: number }).status;
|
|
if (status !== 404) {
|
|
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
|
|
error(500, `Could not get presigned URL: ${e}`);
|
|
}
|
|
}
|
|
|
|
// Audio not found — automatically trigger TTS generation so the caller
|
|
// doesn't need a separate POST step. Return 202 with the job status.
|
|
log.info('presign', 'audio not found, triggering TTS generation', { slug, n, voice });
|
|
try {
|
|
const triggerRes = await backendFetch(`/api/audio/${slug}/${n}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(voice ? { voice } : {})
|
|
});
|
|
|
|
if (!triggerRes.ok) {
|
|
const text = await triggerRes.text().catch(() => '');
|
|
log.error('presign', 'audio trigger failed', { slug, n, status: triggerRes.status, body: text });
|
|
error(triggerRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
|
|
}
|
|
|
|
const data = (await triggerRes.json()) as
|
|
| { url: string; status: 'done' }
|
|
| { task_id: string; status: string };
|
|
|
|
// If the backend says it's already done (race: generated between presign
|
|
// check and the POST), try to presign once more and return 200.
|
|
if (triggerRes.status === 200 || ('status' in data && data.status === 'done')) {
|
|
try {
|
|
const presignedUrl = await presignAudio(slug, n, voice || undefined);
|
|
await cache.set(cacheKey, presignedUrl);
|
|
return json({ url: presignedUrl });
|
|
} catch {
|
|
// Ignore — fall through to 202 below.
|
|
}
|
|
}
|
|
|
|
// Generation is in progress — return 202 so the caller can poll.
|
|
return new Response(JSON.stringify(data), {
|
|
status: 202,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
} catch (e) {
|
|
if ((e as { status?: number }).status) throw e; // re-throw SvelteKit errors
|
|
log.error('presign', 'audio trigger error', { slug, n, err: String(e) });
|
|
error(500, `Could not trigger audio generation: ${e}`);
|
|
}
|
|
};
|