From 015cb8a0cd75ecae4161be37db0e99f4d212d6d2 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Apr 2026 10:18:40 +0500 Subject: [PATCH] add Ready to Listen feature: audio book shelf on home + /listen browse page - getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min - GET /api/audio/books endpoint - home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable - /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state --- ui/src/lib/server/pocketbase.ts | 54 ++++++ ui/src/routes/+page.server.ts | 16 +- ui/src/routes/+page.svelte | 66 +++++++- ui/src/routes/api/audio/books/+server.ts | 17 ++ ui/src/routes/listen/+page.server.ts | 11 ++ ui/src/routes/listen/+page.svelte | 202 +++++++++++++++++++++++ 6 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 ui/src/routes/api/audio/books/+server.ts create mode 100644 ui/src/routes/listen/+page.server.ts create mode 100644 ui/src/routes/listen/+page.svelte diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index e025a4f..70d3522 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1165,6 +1165,60 @@ export async function getSlugsWithAudio(): Promise> { return new Set(jobs.map((j) => j.slug)); } +/** + * Returns books that have at least one completed audio chapter, sorted by + * number of narrated chapters descending. + * Cached for 5 minutes (same TTL as the catalogue audio badge). + */ +const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count'; +const AUDIO_BOOKS_CACHE_TTL = 5 * 60; + +export interface AudioBookEntry { + book: Book; + audioChapters: number; +} + +export async function getBooksWithAudioCount(limit = 100): Promise { + const cached = await cache.get(AUDIO_BOOKS_CACHE_KEY); + if (cached) return cached.slice(0, limit); + + // Count done jobs per slug + const jobs = await listAll('audio_jobs', 'status="done"', 'slug'); + const countBySlug = new Map(); + for (const j of jobs) { + // audio_jobs can have multiple voice variants for the same chapter — deduplicate + // by chapter number so we count chapters, not voice variants. + // cache_key format: "slug/chapter/voice" + const slug = j.slug; + if (!countBySlug.has(slug)) countBySlug.set(slug, 0); + // We'll use a Set per slug after this loop instead + } + // Build slug → Set to deduplicate voice variants + const chapsBySlug = new Map>(); + for (const j of jobs) { + if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set()); + chapsBySlug.get(j.slug)!.add(j.chapter); + } + + const slugs = [...chapsBySlug.keys()]; + if (slugs.length === 0) return []; + + const books = await getBooksBySlugs(slugs); + const bookMap = new Map(books.map((b) => [b.slug, b])); + + const entries: AudioBookEntry[] = []; + for (const [slug, chapters] of chapsBySlug) { + const book = bookMap.get(slug); + if (!book) continue; + entries.push({ book, audioChapters: chapters.size }); + } + // Sort by most chapters narrated first + entries.sort((a, b) => b.audioChapters - a.audioChapters); + + await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL); + return entries.slice(0, limit); +} + // ─── Translation jobs ───────────────────────────────────────────────────────── export interface TranslationJob { diff --git a/ui/src/routes/+page.server.ts b/ui/src/routes/+page.server.ts index 102a702..387846c 100644 --- a/ui/src/routes/+page.server.ts +++ b/ui/src/routes/+page.server.ts @@ -6,7 +6,8 @@ import { getHomeStats, getSubscriptionFeed, getTrendingBooks, - getRecommendedBooks + getRecommendedBooks, + getBooksWithAudioCount } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import type { Book, Progress } from '$lib/server/pocketbase'; @@ -87,8 +88,8 @@ export const load: PageServerLoad = async ({ locals }) => { const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); - // Fetch trending, recommendations, and subscription feed in parallel - const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([ + // Fetch trending, recommendations, subscription feed, and audio books in parallel + const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([ getTrendingBooks(8).catch(() => [] as Book[]), topGenres.length > 0 ? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[]) @@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => { log.error('home', 'failed to load subscription feed', { err: String(e) }); return [] as Awaited>; }) - : Promise.resolve([]) + : Promise.resolve([]), + getBooksWithAudioCount(20).catch(() => []) ]); // Strip books the user is already reading from trending (redundant) const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug)); + // Strip already-reading books from audio shelf; cap at 8 + const readyToListen = audioBooks + .filter((e) => !inProgressSlugs.has(e.book.slug)) + .slice(0, 8); + return { continueInProgress, continueCompleted, @@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => { subscriptionFeed, trendingBooks: trendingFiltered, recommendedBooks, + readyToListen, topGenre: topGenres[0] ?? null, stats: { ...stats, diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 25f0b03..b07f461 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -8,7 +8,7 @@ let { data }: { data: PageData } = $props(); // ── Section visibility ──────────────────────────────────────────────────────── - type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read'; + type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen'; const SECTIONS_KEY = 'home_sections_v1'; function loadHidden(): Set { @@ -40,6 +40,7 @@ 'from-following': 'From Following', 'trending': 'Trending Now', 'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations', + 'ready-to-listen': 'Ready to Listen', }); const hiddenList = $derived( @@ -307,6 +308,69 @@ {/if} + +{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')} +
+
+

