Feature/failover (#924)

* feature stream failover
This commit is contained in:
euzu
2026-10-16 21:10:07 +02:00
committed by GitHub
parent 86d19f955c
commit 0e4e01409c
203 changed files with 15664 additions and 820 deletions
@@ -0,0 +1,544 @@
use crate::processor::match_key::{extract_normalized_match_key, MatchKeyNormalizer};
use indexmap::IndexMap;
use shared::{
model::{
ChannelGroupOverridesDto, ChannelGroupPolicy, ChannelGroupsConfig, ChannelMemberRefDto, EpgConflictMode,
LiveStreamProperties, PlaylistGroup, PlaylistItem, StreamProperties, UUIDType, XtreamCluster,
MIN_GROUP_MEMBERS,
},
utils::{hash_string, token_quality, Internable},
};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use tuliprox_repository::{ChannelGroupOrigin, PendingChannelGroup};
pub(in crate::processor) fn strip_quality_words(value: &str) -> String {
let words: Vec<&str> = value.split_whitespace().collect();
let mut kept = Vec::with_capacity(words.len());
for word in words {
let core = word.trim_matches(|c: char| !c.is_alphanumeric());
if !core.is_empty() && token_quality(core).is_some() {
continue;
}
kept.push(word);
}
if kept.is_empty() {
value.to_string()
} else {
kept.join(" ")
}
}
fn is_eligible(item: &PlaylistItem) -> bool {
item.header.xtream_cluster == XtreamCluster::Live && !item.header.item_type.is_local()
}
fn item_member_ref(item: &PlaylistItem) -> Option<ChannelMemberRefDto> {
if !is_eligible(item) {
return None;
}
let stream_id = item.header.get_input_stream_id()?;
if stream_id.is_empty() {
return None;
}
Some(ChannelMemberRefDto { input: item.header.input_name.to_string(), stream_id: stream_id.to_string() })
}
struct GroupCandidate {
key: String,
origin: ChannelGroupOrigin,
policy: ChannelGroupPolicy,
positions: Vec<(usize, usize)>,
orphaned: Vec<ChannelMemberRefDto>,
epg_split: bool,
name_override: Option<String>,
logo_override: Option<String>,
epg_override: Option<String>,
playlist_group_override: Option<String>,
}
struct AutoBucket {
positions: Vec<(usize, usize)>,
has_dsl_forced: bool,
epg_split: bool,
}
struct GroupSlot {
id: u32,
title: Arc<str>,
xtream_cluster: XtreamCluster,
had_channels: bool,
channels: Vec<Option<PlaylistItem>>,
}
fn build_member_index(playlist: &[PlaylistGroup]) -> HashMap<ChannelMemberRefDto, (usize, usize)> {
let mut member_pos_map = HashMap::new();
for (group_idx, group) in playlist.iter().enumerate() {
if group.xtream_cluster != XtreamCluster::Live {
continue;
}
for (channel_idx, channel) in group.channels.iter().enumerate() {
if let Some(mref) = item_member_ref(channel) {
member_pos_map.entry(mref).or_insert((group_idx, channel_idx));
}
}
}
member_pos_map
}
fn resolve_manual_groups(
config: &ChannelGroupsConfig,
overrides: Option<&ChannelGroupOverridesDto>,
member_pos_map: &HashMap<ChannelMemberRefDto, (usize, usize)>,
taken_positions: &mut HashSet<(usize, usize)>,
candidates: &mut Vec<GroupCandidate>,
) {
let Some(overrides) = overrides else {
return;
};
for mg in &overrides.groups {
let mut matched_positions = Vec::new();
let mut orphaned = Vec::new();
for mref in &mg.members {
if let Some(&pos) = member_pos_map.get(mref) {
if !taken_positions.contains(&pos) {
taken_positions.insert(pos);
matched_positions.push(pos);
continue;
}
}
log::warn!(
"Manual channel group '{}' member '{}:{}' not found or already taken",
mg.id,
mref.input,
mref.stream_id
);
orphaned.push(mref.clone());
}
if matched_positions.len() < MIN_GROUP_MEMBERS {
continue;
}
candidates.push(GroupCandidate {
key: mg.id.clone(),
origin: ChannelGroupOrigin::Manual,
policy: mg.policy.unwrap_or(config.policy),
positions: matched_positions,
orphaned,
epg_split: false,
name_override: Some(mg.name.clone()),
logo_override: mg.logo.clone(),
epg_override: mg.epg_channel_id.clone(),
playlist_group_override: mg.playlist_group.clone(),
});
}
}
fn collect_auto_buckets(
config: &ChannelGroupsConfig,
playlist: &[PlaylistGroup],
excluded_set: &HashSet<&ChannelMemberRefDto>,
taken_positions: &HashSet<(usize, usize)>,
) -> IndexMap<String, AutoBucket> {
let normalizer = MatchKeyNormalizer::new(&config.match_normalize, config.match_as_ascii);
let mut auto_buckets: IndexMap<String, AutoBucket> = IndexMap::new();
for (group_idx, group) in playlist.iter().enumerate() {
if group.xtream_cluster != XtreamCluster::Live {
continue;
}
for (channel_idx, channel) in group.channels.iter().enumerate() {
let pos = (group_idx, channel_idx);
if taken_positions.contains(&pos) {
continue;
}
let Some(mref) = item_member_ref(channel) else {
continue;
};
if excluded_set.contains(&mref) {
continue;
}
let mut dsl_forced = false;
let key = match channel.header.channel_group.as_deref() {
Some("") => continue,
Some(forced) => {
dsl_forced = true;
normalizer.key(forced)
}
None => extract_normalized_match_key(
&channel.header,
config.match_by,
config.match_as_ascii,
Some(&normalizer),
),
};
if key.trim().is_empty() {
continue;
}
let bucket = auto_buckets.entry(key).or_insert_with(|| AutoBucket {
positions: Vec::new(),
has_dsl_forced: false,
epg_split: false,
});
bucket.positions.push(pos);
if dsl_forced {
bucket.has_dsl_forced = true;
}
}
}
auto_buckets
}
fn split_epg_buckets(
playlist: &[PlaylistGroup],
auto_buckets: IndexMap<String, AutoBucket>,
) -> IndexMap<String, AutoBucket> {
let mut split_buckets: IndexMap<String, AutoBucket> = IndexMap::new();
for (key, bucket) in auto_buckets {
if bucket.has_dsl_forced {
split_buckets.insert(key, bucket);
continue;
}
let mut epg_groups: IndexMap<String, Vec<(usize, usize)>> = IndexMap::new();
for &pos in &bucket.positions {
let ch = &playlist[pos.0].channels[pos.1];
if let Some(epg) = ch.header.epg_channel_id.as_deref() {
let trimmed = epg.trim().to_lowercase();
if !trimmed.is_empty() {
epg_groups.entry(trimmed).or_default().push(pos);
}
}
}
if epg_groups.len() >= 2 {
for (epg_id, sub_positions) in epg_groups {
let sub_key = format!("{key}|{epg_id}");
split_buckets
.insert(sub_key, AutoBucket { positions: sub_positions, has_dsl_forced: false, epg_split: true });
}
} else {
split_buckets.insert(key, bucket);
}
}
split_buckets
}
fn apply_auto_override(
bucket: &mut AutoBucket,
ao: &shared::model::AutoGroupOverrideDto,
playlist: &[PlaylistGroup],
member_pos_map: &HashMap<ChannelMemberRefDto, (usize, usize)>,
taken_positions: &HashSet<(usize, usize)>,
excluded_set: &HashSet<&ChannelMemberRefDto>,
) {
if !ao.remove.is_empty() {
bucket.positions.retain(|&pos| {
let ch = &playlist[pos.0].channels[pos.1];
!ao.remove.iter().any(|rem| {
ch.header.input_name.as_ref() == rem.input.as_str()
&& ch.header.get_input_stream_id().as_deref() == Some(rem.stream_id.as_str())
})
});
}
for add_ref in &ao.add {
if let Some(&pos) = member_pos_map.get(add_ref) {
if !taken_positions.contains(&pos) && !excluded_set.contains(add_ref) && !bucket.positions.contains(&pos) {
bucket.positions.push(pos);
}
}
}
if !ao.order.is_empty() {
let mut ordered = Vec::with_capacity(bucket.positions.len());
let mut used = HashSet::new();
for ord_ref in &ao.order {
if let Some(&pos) = bucket.positions.iter().find(|&&pos| {
let ch = &playlist[pos.0].channels[pos.1];
ch.header.input_name.as_ref() == ord_ref.input.as_str()
&& ch.header.get_input_stream_id().as_deref() == Some(ord_ref.stream_id.as_str())
}) {
if used.insert(pos) {
ordered.push(pos);
}
}
}
for &pos in &bucket.positions {
if !used.contains(&pos) {
ordered.push(pos);
}
}
bucket.positions = ordered;
}
}
fn build_group_metadata(
candidate: &GroupCandidate,
rep_header: &shared::model::PlaylistItemHeader,
slots: &[GroupSlot],
config: &ChannelGroupsConfig,
group_uuid: UUIDType,
) -> shared::model::PlaylistItemHeader {
let mut group_header = rep_header.clone();
group_header.uuid = group_uuid;
group_header.channel_group = None;
group_header.channel_group_uuid = Some(group_uuid);
if let Some(name) = &candidate.name_override {
group_header.name = name.as_str().intern();
group_header.title = name.as_str().intern();
} else if config.name_strip_quality {
group_header.name = strip_quality_words(&rep_header.name).intern();
group_header.title = strip_quality_words(&rep_header.title).intern();
}
if let Some(logo) = &candidate.logo_override {
group_header.logo = logo.as_str().intern();
} else if group_header.logo.is_empty() {
for &pos in &candidate.positions {
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
if !item.header.logo.is_empty() {
group_header.logo = item.header.logo.clone();
break;
}
}
}
}
if let Some(epg) = &candidate.epg_override {
group_header.epg_channel_id = Some(epg.as_str().intern());
} else if group_header.epg_channel_id.as_deref().is_none_or(|s| s.trim().is_empty()) {
for &pos in &candidate.positions {
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
if let Some(epg) = &item.header.epg_channel_id {
if !epg.trim().is_empty() {
group_header.epg_channel_id = Some(epg.clone());
break;
}
}
}
}
}
apply_archive_metadata(&mut group_header, candidate, slots);
group_header
}
fn apply_archive_metadata(
group_header: &mut shared::model::PlaylistItemHeader,
candidate: &GroupCandidate,
slots: &[GroupSlot],
) {
let mut has_archive = false;
let mut max_archive_duration: Option<i32> = None;
for &pos in &candidate.positions {
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
if let Some(StreamProperties::Live(live)) = &item.header.additional_properties {
if live.tv_archive.unwrap_or(0) > 0 {
has_archive = true;
if let Some(dur) = live.tv_archive_duration {
max_archive_duration = Some(max_archive_duration.map_or(dur, |m| m.max(dur)));
}
}
}
}
}
if has_archive {
match &mut group_header.additional_properties {
Some(StreamProperties::Live(live)) => {
live.tv_archive = Some(1);
live.tv_archive_duration = max_archive_duration;
}
_ => {
group_header.additional_properties = Some(StreamProperties::Live(Box::new(LiveStreamProperties {
name: group_header.name.clone(),
category_id: group_header.category_id,
stream_id: 0,
stream_icon: group_header.logo.clone(),
direct_source: "".intern(),
custom_sid: None,
added: None,
stream_type: None,
epg_channel_id: group_header.epg_channel_id.clone(),
tv_archive: Some(1),
tv_archive_duration: max_archive_duration,
is_adult: 0,
..Default::default()
})));
}
}
}
}
fn collect_group_candidates(
config: &ChannelGroupsConfig,
overrides: Option<&ChannelGroupOverridesDto>,
playlist: &[PlaylistGroup],
) -> Vec<GroupCandidate> {
let member_pos_map = build_member_index(playlist);
let mut taken_positions = HashSet::new();
let mut candidates = Vec::new();
resolve_manual_groups(config, overrides, &member_pos_map, &mut taken_positions, &mut candidates);
let excluded_set: HashSet<&ChannelMemberRefDto> =
overrides.map_or_else(HashSet::new, |o| o.excluded.iter().collect());
let mut auto_buckets = collect_auto_buckets(config, playlist, &excluded_set, &taken_positions);
if config.epg_conflict == EpgConflictMode::Split {
auto_buckets = split_epg_buckets(playlist, auto_buckets);
}
for (key, mut bucket) in auto_buckets {
let auto_override = overrides.and_then(|o| o.auto_overrides.iter().find(|ao| ao.auto_key == key));
let origin = if auto_override.is_some() { ChannelGroupOrigin::AutoEdited } else { ChannelGroupOrigin::Auto };
if let Some(ao) = auto_override {
apply_auto_override(&mut bucket, ao, playlist, &member_pos_map, &taken_positions, &excluded_set);
}
if bucket.positions.len() < MIN_GROUP_MEMBERS {
continue;
}
for &pos in &bucket.positions {
taken_positions.insert(pos);
}
candidates.push(GroupCandidate {
key: key.clone(),
origin,
policy: auto_override.and_then(|ao| ao.policy).unwrap_or(config.policy),
positions: bucket.positions,
orphaned: Vec::new(),
epg_split: bucket.epg_split,
name_override: auto_override.and_then(|ao| ao.name.clone()),
logo_override: auto_override.and_then(|ao| ao.logo.clone()),
epg_override: auto_override.and_then(|ao| ao.epg_channel_id.clone()),
playlist_group_override: auto_override.and_then(|ao| ao.playlist_group.clone()),
});
}
candidates
}
fn rebuild_playlist_from_slots(
slots: Vec<GroupSlot>,
deferred_placements: Vec<(String, PlaylistItem)>,
playlist: &mut Vec<PlaylistGroup>,
) {
for slot in slots {
let channels: Vec<PlaylistItem> = slot.channels.into_iter().flatten().collect();
if channels.is_empty() && slot.had_channels {
continue;
}
playlist.push(PlaylistGroup { id: slot.id, title: slot.title, channels, xtream_cluster: slot.xtream_cluster });
}
for (pg_override, mut item) in deferred_placements {
if let Some(g) = playlist.iter_mut().find(|g| g.title.as_ref() == pg_override.as_str()) {
item.header.category_id = g.id;
item.header.group = g.title.clone();
g.channels.push(item);
} else {
let next_id = playlist.iter().map(|g| g.id).max().unwrap_or(0) + 1;
item.header.category_id = next_id;
item.header.group = pg_override.as_str().intern();
playlist.push(PlaylistGroup {
id: next_id,
title: pg_override.as_str().intern(),
channels: vec![item],
xtream_cluster: XtreamCluster::Live,
});
}
}
}
fn materialize_groups(
candidates: &[GroupCandidate],
config: &ChannelGroupsConfig,
target_name: &str,
mut slots: Vec<GroupSlot>,
playlist: &mut Vec<PlaylistGroup>,
) -> Vec<PendingChannelGroup> {
let mut deferred_placements = Vec::new();
let mut pending_groups = Vec::with_capacity(candidates.len());
for candidate in candidates {
let rep_pos = candidate.positions[0];
let Some(rep_item) = slots.get(rep_pos.0).and_then(|g| g.channels.get(rep_pos.1)).and_then(Option::as_ref)
else {
continue;
};
let group_uuid = hash_string(&format!("channel-group|{target_name}|{}", candidate.key));
let group_header = build_group_metadata(candidate, &rep_item.header, &slots, config, group_uuid);
let mut members = Vec::with_capacity(candidate.positions.len());
for &pos in &candidate.positions {
if let Some(item) = slots.get_mut(pos.0).and_then(|g| g.channels.get_mut(pos.1)).and_then(Option::take) {
members.push(item);
}
}
let group_item = PlaylistItem { header: group_header };
if let Some(pg_override) = &candidate.playlist_group_override {
deferred_placements.push((pg_override.clone(), group_item));
} else if let Some(slot) = slots.get_mut(rep_pos.0).and_then(|g| g.channels.get_mut(rep_pos.1)) {
*slot = Some(group_item);
}
pending_groups.push(PendingChannelGroup {
group_uuid,
key: candidate.key.as_str().intern(),
origin: candidate.origin,
policy: candidate.policy,
members,
orphaned: candidate.orphaned.clone(),
epg_split: candidate.epg_split,
});
}
rebuild_playlist_from_slots(slots, deferred_placements, playlist);
pending_groups
}
pub(in crate::processor) fn group_playlist(
config: &ChannelGroupsConfig,
overrides: Option<&ChannelGroupOverridesDto>,
target_name: &str,
playlist: &mut Vec<PlaylistGroup>,
) -> Vec<PendingChannelGroup> {
if playlist.is_empty() {
return Vec::new();
}
let candidates = collect_group_candidates(config, overrides, playlist);
if candidates.is_empty() {
return Vec::new();
}
let slots: Vec<GroupSlot> = playlist
.drain(..)
.map(|g| GroupSlot {
id: g.id,
title: g.title,
xtream_cluster: g.xtream_cluster,
had_channels: !g.channels.is_empty(),
channels: g.channels.into_iter().map(Some).collect(),
})
.collect();
materialize_groups(&candidates, config, target_name, slots, playlist)
}
@@ -0,0 +1,5 @@
mod assemble;
#[cfg(test)]
mod tests;
pub(in crate::processor) use assemble::*;
@@ -0,0 +1,276 @@
use super::*;
use shared::{
model::{
AutoGroupOverrideDto, ChannelGroupOverridesDto, ChannelGroupsConfig, ChannelMemberRefDto, EpgConflictMode,
LiveStreamProperties, ManualChannelGroupDto, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
StreamProperties, XtreamCluster,
},
utils::Internable,
};
use tuliprox_repository::ChannelGroupOrigin;
fn live(input: &str, stream_id: &str, title: &str) -> PlaylistItem {
let mut header = PlaylistItemHeader {
input_name: input.intern(),
id: stream_id.intern(),
title: title.intern(),
name: title.intern(),
url: format!("http://{input}.invalid/live/u/p/{stream_id}.ts").intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
};
header.freeze_input_stream_id();
header.gen_uuid();
PlaylistItem { header }
}
fn group(title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live }
}
fn cfg() -> ChannelGroupsConfig { ChannelGroupsConfig::default() }
fn with_epg(mut item: PlaylistItem, epg: &str) -> PlaylistItem {
item.header.epg_channel_id = Some(epg.intern());
item
}
fn overrides_with_manual(id: &str, members: &[(&str, &str)]) -> ChannelGroupOverridesDto {
let mut overrides = ChannelGroupOverridesDto::new("T");
overrides.groups.push(ManualChannelGroupDto {
id: id.into(),
name: id.into(),
members: members
.iter()
.map(|(input, stream_id)| ChannelMemberRefDto { input: (*input).into(), stream_id: (*stream_id).into() })
.collect(),
..Default::default()
});
overrides
}
fn set_archive(item: &mut PlaylistItem, duration: i32) {
let props = LiveStreamProperties { tv_archive: Some(1), tv_archive_duration: Some(duration), ..Default::default() };
item.header.additional_properties = Some(StreamProperties::Live(Box::new(props)));
}
fn archive_of(item: &PlaylistItem) -> Option<i32> {
match &item.header.additional_properties {
Some(StreamProperties::Live(live)) => live.tv_archive_duration,
_ => None,
}
}
#[test]
fn auto_groups_same_caption_across_inputs() {
let mut playlist = vec![group(
"News",
vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD"), live("solis", "2", "ARD")],
)];
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].members.len(), 2);
assert_eq!(pending[0].origin, ChannelGroupOrigin::Auto);
let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect();
assert_eq!(titles, vec!["ZDF HD", "ARD"]); // group item at representative position
assert_eq!(playlist[0].channels[0].header.uuid, pending[0].group_uuid);
}
#[test]
fn single_member_key_is_not_grouped() {
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("solis", "2", "ARD")])];
assert!(group_playlist(&cfg(), None, "T", &mut playlist).is_empty());
assert_eq!(playlist[0].channels.len(), 2);
}
#[test]
fn dsl_opt_out_and_forced_key() {
let mut a = live("solis", "1", "ZDF HD");
a.header.channel_group = Some("".intern());
let mut b = live("korath", "7", "Zweites");
b.header.channel_group = Some("zdf hd".intern());
let c = live("veyra", "3", "ZDF");
let mut playlist = vec![group("News", vec![a, b, c])];
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!(pending.len(), 1);
let inputs: Vec<_> = pending[0].members.iter().map(|m| m.header.input_name.to_string()).collect();
assert_eq!(inputs, vec!["korath", "veyra"]);
}
#[test]
fn manual_group_wins_over_auto_and_keeps_order() {
let overrides = overrides_with_manual("zdf", &[("korath", "7"), ("solis", "1")]);
let mut playlist = vec![group(
"News",
vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD"), live("veyra", "3", "ZDF")],
)];
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
let manual = pending.iter().find(|p| p.origin == ChannelGroupOrigin::Manual).expect("manual");
let inputs: Vec<_> = manual.members.iter().map(|m| m.header.input_name.to_string()).collect();
assert_eq!(inputs, vec!["korath", "solis"]);
// veyra alone is a single member and stays a normal channel
assert!(playlist[0].channels.iter().any(|c| c.header.input_name.as_ref() == "veyra"));
}
#[test]
fn excluded_member_never_grouped() {
let mut overrides = ChannelGroupOverridesDto::new("T");
overrides.excluded.push(ChannelMemberRefDto { input: "solis".into(), stream_id: "1".into() });
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
assert!(group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).is_empty());
}
#[test]
fn auto_override_orders_and_marks_edited() {
let mut overrides = ChannelGroupOverridesDto::new("T");
overrides.auto_overrides.push(AutoGroupOverrideDto {
auto_key: "zdf".into(),
order: vec![ChannelMemberRefDto { input: "korath".into(), stream_id: "7".into() }],
..Default::default()
});
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
assert_eq!(pending[0].origin, ChannelGroupOrigin::AutoEdited);
assert_eq!(pending[0].members[0].header.input_name.as_ref(), "korath");
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "ZDF FHD"); // representative
}
#[test]
fn orphaned_manual_member_is_skipped() {
let overrides = overrides_with_manual("zdf", &[("solis", "1"), ("korath", "7"), ("gone", "99")]);
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
assert_eq!(pending[0].members.len(), 2);
assert_eq!(pending[0].orphaned, vec![ChannelMemberRefDto { input: "gone".into(), stream_id: "99".into() }]);
}
#[test]
fn manual_group_with_one_member_dissolves() {
let overrides = overrides_with_manual("zdf", &[("solis", "1"), ("gone", "99")]);
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("veyra", "2", "ARD")])];
assert!(group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).is_empty());
assert_eq!(playlist[0].channels.len(), 2);
}
#[test]
fn archive_is_max_of_members() {
let mut a = live("solis", "1", "ZDF HD");
set_archive(&mut a, 3);
let mut b = live("korath", "7", "ZDF FHD");
set_archive(&mut b, 7);
let mut playlist = vec![group("News", vec![a, b])];
group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!(archive_of(&playlist[0].channels[0]), Some(7));
}
#[test]
fn vod_and_series_are_never_grouped() {
let mut a = live("solis", "1", "Film");
a.header.item_type = PlaylistItemType::Video;
a.header.xtream_cluster = XtreamCluster::Video;
let mut b = live("korath", "2", "Film");
b.header.item_type = PlaylistItemType::Video;
b.header.xtream_cluster = XtreamCluster::Video;
let mut playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![a, b],
xtream_cluster: XtreamCluster::Video,
}];
assert!(group_playlist(&cfg(), None, "T", &mut playlist).is_empty());
}
#[test]
fn different_epg_ids_split_the_auto_bucket() {
let mut playlist = vec![group(
"Sport",
vec![
with_epg(live("solis", "1", "Sport 1 HD"), "sport1.de"),
with_epg(live("korath", "7", "Sport 1"), "sport1.de"),
with_epg(live("veyra", "3", "Sport 1 FHD"), "sport1.uk"),
with_epg(live("orun", "4", "Sport 1"), "sport1.uk"),
live("solis", "9", "Sport 1"), // no EPG id → stays a normal channel
],
)];
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!(pending.len(), 2);
assert!(pending.iter().all(|p| p.epg_split && p.members.len() == 2));
assert!(playlist[0]
.channels
.iter()
.any(|c| c.header.input_name.as_ref() == "solis" && c.header.id.as_ref() == "9"));
}
#[test]
fn epg_conflict_ignore_keeps_one_group() {
let mut config = cfg();
config.epg_conflict = EpgConflictMode::Ignore;
let mut playlist = vec![group(
"Sport",
vec![
with_epg(live("solis", "1", "Sport 1"), "sport1.de"),
with_epg(live("korath", "7", "Sport 1"), "sport1.uk"),
],
)];
let pending = group_playlist(&config, None, "T", &mut playlist);
assert_eq!((pending.len(), pending[0].epg_split), (1, false));
}
#[test]
fn dsl_forced_key_is_never_split() {
let mut a = with_epg(live("solis", "1", "Eins"), "a.de");
a.header.channel_group = Some("eins".intern());
let mut b = with_epg(live("korath", "7", "One"), "b.uk");
b.header.channel_group = Some("eins".intern());
let mut playlist = vec![group("News", vec![a, b])];
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!((pending.len(), pending[0].members.len()), (1, 2));
}
#[test]
fn match_normalize_groups_prefixed_and_unprefixed_captions() {
let mut config = cfg();
config.match_normalize.strip_regex = Some(r"^\s*(?:\|[A-Z]{2,3}\||[A-Z]{2,3}\s*[:|])\s*".to_string());
config.match_normalize.ignore_tokens = vec!["hevc".to_string()];
config.prepare().expect("valid");
let mut playlist = vec![group("News", vec![live("solis", "1", "DE: ZDF HEVC"), live("korath", "7", "ZDF")])];
let pending = group_playlist(&config, None, "T", &mut playlist);
assert_eq!((pending.len(), pending[0].members.len()), (1, 2));
}
#[test]
fn name_strip_quality_cleans_derived_name_only() {
assert_eq!(strip_quality_words("ZDF [FHD]"), "ZDF");
assert_eq!(strip_quality_words("DE: ZDF HD"), "DE: ZDF");
assert_eq!(strip_quality_words("ZDF-HD"), "ZDF-HD");
assert_eq!(strip_quality_words("HD"), "HD"); // nothing left → original
let mut config = cfg();
config.name_strip_quality = true;
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
group_playlist(&config, None, "T", &mut playlist);
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "ZDF");
}
#[test]
fn emptied_playlist_group_is_dropped_and_group_uuid_is_stable() {
let build =
|| vec![group("A", vec![live("solis", "1", "ZDF HD")]), group("B", vec![live("korath", "7", "ZDF FHD")])];
let mut first = build();
let p1 = group_playlist(&cfg(), None, "T", &mut first);
let mut second = build();
let p2 = group_playlist(&cfg(), None, "T", &mut second);
assert_eq!(first.len(), 1);
assert_eq!(p1[0].group_uuid, p2[0].group_uuid);
}
#[test]
fn members_keep_original_uuid_and_group_item_is_marked() {
let a = live("solis", "1", "ZDF HD");
let b = live("korath", "7", "ZDF FHD");
let (uuid_a, uuid_b) = (a.header.uuid, b.header.uuid);
let mut playlist = vec![group("News", vec![a, b])];
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
assert_eq!(pending[0].members.iter().map(|m| m.header.uuid).collect::<Vec<_>>(), vec![uuid_a, uuid_b]);
assert_eq!(playlist[0].channels[0].header.channel_group_uuid, Some(pending[0].group_uuid));
}
+40 -44
View File
@@ -1,45 +1,20 @@
use super::match_key::{dedup_match_by, extract_normalized_match_key, raw_match_value};
use shared::{
model::{DeduplicateConfig, DeduplicateKeep, DeduplicateMatchBy, PlaylistGroup, PlaylistItem, XtreamCluster},
utils::{deunicode_string, quality_rank, quality_tokens, token_quality},
model::{DeduplicateConfig, DeduplicateKeep, PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster},
utils::quality_rank,
};
use std::collections::HashMap;
/// Lowercased token join with quality tokens removed, so "News HD" and
/// "NEWS [FHD]" produce the same key.
fn normalized_dedup_key(value: &str, match_as_ascii: bool) -> String {
let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) };
let mut key = String::with_capacity(value.len());
for token in quality_tokens(&value) {
if token_quality(token).is_some() {
continue;
}
if !key.is_empty() {
key.push(' ');
}
for ch in token.chars() {
key.extend(ch.to_lowercase());
}
}
key
}
use std::collections::{HashMap, HashSet};
fn match_value(config: DeduplicateConfig, item: &PlaylistItem) -> String {
normalized_dedup_key(raw_match_value(config, item), config.match_as_ascii)
extract_normalized_match_key(&item.header, dedup_match_by(config.match_by), config.match_as_ascii, None)
}
fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
let header = &item.header;
match config.match_by {
DeduplicateMatchBy::Caption => {
if header.title.is_empty() {
header.name.as_ref()
} else {
header.title.as_ref()
}
}
DeduplicateMatchBy::Name => header.name.as_ref(),
DeduplicateMatchBy::Title => header.title.as_ref(),
}
fn item_raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
raw_match_value(&item.header, dedup_match_by(config.match_by))
}
fn should_skip(channel: &PlaylistItem, skip: &HashSet<UUIDType>) -> bool {
skip.contains(&channel.header.uuid) || channel.header.channel_group_uuid.is_some()
}
/// Collapse duplicate channels across the whole target playlist (per cluster).
@@ -48,16 +23,20 @@ fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
pub(in crate::processor) fn deduplicate_playlist(
config: DeduplicateConfig,
playlist: &mut Vec<PlaylistGroup>,
skip: &HashSet<UUIDType>,
) -> usize {
// winner per key: (quality rank, group index, channel index)
let mut winners: HashMap<(XtreamCluster, String), (u8, usize, usize)> = HashMap::new();
for (group_idx, group) in playlist.iter().enumerate() {
for (channel_idx, channel) in group.channels.iter().enumerate() {
if should_skip(channel, skip) {
continue;
}
let key_value = match_value(config, channel);
if key_value.is_empty() {
continue;
}
let rank = quality_rank(raw_match_value(config, channel));
let rank = quality_rank(item_raw_match_value(config, channel));
match winners.entry((group.xtream_cluster, key_value)) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert((rank, group_idx, channel_idx));
@@ -81,6 +60,9 @@ pub(in crate::processor) fn deduplicate_playlist(
group.channels.retain(|channel| {
let idx = channel_idx;
channel_idx += 1;
if should_skip(channel, skip) {
return true;
}
let key_value = match_value(config, channel);
if key_value.is_empty() {
return true;
@@ -104,7 +86,10 @@ pub(in crate::processor) fn deduplicate_playlist(
#[cfg(test)]
mod tests {
use super::*;
use shared::{model::PlaylistItemHeader, utils::Internable};
use shared::{
model::{DeduplicateMatchBy, PlaylistItemHeader},
utils::Internable,
};
fn make_item(title: &str) -> PlaylistItem {
PlaylistItem { header: PlaylistItemHeader { title: title.intern(), ..Default::default() } }
@@ -134,7 +119,7 @@ mod tests {
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
assert_eq!(removed, 2);
let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect();
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
@@ -150,7 +135,7 @@ mod tests {
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
// Target-wide curation evaluates only this surviving winner.
assert_eq!(removed, 1);
@@ -167,7 +152,7 @@ mod tests {
keep: DeduplicateKeep::First,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
assert_eq!(removed, 1);
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "News HD");
}
@@ -184,7 +169,7 @@ mod tests {
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
assert_eq!(removed, 1);
// "A" was emptied by dedup and dropped, the already-empty group survives
assert_eq!(playlist.len(), 2);
@@ -203,7 +188,7 @@ mod tests {
keep: DeduplicateKeep::BestQuality,
match_as_ascii: true,
};
let removed = deduplicate_playlist(config, &mut playlist);
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
assert_eq!(removed, 1);
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Cafe FHD");
@@ -214,6 +199,17 @@ mod tests {
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
assert_eq!(deduplicate_playlist(config, &mut playlist), 0);
assert_eq!(deduplicate_playlist(config, &mut playlist, &HashSet::new()), 0);
}
#[test]
fn dedup_never_drops_group_item() {
let group_item = make_item("ZDF HD");
let excluded = make_item("ZDF UHD");
let skip: HashSet<UUIDType> = [group_item.header.uuid].into_iter().collect();
let mut playlist = vec![make_group("News", vec![group_item, excluded])];
let removed = deduplicate_playlist(DeduplicateConfig::default(), &mut playlist, &skip);
assert_eq!(removed, 0);
assert_eq!(playlist[0].channels.len(), 2);
}
}
@@ -0,0 +1,195 @@
use regex::Regex;
use shared::{
model::{ChannelMatchBy, ChannelMatchNormalize, DeduplicateMatchBy, PlaylistItemHeader},
utils::{deunicode_string, quality_tokens, token_quality},
};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
};
pub(in crate::processor) const fn dedup_match_by(value: DeduplicateMatchBy) -> ChannelMatchBy {
match value {
DeduplicateMatchBy::Caption => ChannelMatchBy::Caption,
DeduplicateMatchBy::Name => ChannelMatchBy::Name,
DeduplicateMatchBy::Title => ChannelMatchBy::Title,
}
}
/// Lowercased token join with quality tokens removed, so "News HD" and
/// "NEWS [FHD]" produce the same key.
pub(in crate::processor) fn normalized_match_key(value: &str, match_as_ascii: bool) -> String {
let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) };
let mut key = String::with_capacity(value.len());
for token in quality_tokens(&value) {
if token_quality(token).is_some() {
continue;
}
if !key.is_empty() {
key.push(' ');
}
for ch in token.chars() {
key.extend(ch.to_lowercase());
}
}
key
}
pub(in crate::processor) fn raw_match_value(header: &PlaylistItemHeader, match_by: ChannelMatchBy) -> &str {
match match_by {
ChannelMatchBy::Caption => {
if header.title.is_empty() {
header.name.as_ref()
} else {
header.title.as_ref()
}
}
ChannelMatchBy::Name => header.name.as_ref(),
ChannelMatchBy::Title => header.title.as_ref(),
ChannelMatchBy::EpgChannelId => header.epg_channel_id.as_deref().unwrap_or(""),
}
}
pub(in crate::processor) fn extract_normalized_match_key(
header: &PlaylistItemHeader,
match_by: ChannelMatchBy,
match_as_ascii: bool,
normalizer: Option<&MatchKeyNormalizer<'_>>,
) -> String {
let raw = raw_match_value(header, match_by);
if match_by == ChannelMatchBy::EpgChannelId {
return raw.trim().to_lowercase();
}
match normalizer {
Some(normalizer) => normalizer.key(raw),
None => normalized_match_key(raw, match_as_ascii),
}
}
#[allow(dead_code)]
pub(in crate::processor) struct MatchKeyNormalizer<'a> {
strip: Option<&'a Regex>,
ignore: HashSet<String>,
aliases: HashMap<String, String>,
match_as_ascii: bool,
}
#[allow(dead_code)]
impl<'a> MatchKeyNormalizer<'a> {
pub(in crate::processor) fn new(config: &'a ChannelMatchNormalize, match_as_ascii: bool) -> Self {
let mut normalizer = Self {
strip: config.t_strip_regex.as_deref(),
ignore: config.ignore_tokens.iter().map(|token| token.to_lowercase()).collect(),
aliases: HashMap::new(),
match_as_ascii,
};
let aliases =
config.aliases.iter().map(|(from, to)| (normalizer.base_key(from), normalizer.base_key(to))).collect();
normalizer.aliases = aliases;
normalizer
}
pub(in crate::processor) fn key(&self, value: &str) -> String {
let key = self.base_key(value);
self.aliases.get(&key).cloned().unwrap_or(key)
}
fn base_key(&self, value: &str) -> String {
let stripped = self.strip.map_or(Cow::Borrowed(value), |regex| regex.replace_all(value, ""));
let folded = if self.match_as_ascii { deunicode_string(&stripped) } else { stripped };
let mut key = String::with_capacity(folded.len());
for token in quality_tokens(&folded) {
let lower = token.to_lowercase();
if token_quality(token).is_some() || self.ignore.contains(&lower) {
continue;
}
if !key.is_empty() {
key.push(' ');
}
key.push_str(&lower);
}
key
}
}
#[cfg(test)]
mod tests {
use super::*;
use shared::{
model::{ChannelGroupsConfig, ChannelMatchNormalize, PlaylistItemHeader},
utils::Internable,
};
fn header(title: &str, name: &str, epg: Option<&str>) -> PlaylistItemHeader {
PlaylistItemHeader {
title: title.intern(),
name: name.intern(),
epg_channel_id: epg.map(Internable::intern),
..Default::default()
}
}
#[test]
fn strips_quality_tokens_and_lowercases() {
assert_eq!(normalized_match_key("News HD", false), "news");
assert_eq!(normalized_match_key("NEWS [FHD]", false), normalized_match_key("news 1080p", false));
}
#[test]
fn caption_falls_back_to_name() {
assert_eq!(
extract_normalized_match_key(&header("", "Sport HD", None), ChannelMatchBy::Caption, false, None),
"sport"
);
}
#[test]
fn epg_channel_id_is_used_verbatim_lowercased() {
let h = header("ZDF HD", "ZDF", Some("ZDF.de"));
assert_eq!(extract_normalized_match_key(&h, ChannelMatchBy::EpgChannelId, false, None), "zdf.de");
assert_eq!(
extract_normalized_match_key(&header("X", "X", None), ChannelMatchBy::EpgChannelId, false, None),
""
);
}
#[test]
fn ascii_folding() {
assert_eq!(normalized_match_key("Café HD", true), normalized_match_key("Cafe FHD", true));
}
fn normalize(strip: Option<&str>, ignore: &[&str], aliases: &[(&str, &str)]) -> ChannelMatchNormalize {
let mut cfg = ChannelGroupsConfig {
match_normalize: ChannelMatchNormalize {
strip_regex: strip.map(str::to_string),
ignore_tokens: ignore.iter().map(|t| (*t).to_string()).collect(),
aliases: aliases.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(),
..Default::default()
},
..ChannelGroupsConfig::default()
};
cfg.prepare().expect("valid");
cfg.match_normalize
}
#[test]
fn normalizer_strips_prefix_ignores_tokens_and_resolves_aliases() {
let cfg = normalize(
Some(r"^\s*(?:\|[A-Z]{2,3}\||[A-Z]{2,3}\s*[:|])\s*"),
&["HEVC", "50fps"],
&[("Das Erste HD", "ARD")],
);
let normalizer = MatchKeyNormalizer::new(&cfg, false);
assert_eq!(normalizer.key("DE: ZDF HEVC"), "zdf");
assert_eq!(normalizer.key("|DE| ZDF 50FPS"), "zdf");
assert_eq!(normalizer.key("ZDF"), "zdf");
assert_eq!(normalizer.key("ZDF NEO"), "zdf neo");
assert_eq!(normalizer.key("DE: Das Erste"), "ard");
}
#[test]
fn dedup_keys_unchanged_without_normalizer() {
let h = header("DE: ZDF HEVC", "", None);
assert_eq!(extract_normalized_match_key(&h, ChannelMatchBy::Caption, false, None), "de zdf hevc");
}
}
+2
View File
@@ -3,9 +3,11 @@ mod stalker;
mod stalker_refresh;
mod xtream;
// mod affix;
pub(in crate::processor) mod channel_group;
mod deduplicate;
mod epg;
mod library;
pub(in crate::processor) mod match_key;
mod probe_handle_guard;
mod providers;
mod resolve_options;
@@ -59,8 +59,8 @@ use tuliprox_curation::{
SelectorOutcome,
};
use tuliprox_repository::{
persist_playlist_views, CategoryKey, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource,
PlaylistStorageState, TargetPlaylistPersistOptions,
persist_playlist_views_with_groups, CategoryKey, MemoryPlaylistSource, PendingChannelGroup,
PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState, TargetPlaylistPersistOptions,
};
use tuliprox_session::ActiveProviderManager;
@@ -414,13 +414,14 @@ fn finalization_step_label(view: &str, stage: &str) -> String {
pub(crate) fn prepare_eligible_catalog(
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
group_uuids: &HashSet<UUIDType>,
step: &mut StepMeasure,
) -> Vec<PlaylistGroup> {
let mut playlist = flatten_groups(playlist);
step.tick("playlist merge");
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist);
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist, group_uuids);
if removed > 0 {
info!("Deduplicated {removed} eligible channels for target {}", target.name);
}
@@ -506,6 +507,29 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
finalize_prepared_target_with_tmdb(ctx, prepared, tmdb_client.as_ref()).await
}
pub(super) async fn process_target_channel_groups(
config: &tuliprox_core::model::AppConfig,
target: &ConfigTarget,
playlist: &mut Vec<PlaylistGroup>,
step: &mut StepMeasure,
errors: &mut Vec<TuliproxError>,
) -> Vec<PendingChannelGroup> {
let Some(target_cfg) = target.execution_plan.channel_groups.as_ref() else {
return Vec::new();
};
let cg_cfg = config.config.load().channel_groups.for_target(target_cfg);
let overrides =
tuliprox_repository::load_channel_group_overrides(config, &target.name).await.unwrap_or_else(|err| {
errors.push(err);
None
});
step.broadcast("Processing channel groups for '{}' playlist", &target.name);
let groups = crate::processor::channel_group::group_playlist(&cg_cfg, overrides.as_ref(), &target.name, playlist);
step.tick("channel grouping");
log_memory_snapshot(format!("target '{}' after_channel_grouping", target.name).as_str());
groups
}
pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: Arc<PlaylistProcessingContext<E, M>>,
prepared: PreparedTarget,
@@ -517,6 +541,10 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
let mut errors = Vec::new();
let broadcast_step = create_broadcast_callback(&ctx.events, &ctx.run_id, ctx.execution_order);
let mut step = StepMeasure::new(&target.name, broadcast_step);
let pending_channel_groups =
process_target_channel_groups(&ctx.config, target, &mut new_playlist, &mut step, &mut errors).await;
if target.favourites.is_some() {
step.broadcast("Processing favourites for '{}' playlist", &target.name);
process_favourites(&mut new_playlist, target.favourites.as_deref());
@@ -531,8 +559,11 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
return (Ok(()), errors);
}
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
let views = prepare_target_playlist_views(&ctx.client, tmdb_client, target, eligible_catalog).await;
let group_uuids: HashSet<UUIDType> = pending_channel_groups.iter().map(|p| p.group_uuid).collect();
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &group_uuids, &mut step);
let mut views = prepare_target_playlist_views(&ctx.client, tmdb_client, target, eligible_catalog).await;
views.channel_groups = pending_channel_groups;
if views.base.is_empty()
&& views.xtream.is_none()
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
@@ -577,7 +608,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
flatten_tvguide(new_epg)
};
let library_empty = library_empty.for_filtered_playlist(&finalized_base);
let result = persist_playlist_views(
let result = persist_playlist_views_with_groups(
&ctx.config,
&mut finalized_base,
finalized_xtream.as_deref_mut(),
@@ -589,6 +620,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
library_empty,
publication_plan: views.publication_plan,
},
&mut views.channel_groups,
)
.await;
let watch_playlist = target_watch_view(&finalized_base, finalized_xtream.as_deref());
@@ -901,6 +933,7 @@ pub(crate) struct TargetPlaylistViews {
pub(crate) base: Vec<PlaylistGroup>,
pub(crate) xtream: Option<Vec<PlaylistGroup>>,
pub(crate) publication_plan: PlaylistPublicationPlan,
pub(crate) channel_groups: Vec<PendingChannelGroup>,
}
pub(crate) fn build_curated_playlist_views(
@@ -926,6 +959,7 @@ pub(crate) fn build_curated_playlist_views(
has_xtream_output && !config.include_xtream_base_categories,
appearance_filter_configured,
),
channel_groups: Vec::new(),
}
}
@@ -940,6 +974,7 @@ pub(crate) async fn prepare_target_playlist_views(
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
channel_groups: Vec::new(),
};
};
@@ -954,13 +989,21 @@ pub(super) fn curation_playlist_views(
outcome: CurationRunOutcome,
) -> TargetPlaylistViews {
match outcome {
CurationRunOutcome::NotConfigured => {
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
}
CurationRunOutcome::NotConfigured => TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
channel_groups: Vec::new(),
},
CurationRunOutcome::Failed(failure) => {
warn_curation_failure(&target.name, &failure);
// Discovery is optional; unavailable selections must not suppress the source catalog or EPG.
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
channel_groups: Vec::new(),
}
}
CurationRunOutcome::Complete(evaluation) => {
let views = build_curated_playlist_views(
@@ -1170,3 +1213,24 @@ pub(crate) async fn process_watch<E: EventSink>(
true
}
#[cfg(test)]
pub(crate) async fn run_finalize_until_persist(
target: &ConfigTarget,
mut playlist: Vec<PlaylistGroup>,
) -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
let mut step = StepMeasure::new("test", |_, _| {});
let pending = if let Some(target_cfg) = target.execution_plan.channel_groups.as_ref() {
let cg_cfg = shared::model::ChannelGroupsConfig::default().for_target(target_cfg);
crate::processor::channel_group::group_playlist(&cg_cfg, None, &target.name, &mut playlist)
} else {
Vec::new()
};
if target.favourites.is_some() {
process_favourites(&mut playlist, target.favourites.as_deref());
}
let group_uuids: HashSet<UUIDType> = pending.iter().map(|p| p.group_uuid).collect();
let eligible = prepare_eligible_catalog(target, playlist, &group_uuids, &mut step);
let finalized = finalize_playlist_view(target, eligible, &mut step, "base");
(finalized, pending)
}
@@ -24,6 +24,8 @@ mod curation_effect_gate;
mod watch_contexts;
mod channel_groups;
#[cfg(test)]
mod quality_rejection_fallback;
@@ -0,0 +1,122 @@
use crate::processor::playlist::target::run_finalize_until_persist;
use shared::{
model::{
ConfigFavouritesDto, ConfigTargetDto, ConfigTargetOptions, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, Prepare, XtreamCluster,
},
utils::Internable,
};
use tuliprox_core::model::ConfigTarget;
fn live(input: &str, stream_id: &str, title: &str) -> PlaylistItem {
let mut header = PlaylistItemHeader {
input_name: input.intern(),
id: stream_id.intern(),
title: title.intern(),
name: title.intern(),
url: format!("http://{input}.invalid/live/u/p/{stream_id}.ts").intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
};
header.freeze_input_stream_id();
header.gen_uuid();
PlaylistItem { header }
}
fn group(title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live }
}
fn target_with_options(options_json: &str) -> ConfigTarget {
let mut options: ConfigTargetOptions = serde_json::from_str(options_json).expect("valid options");
options.prepare().expect("prepared");
let dto = ConfigTargetDto { name: "T".to_string(), options: Some(options), ..Default::default() };
ConfigTarget::from(&dto)
}
fn target_with_options_and_favourites(options_json: &str, group_name: &str, filter_str: &str) -> ConfigTarget {
let mut options: ConfigTargetOptions = serde_json::from_str(options_json).expect("valid options");
options.prepare().expect("prepared");
let mut fav = ConfigFavouritesDto {
cluster: XtreamCluster::Live,
group: group_name.intern(),
filter: filter_str.to_string(),
match_as_ascii: false,
t_filter: None,
};
fav.prepare(None).expect("prepared filter");
let dto = ConfigTargetDto {
name: "T".to_string(),
options: Some(options),
favourites: Some(vec![fav]),
..Default::default()
};
ConfigTarget::from(&dto)
}
#[tokio::test]
async fn favourites_copy_the_group_item_instead_of_joining_the_group() {
let target = target_with_options_and_favourites(
r#"{"channel_groups":{"enabled":true}}"#,
"My Favourites",
r#"Caption ~ "ZDF.*""#,
);
let playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
let (finalized, pending) = run_finalize_until_persist(&target, playlist).await;
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].members.len(), 2);
let fav = finalized.iter().find(|g| g.title.as_ref() == "My Favourites").expect("favourites group");
assert_eq!(fav.channels.len(), 1);
assert_eq!(fav.channels[0].header.channel_group_uuid, Some(pending[0].group_uuid));
}
#[tokio::test]
async fn grouping_runs_before_deduplicate_and_dedup_ignores_group_members() {
let target = target_with_options(r#"{"channel_groups":{"enabled":true},"deduplicate":{}}"#);
let playlist = vec![group(
"News",
vec![
live("solis", "1", "ZDF HD"),
live("korath", "7", "ZDF FHD"),
live("veyra", "3", "ARD HD"),
live("veyra", "4", "ARD"),
],
)];
let (finalized, pending) = run_finalize_until_persist(&target, playlist).await;
assert_eq!(pending.len(), 2);
assert_eq!(finalized[0].channels.len(), 2);
}
#[tokio::test]
async fn grouping_uses_global_config_with_target_overrides() {
let dir = tempfile::tempdir().expect("tempdir");
let app_config = super::curation_effect_gate::app_config(dir.path());
let mut global: shared::model::ChannelGroupsConfig =
serde_json::from_str(r#"{"match_normalize":{"ignore_tokens":["backup"]},"name_strip_quality":true}"#)
.expect("global config");
global.prepare().expect("prepared");
app_config.config.store(std::sync::Arc::new(tuliprox_core::model::Config {
storage_dir: dir.path().to_string_lossy().into_owned(),
channel_groups: global,
..tuliprox_core::model::Config::default()
}));
let target = target_with_options(r#"{"channel_groups":{"enabled":true,"name_strip_quality":false}}"#);
let mut playlist = vec![group("News", vec![live("a", "1", "ZDF HD"), live("b", "2", "ZDF HD backup")])];
let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {});
let mut errors = Vec::new();
let pending = crate::processor::playlist::target::process_target_channel_groups(
&app_config,
&target,
&mut playlist,
&mut step,
&mut errors,
)
.await;
assert!(errors.is_empty());
assert_eq!(pending.len(), 1, "the global ignore token joins both channels");
let names: Vec<&str> = playlist[0].channels.iter().map(|item| item.header.name.as_ref()).collect();
assert_eq!(names, ["ZDF HD"], "the target keeps quality words");
}
@@ -15,6 +15,7 @@ use tuliprox_core::{
model::{ApiProxyConfig, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use tuliprox_repository::persist_playlist_views;
pub(super) fn app_config(storage_dir: &Path) -> Arc<AppConfig> {
Arc::new(AppConfig {
@@ -69,7 +69,7 @@ fn curation_eligible_catalog_is_merged_and_deduplicated_before_matching() {
];
let mut step = StepMeasure::new("test", |_, _| {});
let eligible = prepare_eligible_catalog(&target, playlist, &mut step);
let eligible = prepare_eligible_catalog(&target, playlist, &std::collections::HashSet::new(), &mut step);
assert_eq!(eligible.len(), 1);
assert_eq!(eligible[0].channels.len(), 1);
@@ -220,7 +220,7 @@ fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
},
};
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]);
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xc0, 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());
@@ -259,8 +259,9 @@ fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() {
},
};
let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0]))
.expect("previous header should deserialize");
let header: PlaylistItemHeader =
rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0, 0xc0, 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");
@@ -289,11 +289,21 @@ async fn prepare_generic_stream_metadata<E: EventSink>(
}
let (raw_video, raw_audio, stats) = match probe_data {
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => (
raw_video.map(|value| Arc::<str>::from(value.to_string())),
raw_audio.map(|value| Arc::<str>::from(value.to_string())),
stats,
),
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => {
let rv = raw_video.map(|value| Arc::<str>::from(value.to_string()));
let ra = raw_audio.map(|value| Arc::<str>::from(value.to_string()));
if item_type.is_live() {
let bitrate_kbps = stats.bitrate.map_or(0, |b| b / 1000);
events.observe_stream_probe(
Arc::clone(&input.name),
Arc::from(unique_id),
rv.as_deref(),
ra.as_deref(),
bitrate_kbps,
);
}
(rv, ra, stats)
}
// The three failure arms are the only place the *reason* survives:
// both outcome enums above collapse to a single `ProbeFailed`, so a
// caller cannot tell a dead provider from a cancelled shutdown. The