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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user