Files
libnovel/ui/src/routes/+page.server.ts
root 015cb8a0cd
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
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
2026-04-12 10:18:40 +05:00

130 lines
4.2 KiB
TypeScript

import type { PageServerLoad } from './$types';
import {
getBooksBySlugs,
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed,
getTrendingBooks,
getRecommendedBooks,
getBooksWithAudioCount
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
function parseGenresLocal(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
function computeStreak(progressList: Progress[]): number {
const days = new Set(
progressList.filter((p) => p.updated).map((p) => p.updated.slice(0, 10))
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
if (days.has(d.toISOString().slice(0, 10))) streak++;
else if (i > 0) break;
}
return streak;
}
export const load: PageServerLoad = async ({ locals }) => {
let recentBooks: Book[] = [];
let progressList: Progress[] = [];
let stats = { totalBooks: 0, totalChapters: 0 };
try {
[recentBooks, progressList, stats] = await Promise.all([
recentlyUpdatedBooks(8).catch(() => [] as Book[]),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);
} catch (e) {
log.error('home', 'failed to load home data', { err: String(e) });
}
const streak = computeStreak(progressList);
// Fetch only the books we need for continue-reading (avoid loading all books)
const progressSlugs = progressList.map((p) => p.slug);
const progressBooks = progressSlugs.length > 0
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
: [];
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
// All continue-reading entries joined with book data, most recent first
const continueReading = progressList
.filter((p) => bookMap.has(p.slug))
.slice(0, 8)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
// Split into in-progress vs completed
const continueInProgress = continueReading.filter(
({ book, chapter }) => book.total_chapters === 0 || chapter < book.total_chapters
);
const continueCompleted = continueReading.filter(
({ book, chapter }) => book.total_chapters > 0 && chapter >= book.total_chapters
);
// Top genres from books the user has been reading
const genreFreq = new Map<string, number>();
for (const { book } of continueReading) {
for (const g of parseGenresLocal(book.genres)) {
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
}
}
const topGenres = [...genreFreq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
// Deduplicate recently-updated against in-progress slugs
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, 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[])
: Promise.resolve([] as Book[]),
locals.user
? getSubscriptionFeed(locals.user.id, 12).catch((e) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: 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,
recentlyUpdated,
subscriptionFeed,
trendingBooks: trendingFiltered,
recommendedBooks,
readyToListen,
topGenre: topGenres[0] ?? null,
stats: {
...stats,
booksInProgress: continueInProgress.length,
streak
}
};
};