mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-11 18:32:24 +02:00
* test: characterize target curation transition * refactor: model complete curation outcomes * feat: configure target-wide curation projection * feat: apply target-wide curation selections * docs: explain target-wide Trakt curation * fix: preserve source-less Trakt policy edits * target wide curaton selection * target wide curaton selection --------- Co-authored-by: Juanjo Presa <juanjop@gmail.com> Co-authored-by: euzu <euzu@proton.me>
984 lines
36 KiB
Rust
984 lines
36 KiB
Rust
use crate::{
|
|
defaults::{
|
|
default_as_default, default_as_true, is_config_target_options_empty, is_default_processing_order, is_false,
|
|
is_true, is_zero_u16,
|
|
},
|
|
error::TuliproxError,
|
|
foundation::{get_filter, Filter},
|
|
model::{
|
|
ClusterFlags, ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, HdHomeRunDeviceOverview, PatternTemplate,
|
|
Prepare, PrepareAll, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto,
|
|
},
|
|
utils::is_blank_optional_string,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ConfigTargetFilterDto {
|
|
pub processing: Option<String>,
|
|
pub persist: Option<String>,
|
|
pub t_processing: Option<Filter>,
|
|
pub t_persist: Option<Filter>,
|
|
}
|
|
|
|
impl PartialEq for ConfigTargetFilterDto {
|
|
fn eq(&self, other: &Self) -> bool { self.processing == other.processing && self.persist == other.persist }
|
|
}
|
|
|
|
impl From<String> for ConfigTargetFilterDto {
|
|
fn from(processing: String) -> Self { Self { processing: Some(processing), ..Self::default() } }
|
|
}
|
|
|
|
impl From<&str> for ConfigTargetFilterDto {
|
|
fn from(processing: &str) -> Self { processing.to_string().into() }
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct StagedTargetFilterDto {
|
|
#[serde(default)]
|
|
processing: Option<String>,
|
|
#[serde(default)]
|
|
persist: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(untagged)]
|
|
enum TargetFilterRepr {
|
|
Processing(String),
|
|
Staged(StagedTargetFilterDto),
|
|
}
|
|
|
|
impl<'de> serde::Deserialize<'de> for ConfigTargetFilterDto {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
match <TargetFilterRepr as serde::Deserialize>::deserialize(deserializer)? {
|
|
TargetFilterRepr::Processing(processing) => Ok(processing.into()),
|
|
TargetFilterRepr::Staged(staged) => {
|
|
if staged.processing.is_none() && staged.persist.is_none() {
|
|
return Err(serde::de::Error::custom("staged target filter requires at least one stage"));
|
|
}
|
|
Ok(Self { processing: staged.processing, persist: staged.persist, ..Self::default() })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConfigTargetFilterDto {
|
|
pub const fn is_empty(&self) -> bool { self.processing.is_none() && self.persist.is_none() }
|
|
|
|
fn prepare(&mut self, templates: Option<&[PatternTemplate]>) -> Result<(), TuliproxError> {
|
|
fn compile_filter(
|
|
value: Option<&str>,
|
|
templates: Option<&[PatternTemplate]>,
|
|
) -> Result<Option<Filter>, TuliproxError> {
|
|
value
|
|
.map(str::trim)
|
|
.filter(|filter| !filter.is_empty())
|
|
.map(|filter| get_filter(filter, templates))
|
|
.transpose()
|
|
}
|
|
|
|
self.t_processing = compile_filter(self.processing.as_deref(), templates)?;
|
|
self.t_persist = compile_filter(self.persist.as_deref(), templates)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl serde::Serialize for ConfigTargetFilterDto {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
use serde::ser::SerializeStruct;
|
|
|
|
if self.persist.is_none() {
|
|
return serializer.serialize_str(self.processing.as_deref().unwrap_or_default());
|
|
}
|
|
let field_count = usize::from(self.processing.is_some()) + usize::from(self.persist.is_some());
|
|
let mut state = serializer.serialize_struct("ConfigTargetFilterDto", field_count)?;
|
|
if let Some(processing) = self.processing.as_ref() {
|
|
state.serialize_field("processing", processing)?;
|
|
}
|
|
if let Some(persist) = self.persist.as_ref() {
|
|
state.serialize_field("persist", persist)?;
|
|
}
|
|
state.end()
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfigTargetShareLiveStreams {
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub hls: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub mpeg_ts: bool,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(untagged)]
|
|
enum ConfigTargetShareLiveStreamsCompat {
|
|
Legacy(bool),
|
|
Structured(ConfigTargetShareLiveStreams),
|
|
}
|
|
|
|
fn deserialize_share_live_streams<'de, D>(deserializer: D) -> Result<ConfigTargetShareLiveStreams, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Ok(match <ConfigTargetShareLiveStreamsCompat as serde::Deserialize>::deserialize(deserializer)? {
|
|
ConfigTargetShareLiveStreamsCompat::Legacy(enabled) => {
|
|
ConfigTargetShareLiveStreams { hls: false, mpeg_ts: enabled }
|
|
}
|
|
ConfigTargetShareLiveStreamsCompat::Structured(config) => config,
|
|
})
|
|
}
|
|
|
|
impl ConfigTargetShareLiveStreams {
|
|
pub fn is_empty(&self) -> bool { !self.hls && !self.mpeg_ts }
|
|
}
|
|
|
|
/// Controls optional canonicalization of EPG data emitted for a target.
|
|
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct EpgOutputOptions {
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub lowercase_ids: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub lowercase_xmltv_display_names: bool,
|
|
}
|
|
|
|
impl EpgOutputOptions {
|
|
pub const fn is_empty(&self) -> bool { !self.lowercase_ids && !self.lowercase_xmltv_display_names }
|
|
}
|
|
|
|
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DeduplicateMatchBy {
|
|
#[default]
|
|
Caption,
|
|
Name,
|
|
Title,
|
|
}
|
|
|
|
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DeduplicateKeep {
|
|
#[default]
|
|
BestQuality,
|
|
First,
|
|
}
|
|
|
|
/// Quality-aware duplicate removal: channels with the same normalized match
|
|
/// value (quality tokens stripped) collapse to a single entry.
|
|
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeduplicateConfig {
|
|
#[serde(default)]
|
|
pub match_by: DeduplicateMatchBy,
|
|
#[serde(default)]
|
|
pub keep: DeduplicateKeep,
|
|
/// Normalize accented characters in match keys ("Café HD" matches "Cafe FHD").
|
|
#[serde(default)]
|
|
pub match_as_ascii: bool,
|
|
}
|
|
|
|
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfigTargetOptions {
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub ignore_logo: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
#[serde(alias = "required_epg")]
|
|
pub clear_invalid_epg_ids: bool,
|
|
#[serde(
|
|
default,
|
|
deserialize_with = "deserialize_share_live_streams",
|
|
skip_serializing_if = "ConfigTargetShareLiveStreams::is_empty"
|
|
)]
|
|
pub share_live_streams: ConfigTargetShareLiveStreams,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub remove_duplicates: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub deduplicate: Option<DeduplicateConfig>,
|
|
#[serde(default, skip_serializing_if = "EpgOutputOptions::is_empty")]
|
|
pub epg_output: EpgOutputOptions,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub force_redirect: Option<ClusterFlags>,
|
|
}
|
|
|
|
impl ConfigTargetOptions {
|
|
pub fn is_empty(&self) -> bool {
|
|
!self.ignore_logo
|
|
&& !self.clear_invalid_epg_ids
|
|
&& self.share_live_streams.is_empty()
|
|
&& !self.remove_duplicates
|
|
&& self.deduplicate.is_none()
|
|
&& self.epg_output.is_empty()
|
|
&& self.force_redirect.is_none_or(|f| f.has_full_flags() || f.is_empty())
|
|
}
|
|
|
|
pub const fn lowercase_epg_ids(&self) -> bool { self.epg_output.lowercase_ids }
|
|
|
|
pub const fn lowercase_xmltv_display_names(&self) -> bool { self.epg_output.lowercase_xmltv_display_names }
|
|
|
|
pub const fn clear_invalid_epg_ids(&self) -> bool { self.clear_invalid_epg_ids }
|
|
|
|
pub fn share_live_hls_enabled(&self) -> bool { self.share_live_streams.hls }
|
|
|
|
pub fn share_live_mpeg_ts_enabled(&self) -> bool { self.share_live_streams.mpeg_ts }
|
|
|
|
pub fn share_live_any_enabled(&self) -> bool { self.share_live_hls_enabled() || self.share_live_mpeg_ts_enabled() }
|
|
}
|
|
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct XtreamTargetOutputDto {
|
|
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
|
pub skip_live_direct_source: bool,
|
|
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
|
pub skip_video_direct_source: bool,
|
|
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
|
pub skip_series_direct_source: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub trakt: Option<TraktConfigDto>,
|
|
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
|
pub filter: Option<String>,
|
|
#[serde(skip)]
|
|
pub t_filter: Option<Filter>,
|
|
}
|
|
|
|
impl Default for XtreamTargetOutputDto {
|
|
fn default() -> Self {
|
|
XtreamTargetOutputDto {
|
|
skip_live_direct_source: default_as_true(),
|
|
skip_video_direct_source: default_as_true(),
|
|
skip_series_direct_source: default_as_true(),
|
|
trakt: None,
|
|
filter: None,
|
|
t_filter: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl XtreamTargetOutputDto {
|
|
pub fn has_any_option(&self) -> bool {
|
|
self.skip_live_direct_source
|
|
|| self.skip_video_direct_source
|
|
|| self.skip_series_direct_source
|
|
|| self.trakt.is_some()
|
|
|| self.filter.is_some()
|
|
}
|
|
}
|
|
|
|
impl Prepare for XtreamTargetOutputDto {
|
|
type Ctx<'a> = Option<&'a [PatternTemplate]>;
|
|
|
|
fn prepare(&mut self, templates: Self::Ctx<'_>) -> Result<(), TuliproxError> {
|
|
if let Some(raw_filter) = &self.filter {
|
|
self.t_filter = Some(get_filter(raw_filter, templates)?);
|
|
}
|
|
if let Some(trakt) = &mut self.trakt {
|
|
trakt.prepare()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct M3uTargetOutputDto {
|
|
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
|
pub filename: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub include_type_in_url: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub mask_redirect_url: bool,
|
|
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
|
pub filter: Option<String>,
|
|
#[serde(skip)]
|
|
pub t_filter: Option<Filter>,
|
|
}
|
|
|
|
impl M3uTargetOutputDto {
|
|
pub fn has_any_option(&self) -> bool {
|
|
self.filename.is_some() || self.include_type_in_url || self.mask_redirect_url || self.filter.is_some()
|
|
}
|
|
}
|
|
|
|
impl Prepare for M3uTargetOutputDto {
|
|
type Ctx<'a> = Option<&'a [PatternTemplate]>;
|
|
|
|
fn prepare(&mut self, templates: Self::Ctx<'_>) -> Result<(), TuliproxError> {
|
|
if let Some(raw_filter) = &self.filter {
|
|
self.t_filter = Some(get_filter(raw_filter, templates)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct StrmTargetOutputDto {
|
|
pub directory: String,
|
|
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
|
pub username: Option<String>,
|
|
#[serde(default)]
|
|
pub style: StrmExportStyle,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub flat: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub underscore_whitespace: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub cleanup: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub strm_props: Option<Vec<String>>,
|
|
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
|
pub filter: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub add_quality_to_filename: bool,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub use_metadata: bool,
|
|
|
|
// New Fields for Metadata and Probe
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub probe_probe_size_bytes: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub probe_analyze_duration: Option<u64>,
|
|
|
|
#[serde(skip)]
|
|
pub t_filter: Option<Filter>,
|
|
}
|
|
|
|
impl Prepare for StrmTargetOutputDto {
|
|
type Ctx<'a> = Option<&'a [PatternTemplate]>;
|
|
|
|
fn prepare(&mut self, templates: Self::Ctx<'_>) -> Result<(), TuliproxError> {
|
|
if let Some(raw_filter) = &self.filter {
|
|
self.t_filter = Some(get_filter(raw_filter, templates)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HdHomeRunTargetOutputDto {
|
|
pub device: String,
|
|
pub username: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub use_output: Option<TargetType>,
|
|
}
|
|
|
|
impl Default for HdHomeRunTargetOutputDto {
|
|
fn default() -> Self { Self { device: String::new(), username: String::new(), use_output: Some(TargetType::M3u) } }
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
|
|
pub enum TargetOutputDto {
|
|
Xtream(XtreamTargetOutputDto),
|
|
M3u(M3uTargetOutputDto),
|
|
Strm(StrmTargetOutputDto),
|
|
HdHomeRun(HdHomeRunTargetOutputDto),
|
|
}
|
|
|
|
impl Prepare for TargetOutputDto {
|
|
type Ctx<'a> = Option<&'a [PatternTemplate]>;
|
|
|
|
fn prepare(&mut self, templates: Self::Ctx<'_>) -> Result<(), TuliproxError> {
|
|
match self {
|
|
TargetOutputDto::Xtream(output) => output.prepare(templates),
|
|
TargetOutputDto::M3u(output) => output.prepare(templates),
|
|
TargetOutputDto::Strm(output) => output.prepare(templates),
|
|
TargetOutputDto::HdHomeRun(_) => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfigTargetDto {
|
|
#[serde(default, skip_serializing_if = "is_zero_u16")]
|
|
pub id: u16,
|
|
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_as_default")]
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "is_config_target_options_empty")]
|
|
pub options: Option<ConfigTargetOptions>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub sort: Option<ConfigSortDto>,
|
|
#[serde(default, skip_serializing_if = "ConfigTargetFilterDto::is_empty")]
|
|
pub filter: ConfigTargetFilterDto,
|
|
#[serde(default)]
|
|
pub output: Vec<TargetOutputDto>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub rename: Option<Vec<ConfigRenameDto>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mapping: Option<Vec<String>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub favourites: Option<Vec<ConfigFavouritesDto>>,
|
|
#[serde(default, skip_serializing_if = "is_default_processing_order")]
|
|
pub processing_order: ProcessingOrder,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub watch: Option<Vec<String>>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub use_memory_cache: bool,
|
|
}
|
|
|
|
impl Default for ConfigTargetDto {
|
|
fn default() -> Self {
|
|
ConfigTargetDto {
|
|
id: 0,
|
|
enabled: default_as_true(),
|
|
name: default_as_default(),
|
|
options: None,
|
|
sort: None,
|
|
filter: ConfigTargetFilterDto::default(),
|
|
output: Vec::new(),
|
|
rename: None,
|
|
mapping: None,
|
|
favourites: None,
|
|
processing_order: ProcessingOrder::default(),
|
|
watch: None,
|
|
use_memory_cache: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConfigTargetDto {
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn prepare(
|
|
&mut self,
|
|
id: u16,
|
|
templates: Option<&[PatternTemplate]>,
|
|
hdhr_config: Option<&HdHomeRunDeviceOverview>,
|
|
) -> Result<(), TuliproxError> {
|
|
self.id = id;
|
|
if self.output.is_empty() {
|
|
return Err(TuliproxError::ConfigTarget(format!("Missing output format for {}", self.name)));
|
|
}
|
|
self.name = self.name.trim().to_string();
|
|
if self.name.is_empty() {
|
|
return Err(TuliproxError::ConfigTarget("target name required".to_string()));
|
|
}
|
|
|
|
let mut m3u_cnt = 0;
|
|
let mut xtream_cnt = 0;
|
|
let mut strm_cnt = 0;
|
|
let mut hdhr_cnt = 0;
|
|
let mut hdhomerun_needs_m3u = false;
|
|
let mut hdhomerun_needs_xtream = false;
|
|
|
|
//let mut strm_export_styles = vec![];
|
|
let mut strm_directories: Vec<&str> = vec![];
|
|
|
|
for target_output in &mut self.output {
|
|
target_output.prepare(templates)?;
|
|
match target_output {
|
|
TargetOutputDto::Xtream(_) => {
|
|
xtream_cnt += 1;
|
|
if default_as_default().eq_ignore_ascii_case(&self.name) {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"unique target name is required for xtream type output: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
}
|
|
TargetOutputDto::M3u(m3u_output) => {
|
|
m3u_cnt += 1;
|
|
m3u_output.filename = m3u_output.filename.as_ref().and_then(|s| {
|
|
let trimmed = s.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
});
|
|
}
|
|
TargetOutputDto::Strm(strm_output) => {
|
|
strm_cnt += 1;
|
|
strm_output.directory = strm_output.directory.trim().to_string();
|
|
if strm_output.directory.trim().is_empty() {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"directory is required for strm type: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
if let Some(username) = &mut strm_output.username {
|
|
*username = username.trim().to_string();
|
|
}
|
|
// if strm_export_styles.contains(&strm_output.style) {
|
|
// return Err(TuliproxError::ConfigTarget(format!("strm outputs with same export style are not allowed: {}", self.name)));
|
|
// }
|
|
// strm_export_styles.push(strm_output.style);
|
|
if strm_directories.contains(&strm_output.directory.as_str()) {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"strm outputs with same export directory are not allowed: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
strm_directories.push(strm_output.directory.as_str());
|
|
}
|
|
TargetOutputDto::HdHomeRun(hdhomerun_output) => {
|
|
hdhr_cnt += 1;
|
|
hdhomerun_output.username = hdhomerun_output.username.trim().to_string();
|
|
if hdhomerun_output.username.is_empty() {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"Username is required for HdHomeRun type: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
|
|
hdhomerun_output.device = hdhomerun_output.device.trim().to_string();
|
|
if hdhomerun_output.device.is_empty() {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"Device is required for HdHomeRun type: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
|
|
if let Some(use_output) = hdhomerun_output.use_output.as_ref() {
|
|
match &use_output {
|
|
TargetType::M3u => {
|
|
hdhomerun_needs_m3u = true;
|
|
}
|
|
TargetType::Xtream => {
|
|
hdhomerun_needs_xtream = true;
|
|
}
|
|
_ => {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"HdHomeRun output option `use_output` only accepts `m3u` or `xtream` for target: {}",
|
|
self.name
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
if let Some(hdhr_devices) = hdhr_config {
|
|
if !hdhr_devices.devices.contains(&hdhomerun_output.device) {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"HdHomeRun output device is not defined: {}",
|
|
hdhomerun_output.device
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if m3u_cnt > 1 || xtream_cnt > 1 || hdhr_cnt > 1 {
|
|
return Err(TuliproxError::ConfigTarget(format!("Multiple output formats with same type : {}", self.name)));
|
|
}
|
|
|
|
if strm_cnt > 0 && xtream_cnt == 0 && m3u_cnt == 0 {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"strm output is only permitted when used in combination with xtream or m3u output: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
|
|
if hdhr_cnt > 0 {
|
|
if xtream_cnt == 0 && m3u_cnt == 0 {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"HdHomeRun output is only permitted when used in combination with xtream or m3u output: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
if hdhomerun_needs_m3u && m3u_cnt == 0 {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"HdHomeRun output has `use_output=m3u` but no `m3u` output defined: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
if hdhomerun_needs_xtream && xtream_cnt == 0 {
|
|
return Err(TuliproxError::ConfigTarget(format!(
|
|
"HdHomeRun output has `use_output=xtream` but no `xtream` output defined: {}",
|
|
self.name
|
|
)));
|
|
}
|
|
|
|
if let Some(hdhr_devices) = hdhr_config {
|
|
if !hdhr_devices.enabled {
|
|
log::warn!("You have defined an HDHomeRun output, but HDHomeRun devices are disabled.");
|
|
}
|
|
}
|
|
}
|
|
|
|
self.favourites.prepare(templates)?;
|
|
|
|
if let Some(watch) = &self.watch {
|
|
for pat in watch {
|
|
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(pat) {
|
|
return Err(TuliproxError::ConfigTarget(format!("Invalid watch regular expression: {err}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
self.filter.prepare(templates)?;
|
|
self.rename.prepare_all(templates)?;
|
|
if let Some(sort) = self.sort.as_mut() {
|
|
sort.prepare(templates)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, ConfigTargetShareLiveStreams, EpgOutputOptions,
|
|
M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto,
|
|
};
|
|
|
|
fn target_with_outputs(output: Vec<TargetOutputDto>) -> ConfigTargetDto {
|
|
ConfigTargetDto {
|
|
name: "target".to_string(),
|
|
filter: "Group ~ \".*\"".into(),
|
|
output,
|
|
..ConfigTargetDto::default()
|
|
}
|
|
}
|
|
|
|
fn strm_with_username() -> TargetOutputDto {
|
|
TargetOutputDto::Strm(StrmTargetOutputDto {
|
|
directory: "/tmp/strm".to_string(),
|
|
username: Some("alice".to_string()),
|
|
..StrmTargetOutputDto::default()
|
|
})
|
|
}
|
|
|
|
fn strm_without_username() -> TargetOutputDto {
|
|
TargetOutputDto::Strm(StrmTargetOutputDto {
|
|
directory: "/tmp/strm".to_string(),
|
|
..StrmTargetOutputDto::default()
|
|
})
|
|
}
|
|
|
|
fn xtream_output() -> TargetOutputDto { TargetOutputDto::Xtream(XtreamTargetOutputDto::default()) }
|
|
|
|
#[test]
|
|
fn target_filter_string_roundtrips_as_string() {
|
|
let dto = serde_saphyr::from_str::<ConfigTargetDto>(
|
|
r#"
|
|
name: target
|
|
filter: 'Group ~ ".*"'
|
|
output:
|
|
- type: m3u
|
|
"#,
|
|
)
|
|
.expect("legacy target filter should deserialize");
|
|
|
|
assert_eq!(dto.filter.processing.as_deref(), Some(r#"Group ~ ".*""#));
|
|
assert_eq!(dto.filter.persist, None);
|
|
let serialized = serde_saphyr::to_string(&dto).expect("target should serialize");
|
|
assert!(serialized.contains("filter: Group ~"), "expected scalar filter, got: {serialized}");
|
|
assert!(!serialized.contains("processing:"), "processing-only filter must stay scalar: {serialized}");
|
|
}
|
|
|
|
#[test]
|
|
fn target_without_filter_defaults_to_no_stages_and_omits_filter() {
|
|
let dto = serde_saphyr::from_str::<ConfigTargetDto>(
|
|
r"
|
|
name: target
|
|
output:
|
|
- type: m3u
|
|
",
|
|
)
|
|
.expect("target without filter should deserialize");
|
|
|
|
assert!(dto.filter.is_empty());
|
|
let serialized = serde_saphyr::to_string(&dto).expect("target should serialize");
|
|
assert!(!serialized.contains("filter:"), "empty filter should be omitted: {serialized}");
|
|
}
|
|
|
|
#[test]
|
|
fn target_filter_stages_roundtrip_as_mapping() {
|
|
let dto = serde_saphyr::from_str::<ConfigTargetDto>(
|
|
r#"
|
|
name: target
|
|
filter:
|
|
processing: 'Group ~ ".*"'
|
|
persist: 'EpgId ~ ".+"'
|
|
output:
|
|
- type: m3u
|
|
"#,
|
|
)
|
|
.expect("staged target filter should deserialize");
|
|
|
|
assert_eq!(dto.filter.processing.as_deref(), Some(r#"Group ~ ".*""#));
|
|
assert_eq!(dto.filter.persist.as_deref(), Some(r#"EpgId ~ ".+""#));
|
|
let serialized = serde_saphyr::to_string(&dto).expect("target should serialize");
|
|
assert!(serialized.contains("processing:"), "expected staged filter mapping: {serialized}");
|
|
assert!(serialized.contains("persist:"), "expected persist filter: {serialized}");
|
|
}
|
|
|
|
#[test]
|
|
fn staged_target_filter_rejects_empty_mapping() {
|
|
let result = serde_saphyr::from_str::<ConfigTargetFilterDto>("{}\n");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn staged_target_filter_accepts_missing_processing_stage() {
|
|
let filter = serde_saphyr::from_str::<ConfigTargetFilterDto>(
|
|
r#"persist: 'EpgId ~ ".+"'
|
|
"#,
|
|
)
|
|
.expect("persist-only filter should deserialize");
|
|
|
|
assert_eq!(filter.processing, None);
|
|
assert_eq!(filter.persist.as_deref(), Some(r#"EpgId ~ ".+""#));
|
|
}
|
|
|
|
#[test]
|
|
fn target_filter_treats_empty_and_whitespace_stages_as_match_all() {
|
|
for value in ["", " \t\r\n "] {
|
|
let mut filter = ConfigTargetFilterDto {
|
|
processing: Some(value.to_string()),
|
|
persist: Some(value.to_string()),
|
|
..ConfigTargetFilterDto::default()
|
|
};
|
|
|
|
filter.prepare(None).expect("blank filter stages should prepare");
|
|
|
|
assert!(filter.t_processing.is_none());
|
|
assert!(filter.t_persist.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn processing_only_mapping_serializes_as_scalar() {
|
|
let filter = serde_saphyr::from_str::<ConfigTargetFilterDto>(
|
|
r#"processing: 'Group ~ ".*"'
|
|
"#,
|
|
)
|
|
.expect("processing-only mapping should deserialize");
|
|
|
|
let serialized = serde_saphyr::to_string(&filter).expect("processing filter should serialize");
|
|
assert!(!serialized.contains("processing:"), "processing-only filter must become scalar: {serialized}");
|
|
assert!(serialized.contains("Group ~"));
|
|
}
|
|
|
|
#[test]
|
|
fn target_filter_persist_roundtrips_as_mapping() {
|
|
let filter = ConfigTargetFilterDto {
|
|
processing: None,
|
|
persist: Some(r#"EpgId ~ ".+""#.to_string()),
|
|
..ConfigTargetFilterDto::default()
|
|
};
|
|
|
|
let serialized = serde_saphyr::to_string(&filter).expect("target filter should serialize");
|
|
assert!(!serialized.contains("processing:"));
|
|
assert!(serialized.contains("persist:"));
|
|
let reparsed =
|
|
serde_saphyr::from_str::<ConfigTargetFilterDto>(&serialized).expect("target filter should deserialize");
|
|
assert_eq!(reparsed, filter);
|
|
}
|
|
|
|
#[test]
|
|
fn strm_with_username_is_allowed_with_m3u_output() {
|
|
let mut target =
|
|
target_with_outputs(vec![TargetOutputDto::M3u(M3uTargetOutputDto::default()), strm_with_username()]);
|
|
|
|
assert!(target.prepare(1, None, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn strm_with_username_is_allowed_with_xtream_output() {
|
|
let mut target = target_with_outputs(vec![xtream_output(), strm_with_username()]);
|
|
|
|
assert!(target.prepare(1, None, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn strm_without_username_is_allowed_with_m3u_output() {
|
|
let mut target =
|
|
target_with_outputs(vec![TargetOutputDto::M3u(M3uTargetOutputDto::default()), strm_without_username()]);
|
|
|
|
assert!(target.prepare(1, None, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn strm_with_username_requires_m3u_or_xtream_output() {
|
|
let mut target = target_with_outputs(vec![strm_with_username()]);
|
|
|
|
let err = target.prepare(1, None, None).expect_err("STRM username without stream output should fail");
|
|
|
|
assert!(err.to_string().contains("xtream or m3u output"));
|
|
}
|
|
|
|
#[test]
|
|
fn strm_without_username_requires_m3u_or_xtream_output() {
|
|
let mut target = target_with_outputs(vec![strm_without_username()]);
|
|
|
|
let err = target.prepare(1, None, None).expect_err("STRM without stream output should fail");
|
|
|
|
assert!(err.to_string().contains("xtream or m3u output"));
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_deserialize_structured_share_live_streams() {
|
|
let yaml = r"
|
|
share_live_streams:
|
|
hls: true
|
|
mpeg_ts: true
|
|
";
|
|
|
|
let options: ConfigTargetOptions =
|
|
serde_saphyr::from_str(yaml).expect("structured share_live_streams should deserialize");
|
|
|
|
assert!(options.share_live_hls_enabled());
|
|
assert!(options.share_live_mpeg_ts_enabled());
|
|
assert!(options.share_live_any_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_maps_legacy_true_share_live_streams_to_both_modes() {
|
|
let yaml = r"
|
|
share_live_streams: true
|
|
";
|
|
|
|
let options = serde_saphyr::from_str::<ConfigTargetOptions>(yaml);
|
|
|
|
assert!(options.is_ok(), "legacy boolean should deserialize: {options:?}");
|
|
if let Ok(options) = options {
|
|
assert_eq!(options.share_live_streams, ConfigTargetShareLiveStreams { hls: false, mpeg_ts: true });
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_maps_legacy_false_share_live_streams_to_both_modes() {
|
|
let yaml = r"
|
|
share_live_streams: false
|
|
";
|
|
|
|
let options: ConfigTargetOptions =
|
|
serde_saphyr::from_str(yaml).expect("legacy false share_live_streams should deserialize");
|
|
|
|
assert_eq!(options.share_live_streams, ConfigTargetShareLiveStreams { hls: false, mpeg_ts: false });
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_omit_default_share_live_streams() {
|
|
let options = ConfigTargetOptions::default();
|
|
|
|
assert!(options.is_empty());
|
|
assert!(!options.clear_invalid_epg_ids());
|
|
|
|
let serialized = serde_saphyr::to_string(&options).expect("default options should serialize");
|
|
assert!(
|
|
!serialized.contains("share_live_streams"),
|
|
"default share_live_streams should be omitted, got: {serialized}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_round_trips_partial_share_live_streams() {
|
|
let options = ConfigTargetOptions {
|
|
share_live_streams: ConfigTargetShareLiveStreams { hls: true, mpeg_ts: false },
|
|
..ConfigTargetOptions::default()
|
|
};
|
|
|
|
let serialized = serde_saphyr::to_string(&options).expect("partial share_live_streams should serialize");
|
|
let reparsed: ConfigTargetOptions =
|
|
serde_saphyr::from_str(&serialized).expect("partial share_live_streams should deserialize");
|
|
|
|
assert_eq!(reparsed.share_live_streams, options.share_live_streams);
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_clear_invalid_epg_ids_roundtrips_and_accepts_legacy_alias() {
|
|
let options = serde_saphyr::from_str::<ConfigTargetOptions>("required_epg: true\n")
|
|
.expect("legacy required_epg should deserialize");
|
|
|
|
assert!(options.clear_invalid_epg_ids());
|
|
assert!(!options.is_empty());
|
|
|
|
let serialized = serde_saphyr::to_string(&options).expect("clear_invalid_epg_ids should serialize");
|
|
assert!(serialized.contains("clear_invalid_epg_ids: true"));
|
|
assert!(!serialized.contains("required_epg:"));
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_default_epg_output_is_disabled_and_omitted() {
|
|
let options = serde_saphyr::from_str::<ConfigTargetOptions>("{}")
|
|
.expect("target options without epg_output should deserialize");
|
|
|
|
assert!(!options.lowercase_epg_ids());
|
|
assert!(!options.lowercase_xmltv_display_names());
|
|
assert!(options.epg_output.is_empty());
|
|
assert!(options.is_empty());
|
|
|
|
let serialized = serde_saphyr::to_string(&options).expect("default target options should serialize");
|
|
assert!(!serialized.contains("epg_output"), "default epg_output should be omitted, got: {serialized}");
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_epg_output_roundtrips() {
|
|
let yaml = r"
|
|
epg_output:
|
|
lowercase_ids: true
|
|
lowercase_xmltv_display_names: true
|
|
";
|
|
|
|
let options =
|
|
serde_saphyr::from_str::<ConfigTargetOptions>(yaml).expect("configured epg_output should deserialize");
|
|
|
|
assert!(options.lowercase_epg_ids());
|
|
assert!(options.lowercase_xmltv_display_names());
|
|
assert!(!options.is_empty());
|
|
|
|
let serialized = serde_saphyr::to_string(&options).expect("configured epg_output should serialize");
|
|
let roundtripped = serde_saphyr::from_str::<ConfigTargetOptions>(&serialized)
|
|
.expect("serialized epg_output should deserialize");
|
|
assert_eq!(roundtripped, options);
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_epg_output_makes_options_nonempty() {
|
|
let lowercase_ids = ConfigTargetOptions {
|
|
epg_output: EpgOutputOptions { lowercase_ids: true, ..EpgOutputOptions::default() },
|
|
..ConfigTargetOptions::default()
|
|
};
|
|
let lowercase_display_names = ConfigTargetOptions {
|
|
epg_output: EpgOutputOptions { lowercase_xmltv_display_names: true, ..EpgOutputOptions::default() },
|
|
..ConfigTargetOptions::default()
|
|
};
|
|
|
|
assert!(!lowercase_ids.is_empty());
|
|
assert!(!lowercase_display_names.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_reject_unknown_epg_output_fields() {
|
|
let yaml = r"
|
|
epg_output:
|
|
lowercase_id: true
|
|
";
|
|
|
|
let result = serde_saphyr::from_str::<ConfigTargetOptions>(yaml);
|
|
|
|
assert!(result.is_err(), "unknown epg_output fields must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn target_options_mpeg_ts_helper_keeps_existing_stream_share_semantics() {
|
|
let hls_only = ConfigTargetOptions {
|
|
share_live_streams: ConfigTargetShareLiveStreams { hls: true, mpeg_ts: false },
|
|
..Default::default()
|
|
};
|
|
let mpeg_ts = ConfigTargetOptions {
|
|
share_live_streams: ConfigTargetShareLiveStreams { hls: false, mpeg_ts: true },
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(hls_only.share_live_hls_enabled());
|
|
assert!(!hls_only.share_live_mpeg_ts_enabled());
|
|
assert!(mpeg_ts.share_live_mpeg_ts_enabled());
|
|
}
|
|
}
|