staged m3u to xtream fix (#877)

* staged m3u to xtream fix

* staged m3u to xtream fix

* staged m3u to xtream fix

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

* fixed race some conditions

---------

Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
euzu
2026-09-24 00:51:12 +02:00
committed by GitHub
co-authored by euzu
parent e6ceddec9a
commit fc51a3acd4
12 changed files with 1973 additions and 483 deletions
@@ -485,25 +485,432 @@ pub(crate) fn cluster_selected(cluster: XtreamCluster, clusters: ClusterFlags) -
}
}
fn prepare_staged_xtream_group(
mut group: PlaylistGroup,
provider: &ConfigInput,
provider_urls: &HashMap<(XtreamCluster, u32), &Arc<str>>,
username: &str,
password: &str,
live_stream_use_prefix: bool,
live_stream_without_extension: bool,
) -> PlaylistGroup {
let initial_count = group.channels.len();
group.channels.retain_mut(|item| {
// The staged item's ID identifies its original Xtream stream.
let Ok(stream_id) = item.header.id.parse::<u32>() else {
return false;
};
// Existing streams keep the provider's playback URL, including a direct source URL.
item.header.url = provider_urls.get(&(group.xtream_cluster, stream_id)).map_or_else(
|| {
let metadata_extension = item.header.get_container_extension();
let container_extension = if group.xtream_cluster == XtreamCluster::Video {
metadata_extension.as_deref().or_else(|| {
shared::utils::extract_extension_from_url(&item.header.url)
.and_then(|extension| extension.strip_prefix('.'))
})
} else {
None
};
tuliprox_parser::xtream::get_xtream_url(
group.xtream_cluster,
&provider.url,
username,
password,
stream_id,
container_extension,
live_stream_use_prefix,
live_stream_without_extension,
)
.into()
},
|url| Arc::clone(url),
);
item.header.input_name = Arc::clone(&provider.name);
true
});
let dropped = initial_count - group.channels.len();
if dropped > 0 {
warn!("Skipped {dropped} staged channel(s) in group '{}' without a numeric Xtream stream ID", group.title);
}
group
}
/// Overlays the staged source on the provider playlist of the clusters it covers.
///
/// The staged playlist is the provider playlist restructured by an external tool: group and channel
/// names may differ, the ids do not. Staged groups are therefore resolved against the provider groups
/// by id, never by name ([`match_staged_groups`]). An overlaid group keeps the provider category id;
/// a staged group that introduces a new category keeps its own id only when that id is still free. A
/// staged group whose channels carry no numeric stream id leaves the provider category untouched, so
/// unusable overlay rows cannot remove provider content.
pub(crate) fn apply_staged_overlay_groups(
provider_name: &Arc<str>,
provider: &ConfigInput,
staged_type: StagedInputType,
clusters: ClusterFlags,
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<PlaylistGroup>,
) -> Vec<PlaylistGroup> {
let mut groups: Vec<PlaylistGroup> =
provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)).collect();
if provider.input_type != InputType::Xtream {
let mut groups: Vec<PlaylistGroup> =
provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)).collect();
groups.extend(staged_groups.into_iter().filter(|group| cluster_selected(group.xtream_cluster, clusters)).map(
|mut group| {
for item in &mut group.channels {
item.header.input_name = Arc::clone(&provider.name);
}
group
},
));
return groups;
}
groups.extend(staged_groups.into_iter().filter(|group| cluster_selected(group.xtream_cluster, clusters)).map(
|mut group| {
for item in &mut group.channels {
item.header.input_name = Arc::clone(provider_name);
let (Some(username), Some(password)) = (provider.username.as_deref(), provider.password.as_deref()) else {
warn!("Skipping staged channels for Xtream input '{}' without credentials", provider.name);
return provider_groups;
};
let live_stream_use_prefix = provider.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix);
let live_stream_without_extension = provider.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension);
let staged_stream_ids: HashSet<(XtreamCluster, u32)> = staged_groups
.iter()
.filter(|group| cluster_selected(group.xtream_cluster, clusters))
.flat_map(|group| {
group.channels.iter().filter_map(|item| {
item.header.id.parse::<u32>().ok().map(|stream_id| (group.xtream_cluster, stream_id))
})
})
.collect();
let mut provider_urls = HashMap::with_capacity(staged_stream_ids.len());
for group in &provider_groups {
if !cluster_selected(group.xtream_cluster, clusters) {
continue;
}
for item in &group.channels {
if let Ok(stream_id) = item.header.id.parse::<u32>() {
let key = (group.xtream_cluster, stream_id);
if staged_stream_ids.contains(&key) {
provider_urls.entry(key).or_insert(&item.header.url);
}
}
group
},
));
}
}
groups
let mut staged_selected: Vec<Option<PlaylistGroup>> = staged_groups
.into_iter()
.filter(|group| cluster_selected(group.xtream_cluster, clusters))
.map(|group| {
Some(prepare_staged_xtream_group(
group,
provider,
&provider_urls,
username,
password,
live_stream_use_prefix,
live_stream_without_extension,
))
})
.collect();
// Category ids of the provider playlist are authoritative and stay reserved across the overlay:
// persistence keys input groups by `(cluster, id)`, so two groups of one cluster sharing an id
// would be collapsed into a single category. Only staged ids are renumbered; a provider playlist
// that already repeats a category id is passed through unchanged.
let mut used_ids = GroupCategoryIds::from_groups(&provider_groups);
let assignment = match_staged_groups(&provider_groups, &staged_selected, staged_type, clusters);
let mut result: Vec<PlaylistGroup> = Vec::with_capacity(provider_groups.len() + staged_selected.len());
for (provider_idx, provider_group) in provider_groups.into_iter().enumerate() {
if !cluster_selected(provider_group.xtream_cluster, clusters) {
result.push(provider_group);
continue;
}
let matched = assignment.staged_of(provider_idx).and_then(|staged_idx| staged_selected[staged_idx].take());
let Some(mut staged) = matched.filter(|staged| !staged.channels.is_empty()) else {
// A staged group whose channels carried no numeric stream id keeps the provider category,
// so the overlaid cluster is never emptied by unusable overlay rows.
result.push(provider_group);
continue;
};
// The provider category id is the identity of the overlaid category: a staged group matched
// by stream ids or title takes it over instead of keeping its own numbering.
staged.id =
if provider_group.id == 0 { used_ids.allocate(provider_group.xtream_cluster) } else { provider_group.id };
sync_channel_category_id(&mut staged);
result.push(staged);
}
// Append newly introduced staged groups that had no corresponding provider group.
for mut staged in staged_selected.into_iter().flatten().filter(|staged| !staged.channels.is_empty()) {
if staged.id == 0 {
// A staged group without a category id introduces a new category, so it only needs a free id.
staged.id = used_ids.allocate(staged.xtream_cluster);
} else if !used_ids.claim(staged.xtream_cluster, staged.id) {
let allocated = used_ids.allocate(staged.xtream_cluster);
warn!(
"Staged group '{}' uses category id {} which is already taken in {}; renumbered to {allocated}",
staged.title, staged.id, staged.xtream_cluster
);
staged.id = allocated;
}
sync_channel_category_id(&mut staged);
result.push(staged);
}
result
}
/// Keeps the channels of a group consistent with its final category id.
///
/// Persistence re-syncs this through [`PlaylistGroup::on_load`], but the overlay result is consumed as
/// a whole: a reader must not see the category id of the staged source on a group that was just given
/// the provider category id.
fn sync_channel_category_id(group: &mut PlaylistGroup) {
let category_id = group.id;
for item in &mut group.channels {
item.header.category_id = category_id;
}
}
/// Staged groups resolved against the provider groups they overlay.
///
/// Both directions are kept in sync, so one staged group can never overlay two provider categories
/// and one provider category can never receive two staged groups.
struct StagedOverlayAssignment {
staged_by_provider: Vec<Option<usize>>,
provider_by_staged: Vec<Option<usize>>,
has_stream_id_evidence: Vec<bool>,
}
impl StagedOverlayAssignment {
fn new(provider_len: usize, staged_len: usize) -> Self {
Self {
staged_by_provider: vec![None; provider_len],
provider_by_staged: vec![None; staged_len],
has_stream_id_evidence: vec![false; staged_len],
}
}
/// Assigns `staged_idx` to `provider_idx`; returns `false` when either side is already taken.
fn assign(&mut self, provider_idx: usize, staged_idx: usize) -> bool {
if self.staged_by_provider[provider_idx].is_some() || self.provider_by_staged[staged_idx].is_some() {
return false;
}
self.staged_by_provider[provider_idx] = Some(staged_idx);
self.provider_by_staged[staged_idx] = Some(provider_idx);
true
}
fn staged_of(&self, provider_idx: usize) -> Option<usize> { self.staged_by_provider[provider_idx] }
fn is_assigned(&self, staged_idx: usize) -> bool { self.provider_by_staged[staged_idx].is_some() }
}
/// Resolves every staged group against the provider groups it overlays.
///
/// The staged playlist is the provider playlist restructured by an external tool: group and channel
/// names may be rewritten, the ids do not. The passes therefore rank the id evidence above the name:
///
/// 1. the channel stream ids decide. A staged channel carries the provider stream id, and the provider
/// playlist itself states which category owns a stream, so this also covers staged sources whose
/// group ids are not provider category ids (a `m3u` staged playlist numbers its groups positionally).
/// 2. the category id decides for Xtream staged groups whose channels the provider playlist does not know.
/// M3U staged group ids are positional and cannot identify provider categories.
/// 3. the title remains the last resort for staged groups without provider stream-id evidence.
fn match_staged_groups(
provider_groups: &[PlaylistGroup],
staged_groups: &[Option<PlaylistGroup>],
staged_type: StagedInputType,
clusters: ClusterFlags,
) -> StagedOverlayAssignment {
let mut assignment = StagedOverlayAssignment::new(provider_groups.len(), staged_groups.len());
match_staged_by_stream_id(provider_groups, staged_groups, clusters, &mut assignment);
if staged_type == StagedInputType::Xtream {
match_staged_by_category_id(provider_groups, staged_groups, clusters, &mut assignment);
}
match_staged_by_title(provider_groups, staged_groups, clusters, &mut assignment);
assignment
}
/// Pass 1: a staged group overlays the provider category owning its streams, strongest overlap first.
fn match_staged_by_stream_id(
provider_groups: &[PlaylistGroup],
staged_groups: &[Option<PlaylistGroup>],
clusters: ClusterFlags,
assignment: &mut StagedOverlayAssignment,
) {
// Only the staged stream ids are indexed, so the memory cost is bounded by the overlay and not
// by the size of the provider playlist.
let staged_stream_ids: HashSet<(XtreamCluster, &str)> = staged_groups
.iter()
.flatten()
.flat_map(|staged| staged.channels.iter().map(|item| (staged.xtream_cluster, item.header.id.as_ref())))
.filter(|(_, stream_id)| !stream_id.is_empty())
.collect();
if staged_stream_ids.is_empty() {
return;
}
let mut provider_idx_by_stream_id: HashMap<(XtreamCluster, &str), usize> = HashMap::new();
for (provider_idx, provider_group) in provider_groups.iter().enumerate() {
if !cluster_selected(provider_group.xtream_cluster, clusters) {
continue;
}
for item in &provider_group.channels {
let key = (provider_group.xtream_cluster, item.header.id.as_ref());
if staged_stream_ids.contains(&key) {
provider_idx_by_stream_id.entry(key).or_insert(provider_idx);
}
}
}
if provider_idx_by_stream_id.is_empty() {
return;
}
// Every staged group is offered all provider categories owning its streams, ranked by overlap: when
// a stronger overlap takes the best-matching category, the next candidate can still claim the group.
let mut candidates: Vec<StreamOverlap> = Vec::new();
let mut hits_by_provider: HashMap<usize, usize> = HashMap::new();
for (staged_idx, staged) in staged_groups.iter().enumerate() {
let Some(staged) = staged else {
continue;
};
hits_by_provider.clear();
for item in &staged.channels {
if let Some(provider_idx) = provider_idx_by_stream_id.get(&(staged.xtream_cluster, item.header.id.as_ref()))
{
*hits_by_provider.entry(*provider_idx).or_insert(0) += 1;
}
}
assignment.has_stream_id_evidence[staged_idx] = !hits_by_provider.is_empty();
candidates.extend(hits_by_provider.iter().map(|(provider_idx, hits)| StreamOverlap {
hits: *hits,
staged_idx,
provider_idx: *provider_idx,
}));
}
// The strongest overlap wins first, so a staged group never loses its category to a weaker candidate.
candidates
.sort_unstable_by_key(|overlap| (std::cmp::Reverse(overlap.hits), overlap.staged_idx, overlap.provider_idx));
for overlap in candidates {
assignment.assign(overlap.provider_idx, overlap.staged_idx);
}
}
/// One provider category a staged group shares streams with.
struct StreamOverlap {
hits: usize,
staged_idx: usize,
provider_idx: usize,
}
/// Pass 2: a staged group overlays the provider category carrying its id.
fn match_staged_by_category_id(
provider_groups: &[PlaylistGroup],
staged_groups: &[Option<PlaylistGroup>],
clusters: ClusterFlags,
assignment: &mut StagedOverlayAssignment,
) {
let mut provider_idx_by_id: HashMap<(XtreamCluster, u32), usize> = HashMap::new();
for (provider_idx, provider_group) in provider_groups.iter().enumerate() {
if provider_group.id != 0 && cluster_selected(provider_group.xtream_cluster, clusters) {
provider_idx_by_id.entry((provider_group.xtream_cluster, provider_group.id)).or_insert(provider_idx);
}
}
for (staged_idx, staged) in staged_groups.iter().enumerate() {
let Some(staged) = staged else {
continue;
};
if staged.id == 0 || assignment.is_assigned(staged_idx) || assignment.has_stream_id_evidence[staged_idx] {
continue;
}
if let Some(provider_idx) = provider_idx_by_id.get(&(staged.xtream_cluster, staged.id)).copied() {
assignment.assign(provider_idx, staged_idx);
}
}
}
/// Pass 3: a staged group overlays the provider category of the same name.
fn match_staged_by_title(
provider_groups: &[PlaylistGroup],
staged_groups: &[Option<PlaylistGroup>],
clusters: ClusterFlags,
assignment: &mut StagedOverlayAssignment,
) {
// Staged groups of the same name are interchangeable, so one lookup per provider category is enough.
let mut staged_by_title: HashMap<(XtreamCluster, &str), Vec<usize>> = HashMap::new();
for (staged_idx, staged) in staged_groups.iter().enumerate() {
if assignment.is_assigned(staged_idx) || assignment.has_stream_id_evidence[staged_idx] {
continue;
}
if let Some(staged) = staged {
staged_by_title.entry((staged.xtream_cluster, staged.title.as_ref())).or_default().push(staged_idx);
}
}
for (provider_idx, provider_group) in provider_groups.iter().enumerate() {
if assignment.staged_of(provider_idx).is_some() || !cluster_selected(provider_group.xtream_cluster, clusters) {
continue;
}
let key = (provider_group.xtream_cluster, provider_group.title.as_ref());
let Some(free_groups) = staged_by_title.get_mut(&key) else {
continue;
};
if !free_groups.is_empty() {
// Buckets stay tiny, so taking the first free group keeps the earlier first-match semantics.
let staged_idx = free_groups.remove(0);
assignment.assign(provider_idx, staged_idx);
}
}
}
/// Cluster-scoped category ids of a merged playlist.
///
/// Provider category ids are reserved up front because they are authoritative: an overlaid group
/// keeps them, and a staged group that introduces a new category must not take them over.
#[derive(Default)]
struct GroupCategoryIds {
used: HashMap<XtreamCluster, HashSet<u32>>,
next_free: HashMap<XtreamCluster, u32>,
}
impl GroupCategoryIds {
fn from_groups(groups: &[PlaylistGroup]) -> Self {
let mut ids = Self::default();
for group in groups {
if group.id != 0 {
ids.used.entry(group.xtream_cluster).or_default().insert(group.id);
}
}
for (cluster, used) in &ids.used {
ids.next_free.insert(*cluster, used.iter().copied().max().unwrap_or(0).saturating_add(1));
}
ids
}
/// Claims `id` for `cluster`; a zero id or an id already in use is not claimable.
fn claim(&mut self, cluster: XtreamCluster, id: u32) -> bool {
id != 0 && self.used.entry(cluster).or_default().insert(id)
}
/// Reserves and returns the next free id of `cluster`.
fn allocate(&mut self, cluster: XtreamCluster) -> u32 {
let used = self.used.entry(cluster).or_default();
let mut candidate = self.next_free.get(&cluster).copied().unwrap_or(1);
// Zero is never a valid category id. The loop wraps at the u32 edge and ends at the latest
// when it reaches the first free id, which the finite id set always contains.
while candidate == 0 || used.contains(&candidate) {
candidate = candidate.checked_add(1).unwrap_or(1);
}
used.insert(candidate);
self.next_free.insert(cluster, candidate.saturating_add(1));
candidate
}
}
pub(crate) fn should_apply_staged_overlay(download_result: &PlaylistDownloadResult) -> bool {
@@ -1275,7 +1682,8 @@ pub(crate) async fn download_input<E: EventSink + Clone + 'static, M: MetadataUp
} else {
let provider_groups = playlist.take_groups();
let staged_groups = staged_result.source.take_groups();
let merged_groups = apply_staged_overlay_groups(&input.name, clusters, provider_groups, staged_groups);
let merged_groups =
apply_staged_overlay_groups(input, staged_input.staged_type, clusters, provider_groups, staged_groups);
if let Some(input_telemetry) = playlist_download_result.input_telemetry.as_mut() {
neutralize_overlaid_cluster_facts(input_telemetry, clusters);
}
@@ -30,8 +30,8 @@ use shared::{
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision,
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, TargetStats, UUIDType,
WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StagedInputType, StreamProperties, TargetStats,
UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
},
utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable},
};
@@ -16,6 +16,15 @@ use tuliprox_curation::{
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
};
fn apply_staged_overlay_groups(
provider: &ConfigInput,
clusters: ClusterFlags,
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<PlaylistGroup>,
) -> Vec<PlaylistGroup> {
super::apply_staged_overlay_groups(provider, StagedInputType::Xtream, clusters, provider_groups, staged_groups)
}
#[derive(Clone, Default)]
struct PlaylistRunCollectSink(Arc<std::sync::Mutex<Vec<EventMessage>>>);
@@ -682,22 +691,36 @@ fn test_group(cluster: XtreamCluster, item_name: &str, input_name: &str) -> Play
#[test]
fn pipeline_transparency_staged_overlay_replaces_groups_and_neutralizes_overlaid_runtime_facts() {
let provider_name = "provider".intern();
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
options: Some(ConfigInputOptions::defaults().clone()),
..Default::default()
};
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 mut staged_live = test_group(XtreamCluster::Live, "staged-live", "staged");
staged_live.channels[0].header.id = "11203".intern();
staged_live.channels[0].header.url = "http://iptvhost.example/live/fake-user/fake-pass/11203.ts".intern();
let mut invalid = test_group(XtreamCluster::Live, "invalid", "staged").channels.remove(0);
invalid.header.id = "invalid".intern();
invalid.header.url = "http://iptvhost.example/live/fake-user/fake-pass/invalid.ts".intern();
staged_live.channels.push(invalid);
let staged_groups = vec![staged_live, test_group(XtreamCluster::Series, "staged-series", "staged")];
let groups = apply_staged_overlay_groups(&provider_name, ClusterFlags::Live, provider_groups, staged_groups);
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Live, provider_groups, staged_groups);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "provider-vod");
assert_eq!(groups[0].title.as_ref(), "staged-live");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
assert_eq!(groups[1].title.as_ref(), "staged-live");
assert_eq!(groups[0].channels[0].header.url.as_ref(), "http://provider.example/live/real-user/real-pass/11203.ts");
assert_eq!(groups[1].title.as_ref(), "provider-vod");
assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider");
let mut telemetry = PlaylistUpdateInputTelemetry {
@@ -754,6 +777,796 @@ fn pipeline_transparency_staged_overlay_replaces_groups_and_neutralizes_overlaid
assert_eq!(video.decision, Some(PlaylistUpdateClusterDecision::Accepted));
}
#[test]
fn staged_xtream_vod_url_uses_provider_credentials_and_one_extension() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut staged_vod = test_group(XtreamCluster::Video, "staged-vod", "staged");
staged_vod.channels[0].header.id = "310".intern();
staged_vod.channels[0].header.url = "http://iptvhost.example/movie/fake-user/fake-pass/310.mkv".intern();
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Vod, Vec::new(), vec![staged_vod]);
assert_eq!(groups[0].channels[0].header.url.as_ref(), "http://provider.example/movie/real-user/real-pass/310.mkv");
}
#[test]
fn staged_xtream_overlay_keeps_known_provider_stream_urls() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_vod = test_group(XtreamCluster::Video, "Movies", "provider");
provider_vod.channels[0].header.id = "310".intern();
provider_vod.channels[0].header.url = "https://cdn.example/video?id=310".intern();
let mut second_provider = provider_vod.channels[0].clone();
second_provider.header.id = "311".intern();
second_provider.header.url = "http://provider.example/movie/real-user/real-pass/311.mp4".intern();
provider_vod.channels.push(second_provider);
let mut staged_vod = test_group(XtreamCluster::Video, "Edited Movies", "staged");
staged_vod.channels[0].header.id = "310".intern();
staged_vod.channels[0].header.url = "http://editor.example/movie/310.mkv".intern();
let mut second_staged = staged_vod.channels[0].clone();
second_staged.header.id = "311".intern();
second_staged.header.url = "http://editor.example/movie/311.mkv".intern();
staged_vod.channels.push(second_staged);
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Vod, vec![provider_vod], vec![staged_vod]);
assert_eq!(groups[0].title.as_ref(), "Edited Movies");
assert_eq!(groups[0].channels[0].header.url.as_ref(), "https://cdn.example/video?id=310");
assert_eq!(groups[0].channels[1].header.url.as_ref(), "http://provider.example/movie/real-user/real-pass/311.mp4");
}
#[test]
fn staged_xtream_new_vod_stream_uses_metadata_extension() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut staged_vod = test_group(XtreamCluster::Video, "New Movies", "staged");
staged_vod.channels[0].header.id = "310".intern();
staged_vod.channels[0].header.url = "http://editor.example/movie/310".intern();
staged_vod.channels[0].header.additional_properties =
Some(StreamProperties::Video(Box::new(shared::model::VideoStreamProperties {
container_extension: "mkv".intern(),
..Default::default()
})));
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Vod, Vec::new(), vec![staged_vod]);
assert_eq!(groups[0].channels[0].header.url.as_ref(), "http://provider.example/movie/real-user/real-pass/310.mkv");
}
#[test]
fn staged_xtream_live_url_respects_prefix_and_without_extension_flags() {
let provider_prefix = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
options: Some(ConfigInputOptions {
flags: ConfigInputFlags::XtreamLiveStreamUsePrefix.into(),
..ConfigInputOptions::defaults().clone()
}),
..Default::default()
};
let mut staged_live_prefix = test_group(XtreamCluster::Live, "staged-live", "staged");
staged_live_prefix.channels[0].header.id = "11203".intern();
staged_live_prefix.channels[0].header.url = "http://iptvhost.example/fake-user/fake-pass/11203.ts".intern();
let groups_prefix =
apply_staged_overlay_groups(&provider_prefix, ClusterFlags::Live, Vec::new(), vec![staged_live_prefix]);
assert_eq!(
groups_prefix[0].channels[0].header.url.as_ref(),
"http://provider.example/live/real-user/real-pass/11203.ts"
);
let provider_no_ext = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
options: Some(ConfigInputOptions {
flags: ConfigInputFlags::XtreamLiveStreamWithoutExtension.into(),
..ConfigInputOptions::defaults().clone()
}),
..Default::default()
};
let mut staged_live_no_ext = test_group(XtreamCluster::Live, "staged-live", "staged");
staged_live_no_ext.channels[0].header.id = "11203".intern();
staged_live_no_ext.channels[0].header.url = "http://iptvhost.example/fake-user/fake-pass/11203.ts".intern();
let groups_no_ext =
apply_staged_overlay_groups(&provider_no_ext, ClusterFlags::Live, Vec::new(), vec![staged_live_no_ext]);
assert_eq!(groups_no_ext[0].channels[0].header.url.as_ref(), "http://provider.example/real-user/real-pass/11203");
}
#[test]
fn staged_xtream_overlay_recomputes_uuid_on_group_load() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
options: Some(ConfigInputOptions {
flags: ConfigInputFlags::XtreamLiveStreamUsePrefix.into(),
..ConfigInputOptions::defaults().clone()
}),
..Default::default()
};
let mut staged_live = test_group(XtreamCluster::Live, "staged-live", "staged");
staged_live.channels[0].header.id = "11203".intern();
staged_live.channels[0].header.freeze_input_stream_id();
staged_live.channels[0].header.url = "http://iptvhost.example/live/fake-user/fake-pass/11203.ts".intern();
staged_live.channels[0].header.gen_uuid();
let old_uuid = staged_live.channels[0].header.get_uuid().clone();
let mut groups = apply_staged_overlay_groups(&provider, ClusterFlags::Live, Vec::new(), vec![staged_live]);
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
let reconstructed_url = "http://provider.example/live/real-user/real-pass/11203.ts";
assert_eq!(groups[0].channels[0].header.url.as_ref(), reconstructed_url);
assert_eq!(groups[0].channels[0].header.get_input_stream_id().as_deref(), Some("11203"));
groups[0].on_load();
let expected_uuid = shared::utils::generate_runtime_playlist_uuid(
"provider",
"11203",
shared::model::PlaylistItemType::Live,
reconstructed_url,
);
assert_ne!(groups[0].channels[0].header.get_uuid(), &old_uuid);
assert_eq!(groups[0].channels[0].header.get_uuid(), &expected_uuid);
assert_eq!(groups[0].channels[0].header.get_input_stream_id().as_deref(), Some("11203"));
}
#[test]
fn staged_xtream_overlay_full_cluster_staging_replaces_all_matching_groups() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_action = test_group(XtreamCluster::Video, "Action", "provider");
provider_action.channels[0].header.id = "10".intern();
let mut provider_comedy = test_group(XtreamCluster::Video, "Comedy", "provider");
provider_comedy.channels[0].header.id = "20".intern();
let mut staged_action = test_group(XtreamCluster::Video, "Action", "staged");
staged_action.channels[0].header.id = "11".intern();
let mut staged_comedy = test_group(XtreamCluster::Video, "Comedy", "staged");
staged_comedy.channels[0].header.id = "21".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Vod,
vec![provider_action, provider_comedy],
vec![staged_action, staged_comedy],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "Action");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "11");
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
assert_eq!(groups[1].title.as_ref(), "Comedy");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "21");
assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider");
}
#[test]
fn staged_xtream_overlay_partial_cluster_staging_preserves_unstaged_provider_groups() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_action = test_group(XtreamCluster::Video, "Action", "provider");
provider_action.channels[0].header.id = "10".intern();
let mut provider_comedy = test_group(XtreamCluster::Video, "Comedy", "provider");
provider_comedy.channels[0].header.id = "20".intern();
let mut provider_drama = test_group(XtreamCluster::Video, "Drama", "provider");
provider_drama.channels[0].header.id = "30".intern();
let mut staged_action = test_group(XtreamCluster::Video, "Action", "staged");
staged_action.channels[0].header.id = "11".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Vod,
vec![provider_action, provider_comedy, provider_drama],
vec![staged_action],
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].title.as_ref(), "Action");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "11");
assert_eq!(groups[1].title.as_ref(), "Comedy");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "20");
assert_eq!(groups[2].title.as_ref(), "Drama");
assert_eq!(groups[2].channels[0].header.id.as_ref(), "30");
}
#[test]
fn staged_xtream_overlay_no_staged_groups_for_selected_cluster_preserves_all_provider_groups() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let provider_news = test_group(XtreamCluster::Live, "News", "provider");
let provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
let staged_series = test_group(XtreamCluster::Series, "Shows", "staged");
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_news, provider_sports],
vec![staged_series],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "News");
assert_eq!(groups[1].title.as_ref(), "Sports");
}
#[test]
fn staged_xtream_overlay_all_invalid_ids_falls_back_to_provider_group() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.channels[0].header.id = "501".intern();
let mut staged_sports = test_group(XtreamCluster::Live, "Sports", "staged");
staged_sports.channels[0].header.id = "non-numeric-a".intern();
let mut second_invalid = staged_sports.channels[0].clone();
second_invalid.header.id = "non-numeric-b".intern();
staged_sports.channels.push(second_invalid);
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Live, vec![provider_sports], vec![staged_sports]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "Sports");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "501");
}
#[test]
fn staged_xtream_overlay_partially_valid_ids_keeps_valid_and_drops_invalid() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.channels[0].header.id = "501".intern();
let mut staged_sports = test_group(XtreamCluster::Live, "Sports", "staged");
staged_sports.channels[0].header.id = "502".intern();
let mut invalid = staged_sports.channels[0].clone();
invalid.header.id = "invalid-stream".intern();
staged_sports.channels.push(invalid);
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Live, vec![provider_sports], vec![staged_sports]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "Sports");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "502");
}
#[test]
fn staged_xtream_overlay_missing_credentials_preserves_all_provider_groups() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: None,
password: None,
..Default::default()
};
let provider_news = test_group(XtreamCluster::Live, "News", "provider");
let provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
let mut staged_sports = test_group(XtreamCluster::Live, "Sports", "staged");
staged_sports.channels[0].header.id = "501".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_news, provider_sports],
vec![staged_sports],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "News");
assert_eq!(groups[1].title.as_ref(), "Sports");
}
#[test]
fn staged_xtream_overlay_multiple_groups_with_different_clusters() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_live = test_group(XtreamCluster::Live, "News", "provider");
provider_live.channels[0].header.id = "100".intern();
let mut provider_action = test_group(XtreamCluster::Video, "Action", "provider");
provider_action.channels[0].header.id = "200".intern();
let mut provider_comedy = test_group(XtreamCluster::Video, "Comedy", "provider");
provider_comedy.channels[0].header.id = "300".intern();
let mut provider_series = test_group(XtreamCluster::Series, "Shows", "provider");
provider_series.channels[0].header.id = "400".intern();
let mut staged_live = test_group(XtreamCluster::Live, "News", "staged");
staged_live.channels[0].header.id = "101".intern();
let mut staged_action = test_group(XtreamCluster::Video, "Action", "staged");
staged_action.channels[0].header.id = "201".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live | ClusterFlags::Vod,
vec![provider_live, provider_action, provider_comedy, provider_series],
vec![staged_live, staged_action],
);
assert_eq!(groups.len(), 4);
assert_eq!(groups[0].title.as_ref(), "News");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "101");
assert_eq!(groups[1].title.as_ref(), "Action");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "201");
assert_eq!(groups[2].title.as_ref(), "Comedy");
assert_eq!(groups[2].channels[0].header.id.as_ref(), "300");
assert_eq!(groups[3].title.as_ref(), "Shows");
assert_eq!(groups[3].channels[0].header.id.as_ref(), "400");
}
#[test]
fn staged_xtream_overlay_preserves_original_group_order() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut live_1 = test_group(XtreamCluster::Live, "Live1", "provider");
live_1.channels[0].header.id = "1".intern();
let mut vod_1 = test_group(XtreamCluster::Video, "Vod1", "provider");
vod_1.channels[0].header.id = "2".intern();
let mut live_2 = test_group(XtreamCluster::Live, "Live2", "provider");
live_2.channels[0].header.id = "3".intern();
let mut vod_2 = test_group(XtreamCluster::Video, "Vod2", "provider");
vod_2.channels[0].header.id = "4".intern();
let mut staged_live_1 = test_group(XtreamCluster::Live, "Live1", "staged");
staged_live_1.channels[0].header.id = "11".intern();
let mut staged_live_2 = test_group(XtreamCluster::Live, "Live2", "staged");
staged_live_2.channels[0].header.id = "33".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![live_1, vod_1, live_2, vod_2],
vec![staged_live_1, staged_live_2],
);
assert_eq!(groups.len(), 4);
assert_eq!(groups[0].title.as_ref(), "Live1");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "11");
assert_eq!(groups[1].title.as_ref(), "Vod1");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "2");
assert_eq!(groups[2].title.as_ref(), "Live2");
assert_eq!(groups[2].channels[0].header.id.as_ref(), "33");
assert_eq!(groups[3].title.as_ref(), "Vod2");
assert_eq!(groups[3].channels[0].header.id.as_ref(), "4");
}
/// The merged playlist reaches persistence keyed by `(cluster, id)`, which collapses groups that
/// share an id within one cluster.
fn assert_unique_category_ids(groups: &[PlaylistGroup]) {
let mut seen = std::collections::HashSet::new();
for group in groups {
assert!(
seen.insert((group.xtream_cluster, group.id)),
"category id {} is used by more than one {} group",
group.id,
group.xtream_cluster
);
}
}
#[test]
fn staged_overlay_syncs_channel_category_ids_with_the_final_group_id() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_news = test_group(XtreamCluster::Live, "News", "provider");
provider_news.id = 5;
provider_news.channels[0].header.id = "100".intern();
// Matched by stream id, so the group takes over the provider category id.
let mut staged_rewritten = test_group(XtreamCluster::Live, "Rewritten", "staged");
staged_rewritten.id = 7;
staged_rewritten.channels[0].header.id = "100".intern();
staged_rewritten.channels[0].header.category_id = 7;
// No provider counterpart: the group becomes a new category with an allocated id.
let mut staged_kids = test_group(XtreamCluster::Live, "Kids", "staged");
staged_kids.id = 0;
staged_kids.channels[0].header.id = "300".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_news],
vec![staged_rewritten, staged_kids],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].id, 5);
assert_eq!(groups[0].channels[0].header.category_id, 5);
assert_eq!(groups[1].id, 6);
assert_eq!(groups[1].channels[0].header.category_id, 6);
assert_unique_category_ids(&groups);
}
#[test]
fn staged_overlay_on_non_xtream_provider_replaces_cluster_and_rewrites_input_name() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::M3u,
url: "http://provider.example/list.m3u".to_string(),
..Default::default()
};
let provider_live = test_group(XtreamCluster::Live, "provider-live", "provider");
let provider_vod = test_group(XtreamCluster::Video, "provider-vod", "provider");
let mut staged_live = test_group(XtreamCluster::Live, "staged-live", "staged");
staged_live.channels[0].header.url = "http://editor.example/live/1.ts".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_live, provider_vod],
vec![staged_live],
);
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");
// A non-Xtream provider cannot rebuild Xtream stream URLs, so the staged URL and its group id stay.
assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider");
assert_eq!(groups[1].channels[0].header.url.as_ref(), "http://editor.example/live/1.ts");
assert_eq!(groups[1].id, 1);
}
#[test]
fn staged_xtream_overlay_matches_by_category_id_not_by_title() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
// The staged tool renamed the provider category 7 and moved its streams; the title now collides
// with the unrelated provider category 5.
let mut provider_kids = test_group(XtreamCluster::Live, "Kids", "provider");
provider_kids.id = 5;
provider_kids.channels[0].header.id = "105".intern();
let mut provider_news = test_group(XtreamCluster::Live, "News", "provider");
provider_news.id = 7;
provider_news.channels[0].header.id = "107".intern();
let mut staged_renamed = test_group(XtreamCluster::Live, "Kids", "staged");
staged_renamed.id = 7;
staged_renamed.channels[0].header.id = "207".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_kids, provider_news],
vec![staged_renamed],
);
assert_eq!(groups.len(), 2);
// Category 5 keeps its content: the staged group belongs to category 7.
assert_eq!(groups[0].title.as_ref(), "Kids");
assert_eq!(groups[0].id, 5);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "105");
// Category 7 carries the staged rename and the staged stream, under its own id.
assert_eq!(groups[1].title.as_ref(), "Kids");
assert_eq!(groups[1].id, 7);
assert_eq!(groups[1].channels[0].header.id.as_ref(), "207");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_xtream_overlay_matches_by_stream_id_over_group_id() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_news = test_group(XtreamCluster::Live, "News", "provider");
provider_news.id = 5;
provider_news.channels[0].header.id = "100".intern();
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.id = 7;
provider_sports.channels[0].header.id = "200".intern();
// A m3u staged playlist numbers its groups on its own, so the group id points at another
// provider category. The stream id of the overlaid channel proves which category it replaces.
let mut staged_sports = test_group(XtreamCluster::Live, "Sports Rewritten", "staged");
staged_sports.id = 5;
staged_sports.channels[0].header.id = "200".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_news, provider_sports],
vec![staged_sports],
);
assert_eq!(groups.len(), 2);
// Category 5 keeps its content: no staged channel belongs to it.
assert_eq!(groups[0].title.as_ref(), "News");
assert_eq!(groups[0].id, 5);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "100");
// Category 7 carries the rewritten staged group, under its own id.
assert_eq!(groups[1].title.as_ref(), "Sports Rewritten");
assert_eq!(groups[1].id, 7);
assert_eq!(groups[1].channels[0].header.id.as_ref(), "200");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_xtream_split_group_does_not_replace_an_unrelated_category() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.id = 5;
provider_sports.channels[0].header.id = "100".intern();
let mut second_sport = provider_sports.channels[0].clone();
second_sport.header.id = "101".intern();
provider_sports.channels.push(second_sport);
let mut provider_news = test_group(XtreamCluster::Live, "News", "provider");
provider_news.id = 7;
provider_news.channels[0].header.id = "200".intern();
let mut staged_first = test_group(XtreamCluster::Live, "Sports A", "staged");
staged_first.id = 5;
staged_first.channels[0].header.id = "100".intern();
let mut staged_second = test_group(XtreamCluster::Live, "News", "staged");
staged_second.id = 7;
staged_second.channels[0].header.id = "101".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_sports, provider_news],
vec![staged_first, staged_second],
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].id, 5);
assert_eq!(groups[0].title.as_ref(), "Sports A");
assert_eq!(groups[1].id, 7);
assert_eq!(groups[1].channels[0].header.id.as_ref(), "200");
assert_eq!(groups[2].title.as_ref(), "News");
assert_eq!(groups[2].channels[0].header.id.as_ref(), "101");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_m3u_unknown_streams_do_not_match_positional_category_id() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.id = 5;
provider_sports.channels[0].header.id = "100".intern();
let mut staged_new = test_group(XtreamCluster::Live, "New", "staged");
staged_new.id = 5;
staged_new.channels[0].header.id = "300".intern();
let groups = super::apply_staged_overlay_groups(
&provider,
StagedInputType::M3u,
ClusterFlags::Live,
vec![provider_sports],
vec![staged_new],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "Sports");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "100");
assert_eq!(groups[1].title.as_ref(), "New");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "300");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_xtream_overlay_falls_back_to_the_secondary_stream_overlap() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_alpha = test_group(XtreamCluster::Live, "Alpha", "provider");
provider_alpha.id = 5;
provider_alpha.channels[0].header.id = "100".intern();
let mut provider_beta = test_group(XtreamCluster::Live, "Beta", "provider");
provider_beta.id = 7;
provider_beta.channels[0].header.id = "200".intern();
let mut beta_second = provider_beta.channels[0].clone();
beta_second.header.id = "201".intern();
provider_beta.channels.push(beta_second);
// Both staged groups overlap Beta; the stronger one takes it, so the other has to fall back to the
// provider category it also owns streams of instead of becoming a new category.
let mut staged_strong = test_group(XtreamCluster::Live, "Beta Rewritten", "staged");
staged_strong.id = 0;
staged_strong.channels[0].header.id = "200".intern();
let mut strong_second = staged_strong.channels[0].clone();
strong_second.header.id = "201".intern();
staged_strong.channels.push(strong_second);
let mut staged_weak = test_group(XtreamCluster::Live, "Alpha Rewritten", "staged");
staged_weak.id = 0;
staged_weak.channels[0].header.id = "200".intern();
let mut weak_second = staged_weak.channels[0].clone();
weak_second.header.id = "201".intern();
staged_weak.channels.push(weak_second);
let mut weak_third = staged_weak.channels[0].clone();
weak_third.header.id = "100".intern();
staged_weak.channels.push(weak_third);
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_alpha, provider_beta],
vec![staged_strong, staged_weak],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "Alpha Rewritten");
assert_eq!(groups[0].id, 5);
assert_eq!(groups[0].channels.len(), 3);
assert_eq!(groups[1].title.as_ref(), "Beta Rewritten");
assert_eq!(groups[1].id, 7);
assert_eq!(groups[1].channels.len(), 2);
assert_unique_category_ids(&groups);
}
#[test]
fn staged_xtream_overlay_gives_a_new_category_a_free_category_id() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_sports = test_group(XtreamCluster::Live, "Sports", "provider");
provider_sports.id = 2;
provider_sports.channels[0].header.id = "200".intern();
// Staged groups without a category id must not reach persistence as a shared zero key.
let mut staged_kids = test_group(XtreamCluster::Live, "Kids", "staged");
staged_kids.id = 0;
staged_kids.channels[0].header.id = "300".intern();
let mut staged_movies = test_group(XtreamCluster::Live, "Movies", "staged");
staged_movies.id = 0;
staged_movies.channels[0].header.id = "400".intern();
let groups = apply_staged_overlay_groups(
&provider,
ClusterFlags::Live,
vec![provider_sports],
vec![staged_kids, staged_movies],
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].title.as_ref(), "Sports");
assert_eq!(groups[0].id, 2);
assert_eq!(groups[1].title.as_ref(), "Kids");
assert_eq!(groups[1].id, 3);
assert_eq!(groups[2].title.as_ref(), "Movies");
assert_eq!(groups[2].id, 4);
assert_unique_category_ids(&groups);
}
#[test]
fn staged_xtream_overlay_empty_staged_group_without_provider_fallback_is_omitted() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_action = test_group(XtreamCluster::Video, "Action", "provider");
provider_action.channels[0].header.id = "10".intern();
let mut staged_horror = test_group(XtreamCluster::Video, "Horror", "staged");
staged_horror.id = 2;
staged_horror.channels[0].header.id = "non-numeric".intern();
let groups = apply_staged_overlay_groups(&provider, ClusterFlags::Vod, vec![provider_action], vec![staged_horror]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "Action");
}
#[test]
fn staged_overlay_is_skipped_when_provider_playlist_is_cached() {
let result = PlaylistDownloadResult::new(vec![], vec![], true, false);