2026-08-31 01:20:31 +02:00
|
|
|
#![allow(clippy::wildcard_imports)]
|
|
|
|
|
use super::*;
|
|
|
|
|
use shared::{
|
|
|
|
|
foundation::{get_filter, MapperScript, ValueProvider},
|
|
|
|
|
model::{
|
|
|
|
|
ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor,
|
|
|
|
|
ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
|
|
|
|
|
XtreamCluster, XtreamPlaylistItem,
|
|
|
|
|
},
|
|
|
|
|
utils::Internable,
|
|
|
|
|
};
|
2026-09-01 15:35:57 +02:00
|
|
|
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias};
|
2026-08-31 01:20:31 +02:00
|
|
|
|
|
|
|
|
fn serialize_without_trailing_fields<T: serde::Serialize>(value: &T, trailing_fields: &[u8]) -> Vec<u8> {
|
|
|
|
|
let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize");
|
|
|
|
|
for expected in trailing_fields {
|
|
|
|
|
assert_eq!(encoded.pop(), Some(*expected), "unexpected trailing MessagePack field");
|
|
|
|
|
}
|
|
|
|
|
let removed = trailing_fields.len();
|
|
|
|
|
match encoded[0] {
|
|
|
|
|
marker @ 0x92..=0x9f => {
|
|
|
|
|
let len = usize::from(marker - 0x90);
|
|
|
|
|
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
|
|
|
|
|
encoded[0] = 0x90 + u8::try_from(len - removed).unwrap_or_default();
|
|
|
|
|
}
|
|
|
|
|
0xdc => {
|
|
|
|
|
let len = u16::from_be_bytes([encoded[1], encoded[2]]);
|
|
|
|
|
let removed = u16::try_from(removed).unwrap_or(u16::MAX);
|
|
|
|
|
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
|
|
|
|
|
encoded[1..3].copy_from_slice(&(len - removed).to_be_bytes());
|
|
|
|
|
}
|
|
|
|
|
0xdd => {
|
|
|
|
|
let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]);
|
|
|
|
|
let removed = u32::try_from(removed).unwrap_or(u32::MAX);
|
|
|
|
|
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
|
|
|
|
|
encoded[1..5].copy_from_slice(&(len - removed).to_be_bytes());
|
|
|
|
|
}
|
|
|
|
|
marker => panic!("unexpected MessagePack sequence marker {marker:#x}"),
|
|
|
|
|
}
|
|
|
|
|
encoded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn item_with_props(props: StreamProperties) -> PlaylistItem {
|
|
|
|
|
let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() };
|
|
|
|
|
PlaylistItem { header }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn live_item_with_probe_timestamp_and_bitrate(last_probed_timestamp: i64, bitrate: u32) -> PlaylistItem {
|
|
|
|
|
item_with_props(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: Some("{\"codec_name\":\"aac\"}".intern()),
|
|
|
|
|
bitrate,
|
|
|
|
|
last_probed_timestamp: Some(last_probed_timestamp),
|
|
|
|
|
..Default::default()
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rename_preserves_input_stream_id_captured_at_target_boundary() {
|
|
|
|
|
let mut item = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
id: "origin-alpha".intern(),
|
|
|
|
|
url: "http://provider.example/channel.m3u8".intern(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
item.header.freeze_input_stream_id();
|
|
|
|
|
let rename = ConfigRename::from(&ConfigRenameDto {
|
|
|
|
|
field: ItemField::Url,
|
|
|
|
|
pattern: "provider".to_string(),
|
|
|
|
|
new_name: "target".to_string(),
|
|
|
|
|
t_pattern: None,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
exec_rename(&mut item, Some(&vec![rename]));
|
|
|
|
|
|
|
|
|
|
assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8");
|
|
|
|
|
assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mapper_changes_id_without_changing_frozen_input_stream_id() {
|
|
|
|
|
let mut item = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader { id: "origin-alpha".intern(), name: "Channel".intern(), ..Default::default() },
|
|
|
|
|
};
|
|
|
|
|
item.header.freeze_input_stream_id();
|
|
|
|
|
let mapping = CompiledMapping {
|
|
|
|
|
rules: vec![CompiledMappingRule {
|
|
|
|
|
name: None,
|
|
|
|
|
filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"),
|
|
|
|
|
program: MappingProgram::Script(
|
|
|
|
|
MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"),
|
|
|
|
|
),
|
|
|
|
|
}],
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let outcome = map_channel(item, &mapping);
|
|
|
|
|
|
|
|
|
|
assert_eq!(outcome.matched_rules, 1);
|
|
|
|
|
assert_eq!(outcome.channel.header.id.as_ref(), "target-id");
|
|
|
|
|
assert_eq!(outcome.channel.header.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() {
|
|
|
|
|
let mut source = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
id: "80510".intern(),
|
|
|
|
|
url: "http://provider.example/live/user/pass/80510.ts".intern(),
|
|
|
|
|
input_name: "input".intern(),
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
source.header.freeze_input_stream_id();
|
|
|
|
|
let mut legacy_xtream = XtreamPlaylistItem::from(&source);
|
|
|
|
|
legacy_xtream.provider_id = 0;
|
|
|
|
|
legacy_xtream.input_stream_id = "".intern();
|
|
|
|
|
legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern();
|
|
|
|
|
let mut legacy_item = PlaylistItem::from(&legacy_xtream);
|
|
|
|
|
legacy_item.header.freeze_input_stream_id();
|
|
|
|
|
let mapping = CompiledMapping {
|
|
|
|
|
rules: vec![CompiledMappingRule {
|
|
|
|
|
name: None,
|
|
|
|
|
filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"),
|
|
|
|
|
program: MappingProgram::Script(
|
|
|
|
|
MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"),
|
|
|
|
|
),
|
|
|
|
|
}],
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let outcome = map_channel(legacy_item, &mapping);
|
|
|
|
|
let materialized_m3u = M3uPlaylistItem::from(&outcome.channel);
|
|
|
|
|
let materialized_xtream = XtreamPlaylistItem::from(&outcome.channel);
|
|
|
|
|
|
|
|
|
|
assert_eq!(outcome.matched_rules, 1);
|
|
|
|
|
assert_eq!(outcome.channel.header.id.as_ref(), "target-id");
|
|
|
|
|
assert_eq!(outcome.channel.get_input_stream_id(), None);
|
|
|
|
|
assert!(materialized_m3u.provider_id.is_empty());
|
|
|
|
|
assert_eq!(materialized_m3u.get_input_stream_id(), None);
|
|
|
|
|
assert_eq!(materialized_xtream.provider_id, 0);
|
|
|
|
|
assert_eq!(materialized_xtream.get_input_stream_id(), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() {
|
|
|
|
|
let input = ConfigInput::default();
|
|
|
|
|
let item = PlaylistItem { header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() } };
|
|
|
|
|
let source = MemoryPlaylistSource::new(vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Group".intern(),
|
|
|
|
|
channels: vec![item],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}])
|
|
|
|
|
.into_source();
|
|
|
|
|
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
|
|
|
|
|
let mut duplicates = HashSet::new();
|
|
|
|
|
let target = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
|
|
|
|
|
let (mut processed, _outcome) =
|
|
|
|
|
execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false).expect("target processing should succeed");
|
|
|
|
|
let mut groups = processed.source.take_groups();
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
assert!(groups[0].channels[0].header.set_field("id", "late-target-id"));
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
|
|
|
|
|
let mut source = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
id: "origin-alpha".intern(),
|
|
|
|
|
url: "http://provider.example/live/user/pass/80510.ts".intern(),
|
|
|
|
|
input_name: "input".intern(),
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]);
|
|
|
|
|
let decoded_header: PlaylistItemHeader =
|
|
|
|
|
rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize");
|
|
|
|
|
assert!(decoded_header.input_stream_id.is_empty());
|
|
|
|
|
assert_eq!(decoded_header.get_input_stream_id(), None);
|
|
|
|
|
assert_eq!(decoded_header.upstream_user_agent, None);
|
|
|
|
|
let mut decoded_header = decoded_header;
|
|
|
|
|
decoded_header.freeze_input_stream_id();
|
|
|
|
|
assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha"));
|
|
|
|
|
|
|
|
|
|
source.header.freeze_input_stream_id();
|
|
|
|
|
let mut m3u_item = M3uPlaylistItem::from(&source);
|
|
|
|
|
m3u_item.input_stream_id = "".intern();
|
|
|
|
|
let m3u_bytes = serialize_without_trailing_fields(&m3u_item, &[0xc0, 0xa0]);
|
|
|
|
|
let decoded_m3u: M3uPlaylistItem = rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize");
|
|
|
|
|
assert!(decoded_m3u.input_stream_id.is_empty());
|
|
|
|
|
assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha"));
|
|
|
|
|
assert_eq!(decoded_m3u.upstream_user_agent, None);
|
|
|
|
|
|
|
|
|
|
let mut xtream_item = XtreamPlaylistItem::from(&source);
|
|
|
|
|
xtream_item.input_stream_id = "".intern();
|
|
|
|
|
let xtream_bytes = serialize_without_trailing_fields(&xtream_item, &[0xc0, 0xa0]);
|
|
|
|
|
let decoded_xtream: XtreamPlaylistItem =
|
|
|
|
|
rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize");
|
|
|
|
|
assert!(decoded_xtream.input_stream_id.is_empty());
|
|
|
|
|
assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510"));
|
|
|
|
|
assert_eq!(decoded_xtream.upstream_user_agent, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() {
|
|
|
|
|
let source = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
id: "80510".intern(),
|
|
|
|
|
input_stream_id: "origin-alpha".intern(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0]))
|
|
|
|
|
.expect("previous header should deserialize");
|
|
|
|
|
let m3u: M3uPlaylistItem =
|
|
|
|
|
rmp_serde::from_slice(&serialize_without_trailing_fields(&M3uPlaylistItem::from(&source), &[0xc0]))
|
|
|
|
|
.expect("previous M3U item should deserialize");
|
|
|
|
|
let xtream: XtreamPlaylistItem =
|
|
|
|
|
rmp_serde::from_slice(&serialize_without_trailing_fields(&XtreamPlaylistItem::from(&source), &[0xc0]))
|
|
|
|
|
.expect("previous Xtream item should deserialize");
|
|
|
|
|
|
|
|
|
|
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
assert_eq!(m3u.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
assert_eq!(xtream.input_stream_id.as_ref(), "origin-alpha");
|
|
|
|
|
assert_eq!(header.upstream_user_agent, None);
|
|
|
|
|
assert_eq!(m3u.upstream_user_agent, None);
|
|
|
|
|
assert_eq!(xtream.upstream_user_agent, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn messagepack_playlist_items_preserve_upstream_user_agent() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
let source = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader { upstream_user_agent: Some("Provider-UA".intern()), ..Default::default() },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let header: PlaylistItemHeader = rmp_serde::from_slice(&rmp_serde::to_vec(&source.header)?)?;
|
|
|
|
|
let m3u: M3uPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&M3uPlaylistItem::from(&source))?)?;
|
|
|
|
|
let xtream: XtreamPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&XtreamPlaylistItem::from(&source))?)?;
|
|
|
|
|
|
|
|
|
|
assert_eq!(header.upstream_user_agent.as_deref(), Some("Provider-UA"));
|
|
|
|
|
assert_eq!(m3u.upstream_user_agent.as_deref(), Some("Provider-UA"));
|
|
|
|
|
assert_eq!(xtream.upstream_user_agent.as_deref(), Some("Provider-UA"));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn has_probe_details_requires_video_and_audio_for_video() {
|
|
|
|
|
let video = shared::model::VideoStreamProperties {
|
|
|
|
|
details: Some(shared::model::VideoStreamDetailProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: None,
|
|
|
|
|
..Default::default()
|
|
|
|
|
}),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let item_missing_audio = item_with_props(StreamProperties::Video(Box::new(video)));
|
|
|
|
|
assert!(!has_probe_details(&item_missing_audio));
|
|
|
|
|
|
|
|
|
|
let video_complete = shared::model::VideoStreamProperties {
|
|
|
|
|
details: Some(shared::model::VideoStreamDetailProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: Some("{\"codec_name\":\"aac\"}".intern()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let item_complete = item_with_props(StreamProperties::Video(Box::new(video_complete)));
|
|
|
|
|
assert!(has_probe_details(&item_complete));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn has_probe_details_requires_video_audio_and_bitrate_for_live() {
|
|
|
|
|
let live_missing_audio = shared::model::LiveStreamProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: None,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let item_missing_audio = item_with_props(StreamProperties::Live(Box::new(live_missing_audio)));
|
|
|
|
|
assert!(!has_probe_details(&item_missing_audio));
|
|
|
|
|
|
|
|
|
|
let live_missing_bitrate = shared::model::LiveStreamProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: Some("{\"codec_name\":\"aac\"}".intern()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let item_missing_bitrate = item_with_props(StreamProperties::Live(Box::new(live_missing_bitrate)));
|
|
|
|
|
assert!(!has_probe_details(&item_missing_bitrate));
|
|
|
|
|
|
|
|
|
|
let live_complete = shared::model::LiveStreamProperties {
|
|
|
|
|
video: Some("{\"codec_name\":\"h264\"}".intern()),
|
|
|
|
|
audio: Some("{\"codec_name\":\"aac\"}".intern()),
|
|
|
|
|
bitrate: 2_500_000,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let item_complete = item_with_props(StreamProperties::Live(Box::new(live_complete)));
|
|
|
|
|
assert!(has_probe_details(&item_complete));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn needs_live_probe_when_fresh_probe_has_no_bitrate() {
|
|
|
|
|
let item = live_item_with_probe_timestamp_and_bitrate(101, 0);
|
|
|
|
|
|
|
|
|
|
assert!(needs_live_probe(&item, 100));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn does_not_need_live_probe_when_fresh_probe_has_positive_bitrate() {
|
|
|
|
|
let item = live_item_with_probe_timestamp_and_bitrate(101, 2_500_000);
|
|
|
|
|
|
|
|
|
|
assert!(!needs_live_probe(&item, 100));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn needs_live_probe_when_positive_bitrate_probe_is_older_than_cutoff() {
|
|
|
|
|
let item = live_item_with_probe_timestamp_and_bitrate(99, 2_500_000);
|
|
|
|
|
|
|
|
|
|
assert!(needs_live_probe(&item, 100));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn has_probe_details_is_false_for_series() {
|
|
|
|
|
let series = shared::model::SeriesStreamProperties::default();
|
|
|
|
|
let item = item_with_props(StreamProperties::Series(Box::new(series)));
|
|
|
|
|
assert!(!has_probe_details(&item));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn collect_effective_skip_clusters_uses_input_skip_flags() {
|
|
|
|
|
use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions};
|
|
|
|
|
let input = ConfigInput {
|
|
|
|
|
name: "skip_live".intern(),
|
|
|
|
|
input_type: InputType::Xtream,
|
|
|
|
|
options: Some(ConfigInputOptions {
|
|
|
|
|
flags: ConfigInputFlags::SkipLive.into(),
|
|
|
|
|
..ConfigInputOptions::defaults().clone()
|
|
|
|
|
}),
|
|
|
|
|
..ConfigInput::default()
|
|
|
|
|
};
|
|
|
|
|
let skip = collect_effective_skip_clusters(&input);
|
|
|
|
|
assert!(skip.contains(&XtreamCluster::Live));
|
|
|
|
|
assert!(!skip.contains(&XtreamCluster::Video));
|
|
|
|
|
assert!(!skip.contains(&XtreamCluster::Series));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn filter_skipped_clusters_removes_cached_groups() {
|
|
|
|
|
use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions};
|
|
|
|
|
let live_item = PlaylistItem {
|
|
|
|
|
header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Live, ..Default::default() },
|
|
|
|
|
};
|
|
|
|
|
let vod_item = PlaylistItem {
|
|
|
|
|
header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Video, ..Default::default() },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let groups = vec![
|
|
|
|
|
PlaylistGroup { id: 1, title: "Live".intern(), channels: vec![live_item], xtream_cluster: XtreamCluster::Live },
|
|
|
|
|
PlaylistGroup { id: 2, title: "Vod".intern(), channels: vec![vod_item], xtream_cluster: XtreamCluster::Video },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let source = MemoryPlaylistSource::new(groups).into_source();
|
|
|
|
|
let input = ConfigInput {
|
|
|
|
|
name: "skip_live".intern(),
|
|
|
|
|
input_type: InputType::Xtream,
|
|
|
|
|
options: Some(ConfigInputOptions {
|
|
|
|
|
flags: ConfigInputFlags::SkipLive.into(),
|
|
|
|
|
..ConfigInputOptions::defaults().clone()
|
|
|
|
|
}),
|
|
|
|
|
..ConfigInput::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut filtered = filter_skipped_clusters_from_source(source, &input);
|
|
|
|
|
let filtered_groups = filtered.take_groups();
|
|
|
|
|
assert_eq!(filtered_groups.len(), 1);
|
|
|
|
|
assert_eq!(filtered_groups[0].xtream_cluster, XtreamCluster::Video);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn test_group(cluster: XtreamCluster, item_name: &str, input_name: &str) -> PlaylistGroup {
|
|
|
|
|
PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: item_name.intern(),
|
|
|
|
|
xtream_cluster: cluster,
|
|
|
|
|
channels: vec![PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
name: item_name.intern(),
|
|
|
|
|
input_name: input_name.intern(),
|
|
|
|
|
xtream_cluster: cluster,
|
|
|
|
|
item_type: match cluster {
|
|
|
|
|
XtreamCluster::Live => PlaylistItemType::Live,
|
|
|
|
|
XtreamCluster::Video => PlaylistItemType::Video,
|
|
|
|
|
XtreamCluster::Series => PlaylistItemType::Series,
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn staged_overlay_replaces_selected_clusters_and_rewrites_input_name() {
|
|
|
|
|
let provider_name = "provider".intern();
|
|
|
|
|
let provider_groups = vec![
|
|
|
|
|
test_group(XtreamCluster::Live, "provider-live", "provider"),
|
|
|
|
|
test_group(XtreamCluster::Video, "provider-vod", "provider"),
|
|
|
|
|
];
|
|
|
|
|
let staged_groups = vec![
|
|
|
|
|
test_group(XtreamCluster::Live, "staged-live", "staged"),
|
|
|
|
|
test_group(XtreamCluster::Series, "staged-series", "staged"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let groups = apply_staged_overlay_groups(&provider_name, ClusterFlags::Live, provider_groups, staged_groups);
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups.len(), 2);
|
|
|
|
|
assert_eq!(groups[0].title.as_ref(), "provider-vod");
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
|
|
|
|
|
assert_eq!(groups[1].title.as_ref(), "staged-live");
|
|
|
|
|
assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn staged_overlay_is_skipped_when_provider_playlist_is_cached() {
|
|
|
|
|
let result = PlaylistDownloadResult::new(vec![], vec![], true, false);
|
|
|
|
|
|
|
|
|
|
assert!(!should_apply_staged_overlay(&result));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_test_item(name: &str, item_type: PlaylistItemType) -> PlaylistItem {
|
|
|
|
|
let header =
|
|
|
|
|
PlaylistItemHeader { name: name.into(), group: "Test Group".intern(), item_type, ..Default::default() };
|
|
|
|
|
PlaylistItem { header }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_filter_evalutes_correctly() {
|
|
|
|
|
let filter = get_filter(r#"name ~ "Allowed""#, None).unwrap();
|
|
|
|
|
|
|
|
|
|
let allowed_item = make_test_item("Allowed Channel", PlaylistItemType::Live);
|
|
|
|
|
let denied_item = make_test_item("Denied Channel", PlaylistItemType::Live);
|
|
|
|
|
|
|
|
|
|
let allowed_provider = ValueProvider { pli: &allowed_item, match_as_ascii: false };
|
|
|
|
|
let denied_provider = ValueProvider { pli: &denied_item, match_as_ascii: false };
|
|
|
|
|
|
|
|
|
|
assert!(filter.filter(&allowed_provider));
|
|
|
|
|
assert!(!filter.filter(&denied_provider));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_filter_with_type_comparison() {
|
|
|
|
|
let filter = get_filter("type = vod", None).unwrap();
|
|
|
|
|
|
|
|
|
|
let vod_item = make_test_item("Test Movie", PlaylistItemType::Video);
|
|
|
|
|
let live_item = make_test_item("Test Channel", PlaylistItemType::Live);
|
|
|
|
|
|
|
|
|
|
let vod_provider = ValueProvider { pli: &vod_item, match_as_ascii: false };
|
|
|
|
|
let live_provider = ValueProvider { pli: &live_item, match_as_ascii: false };
|
|
|
|
|
|
|
|
|
|
assert!(filter.filter(&vod_provider));
|
|
|
|
|
assert!(!filter.filter(&live_provider));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn playlist_retention_reports_filter_counts() {
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Test Group".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
make_test_item("Allowed", PlaylistItemType::Live),
|
|
|
|
|
make_test_item("Denied", PlaylistItemType::Live),
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let mut source = MemoryPlaylistSource::new(groups).into_source();
|
|
|
|
|
|
|
|
|
|
let (filtered, outcome) = retain_playlist_items(&mut source, |item| item.header.name.as_ref() == "Allowed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(outcome, FilterOutcome { inspected: 2, retained: 1, removed: 1 });
|
|
|
|
|
assert_eq!(filtered.expect("one item should remain")[0].channels[0].header.name.as_ref(), "Allowed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn filter_stage_can_remove_every_item() {
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Test Group".intern(),
|
|
|
|
|
channels: vec![make_test_item("Denied", PlaylistItemType::Live)],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into();
|
|
|
|
|
|
|
|
|
|
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
|
|
|
|
|
|
|
|
|
|
assert!(groups.is_empty());
|
|
|
|
|
assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 1, retained: 0, removed: 1 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_processing_filter_skips_filter_stage() {
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Test Group".intern(),
|
|
|
|
|
channels: vec![make_test_item("Allowed", PlaylistItemType::Live)],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let target = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
|
|
|
|
|
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups.len(), 1);
|
|
|
|
|
assert_eq!(groups[0].channels.len(), 1);
|
|
|
|
|
assert!(outcome.filter.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_processing_filter_preserves_filter_stage_group_normalization() {
|
|
|
|
|
let mut first = make_test_item("One", PlaylistItemType::Live);
|
|
|
|
|
first.header.group = "News".intern();
|
|
|
|
|
let mut second = make_test_item("Two", PlaylistItemType::Live);
|
|
|
|
|
second.header.group = "news".intern();
|
|
|
|
|
let groups = vec![
|
|
|
|
|
PlaylistGroup { id: 1, title: "News".intern(), channels: vec![first], xtream_cluster: XtreamCluster::Live },
|
|
|
|
|
PlaylistGroup { id: 2, title: "news".intern(), channels: vec![second], xtream_cluster: XtreamCluster::Live },
|
|
|
|
|
];
|
|
|
|
|
let target = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
|
|
|
|
|
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups.len(), 1);
|
|
|
|
|
assert_eq!(groups[0].channels.len(), 2);
|
|
|
|
|
assert!(outcome.filter.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn pipeline_reports_filter_and_rename_outcomes() {
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Test Group".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
make_test_item("Allowed", PlaylistItemType::Live),
|
|
|
|
|
make_test_item("Denied", PlaylistItemType::Live),
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into();
|
|
|
|
|
target.rename = Some(vec![ConfigRename::from(&ConfigRenameDto {
|
|
|
|
|
field: ItemField::Name,
|
|
|
|
|
pattern: "Allowed".to_string(),
|
|
|
|
|
new_name: "Renamed".to_string(),
|
|
|
|
|
t_pattern: None,
|
|
|
|
|
})]);
|
|
|
|
|
|
|
|
|
|
let (groups, outcome) =
|
|
|
|
|
execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter, TransformStage::Rename]);
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Renamed");
|
|
|
|
|
assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 2, retained: 1, removed: 1 }));
|
|
|
|
|
assert_eq!(outcome.rename, Some(RenameOutcome { inspected: 1, changed_items: 1, changed_fields: 1 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn assign_channel_no_playlist_preserves_non_zero_chno() {
|
|
|
|
|
let mut groups = vec![
|
|
|
|
|
PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Group A".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 10, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
},
|
|
|
|
|
PlaylistGroup {
|
|
|
|
|
id: 2,
|
|
|
|
|
title: "Group C".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 1, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
assign_channel_no_playlist(&mut groups);
|
|
|
|
|
|
|
|
|
|
// Non-zero chno values must be preserved
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.chno, 10);
|
|
|
|
|
assert_eq!(groups[1].channels[0].header.chno, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn assign_channel_no_playlist_assigns_zero_chno_only() {
|
|
|
|
|
let mut groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Group A".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
assign_channel_no_playlist(&mut groups);
|
|
|
|
|
|
|
|
|
|
// All zero-chno channels should get assigned numbers starting at 1
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.chno, 1);
|
|
|
|
|
assert_eq!(groups[0].channels[1].header.chno, 2);
|
|
|
|
|
assert_eq!(groups[0].channels[2].header.chno, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn assign_channel_no_playlist_skips_existing_nonzero_numbers() {
|
|
|
|
|
let mut groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Group A".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 5, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 2, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
assign_channel_no_playlist(&mut groups);
|
|
|
|
|
|
|
|
|
|
// Existing non-zero numbers (2, 5) must be skipped when assigning new numbers
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.chno, 5); // preserved
|
|
|
|
|
assert_eq!(groups[0].channels[2].header.chno, 2); // preserved
|
|
|
|
|
// B gets 1 (smallest available), D gets 3 (next available after 1 and existing 2)
|
|
|
|
|
assert_eq!(groups[0].channels[1].header.chno, 1);
|
|
|
|
|
assert_eq!(groups[0].channels[3].header.chno, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn assign_channel_no_playlist_assigns_following_group_order() {
|
|
|
|
|
let mut groups = vec![
|
|
|
|
|
PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Group 1".intern(),
|
|
|
|
|
channels: vec![
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
|
|
|
|
|
],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
},
|
|
|
|
|
PlaylistGroup {
|
|
|
|
|
id: 2,
|
|
|
|
|
title: "Group 2".intern(),
|
|
|
|
|
channels: vec![PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() },
|
|
|
|
|
}],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
assign_channel_no_playlist(&mut groups);
|
|
|
|
|
|
|
|
|
|
// Numbers should follow iteration order across groups: A=1, B=2, C=3
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.chno, 1);
|
|
|
|
|
assert_eq!(groups[0].channels[1].header.chno, 2);
|
|
|
|
|
assert_eq!(groups[1].channels[0].header.chno, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn parallel_input_scheduler_serializes_equal_groups_and_overlaps_distinct_groups() {
|
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
|
|
|
|
|
|
async fn observe(active: &AtomicUsize, maximum: &AtomicUsize) {
|
|
|
|
|
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
maximum.fetch_max(current, Ordering::SeqCst);
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
|
|
|
active.fetch_sub(1, Ordering::SeqCst);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let locks = tuliprox_core::utils::FileLockManager::default();
|
|
|
|
|
let active = AtomicUsize::new(0);
|
|
|
|
|
let maximum = AtomicUsize::new(0);
|
|
|
|
|
tokio::join!(
|
|
|
|
|
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
|
|
|
|
|
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(maximum.load(Ordering::SeqCst), 1);
|
|
|
|
|
|
|
|
|
|
maximum.store(0, Ordering::SeqCst);
|
|
|
|
|
tokio::join!(
|
|
|
|
|
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
|
|
|
|
|
with_sequential_group(&locks, Some(8), true, observe(&active, &maximum)),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(maximum.load(Ordering::SeqCst), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn parallel_input_scheduler_releases_group_after_abort() {
|
|
|
|
|
let locks = Arc::new(tuliprox_core::utils::FileLockManager::default());
|
|
|
|
|
let task_locks = Arc::clone(&locks);
|
|
|
|
|
let task = tokio::spawn(async move {
|
|
|
|
|
with_sequential_group(&task_locks, Some(7), true, std::future::pending::<()>()).await;
|
|
|
|
|
});
|
|
|
|
|
tokio::task::yield_now().await;
|
|
|
|
|
task.abort();
|
|
|
|
|
let _ = task.await;
|
|
|
|
|
|
|
|
|
|
tokio::time::timeout(Duration::from_secs(1), with_sequential_group(&locks, Some(7), true, std::future::ready(())))
|
|
|
|
|
.await
|
|
|
|
|
.expect("aborting an input job must release its sequential group");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn input_progress_message_contains_each_target_and_blocking_input() {
|
|
|
|
|
let targets = ["target-a", "target-b"];
|
|
|
|
|
let inputs = ["input-a", "input-b"];
|
|
|
|
|
let messages: Vec<_> = targets
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|target| inputs.iter().map(move |input| target_waiting_message(target, input)))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
assert_eq!(messages.len(), 4);
|
|
|
|
|
for target in targets {
|
|
|
|
|
for input in inputs {
|
|
|
|
|
assert!(messages.contains(&format!("Target '{target}' is waiting for input '{input}'")));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assert!(stalker_checkpoint_message("portal-a").contains("portal-a"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn parallel_target_pipeline_bounds_active_finalizers() {
|
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
|
|
|
|
|
|
let active = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
let maximum = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
let mut tasks = JoinSet::new();
|
|
|
|
|
let mut results = Vec::new();
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
for index in 0..6 {
|
|
|
|
|
wait_for_target_finalizer_slot(&mut tasks, &mut results, &mut errors).await;
|
|
|
|
|
let active = Arc::clone(&active);
|
|
|
|
|
let maximum = Arc::clone(&maximum);
|
|
|
|
|
tasks.spawn(async move {
|
|
|
|
|
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
|
maximum.fetch_max(current, Ordering::SeqCst);
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
|
active.fetch_sub(1, Ordering::SeqCst);
|
|
|
|
|
TargetJobResult {
|
|
|
|
|
index,
|
|
|
|
|
name: format!("target-{index}"),
|
|
|
|
|
result: Ok(()),
|
|
|
|
|
errors: Vec::new(),
|
|
|
|
|
processing: PipelineStats::default(),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
while let Some(result) = tasks.join_next().await {
|
|
|
|
|
collect_target_task_result(result, &mut results, &mut errors);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert!(maximum.load(Ordering::SeqCst) <= MAX_CONCURRENT_TARGET_FINALIZERS);
|
|
|
|
|
assert_eq!(results.len(), 6);
|
|
|
|
|
assert!(errors.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parallel_target_pipeline_normalizes_conflicting_output_resources() {
|
|
|
|
|
let config = Config { storage_dir: "/tmp/tuliprox-target-resources".to_string(), ..Config::default() };
|
|
|
|
|
|
|
|
|
|
let mut spaced = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
spaced.name = "A B".to_string();
|
|
|
|
|
let mut underscored = ConfigTarget::from(&ConfigTargetDto::default());
|
|
|
|
|
underscored.name = "A_B".to_string();
|
|
|
|
|
assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)));
|
|
|
|
|
|
|
|
|
|
spaced.name = "one".to_string();
|
|
|
|
|
spaced.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
|
|
|
|
|
filename: Some("out/../x.m3u".to_string()),
|
|
|
|
|
include_type_in_url: false,
|
|
|
|
|
mask_redirect_url: false,
|
|
|
|
|
filter: None,
|
|
|
|
|
})];
|
|
|
|
|
underscored.name = "two".to_string();
|
|
|
|
|
underscored.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
|
|
|
|
|
filename: Some("x.m3u".to_string()),
|
|
|
|
|
include_type_in_url: false,
|
|
|
|
|
mask_redirect_url: false,
|
|
|
|
|
filter: None,
|
|
|
|
|
})];
|
|
|
|
|
assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod mapping_stage {
|
|
|
|
|
use super::*;
|
|
|
|
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
|
|
|
|
use shared::model::{ConfigPaths, EpgSmartMatchConfigDto};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
use tokio::runtime::Runtime;
|
|
|
|
|
use tuliprox_core::{
|
|
|
|
|
model::{
|
|
|
|
|
EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, MediaToolCapabilities, PersistedEpgSource,
|
|
|
|
|
PersistedEpgSourceKind, SourcesConfig,
|
|
|
|
|
},
|
|
|
|
|
utils::FileLockManager,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fn build_mapping(id: &str, stage: MappingStage, script: &str) -> CompiledMapping {
|
|
|
|
|
CompiledMapping {
|
|
|
|
|
id: id.to_string(),
|
|
|
|
|
match_as_ascii: false,
|
|
|
|
|
stage,
|
|
|
|
|
rules: vec![CompiledMappingRule {
|
|
|
|
|
name: None,
|
|
|
|
|
filter: get_filter(r#"name ~ ".*""#, None).expect("filter parses"),
|
|
|
|
|
program: MappingProgram::Script(MapperScript::parse(script, None).expect("script parses")),
|
|
|
|
|
}],
|
|
|
|
|
counters: vec![],
|
|
|
|
|
templates: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_target(mappings: Vec<CompiledMapping>, remove_duplicates: bool) -> ConfigTarget {
|
|
|
|
|
let dto = ConfigTargetDto {
|
|
|
|
|
options: if remove_duplicates {
|
|
|
|
|
Some(ConfigTargetOptions { remove_duplicates, ..Default::default() })
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
let mut target = ConfigTarget::from(&dto);
|
|
|
|
|
target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(CompiledTargetMappings::new(
|
|
|
|
|
mappings.into_iter().map(Arc::new).collect(),
|
|
|
|
|
)))));
|
|
|
|
|
target
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pinned to `NoopSink` rather than staying generic: these tests
|
|
|
|
|
/// exercise the pipeline, not the bus, and an inferred sink type
|
|
|
|
|
/// would just make every call site name one.
|
|
|
|
|
fn processing_context() -> PlaylistProcessingContext<shared::model::NoopSink> {
|
|
|
|
|
let paths = ConfigPaths {
|
|
|
|
|
home_path: String::new(),
|
|
|
|
|
config_path: String::new(),
|
|
|
|
|
storage_path: String::new(),
|
|
|
|
|
config_file_path: String::new(),
|
|
|
|
|
sources_file_path: String::new(),
|
|
|
|
|
mapping_file_path: None,
|
|
|
|
|
mapping_files_used: None,
|
|
|
|
|
template_file_path: None,
|
|
|
|
|
template_files_used: None,
|
|
|
|
|
api_proxy_file_path: String::new(),
|
|
|
|
|
custom_stream_response_path: None,
|
|
|
|
|
};
|
|
|
|
|
let config = AppConfig {
|
|
|
|
|
config: Arc::new(ArcSwap::from_pointee(Config::default())),
|
|
|
|
|
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
|
|
|
|
|
hdhomerun: Arc::new(ArcSwapOption::default()),
|
|
|
|
|
api_proxy: Arc::new(ArcSwapOption::default()),
|
|
|
|
|
file_locks: Arc::new(FileLockManager::default()),
|
|
|
|
|
paths: Arc::new(ArcSwap::from_pointee(paths)),
|
|
|
|
|
custom_stream_response: Arc::new(ArcSwapOption::default()),
|
|
|
|
|
access_token_secret: [0; 32],
|
|
|
|
|
encrypt_secret: [0; 16],
|
|
|
|
|
media_tools: Arc::new(MediaToolCapabilities::new()),
|
|
|
|
|
};
|
|
|
|
|
PlaylistProcessingContext {
|
|
|
|
|
client: reqwest::Client::new(),
|
|
|
|
|
config: Arc::new(config),
|
|
|
|
|
user_targets: Arc::new(ProcessTargets {
|
|
|
|
|
enabled: false,
|
|
|
|
|
inputs: Vec::new(),
|
|
|
|
|
targets: Vec::new(),
|
|
|
|
|
target_names: Vec::new(),
|
|
|
|
|
}),
|
|
|
|
|
events: shared::model::NoopSink,
|
|
|
|
|
playlist_state: None,
|
|
|
|
|
disabled_headers: None,
|
|
|
|
|
processed_inputs: Arc::new(Mutex::new(HashSet::new())),
|
|
|
|
|
input_locks: Arc::new(Mutex::new(HashMap::new())),
|
|
|
|
|
provider_manager: None,
|
|
|
|
|
metadata_manager: None,
|
|
|
|
|
pre_processed_inputs: None,
|
|
|
|
|
stalker_refresh_mode: StalkerRefreshMode::Complete,
|
|
|
|
|
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 15:35:57 +02:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn m3u_alias_playlist_is_downloaded_and_indexed_separately() {
|
|
|
|
|
let temp = tempfile::tempdir().expect("temp dir should be created");
|
|
|
|
|
let primary_playlist_path = temp.path().join("primary.m3u");
|
|
|
|
|
let alias_playlist_path = temp.path().join("backup.m3u");
|
|
|
|
|
tokio::fs::write(
|
|
|
|
|
&primary_playlist_path,
|
|
|
|
|
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=primary-stream-token\n",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("primary fixture should be written");
|
|
|
|
|
tokio::fs::write(
|
|
|
|
|
&alias_playlist_path,
|
|
|
|
|
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=backup-stream-token\n",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("alias fixture should be written");
|
|
|
|
|
|
|
|
|
|
let ctx = processing_context();
|
|
|
|
|
let config =
|
|
|
|
|
Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() };
|
|
|
|
|
ctx.config.config.store(Arc::new(config));
|
|
|
|
|
let input = Arc::new(ConfigInput {
|
|
|
|
|
id: 1,
|
|
|
|
|
name: "primary-account".intern(),
|
|
|
|
|
input_type: InputType::M3u,
|
|
|
|
|
url: primary_playlist_path.to_string_lossy().into_owned(),
|
|
|
|
|
enabled: true,
|
|
|
|
|
aliases: Some(vec![ConfigInputAlias {
|
|
|
|
|
id: 2,
|
|
|
|
|
name: "backup-account".intern(),
|
|
|
|
|
url: alias_playlist_path.to_string_lossy().into_owned(),
|
|
|
|
|
username: None,
|
|
|
|
|
password: None,
|
|
|
|
|
priority: 1,
|
|
|
|
|
max_connections: 1,
|
|
|
|
|
exp_date: None,
|
|
|
|
|
enabled: true,
|
|
|
|
|
stalker: None,
|
|
|
|
|
}]),
|
|
|
|
|
..ConfigInput::default()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let (errors, mut primary_playlist, storage_error, partial) = download_input(&ctx, &input, false).await;
|
|
|
|
|
|
|
|
|
|
assert!(errors.is_empty(), "unexpected download errors: {errors:?}");
|
|
|
|
|
assert!(storage_error.is_none(), "unexpected primary storage error: {storage_error:?}");
|
|
|
|
|
assert!(!partial);
|
|
|
|
|
assert!(!primary_playlist.is_empty());
|
|
|
|
|
let alias_url = tuliprox_repository::load_input_m3u_stream_url(
|
|
|
|
|
&ctx.config,
|
|
|
|
|
&"backup-account".intern(),
|
|
|
|
|
"http://stream.example:4000/323/mono.m3u8?token=primary-stream-token",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("alias URL lookup should succeed");
|
|
|
|
|
assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=backup-stream-token"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn failed_m3u_alias_is_retried_after_primary_input_is_processed() {
|
|
|
|
|
let temp = tempfile::tempdir().expect("temp dir should be created");
|
|
|
|
|
let primary_playlist_path = temp.path().join("main.m3u");
|
|
|
|
|
let alias_playlist_path = temp.path().join("retry.m3u");
|
|
|
|
|
tokio::fs::write(
|
|
|
|
|
&primary_playlist_path,
|
|
|
|
|
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=main-stream-token\n",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("primary fixture should be written");
|
|
|
|
|
|
|
|
|
|
let ctx = processing_context();
|
|
|
|
|
let config =
|
|
|
|
|
Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() };
|
|
|
|
|
ctx.config.config.store(Arc::new(config));
|
|
|
|
|
let input = Arc::new(ConfigInput {
|
|
|
|
|
id: 1,
|
|
|
|
|
name: "main-account".intern(),
|
|
|
|
|
input_type: InputType::M3u,
|
|
|
|
|
url: primary_playlist_path.to_string_lossy().into_owned(),
|
|
|
|
|
enabled: true,
|
|
|
|
|
aliases: Some(vec![ConfigInputAlias {
|
|
|
|
|
id: 2,
|
|
|
|
|
name: "retry-account".intern(),
|
|
|
|
|
url: alias_playlist_path.to_string_lossy().into_owned(),
|
|
|
|
|
username: None,
|
|
|
|
|
password: None,
|
|
|
|
|
priority: 1,
|
|
|
|
|
max_connections: 1,
|
|
|
|
|
exp_date: None,
|
|
|
|
|
enabled: true,
|
|
|
|
|
stalker: None,
|
|
|
|
|
}]),
|
|
|
|
|
..ConfigInput::default()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let (first_errors, mut first_playlist, first_storage_error, first_partial) =
|
|
|
|
|
download_input(&ctx, &input, false).await;
|
|
|
|
|
|
|
|
|
|
assert!(!first_errors.is_empty(), "missing alias should report an error");
|
|
|
|
|
assert!(first_storage_error.is_none(), "primary storage should succeed: {first_storage_error:?}");
|
|
|
|
|
assert!(!first_partial);
|
|
|
|
|
assert!(!first_playlist.is_empty());
|
|
|
|
|
assert!(ctx.is_input_downloaded("main-account").await);
|
|
|
|
|
assert!(!ctx.is_input_downloaded("retry-account").await);
|
|
|
|
|
|
|
|
|
|
tokio::fs::write(
|
|
|
|
|
&alias_playlist_path,
|
|
|
|
|
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=retry-stream-token\n",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("alias fixture should be written");
|
|
|
|
|
|
|
|
|
|
let (second_errors, mut second_playlist, second_storage_error, second_partial) =
|
|
|
|
|
download_input(&ctx, &input, false).await;
|
|
|
|
|
|
|
|
|
|
assert!(second_errors.is_empty(), "unexpected retry errors: {second_errors:?}");
|
|
|
|
|
assert!(second_storage_error.is_none(), "primary storage should remain readable: {second_storage_error:?}");
|
|
|
|
|
assert!(!second_partial);
|
|
|
|
|
assert!(!second_playlist.is_empty());
|
|
|
|
|
assert!(ctx.is_input_downloaded("retry-account").await);
|
|
|
|
|
let alias_url = tuliprox_repository::load_input_m3u_stream_url(
|
|
|
|
|
&ctx.config,
|
|
|
|
|
&"retry-account".intern(),
|
|
|
|
|
"http://stream.example:4000/323/mono.m3u8?token=main-stream-token",
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("retried alias URL lookup should succeed");
|
|
|
|
|
assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=retry-stream-token"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 01:20:31 +02:00
|
|
|
#[test]
|
|
|
|
|
fn persist_filter_runs_after_after_epg_mapping() {
|
|
|
|
|
let runtime = Runtime::new().expect("runtime");
|
|
|
|
|
runtime.block_on(async {
|
|
|
|
|
let mut input = ConfigInput::from(ConfigInputDto::default());
|
|
|
|
|
input.name = "input".intern();
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Live".intern(),
|
|
|
|
|
channels: vec![PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
name: "Before".intern(),
|
|
|
|
|
group: "Live".intern(),
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let mut playlist =
|
|
|
|
|
FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None };
|
|
|
|
|
let rename = build_mapping("rename", MappingStage::AfterEpg, r#"@Name = "After""#);
|
|
|
|
|
let mut target = build_target(vec![rename], false);
|
|
|
|
|
target.filter.persist = Some(get_filter(r#"Name = "After""#, None).expect("filter parses"));
|
|
|
|
|
let mut stats = HashMap::from([(
|
|
|
|
|
Arc::clone(&input.name),
|
|
|
|
|
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
|
|
|
|
|
)]);
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
let mut prepared = prepare_playlist_for_target(
|
|
|
|
|
&processing_context(),
|
|
|
|
|
std::slice::from_mut(&mut playlist),
|
|
|
|
|
&target,
|
|
|
|
|
&mut stats,
|
|
|
|
|
&mut errors,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("target preparation");
|
|
|
|
|
|
|
|
|
|
assert!(errors.is_empty());
|
|
|
|
|
apply_persist_filter(&target, &mut prepared.playlist);
|
|
|
|
|
let item = &prepared.playlist[0].channels[0];
|
|
|
|
|
assert_eq!(item.header.name.as_ref(), "After");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_channel(name: &str) -> PlaylistItem {
|
|
|
|
|
let mut item = PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
name: name.intern(),
|
|
|
|
|
group: "Originals".intern(),
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
item.header.freeze_input_stream_id();
|
|
|
|
|
item
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn memory_source(channels: Vec<PlaylistItem>) -> PlaylistSource {
|
|
|
|
|
MemoryPlaylistSource::new(vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Live".intern(),
|
|
|
|
|
channels,
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}])
|
|
|
|
|
.into_source()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn channel_count(source: &mut PlaylistSource) -> usize {
|
|
|
|
|
source.take_groups().iter().map(|g| g.channels.len()).sum()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn map_playlist_applies_only_the_requested_stage() {
|
|
|
|
|
let processing = build_mapping("processing", MappingStage::Processing, r#"@name = concat(@Name, "-P")"#);
|
|
|
|
|
let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"@name = concat(@Name, "-E")"#);
|
|
|
|
|
let target = build_target(vec![processing, after_epg], false);
|
|
|
|
|
|
|
|
|
|
let mut source = memory_source(vec![make_channel("Alpha")]);
|
|
|
|
|
let (groups, _) = execute_pipeline_on_groups(source.take_groups(), &target, &[TransformStage::Map]);
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P");
|
|
|
|
|
|
|
|
|
|
let mut source = MemoryPlaylistSource::new(groups).into_source();
|
|
|
|
|
let groups = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None)
|
|
|
|
|
.expect("after_epg mapping should run");
|
|
|
|
|
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P-E");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn map_playlist_at_stage_returns_none_without_consuming_source_when_no_match() {
|
|
|
|
|
let target = build_target(Vec::new(), false);
|
|
|
|
|
let mut source = memory_source(vec![make_channel("Alpha")]);
|
|
|
|
|
|
|
|
|
|
let result = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None);
|
|
|
|
|
assert!(result.is_none(), "no matching stage must return None");
|
|
|
|
|
assert_eq!(channel_count(&mut source), 1, "source must remain intact");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prepare_target_applies_after_epg_mapping_before_sampling_stats() {
|
|
|
|
|
let runtime = Runtime::new().expect("runtime");
|
|
|
|
|
runtime.block_on(async {
|
|
|
|
|
let dir = tempdir().expect("tempdir");
|
|
|
|
|
let ics_path = dir.path().join("bbc.ics");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&ics_path,
|
|
|
|
|
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR",
|
|
|
|
|
)
|
|
|
|
|
.expect("write ics");
|
|
|
|
|
|
|
|
|
|
let mut smart_dto = EpgSmartMatchConfigDto {
|
|
|
|
|
enabled: true,
|
|
|
|
|
fuzzy_matching: false,
|
|
|
|
|
..EpgSmartMatchConfigDto::default()
|
|
|
|
|
};
|
|
|
|
|
smart_dto.prepare().expect("smart config");
|
|
|
|
|
let mut input = ConfigInput::from(ConfigInputDto::default());
|
|
|
|
|
input.name = "input".intern();
|
|
|
|
|
input.epg = Some(EpgConfig {
|
|
|
|
|
sources: vec![],
|
|
|
|
|
smart_match: Some(EpgSmartMatchConfig::from(smart_dto)),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let channels = vec![live_item_for_epg("BBC One")];
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Live".intern(),
|
|
|
|
|
channels,
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
|
|
|
|
|
file_path: ics_path,
|
|
|
|
|
priority: 0,
|
|
|
|
|
logo_override: false,
|
|
|
|
|
kind: PersistedEpgSourceKind::Ics {
|
|
|
|
|
channel_id: "bbc.one".intern(),
|
|
|
|
|
channel_title: Some("BBC One".intern()),
|
|
|
|
|
match_names: vec!["BBC One".intern()],
|
|
|
|
|
config: Box::new(IcsEpgSourceConfig::default()),
|
|
|
|
|
},
|
|
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
let mut playlist = FetchedPlaylist {
|
|
|
|
|
input: &input,
|
|
|
|
|
source: MemoryPlaylistSource::new(groups).into_source(),
|
|
|
|
|
epg: Some(tv_guide),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rename_from_epg = build_mapping(
|
|
|
|
|
"rename",
|
|
|
|
|
MappingStage::AfterEpg,
|
|
|
|
|
r#"epg = @epg_channel_id ~ "(.+)"
|
|
|
|
|
match {
|
|
|
|
|
epg => @Name = epg.1
|
|
|
|
|
}"#,
|
|
|
|
|
);
|
|
|
|
|
let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
|
|
|
|
|
let target = build_target(vec![rename_from_epg, add_virtual], false);
|
|
|
|
|
let mut stats = HashMap::from([(
|
|
|
|
|
Arc::clone(&input.name),
|
|
|
|
|
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
|
|
|
|
|
)]);
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
let prepared = prepare_playlist_for_target(
|
|
|
|
|
&processing_context(),
|
|
|
|
|
std::slice::from_mut(&mut playlist),
|
|
|
|
|
&target,
|
|
|
|
|
&mut stats,
|
|
|
|
|
&mut errors,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("target preparation");
|
|
|
|
|
|
|
|
|
|
assert!(errors.is_empty());
|
|
|
|
|
assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::<usize>(), 2);
|
|
|
|
|
let channel = prepared
|
|
|
|
|
.playlist
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|group| &group.channels)
|
|
|
|
|
.find(|channel| channel.header.group.as_ref() != "Echo")
|
|
|
|
|
.expect("original channel");
|
|
|
|
|
assert_eq!(channel.header.epg_channel_id.as_deref(), Some("bbc.one"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
channel.header.name.as_ref(),
|
|
|
|
|
"bbc.one",
|
|
|
|
|
"after_epg mapper must consume the EPG-enriched field"
|
|
|
|
|
);
|
|
|
|
|
let processed_stats = &stats[&input.name].processed_stats;
|
|
|
|
|
assert_eq!(processed_stats.group_count, 2);
|
|
|
|
|
assert_eq!(processed_stats.channel_count, 2);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn clear_invalid_epg_ids_clears_ids_invalidated_by_after_epg_mapping() {
|
|
|
|
|
let runtime = Runtime::new().expect("runtime");
|
|
|
|
|
runtime.block_on(async {
|
|
|
|
|
let dir = tempdir().expect("tempdir");
|
|
|
|
|
let ics_path = dir.path().join("bbc.ics");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&ics_path,
|
|
|
|
|
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR",
|
|
|
|
|
)
|
|
|
|
|
.expect("write ics");
|
|
|
|
|
|
|
|
|
|
let mut input = ConfigInput::from(ConfigInputDto::default());
|
|
|
|
|
input.name = "input".intern();
|
|
|
|
|
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
|
|
|
|
|
let groups = vec![PlaylistGroup {
|
|
|
|
|
id: 1,
|
|
|
|
|
title: "Live".intern(),
|
|
|
|
|
channels: vec![PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
name: "BBC One".intern(),
|
|
|
|
|
epg_channel_id: Some("bbc.one".intern()),
|
|
|
|
|
group: "Live".intern(),
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
}];
|
|
|
|
|
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
|
|
|
|
|
file_path: ics_path,
|
|
|
|
|
priority: 0,
|
|
|
|
|
logo_override: false,
|
|
|
|
|
kind: PersistedEpgSourceKind::Ics {
|
|
|
|
|
channel_id: "bbc.one".intern(),
|
|
|
|
|
channel_title: Some("BBC One".intern()),
|
|
|
|
|
match_names: vec![],
|
|
|
|
|
config: Box::new(IcsEpgSourceConfig::default()),
|
|
|
|
|
},
|
|
|
|
|
}]);
|
|
|
|
|
let mut playlist = FetchedPlaylist {
|
|
|
|
|
input: &input,
|
|
|
|
|
source: MemoryPlaylistSource::new(groups).into_source(),
|
|
|
|
|
epg: Some(tv_guide),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rewrite_epg =
|
|
|
|
|
build_mapping("rewrite", MappingStage::AfterEpg, r#"@epg_channel_id = "missing.epg""#);
|
|
|
|
|
let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
|
|
|
|
|
let mut target = build_target(vec![rewrite_epg, add_virtual], false);
|
|
|
|
|
target.options = Some(ConfigTargetOptions { clear_invalid_epg_ids: true, ..Default::default() });
|
|
|
|
|
let mut stats = HashMap::from([(
|
|
|
|
|
Arc::clone(&input.name),
|
|
|
|
|
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
|
|
|
|
|
)]);
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
let prepared = prepare_playlist_for_target(
|
|
|
|
|
&processing_context(),
|
|
|
|
|
std::slice::from_mut(&mut playlist),
|
|
|
|
|
&target,
|
|
|
|
|
&mut stats,
|
|
|
|
|
&mut errors,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("target preparation");
|
|
|
|
|
|
|
|
|
|
assert!(errors.is_empty());
|
|
|
|
|
assert!(!prepared.playlist.is_empty());
|
|
|
|
|
assert!(prepared
|
|
|
|
|
.playlist
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|group| &group.channels)
|
|
|
|
|
.all(|channel| channel.header.epg_channel_id.is_none()));
|
|
|
|
|
assert_eq!(stats[&input.name].processed_stats.channel_count, 2);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn live_item_for_epg(name: &str) -> PlaylistItem {
|
|
|
|
|
PlaylistItem {
|
|
|
|
|
header: PlaylistItemHeader {
|
|
|
|
|
name: name.intern(),
|
|
|
|
|
group: "Live".intern(),
|
|
|
|
|
xtream_cluster: XtreamCluster::Live,
|
|
|
|
|
item_type: PlaylistItemType::Live,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn after_epg_hook_runs_on_source_already_deduplicated_by_processing_pipe() {
|
|
|
|
|
let processing = build_mapping("processing", MappingStage::Processing, r#"@group = "PROCESSED""#);
|
|
|
|
|
let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
|
|
|
|
|
let target = build_target(vec![processing, after_epg], true);
|
|
|
|
|
|
|
|
|
|
let input = ConfigInput::default();
|
|
|
|
|
let channel = make_channel("Alpha");
|
|
|
|
|
let mut fetched =
|
|
|
|
|
FetchedPlaylist { input: &input, source: memory_source(vec![channel.clone(), channel]), epg: None };
|
|
|
|
|
let mut duplicates = HashSet::new();
|
|
|
|
|
let (mut processed, _outcome) =
|
|
|
|
|
execute_pipe(&target, &get_processing_pipe(&target), &mut fetched, &mut duplicates, false)
|
|
|
|
|
.expect("processing pipe must run");
|
|
|
|
|
assert_eq!(processed.get_channel_count(), 1, "processing pipe must remove the duplicate");
|
|
|
|
|
|
|
|
|
|
let groups = map_playlist_at_stage(&mut processed.source, &target, MappingStage::AfterEpg, None)
|
|
|
|
|
.expect("after_epg hook must run");
|
|
|
|
|
|
|
|
|
|
assert_eq!(groups.len(), 2);
|
|
|
|
|
assert_eq!(groups[0].title.as_ref(), "PROCESSED");
|
|
|
|
|
assert_eq!(groups[0].channels.len(), 1);
|
|
|
|
|
assert_eq!(groups[1].title.as_ref(), "Echo");
|
|
|
|
|
assert_eq!(groups[1].channels.len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prepare_target_deduplicates_virtual_items_created_by_after_epg_mappings() {
|
|
|
|
|
let runtime = Runtime::new().expect("runtime");
|
|
|
|
|
runtime.block_on(async {
|
|
|
|
|
let first = build_mapping("first", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
|
|
|
|
|
let second = build_mapping("second", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
|
|
|
|
|
let target = build_target(vec![first, second], true);
|
|
|
|
|
let input = ConfigInput { name: "input".intern(), ..Default::default() };
|
|
|
|
|
let mut playlist =
|
|
|
|
|
FetchedPlaylist { input: &input, source: memory_source(vec![make_channel("Alpha")]), epg: None };
|
|
|
|
|
let mut stats = HashMap::from([(
|
|
|
|
|
Arc::clone(&input.name),
|
|
|
|
|
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
|
|
|
|
|
)]);
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
let prepared = prepare_playlist_for_target(
|
|
|
|
|
&processing_context(),
|
|
|
|
|
std::slice::from_mut(&mut playlist),
|
|
|
|
|
&target,
|
|
|
|
|
&mut stats,
|
|
|
|
|
&mut errors,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.expect("target preparation");
|
|
|
|
|
|
|
|
|
|
assert!(errors.is_empty());
|
|
|
|
|
assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::<usize>(), 3);
|
|
|
|
|
assert_eq!(stats[&input.name].processed_stats.channel_count, 3);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod disk_epg_wireup_tests {
|
|
|
|
|
use super::spill_epg_to_disk;
|
|
|
|
|
use shared::model::EpgChannel;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tuliprox_core::model::Epg;
|
|
|
|
|
|
|
|
|
|
/// Build an `Epg` with `channel_count` channels whose ids follow the
|
|
|
|
|
/// `id_base` prefix. Two sources built with the same `id_base` and
|
|
|
|
|
/// `channel_count` will share all channel ids, which is what we need to
|
|
|
|
|
/// exercise the priority-override `Occupied` branch in
|
|
|
|
|
/// `EpgMergeAccumulator::upsert_channel`.
|
|
|
|
|
fn build_epg(id_base: &str, priority: i16, channel_count: usize) -> Epg {
|
|
|
|
|
Epg {
|
|
|
|
|
priority,
|
|
|
|
|
logo_override: false,
|
|
|
|
|
attributes: None,
|
|
|
|
|
children: (0..channel_count)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
let id: Arc<str> = format!("{id_base}-ch-{i:04}").into();
|
|
|
|
|
Arc::new(EpgChannel {
|
|
|
|
|
id: Arc::clone(&id),
|
|
|
|
|
title: Some(format!("title-{priority}-{i}").into()),
|
|
|
|
|
icon: None,
|
|
|
|
|
programmes: vec![shared::model::EpgProgramme::new(
|
|
|
|
|
i64::try_from(i).expect("test index fits in i64"),
|
|
|
|
|
i64::try_from(i + 1).expect("test index fits in i64"),
|
|
|
|
|
id,
|
|
|
|
|
)],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wire-up regression guard: `spill_epg_to_disk` is the function called
|
|
|
|
|
/// by `finalize_prepared_target` when `disk_based_processing = true`. It
|
|
|
|
|
/// must (a) preserve per-source priority on shared channels, (b) clean up
|
|
|
|
|
/// its temp files, and (c) merge into a single `Epg` of the right size.
|
|
|
|
|
///
|
|
|
|
|
/// Both sources share channel ids (`shared-ch-NNNN`), forcing the merge
|
|
|
|
|
/// to take the `Occupied` branch in `EpgMergeAccumulator::upsert_channel`.
|
|
|
|
|
/// The lower-priority source (priority 3) must win, the higher-priority
|
|
|
|
|
/// (priority 7) must be discarded for shared ids. Without this assertion
|
|
|
|
|
/// the test would pass even if priority resolution were broken — the
|
|
|
|
|
/// earlier version used unique ids and therefore never hit the merge path.
|
|
|
|
|
#[test]
|
|
|
|
|
fn spill_epg_to_disk_merges_shared_channels_by_priority() {
|
|
|
|
|
let epg_low = build_epg("shared", 3, 50); // wins on every shared channel
|
|
|
|
|
let epg_high = build_epg("shared", 7, 50); // discarded on every shared channel
|
|
|
|
|
|
|
|
|
|
let merged = spill_epg_to_disk(vec![epg_low, epg_high])
|
|
|
|
|
.expect("disk merge returned an error")
|
|
|
|
|
.expect("merged Epg is unexpectedly None for two non-empty sources");
|
|
|
|
|
|
|
|
|
|
// 50 distinct channels, not 100 — the merge must have collapsed the
|
|
|
|
|
// shared ids.
|
|
|
|
|
assert_eq!(merged.children.len(), 50, "shared channel ids must collapse to one entry, not be duplicated");
|
|
|
|
|
|
|
|
|
|
// Every channel title comes from the lower-priority source. If the
|
|
|
|
|
// merge logic is wrong, some titles will carry the "-7-" marker.
|
|
|
|
|
for ch in &merged.children {
|
|
|
|
|
let title = ch.title.as_deref().expect("title preserved through merge");
|
|
|
|
|
assert!(
|
|
|
|
|
title.starts_with("title-3-"),
|
|
|
|
|
"channel {:?} kept title {title:?} from higher-priority source; \
|
|
|
|
|
priority override is broken",
|
|
|
|
|
ch.id,
|
|
|
|
|
);
|
|
|
|
|
// `add_channel_with_programmes` on the disk-merge path must
|
|
|
|
|
// preserve the lower-priority source's single programme per
|
|
|
|
|
// channel — `upsert_channel` would silently drop them.
|
|
|
|
|
assert_eq!(ch.programmes.len(), 1, "channel {:?} lost programmes through the disk-merge path", ch.id);
|
|
|
|
|
let prog = &ch.programmes[0];
|
|
|
|
|
assert!(prog.title.is_none() || prog.title.as_deref() != Some("title-7"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The non-shared case: sources with disjoint channel ids. Both
|
|
|
|
|
/// sources' channels appear in the result with no priority loss (no
|
|
|
|
|
/// `Occupied` branch is taken).
|
|
|
|
|
#[test]
|
|
|
|
|
fn spill_epg_to_disk_keeps_disjoint_sources_intact() {
|
|
|
|
|
let epg_low = build_epg("src-a", 3, 50);
|
|
|
|
|
let epg_high = build_epg("src-b", 7, 50);
|
|
|
|
|
|
|
|
|
|
let merged = spill_epg_to_disk(vec![epg_low, epg_high])
|
|
|
|
|
.expect("disk merge returned an error")
|
|
|
|
|
.expect("merged Epg is unexpectedly None for two non-empty sources");
|
|
|
|
|
|
|
|
|
|
assert_eq!(merged.children.len(), 100, "disjoint ids must not collapse");
|
|
|
|
|
assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-3-0")));
|
|
|
|
|
assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-7-0")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn spill_epg_to_disk_returns_none_for_empty_input() {
|
|
|
|
|
let merged = spill_epg_to_disk(vec![]).expect("disk merge returned an error");
|
|
|
|
|
assert!(merged.is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|