feat(analysis): admin UI — reader context-menu action + dashboard section

Surfaces the scoped re-enqueue and per-page force-analyze in the UI:

- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
  analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
  (canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
  endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
  (whole library / one manga / one chapter), id input, and an
  "include already-analyzed" toggle (default off), with busy/notice/error.

Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 19:57:03 +02:00
parent 9607488278
commit 8b7ea2e1b2
11 changed files with 551 additions and 8 deletions

View File

@@ -26,7 +26,9 @@ import {
listDeadJobs,
requeueDeadJobs,
listActiveJobs,
listMissingCovers
listMissingCovers,
reenqueueAnalysis,
analyzePage
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -494,4 +496,42 @@ describe('admin crawler api client', () => {
fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled'));
await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 });
});
it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 }));
const r = await reenqueueAnalysis();
expect(r.enqueued).toBe(42);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/);
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: true
});
});
it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 }));
await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false });
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: false,
manga_id: 'm1'
});
});
it('reenqueueAnalysis scopes to a chapter', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 }));
await reenqueueAnalysis({ chapterId: 'c1' });
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
only_unanalyzed: true,
chapter_id: 'c1'
});
});
it('analyzePage posts to the force-analyze endpoint', async () => {
fetchSpy.mockResolvedValueOnce(ok({ enqueued: true }));
const r = await analyzePage('p1');
expect(r.enqueued).toBe(true);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
});
});

View File

@@ -403,3 +403,41 @@ export async function listMissingCovers(
init
);
}
// ---- AI content analysis ---------------------------------------------------
/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId`
* are mutually exclusive; omit both to target the whole library. */
export type ReenqueueAnalysisOptions = {
/** Skip pages that already have a completed analysis (default true).
* When false, in-scope pages are force re-analyzed. */
onlyUnanalyzed?: boolean;
mangaId?: string;
chapterId?: string;
};
/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's
* pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED
* on the backend (503 otherwise). */
export async function reenqueueAnalysis(
opts: ReenqueueAnalysisOptions = {}
): Promise<{ enqueued: number }> {
const body: Record<string, unknown> = {
only_unanalyzed: opts.onlyUnanalyzed ?? true
};
if (opts.mangaId) body.manga_id = opts.mangaId;
if (opts.chapterId) body.chapter_id = opts.chapterId;
return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
}
/** Force re-analysis of a single page (used by the reader context menu). */
export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> {
return request<{ enqueued: boolean }>(
`/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`,
{ method: 'POST' }
);
}