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
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user