feat: cover retry backfill + admin force-resync for manga & chapter (0.50.0)

Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose
cover download failed on first attempt get retried — the metadata pass's
early-stop optimisation otherwise prevents the walk from revisiting them.

Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync
that refetch metadata + cover (or chapter content with force_refetch) from the
crawler source synchronously and return the refreshed row. Surfaced in the
UI as "Force resync" buttons on the manga detail and reader pages,
admin-only via session.user.is_admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 22:00:09 +02:00
parent 5c22dfdb41
commit c134bdbbde
19 changed files with 1505 additions and 17 deletions

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import { fileUrl } from '$lib/api/client';
import { fileUrl, ApiError } from '$lib/api/client';
import { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
import {
attachTag,
detachTag,
type AuthorRef,
type GenreRef,
type MangaDetail,
type TagRef
} from '$lib/api/mangas';
import { resyncManga } from '$lib/api/admin';
import { listTags, type Tag } from '$lib/api/tags';
import { session } from '$lib/session.svelte';
import Chip from '$lib/components/Chip.svelte';
@@ -16,9 +18,15 @@
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Pencil from '@lucide/svelte/icons/pencil';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props();
const manga = $derived(data.manga);
// `manga` is locally overridable so a successful force resync can
// swap in the refreshed detail (new cover URL, refreshed status,
// etc.) without a router reload. Falls back to the server-loaded
// data otherwise.
let mangaOverride = $state<MangaDetail | null>(null);
const manga = $derived<MangaDetail>(mangaOverride ?? data.manga);
const chapters = $derived(data.chapters);
const readProgress = $derived(data.readProgress);
/** Chapter row from the local chapters list when present (so we
@@ -171,6 +179,31 @@
const statusLabel = $derived(manga.status === 'completed' ? 'Completed' : 'Ongoing');
let collectionModalOpen = $state(false);
// ---- Admin force resync ----
let resyncBusy = $state(false);
let resyncMessage = $state<{ kind: 'ok' | 'err'; text: string } | null>(null);
async function forceResync() {
if (!session.user?.is_admin || resyncBusy) return;
resyncBusy = true;
resyncMessage = null;
try {
const r = await resyncManga(manga.id);
mangaOverride = r.manga;
const coverNote = r.cover_fetched
? ' Cover re-downloaded.'
: ' Cover unchanged.';
resyncMessage = {
kind: 'ok',
text: `Metadata ${r.metadata_status}.${coverNote}`
};
} catch (e) {
const msg = e instanceof ApiError ? e.message : (e as Error).message;
resyncMessage = { kind: 'err', text: msg };
} finally {
resyncBusy = false;
}
}
</script>
<svelte:head>
@@ -344,7 +377,34 @@
<UploadCloud size={16} aria-hidden="true" />
<span>Upload chapter</span>
</a>
{#if session.user.is_admin}
<button
type="button"
class="action"
onclick={forceResync}
disabled={resyncBusy}
title="Refetch metadata + cover from the crawler source"
data-testid="force-resync-manga"
>
<RefreshCw
size={16}
aria-hidden="true"
class={resyncBusy ? 'spin' : ''}
/>
<span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
</button>
{/if}
</div>
{#if resyncMessage}
<p
class="resync-msg"
class:err={resyncMessage.kind === 'err'}
role="status"
data-testid="force-resync-message"
>
{resyncMessage.text}
</p>
{/if}
{:else}
<a class="action" href="/login" data-testid="bookmark-signin">
Sign in to bookmark or collect
@@ -586,6 +646,29 @@
color: var(--text);
}
.resync-msg {
margin-top: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.resync-msg.err {
color: var(--danger);
}
:global(.spin) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.continue {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { fileUrl } from '$lib/api/client';
import { goto, invalidateAll } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
import { preferences } from '$lib/preferences.svelte';
import { updateReadProgress } from '$lib/api/read_progress';
import { resyncChapter } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -15,6 +16,7 @@
import ScrollText from '@lucide/svelte/icons/scroll-text';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let { data } = $props();
const manga = $derived(data.manga);
@@ -256,6 +258,36 @@
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
});
// ---- Admin force resync (current chapter) ----
let resyncBusy = $state(false);
let resyncMessage = $state<{ kind: 'ok' | 'err'; text: string } | null>(null);
async function forceResync() {
if (!session.user?.is_admin || resyncBusy) return;
resyncBusy = true;
resyncMessage = null;
try {
const r = await resyncChapter(chapter.id);
if (r.outcome === 'fetched') {
resyncMessage = {
kind: 'ok',
text: `Refetched ${r.pages} page${r.pages === 1 ? '' : 's'}. Reloading…`
};
// Re-run all loaders for this route so the reader picks
// up the freshly-downloaded pages. The page.ts loader
// doesn't `depends()` on anything explicitly, so
// invalidateAll is the right brush here.
await invalidateAll();
} else {
resyncMessage = { kind: 'ok', text: 'No new pages — source had nothing fresh.' };
}
} catch (e) {
const msg = e instanceof ApiError ? e.message : (e as Error).message;
resyncMessage = { kind: 'err', text: msg };
} finally {
resyncBusy = false;
}
}
// ---- Reading progress tracking ----
//
// High-water mark seeded from the server: progress only ever moves
@@ -481,6 +513,23 @@
{/if}
</span>
{#if session.user?.is_admin}
<button
type="button"
class="reader-resync"
onclick={forceResync}
disabled={resyncBusy}
title={resyncMessage?.kind === 'err'
? resyncMessage.text
: 'Force refetch this chapter from the crawler source'}
aria-label="Force resync chapter"
data-testid="force-resync-chapter"
>
<RefreshCw size={16} aria-hidden="true" class={resyncBusy ? 'spin' : ''} />
<span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
</button>
{/if}
<button
type="button"
class="fullscreen-toggle"
@@ -494,6 +543,17 @@
</button>
</nav>
{#if resyncMessage}
<p
class="resync-toast"
class:err={resyncMessage.kind === 'err'}
role="status"
data-testid="force-resync-message"
>
{resyncMessage.text}
</p>
{/if}
<!--
Floating exit affordance — only rendered while focus mode is on.
Lives in the top-right corner with a low resting opacity so it
@@ -911,7 +971,8 @@
}
/* ===== Focus-mode controls ===== */
.fullscreen-toggle {
.fullscreen-toggle,
.reader-resync {
display: inline-flex;
align-items: center;
gap: var(--space-1);
@@ -925,12 +986,52 @@
font-size: var(--font-xs);
}
.fullscreen-toggle:hover {
.fullscreen-toggle:hover,
.reader-resync:hover:not(:disabled) {
background: var(--surface-elevated);
color: var(--text);
border-color: var(--primary);
}
.reader-resync:disabled {
opacity: 0.7;
cursor: progress;
}
.resync-toast {
position: fixed;
top: calc(var(--app-header-h) + var(--reader-nav-h, 48px) + var(--space-2));
right: var(--space-3);
z-index: 11;
margin: 0;
padding: var(--space-2) var(--space-3);
max-width: min(420px, calc(100vw - 2 * var(--space-3)));
background: var(--surface);
color: var(--text);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
font-size: var(--font-sm);
}
.resync-toast.err {
border-color: var(--danger);
color: var(--danger);
}
:global(.spin) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Small floating exit affordance — corner-pinned, low resting
opacity so it doesn't sit on the chapter image too aggressively
but is still findable without hover. */