diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 310c98d..1bb0423 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.73.0" +version = "0.74.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4375c7b..96b86f0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.73.0" +version = "0.74.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/admin-analysis.spec.ts b/frontend/e2e/admin-analysis.spec.ts index 00ddeab..fbf5841 100644 --- a/frontend/e2e/admin-analysis.spec.ts +++ b/frontend/e2e/admin-analysis.spec.ts @@ -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 | 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(); - }); }); diff --git a/frontend/package.json b/frontend/package.json index d6c7990..6083e0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.73.0", + "version": "0.74.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/admin/analysis/+page.svelte b/frontend/src/routes/admin/analysis/+page.svelte index 47f172a..d48d6a2 100644 --- a/frontend/src/routes/admin/analysis/+page.svelte +++ b/frontend/src/routes/admin/analysis/+page.svelte @@ -1,76 +1,116 @@ @@ -80,78 +120,147 @@

Content analysis

Queue page images for the AI analysis worker (OCR, auto-tags, scene - description, NSFW moderation). Pick a scope and run it. + description, NSFW moderation).

-
-
- Scope - {#each scopeOptions as o (o.value)} - - {/each} -
- - {#if scope === 'manga'} - - {:else if scope === 'chapter'} - - {/if} - - -
-
+ - {#if notice} -

{notice}

+
+ + + {#if searching} +

Searching…

+ {:else if searched && mangas.length === 0} +

+ No mangas match "{query.trim()}". +

{/if} - {#if error} - + + {#if mangas.length > 0} +
    + {#each mangas as m (m.id)} +
  • +
    + + +
    + + {#if expandedId === m.id} + {#if chapters[m.id] === 'loading'} +

    Loading chapters…

    + {:else if Array.isArray(chapters[m.id])} + {@const rows = chapters[m.id] as AdminChapterRow[]} + {#if rows.length === 0} +

    No chapters.

    + {:else} +
      + {#each rows as c (c.id)} +
    • + + {chapterLabel({ + number: c.number, + title: c.title + })} + + {c.page_count} page{c.page_count === 1 + ? '' + : 's'} + + + +
    • + {/each} +
    + {/if} + {/if} + {/if} +
  • + {/each} +
{/if} - +
+ +{#if notice} +

{notice}

+{/if} +{#if error} + +{/if}