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

@@ -49,32 +49,62 @@ pub async fn enqueue_for_page(pool: &PgPool, page_id: Uuid, force: bool) -> AppR
Ok(())
}
/// Bulk-enqueue `analyze_page` jobs for existing pages — the admin
/// backfill path for content uploaded/crawled before analysis was
/// enabled. When `only_unanalyzed` is true, pages that already have a
/// `done` analysis row are skipped. Pages with a pending/running
/// How wide a bulk re-enqueue reaches.
#[derive(Debug, Clone, Copy)]
pub enum ReenqueueScope {
/// Every page in the library.
All,
/// Every page across all chapters of one manga.
Manga(uuid::Uuid),
/// Every page of one chapter.
Chapter(uuid::Uuid),
}
/// Bulk-enqueue `analyze_page` jobs for existing pages within `scope` — the
/// admin backfill / re-analyze path.
///
/// When `only_unanalyzed` is true, pages that already have a `done`
/// analysis row are skipped and the enqueued jobs carry `force = false`.
/// When false, ALL in-scope pages are enqueued with `force = true` so the
/// worker re-analyzes even already-done pages (otherwise the worker's
/// skip-if-done net would no-op them). Pages with a pending/running
/// `analyze_page` job are always skipped so repeated calls don't pile up
/// duplicates. Returns the number of jobs enqueued.
pub async fn enqueue_all_pages(pool: &PgPool, only_unanalyzed: bool) -> AppResult<u64> {
let result = sqlx::query(
pub async fn enqueue_pages(
pool: &PgPool,
scope: ReenqueueScope,
only_unanalyzed: bool,
) -> AppResult<u64> {
// Scope predicate; the bound uuid (when present) is always $2.
let scope_clause = match scope {
ReenqueueScope::All => "",
ReenqueueScope::Manga(_) => {
"AND p.chapter_id IN (SELECT id FROM chapters WHERE manga_id = $2)"
}
ReenqueueScope::Chapter(_) => "AND p.chapter_id = $2",
};
let sql = format!(
r#"
INSERT INTO crawler_jobs (payload)
SELECT jsonb_build_object('kind', 'analyze_page', 'page_id', p.id, 'force', false)
SELECT jsonb_build_object('kind', 'analyze_page', 'page_id', p.id, 'force', NOT $1)
FROM pages p
WHERE ($1 = false OR NOT EXISTS (
SELECT 1 FROM page_analysis pa
WHERE pa.page_id = p.id AND pa.status = 'done'))
{scope_clause}
AND NOT 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'))
"#,
)
.bind(only_unanalyzed)
.execute(pool)
.await?;
Ok(result.rows_affected())
"#
);
let query = sqlx::query(&sql).bind(only_unanalyzed);
let query = match scope {
ReenqueueScope::All => query,
ReenqueueScope::Manga(id) | ReenqueueScope::Chapter(id) => query.bind(id),
};
Ok(query.execute(pool).await?.rows_affected())
}
/// Deduplicated union of content warnings across all of a manga's pages,