diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 53d222a..310c98d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.72.0" +version = "0.73.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ece2ea5..4375c7b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.72.0" +version = "0.73.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/admin-analysis.spec.ts b/frontend/e2e/admin-analysis.spec.ts new file mode 100644 index 0000000..00ddeab --- /dev/null +++ b/frontend/e2e/admin-analysis.spec.ts @@ -0,0 +1,124 @@ +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. + +const DESKTOP = { width: 1280, height: 720 } as const; + +const adminUser = { + id: 'u11111111-1111-1111-1111-111111111111', + username: 'admin', + created_at: '2026-01-01T00:00:00Z', + is_admin: true +}; + +const systemStats = { + disk: null, + memory: { total_bytes: 1, used_bytes: 0, percent_used: 0 }, + cpu: { percent_used: 0 }, + alerts: [] +}; + +type Captured = { body: Record | null }; + +async function mockAdmin(page: Page, captured: Captured) { + await page.route('**/api/v1/auth/config', (route) => + route.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({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ user: adminUser }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.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({ + 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({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(systemStats) + }) + ); + await page.route('**/api/v1/admin/analysis/reenqueue', (route) => { + captured.body = JSON.parse(route.request().postData() ?? '{}'); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enqueued: 12 }) + }); + }); +} + +test.describe('/admin/analysis', () => { + test('default whole-library re-enqueue 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 expect(page.getByTestId('admin-analysis-notice')).toContainText( + 'Enqueued 12 pages' + ); + expect(captured.body).toEqual({ only_unanalyzed: true }); + }); + + test('manga scope + include-analyzed posts manga_id and only_unanalyzed=false', 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 expect(page.getByTestId('admin-analysis-notice')).toBeVisible(); + expect(captured.body).toEqual({ + only_unanalyzed: false, + manga_id: 'a9999999-9999-9999-9999-999999999999' + }); + }); + + 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/e2e/page-context-menu.spec.ts b/frontend/e2e/page-context-menu.spec.ts index 08d8f36..d7ea12c 100644 --- a/frontend/e2e/page-context-menu.spec.ts +++ b/frontend/e2e/page-context-menu.spec.ts @@ -73,7 +73,12 @@ const collectionFixture = { */ async function mockReader( page: Page, - state: { collectionsContainingPage: string[]; tagsOnPage: string[] } + state: { + collectionsContainingPage: string[]; + tagsOnPage: string[]; + admin?: boolean; + analyzeCalls?: number; + } ) { await page.route('**/api/v1/auth/config', (route) => route.fulfill({ @@ -86,9 +91,20 @@ async function mockReader( route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ user: userFixture }) + body: JSON.stringify({ + user: { ...userFixture, is_admin: state.admin ?? false } + }) }) ); + // Admin force-analyze endpoint — counts invocations for assertions. + await page.route(`**/api/v1/admin/pages/${pageId}/analyze`, (route) => { + state.analyzeCalls = (state.analyzeCalls ?? 0) + 1; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enqueued: true }) + }); + }); await page.route('**/api/v1/auth/me/preferences', (route) => route.fulfill({ status: 200, @@ -266,6 +282,37 @@ test.describe('page context menu (desktop right-click)', () => { ); }); + test('non-admin does not see the Queue for analysis action', async ({ page }) => { + await mockReader(page, { collectionsContainingPage: [], tagsOnPage: [] }); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await page.getByTestId('reader-page').click({ button: 'right' }); + await expect(page.getByTestId('page-context-menu')).toBeVisible(); + await expect(page.getByTestId('page-context-analyze')).toBeHidden(); + }); + + test('admin can queue the page for analysis from the menu', async ({ page }) => { + const state = { + collectionsContainingPage: [], + tagsOnPage: [], + admin: true, + analyzeCalls: 0 + }; + await mockReader(page, state); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); + + await page.getByTestId('reader-page').click({ button: 'right' }); + const item = page.getByTestId('page-context-analyze'); + await expect(item).toBeVisible(); + await expect(item).toContainText('Queue for analysis'); + + await item.click(); + await expect(item).toContainText('Queued for analysis'); + expect(state.analyzeCalls).toBe(1); + }); + test('Shift+right-click falls through to the browser native menu', async ({ page }) => { diff --git a/frontend/package.json b/frontend/package.json index ffca88e..d6c7990 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.72.0", + "version": "0.73.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 3ef4946..ec8e3e2 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -26,7 +26,9 @@ import { listDeadJobs, requeueDeadJobs, listActiveJobs, - listMissingCovers + listMissingCovers, + reenqueueAnalysis, + analyzePage } from './admin'; function ok(body: unknown, status = 200): Response { @@ -494,4 +496,42 @@ describe('admin crawler api client', () => { fetchSpy.mockResolvedValueOnce(envelope(503, 'service_unavailable', 'disabled')); await expect(runCrawlerPass()).rejects.toMatchObject({ status: 503 }); }); + + it('reenqueueAnalysis defaults to whole-library, exclude-analyzed', async () => { + fetchSpy.mockResolvedValueOnce(ok({ enqueued: 42 })); + const r = await reenqueueAnalysis(); + expect(r.enqueued).toBe(42); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/analysis\/reenqueue$/); + expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ + only_unanalyzed: true + }); + }); + + it('reenqueueAnalysis scopes to a manga and can include analyzed', async () => { + fetchSpy.mockResolvedValueOnce(ok({ enqueued: 7 })); + await reenqueueAnalysis({ mangaId: 'm1', onlyUnanalyzed: false }); + expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ + only_unanalyzed: false, + manga_id: 'm1' + }); + }); + + it('reenqueueAnalysis scopes to a chapter', async () => { + fetchSpy.mockResolvedValueOnce(ok({ enqueued: 3 })); + await reenqueueAnalysis({ chapterId: 'c1' }); + expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({ + only_unanalyzed: true, + chapter_id: 'c1' + }); + }); + + it('analyzePage posts to the force-analyze endpoint', async () => { + fetchSpy.mockResolvedValueOnce(ok({ enqueued: true })); + const r = await analyzePage('p1'); + expect(r.enqueued).toBe(true); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/pages\/p1\/analyze$/); + expect(fetchSpy.mock.calls[0][1]!.method).toBe('POST'); + }); }); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 547e622..2ac7ba5 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -403,3 +403,41 @@ export async function listMissingCovers( init ); } + +// ---- AI content analysis --------------------------------------------------- + +/** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId` + * are mutually exclusive; omit both to target the whole library. */ +export type ReenqueueAnalysisOptions = { + /** Skip pages that already have a completed analysis (default true). + * When false, in-scope pages are force re-analyzed. */ + onlyUnanalyzed?: boolean; + mangaId?: string; + chapterId?: string; +}; + +/** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's + * pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED + * on the backend (503 otherwise). */ +export async function reenqueueAnalysis( + opts: ReenqueueAnalysisOptions = {} +): Promise<{ enqueued: number }> { + const body: Record = { + only_unanalyzed: opts.onlyUnanalyzed ?? true + }; + if (opts.mangaId) body.manga_id = opts.mangaId; + if (opts.chapterId) body.chapter_id = opts.chapterId; + return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +/** Force re-analysis of a single page (used by the reader context menu). */ +export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> { + return request<{ enqueued: boolean }>( + `/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`, + { method: 'POST' } + ); +} diff --git a/frontend/src/lib/components/PageContextMenu.svelte b/frontend/src/lib/components/PageContextMenu.svelte index 84448a0..1ac5956 100644 --- a/frontend/src/lib/components/PageContextMenu.svelte +++ b/frontend/src/lib/components/PageContextMenu.svelte @@ -4,6 +4,7 @@ import Tag from '@lucide/svelte/icons/tag'; import Download from '@lucide/svelte/icons/download'; import Link2 from '@lucide/svelte/icons/link-2'; + import Sparkles from '@lucide/svelte/icons/sparkles'; /** * Floating context menu anchored at `(anchor.x, anchor.y)` in @@ -25,7 +26,10 @@ onSaveImage, onCopyLink, collectionsCount, - tags + tags, + canAnalyze = false, + onAnalyzePage, + analyzeState = 'idle' }: { open: boolean; anchor: { x: number; y: number }; @@ -41,6 +45,12 @@ collectionsCount: number | null; /** May be empty. */ tags: string[]; + /** Admin-only: show the "Queue for analysis" action. */ + canAnalyze?: boolean; + /** Force-enqueue the current page for AI analysis. */ + onAnalyzePage?: () => void; + /** Transient state for the analyze action's label. */ + analyzeState?: 'idle' | 'busy' | 'done' | 'error'; } = $props(); let menuEl: HTMLDivElement | undefined = $state(); @@ -170,6 +180,24 @@