From 44f81bbf5cb8553d1a6bf1eb29ec5ca41569326d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Apr 2026 11:13:36 +0500 Subject: [PATCH] surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/src/lib/server/pocketbase.ts | 41 +++++++ ui/src/routes/api/audio/chapters/+server.ts | 18 +++ .../books/[slug]/chapters/+page.server.ts | 16 ++- .../routes/books/[slug]/chapters/+page.svelte | 109 +++++++++++++----- .../books/[slug]/chapters/[n]/+page.server.ts | 26 +++-- .../books/[slug]/chapters/[n]/+page.svelte | 28 +++++ 6 files changed, 198 insertions(+), 40 deletions(-) create mode 100644 ui/src/routes/api/audio/chapters/+server.ts diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 70d3522..53b9f13 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1219,6 +1219,47 @@ export async function getBooksWithAudioCount(limit = 100): Promise> { + 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('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(); + 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(); + 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 { diff --git a/ui/src/routes/api/audio/chapters/+server.ts b/ui/src/routes/api/audio/chapters/+server.ts new file mode 100644 index 0000000..627437f --- /dev/null +++ b/ui/src/routes/api/audio/chapters/+server.ts @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getReadyChaptersForSlug } from '$lib/server/pocketbase'; + +export const GET: RequestHandler = async ({ url }) => { + const slug = url.searchParams.get('slug') ?? ''; + if (!slug) error(400, 'slug is required'); + + const voice = url.searchParams.get('voice') ?? ''; + const readyMap = await getReadyChaptersForSlug(slug, voice); + + // Return array of { chapter, voice } pairs + const chapters = [...readyMap.entries()].map(([chapter, v]) => ({ chapter, voice: v })); + + return json({ chapters }, { + headers: { 'Cache-Control': 'public, max-age=60' } + }); +}; diff --git a/ui/src/routes/books/[slug]/chapters/+page.server.ts b/ui/src/routes/books/[slug]/chapters/+page.server.ts index 9352791..8a0a261 100644 --- a/ui/src/routes/books/[slug]/chapters/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/+page.server.ts @@ -1,6 +1,6 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase'; +import { getBook, listChapterIdx, getProgress, getReadyChaptersForSlug } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ params, locals }) => { @@ -13,20 +13,26 @@ export const load: PageServerLoad = async ({ params, locals }) => { if (!book) error(404, `Book "${slug}" not found`); - let chapters, progress; + let chapters, progress, readyMap; try { - [chapters, progress] = await Promise.all([ + [chapters, progress, readyMap] = await Promise.all([ listChapterIdx(slug), - getProgress(locals.sessionId, slug, locals.user?.id) + getProgress(locals.sessionId, slug, locals.user?.id), + getReadyChaptersForSlug(slug).catch(() => new Map()) ]); } catch (e) { log.error('chapters', 'failed to load chapters', { slug, err: String(e) }); throw error(500, 'Failed to load chapters'); } + // Serialize Map as plain object for SvelteKit data transfer + const readyChapters: Record = {}; + for (const [ch, voice] of readyMap) readyChapters[ch] = voice; + return { book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters }, chapters, - lastChapter: progress?.chapter ?? null + lastChapter: progress?.chapter ?? null, + readyChapters }; }; diff --git a/ui/src/routes/books/[slug]/chapters/+page.svelte b/ui/src/routes/books/[slug]/chapters/+page.svelte index 0606937..adad155 100644 --- a/ui/src/routes/books/[slug]/chapters/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/+page.svelte @@ -1,4 +1,6 @@ @@ -600,6 +607,27 @@ {:else} + + {#if data.audioReady && !(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active)} +
+ + + +
+

Audio ready

+

This chapter has been narrated — listen instantly

+
+ +
+ {/if} +