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
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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user