feat(analysis): admin coverage overview + per-page result inspector UI

Reworks /admin/analysis from a blind enqueue form into a coverage browser:

- Loads a paginated overview of mangas with a CoverageBadge
  (Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
  done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
  NSFW + content-warning chips, scene description, tags, and OCR lines
  (labeled by kind). Unanalyzed/failed pages get a "Queue this page"
  action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
  toggle; enqueue refreshes the open chapter's grid so queued pages show.

API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.

Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:39:54 +02:00
parent 824f5acf22
commit 6bb72b8775
8 changed files with 902 additions and 269 deletions

View File

@@ -28,7 +28,11 @@ import {
listActiveJobs,
listMissingCovers,
reenqueueAnalysis,
analyzePage
analyzePage,
getAnalysisMangaCoverage,
getAnalysisChapterCoverage,
getAnalysisChapterPages,
getAnalysisPageDetail
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -534,4 +538,46 @@ describe('admin crawler api client', () => {
expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/);
expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST');
});
it('getAnalysisMangaCoverage passes search + pagination', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [], page: { limit: 25, offset: 0, total: 0 } })
);
await getAnalysisMangaCoverage({ search: 'ber', limit: 25, offset: 50 });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('/v1/admin/analysis/mangas?');
expect(url).toContain('search=ber');
expect(url).toContain('limit=25');
expect(url).toContain('offset=50');
});
it('getAnalysisChapterCoverage unwraps items', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
);
const items = await getAnalysisChapterCoverage('m1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/mangas\/m1\/chapters$/);
expect(items).toHaveLength(1);
});
it('getAnalysisChapterPages unwraps items', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [{ page_id: 'p1', page_number: 1, status: 'done' }] })
);
const items = await getAnalysisChapterPages('c1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/chapters\/c1\/pages$/);
expect(items[0].status).toBe('done');
});
it('getAnalysisPageDetail hits the page endpoint', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ page_id: 'p1', status: 'none', ocr: [], tags: [], content_warnings: [] })
);
const d = await getAnalysisPageDetail('p1');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
expect(d.status).toBe('none');
});
});

View File

@@ -7,6 +7,7 @@ import { request, apiUrl, type Page } from './client';
import type { User } from './auth';
import type { MangaDetail } from './mangas';
import type { Chapter } from './chapters';
import type { ContentWarning } from './page_tags';
// ---- users -----------------------------------------------------------------
@@ -441,3 +442,93 @@ export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }
{ method: 'POST' }
);
}
// ---- AI content analysis: coverage & inspection ----------------------------
export type MangaCoverage = {
manga_id: string;
title: string;
total_pages: number;
analyzed_pages: number;
};
export type MangaCoveragePage = {
items: MangaCoverage[];
page: Page;
};
export type ChapterCoverage = {
chapter_id: string;
number: number;
title: string | null;
total_pages: number;
analyzed_pages: number;
};
/** Per-page status in a chapter grid. */
export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none';
export type PageStatusItem = {
page_id: string;
page_number: number;
status: PageAnalysisStatus;
};
export type OcrLine = { kind: string; text: string };
export type PageAnalysisDetail = {
page_id: string;
page_number: number;
chapter_id: string;
manga_id: string;
/** `done` | `failed` | `none` (no analysis row yet). */
status: 'done' | 'failed' | 'none';
is_nsfw: boolean;
scene_description: string | null;
model: string | null;
error: string | null;
analyzed_at: string | null;
ocr: OcrLine[];
tags: string[];
content_warnings: ContentWarning[];
};
/** Paginated per-manga analysis coverage (admin overview). */
export async function getAnalysisMangaCoverage(
opts: { search?: string; limit?: number; offset?: number } = {}
): Promise<MangaCoveragePage> {
const params = new URLSearchParams();
if (opts.search) params.set('search', opts.search);
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<MangaCoveragePage>(
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
);
}
export async function getAnalysisChapterCoverage(
mangaId: string
): Promise<ChapterCoverage[]> {
const r = await request<{ items: ChapterCoverage[] }>(
`/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
);
return r.items;
}
export async function getAnalysisChapterPages(
chapterId: string
): Promise<PageStatusItem[]> {
const r = await request<{ items: PageStatusItem[] }>(
`/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
);
return r.items;
}
export async function getAnalysisPageDetail(
pageId: string
): Promise<PageAnalysisDetail> {
return request<PageAnalysisDetail>(
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
);
}