feat(analysis): admin coverage + per-page inspection API

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:34:05 +02:00
parent 268e8cc6c2
commit 824f5acf22
7 changed files with 469 additions and 6 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -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<AppState> {
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<String>,
#[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<AppState>,
_admin: RequireAdmin,
Query(params): Query<CoverageParams>,
) -> AppResult<Json<PagedResponse<MangaCoverage>>> {
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<AppState>,
_admin: RequireAdmin,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<ItemsResponse<ChapterCoverage>>> {
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<AppState>,
_admin: RequireAdmin,
Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ItemsResponse<PageStatusItem>>> {
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<AppState>,
_admin: RequireAdmin,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<PageAnalysisDetail>> {
let detail = repo::page_analysis::page_detail(&state.db, page_id)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(detail))
}
#[derive(Debug, Serialize)]
struct ItemsResponse<T> {
items: Vec<T>,
}
#[derive(Debug, Deserialize)]

View File

@@ -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<String>,
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<String>,
pub model: Option<String>,
pub error: Option<String>,
pub analyzed_at: Option<DateTime<Utc>>,
pub ocr: Vec<OcrLine>,
pub tags: Vec<String>,
pub content_warnings: Vec<ContentWarning>,
}
/// One persisted `page_analysis` row.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct PageAnalysis {

View File

@@ -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<MangaCoverage>, 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<Vec<ChapterCoverage>> {
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<Vec<PageStatusItem>> {
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<Option<PageAnalysisDetail>> {
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<String> = 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(

View File

@@ -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);
}

View File

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