Feature/staged input and path improvements (#632)

* No template file not found error message
* Setup wizard improvmenets
* path improvements
* staged input fix
* path improvements
* Provider dns resolved ips now writing to a file instead of source.yml
* Playlist update hard kill after 1h
* matadata update tmdb fix
* Web UI fixes
* GeoIp update schedule
* batch:// prefix  for aliases
This commit is contained in:
euzu
2026-03-06 17:29:42 +01:00
committed by GitHub
parent c5632d0c98
commit 737d04d08b
82 changed files with 2506 additions and 1433 deletions
+45 -1
View File
@@ -1,8 +1,52 @@
use crate::utils::{get_default_web_root, is_blank_or_default_web_root};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigApiDto {
pub host: String,
pub port: u16,
#[serde(default, skip_serializing_if = "String::is_empty")]
#[serde(default, skip_serializing_if = "is_blank_or_default_web_root")]
pub web_root: String,
}
impl ConfigApiDto {
pub fn default_web_root() -> String { get_default_web_root() }
pub fn prepare(&mut self) {
if self.web_root.trim().is_empty() {
self.web_root = Self::default_web_root();
}
}
}
#[cfg(test)]
mod tests {
use super::ConfigApiDto;
#[test]
fn serialization_skips_default_web_root() {
let dto = ConfigApiDto { host: "0.0.0.0".to_string(), port: 8901, web_root: "./web".to_string() };
let serialized = serde_json::to_string(&dto).expect("api dto serialization should succeed");
assert!(!serialized.contains("\"web_root\""), "expected default web_root to be skipped, got: {serialized}");
}
#[test]
fn serialization_keeps_non_default_web_root() {
let dto = ConfigApiDto { host: "0.0.0.0".to_string(), port: 8901, web_root: "/srv/tuliprox/web".to_string() };
let serialized = serde_json::to_string(&dto).expect("api dto serialization should succeed");
assert!(
serialized.contains("\"web_root\""),
"expected non-default web_root to be persisted, got: {serialized}"
);
}
#[test]
fn prepare_sets_default_web_root_when_empty() {
let mut dto = ConfigApiDto { host: "0.0.0.0".to_string(), port: 8901, web_root: String::new() };
let expected_web_root = ConfigApiDto::default_web_root();
dto.prepare();
assert_eq!(dto.web_root, expected_web_root);
}
}
+200 -69
View File
@@ -6,55 +6,18 @@ use crate::{
WebUiConfigDto,
},
utils::{
default_connect_timeout_secs, default_supported_video_extensions, is_blank_optional_string,
is_default_connect_timeout_secs, is_false, CONFIG_PATH, MAPPING_FILE, TEMPLATE_FILE,
default_connect_timeout_secs, default_custom_stream_response_path, default_default_user_agent,
default_main_backup_dir, default_main_mapping_path, default_main_storage_dir, default_main_template_path,
default_main_user_config_dir, default_supported_video_extensions, is_blank_optional_string,
is_blank_or_default_backup_dir, is_blank_or_default_custom_stream_response_path,
is_blank_or_default_mapping_path, is_blank_or_default_storage_dir, is_blank_or_default_template_path,
is_blank_or_default_user_config_dir, is_default_connect_timeout_secs, is_false,
is_none_or_empty_metadata_update, is_none_or_empty_video, normalize_optional_config_file_path,
normalize_optional_dir, DEFAULT_BACKUP_DIR, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH, DEFAULT_STORAGE_DIR,
DEFAULT_USER_CONFIG_DIR, MAPPING_FILE, TEMPLATE_FILE,
},
};
pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16";
fn default_default_user_agent() -> Option<String> { Some(DEFAULT_USER_AGENT.to_string()) }
fn default_main_mapping_path() -> Option<String> { Some(format!("./{CONFIG_PATH}/{MAPPING_FILE}")) }
fn default_main_template_path() -> Option<String> { Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}")) }
fn is_default_config_file_path(value: &str, file_name: &str) -> bool {
let normalized = value.trim().replace('\\', "/");
let normalized = normalized.trim_start_matches("./");
normalized == file_name
|| normalized.rsplit_once('/').is_some_and(|(dir, file)| dir == CONFIG_PATH && file == file_name)
}
fn is_blank_or_default_mapping_path(path: &Option<String>) -> bool {
path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_config_file_path(value, MAPPING_FILE))
}
fn is_blank_or_default_template_path(path: &Option<String>) -> bool {
path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_config_file_path(value, TEMPLATE_FILE))
}
fn effective_main_mapping_path(path: Option<String>) -> Option<String> {
if is_blank_or_default_mapping_path(&path) {
default_main_mapping_path()
} else {
path
}
}
fn effective_main_template_path(path: Option<String>) -> Option<String> {
if is_blank_or_default_template_path(&path) {
default_main_template_path()
} else {
path
}
}
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)]
#[serde(deny_unknown_fields)]
@@ -62,18 +25,22 @@ pub struct ConfigDto {
#[serde(default, skip_serializing_if = "is_false")]
pub process_parallel: bool,
pub api: ConfigApiDto,
pub working_dir: String,
#[serde(default, alias = "working_dir", skip_serializing_if = "is_blank_or_default_storage_dir")]
pub storage_dir: Option<String>,
#[serde(default = "default_default_user_agent", skip_serializing_if = "is_blank_optional_string")]
pub default_user_agent: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default, skip_serializing_if = "is_blank_or_default_backup_dir")]
pub backup_dir: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default, skip_serializing_if = "is_blank_or_default_user_config_dir")]
pub user_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_or_default_mapping_path")]
pub mapping_path: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_or_default_template_path")]
pub template_path: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(
default = "default_custom_stream_response_path",
skip_serializing_if = "is_blank_or_default_custom_stream_response_path"
)]
pub custom_stream_response_path: Option<String>,
#[serde(default, skip_serializing_if = "is_none_or_empty_video")]
pub video: Option<VideoConfigDto>,
@@ -118,7 +85,7 @@ impl Default for ConfigDto {
Self {
process_parallel: false,
api: ConfigApiDto::default(),
working_dir: String::new(),
storage_dir: None,
default_user_agent: default_default_user_agent(),
backup_dir: None,
user_config_dir: None,
@@ -153,12 +120,13 @@ impl Default for ConfigDto {
pub struct MainConfigDto {
#[serde(default, skip_serializing_if = "is_false")]
pub process_parallel: bool,
pub working_dir: String,
#[serde(default = "default_main_storage_dir", skip_serializing_if = "is_blank_or_default_storage_dir")]
pub storage_dir: Option<String>,
#[serde(default = "default_default_user_agent", skip_serializing_if = "is_blank_optional_string")]
pub default_user_agent: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default = "default_main_backup_dir", skip_serializing_if = "is_blank_or_default_backup_dir")]
pub backup_dir: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default = "default_main_user_config_dir", skip_serializing_if = "is_blank_or_default_user_config_dir")]
pub user_config_dir: Option<String>,
#[serde(default = "default_main_mapping_path", skip_serializing_if = "is_blank_or_default_mapping_path")]
pub mapping_path: Option<String>,
@@ -187,10 +155,10 @@ impl Default for MainConfigDto {
MainConfigDto {
process_parallel: false,
disk_based_processing: false,
working_dir: String::new(),
storage_dir: default_main_storage_dir(),
default_user_agent: default_default_user_agent(),
backup_dir: None,
user_config_dir: None,
backup_dir: default_main_backup_dir(),
user_config_dir: default_main_user_config_dir(),
mapping_path: default_main_mapping_path(),
template_path: default_main_template_path(),
custom_stream_response_path: None,
@@ -209,12 +177,12 @@ impl From<&ConfigDto> for MainConfigDto {
Self {
process_parallel: config.process_parallel,
disk_based_processing: config.disk_based_processing,
working_dir: config.working_dir.clone(),
storage_dir: config.storage_dir.clone(),
default_user_agent: config.default_user_agent.clone(),
backup_dir: config.backup_dir.clone(),
user_config_dir: config.user_config_dir.clone(),
mapping_path: effective_main_mapping_path(config.mapping_path.clone()),
template_path: effective_main_template_path(config.template_path.clone()),
mapping_path: config.mapping_path.clone(),
template_path: config.template_path.clone(),
custom_stream_response_path: config.custom_stream_response_path.clone(),
user_access_control: config.user_access_control,
connect_timeout_secs: config.connect_timeout_secs,
@@ -249,9 +217,26 @@ pub struct HdHomeRunDeviceOverview {
impl ConfigDto {
pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> {
self.api.prepare();
if is_blank_optional_string(&self.default_user_agent) {
self.default_user_agent = default_default_user_agent();
}
if is_blank_or_default_storage_dir(&self.storage_dir) {
self.storage_dir = default_main_storage_dir();
}
if is_blank_or_default_backup_dir(&self.backup_dir) {
self.backup_dir = default_main_backup_dir();
}
if is_blank_or_default_user_config_dir(&self.user_config_dir) {
self.user_config_dir = default_main_user_config_dir();
}
if is_blank_or_default_mapping_path(&self.mapping_path) {
self.mapping_path = default_main_mapping_path();
}
if is_blank_or_default_template_path(&self.template_path) {
self.template_path = default_main_template_path();
}
if let Some(mins) = self.sleep_timer_mins {
if mins == 0 {
@@ -268,7 +253,7 @@ impl ConfigDto {
self.prepare_metadata_update_config()?;
if let Some(reverse_proxy) = self.reverse_proxy.as_mut() {
reverse_proxy.prepare(&self.working_dir)?;
reverse_proxy.prepare(self.storage_dir.as_deref().unwrap_or_default())?;
}
if let Some(proxy) = &mut self.proxy {
proxy.prepare()?;
@@ -280,6 +265,9 @@ impl ConfigDto {
if let Some(messaging) = &mut self.messaging {
messaging.prepare(include_computed)?;
}
if let Some(library) = &mut self.library {
library.playlist.prepare();
}
Ok(())
}
@@ -361,13 +349,14 @@ impl ConfigDto {
pub fn update_from_main_config(&mut self, main_config: &MainConfigDto) {
self.process_parallel = main_config.process_parallel;
self.disk_based_processing = main_config.disk_based_processing;
self.working_dir = main_config.working_dir.clone();
self.storage_dir = normalize_optional_dir(&main_config.storage_dir, DEFAULT_STORAGE_DIR);
self.default_user_agent = main_config.default_user_agent.clone();
self.backup_dir = main_config.backup_dir.clone();
self.user_config_dir = main_config.user_config_dir.clone();
self.mapping_path = main_config.mapping_path.clone();
self.template_path = main_config.template_path.clone();
self.custom_stream_response_path = main_config.custom_stream_response_path.clone();
self.backup_dir = normalize_optional_dir(&main_config.backup_dir, DEFAULT_BACKUP_DIR);
self.user_config_dir = normalize_optional_dir(&main_config.user_config_dir, DEFAULT_USER_CONFIG_DIR);
self.mapping_path = normalize_optional_config_file_path(&main_config.mapping_path, MAPPING_FILE);
self.template_path = normalize_optional_config_file_path(&main_config.template_path, TEMPLATE_FILE);
self.custom_stream_response_path =
normalize_optional_dir(&main_config.custom_stream_response_path, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH);
self.user_access_control = main_config.user_access_control;
self.connect_timeout_secs = main_config.connect_timeout_secs;
self.sleep_timer_mins = main_config.sleep_timer_mins;
@@ -386,7 +375,7 @@ impl ConfigDto {
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::default_supported_video_extensions;
use crate::utils::{default_supported_video_extensions, CONFIG_PATH};
use serde_json::json;
#[test]
@@ -425,6 +414,104 @@ mod tests {
assert!(serialized.contains("\"video\""), "expected video field, got: {serialized}");
}
#[test]
fn serializing_skips_default_storage_backup_and_user_config_dirs() {
let cfg = ConfigDto {
storage_dir: Some(DEFAULT_STORAGE_DIR.to_string()),
backup_dir: Some(DEFAULT_BACKUP_DIR.to_string()),
user_config_dir: Some(DEFAULT_USER_CONFIG_DIR.to_string()),
..ConfigDto::default()
};
let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed");
assert!(
!serialized.contains("\"storage_dir\""),
"expected no storage_dir field for default value, got: {serialized}"
);
assert!(
!serialized.contains("\"backup_dir\""),
"expected no backup_dir field for default value, got: {serialized}"
);
assert!(
!serialized.contains("\"user_config_dir\""),
"expected no user_config_dir field for default value, got: {serialized}"
);
}
#[test]
fn serializing_keeps_non_default_storage_and_backup_dirs() {
let cfg = ConfigDto {
storage_dir: Some("custom-storage".to_string()),
backup_dir: Some("custom-backup".to_string()),
user_config_dir: Some("custom-user-config".to_string()),
..ConfigDto::default()
};
let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed");
assert!(
serialized.contains("\"storage_dir\""),
"expected storage_dir field for non-default value, got: {serialized}"
);
assert!(
serialized.contains("\"backup_dir\""),
"expected backup_dir field for non-default value, got: {serialized}"
);
assert!(
serialized.contains("\"user_config_dir\""),
"expected user_config_dir field for non-default value, got: {serialized}"
);
}
#[test]
fn main_config_from_applies_default_storage_backup_and_user_config_dirs() {
let mut cfg = ConfigDto::default();
cfg.prepare(false).expect("prepare should succeed");
let main = MainConfigDto::from(&cfg);
assert_eq!(main.storage_dir.as_deref(), Some(DEFAULT_STORAGE_DIR));
assert_eq!(main.backup_dir.as_deref(), Some(DEFAULT_BACKUP_DIR));
assert_eq!(main.user_config_dir.as_deref(), Some(DEFAULT_USER_CONFIG_DIR));
assert_eq!(main.mapping_path.as_deref(), Some(format!("./{CONFIG_PATH}/{MAPPING_FILE}").as_str()));
assert_eq!(main.template_path.as_deref(), Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}").as_str()));
}
#[test]
fn update_from_main_config_omits_default_optional_paths() {
let mut cfg = ConfigDto::default();
let main = MainConfigDto {
storage_dir: Some(DEFAULT_STORAGE_DIR.to_string()),
backup_dir: Some(DEFAULT_BACKUP_DIR.to_string()),
user_config_dir: Some(DEFAULT_USER_CONFIG_DIR.to_string()),
mapping_path: Some(format!("./{CONFIG_PATH}/{MAPPING_FILE}")),
template_path: Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}")),
..MainConfigDto::default()
};
cfg.update_from_main_config(&main);
assert!(cfg.storage_dir.is_none());
assert!(cfg.backup_dir.is_none());
assert!(cfg.user_config_dir.is_none());
assert!(cfg.mapping_path.is_none());
assert!(cfg.template_path.is_none());
}
#[test]
fn prepare_sets_default_optional_paths() {
let mut cfg = ConfigDto {
storage_dir: None,
backup_dir: None,
user_config_dir: None,
mapping_path: None,
template_path: None,
..ConfigDto::default()
};
cfg.prepare(false).expect("prepare should succeed");
assert_eq!(cfg.storage_dir.as_deref(), Some(DEFAULT_STORAGE_DIR));
assert_eq!(cfg.backup_dir.as_deref(), Some(DEFAULT_BACKUP_DIR));
assert_eq!(cfg.user_config_dir.as_deref(), Some(DEFAULT_USER_CONFIG_DIR));
assert_eq!(cfg.mapping_path.as_deref(), Some(format!("./{CONFIG_PATH}/{MAPPING_FILE}").as_str()));
assert_eq!(cfg.template_path.as_deref(), Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}").as_str()));
}
#[test]
fn deserializing_rejects_legacy_video_ffprobe_fields() {
let raw = json!({
@@ -433,7 +520,7 @@ mod tests {
"port": 8901,
"web_root": "./web"
},
"working_dir": ".",
"storage_dir": ".",
"video": {
"extensions": ["mp4"],
"ffprobe_enabled": true
@@ -445,4 +532,48 @@ mod tests {
let err = result.unwrap_err().to_string();
assert!(err.contains("ffprobe_enabled"), "unexpected error text: {err}");
}
#[test]
fn deserializing_rejects_legacy_data_dir_alias() {
let raw = json!({
"api": {
"host": "127.0.0.1",
"port": 8901,
"web_root": "./web"
},
"data_dir": "."
});
let result: Result<ConfigDto, _> = serde_json::from_value(raw);
assert!(result.is_err(), "data_dir should not deserialize");
}
#[test]
fn deserializing_accepts_legacy_working_dir_alias() {
let raw = json!({
"api": {
"host": "127.0.0.1",
"port": 8901,
"web_root": "./web"
},
"working_dir": "."
});
let cfg: ConfigDto = serde_json::from_value(raw).expect("working_dir should deserialize as legacy alias");
assert_eq!(cfg.storage_dir.as_deref(), Some("."));
}
#[test]
fn deserializing_accepts_missing_storage_dir() {
let raw = json!({
"api": {
"host": "127.0.0.1",
"port": 8901,
"web_root": "./web"
}
});
let cfg: ConfigDto = serde_json::from_value(raw).expect("missing storage_dir should deserialize");
assert!(cfg.storage_dir.is_none());
}
}
+45 -17
View File
@@ -1,10 +1,11 @@
use crate::{
error::TuliproxError,
info_err_res,
utils::{is_blank_optional_str, is_blank_optional_string, parse_size_base_2},
utils::{
is_blank_optional_str, is_blank_optional_string, is_blank_or_default_cache_dir, parse_size_base_2,
DEFAULT_CACHE_DIR,
},
};
use path_clean::PathClean;
use std::path::PathBuf;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
@@ -13,27 +14,21 @@ pub struct CacheConfigDto {
pub enabled: bool,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub size: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub dir: Option<String>,
#[serde(default, alias = "dir", skip_serializing_if = "is_blank_or_default_cache_dir")]
pub directory: Option<String>,
}
impl CacheConfigDto {
pub fn is_empty(&self) -> bool {
!self.enabled && is_blank_optional_str(self.size.as_deref()) && is_blank_optional_str(self.dir.as_deref())
!self.enabled && is_blank_optional_str(self.size.as_deref()) && is_blank_optional_str(self.directory.as_deref())
}
pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> {
pub(crate) fn prepare(&mut self, _storage_dir: &str) -> Result<(), TuliproxError> {
if self.enabled {
let work_path = PathBuf::from(working_dir);
match self.dir.as_ref() {
None => self.dir = Some(work_path.join("cache").to_string_lossy().to_string()),
Some(work_dir) => {
let mut cache_dir = work_dir.to_string();
if PathBuf::from(&cache_dir).is_relative() {
cache_dir = work_path.join(&cache_dir).clean().to_string_lossy().to_string();
}
self.dir = Some(cache_dir.to_string());
}
if is_blank_or_default_cache_dir(&self.directory) {
self.directory = Some(DEFAULT_CACHE_DIR.to_string());
} else if let Some(dir) = self.directory.as_ref() {
self.directory = Some(dir.trim().to_string());
}
if let Some(val) = self.size.as_ref() {
@@ -50,3 +45,36 @@ impl CacheConfigDto {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_sets_default_cache_dir_when_enabled_and_missing() {
let mut cache = CacheConfigDto { enabled: true, directory: None, size: None };
cache.prepare("storage").expect("prepare should succeed");
assert_eq!(cache.directory.as_deref(), Some(DEFAULT_CACHE_DIR));
}
#[test]
fn prepare_keeps_custom_cache_dir_when_enabled() {
let mut cache = CacheConfigDto { enabled: true, directory: Some("custom-cache".to_string()), size: None };
cache.prepare("storage").expect("prepare should succeed");
assert_eq!(cache.directory.as_deref(), Some("custom-cache"));
}
#[test]
fn serializing_skips_default_cache_dir() {
let cache = CacheConfigDto { enabled: true, directory: Some(DEFAULT_CACHE_DIR.to_string()), size: None };
let serialized = serde_json::to_string(&cache).expect("cache serialization should succeed");
assert!(!serialized.contains("\"directory\""), "expected no dir field for default value, got: {serialized}");
}
#[test]
fn serializing_keeps_non_default_cache_dir() {
let cache = CacheConfigDto { enabled: true, directory: Some("custom-cache".to_string()), size: None };
let serialized = serde_json::to_string(&cache).expect("cache serialization should succeed");
assert!(serialized.contains("\"directory\""), "expected dir field for custom value, got: {serialized}");
}
}
+13 -28
View File
@@ -10,11 +10,10 @@ use crate::{
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_i16, 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,
serialize_option_vec_flow_map_items, trim_last_slash, Internable, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
},
};
use enum_iterator::Sequence;
use indexmap::IndexMap;
use log::warn;
use std::{
collections::{HashMap, HashSet},
@@ -511,7 +510,7 @@ impl ConfigInputDto {
self.persist = get_trimmed_string(self.persist.as_deref());
if self.url.starts_with("file://") {
if self.url.starts_with(BATCH_SCHEME_PREFIX) {
match self.input_type {
InputType::M3u => {
self.input_type = InputType::M3uBatch;
@@ -524,7 +523,10 @@ impl ConfigInputDto {
} else if self.url.starts_with(PROVIDER_SCHEME_PREFIX)
&& matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch)
{
return info_err_res!("input type {} does not support provider:// URLs for batch definitions; use a local CSV path or file:// URL", self.input_type);
return info_err_res!(
"input type {} does not support provider:// URLs for batch definitions; use batch:// URL",
self.input_type
);
}
check_provider_scheme_url!(self.url, provider_names);
@@ -654,7 +656,10 @@ impl ConfigInputDto {
if self.url.starts_with(PROVIDER_SCHEME_PREFIX)
&& matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch)
{
return info_err_res!("input type {} does not support provider:// URLs for batch definitions; use a local CSV path or file:// URL", self.input_type);
return info_err_res!(
"input type {} does not support provider:// URLs for batch definitions; use batch:// URL",
self.input_type
);
}
Ok(())
}
@@ -791,8 +796,6 @@ pub struct ProviderDnsDto {
pub keep_vhost: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overrides: Option<HashMap<String, Vec<IpAddr>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved: Option<IndexMap<String, Vec<IpAddr>>>,
#[serde(default, skip_serializing_if = "is_default_on_resolve_error")]
pub on_resolve_error: OnResolveErrorPolicy,
#[serde(default, skip_serializing_if = "is_default_on_connect_error")]
@@ -809,7 +812,6 @@ impl Default for ProviderDnsDto {
schemes: None,
keep_vhost: false,
overrides: None,
resolved: None,
on_resolve_error: OnResolveErrorPolicy::default(),
on_connect_error: OnConnectErrorPolicy::default(),
}
@@ -1010,29 +1012,12 @@ mod tests {
assert_eq!(overrides["example.com"].len(), 1);
}
#[test]
fn test_provider_dns_resolved_deserializes() {
let json = r#"{
"name":"p1",
"urls":["http://example.com"],
"dns":{
"enabled":true,
"resolved":{"example.com":["203.0.113.10"]}
}
}"#;
let dto: ConfigProviderDto = serde_json::from_str(json).expect("provider json should parse");
let dns = dto.dns.expect("dns should be present");
let resolved = dns.resolved.expect("resolved must be deserialized");
assert_eq!(resolved.get("example.com"), Some(&vec!["203.0.113.10".parse::<IpAddr>().expect("valid ip")]));
}
#[test]
fn prepare_switches_xtream_to_xtream_batch_when_alias_exists() {
let mut dto = ConfigInputDto {
name: "input_alias".intern(),
input_type: InputType::Xtream,
url: "file:///tmp/input_alias.csv".to_string(),
url: "batch:///tmp/input_alias.csv".to_string(),
aliases: Some(vec![ConfigInputAliasDto {
id: 1,
name: "alias_1".intern(),
@@ -1046,7 +1031,7 @@ mod tests {
};
dto.prepare_type().expect("prepare type should succeed");
dto.prepare(0, true, &HashSet::new()).expect("prepare should succeed and infer batch type");
dto.prepare(0, true, &HashSet::new()).expect("prepare should succeed and infer batch type from batch:// URL");
assert_eq!(dto.input_type, InputType::XtreamBatch);
}
@@ -1069,6 +1054,6 @@ mod tests {
};
let err = dto.prepare(0, true, &HashSet::new()).expect_err("prepare must reject provider:// for batch input");
assert!(err.to_string().contains("does not support provider:// URLs"));
assert!(err.to_string().contains("does not support provider:// URLs"), "Error: {err}");
}
}
+32 -2
View File
@@ -106,7 +106,7 @@ pub enum LibraryMetadataFormat {
Nfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LibraryPlaylistConfigDto {
#[serde(default = "default_movie_category")]
@@ -119,7 +119,7 @@ impl LibraryPlaylistConfigDto {
pub fn is_empty(&self) -> bool {
self.movie_category == default_movie_category() && self.series_category == default_series_category()
}
pub fn clean(&mut self) {
pub fn prepare(&mut self) {
if self.movie_category.trim().is_empty() {
self.movie_category = default_movie_category();
}
@@ -127,10 +127,19 @@ impl LibraryPlaylistConfigDto {
self.series_category = default_series_category();
}
}
pub fn clean(&mut self) { self.prepare(); }
}
impl Default for LibraryPlaylistConfigDto {
fn default() -> Self {
Self { movie_category: default_movie_category(), series_category: default_series_category() }
}
}
impl LibraryConfigDto {
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
self.playlist.prepare();
// Validate enabled state
if self.enabled && self.scan_directories.is_empty() {
return info_err_res!("Library enabled but no scan_directories configured");
@@ -146,3 +155,24 @@ impl LibraryConfigDto {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{default_movie_category, default_series_category, LibraryPlaylistConfigDto};
#[test]
fn playlist_default_uses_default_categories() {
let playlist = LibraryPlaylistConfigDto::default();
assert_eq!(playlist.movie_category, default_movie_category());
assert_eq!(playlist.series_category, default_series_category());
}
#[test]
fn playlist_prepare_sets_default_categories_when_empty() {
let mut playlist =
LibraryPlaylistConfigDto { movie_category: String::new(), series_category: " ".to_string() };
playlist.prepare();
assert_eq!(playlist.movie_category, default_movie_category());
assert_eq!(playlist.series_category, default_series_category());
}
}
+4
View File
@@ -2,7 +2,11 @@ use crate::utils::{is_blank_optional_string, is_empty_optional_vec};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigPaths {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub home_path: String,
pub config_path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub storage_path: String,
pub config_file_path: String,
pub sources_file_path: String,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
+2 -2
View File
@@ -89,7 +89,7 @@ impl ReverseProxyConfigDto {
}
}
pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> {
pub(crate) fn prepare(&mut self, storage_dir: &str) -> Result<(), TuliproxError> {
self.rewrite_secret = self.rewrite_secret.trim().to_string();
if !self.resource_rewrite_disabled {
if self.rewrite_secret.is_empty() {
@@ -106,7 +106,7 @@ impl ReverseProxyConfigDto {
warn!("The cache is disabled because resource rewrite is disabled");
cache.enabled = false;
}
cache.prepare(working_dir)?;
cache.prepare(storage_dir)?;
}
if let Some(rate_limit) = self.rate_limit.as_mut() {
+88 -1
View File
@@ -3,6 +3,51 @@ pub enum ScheduleTaskType {
#[default]
PlaylistUpdate,
LibraryScan,
GeoIpUpdate,
}
const fn default_schedule_task_type() -> ScheduleTaskType { ScheduleTaskType::PlaylistUpdate }
fn parse_schedule_task_type(value: &str) -> Option<ScheduleTaskType> {
match value.trim().to_ascii_lowercase().as_str() {
"playlistupdate" | "playlist_update" | "playlist-update" | "update" => Some(ScheduleTaskType::PlaylistUpdate),
"libraryscan" | "library_scan" | "library-scan" | "scan" => Some(ScheduleTaskType::LibraryScan),
"geoipupdate" | "geo_ip_update" | "geoip_update" | "geoip-update" | "geoip" => {
Some(ScheduleTaskType::GeoIpUpdate)
}
_ => None,
}
}
fn deserialize_schedule_task_type<'de, D>(deserializer: D) -> Result<ScheduleTaskType, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum CompatTaskType {
Enum(ScheduleTaskType),
String(String),
}
let value = Option::<CompatTaskType>::deserialize(deserializer)?;
match value {
None => Ok(default_schedule_task_type()),
Some(CompatTaskType::Enum(task_type)) => Ok(task_type),
Some(CompatTaskType::String(raw)) => {
if raw.trim().is_empty() {
Ok(default_schedule_task_type())
} else if let Some(task_type) = parse_schedule_task_type(&raw) {
Ok(task_type)
} else {
Err(serde::de::Error::custom(format!(
"invalid schedule type '{raw}', expected one of: PlaylistUpdate, LibraryScan, GeoIpUpdate"
)))
}
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
@@ -10,8 +55,50 @@ pub enum ScheduleTaskType {
pub struct ScheduleConfigDto {
#[serde(default)]
pub schedule: String,
#[serde(default, rename = "type")]
#[serde(
default = "default_schedule_task_type",
rename = "type",
alias = "task_type",
deserialize_with = "deserialize_schedule_task_type"
)]
pub task_type: ScheduleTaskType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub targets: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::{ScheduleConfigDto, ScheduleTaskType};
#[test]
fn defaults_to_playlist_update_when_type_is_missing() {
let dto: ScheduleConfigDto = serde_saphyr::from_str("schedule: \"0 0 * * * * *\"\ntargets: [\"main\"]")
.expect("schedule dto should parse");
assert_eq!(dto.task_type, ScheduleTaskType::PlaylistUpdate);
}
#[test]
fn defaults_to_playlist_update_when_type_is_null_or_empty() {
let null_type: ScheduleConfigDto =
serde_saphyr::from_str("schedule: \"0 0 * * * * *\"\ntype: ~").expect("null type should parse");
let empty_type: ScheduleConfigDto =
serde_saphyr::from_str("schedule: \"0 0 * * * * *\"\ntype: \"\"").expect("empty type should parse");
assert_eq!(null_type.task_type, ScheduleTaskType::PlaylistUpdate);
assert_eq!(empty_type.task_type, ScheduleTaskType::PlaylistUpdate);
}
#[test]
fn accepts_legacy_task_type_aliases() {
let legacy: ScheduleConfigDto = serde_saphyr::from_str("schedule: \"0 0 * * * * *\"\ntask_type: scan")
.expect("legacy task_type alias should parse");
assert_eq!(legacy.task_type, ScheduleTaskType::LibraryScan);
}
#[test]
fn accepts_geoip_update_aliases() {
let legacy: ScheduleConfigDto =
serde_saphyr::from_str("schedule: \"0 0 * * * * *\"\ntype: geoip").expect("geoip alias should parse");
assert_eq!(legacy.task_type, ScheduleTaskType::GeoIpUpdate);
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::{
model::DEFAULT_USER_AGENT,
utils::{default_as_true, default_trakt_fuzzy_threshold, is_true, TRAKT_API_KEY, TRAKT_API_URL, TRAKT_API_VERSION},
use crate::utils::{
default_as_true, default_trakt_fuzzy_threshold, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, TRAKT_API_URL,
TRAKT_API_VERSION,
};
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
+118 -7
View File
@@ -1,10 +1,10 @@
use crate::{
error::TuliproxError,
info_err_res,
model::DEFAULT_USER_AGENT,
utils::{
default_supported_video_extensions, is_blank_optional_str, is_blank_optional_string,
is_default_supported_video_extensions, is_false,
default_download_dir, default_episode_pattern, default_supported_video_extensions, is_blank_optional_str,
is_blank_optional_string, is_blank_or_default_download_dir, is_blank_or_default_episode_pattern,
is_default_supported_video_extensions, is_false, DEFAULT_USER_AGENT,
},
};
use std::{borrow::BorrowMut, collections::HashMap};
@@ -14,12 +14,12 @@ use std::{borrow::BorrowMut, collections::HashMap};
pub struct VideoDownloadConfigDto {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default = "default_download_dir", skip_serializing_if = "is_blank_or_default_download_dir")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub organize_into_directories: bool,
// TODO use ptt
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default = "default_episode_pattern", skip_serializing_if = "is_blank_or_default_episode_pattern")]
pub episode_pattern: Option<String>,
}
@@ -27,8 +27,8 @@ impl VideoDownloadConfigDto {
pub fn is_empty(&self) -> bool {
!self.organize_into_directories
&& self.headers.is_empty()
&& is_blank_optional_str(self.directory.as_deref())
&& is_blank_optional_str(self.episode_pattern.as_deref())
&& is_blank_or_default_download_dir(&self.directory)
&& is_blank_or_default_episode_pattern(&self.episode_pattern)
}
}
@@ -69,11 +69,23 @@ impl VideoConfigDto {
match &mut self.download {
None => {}
Some(downl) => {
if is_blank_or_default_download_dir(&downl.directory) {
downl.directory = default_download_dir();
} else if let Some(directory) = downl.directory.as_ref() {
downl.directory = Some(directory.trim().to_string());
}
if downl.headers.is_empty() {
downl.headers.borrow_mut().insert("Accept".to_string(), "video/*".to_string());
downl.headers.borrow_mut().insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string());
}
if is_blank_or_default_episode_pattern(&downl.episode_pattern) {
downl.episode_pattern = default_episode_pattern();
} else if let Some(episode_pattern) = downl.episode_pattern.as_ref() {
downl.episode_pattern = Some(episode_pattern.trim().to_string());
}
if let Some(episode_pattern) = &downl.episode_pattern {
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(episode_pattern) {
return info_err_res!("can't parse regex: {episode_pattern} {err}");
@@ -84,3 +96,102 @@ impl VideoConfigDto {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::DEFAULT_DOWNLOAD_DIR;
#[test]
fn prepare_sets_default_download_dir_when_missing() {
let mut video = VideoConfigDto {
extensions: Vec::new(),
download: Some(VideoDownloadConfigDto {
headers: HashMap::new(),
directory: None,
organize_into_directories: false,
episode_pattern: None,
}),
web_search: None,
};
video.prepare().expect("prepare should succeed");
let download = video.download.expect("download should exist");
assert_eq!(download.directory.as_deref(), Some(DEFAULT_DOWNLOAD_DIR));
}
#[test]
fn prepare_sets_default_episode_pattern_when_missing() {
let mut video = VideoConfigDto {
extensions: Vec::new(),
download: Some(VideoDownloadConfigDto {
headers: HashMap::new(),
directory: None,
organize_into_directories: false,
episode_pattern: None,
}),
web_search: None,
};
video.prepare().expect("prepare should succeed");
let download = video.download.expect("download should exist");
assert!(download.episode_pattern.is_some(), "expected default episode pattern to be set");
}
#[test]
fn prepare_keeps_custom_download_dir() {
let mut video = VideoConfigDto {
extensions: Vec::new(),
download: Some(VideoDownloadConfigDto {
headers: HashMap::new(),
directory: Some("custom-downloads".to_string()),
organize_into_directories: false,
episode_pattern: None,
}),
web_search: None,
};
video.prepare().expect("prepare should succeed");
let download = video.download.expect("download should exist");
assert_eq!(download.directory.as_deref(), Some("custom-downloads"));
}
#[test]
fn serializing_skips_default_download_dir() {
let download = VideoDownloadConfigDto {
headers: HashMap::new(),
directory: Some(DEFAULT_DOWNLOAD_DIR.to_string()),
organize_into_directories: false,
episode_pattern: None,
};
let serialized = serde_json::to_string(&download).expect("download serialization should succeed");
assert!(
!serialized.contains("\"directory\""),
"expected no directory field for default value, got: {serialized}"
);
}
#[test]
fn serializing_keeps_custom_download_dir() {
let download = VideoDownloadConfigDto {
headers: HashMap::new(),
directory: Some("custom-downloads".to_string()),
organize_into_directories: false,
episode_pattern: None,
};
let serialized = serde_json::to_string(&download).expect("download serialization should succeed");
assert!(serialized.contains("\"directory\""), "expected directory field for custom value, got: {serialized}");
}
#[test]
fn serializing_skips_default_episode_pattern() {
let download = VideoDownloadConfigDto {
headers: HashMap::new(),
directory: None,
organize_into_directories: false,
episode_pattern: default_episode_pattern(),
};
let serialized = serde_json::to_string(&download).expect("download serialization should succeed");
assert!(
!serialized.contains("\"episode_pattern\""),
"expected no episode_pattern field for default value, got: {serialized}"
);
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
use crate::utils::{
default_as_true, default_token_ttl_mins, is_blank_optional_str, is_blank_optional_string,
default_as_true, default_token_ttl_mins, default_user_file_path, is_blank_or_default_user_file_path,
is_default_token_ttl_mins, is_true,
};
@@ -12,7 +12,7 @@ pub struct WebAuthConfigDto {
pub secret: String,
#[serde(default = "default_token_ttl_mins", skip_serializing_if = "is_default_token_ttl_mins")]
pub token_ttl_mins: u32,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
#[serde(default = "default_user_file_path", skip_serializing_if = "is_blank_or_default_user_file_path")]
pub userfile: Option<String>,
}
@@ -35,6 +35,6 @@ impl WebAuthConfigDto {
&& self.token_ttl_mins == empty.token_ttl_mins
&& self.issuer.trim().is_empty()
&& self.secret.trim().is_empty()
&& is_blank_optional_str(self.userfile.as_deref())
&& is_blank_or_default_user_file_path(&self.userfile)
}
}