diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index 82c428d..dddcec6 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -567,7 +567,30 @@ return; } - // Slow path: trigger Kokoro generation (non-blocking POST), then poll. + // Slow path: audio not yet in MinIO. + // + // For Kokoro / PocketTTS when presign has NOT already enqueued the runner: + // use the streaming endpoint — audio starts playing within seconds while + // generation runs and MinIO is populated concurrently. + // Skip when enqueued=true to avoid double-generation with the async runner. + if (!voice.startsWith('cfai:') && !presignResult.enqueued) { + const qs = new URLSearchParams({ voice, format: 'mp3' }); + const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`; + // HEAD probe: check paywall without triggering generation. + const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null); + if (headRes?.status === 402) { + audioStore.status = 'idle'; + onProRequired?.(); + return; + } + audioStore.audioUrl = streamUrl; + audioStore.status = 'ready'; + maybeStartPrefetch(); + return; + } + + // CF AI (batch-only) or already enqueued by presign: keep the traditional + // POST → poll → presign flow. For enqueued, we skip the POST and poll. audioStore.status = 'generating'; startProgress(); diff --git a/ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts b/ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts new file mode 100644 index 0000000..33e147c --- /dev/null +++ b/ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts @@ -0,0 +1,134 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; +import * as cache from '$lib/server/cache'; + +const FREE_DAILY_AUDIO_LIMIT = 3; + +function dailyAudioKey(identifier: string): string { + const today = new Date().toISOString().slice(0, 10); + return `audio:daily:${identifier}:${today}`; +} + +/** + * Return the number of audio chapters a user/session has generated today, + * and increment the counter. Shared logic with POST /api/audio/[slug]/[n]. + * + * Key: audio:daily:: + */ +async function incrementDailyAudioCount(identifier: string): Promise { + const key = dailyAudioKey(identifier); + const now = new Date(); + const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); + const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000); + try { + const raw = await cache.get(key); + const current = (raw ?? 0) + 1; + await cache.set(key, current, ttl); + return current; + } catch { + // On cache failure, fail open (don't block audio for cache errors) + return 0; + } +} + +/** + * GET /api/audio-stream/[slug]/[n]?voice=... + * + * Proxies the backend's streaming TTS endpoint to the browser. + * + * Fast path: if audio already in MinIO, backend sends a 302 → fetch follows it + * and we stream the MinIO audio through. + * + * Slow path (Kokoro/PocketTTS): backend streams audio bytes as they are + * generated. The browser receives the first bytes within seconds and can start + * playing before generation completes. MinIO upload happens concurrently + * server-side so subsequent requests use the cached fast path. + * + * Slow path (CF AI): backend buffers the full response (batch API limitation) + * before sending — effectively the same as the old POST+poll approach but + * without the separate progress-bar flow. Prefer the traditional POST route + * for CF AI voices to preserve the generating UI. + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + 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') ?? ''; + + // ── Paywall: 3 audio chapters/day for free users ───────────────────────── + // Only count when the audio is not already cached — same rule as POST route. + if (!locals.isPro) { + const statusRes = await backendFetch( + `/api/audio/status/${slug}/${chapter}${voice ? `?voice=${encodeURIComponent(voice)}` : ''}` + ).catch(() => null); + const statusData = statusRes?.ok + ? ((await statusRes.json().catch(() => ({}))) as { status?: string }) + : {}; + + if (statusData.status !== 'done') { + const identifier = locals.user?.id ?? locals.sessionId; + const count = await incrementDailyAudioCount(identifier); + if (count > FREE_DAILY_AUDIO_LIMIT) { + log.info('polar', 'free audio stream limit reached', { identifier, count }); + return new Response( + JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }), + { status: 402, headers: { 'Content-Type': 'application/json' } } + ); + } + } + } + + const qs = new URLSearchParams({ format: 'mp3' }); + if (voice) qs.set('voice', voice); + + // fetch() follows the backend's 302 (MinIO fast path) automatically. + const backendRes = await backendFetch(`/api/audio-stream/${slug}/${chapter}?${qs}`); + + if (!backendRes.ok) { + const text = await backendRes.text().catch(() => ''); + log.error('audio-stream', 'backend stream failed', { + slug, + chapter, + status: backendRes.status, + body: text + }); + error(backendRes.status as Parameters[0], text || 'Audio stream failed'); + } + + // Stream the response body directly — no buffering. + return new Response(backendRes.body, { + status: 200, + headers: { + 'Content-Type': backendRes.headers.get('Content-Type') ?? 'audio/mpeg', + 'Cache-Control': 'no-store', + 'X-Accel-Buffering': 'no' + } + }); +}; + +/** + * HEAD /api/audio-stream/[slug]/[n]?voice=... + * + * Paywall pre-check without incrementing the daily counter or triggering + * any generation. The AudioPlayer uses this to surface the upgrade CTA + * before pointing the