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

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)]