mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-10 18:02:17 +02:00
MetadatResolve hardening rerties after errors (#611)
* MetadatResolve hardening rerties after errors
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user