feat(analysis): admin analysis section uses manga search + chapter picker

Replaces the raw manga-id / chapter-id UUID inputs with a real picker:

- Search a manga (debounced, reuses listAdminMangas) → result rows show
  title + chapter count; "Queue manga" enqueues the whole manga.
- Expand a result to load its chapters (listAdminChapters) and "Queue
  chapter" enqueues a single chapter.
- Keeps the "Queue all (whole library)" action and a single global
  "include already-analyzed" toggle applied to every action; per-action
  busy state + a result message naming what was queued.

Tests: rewrote the Playwright spec to drive the search → manga/chapter
flow (whole library, search+queue manga, expand+queue chapter with
include-analyzed). svelte-check + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:05:09 +02:00
parent 8b7ea2e1b2
commit 7e675b72cc
5 changed files with 401 additions and 215 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.73.0"
version = "0.74.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.73.0"
version = "0.74.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,10 +1,14 @@
import { test, expect, type Page } from '@playwright/test';
// E2E for the admin Analysis section: scoped re-enqueue (all / manga /
// chapter) with the include-already-analyzed toggle. Fully mocked.
// 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.
const DESKTOP = { width: 1280, height: 720 } as const;
const mangaId = 'a9999999-9999-9999-9999-999999999999';
const chapterId = 'c9999999-9999-9999-9999-999999999999';
const adminUser = {
id: 'u11111111-1111-1111-1111-111111111111',
username: 'admin',
@@ -19,6 +23,29 @@ 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
};
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<string, unknown> | null };
async function mockAdmin(page: Page, captured: Captured) {
@@ -57,6 +84,28 @@ async function mockAdmin(page: Page, captured: Captured) {
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({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [mangaRow],
page: { limit: 20, offset: 0, total: 1 }
})
})
);
await page.route('**/api/v1/admin/mangas/*/chapters**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [chapterRow],
page: { limit: 500, offset: 0, total: 1 }
})
})
);
await page.route('**/api/v1/admin/analysis/reenqueue', (route) => {
captured.body = JSON.parse(route.request().postData() ?? '{}');
return route.fulfill({
@@ -68,16 +117,13 @@ async function mockAdmin(page: Page, captured: Captured) {
}
test.describe('/admin/analysis', () => {
test('default whole-library re-enqueue posts only_unanalyzed=true', async ({
page
}) => {
test('queue the whole library posts only_unanalyzed=true', async ({ page }) => {
const captured: Captured = { body: null };
await mockAdmin(page, captured);
await page.setViewportSize(DESKTOP);
await page.goto('/admin/analysis');
await expect(page.getByTestId('admin-analysis-form')).toBeVisible();
await page.getByTestId('admin-analysis-submit').click();
await page.getByTestId('admin-analysis-enqueue-library').click();
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
'Enqueued 12 pages'
@@ -85,40 +131,41 @@ test.describe('/admin/analysis', () => {
expect(captured.body).toEqual({ only_unanalyzed: true });
});
test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', async ({
page
}) => {
test('search a manga and queue it', 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-search').fill('ber');
await expect(
page.getByTestId(`admin-analysis-manga-${mangaId}`)
).toBeVisible();
await page.getByTestId(`admin-analysis-enqueue-manga-${mangaId}`).click();
await expect(page.getByTestId('admin-analysis-notice')).toContainText(
'Berserk'
);
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-scope-manga').check();
await page
.getByTestId('admin-analysis-manga-id')
.fill('a9999999-9999-9999-9999-999999999999');
await page.getByTestId('admin-analysis-include-analyzed').check();
await page.getByTestId('admin-analysis-submit').click();
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,
manga_id: 'a9999999-9999-9999-9999-999999999999'
chapter_id: chapterId
});
});
test('chapter scope requires an id before posting', 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-scope-chapter').check();
await page.getByTestId('admin-analysis-submit').click();
await expect(page.getByTestId('admin-analysis-error')).toContainText(
'Enter a chapter id'
);
expect(captured.body).toBeNull();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.73.0",
"version": "0.74.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,76 +1,116 @@
<script lang="ts">
import { ApiError } from '$lib/api/client';
import { reenqueueAnalysis } from '$lib/api/admin';
import {
reenqueueAnalysis,
listAdminMangas,
listAdminChapters,
type AdminMangaRow,
type AdminChapterRow,
type ReenqueueAnalysisOptions
} from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
type Scope = 'all' | 'manga' | 'chapter';
let scope = $state<Scope>('all');
let mangaId = $state('');
let chapterId = $state('');
// Default: skip pages that already have a completed analysis.
// One global toggle applied to whichever enqueue action runs.
let includeAnalyzed = $state(false);
let busy = $state(false);
let error = $state<string | null>(null);
// Manga search.
let query = $state('');
let mangas = $state<AdminMangaRow[]>([]);
let searching = $state(false);
let searched = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Per-manga chapter expansion: id → loading | rows.
let expandedId = $state<string | null>(null);
let chapters = $state<Record<string, 'loading' | AdminChapterRow[]>>({});
// In-flight action key ('library' | `manga:<id>` | `chapter:<id>`).
let busyKey = $state<string | null>(null);
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
const scopeOptions: { value: Scope; label: string; hint: string }[] = [
{
value: 'all',
label: 'Whole library',
hint: 'Every page of every chapter of every manga.'
},
{
value: 'manga',
label: 'One manga',
hint: 'All pages across the chosen mangas chapters.'
},
{ value: 'chapter', label: 'One chapter', hint: 'All pages of the chosen chapter.' }
];
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 300);
}
async function submit(e: SubmitEvent) {
e.preventDefault();
if (busy) return;
error = null;
notice = null;
const opts: {
onlyUnanalyzed: boolean;
mangaId?: string;
chapterId?: string;
} = { onlyUnanalyzed: !includeAnalyzed };
if (scope === 'manga') {
if (!mangaId.trim()) {
error = 'Enter a manga id.';
return;
}
opts.mangaId = mangaId.trim();
} else if (scope === 'chapter') {
if (!chapterId.trim()) {
error = 'Enter a chapter id.';
return;
}
opts.chapterId = chapterId.trim();
async function runSearch() {
const q = query.trim();
if (!q) {
mangas = [];
searched = false;
return;
}
busy = true;
searching = true;
error = null;
try {
const r = await reenqueueAnalysis(opts);
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis.`;
} catch (err) {
if (err instanceof ApiError) {
const r = await listAdminMangas({ search: q, limit: 20 });
mangas = r.items;
searched = true;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Search failed.';
} finally {
searching = false;
}
}
async function toggleChapters(mangaId: string) {
if (expandedId === mangaId) {
expandedId = null;
return;
}
expandedId = mangaId;
if (!chapters[mangaId]) {
chapters[mangaId] = 'loading';
try {
const r = await listAdminChapters(mangaId, { limit: 500 });
chapters[mangaId] = r.items;
} catch (e) {
delete chapters[mangaId];
expandedId = null;
error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
}
}
}
async function enqueue(
key: string,
scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
label: string
) {
if (busyKey) return;
busyKey = key;
notice = null;
error = null;
try {
const r = await reenqueueAnalysis({
...scope,
onlyUnanalyzed: !includeAnalyzed
});
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis (${label}).`;
} catch (e) {
if (e instanceof ApiError) {
error =
err.status === 503
e.status === 503
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
: err.message;
: e.message;
} else {
error = 'Failed to enqueue analysis.';
}
} finally {
busy = false;
busyKey = null;
}
}
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
const enqueueManga = (m: AdminMangaRow) =>
enqueue(`manga:${m.id}`, { mangaId: m.id }, m.title);
const enqueueChapter = (m: AdminMangaRow, c: AdminChapterRow) =>
enqueue(
`chapter:${c.id}`,
{ chapterId: c.id },
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
);
</script>
<svelte:head>
@@ -80,78 +120,147 @@
<h1 class="heading">Content analysis</h1>
<p class="lede">
Queue page images for the AI analysis worker (OCR, auto-tags, scene
description, NSFW moderation). Pick a scope and run it.
description, NSFW moderation).
</p>
<form class="panel" onsubmit={submit} data-testid="admin-analysis-form">
<fieldset class="scopes">
<legend>Scope</legend>
{#each scopeOptions as o (o.value)}
<label class="scope-opt" class:active={scope === o.value}>
<input
type="radio"
name="scope"
value={o.value}
checked={scope === o.value}
onchange={() => (scope = o.value)}
data-testid={`admin-analysis-scope-${o.value}`}
/>
<span class="scope-label">{o.label}</span>
<span class="scope-hint">{o.hint}</span>
</label>
{/each}
</fieldset>
{#if scope === 'manga'}
<label class="field">
<span>Manga id</span>
<input
type="text"
bind:value={mangaId}
placeholder="uuid"
data-testid="admin-analysis-manga-id"
/>
</label>
{:else if scope === 'chapter'}
<label class="field">
<span>Chapter id</span>
<input
type="text"
bind:value={chapterId}
placeholder="uuid"
data-testid="admin-analysis-chapter-id"
/>
</label>
{/if}
<label class="checkbox">
<input
type="checkbox"
bind:checked={includeAnalyzed}
data-testid="admin-analysis-include-analyzed"
/>
<span>
Include already-analyzed pages
<span class="muted">
— re-runs analysis on pages that already have results
(default off: only un-analyzed pages are queued).
</span>
<label class="checkbox" data-testid="admin-analysis-include-analyzed-wrap">
<input
type="checkbox"
bind:checked={includeAnalyzed}
data-testid="admin-analysis-include-analyzed"
/>
<span>
Include already-analyzed pages
<span class="muted">
— re-runs analysis on pages that already have results (default
off: only un-analyzed pages are queued). Applies to every action
below.
</span>
</label>
</span>
</label>
<div class="actions">
<button type="submit" disabled={busy} data-testid="admin-analysis-submit">
{busy ? 'Queueing…' : 'Queue for analysis'}
<section class="panel" aria-label="Whole library">
<div class="row">
<div class="row-main">
<span class="row-title">Whole library</span>
<span class="row-sub">Every page of every chapter of every manga.</span>
</div>
<button
type="button"
disabled={busyKey !== null}
onclick={enqueueLibrary}
data-testid="admin-analysis-enqueue-library"
>
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
</button>
</div>
</section>
{#if notice}
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
<section class="panel" aria-label="Search a manga">
<label class="field">
<span>Search a manga to queue it or one of its chapters</span>
<input
type="text"
bind:value={query}
oninput={onSearchInput}
placeholder="Title or author…"
data-testid="admin-analysis-search"
/>
</label>
{#if searching}
<p class="muted" data-testid="admin-analysis-searching">Searching…</p>
{:else if searched && mangas.length === 0}
<p class="muted" data-testid="admin-analysis-no-results">
No mangas match "{query.trim()}".
</p>
{/if}
{#if error}
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{#if mangas.length > 0}
<ul class="results" data-testid="admin-analysis-results">
{#each mangas as m (m.id)}
<li class="manga" data-testid={`admin-analysis-manga-${m.id}`}>
<div class="row">
<button
type="button"
class="disclosure"
aria-expanded={expandedId === m.id}
onclick={() => toggleChapters(m.id)}
data-testid={`admin-analysis-expand-${m.id}`}
>
<span class="caret" class:open={expandedId === m.id}>▸</span>
<span class="row-main">
<span class="row-title">{m.title}</span>
<span class="row-sub">
{m.chapter_count} chapter{m.chapter_count === 1
? ''
: 's'}
</span>
</span>
</button>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueManga(m)}
data-testid={`admin-analysis-enqueue-manga-${m.id}`}
>
{busyKey === `manga:${m.id}` ? 'Queueing…' : 'Queue manga'}
</button>
</div>
{#if expandedId === m.id}
{#if chapters[m.id] === 'loading'}
<p class="muted chapters-loading">Loading chapters…</p>
{:else if Array.isArray(chapters[m.id])}
{@const rows = chapters[m.id] as AdminChapterRow[]}
{#if rows.length === 0}
<p class="muted chapters-loading">No chapters.</p>
{:else}
<ul class="chapters">
{#each rows as c (c.id)}
<li
class="chapter"
data-testid={`admin-analysis-chapter-${c.id}`}
>
<span class="chapter-label">
{chapterLabel({
number: c.number,
title: c.title
})}
<span class="row-sub">
{c.page_count} page{c.page_count === 1
? ''
: 's'}
</span>
</span>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueChapter(m, c)}
data-testid={`admin-analysis-enqueue-chapter-${c.id}`}
>
{busyKey === `chapter:${c.id}`
? 'Queueing…'
: 'Queue chapter'}
</button>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</form>
</section>
{#if notice}
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
{/if}
{#if error}
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
<style>
.heading {
@@ -162,85 +271,115 @@
margin-bottom: var(--space-4);
max-width: 42rem;
}
.panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 42rem;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg, 12px);
background: var(--surface);
}
fieldset.scopes {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
legend {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-1);
}
.scope-opt {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--space-2);
align-items: baseline;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
}
.scope-opt.active {
border-color: var(--primary);
background: var(--primary-soft-bg);
}
.scope-opt input {
grid-row: 1 / span 2;
align-self: center;
}
.scope-label {
font-weight: var(--weight-semibold);
}
.scope-hint {
grid-column: 2;
color: var(--text-muted);
font-size: var(--font-sm);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field span {
color: var(--text-muted);
font-size: var(--font-sm);
}
.field input {
max-width: 26rem;
}
.checkbox {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2);
align-items: start;
font-size: var(--font-sm);
margin-bottom: var(--space-4);
max-width: 42rem;
}
.checkbox .muted {
.muted {
color: var(--text-muted);
}
.actions {
.panel {
max-width: 46rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg, 12px);
background: var(--surface);
margin-bottom: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field > span {
color: var(--text-muted);
font-size: var(--font-sm);
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.row-main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
text-align: left;
}
.row-title {
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
color: var(--text-muted);
font-size: var(--font-sm);
}
.results {
list-style: none;
margin: var(--space-3) 0 0;
padding: 0;
display: flex;
flex-direction: column;
}
.manga {
border-top: 1px solid var(--border);
padding: var(--space-2) 0;
}
.disclosure {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: inherit;
}
.caret {
transition: transform 0.12s ease;
color: var(--text-muted);
}
.caret.open {
transform: rotate(90deg);
}
.chapters {
list-style: none;
margin: var(--space-2) 0 0 var(--space-4);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.chapter {
display: flex;
align-items: center;
gap: var(--space-3);
}
.chapter-label {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.chapters-loading {
margin: var(--space-2) 0 0 var(--space-4);
}
.notice {
color: var(--success, #2e7d32);
max-width: 46rem;
}
.error {
color: var(--danger);
max-width: 46rem;
}
</style>