- 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
130 lines
4.2 KiB
TypeScript
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
|
|
}
|
|
};
|
|
};
|