diff --git a/ui/src/routes/api/scrape/task/[id]/+server.ts b/ui/src/routes/api/scrape/task/[id]/+server.ts index d4092d2..be026ee 100644 --- a/ui/src/routes/api/scrape/task/[id]/+server.ts +++ b/ui/src/routes/api/scrape/task/[id]/+server.ts @@ -15,5 +15,5 @@ export const GET: RequestHandler = async ({ params }) => { const task = await getScrapingTask(id).catch(() => null); if (!task) throw error(404, 'Task not found'); - return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' }); + return json({ id: task.id, status: task.status, chapters_scraped: task.chapters_scraped ?? 0, error_message: task.error_message ?? '' }); }; diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts index 438d745..ac534b3 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -167,17 +167,24 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { // ── Original content path ────────────────────────────────────────────── let html = ''; + let contentMissing = false; try { const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`); if (!res.ok) { log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status }); - error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content'); + if (res.status === 404) { + // Content file missing from storage — render the page shell with an + // empty body so admins can trigger a re-scrape from the page itself. + contentMissing = true; + } else { + error(502, 'Could not fetch chapter content'); + } + } else { + const markdown = await res.text(); + html = await marked(markdown); } - const markdown = await res.text(); - html = await marked(markdown); } catch (e) { if (e instanceof Error && 'status' in e) throw e; - // Don't hard-fail — show empty content with error message log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) }); error(502, 'Could not fetch chapter content'); } @@ -202,9 +209,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const nextChapter = chapters.find((c) => c.number === n + 1) ?? null; return { - book: { slug: book.slug, title: book.title, cover: book.cover ?? '' }, + book: { slug: book.slug, title: book.title, cover: book.cover ?? '', source_url: book.source_url ?? '' }, chapter: chapterIdx, html, + contentMissing, voices, prev: prevChapter ? prevChapter.number : null, next: nextChapter ? nextChapter.number : null, @@ -214,6 +222,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { lang: useTranslation ? lang : '', translationStatus, isPro: locals.isPro, + isAdmin: locals.user?.role === 'admin', chapterImageUrl, audioReady, availableVoice diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index 4311df4..3b01264 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -374,7 +374,9 @@ } // If the normal path returned no content, fall back to live preview scrape - if (!data.isPreview && !data.html) { + // (but only when contentMissing is false — if the file is genuinely absent + // from storage the preview scrape won't help either and admins can re-queue) + if (!data.isPreview && !data.html && !data.contentMissing) { fetchingContent = true; (async () => { try { @@ -406,6 +408,62 @@ html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0 ); + // ── Admin chapter tools ──────────────────────────────────────────────────── + let adminOpen = $state(false); + let scrapeStatus = $state<'idle' | 'busy' | 'queued' | 'error'>('idle'); + let scrapeTaskId = $state(''); + let scrapeProgress = $state(''); + let pollTimer = 0; + + async function scrapeThisChapter() { + if (scrapeStatus === 'busy' || !data.book.source_url) return; + scrapeStatus = 'busy'; + scrapeProgress = ''; + try { + const res = await fetch('/api/scrape/range', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: data.book.source_url, from: data.chapter.number, to: data.chapter.number }) + }); + const d = await res.json().catch(() => ({})); + if (res.ok) { + scrapeStatus = 'queued'; + scrapeTaskId = d.task_id ?? ''; + if (scrapeTaskId) startPollTask(scrapeTaskId); + } else if (res.status === 409) { + scrapeStatus = 'error'; + scrapeProgress = 'A scrape job is already running — try again shortly.'; + } else { + scrapeStatus = 'error'; + scrapeProgress = d.message ?? `Error ${res.status}`; + } + } catch (e) { + scrapeStatus = 'error'; + scrapeProgress = String(e); + } + } + + function startPollTask(id: string) { + clearTimeout(pollTimer); + pollTimer = setTimeout(async () => { + try { + const res = await fetch(`/api/scrape/task/${id}`); + if (!res.ok) return; + const d = await res.json() as { status: string; chapters_scraped?: number; error_message?: string }; + if (d.status === 'done') { + scrapeProgress = `Done — ${d.chapters_scraped ?? 0} chapter(s) scraped. Reload to see content.`; + scrapeStatus = 'queued'; + } else if (d.status === 'failed') { + scrapeProgress = `Failed: ${d.error_message ?? 'unknown error'}`; + scrapeStatus = 'error'; + } else { + scrapeProgress = `Status: ${d.status}${d.chapters_scraped ? ` (${d.chapters_scraped} done)` : ''}`; + startPollTask(id); // keep polling + } + } catch { /* ignore */ } + }, 3000) as unknown as number; + } + // Strip scraper artifacts from chapter titles: // - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...") // - Everything after the first newline (often includes a scraped date) @@ -717,8 +775,115 @@ {m.reader_fetching_chapter()} {:else if !html} -
{fetchError || m.reader_audio_error()}
+ +Chapter content not available
++ {#if data.contentMissing} + This chapter exists in the index but its content hasn't been stored yet. + {#if !data.isAdmin}It should appear soon — try refreshing in a few minutes.{/if} + {:else} + {fetchError || 'Could not load this chapter. Please try again.'} + {/if} +
+Source URL
+ + {data.book.source_url} + +Scrape Chapter {data.chapter.number}
+{scrapeProgress}
+ {:else if scrapeStatus === 'queued'} +Queued — polling for completion…
+ {/if} +Quick links
+ +