Wires the analyze_page job kind into the page-create paths and adds admin controls, all gated on a new ANALYSIS_ENABLED flag (AppState.analysis_enabled, config::AnalysisConfig): - Chapter upload (api::chapters) enqueues one analyze_page job per page after the tx commits (best-effort; failures logged, never fatal). - Crawler content sync (persist_pages → RETURNING id) enqueues per persisted page when the daemon dispatcher's analysis_enabled flag is set; threaded through sync_chapter_content (CLI/resync pass false). - repo::page_analysis::enqueue_all_pages: bulk backfill via INSERT..SELECT, skipping done pages (only_unanalyzed) and existing pending jobs. - New admin endpoints (RequireAdmin, 503 when disabled, audited): POST /admin/analysis/reenqueue and POST /admin/pages/:id/analyze (force). Tests: upload enqueues N jobs / no-op when disabled; crawler persist_pages enqueue gate; admin reenqueue (backfill, idempotent, only-unanalyzed, 503, non-admin 403) and force-analyze (force flag, 404). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! Admin-triggered resync of a single manga's metadata + cover, or a
|
|
//! single chapter's content.
|
|
//!
|
|
//! The cron tick already retries covers and chapter content on its own
|
|
//! schedule. This module exists for the operator-controlled path:
|
|
//! "this manga's metadata is stale / its cover never landed / this
|
|
//! chapter is broken — pull from source now, not at the next daily
|
|
//! tick." Wired into the admin API, never into the queue, so the work
|
|
//! happens synchronously with the HTTP request and the admin sees the
|
|
//! refreshed row in the response.
|
|
//!
|
|
//! Shares the daemon's [`BrowserManager`], rate limiter, HTTP client,
|
|
//! and TOR controller so a force resync respects the same per-host
|
|
//! pacing and recircuit budget the daily crawl uses — admin actions
|
|
//! must not let an operator accidentally hammer the source.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Context;
|
|
use async_trait::async_trait;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::crawler::browser_manager::BrowserManager;
|
|
use crate::crawler::content::{self, SyncOutcome};
|
|
use crate::crawler::pipeline;
|
|
use crate::crawler::rate_limit::HostRateLimiters;
|
|
use crate::crawler::safety::DownloadAllowlist;
|
|
use crate::crawler::source::target::TargetSource;
|
|
use crate::crawler::source::{FetchContext, Source, SourceMangaRef};
|
|
use crate::crawler::tor::TorController;
|
|
use crate::repo;
|
|
use crate::repo::crawler::UpsertStatus;
|
|
use crate::storage::Storage;
|
|
|
|
/// Outcome of [`ResyncService::resync_manga`]. Mirrors the bits the
|
|
/// admin UI cares about — was the row actually re-upserted, did the
|
|
/// cover land — so the response can show "metadata refreshed, cover
|
|
/// re-downloaded" or "metadata unchanged" without a second round-trip.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct MangaResyncOutcome {
|
|
pub manga_id: Uuid,
|
|
pub metadata_status: UpsertStatus,
|
|
pub cover_fetched: bool,
|
|
}
|
|
|
|
/// Outcome of [`ResyncService::resync_chapter`]. `Fetched(pages)` is the
|
|
/// success case; `Skipped` means the source row was already gone or the
|
|
/// chapter had no live source.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ChapterResyncOutcome {
|
|
Fetched { chapter_id: Uuid, pages: usize },
|
|
Skipped { chapter_id: Uuid, reason: String },
|
|
}
|
|
|
|
/// Service exposed by the daemon to the admin API. Optional on
|
|
/// [`AppState`] — `None` when the crawler daemon is disabled
|
|
/// (`CRAWLER_DAEMON=false`), in which case admin handlers return 503.
|
|
#[async_trait]
|
|
pub trait ResyncService: Send + Sync {
|
|
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome>;
|
|
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome>;
|
|
}
|
|
|
|
/// Errors with a stable shape so the API layer can map them to the
|
|
/// right HTTP status (404 vs 422 vs 5xx). Anything else surfaces as a
|
|
/// generic 500.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ResyncError {
|
|
#[error("manga has no source to resync from")]
|
|
NoMangaSource,
|
|
#[error("chapter has no source to resync from")]
|
|
NoChapterSource,
|
|
}
|
|
|
|
pub struct RealResyncService {
|
|
pub browser_manager: Arc<BrowserManager>,
|
|
pub db: PgPool,
|
|
pub storage: Arc<dyn Storage>,
|
|
pub http: reqwest::Client,
|
|
pub rate: Arc<HostRateLimiters>,
|
|
pub download_allowlist: DownloadAllowlist,
|
|
pub max_image_bytes: usize,
|
|
pub tor: Option<Arc<TorController>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ResyncService for RealResyncService {
|
|
async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result<MangaResyncOutcome> {
|
|
// Pick the freshest live source row. Multi-source mangas
|
|
// (theoretical — only one Source impl today) get the row whose
|
|
// `last_seen_at` is newest; soft-dropped rows are skipped.
|
|
let row: Option<(String, String, String)> = sqlx::query_as(
|
|
"SELECT source_id, source_manga_key, source_url \
|
|
FROM manga_sources \
|
|
WHERE manga_id = $1 AND dropped_at IS NULL \
|
|
ORDER BY last_seen_at DESC \
|
|
LIMIT 1",
|
|
)
|
|
.bind(manga_id)
|
|
.fetch_optional(&self.db)
|
|
.await
|
|
.context("look up manga_sources for resync")?;
|
|
let Some((_source_id, source_manga_key, source_url)) = row else {
|
|
return Err(ResyncError::NoMangaSource.into());
|
|
};
|
|
|
|
let lease = self
|
|
.browser_manager
|
|
.acquire()
|
|
.await
|
|
.context("acquire browser lease for manga resync")?;
|
|
let browser_ref: &chromiumoxide::Browser = &lease;
|
|
let ctx = FetchContext {
|
|
browser: browser_ref,
|
|
rate: &self.rate,
|
|
tor: self.tor.as_deref(),
|
|
};
|
|
|
|
// Parse chapters too — a force resync is "make this manga fully
|
|
// current," not just metadata. The full pipeline handles the
|
|
// partial-render guard for us; we replicate the same caution
|
|
// here by skipping the chapter sync when the parser returned
|
|
// empty but the manga previously had chapters.
|
|
let source = TargetSource::new(source_url.clone());
|
|
let r = SourceMangaRef {
|
|
source_manga_key: source_manga_key.clone(),
|
|
title: String::new(),
|
|
url: source_url.clone(),
|
|
};
|
|
let manga = source
|
|
.fetch_manga(&ctx, &r)
|
|
.await
|
|
.with_context(|| format!("fetch_manga during resync of {manga_id}"))?;
|
|
|
|
// Partial-render guard: same logic as run_metadata_pass.
|
|
let source_id = source.id();
|
|
if !manga.chapters.is_empty() || {
|
|
let prior = repo::crawler::live_chapter_count_for_source_manga(
|
|
&self.db,
|
|
source_id,
|
|
&source_manga_key,
|
|
)
|
|
.await
|
|
.unwrap_or(0);
|
|
prior == 0
|
|
} {
|
|
// Either the new fetch surfaced chapters, or there were
|
|
// none before either — chapter sync is safe to run.
|
|
} else {
|
|
tracing::warn!(
|
|
%manga_id,
|
|
source_url = %source_url,
|
|
"resync_manga: fetch returned empty chapters but prior count > 0; skipping chapter sync to avoid soft-drop"
|
|
);
|
|
}
|
|
|
|
let upsert = repo::crawler::upsert_manga_from_source(
|
|
&self.db,
|
|
source_id,
|
|
&source_url,
|
|
&manga,
|
|
)
|
|
.await
|
|
.with_context(|| format!("upsert_manga_from_source during resync of {manga_id}"))?;
|
|
|
|
// Cover refetch: force-download regardless of UpsertStatus.
|
|
// Admin clicked "resync" because they want the cover too.
|
|
let mut cover_fetched = false;
|
|
if let Some(cover_url) = manga.cover_url.as_deref() {
|
|
match pipeline::download_and_store_cover(
|
|
&self.db,
|
|
self.storage.as_ref(),
|
|
&self.http,
|
|
&self.rate,
|
|
&source_url,
|
|
upsert.manga_id,
|
|
cover_url,
|
|
&self.download_allowlist,
|
|
self.max_image_bytes,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => cover_fetched = true,
|
|
Err(e) => tracing::warn!(
|
|
%manga_id,
|
|
error = ?e,
|
|
"resync_manga: cover download failed"
|
|
),
|
|
}
|
|
}
|
|
|
|
// Chapter sync — only when the partial-render guard above
|
|
// didn't bail.
|
|
let prior_chapter_count = repo::crawler::live_chapter_count_for_source_manga(
|
|
&self.db,
|
|
source_id,
|
|
&source_manga_key,
|
|
)
|
|
.await
|
|
.unwrap_or(0);
|
|
if !manga.chapters.is_empty() || prior_chapter_count == 0 {
|
|
match repo::crawler::sync_manga_chapters(
|
|
&self.db,
|
|
source_id,
|
|
upsert.manga_id,
|
|
&manga.chapters,
|
|
)
|
|
.await
|
|
{
|
|
Ok(diff) => tracing::info!(
|
|
%manga_id,
|
|
new = diff.new,
|
|
refreshed = diff.refreshed,
|
|
dropped = diff.dropped,
|
|
"resync_manga: chapters synced"
|
|
),
|
|
Err(e) => tracing::warn!(
|
|
%manga_id,
|
|
error = ?e,
|
|
"resync_manga: chapter sync failed"
|
|
),
|
|
}
|
|
}
|
|
|
|
drop(lease);
|
|
Ok(MangaResyncOutcome {
|
|
manga_id: upsert.manga_id,
|
|
metadata_status: upsert.status,
|
|
cover_fetched,
|
|
})
|
|
}
|
|
|
|
async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result<ChapterResyncOutcome> {
|
|
let row = repo::chapter::dispatch_target(&self.db, chapter_id)
|
|
.await
|
|
.context("look up chapter_sources for resync")?;
|
|
let Some((manga_id, source_url, _title, _number)) = row else {
|
|
return Err(ResyncError::NoChapterSource.into());
|
|
};
|
|
|
|
let lease = self
|
|
.browser_manager
|
|
.acquire()
|
|
.await
|
|
.context("acquire browser lease for chapter resync")?;
|
|
let result = content::sync_chapter_content(
|
|
&lease,
|
|
&self.db,
|
|
self.storage.as_ref(),
|
|
&self.http,
|
|
&self.rate,
|
|
chapter_id,
|
|
manga_id,
|
|
&source_url,
|
|
true,
|
|
&self.download_allowlist,
|
|
self.max_image_bytes,
|
|
self.tor.as_deref(),
|
|
// Admin resync isn't a daemon worker slot — no live status.
|
|
None,
|
|
// Resync re-fetches existing pages (same ids); analysis isn't
|
|
// re-enqueued here — use the admin force re-analyze endpoint.
|
|
false,
|
|
)
|
|
.await;
|
|
drop(lease);
|
|
|
|
match result? {
|
|
SyncOutcome::Fetched { pages } => {
|
|
Ok(ChapterResyncOutcome::Fetched { chapter_id, pages })
|
|
}
|
|
SyncOutcome::Skipped => Ok(ChapterResyncOutcome::Skipped {
|
|
chapter_id,
|
|
reason: "chapter already had pages on disk".to_string(),
|
|
}),
|
|
SyncOutcome::SessionExpired => {
|
|
anyhow::bail!("source session expired — operator must refresh PHPSESSID")
|
|
}
|
|
}
|
|
}
|
|
}
|