Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51adf0e7a5 | ||
|
|
5bc69ad9ce | ||
|
|
c09d9d0ca2 |
@@ -559,6 +559,11 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
||||
go func() {
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer jobCancel()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("admin: image-gen goroutine panic", "job_id", jobID, "recover", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if jobCtx.Err() != nil {
|
||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
@@ -625,13 +630,19 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
||||
Guidance: capturedReq.Guidance,
|
||||
})
|
||||
|
||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(domain.TaskStatusDone),
|
||||
"items_done": 1,
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(domain.TaskStatusDone),
|
||||
"items_done": 1,
|
||||
"items_total": 1,
|
||||
"payload": string(resultJSON),
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
logger.Error("admin: image-gen failed to mark job done", "job_id", jobID, "err", err)
|
||||
}
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"payload": string(resultJSON),
|
||||
}); err != nil {
|
||||
logger.Error("admin: image-gen failed to write job payload", "job_id", jobID, "err", err)
|
||||
}
|
||||
|
||||
logger.Info("admin: image-gen async done",
|
||||
"job_id", jobID, "slug", capturedReq.Slug,
|
||||
|
||||
@@ -516,6 +516,11 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
||||
go func() {
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer jobCancel()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("admin: text-gen chapter-names goroutine panic", "job_id", jobID, "recover", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var allResults []proposedChapterTitle
|
||||
chaptersDone := 0
|
||||
@@ -574,12 +579,18 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
||||
if jobCtx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
"payload": finalPayload,
|
||||
})
|
||||
}); err != nil {
|
||||
logger.Error("admin: text-gen chapter-names failed to mark job done", "job_id", jobID, "err", err)
|
||||
}
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"payload": finalPayload,
|
||||
}); err != nil {
|
||||
logger.Error("admin: text-gen chapter-names failed to write job payload", "job_id", jobID, "err", err)
|
||||
}
|
||||
logger.Info("admin: text-gen chapter-names async done",
|
||||
"job_id", jobID, "slug", capturedSlug,
|
||||
"results", len(allResults), "status", string(status))
|
||||
@@ -902,6 +913,11 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
||||
go func() {
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer jobCancel()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("admin: text-gen description goroutine panic", "job_id", jobID, "recover", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if jobCtx.Err() != nil {
|
||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
@@ -955,13 +971,19 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
||||
NewDescription: strings.TrimSpace(newDesc),
|
||||
})
|
||||
|
||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(domain.TaskStatusDone),
|
||||
"items_done": 1,
|
||||
"items_total": 1,
|
||||
"payload": string(resultJSON),
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}); err != nil {
|
||||
logger.Error("admin: text-gen description failed to mark job done", "job_id", jobID, "err", err)
|
||||
}
|
||||
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"payload": string(resultJSON),
|
||||
}); err != nil {
|
||||
logger.Error("admin: text-gen description failed to write job payload", "job_id", jobID, "err", err)
|
||||
}
|
||||
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
||||
}()
|
||||
|
||||
|
||||
@@ -237,14 +237,18 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
|
||||
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
||||
return text, nil
|
||||
}
|
||||
// Fall back: array of objects with a "generated_text" field.
|
||||
// Fall back: array of objects with a "generated_text" field
|
||||
// (older CF AI models return [{"generated_text":"..."}]).
|
||||
var arr []struct {
|
||||
GeneratedText string `json:"generated_text"`
|
||||
}
|
||||
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
|
||||
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 && arr[0].GeneratedText != "" {
|
||||
return arr[0].GeneratedText, nil
|
||||
}
|
||||
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
|
||||
// Final fallback: model returned the result directly as a JSON value
|
||||
// (e.g. Llama 4 Scout returns [{"number":1,"title":"..."},...] directly).
|
||||
// Return the raw JSON bytes as a string so callers can parse it themselves.
|
||||
return string(wrapper.Result.Response), nil
|
||||
}
|
||||
|
||||
// Models returns all supported text generation model metadata.
|
||||
|
||||
@@ -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 ?? '' });
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
</div>
|
||||
{:else if !html}
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
<!-- ── Content unavailable ─────────────────────────────────────────── -->
|
||||
<div class="mt-8 rounded-xl border border-(--color-border) bg-(--color-surface-2) overflow-hidden">
|
||||
<!-- Banner — visible to all users -->
|
||||
<div class="flex items-start gap-4 p-6">
|
||||
<div class="w-10 h-10 rounded-full bg-(--color-surface-3) flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg class="w-5 h-5 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-(--color-text) mb-1">Chapter content not available</p>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin toolbar — only visible to admins -->
|
||||
{#if data.isAdmin}
|
||||
<div class="border-t border-(--color-border)">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
class="w-full flex items-center gap-2 px-5 py-3 text-xs font-semibold text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)/40 transition-colors text-left"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Admin Tools
|
||||
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if adminOpen}
|
||||
<div class="px-5 pb-5 flex flex-col gap-4 border-t border-(--color-border)">
|
||||
|
||||
<!-- Source URL info -->
|
||||
{#if data.book.source_url}
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-(--color-muted) mb-1">Source URL</p>
|
||||
<a href={data.book.source_url} target="_blank" rel="noopener noreferrer"
|
||||
class="text-xs text-(--color-brand) hover:underline break-all">
|
||||
{data.book.source_url}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrape this chapter -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Scrape Chapter {data.chapter.number}</p>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onclick={scrapeThisChapter}
|
||||
disabled={scrapeStatus === 'busy' || !data.book.source_url}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if scrapeStatus === 'busy'}
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Scraping…
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Re-scrape ch.{data.chapter.number}
|
||||
{/if}
|
||||
</button>
|
||||
{#if !data.book.source_url}
|
||||
<span class="text-xs text-(--color-muted)">No source URL on this book.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if scrapeProgress}
|
||||
<p class="text-xs {scrapeStatus === 'error' ? 'text-red-400' : 'text-green-400'}">{scrapeProgress}</p>
|
||||
{:else if scrapeStatus === 'queued'}
|
||||
<p class="text-xs text-(--color-muted)">Queued — polling for completion…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Quick links</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/admin/scrape" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Scrape dashboard
|
||||
</a>
|
||||
<a href="/books/{data.book.slug}" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Book page
|
||||
</a>
|
||||
<a href="https://pb.libnovel.cc/_/" target="_blank" rel="noopener noreferrer"
|
||||
class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
PocketBase ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
||||
|
||||
Reference in New Issue
Block a user