//! Admin-triggered force resync of a single manga's metadata + cover, //! or a single chapter's content. //! //! Both endpoints are admin-only (`RequireAdmin`, cookie-only) and run //! synchronously with the request — the response carries the refreshed //! resource so the UI can swap it in without a follow-up GET. The work //! itself is delegated to [`ResyncService`] (set on AppState by //! `app::build` when the crawler daemon is enabled); when the daemon //! is disabled, both handlers return 503. use axum::extract::{Path, State}; use axum::routing::post; use axum::{Json, Router}; use serde::Serialize; use serde_json::json; use uuid::Uuid; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; use crate::crawler::resync::{ChapterResyncOutcome, ResyncError}; use crate::domain::manga::MangaDetail; use crate::domain::Chapter; use crate::error::{AppError, AppResult}; use crate::repo; use crate::repo::crawler::UpsertStatus; pub fn routes() -> Router { Router::new() .route("/admin/mangas/:id/resync", post(resync_manga)) .route("/admin/chapters/:id/resync", post(resync_chapter)) } #[derive(Debug, Serialize)] pub struct MangaResyncResponse { pub manga: MangaDetail, /// `"new" | "updated" | "unchanged"` — mirrors [`UpsertStatus`]. pub metadata_status: &'static str, pub cover_fetched: bool, } #[derive(Debug, Serialize)] pub struct ChapterResyncResponse { pub chapter: Chapter, /// `"fetched" | "skipped"` — whether new pages landed or the /// service short-circuited (e.g. chapter already had pages and the /// session was lost so force was downgraded). pub outcome: &'static str, /// Page count when `outcome == "fetched"`. `None` for `skipped`. pub pages: Option, } async fn resync_manga( State(state): State, admin: RequireAdmin, Path(manga_id): Path, ) -> AppResult> { if !repo::manga::exists(&state.db, manga_id).await? { return Err(AppError::NotFound); } let resync = state .resync .as_ref() .ok_or_else(|| AppError::ServiceUnavailable( "crawler daemon is disabled; force resync unavailable".into(), ))?; let outcome = resync.resync_manga(manga_id).await.map_err(map_resync_err)?; // Audit the action with the actor + the resync outcome so an // operator-of-operators can answer "who refetched this manga, and // did the cover land?" from the log alone. repo::admin_audit::insert( &state.db, admin.0.id, "manga_resync", "manga", Some(manga_id), json!({ "metadata_status": status_str(outcome.metadata_status), "cover_fetched": outcome.cover_fetched, }), ) .await?; let manga = repo::manga::get_detail(&state.db, manga_id).await?; Ok(Json(MangaResyncResponse { manga, metadata_status: status_str(outcome.metadata_status), cover_fetched: outcome.cover_fetched, })) } async fn resync_chapter( State(state): State, admin: RequireAdmin, Path(chapter_id): Path, ) -> AppResult> { let resync = state .resync .as_ref() .ok_or_else(|| AppError::ServiceUnavailable( "crawler daemon is disabled; force resync unavailable".into(), ))?; // Look up the manga the chapter belongs to so we can return the // refreshed chapter row in the response and 404 for unknown ids. let manga_id: Option = sqlx::query_scalar("SELECT manga_id FROM chapters WHERE id = $1") .bind(chapter_id) .fetch_optional(&state.db) .await?; let Some(manga_id) = manga_id else { return Err(AppError::NotFound); }; let outcome = resync .resync_chapter(chapter_id) .await .map_err(map_resync_err)?; let (outcome_str, pages) = match &outcome { ChapterResyncOutcome::Fetched { pages, .. } => ("fetched", Some(*pages)), ChapterResyncOutcome::Skipped { .. } => ("skipped", None), }; repo::admin_audit::insert( &state.db, admin.0.id, "chapter_resync", "chapter", Some(chapter_id), json!({ "outcome": outcome_str, "pages": pages, }), ) .await?; let chapter = repo::chapter::find_by_id_in_manga(&state.db, manga_id, chapter_id) .await? .ok_or(AppError::NotFound)?; Ok(Json(ChapterResyncResponse { chapter, outcome: outcome_str, pages, })) } fn status_str(s: UpsertStatus) -> &'static str { match s { UpsertStatus::New => "new", UpsertStatus::Updated => "updated", UpsertStatus::Unchanged => "unchanged", } } /// Map [`ResyncError`] (and the anyhow envelopes wrapping it) onto the /// right [`AppError`]. Anything else surfaces as a generic 500 via the /// `Other` arm — the operator sees the underlying anyhow chain in /// server logs, the client sees a clean envelope. fn map_resync_err(err: anyhow::Error) -> AppError { if let Some(rerr) = err.downcast_ref::() { match rerr { ResyncError::NoMangaSource => AppError::ValidationFailed { message: "manga has no live crawler source — cannot resync".into(), details: json!({ "manga": "no_source" }), }, ResyncError::NoChapterSource => AppError::ValidationFailed { message: "chapter has no live crawler source — cannot resync".into(), details: json!({ "chapter": "no_source" }), }, } } else { AppError::Other(err) } }