feat(analysis): scoped admin re-enqueue (all/manga/chapter) + force-on-include

Generalizes the admin re-enqueue beyond global backfill:

- repo::page_analysis::enqueue_pages(scope, only_unanalyzed) with
  ReenqueueScope::{All,Manga,Chapter}. Fixes include-analyzed semantics:
  only_unanalyzed=false now enqueues jobs with force=true so the worker
  actually re-analyzes already-done pages instead of skipping them.
- POST /v1/admin/analysis/reenqueue accepts optional manga_id / chapter_id
  (mutually exclusive, 404 on unknown target) alongside only_unanalyzed.

Tests: manga/chapter scope counts, include-analyzed sets force=true on the
done page, mutual-exclusivity 422, unknown-target 404. Existing global
backfill tests still green (13 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 19:49:54 +02:00
parent d36f24e9af
commit 9607488278
6 changed files with 255 additions and 28 deletions

View File

@@ -27,12 +27,26 @@ pub fn routes() -> Router<AppState> {
.route("/admin/pages/:id/analyze", post(analyze_page))
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct ReenqueueBody {
/// Skip pages that already have a `done` analysis row. Defaults to
/// true so a routine backfill only touches the gaps.
/// true so a routine backfill only touches the gaps; `false` forces a
/// full re-analysis of in-scope pages.
#[serde(default = "default_true")]
pub only_unanalyzed: bool,
/// Limit the re-enqueue to one manga (all its chapters' pages).
#[serde(default)]
pub manga_id: Option<Uuid>,
/// Limit the re-enqueue to one chapter's pages. Mutually exclusive
/// with `manga_id`.
#[serde(default)]
pub chapter_id: Option<Uuid>,
}
impl Default for ReenqueueBody {
fn default() -> Self {
Self { only_unanalyzed: true, manga_id: None, chapter_id: None }
}
}
fn default_true() -> bool {
@@ -67,17 +81,58 @@ async fn reenqueue(
body: Option<Json<ReenqueueBody>>,
) -> AppResult<Json<ReenqueueResponse>> {
ensure_enabled(&state)?;
let only_unanalyzed = body.map(|b| b.0.only_unanalyzed).unwrap_or(true);
let body = body.map(|b| b.0).unwrap_or_default();
let enqueued = repo::page_analysis::enqueue_all_pages(&state.db, only_unanalyzed).await?;
// Resolve the scope. manga_id and chapter_id are mutually exclusive;
// an unknown target is a 404 rather than a silent zero-enqueue.
if body.manga_id.is_some() && body.chapter_id.is_some() {
return Err(AppError::ValidationFailed {
message: "manga_id and chapter_id are mutually exclusive".into(),
details: json!({ "scope": "pick at most one" }),
});
}
let (scope, target_type, target_id) = if let Some(chapter_id) = body.chapter_id {
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);
}
(
repo::page_analysis::ReenqueueScope::Chapter(chapter_id),
"chapter",
Some(chapter_id),
)
} else if let Some(manga_id) = body.manga_id {
if !repo::manga::exists(&state.db, manga_id).await? {
return Err(AppError::NotFound);
}
(
repo::page_analysis::ReenqueueScope::Manga(manga_id),
"manga",
Some(manga_id),
)
} else {
(repo::page_analysis::ReenqueueScope::All, "analysis", None)
};
let enqueued =
repo::page_analysis::enqueue_pages(&state.db, scope, body.only_unanalyzed).await?;
repo::admin_audit::insert(
&state.db,
admin.0.id,
"analysis_reenqueue",
"analysis",
None,
json!({ "only_unanalyzed": only_unanalyzed, "enqueued": enqueued }),
target_type,
target_id,
json!({
"only_unanalyzed": body.only_unanalyzed,
"manga_id": body.manga_id,
"chapter_id": body.chapter_id,
"enqueued": enqueued,
}),
)
.await?;