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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.49.1",
|
||||
"version": "0.50.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
createAdminUser,
|
||||
listAdminMangas,
|
||||
listAdminChapters,
|
||||
getSystemStats
|
||||
getSystemStats,
|
||||
resyncManga,
|
||||
resyncChapter
|
||||
} from './admin';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
@@ -242,4 +244,88 @@ describe('admin api client', () => {
|
||||
const s = await getSystemStats();
|
||||
expect(s.disk).toBeNull();
|
||||
});
|
||||
|
||||
// ---- force resync ----
|
||||
|
||||
it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => {
|
||||
const resp = {
|
||||
manga: {
|
||||
id: 'm-1',
|
||||
title: 'T',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: 'mangas/m-1/cover.jpg',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: []
|
||||
},
|
||||
metadata_status: 'updated',
|
||||
cover_fetched: true
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(ok(resp));
|
||||
const got = await resyncManga('m-1');
|
||||
expect(got.metadata_status).toBe('updated');
|
||||
expect(got.cover_fetched).toBe(true);
|
||||
expect(got.manga.id).toBe('m-1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/resync$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('resyncManga surfaces 503 service_unavailable when the daemon is off', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
envelope(503, 'service_unavailable', 'crawler daemon is disabled')
|
||||
);
|
||||
await expect(resyncManga('m-1')).rejects.toMatchObject({
|
||||
status: 503,
|
||||
code: 'service_unavailable'
|
||||
});
|
||||
});
|
||||
|
||||
it('resyncChapter POSTs to /v1/admin/chapters/{id}/resync and returns the envelope', async () => {
|
||||
const resp = {
|
||||
chapter: {
|
||||
id: 'c-1',
|
||||
manga_id: 'm-1',
|
||||
number: 1,
|
||||
title: 'Foo',
|
||||
page_count: 7,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
},
|
||||
outcome: 'fetched',
|
||||
pages: 7
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(ok(resp));
|
||||
const got = await resyncChapter('c-1');
|
||||
expect(got.outcome).toBe('fetched');
|
||||
expect(got.pages).toBe(7);
|
||||
expect(got.chapter.page_count).toBe(7);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/chapters\/c-1\/resync$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('resyncChapter handles the "skipped" outcome envelope', async () => {
|
||||
const resp = {
|
||||
chapter: {
|
||||
id: 'c-1',
|
||||
manga_id: 'm-1',
|
||||
number: 1,
|
||||
title: null,
|
||||
page_count: 7,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
},
|
||||
outcome: 'skipped',
|
||||
pages: null
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(ok(resp));
|
||||
const got = await resyncChapter('c-1');
|
||||
expect(got.outcome).toBe('skipped');
|
||||
expect(got.pages).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { request, type Page } from './client';
|
||||
import type { User } from './auth';
|
||||
import type { MangaDetail } from './mangas';
|
||||
import type { Chapter } from './chapters';
|
||||
|
||||
// ---- users -----------------------------------------------------------------
|
||||
|
||||
@@ -176,3 +178,39 @@ export type SystemStats = {
|
||||
export async function getSystemStats(): Promise<SystemStats> {
|
||||
return request<SystemStats>('/v1/admin/system');
|
||||
}
|
||||
|
||||
// ---- force resync ----------------------------------------------------------
|
||||
|
||||
export type MangaResyncResponse = {
|
||||
manga: MangaDetail;
|
||||
metadata_status: 'new' | 'updated' | 'unchanged';
|
||||
cover_fetched: boolean;
|
||||
};
|
||||
|
||||
export type ChapterResyncResponse = {
|
||||
chapter: Chapter;
|
||||
outcome: 'fetched' | 'skipped';
|
||||
/** Page count when `outcome === 'fetched'`; null when skipped. */
|
||||
pages: number | null;
|
||||
};
|
||||
|
||||
/** POST /v1/admin/mangas/:id/resync — refetches metadata + cover from
|
||||
* the manga's live crawler source. Long-running (one HTTP request per
|
||||
* Chromium nav + image download), so the UI should disable the trigger
|
||||
* and surface progress. */
|
||||
export async function resyncManga(id: string): Promise<MangaResyncResponse> {
|
||||
return request<MangaResyncResponse>(
|
||||
`/v1/admin/mangas/${encodeURIComponent(id)}/resync`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /v1/admin/chapters/:id/resync — force-refetches a chapter's
|
||||
* pages even if `page_count > 0`. Same long-running caveat as
|
||||
* `resyncManga`. */
|
||||
export async function resyncChapter(id: string): Promise<ChapterResyncResponse> {
|
||||
return request<ChapterResyncResponse>(
|
||||
`/v1/admin/chapters/${encodeURIComponent(id)}/resync`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user