feat: add media-server input foundations (#742)

* feat: add remote media input foundations
* fixed macro variable names
* Added todo comment for source editor sidebar
This commit is contained in:
Juanjo Presa
2026-05-04 20:22:51 +02:00
committed by GitHub
parent ef8350a68c
commit 8a2e379f6d
39 changed files with 3376 additions and 56 deletions
+705 -3
View File
@@ -8,9 +8,10 @@ use crate::{
arc_str_serde, arc_str_vec_serde, default_as_true, default_probe_delay_secs, default_probe_live_interval,
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_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, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false,
is_non_blank_optional_string, 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, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX,
},
};
use enum_iterator::Sequence;
@@ -94,6 +95,12 @@ pub enum InputType {
XtreamBatch,
#[serde(rename = "library")]
Library,
#[serde(rename = "emby")]
Emby,
#[serde(rename = "jellyfin")]
Jellyfin,
#[serde(rename = "plex")]
Plex,
}
impl InputType {
@@ -102,10 +109,17 @@ impl InputType {
const M3U_BATCH: &'static str = "m3u_batch";
const XTREAM_BATCH: &'static str = "xtream_batch";
const LIBRARY: &'static str = "library";
const EMBY: &'static str = "emby";
const JELLYFIN: &'static str = "jellyfin";
const PLEX: &'static str = "plex";
pub fn is_xtream(&self) -> bool { matches!(self, Self::Xtream | Self::XtreamBatch) }
pub fn is_m3u(&self) -> bool { matches!(self, Self::M3u | Self::M3uBatch) }
pub fn uses_standard_input_url(&self) -> bool {
matches!(self, Self::M3u | Self::Xtream | Self::M3uBatch | Self::XtreamBatch)
}
pub fn is_library(&self) -> bool { matches!(self, Self::Library) }
pub fn is_media_server(&self) -> bool { matches!(self, Self::Emby | Self::Jellyfin | Self::Plex) }
}
impl Display for InputType {
@@ -119,6 +133,9 @@ impl Display for InputType {
Self::M3uBatch => Self::M3U_BATCH,
Self::XtreamBatch => Self::XTREAM_BATCH,
Self::Library => Self::LIBRARY,
Self::Emby => Self::EMBY,
Self::Jellyfin => Self::JELLYFIN,
Self::Plex => Self::PLEX,
}
)
}
@@ -138,6 +155,12 @@ impl FromStr for InputType {
Ok(Self::XtreamBatch)
} else if s.eq(Self::LIBRARY) {
Ok(Self::Library)
} else if s.eq(Self::EMBY) {
Ok(Self::Emby)
} else if s.eq(Self::JELLYFIN) {
Ok(Self::Jellyfin)
} else if s.eq(Self::PLEX) {
Ok(Self::Plex)
} else {
Err(TuliproxError::ConfigInput(format!("Unknown InputType: {}", s)))
}
@@ -313,6 +336,304 @@ impl ConfigInputOptionsDto {
}
}
pub const fn default_media_server_catalog_page_size() -> u16 { 100 }
pub const fn default_media_server_catalog_request_delay_ms() -> u64 { 250 }
pub const fn is_default_media_server_catalog_page_size(value: &u16) -> bool {
*value == default_media_server_catalog_page_size()
}
pub const fn is_default_media_server_catalog_request_delay_ms(value: &u64) -> bool {
*value == default_media_server_catalog_request_delay_ms()
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
pub enum MediaServerCatalogRefreshModeDto {
#[serde(rename = "manual")]
#[default]
Manual,
#[serde(rename = "scheduled")]
Scheduled,
}
pub fn is_default_media_server_catalog_refresh_mode(value: &MediaServerCatalogRefreshModeDto) -> bool {
*value == MediaServerCatalogRefreshModeDto::default()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MediaServerCatalogConfigDto {
#[serde(default, skip_serializing_if = "is_default_media_server_catalog_refresh_mode")]
pub refresh_mode: MediaServerCatalogRefreshModeDto,
#[serde(default, skip_serializing_if = "is_false")]
pub refresh_on_startup: bool,
#[serde(
default = "default_media_server_catalog_page_size",
skip_serializing_if = "is_default_media_server_catalog_page_size"
)]
pub page_size: u16,
#[serde(
default = "default_media_server_catalog_request_delay_ms",
skip_serializing_if = "is_default_media_server_catalog_request_delay_ms"
)]
pub request_delay_ms: u64,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub include_media_sources: bool,
#[serde(default, alias = "include_file_paths", skip_serializing_if = "is_false")]
pub include_paths: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub include_user_state: bool,
}
impl Default for MediaServerCatalogConfigDto {
fn default() -> Self {
Self {
refresh_mode: MediaServerCatalogRefreshModeDto::default(),
refresh_on_startup: false,
page_size: default_media_server_catalog_page_size(),
request_delay_ms: default_media_server_catalog_request_delay_ms(),
include_media_sources: default_as_true(),
include_paths: false,
include_user_state: false,
}
}
}
impl MediaServerCatalogConfigDto {
pub fn is_default(&self) -> bool { self == &Self::default() }
pub fn prepare(&self, input_name: &Arc<str>) -> Result<(), TuliproxError> {
if self.page_size == 0 {
return Err(TuliproxError::ConfigInput(format!(
"media server catalog page_size must be greater than zero (input: {input_name})"
)));
}
Ok(())
}
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
pub enum MediaServerPlaybackInfoPolicyDto {
#[serde(rename = "on_demand")]
#[default]
OnDemand,
#[serde(rename = "disabled")]
Disabled,
}
pub fn is_default_media_server_playback_info_policy(value: &MediaServerPlaybackInfoPolicyDto) -> bool {
*value == MediaServerPlaybackInfoPolicyDto::default()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MediaServerPlaybackConfigDto {
#[serde(default, skip_serializing_if = "is_default_media_server_playback_info_policy")]
pub playback_info_policy: MediaServerPlaybackInfoPolicyDto,
#[serde(default, skip_serializing_if = "is_false")]
pub preflight_streams: bool,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub direct_play_only: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_transcode: bool,
}
impl Default for MediaServerPlaybackConfigDto {
fn default() -> Self {
Self {
playback_info_policy: MediaServerPlaybackInfoPolicyDto::default(),
preflight_streams: false,
direct_play_only: default_as_true(),
allow_transcode: false,
}
}
}
impl MediaServerPlaybackConfigDto {
pub fn is_default(&self) -> bool { self == &Self::default() }
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MediaServerEnrichmentConfigDto {
#[serde(default, skip_serializing_if = "is_false")]
pub ffprobe: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub tmdb_lookup: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub fetch_images: bool,
}
impl MediaServerEnrichmentConfigDto {
pub fn is_default(&self) -> bool { self == &Self::default() }
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
pub enum MediaServerImagePolicyDto {
#[serde(rename = "proxy_on_demand")]
#[default]
ProxyOnDemand,
#[serde(rename = "disabled")]
Disabled,
}
pub fn is_default_media_server_image_policy(value: &MediaServerImagePolicyDto) -> bool {
*value == MediaServerImagePolicyDto::default()
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq)]
pub enum MediaServerLibraryKindDto {
#[serde(rename = "movies")]
Movies,
#[serde(rename = "tvshows", alias = "shows", alias = "series")]
TvShows,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MediaServerLibrarySelectorDetailsDto {
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<MediaServerLibraryKindDto>,
}
impl MediaServerLibrarySelectorDetailsDto {
fn prepare(&mut self) {
self.id = get_trimmed_string(self.id.as_deref());
self.key = get_trimmed_string(self.key.as_deref());
self.name = get_trimmed_string(self.name.as_deref());
}
fn is_empty(&self) -> bool {
self.id.as_ref().is_none_or(|s| s.trim().is_empty())
&& self.key.as_ref().is_none_or(|s| s.trim().is_empty())
&& self.name.as_ref().is_none_or(|s| s.trim().is_empty())
&& self.kind.is_none()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum MediaServerLibrarySelectorDto {
Name(String),
Detailed(MediaServerLibrarySelectorDetailsDto),
}
impl MediaServerLibrarySelectorDto {
fn prepare(&mut self) {
match self {
Self::Name(name) => *name = name.trim().to_string(),
Self::Detailed(details) => details.prepare(),
}
}
pub fn is_empty(&self) -> bool {
match self {
Self::Name(name) => name.trim().is_empty(),
Self::Detailed(details) => details.is_empty(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MediaServerInputConfigDto {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub libraries: Vec<MediaServerLibrarySelectorDto>,
#[serde(default, skip_serializing_if = "MediaServerCatalogConfigDto::is_default")]
pub catalog: MediaServerCatalogConfigDto,
#[serde(default, skip_serializing_if = "MediaServerPlaybackConfigDto::is_default")]
pub playback: MediaServerPlaybackConfigDto,
#[serde(default, skip_serializing_if = "MediaServerEnrichmentConfigDto::is_default")]
pub enrichment: MediaServerEnrichmentConfigDto,
#[serde(default, skip_serializing_if = "is_default_media_server_image_policy")]
pub image_policy: MediaServerImagePolicyDto,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub user_id: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub account_token: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub server_id: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub machine_id: Option<String>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub server_name: Option<String>,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub prefer_https: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub allow_relay: bool,
}
impl Default for MediaServerInputConfigDto {
fn default() -> Self {
Self {
libraries: Vec::new(),
catalog: MediaServerCatalogConfigDto::default(),
playback: MediaServerPlaybackConfigDto::default(),
enrichment: MediaServerEnrichmentConfigDto::default(),
image_policy: MediaServerImagePolicyDto::default(),
token: None,
api_key: None,
user_id: None,
account_token: None,
server_id: None,
machine_id: None,
server_name: None,
prefer_https: default_as_true(),
allow_relay: false,
}
}
}
impl MediaServerInputConfigDto {
pub fn normalize(&mut self) {
self.token = get_trimmed_string(self.token.as_deref());
self.api_key = get_trimmed_string(self.api_key.as_deref());
self.user_id = get_trimmed_string(self.user_id.as_deref());
self.account_token = get_trimmed_string(self.account_token.as_deref());
self.server_id = get_trimmed_string(self.server_id.as_deref());
self.machine_id = get_trimmed_string(self.machine_id.as_deref());
self.server_name = get_trimmed_string(self.server_name.as_deref());
for library in &mut self.libraries {
library.prepare();
}
}
pub fn prepare(&mut self, input_name: &Arc<str>) -> Result<(), TuliproxError> {
self.normalize();
self.catalog.prepare(input_name)?;
if self.libraries.iter().any(MediaServerLibrarySelectorDto::is_empty) {
return Err(TuliproxError::ConfigInput(format!(
"media_server library selectors must not be empty (input: {input_name})"
)));
}
Ok(())
}
pub fn has_any_emby_jellyfin_auth(&self) -> bool {
is_non_blank_optional_string(&self.token) || is_non_blank_optional_string(&self.api_key)
}
pub fn has_any_plex_token(&self) -> bool {
is_non_blank_optional_string(&self.account_token) || is_non_blank_optional_string(&self.token)
}
pub fn has_plex_server_selector(&self) -> bool {
is_non_blank_optional_string(&self.server_id)
|| is_non_blank_optional_string(&self.machine_id)
|| is_non_blank_optional_string(&self.server_name)
}
}
#[derive(Debug, Default, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum ClusterSource {
#[serde(rename = "staged")]
@@ -495,6 +816,8 @@ pub struct ConfigInputDto {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options: Option<ConfigInputOptionsDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_server: Option<MediaServerInputConfigDto>,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub cache_duration: Option<String>,
#[serde(skip)]
@@ -531,6 +854,7 @@ impl Default for ConfigInputDto {
persist: None,
enabled: default_as_true(),
options: None,
media_server: None,
cache_duration: None,
cache_duration_seconds: 0,
aliases: None,
@@ -566,9 +890,105 @@ impl ConfigInputDto {
}
}
InputType::Library => InputType::Library,
InputType::Emby => InputType::Emby,
InputType::Jellyfin => InputType::Jellyfin,
InputType::Plex => InputType::Plex,
};
}
fn prepare_media_server_input(&mut self) -> Result<(), TuliproxError> {
if !self.input_type.is_media_server() {
return Ok(());
}
let trimmed_url = self.url.trim();
if trimmed_url.starts_with(BATCH_SCHEME_PREFIX) || trimmed_url.starts_with(PROVIDER_SCHEME_PREFIX) {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support batch:// or provider:// URLs (input: {})",
self.name
)));
}
if self.aliases.as_ref().is_some_and(|aliases| !aliases.is_empty()) {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support aliases (input: {})",
self.name
)));
}
if self.staged.as_ref().is_some_and(|staged| staged.enabled) {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support staged inputs (input: {})",
self.name
)));
}
if self.epg.is_some() {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support EPG configuration (input: {})",
self.name
)));
}
if self.panel_api.is_some() {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support panel_api configuration (input: {})",
self.name
)));
}
if self.provider.as_ref().is_some_and(|provider| !provider.is_empty()) {
return Err(TuliproxError::ConfigInput(format!(
"media-server input does not support provider failover definitions (input: {})",
self.name
)));
}
let Some(media_server) = self.media_server.as_mut() else {
return Err(TuliproxError::ConfigInput(format!(
"media_server configuration is mandatory for input type {} (input: {})",
self.input_type, self.name
)));
};
media_server.prepare(&self.name)?;
if media_server.libraries.is_empty() {
return Err(TuliproxError::ConfigInput(format!(
"media-server input requires at least one selected library (input: {})",
self.name
)));
}
match self.input_type {
InputType::Emby | InputType::Jellyfin => {
if trimmed_url.is_empty() {
return Err(TuliproxError::ConfigInput(format!(
"url is mandatory for input type {} (input: {})",
self.input_type, self.name
)));
}
let has_login = self.username.as_ref().is_some_and(|u| !u.trim().is_empty())
&& self.password.as_ref().is_some_and(|p| !p.trim().is_empty());
if !media_server.has_any_emby_jellyfin_auth() && !has_login {
return Err(TuliproxError::ConfigInput(format!(
"media-server input type {} requires media_server token/api_key or username/password bootstrap credentials (input: {})",
self.input_type, self.name
)));
}
}
InputType::Plex => {
if !media_server.has_any_plex_token() {
return Err(TuliproxError::ConfigInput(format!(
"media-server input type plex requires media_server.account_token or media_server.token (input: {})",
self.name
)));
}
if trimmed_url.is_empty() && !media_server.has_plex_server_selector() {
return Err(TuliproxError::ConfigInput(format!(
"media-server input type plex requires a server selector such as media_server.machine_id, media_server.server_id, or media_server.server_name when input.url is omitted (input: {})",
self.name
)));
}
}
InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => {}
}
Ok(())
}
#[allow(clippy::cast_possible_truncation)]
pub fn prepare(
&mut self,
@@ -590,6 +1010,12 @@ impl ConfigInputDto {
self.url = self.url.trim().to_string();
self.normalize_input_type_from_batch_url();
if let Some(media_server) = self.media_server.as_mut() {
media_server.normalize();
}
if self.enabled {
self.prepare_media_server_input()?;
}
if self.url.starts_with(PROVIDER_SCHEME_PREFIX)
&& matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch)
{
@@ -779,6 +1205,7 @@ impl ConfigInputDto {
}
pub fn prepare_type(&mut self) -> Result<(), TuliproxError> {
self.url = self.url.trim().to_string();
self.normalize_input_type_from_batch_url();
if self.url.starts_with(PROVIDER_SCHEME_PREFIX)
&& matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch)
@@ -1017,6 +1444,267 @@ mod tests {
ConfigInputDto { name: "test_input".intern(), ..ConfigInputDto::default() }
}
fn media_server_config_with_library() -> MediaServerInputConfigDto {
MediaServerInputConfigDto {
libraries: vec![MediaServerLibrarySelectorDto::Name("Movies".to_string())],
..MediaServerInputConfigDto::default()
}
}
#[test]
fn prepare_rejects_blank_media_server_credentials_and_selectors() {
let mut emby = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: "https://media.example.invalid".to_string(),
media_server: Some(MediaServerInputConfigDto {
token: Some(" ".to_string()),
api_key: Some("".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut emby).expect_err("blank token/api_key should be rejected");
assert!(err.to_string().contains("requires media_server token/api_key"));
let mut plex = ConfigInputDto {
name: "plex_media_server".intern(),
input_type: InputType::Plex,
media_server: Some(MediaServerInputConfigDto {
account_token: Some(" ".to_string()),
server_id: Some(" ".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut plex).expect_err("blank plex token should be rejected");
assert!(err.to_string().contains("requires media_server.account_token or media_server.token"));
}
#[test]
fn prepare_accepts_media_server_max_connections_as_stream_limit() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: "https://media.example.invalid".to_string(),
max_connections: 1,
media_server: Some(MediaServerInputConfigDto {
token: Some("token-value".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("media_server inputs reuse max_connections stream-limit semantics");
}
fn prepare_dto(dto: &mut ConfigInputDto) -> Result<u16, TuliproxError> {
dto.prepare(0, false, &HashSet::new(), None)
}
#[test]
fn media_server_defaults_are_conservative() {
let media_server = MediaServerInputConfigDto::default();
assert_eq!(media_server.catalog.page_size, 100);
assert_eq!(media_server.catalog.request_delay_ms, 250);
assert!(media_server.catalog.include_media_sources);
assert!(!media_server.catalog.include_paths);
assert!(!media_server.catalog.include_user_state);
assert!(!media_server.catalog.refresh_on_startup);
assert!(media_server.playback.direct_play_only);
assert!(!media_server.playback.allow_transcode);
assert!(!media_server.playback.preflight_streams);
assert!(!media_server.enrichment.ffprobe);
assert!(!media_server.enrichment.tmdb_lookup);
assert!(!media_server.enrichment.fetch_images);
assert_eq!(media_server.image_policy, MediaServerImagePolicyDto::ProxyOnDemand);
assert!(!media_server.allow_relay);
}
#[test]
fn prepare_accepts_emby_media_server_with_token_and_library() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: " https://media.example.invalid/ ".to_string(),
media_server: Some(MediaServerInputConfigDto {
token: Some(" token-value ".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("emby media_server config should prepare");
assert_eq!(dto.url, "https://media.example.invalid/");
assert!(dto.input_type.is_media_server());
assert_eq!(
dto.media_server.as_ref().and_then(|media_server| media_server.token.as_deref()),
Some("token-value")
);
}
#[test]
fn prepare_rejects_media_server_without_media_server_block() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: "https://media.example.invalid".to_string(),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut dto).expect_err("media_server block should be mandatory");
assert!(err.to_string().contains("media_server configuration is mandatory"));
}
#[test]
fn prepare_allows_disabled_media_server_input_with_incomplete_config() {
let mut dto = ConfigInputDto {
name: "disabled_plex".intern(),
input_type: InputType::Plex,
enabled: false,
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("disabled media_server input should not require active playback/catalog config");
assert_eq!(dto.input_type, InputType::Plex);
assert!(!dto.enabled);
}
#[test]
fn prepare_normalizes_disabled_media_server_config_without_enforcing_invariants() {
let mut dto = ConfigInputDto {
name: "disabled_emby".intern(),
input_type: InputType::Emby,
enabled: false,
media_server: Some(MediaServerInputConfigDto {
token: Some(" token-value ".to_string()),
libraries: vec![MediaServerLibrarySelectorDto::Name(" ".to_string())],
catalog: MediaServerCatalogConfigDto { page_size: 0, ..MediaServerCatalogConfigDto::default() },
..MediaServerInputConfigDto::default()
}),
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("disabled media_server input can preserve incomplete config for later repair");
let media_server = dto.media_server.as_ref().expect("media_server config should be preserved");
assert_eq!(media_server.token.as_deref(), Some("token-value"));
assert!(media_server.libraries[0].is_empty());
}
#[test]
fn prepare_rejects_emby_media_server_without_input_url() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
media_server: Some(MediaServerInputConfigDto {
token: Some("token-value".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut dto).expect_err("emby media_server input should require a direct server URL");
assert!(err.to_string().contains("url is mandatory for input type emby"));
}
#[test]
fn prepare_rejects_media_server_provider_scheme_url() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: " provider://media-server ".to_string(),
media_server: Some(MediaServerInputConfigDto {
token: Some("token-value".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut dto).expect_err("media_server input must not use provider URLs");
assert!(err.to_string().contains("does not support batch:// or provider://"));
}
#[test]
fn prepare_rejects_media_server_staged_input() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: "https://media.example.invalid".to_string(),
media_server: Some(MediaServerInputConfigDto {
token: Some("token-value".to_string()),
..media_server_config_with_library()
}),
staged: Some(StagedInputDto { enabled: true, name: "staged".intern(), ..StagedInputDto::default() }),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut dto).expect_err("media_server input must reject staged config");
assert!(err.to_string().contains("does not support staged inputs"));
}
#[test]
fn prepare_rejects_plex_without_token_or_server_selector() {
let mut without_token = ConfigInputDto {
name: "plex_media_server".intern(),
input_type: InputType::Plex,
media_server: Some(MediaServerInputConfigDto {
machine_id: Some("machine".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut without_token).expect_err("plex token should be mandatory");
assert!(err.to_string().contains("requires media_server.account_token or media_server.token"));
let mut without_selector = ConfigInputDto {
name: "plex_media_server".intern(),
input_type: InputType::Plex,
media_server: Some(MediaServerInputConfigDto {
account_token: Some("token".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
let err = prepare_dto(&mut without_selector).expect_err("plex server selector should be mandatory");
assert!(err.to_string().contains("requires a server selector"));
}
#[test]
fn prepare_accepts_plex_without_input_url_when_discovery_is_configured() {
let mut dto = ConfigInputDto {
name: "plex_media_server".intern(),
input_type: InputType::Plex,
media_server: Some(MediaServerInputConfigDto {
account_token: Some("token".to_string()),
machine_id: Some("machine".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("plex discovery config should not require input.url");
assert_eq!(dto.input_type, InputType::Plex);
}
#[test]
fn prepare_accepts_plex_media_server_with_direct_url_without_selector() {
let mut dto = ConfigInputDto {
name: "plex_media_server".intern(),
input_type: InputType::Plex,
url: "https://plex.example.invalid".to_string(),
media_server: Some(MediaServerInputConfigDto {
token: Some("token".to_string()),
..media_server_config_with_library()
}),
..ConfigInputDto::default()
};
prepare_dto(&mut dto).expect("direct Plex URL should not require MyPlex server selector");
assert_eq!(dto.input_type, InputType::Plex);
}
#[test]
fn test_epg_url_from_explicit_main_credentials() {
let mut dto = create_test_dto();
@@ -1264,6 +1952,20 @@ mod tests {
assert_eq!(dto.input_type, InputType::Xtream);
}
#[test]
fn prepare_type_does_not_validate_media_server_config() {
let mut dto = ConfigInputDto {
name: "emby_media_server".intern(),
input_type: InputType::Emby,
url: "https://media.example.invalid".to_string(),
..ConfigInputDto::default()
};
dto.prepare_type().expect("prepare_type only normalizes type/url");
let err = prepare_dto(&mut dto).expect_err("full prepare should validate missing media_server block");
assert!(err.to_string().contains("media_server configuration is mandatory"));
}
#[test]
fn prepare_batch_url_does_not_require_xtream_credentials() {
let mut dto = ConfigInputDto {
+24 -30
View File
@@ -1,15 +1,17 @@
#[macro_export]
macro_rules! check_input_credentials {
($this:ident, $input_type:expr, $definition:expr, $alias:expr ) => {
let __tp_input_name = $this.name.to_string();
let __tp_input_name = __tp_input_name.trim().to_string();
let __tp_input_name_suffix =
if __tp_input_name.is_empty() { String::new() } else { format!(" (input: {})", __tp_input_name) };
let input_name = $this.name.to_string();
let input_name = input_name.trim().to_string();
let input_name_suffix =
if input_name.is_empty() { String::new() } else { format!(" (input: {input_name})") };
if !matches!($input_type, InputType::Library) {
$this.url = $this.url.trim().to_string();
if $this.url.is_empty() {
return Err($crate::error::TuliproxError::ConfigInput(format!("url for input is mandatory{}", __tp_input_name_suffix)));
// This generic check only applies to classic URL-backed playlist inputs. Media-server
// inputs have provider-specific URL/discovery validation in their prepare path.
if $input_type.uses_standard_input_url() && $this.url.is_empty() {
return Err($crate::error::TuliproxError::ConfigInput(format!("url for input is mandatory{input_name_suffix}")));
}
$this.username = $crate::utils::get_trimmed_string($this.username.as_deref());
@@ -28,7 +30,7 @@ macro_rules! check_input_credentials {
InputType::M3uBatch => {
if $definition {
if $this.url.trim().is_empty() {
return Err($crate::error::TuliproxError::ConfigInput(format!("for input type m3u-batch: url is mandatory{}", __tp_input_name_suffix)));
return Err($crate::error::TuliproxError::ConfigInput(format!("for input type m3u-batch: url is mandatory{input_name_suffix}")));
}
}
@@ -40,8 +42,7 @@ macro_rules! check_input_credentials {
InputType::Xtream => {
if $this.username.is_none() || $this.password.is_none() {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"for input type xtream: username and password are mandatory{}",
__tp_input_name_suffix
"for input type xtream: username and password are mandatory{input_name_suffix}",
)));
}
}
@@ -49,8 +50,7 @@ macro_rules! check_input_credentials {
if $definition {
if $this.url.trim().is_empty() {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"for input type xtream-batch: url is mandatory{}",
__tp_input_name_suffix
"for input type xtream-batch: url is mandatory{input_name_suffix}",
)));
}
}
@@ -64,20 +64,19 @@ macro_rules! check_input_credentials {
if is_batch_url {
if has_credentials {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"input type xtream-batch with batch:// URL should not define username or password attribute{}",
__tp_input_name_suffix
"input type xtream-batch with batch:// URL should not define username or password attribute{input_name_suffix}",
)));
}
} else if !has_username || !has_password {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"for input type xtream-batch without batch:// URL: username and password are mandatory{}",
__tp_input_name_suffix
"for input type xtream-batch without batch:// URL: username and password are mandatory{input_name_suffix}",
)));
}
}
}
InputType::Library => {
// nothing to do
InputType::Library | InputType::Emby | InputType::Jellyfin | InputType::Plex => {
// Media-server credentials live in the dedicated media_server block; detailed
// validation happens in ConfigInputDto/ConfigInput prepare methods.
}
}
};
@@ -86,10 +85,9 @@ macro_rules! check_input_credentials {
#[macro_export]
macro_rules! check_input_connections {
($this:ident, $input_type:expr, $alias:expr) => {
let __tp_input_name = $this.name.to_string();
let __tp_input_name = __tp_input_name.trim().to_string();
let __tp_input_name_suffix =
if __tp_input_name.is_empty() { String::new() } else { format!(" (input: {})", __tp_input_name) };
let input_name = $this.name.to_string();
let input_name = input_name.trim().to_string();
let input_name_suffix = if input_name.is_empty() { String::new() } else { format!(" (input: {input_name})") };
match $input_type {
InputType::M3u | InputType::Xtream => {}
@@ -97,14 +95,12 @@ macro_rules! check_input_connections {
if !$alias {
if $this.max_connections > 0 {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"input type m3u-batch should not define max_connections attribute{}",
__tp_input_name_suffix
"input type m3u-batch should not define max_connections attribute{input_name_suffix}",
)));
}
if $this.priority != 0 {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"input type m3u-batch should not define priority attribute{}",
__tp_input_name_suffix
"input type m3u-batch should not define priority attribute{input_name_suffix}",
)));
}
}
@@ -113,19 +109,17 @@ macro_rules! check_input_connections {
if !$alias {
if $this.max_connections > 0 {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"input type xtream-batch should not define max_connections attribute{}",
__tp_input_name_suffix
"input type xtream-batch should not define max_connections attribute{input_name_suffix}",
)));
}
if $this.priority != 0 {
return Err($crate::error::TuliproxError::ConfigInput(format!(
"input type xtream-batch should not define priority attribute{}",
__tp_input_name_suffix
"input type xtream-batch should not define priority attribute{input_name_suffix}",
)));
}
}
}
InputType::Library => {}
InputType::Library | InputType::Emby | InputType::Jellyfin | InputType::Plex => {}
}
};
}
+2 -2
View File
@@ -29,8 +29,8 @@ impl fmt::Debug for XtreamLoginRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("XtreamLoginRequest")
.field("url", &self.url)
.field("username", &"<redacted>")
.field("password", &"<redacted>")
.field("username", &"***")
.field("password", &"***")
.finish()
}
}