Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
5.6 KiB
Rust
175 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()
|
|
.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()
|
|
.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)
|
|
}
|
|
}
|