feat(preview): add on-demand book/chapter preview for unlibrary'd books

Adds GET /api/book-preview/{slug} and GET /api/chapter-text-preview/{slug}/{n}
Go endpoints that scrape live from novelfire.net without persisting to
PocketBase or MinIO. The UI book page falls back to the preview endpoint when
a book is not found in PocketBase, showing a 'not in library' badge and the
scraped chapter list. Chapter pages handle ?preview=1 to fetch and render
chapter text live, skipping MinIO and suppressing the audio player.
This commit is contained in:
Admin
2026-03-05 14:00:41 +05:00
parent fb6b364382
commit 97e7a8dc02
4 changed files with 315 additions and 26 deletions

View File

@@ -2,34 +2,99 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
// Minimal chapter shape returned by /api/book-preview
export interface PreviewChapter {
number: number;
title: string;
url: string;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
let book: Awaited<ReturnType<typeof getBook>>;
let chapters: Awaited<ReturnType<typeof listChapterIdx>>;
let progress: Awaited<ReturnType<typeof getProgress>>;
// Try fetching from PocketBase first
let book = await getBook(slug).catch((e) => {
log.error('books', 'getBook failed', { slug, err: String(e) });
return null;
});
try {
[book, chapters, progress] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load book page', { slug, err: String(e) });
throw error(500, 'Failed to load book');
if (book) {
// Book is in the library — normal path
let chapters, progress;
try {
[chapters, progress] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
throw error(500, 'Failed to load book');
}
return {
book,
chapters,
previewChapters: null as PreviewChapter[] | null,
inLib: true,
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin'
};
}
if (!book) {
log.warn('books', 'book not found', { slug });
// Book not in PocketBase — try live preview from scraper
try {
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
if (!res.ok) {
log.warn('books', 'book-preview returned error', { slug, status: res.status });
error(404, `Book "${slug}" not found`);
}
const preview: {
in_lib: boolean;
meta: {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
};
chapters: PreviewChapter[];
} = await res.json();
// Shape the meta into a Book-like object (no PocketBase id fields)
const previewBook = {
id: '',
slug: preview.meta.slug || slug,
title: preview.meta.title,
author: preview.meta.author,
cover: preview.meta.cover,
status: preview.meta.status,
genres: preview.meta.genres ?? [],
summary: preview.meta.summary,
total_chapters: preview.meta.total_chapters,
source_url: preview.meta.source_url,
ranking: 0,
meta_updated: ''
};
return {
book: previewBook,
chapters: [],
previewChapters: preview.chapters,
inLib: preview.in_lib,
lastChapter: null,
isAdmin: locals.user?.role === 'admin'
};
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('books', 'book-preview fetch failed', { slug, err: String(e) });
error(404, `Book "${slug}" not found`);
}
return {
book,
chapters,
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin'
};
};

View File

@@ -8,12 +8,81 @@ import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
export const load: PageServerLoad = async ({ params, locals }) => {
export const load: PageServerLoad = async ({ params, url, locals }) => {
const { slug } = params;
const n = parseInt(params.n, 10);
if (!n || n < 1) error(400, 'Invalid chapter number');
const isPreview = url.searchParams.get('preview') === '1';
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
const chapterTitle = url.searchParams.get('title') ?? '';
if (isPreview) {
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
const previewParams = new URLSearchParams();
if (chapterUrl) previewParams.set('chapter_url', chapterUrl);
if (chapterTitle) previewParams.set('title', chapterTitle);
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
try {
const res = await fetch(
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
);
if (!res.ok) {
log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
error(404, `Chapter ${n} not found`);
}
chapterData = await res.json();
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter preview');
}
// Wrap plain text in minimal HTML paragraphs for display
const html = chapterData.text
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: '';
// Fetch voices (non-critical for preview)
let voices: string[] = [];
try {
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
voices = d.voices ?? [];
}
} catch {
// Non-critical
}
// Try to get book title/cover from PocketBase for breadcrumbs; fall back to slug
const pb = await getBook(slug).catch(() => null);
return {
book: {
slug,
title: pb?.title ?? slug,
cover: pb?.cover ?? ''
},
chapter: {
id: '',
slug,
number: n,
title: chapterData.title || `Chapter ${n}`,
date_label: ''
},
html,
voices,
prev: null as number | null,
next: null as number | null,
sessionId: locals.sessionId,
isPreview: true
};
}
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
// Fetch book metadata, chapter index, and voice list in parallel
const [book, chapters, voicesRes] = await Promise.all([
getBook(slug),
@@ -40,8 +109,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
// Get presigned URL and fetch chapter markdown server-side
let html = '';
try {
const url = await presignChapter(slug, n);
const res = await fetch(url);
const presignUrl = await presignChapter(slug, n);
const res = await fetch(presignUrl);
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
const markdown = await res.text();
html = await marked(markdown, { async: true });
@@ -60,6 +129,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
sessionId: locals.sessionId
sessionId: locals.sessionId,
isPreview: false
};
};

View File

@@ -5,8 +5,9 @@
let { data }: { data: PageData } = $props();
// Record reading progress when the chapter is opened
// Record reading progress when the chapter is opened (skip for preview chapters)
onMount(async () => {
if (data.isPreview) return;
try {
await fetch('/api/progress', {
method: 'POST',
@@ -67,6 +68,7 @@
</div>
<!-- Audio player -->
{#if !data.isPreview}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
@@ -76,6 +78,11 @@
nextChapter={data.next}
voices={data.voices}
/>
{:else}
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
Preview chapter — audio not available for books outside the library.
</div>
{/if}
<!-- Chapter content -->
{#if !data.html}