Files
libnovel/v3/ui/src/routes/api/presign/audio/+server.ts
Admin 0ed1112b20 feat(ui): add Sentry/GlitchTip, Umami analytics, presign auto-trigger TTS, feedback link
- 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
2026-03-23 16:35:58 +05:00

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}`);
}
};