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:
@@ -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<string | null> {
|
||||
): Promise<PresignResult> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user