diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index d1c97d6..468ad59 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.75.0"
+version = "0.76.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 9f53d99..1ab6695 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.75.0"
+version = "0.76.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/admin-analysis.spec.ts b/frontend/e2e/admin-analysis.spec.ts
index fbf5841..7acad49 100644
--- a/frontend/e2e/admin-analysis.spec.ts
+++ b/frontend/e2e/admin-analysis.spec.ts
@@ -1,13 +1,15 @@
import { test, expect, type Page } from '@playwright/test';
-// E2E for the admin Analysis section: enqueue the whole library, or
-// search a manga and enqueue it / one of its chapters, with the
-// include-already-analyzed toggle. Fully mocked.
+// E2E for the admin Analysis section: coverage overview + badges, drill
+// manga → chapter → page, the page-detail modal, and the enqueue actions.
+// Fully mocked.
const DESKTOP = { width: 1280, height: 720 } as const;
const mangaId = 'a9999999-9999-9999-9999-999999999999';
const chapterId = 'c9999999-9999-9999-9999-999999999999';
+const pageDone = 'p1111111-1111-1111-1111-111111111111';
+const pageNone = 'p2222222-2222-2222-2222-222222222222';
const adminUser = {
id: 'u11111111-1111-1111-1111-111111111111',
@@ -23,149 +25,220 @@ const systemStats = {
alerts: []
};
-const mangaRow = {
- id: mangaId,
- title: 'Berserk',
- status: 'ongoing',
- cover_image_path: null,
- created_at: '2026-01-01T00:00:00Z',
- updated_at: '2026-01-01T00:00:00Z',
- sync_state: 'synced',
- chapter_count: 1,
- latest_seen_at: null
-};
+type Captured = { reenqueue: Record | null; analyzeCalls: number };
-const chapterRow = {
- id: chapterId,
- manga_id: mangaId,
- number: 1,
- title: 'The Brand',
- page_count: 20,
- created_at: '2026-01-01T00:00:00Z',
- sync_state: 'synced',
- latest_seen_at: null
-};
-
-type Captured = { body: Record | null };
-
-async function mockAdmin(page: Page, captured: Captured) {
- await page.route('**/api/v1/auth/config', (route) =>
- route.fulfill({
+async function mockAdmin(page: Page, cap: Captured) {
+ await page.route('**/api/v1/auth/config', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
- await page.route('**/api/v1/auth/me', (route) =>
- route.fulfill({
+ await page.route('**/api/v1/auth/me', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: adminUser })
})
);
- await page.route('**/api/v1/auth/me/preferences', (route) =>
- route.fulfill({
+ await page.route('**/api/v1/auth/me/preferences', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reader_mode: 'single', reader_page_gap: 'small' })
})
);
- await page.route('**/api/v1/me/bookmarks*', (route) =>
- route.fulfill({
+ await page.route('**/api/v1/me/bookmarks*', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
- await page.route('**/api/v1/admin/system', (route) =>
- route.fulfill({
+ await page.route('**/api/v1/admin/system', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(systemStats)
})
);
- // Manga search. Registered before the chapters route so the more
- // specific chapters glob (added later) wins for chapter URLs.
- await page.route('**/api/v1/admin/mangas**', (route) =>
- route.fulfill({
+
+ // Coverage overview. Registered before the more specific routes below
+ // so the later-registered (more specific) globs win for their URLs.
+ await page.route('**/api/v1/admin/analysis/mangas**', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- items: [mangaRow],
- page: { limit: 20, offset: 0, total: 1 }
+ items: [
+ {
+ manga_id: mangaId,
+ title: 'Berserk',
+ total_pages: 2,
+ analyzed_pages: 1
+ }
+ ],
+ page: { limit: 25, offset: 0, total: 1 }
})
})
);
- await page.route('**/api/v1/admin/mangas/*/chapters**', (route) =>
- route.fulfill({
+ await page.route('**/api/v1/admin/analysis/mangas/*/chapters', (r) =>
+ r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
- items: [chapterRow],
- page: { limit: 500, offset: 0, total: 1 }
+ items: [
+ {
+ chapter_id: chapterId,
+ number: 1,
+ title: 'The Brand',
+ total_pages: 2,
+ analyzed_pages: 1
+ }
+ ]
})
})
);
- await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
- captured.body = JSON.parse(route.request().postData() ?? '{}');
- return route.fulfill({
+ await page.route('**/api/v1/admin/analysis/chapters/*/pages', (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ items: [
+ { page_id: pageDone, page_number: 1, status: 'done' },
+ { page_id: pageNone, page_number: 2, status: 'none' }
+ ]
+ })
+ })
+ );
+ await page.route(`**/api/v1/admin/analysis/pages/${pageDone}`, (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ page_id: pageDone,
+ page_number: 1,
+ chapter_id: chapterId,
+ manga_id: mangaId,
+ status: 'done',
+ is_nsfw: true,
+ scene_description: 'A rainy street at night.',
+ model: 'test-model',
+ error: null,
+ analyzed_at: '2026-06-13T12:00:00Z',
+ ocr: [{ kind: 'speech', text: 'Hello there' }],
+ tags: ['action', 'city'],
+ content_warnings: ['gore']
+ })
+ })
+ );
+ await page.route(`**/api/v1/admin/analysis/pages/${pageNone}`, (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ page_id: pageNone,
+ page_number: 2,
+ chapter_id: chapterId,
+ manga_id: mangaId,
+ status: 'none',
+ is_nsfw: false,
+ scene_description: null,
+ model: null,
+ error: null,
+ analyzed_at: null,
+ ocr: [],
+ tags: [],
+ content_warnings: []
+ })
+ })
+ );
+
+ await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
+ cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
+ return r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enqueued: 12 })
});
});
+ await page.route(`**/api/v1/admin/pages/${pageNone}/analyze`, (r) => {
+ cap.analyzeCalls += 1;
+ return r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ enqueued: true })
+ });
+ });
}
test.describe('/admin/analysis', () => {
- test('queue the whole library posts only_unanalyzed=true', async ({ page }) => {
- const captured: Captured = { body: null };
- await mockAdmin(page, captured);
+ test('overview shows coverage badges and queues the whole library', async ({
+ page
+ }) => {
+ const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
+ await mockAdmin(page, cap);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
- await page.getByTestId('admin-analysis-enqueue-library').click();
+ const badge = page.getByTestId(`admin-analysis-coverage-manga-${mangaId}`);
+ await expect(badge).toBeVisible();
+ await expect(badge).toContainText('Partial 1/2');
+ await page.getByTestId('admin-analysis-enqueue-library').click();
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
'Enqueued 12 pages'
);
- expect(captured.body).toEqual({ only_unanalyzed: true });
+ expect(cap.reenqueue).toEqual({ only_unanalyzed: true });
});
- test('search a manga and queue it', async ({ page }) => {
- const captured: Captured = { body: null };
- await mockAdmin(page, captured);
+ test('drill manga → chapter → page and inspect the result', async ({ page }) => {
+ const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
+ await mockAdmin(page, cap);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
- await page.getByTestId('admin-analysis-search').fill('ber');
+ await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
await expect(
- page.getByTestId(`admin-analysis-manga-${mangaId}`)
- ).toBeVisible();
+ page.getByTestId(`admin-analysis-coverage-chapter-${chapterId}`)
+ ).toContainText('Partial 1/2');
- await page.getByTestId(`admin-analysis-enqueue-manga-${mangaId}`).click();
+ await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
+ const doneChip = page.getByTestId(`admin-analysis-page-${pageDone}`);
+ await expect(doneChip).toHaveAttribute('data-status', 'done');
+
+ await doneChip.click();
+ const modal = page.getByTestId('admin-analysis-detail');
+ await expect(modal).toBeVisible();
+ await expect(
+ page.getByTestId('admin-analysis-detail-status')
+ ).toContainText('Analyzed');
+ await expect(modal).toContainText('Hello there');
+ await expect(modal).toContainText('action');
+ await expect(page.getByTestId('admin-analysis-detail-warnings')).toContainText(
+ 'gore'
+ );
+ });
+
+ test('queue an unanalyzed page from its detail modal', async ({ page }) => {
+ const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
+ await mockAdmin(page, cap);
+ await page.setViewportSize(DESKTOP);
+ await page.goto('/admin/analysis');
+
+ await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
+ await page.getByTestId(`admin-analysis-expand-chapter-${chapterId}`).click();
+ await page.getByTestId(`admin-analysis-page-${pageNone}`).click();
+
+ await expect(page.getByTestId('admin-analysis-detail-status')).toContainText(
+ 'Not analyzed'
+ );
+ await page.getByTestId('admin-analysis-detail-queue').click();
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
- 'Berserk'
+ 'Queued page 2'
);
- expect(captured.body).toEqual({ only_unanalyzed: true, manga_id: mangaId });
- });
-
- test('expand chapters and queue one with include-analyzed on', async ({ page }) => {
- const captured: Captured = { body: null };
- await mockAdmin(page, captured);
- await page.setViewportSize(DESKTOP);
- await page.goto('/admin/analysis');
-
- await page.getByTestId('admin-analysis-include-analyzed').check();
- await page.getByTestId('admin-analysis-search').fill('ber');
- await page.getByTestId(`admin-analysis-expand-${mangaId}`).click();
-
- await page.getByTestId(`admin-analysis-enqueue-chapter-${chapterId}`).click();
-
- await expect(page.getByTestId('admin-analysis-notice')).toBeVisible();
- expect(captured.body).toEqual({
- only_unanalyzed: false,
- chapter_id: chapterId
- });
+ expect(cap.analyzeCalls).toBe(1);
});
});
diff --git a/frontend/package.json b/frontend/package.json
index d968d98..44bb5eb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.75.0",
+ "version": "0.76.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts
index ec8e3e2..58ca9e0 100644
--- a/frontend/src/lib/api/admin.test.ts
+++ b/frontend/src/lib/api/admin.test.ts
@@ -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');
+ });
});
diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts
index 2ac7ba5..8bf3563 100644
--- a/frontend/src/lib/api/admin.ts
+++ b/frontend/src/lib/api/admin.ts
@@ -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 {
+ 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(
+ `/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
+ );
+}
+
+export async function getAnalysisChapterCoverage(
+ mangaId: string
+): Promise {
+ const r = await request<{ items: ChapterCoverage[] }>(
+ `/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters`
+ );
+ return r.items;
+}
+
+export async function getAnalysisChapterPages(
+ chapterId: string
+): Promise {
+ const r = await request<{ items: PageStatusItem[] }>(
+ `/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages`
+ );
+ return r.items;
+}
+
+export async function getAnalysisPageDetail(
+ pageId: string
+): Promise {
+ return request(
+ `/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
+ );
+}
diff --git a/frontend/src/lib/components/CoverageBadge.svelte b/frontend/src/lib/components/CoverageBadge.svelte
new file mode 100644
index 0000000..78c254d
--- /dev/null
+++ b/frontend/src/lib/components/CoverageBadge.svelte
@@ -0,0 +1,56 @@
+
+
+
+ {word} {analyzed}/{total}
+
+
+
diff --git a/frontend/src/routes/admin/analysis/+page.svelte b/frontend/src/routes/admin/analysis/+page.svelte
index d48d6a2..d9a71d1 100644
--- a/frontend/src/routes/admin/analysis/+page.svelte
+++ b/frontend/src/routes/admin/analysis/+page.svelte
@@ -1,78 +1,174 @@
@@ -119,11 +222,12 @@
Content analysis
- Queue page images for the AI analysis worker (OCR, auto-tags, scene
- description, NSFW moderation).
+ Coverage of the AI analysis worker (OCR, auto-tags, scene description,
+ NSFW moderation). Browse what's analyzed, queue more, and inspect any
+ page's result.
-
+
— re-runs analysis on pages that already have results (default
- off: only un-analyzed pages are queued). Applies to every action
- below.
+ off). Applies to every "Queue" action.
-
-
-
- Whole library
- Every page of every chapter of every manga.
-
-
- {busyKey === 'library' ? 'Queueing…' : 'Queue all'}
-
-
-
+
+ Whole library
+
+ {busyKey === 'library' ? 'Queueing…' : 'Queue all'}
+
+
-
+ {:else if Array.isArray(chapters[m.manga_id])}
+ {@const chs = chapters[m.manga_id] as ChapterCoverage[]}
+
+ {#each chs as c (c.chapter_id)}
+
+
+ toggleChapter(c.chapter_id)}
+ data-testid={`admin-analysis-expand-chapter-${c.chapter_id}`}
>
-
- {chapterLabel({
- number: c.number,
- title: c.title
- })}
-
- {c.page_count} page{c.page_count === 1
- ? ''
- : 's'}
+ ▸
+
+
+ {chapterLabel({
+ number: c.number,
+ title: c.title
+ })}
- enqueueChapter(m, c)}
- data-testid={`admin-analysis-enqueue-chapter-${c.id}`}
+
+
+ enqueueChapter(m, c)}
+ data-testid={`admin-analysis-enqueue-chapter-${c.chapter_id}`}
+ >
+ {busyKey === `chapter:${c.chapter_id}`
+ ? 'Queueing…'
+ : 'Queue chapter'}
+
+
+
+ {#if expandedChapter === c.chapter_id}
+ {#if pages[c.chapter_id] === 'loading'}
+ Loading pages…
+ {:else if Array.isArray(pages[c.chapter_id])}
+ {@const ps = pages[c.chapter_id] as PageStatusItem[]}
+
- {busyKey === `chapter:${c.id}`
- ? 'Queueing…'
- : 'Queue chapter'}
-
-
- {/each}
-
- {/if}
- {/if}
+ {#each ps as p (p.page_id)}
+ openDetail(p.page_id)}
+ data-testid={`admin-analysis-page-${p.page_id}`}
+ data-status={p.status}
+ >
+ {p.page_number}
+
+ {/each}
+
+
+ analyzed
+ queued
+ failed
+ not analyzed
+
+ {/if}
+ {/if}
+
+ {/each}
+
{/if}
-
- {/each}
-
+ {/if}
+
+ {/each}
+
+
+ {#if mangas.length < total}
+
+ {loadingMore ? 'Loading…' : `Load more (${mangas.length}/${total})`}
+
{/if}
-
+{/if}
{#if notice}
{notice}
@@ -262,6 +412,89 @@
{error}
{/if}
+
+ {#if detailLoading}
+ Loading…
+ {:else if detailError}
+ {detailError}
+ {:else if detail}
+
+
+ {detail.status === 'done'
+ ? 'Analyzed'
+ : detail.status === 'failed'
+ ? 'Failed'
+ : 'Not analyzed'}
+
+ {#if detail.model}model {detail.model} {/if}
+ {#if detail.analyzed_at}{fmtDate(detail.analyzed_at)} {/if}
+
+
+ {#if detail.status === 'none'}
+ This page hasn't been analyzed yet.
+ {:else if detail.status === 'failed'}
+ {detail.error ?? 'Analysis failed.'}
+ {/if}
+
+ {#if detail.is_nsfw || detail.content_warnings.length > 0}
+
+ ⚠ NSFW
+ {#each detail.content_warnings as w (w)}
+ {w}
+ {/each}
+
+ {/if}
+
+ {#if detail.scene_description}
+
+ Scene
+ {detail.scene_description}
+
+ {/if}
+
+ {#if detail.tags.length > 0}
+
+ Tags
+
+ {#each detail.tags as t (t)}{t} {/each}
+
+
+ {/if}
+
+ {#if detail.ocr.length > 0}
+
+ OCR text
+
+ {#each detail.ocr as line, i (i)}
+ {line.kind} {line.text}
+ {/each}
+
+
+ {/if}
+ {/if}
+
+ {#snippet footer()}
+ {#if detail && detail.status !== 'done'}
+
+ {detailQueueBusy ? 'Queueing…' : 'Queue this page'}
+
+ {/if}
+ Close
+ {/snippet}
+
+