fix(tts): fix pocket-tts voices missing in UI and 500 on first TTS enqueue
Some checks failed
CI / Test backend (pull_request) Successful in 40s
CI / Check ui (pull_request) Successful in 45s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 1m8s
CI / Docker / runner (pull_request) Failing after 35s
CI / Docker / caddy (pull_request) Successful in 3m39s
CI / Docker / ui (pull_request) Successful in 1m12s
CI / Docker / backend (pull_request) Successful in 3m24s
Release / Upload source maps (push) Failing after 43s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m51s
Release / Docker / backend (push) Failing after 7m14s
Release / Gitea Release (push) Has been skipped

- Add POCKET_TTS_URL env to backend service in docker-compose.yml so
  pocket-tts voices appear in the voice selector (Doppler secret existed
  but the env var was never passed to the container)
- Fix GetAudioTask PocketBase filter using %q (double-quotes) instead of
  single-quoted string, causing the duplicate-task guard to always miss
- Fix AudioPlayer double-POST: GET /api/presign/audio already enqueues
  TTS internally on 404; AudioPlayer now skips the redundant POST and
  polls directly, eliminating the 500 from the PB unique-key conflict
This commit is contained in:
Admin
2026-03-28 16:02:46 +05:00
parent 4a00d953bb
commit c5c167035d
3 changed files with 78 additions and 59 deletions

View File

@@ -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) { 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") items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started")
if err != nil || len(items) == 0 { if err != nil || len(items) == 0 {
return domain.AudioTask{}, false, err return domain.AudioTask{}, false, err

View File

@@ -154,12 +154,13 @@ services:
# No public port — all traffic is routed via Caddy. # No public port — all traffic is routed via Caddy.
expose: expose:
- "8080" - "8080"
environment: environment:
<<: *infra-env <<: *infra-env
BACKEND_HTTP_ADDR: ":8080" BACKEND_HTTP_ADDR: ":8080"
LOG_LEVEL: "${LOG_LEVEL}" LOG_LEVEL: "${LOG_LEVEL}"
KOKORO_URL: "${KOKORO_URL}" KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}" KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}" GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}" OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "backend" OTEL_SERVICE_NAME: "backend"

View File

@@ -343,23 +343,28 @@
// ── API helpers ──────────────────────────────────────────────────────────── // ── API helpers ────────────────────────────────────────────────────────────
type PresignResult =
| { ready: true; url: string }
| { ready: false; enqueued: boolean }; // enqueued=true → presign already POSTed
async function tryPresign( async function tryPresign(
targetSlug: string, targetSlug: string,
targetChapter: number, targetChapter: number,
targetVoice: string targetVoice: string
): Promise<string | null> { ): Promise<PresignResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
slug: targetSlug, slug: targetSlug,
n: String(targetChapter), n: String(targetChapter),
voice: targetVoice voice: targetVoice
}); });
const res = await fetch(`/api/presign/audio?${params}`); 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). // 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}`); if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
const data = (await res.json()) as { url: string }; const data = (await res.json()) as { url: string };
return data.url; return { ready: true, url: data.url };
} }
type AudioStatusResponse = type AudioStatusResponse =
@@ -421,50 +426,52 @@
try { try {
// Fast path: already generated // Fast path: already generated
const url = await tryPresign(slug, nextChapter, voice); const presignResult = await tryPresign(slug, nextChapter, voice);
if (url) { if (presignResult.ready) {
stopNextProgress(); stopNextProgress();
audioStore.nextProgress = 100; audioStore.nextProgress = 100;
audioStore.nextAudioUrl = url; audioStore.nextAudioUrl = presignResult.url;
audioStore.nextStatus = 'prefetched'; audioStore.nextStatus = 'prefetched';
return; return;
} }
// Slow path: trigger Kokoro generation (non-blocking POST), then poll. // Slow path: trigger generation (or skip POST if presign already enqueued).
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, { if (!presignResult.enqueued) {
method: 'POST', const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ voice }) headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ voice })
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`); });
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// Whether the server returned 200 (already cached) or 202 (enqueued), if (res.status === 200) {
// always presign — the status endpoint no longer returns a proxy URL. // Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
if (res.status === 200) { await res.body?.cancel();
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it. stopNextProgress();
await res.body?.cancel(); audioStore.nextProgress = 100;
} const doneUrl = await tryPresign(slug, nextChapter, voice);
// else 202: generation enqueued — fall through to poll. if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
audioStore.nextAudioUrl = doneUrl.url;
if (res.status !== 200) { audioStore.nextStatus = 'prefetched';
// 202: poll until done. return;
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'}`);
} }
} else { // 202: generation enqueued — fall through to poll.
stopNextProgress(); }
audioStore.nextProgress = 100;
// 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. // Audio is ready in MinIO — get a direct presigned URL.
const doneUrl = await tryPresign(slug, nextChapter, voice); 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'; audioStore.nextStatus = 'prefetched';
} catch { } catch {
stopNextProgress(); stopNextProgress();
@@ -532,9 +539,9 @@
} }
// Fast path B: audio already in MinIO (presign check). // Fast path B: audio already in MinIO (presign check).
const url = await tryPresign(slug, chapter, voice); const presignResult = await tryPresign(slug, chapter, voice);
if (url) { if (presignResult.ready) {
audioStore.audioUrl = url; audioStore.audioUrl = presignResult.url;
audioStore.status = 'ready'; audioStore.status = 'ready';
// Restore last saved position after the audio element loads // Restore last saved position after the audio element loads
restoreSavedAudioTime(); restoreSavedAudioTime();
@@ -547,33 +554,44 @@
audioStore.status = 'generating'; audioStore.status = 'generating';
startProgress(); startProgress();
const res = await fetch(`/api/audio/${slug}/${chapter}`, { // presignResult.enqueued=true means /api/presign/audio already POSTed on our
method: 'POST', // behalf — skip the duplicate POST and go straight to polling.
headers: { 'Content-Type': 'application/json' }, if (!presignResult.enqueued) {
body: JSON.stringify({ voice }) const res = await fetch(`/api/audio/${slug}/${chapter}`, {
}); method: 'POST',
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status !== 200) { if (res.status === 200) {
// 202: generation enqueued — poll until done. // Already cached — body is { status: 'done' }, no url needed.
const final = await pollAudioStatus(slug, chapter, voice); await res.body?.cancel();
await finishProgress();
if (final.status === 'failed') { const doneUrl = await tryPresign(slug, chapter, voice);
throw new Error( if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}` audioStore.audioUrl = doneUrl.url;
); audioStore.status = 'ready';
maybeStartPrefetch();
return;
} }
} else { // 202: fall through to polling below.
// 200: already cached — body is { status: 'done' }, no url needed. }
await res.body?.cancel();
// 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(); await finishProgress();
// Audio is ready in MinIO — always use a presigned URL for direct playback. // Audio is ready in MinIO — always use a presigned URL for direct playback.
const doneUrl = await tryPresign(slug, chapter, voice); const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl) throw new Error('Audio generated but presign returned 404'); if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl; audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready'; audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0 // Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background. // Immediately start pre-generating the next chapter in background.