surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 21s

- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice
- GET /api/audio/chapters?slug=&voice= endpoint
- Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count
- Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
This commit is contained in:
root
2026-04-12 11:13:36 +05:00
parent a2ce907480
commit 44f81bbf5c
6 changed files with 198 additions and 40 deletions

View File

@@ -1219,6 +1219,47 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
return entries.slice(0, limit);
}
/**
* Returns a map of chapter number → best available voice for a given slug.
* "Best" means: prefer `preferredVoice` if a done job exists for it,
* otherwise fall back to any done voice for that chapter.
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
*/
export async function getReadyChaptersForSlug(
slug: string,
preferredVoice = ''
): Promise<Map<number, string>> {
const cacheKey = `audio:ready_chapters:${slug}`;
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
const raw = cached ?? await (async () => {
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
chapter: j.chapter,
voice: j.voice ?? ''
}));
await cache.set(cacheKey, result, 60);
return result;
})();
// Build chapter → voices map
const byChapter = new Map<number, string[]>();
for (const { chapter, voice } of raw) {
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
byChapter.get(chapter)!.push(voice);
}
// Resolve best voice per chapter
const result = new Map<number, string>();
for (const [chapter, voices] of byChapter) {
const best = preferredVoice && voices.includes(preferredVoice)
? preferredVoice
: voices[0] ?? '';
result.set(chapter, best);
}
return result;
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {