Files
alchemist/src/config.rs
bybrooklyn e50ca64e80 Resolve audit findings + split db.rs into db/ module
- P1: Fix cancel race in pipeline, fix VideoToolbox quality mapping
- P2: SSRF protection, batch cancel N+1, archived filter fixes,
  metadata persistence, reverse proxy hardening, reprobe logging
- TD: Remove AlchemistEvent legacy bridge, fix silent .ok() on DB
  writes, optimize sort-by-size query, split db.rs (3400 LOC) into
  8 focused submodules under src/db/
- UX: Add queue position display for queued jobs
- Docs: Update API docs, engine modes, library doctor, config ref
- Plans: Add plans.md for remaining open items (UX-2/3, FG-4, RG-2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:02:11 -04:00

1141 lines
36 KiB
Rust

use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub appearance: AppearanceConfig,
pub transcode: TranscodeConfig,
pub hardware: HardwareConfig,
pub scanner: ScannerConfig,
#[serde(default)]
pub notifications: NotificationsConfig,
#[serde(default)]
pub files: FileSettingsConfig,
#[serde(default)]
pub schedule: ScheduleConfig,
#[serde(default)]
pub quality: QualityConfig,
#[serde(default)]
pub system: SystemConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct AppearanceConfig {
#[serde(default)]
pub active_theme_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum QualityProfile {
Quality,
#[default]
Balanced,
Speed,
}
impl QualityProfile {
pub fn as_str(&self) -> &'static str {
match self {
Self::Quality => "quality",
Self::Balanced => "balanced",
Self::Speed => "speed",
}
}
}
impl std::fmt::Display for QualityProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl QualityProfile {
/// Get FFmpeg preset/CRF values for CPU encoding (libsvtav1)
pub fn cpu_params(&self) -> (&'static str, &'static str) {
match self {
Self::Quality => ("4", "24"),
Self::Balanced => ("8", "28"),
Self::Speed => ("12", "32"),
}
}
/// Get FFmpeg quality value for Intel QSV
pub fn qsv_quality(&self) -> &'static str {
match self {
Self::Quality => "20",
Self::Balanced => "25",
Self::Speed => "30",
}
}
/// Get FFmpeg preset for NVIDIA NVENC
pub fn nvenc_preset(&self) -> &'static str {
match self {
Self::Quality => "p7",
Self::Balanced => "p4",
Self::Speed => "p1",
}
}
/// Get FFmpeg quality value for Apple VideoToolbox (-q:v 1-100, lower is better)
pub fn videotoolbox_quality(&self) -> &'static str {
match self {
Self::Quality => "24",
Self::Balanced => "28",
Self::Speed => "32",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum CpuPreset {
Slow,
#[default]
Medium,
Fast,
Faster,
}
impl CpuPreset {
pub fn as_str(&self) -> &'static str {
match self {
Self::Slow => "slow",
Self::Medium => "medium",
Self::Fast => "fast",
Self::Faster => "faster",
}
}
pub fn params(&self) -> (&'static str, &'static str) {
match self {
Self::Slow => ("4", "28"),
Self::Medium => ("8", "32"),
Self::Fast => ("12", "35"),
Self::Faster => ("13", "38"),
}
}
}
impl std::fmt::Display for CpuPreset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Output codec selection
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputCodec {
#[default]
Av1,
Hevc,
H264,
}
impl OutputCodec {
pub fn as_str(&self) -> &'static str {
match self {
Self::Av1 => "av1",
Self::Hevc => "hevc",
Self::H264 => "h264",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AudioMode {
#[default]
Copy,
Aac,
AacStereo,
}
impl AudioMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Copy => "copy",
Self::Aac => "aac",
Self::AacStereo => "aac_stereo",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum HdrMode {
#[default]
Preserve,
Tonemap,
}
impl HdrMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Preserve => "preserve",
Self::Tonemap => "tonemap",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum TonemapAlgorithm {
#[default]
Hable,
Mobius,
Reinhard,
Clip,
}
impl TonemapAlgorithm {
pub fn as_str(&self) -> &'static str {
match self {
Self::Hable => "hable",
Self::Mobius => "mobius",
Self::Reinhard => "reinhard",
Self::Clip => "clip",
}
}
}
/// Subtitle handling mode
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SubtitleMode {
#[default]
Copy,
Burn,
Extract,
None,
}
impl SubtitleMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Copy => "copy",
Self::Burn => "burn",
Self::Extract => "extract",
Self::None => "none",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum EngineMode {
Background,
#[default]
Balanced,
Throughput,
}
impl EngineMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Background => "background",
Self::Balanced => "balanced",
Self::Throughput => "throughput",
}
}
/// Compute the appropriate concurrent job count for this
/// mode given the number of logical CPU cores available.
/// Returns 0 to signal "use the stored manual override".
#[allow(clippy::manual_clamp)]
pub fn concurrent_jobs_for_cpu_count(&self, cpu_count: usize) -> usize {
match self {
// Background: always 1 job, minimal impact
Self::Background => 1,
// Balanced: half the cores, minimum 1, maximum 4
Self::Balanced => (cpu_count / 2).max(1).min(4),
// Throughput: half the cores uncapped, minimum 1
Self::Throughput => (cpu_count / 2).max(1),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScannerConfig {
pub directories: Vec<String>,
#[serde(default)]
pub watch_enabled: bool,
#[serde(default)]
pub extra_watch_dirs: Vec<WatchDirConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct WatchDirConfig {
pub path: String,
#[serde(default = "default_true")]
pub is_recursive: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TranscodeConfig {
pub size_reduction_threshold: f64, // e.g., 0.3 for 30%
pub min_bpp_threshold: f64, // e.g., 0.1
pub min_file_size_mb: u64, // e.g., 50
pub concurrent_jobs: usize,
#[serde(default)]
pub threads: usize, // 0 = auto
#[serde(default)]
pub quality_profile: QualityProfile,
#[serde(default)]
pub output_codec: OutputCodec,
#[serde(default = "default_allow_fallback")]
pub allow_fallback: bool,
#[serde(default)]
pub hdr_mode: HdrMode,
#[serde(default)]
pub tonemap_algorithm: TonemapAlgorithm,
#[serde(default = "default_tonemap_peak")]
pub tonemap_peak: f32,
#[serde(default = "default_tonemap_desat")]
pub tonemap_desat: f32,
#[serde(default)]
pub subtitle_mode: SubtitleMode,
#[serde(default)]
pub stream_rules: StreamRules,
#[serde(default)]
pub vmaf_min_score: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct StreamRules {
/// Strip audio tracks whose title contains any of these
/// strings (case-insensitive). Common use: ["commentary",
/// "director"].
#[serde(default)]
pub strip_audio_by_title: Vec<String>,
/// If non-empty, keep ONLY audio tracks whose language tag
/// matches one of these ISO 639-2 codes (e.g. ["eng", "jpn"]).
/// Tracks with no language tag are always kept.
/// If empty, all languages are kept (default).
#[serde(default)]
pub keep_audio_languages: Vec<String>,
/// If true, strip all audio tracks except the one marked
/// default by the source file. Overridden by
/// keep_audio_languages if both are set.
#[serde(default)]
pub keep_only_default_audio: bool,
}
// Removed default_quality_profile helper as Default trait on enum handles it now.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HardwareConfig {
pub preferred_vendor: Option<String>,
pub device_path: Option<String>,
pub allow_cpu_fallback: bool,
#[serde(default)]
pub cpu_preset: CpuPreset,
#[serde(default = "default_allow_cpu_encoding")]
pub allow_cpu_encoding: bool,
}
// Removed default_cpu_preset helper as Default trait on enum handles it now.
fn default_allow_cpu_encoding() -> bool {
true
}
pub(crate) fn default_allow_fallback() -> bool {
true
}
pub(crate) fn default_tonemap_peak() -> f32 {
// HDR10 content is typically mastered at 1000 nits. Using 100 (SDR level)
// causes severe over-compression of highlights during tone-mapping.
1000.0
}
pub(crate) fn default_tonemap_desat() -> f32 {
0.2
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotificationsConfig {
pub enabled: bool,
#[serde(default)]
pub allow_local_notifications: bool,
#[serde(default)]
pub targets: Vec<NotificationTargetConfig>,
#[serde(default = "default_daily_summary_time_local")]
pub daily_summary_time_local: String,
#[serde(default)]
pub webhook_url: Option<String>,
#[serde(default)]
pub discord_webhook: Option<String>,
#[serde(default)]
pub notify_on_complete: bool,
#[serde(default)]
pub notify_on_failure: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct NotificationTargetConfig {
pub name: String,
pub target_type: String,
#[serde(default)]
pub config_json: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_token: Option<String>,
#[serde(default)]
pub events: Vec<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
enabled: false,
allow_local_notifications: false,
targets: Vec::new(),
daily_summary_time_local: default_daily_summary_time_local(),
webhook_url: None,
discord_webhook: None,
notify_on_complete: false,
notify_on_failure: false,
}
}
}
fn default_daily_summary_time_local() -> String {
"09:00".to_string()
}
pub const NOTIFICATION_EVENT_ENCODE_QUEUED: &str = "encode.queued";
pub const NOTIFICATION_EVENT_ENCODE_STARTED: &str = "encode.started";
pub const NOTIFICATION_EVENT_ENCODE_COMPLETED: &str = "encode.completed";
pub const NOTIFICATION_EVENT_ENCODE_FAILED: &str = "encode.failed";
pub const NOTIFICATION_EVENT_SCAN_COMPLETED: &str = "scan.completed";
pub const NOTIFICATION_EVENT_ENGINE_IDLE: &str = "engine.idle";
pub const NOTIFICATION_EVENT_DAILY_SUMMARY: &str = "daily.summary";
pub const NOTIFICATION_EVENTS: [&str; 7] = [
NOTIFICATION_EVENT_ENCODE_QUEUED,
NOTIFICATION_EVENT_ENCODE_STARTED,
NOTIFICATION_EVENT_ENCODE_COMPLETED,
NOTIFICATION_EVENT_ENCODE_FAILED,
NOTIFICATION_EVENT_SCAN_COMPLETED,
NOTIFICATION_EVENT_ENGINE_IDLE,
NOTIFICATION_EVENT_DAILY_SUMMARY,
];
fn normalize_notification_event(event: &str) -> Option<&'static str> {
match event.trim() {
"queued" | "encode.queued" => Some(NOTIFICATION_EVENT_ENCODE_QUEUED),
"encoding" | "remuxing" | "encode.started" => Some(NOTIFICATION_EVENT_ENCODE_STARTED),
"completed" | "encode.completed" => Some(NOTIFICATION_EVENT_ENCODE_COMPLETED),
"failed" | "encode.failed" => Some(NOTIFICATION_EVENT_ENCODE_FAILED),
"scan.completed" => Some(NOTIFICATION_EVENT_SCAN_COMPLETED),
"engine.idle" => Some(NOTIFICATION_EVENT_ENGINE_IDLE),
"daily.summary" => Some(NOTIFICATION_EVENT_DAILY_SUMMARY),
_ => None,
}
}
pub fn normalize_notification_events(events: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for event in events {
if let Some(value) = normalize_notification_event(event) {
if !normalized.iter().any(|candidate| candidate == value) {
normalized.push(value.to_string());
}
}
}
normalized
}
fn config_json_string(config_json: &JsonValue, key: &str) -> Option<String> {
config_json
.get(key)
.and_then(JsonValue::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
impl NotificationTargetConfig {
pub fn migrate_legacy_shape(&mut self) {
self.target_type = match self.target_type.as_str() {
"discord" => "discord_webhook".to_string(),
other => other.to_string(),
};
if !self.config_json.is_object() {
self.config_json = JsonValue::Object(JsonMap::new());
}
let mut config_map = self
.config_json
.as_object()
.cloned()
.unwrap_or_else(JsonMap::new);
match self.target_type.as_str() {
"discord_webhook" => {
if !config_map.contains_key("webhook_url") {
if let Some(endpoint_url) = self.endpoint_url.clone() {
config_map
.insert("webhook_url".to_string(), JsonValue::String(endpoint_url));
}
}
}
"gotify" => {
if !config_map.contains_key("server_url") {
if let Some(endpoint_url) = self.endpoint_url.clone() {
config_map
.insert("server_url".to_string(), JsonValue::String(endpoint_url));
}
}
if !config_map.contains_key("app_token") {
if let Some(auth_token) = self.auth_token.clone() {
config_map.insert("app_token".to_string(), JsonValue::String(auth_token));
}
}
}
"webhook" => {
if !config_map.contains_key("url") {
if let Some(endpoint_url) = self.endpoint_url.clone() {
config_map.insert("url".to_string(), JsonValue::String(endpoint_url));
}
}
if !config_map.contains_key("auth_token") {
if let Some(auth_token) = self.auth_token.clone() {
config_map.insert("auth_token".to_string(), JsonValue::String(auth_token));
}
}
}
_ => {}
}
self.config_json = JsonValue::Object(config_map);
self.events = normalize_notification_events(&self.events);
}
pub fn canonicalize_for_save(&mut self) {
self.endpoint_url = None;
self.auth_token = None;
self.events = normalize_notification_events(&self.events);
if !self.config_json.is_object() {
self.config_json = JsonValue::Object(JsonMap::new());
}
}
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
anyhow::bail!("notification target name must not be empty");
}
if !self.config_json.is_object() {
anyhow::bail!("notification target config_json must be an object");
}
if self.events.is_empty() {
anyhow::bail!("notification target events must not be empty");
}
for event in &self.events {
if normalize_notification_event(event).is_none() {
anyhow::bail!("unsupported notification event '{}'", event);
}
}
match self.target_type.as_str() {
"discord_webhook" => {
if config_json_string(&self.config_json, "webhook_url").is_none() {
anyhow::bail!("discord_webhook target requires config_json.webhook_url");
}
}
"discord_bot" => {
if config_json_string(&self.config_json, "bot_token").is_none() {
anyhow::bail!("discord_bot target requires config_json.bot_token");
}
if config_json_string(&self.config_json, "channel_id").is_none() {
anyhow::bail!("discord_bot target requires config_json.channel_id");
}
}
"gotify" => {
if config_json_string(&self.config_json, "server_url").is_none() {
anyhow::bail!("gotify target requires config_json.server_url");
}
if config_json_string(&self.config_json, "app_token").is_none() {
anyhow::bail!("gotify target requires config_json.app_token");
}
}
"webhook" => {
if config_json_string(&self.config_json, "url").is_none() {
anyhow::bail!("webhook target requires config_json.url");
}
}
"telegram" => {
if config_json_string(&self.config_json, "bot_token").is_none() {
anyhow::bail!("telegram target requires config_json.bot_token");
}
if config_json_string(&self.config_json, "chat_id").is_none() {
anyhow::bail!("telegram target requires config_json.chat_id");
}
}
"email" => {
if config_json_string(&self.config_json, "smtp_host").is_none() {
anyhow::bail!("email target requires config_json.smtp_host");
}
if config_json_string(&self.config_json, "from_address").is_none() {
anyhow::bail!("email target requires config_json.from_address");
}
if self
.config_json
.get("to_addresses")
.and_then(JsonValue::as_array)
.map(|values| !values.is_empty())
!= Some(true)
{
anyhow::bail!("email target requires non-empty config_json.to_addresses");
}
}
other => anyhow::bail!("unsupported notification target type '{}'", other),
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileSettingsConfig {
pub delete_source: bool,
pub output_extension: String,
pub output_suffix: String,
pub replace_strategy: String,
#[serde(default)]
pub output_root: Option<String>,
}
impl Default for FileSettingsConfig {
fn default() -> Self {
Self {
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
output_root: None,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ScheduleConfig {
#[serde(default)]
pub windows: Vec<ScheduleWindowConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ScheduleWindowConfig {
pub start_time: String,
pub end_time: String,
#[serde(default)]
pub days_of_week: Vec<i32>,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QualityConfig {
pub enable_vmaf: bool,
pub min_vmaf_score: f64,
pub revert_on_low_quality: bool,
}
impl Default for QualityConfig {
fn default() -> Self {
Self {
enable_vmaf: false,
min_vmaf_score: 90.0,
revert_on_low_quality: true,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SystemConfig {
#[serde(default = "default_poll_interval")]
pub monitoring_poll_interval: f64,
#[serde(default = "default_telemetry")]
pub enable_telemetry: bool,
#[serde(default = "default_log_retention_days")]
pub log_retention_days: Option<u32>,
#[serde(default)]
pub engine_mode: EngineMode,
/// Enable HSTS header (only enable if running behind HTTPS)
#[serde(default)]
pub https_only: bool,
/// Explicit list of reverse proxy IPs (e.g. "192.168.1.1") whose
/// X-Forwarded-For / X-Real-IP headers are trusted. When non-empty,
/// only these IPs (plus loopback) are trusted as proxies; private
/// ranges are no longer trusted by default. Leave empty to preserve
/// the previous behaviour (trust all RFC-1918 private addresses).
#[serde(default)]
pub trusted_proxies: Vec<String>,
}
fn default_true() -> bool {
true
}
fn default_telemetry() -> bool {
false
}
fn default_poll_interval() -> f64 {
2.0
}
fn default_log_retention_days() -> Option<u32> {
Some(30)
}
impl Default for SystemConfig {
fn default() -> Self {
Self {
monitoring_poll_interval: default_poll_interval(),
enable_telemetry: default_telemetry(),
log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(),
https_only: false,
trusted_proxies: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Clone, Copy)]
pub struct BuiltInLibraryProfile {
pub id: i64,
pub name: &'static str,
pub preset: &'static str,
pub codec: OutputCodec,
pub quality_profile: QualityProfile,
pub hdr_mode: HdrMode,
pub audio_mode: AudioMode,
pub crf_override: Option<i32>,
pub notes: Option<&'static str>,
}
pub const PRESET_SPACE_SAVER: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 1,
name: "Space Saver",
preset: "space_saver",
codec: OutputCodec::Av1,
quality_profile: QualityProfile::Speed,
hdr_mode: HdrMode::Tonemap,
audio_mode: AudioMode::Aac,
crf_override: None,
notes: Some("Optimized for aggressive size reduction."),
};
pub const PRESET_QUALITY_FIRST: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 2,
name: "Quality First",
preset: "quality_first",
codec: OutputCodec::Hevc,
quality_profile: QualityProfile::Quality,
hdr_mode: HdrMode::Preserve,
audio_mode: AudioMode::Copy,
crf_override: None,
notes: Some("Prioritizes fidelity over maximum compression."),
};
pub const PRESET_BALANCED: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 3,
name: "Balanced",
preset: "balanced",
codec: OutputCodec::Av1,
quality_profile: QualityProfile::Balanced,
hdr_mode: HdrMode::Preserve,
audio_mode: AudioMode::Copy,
crf_override: None,
notes: Some("Balanced compression and playback quality."),
};
pub const PRESET_STREAMING: BuiltInLibraryProfile = BuiltInLibraryProfile {
id: 4,
name: "Streaming",
preset: "streaming",
codec: OutputCodec::H264,
quality_profile: QualityProfile::Balanced,
hdr_mode: HdrMode::Tonemap,
audio_mode: AudioMode::AacStereo,
crf_override: None,
notes: Some("Maximizes compatibility for streaming clients."),
};
pub const BUILT_IN_LIBRARY_PROFILES: [BuiltInLibraryProfile; 4] = [
PRESET_SPACE_SAVER,
PRESET_QUALITY_FIRST,
PRESET_BALANCED,
PRESET_STREAMING,
];
impl Default for Config {
fn default() -> Self {
Self {
appearance: AppearanceConfig::default(),
transcode: TranscodeConfig {
size_reduction_threshold: 0.3,
min_bpp_threshold: 0.1,
min_file_size_mb: 50,
concurrent_jobs: 1,
threads: 0,
quality_profile: QualityProfile::Balanced,
output_codec: OutputCodec::Av1,
allow_fallback: default_allow_fallback(),
hdr_mode: HdrMode::Preserve,
tonemap_algorithm: TonemapAlgorithm::Hable,
tonemap_peak: default_tonemap_peak(),
tonemap_desat: default_tonemap_desat(),
subtitle_mode: SubtitleMode::Copy,
stream_rules: StreamRules::default(),
vmaf_min_score: None,
},
hardware: HardwareConfig {
preferred_vendor: None,
device_path: None,
allow_cpu_fallback: true,
cpu_preset: CpuPreset::Medium,
allow_cpu_encoding: true,
},
scanner: ScannerConfig {
directories: Vec::new(),
watch_enabled: false,
extra_watch_dirs: Vec::new(),
},
notifications: NotificationsConfig::default(),
files: FileSettingsConfig::default(),
schedule: ScheduleConfig::default(),
quality: QualityConfig::default(),
system: SystemConfig {
monitoring_poll_interval: default_poll_interval(),
enable_telemetry: default_telemetry(),
log_retention_days: default_log_retention_days(),
engine_mode: EngineMode::default(),
https_only: false,
trusted_proxies: Vec::new(),
},
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&content)?;
config.migrate_legacy_notifications();
config.apply_env_overrides();
config.validate()?;
Ok(config)
}
/// Validate configuration values
pub fn validate(&self) -> Result<()> {
// Enums automatically handle valid values via Serde,
// so we don't need manual string checks for presets/profiles anymore.
// Validate system monitoring poll interval
if self.system.monitoring_poll_interval < 0.5 || self.system.monitoring_poll_interval > 10.0
{
anyhow::bail!(
"monitoring_poll_interval must be between 0.5 and 10.0 seconds, got {}",
self.system.monitoring_poll_interval
);
}
// Validate thresholds
if self.transcode.size_reduction_threshold < 0.0
|| self.transcode.size_reduction_threshold > 1.0
{
anyhow::bail!(
"size_reduction_threshold must be between 0.0 and 1.0, got {}",
self.transcode.size_reduction_threshold
);
}
if self.transcode.min_bpp_threshold < 0.0 {
anyhow::bail!(
"min_bpp_threshold must be >= 0.0, got {}",
self.transcode.min_bpp_threshold
);
}
if self.transcode.concurrent_jobs == 0 {
anyhow::bail!("concurrent_jobs must be >= 1");
}
if self.transcode.tonemap_peak < 50.0 || self.transcode.tonemap_peak > 1000.0 {
anyhow::bail!(
"tonemap_peak must be between 50 and 1000, got {}",
self.transcode.tonemap_peak
);
}
if !(0.0..=1.0).contains(&self.transcode.tonemap_desat) {
anyhow::bail!(
"tonemap_desat must be between 0.0 and 1.0, got {}",
self.transcode.tonemap_desat
);
}
if self
.files
.output_extension
.chars()
.any(|c| c == '/' || c == '\\')
{
anyhow::bail!("files.output_extension must not contain path separators");
}
if self
.files
.output_suffix
.chars()
.any(|c| c == '/' || c == '\\')
{
anyhow::bail!("files.output_suffix must not contain path separators");
}
for window in &self.schedule.windows {
validate_schedule_time(&window.start_time)?;
validate_schedule_time(&window.end_time)?;
if window.days_of_week.is_empty()
|| window.days_of_week.iter().any(|day| !(0..=6).contains(day))
{
anyhow::bail!("schedule.windows days_of_week must contain values 0-6");
}
}
validate_schedule_time(&self.notifications.daily_summary_time_local)?;
for target in &self.notifications.targets {
target.validate()?;
}
// Validate VMAF threshold
if self.quality.min_vmaf_score < 0.0 || self.quality.min_vmaf_score > 100.0 {
anyhow::bail!(
"min_vmaf_score must be between 0.0 and 100.0, got {}",
self.quality.min_vmaf_score
);
}
if let Some(vmaf_min_score) = self.transcode.vmaf_min_score {
if !(0.0..=100.0).contains(&vmaf_min_score) {
anyhow::bail!(
"vmaf_min_score must be between 0.0 and 100.0, got {}",
vmaf_min_score
);
}
}
Ok(())
}
/// Save config to file atomically (write to temp, then rename).
/// This prevents corruption if the process crashes mid-write.
pub fn save(&self, path: &Path) -> Result<()> {
let mut config = self.clone();
config.canonicalize_for_save();
let content = toml::to_string_pretty(&config)?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &content)?;
// Atomic rename: if this fails, the original config is still intact.
if let Err(e) = std::fs::rename(&tmp, path) {
// Clean up the temp file on rename failure
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
pub(crate) fn migrate_legacy_notifications(&mut self) {
if self.notifications.targets.is_empty() {
let mut targets = Vec::new();
let events = normalize_notification_events(
&[
self.notifications
.notify_on_complete
.then_some("completed".to_string()),
self.notifications
.notify_on_failure
.then_some("failed".to_string()),
]
.into_iter()
.flatten()
.collect::<Vec<_>>(),
);
if let Some(discord_webhook) = self.notifications.discord_webhook.clone() {
targets.push(NotificationTargetConfig {
name: "Discord".to_string(),
target_type: "discord_webhook".to_string(),
config_json: serde_json::json!({ "webhook_url": discord_webhook }),
endpoint_url: None,
auth_token: None,
events: events.clone(),
enabled: self.notifications.enabled,
});
}
if let Some(webhook_url) = self.notifications.webhook_url.clone() {
targets.push(NotificationTargetConfig {
name: "Webhook".to_string(),
target_type: "webhook".to_string(),
config_json: serde_json::json!({ "url": webhook_url }),
endpoint_url: None,
auth_token: None,
events,
enabled: self.notifications.enabled,
});
}
self.notifications.targets = targets;
}
for target in &mut self.notifications.targets {
target.migrate_legacy_shape();
}
self.notifications.daily_summary_time_local = self
.notifications
.daily_summary_time_local
.trim()
.to_string();
if self.notifications.daily_summary_time_local.is_empty() {
self.notifications.daily_summary_time_local = default_daily_summary_time_local();
}
}
pub(crate) fn canonicalize_for_save(&mut self) {
if !self.notifications.targets.is_empty() {
self.notifications.webhook_url = None;
self.notifications.discord_webhook = None;
self.notifications.notify_on_complete = false;
self.notifications.notify_on_failure = false;
}
self.notifications.daily_summary_time_local = self
.notifications
.daily_summary_time_local
.trim()
.to_string();
if self.notifications.daily_summary_time_local.is_empty() {
self.notifications.daily_summary_time_local = default_daily_summary_time_local();
}
for target in &mut self.notifications.targets {
target.canonicalize_for_save();
}
}
pub(crate) fn apply_env_overrides(&mut self) {}
}
fn validate_schedule_time(value: &str) -> Result<()> {
let trimmed = value.trim();
let parts: Vec<&str> = trimmed.split(':').collect();
if parts.len() != 2 {
anyhow::bail!("schedule time must be HH:MM");
}
let hour: u32 = parts[0].parse()?;
let minute: u32 = parts[1].parse()?;
if hour > 23 || minute > 59 {
anyhow::bail!("schedule time must be HH:MM");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_notification_fields_migrate_into_targets() {
let raw = r#"
[transcode]
size_reduction_threshold = 0.3
min_bpp_threshold = 0.1
min_file_size_mb = 50
concurrent_jobs = 1
[hardware]
preferred_vendor = "cpu"
allow_cpu_fallback = true
[scanner]
directories = []
[notifications]
enabled = true
discord_webhook = "https://discord.com/api/webhooks/test"
notify_on_complete = true
notify_on_failure = true
"#;
let mut config: Config = match toml::from_str(raw) {
Ok(config) => config,
Err(err) => panic!("failed to parse config fixture: {err}"),
};
config.migrate_legacy_notifications();
assert_eq!(config.notifications.targets.len(), 1);
assert_eq!(
config.notifications.targets[0].target_type,
"discord_webhook"
);
assert_eq!(
config.notifications.targets[0].events,
vec!["encode.completed".to_string(), "encode.failed".to_string()]
);
}
#[test]
fn save_canonicalizes_legacy_notification_fields() {
let mut config = Config::default();
config.notifications.targets = vec![NotificationTargetConfig {
name: "Webhook".to_string(),
target_type: "webhook".to_string(),
config_json: serde_json::json!({ "url": "https://example.com/webhook" }),
endpoint_url: Some("https://example.com/webhook".to_string()),
auth_token: None,
events: vec!["encode.completed".to_string()],
enabled: true,
}];
config.notifications.webhook_url = Some("https://legacy.example.com".to_string());
config.notifications.notify_on_complete = true;
config.canonicalize_for_save();
assert!(config.notifications.webhook_url.is_none());
assert!(!config.notifications.notify_on_complete);
}
#[test]
fn engine_mode_defaults_to_balanced() {
assert_eq!(EngineMode::default(), EngineMode::Balanced);
assert_eq!(EngineMode::Balanced.concurrent_jobs_for_cpu_count(8), 4);
}
}