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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user