feat(admin): runtime-editable crawler & analysis config in the dashboard
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled

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>
This commit is contained in:
MechaCat02
2026-06-14 13:32:20 +02:00
parent d3b827421f
commit 2b7a11b480
30 changed files with 2800 additions and 165 deletions

View File

@@ -36,10 +36,12 @@ pub const MAX_SCENE_CHARS: usize = 1000;
pub const MAX_OCR_PER_CALL: usize = 50;
pub const MAX_TAGS_PER_CALL: usize = 12;
/// The system prompt. Self-contained: it carries the exact JSON schema so
/// the same string drives both the model and (implicitly) the
/// [`crate::domain::page_analysis::VisionAnalysis`] parser.
pub const SYSTEM_PROMPT: &str = concat!(
/// The default system prompt. Self-contained: it carries the exact JSON
/// schema so the same string drives both the model and (implicitly) the
/// [`crate::domain::page_analysis::VisionAnalysis`] parser. An admin can
/// override it at runtime (see [`crate::config::AnalysisConfig::system_prompt`]);
/// this const is the fallback and the "reset to default" target.
pub const SYSTEM_PROMPT_DEFAULT: &str = concat!(
"You are a manga/comic page analyzer. Look at the single page image and ",
"return ONLY one minified JSON object — no prose, no markdown fences.\n",
"Schema:\n",
@@ -64,8 +66,9 @@ pub const SYSTEM_PROMPT: &str = concat!(
// --- Two-pass prompts (long-page slicing) -----------------------------------
/// Pass A: OCR-only over a single vertical slice of a tall page. Kept
/// narrow so each slice call is fast and never truncates.
pub const OCR_PROMPT: &str = concat!(
/// narrow so each slice call is fast and never truncates. Runtime-overridable
/// default — see [`crate::config::AnalysisConfig::ocr_prompt`].
pub const OCR_PROMPT_DEFAULT: &str = concat!(
"You are an OCR engine for manga/comic pages. The image is one vertical ",
"slice of a larger page. Transcribe EVERY visible text element verbatim ",
"into ocr_results, each with its kind ",
@@ -80,7 +83,9 @@ pub const OCR_PROMPT: &str = concat!(
/// Pass B: tags + scene + safety for the whole page, grounded in the merged
/// OCR text (supplied as a separate user message part by the client).
pub const GROUNDING_PROMPT: &str = concat!(
/// Runtime-overridable default — see
/// [`crate::config::AnalysisConfig::grounding_prompt`].
pub const GROUNDING_PROMPT_DEFAULT: &str = concat!(
"You are a manga/comic page analyzer. You are given the page image ",
"(possibly downscaled) AND the OCR text already extracted from it. Using ",
"both, return ONLY one minified JSON object with: tagging_results (5-10 ",
@@ -230,7 +235,7 @@ mod tests {
#[test]
fn system_prompt_mentions_the_schema_keys() {
for key in ["ocr_results", "tagging_results", "scene_description", "safety_flag"] {
assert!(SYSTEM_PROMPT.contains(key), "prompt missing {key}");
assert!(SYSTEM_PROMPT_DEFAULT.contains(key), "prompt missing {key}");
}
}

View File

@@ -37,6 +37,13 @@ pub struct VisionClient {
max_tokens: u32,
response_format: ResponseFormat,
frequency_penalty: f64,
temperature: f64,
/// System prompt for the single-call (normal-aspect) path.
system_prompt: String,
/// Pass-A OCR-only prompt (tall-page slices).
ocr_prompt: String,
/// Pass-B grounding prompt (tags/scene/safety).
grounding_prompt: String,
slice: SliceParams,
}
@@ -60,6 +67,10 @@ impl VisionClient {
max_tokens: cfg.max_tokens,
response_format: cfg.response_format,
frequency_penalty: cfg.frequency_penalty,
temperature: cfg.temperature,
system_prompt: cfg.system_prompt.clone(),
ocr_prompt: cfg.ocr_prompt.clone(),
grounding_prompt: cfg.grounding_prompt.clone(),
slice: SliceParams {
max_pixels: cfg.max_pixels,
min_slice_height: cfg.min_slice_height,
@@ -83,6 +94,8 @@ impl VisionClient {
&url,
self.response_format,
self.frequency_penalty,
self.temperature,
&self.system_prompt,
);
return parse_chat_completion(&self.post_chat(body).await?);
};
@@ -97,6 +110,8 @@ impl VisionClient {
&data_url(&jpeg),
self.response_format,
self.frequency_penalty,
self.temperature,
&self.system_prompt,
);
parse_chat_completion(&self.post_chat(body).await?)
}
@@ -124,6 +139,8 @@ impl VisionClient {
self.max_tokens,
self.response_format,
self.frequency_penalty,
self.temperature,
&self.ocr_prompt,
&data_url(&jpeg),
);
let parsed = parse_chat_completion(&self.post_chat(body).await?)?;
@@ -148,6 +165,8 @@ impl VisionClient {
self.max_tokens,
self.response_format,
self.frequency_penalty,
self.temperature,
&self.grounding_prompt,
&data_url(&whole),
&ocr_text,
);
@@ -451,19 +470,23 @@ fn similar(a: &str, b: &str) -> bool {
/// Combined single-call body (OCR + tags + scene + safety). Public + this
/// exact signature so the existing tests keep working.
#[allow(clippy::too_many_arguments)]
pub fn build_request_body(
model: &str,
max_tokens: u32,
data_url: &str,
response_format: ResponseFormat,
frequency_penalty: f64,
temperature: f64,
system_prompt: &str,
) -> serde_json::Value {
build_body(
model,
max_tokens,
response_format,
frequency_penalty,
prompt::SYSTEM_PROMPT,
temperature,
system_prompt,
"page_analysis",
prompt::output_json_schema(),
data_url,
@@ -472,11 +495,14 @@ pub fn build_request_body(
}
/// Pass-A OCR-only body.
#[allow(clippy::too_many_arguments)]
fn build_ocr_body(
model: &str,
max_tokens: u32,
response_format: ResponseFormat,
frequency_penalty: f64,
temperature: f64,
ocr_prompt: &str,
data_url: &str,
) -> serde_json::Value {
build_body(
@@ -484,7 +510,8 @@ fn build_ocr_body(
max_tokens,
response_format,
frequency_penalty,
prompt::OCR_PROMPT,
temperature,
ocr_prompt,
"page_ocr",
prompt::ocr_json_schema(),
data_url,
@@ -493,11 +520,14 @@ fn build_ocr_body(
}
/// Pass-B grounding body — whole image + the merged OCR text as context.
#[allow(clippy::too_many_arguments)]
fn build_grounding_body(
model: &str,
max_tokens: u32,
response_format: ResponseFormat,
frequency_penalty: f64,
temperature: f64,
grounding_prompt: &str,
data_url: &str,
ocr_text: &str,
) -> serde_json::Value {
@@ -507,7 +537,8 @@ fn build_grounding_body(
max_tokens,
response_format,
frequency_penalty,
prompt::GROUNDING_PROMPT,
temperature,
grounding_prompt,
"page_grounding",
prompt::grounding_json_schema(),
data_url,
@@ -524,6 +555,7 @@ fn build_body(
max_tokens: u32,
response_format: ResponseFormat,
frequency_penalty: f64,
temperature: f64,
system_prompt: &str,
schema_name: &str,
schema: serde_json::Value,
@@ -537,7 +569,7 @@ fn build_body(
}
let mut body = json!({
"model": model,
"temperature": 0,
"temperature": temperature,
"max_tokens": max_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
@@ -740,8 +772,15 @@ mod tests {
#[test]
fn combined_body_uses_full_schema_and_image() {
let body =
build_request_body("m", 100, "data:image/png;base64,AA", ResponseFormat::JsonSchema, 0.3);
let body = build_request_body(
"m",
100,
"data:image/png;base64,AA",
ResponseFormat::JsonSchema,
0.3,
0.0,
prompt::SYSTEM_PROMPT_DEFAULT,
);
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(
body["messages"][1]["content"][0]["image_url"]["url"],
@@ -752,9 +791,25 @@ mod tests {
assert!(req.as_array().unwrap().iter().any(|v| v == "safety_flag"));
}
#[test]
fn body_carries_the_configured_prompt_and_temperature() {
let body = build_request_body(
"m",
100,
"d",
ResponseFormat::None,
0.0,
0.7,
"CUSTOM PROMPT",
);
assert_eq!(body["messages"][0]["content"], "CUSTOM PROMPT");
assert_eq!(body["temperature"], 0.7);
}
#[test]
fn ocr_body_carries_only_the_ocr_schema_and_caps() {
let body = build_ocr_body("m", 100, ResponseFormat::JsonSchema, 0.3, "d");
let body =
build_ocr_body("m", 100, ResponseFormat::JsonSchema, 0.3, 0.0, prompt::OCR_PROMPT_DEFAULT, "d");
let schema = &body["response_format"]["json_schema"]["schema"];
assert_eq!(body["response_format"]["json_schema"]["name"], "page_ocr");
assert_eq!(schema["required"], json!(["ocr_results"]));
@@ -769,8 +824,16 @@ mod tests {
#[test]
fn grounding_body_includes_ocr_context_and_grounding_schema() {
let body =
build_grounding_body("m", 100, ResponseFormat::JsonSchema, 0.3, "d", "[speech] Hi");
let body = build_grounding_body(
"m",
100,
ResponseFormat::JsonSchema,
0.3,
0.0,
prompt::GROUNDING_PROMPT_DEFAULT,
"d",
"[speech] Hi",
);
assert_eq!(body["response_format"]["json_schema"]["name"], "page_grounding");
let parts = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(parts.len(), 2, "image + text parts");
@@ -781,15 +844,25 @@ mod tests {
#[test]
fn response_format_none_omits_the_field() {
let body = build_request_body("m", 100, "d", ResponseFormat::None, 0.3);
let body = build_request_body(
"m",
100,
"d",
ResponseFormat::None,
0.3,
0.0,
prompt::SYSTEM_PROMPT_DEFAULT,
);
assert!(body.get("response_format").is_none());
}
#[test]
fn frequency_penalty_included_only_when_nonzero() {
let on = build_ocr_body("m", 100, ResponseFormat::None, 0.4, "d");
let on =
build_ocr_body("m", 100, ResponseFormat::None, 0.4, 0.0, prompt::OCR_PROMPT_DEFAULT, "d");
assert_eq!(on["frequency_penalty"], 0.4);
let off = build_ocr_body("m", 100, ResponseFormat::None, 0.0, "d");
let off =
build_ocr_body("m", 100, ResponseFormat::None, 0.0, 0.0, prompt::OCR_PROMPT_DEFAULT, "d");
assert!(off.get("frequency_penalty").is_none());
}

View File

@@ -188,7 +188,7 @@ pub struct AnalyzePageResponse {
/// Reject the request with 503 when the analysis worker is disabled, so
/// an operator doesn't pile up jobs that nothing will ever drain.
fn ensure_enabled(state: &AppState) -> AppResult<()> {
if state.analysis_enabled {
if state.analysis_enabled() {
Ok(())
} else {
Err(AppError::ServiceUnavailable(

View File

@@ -37,13 +37,14 @@ pub(super) fn default_limit() -> i64 {
}
/// Shared 503 helper: the daemon-only control + observability endpoints
/// gate on `AppState.crawler == Some(_)`. Returns the same code and body
/// across every caller so the frontend can rely on `service_unavailable`
/// rather than each handler returning its own variant.
/// gate on a running crawler daemon. Returns the same code and body across
/// every caller so the frontend can rely on `service_unavailable` rather
/// than each handler returning its own variant. Yields an owned `Arc` (the
/// control handle is swapped on a config reload).
pub(super) fn require_crawler(
state: &AppState,
) -> Result<&std::sync::Arc<CrawlerControl>, AppError> {
state.crawler.as_ref().ok_or_else(|| {
) -> Result<std::sync::Arc<CrawlerControl>, AppError> {
state.crawler().ok_or_else(|| {
AppError::ServiceUnavailable("crawler daemon is disabled".into())
})
}

View File

@@ -140,7 +140,7 @@ async fn compose_status(
dead,
};
Ok(match state.crawler.as_ref() {
Ok(match state.crawler().as_ref() {
None => CrawlerStatusResponse {
daemon: "disabled",
phase: None,
@@ -200,7 +200,7 @@ async fn stream_status(
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Subscribe before the first emit so no change between the initial
// snapshot and the first await is lost.
let rx = state.crawler.as_ref().map(|c| c.status.subscribe());
let rx = state.crawler().as_ref().map(|c| c.status.subscribe());
let memo = QueueCountsMemo::new();
let stream = futures_util::stream::unfold(

View File

@@ -8,6 +8,7 @@ pub mod analysis;
pub mod crawler;
pub mod mangas;
pub mod resync;
pub mod settings;
pub mod system;
pub mod users;
@@ -23,4 +24,5 @@ pub fn routes() -> Router<AppState> {
.merge(system::routes())
.merge(crawler::routes())
.merge(analysis::routes())
.merge(settings::routes())
}

View File

@@ -58,8 +58,7 @@ async fn resync_manga(
return Err(AppError::NotFound);
}
let resync = state
.resync
.as_ref()
.resync()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;
@@ -96,8 +95,7 @@ async fn resync_chapter(
Path(chapter_id): Path<Uuid>,
) -> AppResult<Json<ChapterResyncResponse>> {
let resync = state
.resync
.as_ref()
.resync()
.ok_or_else(|| AppError::ServiceUnavailable(
"crawler daemon is disabled; force resync unavailable".into(),
))?;

View File

@@ -0,0 +1,239 @@
//! Admin endpoints for runtime-editable crawler / analysis configuration.
//!
//! `GET` returns the current editable settings DTO plus a read-only view of
//! the env-managed (host/infra + secret) fields, so the dashboard can show
//! the full picture while only letting the operator edit the safe knobs.
//!
//! `PUT` validates the incoming DTO against the env-derived base (re-parsing
//! timezone/time, rebuilding the download allowlist), persists a normalized
//! DTO and an audit row in one transaction, then asks the [`DaemonReloader`]
//! to gracefully respawn the affected daemon with the new config — so the
//! change takes effect without a restart. Validation failures return 422 with
//! per-field details (the established `validation_failed` envelope).
//!
//! `PUT` is a **full replace**, not a patch: the body is deserialized with
//! field-level defaults, so any omitted field is reset to its compiled
//! default rather than retaining the stored value. The dashboard always
//! sends the complete DTO (it round-trips the one it loaded); programmatic
//! callers must do the same.
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::config::CrawlerConfig;
use crate::crawler::browser::BrowserMode;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::settings::{
AnalysisSettings, CrawlerSettings, FieldErrors, PromptDefaults, KEY_ANALYSIS, KEY_CRAWLER,
};
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/admin/settings/crawler",
get(get_crawler).put(put_crawler),
)
.route(
"/admin/settings/analysis",
get(get_analysis).put(put_analysis),
)
}
// ---------------------------------------------------------------------------
// Response shapes
// ---------------------------------------------------------------------------
/// Read-only mirror of the crawler fields managed via environment (host/infra
/// + secrets), surfaced so the admin sees what's in effect without editing it.
#[derive(Debug, Serialize)]
struct CrawlerEnvOnly {
browser_mode: &'static str,
browser_args: Vec<String>,
proxy: Option<String>,
tor_control_url: Option<String>,
/// Whether a TOR control credential (password or cookie file) is set.
tor_credentials_configured: bool,
/// Whether an initial `CRAWLER_PHPSESSID` is configured (the live session
/// is managed separately via the crawler dashboard).
session_configured: bool,
}
impl CrawlerEnvOnly {
fn from_base(base: &CrawlerConfig) -> Self {
Self {
browser_mode: match base.browser.mode {
BrowserMode::Headed => "headed",
BrowserMode::Headless => "headless",
},
browser_args: base.browser.extra_args.clone(),
proxy: base.proxy.clone(),
tor_control_url: base.tor_control_url.clone(),
tor_credentials_configured: base.tor_control_password.is_some()
|| base.tor_control_cookie_path.is_some(),
session_configured: base.phpsessid.is_some(),
}
}
}
#[derive(Debug, Serialize)]
struct CrawlerSettingsResponse {
editable: CrawlerSettings,
env_only: CrawlerEnvOnly,
}
/// Read-only mirror of analysis env-managed fields (just the secret).
#[derive(Debug, Serialize)]
struct AnalysisEnvOnly {
api_key_configured: bool,
}
#[derive(Debug, Serialize)]
struct AnalysisSettingsResponse {
editable: AnalysisSettings,
env_only: AnalysisEnvOnly,
/// The compiled prompt defaults so the UI can show placeholders and
/// implement per-prompt "reset to default".
prompt_defaults: PromptDefaults,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
fn validation_error(errs: FieldErrors) -> AppError {
AppError::ValidationFailed {
message: "invalid settings".to_string(),
details: serde_json::json!({ "fields": errs.errors }),
}
}
/// Current effective crawler settings: the stored DTO when present, otherwise
/// derived from the env base (the row is seeded at boot, so the stored branch
/// is the norm).
async fn load_crawler_dto(state: &AppState) -> AppResult<CrawlerSettings> {
Ok(match repo::app_settings::get(&state.db, KEY_CRAWLER).await? {
Some(v) => serde_json::from_value(v)
.unwrap_or_else(|_| CrawlerSettings::from_config(&state.crawler_base)),
None => CrawlerSettings::from_config(&state.crawler_base),
})
}
async fn load_analysis_dto(state: &AppState) -> AppResult<AnalysisSettings> {
Ok(match repo::app_settings::get(&state.db, KEY_ANALYSIS).await? {
Some(v) => serde_json::from_value(v)
.unwrap_or_else(|_| AnalysisSettings::from_config(&state.analysis_base)),
None => AnalysisSettings::from_config(&state.analysis_base),
})
}
async fn get_crawler(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<CrawlerSettingsResponse>> {
let editable = load_crawler_dto(&state).await?;
Ok(Json(CrawlerSettingsResponse {
editable,
env_only: CrawlerEnvOnly::from_base(&state.crawler_base),
}))
}
async fn put_crawler(
State(state): State<AppState>,
admin: RequireAdmin,
Json(incoming): Json<CrawlerSettings>,
) -> AppResult<Json<CrawlerSettingsResponse>> {
// Validate by converting against the env base; this also rebuilds the
// download allowlist and re-parses tz / time.
let cfg = incoming
.to_config(&state.crawler_base)
.map_err(validation_error)?;
// Persist a normalized DTO (so the stored/returned allowlist reflects the
// effective hosts, prompts are canonicalized, etc.).
let normalized = CrawlerSettings::from_config(&cfg);
let value = serde_json::to_value(&normalized).map_err(|e| AppError::Other(e.into()))?;
let mut tx = state.db.begin().await?;
repo::app_settings::upsert(&mut *tx, KEY_CRAWLER, &value).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"update_crawler_settings",
"settings",
None,
value.clone(),
)
.await?;
tx.commit().await?;
// Apply live (graceful respawn) when a reloader is wired up.
if let Some(reloader) = &state.reloader {
reloader
.reload_crawler(cfg)
.await
.map_err(AppError::Other)?;
}
Ok(Json(CrawlerSettingsResponse {
editable: normalized,
env_only: CrawlerEnvOnly::from_base(&state.crawler_base),
}))
}
async fn get_analysis(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<AnalysisSettingsResponse>> {
let editable = load_analysis_dto(&state).await?;
Ok(Json(AnalysisSettingsResponse {
editable,
env_only: AnalysisEnvOnly {
api_key_configured: state.analysis_base.api_key.is_some(),
},
prompt_defaults: PromptDefaults::get(),
}))
}
async fn put_analysis(
State(state): State<AppState>,
admin: RequireAdmin,
Json(incoming): Json<AnalysisSettings>,
) -> AppResult<Json<AnalysisSettingsResponse>> {
let cfg = incoming
.to_config(&state.analysis_base)
.map_err(validation_error)?;
let normalized = AnalysisSettings::from_config(&cfg);
let value = serde_json::to_value(&normalized).map_err(|e| AppError::Other(e.into()))?;
let mut tx = state.db.begin().await?;
repo::app_settings::upsert(&mut *tx, KEY_ANALYSIS, &value).await?;
repo::admin_audit::insert(
&mut *tx,
admin.0.id,
"update_analysis_settings",
"settings",
None,
value.clone(),
)
.await?;
tx.commit().await?;
if let Some(reloader) = &state.reloader {
reloader
.reload_analysis(cfg)
.await
.map_err(AppError::Other)?;
}
Ok(Json(AnalysisSettingsResponse {
editable: normalized,
env_only: AnalysisEnvOnly {
api_key_configured: state.analysis_base.api_key.is_some(),
},
prompt_defaults: PromptDefaults::get(),
}))
}

View File

@@ -161,7 +161,7 @@ async fn create(
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
// failed enqueue is logged but doesn't fail the upload (the admin
// re-enqueue endpoint can backfill).
if state.analysis_enabled {
if state.analysis_enabled() {
for page_id in page_ids {
if let Err(e) =
repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::RwLock as StdRwLock;
use anyhow::Context;
use async_trait::async_trait;
@@ -17,7 +18,7 @@ use tower_http::trace::TraceLayer;
use crate::auth::extractor::CurrentUser;
use crate::auth::rate_limit::AuthRateLimiter;
use crate::error::AppError;
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
use crate::config::{AnalysisConfig, AuthConfig, Config, CrawlerConfig, UploadConfig};
use crate::crawler::browser_manager::{self, BrowserManager};
use crate::crawler::content::{self, SyncOutcome};
use crate::crawler::daemon::{self, ChapterDispatcher, DaemonConfig, MetadataPass};
@@ -40,33 +41,125 @@ pub struct AppState {
/// One instance per AppState so tests stay isolated across the
/// same process.
pub auth_limiter: Arc<AuthRateLimiter>,
/// Admin-triggered force resync. `None` when the crawler daemon
/// is disabled (`CRAWLER_DAEMON=false`); admin handlers gate on
/// `.is_some()` and return 503 otherwise. Set by [`build`] from the
/// same wiring that builds the daemon's chapter dispatcher, so a
/// force resync uses the daemon's BrowserManager + rate limiters.
pub resync: Option<Arc<dyn ResyncService>>,
/// Crawler observability + control handle (live status, coordinated
/// browser restart, runtime session, manual run). `None` when the
/// daemon is disabled; admin handlers gate on `.is_some()` → 503.
pub crawler: Option<Arc<CrawlerControl>>,
/// Runtime-swappable controls (crawler resync/control handles + the
/// analysis enable gate). Shared with the [`Supervisors`] so a config
/// reload that respawns a daemon updates what handlers see, live —
/// without a restart. In tests this starts empty (no daemons).
pub runtime: Arc<RuntimeControls>,
/// Applies a persisted settings change to the running daemons
/// (graceful stop + respawn). `Some` in production ([`build`]); `None`
/// in the test harness, where settings still persist but no daemon is
/// spawned. See [`DaemonReloader`].
pub reloader: Option<Arc<dyn DaemonReloader>>,
/// Env-derived crawler config — the **base** the stored settings DTO is
/// overlaid onto (carries env-only fields: browser, proxy, TOR, session).
pub crawler_base: CrawlerConfig,
/// Env-derived analysis config base (carries the env-only vision API key).
pub analysis_base: AnalysisConfig,
/// Browser origins permitted to issue mutating requests to
/// `/api/v1/admin/*`. See [`crate::config::Config::admin_allowed_origins`]
/// for the policy. Cloned per-request into the CSRF middleware; the
/// `Arc` keeps the clone cheap. Empty list → check is skipped
/// (operator opt-out documented in `.env.example`).
pub admin_allowed_origins: Arc<Vec<String>>,
/// When `true`, page-create paths (upload + crawler) enqueue
/// `analyze_page` jobs and the admin re-enqueue endpoints are active.
/// Mirrors `config.analysis.enabled`; defaults to `false` so the
/// feature is opt-in and the test harness inherits a no-op gate.
pub analysis_enabled: bool,
/// Live analysis-event broadcaster. Always present (the worker and the
/// admin enqueue path publish here; the SSE endpoint subscribes). When
/// the worker is disabled the channel simply stays quiet.
pub analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
}
impl AppState {
/// Current crawler control handle, or `None` when the daemon is stopped.
pub fn crawler(&self) -> Option<Arc<CrawlerControl>> {
self.runtime.crawler_control()
}
/// Current force-resync service, or `None` when the daemon is stopped.
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
self.runtime.resync()
}
/// Whether AI page analysis is currently enabled (read live).
pub fn analysis_enabled(&self) -> bool {
self.runtime.analysis_enabled()
}
}
/// The crawler control handles, swapped atomically when the daemon is
/// (re)spawned or stopped. Cloned cheaply on every read.
#[derive(Clone, Default)]
struct CrawlerControls {
resync: Option<Arc<dyn ResyncService>>,
control: Option<Arc<CrawlerControl>>,
}
/// Shared, runtime-mutable surface read by handlers and mutated by the
/// [`Supervisors`] on a config reload. The analysis gate is an `Arc<AtomicBool>`
/// so the crawler's chapter dispatcher (which enqueues `analyze_page` jobs)
/// sees toggles live without being respawned.
pub struct RuntimeControls {
crawler: StdRwLock<CrawlerControls>,
analysis_enabled: Arc<AtomicBool>,
}
impl RuntimeControls {
/// Empty controls (no crawler daemon) with the given initial analysis gate.
pub fn new(analysis_enabled: bool) -> Self {
Self {
crawler: StdRwLock::new(CrawlerControls::default()),
analysis_enabled: Arc::new(AtomicBool::new(analysis_enabled)),
}
}
pub fn analysis_enabled(&self) -> bool {
self.analysis_enabled.load(Ordering::Relaxed)
}
/// Flip the analysis gate. Used by the [`Supervisors`] on reload and by
/// the test harness's stub reloader.
pub fn set_analysis_enabled(&self, v: bool) {
self.analysis_enabled.store(v, Ordering::Relaxed);
}
/// The shared gate handle, passed to the crawler chapter dispatcher.
fn analysis_gate(&self) -> Arc<AtomicBool> {
Arc::clone(&self.analysis_enabled)
}
pub fn crawler_control(&self) -> Option<Arc<CrawlerControl>> {
self.crawler.read().unwrap().control.clone()
}
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
self.crawler.read().unwrap().resync.clone()
}
/// Test helper: install a resync service without a running daemon.
pub fn set_resync(&self, resync: Option<Arc<dyn ResyncService>>) {
self.crawler.write().unwrap().resync = resync;
}
fn set_crawler(
&self,
resync: Option<Arc<dyn ResyncService>>,
control: Option<Arc<CrawlerControl>>,
) {
let mut g = self.crawler.write().unwrap();
g.resync = resync;
g.control = control;
}
}
/// Applies a validated settings change to the running daemons by gracefully
/// stopping and respawning them with the new effective config (and updating
/// the shared [`RuntimeControls`]). Implemented by [`Supervisors`] in
/// production; the settings handlers call it after persisting.
#[async_trait]
pub trait DaemonReloader: Send + Sync {
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()>;
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()>;
}
/// Shared handle the admin crawler endpoints use to observe and control
/// the running daemon. Bundled so the handlers take one optional field on
/// `AppState` rather than many.
@@ -90,27 +183,96 @@ pub struct CrawlerControl {
}
/// Bundle returned by [`build`]. The router is what `axum::serve` consumes;
/// the daemon (when enabled) outlives the HTTP server and is awaited via
/// [`AppHandle::shutdown`] after the listener has finished gracefully.
/// the [`Supervisors`] own the background daemons (when enabled), outlive the
/// HTTP server, and are awaited via [`AppHandle::shutdown`] after the listener
/// has finished gracefully.
pub struct AppHandle {
pub router: Router,
pub daemon: Option<daemon::DaemonHandle>,
/// AI content-analysis worker daemon; `None` when `ANALYSIS_ENABLED`
/// is off. Awaited alongside the crawler daemon on shutdown.
pub analysis_daemon: Option<crate::analysis::daemon::AnalysisDaemonHandle>,
pub supervisors: Arc<Supervisors>,
}
impl AppHandle {
pub async fn shutdown(self) {
if let Some(d) = self.daemon {
d.shutdown().await;
self.supervisors.shutdown().await;
}
}
/// Owns the crawler + analysis daemon handles and respawns them on a config
/// reload. Holds the build-time inputs (db, storage, env-base configs, the
/// shared event bus and [`RuntimeControls`]) needed to spawn from scratch.
pub struct Supervisors {
db: PgPool,
storage: Arc<dyn Storage>,
runtime: Arc<RuntimeControls>,
analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
/// Serializes reloads + owns the live crawler daemon handle.
crawler_handle: tokio::sync::Mutex<Option<daemon::DaemonHandle>>,
/// Serializes reloads + owns the live analysis daemon handle.
analysis_handle: tokio::sync::Mutex<Option<crate::analysis::daemon::AnalysisDaemonHandle>>,
}
impl Supervisors {
pub async fn shutdown(&self) {
if let Some(h) = self.crawler_handle.lock().await.take() {
h.shutdown().await;
}
if let Some(a) = self.analysis_daemon {
a.shutdown().await;
if let Some(h) = self.analysis_handle.lock().await.take() {
h.shutdown().await;
}
}
}
#[async_trait]
impl DaemonReloader for Supervisors {
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
// Serialize reloads; do the (possibly slow) graceful shutdown under
// the handle lock but outside the read-path RwLock so status reads
// never block on a draining browser.
let mut guard = self.crawler_handle.lock().await;
if let Some(h) = guard.take() {
h.shutdown().await;
}
self.runtime.set_crawler(None, None);
if cfg.daemon_enabled {
let spawned = spawn_crawler_daemon(
self.db.clone(),
Arc::clone(&self.storage),
&cfg,
self.runtime.analysis_gate(),
)
.await?;
self.runtime
.set_crawler(Some(spawned.resync), Some(spawned.crawler));
*guard = Some(spawned.handle);
tracing::info!("crawler daemon (re)started from settings");
} else {
tracing::info!("crawler daemon stopped (disabled in settings)");
}
Ok(())
}
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
let mut guard = self.analysis_handle.lock().await;
if let Some(h) = guard.take() {
h.shutdown().await;
}
self.runtime.set_analysis_enabled(cfg.enabled);
if cfg.enabled {
let handle = spawn_analysis_daemon(
self.db.clone(),
Arc::clone(&self.storage),
&cfg,
Arc::clone(&self.analysis_events),
)?;
*guard = Some(handle);
tracing::info!(model = %cfg.model, "analysis daemon (re)started from settings");
} else {
tracing::info!("analysis daemon stopped (disabled in settings)");
}
Ok(())
}
}
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let db = PgPoolOptions::new()
.max_connections(10)
@@ -127,60 +289,48 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
let (daemon, resync, crawler) = if config.crawler.daemon_enabled {
let spawned = spawn_crawler_daemon(
db.clone(),
Arc::clone(&storage),
&config.crawler,
config.analysis.enabled,
)
.await?;
(Some(spawned.handle), Some(spawned.resync), Some(spawned.crawler))
} else {
tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)");
(None, None, None)
};
// Seed the settings rows from env on first boot, then load the effective
// config (DB overlaid on the env base). The DB is the source of truth
// after the first boot; env only fills a missing row.
let crawler_cfg = load_effective_crawler(&db, &config.crawler).await?;
let analysis_cfg = load_effective_analysis(&db, &config.analysis).await?;
// Live-event bus shared by the worker (progress) and the admin enqueue
// path; the SSE endpoint subscribes. Created unconditionally so the
// stream exists even with the worker disabled.
let analysis_events = Arc::new(crate::analysis::events::AnalysisEvents::new());
// AI content-analysis worker. Independent of the crawler daemon (works
// for uploads with the crawler off), gated on ANALYSIS_ENABLED. Uses a
// plain reqwest client — no cookie jar / proxy, unlike the crawler's.
let analysis_daemon = if config.analysis.enabled {
let http = reqwest::Client::builder()
.timeout(config.analysis.request_timeout)
.build()
.context("build analysis http client")?;
let vision = crate::analysis::vision::VisionClient::new(http, &config.analysis);
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
db: db.clone(),
storage: Arc::clone(&storage),
vision,
model: config.analysis.model.clone(),
max_image_bytes: config.analysis.max_image_bytes,
});
let handle = crate::analysis::daemon::spawn(
db.clone(),
tokio_util::sync::CancellationToken::new(),
crate::analysis::daemon::AnalysisDaemonConfig {
dispatcher,
workers: config.analysis.workers,
job_timeout: config.analysis.job_timeout,
events: Arc::clone(&analysis_events),
},
);
tracing::info!(
workers = config.analysis.workers,
model = %config.analysis.model,
"analysis worker daemon started"
);
Some(handle)
let runtime = Arc::new(RuntimeControls::new(analysis_cfg.enabled));
let supervisors = Arc::new(Supervisors {
db: db.clone(),
storage: Arc::clone(&storage),
runtime: Arc::clone(&runtime),
analysis_events: Arc::clone(&analysis_events),
crawler_handle: tokio::sync::Mutex::new(None),
analysis_handle: tokio::sync::Mutex::new(None),
});
// Initial spawn goes through the same reload path so there's one code
// path for "bring the daemon up with this config". A spawn failure is
// logged but does NOT abort startup: the persisted settings are the
// source of truth and a bad value (e.g. a config that won't launch the
// browser) must not wedge the whole server into a boot loop — the
// server comes up with that daemon stopped so an admin can fix it via
// the settings UI.
if crawler_cfg.daemon_enabled {
if let Err(e) = supervisors.reload_crawler(crawler_cfg).await {
tracing::error!(?e, "crawler daemon failed to start; continuing with it stopped");
}
} else {
None
};
tracing::info!("crawler daemon disabled");
}
if analysis_cfg.enabled {
if let Err(e) = supervisors.reload_analysis(analysis_cfg).await {
tracing::error!(?e, "analysis worker failed to start; continuing with it stopped");
}
} else {
tracing::info!("analysis worker disabled");
}
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
let state = AppState {
@@ -189,14 +339,99 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
auth: config.auth.clone(),
upload: config.upload.clone(),
auth_limiter,
resync,
crawler,
runtime,
reloader: Some(Arc::clone(&supervisors) as Arc<dyn DaemonReloader>),
crawler_base: config.crawler.clone(),
analysis_base: config.analysis.clone(),
admin_allowed_origins: Arc::new(config.admin_allowed_origins.clone()),
analysis_enabled: config.analysis.enabled,
analysis_events,
};
let router = router(state).layer(cors_layer(&config.cors_allowed_origins));
Ok(AppHandle { router, daemon, analysis_daemon })
Ok(AppHandle { router, supervisors })
}
/// Seed the crawler settings row from env when absent, then return the
/// effective [`CrawlerConfig`] (DB DTO overlaid on the env base). A stored
/// DTO that fails to convert (corruption / drift) falls back to the env base
/// rather than blocking startup.
async fn load_effective_crawler(
db: &PgPool,
base: &CrawlerConfig,
) -> anyhow::Result<CrawlerConfig> {
use crate::settings::{CrawlerSettings, KEY_CRAWLER};
let dto = match repo::app_settings::get(db, KEY_CRAWLER).await? {
Some(v) => serde_json::from_value::<CrawlerSettings>(v)
.unwrap_or_else(|_| CrawlerSettings::from_config(base)),
None => {
let dto = CrawlerSettings::from_config(base);
repo::app_settings::seed_if_absent(db, KEY_CRAWLER, &serde_json::to_value(&dto)?)
.await?;
tracing::info!("seeded crawler settings from env");
dto
}
};
Ok(dto.to_config(base).unwrap_or_else(|e| {
tracing::warn!(?e, "stored crawler settings invalid; using env base");
base.clone()
}))
}
/// Analysis counterpart of [`load_effective_crawler`].
async fn load_effective_analysis(
db: &PgPool,
base: &AnalysisConfig,
) -> anyhow::Result<AnalysisConfig> {
use crate::settings::{AnalysisSettings, KEY_ANALYSIS};
let dto = match repo::app_settings::get(db, KEY_ANALYSIS).await? {
Some(v) => serde_json::from_value::<AnalysisSettings>(v)
.unwrap_or_else(|_| AnalysisSettings::from_config(base)),
None => {
let dto = AnalysisSettings::from_config(base);
repo::app_settings::seed_if_absent(db, KEY_ANALYSIS, &serde_json::to_value(&dto)?)
.await?;
tracing::info!("seeded analysis settings from env");
dto
}
};
Ok(dto.to_config(base).unwrap_or_else(|e| {
tracing::warn!(?e, "stored analysis settings invalid; using env base");
base.clone()
}))
}
/// Spawn the AI content-analysis worker daemon with the given config. Returns
/// its handle. Independent of the crawler daemon (works for uploads with the
/// crawler off). Uses a plain reqwest client — no cookie jar / proxy.
fn spawn_analysis_daemon(
db: PgPool,
storage: Arc<dyn Storage>,
cfg: &AnalysisConfig,
events: Arc<crate::analysis::events::AnalysisEvents>,
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
let http = reqwest::Client::builder()
.timeout(cfg.request_timeout)
.build()
.context("build analysis http client")?;
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
db: db.clone(),
storage,
vision,
model: cfg.model.clone(),
max_image_bytes: cfg.max_image_bytes,
});
let handle = crate::analysis::daemon::spawn(
db,
CancellationToken::new(),
crate::analysis::daemon::AnalysisDaemonConfig {
dispatcher,
workers: cfg.workers,
job_timeout: cfg.job_timeout,
events,
},
);
tracing::info!(workers = cfg.workers, model = %cfg.model, "analysis worker daemon started");
Ok(handle)
}
/// Bundle returned by [`spawn_crawler_daemon`]. The handle owns the
@@ -213,7 +448,7 @@ async fn spawn_crawler_daemon(
db: PgPool,
storage: Arc<dyn Storage>,
cfg: &CrawlerConfig,
analysis_enabled: bool,
analysis_enabled: Arc<AtomicBool>,
) -> anyhow::Result<SpawnedDaemon> {
// Reqwest client with a shared cookie jar so CDN image fetches include
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
@@ -512,9 +747,10 @@ struct RealChapterDispatcher {
rate: Arc<HostRateLimiters>,
download_allowlist: DownloadAllowlist,
max_image_bytes: usize,
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Mirrors
/// `config.analysis.enabled`.
analysis_enabled: bool,
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Shared gate
/// (read live) so toggling analysis at runtime takes effect without a
/// crawler respawn. Mirrors the analysis enable setting.
analysis_enabled: Arc<AtomicBool>,
/// Consecutive transient chapter failures; resets on any success.
/// Drives the automatic coordinated browser restart.
transient_failures: Arc<std::sync::atomic::AtomicU32>,
@@ -570,7 +806,7 @@ impl ChapterDispatcher for RealChapterDispatcher {
self.max_image_bytes,
self.tor.as_deref(),
Some(&self.status),
self.analysis_enabled,
self.analysis_enabled.load(Ordering::Relaxed),
)
.await;
drop(lease);

View File

@@ -90,6 +90,27 @@ impl ResponseFormat {
_ => ResponseFormat::JsonSchema,
}
}
/// Canonical wire string, the inverse of [`Self::from_str`] for the
/// three modes the API exposes.
pub fn as_str(self) -> &'static str {
match self {
ResponseFormat::JsonSchema => "json_schema",
ResponseFormat::JsonObject => "json_object",
ResponseFormat::None => "none",
}
}
/// Parse a wire value, rejecting unknown modes (stricter than
/// [`Self::from_str`], which the env loader uses to stay lenient).
pub fn parse_strict(s: &str) -> Option<ResponseFormat> {
match s.trim().to_lowercase().as_str() {
"json_schema" => Some(ResponseFormat::JsonSchema),
"json_object" => Some(ResponseFormat::JsonObject),
"none" => Some(ResponseFormat::None),
_ => None,
}
}
}
/// AI content-analysis worker configuration: the enable gate, the local
@@ -146,6 +167,21 @@ pub struct AnalysisConfig {
/// the repetition loops that otherwise run small models into the token
/// ceiling. `0` omits the field.
pub frequency_penalty: f64,
/// Sampling `temperature` sent with each request (`ANALYSIS_TEMPERATURE`).
/// Defaults to `0.0` (deterministic), which is the most reliable for
/// structured JSON output; some models behave better with a small
/// positive value.
pub temperature: f64,
/// System prompt for the single-call (normal-aspect) analysis path
/// (`ANALYSIS_SYSTEM_PROMPT`). Defaults to
/// [`crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT`].
pub system_prompt: String,
/// Pass-A OCR-only prompt for tall-page slices (`ANALYSIS_OCR_PROMPT`).
/// Defaults to [`crate::analysis::prompt::OCR_PROMPT_DEFAULT`].
pub ocr_prompt: String,
/// Pass-B grounding prompt — tags/scene/safety (`ANALYSIS_GROUNDING_PROMPT`).
/// Defaults to [`crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT`].
pub grounding_prompt: String,
}
impl Default for AnalysisConfig {
@@ -170,6 +206,10 @@ impl Default for AnalysisConfig {
max_image_bytes: 8 * 1024 * 1024,
response_format: ResponseFormat::JsonSchema,
frequency_penalty: 0.3,
temperature: 0.0,
system_prompt: crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT.to_string(),
ocr_prompt: crate::analysis::prompt::OCR_PROMPT_DEFAULT.to_string(),
grounding_prompt: crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT.to_string(),
}
}
}
@@ -206,10 +246,23 @@ impl AnalysisConfig {
.map(|s| ResponseFormat::from_str(&s))
.unwrap_or(d.response_format),
frequency_penalty: env_f64("ANALYSIS_FREQUENCY_PENALTY", d.frequency_penalty),
temperature: env_f64("ANALYSIS_TEMPERATURE", d.temperature),
system_prompt: env_prompt("ANALYSIS_SYSTEM_PROMPT", d.system_prompt),
ocr_prompt: env_prompt("ANALYSIS_OCR_PROMPT", d.ocr_prompt),
grounding_prompt: env_prompt("ANALYSIS_GROUNDING_PROMPT", d.grounding_prompt),
}
}
}
/// Read a prompt override from env, falling back to the default when unset
/// or blank (so `ANALYSIS_SYSTEM_PROMPT=` doesn't wipe the prompt).
fn env_prompt(name: &str, default: String) -> String {
std::env::var(name)
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or(default)
}
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,

View File

@@ -91,6 +91,16 @@ impl DownloadAllowlist {
self.hosts.is_empty()
}
/// The explicitly-allowed hosts (lowercased). Empty when `allow_any`.
pub fn hosts(&self) -> &[String] {
&self.hosts
}
/// Whether the host check is bypassed entirely (`CRAWLER_ALLOW_ANY_HOST`).
pub fn is_allow_any(&self) -> bool {
self.allow_any
}
pub fn contains(&self, host: &str) -> bool {
if self.allow_any {
return true;

View File

@@ -7,5 +7,6 @@ pub mod crawler;
pub mod domain;
pub mod error;
pub mod repo;
pub mod settings;
pub mod storage;
pub mod upload;

View File

@@ -21,11 +21,8 @@ async fn main() -> anyhow::Result<()> {
let config = mangalord::config::Config::from_env()?;
let addr: SocketAddr = config.bind_address.parse()?;
let mangalord::app::AppHandle {
router,
daemon,
analysis_daemon,
} = mangalord::app::build(config).await?;
let mangalord::app::AppHandle { router, supervisors } =
mangalord::app::build(config).await?;
tracing::info!(%addr, "mangalord listening");
let listener = tokio::net::TcpListener::bind(addr).await?;
@@ -33,30 +30,17 @@ async fn main() -> anyhow::Result<()> {
.with_graceful_shutdown(shutdown_signal())
.await?;
// Drain background tasks (crawler daemon) before exiting so Chromium
// gets a clean shutdown rather than relying on kill-on-drop. Bounded
// by a timeout so a wedged shutdown path can't trap the process.
if let Some(d) = daemon {
if tokio::time::timeout(CRAWLER_SHUTDOWN_TIMEOUT, d.shutdown())
.await
.is_err()
{
tracing::warn!(
timeout_s = CRAWLER_SHUTDOWN_TIMEOUT.as_secs(),
"crawler daemon shutdown exceeded timeout; abandoning"
);
}
}
if let Some(a) = analysis_daemon {
if tokio::time::timeout(CRAWLER_SHUTDOWN_TIMEOUT, a.shutdown())
.await
.is_err()
{
tracing::warn!(
timeout_s = CRAWLER_SHUTDOWN_TIMEOUT.as_secs(),
"analysis daemon shutdown exceeded timeout; abandoning"
);
}
// Drain background daemons (crawler + analysis) before exiting so
// Chromium gets a clean shutdown rather than relying on kill-on-drop.
// Bounded by a timeout so a wedged shutdown path can't trap the process.
if tokio::time::timeout(CRAWLER_SHUTDOWN_TIMEOUT, supervisors.shutdown())
.await
.is_err()
{
tracing::warn!(
timeout_s = CRAWLER_SHUTDOWN_TIMEOUT.as_secs(),
"daemon shutdown exceeded timeout; abandoning"
);
}
Ok(())
}

View File

@@ -0,0 +1,54 @@
//! Persistence for runtime-editable application settings (`app_settings`),
//! a small key-value JSONB table (one row per subsystem group). Mirrors the
//! `crawler_state` access style: plain async fns over `&PgPool`, the `value`
//! a `serde_json::Value` the caller (de)serializes into a settings DTO.
use sqlx::{PgExecutor, PgPool};
/// Fetch the raw JSONB for a settings group, or `None` if unset.
pub async fn get(pool: &PgPool, key: &str) -> sqlx::Result<Option<serde_json::Value>> {
sqlx::query_scalar("SELECT value FROM app_settings WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
}
/// Insert-or-replace the JSONB for a settings group, stamping `updated_at`.
/// Generic over the executor so it can run inside the same transaction as
/// the matching admin-audit insert.
pub async fn upsert<'e, E: PgExecutor<'e>>(
executor: E,
key: &str,
value: &serde_json::Value,
) -> sqlx::Result<()> {
sqlx::query(
"INSERT INTO app_settings (key, value, updated_at) \
VALUES ($1, $2, now()) \
ON CONFLICT (key) DO UPDATE \
SET value = EXCLUDED.value, updated_at = now()",
)
.bind(key)
.bind(value)
.execute(executor)
.await?;
Ok(())
}
/// Write the value only if the row is absent (the env → DB boot seed). Returns
/// `true` when a row was inserted, `false` when one already existed. Uses
/// `ON CONFLICT DO NOTHING` so a concurrent boot can't race two seeds.
pub async fn seed_if_absent(
pool: &PgPool,
key: &str,
value: &serde_json::Value,
) -> sqlx::Result<bool> {
let result = sqlx::query(
"INSERT INTO app_settings (key, value) VALUES ($1, $2) \
ON CONFLICT (key) DO NOTHING",
)
.bind(key)
.bind(value)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

View File

@@ -1,6 +1,7 @@
pub mod admin_audit;
pub mod admin_view;
pub mod api_token;
pub mod app_settings;
pub mod author;
pub mod bookmark;
pub mod chapter;

643
backend/src/settings.rs Normal file
View File

@@ -0,0 +1,643 @@
//! Serializable, admin-editable settings DTOs for the crawler and analysis
//! subsystems, and their conversion to/from the runtime [`CrawlerConfig`] /
//! [`AnalysisConfig`].
//!
//! These DTOs are the boundary persisted in the `app_settings` table (one
//! JSONB row per group) and exchanged over the admin API. They carry **only
//! the operationally-safe, UI-editable fields** as primitive types — the
//! runtime configs additionally hold env-only/structural fields (browser
//! launch options, proxy, TOR control, the vision API key, the cookie
//! domain) that are never persisted here.
//!
//! Flow: at boot, [`CrawlerSettings::from_config`] /
//! [`AnalysisSettings::from_config`] derive the env-seed DTO; it is written to
//! the DB only when the row is absent. Thereafter the DB row is the source of
//! truth, and [`CrawlerSettings::to_config`] / [`AnalysisSettings::to_config`]
//! overlay it onto the env-derived **base** (which carries the env-only
//! fields) to produce the effective config the daemons are (re)spawned with.
use std::time::Duration;
use chrono::NaiveTime;
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use crate::analysis::prompt::{
GROUNDING_PROMPT_DEFAULT, OCR_PROMPT_DEFAULT, SYSTEM_PROMPT_DEFAULT,
};
use crate::config::{AnalysisConfig, CrawlerConfig, ResponseFormat};
use crate::crawler::safety::DownloadAllowlist;
/// `app_settings.key` for the crawler group.
pub const KEY_CRAWLER: &str = "crawler";
/// `app_settings.key` for the analysis group.
pub const KEY_ANALYSIS: &str = "analysis";
/// One field-level validation failure, surfaced to the UI per input.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FieldError {
pub field: String,
pub message: String,
}
/// Collected validation failures for a settings payload. Empty == valid.
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
pub struct FieldErrors {
pub errors: Vec<FieldError>,
}
impl FieldErrors {
fn push(&mut self, field: &str, message: impl Into<String>) {
self.errors.push(FieldError {
field: field.to_string(),
message: message.into(),
});
}
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
}
// ---------------------------------------------------------------------------
// Crawler
// ---------------------------------------------------------------------------
/// Admin-editable crawler-daemon settings. Mirrors the operational `CRAWLER_*`
/// env vars; host/infra and session fields stay env-only.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct CrawlerSettings {
pub daemon_enabled: bool,
/// Daily metadata-pass time, `"HH:MM"` (24h).
pub daily_at: String,
/// IANA timezone for the daily schedule.
pub tz: String,
pub idle_timeout_secs: u64,
pub chapter_workers: u64,
pub retention_days: u32,
pub start_url: Option<String>,
pub rate_ms: u64,
pub cdn_host: Option<String>,
pub cdn_rate_ms: u64,
/// Domain the session cookie (PHPSESSID) is scoped to when injecting it
/// into the browser / CDN requests. Pairs with `start_url`.
pub cookie_domain: Option<String>,
pub user_agent: Option<String>,
/// Hosts the crawler may download images from (in addition to the
/// auto-seeded start-url / CDN hosts). Ignored when `allow_any_host`.
pub download_allowlist: Vec<String>,
pub allow_any_host: bool,
pub max_image_bytes: u64,
/// Max manga detail fetches per metadata pass; `0` = unlimited.
pub manga_limit: u64,
pub job_timeout_secs: u64,
pub metadata_max_consecutive_failures: u32,
pub browser_restart_threshold: u32,
}
impl Default for CrawlerSettings {
fn default() -> Self {
Self::from_config(&CrawlerConfig::default())
}
}
impl CrawlerSettings {
/// Derive the DTO from a runtime config (used to seed the DB from env).
pub fn from_config(c: &CrawlerConfig) -> Self {
Self {
daemon_enabled: c.daemon_enabled,
daily_at: c.daily_at.format("%H:%M").to_string(),
tz: c.tz.name().to_string(),
idle_timeout_secs: c.idle_timeout.as_secs(),
chapter_workers: c.chapter_workers as u64,
retention_days: c.retention_days,
start_url: c.start_url.clone(),
rate_ms: c.rate_ms,
cdn_host: c.cdn_host.clone(),
cdn_rate_ms: c.cdn_rate_ms,
cookie_domain: c.cookie_domain.clone(),
user_agent: c.user_agent.clone(),
download_allowlist: c.download_allowlist.hosts().to_vec(),
allow_any_host: c.download_allowlist.is_allow_any(),
max_image_bytes: c.max_image_bytes as u64,
manga_limit: c.manga_limit as u64,
job_timeout_secs: c.job_timeout.as_secs(),
metadata_max_consecutive_failures: c.metadata_max_consecutive_failures,
browser_restart_threshold: c.browser_restart_threshold,
}
}
/// Overlay the DTO onto an env-derived `base` (which carries the env-only
/// fields: browser, proxy, TOR, session, cookie domain) to produce the
/// effective runtime config. Validates and returns all field errors at
/// once on failure.
pub fn to_config(&self, base: &CrawlerConfig) -> Result<CrawlerConfig, FieldErrors> {
let mut errs = FieldErrors::default();
let daily_at = match NaiveTime::parse_from_str(&self.daily_at, "%H:%M") {
Ok(t) => t,
Err(_) => {
errs.push("daily_at", "must be HH:MM (24-hour)");
base.daily_at
}
};
let tz: Tz = match self.tz.parse() {
Ok(t) => t,
Err(_) => {
errs.push("tz", "must be a valid IANA timezone (e.g. UTC, Europe/Berlin)");
base.tz
}
};
if self.chapter_workers < 1 {
errs.push("chapter_workers", "must be at least 1");
}
if let Some(url) = self.start_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if reqwest::Url::parse(url).is_err() {
errs.push("start_url", "must be a valid absolute URL");
}
}
if self.job_timeout_secs < 1 {
errs.push("job_timeout_secs", "must be at least 1 second");
}
if !errs.is_empty() {
return Err(errs);
}
let start_url = self
.start_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let cdn_host = self
.cdn_host
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let download_allowlist = build_allowlist(
self.allow_any_host,
start_url.as_deref(),
cdn_host.as_deref(),
&self.download_allowlist,
);
Ok(CrawlerConfig {
daemon_enabled: self.daemon_enabled,
daily_at,
tz,
idle_timeout: Duration::from_secs(self.idle_timeout_secs),
chapter_workers: (self.chapter_workers as usize).max(1),
retention_days: self.retention_days,
start_url,
rate_ms: self.rate_ms,
cdn_host,
cdn_rate_ms: self.cdn_rate_ms,
user_agent: self
.user_agent
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
cookie_domain: self
.cookie_domain
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
download_allowlist,
max_image_bytes: self.max_image_bytes as usize,
manga_limit: self.manga_limit as usize,
job_timeout: Duration::from_secs(self.job_timeout_secs.max(1)),
metadata_max_consecutive_failures: self.metadata_max_consecutive_failures,
browser_restart_threshold: self.browser_restart_threshold,
// Env-only / structural fields preserved from the base.
phpsessid: base.phpsessid.clone(),
proxy: base.proxy.clone(),
tor_control_url: base.tor_control_url.clone(),
tor_control_password: base.tor_control_password.clone(),
tor_control_cookie_path: base.tor_control_cookie_path.clone(),
tor_recircuit_max_attempts: base.tor_recircuit_max_attempts,
browser: base.browser.clone(),
})
}
}
/// Rebuild a [`DownloadAllowlist`] from the DTO, always seeding the start-url
/// and CDN hosts (mirrors `config::build_download_allowlist`).
fn build_allowlist(
allow_any: bool,
start_url: Option<&str>,
cdn_host: Option<&str>,
extras: &[String],
) -> DownloadAllowlist {
if allow_any {
return DownloadAllowlist::allow_any();
}
let mut allow = DownloadAllowlist::new();
if let Some(url) = start_url {
if let Ok(parsed) = reqwest::Url::parse(url) {
if let Some(h) = parsed.host_str() {
allow = allow.allow(h);
}
}
}
if let Some(host) = cdn_host {
allow = allow.allow(host);
}
for h in extras {
let trimmed = h.trim();
if !trimmed.is_empty() {
allow = allow.allow(trimmed);
}
}
allow
}
// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------
/// Admin-editable analysis-worker settings. The vision `api_key` is env-only
/// and never carried here. Prompts are `Option<String>`: `None` means "use
/// the compiled default" (the UI's "reset to default").
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AnalysisSettings {
pub enabled: bool,
pub workers: u64,
pub endpoint: String,
pub model: String,
pub request_timeout_secs: u64,
pub job_timeout_secs: u64,
pub max_tokens: u32,
pub max_pixels: u32,
pub min_slice_height: u32,
pub slice_overlap: f64,
pub tall_aspect_threshold: f64,
pub max_slices: u64,
pub max_image_bytes: u64,
/// `"json_schema" | "json_object" | "none"`.
pub response_format: String,
pub frequency_penalty: f64,
pub temperature: f64,
pub system_prompt: Option<String>,
pub ocr_prompt: Option<String>,
pub grounding_prompt: Option<String>,
}
impl Default for AnalysisSettings {
fn default() -> Self {
Self::from_config(&AnalysisConfig::default())
}
}
impl AnalysisSettings {
pub fn from_config(c: &AnalysisConfig) -> Self {
// Capture a prompt only when it diverges from the compiled default,
// so an unmodified config seeds as `None` ("use default").
let opt = |cur: &str, def: &str| (cur != def).then(|| cur.to_string());
Self {
enabled: c.enabled,
workers: c.workers as u64,
endpoint: c.endpoint.clone(),
model: c.model.clone(),
request_timeout_secs: c.request_timeout.as_secs(),
job_timeout_secs: c.job_timeout.as_secs(),
max_tokens: c.max_tokens,
max_pixels: c.max_pixels,
min_slice_height: c.min_slice_height,
slice_overlap: c.slice_overlap,
tall_aspect_threshold: c.tall_aspect_threshold,
max_slices: c.max_slices as u64,
max_image_bytes: c.max_image_bytes as u64,
response_format: c.response_format.as_str().to_string(),
frequency_penalty: c.frequency_penalty,
temperature: c.temperature,
system_prompt: opt(&c.system_prompt, SYSTEM_PROMPT_DEFAULT),
ocr_prompt: opt(&c.ocr_prompt, OCR_PROMPT_DEFAULT),
grounding_prompt: opt(&c.grounding_prompt, GROUNDING_PROMPT_DEFAULT),
}
}
pub fn to_config(&self, base: &AnalysisConfig) -> Result<AnalysisConfig, FieldErrors> {
let mut errs = FieldErrors::default();
if self.workers < 1 {
errs.push("workers", "must be at least 1");
}
if self.endpoint.trim().is_empty() || reqwest::Url::parse(self.endpoint.trim()).is_err() {
errs.push("endpoint", "must be a valid absolute URL");
}
if self.enabled && self.model.trim().is_empty() {
errs.push("model", "required when analysis is enabled");
}
if self.max_tokens < 1 {
errs.push("max_tokens", "must be at least 1");
}
if self.request_timeout_secs < 1 {
errs.push("request_timeout_secs", "must be at least 1 second");
}
if self.job_timeout_secs < 1 {
errs.push("job_timeout_secs", "must be at least 1 second");
}
if self.max_pixels < 1 {
errs.push("max_pixels", "must be at least 1");
}
if self.max_image_bytes < 1 {
errs.push("max_image_bytes", "must be greater than 0");
}
if !(0.0..=0.9).contains(&self.slice_overlap) {
errs.push("slice_overlap", "must be between 0.0 and 0.9");
}
if self.tall_aspect_threshold < 1.0 {
errs.push("tall_aspect_threshold", "must be at least 1.0");
}
if self.min_slice_height < 1 {
errs.push("min_slice_height", "must be at least 1");
}
if self.max_slices < 1 {
errs.push("max_slices", "must be at least 1");
}
if self.temperature < 0.0 {
errs.push("temperature", "must be 0 or greater");
}
let response_format = match ResponseFormat::parse_strict(&self.response_format) {
Some(rf) => rf,
None => {
errs.push(
"response_format",
"must be one of: json_schema, json_object, none",
);
base.response_format
}
};
if !errs.is_empty() {
return Err(errs);
}
let prompt = |o: &Option<String>, def: &str| {
o.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(def)
.to_string()
};
Ok(AnalysisConfig {
enabled: self.enabled,
workers: (self.workers as usize).max(1),
endpoint: self.endpoint.trim().to_string(),
model: self.model.trim().to_string(),
request_timeout: Duration::from_secs(self.request_timeout_secs),
job_timeout: Duration::from_secs(self.job_timeout_secs),
max_tokens: self.max_tokens,
max_pixels: self.max_pixels,
min_slice_height: self.min_slice_height.max(1),
slice_overlap: self.slice_overlap.clamp(0.0, 0.9),
tall_aspect_threshold: self.tall_aspect_threshold.max(1.0),
max_slices: (self.max_slices as usize).max(1),
max_image_bytes: self.max_image_bytes as usize,
response_format,
frequency_penalty: self.frequency_penalty,
temperature: self.temperature.max(0.0),
system_prompt: prompt(&self.system_prompt, SYSTEM_PROMPT_DEFAULT),
ocr_prompt: prompt(&self.ocr_prompt, OCR_PROMPT_DEFAULT),
grounding_prompt: prompt(&self.grounding_prompt, GROUNDING_PROMPT_DEFAULT),
// Env-only secret preserved from the base.
api_key: base.api_key.clone(),
})
}
}
/// Prompt defaults, returned by the API so the UI can render placeholders and
/// implement "reset to default".
#[derive(Debug, Clone, Serialize)]
pub struct PromptDefaults {
pub system_prompt: &'static str,
pub ocr_prompt: &'static str,
pub grounding_prompt: &'static str,
}
impl PromptDefaults {
pub fn get() -> Self {
Self {
system_prompt: SYSTEM_PROMPT_DEFAULT,
ocr_prompt: OCR_PROMPT_DEFAULT,
grounding_prompt: GROUNDING_PROMPT_DEFAULT,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- crawler round-trip & validation ---
#[test]
fn crawler_round_trips_through_dto() {
let mut base = CrawlerConfig::default();
base.start_url = Some("https://example.com/".to_string());
base.tz = Tz::Europe__Berlin;
base.chapter_workers = 3;
base.cookie_domain = Some("example.com".to_string());
let dto = CrawlerSettings::from_config(&base);
let back = dto.to_config(&base).expect("valid");
assert_eq!(back.tz, Tz::Europe__Berlin);
assert_eq!(back.chapter_workers, 3);
assert_eq!(back.start_url.as_deref(), Some("https://example.com/"));
// cookie_domain is now an editable DTO field, round-tripping like the rest.
assert_eq!(back.cookie_domain.as_deref(), Some("example.com"));
assert_eq!(dto.daily_at, "00:00");
}
#[test]
fn crawler_cookie_domain_is_editable() {
let base = CrawlerConfig::default();
let dto = CrawlerSettings {
cookie_domain: Some("new.example.org".to_string()),
..CrawlerSettings::from_config(&base)
};
assert_eq!(
dto.to_config(&base).unwrap().cookie_domain.as_deref(),
Some("new.example.org")
);
}
#[test]
fn crawler_overlay_preserves_env_only_fields() {
let mut base = CrawlerConfig::default();
base.proxy = Some("socks5://127.0.0.1:9050".to_string());
base.tor_control_password = Some("secret".to_string());
base.phpsessid = Some("abc123".to_string());
// A DTO that knows nothing about the env-only fields.
let dto = CrawlerSettings {
rate_ms: 2000,
..CrawlerSettings::from_config(&base)
};
let out = dto.to_config(&base).expect("valid");
assert_eq!(out.rate_ms, 2000);
assert_eq!(out.proxy.as_deref(), Some("socks5://127.0.0.1:9050"));
assert_eq!(out.tor_control_password.as_deref(), Some("secret"));
assert_eq!(out.phpsessid.as_deref(), Some("abc123"));
}
#[test]
fn crawler_rejects_bad_tz_and_time() {
let base = CrawlerConfig::default();
let dto = CrawlerSettings {
tz: "Mars/Phobos".to_string(),
daily_at: "9am".to_string(),
..CrawlerSettings::from_config(&base)
};
let errs = dto.to_config(&base).unwrap_err();
let fields: Vec<_> = errs.errors.iter().map(|e| e.field.as_str()).collect();
assert!(fields.contains(&"tz"));
assert!(fields.contains(&"daily_at"));
}
#[test]
fn crawler_allowlist_seeds_start_and_cdn_hosts() {
let base = CrawlerConfig::default();
let dto = CrawlerSettings {
start_url: Some("https://catalog.example.com/".to_string()),
cdn_host: Some("cdn.example.com".to_string()),
download_allowlist: vec!["extra.example.net".to_string()],
allow_any_host: false,
..CrawlerSettings::from_config(&base)
};
let out = dto.to_config(&base).expect("valid");
assert!(out.download_allowlist.contains("catalog.example.com"));
assert!(out.download_allowlist.contains("cdn.example.com"));
assert!(out.download_allowlist.contains("extra.example.net"));
assert!(!out.download_allowlist.is_allow_any());
}
#[test]
fn crawler_allow_any_host_bypasses_list() {
let base = CrawlerConfig::default();
let dto = CrawlerSettings {
allow_any_host: true,
..CrawlerSettings::from_config(&base)
};
let out = dto.to_config(&base).expect("valid");
assert!(out.download_allowlist.is_allow_any());
}
// --- analysis round-trip, validation & prompt defaults ---
#[test]
fn analysis_round_trips_and_omits_default_prompts() {
let base = AnalysisConfig::default();
let dto = AnalysisSettings::from_config(&base);
// Unmodified prompts seed as None ("use default").
assert!(dto.system_prompt.is_none());
assert!(dto.ocr_prompt.is_none());
assert!(dto.grounding_prompt.is_none());
assert_eq!(dto.response_format, "json_schema");
let back = dto.to_config(&base).expect("valid");
assert_eq!(back.system_prompt, SYSTEM_PROMPT_DEFAULT);
assert_eq!(back.response_format, ResponseFormat::JsonSchema);
}
#[test]
fn analysis_captures_env_prompt_override() {
let mut base = AnalysisConfig::default();
base.system_prompt = "custom env prompt".to_string();
let dto = AnalysisSettings::from_config(&base);
assert_eq!(dto.system_prompt.as_deref(), Some("custom env prompt"));
}
#[test]
fn analysis_prompt_override_and_reset() {
let base = AnalysisConfig::default();
// Override applied.
let dto = AnalysisSettings {
system_prompt: Some("OVERRIDE".to_string()),
..AnalysisSettings::from_config(&base)
};
assert_eq!(dto.to_config(&base).unwrap().system_prompt, "OVERRIDE");
// None / blank falls back to the compiled default.
let dto = AnalysisSettings {
system_prompt: Some(" ".to_string()),
..AnalysisSettings::from_config(&base)
};
assert_eq!(dto.to_config(&base).unwrap().system_prompt, SYSTEM_PROMPT_DEFAULT);
}
#[test]
fn analysis_overlay_preserves_api_key() {
let mut base = AnalysisConfig::default();
base.api_key = Some("sk-secret".to_string());
let dto = AnalysisSettings::from_config(&base);
assert_eq!(dto.to_config(&base).unwrap().api_key.as_deref(), Some("sk-secret"));
}
#[test]
fn analysis_rejects_out_of_range() {
let base = AnalysisConfig::default();
let dto = AnalysisSettings {
workers: 0,
slice_overlap: 1.5,
tall_aspect_threshold: 0.5,
response_format: "yaml".to_string(),
endpoint: "not a url".to_string(),
request_timeout_secs: 0,
job_timeout_secs: 0,
max_pixels: 0,
max_image_bytes: 0,
..AnalysisSettings::from_config(&base)
};
let errs = dto.to_config(&base).unwrap_err();
let fields: Vec<_> = errs.errors.iter().map(|e| e.field.as_str()).collect();
for f in [
"workers",
"slice_overlap",
"tall_aspect_threshold",
"response_format",
"endpoint",
"request_timeout_secs",
"job_timeout_secs",
"max_pixels",
"max_image_bytes",
] {
assert!(fields.contains(&f), "missing error for {f}");
}
}
#[test]
fn analysis_requires_model_only_when_enabled() {
let base = AnalysisConfig::default();
let disabled = AnalysisSettings {
enabled: false,
model: "".to_string(),
..AnalysisSettings::from_config(&base)
};
assert!(disabled.to_config(&base).is_ok());
let enabled = AnalysisSettings {
enabled: true,
model: "".to_string(),
..AnalysisSettings::from_config(&base)
};
let errs = enabled.to_config(&base).unwrap_err();
assert!(errs.errors.iter().any(|e| e.field == "model"));
}
#[test]
fn dtos_serialize_to_json_and_back() {
let c = CrawlerSettings::default();
let v = serde_json::to_value(&c).unwrap();
let back: CrawlerSettings = serde_json::from_value(v).unwrap();
assert_eq!(c, back);
let a = AnalysisSettings::default();
let v = serde_json::to_value(&a).unwrap();
let back: AnalysisSettings = serde_json::from_value(v).unwrap();
assert_eq!(a, back);
}
}