diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index c24c4b5..a6eb5e8 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -706,7 +706,7 @@ func (s *Store) ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error) } func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) { - filter := fmt.Sprintf(`cache_key=%q`, cacheKey) + filter := fmt.Sprintf(`cache_key='%s'`, cacheKey) items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started") if err != nil || len(items) == 0 { return domain.AudioTask{}, false, err diff --git a/docker-compose.yml b/docker-compose.yml index ad6e686..d749877 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -154,12 +154,13 @@ services: # No public port — all traffic is routed via Caddy. expose: - "8080" - environment: + environment: <<: *infra-env BACKEND_HTTP_ADDR: ":8080" LOG_LEVEL: "${LOG_LEVEL}" KOKORO_URL: "${KOKORO_URL}" KOKORO_VOICE: "${KOKORO_VOICE}" + POCKET_TTS_URL: "${POCKET_TTS_URL}" GLITCHTIP_DSN: "${GLITCHTIP_DSN}" OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}" OTEL_SERVICE_NAME: "backend" diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index 0aa9a3b..bd7b337 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -343,23 +343,28 @@ // ── API helpers ──────────────────────────────────────────────────────────── + type PresignResult = + | { ready: true; url: string } + | { ready: false; enqueued: boolean }; // enqueued=true → presign already POSTed + async function tryPresign( targetSlug: string, targetChapter: number, targetVoice: string - ): Promise { + ): Promise { const params = new URLSearchParams({ slug: targetSlug, n: String(targetChapter), voice: targetVoice }); const res = await fetch(`/api/presign/audio?${params}`); - // 202: TTS was just enqueued by the presign endpoint — audio not ready yet. + // 202: presign endpoint already triggered TTS — skip the POST, go straight to polling. // 404: legacy fallback (should no longer occur after endpoint change). - if (res.status === 202 || res.status === 404) return null; + if (res.status === 202) return { ready: false, enqueued: true }; + if (res.status === 404) return { ready: false, enqueued: false }; if (!res.ok) throw new Error(`presign HTTP ${res.status}`); const data = (await res.json()) as { url: string }; - return data.url; + return { ready: true, url: data.url }; } type AudioStatusResponse = @@ -421,50 +426,52 @@ try { // Fast path: already generated - const url = await tryPresign(slug, nextChapter, voice); - if (url) { + const presignResult = await tryPresign(slug, nextChapter, voice); + if (presignResult.ready) { stopNextProgress(); audioStore.nextProgress = 100; - audioStore.nextAudioUrl = url; + audioStore.nextAudioUrl = presignResult.url; audioStore.nextStatus = 'prefetched'; return; } - // Slow path: trigger Kokoro generation (non-blocking POST), then poll. - const res = await fetch(`/api/audio/${slug}/${nextChapter}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ voice }) - }); - if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`); + // Slow path: trigger generation (or skip POST if presign already enqueued). + if (!presignResult.enqueued) { + const res = await fetch(`/api/audio/${slug}/${nextChapter}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voice }) + }); + if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`); - // Whether the server returned 200 (already cached) or 202 (enqueued), - // always presign — the status endpoint no longer returns a proxy URL. - if (res.status === 200) { - // Body is { status: 'done' } — audio confirmed in MinIO. Presign it. - await res.body?.cancel(); - } - // else 202: generation enqueued — fall through to poll. - - if (res.status !== 200) { - // 202: poll until done. - const final = await pollAudioStatus(slug, nextChapter, voice); - stopNextProgress(); - audioStore.nextProgress = 100; - - if (final.status === 'failed') { - throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`); + if (res.status === 200) { + // Body is { status: 'done' } — audio confirmed in MinIO. Presign it. + await res.body?.cancel(); + stopNextProgress(); + audioStore.nextProgress = 100; + const doneUrl = await tryPresign(slug, nextChapter, voice); + if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404'); + audioStore.nextAudioUrl = doneUrl.url; + audioStore.nextStatus = 'prefetched'; + return; } - } else { - stopNextProgress(); - audioStore.nextProgress = 100; + // 202: generation enqueued — fall through to poll. + } + + // Poll until done (covers both: presign-enqueued and POST-enqueued paths). + const final = await pollAudioStatus(slug, nextChapter, voice); + stopNextProgress(); + audioStore.nextProgress = 100; + + if (final.status === 'failed') { + throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`); } // Audio is ready in MinIO — get a direct presigned URL. const doneUrl = await tryPresign(slug, nextChapter, voice); - if (!doneUrl) throw new Error('Prefetch: audio done but presign returned 404'); + if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404'); - audioStore.nextAudioUrl = doneUrl; + audioStore.nextAudioUrl = doneUrl.url; audioStore.nextStatus = 'prefetched'; } catch { stopNextProgress(); @@ -532,9 +539,9 @@ } // Fast path B: audio already in MinIO (presign check). - const url = await tryPresign(slug, chapter, voice); - if (url) { - audioStore.audioUrl = url; + const presignResult = await tryPresign(slug, chapter, voice); + if (presignResult.ready) { + audioStore.audioUrl = presignResult.url; audioStore.status = 'ready'; // Restore last saved position after the audio element loads restoreSavedAudioTime(); @@ -547,33 +554,44 @@ audioStore.status = 'generating'; startProgress(); - const res = await fetch(`/api/audio/${slug}/${chapter}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ voice }) - }); - if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); + // presignResult.enqueued=true means /api/presign/audio already POSTed on our + // behalf — skip the duplicate POST and go straight to polling. + if (!presignResult.enqueued) { + const res = await fetch(`/api/audio/${slug}/${chapter}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voice }) + }); + if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); - if (res.status !== 200) { - // 202: generation enqueued — poll until done. - const final = await pollAudioStatus(slug, chapter, voice); - - if (final.status === 'failed') { - throw new Error( - `Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}` - ); + if (res.status === 200) { + // Already cached — body is { status: 'done' }, no url needed. + await res.body?.cancel(); + await finishProgress(); + const doneUrl = await tryPresign(slug, chapter, voice); + if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404'); + audioStore.audioUrl = doneUrl.url; + audioStore.status = 'ready'; + maybeStartPrefetch(); + return; } - } else { - // 200: already cached — body is { status: 'done' }, no url needed. - await res.body?.cancel(); + // 202: fall through to polling below. + } + + // Poll until the runner finishes generating. + const final = await pollAudioStatus(slug, chapter, voice); + if (final.status === 'failed') { + throw new Error( + `Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}` + ); } await finishProgress(); // Audio is ready in MinIO — always use a presigned URL for direct playback. const doneUrl = await tryPresign(slug, chapter, voice); - if (!doneUrl) throw new Error('Audio generated but presign returned 404'); - audioStore.audioUrl = doneUrl; + if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404'); + audioStore.audioUrl = doneUrl.url; audioStore.status = 'ready'; // Don't restore time for freshly generated audio — position is 0 // Immediately start pre-generating the next chapter in background.