Adds a per-tick cover-backfill pass to the crawler daemon so mangas whose cover download failed on first attempt get retried — the metadata pass's early-stop optimisation otherwise prevents the walk from revisiting them. Adds admin-only POST /admin/mangas/:id/resync and POST /admin/chapters/:id/resync that refetch metadata + cover (or chapter content with force_refetch) from the crawler source synchronously and return the refreshed row. Surfaced in the UI as "Force resync" buttons on the manga detail and reader pages, admin-only via session.user.is_admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
5.6 KiB
Rust
177 lines
5.6 KiB
Rust
//! 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<AppState> {
|
|
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<usize>,
|
|
}
|
|
|
|
async fn resync_manga(
|
|
State(state): State<AppState>,
|
|
admin: RequireAdmin,
|
|
Path(manga_id): Path<Uuid>,
|
|
) -> AppResult<Json<MangaResyncResponse>> {
|
|
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<AppState>,
|
|
admin: RequireAdmin,
|
|
Path(chapter_id): Path<Uuid>,
|
|
) -> AppResult<Json<ChapterResyncResponse>> {
|
|
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<Uuid> =
|
|
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::<ResyncError>() {
|
|
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)
|
|
}
|
|
}
|