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