add Ready to Listen feature: audio book shelf on home + /listen browse page
Some checks failed
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Failing after 45s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped

- 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
This commit is contained in:
root
2026-04-12 10:18:40 +05:00
parent 53edb6fdef
commit 015cb8a0cd
6 changed files with 361 additions and 5 deletions

View File

@@ -1165,6 +1165,60 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
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<AudioBookEntry[]> {
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
if (cached) return cached.slice(0, limit);
// Count done jobs per slug
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
const countBySlug = new Map<string, number>();
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<chapter> to deduplicate voice variants
const chapsBySlug = new Map<string, Set<number>>();
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 {