Ready to Listen

+
+ View all + +
+
+
+ {#each data.readyToListen as { book, audioChapters }} + {@const genres = parseGenres(book.genres)} +
+ +
+ {#if book.cover} + {book.title} + {:else} +
+ +
+ {/if} + + + + {audioChapters} ch + +
+
+
+ +

{book.title ?? ''}

+
+ {#if genres.length > 0} +
+ {#each genres.slice(0, 2) as genre} + {genre} + {/each} +
+ {/if} +
+ + +
+ {/each} +
+
+{/if} + {#if !hidden.has('browse-genre')}
diff --git a/ui/src/routes/api/audio/books/+server.ts b/ui/src/routes/api/audio/books/+server.ts new file mode 100644 index 0000000..7a23f2a --- /dev/null +++ b/ui/src/routes/api/audio/books/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getBooksWithAudioCount } from '$lib/server/pocketbase'; + +/** + * GET /api/audio/books + * Returns books that have at least one completed narrated chapter, + * sorted by number of narrated chapters descending. + * Cached 5 minutes at the CDN/proxy level. + */ +export const GET: RequestHandler = async () => { + const entries = await getBooksWithAudioCount(100).catch(() => []); + return json( + { books: entries }, + { headers: { 'Cache-Control': 'public, max-age=300' } } + ); +}; diff --git a/ui/src/routes/listen/+page.server.ts b/ui/src/routes/listen/+page.server.ts new file mode 100644 index 0000000..c705119 --- /dev/null +++ b/ui/src/routes/listen/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; +import { getBooksWithAudioCount } from '$lib/server/pocketbase'; + +export const load: PageServerLoad = async ({ url }) => { + const sort = url.searchParams.get('sort') ?? 'chapters'; + const q = url.searchParams.get('q') ?? ''; + + const audioBooks = await getBooksWithAudioCount(200).catch(() => []); + + return { audioBooks, sort, q }; +}; diff --git a/ui/src/routes/listen/+page.svelte b/ui/src/routes/listen/+page.svelte new file mode 100644 index 0000000..75603cb --- /dev/null +++ b/ui/src/routes/listen/+page.svelte @@ -0,0 +1,202 @@ + + + + Narrated Books — LibNovel + + + +
+
+ + + +

Narrated Books

+
+

Books with generated TTS audio ready to listen

+
+ + +
+ +
+ + +
+ + +
+ {#each [['chapters', 'Most narrated'], ['title', 'A–Z'], ['recent', 'Recent']] as [val, label]} + + {/each} +
+
+ + +{#if filtered.length > 0} +

{filtered.length} book{filtered.length !== 1 ? 's' : ''}

+{/if} + + +{#if filtered.length === 0} +
+ {#if q.trim()} +

No results for "{q}"

+

Try a different search term.

+ {:else} +

No narrated books yet

+

Audio is generated as books are read. Check back soon.

+ {/if} +
+{:else} +
+ {#each filtered as { book, audioChapters }} + {@const genres = parseGenres(book.genres)} +
+ +
+ {#if book.cover} + {book.title} + {:else} +
+ + + +
+ {/if} + + + + {audioChapters} ch + +
+
+ +
+ +

{book.title ?? ''}

+
+ {#if book.author} +

{book.author}

+ {/if} + {#if genres.length > 0} +
+ {#each genres.slice(0, 2) as genre} + {genre} + {/each} +
+ {/if} +
+ + +
+ + + + + + +
+
+ {/each} +
+{/if}