MetadatResolve hardening rerties after errors (#611)

* MetadatResolve hardening rerties after errors
This commit is contained in:
euzu
2026-02-25 19:30:19 +01:00
committed by GitHub
parent b3b0ccd92b
commit 3be0fa347f
36 changed files with 3164 additions and 666 deletions
+46 -8
View File
@@ -2,7 +2,8 @@ use crate::{
error::{TuliproxError, TuliproxErrorKind},
model::{
ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto,
ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto,
MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto,
WebUiConfigDto,
},
utils::{
default_connect_timeout_secs, default_supported_video_extensions, is_blank_optional_string,
@@ -16,6 +17,9 @@ fn default_default_user_agent() -> Option<String> { Some(DEFAULT_USER_AGENT.to_s
fn is_none_or_empty_video(video: &Option<VideoConfigDto>) -> bool {
video.as_ref().is_none_or(VideoConfigDto::is_empty)
}
fn is_none_or_empty_metadata_update(metadata_update: &Option<MetadataUpdateConfigDto>) -> bool {
metadata_update.as_ref().is_none_or(MetadataUpdateConfigDto::is_empty)
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
@@ -39,6 +43,8 @@ pub struct ConfigDto {
pub custom_stream_response_path: Option<String>,
#[serde(default, skip_serializing_if = "is_none_or_empty_video")]
pub video: Option<VideoConfigDto>,
#[serde(default, skip_serializing_if = "is_none_or_empty_metadata_update")]
pub metadata_update: Option<MetadataUpdateConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedules: Option<Vec<ScheduleConfigDto>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -86,6 +92,7 @@ impl Default for ConfigDto {
template_path: None,
custom_stream_response_path: None,
video: None,
metadata_update: None,
schedules: None,
log: None,
user_access_control: false,
@@ -224,6 +231,7 @@ impl ConfigDto {
self.prepare_web()?;
self.prepare_hdhomerun(include_computed)?;
self.prepare_video_config()?;
self.prepare_metadata_update_config()?;
if let Some(reverse_proxy) = self.reverse_proxy.as_mut() {
reverse_proxy.prepare(&self.working_dir)?;
@@ -265,8 +273,6 @@ impl ConfigDto {
extensions: default_supported_video_extensions(),
download: None,
web_search: None,
ffprobe_enabled: false,
ffprobe_timeout: None,
});
}
Some(video) => match video.prepare() {
@@ -277,6 +283,20 @@ impl ConfigDto {
Ok(())
}
fn prepare_metadata_update_config(&mut self) -> Result<(), TuliproxError> {
let mut metadata_update = self.metadata_update.clone().unwrap_or_default();
metadata_update.prepare()?;
if metadata_update.is_empty() {
self.metadata_update = None;
} else {
self.metadata_update = Some(metadata_update);
}
Ok(())
}
pub fn is_valid(&self) -> bool {
if self.api.host.is_empty() {
return false;
@@ -333,6 +353,7 @@ impl ConfigDto {
mod tests {
use super::*;
use crate::utils::default_supported_video_extensions;
use serde_json::json;
#[test]
fn default_uses_connect_timeout_default_value() {
@@ -347,8 +368,6 @@ mod tests {
extensions: default_supported_video_extensions(),
download: None,
web_search: None,
ffprobe_enabled: false,
ffprobe_timeout: None,
}),
..ConfigDto::default()
};
@@ -363,9 +382,7 @@ mod tests {
video: Some(VideoConfigDto {
extensions: default_supported_video_extensions(),
download: None,
web_search: None,
ffprobe_enabled: true,
ffprobe_timeout: None,
web_search: Some("https://example.org?q={}".to_string()),
}),
..ConfigDto::default()
};
@@ -373,4 +390,25 @@ mod tests {
let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed");
assert!(serialized.contains("\"video\""), "expected video field, got: {serialized}");
}
#[test]
fn deserializing_rejects_legacy_video_ffprobe_fields() {
let raw = json!({
"api": {
"host": "127.0.0.1",
"port": 8901,
"web_root": "./web"
},
"working_dir": ".",
"video": {
"extensions": ["mp4"],
"ffprobe_enabled": true
}
});
let result: Result<ConfigDto, _> = serde_json::from_value(raw);
assert!(result.is_err(), "legacy ffprobe field under video must fail");
let err = result.unwrap_err().to_string();
assert!(err.contains("ffprobe_enabled"), "unexpected error text: {err}");
}
}
+6 -25
View File
@@ -9,8 +9,8 @@ use crate::{
default_resolve_background, default_resolve_delay_secs, default_xtream_live_stream_use_prefix,
deserialize_timestamp, get_credentials_from_url_str, get_trimmed_string, is_blank_optional_string,
is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false, is_true,
is_zero_u16, parse_provider_scheme_url_parts, sanitize_sensitive_info, serialize_option_vec_flow_map_items,
trim_last_slash, Internable, PROVIDER_SCHEME_PREFIX,
is_zero_u16, parse_duration_seconds, parse_provider_scheme_url_parts, sanitize_sensitive_info,
serialize_option_vec_flow_map_items, trim_last_slash, Internable, PROVIDER_SCHEME_PREFIX,
},
};
use enum_iterator::Sequence;
@@ -509,29 +509,10 @@ impl ConfigInputDto {
}
fn parse_duration(&self, duration_str: &str) -> Result<u64, TuliproxError> {
Ok(match duration_str.parse::<u64>() {
Ok(secs) => secs,
Err(_) => {
let len = duration_str.len();
if len > 1 {
let (num_str, unit) = duration_str.split_at(len - 1);
match num_str.parse::<u64>() {
Ok(val) => match unit {
"s" => val,
"m" => val * 60,
"h" => val * 3600,
"d" => val * 86400,
_ => return info_err_res!("Invalid cache_duration unit in '{}': {}", self.name, unit),
},
Err(_) => {
return info_err_res!("Invalid cache_duration format in '{}': {}", self.name, duration_str)
}
}
} else {
return info_err_res!("Invalid cache_duration format in '{}'", self.name);
}
}
})
match parse_duration_seconds(duration_str, false) {
Some(seconds) => Ok(seconds),
None => info_err_res!("Invalid cache_duration format in '{}': {}", self.name, duration_str),
}
}
pub fn prepare_epg(&mut self, include_computed: bool) -> Result<(), TuliproxError> {
+434
View File
@@ -0,0 +1,434 @@
use crate::{
error::TuliproxError,
info_err_res,
utils::{
default_metadata_backoff_jitter_percent, default_metadata_ffprobe_analyze_duration,
default_metadata_ffprobe_live_analyze_duration, default_metadata_ffprobe_live_probe_size,
default_metadata_ffprobe_probe_size, default_metadata_max_attempts_probe,
default_metadata_max_attempts_resolve, default_metadata_max_queue_size,
default_metadata_max_resolve_retry_backoff, default_metadata_probe_cooldown,
default_metadata_probe_retry_backoff_step_1, default_metadata_probe_retry_backoff_step_2,
default_metadata_probe_retry_backoff_step_3, default_metadata_probe_retry_load_retry_delay,
default_metadata_progress_log_interval, default_metadata_queue_log_interval,
default_metadata_resolve_exhaustion_reset_gap, default_metadata_resolve_min_retry_base,
default_metadata_retry_delay, default_metadata_worker_idle_timeout, deserialize_as_string,
is_default_metadata_backoff_jitter_percent, is_default_metadata_ffprobe_analyze_duration,
is_default_metadata_ffprobe_live_analyze_duration, is_default_metadata_ffprobe_live_probe_size,
is_default_metadata_ffprobe_probe_size, is_default_metadata_max_attempts_probe,
is_default_metadata_max_attempts_resolve, is_default_metadata_max_queue_size,
is_default_metadata_max_resolve_retry_backoff, is_default_metadata_probe_cooldown,
is_default_metadata_probe_retry_backoff_step_1, is_default_metadata_probe_retry_backoff_step_2,
is_default_metadata_probe_retry_backoff_step_3, is_default_metadata_probe_retry_load_retry_delay,
is_default_metadata_progress_log_interval, is_default_metadata_queue_log_interval,
is_default_metadata_resolve_exhaustion_reset_gap, is_default_metadata_resolve_min_retry_base,
is_default_metadata_retry_delay, is_default_metadata_worker_idle_timeout, is_false, parse_duration_seconds,
parse_size_base_2,
},
};
use std::sync::OnceLock;
const MIN_DURATION_SECS: u64 = 1;
const MIN_ATTEMPTS: u8 = 1;
const MAX_JITTER_PERCENT: u8 = 95;
const MIN_QUEUE_SIZE: usize = 1;
const DEFAULT_FFPROBE_TIMEOUT_SECS: u64 = 60;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MetadataUpdateConfigDto {
#[serde(
default = "default_metadata_queue_log_interval",
skip_serializing_if = "is_default_metadata_queue_log_interval"
)]
pub queue_log_interval: String,
#[serde(
default = "default_metadata_progress_log_interval",
skip_serializing_if = "is_default_metadata_progress_log_interval"
)]
pub progress_log_interval: String,
#[serde(
default = "default_metadata_max_resolve_retry_backoff",
skip_serializing_if = "is_default_metadata_max_resolve_retry_backoff"
)]
pub max_resolve_retry_backoff: String,
#[serde(
default = "default_metadata_resolve_min_retry_base",
skip_serializing_if = "is_default_metadata_resolve_min_retry_base"
)]
pub resolve_min_retry_base: String,
#[serde(
default = "default_metadata_resolve_exhaustion_reset_gap",
skip_serializing_if = "is_default_metadata_resolve_exhaustion_reset_gap"
)]
pub resolve_exhaustion_reset_gap: String,
#[serde(default = "default_metadata_probe_cooldown", skip_serializing_if = "is_default_metadata_probe_cooldown")]
pub probe_cooldown: String,
#[serde(default = "default_metadata_retry_delay", skip_serializing_if = "is_default_metadata_retry_delay")]
pub retry_delay: String,
#[serde(
default = "default_metadata_probe_retry_load_retry_delay",
skip_serializing_if = "is_default_metadata_probe_retry_load_retry_delay"
)]
pub probe_retry_load_retry_delay: String,
#[serde(
default = "default_metadata_worker_idle_timeout",
skip_serializing_if = "is_default_metadata_worker_idle_timeout"
)]
pub worker_idle_timeout: String,
#[serde(
default = "default_metadata_probe_retry_backoff_step_1",
skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_1"
)]
pub probe_retry_backoff_step_1: String,
#[serde(
default = "default_metadata_probe_retry_backoff_step_2",
skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_2"
)]
pub probe_retry_backoff_step_2: String,
#[serde(
default = "default_metadata_probe_retry_backoff_step_3",
skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_3"
)]
pub probe_retry_backoff_step_3: String,
#[serde(
default = "default_metadata_max_attempts_resolve",
skip_serializing_if = "is_default_metadata_max_attempts_resolve"
)]
pub max_attempts_resolve: u8,
#[serde(
default = "default_metadata_max_attempts_probe",
skip_serializing_if = "is_default_metadata_max_attempts_probe"
)]
pub max_attempts_probe: u8,
#[serde(
default = "default_metadata_backoff_jitter_percent",
skip_serializing_if = "is_default_metadata_backoff_jitter_percent"
)]
pub backoff_jitter_percent: u8,
#[serde(default = "default_metadata_max_queue_size", skip_serializing_if = "is_default_metadata_max_queue_size")]
pub max_queue_size: usize,
#[serde(default, skip_serializing_if = "is_false")]
pub ffprobe_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ffprobe_timeout: Option<u64>,
#[serde(
default = "default_metadata_ffprobe_analyze_duration",
skip_serializing_if = "is_default_metadata_ffprobe_analyze_duration",
deserialize_with = "deserialize_as_string"
)]
pub ffprobe_analyze_duration: String,
#[serde(
default = "default_metadata_ffprobe_probe_size",
skip_serializing_if = "is_default_metadata_ffprobe_probe_size",
deserialize_with = "deserialize_as_string"
)]
pub ffprobe_probe_size: String,
#[serde(
default = "default_metadata_ffprobe_live_analyze_duration",
skip_serializing_if = "is_default_metadata_ffprobe_live_analyze_duration",
deserialize_with = "deserialize_as_string"
)]
pub ffprobe_live_analyze_duration: String,
#[serde(
default = "default_metadata_ffprobe_live_probe_size",
skip_serializing_if = "is_default_metadata_ffprobe_live_probe_size",
deserialize_with = "deserialize_as_string"
)]
pub ffprobe_live_probe_size: String,
}
impl Default for MetadataUpdateConfigDto {
fn default() -> Self {
Self {
queue_log_interval: default_metadata_queue_log_interval(),
progress_log_interval: default_metadata_progress_log_interval(),
max_resolve_retry_backoff: default_metadata_max_resolve_retry_backoff(),
resolve_min_retry_base: default_metadata_resolve_min_retry_base(),
resolve_exhaustion_reset_gap: default_metadata_resolve_exhaustion_reset_gap(),
probe_cooldown: default_metadata_probe_cooldown(),
retry_delay: default_metadata_retry_delay(),
probe_retry_load_retry_delay: default_metadata_probe_retry_load_retry_delay(),
worker_idle_timeout: default_metadata_worker_idle_timeout(),
probe_retry_backoff_step_1: default_metadata_probe_retry_backoff_step_1(),
probe_retry_backoff_step_2: default_metadata_probe_retry_backoff_step_2(),
probe_retry_backoff_step_3: default_metadata_probe_retry_backoff_step_3(),
max_attempts_resolve: default_metadata_max_attempts_resolve(),
max_attempts_probe: default_metadata_max_attempts_probe(),
backoff_jitter_percent: default_metadata_backoff_jitter_percent(),
max_queue_size: default_metadata_max_queue_size(),
ffprobe_enabled: false,
ffprobe_timeout: None,
ffprobe_analyze_duration: default_metadata_ffprobe_analyze_duration(),
ffprobe_probe_size: default_metadata_ffprobe_probe_size(),
ffprobe_live_analyze_duration: default_metadata_ffprobe_live_analyze_duration(),
ffprobe_live_probe_size: default_metadata_ffprobe_live_probe_size(),
}
}
}
impl MetadataUpdateConfigDto {
fn defaults() -> &'static Self {
static DEFAULTS: OnceLock<MetadataUpdateConfigDto> = OnceLock::new();
DEFAULTS.get_or_init(Self::default)
}
pub fn is_empty(&self) -> bool { self == Self::defaults() }
pub fn clean(&mut self) {
if self.ffprobe_timeout.is_some_and(|v| v == DEFAULT_FFPROBE_TIMEOUT_SECS) {
self.ffprobe_timeout = None;
}
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
let queue_log_interval_secs =
Self::parse_and_clamp_duration(&self.queue_log_interval, MIN_DURATION_SECS, "queue_log_interval")?;
self.queue_log_interval = Self::canonicalize_seconds(queue_log_interval_secs);
let progress_log_interval_secs =
Self::parse_and_clamp_duration(&self.progress_log_interval, MIN_DURATION_SECS, "progress_log_interval")?;
self.progress_log_interval = Self::canonicalize_seconds(progress_log_interval_secs);
let max_resolve_retry_backoff_secs = Self::parse_and_clamp_duration(
&self.max_resolve_retry_backoff,
MIN_DURATION_SECS,
"max_resolve_retry_backoff",
)?;
self.max_resolve_retry_backoff = Self::canonicalize_seconds(max_resolve_retry_backoff_secs);
let resolve_min_retry_base_secs =
Self::parse_and_clamp_duration(&self.resolve_min_retry_base, MIN_DURATION_SECS, "resolve_min_retry_base")?;
self.resolve_min_retry_base = Self::canonicalize_seconds(resolve_min_retry_base_secs);
let resolve_exhaustion_reset_gap_secs = Self::parse_and_clamp_duration(
&self.resolve_exhaustion_reset_gap,
MIN_DURATION_SECS,
"resolve_exhaustion_reset_gap",
)?;
self.resolve_exhaustion_reset_gap = Self::canonicalize_seconds(resolve_exhaustion_reset_gap_secs);
let probe_cooldown_secs =
Self::parse_and_clamp_duration(&self.probe_cooldown, MIN_DURATION_SECS, "probe_cooldown")?;
self.probe_cooldown = Self::canonicalize_seconds(probe_cooldown_secs);
let retry_delay_secs = Self::parse_and_clamp_duration(&self.retry_delay, MIN_DURATION_SECS, "retry_delay")?;
self.retry_delay = Self::canonicalize_seconds(retry_delay_secs);
let probe_retry_load_retry_delay_secs = Self::parse_and_clamp_duration(
&self.probe_retry_load_retry_delay,
MIN_DURATION_SECS,
"probe_retry_load_retry_delay",
)?;
self.probe_retry_load_retry_delay = Self::canonicalize_seconds(probe_retry_load_retry_delay_secs);
let worker_idle_timeout_secs =
Self::parse_and_clamp_duration(&self.worker_idle_timeout, MIN_DURATION_SECS, "worker_idle_timeout")?;
self.worker_idle_timeout = Self::canonicalize_seconds(worker_idle_timeout_secs);
let probe_retry_backoff_step_1_secs = Self::parse_and_clamp_duration(
&self.probe_retry_backoff_step_1,
MIN_DURATION_SECS,
"probe_retry_backoff_step_1",
)?;
self.probe_retry_backoff_step_1 = Self::canonicalize_seconds(probe_retry_backoff_step_1_secs);
let probe_retry_backoff_step_2_secs = Self::parse_and_clamp_duration(
&self.probe_retry_backoff_step_2,
MIN_DURATION_SECS,
"probe_retry_backoff_step_2",
)?;
self.probe_retry_backoff_step_2 = Self::canonicalize_seconds(probe_retry_backoff_step_2_secs);
let probe_retry_backoff_step_3_secs = Self::parse_and_clamp_duration(
&self.probe_retry_backoff_step_3,
MIN_DURATION_SECS,
"probe_retry_backoff_step_3",
)?;
self.probe_retry_backoff_step_3 = Self::canonicalize_seconds(probe_retry_backoff_step_3_secs);
self.max_attempts_resolve = self.max_attempts_resolve.max(MIN_ATTEMPTS);
self.max_attempts_probe = self.max_attempts_probe.max(MIN_ATTEMPTS);
self.backoff_jitter_percent = self.backoff_jitter_percent.min(MAX_JITTER_PERCENT);
self.max_queue_size = self.max_queue_size.max(MIN_QUEUE_SIZE);
self.ffprobe_timeout = self.ffprobe_timeout.map(|timeout| timeout.max(MIN_DURATION_SECS));
let ffprobe_analyze_duration_secs = Self::parse_and_clamp_duration_with_required_unit(
&self.ffprobe_analyze_duration,
MIN_DURATION_SECS,
"ffprobe_analyze_duration",
)?;
self.ffprobe_analyze_duration = Self::canonicalize_seconds(ffprobe_analyze_duration_secs);
let ffprobe_probe_size_bytes = parse_size_base_2(&self.ffprobe_probe_size)
.map_err(|err| crate::error::info_err!("Invalid size for `ffprobe_probe_size`: {err}"))?
.max(1);
self.ffprobe_probe_size = Self::canonicalize_size_bytes(ffprobe_probe_size_bytes);
let ffprobe_live_analyze_duration_secs = Self::parse_and_clamp_duration_with_required_unit(
&self.ffprobe_live_analyze_duration,
MIN_DURATION_SECS,
"ffprobe_live_analyze_duration",
)?;
self.ffprobe_live_analyze_duration = Self::canonicalize_seconds(ffprobe_live_analyze_duration_secs);
let ffprobe_live_probe_size_bytes = parse_size_base_2(&self.ffprobe_live_probe_size)
.map_err(|err| crate::error::info_err!("Invalid size for `ffprobe_live_probe_size`: {err}"))?
.max(1);
self.ffprobe_live_probe_size = Self::canonicalize_size_bytes(ffprobe_live_probe_size_bytes);
self.clean();
Ok(())
}
fn parse_and_clamp_duration(value: &str, min_seconds: u64, field_name: &str) -> Result<u64, TuliproxError> {
let parsed = Self::parse_duration(value, field_name)?;
Ok(parsed.max(min_seconds))
}
fn parse_and_clamp_duration_with_required_unit(
value: &str,
min_seconds: u64,
field_name: &str,
) -> Result<u64, TuliproxError> {
let parsed = Self::parse_duration_with_required_unit(value, field_name)?;
Ok(parsed.max(min_seconds))
}
fn parse_duration_with_required_unit(value: &str, field_name: &str) -> Result<u64, TuliproxError> {
if value.parse::<u64>().is_ok() {
return info_err_res!(
"Invalid duration format for `{field_name}`: {value}. Use explicit unit suffix (`s`, `m`, `h`, `d`), e.g. `10s`."
);
}
Self::parse_duration(value, field_name)
}
fn parse_duration(value: &str, field_name: &str) -> Result<u64, TuliproxError> {
parse_duration_seconds(value, false)
.ok_or_else(|| crate::error::info_err!("Invalid duration format for `{field_name}`: {value}"))
}
fn canonicalize_seconds(seconds: u64) -> String {
if seconds.is_multiple_of(24 * 60 * 60) {
format!("{}d", seconds / (24 * 60 * 60))
} else if seconds.is_multiple_of(60 * 60) {
format!("{}h", seconds / (60 * 60))
} else if seconds.is_multiple_of(60) {
format!("{}m", seconds / 60)
} else {
format!("{seconds}s")
}
}
fn canonicalize_size_bytes(bytes: u64) -> String {
if bytes.is_multiple_of(1_099_511_627_776) {
format!("{}TB", bytes / 1_099_511_627_776)
} else if bytes.is_multiple_of(1_073_741_824) {
format!("{}GB", bytes / 1_073_741_824)
} else if bytes.is_multiple_of(1_048_576) {
format!("{}MB", bytes / 1_048_576)
} else if bytes.is_multiple_of(1_024) {
format!("{}KB", bytes / 1_024)
} else {
format!("{bytes}B")
}
}
}
#[cfg(test)]
mod tests {
use super::MetadataUpdateConfigDto;
#[test]
fn default_config_is_empty() {
let cfg = MetadataUpdateConfigDto::default();
assert!(cfg.is_empty());
}
#[test]
fn prepare_parses_duration_suffixes() {
let mut cfg = MetadataUpdateConfigDto {
queue_log_interval: "1m".to_string(),
progress_log_interval: "2h".to_string(),
probe_cooldown: "1d".to_string(),
..MetadataUpdateConfigDto::default()
};
cfg.prepare().expect("metadata update config should parse duration values");
assert_eq!(cfg.queue_log_interval, "1m");
assert_eq!(cfg.progress_log_interval, "2h");
assert_eq!(cfg.probe_cooldown, "1d");
}
#[test]
fn prepare_clamps_minimum_values() {
let mut cfg = MetadataUpdateConfigDto {
queue_log_interval: "0".to_string(),
max_attempts_resolve: 0,
max_attempts_probe: 0,
max_queue_size: 0,
ffprobe_timeout: Some(0),
ffprobe_analyze_duration: "0s".to_string(),
ffprobe_probe_size: "0".to_string(),
..MetadataUpdateConfigDto::default()
};
cfg.prepare().expect("metadata update config should clamp minimum values");
assert_eq!(cfg.queue_log_interval, "1s");
assert_eq!(cfg.max_attempts_resolve, 1);
assert_eq!(cfg.max_attempts_probe, 1);
assert_eq!(cfg.max_queue_size, 1);
assert_eq!(cfg.ffprobe_timeout, Some(1));
assert_eq!(cfg.ffprobe_analyze_duration, "1s");
assert_eq!(cfg.ffprobe_probe_size, "1B");
}
#[test]
fn prepare_rejects_invalid_duration_unit() {
let mut cfg =
MetadataUpdateConfigDto { queue_log_interval: "1w".to_string(), ..MetadataUpdateConfigDto::default() };
let result = cfg.prepare();
assert!(result.is_err(), "invalid duration unit must fail");
}
#[test]
fn prepare_canonicalizes_to_larger_units() {
let mut cfg = MetadataUpdateConfigDto {
probe_cooldown: "604800".to_string(),
worker_idle_timeout: "60".to_string(),
probe_retry_backoff_step_3: "3600".to_string(),
ffprobe_analyze_duration: "10s".to_string(),
ffprobe_probe_size: "10485760".to_string(),
ffprobe_live_analyze_duration: "5s".to_string(),
ffprobe_live_probe_size: "5242880".to_string(),
..MetadataUpdateConfigDto::default()
};
cfg.prepare().expect("metadata update config should canonicalize durations");
assert_eq!(cfg.probe_cooldown, "7d");
assert_eq!(cfg.worker_idle_timeout, "1m");
assert_eq!(cfg.probe_retry_backoff_step_3, "1h");
assert_eq!(cfg.ffprobe_analyze_duration, "10s");
assert_eq!(cfg.ffprobe_probe_size, "10MB");
assert_eq!(cfg.ffprobe_live_analyze_duration, "5s");
assert_eq!(cfg.ffprobe_live_probe_size, "5MB");
}
#[test]
fn prepare_rejects_ffprobe_duration_without_unit() {
let mut cfg = MetadataUpdateConfigDto {
ffprobe_analyze_duration: "10000000".to_string(),
..MetadataUpdateConfigDto::default()
};
let result = cfg.prepare();
assert!(result.is_err(), "numeric ffprobe analyze duration without unit must fail");
let err_text = result.unwrap_err().to_string();
assert!(err_text.contains("ffprobe_analyze_duration"));
}
}
+2
View File
@@ -13,6 +13,7 @@ mod ipcheck;
mod log;
pub mod macros;
mod messaging;
mod metadata_update;
mod panel_api;
mod paths;
mod pattern_template;
@@ -56,6 +57,7 @@ pub use ipcheck::*;
pub use library::*;
pub use log::*;
pub use messaging::*;
pub use metadata_update::*;
pub use panel_api::*;
pub use paths::*;
pub use pattern_template::*;
@@ -44,10 +44,6 @@ pub struct VideoConfigDto {
pub download: Option<VideoDownloadConfigDto>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub web_search: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub ffprobe_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ffprobe_timeout: Option<u64>,
}
impl VideoConfigDto {
@@ -55,8 +51,6 @@ impl VideoConfigDto {
(self.extensions.is_empty() || is_default_supported_video_extensions(&self.extensions))
&& is_blank_optional_str(self.web_search.as_deref())
&& (self.download.is_none() || self.download.as_ref().is_some_and(|d| d.is_empty()))
&& !self.ffprobe_enabled
&& self.ffprobe_timeout.is_none()
}
pub fn clean(&mut self) {