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.

-