//! 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; async fn resync_chapter(&self, chapter_id: Uuid) -> anyhow::Result; } /// 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, pub db: PgPool, pub storage: Arc, pub http: reqwest::Client, pub rate: Arc, pub download_allowlist: DownloadAllowlist, pub max_image_bytes: usize, pub tor: Option>, } #[async_trait] impl ResyncService for RealResyncService { async fn resync_manga(&self, manga_id: Uuid) -> anyhow::Result { // 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 { 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") } } } }