From 824f5acf220ee61d4b7a0b8804bba196833d3320 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 13 Jun 2026 20:34:05 +0200 Subject: [PATCH] feat(analysis): admin coverage + per-page inspection API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only admin endpoints (admin-gated, not analysis-enabled-gated) for the dashboard's coverage overview and page-detail view: - GET /v1/admin/analysis/mangas?search= — paginated per-manga coverage (analyzed/total pages; only mangas with pages). - GET /v1/admin/analysis/mangas/:id/chapters — per-chapter coverage. - GET /v1/admin/analysis/chapters/:id/pages — per-page status grid (done | failed | queued | none). - GET /v1/admin/analysis/pages/:id — full result (status, is_nsfw, scene, model, analyzed_at, error, OCR lines with kind, tags, content warnings); "none" for an existing-but-unanalyzed page, 404 for a missing page. repo::page_analysis gains manga_coverage / chapter_coverage / chapter_page_status / page_detail; domain adds the matching row types. Tests: coverage counts (manga + chapter), per-page status, full detail + unanalyzed "none" + 404, non-admin 403. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/api/admin/analysis.rs | 90 ++++++++++++++- backend/src/domain/page_analysis.rs | 55 +++++++++ backend/src/repo/page_analysis.rs | 170 +++++++++++++++++++++++++++- backend/tests/api_admin_analysis.rs | 154 +++++++++++++++++++++++++ frontend/package.json | 2 +- 7 files changed, 469 insertions(+), 6 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1c0f189..d1c97d6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.74.1" +version = "0.75.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e23442a..9f53d99 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.74.1" +version = "0.75.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/admin/analysis.rs b/backend/src/api/admin/analysis.rs index cd238c8..7f054a8 100644 --- a/backend/src/api/admin/analysis.rs +++ b/backend/src/api/admin/analysis.rs @@ -9,15 +9,19 @@ //! * `POST /admin/pages/:id/analyze` — force re-analysis of a single page //! (re-runs even if already `done`). -use axum::extract::{Path, State}; -use axum::routing::post; +use axum::extract::{Path, Query, State}; +use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; +use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; +use crate::domain::page_analysis::{ + ChapterCoverage, MangaCoverage, PageAnalysisDetail, PageStatusItem, +}; use crate::error::{AppError, AppResult}; use crate::repo; @@ -25,6 +29,88 @@ pub fn routes() -> Router { Router::new() .route("/admin/analysis/reenqueue", post(reenqueue)) .route("/admin/pages/:id/analyze", post(analyze_page)) + // Coverage / inspection (admin-gated, but NOT analysis-enabled-gated + // — an operator can browse coverage with the worker off). + .route("/admin/analysis/mangas", get(coverage_mangas)) + .route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters)) + .route("/admin/analysis/chapters/:id/pages", get(chapter_pages)) + .route("/admin/analysis/pages/:id", get(page_detail)) +} + +#[derive(Debug, Deserialize)] +pub struct CoverageParams { + #[serde(default)] + pub search: Option, + #[serde(default = "default_coverage_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_coverage_limit() -> i64 { + 25 +} + +async fn coverage_mangas( + State(state): State, + _admin: RequireAdmin, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 100); + let offset = params.offset.max(0); + let search = params + .search + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let (items, total) = + repo::page_analysis::manga_coverage(&state.db, search, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn coverage_chapters( + State(state): State, + _admin: RequireAdmin, + Path(manga_id): Path, +) -> AppResult>> { + if !repo::manga::exists(&state.db, manga_id).await? { + return Err(AppError::NotFound); + } + let items = repo::page_analysis::chapter_coverage(&state.db, manga_id).await?; + Ok(Json(ItemsResponse { items })) +} + +async fn chapter_pages( + State(state): State, + _admin: RequireAdmin, + Path(chapter_id): Path, +) -> AppResult>> { + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM chapters WHERE id = $1)") + .bind(chapter_id) + .fetch_one(&state.db) + .await?; + if !exists { + return Err(AppError::NotFound); + } + let items = repo::page_analysis::chapter_page_status(&state.db, chapter_id).await?; + Ok(Json(ItemsResponse { items })) +} + +async fn page_detail( + State(state): State, + _admin: RequireAdmin, + Path(page_id): Path, +) -> AppResult> { + let detail = repo::page_analysis::page_detail(&state.db, page_id) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(detail)) +} + +#[derive(Debug, Serialize)] +struct ItemsResponse { + items: Vec, } #[derive(Debug, Deserialize)] diff --git a/backend/src/domain/page_analysis.rs b/backend/src/domain/page_analysis.rs index aad4655..d654e2d 100644 --- a/backend/src/domain/page_analysis.rs +++ b/backend/src/domain/page_analysis.rs @@ -128,6 +128,61 @@ pub struct PageSearchItem { pub rank: f32, } +/// Analysis coverage for one manga (admin overview): how many of its pages +/// have a completed analysis vs. how many exist. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct MangaCoverage { + pub manga_id: Uuid, + pub title: String, + pub total_pages: i64, + pub analyzed_pages: i64, +} + +/// Analysis coverage for one chapter. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ChapterCoverage { + pub chapter_id: Uuid, + pub number: i32, + pub title: Option, + pub total_pages: i64, + pub analyzed_pages: i64, +} + +/// Per-page analysis status for a chapter's page grid. `status` is one of +/// `done` | `failed` | `queued` (a pending/running job) | `none`. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PageStatusItem { + pub page_id: Uuid, + pub page_number: i32, + pub status: String, +} + +/// One OCR line in the page-detail view. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct OcrLine { + pub kind: OcrKind, + pub text: String, +} + +/// Full analysis result for one page, for the admin detail modal. `status` +/// is `done` | `failed` | `none` (no analysis row yet). +#[derive(Debug, Clone, Serialize)] +pub struct PageAnalysisDetail { + pub page_id: Uuid, + pub page_number: i32, + pub chapter_id: Uuid, + pub manga_id: Uuid, + pub status: String, + pub is_nsfw: bool, + pub scene_description: Option, + pub model: Option, + pub error: Option, + pub analyzed_at: Option>, + pub ocr: Vec, + pub tags: Vec, + pub content_warnings: Vec, +} + /// One persisted `page_analysis` row. #[derive(Debug, Clone, Serialize, FromRow)] pub struct PageAnalysis { diff --git a/backend/src/repo/page_analysis.rs b/backend/src/repo/page_analysis.rs index 9b44dd6..b3f041c 100644 --- a/backend/src/repo/page_analysis.rs +++ b/backend/src/repo/page_analysis.rs @@ -16,7 +16,8 @@ use uuid::Uuid; use crate::crawler::jobs::{self, JobPayload}; use crate::domain::page_analysis::{ - ContentWarning, OcrKind, PageAnalysis, PageSearchItem, VisionAnalysis, + ChapterCoverage, ContentWarning, MangaCoverage, OcrKind, OcrLine, PageAnalysis, + PageAnalysisDetail, PageSearchItem, PageStatusItem, VisionAnalysis, }; use crate::error::AppResult; @@ -107,6 +108,173 @@ pub async fn enqueue_pages( Ok(query.execute(pool).await?.rows_affected()) } +/// Per-manga analysis coverage for the admin overview. Only mangas that +/// have at least one page appear (nothing to analyze otherwise). `search` +/// filters by title (case-insensitive substring). Ordered by title. +/// Returns the page plus the total matching-manga count for pagination. +pub async fn manga_coverage( + pool: &PgPool, + search: Option<&str>, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, MangaCoverage>( + r#" + SELECT m.id AS manga_id, m.title, + count(p.id) AS total_pages, + count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages + FROM mangas m + JOIN chapters c ON c.manga_id = m.id + JOIN pages p ON p.chapter_id = c.id + LEFT JOIN page_analysis pa ON pa.page_id = p.id + WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%') + GROUP BY m.id, m.title + ORDER BY lower(m.title), m.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(search) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT count(*) FROM ( + SELECT m.id + FROM mangas m + JOIN chapters c ON c.manga_id = m.id + JOIN pages p ON p.chapter_id = c.id + WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%') + GROUP BY m.id + ) x + "#, + ) + .bind(search) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +/// Per-chapter analysis coverage for one manga, ordered by chapter number. +pub async fn chapter_coverage( + pool: &PgPool, + manga_id: uuid::Uuid, +) -> AppResult> { + let rows = sqlx::query_as::<_, ChapterCoverage>( + r#" + SELECT c.id AS chapter_id, c.number, c.title, + count(p.id) AS total_pages, + count(*) FILTER (WHERE pa.status = 'done') AS analyzed_pages + FROM chapters c + JOIN pages p ON p.chapter_id = c.id + LEFT JOIN page_analysis pa ON pa.page_id = p.id + WHERE c.manga_id = $1 + GROUP BY c.id, c.number, c.title + ORDER BY c.number, c.id + "#, + ) + .bind(manga_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Per-page status grid for a chapter. `status` resolves to the analysis +/// row's state (`done`/`failed`), else `queued` when a pending/running +/// analyze_page job exists, else `none`. +pub async fn chapter_page_status( + pool: &PgPool, + chapter_id: uuid::Uuid, +) -> AppResult> { + let rows = sqlx::query_as::<_, PageStatusItem>( + r#" + SELECT p.id AS page_id, p.page_number, + COALESCE( + pa.status, + CASE WHEN EXISTS ( + SELECT 1 FROM crawler_jobs j + WHERE j.payload->>'kind' = 'analyze_page' + AND j.payload->>'page_id' = p.id::text + AND j.state IN ('pending', 'running') + ) THEN 'queued' ELSE 'none' END + ) AS status + FROM pages p + LEFT JOIN page_analysis pa ON pa.page_id = p.id + WHERE p.chapter_id = $1 + ORDER BY p.page_number + "#, + ) + .bind(chapter_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Full analysis detail for one page. `None` when the page itself doesn't +/// exist; an existing-but-unanalyzed page returns `status = "none"` with +/// empty OCR/tags/warnings so the UI can show "not analyzed yet". +pub async fn page_detail( + pool: &PgPool, + page_id: uuid::Uuid, +) -> AppResult> { + let Some((page_number, chapter_id, manga_id)): Option<(i32, uuid::Uuid, uuid::Uuid)> = + sqlx::query_as( + "SELECT p.page_number, p.chapter_id, c.manga_id \ + FROM pages p JOIN chapters c ON c.id = p.chapter_id WHERE p.id = $1", + ) + .bind(page_id) + .fetch_optional(pool) + .await? + else { + return Ok(None); + }; + + let analysis = load(pool, page_id).await?; + + let ocr = sqlx::query_as::<_, OcrLine>( + "SELECT kind, text FROM page_ocr_text WHERE page_id = $1 ORDER BY ord, id", + ) + .bind(page_id) + .fetch_all(pool) + .await?; + + let tags: Vec = sqlx::query_scalar( + "SELECT t.name FROM page_auto_tags pat JOIN tags t ON t.id = pat.tag_id \ + WHERE pat.page_id = $1 ORDER BY t.name", + ) + .bind(page_id) + .fetch_all(pool) + .await?; + + let content_warnings = sqlx::query_scalar::<_, ContentWarning>( + "SELECT warning FROM page_content_warnings WHERE page_id = $1 ORDER BY warning", + ) + .bind(page_id) + .fetch_all(pool) + .await?; + + Ok(Some(PageAnalysisDetail { + page_id, + page_number, + chapter_id, + manga_id, + status: analysis + .as_ref() + .map(|a| format!("{:?}", a.status).to_lowercase()) + .unwrap_or_else(|| "none".to_string()), + is_nsfw: analysis.as_ref().map(|a| a.is_nsfw).unwrap_or(false), + scene_description: analysis.as_ref().and_then(|a| a.scene_description.clone()), + model: analysis.as_ref().and_then(|a| a.model.clone()), + error: analysis.as_ref().and_then(|a| a.error.clone()), + analyzed_at: analysis.as_ref().and_then(|a| a.analyzed_at), + ocr, + tags, + content_warnings, + })) +} + /// Deduplicated union of content warnings across all of a manga's pages, /// ordered alphabetically. Powers the manga-detail content-warning banner. pub async fn warnings_for_manga( diff --git a/backend/tests/api_admin_analysis.rs b/backend/tests/api_admin_analysis.rs index 3cb85f0..5fef297 100644 --- a/backend/tests/api_admin_analysis.rs +++ b/backend/tests/api_admin_analysis.rs @@ -409,3 +409,157 @@ async fn force_analyze_unknown_page_is_404(pool: PgPool) { .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +// ---- coverage / inspection ------------------------------------------------- + +async fn get_json(h: &common::Harness, cookie: &str, uri: &str) -> serde_json::Value { + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie(uri, cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed"); + common::body_json(resp).await +} + +/// Seed a manga (1 chapter, 2 pages), analyze the first, return ids. +async fn seed_partial(pool: &PgPool) -> (uuid::Uuid, uuid::Uuid, uuid::Uuid, uuid::Uuid) { + use mangalord::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis}; + let (manga_id, chapter_id, ids) = seed_manga_chapter_pages(pool, 2).await; + repo::page_analysis::persist_analysis( + pool, + ids[0], + &VisionAnalysis { + ocr_results: vec![OcrResult { + text: "Hello".into(), + kind: "speech".into(), + }], + tagging_results: vec!["action".into(), "city".into()], + scene_description: "A rainy street.".into(), + safety_flag: SafetyFlag { + is_nsfw: true, + content_type: vec!["gore".into()], + }, + }, + "test-model", + ) + .await + .unwrap(); + (manga_id, chapter_id, ids[0], ids[1]) +} + +#[sqlx::test(migrations = "./migrations")] +async fn coverage_mangas_reports_analyzed_over_total(pool: PgPool) { + // Coverage works with the worker disabled — use the plain harness. + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + let (manga_id, _ch, _p1, _p2) = seed_partial(&pool).await; + + let body = get_json(&h, &cookie, "/api/v1/admin/analysis/mangas?search=M").await; + let item = &body["items"][0]; + assert_eq!(item["manga_id"].as_str().unwrap(), manga_id.to_string()); + assert_eq!(item["total_pages"], 2); + assert_eq!(item["analyzed_pages"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn coverage_chapters_lists_per_chapter_counts(pool: PgPool) { + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + let (manga_id, chapter_id, _p1, _p2) = seed_partial(&pool).await; + + let body = get_json( + &h, + &cookie, + &format!("/api/v1/admin/analysis/mangas/{manga_id}/chapters"), + ) + .await; + let item = &body["items"][0]; + assert_eq!(item["chapter_id"].as_str().unwrap(), chapter_id.to_string()); + assert_eq!(item["total_pages"], 2); + assert_eq!(item["analyzed_pages"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn chapter_pages_reports_per_page_status(pool: PgPool) { + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + let (_m, chapter_id, p1, p2) = seed_partial(&pool).await; + + let body = get_json( + &h, + &cookie, + &format!("/api/v1/admin/analysis/chapters/{chapter_id}/pages"), + ) + .await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + let status_for = |id: uuid::Uuid| { + items + .iter() + .find(|i| i["page_id"].as_str().unwrap() == id.to_string()) + .unwrap()["status"] + .as_str() + .unwrap() + .to_string() + }; + assert_eq!(status_for(p1), "done"); + assert_eq!(status_for(p2), "none"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn page_detail_returns_full_result(pool: PgPool) { + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + let (_m, _ch, p1, p2) = seed_partial(&pool).await; + + let d = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p1}")).await; + assert_eq!(d["status"], "done"); + assert_eq!(d["is_nsfw"], true); + assert_eq!(d["scene_description"], "A rainy street."); + assert_eq!(d["model"], "test-model"); + assert_eq!(d["ocr"][0]["kind"], "speech"); + assert_eq!(d["ocr"][0]["text"], "Hello"); + let tags: Vec<&str> = d["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); + assert!(tags.contains(&"action") && tags.contains(&"city")); + assert_eq!(d["content_warnings"][0], "gore"); + + // Unanalyzed page → status "none", empty result. + let d2 = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p2}")).await; + assert_eq!(d2["status"], "none"); + assert_eq!(d2["ocr"].as_array().unwrap().len(), 0); + assert_eq!(d2["tags"].as_array().unwrap().len(), 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn page_detail_unknown_page_is_404(pool: PgPool) { + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie( + &format!("/api/v1/admin/analysis/pages/{}", uuid::Uuid::new_v4()), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn coverage_requires_admin(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_u, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .clone() + .oneshot(common::get_with_cookie( + "/api/v1/admin/analysis/mangas", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/frontend/package.json b/frontend/package.json index df8c36c..d968d98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.74.1", + "version": "0.75.0", "private": true, "type": "module", "scripts": {