mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-27 01:02:45 +01:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user