fix hls close revision file at reader deadline (#907)

* fix hls close revision file at reader deadline

Move the progressive revision reader onto its own task and feed the
response stream through a bounded channel. At the deadline the watchdog
aborts the reader, so the file handle and revision pin are released
even if a stalled client never polls the stream again.

Each open revision file now holds its own pin, so GC cannot retire a
revision while a handle is open, including one held by an in-flight
blocking read.

* fix hls close revision file at reader deadline

Move the progressive revision reader onto its own task and feed the
response stream through a bounded channel. At the deadline the watchdog
aborts the reader, so the file handle and revision pin are released
even if a stalled client never polls the stream again.

Each open revision file now holds its own pin, so GC cannot retire a
revision while a handle is open, including one held by an in-flight
blocking read.

---------

Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
euzu
2026-10-09 17:41:39 +02:00
committed by GitHub
co-authored by euzu
parent 7cfd9ac3a9
commit d35875e3d1
804 changed files with 200880 additions and 187480 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
use super::{EpgMergeAccumulator, IcsPersistedSource, TVGuide};
use crate::processor::EpgIdCache;
use std::sync::Arc;
use tuliprox_core::model::{IcsDummyPolicy, PersistedEpgSource};
use tuliprox_parser::ics;
impl TVGuide {
pub(super) async fn process_ics_file(
id_cache: &mut EpgIdCache,
epg_source: &PersistedEpgSource,
source_order: usize,
accumulator: &mut EpgMergeAccumulator,
source: IcsPersistedSource<'_>,
) -> bool {
let IcsPersistedSource { channel_id, channel_title, match_names, config } = source;
let mut candidates = Vec::with_capacity(2 + match_names.len());
candidates.push(channel_id.to_string());
if let Some(title) = channel_title {
candidates.push(title.to_string());
}
candidates.extend(match_names.iter().map(ToString::to_string));
let normalized_candidates = id_cache.normalize_candidates(candidates);
let add_channel = if id_cache.smart_match_enabled {
let direct_match = id_cache.contains_channel_epg_id(channel_id);
let normalized_match = id_cache.match_epg_channel_candidates(
channel_id,
&normalized_candidates,
epg_source.priority,
source_order,
);
direct_match || normalized_match
} else {
id_cache.contains_channel_epg_id(channel_id)
};
if !add_channel {
return false;
}
match ics::parse_ics_file_to_channel(
&epg_source.file_path,
Arc::clone(channel_id),
channel_title.cloned(),
config,
)
.await
{
Ok(channel) => {
id_cache.insert_processed_epg_id(channel_id);
accumulator.add_channel_with_programmes(
epg_source.priority,
source_order,
epg_source.logo_override,
channel,
);
accumulator.register_dummy_policy(
channel_id,
epg_source.priority,
source_order,
IcsDummyPolicy { timezone: config.timezone.clone(), config: config.dummy.clone() },
);
true
}
Err(err) => {
log::warn!("Failed to process ICS EPG file {}: {err}", epg_source.file_path.display());
false
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
use super::{
merge::{normalize_channel_programmes, EPG_DISK_BATCH_SIZE},
EpgMergeAccumulator, TempFileGuard,
};
use serde::{Deserialize, Serialize};
use shared::{model::EpgChannel, utils::Internable};
use std::{collections::HashMap, io, path::PathBuf, sync::Arc};
use tuliprox_core::utils::{arc_str_serde, with_folded_epg_id};
use tuliprox_repository::{BPlusTree, BPlusTreeUpdate, FlushPolicy};
impl EpgMergeAccumulator {
/// Drain the accumulator directly into a temp `BPlusTree` on disk. Peak RAM
/// here is `EPG_DISK_BATCH_SIZE × max_channel_size` — channels never sit
/// in a `Vec`. The returned `DiskEpgSource` removes the file in its
/// `Drop`.
///
/// `source_priority` and `source_order` are the per-source values used
/// for `set_attributes_if_preferred` at merge time. The accumulator
/// itself does not aggregate them, so the caller — which knows the
/// original Epg's `priority` and `source_order` — must hand them in.
///
/// # Panics
///
/// Panics if `source_order` exceeds `u32`, which would mean four billion
/// sources in one import.
pub fn finish_into_disk(self, path: PathBuf, source_priority: i16, source_order: u32) -> io::Result<DiskEpgSource> {
// Fresh tree at the temp path. `store` creates the file; the
// subsequent updater opens it for batched writes.
BPlusTree::<EpgDiskChannelKey, EpgChannel>::new()
.store(&path)
.map_err(|e| io::Error::other(format!("create temp EPG tree: {e}")))?;
// The temp file exists from here until `DiskEpgSource::new` takes
// ownership at the end. Any `?` in between would otherwise leak the
// file because no `Drop` is wired up yet. The local guard covers the
// fallible window and is disarmed just before we hand the path off.
let mut temp_guard = TempFileGuard(Some(path.clone()));
let mut updater = BPlusTreeUpdate::<EpgDiskChannelKey, EpgChannel>::try_new_with_backoff(&path)
.map_err(|e| io::Error::other(format!("open temp EPG tree: {e}")))?;
updater.set_flush_policy(FlushPolicy::Batch);
let EpgMergeAccumulator { attributes, channels, dummy_policies: _ } = self;
let total = channels.len();
let mut batch: Vec<(EpgDiskChannelKey, EpgChannel)> = Vec::with_capacity(EPG_DISK_BATCH_SIZE);
let mut written = 0usize;
for (_, mut acc) in channels {
normalize_channel_programmes(&mut acc);
let folded = with_folded_epg_id(&acc.channel.id, |folded| folded.intern());
batch.push((
EpgDiskChannelKey {
folded_id: folded,
priority: acc.priority,
// `usize -> u32` overflow requires > 4 billion sources, which
// is not representable in the file format anyway; treat it
// as a programmer error rather than a recoverable I/O
// failure.
source_order: u32::try_from(acc.source_order)
.expect("source_order exceeds u32 (4 billion sources in one import)"),
},
acc.channel,
));
if batch.len() >= EPG_DISK_BATCH_SIZE {
flush_batch(&mut updater, &mut batch, &mut written)?;
}
}
flush_batch(&mut updater, &mut batch, &mut written)?;
updater.commit()?;
// Reclaim space wasted by priority-override entries (multiple keys per
// channel). Without compact, the file is ~2× its eventual size.
updater.compact()?;
log::debug!(
"Drained {written} channels ({total} total before normalize) into temp EPG tree at {}",
path.display()
);
let source = DiskEpgSource::new(path, attributes.map(|a| a.attributes), source_priority, source_order);
// DiskEpgSource now owns the path; disarm the guard so its Drop does
// not double-remove the file. `mem::forget` would also work, but
// `take()` keeps the guard in scope and is auditable.
temp_guard.0.take();
Ok(source)
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
if let Err(err) = std::fs::remove_file(&path) {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to remove temp EPG tree at {}: {err}", path.display());
}
}
}
}
}
/// Flush a non-empty batch and bump the written counter. No-op for empty
/// batches, so the caller does not need a guard around the trailing flush.
fn flush_batch(
updater: &mut BPlusTreeUpdate<EpgDiskChannelKey, EpgChannel>,
batch: &mut Vec<(EpgDiskChannelKey, EpgChannel)>,
written: &mut usize,
) -> io::Result<()> {
if batch.is_empty() {
return Ok(());
}
let items: Vec<(&EpgDiskChannelKey, &EpgChannel)> = batch.iter().map(|(k, v)| (k, v)).collect();
let prepared = BPlusTreeUpdate::<EpgDiskChannelKey, EpgChannel>::prepare_upsert_batch(&items)?;
updater.upsert_batch_encoded(prepared)?;
*written += batch.len();
batch.clear();
Ok(())
}
/// Sort key for the per-source temp `BPlusTree`. Order: folded channel id ascending,
/// then priority ascending, then source order ascending. This matches the existing
/// rule in `EpgMergeAccumulator::upsert_channel` (`(priority, source_order) < (acc.priority, acc.source_order)`)
/// — lower numbers win. Sorted iteration over the tree therefore yields channels in
/// the right order for the multi-way merge downstream.
///
/// The `Ord` implementation below is hand-written to make the value-based,
/// deterministic ordering explicit (`folded_id` → `priority` → `source_order`).
/// `Arc<str>` already compares by value when the inner `str` is `Ord`, so a
/// derived `Ord` would produce the same total order — the custom impl exists
/// to lock the contract in source rather than rely on a derived behaviour.
/// Same key type serialises through `rmp_serde` as a record.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct EpgDiskChannelKey {
#[serde(with = "arc_str_serde")]
pub folded_id: Arc<str>,
pub priority: i16,
pub source_order: u32,
}
// Explicit value-based ordering. `#[derive(Ord)]` would derive the same total
// order via `Arc<str>`'s value-based `Ord` impl, but writing it out documents
// the contract: `folded_id` ascending, then `priority` ascending, then
// `source_order` ascending. Sorted iteration in `merge_epg_trees` depends on
// this exact order for deterministic multi-way merge results.
impl Ord for EpgDiskChannelKey {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.folded_id
.as_ref()
.cmp(other.folded_id.as_ref())
.then(self.priority.cmp(&other.priority))
.then(self.source_order.cmp(&other.source_order))
}
}
impl PartialOrd for EpgDiskChannelKey {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) }
}
/// Handle for a temp `BPlusTree` that holds the channels of one EPG source.
/// `Drop` removes the file — even on panic — so the temp dir never accumulates
/// stale trees. The caller is expected to hand ownership to `merge_epg_trees`,
/// which opens a query handle and drains the file before letting the guard drop.
///
/// `source_priority` and `source_order` are the per-source values used at
/// merge time by `set_attributes_if_preferred`. They live on the source
/// rather than being reconstructed from the disk tree, because the disk
/// tree only stores per-channel priorities (which can differ across
/// channels if sources overlap), not a single source-level value.
pub struct DiskEpgSource {
pub(in crate::parser) path: PathBuf,
pub(in crate::parser) attributes: Option<HashMap<Arc<str>, Arc<str>>>,
pub(in crate::parser) source_priority: i16,
pub(in crate::parser) source_order: u32,
}
impl DiskEpgSource {
pub fn new(
path: PathBuf,
attributes: Option<HashMap<Arc<str>, Arc<str>>>,
source_priority: i16,
source_order: u32,
) -> Self {
Self { path, attributes, source_priority, source_order }
}
}
impl Drop for DiskEpgSource {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
// Missing-file is fine (already cleaned up). Anything else is worth
// a warning — it leaks the temp file until the OS clears /tmp.
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to remove temp EPG tree at {}: {err}", self.path.display());
}
}
}
}
@@ -0,0 +1,62 @@
use super::{EpgMergeAccumulator, IcsPersistedSource, MergedEpgWithIconOverrides, TVGuide};
use crate::processor::EpgIdCache;
#[cfg(test)]
use tuliprox_core::model::Epg;
use tuliprox_core::model::PersistedEpgSourceKind;
impl TVGuide {
// Exercised only by this module's own tests.
#[cfg(test)]
pub async fn filter(&self, id_cache: &mut EpgIdCache) -> Option<Vec<Epg>> {
self.filter_merged(id_cache).await.map(|epg| vec![epg])
}
// Exercised only by this module's own tests.
#[cfg(test)]
pub async fn filter_merged(&self, id_cache: &mut EpgIdCache) -> Option<Epg> {
self.filter_merged_with_icon_overrides(id_cache).await.map(|(epg, _)| epg)
}
pub async fn filter_merged_with_icon_overrides(
&self,
id_cache: &mut EpgIdCache,
) -> Option<MergedEpgWithIconOverrides> {
if id_cache.channel_epg_id.is_empty() && id_cache.normalized.is_empty() {
return None;
}
let mut accumulator = EpgMergeAccumulator::new();
for (source_order, epg_source) in self.get_epg_sources().iter().enumerate() {
let _source_read_lock = match self.get_file_locks() {
Some(file_locks) => Some(file_locks.read_lock(&epg_source.file_path).await),
None => None,
};
match &epg_source.kind {
PersistedEpgSourceKind::Xmltv => {
Self::process_epg_file(id_cache, epg_source, source_order, &mut accumulator).await;
}
PersistedEpgSourceKind::Ics { channel_id, channel_title, match_names, config } => {
Self::process_ics_file(
id_cache,
epg_source,
source_order,
&mut accumulator,
IcsPersistedSource {
channel_id,
channel_title: channel_title.as_ref(),
match_names,
config: config.as_ref(),
},
)
.await;
}
}
}
let available_epg_ids = accumulator.channel_ids_with_programmes();
id_cache.finalize_matches(&available_epg_ids);
let mut selected_epg_ids = id_cache.selected_epg_ids(&available_epg_ids);
selected_epg_ids.extend(id_cache.channel_epg_id.iter().cloned());
let retained_epg_ids = accumulator.retain_channels(&selected_epg_ids);
id_cache.replace_processed_epg_ids(retained_epg_ids.intersection(&available_epg_ids).cloned().collect());
accumulator.finish_epg_with_icon_overrides()
}
}
@@ -0,0 +1,453 @@
use super::{
ChannelMergeAcc, DiskEpgSource, EpgDiskChannelKey, EpgMergeAccumulator, PreferredAttributes, PreferredDummyPolicy,
ProgrammeMergeEntry,
};
use log::error;
use quick_xml::events::BytesStart;
use shared::{
model::{EpgChannel, EpgProgramme},
utils::Internable,
};
use std::{
collections::{HashMap, HashSet},
io,
sync::Arc,
};
use tuliprox_core::{
model::{Epg, IcsDummyPolicy, EPG_TAG_CHANNEL, EPG_TAG_PROGRAMME, EPG_TAG_TV},
utils::with_folded_epg_id,
};
use tuliprox_parser::ics;
use tuliprox_repository::BPlusTreeQuery;
/// Channels per `BPlusTree` write batch. Sized so the in-flight batch + its
/// prepared references stay under 1 MiB on the typical 100-KiB-per-channel
/// EPG feed. This is an educated guess; profile-driven tuning is fine.
pub(super) const EPG_DISK_BATCH_SIZE: usize = 100;
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub(super) enum XmlTagType {
Ignored,
Tv,
Channel,
Programme,
}
impl XmlTagType {
#[inline]
pub fn is_tv(self) -> bool { self == XmlTagType::Tv }
}
pub(super) fn get_tag_type(name: &str) -> XmlTagType {
match name {
EPG_TAG_TV => XmlTagType::Tv,
EPG_TAG_CHANNEL => XmlTagType::Channel,
EPG_TAG_PROGRAMME => XmlTagType::Programme,
_ => XmlTagType::Ignored,
}
}
pub(super) fn collect_tag_attributes(e: &BytesStart) -> HashMap<Arc<str>, Arc<str>> {
let attributes = e
.attributes()
.filter_map(Result::ok)
.filter_map(|a| {
let key_binding = a.key;
let key_raw = String::from_utf8_lossy(key_binding.as_ref());
let key = key_raw.intern();
if let Ok(value) = a.normalized_value(quick_xml::XmlVersion::Implicit1_0).as_ref() {
if value.is_empty() {
None
} else {
// Ids are no longer lowercased when parsed; EPG matching folds case
// at the comparison instead, so the guide's original-case <channel id>
// and <programme channel> are preserved in the output.
Some((key, value.intern()))
}
} else {
None
}
})
.collect::<HashMap<Arc<str>, Arc<str>>>();
attributes
}
/// Carries the source rank required to select a preview dummy policy exactly like the main EPG merge.
#[derive(Debug)]
pub struct EpgDummyPolicySource {
pub priority: i16,
pub source_order: usize,
pub channel_id: Arc<str>,
pub policy: IcsDummyPolicy,
}
pub(super) type FinishedEpgChannels = (Option<HashMap<Arc<str>, Arc<str>>>, Vec<EpgChannel>);
pub type MergedEpgWithIconOverrides = (Epg, HashSet<Arc<str>>);
impl EpgMergeAccumulator {
pub fn new() -> Self { Self::default() }
pub(super) fn channel_ids_with_programmes(&self) -> HashSet<Arc<str>> {
self.channels
.iter()
.filter(|(id, channel)| !channel.programmes.is_empty() || self.dummy_policies.contains_key(*id))
.map(|(id, _)| Arc::clone(id))
.collect()
}
pub(super) fn retain_channels(&mut self, selected_epg_ids: &HashSet<Arc<str>>) -> HashSet<Arc<str>> {
self.channels.retain(|id, _| selected_epg_ids.contains(id));
self.dummy_policies.retain(|id, _| selected_epg_ids.contains(id));
self.channels.keys().cloned().collect()
}
pub fn set_attributes_if_preferred(
&mut self,
priority: i16,
source_order: usize,
attributes: Option<HashMap<Arc<str>, Arc<str>>>,
) {
let Some(attributes) = attributes else {
return;
};
let replace = self
.attributes
.as_ref()
.is_none_or(|current| (priority, source_order) < (current.priority, current.source_order));
if replace {
self.attributes = Some(PreferredAttributes { priority, source_order, attributes });
}
}
pub(super) fn upsert_channel(
&mut self,
priority: i16,
source_order: usize,
logo_override: bool,
mut channel: EpgChannel,
) {
let channel_key = with_folded_epg_id(&channel.id, |folded| folded.intern());
match self.channels.entry(channel_key) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
let acc = entry.get_mut();
acc.needs_programme_merge = true;
if (priority, source_order) < (acc.priority, acc.source_order) {
if channel.title.is_none() {
channel.title = acc.channel.title.take();
}
if channel.icon.is_none() {
channel.icon = acc.channel.icon.take();
} else {
acc.icon_logo_override = logo_override;
}
acc.priority = priority;
acc.source_order = source_order;
acc.logo_override = logo_override;
acc.channel.title = channel.title;
acc.channel.icon = channel.icon;
} else {
if acc.channel.title.is_none() {
acc.channel.title = channel.title.take();
}
if acc.channel.icon.is_none() {
acc.channel.icon = channel.icon.take();
acc.icon_logo_override = logo_override;
}
}
}
std::collections::hash_map::Entry::Vacant(entry) => {
let icon_logo_override = channel.icon.is_some() && logo_override;
entry.insert(ChannelMergeAcc {
priority,
source_order,
logo_override,
icon_logo_override,
needs_programme_merge: false,
channel,
programmes: Vec::new(),
});
}
}
}
pub(super) fn push_programme(&mut self, priority: i16, source_order: usize, programme: EpgProgramme) {
if let Some(channel) =
with_folded_epg_id(programme.get_transient_channel_id(), |folded| self.channels.get_mut(folded))
{
channel.programmes.push(ProgrammeMergeEntry { priority, source_order, programme });
} else {
error!("Channel {} not found in EPG, dangling programme", programme.get_transient_channel_id());
}
}
pub(super) fn register_dummy_policy(
&mut self,
channel_id: &Arc<str>,
priority: i16,
source_order: usize,
policy: IcsDummyPolicy,
) {
if !policy.config.enabled {
return;
}
let key = with_folded_epg_id(channel_id, |folded| folded.intern());
let replace = self
.dummy_policies
.get(&key)
.is_none_or(|current| (priority, source_order) < (current.priority, current.source_order));
if replace {
self.dummy_policies.insert(key, PreferredDummyPolicy { priority, source_order, policy });
}
}
pub fn add_channel_with_programmes(
&mut self,
priority: i16,
source_order: usize,
logo_override: bool,
mut channel: EpgChannel,
) {
let programmes = std::mem::take(&mut channel.programmes);
let channel_key = with_folded_epg_id(&channel.id, |folded| folded.intern());
self.upsert_channel(priority, source_order, logo_override, channel);
if let Some(acc) = self.channels.get_mut(&channel_key) {
if !acc.programmes.is_empty() {
acc.needs_programme_merge = true;
}
acc.programmes.extend(programmes.into_iter().map(|programme| ProgrammeMergeEntry {
priority,
source_order,
programme,
}));
}
}
pub(super) fn finish_channels(mut self) -> Option<FinishedEpgChannels> {
if self.channels.is_empty() {
return None;
}
let mut channels = self
.channels
.drain()
.map(|(_, mut acc)| {
normalize_channel_programmes(&mut acc);
acc
})
.collect::<Vec<_>>();
channels.sort_by(|left, right| left.channel.id.cmp(&right.channel.id));
let channels = channels.into_iter().map(|acc| acc.channel).collect();
Some((self.attributes.map(|attributes| attributes.attributes), channels))
}
pub(super) fn finish(self) -> Option<Epg> {
self.finish_channels().map(|(attributes, channels)| Epg {
logo_override: false,
priority: 0,
attributes,
children: channels.into_iter().map(Arc::new).collect(),
})
}
pub(super) fn finish_epg_with_icon_overrides(self) -> Option<MergedEpgWithIconOverrides> {
let EpgMergeAccumulator { attributes, channels, dummy_policies } = self;
let mut channels = channels.into_values().collect::<Vec<_>>();
if channels.is_empty() {
return None;
}
for acc in &mut channels {
normalize_channel_programmes(acc);
}
apply_dummy_policies(&mut channels, &dummy_policies);
channels.sort_by(|left, right| left.channel.id.cmp(&right.channel.id));
let icon_override_channels = channels
.iter()
.filter(|acc| acc.icon_logo_override)
.map(|acc| Arc::clone(&acc.channel.id))
.collect::<HashSet<_>>();
let children = channels.into_iter().map(|acc| Arc::new(acc.channel)).collect();
Some((
Epg {
logo_override: false,
priority: 0,
attributes: attributes.map(|attributes| attributes.attributes),
children,
},
icon_override_channels,
))
}
}
fn apply_dummy_policies(channels: &mut [ChannelMergeAcc], dummy_policies: &HashMap<Arc<str>, PreferredDummyPolicy>) {
let now = chrono::Utc::now();
for acc in channels {
let key = with_folded_epg_id(&acc.channel.id, |folded| folded.intern());
if let Some(preferred) = dummy_policies.get(&key) {
let policy = &preferred.policy;
if let Err(err) = ics::fill_dummy_gaps(
&mut acc.channel.programmes,
&acc.channel.id,
&policy.timezone,
&policy.config,
now,
) {
log::warn!("Failed to apply ICS dummy policy for {}: {err}", acc.channel.id);
}
}
}
}
fn backfill_programme_metadata(existing: &mut EpgProgramme, incoming: EpgProgramme) {
if existing.title.is_none() {
existing.title = incoming.title;
}
if existing.desc.is_none() {
existing.desc = incoming.desc;
}
if existing.icon.is_none() {
existing.icon = incoming.icon;
}
if existing.catchup_id.is_none() {
existing.catchup_id = incoming.catchup_id;
}
if existing.categories.is_empty() {
existing.categories = incoming.categories;
}
existing.is_live |= incoming.is_live;
existing.is_new |= incoming.is_new;
}
pub(super) fn normalize_channel_programmes(acc: &mut ChannelMergeAcc) {
// `acc.programmes` is the cross-source list of `ProgrammeMergeEntry` records
// (populated by `add_channel_with_programmes` and `push_programme`).
// When the list is empty, the channel's own programmes — set by the
// vacant path of `upsert_channel` — are authoritative; the previous
// implementation unconditionally overwrote `acc.channel.programmes` with
// an empty vector here, which silently dropped every programme on the
// single-source and disk-spill paths.
if acc.programmes.is_empty() {
return;
}
acc.programmes
.sort_by_key(|entry| (entry.programme.start, entry.programme.stop, entry.priority, entry.source_order));
let programme_entries = std::mem::take(&mut acc.programmes);
let mut merged_programmes = Vec::with_capacity(programme_entries.len());
let mut entries = programme_entries.into_iter();
if let Some(first_entry) = entries.next() {
let mut current = first_entry.programme;
for entry in entries {
if entry.programme.start == current.start && entry.programme.stop == current.stop {
backfill_programme_metadata(&mut current, entry.programme);
} else {
merged_programmes.push(current);
current = entry.programme;
}
}
merged_programmes.push(current);
}
merged_programmes.sort_by_key(|programme| (programme.start, programme.stop));
acc.channel.programmes = merged_programmes;
}
#[cfg(test)]
pub fn merge_epg_channels_by_priority(channels_by_source: Vec<(i16, Vec<EpgChannel>)>) -> Vec<EpgChannel> {
let mut accumulator = EpgMergeAccumulator::new();
for (source_order, (priority, channels)) in channels_by_source.into_iter().enumerate() {
for channel in channels {
accumulator.add_channel_with_programmes(priority, source_order, false, channel);
}
}
accumulator.finish_channels().map(|(_, channels)| channels).unwrap_or_default()
}
pub fn merge_epg_channels_by_priority_with_dummy_policies(
channels_by_source: Vec<(i16, Vec<EpgChannel>)>,
dummy_policies: Vec<EpgDummyPolicySource>,
) -> Vec<EpgChannel> {
let mut accumulator = EpgMergeAccumulator::new();
for (source_order, (priority, channels)) in channels_by_source.into_iter().enumerate() {
for channel in channels {
accumulator.add_channel_with_programmes(priority, source_order, false, channel);
}
}
for source in dummy_policies {
accumulator.register_dummy_policy(&source.channel_id, source.priority, source.source_order, source.policy);
}
accumulator
.finish_epg_with_icon_overrides()
.map(|(epg, _)| epg.children.into_iter().map(Arc::unwrap_or_clone).collect())
.unwrap_or_default()
}
pub fn flatten_tvguide(tv_guides: Vec<Epg>) -> Option<Epg> {
let mut accumulator = EpgMergeAccumulator::new();
for (source_order, guide) in tv_guides.into_iter().enumerate() {
accumulator.set_attributes_if_preferred(guide.priority, source_order, guide.attributes);
for channel in guide.children {
accumulator.add_channel_with_programmes(
guide.priority,
source_order,
guide.logo_override,
Arc::unwrap_or_clone(channel),
);
}
}
accumulator.finish()
}
/// Merge N temp `BPlusTree`s (one per EPG source) into a single `Epg`,
/// applying dummy policies and the existing priority/sort rules. Sources
/// are consumed one at a time — each `DiskEpgSource`'s temp file is
/// removed by its `Drop` as soon as its iterator is exhausted, so peak RAM
/// is bounded by the largest single source plus the accumulator's
/// per-channel `HashMap`, not the total feed size.
///
/// Complexity: `O(n_sources + total_channels)`. The per-channel priority
/// resolution happens inside `EpgMergeAccumulator::upsert_channel`
/// (`HashMap` insert), not in a heap. Sources are processed in iteration
/// order, so earlier sources win priority ties for shared channels.
///
/// The constant-memory guarantee lives on the write side
/// (`EpgMergeAccumulator::finish_into_disk`); this function is bounded by
/// the union's metadata `HashMap` in the accumulator, which is the same
/// shape the in-memory path already carries.
///
/// The icon-override channel set returned by `finish_epg_with_icon_overrides`
/// is intentionally dropped — the wire-up path persists channels directly,
/// not the override metadata. If a caller later needs that set, the
/// `MergedEpgWithIconOverrides` tuple is already in scope via this
/// function's return type.
pub fn merge_epg_trees(sources: Vec<DiskEpgSource>) -> io::Result<Option<MergedEpgWithIconOverrides>> {
let mut accumulator = EpgMergeAccumulator::new();
for source in sources {
if let Some(attrs) = &source.attributes {
accumulator.set_attributes_if_preferred(
source.source_priority,
source.source_order as usize,
Some(attrs.clone()),
);
}
let mut query = BPlusTreeQuery::<EpgDiskChannelKey, EpgChannel>::try_new(&source.path)
.map_err(|e| io::Error::other(format!("open temp EPG tree at {}: {e}", source.path.display())))?;
for entry in query.iter() {
let (key, channel) = entry.map_err(|e| io::Error::other(format!("read temp EPG entry: {e}")))?;
// `add_channel_with_programmes` preserves the channel's programmes
// on the merge accumulator; plain `upsert_channel` would drop them
// because the vacant-entry path constructs a fresh `ChannelMergeAcc`
// with `programmes: Vec::new()`.
accumulator.add_channel_with_programmes(key.priority, key.source_order as usize, false, channel);
}
// `source` drops at the end of this iteration, which removes the
// temp file. The query's mmap/buffer is closed first because `query`
// goes out of scope before `source`.
}
Ok(accumulator.finish_epg_with_icon_overrides())
}
@@ -0,0 +1,100 @@
use shared::{
model::EpgNamePrefix,
utils::{deunicode_string, CONSTANTS},
};
use tuliprox_core::model::EpgSmartMatchConfig;
/// Splits a string at the first delimiter if the prefix matches a known country code.
///
/// Returns a tuple containing the country code prefix (if found) and the remainder of the string, both trimmed. If no valid prefix is found, returns `None` and the original input.
///
/// # Examples
///
/// ```text
/// let delimiters = vec!['.', '-', '_'];
/// let (prefix, rest) = split_by_first_match("US.HBO", &delimiters);
/// assert_eq!(prefix, Some("US"));
/// assert_eq!(rest, "HBO");
///
/// let (prefix, rest) = split_by_first_match("HBO", &delimiters);
/// assert_eq!(prefix, None);
/// assert_eq!(rest, "HBO");
/// ```
fn split_by_first_match<'a>(input: &'a str, delimiters: &[char]) -> (Option<&'a str>, &'a str) {
let content = input.trim_start_matches(|c: char| !c.is_alphanumeric());
for delim in delimiters {
if let Some(index) = content.find(*delim) {
let (left, right) = content.split_at(index);
let right = &right[delim.len_utf8()..].trim();
if !right.is_empty() {
let prefix = left.trim();
if CONSTANTS.country_codes.contains(&prefix) {
return (Some(prefix), right.trim());
}
}
}
}
(None, input)
}
pub(super) fn name_prefix<'a>(name: &'a str, smart_config: &EpgSmartMatchConfig) -> (&'a str, Option<&'a str>) {
if smart_config.name_prefix != EpgNamePrefix::Ignore {
let (prefix, suffix) = split_by_first_match(name, &smart_config.name_prefix_separator);
if prefix.is_some() {
return (suffix, prefix);
}
}
(name, None)
}
fn combine(join: &str, left: &str, right: &str) -> String {
let mut combined = String::with_capacity(left.len() + join.len() + right.len());
combined.push_str(left);
combined.push_str(join);
combined.push_str(right);
combined
}
pub(super) fn strip_markers(input: &str, markers: &[String]) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while cursor < input.len() {
let left_is_boundary = cursor == 0 || !input.as_bytes()[cursor - 1].is_ascii_alphanumeric();
let matched_end = left_is_boundary
.then(|| {
markers.iter().filter(|marker| !marker.is_empty()).find_map(|marker| {
let end = cursor + marker.len();
let candidate = input.get(cursor..end)?;
(candidate.eq_ignore_ascii_case(marker)
&& input.as_bytes().get(end).is_none_or(|byte| !byte.is_ascii_alphanumeric()))
.then_some(end)
})
})
.flatten();
if let Some(end) = matched_end {
cursor = end;
} else {
let next = input[cursor..].chars().next().expect("cursor is within the string");
output.push(next);
cursor += next.len_utf8();
}
}
output
}
/// # Panics
pub fn normalize_channel_name(name: &str, normalize_config: &EpgSmartMatchConfig) -> String {
let normalized = deunicode_string(name.trim()).to_lowercase();
let (channel_name, suffix) = name_prefix(&normalized, normalize_config);
let stripped_name = strip_markers(channel_name, &normalize_config.strip);
let reconstructed = match suffix {
None => stripped_name,
Some(sfx) => match &normalize_config.name_prefix {
EpgNamePrefix::Ignore => stripped_name,
EpgNamePrefix::Suffix(separator) => combine(separator, &stripped_name, sfx),
EpgNamePrefix::Prefix(separator) => combine(separator, sfx, &stripped_name),
},
};
normalize_config.normalize_regex.replace_all(&reconstructed, "").into_owned()
}
@@ -0,0 +1,67 @@
use super::TVGuide;
use crate::parser::xmltv::{
flatten_tvguide, merge_epg_channels_by_priority, merge_epg_channels_by_priority_with_dummy_policies,
normalize_channel_name, EpgDummyPolicySource, EpgMergeAccumulator,
};
use shared::model::{EpgCategory, EpgChannel, EpgProgramme};
use std::{collections::HashSet, fs, path::PathBuf, sync::Arc};
use tempfile::tempdir;
use tuliprox_core::{
model::{Epg, EpgSmartMatchConfig, IcsDummyConfig, IcsEpgSourceConfig, PersistedEpgSource, PersistedEpgSourceKind},
utils::FileLockManager,
};
/// Run an async test body on a freshly-created multi-threaded tokio
/// runtime. Centralizes the `Runtime::new()...block_on(...)` boilerplate
/// shared by every test in this module that exercises async EPG code.
fn run_async_test<F>(future: F)
where
F: std::future::Future<Output = ()>,
{
tokio::runtime::Runtime::new().unwrap().block_on(future);
}
fn xmltv_source(file_path: PathBuf, priority: i16, logo_override: bool) -> PersistedEpgSource {
PersistedEpgSource { file_path, priority, logo_override, kind: PersistedEpgSourceKind::Xmltv }
}
fn dummy_policy_source(priority: i16, source_order: usize, title: &str) -> EpgDummyPolicySource {
EpgDummyPolicySource {
priority,
source_order,
channel_id: "f1.calendar".intern(),
policy: tuliprox_core::model::IcsDummyPolicy {
timezone: "UTC".to_string(),
config: IcsDummyConfig {
enabled: true,
title: title.to_string(),
description: String::new(),
days_past: 0,
days_future: 0,
block_hours: 24,
min_gap_minutes: 1,
},
},
}
}
fn epg_channel(id: &str, title: Option<&str>, icon: Option<&str>, programmes: Vec<EpgProgramme>) -> EpgChannel {
EpgChannel { id: id.intern(), title: title.map(Internable::intern), icon: icon.map(Internable::intern), programmes }
}
fn epg_programme(id: &str, start: i64, stop: i64, title: Option<&str>, desc: Option<&str>) -> EpgProgramme {
EpgProgramme::new_all(start, stop, id.intern(), title.map(Internable::intern), desc.map(Internable::intern), None)
}
use crate::processor::EpgIdCache;
use rphonetic::{Encoder, Metaphone};
use shared::{
model::{EpgNamePrefix, EpgSmartMatchConfigDto},
utils::Internable,
};
mod lifecycle;
mod playlist;
mod policy;
mod storage;
mod transport;
@@ -0,0 +1,16 @@
/// Smallest testable piece of the disk-spilling path. The Drop guard is
/// what keeps a panic or early return from leaking temp files into /tmp.
/// If this fails, every other disk-spilling test is built on sand.
#[test]
fn disk_epg_source_removes_its_temp_file_on_drop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("epg-source.db");
std::fs::write(&path, b"placeholder").unwrap();
assert!(path.exists(), "precondition: temp file must exist");
{
let _src = super::super::DiskEpgSource::new(path.clone(), None, 0, 0);
} // _src dropped here
assert!(!path.exists(), "DiskEpgSource::Drop must remove its temp file");
}
@@ -0,0 +1,528 @@
use super::*;
#[test]
fn epg_priority_merge_preserves_high_priority_metadata_and_fills_programme_gaps() {
let merged = merge_epg_channels_by_priority(vec![
(
10,
vec![epg_channel(
"demo.channel",
Some("Low"),
Some("http://low/icon.png"),
vec![epg_programme("demo.channel", 10, 20, Some("Low Show"), None)],
)],
),
(
0,
vec![epg_channel(
"demo.channel",
Some("High"),
Some("http://high/icon.png"),
vec![epg_programme("demo.channel", 20, 30, Some("High Show"), None)],
)],
),
]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].title.as_deref(), Some("High"));
assert_eq!(merged[0].icon.as_deref(), Some("http://high/icon.png"));
assert_eq!(
merged[0].programmes.iter().map(|programme| (programme.start, programme.stop)).collect::<Vec<_>>(),
vec![(10, 20), (20, 30)],
);
}
#[test]
fn epg_priority_merge_backfills_programme_tags() {
let low_priority_categories = vec![
EpgCategory { value: "Sports".intern(), lang: None },
EpgCategory { value: "Live".intern(), lang: Some("en".intern()) },
];
let mut low_programme = epg_programme("demo.channel", 10, 20, None, None);
low_programme.icon = Some("https://example.com/programme.jpg".intern());
low_programme.categories = low_priority_categories.clone();
low_programme.is_live = true;
low_programme.is_new = true;
let merged = merge_epg_channels_by_priority(vec![
(
0,
vec![epg_channel(
"demo.channel",
Some("High"),
None,
vec![epg_programme("demo.channel", 10, 20, Some("High Title"), None)],
)],
),
(10, vec![epg_channel("demo.channel", None, None, vec![low_programme])]),
]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].programmes.len(), 1);
assert_eq!(merged[0].programmes[0].icon.as_deref(), Some("https://example.com/programme.jpg"));
assert_eq!(merged[0].programmes[0].categories, low_priority_categories);
assert!(merged[0].programmes[0].is_live);
assert!(merged[0].programmes[0].is_new);
let high_programme_with_categories = {
let mut programme = epg_programme("demo.channel", 30, 40, Some("High Title 2"), None);
programme.categories = vec![EpgCategory { value: "Drama".intern(), lang: None }];
programme.is_new = true;
programme
};
let low_programme_extra = {
let mut programme = epg_programme("demo.channel", 30, 40, None, None);
programme.categories = vec![EpgCategory { value: "ShouldNotWin".intern(), lang: None }];
programme.is_live = true;
programme
};
let merged = merge_epg_channels_by_priority(vec![
(0, vec![epg_channel("demo.channel", None, None, vec![high_programme_with_categories])]),
(10, vec![epg_channel("demo.channel", None, None, vec![low_programme_extra])]),
]);
assert_eq!(merged.len(), 1);
let programme = &merged[0].programmes[0];
assert_eq!(programme.categories, vec![EpgCategory { value: "Drama".intern(), lang: None }],);
assert!(programme.is_live);
assert!(programme.is_new);
}
#[test]
fn epg_priority_merge_keeps_same_priority_deterministic_order() {
let merged = merge_epg_channels_by_priority(vec![
(
0,
vec![epg_channel(
"demo.channel",
Some("First"),
None,
vec![epg_programme("demo.channel", 10, 20, Some("First Title"), None)],
)],
),
(
0,
vec![epg_channel(
"demo.channel",
Some("Second"),
Some("http://second/icon.png"),
vec![
epg_programme("demo.channel", 10, 20, Some("Second Title"), Some("Second desc")),
epg_programme("demo.channel", 20, 30, Some("Second Programme"), None),
],
)],
),
]);
assert_eq!(merged[0].title.as_deref(), Some("First"));
assert_eq!(merged[0].icon.as_deref(), Some("http://second/icon.png"));
assert_eq!(merged[0].programmes[0].title.as_deref(), Some("First Title"));
assert_eq!(merged[0].programmes[0].desc.as_deref(), Some("Second desc"));
assert_eq!(merged[0].programmes.len(), 2);
}
#[test]
fn epg_priority_merge_flatten_uses_same_backfill_rules() {
let flattened = flatten_tvguide(vec![
Epg {
logo_override: false,
priority: 0,
attributes: None,
children: vec![Arc::new(epg_channel(
"demo.channel",
Some("High"),
None,
vec![epg_programme("demo.channel", 10, 20, Some("High Show"), None)],
))],
},
Epg {
logo_override: false,
priority: 10,
attributes: None,
children: vec![Arc::new(epg_channel(
"demo.channel",
Some("Low"),
Some("http://low/icon.png"),
vec![epg_programme("demo.channel", 20, 30, Some("Low Show"), None)],
))],
},
])
.expect("flattened epg");
assert_eq!(flattened.children.len(), 1);
assert_eq!(flattened.children[0].title.as_deref(), Some("High"));
assert_eq!(flattened.children[0].icon.as_deref(), Some("http://low/icon.png"));
assert_eq!(flattened.children[0].programmes.len(), 2);
}
#[test]
fn epg_priority_merge_flatten_keeps_shared_arc_channels() {
let shared_channel = Arc::new(epg_channel(
"demo.channel",
Some("Shared"),
Some("http://shared/icon.png"),
vec![epg_programme("demo.channel", 10, 20, Some("Shared Show"), None)],
));
let flattened = flatten_tvguide(vec![Epg {
logo_override: false,
priority: 0,
attributes: None,
children: vec![Arc::clone(&shared_channel)],
}])
.expect("flattened epg");
assert_eq!(flattened.children.len(), 1);
assert_eq!(flattened.children[0].title.as_deref(), Some("Shared"));
assert_eq!(flattened.children[0].icon.as_deref(), Some("http://shared/icon.png"));
}
#[test]
fn epg_priority_merge_tracks_icon_override_per_channel() {
let mut accumulator = EpgMergeAccumulator::new();
accumulator.add_channel_with_programmes(
0,
0,
false,
epg_channel("demo.keep", Some("Keep"), Some("http://keep/icon.png"), vec![]),
);
accumulator.add_channel_with_programmes(
1,
1,
true,
epg_channel("demo.override", Some("Override"), Some("http://override/icon.png"), vec![]),
);
let (_epg, icon_override_channels) = accumulator.finish_epg_with_icon_overrides().expect("merged epg");
let keep_id: Arc<str> = "demo.keep".intern();
let override_id: Arc<str> = "demo.override".intern();
assert!(!icon_override_channels.contains(&keep_id));
assert!(icon_override_channels.contains(&override_id));
}
#[test]
fn epg_priority_merge_preserves_exact_id_match_when_smart_match_is_enabled() {
run_async_test(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("smart-exact.xml");
fs::write(
&epg_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="demo.channel">
<display-name>Completely Different Name</display-name>
</channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="demo.channel">
<title>Exact Match Source</title>
</programme>
</tv>"#,
)
.unwrap();
let mut smart_cfg = EpgSmartMatchConfigDto { enabled: true, ..Default::default() };
smart_cfg.prepare().expect("smart match config");
let tv_guide = TVGuide::new(vec![xmltv_source(epg_path, 0, false)]);
let mut id_cache = EpgIdCache::new(Some(&tuliprox_core::model::EpgConfig {
sources: vec![],
smart_match: Some(EpgSmartMatchConfig::from(smart_cfg)),
}));
id_cache.insert_channel_epg_id("demo.channel");
let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg");
assert_eq!(merged.children.len(), 1);
assert_eq!(merged.children[0].id.as_ref(), "demo.channel");
assert_eq!(merged.children[0].programmes.len(), 1);
});
}
#[test]
fn epg_channel_id_match_is_case_insensitive_and_preserves_guide_case() {
run_async_test(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("mixed-case.xml");
// Guide channel ids are MixedCase and differ in case from the playlist ids.
fs::write(
&epg_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="Sport.Extra1.DE">
<display-name>Sport Extra</display-name>
</channel>
<channel id="CNN.US">
<display-name>CNN</display-name>
</channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="Sport.Extra1.DE">
<title>Match A</title>
</programme>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="CNN.US">
<title>Match B</title>
</programme>
</tv>"#,
)
.unwrap();
let tv_guide = TVGuide::new(vec![xmltv_source(epg_path, 0, false)]);
let mut id_cache = EpgIdCache::new(None);
// Playlist epg ids arrive in a *different* case than the guide. Both origins
// (an Xtream source and a mapper-script literal) go through the same
// `insert_channel_epg_id`, which folds the membership key.
id_cache.insert_channel_epg_id("sport.EXTRA1.de"); // e.g. from an Xtream source
id_cache.insert_channel_epg_id("cnn.us"); // e.g. set by a mapper literal @epg_channel_id = "cnn.us"
let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg");
// Both MixedCase guide channels matched despite the case difference.
assert_eq!(merged.children.len(), 2);
let ids: HashSet<&str> = merged.children.iter().map(|c| c.id.as_ref()).collect();
// Emitted <channel id> preserves the guide's ORIGINAL case (not folded).
assert!(ids.contains("Sport.Extra1.DE"), "emitted ids: {ids:?}");
assert!(ids.contains("CNN.US"), "emitted ids: {ids:?}");
for channel in &merged.children {
assert_eq!(channel.programmes.len(), 1, "channel {} programmes", channel.id);
}
});
}
#[test]
fn epg_programme_channel_match_is_case_insensitive_within_source() {
run_async_test(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("mixed-case-programme.xml");
fs::write(
&epg_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="Demo.Channel">
<display-name>Demo</display-name>
</channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="demo.channel">
<title>Case Variant Programme</title>
</programme>
</tv>"#,
)
.unwrap();
let tv_guide = TVGuide::new(vec![xmltv_source(epg_path, 0, false)]);
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("demo.channel");
let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg");
assert_eq!(merged.children.len(), 1);
assert_eq!(merged.children[0].id.as_ref(), "Demo.Channel");
assert_eq!(merged.children[0].programmes.len(), 1);
assert_eq!(merged.children[0].programmes[0].get_transient_channel_id().as_ref(), "demo.channel");
});
}
#[test]
fn ics_dummy_policy_fills_after_real_programme_merge_without_overwriting_xmltv() {
run_async_test(async move {
use chrono::{Datelike, TimeZone, Utc};
let dir = tempdir().unwrap();
let xml_path = dir.path().join("guide.xml");
let ics_path = dir.path().join("empty.ics");
let now = Utc::now();
let real_start = Utc.with_ymd_and_hms(now.year(), now.month(), now.day(), 4, 0, 0).single().expect("start");
let real_stop = Utc.with_ymd_and_hms(now.year(), now.month(), now.day(), 6, 0, 0).single().expect("stop");
fs::write(
&xml_path,
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="f1.calendar">
<display-name>Formula 1</display-name>
</channel>
<programme start="{start}" stop="{stop}" channel="f1.calendar">
<title>Real Show</title>
</programme>
</tv>"#,
start = real_start.format("%Y%m%d%H%M%S %z"),
stop = real_stop.format("%Y%m%d%H%M%S %z"),
),
)
.unwrap();
fs::write(&ics_path, "BEGIN:VCALENDAR\nEND:VCALENDAR\n").unwrap();
let guide = TVGuide::new(vec![
xmltv_source(xml_path, 0, false),
PersistedEpgSource {
file_path: ics_path,
priority: 1,
logo_override: false,
kind: PersistedEpgSourceKind::Ics {
channel_id: "f1.calendar".intern(),
channel_title: Some("Formula 1".intern()),
match_names: Vec::new(),
config: Box::new(IcsEpgSourceConfig {
timezone: "UTC".to_string(),
dummy: IcsDummyConfig {
enabled: true,
title: "No programme".to_string(),
description: String::new(),
days_past: 0,
days_future: 0,
block_hours: 4,
min_gap_minutes: 1,
},
..IcsEpgSourceConfig::default()
}),
},
},
]);
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("f1.calendar");
let merged = guide.filter_merged(&mut id_cache).await.expect("merged");
let channel = &merged.children[0];
assert_eq!(channel.id.as_ref(), "f1.calendar");
assert_eq!(
channel.programmes.iter().filter(|programme| programme.title.as_deref() == Some("Real Show")).count(),
1
);
let dummy_programmes = channel
.programmes
.iter()
.filter(|programme| programme.title.as_deref() == Some("No programme"))
.collect::<Vec<_>>();
assert!(!dummy_programmes.is_empty());
for dummy in dummy_programmes {
assert!(
dummy.stop <= real_start.timestamp() || dummy.start >= real_stop.timestamp(),
"dummy overlaps real programme: {}-{}",
dummy.start,
dummy.stop
);
}
});
}
#[test]
fn dummy_policy_selection_uses_priority_then_source_order() {
let channel = || EpgChannel {
id: "f1.calendar".intern(),
title: Some("Formula 1".intern()),
icon: None,
programmes: Vec::new(),
};
let merge = |policies| {
merge_epg_channels_by_priority_with_dummy_policies(vec![(0, vec![channel()])], policies)
.into_iter()
.next()
.expect("merged channel")
};
let priority_winner =
merge(vec![dummy_policy_source(10, 0, "Low priority"), dummy_policy_source(-10, 1, "High priority")]);
assert!(!priority_winner.programmes.is_empty());
assert!(priority_winner.programmes.iter().all(|programme| programme.title.as_deref() == Some("High priority")));
let source_order_winner =
merge(vec![dummy_policy_source(0, 2, "Later source"), dummy_policy_source(0, 1, "Earlier source")]);
assert!(!source_order_winner.programmes.is_empty());
assert!(source_order_winner
.programmes
.iter()
.all(|programme| programme.title.as_deref() == Some("Earlier source")));
}
#[test]
fn xmltv_programme_tags_are_extracted() {
run_async_test(async move {
let dir = tempdir().expect("temp dir");
let epg_path = dir.path().join("programme-tags.xml");
fs::write(
&epg_path,
r#"<tv>
<channel id="ESPN.us"><display-name>ESPN</display-name></channel>
<programme start="20260718180000 +0000" stop="20260718200000 +0000" channel="ESPN.us">
<title lang="en">Softball</title>
<icon src="https://example.com/softball.jpg"/>
<icon src=""/>
<category lang="en">Softball</category>
<category>Sports</category>
<live/>
<new></new>
</programme>
</tv>"#,
)
.expect("write XMLTV fixture");
let guide = TVGuide::new(vec![xmltv_source(epg_path, 0, false)]);
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("ESPN.us");
let merged = guide.filter_merged(&mut id_cache).await.expect("merged EPG");
let programme = &merged.children[0].programmes[0];
assert_eq!(
programme.categories,
vec![
EpgCategory { value: "Softball".intern(), lang: Some("en".intern()) },
EpgCategory { value: "Sports".intern(), lang: None },
],
);
assert_eq!(programme.icon.as_deref(), Some("https://example.com/softball.jpg"));
assert!(programme.is_live);
assert!(programme.is_new);
});
}
/// Regression guard for the buffer-preallocation in `parse_tvguide`.
///
/// `quick_xml` uses the caller-provided `Vec<u8>` as a monotonically growing
/// read buffer (it does not call `.clear()` between events; the caller
/// slices the new portion). For an XMLTV feed with a single giant
/// `<programme>` description, that buffer used to start at 0 capacity and
/// double ~25 times on the way to ~163 MiB — every doubling being a
/// copying realloc. Starting with `Vec::with_capacity(64 * 1024)` removes
/// the realloc chain. This test feeds a programme whose description is
/// bigger than that initial capacity and checks that parsing still
/// succeeds; the absence of the test would have allowed a silent
/// regression to `Vec::new()` without breaking any visible behaviour.
#[test]
fn parse_tvguide_handles_giant_programme_description() {
use crate::parser::xmltv::parse_tvguide;
use tuliprox_core::model::{EPG_TAG_DESC, EPG_TAG_PROGRAMME};
run_async_test(async {
// 200 KiB of text content — well beyond the 64 KiB preallocation.
let big_text = "x".repeat(200 * 1024);
// `channel` attribute is required for the parser to fire the
// callback on a <programme> tag (see `handle_tag_end`).
let xml = format!(
r#"<?xml version="1.0"?><tv><programme channel="c1"><title>t</title><desc>{big_text}</desc></programme></tv>"#
);
let mut emitted_tags: Vec<tuliprox_core::model::XmlTag> = Vec::new();
parse_tvguide(xml.as_bytes(), &mut |tag| {
emitted_tags.push(tag);
})
.await;
// The whole point of the preallocation is that the full payload
// survives the round trip — not just the presence of a tag.
let programme = emitted_tags
.iter()
.find(|tag| tag.name.as_ref() == EPG_TAG_PROGRAMME)
.expect("parser emitted no <programme> tag");
let children = programme.children.as_deref().expect("<programme> has no children");
let desc = children
.iter()
.find(|child| child.name.as_ref() == EPG_TAG_DESC)
.expect("<programme> emitted no <desc> child");
assert_eq!(
desc.value.as_deref().map(str::len),
Some(big_text.len()),
"<desc> payload was truncated; got {} bytes, expected {}",
desc.value.as_deref().map_or(0, str::len),
big_text.len()
);
});
}
@@ -0,0 +1,135 @@
use super::*;
#[test]
/// Tests normalization of a channel name using the default smart match configuration.
///
/// # Examples
///
/// ```
/// parse_normalize().unwrap();
/// ```
fn parse_normalize() {
let epg_normalize_dto = EpgSmartMatchConfigDto { ..Default::default() };
let epg_normalize = EpgSmartMatchConfig::from(epg_normalize_dto);
let normalized = normalize_channel_name("Love Nature", &epg_normalize);
assert_eq!(normalized, "lovenature".to_string());
}
#[test]
fn normalization_keeps_country_suffixes_consistent() {
let mut dto = EpgSmartMatchConfigDto {
enabled: true,
name_prefix: EpgNamePrefix::Suffix(".".to_string()),
..Default::default()
};
dto.prepare().expect("valid smart-match config");
let config = EpgSmartMatchConfig::from(dto);
assert_eq!(normalize_channel_name("FR: TF1 FHD", &config), "tf1.fr");
assert_eq!(normalize_channel_name("TF1.fr", &config), "tf1.fr");
}
#[test]
fn normalization_strips_quality_markers_without_corrupting_names() {
let mut dto = EpgSmartMatchConfigDto { enabled: true, ..Default::default() };
dto.prepare().expect("valid smart-match config");
let config = EpgSmartMatchConfig::from(dto);
assert_eq!(normalize_channel_name("RMC Story H265 50FPS", &config), "rmcstory");
assert_eq!(normalize_channel_name("Ashdod TV HD", &config), "ashdodtv");
}
#[test]
fn marker_stripping_skips_non_utf8_boundaries() {
assert_eq!(super::super::strip_markers("éclair", &["x".to_string()]), "éclair");
}
#[ignore = "requires a local XMLTV fixture under /tmp"]
#[test]
fn parse_test() {
let run_test = async move || {
//let file_path = PathBuf::from("/tmp/epg.xml.gz");
let file_path = PathBuf::from("/tmp/invalid_epg.xml");
if file_path.exists() {
let tv_guide = TVGuide::new(vec![xmltv_source(file_path, 0, false)]);
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("342");
//id_cache.collect_epg_id(fp);
let channel_ids = HashSet::from([342u32.intern()]);
match tv_guide.filter(&mut id_cache).await {
None => panic!("No epg filtered"),
Some(epgs) => {
for epg in epgs {
assert_eq!(epg.children.len(), channel_ids.len() * 2, "Epg size does not match");
}
}
}
}
};
tokio::runtime::Runtime::new().unwrap().block_on(run_test());
}
#[test]
/// Tests normalization of channel names with various prefixes, suffixes, and special characters using a configured `EpgSmartMatchConfig`.
///
/// # Examples
///
/// ```
/// normalize();
/// // This will assert that various channel names are normalized as expected.
/// ```
fn normalize() {
let mut epg_smart_cfg_dto = EpgSmartMatchConfigDto {
enabled: true,
name_prefix: EpgNamePrefix::Suffix(".".to_string()),
..Default::default()
};
let _ = epg_smart_cfg_dto.prepare();
let epg_smart_cfg = EpgSmartMatchConfig::from(epg_smart_cfg_dto);
println!("{epg_smart_cfg:?}");
assert_eq!("supersport6.ru", normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_smart_cfg));
assert_eq!("odisea.sat", normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_smart_cfg));
assert_eq!("odisea.4k", normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
assert_eq!("odisea", normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
assert_eq!("odisea.bu", normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
assert_eq!("odisea.bg", normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg));
}
#[test]
/// Demonstrates phonetic encoding (Metaphone) of normalized channel names with various prefixes and suffixes.
///
/// This test prints the Metaphone-encoded representations of several normalized channel names using a configured `EpgSmartMatchConfig`.
///
/// # Examples
///
/// ```
/// test_metaphone();
/// // Output will show the Metaphone encodings for different channel name variants.
/// ```
fn test_metaphone() {
let metaphone = Metaphone::default();
let mut epg_smart_cfg_dto = EpgSmartMatchConfigDto {
enabled: true,
name_prefix: EpgNamePrefix::Suffix(".".to_string()),
..Default::default()
};
let _ = epg_smart_cfg_dto.prepare();
let epg_smart_cfg = EpgSmartMatchConfig::from(epg_smart_cfg_dto);
println!("{epg_smart_cfg:?}");
// assert_eq!("supersport6.ru", metaphone.encode(&normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_normalize_cfg)));
// assert_eq!("odisea.sat", metaphone.encode(&normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_normalize_cfg)));
// assert_eq!("odisea", metaphone.encode(&normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
// assert_eq!("odisea", metaphone.encode(&normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
// assert_eq!("odisea.bu", metaphone.encode(&normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
// assert_eq!("odisea.bg", metaphone.encode(&normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_normalize_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("RU: SUPERSPORT 6 ᴿᴬᵂ", &epg_smart_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("SAT: ODISEA ᴿᴬᵂ", &epg_smart_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("4K: ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("BU | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
println!("{}", metaphone.encode(&normalize_channel_name("BG | ODISEA ᵁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)));
}
@@ -0,0 +1,224 @@
use super::*;
#[test]
fn epg_priority_merge_backfills_duplicate_programme_metadata_without_overwriting() {
let merged = merge_epg_channels_by_priority(vec![
(
0,
vec![epg_channel(
"demo.channel",
Some("High"),
None,
vec![epg_programme("demo.channel", 10, 20, Some("High Title"), None)],
)],
),
(
10,
vec![epg_channel(
"demo.channel",
None,
Some("http://fallback/icon.png"),
vec![epg_programme("demo.channel", 10, 20, Some("Low Title"), Some("Recovered desc"))],
)],
),
]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].title.as_deref(), Some("High"));
assert_eq!(merged[0].icon.as_deref(), Some("http://fallback/icon.png"));
assert_eq!(merged[0].programmes.len(), 1);
assert_eq!(merged[0].programmes[0].title.as_deref(), Some("High Title"));
assert_eq!(merged[0].programmes[0].desc.as_deref(), Some("Recovered desc"));
}
#[test]
fn epg_priority_merge_normalizes_duplicates_within_single_source() {
let merged = merge_epg_channels_by_priority(vec![(
0,
vec![epg_channel(
"demo.channel",
Some("Demo"),
None,
vec![
epg_programme("demo.channel", 20, 30, Some("Later"), None),
epg_programme("demo.channel", 10, 20, Some("First"), None),
epg_programme("demo.channel", 10, 20, None, Some("Recovered desc")),
],
)],
)]);
assert_eq!(merged.len(), 1);
assert_eq!(
merged[0].programmes.iter().map(|programme| (programme.start, programme.stop)).collect::<Vec<_>>(),
vec![(10, 20), (20, 30)],
);
assert_eq!(merged[0].programmes[0].title.as_deref(), Some("First"));
assert_eq!(merged[0].programmes[0].desc.as_deref(), Some("Recovered desc"));
}
#[test]
fn persisted_epg_source_read_uses_shared_file_lock() {
run_async_test(async move {
let dir = tempdir().expect("temp dir");
let epg_path = dir.path().join("locked.xml");
fs::write(
&epg_path,
r#"<tv>
<channel id="demo.channel"><display-name>Demo</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="demo.channel">
<title>Locked read</title>
</programme>
</tv>"#,
)
.expect("write XMLTV fixture");
let file_locks = Arc::new(FileLockManager::new());
let guide =
TVGuide::new(vec![xmltv_source(epg_path.clone(), 0, false)]).with_file_locks(Arc::clone(&file_locks));
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("demo.channel");
let write_guard = file_locks.write_lock(&epg_path).await;
let filter = guide.filter_merged(&mut id_cache);
tokio::pin!(filter);
assert!(tokio::time::timeout(std::time::Duration::from_millis(25), filter.as_mut()).await.is_err());
drop(write_guard);
let merged = filter.await.expect("EPG parse after write lock release");
assert_eq!(merged.children[0].programmes[0].title.as_deref(), Some("Locked read"));
});
}
/// Builds an accumulator with N channels and drains it to disk. Asserts:
/// (a) the temp file exists while the guard is alive, (b) it has
/// non-trivial size, (c) the file is removed after Drop. Together these
/// prove the writer runs end-to-end without panicking.
#[test]
fn finish_into_disk_writes_a_real_temp_tree() {
use super::super::EpgMergeAccumulator;
let mut acc = EpgMergeAccumulator::new();
for i in 0..250u32 {
let id: Arc<str> = format!("channel-{i:04}").into();
let ch = shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title {i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(i64::from(i), i64::from(i + 1), id)],
};
acc.upsert_channel(i16::try_from(i).unwrap_or(0), 0, false, ch);
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("epg-source.db");
let src = acc.finish_into_disk(path.clone(), 5, 0).unwrap();
assert!(path.exists(), "temp tree file must exist while guard is alive");
let size = std::fs::metadata(&path).unwrap().len();
assert!(size > 1024, "expected non-trivial size, got {size}");
drop(src);
assert!(!path.exists(), "Drop must remove the temp file");
}
/// Contract test: two temp trees written from two accumulators must
/// merge into the same `Epg` as the in-memory `finish_epg_with_icon_overrides`
/// would produce from the same inputs. Without this, every other
/// optimisation is built on sand.
#[test]
fn disk_path_matches_in_memory_finish() {
use super::super::{merge_epg_trees, EpgMergeAccumulator};
// Build one source by appending one programme per channel through the
// accumulator's primary entry point. `add_channel_with_programmes` is
// the same call the in-memory `merge_epg_channels_by_priority` uses,
// so the in-memory reference and the disk path go through identical
// APIs. The two sources use disjoint programme intervals
// (source 0: 0..1, source 1: 100..101) so the merged channel should
// retain both programmes end-to-end.
fn build_acc(source: usize, channels: std::ops::Range<u32>) -> EpgMergeAccumulator {
let mut acc = EpgMergeAccumulator::new();
for i in channels {
let id: Arc<str> = format!("ch-{i:04}").into();
let priority = if source == 0 { 5 } else { 3 }; // source 1 wins
let (start, stop) =
if source == 0 { (i64::from(i), i64::from(i + 1)) } else { (100 + i64::from(i), 101 + i64::from(i)) };
acc.add_channel_with_programmes(
i16::try_from(priority).unwrap_or(0),
source,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-{source}-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(start, stop, id)],
},
);
}
acc
}
// Reference: in-memory merge of the same two sources.
let ref_acc_a = build_acc(0, 0..50);
let ref_acc_b = build_acc(1, 0..50);
// `EpgMergeAccumulator` is single-shot, so the reference merge has to
// rebuild a fresh accumulator. The two halves contribute disjoint
// programme intervals, so a single accumulator sees both.
let mut ref_acc = EpgMergeAccumulator::new();
for i in 0..50 {
let id: Arc<str> = format!("ch-{i:04}").into();
ref_acc.add_channel_with_programmes(
5,
0,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-0-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(i64::from(i), i64::from(i + 1), id)],
},
);
}
for i in 0..50 {
let id: Arc<str> = format!("ch-{i:04}").into();
ref_acc.add_channel_with_programmes(
3,
1,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-1-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(100 + i64::from(i), 101 + i64::from(i), id)],
},
);
}
let reference = ref_acc.finish_epg_with_icon_overrides().unwrap().0;
let _ = (ref_acc_a, ref_acc_b); // keep the per-source builder API in the test
// Disk path: two temp trees, then merge.
let dir = tempfile::tempdir().unwrap();
let src_a = build_acc(0, 0..50).finish_into_disk(dir.path().join("a.db"), 5, 0).unwrap();
let src_b = build_acc(1, 0..50).finish_into_disk(dir.path().join("b.db"), 3, 1).unwrap();
let merged = merge_epg_trees(vec![src_a, src_b]).unwrap().unwrap().0;
assert_eq!(reference.children.len(), merged.children.len(), "channel counts must match");
for (left, right) in reference.children.iter().zip(merged.children.iter()) {
assert_eq!(left.id, right.id, "channel order must match");
// Source 1 had priority 3 (lower = wins) so its title should win.
assert_eq!(left.title, right.title, "priority winner's title must propagate");
// Both sources contribute distinct, non-overlapping programmes.
// The merged channel must keep both — the disk path uses
// `add_channel_with_programmes` so nothing is dropped on the way in.
assert_eq!(
left.programmes.len(),
2,
"channel {} should retain both source programmes, got {}",
left.id,
left.programmes.len()
);
assert_eq!(right.programmes.len(), 2, "disk-merged channel {} lost programmes", right.id);
for (lp, rp) in left.programmes.iter().zip(right.programmes.iter()) {
assert_eq!(lp.start, rp.start, "programme start must match for channel {}", left.id);
assert_eq!(lp.stop, rp.stop, "programme stop must match for channel {}", left.id);
}
}
}
@@ -0,0 +1,52 @@
use super::*;
#[test]
fn epg_priority_merge_filter_accepts_later_source_for_already_processed_channel() {
run_async_test(async move {
let dir = tempdir().unwrap();
let high_path = dir.path().join("high.xml");
let low_path = dir.path().join("low.xml");
fs::write(
&high_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="demo.channel">
<display-name>High Channel</display-name>
</channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="demo.channel">
<title>High Source</title>
</programme>
</tv>"#,
)
.unwrap();
fs::write(
&low_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="demo.channel">
<display-name>Low Channel</display-name>
</channel>
<programme start="20260425010000 +0000" stop="20260425020000 +0000" channel="demo.channel">
<title>Low Source</title>
</programme>
</tv>"#,
)
.unwrap();
let tv_guide = TVGuide::new(vec![xmltv_source(high_path, 0, false), xmltv_source(low_path, 10, false)]);
let mut id_cache = EpgIdCache::new(None);
id_cache.insert_channel_epg_id("demo.channel");
let merged = tv_guide.filter_merged(&mut id_cache).await.expect("merged epg");
assert!(id_cache.contains_processed_epg_id("demo.channel"));
assert_eq!(merged.children.len(), 1);
assert_eq!(merged.children[0].title.as_deref(), Some("High Channel"));
assert_eq!(merged.children[0].programmes.len(), 2);
assert_eq!(
merged.children[0].programmes.iter().filter_map(|programme| programme.title.as_deref()).collect::<Vec<_>>(),
vec!["High Source", "Low Source"],
);
});
}
+373
View File
@@ -0,0 +1,373 @@
use super::{
merge::{collect_tag_attributes, get_tag_type},
normalize_channel_name, EpgMergeAccumulator, TVGuide,
};
use crate::processor::EpgIdCache;
use log::error;
use quick_xml::events::{BytesStart, BytesText, Event};
use shared::{
concat_string,
model::{EpgCategory, EpgChannel, EpgProgramme},
utils::Internable,
};
use std::{borrow::Cow, collections::HashSet, sync::Arc};
use tokio::io::AsyncRead;
use tuliprox_core::{
model::{
PersistedEpgSource, XmlTag, XmlTagIcon, EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_ATTRIB_LANG, EPG_TAG_CATEGORY,
EPG_TAG_CHANNEL, EPG_TAG_DESC, EPG_TAG_DISPLAY_NAME, EPG_TAG_ICON, EPG_TAG_LIVE, EPG_TAG_NEW,
EPG_TAG_PREVIOUSLY_SHOWN, EPG_TAG_PROGRAMME, EPG_TAG_TITLE, EPG_TAG_TV,
},
utils::{
async_file_reader, compressed_file_reader_async::CompressedFileReaderAsync, parse_xmltv_time,
with_folded_epg_id,
},
};
impl TVGuide {
pub(super) fn prepare_tag(id_cache: &mut EpgIdCache, tag: &mut XmlTag, smart_match: bool) {
{
let maybe_epg_id = { tag.get_attribute_value(&EPG_ATTRIB_ID.intern()).cloned() };
if let Some(epg_id) = maybe_epg_id {
tag.normalized_epg_ids
.get_or_insert_with(Vec::new)
.push(normalize_channel_name(&epg_id, &id_cache.smart_match_config).intern());
}
}
if let Some(children) = &tag.children {
let src = "src".intern();
for child in children {
match child.name.as_ref() {
EPG_TAG_DISPLAY_NAME if smart_match => {
if let Some(name) = &child.value {
tag.normalized_epg_ids
.get_or_insert_with(Vec::new)
.push(normalize_channel_name(name, &id_cache.smart_match_config).intern());
}
}
EPG_TAG_ICON => {
if let Some(src) = child.get_attribute_value(&src) {
if !src.is_empty() {
tag.icon = XmlTagIcon::Src(src.clone());
// We cannot easily modify the child icon since it's inside Arc,
// but we already set the tag.icon, which is what matters.
}
}
}
_ => {}
}
}
}
}
pub(super) fn channel_display_name(tag: &XmlTag) -> Option<Arc<str>> {
tag.children.as_ref().and_then(|children| {
children
.iter()
.find(|child| child.name.as_ref() == EPG_TAG_DISPLAY_NAME)
.and_then(|child| child.value.clone())
})
}
pub(super) fn channel_icon(tag: &XmlTag) -> Option<Arc<str>> {
match &tag.icon {
XmlTagIcon::Src(src) => Some(Arc::clone(src)),
XmlTagIcon::Undefined | XmlTagIcon::Exists => None,
}
}
pub(super) fn extract_programme(
tag: &XmlTag,
epg_id: &Arc<str>,
start_attrib: &Arc<str>,
stop_attrib: &Arc<str>,
catchup_id_attrib: &Arc<str>,
) -> Option<EpgProgramme> {
let Some((Some(start), Some(stop))) =
tag.attributes.as_ref().map(|a| (a.get(start_attrib), a.get(stop_attrib)))
else {
error!("Missing start or stop attribute in programme tag, skipping");
return None;
};
let (Some(start_time), Some(stop_time)) = (parse_xmltv_time(start), parse_xmltv_time(stop)) else {
error!("Failed to parse epg programme time {start} - {stop}");
return None;
};
let mut title = None;
let mut desc = None;
let mut icon = None;
let mut categories = Vec::new();
let mut is_live = false;
let mut is_new = false;
let mut previously_shown = false;
if let Some(children) = tag.children.as_ref() {
for child in children {
match child.name.as_ref() {
EPG_TAG_TITLE => title.clone_from(&child.value),
EPG_TAG_DESC => desc.clone_from(&child.value),
EPG_TAG_ICON => {
if let Some(src) = child
.attributes
.as_ref()
.and_then(|attributes| attributes.get("src"))
.filter(|src| !src.is_empty())
{
icon = Some(Arc::clone(src));
}
}
EPG_TAG_CATEGORY => {
if let Some(value) = child.value.as_ref().filter(|value| !value.is_empty()) {
categories.push(EpgCategory {
value: Arc::clone(value),
lang: child
.attributes
.as_ref()
.and_then(|attributes| attributes.get(EPG_ATTRIB_LANG))
.cloned(),
});
}
}
EPG_TAG_LIVE => is_live = true,
EPG_TAG_NEW => is_new = true,
EPG_TAG_PREVIOUSLY_SHOWN => previously_shown = true,
_ => {}
}
}
}
let catchup_id = tag.attributes.as_ref().and_then(|attributes| attributes.get(catchup_id_attrib)).cloned();
let mut programme = EpgProgramme::new_all(start_time, stop_time, Arc::clone(epg_id), title, desc, catchup_id);
programme.icon = icon;
programme.categories = categories;
programme.is_live = is_live;
programme.is_new = is_new;
programme.previously_shown = previously_shown;
Some(programme)
}
/// Parses and filters a compressed EPG XML file, extracting relevant channel and program tags based on smart and fuzzy matching criteria.
///
/// Returns an `Epg` containing filtered tags and TV attributes if any matching channels are found; otherwise, returns `None`.
/// The returned `Epg` will include the priority from the source, which is used for merging multiple EPG sources.
///
/// # Examples
///
/// ```text
/// let mut id_cache = EpgIdCache::default();
/// let epg_source = PersistedEpgSource { file_path: Path::new("guide.xml.gz"), priority: 0 };
/// if let Some(epg) = process_epg_file(&mut id_cache, &epg_source) {
/// assert!(!epg.children.is_empty());
/// }
/// ```
pub(super) async fn process_epg_file(
id_cache: &mut EpgIdCache,
epg_source: &PersistedEpgSource,
source_order: usize,
accumulator: &mut EpgMergeAccumulator,
) -> bool {
let epg_attrib_id = EPG_ATTRIB_ID.intern();
let epg_attrib_channel = EPG_ATTRIB_CHANNEL.intern();
let start_attrib = "start".intern();
let stop_attrib = "stop".intern();
let catchup_id_attrib = "catchup-id".intern();
match CompressedFileReaderAsync::new(&epg_source.file_path).await {
Ok(mut reader) => {
let mut source_processed: HashSet<Arc<str>> = HashSet::with_capacity(5000);
let mut accepted_channels = 0usize;
let smart_match = id_cache.smart_match_config.enabled;
let mut filter_tags = |mut tag: XmlTag| {
match tag.name.as_ref() {
EPG_TAG_CHANNEL => {
let tag_epg_id =
tag.get_attribute_value(&epg_attrib_id).map_or_else(|| "".intern(), Internable::intern);
if tag_epg_id.is_empty() {
return;
}
Self::prepare_tag(id_cache, &mut tag, smart_match);
if smart_match && id_cache.needs_guide_names(&tag_epg_id) {
id_cache.register_guide_names(
&tag_epg_id,
tag.children.iter().flatten().filter_map(|child| {
(child.name.as_ref() == EPG_TAG_DISPLAY_NAME)
.then_some(child.value.as_ref())
.flatten()
}),
);
}
// Case-insensitive (ASCII) membership: fold the guide id for the
// lookup only; `tag_epg_id` keeps its original case for output.
let add_channel = if smart_match {
let direct_match = id_cache.contains_channel_epg_id(&tag_epg_id);
let normalized_match = tag.normalized_epg_ids.as_ref().is_some_and(|candidates| {
id_cache.match_epg_channel_candidates(
&tag_epg_id,
candidates,
epg_source.priority,
source_order,
)
});
direct_match || normalized_match
} else {
id_cache.contains_channel_epg_id(&tag_epg_id)
};
if add_channel {
with_folded_epg_id(&tag_epg_id, |folded| source_processed.insert(folded.intern()));
id_cache.insert_processed_epg_id(&tag_epg_id);
accumulator.upsert_channel(
epg_source.priority,
source_order,
epg_source.logo_override,
EpgChannel {
id: Arc::clone(&tag_epg_id),
title: Self::channel_display_name(&tag),
icon: Self::channel_icon(&tag),
programmes: vec![],
},
);
accepted_channels += 1;
}
}
EPG_TAG_PROGRAMME => {
if let Some(epg_id) = tag.get_attribute_value(&epg_attrib_channel) {
if with_folded_epg_id(epg_id, |folded| source_processed.contains(folded)) {
if let Some(programme) = Self::extract_programme(
&tag,
epg_id,
&start_attrib,
&stop_attrib,
&catchup_id_attrib,
) {
accumulator.push_programme(epg_source.priority, source_order, programme);
}
}
}
}
EPG_TAG_TV => {
accumulator.set_attributes_if_preferred(epg_source.priority, source_order, tag.attributes);
}
_ => {}
}
};
parse_tvguide(&mut reader, &mut filter_tags).await;
accepted_channels > 0
}
Err(e) => {
log::warn!("Failed to process EPG file {}: {e}", epg_source.file_path.display());
false
}
}
}
}
fn handle_tag_start<F>(callback: &mut F, stack: &mut Vec<XmlTag>, e: &BytesStart)
where
F: FnMut(XmlTag),
{
let binding = e.name();
let name_raw = String::from_utf8_lossy(binding.as_ref());
let name = name_raw.intern();
let tag_type = get_tag_type(&name);
let attributes = collect_tag_attributes(e);
let attribs = if attributes.is_empty() { None } else { Some(attributes) };
let tag = XmlTag::new(name, attribs);
if tag_type.is_tv() {
callback(tag);
} else {
stack.push(tag);
}
}
pub(super) fn handle_tag_end<F>(callback: &mut F, stack: &mut Vec<XmlTag>)
where
F: FnMut(XmlTag),
{
if !stack.is_empty() {
if let Some(tag) = stack.pop() {
if tag.name.as_ref() == EPG_TAG_CHANNEL {
if let Some(chan_id) = tag.get_attribute_value(&EPG_ATTRIB_ID.intern()) {
if !chan_id.is_empty() {
callback(tag);
}
}
} else if tag.name.as_ref() == EPG_TAG_PROGRAMME {
if let Some(chan_id) = tag.get_attribute_value(&EPG_ATTRIB_CHANNEL.intern()) {
if !chan_id.is_empty() {
callback(tag);
}
}
} else if !stack.is_empty() {
let tag_arc = Arc::new(tag);
if let Some(mut parent) = stack.pop() {
parent.children.get_or_insert_with(Vec::new).push(tag_arc);
stack.push(parent);
}
}
}
}
}
fn handle_text_tag(stack: &mut [XmlTag], e: &BytesText) {
if let Some(tag) = stack.last_mut() {
if let Ok(text) = e.decode() {
let t = text.trim();
if !t.is_empty() {
let t_fixed: Cow<str> = if t.ends_with('\\') {
let mut owned = t.to_string();
owned.pop();
owned.push_str("&apos; ");
Cow::Owned(owned)
} else {
Cow::Borrowed(t)
};
tag.value = Some(match tag.value.take() {
None => t_fixed.intern(),
Some(old) => concat_string!(old.as_ref(), t_fixed.as_ref()).intern(),
});
}
}
}
}
pub async fn parse_tvguide<R, F>(content: R, callback: &mut F)
where
R: AsyncRead + Unpin,
F: FnMut(XmlTag),
{
let mut stack: Vec<XmlTag> = vec![];
let mut xml_reader = quick_xml::reader::Reader::from_reader(async_file_reader(content));
// Pre-allocate so the first giant `<programme>` block does not trigger a
// chain of `Vec::grow` reallocs. The buffer is monotonically grown by
// quick_xml (it is reset by the caller, see below), so the eventual size
// is the largest single event — for XMLTV that is the biggest programme
// description. Starting at 0 capacity makes that growth ~25 doubling copies.
let mut buf = Vec::<u8>::with_capacity(64 * 1024);
loop {
match xml_reader.read_event_into_async(&mut buf).await {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => handle_tag_start(callback, &mut stack, &e),
Ok(Event::Empty(e)) => {
handle_tag_start(callback, &mut stack, &e);
handle_tag_end(callback, &mut stack);
}
Ok(Event::End(_e)) => handle_tag_end(callback, &mut stack),
Ok(Event::Text(e)) => handle_text_tag(&mut stack, &e),
_ => {}
}
// quick_xml does not clear the buffer between events — the borrow
// returned by `read_event_into_async` is dropped at the end of each
// match arm, so we can reclaim the capacity here without invalidating
// any handler output. Without this, `buf` grows monotonically over the
// whole file, defeating the 64 KiB pre-allocation above.
buf.clear();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,539 @@
use super::{
apply_playlist_fetch_outcome, apply_staged_overlay_groups, cluster_is_configured, collect_effective_skip_clusters,
filter_skipped_clusters_from_source, neutralize_overlaid_cluster_facts, report_force_updates,
should_apply_staged_overlay, stalker_checkpoint_message,
status::{
cached_playlist_download_result, forced_empty_cluster_flags, in_run_reuse_playlist_download_result,
report_forced_update_request, ClusterStatusSource,
},
telemetry::{
finalize_input_telemetry, input_telemetry_for_fetch, is_cluster_input, persist_finalized_cluster_snapshots,
},
CacheStatusScope, InputDownloadResult, LibraryProvider, PendingInputStatusUpdate, PlaylistDownloadResult,
PlaylistProcessingContext, PlaylistUpdateExecutionRef, PlexProvider, StalkerProvider,
PIPELINE_TRANSPARENCY_CLUSTERS,
};
use crate::{input_cache, metadata_sink::MetadataUpdateSink, processor::StalkerRefreshMode};
use log::{debug, warn};
use shared::{
error::TuliproxError,
model::{
ClusterFlags, EventMessage, EventSink, InputRefreshPolicy, InputType, PlaylistUpdateDataSource,
PlaylistUpdateProgressEvent, ProviderFetchFailure,
},
utils::sanitize_sensitive_info,
};
use std::sync::Arc;
use tuliprox_core::model::{AppConfig, ConfigInput};
use tuliprox_iptv::{
error::ProviderErrorKind,
provider::{
BatchContainerProvider, M3uProvider, PlaylistFetchRequest, PlaylistProvider, UnsupportedProvider,
XtreamProvider,
},
xtream,
};
use tuliprox_repository::{
load_input_playlist, persist_input_playlist_with_options, InputPlaylistPersistOptions, MemoryPlaylistSource,
PlaylistSource,
};
/// Local discovery is not a provider Refresh policy. Both can require a fresh read.
#[derive(Clone, Copy)]
pub(super) enum InputAcquisition {
Provider(InputRefreshPolicy),
RescannedLibrary,
}
impl InputAcquisition {
pub(super) fn refresh_policy(self) -> InputRefreshPolicy {
match self {
Self::Provider(policy) => policy,
Self::RescannedLibrary => InputRefreshPolicy::NORMAL,
}
}
pub(super) fn bypasses_cache(self) -> bool {
match self {
Self::Provider(policy) => policy.bypasses_cache(),
Self::RescannedLibrary => true,
}
}
}
#[allow(clippy::too_many_lines)]
async fn playlist_download_from_input<E: EventSink>(
client: &reqwest::Client,
app_config: &Arc<AppConfig>,
events: &E,
execution: PlaylistUpdateExecutionRef<'_>,
input: &ConfigInput,
stalker_refresh_mode: StalkerRefreshMode,
acquisition: InputAcquisition,
) -> PlaylistDownloadResult {
let refresh_policy = acquisition.refresh_policy();
let config = &*app_config.config.load();
let storage_dir = &config.storage_dir;
// Check Status
let storage_path = input_cache::resolve_input_storage_path(storage_dir, &input.name).await;
let mut status = input_cache::load_input_status(&storage_path);
let cache_duration = input.cache_duration_seconds;
// Ensure data directory exists
match tokio::fs::try_exists(&storage_path).await {
Ok(false) => {
if let Err(err) = tokio::fs::create_dir_all(&storage_path).await {
warn!("Failed to create input storage directory '{}': {err}", storage_path.display());
}
}
Err(err) => {
warn!("Failed to check existence of input storage directory '{}': {err}", storage_path.display());
}
Ok(true) => {}
}
let download_input_type = input.get_download_input_type();
// Use per-cluster cache for effective Xtream downloads.
let use_per_cluster_cache = download_input_type.is_xtream();
let mut xtream_clusters_to_download = Vec::new();
let fully_cached = if use_per_cluster_cache {
let skip_cluster = collect_effective_skip_clusters(input);
let xtream_cache_candidates = xtream::requested_clusters(None, &skip_cluster);
for cluster in xtream_cache_candidates {
if acquisition.bypasses_cache() || !input_cache::is_cache_valid(&status, cluster.as_ref(), cache_duration) {
xtream_clusters_to_download.push(cluster);
}
}
xtream_clusters_to_download.is_empty()
} else {
!acquisition.bypasses_cache() && input_cache::is_cache_valid(&status, "default", cache_duration)
};
let cluster_status_source = if use_per_cluster_cache || download_input_type == InputType::M3u {
ClusterStatusSource::PerCluster
} else {
ClusterStatusSource::Default
};
if fully_cached {
return cached_playlist_download_result(input, refresh_policy).with_pending_status(PendingInputStatusUpdate {
storage_path,
status,
status_changed: false,
cluster_status_source,
});
}
let provider_clusters = if is_cluster_input(download_input_type) {
if use_per_cluster_cache {
xtream_clusters_to_download.clone()
} else {
PIPELINE_TRANSPARENCY_CLUSTERS
.into_iter()
.filter(|cluster| cluster_is_configured(input, *cluster))
.collect()
}
} else {
Vec::new()
};
let request = PlaylistFetchRequest {
app_config,
config: &app_config.config.load(),
client,
input,
xtream_clusters: Some(xtream_clusters_to_download.as_slice()),
update_quality: refresh_policy.quality,
};
// Each arm builds the provider its input type needs and awaits it in place: the
// provider types share no supertype, and building one is free, so this stays a match
// and stays statically dispatched. What changed is the result - one named
// `PlaylistFetch` instead of a six-element tuple assembled by position.
let fetch = match download_input_type {
InputType::M3u => {
super::super::m3u_quality::effective_m3u_fetch(
app_config,
input,
refresh_policy.quality,
M3uProvider.fetch(&request).await,
)
.await
}
InputType::Xtream => XtreamProvider::new(events).fetch(&request).await,
InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch => {
BatchContainerProvider.fetch(&request).await
}
InputType::Stalker => {
StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await
}
InputType::Library => LibraryProvider.fetch(&request).await,
InputType::Plex => PlexProvider.fetch(&request).await,
InputType::Emby | InputType::Jellyfin => {
UnsupportedProvider::new(
"media-server",
format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name),
)
.fetch(&request)
.await
}
InputType::Staged => {
UnsupportedProvider::new(
"staged",
format!("staged input '{}' was not resolved against a parent input", input.name),
)
.fetch(&request)
.await
}
};
// `ProviderErrorKind` has always been able to answer "is this worth
// retrying, and does it need a human" - `needs_operator()` is exactly that
// question - and nothing consumed the answer. Every fetch failure was
// counted, logged and treated identically.
if let Some(kind) = fetch.error_kind() {
let worst = fetch
.errors
.iter()
.max_by_key(|error| ProviderErrorKind::of_tuliprox(error))
.map(|error| sanitize_sensitive_info(&error.to_string()).into_owned());
events.emit(EventMessage::ProviderFetchFailed(ProviderFetchFailure {
input: sanitize_sensitive_info(&input.name).into_owned().into(),
provider: download_input_type.to_string().into(),
kind: kind.into(),
error_count: fetch.errors.len(),
message: worst,
retryable: kind.is_retryable(),
needs_operator: kind.needs_operator(),
partial: fetch.partial,
}));
}
let cache_scope = if use_per_cluster_cache {
CacheStatusScope::RequestedClusters(&xtream_clusters_to_download)
} else {
CacheStatusScope::Default
};
let save_status = apply_playlist_fetch_outcome(
events,
execution.run_id,
execution.execution_order,
input,
&mut status,
cache_scope,
&fetch,
);
let input_telemetry = input_telemetry_for_fetch(
input,
refresh_policy,
PlaylistUpdateDataSource::Provider,
&provider_clusters,
Some(&fetch),
);
PlaylistDownloadResult::from(fetch).with_input_telemetry(input_telemetry).with_pending_status(
PendingInputStatusUpdate { storage_path, status, status_changed: save_status, cluster_status_source },
)
}
/// `invalidate_input_cache_status` performs a non-atomic file I/O sequence
/// (`input_cache::load_input_status` + `input_cache::save_input_status`).
/// Call this only while holding the per-input lock from
/// `PlaylistProcessingContext::get_input_lock` (as done in `download_input`).
pub(crate) async fn invalidate_input_cache_status<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &ConfigInput,
) {
let storage_dir = { ctx.config.config.load().storage_dir.clone() };
let storage_path = input_cache::resolve_input_storage_path(&storage_dir, &input.name).await;
let mut status = input_cache::load_input_status(&storage_path);
if !status.clusters.is_empty() {
status.clusters.clear();
input_cache::save_input_status(&storage_path, &status);
}
}
pub(crate) async fn load_cached_input_playlist<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &Arc<ConfigInput>,
) -> (PlaylistSource, Option<TuliproxError>) {
match load_input_playlist(&ctx.config, input, None).await {
Ok(pl_source) => (pl_source, None),
Err(err) => (MemoryPlaylistSource::default().into_source(), Some(err)),
}
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn download_input<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &Arc<ConfigInput>,
allow_staged_input: bool,
) -> InputDownloadResult {
if input.staged.is_some() && !allow_staged_input {
return InputDownloadResult {
authoritative_library: false,
errors: Vec::new(),
source: MemoryPlaylistSource::default().into_source(),
storage_error: None,
partial: false,
quality_rejections: Vec::new(),
accepted_empty_clusters: ClusterFlags::empty(),
input_telemetry: None,
};
}
let staged_overlay = if input.staged.is_none() {
let sources = ctx.config.sources.load();
sources.get_staged_input_for_provider(&input.name).cloned()
} else {
None
};
// Coordination Logic
let need_download = !ctx.is_input_downloaded(&input.name).await;
// Keep this lock for the whole critical section (download + persist/load + mark processed)
// so parallel sources sharing the same input cannot observe a half-written state.
let mut input_lock = if need_download { Some(ctx.get_input_lock(&input.name).await) } else { None };
let mut mark_as_processed = false;
let refresh_policy = ctx.refresh_policy(input.id);
let mut playlist_download_result = if need_download {
// Check again after lock
let already_processed = ctx.is_input_downloaded(&input.name).await;
if already_processed {
// Use empty results, will load from disk below
in_run_reuse_playlist_download_result()
} else if ctx.pre_processed_inputs.as_ref().is_some_and(|s| s.contains(&input.name)) {
// Input was already processed in a prior session; skip download and load from disk.
// Mark only after load succeeds (or fails) to avoid exposing a half-ready state.
mark_as_processed = true;
cached_playlist_download_result(input, refresh_policy)
} else {
mark_as_processed = true;
report_forced_update_request(ctx, input, refresh_policy);
playlist_download_from_input(
&ctx.client,
&ctx.config,
&ctx.events,
PlaylistUpdateExecutionRef { run_id: &ctx.run_id, execution_order: ctx.execution_order },
input,
ctx.stalker_refresh_mode,
ctx.acquisition(input.id),
)
.await
}
} else {
in_run_reuse_playlist_download_result()
};
let mut preloaded_playlist: Option<(PlaylistSource, Option<TuliproxError>)> = None;
if playlist_download_result.was_cached {
let (cached_playlist, cached_error) = load_cached_input_playlist(ctx, input).await;
// Defensive fallback: if cache metadata says "valid" but persisted data is unreadable,
// retry once before forcing a refresh.
let must_force_refresh = cached_error.is_some();
if must_force_refresh {
warn!("Input '{}' cache hit produced unreadable playlist; retrying cached load once", input.name);
let (retry_playlist, retry_error) = load_cached_input_playlist(ctx, input).await;
if retry_error.is_none() {
preloaded_playlist = Some((retry_playlist, None));
} else {
if input_lock.is_none() {
input_lock = Some(ctx.get_input_lock(&input.name).await);
}
// Re-check immediately after locking to avoid duplicate refreshes when another worker
// repaired the cache between our earlier retry and lock acquisition.
let (locked_retry_playlist, locked_retry_error) = load_cached_input_playlist(ctx, input).await;
if locked_retry_error.is_none() {
warn!("Input '{}' cache became readable after lock re-check; skipping refresh", input.name);
preloaded_playlist = Some((locked_retry_playlist, None));
} else {
warn!(
"Input '{}' cached playlist remained unreadable after retry and lock re-check; invalidating cache and forcing refresh",
input.name
);
invalidate_input_cache_status(ctx, input).await;
playlist_download_result = playlist_download_from_input(
&ctx.client,
&ctx.config,
&ctx.events,
PlaylistUpdateExecutionRef { run_id: &ctx.run_id, execution_order: ctx.execution_order },
input,
ctx.stalker_refresh_mode,
ctx.acquisition(input.id),
)
.await;
}
}
} else {
preloaded_playlist = Some((cached_playlist, None));
}
}
if playlist_download_result.partial {
ctx.partial_refresh.store(true, std::sync::atomic::Ordering::Release);
ctx.events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent::for_run_input(
ctx.run_id.clone(),
ctx.execution_order,
input.id,
input.name.to_string(),
stalker_checkpoint_message(&input.name),
)));
}
let apply_staged_overlay = should_apply_staged_overlay(&playlist_download_result);
let authoritative_library = input.input_type == InputType::Library
&& playlist_download_result.download_err.is_empty()
&& !playlist_download_result.partial;
let mut accepted_empty_clusters = forced_empty_cluster_flags(&playlist_download_result.force_updates);
let reuse_persisted_after_quality_rejection = !playlist_download_result.quality_rejections.is_empty()
&& playlist_download_result.downloaded_playlist.is_empty()
&& !playlist_download_result.persisted;
let (mut playlist, mut error) = if input.input_type == InputType::Library && !authoritative_library {
// A failed catalog read must not replace an existing Library input with an empty tree.
(MemoryPlaylistSource::default().into_source(), None)
} else if let Some(preloaded) = preloaded_playlist {
preloaded
} else if playlist_download_result.was_cached
|| playlist_download_result.persisted
|| reuse_persisted_after_quality_rejection
{
match load_input_playlist(&ctx.config, input, None).await {
Ok(pl_source) => (pl_source, None),
Err(e) => (MemoryPlaylistSource::default().into_source(), Some(e)),
}
} else {
debug!("Persisting input '{}' playlist", input.name);
let (pl, err) = persist_input_playlist_with_options(
&ctx.config,
input,
std::mem::take(&mut playlist_download_result.downloaded_playlist),
InputPlaylistPersistOptions { accepted_empty_clusters },
)
.await;
(MemoryPlaylistSource::new(pl).into_source(), err)
};
playlist = filter_skipped_clusters_from_source(playlist, input);
if let Some(staged_input) = staged_overlay.filter(|_| apply_staged_overlay) {
let clusters = staged_input.staged.as_ref().map_or_else(ClusterFlags::all, |staged| staged.clusters);
let mut staged_result = Box::pin(download_input(ctx, &staged_input, true)).await;
// download_input has released the staged input's lock. Record its own
// facts before merging them into the parent's outcome or consuming groups.
super::super::input_status::complete_staged_input(ctx, &staged_input, &mut staged_result).await;
playlist_download_result.partial |= staged_result.partial;
playlist_download_result.download_err.append(&mut staged_result.errors);
playlist_download_result.quality_rejections.append(&mut staged_result.quality_rejections);
accepted_empty_clusters |= staged_result.accepted_empty_clusters;
if let Some(staged_error) = staged_result.storage_error {
playlist_download_result.download_err.push(staged_error);
} else {
let provider_groups = playlist.take_groups();
let staged_groups = staged_result.source.take_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);
}
let (merged_playlist, persist_error) = persist_input_playlist_with_options(
&ctx.config,
input,
merged_groups,
InputPlaylistPersistOptions { accepted_empty_clusters },
)
.await;
playlist = MemoryPlaylistSource::new(merged_playlist).into_source();
if error.is_none() {
error = persist_error;
} else if let Some(persist_error) = persist_error {
playlist_download_result.download_err.push(persist_error);
}
}
}
if input.input_type == InputType::M3u {
let alias_errors = download_m3u_alias_playlists(ctx, input).await;
playlist_download_result.download_err.extend(alias_errors);
}
if mark_as_processed
&& !playlist_download_result.partial
&& error.is_none()
&& (!playlist.is_empty() || !accepted_empty_clusters.is_empty() || authoritative_library)
{
// Mark after persist/load so other workers only see this input as ready when data is usable.
ctx.mark_input_downloaded(input.name.clone()).await;
}
if !playlist_download_result.persisted && error.is_none() {
report_force_updates(
&ctx.events,
&ctx.run_id,
ctx.execution_order,
input,
&playlist_download_result.force_updates,
);
}
finalize_input_telemetry(&mut playlist_download_result, error.as_ref());
let request_policy = ctx
.input_refresh
.filter(|input_refresh| input_refresh.input_id == input.id)
.map(|input_refresh| input_refresh.policy);
persist_finalized_cluster_snapshots(&mut playlist_download_result, request_policy, error.as_ref());
// Explicitly release per-input lock after load/persist/mark steps are completed.
drop(input_lock);
InputDownloadResult {
authoritative_library,
errors: playlist_download_result.download_err,
source: playlist,
storage_error: error,
partial: playlist_download_result.partial,
quality_rejections: playlist_download_result.quality_rejections,
accepted_empty_clusters,
input_telemetry: playlist_download_result.input_telemetry,
}
}
async fn download_m3u_alias_playlists<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &ConfigInput,
) -> Vec<TuliproxError> {
let Some(aliases) = input.get_enabled_aliases() else { return vec![] };
let mut errors = Vec::new();
for alias in aliases {
if ctx.is_input_downloaded(&alias.name).await {
continue;
}
let mut alias_input = input.as_input(alias);
// A user-provided raw-playlist persist path belongs to the primary input. Alias
// snapshots use their own internal storage so accounts never overwrite each other.
alias_input.persist = None;
alias_input.epg = None;
let alias_input = Arc::new(alias_input);
let mut alias_result = Box::pin(download_input(ctx, &alias_input, false)).await;
let alias_had_errors = !alias_result.errors.is_empty() || alias_result.storage_error.is_some();
errors.append(&mut alias_result.errors);
if let Some(storage_error) = alias_result.storage_error {
errors.push(storage_error);
}
if alias_result.partial {
errors.push(TuliproxError::RepositoryPlaylist(format!(
"M3U alias '{}' returned a partial playlist",
alias.name
)));
} else if alias_result.source.is_empty() && !alias_had_errors {
errors.push(TuliproxError::RepositoryPlaylist(format!("M3U alias '{}' playlist is empty", alias.name)));
}
}
errors
}
@@ -0,0 +1,584 @@
use super::{
acquisition::InputAcquisition, download_input, is_input_enabled, is_target_enabled, join_arc_strs, process_targets,
target_waiting_message, with_sequential_group, InputCompletionFacts, PlaylistProcessingContext, XmltvEpgProvider,
};
use crate::{fetched_playlist::FetchedPlaylist, metadata_sink::MetadataUpdateSink, parser::xmltv::TVGuide};
use futures::{FutureExt, StreamExt};
use log::{debug, error, log_enabled, warn, Level};
use shared::{
concat_string,
error::TuliproxError,
model::{
ClusterFlags, EventMessage, EventSink, InputRefreshPolicy, InputStats, InputType, PlaylistStats,
PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, SourceStats, TargetStats,
},
utils::sanitize_sensitive_info,
};
use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, Weak},
time::Instant,
};
use tokio::{
sync::{OwnedRwLockWriteGuard, RwLock},
task::JoinSet,
};
use tuliprox_core::{
model::{ClusterUpdateRejection, ConfigInput},
utils::{debug_if_enabled, log_memory_snapshot, StepMeasureCallback},
};
use tuliprox_iptv::epg::{CountingEpgSink, EpgFetchRequest, EpgProvider};
use tuliprox_repository::PlaylistSource;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InputJobState {
Ready,
Pending,
Failed,
}
pub(crate) struct InputDownloadResult {
/// Only a completely read Library catalog (or its persisted input) may be empty.
pub(crate) authoritative_library: bool,
pub(crate) errors: Vec<TuliproxError>,
pub(crate) source: PlaylistSource,
pub(crate) storage_error: Option<TuliproxError>,
pub(crate) partial: bool,
pub(crate) quality_rejections: Vec<ClusterUpdateRejection>,
pub(crate) accepted_empty_clusters: ClusterFlags,
pub(crate) input_telemetry: Option<PlaylistUpdateInputTelemetry>,
}
impl InputDownloadResult {
pub(in crate::processor::playlist) fn update_state(&mut self) -> PlaylistUpdateState {
InputCompletionFacts {
job_state: self.job_state(),
had_errors: !self.errors.is_empty() || self.storage_error.is_some(),
had_quality_rejections: !self.quality_rejections.is_empty(),
}
.update_state()
}
pub(crate) fn job_state(&mut self) -> InputJobState {
if self.partial {
InputJobState::Pending
} else if self.storage_error.is_some()
|| (self.source.is_empty() && self.accepted_empty_clusters.is_empty() && !self.authoritative_library)
{
InputJobState::Failed
} else {
InputJobState::Ready
}
}
}
pub(crate) struct InputJobResult {
pub(crate) index: usize,
pub(crate) input_id: u16,
pub(crate) input_name: Arc<str>,
pub(crate) state: InputJobState,
pub(crate) source: Option<PlaylistSource>,
pub(crate) epg: Option<TVGuide>,
pub(crate) stat: InputStats,
pub(crate) errors: Vec<TuliproxError>,
pub(crate) accepted_empty_clusters: ClusterFlags,
pub(crate) had_quality_rejections: bool,
pub(crate) input_telemetry: Option<PlaylistUpdateInputTelemetry>,
}
impl InputJobResult {
pub(crate) fn update_state(&self) -> PlaylistUpdateState {
InputCompletionFacts {
job_state: self.state,
had_errors: !self.errors.is_empty(),
had_quality_rejections: self.had_quality_rejections,
}
.update_state()
}
}
impl InputCompletionFacts {
pub(super) fn update_state(self) -> PlaylistUpdateState {
if self.job_state == InputJobState::Failed || self.had_errors {
PlaylistUpdateState::Failure
} else if self.job_state == InputJobState::Pending || self.had_quality_rejections {
PlaylistUpdateState::Partial
} else {
PlaylistUpdateState::Success
}
}
}
pub(crate) async fn process_input_job<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
index: usize,
ctx: &PlaylistProcessingContext<E, M>,
input: &Arc<ConfigInput>,
process_parallel: bool,
) -> InputJobResult {
with_sequential_group(
&ctx.config.file_locks,
input.sequential_group,
process_parallel,
process_input_job_inner(index, ctx, input),
)
.await
}
pub(crate) async fn process_input_job_inner<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
index: usize,
ctx: &PlaylistProcessingContext<E, M>,
input: &Arc<ConfigInput>,
) -> InputJobResult {
let start_time = Instant::now();
let input_type = input.get_download_input_type();
let broadcast_step = create_input_broadcast_callback(&ctx.events, &ctx.run_id, ctx.execution_order, input.id);
broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name));
let mut download = download_input(ctx, input, false).await;
let state = download.job_state();
let storage_failed = download.storage_error.is_some();
let had_quality_rejections = !download.quality_rejections.is_empty();
if had_quality_rejections {
ctx.had_quality_rejections.store(true, std::sync::atomic::Ordering::Release);
}
if let Some(err) = download.storage_error.take() {
broadcast_step("Playlist download", &format!("Failed to persist/load input '{}' playlist", input.name));
error!("Failed to persist input playlist {}", input.name);
download.errors.push(err);
}
let epg = if input_type == InputType::Library || download.partial || storage_failed {
None
} else {
download_input_epg(ctx, input, &mut download.errors).await
};
let group_count = download.source.get_group_count();
let channel_count = download.source.get_channel_count();
if state == InputJobState::Failed && download.source.is_empty() && download.accepted_empty_clusters.is_empty() {
broadcast_step("Playlist download", &format!("Input '{}' playlist is empty", input.name));
download.errors.push(TuliproxError::RepositoryPlaylist(format!("Source is empty {}", input.name)));
}
let stat = create_input_stat(
group_count,
channel_count,
download.errors.len(),
input_type,
&input.name,
start_time.elapsed().as_secs(),
);
InputJobResult {
index,
input_id: input.id,
input_name: input.name.clone(),
state,
source: (state == InputJobState::Ready).then_some(download.source),
epg,
stat,
errors: download.errors,
accepted_empty_clusters: download.accepted_empty_clusters,
had_quality_rejections,
input_telemetry: download.input_telemetry,
}
}
pub(crate) fn panicked_input_job(index: usize, input: &ConfigInput) -> InputJobResult {
let error = TuliproxError::RepositoryPlaylist(format!("Input '{}' processing panicked", input.name));
InputJobResult {
index,
input_id: input.id,
input_name: input.name.clone(),
state: InputJobState::Failed,
source: None,
epg: None,
stat: create_input_stat(0, 0, 1, input.get_download_input_type(), &input.name, 0),
errors: vec![error],
accepted_empty_clusters: ClusterFlags::empty(),
had_quality_rejections: false,
input_telemetry: None,
}
}
pub(crate) fn report_input_job_completion<E: EventSink>(
events: &E,
run_id: &PlaylistUpdateRunId,
execution_order: PlaylistUpdateRunOrder,
result: &InputJobResult,
) {
report_input_completion(
events,
run_id,
execution_order,
result.input_id,
&result.input_name,
result.update_state(),
result.input_telemetry.as_ref(),
);
}
pub(in crate::processor::playlist) fn report_input_completion<E: EventSink>(
events: &E,
run_id: &PlaylistUpdateRunId,
execution_order: PlaylistUpdateRunOrder,
input_id: u16,
input_name: &str,
state: PlaylistUpdateState,
input_telemetry: Option<&PlaylistUpdateInputTelemetry>,
) {
let input_name = sanitize_sensitive_info(input_name).into_owned();
let message = match state {
PlaylistUpdateState::Success => format!("Input '{input_name}' completed successfully"),
PlaylistUpdateState::Partial => format!("Input '{input_name}' completed partially"),
PlaylistUpdateState::Failure => format!("Input '{input_name}' failed during update"),
};
let progress = PlaylistUpdateProgressEvent::input_completed(
run_id.clone(),
execution_order,
input_id,
state,
input_name,
message,
);
let progress = match input_telemetry {
Some(input_telemetry) => progress.with_input_telemetry(input_telemetry.clone()),
None => progress,
};
events.emit(EventMessage::PlaylistUpdateProgress(progress));
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn process_source<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
source_idx: usize,
ctx: Arc<PlaylistProcessingContext<E, M>>,
) -> (Vec<InputStats>, Vec<TargetStats>, Vec<TuliproxError>) {
log_memory_snapshot(format!("source[{source_idx}] start").as_str());
let sources = ctx.config.sources.load();
let mut errors = vec![];
let mut input_stats = HashMap::<Arc<str>, InputStats>::new();
let mut target_stats = Vec::<TargetStats>::new();
if let Some(source) = sources.get_source_at(source_idx) {
let mut source_playlists = Vec::with_capacity(source.inputs.len());
let broadcast_step = create_broadcast_callback(&ctx.events, &ctx.run_id, ctx.execution_order);
let process_parallel = ctx.config.config.load().process_parallel;
let mut disabled_inputs: Vec<Arc<str>> = vec![];
let mut enabled_inputs = Vec::with_capacity(source.inputs.len());
for (index, input_name) in source.inputs.iter().enumerate() {
let Some(input) = sources.get_input_by_name(input_name) else {
error!("Input {input_name} referenced by source {source_idx} does not exist");
continue;
};
if is_input_enabled(input, &ctx.user_targets) {
enabled_inputs.push((index, input));
} else {
disabled_inputs.push(input.name.clone());
}
}
let source_downloaded = !enabled_inputs.is_empty();
let mut job_results = Vec::with_capacity(enabled_inputs.len());
if process_parallel {
let mut jobs = futures::stream::FuturesUnordered::new();
for &(index, input) in &enabled_inputs {
let job = std::panic::AssertUnwindSafe(process_input_job(index, &ctx, input, true)).catch_unwind();
jobs.push(async move {
match job.await {
Ok(result) => result,
Err(_) => panicked_input_job(index, input),
}
});
}
while let Some(result) = jobs.next().await {
job_results.push(result);
}
} else {
for &(index, input) in &enabled_inputs {
job_results.push(process_input_job(index, &ctx, input, false).await);
}
}
job_results.sort_by_key(|result| result.index);
let mut blockers = Vec::new();
let mut accepted_empty_clusters = ClusterFlags::empty();
for mut result in job_results {
super::super::input_status::persist_input_job_result(&ctx, &result).await;
report_input_job_completion(&ctx.events, &ctx.run_id, ctx.execution_order, &result);
errors.append(&mut result.errors);
input_stats.insert(result.input_name.clone(), result.stat);
if result.state == InputJobState::Ready {
accepted_empty_clusters |= result.accepted_empty_clusters;
if let (Some(input), Some(source)) =
(sources.get_input_by_name(&result.input_name), result.source.take())
{
source_playlists.push(FetchedPlaylist { input, source, epg: result.epg });
}
} else {
blockers.push(result.input_name);
}
}
if !disabled_inputs.is_empty() && !source_downloaded {
warn!(
"Source at index {source_idx} has no enabled inputs for the given targets. Disabled: {}",
join_arc_strs(&disabled_inputs, ", ")
);
}
if source_downloaded {
if !blockers.is_empty() {
for target in source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)) {
for input_name in &blockers {
broadcast_step("Playlist download", &target_waiting_message(&target.name, input_name));
}
}
} else if source_playlists.is_empty() {
debug!("Source at index {source_idx} is empty");
errors.push(TuliproxError::RepositoryPlaylist(format!(
"Source at index {source_idx} is empty: {}",
join_arc_strs(&source.inputs, ", ")
)));
} else {
debug_if_enabled!(
"Source has {} groups",
source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::<usize>()
);
let enabled_targets: Vec<_> =
source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)).collect();
target_stats = process_targets(
&ctx,
&mut source_playlists,
&enabled_targets,
&mut input_stats,
&mut errors,
accepted_empty_clusters,
process_parallel,
)
.await;
}
}
}
log_memory_snapshot(format!("source[{source_idx}] end").as_str());
let ordered_input_stats = sources
.get_source_at(source_idx)
.map_or_else(Vec::new, |source| source.inputs.iter().filter_map(|name| input_stats.remove(name)).collect());
(ordered_input_stats, target_stats, errors)
}
pub(crate) async fn download_input_epg<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &Arc<ConfigInput>,
error_list: &mut Vec<TuliproxError>,
) -> Option<TVGuide> {
// A failed playlist download makes the EPG moot: the channels it would annotate are
// not there.
if !error_list.is_empty() {
return None;
}
let provider = XmltvEpgProvider::new(ctx);
// The XMLTV path produces documents, not programme records, so nothing reaches the
// sink. It is here because the same call answers for a record-streaming provider.
let mut discarded = CountingEpgSink::new();
let outcome = provider.fetch(&EpgFetchRequest::new(input), &mut discarded).await;
error_list.extend(provider.take_errors());
match outcome {
Ok(outcome) => outcome.into_guide(),
Err(err) => {
error_list.push(err);
None
}
}
}
pub(crate) fn create_broadcast_callback<E: EventSink + Clone + 'static>(
events: &E,
run_id: &PlaylistUpdateRunId,
execution_order: PlaylistUpdateRunOrder,
) -> StepMeasureCallback {
let events = events.clone();
let run_id = run_id.clone();
Box::new(move |context: &str, msg: &str| {
events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent::for_run_global(
run_id.clone(),
execution_order,
context,
msg,
)));
})
}
fn create_input_broadcast_callback<E: EventSink + Clone + 'static>(
events: &E,
run_id: &PlaylistUpdateRunId,
execution_order: PlaylistUpdateRunOrder,
input_id: u16,
) -> StepMeasureCallback {
let events = events.clone();
let run_id = run_id.clone();
Box::new(move |context: &str, msg: &str| {
events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent::for_run_input(
run_id.clone(),
execution_order,
input_id,
context,
msg,
)));
})
}
pub(crate) fn create_input_stat(
group_count: usize,
channel_count: usize,
error_count: usize,
input_type: InputType,
input_name: &str,
secs_took: u64,
) -> InputStats {
InputStats {
name: input_name.to_string(),
input_type,
error_count,
raw_stats: PlaylistStats { group_count, channel_count },
processed_stats: PlaylistStats { group_count: 0, channel_count: 0 },
secs_took,
}
}
// Written out rather than derived: `#[derive(Clone)]` would demand `M: Clone`,
// but the sink is held behind an `Arc` and is cloneable whatever `M` is.
impl<E: EventSink + Clone, M: MetadataUpdateSink> Clone for PlaylistProcessingContext<E, M> {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
run_id: self.run_id.clone(),
execution_order: self.execution_order,
config: Arc::clone(&self.config),
user_targets: Arc::clone(&self.user_targets),
events: self.events.clone(),
playlist_state: self.playlist_state.clone(),
disabled_headers: self.disabled_headers.clone(),
processed_inputs: Arc::clone(&self.processed_inputs),
input_completions: Arc::clone(&self.input_completions),
input_locks: Arc::clone(&self.input_locks),
provider_manager: self.provider_manager.clone(),
metadata_manager: self.metadata_manager.clone(),
pre_processed_inputs: self.pre_processed_inputs.clone(),
stalker_refresh_mode: self.stalker_refresh_mode,
partial_refresh: Arc::clone(&self.partial_refresh),
had_quality_rejections: Arc::clone(&self.had_quality_rejections),
input_refresh: self.input_refresh,
library_update_mode: self.library_update_mode,
}
}
}
impl<E: EventSink + Clone + 'static, M: MetadataUpdateSink> PlaylistProcessingContext<E, M> {
#[must_use]
pub fn refresh_policy(&self, input_id: u16) -> InputRefreshPolicy {
self.input_refresh
.filter(|input_refresh| input_refresh.input_id == input_id)
.map_or(InputRefreshPolicy::NORMAL, |input_refresh| input_refresh.policy)
}
pub(super) fn acquisition(&self, input_id: u16) -> InputAcquisition {
if self.library_update_mode.reloads_input(input_id) {
InputAcquisition::RescannedLibrary
} else {
InputAcquisition::Provider(self.refresh_policy(input_id))
}
}
pub async fn is_input_downloaded(&self, input_name: &str) -> bool {
let processed = self.processed_inputs.lock().await;
processed.contains(input_name)
}
pub async fn mark_input_downloaded(&self, input_name: Arc<str>) -> bool {
let mut processed = self.processed_inputs.lock().await;
processed.insert(input_name)
}
pub async fn get_input_lock(&self, input_name: &Arc<str>) -> OwnedRwLockWriteGuard<()> {
let mut locks = self.input_locks.lock().await;
// Try to upgrade the existing weak reference
let lock = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| {
let new_lock = Arc::new(RwLock::new(()));
locks.insert(input_name.clone(), Arc::downgrade(&new_lock));
new_lock
});
// Clean up stale references periodically
locks.retain(|_, weak| weak.strong_count() > 0);
drop(locks); // Release mutex before awaiting write lock
lock.write_owned().await
}
}
pub(crate) async fn process_sources<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
processing_ctx: &PlaylistProcessingContext<E, M>,
) -> (Vec<SourceStats>, Vec<TuliproxError>) {
let mut async_tasks = JoinSet::new();
let sources = processing_ctx.config.sources.load();
let process_parallel = processing_ctx.config.config.load().process_parallel;
if process_parallel && log_enabled!(Level::Debug) {
debug!("Parallel processing enabled");
}
let mut source_results = Vec::new();
let mut errors = Vec::new();
let mut processed_any = false;
for (index, source) in sources.sources.iter().enumerate() {
if !source.should_process_for_user_targets(&processing_ctx.user_targets) {
continue;
}
// We're using the file lock this way on purpose
let source_lock_path = PathBuf::from(concat_string!("source_", &index.to_string()));
let Ok(update_lock) = processing_ctx.config.file_locks.try_write_lock(&source_lock_path).await else {
warn!(
"The update operation for the source at index {index} was skipped because an update is already in progress."
);
continue;
};
let ctx = Arc::new(processing_ctx.clone());
processed_any = true;
if process_parallel {
async_tasks.spawn(async move {
let _update_lock = update_lock;
(index, process_source(index, ctx).await)
});
} else {
source_results.push((index, process_source(index, ctx).await));
drop(update_lock);
}
}
if !processed_any {
warn!(
"No sources were processed for the given targets. Check that:\n\
- Sources have enabled targets matching your target selection\n\
- CLI -t filter or schedule.targets are correct\n\
- No playlist lock is blocking updates"
);
}
while let Some(result) = async_tasks.join_next().await {
match result {
Ok(result) => source_results.push(result),
Err(err) => {
error!("Playlist processing task failed: {err:?}");
errors
.push(TuliproxError::RepositoryPlaylist(format!("Playlist source processing task failed: {err}")));
}
}
}
source_results.sort_by_key(|(index, _)| *index);
let mut stats = Vec::with_capacity(source_results.len());
for (_, (input_stats, target_stats, mut source_errors)) in source_results {
errors.append(&mut source_errors);
if let Some(source_stats) = SourceStats::try_new(input_stats, target_stats) {
stats.push(source_stats);
}
}
(stats, errors)
}
@@ -0,0 +1,447 @@
use super::{cluster_selected, GroupCategoryIds, PlaylistDownloadResult, StagedOverlayAssignment, StreamOverlap};
use log::warn;
use shared::model::{ClusterFlags, InputType, PlaylistGroup, StagedInputType, XtreamCluster};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use tuliprox_core::model::{ConfigInput, ConfigInputFlags};
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 for
/// Xtream staged input, while an M3U staged input remains authoritative for the selected clusters.
pub(crate) fn apply_staged_overlay_groups(
provider: &ConfigInput,
staged_type: StagedInputType,
clusters: ClusterFlags,
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<PlaylistGroup>,
) -> Vec<PlaylistGroup> {
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;
}
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);
}
}
}
}
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);
if staged_type == StagedInputType::M3u {
return apply_staged_m3u_template(provider_groups, staged_selected, &assignment, clusters, used_ids);
}
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 {
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()) {
used_ids.assign_new_group(&mut staged);
sync_channel_category_id(&mut staged);
result.push(staged);
}
result
}
fn apply_staged_m3u_template(
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<Option<PlaylistGroup>>,
assignment: &StagedOverlayAssignment,
clusters: ClusterFlags,
mut used_ids: GroupCategoryIds,
) -> Vec<PlaylistGroup> {
let mut result = Vec::with_capacity(provider_groups.len() + staged_groups.len());
for (staged_idx, staged) in staged_groups.into_iter().enumerate() {
let Some(mut staged) = staged.filter(|group| !group.channels.is_empty()) else {
continue;
};
if let Some(provider_idx) = assignment.provider_by_staged[staged_idx] {
let provider_group = &provider_groups[provider_idx];
staged.id = if provider_group.id == 0 {
used_ids.allocate(provider_group.xtream_cluster)
} else {
provider_group.id
};
} else {
used_ids.assign_new_group(&mut staged);
}
sync_channel_category_id(&mut staged);
result.push(staged);
}
result.extend(provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)));
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;
}
}
impl StagedOverlayAssignment {
pub(super) 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.
pub(super) 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
}
pub(super) fn staged_of(&self, provider_idx: usize) -> Option<usize> { self.staged_by_provider[provider_idx] }
pub(super) 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);
}
}
/// 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);
}
}
}
impl GroupCategoryIds {
pub(super) 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.
pub(super) fn claim(&mut self, cluster: XtreamCluster, id: u32) -> bool {
id != 0 && self.used.entry(cluster).or_default().insert(id)
}
pub(super) fn assign_new_group(&mut self, group: &mut PlaylistGroup) {
if group.id == 0 {
group.id = self.allocate(group.xtream_cluster);
} else if !self.claim(group.xtream_cluster, group.id) {
let allocated = self.allocate(group.xtream_cluster);
warn!(
"Staged group '{}' uses category id {} which is already taken in {}; renumbered to {allocated}",
group.title, group.id, group.xtream_cluster
);
group.id = allocated;
}
}
/// Reserves and returns the next free id of `cluster`.
pub(super) 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 {
!download_result.was_cached
}
@@ -0,0 +1,56 @@
use super::{
telemetry::{finalize_input_telemetry, input_telemetry_for_fetch, persist_finalized_cluster_snapshots},
*,
};
use shared::model::{
ConfigInputOptionsDto, ConfigInputUpdateQualityDto, PersistedPlaylistUpdateClusterSnapshot,
PersistedPlaylistUpdateQualityDecision, PersistedPlaylistUpdateQualitySnapshot,
PersistedPlaylistUpdateTechnicalState, PlaylistUpdateClusterDecision, PlaylistUpdateClusterTelemetry,
PlaylistUpdateDataSource,
};
use tuliprox_core::model::{ClusterUpdateAcceptance, ConfigInput};
use tuliprox_iptv::provider::PlaylistFetch;
fn input(input_type: InputType) -> ConfigInput {
ConfigInput { id: 17, name: Arc::from("provider-a"), input_type, enabled: true, ..ConfigInput::default() }
}
fn cluster(telemetry: &PlaylistUpdateInputTelemetry, cluster: XtreamCluster) -> &PlaylistUpdateClusterTelemetry {
telemetry.clusters.iter().find(|item| item.cluster == cluster).expect("configured cluster telemetry")
}
const fn completed_snapshot_context(
effective_policy: InputRefreshPolicy,
request_policy: Option<InputRefreshPolicy>,
provider_cluster_count: usize,
) -> PersistedSnapshotContext {
PersistedSnapshotContext {
effective_policy,
request_policy,
technical_failure: false,
storage_failure: false,
provider_failure_evidence: ProviderFailureEvidence::None,
provider_cluster_count,
}
}
const fn failed_provider_snapshot_context(
effective_policy: InputRefreshPolicy,
request_policy: Option<InputRefreshPolicy>,
provider_cluster_count: usize,
) -> PersistedSnapshotContext {
PersistedSnapshotContext {
effective_policy,
request_policy,
technical_failure: true,
storage_failure: false,
provider_failure_evidence: ProviderFailureEvidence::TechnicalFailure,
provider_cluster_count,
}
}
mod playlist;
mod policy;
mod storage;
mod streaming;
mod transport;
@@ -0,0 +1,53 @@
use super::*;
#[test]
fn pipeline_transparency_known_cluster_failure_keeps_quality_decision_and_neutralizes_overlay() {
let input = input(InputType::Xtream);
let acceptance = ClusterUpdateAcceptance {
cluster: XtreamCluster::Series,
current_count: Some(100),
candidate_count: 95,
threshold: 90,
quality: Some(95),
};
let mut fetch = PlaylistFetch::groups(Vec::new()).with_quality_acceptances(vec![acceptance]);
fetch.record_cluster_error(XtreamCluster::Series, TuliproxError::RepositoryXtream("publish failure".to_string()));
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&PIPELINE_TRANSPARENCY_CLUSTERS,
Some(&fetch),
);
let mut result = PlaylistDownloadResult::from(fetch).with_input_telemetry(telemetry);
finalize_input_telemetry(&mut result, None);
let telemetry = result.input_telemetry.as_mut().unwrap();
let shows = cluster(telemetry, XtreamCluster::Series);
assert_eq!(shows.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!(shows.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Failed));
assert_eq!(shows.active_count, None);
let context = failed_provider_snapshot_context(InputRefreshPolicy::NORMAL, None, 3);
let snapshot = persisted_cluster_snapshot(shows, context);
assert_eq!(snapshot.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Failed));
assert_eq!(snapshot.quality.unwrap().decision, PersistedPlaylistUpdateQualityDecision::Accepted);
neutralize_overlaid_cluster_facts(telemetry, ClusterFlags::Series);
let overlaid = cluster(telemetry, XtreamCluster::Series);
assert_eq!(overlaid.technical_state, None);
assert_eq!(persisted_cluster_snapshot(overlaid, context).technical_state, None);
}
#[test]
fn pipeline_transparency_telemetry_does_not_assign_an_unscoped_error_to_multiple_clusters() {
let input = input(InputType::Stalker);
let fetch = PlaylistFetch::failed(TuliproxError::Download("provider unavailable".to_string()));
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&PIPELINE_TRANSPARENCY_CLUSTERS,
Some(&fetch),
);
assert!(telemetry.clusters.iter().all(|item| item.requested && item.decision.is_none()));
}
@@ -0,0 +1,145 @@
use super::*;
#[test]
fn pipeline_transparency_telemetry_preserves_cache_rejection_and_force_decisions() {
let input = input(InputType::Xtream);
let fetch = PlaylistFetch::groups(Vec::new())
.with_quality_rejections(vec![ClusterUpdateRejection {
cluster: XtreamCluster::Video,
current_count: 12_543,
candidate_count: 217,
threshold: 90,
quality: 1,
}])
.with_force_updates(vec![ClusterForceUpdate {
cluster: XtreamCluster::Series,
candidate_count: 8_412,
configured_threshold: 100,
}]);
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::FORCE,
PlaylistUpdateDataSource::Provider,
&[XtreamCluster::Video, XtreamCluster::Series],
Some(&fetch),
);
let live = cluster(&telemetry, XtreamCluster::Live);
assert!(live.requested);
assert_eq!(live.source, Some(PlaylistUpdateDataSource::Cache));
assert_eq!(live.decision, None);
let video = cluster(&telemetry, XtreamCluster::Video);
assert_eq!(video.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(video.decision, Some(PlaylistUpdateClusterDecision::Rejected));
assert_eq!(
(video.baseline_count, video.candidate_count, video.active_count),
(Some(12_543), Some(217), Some(12_543))
);
assert_eq!((video.threshold, video.quality), (Some(90), Some(1)));
let series = cluster(&telemetry, XtreamCluster::Series);
assert_eq!(series.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(series.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((series.candidate_count, series.active_count), (Some(8_412), None));
assert_eq!(series.threshold, Some(100));
let mut published = telemetry;
confirm_published_cluster_counts(&mut published);
assert_eq!(cluster(&published, XtreamCluster::Series).active_count, Some(8_412));
}
#[test]
fn pipeline_transparency_telemetry_preserves_accepted_quality_and_bootstrap_facts() {
let mut input = input(InputType::Xtream);
input.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
update_quality: ConfigInputUpdateQualityDto { live: 90, vod: 90, series: 0 },
..ConfigInputOptionsDto::default()
}));
let fetch = PlaylistFetch::groups(Vec::new()).with_quality_acceptances(vec![
ClusterUpdateAcceptance {
cluster: XtreamCluster::Live,
current_count: Some(12_543),
candidate_count: 12_000,
threshold: 90,
quality: Some(95),
},
ClusterUpdateAcceptance {
cluster: XtreamCluster::Video,
current_count: None,
candidate_count: 217,
threshold: 90,
quality: None,
},
]);
let mut telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&PIPELINE_TRANSPARENCY_CLUSTERS,
Some(&fetch),
);
let live = cluster(&telemetry, XtreamCluster::Live);
assert_eq!(live.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((live.baseline_count, live.candidate_count), (Some(12_543), Some(12_000)));
assert_eq!((live.threshold, live.quality, live.active_count), (Some(90), Some(95), None));
let video = cluster(&telemetry, XtreamCluster::Video);
assert_eq!(video.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((video.baseline_count, video.candidate_count), (None, Some(217)));
assert_eq!((video.threshold, video.quality, video.active_count), (Some(90), None, None));
confirm_published_cluster_counts(&mut telemetry);
assert_eq!(cluster(&telemetry, XtreamCluster::Live).active_count, Some(12_000));
assert_eq!(cluster(&telemetry, XtreamCluster::Video).active_count, Some(217));
}
#[test]
fn pipeline_transparency_telemetry_keeps_zero_threshold_quality_disabled() {
let input = input(InputType::Xtream);
let fetch = PlaylistFetch::groups(Vec::new());
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&[XtreamCluster::Live],
Some(&fetch),
);
let live = cluster(&telemetry, XtreamCluster::Live);
assert_eq!(live.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!(live.threshold, None);
assert_eq!(live.quality, None);
assert_eq!(live.active_count, None);
}
#[test]
fn pipeline_transparency_telemetry_does_not_infer_an_unreported_quality_acceptance() {
let mut input = input(InputType::Xtream);
input.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
skip_vod: true,
update_quality: ConfigInputUpdateQualityDto { live: 90, ..ConfigInputUpdateQualityDto::default() },
..ConfigInputOptionsDto::default()
}));
let fetch = PlaylistFetch::groups(Vec::new());
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&[XtreamCluster::Live],
Some(&fetch),
);
let live = cluster(&telemetry, XtreamCluster::Live);
assert_eq!(live.threshold, Some(90));
assert_eq!(live.decision, None, "an accepted quality decision was not retained by the provider result");
let video = cluster(&telemetry, XtreamCluster::Video);
assert!(!video.requested);
assert_eq!(video.source, None);
assert_eq!(video.decision, None);
}
@@ -0,0 +1,311 @@
use super::*;
#[test]
fn persisted_cluster_snapshot_preserves_quality_facts_without_merging_technical_state() {
let accepted = PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(1_000),
candidate_count: Some(950),
active_count: Some(950),
threshold: Some(90),
quality: Some(95),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
};
let completed = persisted_cluster_snapshot(
&accepted,
completed_snapshot_context(InputRefreshPolicy::NORMAL, Some(InputRefreshPolicy::REFRESH), 1),
);
assert_eq!(completed.policy, Some(InputRefreshPolicy::REFRESH));
assert_eq!(completed.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(completed.quality_guard_threshold, None, "an evaluation already owns its threshold");
assert_eq!(
completed.quality,
Some(PersistedPlaylistUpdateQualitySnapshot {
threshold: 90,
baseline_count: Some(1_000),
candidate_count: Some(950),
achieved_quality: Some(95),
decision: PersistedPlaylistUpdateQualityDecision::Accepted,
})
);
assert_eq!(completed.active_count, Some(950));
assert_eq!(completed.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Succeeded));
let mut bootstrap = accepted.clone();
bootstrap.baseline_count = None;
bootstrap.candidate_count = Some(42);
bootstrap.active_count = Some(42);
bootstrap.quality = None;
let bootstrap =
persisted_cluster_snapshot(&bootstrap, completed_snapshot_context(InputRefreshPolicy::NORMAL, None, 1));
assert_eq!(bootstrap.policy, None, "an automatic run must not acquire a default policy");
assert_eq!(bootstrap.quality_guard_threshold, None, "bootstrap evaluation already owns its threshold");
assert_eq!(
bootstrap.quality,
Some(PersistedPlaylistUpdateQualitySnapshot {
threshold: 90,
baseline_count: None,
candidate_count: Some(42),
achieved_quality: None,
decision: PersistedPlaylistUpdateQualityDecision::Accepted,
}),
"bootstrap acceptance must not invent achieved Quality"
);
let mut rejected = accepted.clone();
rejected.candidate_count = Some(217);
rejected.active_count = Some(1_000);
rejected.quality = Some(21);
rejected.decision = Some(PlaylistUpdateClusterDecision::Rejected);
let rejected =
persisted_cluster_snapshot(&rejected, completed_snapshot_context(InputRefreshPolicy::NORMAL, None, 1));
assert_eq!(
rejected.quality.map(|quality| quality.decision),
Some(PersistedPlaylistUpdateQualityDecision::Rejected)
);
assert_eq!(rejected.active_count, Some(1_000), "only the typed active baseline is retained");
assert_eq!(rejected.quality_guard_threshold, None, "a rejection evaluation already owns its threshold");
assert_eq!(rejected.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Succeeded));
let mut accepted_then_failed = accepted;
accepted_then_failed.active_count = None;
let accepted_then_failed = persisted_cluster_snapshot(
&accepted_then_failed,
PersistedSnapshotContext {
effective_policy: InputRefreshPolicy::NORMAL,
request_policy: None,
technical_failure: true,
storage_failure: true,
provider_failure_evidence: ProviderFailureEvidence::None,
provider_cluster_count: 1,
},
);
assert_eq!(
accepted_then_failed.quality.map(|quality| quality.decision),
Some(PersistedPlaylistUpdateQualityDecision::Accepted)
);
assert_eq!(accepted_then_failed.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Failed));
assert_eq!(accepted_then_failed.active_count, None);
}
#[test]
fn persisted_cluster_snapshot_keeps_policy_source_and_disabled_guard_independent() {
let forced = PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Video,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: None,
candidate_count: Some(73),
active_count: Some(73),
threshold: Some(95),
quality: None,
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
};
let forced = persisted_cluster_snapshot(
&forced,
completed_snapshot_context(InputRefreshPolicy::FORCE, Some(InputRefreshPolicy::FORCE), 1),
);
assert_eq!(forced.policy, Some(InputRefreshPolicy::FORCE));
assert_eq!(forced.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(forced.quality_guard_threshold, Some(95));
assert_eq!(forced.quality, None, "a Force bypass is not a Quality evaluation");
let disabled = PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Series,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: None,
candidate_count: Some(12),
active_count: Some(12),
threshold: None,
quality: None,
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
};
let disabled = persisted_cluster_snapshot(
&disabled,
completed_snapshot_context(InputRefreshPolicy::FORCE, Some(InputRefreshPolicy::FORCE), 1),
);
assert_eq!(disabled.quality_guard_threshold, Some(0));
assert_eq!(disabled.quality, None, "threshold zero stays disabled even during Force");
let cached = PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Cache),
baseline_count: None,
candidate_count: None,
active_count: None,
threshold: Some(90),
quality: None,
decision: None,
technical_state: None,
};
let cached = persisted_cluster_snapshot(
&cached,
completed_snapshot_context(InputRefreshPolicy::REFRESH, Some(InputRefreshPolicy::REFRESH), 0),
);
assert_eq!(cached.policy, Some(InputRefreshPolicy::REFRESH));
assert_eq!(cached.quality_guard_threshold, Some(90));
assert_eq!(
cached.source,
Some(PlaylistUpdateDataSource::Cache),
"the actual source remains stronger than request policy"
);
}
#[test]
fn persisted_cluster_snapshot_final_status_boundary_replaces_provider_with_cache_without_history() {
let temp = tempfile::tempdir().expect("temporary status directory");
let mut status = input_cache::InputStatus::default();
status.clusters.insert(
XtreamCluster::Live.as_ref().to_string(),
input_cache::ClusterStatus { status: input_cache::ClusterState::Ok, timestamp: 17, last_update: None },
);
let provider_telemetry = PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::REFRESH,
source: None,
clusters: vec![PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(100),
candidate_count: Some(95),
active_count: Some(95),
threshold: Some(90),
quality: Some(95),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
}],
};
let mut provider_result = PlaylistDownloadResult::new(Vec::new(), Vec::new(), false, false)
.with_input_telemetry(provider_telemetry)
.with_pending_status(PendingInputStatusUpdate {
storage_path: temp.path().to_path_buf(),
status,
status_changed: true,
cluster_status_source: ClusterStatusSource::PerCluster,
});
persist_finalized_cluster_snapshots(&mut provider_result, Some(InputRefreshPolicy::REFRESH), None);
let provider_status = input_cache::load_input_status(temp.path());
let provider_snapshot = provider_status.clusters["live"].last_update.expect("provider snapshot");
assert_eq!(provider_snapshot.source, Some(PlaylistUpdateDataSource::Provider));
assert!(provider_snapshot.quality.is_some());
let cache_telemetry = PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::NORMAL,
source: None,
clusters: vec![PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Cache),
baseline_count: None,
candidate_count: None,
active_count: None,
threshold: Some(90),
quality: None,
decision: None,
technical_state: None,
}],
};
let mut cache_result = PlaylistDownloadResult::new(Vec::new(), Vec::new(), true, false)
.with_input_telemetry(cache_telemetry)
.with_pending_status(PendingInputStatusUpdate {
storage_path: temp.path().to_path_buf(),
status: provider_status,
status_changed: false,
cluster_status_source: ClusterStatusSource::PerCluster,
});
persist_finalized_cluster_snapshots(&mut cache_result, Some(InputRefreshPolicy::NORMAL), None);
let cached_status = input_cache::load_input_status(temp.path());
assert_eq!(cached_status.clusters.len(), 1);
let cached_snapshot = cached_status.clusters["live"].last_update.expect("replacement cache snapshot");
assert_eq!(cached_snapshot.policy, Some(InputRefreshPolicy::NORMAL));
assert_eq!(cached_snapshot.source, Some(PlaylistUpdateDataSource::Cache));
assert_eq!(cached_snapshot.quality_guard_threshold, Some(90));
assert_eq!(cached_snapshot.quality, None);
assert_eq!(cached_snapshot.active_count, None);
assert_eq!(cached_snapshot.technical_state, Some(PersistedPlaylistUpdateTechnicalState::Succeeded));
let encoded = std::fs::read_to_string(temp.path().join(input_cache::STATUS_FILE)).expect("persisted status JSON");
assert_eq!(encoded.matches(r#""last_update""#).count(), 1);
assert!(!encoded.contains("history"));
}
#[test]
fn persisted_cluster_snapshot_discards_unproven_staged_overlay_facts() {
let mut telemetry = PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::FORCE,
source: None,
clusters: vec![PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Video,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(100),
candidate_count: Some(95),
active_count: Some(95),
threshold: Some(90),
quality: Some(95),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
}],
};
neutralize_overlaid_cluster_facts(&mut telemetry, ClusterFlags::Vod);
let snapshot = persisted_cluster_snapshot(
cluster(&telemetry, XtreamCluster::Video),
completed_snapshot_context(telemetry.refresh_policy, Some(InputRefreshPolicy::FORCE), 0),
);
assert_eq!(snapshot, PersistedPlaylistUpdateClusterSnapshot::default());
}
#[test]
fn persisted_cluster_snapshot_leaves_unscoped_multi_cluster_failure_unknown() {
let cluster = PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(100),
candidate_count: Some(95),
active_count: None,
threshold: Some(90),
quality: Some(95),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
};
let snapshot =
persisted_cluster_snapshot(&cluster, failed_provider_snapshot_context(InputRefreshPolicy::NORMAL, None, 2));
assert_eq!(snapshot.technical_state, None);
assert_eq!(
snapshot.quality.map(|quality| quality.decision),
Some(PersistedPlaylistUpdateQualityDecision::Accepted)
);
let unscoped_ancillary_failure = persisted_cluster_snapshot(
&cluster,
PersistedSnapshotContext {
effective_policy: InputRefreshPolicy::NORMAL,
request_policy: None,
technical_failure: true,
storage_failure: false,
provider_failure_evidence: ProviderFailureEvidence::None,
provider_cluster_count: 1,
},
);
assert_eq!(
unscoped_ancillary_failure.technical_state, None,
"an input-wide ancillary failure is not invented as a cluster-scoped provider failure"
);
}
@@ -0,0 +1,38 @@
use super::*;
#[test]
fn pipeline_transparency_accepted_quality_survives_fetch_error_without_active_claim() {
let mut input = input(InputType::Xtream);
input.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
update_quality: ConfigInputUpdateQualityDto { live: 90, ..ConfigInputUpdateQualityDto::default() },
..ConfigInputOptionsDto::default()
}));
let acceptance = ClusterUpdateAcceptance {
cluster: XtreamCluster::Live,
current_count: Some(100),
candidate_count: 95,
threshold: 90,
quality: Some(95),
};
let fetch = PlaylistFetch::groups(Vec::new())
.with_quality_acceptances(vec![acceptance])
.with_errors(vec![TuliproxError::RepositoryXtream("post-quality publish failure".to_string())]);
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Provider,
&[XtreamCluster::Live],
Some(&fetch),
);
let mut download_result = PlaylistDownloadResult::from(fetch).with_input_telemetry(telemetry);
finalize_input_telemetry(&mut download_result, None);
let telemetry = download_result.input_telemetry.as_ref().expect("input telemetry");
let live = cluster(telemetry, XtreamCluster::Live);
assert_eq!(live.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((live.baseline_count, live.candidate_count), (Some(100), Some(95)));
assert_eq!((live.threshold, live.quality), (Some(90), Some(95)));
assert_eq!(live.active_count, None);
assert_eq!(download_result.download_err.len(), 1);
}
@@ -0,0 +1,45 @@
use super::*;
#[test]
fn pipeline_transparency_telemetry_reports_non_cluster_cache_without_synthetic_clusters() {
let telemetry = input_telemetry_for_fetch(
&input(InputType::Plex),
InputRefreshPolicy::NORMAL,
PlaylistUpdateDataSource::Cache,
&[],
None,
);
assert_eq!(telemetry.refresh_policy, InputRefreshPolicy::NORMAL);
assert_eq!(telemetry.source, Some(PlaylistUpdateDataSource::Cache));
assert!(telemetry.clusters.is_empty());
}
#[test]
fn pipeline_transparency_telemetry_types_complete_provider_failure_without_guessing_metrics() {
let mut input = input(InputType::Xtream);
input.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
skip_vod: true,
skip_series: true,
..ConfigInputOptionsDto::default()
}));
let fetch = PlaylistFetch::failed(TuliproxError::Download("provider unavailable".to_string()));
let telemetry = input_telemetry_for_fetch(
&input,
InputRefreshPolicy::REFRESH,
PlaylistUpdateDataSource::Provider,
&[XtreamCluster::Live],
Some(&fetch),
);
assert_eq!(telemetry.refresh_policy, InputRefreshPolicy::REFRESH);
let live = cluster(&telemetry, XtreamCluster::Live);
assert!(live.requested);
assert_eq!(live.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(live.decision, Some(PlaylistUpdateClusterDecision::TechnicalError));
assert!(live.baseline_count.is_none());
assert!(live.candidate_count.is_none());
assert!(live.active_count.is_none());
assert!(live.quality.is_none());
}
@@ -0,0 +1,201 @@
use super::{
telemetry::{input_telemetry_for_fetch, is_cluster_input},
PendingInputStatusUpdate, PlaylistDownloadResult, PlaylistProcessingContext,
};
use crate::metadata_sink::MetadataUpdateSink;
use log::info;
use shared::{
error::TuliproxError,
model::{
ClusterFlags, EventMessage, EventSink, InputRefreshPolicy, InputType, PlaylistGroup, PlaylistUpdateDataSource,
PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, XtreamCluster,
},
};
use std::collections::HashSet;
use tuliprox_core::model::{ClusterForceUpdate, ConfigInput, ConfigInputFlags, ProcessTargets};
use tuliprox_iptv::{provider::PlaylistFetch, xtream};
use tuliprox_repository::PlaylistSource;
// Inputs disabled in the config are always disabled.
// Command-line targets can only restrict enabled inputs, never enable them.
pub(crate) fn is_input_enabled(input: &ConfigInput, user_targets: &ProcessTargets) -> bool {
input.enabled && (!user_targets.enabled || user_targets.has_input(input.id))
}
pub(crate) async fn with_sequential_group<T>(
file_locks: &tuliprox_core::utils::FileLockManager,
group: Option<u32>,
process_parallel: bool,
future: impl std::future::Future<Output = T>,
) -> T {
let _guard = if process_parallel {
if let Some(group) = group {
Some(file_locks.write_lock_str(&format!("sequential_group:{group}")).await)
} else {
None
}
} else {
None
};
future.await
}
#[derive(Clone, Copy)]
pub(super) enum ClusterStatusSource {
PerCluster,
Default,
}
#[derive(Clone, Copy, Default)]
pub(super) enum ProviderFailureEvidence {
#[default]
None,
TechnicalFailure,
}
impl PlaylistDownloadResult {
pub fn new(
downloaded_playlist: Vec<PlaylistGroup>,
download_err: Vec<TuliproxError>,
was_cached: bool,
persisted: bool,
) -> Self {
Self {
downloaded_playlist,
download_err,
quality_rejections: Vec::new(),
force_updates: Vec::new(),
was_cached,
persisted,
partial: false,
input_telemetry: None,
pending_status: None,
provider_failure_evidence: ProviderFailureEvidence::None,
}
}
pub(super) fn with_input_telemetry(mut self, input_telemetry: PlaylistUpdateInputTelemetry) -> Self {
self.input_telemetry = Some(input_telemetry);
self
}
pub(super) fn with_pending_status(mut self, pending_status: PendingInputStatusUpdate) -> Self {
self.pending_status = Some(pending_status);
self
}
}
impl From<PlaylistFetch> for PlaylistDownloadResult {
fn from(fetch: PlaylistFetch) -> Self {
let provider_failure_evidence = if fetch.partial || !fetch.errors.is_empty() {
ProviderFailureEvidence::TechnicalFailure
} else {
ProviderFailureEvidence::None
};
Self {
downloaded_playlist: fetch.groups,
download_err: fetch.errors,
quality_rejections: fetch.quality_rejections,
force_updates: fetch.force_updates,
was_cached: false,
persisted: fetch.persisted,
partial: fetch.partial,
input_telemetry: None,
pending_status: None,
provider_failure_evidence,
}
}
}
pub(super) fn cached_playlist_download_result(
input: &ConfigInput,
refresh_policy: InputRefreshPolicy,
) -> PlaylistDownloadResult {
PlaylistDownloadResult::new(vec![], vec![], true, false).with_input_telemetry(input_telemetry_for_fetch(
input,
refresh_policy,
PlaylistUpdateDataSource::Cache,
&[],
None,
))
}
/// Loads an input already acquired in this run without reporting a second acquisition.
pub(super) fn in_run_reuse_playlist_download_result() -> PlaylistDownloadResult {
PlaylistDownloadResult::new(vec![], vec![], true, false)
}
pub(super) fn forced_empty_cluster_flags(force_updates: &[ClusterForceUpdate]) -> ClusterFlags {
force_updates.iter().filter(|update| update.candidate_count == 0).fold(
ClusterFlags::empty(),
|mut clusters, update| {
clusters.insert(match update.cluster {
XtreamCluster::Live => ClusterFlags::Live,
XtreamCluster::Video => ClusterFlags::Vod,
XtreamCluster::Series => ClusterFlags::Series,
});
clusters
},
)
}
pub(crate) fn collect_effective_skip_clusters(input: &ConfigInput) -> Vec<XtreamCluster> {
if !input.input_type.is_xtream() && input.input_type != InputType::M3u {
return vec![];
}
xtream::get_skip_cluster(input)
}
pub(super) fn report_forced_update_request<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &ConfigInput,
refresh_policy: InputRefreshPolicy,
) {
if !refresh_policy.bypasses_quality() {
return;
}
let input_type = input.get_download_input_type();
if !is_cluster_input(input_type) {
return;
}
let clusters = [
(ConfigInputFlags::SkipLive, "live"),
(ConfigInputFlags::SkipVod, "vod"),
(ConfigInputFlags::SkipSeries, "series"),
]
.into_iter()
.filter_map(|(skip_flag, name)| (!input.has_flag(skip_flag)).then_some(name))
.collect::<Vec<_>>()
.join(",");
let message =
format!("Input '{}': forced update requested; cache and update-quality bypassed for {clusters}", input.name);
info!("{message}");
ctx.events.emit(EventMessage::PlaylistUpdateProgress(
PlaylistUpdateProgressEvent::for_run_input(
ctx.run_id.clone(),
ctx.execution_order,
input.id,
input.name.to_string(),
message,
)
.with_detail(shared::model::PlaylistUpdateProgressDetail::ForcedUpdateRequested),
));
}
pub(crate) fn filter_skipped_clusters_from_source(source: PlaylistSource, input: &ConfigInput) -> PlaylistSource {
let skip_clusters = collect_effective_skip_clusters(input);
if skip_clusters.is_empty() {
return source;
}
let skip_set: HashSet<XtreamCluster> = skip_clusters.into_iter().collect();
PlaylistSource::filtered(source, skip_set)
}
pub(crate) fn cluster_selected(cluster: XtreamCluster, clusters: ClusterFlags) -> bool {
match cluster {
XtreamCluster::Live => clusters.contains(ClusterFlags::Live),
XtreamCluster::Video => clusters.contains(ClusterFlags::Vod),
XtreamCluster::Series => clusters.contains(ClusterFlags::Series),
}
}
@@ -0,0 +1,288 @@
use super::{
cluster_selected,
status::{ClusterStatusSource, ProviderFailureEvidence},
PersistedSnapshotContext, PlaylistDownloadResult,
};
use crate::input_cache;
use shared::{
error::TuliproxError,
model::{
ClusterFlags, InputRefreshPolicy, InputType, PersistedPlaylistUpdateClusterSnapshot,
PersistedPlaylistUpdateQualityDecision, PersistedPlaylistUpdateQualitySnapshot,
PersistedPlaylistUpdateTechnicalState, PlaylistUpdateClusterDecision, PlaylistUpdateClusterTelemetry,
PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry, XtreamCluster,
},
};
use std::collections::HashMap;
use tuliprox_core::model::{ConfigInput, ConfigInputFlags};
use tuliprox_iptv::provider::PlaylistFetch;
pub(in crate::processor::playlist) const PIPELINE_TRANSPARENCY_CLUSTERS: [XtreamCluster; 3] =
[XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series];
pub(super) fn is_cluster_input(input_type: InputType) -> bool {
matches!(input_type, InputType::Xtream | InputType::Stalker | InputType::M3u)
}
pub(in crate::processor::playlist) fn cluster_is_configured(input: &ConfigInput, cluster: XtreamCluster) -> bool {
let skip_flag = match cluster {
XtreamCluster::Live => ConfigInputFlags::SkipLive,
XtreamCluster::Video => ConfigInputFlags::SkipVod,
XtreamCluster::Series => ConfigInputFlags::SkipSeries,
};
!input.has_flag(skip_flag)
}
pub(super) fn input_telemetry_for_fetch(
input: &ConfigInput,
refresh_policy: InputRefreshPolicy,
source: PlaylistUpdateDataSource,
provider_clusters: &[XtreamCluster],
fetch: Option<&PlaylistFetch>,
) -> PlaylistUpdateInputTelemetry {
let input_type = input.get_download_input_type();
if !is_cluster_input(input_type) {
return PlaylistUpdateInputTelemetry { refresh_policy, source: Some(source), clusters: Vec::new() };
}
let mut candidate_counts = HashMap::<XtreamCluster, usize>::new();
if let Some(fetch) = fetch {
for group in &fetch.groups {
*candidate_counts.entry(group.xtream_cluster).or_default() += group.channels.len();
}
if fetch.errors.is_empty() && !fetch.partial && !fetch.persisted {
for cluster in provider_clusters {
candidate_counts.entry(*cluster).or_default();
}
}
}
let clusters = PIPELINE_TRANSPARENCY_CLUSTERS
.into_iter()
.map(|cluster| {
let requested = cluster_is_configured(input, cluster);
let source = requested.then(|| {
if provider_clusters.contains(&cluster) {
PlaylistUpdateDataSource::Provider
} else {
PlaylistUpdateDataSource::Cache
}
});
let configured_threshold =
input.options.as_ref().map_or(0, |options| options.update_quality.threshold(cluster));
let mut telemetry = PlaylistUpdateClusterTelemetry {
cluster,
requested,
source,
baseline_count: None,
candidate_count: candidate_counts.get(&cluster).copied(),
active_count: None,
threshold: (configured_threshold > 0).then_some(configured_threshold),
quality: None,
decision: None,
technical_state: None,
};
if let Some(fetch) = fetch {
if requested && fetch.failed_clusters.contains(&cluster) {
telemetry.technical_state = Some(PersistedPlaylistUpdateTechnicalState::Failed);
}
if let Some(rejection) = fetch.quality_rejections.iter().find(|item| item.cluster == cluster) {
telemetry.baseline_count = Some(rejection.current_count);
telemetry.candidate_count = Some(rejection.candidate_count);
telemetry.active_count = Some(rejection.current_count);
telemetry.threshold = Some(rejection.threshold);
telemetry.quality = Some(rejection.quality);
telemetry.decision = Some(PlaylistUpdateClusterDecision::Rejected);
} else if let Some(acceptance) = fetch.quality_acceptances.iter().find(|item| item.cluster == cluster) {
telemetry.baseline_count = acceptance.current_count;
telemetry.candidate_count = Some(acceptance.candidate_count);
telemetry.threshold = Some(acceptance.threshold);
telemetry.quality = acceptance.quality;
telemetry.decision = Some(PlaylistUpdateClusterDecision::Accepted);
} else if let Some(force_update) = fetch.force_updates.iter().find(|item| item.cluster == cluster) {
telemetry.candidate_count = Some(force_update.candidate_count);
telemetry.threshold =
(force_update.configured_threshold > 0).then_some(force_update.configured_threshold);
telemetry.decision = Some(PlaylistUpdateClusterDecision::Accepted);
} else if source == Some(PlaylistUpdateDataSource::Provider) {
if configured_threshold == 0
&& (candidate_counts.contains_key(&cluster) || (fetch.errors.is_empty() && !fetch.partial))
{
telemetry.decision = Some(PlaylistUpdateClusterDecision::Accepted);
} else if (provider_clusters == [cluster] || input_type == InputType::M3u)
&& fetch.groups.is_empty()
&& !fetch.persisted
&& !fetch.errors.is_empty()
{
telemetry.decision = Some(PlaylistUpdateClusterDecision::TechnicalError);
}
}
}
telemetry
})
.collect();
PlaylistUpdateInputTelemetry { refresh_policy, source: None, clusters }
}
pub(super) fn confirm_published_cluster_counts(input_telemetry: &mut PlaylistUpdateInputTelemetry) {
for cluster in &mut input_telemetry.clusters {
if cluster.decision == Some(PlaylistUpdateClusterDecision::Accepted) {
cluster.active_count = cluster.candidate_count;
}
}
}
pub(super) fn finalize_input_telemetry(
playlist_download_result: &mut PlaylistDownloadResult,
storage_error: Option<&TuliproxError>,
) {
let activation_is_uncertain = storage_error.is_some()
|| playlist_download_result.partial
|| !playlist_download_result.download_err.is_empty();
if let Some(input_telemetry) = playlist_download_result.input_telemetry.as_mut() {
if activation_is_uncertain {
for cluster in &mut input_telemetry.clusters {
cluster.active_count = None;
}
} else {
confirm_published_cluster_counts(input_telemetry);
}
}
}
pub(crate) fn neutralize_overlaid_cluster_facts(
input_telemetry: &mut PlaylistUpdateInputTelemetry,
overlaid_clusters: ClusterFlags,
) {
for cluster in &mut input_telemetry.clusters {
if cluster_selected(cluster.cluster, overlaid_clusters) {
cluster.source = None;
cluster.baseline_count = None;
cluster.candidate_count = None;
cluster.active_count = None;
cluster.quality = None;
cluster.decision = None;
cluster.technical_state = None;
}
}
}
fn persisted_quality_snapshot(
cluster: &PlaylistUpdateClusterTelemetry,
effective_policy: InputRefreshPolicy,
) -> Option<PersistedPlaylistUpdateQualitySnapshot> {
if effective_policy.bypasses_quality() {
return None;
}
let threshold = cluster.threshold?;
let decision = match cluster.decision? {
PlaylistUpdateClusterDecision::Accepted => PersistedPlaylistUpdateQualityDecision::Accepted,
PlaylistUpdateClusterDecision::Rejected => PersistedPlaylistUpdateQualityDecision::Rejected,
PlaylistUpdateClusterDecision::TechnicalError => return None,
};
Some(PersistedPlaylistUpdateQualitySnapshot {
threshold,
baseline_count: cluster.baseline_count,
candidate_count: cluster.candidate_count,
achieved_quality: cluster.quality,
decision,
})
}
fn persisted_technical_state(
cluster: &PlaylistUpdateClusterTelemetry,
context: PersistedSnapshotContext,
) -> Option<PersistedPlaylistUpdateTechnicalState> {
let source = cluster.source?;
if cluster.technical_state == Some(PersistedPlaylistUpdateTechnicalState::Failed) {
return cluster.technical_state;
}
if cluster.decision == Some(PlaylistUpdateClusterDecision::TechnicalError) {
return Some(PersistedPlaylistUpdateTechnicalState::Failed);
}
if context.storage_failure && source == PlaylistUpdateDataSource::Provider {
return Some(PersistedPlaylistUpdateTechnicalState::Failed);
}
if !context.technical_failure {
return Some(PersistedPlaylistUpdateTechnicalState::Succeeded);
}
(source == PlaylistUpdateDataSource::Provider
&& context.provider_cluster_count == 1
&& matches!(context.provider_failure_evidence, ProviderFailureEvidence::TechnicalFailure))
.then_some(PersistedPlaylistUpdateTechnicalState::Failed)
}
pub(super) fn persisted_cluster_snapshot(
cluster: &PlaylistUpdateClusterTelemetry,
context: PersistedSnapshotContext,
) -> PersistedPlaylistUpdateClusterSnapshot {
let quality = persisted_quality_snapshot(cluster, context.effective_policy);
PersistedPlaylistUpdateClusterSnapshot {
policy: cluster.source.and(context.request_policy),
source: cluster.source,
quality_guard_threshold: (quality.is_none() && cluster.source.is_some())
.then_some(cluster.threshold.unwrap_or(0)),
quality,
active_count: cluster.active_count,
technical_state: persisted_technical_state(cluster, context),
}
}
pub(super) fn persist_finalized_cluster_snapshots(
playlist_download_result: &mut PlaylistDownloadResult,
request_policy: Option<InputRefreshPolicy>,
storage_error: Option<&TuliproxError>,
) {
let technical_failure = storage_error.is_some()
|| playlist_download_result.partial
|| !playlist_download_result.download_err.is_empty();
let provider_failure_evidence = playlist_download_result.provider_failure_evidence;
let snapshots = playlist_download_result.input_telemetry.as_ref().map_or_else(Vec::new, |telemetry| {
let provider_cluster_count = telemetry
.clusters
.iter()
.filter(|cluster| cluster.requested && cluster.source == Some(PlaylistUpdateDataSource::Provider))
.count();
telemetry
.clusters
.iter()
.filter(|cluster| cluster.requested)
.map(|cluster| {
let snapshot = persisted_cluster_snapshot(
cluster,
PersistedSnapshotContext {
effective_policy: telemetry.refresh_policy,
request_policy,
technical_failure,
storage_failure: storage_error.is_some(),
provider_failure_evidence,
provider_cluster_count,
},
);
(cluster.cluster, snapshot)
})
.collect()
});
let Some(mut pending_status) = playlist_download_result.pending_status.take() else {
return;
};
let mut changed = pending_status.status_changed;
for (cluster, snapshot) in snapshots {
changed |= match pending_status.cluster_status_source {
ClusterStatusSource::PerCluster => {
input_cache::replace_cluster_snapshot(&mut pending_status.status, cluster.as_ref(), snapshot)
}
ClusterStatusSource::Default => input_cache::replace_cluster_snapshot_from_default_status(
&mut pending_status.status,
cluster.as_ref(),
snapshot,
),
};
}
if changed {
input_cache::save_input_status(&pending_status.storage_path, &pending_status.status);
}
}
@@ -1,404 +0,0 @@
mod persisted_input_status {
use super::*;
use crate::processor::playlist::input_status::{persist_input_completion, persist_input_job_result};
use shared::model::PersistedPlaylistUpdateInputResult;
fn acquired_result(state: PlaylistUpdateState) -> InputJobResult {
let mut result = super::super::playlist_update_run_result(
7,
InputJobState::Ready,
vec![],
state == PlaylistUpdateState::Partial,
);
result.input_telemetry = Some(PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::REFRESH,
source: Some(PlaylistUpdateDataSource::Provider),
clusters: Vec::new(),
});
result
}
#[tokio::test]
async fn playlist_update_status_run_precedence_is_isolated_by_stable_input_id() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let failed = super::super::playlist_update_run_result(7, InputJobState::Failed, vec![], false);
persist_input_job_result(&ctx, &failed).await;
let mut independent = acquired_result(PlaylistUpdateState::Success);
independent.input_id = 8;
independent.input_name = "independent".intern();
persist_input_job_result(&ctx, &independent).await;
for (result, state) in [(failed, PlaylistUpdateState::Failure), (independent, PlaylistUpdateState::Success)] {
let path =
input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &result.input_name).await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, state);
}
}
#[tokio::test]
async fn m3u_update_quality_playlist_update_status_staged_completion_is_independent_of_parent_failure() {
for (parent_fails, staged_fails) in [(false, false), (true, false), (false, true)] {
let temp = tempfile::tempdir().unwrap();
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let parent_path = temp.path().join("parent.m3u");
let staged_path = temp.path().join("overlay.m3u");
for (path, fails) in [(&parent_path, parent_fails), (&staged_path, staged_fails)] {
if !fails {
std::fs::write(path, "#EXTM3U\n#EXTINF:-1,Fixture\nhttp://stream.example/fixture\n").unwrap();
}
}
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_path.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged = ConfigInput {
id: 8,
name: "staged".intern(),
input_type: InputType::Staged,
url: staged_path.to_string_lossy().into_owned(),
enabled: true,
staged: Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
}),
..ConfigInput::default()
};
staged.resolve_staged_download_type();
let staged = Arc::new(staged);
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone()],
..SourcesConfig::default()
}));
let result = download_input(&ctx, &parent, false).await;
assert_eq!(!result.errors.is_empty(), parent_fails || staged_fails);
let path = input_storage_path(&ctx, &staged).await;
let persisted = input_cache::load_input_status(&path);
assert_eq!(
persisted.last_input_update.unwrap().state,
if staged_fails { PlaylistUpdateState::Failure } else { PlaylistUpdateState::Success }
);
assert_eq!(persisted.clusters.len(), 4);
assert!(persisted.clusters["default"].last_update.is_none(), "document cache and cluster facts stay separate");
for cluster in XTREAM_CLUSTER {
assert!(persisted.clusters[cluster.as_ref()].last_update.is_some());
}
let completions = staged_completion::input_completions(&events, staged.id);
assert_eq!(completions.len(), 1, "real staged download must report its own completion");
assert_eq!(completions[0].state, persisted.last_input_update.map(|result| result.state));
assert_eq!(completions[0].run_id, Some(ctx.run_id.clone()));
assert_eq!(completions[0].execution_order, Some(ctx.execution_order));
let telemetry = completions[0].input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.source, None);
assert!(telemetry.clusters.iter().all(|cluster| cluster.source == Some(PlaylistUpdateDataSource::Provider)));
assert!(staged_completion::input_completions(&events, parent.id).is_empty());
}
}
#[tokio::test]
async fn playlist_update_status_delayed_source_results_keep_strongest_completion_until_next_run() {
for weaker in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial] {
for failure_first in [false, true] {
let temp = tempfile::tempdir().unwrap();
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let acquired = acquired_result(weaker);
let failed_reuse = super::super::playlist_update_run_result(7, InputJobState::Failed, vec![], false);
let (first, delayed) = if failure_first { (failed_reuse, acquired) } else { (acquired, failed_reuse) };
let path =
input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &first.input_name).await;
let mut original = input_cache::InputStatus::default();
original.clusters.insert(
"live".into(),
input_cache::ClusterStatus {
status: input_cache::ClusterState::Failed,
timestamp: 123,
last_update: Some(PersistedPlaylistUpdateClusterSnapshot {
source: Some(PlaylistUpdateDataSource::Cache),
policy: Some(InputRefreshPolicy::FORCE),
quality_guard_threshold: Some(95),
..PersistedPlaylistUpdateClusterSnapshot::default()
}),
},
);
input_cache::save_input_status(&path, &original);
let (release, other_input_finished) = tokio::sync::oneshot::channel();
let delayed_ctx = ctx.clone();
// Source A already holds its shared-input result, but cannot drain
// its sorted result list until another input job has completed.
let delayed_source = async move {
let source_results = vec![delayed];
other_input_finished.await.unwrap();
for result in source_results {
persist_input_job_result(&delayed_ctx, &result).await;
report_input_job_completion(
&delayed_ctx.events,
&delayed_ctx.run_id,
delayed_ctx.execution_order,
&result,
);
}
};
let first_source = async {
persist_input_job_result(&ctx, &first).await;
report_input_job_completion(&ctx.events, &ctx.run_id, ctx.execution_order, &first);
let mut persisted = input_cache::load_input_status(&path);
assert_eq!(persisted.last_input_update.unwrap().state, first.update_state());
// Distinct sentinel proves a delayed weaker result does not
// advance the stronger completion's timestamp, without sleeps.
persisted.last_input_update.as_mut().unwrap().timestamp = 456;
input_cache::save_input_status(&path, &persisted);
release.send(()).unwrap();
};
tokio::join!(delayed_source, first_source);
let final_status = input_cache::load_input_status(&path);
let completion = final_status.last_input_update.unwrap();
assert_eq!(
completion.state,
PlaylistUpdateState::Failure,
"weaker={weaker:?}, failure_first={failure_first}"
);
if failure_first {
assert_eq!(completion.timestamp, 456);
}
assert_eq!(final_status.clusters, original.clusters);
let states: Vec<_> = events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) => progress.state,
_ => None,
})
.collect();
assert_eq!(
states,
if failure_first {
vec![PlaylistUpdateState::Failure, weaker]
} else {
vec![weaker, PlaylistUpdateState::Failure]
}
);
let mut next = processing_context(temp.path());
next.run_id = "next-successful-run".into();
next.execution_order = 2.into();
let success = acquired_result(PlaylistUpdateState::Success);
persist_input_job_result(&next, &success).await;
let replacement = input_cache::load_input_status(&path);
assert_eq!(replacement.last_input_update.unwrap().state, PlaylistUpdateState::Success);
assert_eq!(replacement.clusters, original.clusters);
}
}
}
#[tokio::test]
async fn playlist_update_status_parent_download_persists_staged_own_per_cluster_success_and_failure() {
for fails in [false, true] {
let temp = tempfile::tempdir().unwrap();
let mut responses = fixture_responses([1, 0, 0]);
if fails {
responses.insert("get_live_streams".into(), "not JSON".into());
}
let server = TestXtreamServer::start_with_responses(responses);
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let parent_file = temp.path().join("parent.m3u");
std::fs::write(&parent_file, "#EXTM3U\n#EXTINF:-1,Parent\nhttp://stream.example/parent\n").unwrap();
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_file.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged =
(*test_input(&server.base_url, ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 }, 0, &[]))
.clone();
staged.id = 8;
staged.name = "own-staged".intern();
staged.input_type = InputType::Staged;
staged.staged_type = shared::model::StagedInputType::Xtream;
staged.staged = Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
});
staged.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
skip_vod: true,
skip_series: true,
update_quality: ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 },
..ConfigInputOptionsDto::default()
}));
staged.resolve_staged_download_type(); // Same resolver used when SourcesConfig is built.
let staged = Arc::new(staged);
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone()],
..SourcesConfig::default()
}));
let staged_path = input_storage_path(&ctx, &staged).await;
// Referencing or directly skipping a staged input is not an acquisition.
let skipped = download_input(&ctx, &staged, false).await;
assert!(skipped.input_telemetry.is_none());
assert!(input_cache::load_input_status(&staged_path).last_input_update.is_none());
assert!(staged_completion::input_completions(&events, staged.id).is_empty());
let result = download_input(&ctx, &parent, false).await;
let status = input_cache::load_input_status(&staged_path);
assert!(!status.clusters.contains_key("default"));
assert_eq!(status.clusters.len(), 1);
let completion =
status.last_input_update.expect("actual indirect staged acquisition has its own completion");
assert_eq!(
completion.state,
if fails { PlaylistUpdateState::Failure } else { PlaylistUpdateState::Success }
);
assert!(completion.timestamp > 0);
let completions = staged_completion::input_completions(&events, staged.id);
assert_eq!(completions.len(), 1);
assert_eq!(completions[0].state, Some(completion.state));
assert_eq!(completions[0].run_id, Some(ctx.run_id.clone()));
assert_eq!(completions[0].execution_order, Some(ctx.execution_order));
let snapshot = status.clusters["live"].last_update.unwrap();
assert_eq!(snapshot.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(snapshot.policy, None);
if fails {
assert_eq!(snapshot.quality_guard_threshold, Some(95));
assert_eq!(snapshot.quality, None);
} else {
assert_eq!(snapshot.quality_guard_threshold, None, "the evaluation already owns its threshold");
let quality = snapshot.quality.unwrap();
assert_eq!(quality.threshold, 95);
assert_eq!(quality.decision, PersistedPlaylistUpdateQualityDecision::Accepted);
assert_eq!(quality.achieved_quality, None, "bootstrap has no invented Quality");
}
assert_eq!(result.errors.is_empty(), !fails);
let parent_path = input_storage_path(&ctx, &parent).await;
assert_ne!(parent_path, staged_path);
assert!(
input_cache::load_input_status(&parent_path).last_input_update.is_none(),
"staged result does not invent a parent job completion"
);
if !fails {
let bytes = std::fs::read(staged_path.join(input_cache::STATUS_FILE)).unwrap();
let reuse = download_input(&ctx, &parent, false).await;
assert!(reuse.input_telemetry.is_none());
let mut staged_reuse = download_input(&ctx, &staged, true).await;
assert!(staged_reuse.input_telemetry.is_none());
super::super::input_status::complete_staged_input(&ctx, &staged, &mut staged_reuse).await;
assert_eq!(std::fs::read(staged_path.join(input_cache::STATUS_FILE)).unwrap(), bytes);
assert_eq!(staged_completion::input_completions(&events, staged.id), completions);
}
let requests = server.finish();
assert_eq!(
requests.iter().filter(|action| action.as_str() == "get_live_streams").count(),
1,
"no extra staged acquisition on parent reuse"
);
}
}
#[tokio::test]
async fn playlist_update_status_completion_replaces_one_result_without_touching_cache_or_cluster_snapshots() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let input_name: Arc<str> = "result-input".intern();
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &input_name).await;
let mut original = input_cache::InputStatus::default();
input_cache::update_cluster_status(&mut original, "default", input_cache::ClusterState::Ok);
original.clusters.get_mut("default").unwrap().timestamp = 1; // Expired cache must stay expired.
input_cache::replace_cluster_snapshot(
&mut original,
"default",
PersistedPlaylistUpdateClusterSnapshot {
source: Some(PlaylistUpdateDataSource::Cache),
..PersistedPlaylistUpdateClusterSnapshot::default()
},
);
input_cache::save_input_status(&path, &original);
for state in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial, PlaylistUpdateState::Failure] {
persist_input_completion(&ctx, 7, &input_name, state).await;
let reloaded = input_cache::load_input_status(&path);
let result = reloaded.last_input_update.unwrap();
assert_eq!(result.state, state);
assert!(result.timestamp > 1);
assert_eq!(reloaded.clusters, original.clusters);
assert!(!input_cache::is_cache_valid(&reloaded, "default", 3600));
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap()).unwrap();
assert!(json["last_input_update"].is_object());
assert_eq!(json.as_object().unwrap().len(), 2, "only clusters + one result, no history");
}
}
#[tokio::test]
async fn playlist_update_status_in_run_reuse_preserves_original_partial_result_and_timestamp() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let mut result = super::super::playlist_update_run_result(7, InputJobState::Ready, vec![], true);
result.input_telemetry = Some(PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::NORMAL,
source: Some(PlaylistUpdateDataSource::Provider),
clusters: Vec::new(),
});
persist_input_job_result(&ctx, &result).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &result.input_name).await;
let mut original = input_cache::load_input_status(&path);
assert_eq!(original.last_input_update.unwrap().state, PlaylistUpdateState::Partial);
original.last_input_update.as_mut().unwrap().timestamp = 123;
input_cache::save_input_status(&path, &original);
let before = std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap();
result.had_quality_rejections = false;
result.input_telemetry = None; // Same existing absence of acquisition facts as a real in-run reuse.
persist_input_job_result(&ctx, &result).await;
assert_eq!(std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap(), before);
result.state = InputJobState::Failed; // A genuine technical completion must still replace the prior result.
persist_input_job_result(&ctx, &result).await;
assert_eq!(
input_cache::load_input_status(&path).last_input_update.unwrap().state,
PlaylistUpdateState::Failure
);
}
#[tokio::test]
async fn playlist_update_status_completion_covers_every_input_type_without_artificial_clusters() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
for (id, input_type) in [
InputType::Xtream,
InputType::XtreamBatch,
InputType::Stalker,
InputType::StalkerBatch,
InputType::M3u,
InputType::M3uBatch,
InputType::Library,
InputType::Plex,
InputType::Emby,
InputType::Jellyfin,
InputType::Staged,
]
.into_iter()
.enumerate()
{
let input = ConfigInput {
id: u16::try_from(id).unwrap(),
input_type,
name: format!("input-{id}").intern(),
..ConfigInput::default()
};
let result = panicked_input_job(id, &input);
persist_input_job_result(&ctx, &result).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &input.name).await;
let persisted = input_cache::load_input_status(&path);
assert!(persisted.clusters.is_empty());
assert!(
matches!(persisted.last_input_update, Some(PersistedPlaylistUpdateInputResult {state: PlaylistUpdateState::Failure, timestamp}) if timestamp > 0)
);
}
}
}
@@ -1,520 +0,0 @@
mod m3u_update_quality {
use super::*;
use std::fmt::Write;
use tuliprox_repository::{load_input_m3u_playlist, raw_group_catalog_path, RawInputGroupCatalog};
fn document(prefix: &str, counts: [usize; 3]) -> String {
let mut text = String::from("#EXTM3U\n");
for (cluster, count) in XTREAM_CLUSTER.into_iter().zip(counts) {
for index in 0..count {
let (kind, group, title) = match cluster {
XtreamCluster::Live => ("live", format!("{prefix}-live"), format!("Channel {index}")),
XtreamCluster::Video => ("movie", format!("{prefix}-vod"), format!("Movie {index}")),
XtreamCluster::Series => {
("series", format!("{prefix}-show-{index}"), format!("Show {index} S01E01"))
}
};
let id = match cluster {
XtreamCluster::Live => 1000,
XtreamCluster::Video => 2000,
XtreamCluster::Series => 3000,
} + index;
writeln!(text, "#EXTGRP:{prefix}-{cluster}\n#EXTINF:-1 xui-id=\"{id}\" tvg-type=\"{kind}\" group-title=\"{group}\",{title}\nhttp://stream.example/{prefix}/{cluster}/{id}").unwrap();
}
}
text
}
async fn fixture(
root: &Path,
previous: Option<[usize; 3]>,
thresholds: [u8; 3],
) -> (PlaylistProcessingContext<shared::model::NoopSink>, Arc<ConfigInput>) {
let ctx = processing_context(root);
let input = Arc::new(ConfigInput {
id: 27,
name: "m3u-quality".intern(),
input_type: InputType::M3u,
enabled: true,
url: root.join("provider.m3u").to_string_lossy().into_owned(),
options: Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
update_quality: ConfigInputUpdateQualityDto {
live: thresholds[0],
vod: thresholds[1],
series: thresholds[2],
},
..ConfigInputOptionsDto::default()
})),
..ConfigInput::default()
});
if let Some(counts) = previous {
tokio::fs::write(&input.url, document("previous", counts)).await.unwrap();
let (groups, errors) =
tuliprox_iptv::m3u::download_m3u_playlist(&ctx.config, &ctx.client, &ctx.config.config.load(), &input)
.await;
assert!(errors.is_empty());
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, groups).await;
assert!(error.is_none(), "{error:?}");
}
(ctx, input)
}
async fn run(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &Arc<ConfigInput>,
counts: [usize; 3],
) -> InputDownloadResult {
tokio::fs::write(&input.url, document("candidate", counts)).await.unwrap();
download_input(ctx, input, false).await
}
fn counts(groups: &[PlaylistGroup]) -> [usize; 3] {
XTREAM_CLUSTER
.map(|cluster| groups.iter().filter(|g| g.xtream_cluster == cluster).map(|g| g.channels.len()).sum())
}
async fn verify_effective(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &ConfigInput,
result: &mut InputDownloadResult,
expected: [usize; 3],
) {
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert!(result.storage_error.is_none(), "{:?}", result.storage_error);
assert_eq!(result.job_state(), InputJobState::Ready);
let effective = result.source.take_groups();
assert_eq!(counts(&effective), expected);
let storage = tuliprox_repository::get_input_storage_path(&input.name, &ctx.config.config.load().storage_dir)
.await
.unwrap();
let saved = load_input_m3u_playlist(
&ctx.config,
&tuliprox_repository::get_input_m3u_playlist_file_path(&storage, &input.name),
)
.await
.unwrap();
assert_eq!(counts(&saved), expected);
for cluster in XTREAM_CLUSTER {
let catalog: RawInputGroupCatalog =
serde_json::from_slice(&tokio::fs::read(raw_group_catalog_path(&storage, cluster)).await.unwrap())
.unwrap();
let mut expected_groups: Vec<_> =
effective.iter().filter(|g| g.xtream_cluster == cluster).map(|g| g.title.to_string()).collect();
expected_groups.sort();
expected_groups.dedup();
assert_eq!(catalog.groups, expected_groups);
}
}
#[tokio::test]
async fn m3u_update_quality_all_clusters_accepted() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [9, 11, 10]).await;
assert!(result.quality_rejections.is_empty());
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
verify_effective(&ctx, &input, &mut result, [9, 11, 10]).await;
}
async fn order_candidate(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &ConfigInput,
prefix: &str,
) -> tuliprox_iptv::provider::PlaylistFetch {
let mut body = String::from("#EXTM3U\n");
for (id, kind, group) in [
(2000, "movie", "obsolete"),
(2000, "movie", "movies-a"),
(1000, "live", "live-a"),
(2001, "movie", "movies-b"),
(1001, "live", "live-b"),
(3000, "series", "shows-a"),
(3001, "series", "shows-b"),
] {
writeln!(body, "#EXTGRP:{prefix}-{group}\n#EXTINF:-1 tvg-type=\"{kind}\" group-title=\"{prefix}-{group}\",Item {id} S01E01\nhttp://stream.example/{kind}/{id}").unwrap();
}
tokio::fs::write(&input.url, body).await.unwrap();
let (groups, errors) =
tuliprox_iptv::m3u::download_m3u_playlist(&ctx.config, &ctx.client, &ctx.config.config.load(), input).await;
assert!(errors.is_empty());
assert_eq!(
group_names(&groups),
["obsolete", "movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"]
.map(|name| format!("{prefix}-{name}"))
);
tuliprox_iptv::provider::PlaylistFetch::groups(groups)
}
fn group_names(groups: &[PlaylistGroup]) -> Vec<String> {
groups.iter().map(|group| group.title.to_string()).collect()
}
#[tokio::test]
async fn m3u_update_quality_preserves_candidate_group_order_when_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [0, 0, 0]).await;
let candidate = order_candidate(&ctx, &input, "candidate").await;
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty());
assert!(effective.quality_acceptances.is_empty() && effective.quality_rejections.is_empty());
assert_eq!(
group_names(&effective.groups),
["movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"]
.map(|name| format!("candidate-{name}"))
);
assert_eq!(counts(&effective.groups), [2, 2, 2], "disabled quality processing must collapse duplicate keys");
}
#[tokio::test]
async fn m3u_update_quality_preserves_candidate_group_order_when_all_clusters_accepted() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([2, 2, 2]), [100, 100, 100]).await;
let candidate = order_candidate(&ctx, &input, "candidate").await;
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty() && effective.quality_rejections.is_empty());
assert_eq!(effective.quality_acceptances.len(), 3);
assert_eq!(
group_names(&effective.groups),
["movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"].map(|name| format!("candidate-{name}"))
);
}
#[tokio::test]
async fn m3u_update_quality_mixed_fallback_preserves_relative_group_order() {
for _ in 0..3 {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [100, 100, 100]).await;
let mut previous = order_candidate(&ctx, &input, "previous").await.groups;
previous.retain(|group| group.title.as_ref() != "previous-obsolete");
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, previous).await;
assert!(error.is_none());
let mut previous = load_input_playlist(&ctx.config, &input, None).await.unwrap();
let previous = previous.take_groups();
assert_eq!(
previous
.iter()
.filter(|group| group.xtream_cluster == XtreamCluster::Series)
.map(|group| group.title.to_string())
.collect::<Vec<_>>(),
["previous-shows-a", "previous-shows-b"]
);
let mut candidate = order_candidate(&ctx, &input, "candidate").await;
candidate.groups.retain(|group| group.title.as_ref() != "candidate-shows-b");
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty());
assert_eq!(effective.quality_rejections.len(), 1);
assert_eq!(effective.quality_rejections[0].cluster, XtreamCluster::Series);
assert_eq!(
group_names(&effective.groups),
[
"candidate-movies-a",
"candidate-live-a",
"candidate-movies-b",
"candidate-live-b",
"previous-shows-a",
"previous-shows-b"
]
);
}
}
#[tokio::test]
async fn m3u_update_quality_retains_only_rejected_cluster() {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
let mut result = run(&ctx, &input, [9, 11, 8]).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Partial);
assert_eq!(result.quality_rejections.len(), 1);
assert_eq!(result.quality_rejections[0].cluster, XtreamCluster::Series);
verify_effective(&ctx, &input, &mut result, [9, 11, 10]).await;
}
}
#[tokio::test]
async fn m3u_update_quality_zero_candidate_retains_previous_cluster() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [10, 10, 0]).await;
assert_eq!(result.quality_rejections[0].candidate_count, 0);
verify_effective(&ctx, &input, &mut result, [10, 10, 10]).await;
}
#[tokio::test]
async fn m3u_update_quality_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [0, 0, 0]).await;
let mut result = run(&ctx, &input, [1, 1, 0]).await;
assert!(result.quality_rejections.is_empty());
assert!(result
.input_telemetry
.as_ref()
.unwrap()
.clusters
.iter()
.all(|c| c.quality.is_none() && c.threshold.is_none()));
verify_effective(&ctx, &input, &mut result, [1, 1, 0]).await;
}
#[tokio::test]
async fn m3u_update_quality_first_import() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [90, 90, 90]).await;
let mut result = run(&ctx, &input, [1, 1, 1]).await;
assert!(result.quality_rejections.is_empty());
assert!(result.input_telemetry.as_ref().unwrap().clusters.iter().all(|c| c.quality.is_none()
&& c.baseline_count.is_none()
&& c.decision == Some(PlaylistUpdateClusterDecision::Accepted)));
verify_effective(&ctx, &input, &mut result, [1, 1, 1]).await;
}
#[tokio::test]
async fn m3u_update_quality_download_or_parse_failure() {
for body in [None, Some("<html>provider unavailable</html>"), Some("#EXTM3U\n#EXTINF:-1,Truncated\n")] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
if let Some(body) = body {
tokio::fs::write(&input.url, body).await.unwrap();
} else {
tokio::fs::remove_file(&input.url).await.unwrap();
}
let mut result = download_input(&ctx, &input, false).await;
assert!(!result.errors.is_empty());
assert!(result.quality_rejections.is_empty());
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [10, 10, 10]);
}
}
#[tokio::test]
async fn m3u_update_quality_empty_bootstrap_and_structural_empty_use_existing_domain() {
for previous in [None, Some([10, 10, 0])] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), previous, [90, 90, 90]).await;
let mut result = run(&ctx, &input, if previous.is_some() { [10, 10, 0] } else { [0, 0, 0] }).await;
if previous.is_none() {
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert_eq!(result.quality_rejections.len(), 3);
} else {
assert_eq!(result.update_state(), PlaylistUpdateState::Partial);
assert_eq!(result.quality_rejections.len(), 1);
assert_eq!(result.quality_rejections[0].current_count, 0);
assert_eq!(result.quality_rejections[0].cluster, XtreamCluster::Series);
}
}
}
#[tokio::test]
async fn m3u_update_quality_skip_preserves_previous_and_reuse_emits_no_new_facts() {
let temp = tempfile::tempdir().unwrap();
let (ctx, mut input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
Arc::make_mut(&mut input).options.as_mut().unwrap().flags.set(ConfigInputFlags::SkipSeries);
let mut result = run(&ctx, &input, [10, 10, 0]).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.quality_rejections.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&result.source.take_groups()), [10, 10, 0]);
assert_eq!(counts(&saved.take_groups()), [10, 10, 10]);
let telemetry = result.input_telemetry.unwrap();
assert!(!telemetry.clusters.iter().find(|c| c.cluster == XtreamCluster::Series).unwrap().requested);
tokio::fs::remove_file(&input.url).await.unwrap();
let reused = download_input(&ctx, &input, false).await;
assert!(reused.errors.is_empty());
assert!(reused.input_telemetry.is_none());
}
#[tokio::test]
async fn m3u_update_quality_force_bypasses_guard_and_cache_without_inventing_quality() {
for threshold in [0, 95] {
for policy in [InputRefreshPolicy::REFRESH, InputRefreshPolicy::FORCE] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, mut input) = fixture(temp.path(), Some([10, 10, 10]), [threshold; 3]).await;
Arc::make_mut(&mut input).cache_duration_seconds = 3_600;
let storage = input_storage_path(&ctx, &input).await;
let mut status = input_cache::InputStatus::default();
input_cache::update_cluster_status(&mut status, "default", input_cache::ClusterState::Ok);
input_cache::save_input_status(&storage, &status);
set_refresh_policy(&mut ctx, &input, policy);
let mut result = run(&ctx, &input, [1, 1, 1]).await;
let rejected = threshold > 0 && policy == InputRefreshPolicy::REFRESH;
assert_eq!(
result.update_state(),
if rejected { PlaylistUpdateState::Partial } else { PlaylistUpdateState::Success }
);
assert_eq!(counts(&result.source.take_groups()), if rejected { [10; 3] } else { [1; 3] });
let telemetry = result.input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.refresh_policy, policy);
assert!(telemetry
.clusters
.iter()
.all(|cluster| cluster.source == Some(PlaylistUpdateDataSource::Provider)));
let saved = input_cache::load_input_status(&storage);
for cluster in &telemetry.clusters {
if policy == InputRefreshPolicy::FORCE {
assert_eq!(cluster.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((cluster.baseline_count, cluster.quality), (None, None));
assert_eq!((cluster.candidate_count, cluster.active_count), (Some(1), Some(1)));
let snapshot = saved.clusters[cluster.cluster.as_ref()].last_update.unwrap();
assert_eq!(snapshot.quality, None);
assert_eq!(snapshot.quality_guard_threshold, Some(threshold));
assert_eq!(snapshot.policy, Some(policy));
}
}
let reused = download_input(&ctx, &input, false).await;
assert!(reused.input_telemetry.is_none(), "in-run reuse must not create another Force acquisition");
}
}
}
#[tokio::test]
async fn m3u_update_quality_force_keeps_parse_and_empty_publication_errors() {
for body in ["#EXTM3U\n", "#EXTM3U\n#EXTINF:-1,Truncated\n"] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, input) = fixture(temp.path(), Some([10; 3]), [95; 3]).await;
set_refresh_policy(&mut ctx, &input, InputRefreshPolicy::FORCE);
tokio::fs::write(&input.url, body).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(result.quality_rejections.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [10; 3]);
}
}
#[tokio::test]
async fn m3u_update_quality_target_processing_uses_retained_and_accepted_groups() {
for policy in [InputRefreshPolicy::NORMAL, InputRefreshPolicy::FORCE] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
set_refresh_policy(&mut ctx, &input, policy);
tokio::fs::write(&input.url, document("candidate", [10, 0, 10])).await.unwrap();
let output = temp.path().join("client.m3u");
let mut target = ConfigTarget::from(&ConfigTargetDto {
id: 1,
name: "m3u-target".into(),
enabled: true,
..ConfigTargetDto::default()
});
target.filter = get_filter(r#"Name ~ ".*""#, None).unwrap().into();
target.output = vec![TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
filename: Some(output.to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![input.clone()],
sources: vec![ConfigSource { inputs: vec![input.name.clone()], targets: vec![Arc::new(target)] }],
..SourcesConfig::default()
}));
let (_, targets, errors) = process_source(0, Arc::new(ctx)).await;
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(targets.len(), 1);
let published = tokio::fs::read_to_string(output).await.unwrap();
assert!(published.contains("/candidate/live/") && published.contains("/candidate/series/"), "{published}");
assert_eq!(published.contains("/previous/video/"), policy == InputRefreshPolicy::NORMAL, "{published}");
assert!(!published.contains("/previous/live/") && !published.contains("/candidate/video/"));
}
}
#[tokio::test]
async fn m3u_update_quality_raw_group_catalog_uses_effective_state() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [10, 10, 0]).await;
let storage = input_cache::resolve_input_storage_path(&ctx.config.config.load().storage_dir, &input.name).await;
let status = input_cache::load_input_status(&storage);
for cluster in XTREAM_CLUSTER {
let saved = &status.clusters[cluster.as_ref()];
let rejected = cluster == XtreamCluster::Series;
assert_eq!(
saved.status,
if rejected { input_cache::ClusterState::Failed } else { input_cache::ClusterState::Ok }
);
let snapshot = saved.last_update.unwrap();
assert_eq!(
snapshot.quality.unwrap().decision,
if rejected {
PersistedPlaylistUpdateQualityDecision::Rejected
} else {
PersistedPlaylistUpdateQualityDecision::Accepted
}
);
assert_eq!(snapshot.active_count, Some(10));
}
assert_eq!(status.clusters["default"].status, input_cache::ClusterState::Failed);
let series: RawInputGroupCatalog = serde_json::from_slice(
&tokio::fs::read(raw_group_catalog_path(&storage, XtreamCluster::Series)).await.unwrap(),
)
.unwrap();
assert_eq!(series.groups, ["previous-series"]);
verify_effective(&ctx, &input, &mut result, [10, 10, 10]).await;
}
#[tokio::test]
async fn m3u_update_quality_duplicate_keys_match_the_existing_persisted_population() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([1, 1, 0]), [100, 100, 0]).await;
let candidate = document("superseded", [1, 1, 0]) + &document("last", [1, 1, 0]);
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.source.take_groups().iter().all(|group| group.title.starts_with("last")));
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
}
#[tokio::test]
async fn m3u_update_quality_duplicate_keys_match_the_existing_persisted_population_when_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [0, 0, 0]).await;
let candidate = document("superseded", [1, 1, 0]) + &document("last", [1, 1, 0]);
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.source.take_groups().iter().all(|group| group.title.starts_with("last")));
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
}
#[tokio::test]
async fn m3u_update_quality_conflicting_retained_key_cannot_diverge_from_storage() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([1, 1, 0]), [90, 0, 0]).await;
let candidate = document("candidate", [0, 1, 0]).replace("2000", "1000");
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(!result.errors.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
let storage = input_storage_path(&ctx, &input).await;
let catalog: RawInputGroupCatalog = serde_json::from_slice(
&tokio::fs::read(raw_group_catalog_path(&storage, XtreamCluster::Live)).await.unwrap(),
)
.unwrap();
assert_eq!(catalog.groups, ["previous-live"]);
}
}
@@ -1,725 +0,0 @@
mod manual_update_integration {
use super::*;
use shared::model::{ConfigRenameDto, ItemField, LibraryConfigDto, LibraryContentType, LibraryScanResult};
use tuliprox_core::model::{LibraryConfig, LibraryScanDirectory, M3uTargetOutput, MetadataUpdateConfig};
async fn library_context(root: &Path, order: ProcessingOrder) -> PlaylistProcessingContext<CollectSink> {
let ctx = processing_context_with_events(root, CollectSink::default());
let media = root.join("media");
tokio::fs::create_dir_all(&media).await.unwrap();
tokio::fs::write(media.join("Library Movie (2025).mp4"), b"fixture").await.unwrap();
let companion_path = root.join("companion.m3u");
tokio::fs::write(&companion_path, "#EXTM3U\n#EXTINF:-1,Companion\nhttp://stream.example/companion\n")
.await
.unwrap();
let mut config = (**ctx.config.config.load()).clone();
let mut library = LibraryConfig::from(&LibraryConfigDto { enabled: true, ..LibraryConfigDto::default() });
library.metadata.fallback_to_filename = true;
library.thumbnails.enabled = false;
library.scan_directories = vec![LibraryScanDirectory {
enabled: true,
path: media.to_string_lossy().into_owned(),
content_type: LibraryContentType::Movie,
recursive: true,
}];
config.library = Some(library);
let mut metadata = MetadataUpdateConfig::default();
metadata.ffprobe.enabled = false;
metadata.tmdb.enabled = false;
config.metadata_update = Some(metadata);
ctx.config.config.store(Arc::new(config));
let library_input = Arc::new(ConfigInput {
id: 2,
name: "local".intern(),
input_type: InputType::Library,
enabled: true,
cache_duration_seconds: 3600,
..ConfigInput::default()
});
let companion = Arc::new(ConfigInput {
id: 3,
name: "companion".intern(),
input_type: InputType::M3u,
enabled: true,
url: companion_path.to_string_lossy().into_owned(),
..ConfigInput::default()
});
let mut target = ConfigTarget::from(&ConfigTargetDto {
id: 20,
enabled: true,
name: "selected".into(),
processing_order: order,
rename: Some(vec![ConfigRenameDto {
field: ItemField::Name,
pattern: "^MAPPED$".into(),
new_name: "RENAMED".into(),
t_pattern: None,
}]),
..ConfigTargetDto::default()
});
target.output = vec![TargetOutput::M3u(M3uTargetOutput {
filename: Some(root.join("selected.m3u").to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
target.filter = get_filter(r#"Name ~ ".*""#, None).unwrap().into();
let mapped = super::super::mapping_stage::build_target(
vec![super::super::mapping_stage::build_mapping("manual", MappingStage::Processing, r#"@Name = "MAPPED""#)],
false,
);
target.mapping = mapped.mapping;
let mut unselected = target.clone();
unselected.id = 21;
unselected.name = "unselected".into();
unselected.output = vec![TargetOutput::M3u(M3uTargetOutput {
filename: Some(root.join("unselected.m3u").to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![library_input.clone(), companion.clone()],
sources: vec![ConfigSource {
inputs: vec![library_input.name.clone(), companion.name.clone()],
targets: vec![Arc::new(target), Arc::new(unselected)],
}],
..SourcesConfig::default()
}));
ctx
}
fn rescan_run(ctx: &PlaylistProcessingContext<CollectSink>, guard: UpdateGuard) -> ProcessingRun<CollectSink> {
ProcessingRun::for_run(
"manual-library-run".into(),
ctx.client.clone(),
ctx.config.clone(),
Arc::new(ProcessTargets {
enabled: true,
inputs: vec![2, 3],
targets: vec![20],
target_names: vec!["selected".into()],
}),
ctx.events.clone(),
)
.with_update_guard(guard)
.with_manual_input_update(Some(InputUpdateRequest { input_id: 2, action: InputUpdateAction::Rescan }))
}
fn scan_results(ctx: &PlaylistProcessingContext<CollectSink>) -> Vec<LibraryScanResult> {
ctx.events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(2) => {
if progress.library_scan_result.is_some() {
assert_eq!(
progress.run_id.as_ref().map(PlaylistUpdateRunId::as_ref),
Some("manual-library-run")
);
assert!(progress.execution_order.is_some());
}
progress.library_scan_result.clone()
}
_ => None,
})
.collect()
}
fn assert_run_state(ctx: &PlaylistProcessingContext<CollectSink>, state: PlaylistUpdateState) {
let events = ctx.events.0.lock().unwrap();
assert!(
events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == state)),
"{events:?}"
);
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(2) && progress.state == Some(state))), "{events:?}");
}
#[tokio::test]
async fn manual_update_library_empty_rescan_persists_zero_to_zero_and_nonzero_to_zero_with_companion() {
for disk in [false, true] {
for previous_population in [false, true] {
for companion in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
if !companion {
let mut sources = (**ctx.config.sources.load()).clone();
sources.sources[0].inputs.retain(|name| name.as_ref() == "local");
ctx.config.sources.store(Arc::new(sources));
}
if previous_population {
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
ctx.events.0.lock().unwrap().clear();
}
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
for name in std::iter::once("local").chain(companion.then_some("companion")) {
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), name).await;
let status = input_cache::load_input_status(&path);
assert_eq!(status.last_input_update.unwrap().state, PlaylistUpdateState::Success, "{name}");
}
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 0,
groups_scanned: 0,
files_added: 0,
files_updated: 0,
files_removed: usize::from(previous_population),
errors: 0,
}]
);
let output = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(output.matches("#EXTINF").count(), usize::from(companion), "{output}");
assert_eq!(output.contains("http://stream.example/companion"), companion);
assert!(!output.contains("Library%20Movie") && !output.contains("file://"));
assert!(!temp.path().join("unselected.m3u").exists());
let input = ctx.config.sources.load().inputs[0].clone();
let mut persisted = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert!(persisted.is_empty(), "Empty Library must replace its old persisted input");
assert!(!ctx.events.0.lock().unwrap().iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.message.contains("playlist is empty"))));
// Same canonical empty input remains readable on subsequent cache/reuse paths.
let first = download_input(&ctx, &input, false).await;
assert!(first.errors.is_empty() && first.storage_error.is_none());
let mut reuse = download_input(&ctx, &input, false).await;
assert_eq!(reuse.job_state(), InputJobState::Ready);
assert_eq!(reuse.input_telemetry, None, "In-run reuse must not emit another acquisition");
assert_eq!(scan_results(&ctx).len(), 1, "Reuse must not manufacture a second rescan");
}
}
}
}
#[tokio::test]
async fn coderabbit_library_unavailable_is_not_an_authoritative_empty_catalog() {
for disabled in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let mut input = (*ctx.config.sources.load().inputs[0]).clone();
input.cache_duration_seconds = 0;
let input = Arc::new(input);
let storage = input_storage_path(&ctx, &input).await;
let file = tuliprox_repository::get_input_local_library_playlist_file_path(&storage, &input.name);
let before = tokio::fs::read(&file).await.unwrap();
let output = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
let mut config = (**ctx.config.config.load()).clone();
if disabled {
config.library.as_mut().unwrap().enabled = false;
} else {
config.library = None;
}
ctx.config.config.store(Arc::new(config));
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.job_state(), InputJobState::Failed);
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(!result.authoritative_library);
assert!(!result.errors.is_empty());
assert_eq!(tokio::fs::read(&file).await.unwrap(), before);
assert_eq!(tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap(), output);
}
}
#[tokio::test]
async fn coderabbit_library_missing_input_triggers_catalog_recovery_not_empty_success() {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let input = ctx.config.sources.load().inputs[0].clone();
let storage = input_storage_path(&ctx, &input).await;
let file = tuliprox_repository::get_input_local_library_playlist_file_path(&storage, &input.name);
tokio::fs::remove_file(&file).await.unwrap();
assert!(load_input_playlist(&ctx.config, &input, None).await.is_err());
let mut recovered = download_input(&ctx, &input, false).await;
assert_eq!(recovered.job_state(), InputJobState::Ready);
assert!(recovered.errors.is_empty() && recovered.storage_error.is_none());
assert!(!recovered.source.is_empty(), "missing cache must be repaired from the populated catalog");
assert!(file.exists());
let mut persisted = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert!(!persisted.is_empty());
let media_server = ConfigInput { input_type: InputType::Plex, ..(*input).clone() };
assert!(
load_input_playlist(&ctx.config, &media_server, None).await.is_err(),
"the shared loader must not turn a missing Plex cache into a successful empty input"
);
}
}
#[tokio::test]
async fn manual_update_library_scan_errors_keep_real_result_and_block_rebuild() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.library.as_mut().unwrap().metadata.fallback_to_filename = false;
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Failure);
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
assert_eq!(
input_cache::load_input_status(&path).last_input_update.unwrap().state,
PlaylistUpdateState::Failure
);
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 1,
groups_scanned: 1,
files_added: 0,
files_updated: 0,
files_removed: 0,
errors: 1,
}]
);
assert!(!temp.path().join("selected.m3u").exists());
}
#[tokio::test]
async fn manual_update_library_persisted_input_status_keeps_failed_companion_separate() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "companion").await;
assert_eq!(
input_cache::load_input_status(&path).last_input_update.unwrap().state,
PlaylistUpdateState::Success
);
let before = std::fs::read(temp.path().join("selected.m3u")).unwrap();
tokio::fs::write(temp.path().join("companion.m3u"), "#EXTM3U\n").await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_eq!(
input_cache::load_input_status(&path).last_input_update.unwrap().state,
PlaylistUpdateState::Failure
);
let library_path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
assert_eq!(
input_cache::load_input_status(&library_path).last_input_update.unwrap().state,
PlaylistUpdateState::Success
);
assert_eq!(std::fs::read(temp.path().join("selected.m3u")).unwrap(), before);
}
#[tokio::test]
async fn manual_update_library_empty_xtream_and_strm_use_existing_publish_and_keep_companions() {
use tuliprox_repository::{xtream_get_file_path, xtream_get_storage_path, BPlusTreeQuery};
for companion in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
tokio::fs::write(
temp.path().join("companion.m3u"),
"#EXTM3U\n#EXTINF:-1 type=\"vod\",Companion\nhttp://stream.example/companion.mp4\n",
)
.await
.unwrap();
let mut sources = (**ctx.config.sources.load()).clone();
if !companion {
sources.sources[0].inputs.retain(|name| name.as_ref() == "local");
}
let target = Arc::make_mut(&mut sources.sources[0].targets[0]);
target.output.extend([
TargetOutput::Xtream(XtreamTargetOutput {
flags: XtreamTargetFlagsSet::new(),
trakt: None,
filter: None,
}),
TargetOutput::Strm(tuliprox_core::model::StrmTargetOutput::from(&shared::model::StrmTargetOutputDto {
directory: temp.path().join("strm").to_string_lossy().into_owned(),
flat: true,
..shared::model::StrmTargetOutputDto::default()
})),
]);
ctx.config.sources.store(Arc::new(sources));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let config = ctx.config.config.load();
let xtream = xtream_get_storage_path(&config, "selected").unwrap();
for (cluster, expected) in
[(XtreamCluster::Live, 0), (XtreamCluster::Video, usize::from(companion)), (XtreamCluster::Series, 0)]
{
let path = xtream_get_file_path(&xtream, cluster);
if !path.exists() {
assert_eq!(expected, 0);
continue;
}
let mut query = BPlusTreeQuery::<u32, shared::model::XtreamPlaylistItem>::try_new(&path).unwrap();
assert_eq!(query.iter().count(), expected, "{cluster:?}");
}
let entries = strm_artifacts(&temp.path().join("strm")).await;
assert_eq!(entries.len(), usize::from(companion), "{entries:?}");
if companion {
assert!(entries[0].1.contains("http://stream.example/companion"));
}
}
}
#[tokio::test]
async fn manual_update_library_empty_permission_does_not_loosen_other_provider_inputs() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
for input_type in [InputType::M3u, InputType::Xtream, InputType::Stalker, InputType::Plex] {
let input =
ConfigInput { input_type, name: format!("empty-{input_type:?}").intern(), ..ConfigInput::default() };
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, Vec::new()).await;
assert!(error.is_some(), "{input_type:?} must retain its generic empty-input guard");
}
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let before = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
ctx.events.0.lock().unwrap().clear();
tokio::fs::write(temp.path().join("companion.m3u"), "#EXTM3U\n").await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
{
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == PlaylistUpdateState::Failure)));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(3) && progress.state == Some(PlaylistUpdateState::Failure))));
}
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
// An authoritatively empty Library must not mask a failed companion either.
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
{
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == PlaylistUpdateState::Failure)));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(3) && progress.state == Some(PlaylistUpdateState::Failure))));
}
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
}
#[derive(Clone, Copy, Debug)]
enum LibraryFilterBoundary {
Processing,
Persist,
M3u,
Strm,
}
async fn strm_artifacts(root: &Path) -> Vec<(PathBuf, String)> {
let mut directories = vec![root.to_path_buf()];
let mut artifacts = Vec::new();
while let Some(directory) = directories.pop() {
let mut entries = tokio::fs::read_dir(directory).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
if entry.file_type().await.unwrap().is_dir() {
directories.push(entry.path());
} else if entry.path().extension().is_some_and(|extension| extension == "strm") {
artifacts.push((entry.path(), tokio::fs::read_to_string(entry.path()).await.unwrap()));
}
}
}
artifacts.sort();
artifacts
}
async fn assert_library_filtered_empty_publication(boundary: LibraryFilterBoundary) {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
tokio::fs::write(
temp.path().join("companion.m3u"),
"#EXTM3U\n#EXTINF:-1 type=\"vod\",Companion\nhttp://stream.example/companion.mp4\n",
)
.await
.unwrap();
let mut sources = (**ctx.config.sources.load()).clone();
let target = Arc::make_mut(&mut sources.sources[0].targets[0]);
target.rename = None;
target.mapping = Arc::default();
let library_only = get_filter(r#"Name ~ "^Library Movie.*""#, None).unwrap();
if matches!(boundary, LibraryFilterBoundary::Processing) {
target.filter = library_only.clone().into();
}
if matches!(boundary, LibraryFilterBoundary::Persist) {
target.filter.persist = Some(library_only.clone());
}
if let TargetOutput::M3u(output) = &mut target.output[0] {
output.filter = matches!(boundary, LibraryFilterBoundary::M3u).then(|| library_only.clone());
}
let strm_root = temp.path().join("strm");
let mut strm = tuliprox_core::model::StrmTargetOutput::from(&shared::model::StrmTargetOutputDto {
directory: strm_root.to_string_lossy().into_owned(),
flat: true,
cleanup: true,
..shared::model::StrmTargetOutputDto::default()
});
strm.filter = matches!(boundary, LibraryFilterBoundary::Strm).then_some(library_only);
target.output.push(TargetOutput::Strm(strm));
ctx.config.sources.store(Arc::new(sources));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let m3u_path = temp.path().join("selected.m3u");
let before_m3u = tokio::fs::read_to_string(&m3u_path).await.unwrap();
let before_strm = strm_artifacts(&strm_root).await;
let m3u_keeps_companion = matches!(boundary, LibraryFilterBoundary::Strm);
let strm_keeps_companion = matches!(boundary, LibraryFilterBoundary::M3u);
assert_eq!(before_m3u.matches("#EXTINF").count(), 1 + usize::from(m3u_keeps_companion));
assert_eq!(before_strm.len(), 1 + usize::from(strm_keeps_companion));
assert_eq!(before_m3u.contains("http://stream.example/companion"), m3u_keeps_companion);
assert_eq!(
before_strm.iter().any(|(_, body)| body.contains("http://stream.example/companion")),
strm_keeps_companion
);
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let after_m3u = tokio::fs::read_to_string(&m3u_path).await.unwrap();
let after_strm = strm_artifacts(&strm_root).await;
println!("Library filter {boundary:?}, disk={disk}: M3U before={before_m3u:?}, after={after_m3u:?}; STRM before={before_strm:?}, after={after_strm:?}");
assert_run_state(&ctx, PlaylistUpdateState::Success);
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 0,
groups_scanned: 0,
files_added: 0,
files_updated: 0,
files_removed: 1,
errors: 0
}]
);
assert_ne!(before_m3u, after_m3u, "old Library entries must really be replaced");
assert_eq!(after_m3u.matches("#EXTINF").count(), usize::from(m3u_keeps_companion));
assert_eq!(after_m3u.contains("http://stream.example/companion"), m3u_keeps_companion);
assert_eq!(after_strm.len(), usize::from(strm_keeps_companion));
for (path, contents) in &before_strm {
if contents.contains("http://stream.example/companion") {
assert!(
after_strm.contains(&(path.clone(), contents.clone())),
"valid companion must be retained byte-for-byte"
);
} else {
assert!(!path.exists(), "cleanup must remove old Library artifact {path:?}");
}
}
// The existing M3U database is refreshed too, not only the client text file.
let target_path =
tuliprox_repository::get_target_storage_path(&ctx.config.config.load(), "selected").unwrap();
let mut db = tuliprox_repository::BPlusTreeQuery::<u32, shared::model::M3uPlaylistItem>::try_new(
&tuliprox_repository::m3u_get_file_path_for_db(&target_path),
)
.unwrap();
assert_eq!(db.iter().count(), usize::from(m3u_keeps_companion));
assert!(!temp.path().join("unselected.m3u").exists());
}
}
#[tokio::test]
async fn manual_update_library_processing_filter_replaces_old_output_after_last_movie_removed() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Processing).await;
}
#[tokio::test]
async fn manual_update_library_persist_filter_replaces_old_output_after_last_movie_removed() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Persist).await;
}
#[tokio::test]
async fn manual_update_library_m3u_output_filter_replaces_old_output_and_preserves_unfiltered_strm() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::M3u).await;
}
#[tokio::test]
async fn manual_update_library_strm_output_filter_cleans_old_files_and_preserves_unfiltered_m3u() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Strm).await;
}
#[tokio::test]
async fn manual_update_library_empty_does_not_authorize_foreign_forced_empty_target() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let sources = ctx.config.sources.load_full();
let mut playlists = sources
.inputs
.iter()
.map(|input| FetchedPlaylist { input, source: MemoryPlaylistSource::default().into_source(), epg: None })
.collect::<Vec<_>>();
let prepared = prepare_playlist_for_target(
&ctx,
&mut playlists,
&sources.sources[0].targets[0],
&mut HashMap::new(),
&mut Vec::new(),
ClusterFlags::Vod,
false,
)
.await
.unwrap();
assert_eq!(prepared.library_empty, tuliprox_repository::LibraryEmptyPublication::Contribution);
let (result, _) = finalize_prepared_target(Arc::new(ctx), prepared).await;
assert!(result.is_err(), "Library must not relax the existing non-Xtream force-empty output guard");
assert!(!temp.path().join("selected.m3u").exists());
}
#[tokio::test]
async fn manual_update_library_incomplete_discovery_has_no_invented_scan_result_or_rebuild() {
for failure in ["missing", "not-directory", "partial", "no-directories", "corrupt-catalog"] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let before = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
let status_path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
let cache_before = input_cache::load_input_status(&status_path).clusters;
ctx.events.0.lock().unwrap().clear();
let mut config = (**ctx.config.config.load()).clone();
let library = config.library.as_mut().unwrap();
match failure {
"missing" => {
library.scan_directories[0].path = temp.path().join("missing").to_string_lossy().into_owned();
}
"not-directory" => {
library.scan_directories[0].path =
temp.path().join("media/Library Movie (2025).mp4").to_string_lossy().into_owned();
}
"partial" => {
let mut missing = library.scan_directories[0].clone();
missing.path = temp.path().join("missing").to_string_lossy().into_owned();
library.scan_directories.push(missing);
}
"no-directories" => library.scan_directories.clear(),
"corrupt-catalog" => {
let path = tuliprox_library::library::resolve_metadata_storage_path(
config.metadata_update.as_ref(),
&config.storage_dir,
)
.join("library/broken.json");
tokio::fs::write(path, "invalid json").await.unwrap();
}
_ => unreachable!(),
}
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Failure);
assert!(scan_results(&ctx).is_empty(), "{failure}");
let status = input_cache::load_input_status(&status_path);
assert_eq!(status.last_input_update.unwrap().state, PlaylistUpdateState::Failure, "{failure}");
assert_eq!(status.clusters, cache_before, "failed rescan must not mutate cache validity");
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
}
}
#[tokio::test]
async fn manual_update_library_selected_target_keeps_companion_and_real_processing_order() {
for (order, expected) in [(ProcessingOrder::Frm, "MAPPED"), (ProcessingOrder::Fmr, "RENAMED")] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), order).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let output = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(output.matches("#EXTINF").count(), 2, "{output}");
assert!(output.contains("http://stream.example/companion"), "{output}");
assert!(output.contains(expected), "{output}");
assert!(!temp.path().join("unselected.m3u").exists());
let events = ctx.events.0.lock().unwrap();
let scan = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.message.starts_with("Library rescan completed"))
})
.unwrap();
let acquired = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.state == Some(PlaylistUpdateState::Success))
})
.unwrap();
let published = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Success)
})
.unwrap();
assert!(scan < acquired && acquired < published);
assert!(events.iter().all(|event| !matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.input_telemetry.as_ref().is_some_and(|telemetry|
!telemetry.clusters.is_empty() || telemetry.refresh_policy != InputRefreshPolicy::NORMAL))));
}
}
#[tokio::test]
async fn manual_update_library_unreadable_catalog_and_busy_scan_do_not_publish() {
for busy in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let guard = UpdateGuard::new();
let _busy = busy.then(|| guard.try_library().unwrap());
if !busy {
let mut config = (**ctx.config.config.load()).clone();
config.library.as_mut().unwrap().scan_directories[0].path =
temp.path().join("missing").to_string_lossy().into_owned();
ctx.config.config.store(Arc::new(config));
}
exec_processing(rescan_run(&ctx, guard.clone())).await;
assert!(!temp.path().join("selected.m3u").exists());
assert!(!temp.path().join("unselected.m3u").exists());
assert!(ctx
.events
.0
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Failure)));
assert!(guard.try_playlist().is_some());
}
}
#[tokio::test]
async fn manual_update_library_input_success_remains_separate_from_target_publish_failure() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
tokio::fs::create_dir(temp.path().join("selected.m3u")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.state == Some(PlaylistUpdateState::Success))));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Failure)));
}
#[tokio::test]
async fn manual_update_library_rescan_reloads_valid_cache_but_bulk_keeps_catalog() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
tokio::fs::write(temp.path().join("media/Second Movie (2025).mp4"), b"fixture").await.unwrap();
// Ordinary bulk does not discover the new file and does not acquire a library permit.
let guard = UpdateGuard::new();
let standalone_scan = guard.try_library().unwrap();
exec_processing(
ProcessingRun::new(ctx.client.clone(), ctx.config.clone(), ctx.user_targets.clone(), ctx.events.clone())
.with_update_guard(guard.clone()),
)
.await;
let bulk = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(bulk.matches("#EXTINF").count(), 2);
drop(standalone_scan);
exec_processing(rescan_run(&ctx, guard)).await;
let rescanned = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(rescanned.matches("#EXTINF").count(), 3, "{rescanned}");
}
}
@@ -3,7 +3,7 @@ use crate::{
fetched_playlist::FetchedPlaylist,
input_cache,
metadata_sink::{MetadataUpdateSink, NoopMetadataSink},
parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator, TVGuide},
parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator},
playlist_watch::{process_group_watch, process_target_groups_watch},
processor::{
epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups},
@@ -18,22 +18,18 @@ use indexmap::IndexMap;
use log::{debug, error, info, log_enabled, warn, Level};
use path_clean::PathClean;
use shared::{
concat_string,
defaults::{default_as_default, default_probe_delay_secs, default_probe_live_interval},
error::{get_errors_notify_message, TuliproxError},
foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider},
model::{
ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet,
InputRefreshOverride, InputRefreshPolicy, InputStats, InputType, InputUpdateAction, InputUpdateRequest,
MappingStage, PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateQualityDecision,
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistEntry,
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision,
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StagedInputType, StreamProperties, TargetStats,
UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
MappingStage, PipelineStats, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType,
PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, PlaylistUpdateSummary, StreamProperties, TargetStats, UUIDType, WatchDisabled,
WatchDisabledReason, WatchUnmatched, XtreamCluster,
},
utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable},
utils::{create_alias_uuid, interner_gc, Internable},
};
use std::{
collections::{HashMap, HashSet},
@@ -43,35 +39,25 @@ use std::{
time::{Duration, Instant},
};
use tokio::{
sync::{watch, Mutex, OwnedRwLockWriteGuard, RwLock},
sync::{watch, Mutex, RwLock},
task::JoinSet,
};
use tuliprox_core::{
model::{
is_valid, retain_filtered_playlist, AppConfig, ClusterForceUpdate, ClusterUpdateRejection, CompiledMapping,
ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget,
CurationConfig, Epg, FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason,
ReverseProxyDisabledHeaderConfig, TransformStage, UpdateGuard, UpdateTask,
ConfigFavourites, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, CurationConfig, Epg,
FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig,
TransformStage, UpdateGuard, UpdateTask,
},
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback},
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure},
};
use tuliprox_curation::{
evaluate_curation, project_curation_categories, CurationEvaluation, CurationFailure, CurationRunOutcome,
SelectorOutcome,
};
use tuliprox_iptv::{
epg::{CountingEpgSink, EpgFetchRequest, EpgProvider},
error::ProviderErrorKind,
provider::{
BatchContainerProvider, M3uProvider, PlaylistFetch, PlaylistFetchRequest, PlaylistProvider,
UnsupportedProvider, XtreamProvider,
},
xtream,
};
use tuliprox_repository::{
load_input_playlist, persist_input_playlist_with_options, persist_playlist_views, CategoryKey,
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState,
TargetPlaylistPersistOptions,
persist_playlist_views, CategoryKey, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource,
PlaylistStorageState, TargetPlaylistPersistOptions,
};
use tuliprox_session::ActiveProviderManager;
@@ -1,166 +0,0 @@
mod staged_completion {
use super::*;
pub(super) fn input_completions(events: &CollectSink, input_id: u16) -> Vec<PlaylistUpdateProgressEvent> {
events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(input_id) && progress.state.is_some() =>
{
Some(progress.clone())
}
_ => None,
})
.collect()
}
async fn staged_run_context(
root: &Path,
base_url: &str,
) -> (PlaylistProcessingContext<CollectSink>, Arc<ConfigInput>) {
let ctx = processing_context_with_events(root, CollectSink::default());
let parent_file = root.join("parent.m3u");
std::fs::write(&parent_file, "#EXTM3U\n#EXTINF:-1,Parent\nhttp://stream.example/parent\n").unwrap();
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_file.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged = (*test_input(
base_url,
ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 },
0,
&[XtreamCluster::Video, XtreamCluster::Series],
))
.clone();
staged.id = 8;
staged.name = "staged".intern();
staged.input_type = InputType::Staged;
staged.staged_type = shared::model::StagedInputType::Xtream;
staged.staged = Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
});
staged.resolve_staged_download_type();
let staged = Arc::new(staged);
seed_live_baseline(&ctx, &staged, 100).await;
let failed_companion = Arc::new(ConfigInput {
id: 9,
name: "failed-companion".intern(),
input_type: InputType::M3u,
url: root.join("missing.m3u").to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone(), failed_companion.clone()],
sources: vec![ConfigSource {
inputs: vec![parent.name.clone(), failed_companion.name.clone()],
targets: vec![xtream_target(true)],
}],
..SourcesConfig::default()
}));
(ctx, staged)
}
fn normalized_trace(
events: &[EventMessage],
persisted: shared::model::PersistedPlaylistUpdateInputResult,
) -> serde_json::Value {
let summary = events
.iter()
.find_map(|event| match event {
EventMessage::PlaylistUpdate(summary) => Some(summary),
_ => None,
})
.unwrap();
assert_eq!(summary.run_id, Some("staged-completion-run".into()));
assert!(summary.execution_order.is_some());
assert_eq!(summary.state, PlaylistUpdateState::Failure);
let progress: Vec<_> = events
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(8) || progress.state.is_some() =>
{
assert_eq!(progress.run_id, summary.run_id);
assert_eq!(progress.execution_order, summary.execution_order);
let mut progress = progress.clone();
// Only the globally allocated test-run order is normalized. All
// input identities, states, messages and telemetry remain exact.
progress.execution_order = Some(1.into());
Some(progress)
}
_ => None,
})
.collect();
assert!(persisted.timestamp > 0);
serde_json::json!({
"progress": progress,
"completed": {"run_id": summary.run_id, "execution_order": 1, "state": summary.state},
"persisted_input_result": {"state": persisted.state, "timestamp": 1}
})
}
#[tokio::test]
async fn playlist_update_status_staged_reporter_trace_matches_persistence_before_foreign_run_failure() {
let mut traces = Vec::new();
for expected in [PlaylistUpdateState::Partial, PlaylistUpdateState::Success, PlaylistUpdateState::Failure] {
let temp = tempfile::tempdir().unwrap();
let mut responses =
fixture_responses([if expected == PlaylistUpdateState::Success { 100 } else { 1 }, 0, 0]);
if expected == PlaylistUpdateState::Failure {
responses.insert("get_live_streams".into(), "not JSON".into());
}
let server = TestXtreamServer::start_with_responses(responses);
let (ctx, staged) = staged_run_context(temp.path(), &server.base_url).await;
exec_processing(ProcessingRun::for_run(
"staged-completion-run".into(),
ctx.client.clone(),
ctx.config.clone(),
ctx.user_targets.clone(),
ctx.events.clone(),
))
.await;
let path = input_storage_path(&ctx, &staged).await;
let persisted = input_cache::load_input_status(&path);
assert!(!persisted.clusters.contains_key("default"));
assert_eq!(persisted.last_input_update.unwrap().state, expected);
let completions = input_completions(&ctx.events, staged.id);
assert_eq!(completions.len(), 1, "real indirect staged input must emit one correlated completion");
assert_eq!(completions[0].state, Some(expected));
let telemetry = completions[0].input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.refresh_policy, InputRefreshPolicy::NORMAL);
assert_eq!(telemetry.source, None);
assert_eq!(telemetry.clusters[0].source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(telemetry.clusters[0].threshold, Some(95));
if expected == PlaylistUpdateState::Partial {
assert_eq!(telemetry.clusters[0].decision, Some(PlaylistUpdateClusterDecision::Rejected));
assert_eq!(telemetry.clusters[0].active_count, Some(100));
}
let emitted = ctx.events.0.lock().unwrap().clone();
let own_index = emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdateProgress(p) if p.input_id == Some(8) && p.state.is_some())).unwrap();
let parent_index = emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdateProgress(p) if p.input_id == Some(7) && p.state.is_some())).unwrap();
let completed_index =
emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdate(_))).unwrap();
assert!(own_index < parent_index && parent_index < completed_index);
assert_eq!(input_completions(&ctx.events, 9)[0].state, Some(PlaylistUpdateState::Failure));
traces.push(normalized_trace(&emitted, persisted.last_input_update.unwrap()));
assert_eq!(server.finish().iter().filter(|action| action.as_str() == "get_live_streams").count(), 1);
}
let actual = serde_json::Value::Array(traces);
let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../frontend/src/model/test_data/staged_completions.json"
)))
.unwrap();
assert_eq!(actual, fixture, "actual reporter trace: {}", serde_json::to_string_pretty(&actual).unwrap());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,336 @@
use super::*;
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::model::{ConfigPaths, NoopSink};
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use tempfile::tempdir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::JoinHandle,
};
use tuliprox_core::{
model::{ApiProxyConfig, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
pub(super) fn app_config(storage_dir: &Path) -> Arc<AppConfig> {
Arc::new(AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config {
storage_dir: storage_dir.to_string_lossy().into_owned(),
..Config::default()
})),
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
hdhomerun: Arc::new(ArcSwapOption::<HdHomeRunConfig>::default()),
api_proxy: Arc::new(ArcSwapOption::<ApiProxyConfig>::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::<CustomStreamResponse>::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
})
}
pub(super) fn processing_context(
app_config: Arc<AppConfig>,
playlist_state: Option<Arc<PlaylistStorageState>>,
) -> PlaylistProcessingContext<NoopSink> {
PlaylistProcessingContext {
client: reqwest::Client::new(),
run_id: "curation-effect-gate-run".into(),
execution_order: PlaylistUpdateRunOrder::from(1),
config: app_config,
user_targets: Arc::new(ProcessTargets {
enabled: false,
inputs: Vec::new(),
targets: Vec::new(),
target_names: Vec::new(),
}),
events: NoopSink,
playlist_state,
disabled_headers: None,
processed_inputs: Arc::new(Mutex::new(HashSet::new())),
input_completions: Arc::new(Mutex::new(HashMap::new())),
input_locks: Arc::new(Mutex::new(HashMap::new())),
provider_manager: None,
metadata_manager: None,
pre_processed_inputs: None,
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
input_refresh: None,
library_update_mode: LibraryUpdateMode::ExistingCatalog,
}
}
fn curation_persist_options(publication_plan: PlaylistPublicationPlan) -> TargetPlaylistPersistOptions {
TargetPlaylistPersistOptions { publication_plan, ..TargetPlaylistPersistOptions::default() }
}
pub(super) fn file_snapshot(root: &Path) -> BTreeMap<std::path::PathBuf, Vec<u8>> {
fn collect(root: &Path, path: &Path, snapshot: &mut BTreeMap<std::path::PathBuf, Vec<u8>>) {
let Ok(entries) = std::fs::read_dir(path) else { return };
for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_dir() {
collect(root, &entry_path, snapshot);
} else {
snapshot.insert(
entry_path.strip_prefix(root).expect("snapshot path under root").to_path_buf(),
std::fs::read(&entry_path).expect("snapshot file"),
);
}
}
}
let mut snapshot = BTreeMap::new();
collect(root, root, &mut snapshot);
snapshot
}
async fn empty_trakt_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind Trakt test server");
let address = listener.local_addr().expect("Trakt test address");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept Trakt request");
let mut request = Vec::new();
loop {
let mut buffer = [0u8; 1024];
let read = stream.read(&mut buffer).await.expect("read Trakt request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n[]")
.await
.expect("write Trakt response");
});
(format!("http://{address}"), server)
}
#[tokio::test]
async fn unavailable_curation_preserves_the_empty_mixed_output_guard() {
assert_curation_preserves_empty_mixed_output_guard(None).await;
}
#[tokio::test]
async fn tmdb_and_mixed_failures_preserve_the_empty_mixed_output_guard() {
for policy in ["full", "curated"] {
for mixed in [false, true] {
let (url, server) = empty_trakt_server().await;
let mut value = serde_json::json!({"catalog_selection": policy, "tmdb": {"trending": [{"kind": "movie", "time_window": "week", "limit": 100, "category_name": "TMDB"}]}});
if mixed {
value["trakt"] = serde_json::json!({"api": {"api_key": "test-client", "url": url}, "charts": [{"kind": "movies", "chart": "popular", "category_name": "Trakt"}]});
}
let mut dto: shared::model::CurationConfigDto = serde_json::from_value(value).unwrap();
dto.prepare(&[TargetOutputDto::Xtream(XtreamTargetOutputDto::default())]).unwrap();
assert_curation_preserves_empty_mixed_output_guard(Some(dto)).await;
if mixed {
server.await.unwrap();
} else {
server.abort();
}
}
}
}
async fn assert_curation_preserves_empty_mixed_output_guard(canonical: Option<shared::model::CurationConfigDto>) {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let playlist_state = Arc::new(PlaylistStorageState::new());
let mut target = ConfigTarget::from(&ConfigTargetDto {
name: "curation-effect-gate".to_string(),
output: vec![
TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
}),
TargetOutputDto::M3u(M3uTargetOutputDto {
filename: Some("curation-effect-gate.m3u".to_string()),
..M3uTargetOutputDto::default()
}),
],
watch: Some(vec![".*".to_string()]),
use_memory_cache: true,
..ConfigTargetDto::default()
});
if let Some(canonical) = canonical {
target.curation = Some(CurationConfig::from(&canonical));
if let tuliprox_core::model::TargetOutput::Xtream(output) = &mut target.output[0] {
output.trakt = None;
}
}
let mut seeded = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Seeded movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000071"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let seeded_result = persist_playlist_views(
&app_config,
&mut seeded,
None,
None,
&target,
Some(&playlist_state),
curation_persist_options(PlaylistPublicationPlan::Ordinary),
)
.await;
assert!(seeded_result.is_ok(), "seed persist failed: {seeded_result:?}");
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
let before_files = file_snapshot(directory.path());
let before_cache_len = playlist_state
.data
.read()
.await
.get(&target.name)
.and_then(|storage| storage.xtream.as_ref())
.map_or(0, |storage| storage.vod.len());
let context = processing_context(Arc::clone(&app_config), Some(Arc::clone(&playlist_state)));
let prepared = PreparedTarget {
target,
playlist: Vec::new(),
epg: Vec::new(),
processing: PipelineStats::default(),
accepted_empty_clusters: ClusterFlags::Vod,
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
};
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
assert!(result.is_err_and(|errors| errors.iter().any(|error| {
error.message().contains("non-Xtream outputs that cannot safely publish a fully empty forced result")
})));
assert!(errors.is_empty());
assert_eq!(file_snapshot(directory.path()), before_files);
let after_cache_len = playlist_state
.data
.read()
.await
.get("curation-effect-gate")
.and_then(|storage| storage.xtream.as_ref())
.map_or(0, |storage| storage.vod.len());
assert!(before_cache_len > 0);
assert_eq!(after_cache_len, before_cache_len);
}
#[tokio::test]
async fn complete_empty_curation_publishes_empty_watch_group_state() {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let (base_url, server) = empty_trakt_server().await;
let target_name = "curation-empty-watch";
let target = ConfigTarget::from(&ConfigTargetDto {
name: target_name.to_string(),
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
catalog_selection: TraktCatalogSelection::Curated,
api: TraktApiConfigDto {
api_key: "test-client-id".to_string(),
version: "2".to_string(),
url: base_url,
user_agent: "tuliprox-test".to_string(),
},
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
})],
watch: Some(vec![".*".to_string()]),
..ConfigTargetDto::default()
});
let mut seeded = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Seeded movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000072"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let seed_result = persist_playlist_views(
&app_config,
&mut seeded,
None,
None,
&target,
None,
curation_persist_options(PlaylistPublicationPlan::Ordinary),
)
.await;
assert!(seed_result.is_ok(), "seed persist failed: {seed_result:?}");
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
let watch_index = directory.path().join(format!("{target_name}.groups.bin"));
let before: BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(&watch_index).expect("seeded watch index"))
.expect("decode seeded watch index");
assert_eq!(before.len(), 1);
let context = processing_context(Arc::clone(&app_config), None);
let prepared = PreparedTarget {
target,
playlist: seeded,
epg: Vec::new(),
processing: PipelineStats::default(),
accepted_empty_clusters: ClusterFlags::empty(),
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
};
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
server.await.expect("Trakt server should finish");
assert!(result.is_ok(), "complete empty finalization failed: {result:?}");
assert!(errors.is_empty());
let after: BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(watch_index).expect("empty watch index"))
.expect("decode empty watch index");
assert!(after.is_empty());
}
@@ -15,10 +15,14 @@ fn fail_late(server: &DiscoveryServer, failure: &str) {
match failure {
"status" => server.reply(MOVIES_2, 503, "sensitive-body-must-not-appear"),
"wire" => {
server.reply(MOVIES_2, 200, r#"{"page":2,"total_pages":2,"total_results":2,"results":[{"id":7},{"id":0}]}"#)
server.reply(
MOVIES_2,
200,
r#"{"page":2,"total_pages":2,"total_results":2,"results":[{"id":7},{"id":0}]}"#,
);
}
"no-progress" => {
server.reply(MOVIES_2, 200, r#"{"page":2,"total_pages":2,"total_results":1,"results":[{"id":8}]}"#)
server.reply(MOVIES_2, 200, r#"{"page":2,"total_pages":2,"total_results":1,"results":[{"id":8}]}"#);
}
"empty" => server.reply(MOVIES_2, 200, r#"{"page":2,"total_pages":2,"total_results":0,"results":[]}"#),
"body" => server.reply_raw(
@@ -40,7 +44,7 @@ fn fail_late(server: &DiscoveryServer, failure: &str) {
server.reply_raw(MOVIES_2, response);
}
"bytes" => {
server.reply(MOVIES_2, 200, &format!("{MOVIE_PAGE_2}{}", " ".repeat(1024 * 1024 + 1 - MOVIE_PAGE_2.len())))
server.reply(MOVIES_2, 200, &format!("{MOVIE_PAGE_2}{}", " ".repeat(1024 * 1024 + 1 - MOVIE_PAGE_2.len())));
}
"requests" => {
for p in 1..=33 {
@@ -0,0 +1,401 @@
use super::{
apply_persist_filter, build_curated_playlist_views, catalog_membership, catalog_test_item,
complete_catalog_evaluation, prepare_eligible_catalog, prepare_target_playlist_views, select_target_catalog,
FinalizationStage, FINALIZATION_ORDER,
};
use shared::{
foundation::get_filter,
model::{
ConfigTargetDto, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, TargetOutputDto,
TraktApiConfigDto, TraktCatalogSelection, TraktConfigDto, TraktContentType, TraktListConfigDto, UUIDType,
XtreamCluster, XtreamTargetOutputDto,
},
utils::Internable,
};
use tuliprox_core::{
model::{ConfigTarget, CurationConfig, TraktConfig},
utils::StepMeasure,
};
use tuliprox_curation::{
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
};
use tuliprox_repository::PlaylistPublicationPlan;
#[test]
fn current_target_finalization_order_merges_before_dedup_and_presentation() {
assert_eq!(
FINALIZATION_ORDER,
[
FinalizationStage::Merge,
FinalizationStage::Deduplicate,
FinalizationStage::Sort,
FinalizationStage::AssignChannelNumbers,
FinalizationStage::AssignCounters,
]
);
}
#[test]
fn curation_eligible_catalog_is_merged_and_deduplicated_before_matching() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.execution_plan.post_merge_content_dedup = Some(shared::model::DeduplicateConfig::default());
let losing_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000051");
let winning_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000052");
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Movie HD",
losing_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "movies".intern(),
channels: vec![catalog_test_item(
"Movie 4K",
winning_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
},
];
let mut step = StepMeasure::new("test", |_, _| {});
let eligible = prepare_eligible_catalog(&target, playlist, &mut step);
assert_eq!(eligible.len(), 1);
assert_eq!(eligible[0].channels.len(), 1);
assert_eq!(eligible[0].channels[0].header.uuid, winning_uuid);
}
#[test]
fn persist_filter_can_select_a_generated_curation_group() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter.persist = Some(get_filter(r#"Group = "Trending""#, None).expect("persist filter"));
let mut playlist = vec![
PlaylistGroup {
id: 1,
title: "Base".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "Trending".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Trending".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
];
apply_persist_filter(&target, &mut playlist);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Trending");
}
#[tokio::test]
async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
let target = ConfigTarget::from(&ConfigTargetDto::default());
let views = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, Vec::new()).await;
assert!(views.base.is_empty());
assert!(views.xtream.is_none());
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
#[tokio::test]
async fn unavailable_required_selector_continues_with_the_base_catalog() {
let target = ConfigTarget::from(&ConfigTargetDto {
name: "curation-failure".to_string(),
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
})],
..ConfigTargetDto::default()
});
let base = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Base movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000061"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let views = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, base).await;
assert_eq!(views.base.len(), 1);
assert_eq!(views.base[0].channels[0].header.title.as_ref(), "Base movie");
assert!(views.xtream.is_none());
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
#[test]
fn catalog_selection_preserves_live_and_selected_series_children() {
let live_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
let selected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002");
let rejected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000003");
let series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000004");
let episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000005");
let rejected_series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000006");
let rejected_episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000007");
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![catalog_test_item(
"Live channel",
live_uuid,
PlaylistItemType::Live,
XtreamCluster::Live,
None,
)],
xtream_cluster: XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Movies".intern(),
channels: vec![
catalog_test_item(
"Selected movie",
selected_movie_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
),
catalog_test_item(
"Rejected movie",
rejected_movie_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
),
],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 3,
title: "Series".intern(),
channels: vec![
catalog_test_item(
"Selected series",
series_uuid,
PlaylistItemType::SeriesInfo,
XtreamCluster::Series,
None,
),
catalog_test_item(
"Selected episode",
episode_uuid,
PlaylistItemType::Series,
XtreamCluster::Series,
Some(&series_uuid.to_string()),
),
catalog_test_item(
"Rejected series",
rejected_series_uuid,
PlaylistItemType::SeriesInfo,
XtreamCluster::Series,
None,
),
catalog_test_item(
"Rejected episode",
rejected_episode_uuid,
PlaylistItemType::Series,
XtreamCluster::Series,
Some(&rejected_series_uuid.to_string()),
),
],
xtream_cluster: XtreamCluster::Series,
},
];
let evaluation = complete_catalog_evaluation(vec![
catalog_membership(selected_movie_uuid, CurationMediaKind::Movie, 0),
catalog_membership(series_uuid, CurationMediaKind::Series, 1),
]);
let selected = select_target_catalog(playlist.clone(), &evaluation, true);
let titles =
selected.iter().flat_map(|group| &group.channels).map(|item| item.header.title.as_ref()).collect::<Vec<_>>();
assert_eq!(titles, ["Live channel", "Selected movie", "Selected series", "Selected episode"]);
assert_eq!(
select_target_catalog(playlist.clone(), &evaluation, false).iter().flat_map(|group| &group.channels).count(),
7
);
assert_eq!(selected[0].channels[0].header.uuid, live_uuid, "target-wide selection must not rewrite Live");
let remote_empty = select_target_catalog(playlist, &complete_catalog_evaluation(Vec::new()), true);
assert_eq!(remote_empty.len(), 1);
assert_eq!(remote_empty[0].xtream_cluster, XtreamCluster::Live);
assert_eq!(remote_empty[0].channels[0].header.uuid, live_uuid);
}
#[test]
fn curation_policy_truth_table_covers_a_through_h() {
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000041");
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000042");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
],
xtream_cluster: XtreamCluster::Video,
}];
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
let cases = [
("A", TraktCatalogSelection::Full, true, true),
("B", TraktCatalogSelection::Curated, true, true),
("C", TraktCatalogSelection::Curated, true, false),
("D", TraktCatalogSelection::Curated, false, true),
("E", TraktCatalogSelection::Full, true, false),
("F", TraktCatalogSelection::Full, false, true),
("G", TraktCatalogSelection::Full, false, false),
("H", TraktCatalogSelection::Curated, false, false),
];
for (case, catalog_selection, include_base, create_category) in cases {
let config = TraktConfig::from(&TraktConfigDto {
enabled: true,
catalog_selection,
include_xtream_base_categories: include_base,
api: TraktApiConfigDto::default(),
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Curated".to_string()),
create_xtream_category: create_category,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
charts: Vec::new(),
});
let views =
build_curated_playlist_views(playlist.clone(), &evaluation, &CurationConfig::from(&config), false, true);
let base_items = views.base.iter().flat_map(|group| &group.channels).collect::<Vec<_>>();
let expected_base_count = if catalog_selection == TraktCatalogSelection::Full { 2 } else { 1 };
assert_eq!(base_items.len(), expected_base_count, "case {case} selected catalog");
assert!(base_items.iter().any(|item| item.header.uuid == selected_uuid), "case {case} selected subject");
let xtream = views.xtream.expect("complete curation Xtream view");
let base_groups = xtream.iter().filter(|group| group.title.as_ref() == "Movies").count();
let category_groups = xtream.iter().filter(|group| group.title.as_ref() == "Curated").count();
assert_eq!(base_groups, usize::from(include_base), "case {case} base appearance");
assert_eq!(category_groups, usize::from(create_category), "case {case} category appearance");
let expected_unfiltered_items = usize::from(include_base) * expected_base_count + usize::from(create_category);
assert_eq!(
xtream.iter().map(|group| group.channels.len()).sum::<usize>(),
expected_unfiltered_items,
"case {case} unfiltered Xtream catalog"
);
if let Some(alias) =
xtream.iter().find(|group| group.title.as_ref() == "Curated").and_then(|group| group.channels.first())
{
assert_ne!(alias.header.uuid, selected_uuid, "case {case} alias identity");
}
}
}
#[test]
fn large_catalog_projection_smoke_keeps_two_explicit_views_bounded() {
const CATALOG_SIZE: usize = 10_000;
const SELECTOR_COUNT: usize = 4;
const MEMBERSHIP_STRIDE: usize = 10;
let uuid_for = |index: usize| {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&u64::try_from(index + 1).expect("test index").to_be_bytes());
UUIDType(bytes)
};
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: (0..CATALOG_SIZE)
.map(|index| {
catalog_test_item(
&format!("Movie {index}"),
uuid_for(index),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)
})
.collect(),
xtream_cluster: XtreamCluster::Video,
}];
let mut memberships = Vec::with_capacity(CATALOG_SIZE / MEMBERSHIP_STRIDE * SELECTOR_COUNT);
let mut selectors = Vec::with_capacity(SELECTOR_COUNT);
for selector in 0..SELECTOR_COUNT {
let key = CurationSelectorKey(selector);
let start = memberships.len();
for index in (selector..CATALOG_SIZE).step_by(MEMBERSHIP_STRIDE) {
memberships.push(CurationMembership {
selector_key: key,
subject_uuid: uuid_for(index),
media_kind: CurationMediaKind::Movie,
rank: Some(u32::try_from(index).expect("test rank")),
title_tiebreak: format!("movie-{index}"),
candidate_order: index,
});
}
selectors.push(CurationSelectorSummary {
key,
reference_count: memberships.len() - start,
membership_count: memberships.len() - start,
});
}
let evaluation = CurationEvaluation { selectors, memberships };
let config = TraktConfig::from(&TraktConfigDto {
lists: (0..SELECTOR_COUNT)
.map(|selector| TraktListConfigDto {
user: "alice".to_string(),
list_slug: format!("list-{selector}"),
category_name: Some(format!("Curated {selector}")),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
})
.collect(),
..TraktConfigDto::default()
});
let views = build_curated_playlist_views(playlist, &evaluation, &CurationConfig::from(&config), false, true);
assert_eq!(views.base[0].channels.len(), CATALOG_SIZE);
let xtream = views.xtream.expect("Xtream view");
assert_eq!(xtream.len(), SELECTOR_COUNT + 1);
assert_eq!(xtream.iter().map(|group| group.channels.len()).sum::<usize>(), CATALOG_SIZE + 4_000);
}
@@ -0,0 +1,527 @@
use super::{
apply_persist_filter, assign_channel_no_playlist, catalog_membership, catalog_test_item,
complete_catalog_evaluation, execute_pipe, execute_pipeline_on_groups, filter_skipped_clusters_from_source,
retain_playlist_items, target, test_group, InputDownloadResult, InputJobState, PlaylistRunSignals, RenameOutcome,
};
use crate::fetched_playlist::FetchedPlaylist;
use shared::{
foundation::{get_filter, ValueProvider},
model::{
ClusterFlags, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, InputType, ItemField, PlaylistGroup,
PlaylistItem, PlaylistItemHeader, PlaylistItemType, PlaylistUpdateState, XtreamCluster,
},
utils::Internable,
};
use std::collections::HashSet;
use tuliprox_core::model::{
ClusterUpdateRejection, ConfigInput, ConfigRename, ConfigTarget, FilterOutcome, TransformStage,
};
use tuliprox_curation::{CurationMediaKind, CurationRunOutcome};
use tuliprox_repository::{MemoryPlaylistSource, PlaylistPublicationPlan};
#[test]
fn execute_pipe_applies_target_bouquet_prefilter() {
let input = ConfigInput::default();
let item1 = PlaylistItem {
header: PlaylistItemHeader {
id: "ch-1".intern(),
group: "Kids".intern(),
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let item2 = PlaylistItem {
header: PlaylistItemHeader {
id: "ch-2".intern(),
group: "Adults".intern(),
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let source = MemoryPlaylistSource::new(vec![
PlaylistGroup { id: 1, title: "Kids".intern(), channels: vec![item1], xtream_cluster: XtreamCluster::Live },
PlaylistGroup { id: 2, title: "Adults".intern(), channels: vec![item2], xtream_cluster: XtreamCluster::Live },
])
.into_source();
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
let mut duplicates = HashSet::new();
let target = ConfigTarget::from(&ConfigTargetDto::default());
let bouquet_dto =
shared::model::PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None };
let filter =
tuliprox_core::model::TargetBouquetFilter::from_dto(shared::model::TargetBouquetDto::whitelist(bouquet_dto))
.unwrap();
let (mut processed, _outcome) = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false, Some(&filter))
.expect("target processing should succeed");
let groups = processed.source.take_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "Kids");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "ch-1");
}
#[test]
fn execute_pipe_prefilter_does_not_suppress_allowed_duplicate() {
let input = ConfigInput::default();
// Two items with the same URL (same UUID): one in disallowed group, one in allowed group.
let item_disallowed = PlaylistItem {
header: PlaylistItemHeader {
id: "ch-1".intern(),
url: "http://provider.example/stream.ts".intern(),
group: "Adults".intern(),
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let item_allowed = PlaylistItem {
header: PlaylistItemHeader {
id: "ch-2".intern(),
url: "http://provider.example/stream.ts".intern(),
group: "Kids".intern(),
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let source = MemoryPlaylistSource::new(vec![
PlaylistGroup {
id: 1,
title: "Adults".intern(),
channels: vec![item_disallowed],
xtream_cluster: XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Kids".intern(),
channels: vec![item_allowed],
xtream_cluster: XtreamCluster::Live,
},
])
.into_source();
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
let mut duplicates = HashSet::new();
let target = ConfigTarget::from(&ConfigTargetDto {
options: Some(ConfigTargetOptions { remove_duplicates: true, ..Default::default() }),
..Default::default()
});
let bouquet_dto =
shared::model::PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None };
let filter =
tuliprox_core::model::TargetBouquetFilter::from_dto(shared::model::TargetBouquetDto::whitelist(bouquet_dto))
.unwrap();
let (mut processed, _outcome) = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false, Some(&filter))
.expect("target processing should succeed");
let groups = processed.source.take_groups();
// The allowed item must be retained because the rejected item did not consume the duplicate UUID slot.
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "Kids");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[0].channels[0].header.id.as_ref(), "ch-2");
}
#[test]
fn filter_skipped_clusters_removes_cached_groups() {
use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions};
let live_item = PlaylistItem {
header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Live, ..Default::default() },
};
let vod_item = PlaylistItem {
header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Video, ..Default::default() },
};
let groups = vec![
PlaylistGroup { id: 1, title: "Live".intern(), channels: vec![live_item], xtream_cluster: XtreamCluster::Live },
PlaylistGroup { id: 2, title: "Vod".intern(), channels: vec![vod_item], xtream_cluster: XtreamCluster::Video },
];
let source = MemoryPlaylistSource::new(groups).into_source();
let input = ConfigInput {
name: "skip_live".intern(),
input_type: InputType::Xtream,
options: Some(ConfigInputOptions {
flags: ConfigInputFlags::SkipLive.into(),
..ConfigInputOptions::defaults().clone()
}),
..ConfigInput::default()
};
let mut filtered = filter_skipped_clusters_from_source(source, &input);
let filtered_groups = filtered.take_groups();
assert_eq!(filtered_groups.len(), 1);
assert_eq!(filtered_groups[0].xtream_cluster, XtreamCluster::Video);
}
#[test]
fn quality_rejection_keeps_a_usable_input_ready() {
let mut result = InputDownloadResult {
authoritative_library: false,
errors: Vec::new(),
source: MemoryPlaylistSource::new(vec![test_group(XtreamCluster::Video, "retained-vod", "provider-a")])
.into_source(),
storage_error: None,
partial: false,
quality_rejections: vec![ClusterUpdateRejection {
cluster: XtreamCluster::Video,
current_count: 12_543,
candidate_count: 217,
threshold: 90,
quality: 1,
}],
accepted_empty_clusters: ClusterFlags::empty(),
input_telemetry: None,
};
assert_eq!(result.job_state(), InputJobState::Ready);
assert!(result.errors.is_empty());
assert!(!result.partial);
assert_eq!(result.quality_rejections.len(), 1);
}
#[test]
fn quality_rejection_marks_the_run_partial_without_reusing_stalker_partial() {
let quality_rejection = PlaylistRunSignals { has_quality_rejections: true, ..PlaylistRunSignals::default() };
assert!(!quality_rejection.has_pending_stalker_refresh);
assert_eq!(quality_rejection.state(), PlaylistUpdateState::Partial);
let stalker_partial = PlaylistRunSignals { has_pending_stalker_refresh: true, ..PlaylistRunSignals::default() };
assert!(!stalker_partial.has_quality_rejections);
assert_eq!(stalker_partial.state(), PlaylistUpdateState::Partial);
let technical_failure = PlaylistRunSignals { has_error: true, ..quality_rejection };
assert_eq!(technical_failure.state(), PlaylistUpdateState::Failure);
}
pub(in crate::processor::playlist::tests) fn make_test_item(name: &str, item_type: PlaylistItemType) -> PlaylistItem {
let header =
PlaylistItemHeader { name: name.into(), group: "Test Group".intern(), item_type, ..Default::default() };
PlaylistItem { header }
}
#[test]
fn test_filter_evalutes_correctly() {
let filter = get_filter(r#"name ~ "Allowed""#, None).unwrap();
let allowed_item = make_test_item("Allowed Channel", PlaylistItemType::Live);
let denied_item = make_test_item("Denied Channel", PlaylistItemType::Live);
let allowed_provider = ValueProvider { pli: &allowed_item, match_as_ascii: false };
let denied_provider = ValueProvider { pli: &denied_item, match_as_ascii: false };
assert!(filter.filter(&allowed_provider));
assert!(!filter.filter(&denied_provider));
}
#[test]
fn test_filter_with_type_comparison() {
let filter = get_filter("type = vod", None).unwrap();
let vod_item = make_test_item("Test Movie", PlaylistItemType::Video);
let live_item = make_test_item("Test Channel", PlaylistItemType::Live);
let vod_provider = ValueProvider { pli: &vod_item, match_as_ascii: false };
let live_provider = ValueProvider { pli: &live_item, match_as_ascii: false };
assert!(filter.filter(&vod_provider));
assert!(!filter.filter(&live_provider));
}
#[test]
fn playlist_retention_reports_filter_counts() {
let groups = vec![PlaylistGroup {
id: 1,
title: "Test Group".intern(),
channels: vec![
make_test_item("Allowed", PlaylistItemType::Live),
make_test_item("Denied", PlaylistItemType::Live),
],
xtream_cluster: XtreamCluster::Live,
}];
let mut source = MemoryPlaylistSource::new(groups).into_source();
let (filtered, outcome) = retain_playlist_items(&mut source, |item| item.header.name.as_ref() == "Allowed");
assert_eq!(outcome, FilterOutcome { inspected: 2, retained: 1, removed: 1 });
assert_eq!(filtered.expect("one item should remain")[0].channels[0].header.name.as_ref(), "Allowed");
}
#[test]
fn filter_stage_can_remove_every_item() {
let groups = vec![PlaylistGroup {
id: 1,
title: "Test Group".intern(),
channels: vec![make_test_item("Denied", PlaylistItemType::Live)],
xtream_cluster: XtreamCluster::Live,
}];
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into();
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
assert!(groups.is_empty());
assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 1, retained: 0, removed: 1 }));
}
#[test]
fn missing_processing_filter_skips_filter_stage() {
let groups = vec![PlaylistGroup {
id: 1,
title: "Test Group".intern(),
channels: vec![make_test_item("Allowed", PlaylistItemType::Live)],
xtream_cluster: XtreamCluster::Live,
}];
let target = ConfigTarget::from(&ConfigTargetDto::default());
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].channels.len(), 1);
assert!(outcome.filter.is_none());
}
#[test]
fn missing_processing_filter_preserves_filter_stage_group_normalization() {
let mut first = make_test_item("One", PlaylistItemType::Live);
first.header.group = "News".intern();
let mut second = make_test_item("Two", PlaylistItemType::Live);
second.header.group = "news".intern();
let groups = vec![
PlaylistGroup { id: 1, title: "News".intern(), channels: vec![first], xtream_cluster: XtreamCluster::Live },
PlaylistGroup { id: 2, title: "news".intern(), channels: vec![second], xtream_cluster: XtreamCluster::Live },
];
let target = ConfigTarget::from(&ConfigTargetDto::default());
let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].channels.len(), 2);
assert!(outcome.filter.is_none());
}
#[test]
fn pipeline_reports_filter_and_rename_outcomes() {
let groups = vec![PlaylistGroup {
id: 1,
title: "Test Group".intern(),
channels: vec![
make_test_item("Allowed", PlaylistItemType::Live),
make_test_item("Denied", PlaylistItemType::Live),
],
xtream_cluster: XtreamCluster::Live,
}];
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into();
target.rename = Some(vec![ConfigRename::from(&ConfigRenameDto {
field: ItemField::Name,
pattern: "Allowed".to_string(),
new_name: "Renamed".to_string(),
t_pattern: None,
})]);
let (groups, outcome) =
execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter, TransformStage::Rename]);
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Renamed");
assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 2, retained: 1, removed: 1 }));
assert_eq!(outcome.rename, Some(RenameOutcome { inspected: 1, changed_items: 1, changed_fields: 1 }));
}
#[test]
fn assign_channel_no_playlist_preserves_non_zero_chno() {
let mut groups = vec![
PlaylistGroup {
id: 1,
title: "Group A".intern(),
channels: vec![
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 10, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
],
xtream_cluster: XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Group C".intern(),
channels: vec![
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 1, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } },
],
xtream_cluster: XtreamCluster::Live,
},
];
assign_channel_no_playlist(&mut groups);
// Non-zero chno values must be preserved
assert_eq!(groups[0].channels[0].header.chno, 10);
assert_eq!(groups[1].channels[0].header.chno, 1);
}
#[test]
fn assign_channel_no_playlist_assigns_zero_chno_only() {
let mut groups = vec![PlaylistGroup {
id: 1,
title: "Group A".intern(),
channels: vec![
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() } },
],
xtream_cluster: XtreamCluster::Live,
}];
assign_channel_no_playlist(&mut groups);
// All zero-chno channels should get assigned numbers starting at 1
assert_eq!(groups[0].channels[0].header.chno, 1);
assert_eq!(groups[0].channels[1].header.chno, 2);
assert_eq!(groups[0].channels[2].header.chno, 3);
}
#[test]
fn assign_channel_no_playlist_skips_existing_nonzero_numbers() {
let mut groups = vec![PlaylistGroup {
id: 1,
title: "Group A".intern(),
channels: vec![
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 5, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 2, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } },
],
xtream_cluster: XtreamCluster::Live,
}];
assign_channel_no_playlist(&mut groups);
// Existing non-zero numbers (2, 5) must be skipped when assigning new numbers
assert_eq!(groups[0].channels[0].header.chno, 5); // preserved
assert_eq!(groups[0].channels[2].header.chno, 2); // preserved
// B gets 1 (smallest available), D gets 3 (next available after 1 and existing 2)
assert_eq!(groups[0].channels[1].header.chno, 1);
assert_eq!(groups[0].channels[3].header.chno, 3);
}
#[test]
fn assign_channel_no_playlist_assigns_following_group_order() {
let mut groups = vec![
PlaylistGroup {
id: 1,
title: "Group 1".intern(),
channels: vec![
PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } },
PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } },
],
xtream_cluster: XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Group 2".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() },
}],
xtream_cluster: XtreamCluster::Live,
},
];
assign_channel_no_playlist(&mut groups);
// Numbers should follow iteration order across groups: A=1, B=2, C=3
assert_eq!(groups[0].channels[0].header.chno, 1);
assert_eq!(groups[0].channels[1].header.chno, 2);
assert_eq!(groups[1].channels[0].header.chno, 3);
}
#[test]
fn persist_filter_can_select_a_base_group() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter.persist = Some(get_filter(r#"Group = "Base""#, None).expect("persist filter"));
let mut playlist = vec![
PlaylistGroup {
id: 1,
title: "Base".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "Curated".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Curated".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
];
apply_persist_filter(&target, &mut playlist);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Base");
}
#[test]
fn tmdb_complete_target_selection_preserves_live_and_keeps_xtream_aliases_out_of_normal_outputs() {
for has_xtream in [false, true] {
let mut value = serde_json::json!({"name": "discovery", "output": [{"type": "m3u"}],
"curation": {"catalog_selection": "curated", "tmdb": {"trending": [{"kind": "movie", "time_window": "week", "limit": 100, "create_xtream_category": has_xtream, "category_name": "TMDB"}]}}});
if has_xtream {
value["output"].as_array_mut().unwrap().push(serde_json::json!({"type": "xtream"}));
}
let mut dto: ConfigTargetDto = serde_json::from_value(value).unwrap();
dto.prepare(1, None, None).unwrap();
let target = ConfigTarget::from(&dto);
let id = shared::utils::hash_string("selected");
let live_id = shared::utils::hash_string("live");
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Live".intern(),
xtream_cluster: XtreamCluster::Live,
channels: vec![catalog_test_item("Live", live_id, PlaylistItemType::Live, XtreamCluster::Live, None)],
},
PlaylistGroup {
id: 2,
title: "Movies".intern(),
xtream_cluster: XtreamCluster::Video,
channels: vec![
catalog_test_item("Selected", id, PlaylistItemType::Video, XtreamCluster::Video, None),
catalog_test_item(
"Other",
shared::utils::hash_string("other"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
),
],
},
];
let config = target.effective_curation().unwrap();
let outcome = CurationRunOutcome::Complete(complete_catalog_evaluation(vec![catalog_membership(
id,
CurationMediaKind::Movie,
0,
)]));
let views = target::curation_playlist_views(&target, playlist.clone(), &config, outcome);
assert_eq!(
views.base.iter().flat_map(|g| &g.channels).map(|i| i.header.uuid).collect::<Vec<_>>(),
[live_id, id]
);
if has_xtream {
let groups = views.xtream.unwrap();
let projected = groups.iter().find(|g| g.title.as_ref() == "TMDB").unwrap();
assert_ne!(projected.channels[0].header.uuid, id);
} else {
assert!(views.xtream.is_none());
}
let empty = CurationRunOutcome::Complete(complete_catalog_evaluation(Vec::new()));
let empty_views = target::curation_playlist_views(&target, playlist, &config, empty);
assert_eq!(empty_views.base.len(), 1);
assert_eq!(empty_views.base[0].xtream_cluster, XtreamCluster::Live);
assert_ne!(empty_views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
}
@@ -0,0 +1,334 @@
use super::{
apply_staged_overlay_groups, exec_rename, execute_pipe, map_channel, playlist_update_run_result,
report_input_job_completion, test_group, InputJobState, PlaylistRunCollectSink,
};
use crate::fetched_playlist::FetchedPlaylist;
use shared::{
error::TuliproxError,
foundation::{get_filter, MapperScript},
model::{
ClusterFlags, ConfigRenameDto, ConfigTargetDto, EventMessage, FieldSetAccessor, InputType, ItemField,
M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState, XtreamCluster, XtreamPlaylistItem,
},
utils::Internable,
};
use std::collections::HashSet;
use tuliprox_core::model::{
CompiledMapping, CompiledMappingRule, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename,
ConfigTarget, MappingProgram,
};
use tuliprox_repository::MemoryPlaylistSource;
#[test]
fn playlist_update_run_failure_text_makes_no_unverified_previous_data_claim() {
let events = PlaylistRunCollectSink::default();
let run_id = PlaylistUpdateRunId::from("neutral-failure-run");
let execution_order = PlaylistUpdateRunOrder::from(18);
let failed_first_run = playlist_update_run_result(
7,
InputJobState::Failed,
vec![TuliproxError::RepositoryPlaylist("https://first:secret@provider.example/playlist".to_string())],
false,
);
let persisted_then_failed = playlist_update_run_result(
8,
InputJobState::Ready,
vec![TuliproxError::RepositoryPlaylist("https://epg:secret@provider.example/guide".to_string())],
false,
);
report_input_job_completion(&events, &run_id, execution_order, &failed_first_run);
report_input_job_completion(&events, &run_id, execution_order, &persisted_then_failed);
let emitted = events.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let progress = emitted
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) => Some(progress),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(progress.len(), 2);
assert_eq!(progress[0].input_id, Some(7));
assert_eq!(progress[0].message, "Input 'input-7' failed during update");
assert_eq!(progress[1].input_id, Some(8));
assert_eq!(progress[1].message, "Input 'input-8' failed during update");
assert!(progress.iter().all(|event| event.run_id.as_ref() == Some(&run_id)));
assert!(progress.iter().all(|event| event.execution_order == Some(execution_order)));
assert!(progress.iter().all(|event| event.state == Some(PlaylistUpdateState::Failure)));
assert!(progress.iter().all(|event| !event.message.contains("secret")));
assert!(progress.iter().all(|event| !event.message.contains("previous accepted data")));
}
pub(in crate::processor::playlist::tests) fn serialize_without_trailing_fields<T: serde::Serialize>(
value: &T,
trailing_fields: &[u8],
) -> Vec<u8> {
let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize");
for expected in trailing_fields {
assert_eq!(encoded.pop(), Some(*expected), "unexpected trailing MessagePack field");
}
let removed = trailing_fields.len();
match encoded[0] {
marker @ 0x92..=0x9f => {
let len = usize::from(marker - 0x90);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[0] = 0x90 + u8::try_from(len - removed).unwrap_or_default();
}
0xdc => {
let len = u16::from_be_bytes([encoded[1], encoded[2]]);
let removed = u16::try_from(removed).unwrap_or(u16::MAX);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[1..3].copy_from_slice(&(len - removed).to_be_bytes());
}
0xdd => {
let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]);
let removed = u32::try_from(removed).unwrap_or(u32::MAX);
assert!(len >= removed, "trailing field count exceeds MessagePack sequence length");
encoded[1..5].copy_from_slice(&(len - removed).to_be_bytes());
}
marker => panic!("unexpected MessagePack sequence marker {marker:#x}"),
}
encoded
}
#[test]
fn rename_preserves_input_stream_id_captured_at_target_boundary() {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/channel.m3u8".intern(),
..Default::default()
},
};
item.header.freeze_input_stream_id();
let rename = ConfigRename::from(&ConfigRenameDto {
field: ItemField::Url,
pattern: "provider".to_string(),
new_name: "target".to_string(),
t_pattern: None,
});
exec_rename(&mut item, Some(&vec![rename]));
assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8");
assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_changes_id_without_changing_frozen_input_stream_id() {
let mut item = PlaylistItem {
header: PlaylistItemHeader { id: "origin-alpha".intern(), name: "Channel".intern(), ..Default::default() },
};
item.header.freeze_input_stream_id();
let mapping = CompiledMapping {
rules: vec![CompiledMappingRule {
name: None,
filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"),
program: MappingProgram::Script(
MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"),
),
}],
..Default::default()
};
let outcome = map_channel(item, &mapping);
assert_eq!(outcome.matched_rules, 1);
assert_eq!(outcome.channel.header.id.as_ref(), "target-id");
assert_eq!(outcome.channel.header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
source.header.freeze_input_stream_id();
let mut legacy_xtream = XtreamPlaylistItem::from(&source);
legacy_xtream.provider_id = 0;
legacy_xtream.input_stream_id = "".intern();
legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern();
let mut legacy_item = PlaylistItem::from(&legacy_xtream);
legacy_item.header.freeze_input_stream_id();
let mapping = CompiledMapping {
rules: vec![CompiledMappingRule {
name: None,
filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"),
program: MappingProgram::Script(
MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"),
),
}],
..Default::default()
};
let outcome = map_channel(legacy_item, &mapping);
let materialized_m3u = M3uPlaylistItem::from(&outcome.channel);
let materialized_xtream = XtreamPlaylistItem::from(&outcome.channel);
assert_eq!(outcome.matched_rules, 1);
assert_eq!(outcome.channel.header.id.as_ref(), "target-id");
assert_eq!(outcome.channel.get_input_stream_id(), None);
assert!(materialized_m3u.provider_id.is_empty());
assert_eq!(materialized_m3u.get_input_stream_id(), None);
assert_eq!(materialized_xtream.provider_id, 0);
assert_eq!(materialized_xtream.get_input_stream_id(), None);
}
#[test]
fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() {
let input = ConfigInput::default();
let item = PlaylistItem { header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() } };
let source = MemoryPlaylistSource::new(vec![PlaylistGroup {
id: 1,
title: "Group".intern(),
channels: vec![item],
xtream_cluster: XtreamCluster::Live,
}])
.into_source();
let mut fetched = FetchedPlaylist { input: &input, source, epg: None };
let mut duplicates = HashSet::new();
let target = ConfigTarget::from(&ConfigTargetDto::default());
let (mut processed, _outcome) = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false, None)
.expect("target processing should succeed");
let mut groups = processed.source.take_groups();
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
assert!(groups[0].channels[0].header.set_field("id", "late-target-id"));
assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha");
}
#[test]
fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
let mut source = PlaylistItem {
header: PlaylistItemHeader {
id: "origin-alpha".intern(),
url: "http://provider.example/live/user/pass/80510.ts".intern(),
input_name: "input".intern(),
item_type: PlaylistItemType::Live,
xtream_cluster: XtreamCluster::Live,
..Default::default()
},
};
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]);
let decoded_header: PlaylistItemHeader =
rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize");
assert!(decoded_header.input_stream_id.is_empty());
assert_eq!(decoded_header.get_input_stream_id(), None);
assert_eq!(decoded_header.upstream_user_agent, None);
let mut decoded_header = decoded_header;
decoded_header.freeze_input_stream_id();
assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha"));
source.header.freeze_input_stream_id();
let mut m3u_item = M3uPlaylistItem::from(&source);
m3u_item.input_stream_id = "".intern();
let m3u_bytes = serialize_without_trailing_fields(&m3u_item, &[0xc0, 0xa0]);
let decoded_m3u: M3uPlaylistItem = rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize");
assert!(decoded_m3u.input_stream_id.is_empty());
assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha"));
assert_eq!(decoded_m3u.upstream_user_agent, None);
let mut xtream_item = XtreamPlaylistItem::from(&source);
xtream_item.input_stream_id = "".intern();
let xtream_bytes = serialize_without_trailing_fields(&xtream_item, &[0xc0, 0xa0]);
let decoded_xtream: XtreamPlaylistItem =
rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize");
assert!(decoded_xtream.input_stream_id.is_empty());
assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510"));
assert_eq!(decoded_xtream.upstream_user_agent, None);
}
#[test]
fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() {
let source = PlaylistItem {
header: PlaylistItemHeader {
id: "80510".intern(),
input_stream_id: "origin-alpha".intern(),
..Default::default()
},
};
let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0]))
.expect("previous header should deserialize");
let m3u: M3uPlaylistItem =
rmp_serde::from_slice(&serialize_without_trailing_fields(&M3uPlaylistItem::from(&source), &[0xc0]))
.expect("previous M3U item should deserialize");
let xtream: XtreamPlaylistItem =
rmp_serde::from_slice(&serialize_without_trailing_fields(&XtreamPlaylistItem::from(&source), &[0xc0]))
.expect("previous Xtream item should deserialize");
assert_eq!(header.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(m3u.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(xtream.input_stream_id.as_ref(), "origin-alpha");
assert_eq!(header.upstream_user_agent, None);
assert_eq!(m3u.upstream_user_agent, None);
assert_eq!(xtream.upstream_user_agent, None);
}
#[test]
fn messagepack_playlist_items_preserve_upstream_user_agent() -> Result<(), Box<dyn std::error::Error>> {
let source = PlaylistItem {
header: PlaylistItemHeader { upstream_user_agent: Some("Provider-UA".intern()), ..Default::default() },
};
let header: PlaylistItemHeader = rmp_serde::from_slice(&rmp_serde::to_vec(&source.header)?)?;
let m3u: M3uPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&M3uPlaylistItem::from(&source))?)?;
let xtream: XtreamPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&XtreamPlaylistItem::from(&source))?)?;
assert_eq!(header.upstream_user_agent.as_deref(), Some("Provider-UA"));
assert_eq!(m3u.upstream_user_agent.as_deref(), Some("Provider-UA"));
assert_eq!(xtream.upstream_user_agent.as_deref(), Some("Provider-UA"));
Ok(())
}
#[test]
fn 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();
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"));
}
@@ -0,0 +1,597 @@
use super::*;
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::model::{ConfigPaths, EpgSmartMatchConfigDto};
use std::sync::Arc;
use tempfile::tempdir;
use tokio::runtime::Runtime;
use tuliprox_core::{
model::{
EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, MediaToolCapabilities, PersistedEpgSource,
PersistedEpgSourceKind, SourcesConfig,
},
utils::FileLockManager,
};
pub(super) fn build_mapping(id: &str, stage: MappingStage, script: &str) -> CompiledMapping {
CompiledMapping {
id: id.to_string(),
match_as_ascii: false,
stage,
rules: vec![CompiledMappingRule {
name: None,
filter: get_filter(r#"name ~ ".*""#, None).expect("filter parses"),
program: MappingProgram::Script(MapperScript::parse(script, None).expect("script parses")),
}],
counters: vec![],
templates: None,
}
}
pub(super) fn build_target(mappings: Vec<CompiledMapping>, remove_duplicates: bool) -> ConfigTarget {
let dto = ConfigTargetDto {
options: if remove_duplicates {
Some(ConfigTargetOptions { remove_duplicates, ..Default::default() })
} else {
None
},
..Default::default()
};
let mut target = ConfigTarget::from(&dto);
target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(CompiledTargetMappings::new(
mappings.into_iter().map(Arc::new).collect(),
)))));
target
}
/// Pinned to `NoopSink` rather than staying generic: these tests
/// exercise the pipeline, not the bus, and an inferred sink type
/// would just make every call site name one.
fn processing_context() -> PlaylistProcessingContext<shared::model::NoopSink> {
let paths = ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
};
let config = AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(paths)),
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
};
PlaylistProcessingContext {
client: reqwest::Client::new(),
run_id: "m3u-alias-test-run".into(),
execution_order: PlaylistUpdateRunOrder::from(1),
config: Arc::new(config),
user_targets: Arc::new(ProcessTargets {
enabled: false,
inputs: Vec::new(),
targets: Vec::new(),
target_names: Vec::new(),
}),
events: shared::model::NoopSink,
playlist_state: None,
disabled_headers: None,
processed_inputs: Arc::new(Mutex::new(HashSet::new())),
input_completions: Arc::new(Mutex::new(HashMap::new())),
input_locks: Arc::new(Mutex::new(HashMap::new())),
provider_manager: None,
metadata_manager: None,
pre_processed_inputs: None,
input_refresh: None,
library_update_mode: LibraryUpdateMode::ExistingCatalog,
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
#[tokio::test]
async fn m3u_alias_playlist_is_downloaded_and_indexed_separately() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let primary_playlist_path = temp.path().join("primary.m3u");
let alias_playlist_path = temp.path().join("backup.m3u");
tokio::fs::write(
&primary_playlist_path,
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=primary-stream-token\n",
)
.await
.expect("primary fixture should be written");
tokio::fs::write(
&alias_playlist_path,
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=backup-stream-token\n",
)
.await
.expect("alias fixture should be written");
let ctx = processing_context();
let config =
Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() };
ctx.config.config.store(Arc::new(config));
let input = Arc::new(ConfigInput {
id: 1,
name: "primary-account".intern(),
input_type: InputType::M3u,
url: primary_playlist_path.to_string_lossy().into_owned(),
enabled: true,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "backup-account".intern(),
url: alias_playlist_path.to_string_lossy().into_owned(),
username: None,
password: None,
priority: 1,
max_connections: 1,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
});
let InputDownloadResult {
errors, source: mut primary_playlist, storage_error, partial, quality_rejections, ..
} = download_input(&ctx, &input, false).await;
assert!(errors.is_empty(), "unexpected download errors: {errors:?}");
assert!(storage_error.is_none(), "unexpected primary storage error: {storage_error:?}");
assert!(!partial);
assert!(quality_rejections.is_empty());
assert!(!primary_playlist.is_empty());
let alias_url = tuliprox_repository::load_input_m3u_stream_url(
&ctx.config,
&"backup-account".intern(),
"http://stream.example:4000/323/mono.m3u8?token=primary-stream-token",
)
.await
.expect("alias URL lookup should succeed");
assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=backup-stream-token"));
}
#[tokio::test]
async fn failed_m3u_alias_is_retried_after_primary_input_is_processed() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let primary_playlist_path = temp.path().join("main.m3u");
let alias_playlist_path = temp.path().join("retry.m3u");
tokio::fs::write(
&primary_playlist_path,
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=main-stream-token\n",
)
.await
.expect("primary fixture should be written");
let ctx = processing_context();
let config =
Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() };
ctx.config.config.store(Arc::new(config));
let input = Arc::new(ConfigInput {
id: 1,
name: "main-account".intern(),
input_type: InputType::M3u,
url: primary_playlist_path.to_string_lossy().into_owned(),
enabled: true,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "retry-account".intern(),
url: alias_playlist_path.to_string_lossy().into_owned(),
username: None,
password: None,
priority: 1,
max_connections: 1,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
});
let InputDownloadResult {
errors: first_errors,
source: mut first_playlist,
storage_error: first_storage_error,
partial: first_partial,
quality_rejections: first_quality_rejections,
..
} = download_input(&ctx, &input, false).await;
assert!(!first_errors.is_empty(), "missing alias should report an error");
assert!(first_storage_error.is_none(), "primary storage should succeed: {first_storage_error:?}");
assert!(!first_partial);
assert!(first_quality_rejections.is_empty());
assert!(!first_playlist.is_empty());
assert!(ctx.is_input_downloaded("main-account").await);
assert!(!ctx.is_input_downloaded("retry-account").await);
tokio::fs::write(
&alias_playlist_path,
"#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=retry-stream-token\n",
)
.await
.expect("alias fixture should be written");
let InputDownloadResult {
errors: second_errors,
source: mut second_playlist,
storage_error: second_storage_error,
partial: second_partial,
quality_rejections: second_quality_rejections,
..
} = download_input(&ctx, &input, false).await;
assert!(second_errors.is_empty(), "unexpected retry errors: {second_errors:?}");
assert!(second_storage_error.is_none(), "primary storage should remain readable: {second_storage_error:?}");
assert!(!second_partial);
assert!(second_quality_rejections.is_empty());
assert!(!second_playlist.is_empty());
assert!(ctx.is_input_downloaded("retry-account").await);
let alias_url = tuliprox_repository::load_input_m3u_stream_url(
&ctx.config,
&"retry-account".intern(),
"http://stream.example:4000/323/mono.m3u8?token=main-stream-token",
)
.await
.expect("retried alias URL lookup should succeed");
assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=retry-stream-token"));
}
#[test]
fn persist_filter_runs_after_after_epg_mapping() {
let runtime = Runtime::new().expect("runtime");
runtime.block_on(async {
let mut input = ConfigInput::from(ConfigInputDto::default());
input.name = "input".intern();
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader {
name: "Before".intern(),
group: "Live".intern(),
xtream_cluster: XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..Default::default()
},
}],
xtream_cluster: XtreamCluster::Live,
}];
let mut playlist =
FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None };
let rename = build_mapping("rename", MappingStage::AfterEpg, r#"@Name = "After""#);
let mut target = build_target(vec![rename], false);
target.filter.persist = Some(get_filter(r#"Name = "After""#, None).expect("filter parses"));
let mut stats =
HashMap::from([(Arc::clone(&input.name), create_input_stat(1, 1, 0, input.input_type, &input.name, 0))]);
let mut errors = Vec::new();
let mut prepared = prepare_playlist_for_target(
&processing_context(),
std::slice::from_mut(&mut playlist),
&target,
&mut stats,
&mut errors,
ClusterFlags::empty(),
false,
)
.await
.expect("target preparation");
assert!(errors.is_empty());
apply_persist_filter(&target, &mut prepared.playlist);
let item = &prepared.playlist[0].channels[0];
assert_eq!(item.header.name.as_ref(), "After");
});
}
fn make_channel(name: &str) -> PlaylistItem {
let mut item = PlaylistItem {
header: PlaylistItemHeader {
name: name.intern(),
group: "Originals".intern(),
xtream_cluster: XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..Default::default()
},
};
item.header.freeze_input_stream_id();
item
}
fn memory_source(channels: Vec<PlaylistItem>) -> PlaylistSource {
MemoryPlaylistSource::new(vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels,
xtream_cluster: XtreamCluster::Live,
}])
.into_source()
}
fn channel_count(source: &mut PlaylistSource) -> usize { source.take_groups().iter().map(|g| g.channels.len()).sum() }
#[test]
fn map_playlist_applies_only_the_requested_stage() {
let processing = build_mapping("processing", MappingStage::Processing, r#"@name = concat(@Name, "-P")"#);
let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"@name = concat(@Name, "-E")"#);
let target = build_target(vec![processing, after_epg], false);
let mut source = memory_source(vec![make_channel("Alpha")]);
let (groups, _) = execute_pipeline_on_groups(source.take_groups(), &target, &[TransformStage::Map]);
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P");
let mut source = MemoryPlaylistSource::new(groups).into_source();
let groups = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None)
.expect("after_epg mapping should run");
assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P-E");
}
#[test]
fn map_playlist_at_stage_returns_none_without_consuming_source_when_no_match() {
let target = build_target(Vec::new(), false);
let mut source = memory_source(vec![make_channel("Alpha")]);
let result = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None);
assert!(result.is_none(), "no matching stage must return None");
assert_eq!(channel_count(&mut source), 1, "source must remain intact");
}
#[test]
fn prepare_target_applies_after_epg_mapping_before_sampling_stats() {
let runtime = Runtime::new().expect("runtime");
runtime.block_on(async {
let dir = tempdir().expect("tempdir");
let ics_path = dir.path().join("bbc.ics");
std::fs::write(
&ics_path,
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR",
)
.expect("write ics");
let mut smart_dto = EpgSmartMatchConfigDto {
enabled: true,
fuzzy_matching: false,
..EpgSmartMatchConfigDto::default()
};
smart_dto.prepare().expect("smart config");
let mut input = ConfigInput::from(ConfigInputDto::default());
input.name = "input".intern();
input.epg = Some(EpgConfig {
sources: vec![],
smart_match: Some(EpgSmartMatchConfig::from(smart_dto)),
});
let channels = vec![live_item_for_epg("BBC One")];
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels,
xtream_cluster: XtreamCluster::Live,
}];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: ics_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Ics {
channel_id: "bbc.one".intern(),
channel_title: Some("BBC One".intern()),
match_names: vec!["BBC One".intern()],
config: Box::new(IcsEpgSourceConfig::default()),
},
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
let rename_from_epg = build_mapping(
"rename",
MappingStage::AfterEpg,
r#"epg = @epg_channel_id ~ "(.+)"
match {
epg => @Name = epg.1
}"#,
);
let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
let target = build_target(vec![rename_from_epg, add_virtual], false);
let mut stats = HashMap::from([(
Arc::clone(&input.name),
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
)]);
let mut errors = Vec::new();
let prepared = prepare_playlist_for_target(
&processing_context(),
std::slice::from_mut(&mut playlist),
&target,
&mut stats,
&mut errors,
ClusterFlags::empty(),
false,
)
.await
.expect("target preparation");
assert!(errors.is_empty());
assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::<usize>(), 2);
let channel = prepared
.playlist
.iter()
.flat_map(|group| &group.channels)
.find(|channel| channel.header.group.as_ref() != "Echo")
.expect("original channel");
assert_eq!(channel.header.epg_channel_id.as_deref(), Some("bbc.one"));
assert_eq!(
channel.header.name.as_ref(),
"bbc.one",
"after_epg mapper must consume the EPG-enriched field"
);
let processed_stats = &stats[&input.name].processed_stats;
assert_eq!(processed_stats.group_count, 2);
assert_eq!(processed_stats.channel_count, 2);
});
}
#[test]
fn clear_invalid_epg_ids_clears_ids_invalidated_by_after_epg_mapping() {
let runtime = Runtime::new().expect("runtime");
runtime.block_on(async {
let dir = tempdir().expect("tempdir");
let ics_path = dir.path().join("bbc.ics");
std::fs::write(
&ics_path,
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR",
)
.expect("write ics");
let mut input = ConfigInput::from(ConfigInputDto::default());
input.name = "input".intern();
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader {
name: "BBC One".intern(),
epg_channel_id: Some("bbc.one".intern()),
group: "Live".intern(),
xtream_cluster: XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..Default::default()
},
}],
xtream_cluster: XtreamCluster::Live,
}];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: ics_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Ics {
channel_id: "bbc.one".intern(),
channel_title: Some("BBC One".intern()),
match_names: vec![],
config: Box::new(IcsEpgSourceConfig::default()),
},
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
let rewrite_epg =
build_mapping("rewrite", MappingStage::AfterEpg, r#"@epg_channel_id = "missing.epg""#);
let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
let mut target = build_target(vec![rewrite_epg, add_virtual], false);
target.options = Some(ConfigTargetOptions { clear_invalid_epg_ids: true, ..Default::default() });
let mut stats = HashMap::from([(
Arc::clone(&input.name),
create_input_stat(1, 1, 0, input.input_type, &input.name, 0),
)]);
let mut errors = Vec::new();
let prepared = prepare_playlist_for_target(
&processing_context(),
std::slice::from_mut(&mut playlist),
&target,
&mut stats,
&mut errors,
ClusterFlags::empty(),
false,
)
.await
.expect("target preparation");
assert!(errors.is_empty());
assert!(!prepared.playlist.is_empty());
assert!(prepared
.playlist
.iter()
.flat_map(|group| &group.channels)
.all(|channel| channel.header.epg_channel_id.is_none()));
assert_eq!(stats[&input.name].processed_stats.channel_count, 2);
});
}
fn live_item_for_epg(name: &str) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
name: name.intern(),
group: "Live".intern(),
xtream_cluster: XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..Default::default()
},
}
}
#[test]
fn after_epg_hook_runs_on_source_already_deduplicated_by_processing_pipe() {
let processing = build_mapping("processing", MappingStage::Processing, r#"@group = "PROCESSED""#);
let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
let target = build_target(vec![processing, after_epg], true);
let input = ConfigInput::default();
let channel = make_channel("Alpha");
let mut fetched =
FetchedPlaylist { input: &input, source: memory_source(vec![channel.clone(), channel]), epg: None };
let mut duplicates = HashSet::new();
let (mut processed, _outcome) =
execute_pipe(&target, &get_processing_pipe(&target), &mut fetched, &mut duplicates, false, None)
.expect("processing pipe must run");
assert_eq!(processed.get_channel_count(), 1, "processing pipe must remove the duplicate");
let groups = map_playlist_at_stage(&mut processed.source, &target, MappingStage::AfterEpg, None)
.expect("after_epg hook must run");
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "PROCESSED");
assert_eq!(groups[0].channels.len(), 1);
assert_eq!(groups[1].title.as_ref(), "Echo");
assert_eq!(groups[1].channels.len(), 1);
}
#[test]
fn prepare_target_deduplicates_virtual_items_created_by_after_epg_mappings() {
let runtime = Runtime::new().expect("runtime");
runtime.block_on(async {
let first = build_mapping("first", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
let second = build_mapping("second", MappingStage::AfterEpg, r#"add_favourite("Echo")"#);
let target = build_target(vec![first, second], true);
let input = ConfigInput { name: "input".intern(), ..Default::default() };
let mut playlist =
FetchedPlaylist { input: &input, source: memory_source(vec![make_channel("Alpha")]), epg: None };
let mut stats =
HashMap::from([(Arc::clone(&input.name), create_input_stat(1, 1, 0, input.input_type, &input.name, 0))]);
let mut errors = Vec::new();
let prepared = prepare_playlist_for_target(
&processing_context(),
std::slice::from_mut(&mut playlist),
&target,
&mut stats,
&mut errors,
ClusterFlags::empty(),
false,
)
.await
.expect("target preparation");
assert!(errors.is_empty());
assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::<usize>(), 3);
assert_eq!(stats[&input.name].processed_stats.channel_count, 3);
});
}
@@ -0,0 +1,194 @@
use super::{
apply_staged_overlay_groups, neutralize_overlaid_cluster_facts, playlist_update_run_result,
report_input_job_completion, test_group, InputJobState, PlaylistRunCollectSink,
};
use shared::{
error::TuliproxError,
model::{
ClusterFlags, EventMessage, InputRefreshPolicy, InputType, PlaylistUpdateClusterDecision,
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry, PlaylistUpdateRunId,
PlaylistUpdateRunOrder, PlaylistUpdateState, XtreamCluster,
},
utils::Internable,
};
use tuliprox_core::model::{ConfigInput, ConfigInputOptions};
#[test]
fn playlist_update_run_input_completion_uses_existing_success_partial_failure_classification() {
let success = playlist_update_run_result(1, InputJobState::Ready, Vec::new(), false);
let rejection = playlist_update_run_result(2, InputJobState::Ready, Vec::new(), true);
let resumable = playlist_update_run_result(3, InputJobState::Pending, Vec::new(), false);
let failure = playlist_update_run_result(
4,
InputJobState::Ready,
vec![TuliproxError::RepositoryPlaylist("technical".to_string())],
false,
);
assert_eq!(success.update_state(), PlaylistUpdateState::Success);
assert_eq!(rejection.update_state(), PlaylistUpdateState::Partial);
assert_eq!(resumable.update_state(), PlaylistUpdateState::Partial);
assert_eq!(failure.update_state(), PlaylistUpdateState::Failure);
}
#[test]
fn pipeline_transparency_input_completion_reuses_correlated_progress_event_for_typed_telemetry() {
let events = PlaylistRunCollectSink::default();
let run_id = PlaylistUpdateRunId::from("pipeline-transparency-run");
let execution_order = PlaylistUpdateRunOrder::from(19);
let mut result = playlist_update_run_result(7, InputJobState::Ready, Vec::new(), true);
let telemetry = PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::NORMAL,
source: None,
clusters: vec![PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Video,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(12_543),
candidate_count: Some(217),
active_count: Some(12_543),
threshold: Some(90),
quality: Some(1),
decision: Some(PlaylistUpdateClusterDecision::Rejected),
technical_state: None,
}],
};
result.input_telemetry = Some(telemetry.clone());
report_input_job_completion(&events, &run_id, execution_order, &result);
let emitted = events.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let EventMessage::PlaylistUpdateProgress(progress) = &emitted[0] else {
panic!("expected existing playlist progress event");
};
assert_eq!(progress.run_id.as_ref(), Some(&run_id));
assert_eq!(progress.execution_order, Some(execution_order));
assert_eq!(progress.input_id, Some(7));
assert_eq!(progress.state, Some(PlaylistUpdateState::Partial));
assert_eq!(progress.input_telemetry, Some(telemetry));
}
#[test]
fn playlist_update_run_technical_failure_progress_is_correlated_sanitized_and_input_scoped() {
let events = PlaylistRunCollectSink::default();
let run_id = PlaylistUpdateRunId::from("processing-run");
let execution_order = PlaylistUpdateRunOrder::from(17);
let failed = playlist_update_run_result(
7,
InputJobState::Failed,
vec![TuliproxError::RepositoryPlaylist("https://user:secret@provider.example/playlist".to_string())],
false,
);
let succeeded = playlist_update_run_result(8, InputJobState::Ready, Vec::new(), false);
report_input_job_completion(&events, &run_id, execution_order, &failed);
report_input_job_completion(&events, &run_id, execution_order, &succeeded);
let emitted = events.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let progress = emitted
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) => Some(progress),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(progress.len(), 2);
assert!(progress.iter().all(|event| event.run_id.as_ref() == Some(&run_id)));
assert!(progress.iter().all(|event| event.execution_order == Some(execution_order)));
assert_eq!(progress[0].input_id, Some(7));
assert_eq!(progress[0].state, Some(PlaylistUpdateState::Failure));
assert_eq!(progress[0].message, "Input 'input-7' failed during update");
assert!(!progress[0].message.contains("secret"));
assert!(!progress[0].message.contains("previous accepted data"));
assert_eq!(progress[1].input_id, Some(8));
assert_eq!(progress[1].state, Some(PlaylistUpdateState::Success));
assert!(!progress[1].message.contains("failed during update"));
}
#[test]
fn pipeline_transparency_staged_overlay_replaces_groups_and_neutralizes_overlaid_runtime_facts() {
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 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, ClusterFlags::Live, provider_groups, staged_groups);
assert_eq!(groups.len(), 2);
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[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 {
refresh_policy: InputRefreshPolicy::NORMAL,
source: None,
clusters: vec![
PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Live,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(100),
candidate_count: Some(95),
active_count: Some(95),
threshold: Some(90),
quality: Some(95),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
},
PlaylistUpdateClusterTelemetry {
cluster: XtreamCluster::Video,
requested: true,
source: Some(PlaylistUpdateDataSource::Provider),
baseline_count: Some(200),
candidate_count: Some(200),
active_count: Some(200),
threshold: Some(100),
quality: Some(100),
decision: Some(PlaylistUpdateClusterDecision::Accepted),
technical_state: None,
},
],
};
neutralize_overlaid_cluster_facts(&mut telemetry, ClusterFlags::Live);
let live =
telemetry.clusters.iter().find(|cluster| cluster.cluster == XtreamCluster::Live).expect("Live telemetry");
assert!(live.requested);
assert_eq!(live.threshold, Some(90));
assert_eq!(live.source, None);
assert_eq!(live.baseline_count, None);
assert_eq!(live.candidate_count, None);
assert_eq!(live.active_count, None);
assert_eq!(live.quality, None);
assert_eq!(live.decision, None);
let video =
telemetry.clusters.iter().find(|cluster| cluster.cluster == XtreamCluster::Video).expect("Video telemetry");
assert_eq!(video.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(video.baseline_count, Some(200));
assert_eq!(video.candidate_count, Some(200));
assert_eq!(video.active_count, Some(200));
assert_eq!(video.quality, Some(100));
assert_eq!(video.decision, Some(PlaylistUpdateClusterDecision::Accepted));
}
@@ -0,0 +1,123 @@
use super::{collect_effective_skip_clusters, has_probe_details, needs_live_probe};
use shared::{
model::{InputType, PlaylistItem, StreamProperties, XtreamCluster},
utils::Internable,
};
use tuliprox_core::model::ConfigInput;
pub(in crate::processor::playlist::tests) fn item_with_props(props: StreamProperties) -> PlaylistItem {
let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() };
PlaylistItem { header }
}
pub(in crate::processor::playlist::tests) fn live_item_with_probe_timestamp_and_bitrate(
last_probed_timestamp: i64,
bitrate: u32,
) -> PlaylistItem {
item_with_props(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: Some("{\"codec_name\":\"aac\"}".intern()),
bitrate,
last_probed_timestamp: Some(last_probed_timestamp),
..Default::default()
})))
}
#[test]
fn has_probe_details_requires_video_and_audio_for_video() {
let video = shared::model::VideoStreamProperties {
details: Some(shared::model::VideoStreamDetailProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: None,
..Default::default()
}),
..Default::default()
};
let item_missing_audio = item_with_props(StreamProperties::Video(Box::new(video)));
assert!(!has_probe_details(&item_missing_audio));
let video_complete = shared::model::VideoStreamProperties {
details: Some(shared::model::VideoStreamDetailProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: Some("{\"codec_name\":\"aac\"}".intern()),
..Default::default()
}),
..Default::default()
};
let item_complete = item_with_props(StreamProperties::Video(Box::new(video_complete)));
assert!(has_probe_details(&item_complete));
}
#[test]
fn has_probe_details_requires_video_audio_and_bitrate_for_live() {
let live_missing_audio = shared::model::LiveStreamProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: None,
..Default::default()
};
let item_missing_audio = item_with_props(StreamProperties::Live(Box::new(live_missing_audio)));
assert!(!has_probe_details(&item_missing_audio));
let live_missing_bitrate = shared::model::LiveStreamProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: Some("{\"codec_name\":\"aac\"}".intern()),
..Default::default()
};
let item_missing_bitrate = item_with_props(StreamProperties::Live(Box::new(live_missing_bitrate)));
assert!(!has_probe_details(&item_missing_bitrate));
let live_complete = shared::model::LiveStreamProperties {
video: Some("{\"codec_name\":\"h264\"}".intern()),
audio: Some("{\"codec_name\":\"aac\"}".intern()),
bitrate: 2_500_000,
..Default::default()
};
let item_complete = item_with_props(StreamProperties::Live(Box::new(live_complete)));
assert!(has_probe_details(&item_complete));
}
#[test]
fn needs_live_probe_when_fresh_probe_has_no_bitrate() {
let item = live_item_with_probe_timestamp_and_bitrate(101, 0);
assert!(needs_live_probe(&item, 100));
}
#[test]
fn does_not_need_live_probe_when_fresh_probe_has_positive_bitrate() {
let item = live_item_with_probe_timestamp_and_bitrate(101, 2_500_000);
assert!(!needs_live_probe(&item, 100));
}
#[test]
fn needs_live_probe_when_positive_bitrate_probe_is_older_than_cutoff() {
let item = live_item_with_probe_timestamp_and_bitrate(99, 2_500_000);
assert!(needs_live_probe(&item, 100));
}
#[test]
fn has_probe_details_is_false_for_series() {
let series = shared::model::SeriesStreamProperties::default();
let item = item_with_props(StreamProperties::Series(Box::new(series)));
assert!(!has_probe_details(&item));
}
#[test]
fn collect_effective_skip_clusters_uses_input_skip_flags() {
use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions};
let input = ConfigInput {
name: "skip_live".intern(),
input_type: InputType::Xtream,
options: Some(ConfigInputOptions {
flags: ConfigInputFlags::SkipLive.into(),
..ConfigInputOptions::defaults().clone()
}),
..ConfigInput::default()
};
let skip = collect_effective_skip_clusters(&input);
assert!(skip.contains(&XtreamCluster::Live));
assert!(!skip.contains(&XtreamCluster::Video));
assert!(!skip.contains(&XtreamCluster::Series));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,509 @@
use super::*;
use std::fmt::Write;
use tuliprox_repository::{load_input_m3u_playlist, raw_group_catalog_path, RawInputGroupCatalog};
fn document(prefix: &str, counts: [usize; 3]) -> String {
let mut text = String::from("#EXTM3U\n");
for (cluster, count) in XTREAM_CLUSTER.into_iter().zip(counts) {
for index in 0..count {
let (kind, group, title) = match cluster {
XtreamCluster::Live => ("live", format!("{prefix}-live"), format!("Channel {index}")),
XtreamCluster::Video => ("movie", format!("{prefix}-vod"), format!("Movie {index}")),
XtreamCluster::Series => ("series", format!("{prefix}-show-{index}"), format!("Show {index} S01E01")),
};
let id = match cluster {
XtreamCluster::Live => 1000,
XtreamCluster::Video => 2000,
XtreamCluster::Series => 3000,
} + index;
writeln!(text, "#EXTGRP:{prefix}-{cluster}\n#EXTINF:-1 xui-id=\"{id}\" tvg-type=\"{kind}\" group-title=\"{group}\",{title}\nhttp://stream.example/{prefix}/{cluster}/{id}").unwrap();
}
}
text
}
async fn fixture(
root: &Path,
previous: Option<[usize; 3]>,
thresholds: [u8; 3],
) -> (PlaylistProcessingContext<shared::model::NoopSink>, Arc<ConfigInput>) {
let ctx = processing_context(root);
let input = Arc::new(ConfigInput {
id: 27,
name: "m3u-quality".intern(),
input_type: InputType::M3u,
enabled: true,
url: root.join("provider.m3u").to_string_lossy().into_owned(),
options: Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
update_quality: ConfigInputUpdateQualityDto {
live: thresholds[0],
vod: thresholds[1],
series: thresholds[2],
},
..ConfigInputOptionsDto::default()
})),
..ConfigInput::default()
});
if let Some(counts) = previous {
tokio::fs::write(&input.url, document("previous", counts)).await.unwrap();
let (groups, errors) =
tuliprox_iptv::m3u::download_m3u_playlist(&ctx.config, &ctx.client, &ctx.config.config.load(), &input)
.await;
assert!(errors.is_empty());
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, groups).await;
assert!(error.is_none(), "{error:?}");
}
(ctx, input)
}
async fn run(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &Arc<ConfigInput>,
counts: [usize; 3],
) -> InputDownloadResult {
tokio::fs::write(&input.url, document("candidate", counts)).await.unwrap();
download_input(ctx, input, false).await
}
fn counts(groups: &[PlaylistGroup]) -> [usize; 3] {
XTREAM_CLUSTER.map(|cluster| groups.iter().filter(|g| g.xtream_cluster == cluster).map(|g| g.channels.len()).sum())
}
async fn verify_effective(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &ConfigInput,
result: &mut InputDownloadResult,
expected: [usize; 3],
) {
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert!(result.storage_error.is_none(), "{:?}", result.storage_error);
assert_eq!(result.job_state(), InputJobState::Ready);
let effective = result.source.take_groups();
assert_eq!(counts(&effective), expected);
let storage =
tuliprox_repository::get_input_storage_path(&input.name, &ctx.config.config.load().storage_dir).await.unwrap();
let saved = load_input_m3u_playlist(
&ctx.config,
&tuliprox_repository::get_input_m3u_playlist_file_path(&storage, &input.name),
)
.await
.unwrap();
assert_eq!(counts(&saved), expected);
for cluster in XTREAM_CLUSTER {
let catalog: RawInputGroupCatalog =
serde_json::from_slice(&tokio::fs::read(raw_group_catalog_path(&storage, cluster)).await.unwrap()).unwrap();
let mut expected_groups: Vec<_> =
effective.iter().filter(|g| g.xtream_cluster == cluster).map(|g| g.title.to_string()).collect();
expected_groups.sort();
expected_groups.dedup();
assert_eq!(catalog.groups, expected_groups);
}
}
#[tokio::test]
async fn m3u_update_quality_all_clusters_accepted() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [9, 11, 10]).await;
assert!(result.quality_rejections.is_empty());
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
verify_effective(&ctx, &input, &mut result, [9, 11, 10]).await;
}
async fn order_candidate(
ctx: &PlaylistProcessingContext<shared::model::NoopSink>,
input: &ConfigInput,
prefix: &str,
) -> tuliprox_iptv::provider::PlaylistFetch {
let mut body = String::from("#EXTM3U\n");
for (id, kind, group) in [
(2000, "movie", "obsolete"),
(2000, "movie", "movies-a"),
(1000, "live", "live-a"),
(2001, "movie", "movies-b"),
(1001, "live", "live-b"),
(3000, "series", "shows-a"),
(3001, "series", "shows-b"),
] {
writeln!(body, "#EXTGRP:{prefix}-{group}\n#EXTINF:-1 tvg-type=\"{kind}\" group-title=\"{prefix}-{group}\",Item {id} S01E01\nhttp://stream.example/{kind}/{id}").unwrap();
}
tokio::fs::write(&input.url, body).await.unwrap();
let (groups, errors) =
tuliprox_iptv::m3u::download_m3u_playlist(&ctx.config, &ctx.client, &ctx.config.config.load(), input).await;
assert!(errors.is_empty());
assert_eq!(
group_names(&groups),
["obsolete", "movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"]
.map(|name| format!("{prefix}-{name}"))
);
tuliprox_iptv::provider::PlaylistFetch::groups(groups)
}
fn group_names(groups: &[PlaylistGroup]) -> Vec<String> { groups.iter().map(|group| group.title.to_string()).collect() }
#[tokio::test]
async fn m3u_update_quality_preserves_candidate_group_order_when_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [0, 0, 0]).await;
let candidate = order_candidate(&ctx, &input, "candidate").await;
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty());
assert!(effective.quality_acceptances.is_empty() && effective.quality_rejections.is_empty());
assert_eq!(
group_names(&effective.groups),
["movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"].map(|name| format!("candidate-{name}"))
);
assert_eq!(counts(&effective.groups), [2, 2, 2], "disabled quality processing must collapse duplicate keys");
}
#[tokio::test]
async fn m3u_update_quality_preserves_candidate_group_order_when_all_clusters_accepted() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([2, 2, 2]), [100, 100, 100]).await;
let candidate = order_candidate(&ctx, &input, "candidate").await;
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty() && effective.quality_rejections.is_empty());
assert_eq!(effective.quality_acceptances.len(), 3);
assert_eq!(
group_names(&effective.groups),
["movies-a", "live-a", "movies-b", "live-b", "shows-a", "shows-b"].map(|name| format!("candidate-{name}"))
);
}
#[tokio::test]
async fn m3u_update_quality_mixed_fallback_preserves_relative_group_order() {
for _ in 0..3 {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [100, 100, 100]).await;
let mut previous = order_candidate(&ctx, &input, "previous").await.groups;
previous.retain(|group| group.title.as_ref() != "previous-obsolete");
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, previous).await;
assert!(error.is_none());
let mut previous = load_input_playlist(&ctx.config, &input, None).await.unwrap();
let previous = previous.take_groups();
assert_eq!(
previous
.iter()
.filter(|group| group.xtream_cluster == XtreamCluster::Series)
.map(|group| group.title.to_string())
.collect::<Vec<_>>(),
["previous-shows-a", "previous-shows-b"]
);
let mut candidate = order_candidate(&ctx, &input, "candidate").await;
candidate.groups.retain(|group| group.title.as_ref() != "candidate-shows-b");
let effective = super::super::super::m3u_quality::effective_m3u_fetch(
&ctx.config,
&input,
shared::model::UpdateQualityPolicy::Enforce,
candidate,
)
.await;
assert!(effective.errors.is_empty());
assert_eq!(effective.quality_rejections.len(), 1);
assert_eq!(effective.quality_rejections[0].cluster, XtreamCluster::Series);
assert_eq!(
group_names(&effective.groups),
[
"candidate-movies-a",
"candidate-live-a",
"candidate-movies-b",
"candidate-live-b",
"previous-shows-a",
"previous-shows-b"
]
);
}
}
#[tokio::test]
async fn m3u_update_quality_retains_only_rejected_cluster() {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
let mut result = run(&ctx, &input, [9, 11, 8]).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Partial);
assert_eq!(result.quality_rejections.len(), 1);
assert_eq!(result.quality_rejections[0].cluster, XtreamCluster::Series);
verify_effective(&ctx, &input, &mut result, [9, 11, 10]).await;
}
}
#[tokio::test]
async fn m3u_update_quality_zero_candidate_retains_previous_cluster() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [10, 10, 0]).await;
assert_eq!(result.quality_rejections[0].candidate_count, 0);
verify_effective(&ctx, &input, &mut result, [10, 10, 10]).await;
}
#[tokio::test]
async fn m3u_update_quality_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [0, 0, 0]).await;
let mut result = run(&ctx, &input, [1, 1, 0]).await;
assert!(result.quality_rejections.is_empty());
assert!(result
.input_telemetry
.as_ref()
.unwrap()
.clusters
.iter()
.all(|c| c.quality.is_none() && c.threshold.is_none()));
verify_effective(&ctx, &input, &mut result, [1, 1, 0]).await;
}
#[tokio::test]
async fn m3u_update_quality_first_import() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [90, 90, 90]).await;
let mut result = run(&ctx, &input, [1, 1, 1]).await;
assert!(result.quality_rejections.is_empty());
assert!(result.input_telemetry.as_ref().unwrap().clusters.iter().all(|c| c.quality.is_none()
&& c.baseline_count.is_none()
&& c.decision == Some(PlaylistUpdateClusterDecision::Accepted)));
verify_effective(&ctx, &input, &mut result, [1, 1, 1]).await;
}
#[tokio::test]
async fn m3u_update_quality_download_or_parse_failure() {
for body in [None, Some("<html>provider unavailable</html>"), Some("#EXTM3U\n#EXTINF:-1,Truncated\n")] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
if let Some(body) = body {
tokio::fs::write(&input.url, body).await.unwrap();
} else {
tokio::fs::remove_file(&input.url).await.unwrap();
}
let mut result = download_input(&ctx, &input, false).await;
assert!(!result.errors.is_empty());
assert!(result.quality_rejections.is_empty());
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [10, 10, 10]);
}
}
#[tokio::test]
async fn m3u_update_quality_empty_bootstrap_and_structural_empty_use_existing_domain() {
for previous in [None, Some([10, 10, 0])] {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), previous, [90, 90, 90]).await;
let mut result = run(&ctx, &input, if previous.is_some() { [10, 10, 0] } else { [0, 0, 0] }).await;
if previous.is_none() {
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert_eq!(result.quality_rejections.len(), 3);
} else {
assert_eq!(result.update_state(), PlaylistUpdateState::Partial);
assert_eq!(result.quality_rejections.len(), 1);
assert_eq!(result.quality_rejections[0].current_count, 0);
assert_eq!(result.quality_rejections[0].cluster, XtreamCluster::Series);
}
}
}
#[tokio::test]
async fn m3u_update_quality_skip_preserves_previous_and_reuse_emits_no_new_facts() {
let temp = tempfile::tempdir().unwrap();
let (ctx, mut input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
Arc::make_mut(&mut input).options.as_mut().unwrap().flags.set(ConfigInputFlags::SkipSeries);
let mut result = run(&ctx, &input, [10, 10, 0]).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.quality_rejections.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&result.source.take_groups()), [10, 10, 0]);
assert_eq!(counts(&saved.take_groups()), [10, 10, 10]);
let telemetry = result.input_telemetry.unwrap();
assert!(!telemetry.clusters.iter().find(|c| c.cluster == XtreamCluster::Series).unwrap().requested);
tokio::fs::remove_file(&input.url).await.unwrap();
let reused = download_input(&ctx, &input, false).await;
assert!(reused.errors.is_empty());
assert!(reused.input_telemetry.is_none());
}
#[tokio::test]
async fn m3u_update_quality_force_bypasses_guard_and_cache_without_inventing_quality() {
for threshold in [0, 95] {
for policy in [InputRefreshPolicy::REFRESH, InputRefreshPolicy::FORCE] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, mut input) = fixture(temp.path(), Some([10, 10, 10]), [threshold; 3]).await;
Arc::make_mut(&mut input).cache_duration_seconds = 3_600;
let storage = input_storage_path(&ctx, &input).await;
let mut status = input_cache::InputStatus::default();
input_cache::update_cluster_status(&mut status, "default", input_cache::ClusterState::Ok);
input_cache::save_input_status(&storage, &status);
set_refresh_policy(&mut ctx, &input, policy);
let mut result = run(&ctx, &input, [1, 1, 1]).await;
let rejected = threshold > 0 && policy == InputRefreshPolicy::REFRESH;
assert_eq!(
result.update_state(),
if rejected { PlaylistUpdateState::Partial } else { PlaylistUpdateState::Success }
);
assert_eq!(counts(&result.source.take_groups()), if rejected { [10; 3] } else { [1; 3] });
let telemetry = result.input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.refresh_policy, policy);
assert!(telemetry
.clusters
.iter()
.all(|cluster| cluster.source == Some(PlaylistUpdateDataSource::Provider)));
let saved = input_cache::load_input_status(&storage);
for cluster in &telemetry.clusters {
if policy == InputRefreshPolicy::FORCE {
assert_eq!(cluster.decision, Some(PlaylistUpdateClusterDecision::Accepted));
assert_eq!((cluster.baseline_count, cluster.quality), (None, None));
assert_eq!((cluster.candidate_count, cluster.active_count), (Some(1), Some(1)));
let snapshot = saved.clusters[cluster.cluster.as_ref()].last_update.unwrap();
assert_eq!(snapshot.quality, None);
assert_eq!(snapshot.quality_guard_threshold, Some(threshold));
assert_eq!(snapshot.policy, Some(policy));
}
}
let reused = download_input(&ctx, &input, false).await;
assert!(reused.input_telemetry.is_none(), "in-run reuse must not create another Force acquisition");
}
}
}
#[tokio::test]
async fn m3u_update_quality_force_keeps_parse_and_empty_publication_errors() {
for body in ["#EXTM3U\n", "#EXTM3U\n#EXTINF:-1,Truncated\n"] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, input) = fixture(temp.path(), Some([10; 3]), [95; 3]).await;
set_refresh_policy(&mut ctx, &input, InputRefreshPolicy::FORCE);
tokio::fs::write(&input.url, body).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(result.quality_rejections.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [10; 3]);
}
}
#[tokio::test]
async fn m3u_update_quality_target_processing_uses_retained_and_accepted_groups() {
for policy in [InputRefreshPolicy::NORMAL, InputRefreshPolicy::FORCE] {
let temp = tempfile::tempdir().unwrap();
let (mut ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
set_refresh_policy(&mut ctx, &input, policy);
tokio::fs::write(&input.url, document("candidate", [10, 0, 10])).await.unwrap();
let output = temp.path().join("client.m3u");
let mut target = ConfigTarget::from(&ConfigTargetDto {
id: 1,
name: "m3u-target".into(),
enabled: true,
..ConfigTargetDto::default()
});
target.filter = get_filter(r#"Name ~ ".*""#, None).unwrap().into();
target.output = vec![TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
filename: Some(output.to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![input.clone()],
sources: vec![ConfigSource { inputs: vec![input.name.clone()], targets: vec![Arc::new(target)] }],
..SourcesConfig::default()
}));
let (_, targets, errors) = process_source(0, Arc::new(ctx)).await;
assert!(errors.is_empty(), "{errors:?}");
assert_eq!(targets.len(), 1);
let published = tokio::fs::read_to_string(output).await.unwrap();
assert!(published.contains("/candidate/live/") && published.contains("/candidate/series/"), "{published}");
assert_eq!(published.contains("/previous/video/"), policy == InputRefreshPolicy::NORMAL, "{published}");
assert!(!published.contains("/previous/live/") && !published.contains("/candidate/video/"));
}
}
#[tokio::test]
async fn m3u_update_quality_raw_group_catalog_uses_effective_state() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([10, 10, 10]), [90, 90, 90]).await;
let mut result = run(&ctx, &input, [10, 10, 0]).await;
let storage = input_cache::resolve_input_storage_path(&ctx.config.config.load().storage_dir, &input.name).await;
let status = input_cache::load_input_status(&storage);
for cluster in XTREAM_CLUSTER {
let saved = &status.clusters[cluster.as_ref()];
let rejected = cluster == XtreamCluster::Series;
assert_eq!(
saved.status,
if rejected { input_cache::ClusterState::Failed } else { input_cache::ClusterState::Ok }
);
let snapshot = saved.last_update.unwrap();
assert_eq!(
snapshot.quality.unwrap().decision,
if rejected {
PersistedPlaylistUpdateQualityDecision::Rejected
} else {
PersistedPlaylistUpdateQualityDecision::Accepted
}
);
assert_eq!(snapshot.active_count, Some(10));
}
assert_eq!(status.clusters["default"].status, input_cache::ClusterState::Failed);
let series: RawInputGroupCatalog = serde_json::from_slice(
&tokio::fs::read(raw_group_catalog_path(&storage, XtreamCluster::Series)).await.unwrap(),
)
.unwrap();
assert_eq!(series.groups, ["previous-series"]);
verify_effective(&ctx, &input, &mut result, [10, 10, 10]).await;
}
#[tokio::test]
async fn m3u_update_quality_duplicate_keys_match_the_existing_persisted_population() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([1, 1, 0]), [100, 100, 0]).await;
let candidate = document("superseded", [1, 1, 0]) + &document("last", [1, 1, 0]);
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.source.take_groups().iter().all(|group| group.title.starts_with("last")));
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
}
#[tokio::test]
async fn m3u_update_quality_duplicate_keys_match_the_existing_persisted_population_when_disabled() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), None, [0, 0, 0]).await;
let candidate = document("superseded", [1, 1, 0]) + &document("last", [1, 1, 0]);
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Success);
assert!(result.source.take_groups().iter().all(|group| group.title.starts_with("last")));
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
}
#[tokio::test]
async fn m3u_update_quality_conflicting_retained_key_cannot_diverge_from_storage() {
let temp = tempfile::tempdir().unwrap();
let (ctx, input) = fixture(temp.path(), Some([1, 1, 0]), [90, 0, 0]).await;
let candidate = document("candidate", [0, 1, 0]).replace("2000", "1000");
tokio::fs::write(&input.url, candidate).await.unwrap();
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(!result.errors.is_empty());
let mut saved = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert_eq!(counts(&saved.take_groups()), [1, 1, 0]);
let storage = input_storage_path(&ctx, &input).await;
let catalog: RawInputGroupCatalog =
serde_json::from_slice(&tokio::fs::read(raw_group_catalog_path(&storage, XtreamCluster::Live)).await.unwrap())
.unwrap();
assert_eq!(catalog.groups, ["previous-live"]);
}
@@ -0,0 +1,700 @@
use super::*;
use shared::model::{ConfigRenameDto, ItemField, LibraryConfigDto, LibraryContentType, LibraryScanResult};
use tuliprox_core::model::{LibraryConfig, LibraryScanDirectory, M3uTargetOutput, MetadataUpdateConfig};
async fn library_context(root: &Path, order: ProcessingOrder) -> PlaylistProcessingContext<CollectSink> {
let ctx = processing_context_with_events(root, CollectSink::default());
let media = root.join("media");
tokio::fs::create_dir_all(&media).await.unwrap();
tokio::fs::write(media.join("Library Movie (2025).mp4"), b"fixture").await.unwrap();
let companion_path = root.join("companion.m3u");
tokio::fs::write(&companion_path, "#EXTM3U\n#EXTINF:-1,Companion\nhttp://stream.example/companion\n")
.await
.unwrap();
let mut config = (**ctx.config.config.load()).clone();
let mut library = LibraryConfig::from(&LibraryConfigDto { enabled: true, ..LibraryConfigDto::default() });
library.metadata.fallback_to_filename = true;
library.thumbnails.enabled = false;
library.scan_directories = vec![LibraryScanDirectory {
enabled: true,
path: media.to_string_lossy().into_owned(),
content_type: LibraryContentType::Movie,
recursive: true,
}];
config.library = Some(library);
let mut metadata = MetadataUpdateConfig::default();
metadata.ffprobe.enabled = false;
metadata.tmdb.enabled = false;
config.metadata_update = Some(metadata);
ctx.config.config.store(Arc::new(config));
let library_input = Arc::new(ConfigInput {
id: 2,
name: "local".intern(),
input_type: InputType::Library,
enabled: true,
cache_duration_seconds: 3600,
..ConfigInput::default()
});
let companion = Arc::new(ConfigInput {
id: 3,
name: "companion".intern(),
input_type: InputType::M3u,
enabled: true,
url: companion_path.to_string_lossy().into_owned(),
..ConfigInput::default()
});
let mut target = ConfigTarget::from(&ConfigTargetDto {
id: 20,
enabled: true,
name: "selected".into(),
processing_order: order,
rename: Some(vec![ConfigRenameDto {
field: ItemField::Name,
pattern: "^MAPPED$".into(),
new_name: "RENAMED".into(),
t_pattern: None,
}]),
..ConfigTargetDto::default()
});
target.output = vec![TargetOutput::M3u(M3uTargetOutput {
filename: Some(root.join("selected.m3u").to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
target.filter = get_filter(r#"Name ~ ".*""#, None).unwrap().into();
let mapped = super::super::mapping_stage::build_target(
vec![super::super::mapping_stage::build_mapping("manual", MappingStage::Processing, r#"@Name = "MAPPED""#)],
false,
);
target.mapping = mapped.mapping;
let mut unselected = target.clone();
unselected.id = 21;
unselected.name = "unselected".into();
unselected.output = vec![TargetOutput::M3u(M3uTargetOutput {
filename: Some(root.join("unselected.m3u").to_string_lossy().into_owned()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![library_input.clone(), companion.clone()],
sources: vec![ConfigSource {
inputs: vec![library_input.name.clone(), companion.name.clone()],
targets: vec![Arc::new(target), Arc::new(unselected)],
}],
..SourcesConfig::default()
}));
ctx
}
fn rescan_run(ctx: &PlaylistProcessingContext<CollectSink>, guard: UpdateGuard) -> ProcessingRun<CollectSink> {
ProcessingRun::for_run(
"manual-library-run".into(),
ctx.client.clone(),
ctx.config.clone(),
Arc::new(ProcessTargets {
enabled: true,
inputs: vec![2, 3],
targets: vec![20],
target_names: vec!["selected".into()],
}),
ctx.events.clone(),
)
.with_update_guard(guard)
.with_manual_input_update(Some(InputUpdateRequest { input_id: 2, action: InputUpdateAction::Rescan }))
}
fn scan_results(ctx: &PlaylistProcessingContext<CollectSink>) -> Vec<LibraryScanResult> {
ctx.events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(2) => {
if progress.library_scan_result.is_some() {
assert_eq!(progress.run_id.as_ref().map(PlaylistUpdateRunId::as_ref), Some("manual-library-run"));
assert!(progress.execution_order.is_some());
}
progress.library_scan_result.clone()
}
_ => None,
})
.collect()
}
fn assert_run_state(ctx: &PlaylistProcessingContext<CollectSink>, state: PlaylistUpdateState) {
let events = ctx.events.0.lock().unwrap();
assert!(
events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == state)),
"{events:?}"
);
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(2) && progress.state == Some(state))), "{events:?}");
}
#[tokio::test]
async fn manual_update_library_empty_rescan_persists_zero_to_zero_and_nonzero_to_zero_with_companion() {
for disk in [false, true] {
for previous_population in [false, true] {
for companion in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
if !companion {
let mut sources = (**ctx.config.sources.load()).clone();
sources.sources[0].inputs.retain(|name| name.as_ref() == "local");
ctx.config.sources.store(Arc::new(sources));
}
if previous_population {
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
ctx.events.0.lock().unwrap().clear();
}
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
for name in std::iter::once("local").chain(companion.then_some("companion")) {
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), name).await;
let status = input_cache::load_input_status(&path);
assert_eq!(status.last_input_update.unwrap().state, PlaylistUpdateState::Success, "{name}");
}
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 0,
groups_scanned: 0,
files_added: 0,
files_updated: 0,
files_removed: usize::from(previous_population),
errors: 0,
}]
);
let output = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(output.matches("#EXTINF").count(), usize::from(companion), "{output}");
assert_eq!(output.contains("http://stream.example/companion"), companion);
assert!(!output.contains("Library%20Movie") && !output.contains("file://"));
assert!(!temp.path().join("unselected.m3u").exists());
let input = ctx.config.sources.load().inputs[0].clone();
let mut persisted = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert!(persisted.is_empty(), "Empty Library must replace its old persisted input");
assert!(!ctx.events.0.lock().unwrap().iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.message.contains("playlist is empty"))));
// Same canonical empty input remains readable on subsequent cache/reuse paths.
let first = download_input(&ctx, &input, false).await;
assert!(first.errors.is_empty() && first.storage_error.is_none());
let mut reuse = download_input(&ctx, &input, false).await;
assert_eq!(reuse.job_state(), InputJobState::Ready);
assert_eq!(reuse.input_telemetry, None, "In-run reuse must not emit another acquisition");
assert_eq!(scan_results(&ctx).len(), 1, "Reuse must not manufacture a second rescan");
}
}
}
}
#[tokio::test]
async fn coderabbit_library_unavailable_is_not_an_authoritative_empty_catalog() {
for disabled in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let mut input = (*ctx.config.sources.load().inputs[0]).clone();
input.cache_duration_seconds = 0;
let input = Arc::new(input);
let storage = input_storage_path(&ctx, &input).await;
let file = tuliprox_repository::get_input_local_library_playlist_file_path(&storage, &input.name);
let before = tokio::fs::read(&file).await.unwrap();
let output = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
let mut config = (**ctx.config.config.load()).clone();
if disabled {
config.library.as_mut().unwrap().enabled = false;
} else {
config.library = None;
}
ctx.config.config.store(Arc::new(config));
let mut result = download_input(&ctx, &input, false).await;
assert_eq!(result.job_state(), InputJobState::Failed);
assert_eq!(result.update_state(), PlaylistUpdateState::Failure);
assert!(!result.authoritative_library);
assert!(!result.errors.is_empty());
assert_eq!(tokio::fs::read(&file).await.unwrap(), before);
assert_eq!(tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap(), output);
}
}
#[tokio::test]
async fn coderabbit_library_missing_input_triggers_catalog_recovery_not_empty_success() {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let input = ctx.config.sources.load().inputs[0].clone();
let storage = input_storage_path(&ctx, &input).await;
let file = tuliprox_repository::get_input_local_library_playlist_file_path(&storage, &input.name);
tokio::fs::remove_file(&file).await.unwrap();
assert!(load_input_playlist(&ctx.config, &input, None).await.is_err());
let mut recovered = download_input(&ctx, &input, false).await;
assert_eq!(recovered.job_state(), InputJobState::Ready);
assert!(recovered.errors.is_empty() && recovered.storage_error.is_none());
assert!(!recovered.source.is_empty(), "missing cache must be repaired from the populated catalog");
assert!(file.exists());
let mut persisted = load_input_playlist(&ctx.config, &input, None).await.unwrap();
assert!(!persisted.is_empty());
let media_server = ConfigInput { input_type: InputType::Plex, ..(*input).clone() };
assert!(
load_input_playlist(&ctx.config, &media_server, None).await.is_err(),
"the shared loader must not turn a missing Plex cache into a successful empty input"
);
}
}
#[tokio::test]
async fn manual_update_library_scan_errors_keep_real_result_and_block_rebuild() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.library.as_mut().unwrap().metadata.fallback_to_filename = false;
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Failure);
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, PlaylistUpdateState::Failure);
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 1,
groups_scanned: 1,
files_added: 0,
files_updated: 0,
files_removed: 0,
errors: 1,
}]
);
assert!(!temp.path().join("selected.m3u").exists());
}
#[tokio::test]
async fn manual_update_library_persisted_input_status_keeps_failed_companion_separate() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "companion").await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, PlaylistUpdateState::Success);
let before = std::fs::read(temp.path().join("selected.m3u")).unwrap();
tokio::fs::write(temp.path().join("companion.m3u"), "#EXTM3U\n").await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, PlaylistUpdateState::Failure);
let library_path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
assert_eq!(
input_cache::load_input_status(&library_path).last_input_update.unwrap().state,
PlaylistUpdateState::Success
);
assert_eq!(std::fs::read(temp.path().join("selected.m3u")).unwrap(), before);
}
#[tokio::test]
async fn manual_update_library_empty_xtream_and_strm_use_existing_publish_and_keep_companions() {
use tuliprox_repository::{xtream_get_file_path, xtream_get_storage_path, BPlusTreeQuery};
for companion in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
tokio::fs::write(
temp.path().join("companion.m3u"),
"#EXTM3U\n#EXTINF:-1 type=\"vod\",Companion\nhttp://stream.example/companion.mp4\n",
)
.await
.unwrap();
let mut sources = (**ctx.config.sources.load()).clone();
if !companion {
sources.sources[0].inputs.retain(|name| name.as_ref() == "local");
}
let target = Arc::make_mut(&mut sources.sources[0].targets[0]);
target.output.extend([
TargetOutput::Xtream(XtreamTargetOutput { flags: XtreamTargetFlagsSet::new(), trakt: None, filter: None }),
TargetOutput::Strm(tuliprox_core::model::StrmTargetOutput::from(&shared::model::StrmTargetOutputDto {
directory: temp.path().join("strm").to_string_lossy().into_owned(),
flat: true,
..shared::model::StrmTargetOutputDto::default()
})),
]);
ctx.config.sources.store(Arc::new(sources));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let config = ctx.config.config.load();
let xtream = xtream_get_storage_path(&config, "selected").unwrap();
for (cluster, expected) in
[(XtreamCluster::Live, 0), (XtreamCluster::Video, usize::from(companion)), (XtreamCluster::Series, 0)]
{
let path = xtream_get_file_path(&xtream, cluster);
if !path.exists() {
assert_eq!(expected, 0);
continue;
}
let mut query = BPlusTreeQuery::<u32, shared::model::XtreamPlaylistItem>::try_new(&path).unwrap();
assert_eq!(query.iter().count(), expected, "{cluster:?}");
}
let entries = strm_artifacts(&temp.path().join("strm")).await;
assert_eq!(entries.len(), usize::from(companion), "{entries:?}");
if companion {
assert!(entries[0].1.contains("http://stream.example/companion"));
}
}
}
#[tokio::test]
async fn manual_update_library_empty_permission_does_not_loosen_other_provider_inputs() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
for input_type in [InputType::M3u, InputType::Xtream, InputType::Stalker, InputType::Plex] {
let input =
ConfigInput { input_type, name: format!("empty-{input_type:?}").intern(), ..ConfigInput::default() };
let (_, error) = tuliprox_repository::persist_input_playlist(&ctx.config, &input, Vec::new()).await;
assert!(error.is_some(), "{input_type:?} must retain its generic empty-input guard");
}
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let before = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
ctx.events.0.lock().unwrap().clear();
tokio::fs::write(temp.path().join("companion.m3u"), "#EXTM3U\n").await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
{
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == PlaylistUpdateState::Failure)));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(3) && progress.state == Some(PlaylistUpdateState::Failure))));
}
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
// An authoritatively empty Library must not mask a failed companion either.
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
{
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary) if summary.state == PlaylistUpdateState::Failure)));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress) if progress.input_id == Some(3) && progress.state == Some(PlaylistUpdateState::Failure))));
}
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
}
#[derive(Clone, Copy, Debug)]
enum LibraryFilterBoundary {
Processing,
Persist,
M3u,
Strm,
}
async fn strm_artifacts(root: &Path) -> Vec<(PathBuf, String)> {
let mut directories = vec![root.to_path_buf()];
let mut artifacts = Vec::new();
while let Some(directory) = directories.pop() {
let mut entries = tokio::fs::read_dir(directory).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
if entry.file_type().await.unwrap().is_dir() {
directories.push(entry.path());
} else if entry.path().extension().is_some_and(|extension| extension == "strm") {
artifacts.push((entry.path(), tokio::fs::read_to_string(entry.path()).await.unwrap()));
}
}
}
artifacts.sort();
artifacts
}
async fn assert_library_filtered_empty_publication(boundary: LibraryFilterBoundary) {
for disk in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let mut config = (**ctx.config.config.load()).clone();
config.disk_based_processing = disk;
ctx.config.config.store(Arc::new(config));
tokio::fs::write(
temp.path().join("companion.m3u"),
"#EXTM3U\n#EXTINF:-1 type=\"vod\",Companion\nhttp://stream.example/companion.mp4\n",
)
.await
.unwrap();
let mut sources = (**ctx.config.sources.load()).clone();
let target = Arc::make_mut(&mut sources.sources[0].targets[0]);
target.rename = None;
target.mapping = Arc::default();
let library_only = get_filter(r#"Name ~ "^Library Movie.*""#, None).unwrap();
if matches!(boundary, LibraryFilterBoundary::Processing) {
target.filter = library_only.clone().into();
}
if matches!(boundary, LibraryFilterBoundary::Persist) {
target.filter.persist = Some(library_only.clone());
}
if let TargetOutput::M3u(output) = &mut target.output[0] {
output.filter = matches!(boundary, LibraryFilterBoundary::M3u).then(|| library_only.clone());
}
let strm_root = temp.path().join("strm");
let mut strm = tuliprox_core::model::StrmTargetOutput::from(&shared::model::StrmTargetOutputDto {
directory: strm_root.to_string_lossy().into_owned(),
flat: true,
cleanup: true,
..shared::model::StrmTargetOutputDto::default()
});
strm.filter = matches!(boundary, LibraryFilterBoundary::Strm).then_some(library_only);
target.output.push(TargetOutput::Strm(strm));
ctx.config.sources.store(Arc::new(sources));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Success);
let m3u_path = temp.path().join("selected.m3u");
let before_m3u = tokio::fs::read_to_string(&m3u_path).await.unwrap();
let before_strm = strm_artifacts(&strm_root).await;
let m3u_keeps_companion = matches!(boundary, LibraryFilterBoundary::Strm);
let strm_keeps_companion = matches!(boundary, LibraryFilterBoundary::M3u);
assert_eq!(before_m3u.matches("#EXTINF").count(), 1 + usize::from(m3u_keeps_companion));
assert_eq!(before_strm.len(), 1 + usize::from(strm_keeps_companion));
assert_eq!(before_m3u.contains("http://stream.example/companion"), m3u_keeps_companion);
assert_eq!(
before_strm.iter().any(|(_, body)| body.contains("http://stream.example/companion")),
strm_keeps_companion
);
ctx.events.0.lock().unwrap().clear();
tokio::fs::remove_file(temp.path().join("media/Library Movie (2025).mp4")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let after_m3u = tokio::fs::read_to_string(&m3u_path).await.unwrap();
let after_strm = strm_artifacts(&strm_root).await;
println!("Library filter {boundary:?}, disk={disk}: M3U before={before_m3u:?}, after={after_m3u:?}; STRM before={before_strm:?}, after={after_strm:?}");
assert_run_state(&ctx, PlaylistUpdateState::Success);
assert_eq!(
scan_results(&ctx),
vec![LibraryScanResult {
files_scanned: 0,
groups_scanned: 0,
files_added: 0,
files_updated: 0,
files_removed: 1,
errors: 0
}]
);
assert_ne!(before_m3u, after_m3u, "old Library entries must really be replaced");
assert_eq!(after_m3u.matches("#EXTINF").count(), usize::from(m3u_keeps_companion));
assert_eq!(after_m3u.contains("http://stream.example/companion"), m3u_keeps_companion);
assert_eq!(after_strm.len(), usize::from(strm_keeps_companion));
for (path, contents) in &before_strm {
if contents.contains("http://stream.example/companion") {
assert!(
after_strm.contains(&(path.clone(), contents.clone())),
"valid companion must be retained byte-for-byte"
);
} else {
assert!(!path.exists(), "cleanup must remove old Library artifact {path:?}");
}
}
// The existing M3U database is refreshed too, not only the client text file.
let target_path = tuliprox_repository::get_target_storage_path(&ctx.config.config.load(), "selected").unwrap();
let mut db = tuliprox_repository::BPlusTreeQuery::<u32, shared::model::M3uPlaylistItem>::try_new(
&tuliprox_repository::m3u_get_file_path_for_db(&target_path),
)
.unwrap();
assert_eq!(db.iter().count(), usize::from(m3u_keeps_companion));
assert!(!temp.path().join("unselected.m3u").exists());
}
}
#[tokio::test]
async fn manual_update_library_processing_filter_replaces_old_output_after_last_movie_removed() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Processing).await;
}
#[tokio::test]
async fn manual_update_library_persist_filter_replaces_old_output_after_last_movie_removed() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Persist).await;
}
#[tokio::test]
async fn manual_update_library_m3u_output_filter_replaces_old_output_and_preserves_unfiltered_strm() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::M3u).await;
}
#[tokio::test]
async fn manual_update_library_strm_output_filter_cleans_old_files_and_preserves_unfiltered_m3u() {
assert_library_filtered_empty_publication(LibraryFilterBoundary::Strm).await;
}
#[tokio::test]
async fn manual_update_library_empty_does_not_authorize_foreign_forced_empty_target() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let sources = ctx.config.sources.load_full();
let mut playlists = sources
.inputs
.iter()
.map(|input| FetchedPlaylist { input, source: MemoryPlaylistSource::default().into_source(), epg: None })
.collect::<Vec<_>>();
let prepared = prepare_playlist_for_target(
&ctx,
&mut playlists,
&sources.sources[0].targets[0],
&mut HashMap::new(),
&mut Vec::new(),
ClusterFlags::Vod,
false,
)
.await
.unwrap();
assert_eq!(prepared.library_empty, tuliprox_repository::LibraryEmptyPublication::Contribution);
let (result, _) = finalize_prepared_target(Arc::new(ctx), prepared).await;
assert!(result.is_err(), "Library must not relax the existing non-Xtream force-empty output guard");
assert!(!temp.path().join("selected.m3u").exists());
}
#[tokio::test]
async fn manual_update_library_incomplete_discovery_has_no_invented_scan_result_or_rebuild() {
for failure in ["missing", "not-directory", "partial", "no-directories", "corrupt-catalog"] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let before = tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap();
let status_path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), "local").await;
let cache_before = input_cache::load_input_status(&status_path).clusters;
ctx.events.0.lock().unwrap().clear();
let mut config = (**ctx.config.config.load()).clone();
let library = config.library.as_mut().unwrap();
match failure {
"missing" => {
library.scan_directories[0].path = temp.path().join("missing").to_string_lossy().into_owned();
}
"not-directory" => {
library.scan_directories[0].path =
temp.path().join("media/Library Movie (2025).mp4").to_string_lossy().into_owned();
}
"partial" => {
let mut missing = library.scan_directories[0].clone();
missing.path = temp.path().join("missing").to_string_lossy().into_owned();
library.scan_directories.push(missing);
}
"no-directories" => library.scan_directories.clear(),
"corrupt-catalog" => {
let path = tuliprox_library::library::resolve_metadata_storage_path(
config.metadata_update.as_ref(),
&config.storage_dir,
)
.join("library/broken.json");
tokio::fs::write(path, "invalid json").await.unwrap();
}
_ => unreachable!(),
}
ctx.config.config.store(Arc::new(config));
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
assert_run_state(&ctx, PlaylistUpdateState::Failure);
assert!(scan_results(&ctx).is_empty(), "{failure}");
let status = input_cache::load_input_status(&status_path);
assert_eq!(status.last_input_update.unwrap().state, PlaylistUpdateState::Failure, "{failure}");
assert_eq!(status.clusters, cache_before, "failed rescan must not mutate cache validity");
assert_eq!(before, tokio::fs::read(temp.path().join("selected.m3u")).await.unwrap());
}
}
#[tokio::test]
async fn manual_update_library_selected_target_keeps_companion_and_real_processing_order() {
for (order, expected) in [(ProcessingOrder::Frm, "MAPPED"), (ProcessingOrder::Fmr, "RENAMED")] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), order).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let output = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(output.matches("#EXTINF").count(), 2, "{output}");
assert!(output.contains("http://stream.example/companion"), "{output}");
assert!(output.contains(expected), "{output}");
assert!(!temp.path().join("unselected.m3u").exists());
let events = ctx.events.0.lock().unwrap();
let scan = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.message.starts_with("Library rescan completed"))
})
.unwrap();
let acquired = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.state == Some(PlaylistUpdateState::Success))
})
.unwrap();
let published = events
.iter()
.position(|event| {
matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Success)
})
.unwrap();
assert!(scan < acquired && acquired < published);
assert!(events.iter().all(|event| !matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.input_telemetry.as_ref().is_some_and(|telemetry|
!telemetry.clusters.is_empty() || telemetry.refresh_policy != InputRefreshPolicy::NORMAL))));
}
}
#[tokio::test]
async fn manual_update_library_unreadable_catalog_and_busy_scan_do_not_publish() {
for busy in [false, true] {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
let guard = UpdateGuard::new();
let _busy = busy.then(|| guard.try_library().unwrap());
if !busy {
let mut config = (**ctx.config.config.load()).clone();
config.library.as_mut().unwrap().scan_directories[0].path =
temp.path().join("missing").to_string_lossy().into_owned();
ctx.config.config.store(Arc::new(config));
}
exec_processing(rescan_run(&ctx, guard.clone())).await;
assert!(!temp.path().join("selected.m3u").exists());
assert!(!temp.path().join("unselected.m3u").exists());
assert!(ctx.events.0.lock().unwrap().iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Failure)));
assert!(guard.try_playlist().is_some());
}
}
#[tokio::test]
async fn manual_update_library_input_success_remains_separate_from_target_publish_failure() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
tokio::fs::create_dir(temp.path().join("selected.m3u")).await.unwrap();
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
let events = ctx.events.0.lock().unwrap();
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(2) && progress.state == Some(PlaylistUpdateState::Success))));
assert!(events.iter().any(|event| matches!(event, EventMessage::PlaylistUpdate(summary)
if summary.state == PlaylistUpdateState::Failure)));
}
#[tokio::test]
async fn manual_update_library_rescan_reloads_valid_cache_but_bulk_keeps_catalog() {
let temp = tempfile::tempdir().unwrap();
let ctx = library_context(temp.path(), ProcessingOrder::Frm).await;
exec_processing(rescan_run(&ctx, UpdateGuard::new())).await;
tokio::fs::write(temp.path().join("media/Second Movie (2025).mp4"), b"fixture").await.unwrap();
// Ordinary bulk does not discover the new file and does not acquire a library permit.
let guard = UpdateGuard::new();
let standalone_scan = guard.try_library().unwrap();
exec_processing(
ProcessingRun::new(ctx.client.clone(), ctx.config.clone(), ctx.user_targets.clone(), ctx.events.clone())
.with_update_guard(guard.clone()),
)
.await;
let bulk = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(bulk.matches("#EXTINF").count(), 2);
drop(standalone_scan);
exec_processing(rescan_run(&ctx, guard)).await;
let rescanned = tokio::fs::read_to_string(temp.path().join("selected.m3u")).await.unwrap();
assert_eq!(rescanned.matches("#EXTINF").count(), 3, "{rescanned}");
}
@@ -0,0 +1,393 @@
use super::*;
use crate::processor::playlist::input_status::{persist_input_completion, persist_input_job_result};
use shared::model::PersistedPlaylistUpdateInputResult;
fn acquired_result(state: PlaylistUpdateState) -> InputJobResult {
let mut result = super::super::playlist_update_run_result(
7,
InputJobState::Ready,
vec![],
state == PlaylistUpdateState::Partial,
);
result.input_telemetry = Some(PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::REFRESH,
source: Some(PlaylistUpdateDataSource::Provider),
clusters: Vec::new(),
});
result
}
#[tokio::test]
async fn playlist_update_status_run_precedence_is_isolated_by_stable_input_id() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let failed = super::super::playlist_update_run_result(7, InputJobState::Failed, vec![], false);
persist_input_job_result(&ctx, &failed).await;
let mut independent = acquired_result(PlaylistUpdateState::Success);
independent.input_id = 8;
independent.input_name = "independent".intern();
persist_input_job_result(&ctx, &independent).await;
for (result, state) in [(failed, PlaylistUpdateState::Failure), (independent, PlaylistUpdateState::Success)] {
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &result.input_name).await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, state);
}
}
#[tokio::test]
async fn m3u_update_quality_playlist_update_status_staged_completion_is_independent_of_parent_failure() {
for (parent_fails, staged_fails) in [(false, false), (true, false), (false, true)] {
let temp = tempfile::tempdir().unwrap();
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let parent_path = temp.path().join("parent.m3u");
let staged_path = temp.path().join("overlay.m3u");
for (path, fails) in [(&parent_path, parent_fails), (&staged_path, staged_fails)] {
if !fails {
std::fs::write(path, "#EXTM3U\n#EXTINF:-1,Fixture\nhttp://stream.example/fixture\n").unwrap();
}
}
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_path.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged = ConfigInput {
id: 8,
name: "staged".intern(),
input_type: InputType::Staged,
url: staged_path.to_string_lossy().into_owned(),
enabled: true,
staged: Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
}),
..ConfigInput::default()
};
staged.resolve_staged_download_type();
let staged = Arc::new(staged);
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone()],
..SourcesConfig::default()
}));
let result = download_input(&ctx, &parent, false).await;
assert_eq!(!result.errors.is_empty(), parent_fails || staged_fails);
let path = input_storage_path(&ctx, &staged).await;
let persisted = input_cache::load_input_status(&path);
assert_eq!(
persisted.last_input_update.unwrap().state,
if staged_fails { PlaylistUpdateState::Failure } else { PlaylistUpdateState::Success }
);
assert_eq!(persisted.clusters.len(), 4);
assert!(persisted.clusters["default"].last_update.is_none(), "document cache and cluster facts stay separate");
for cluster in XTREAM_CLUSTER {
assert!(persisted.clusters[cluster.as_ref()].last_update.is_some());
}
let completions = staged_completion::input_completions(&events, staged.id);
assert_eq!(completions.len(), 1, "real staged download must report its own completion");
assert_eq!(completions[0].state, persisted.last_input_update.map(|result| result.state));
assert_eq!(completions[0].run_id, Some(ctx.run_id.clone()));
assert_eq!(completions[0].execution_order, Some(ctx.execution_order));
let telemetry = completions[0].input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.source, None);
assert!(telemetry.clusters.iter().all(|cluster| cluster.source == Some(PlaylistUpdateDataSource::Provider)));
assert!(staged_completion::input_completions(&events, parent.id).is_empty());
}
}
#[tokio::test]
async fn playlist_update_status_delayed_source_results_keep_strongest_completion_until_next_run() {
for weaker in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial] {
for failure_first in [false, true] {
let temp = tempfile::tempdir().unwrap();
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let acquired = acquired_result(weaker);
let failed_reuse = super::super::playlist_update_run_result(7, InputJobState::Failed, vec![], false);
let (first, delayed) = if failure_first { (failed_reuse, acquired) } else { (acquired, failed_reuse) };
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &first.input_name).await;
let mut original = input_cache::InputStatus::default();
original.clusters.insert(
"live".into(),
input_cache::ClusterStatus {
status: input_cache::ClusterState::Failed,
timestamp: 123,
last_update: Some(PersistedPlaylistUpdateClusterSnapshot {
source: Some(PlaylistUpdateDataSource::Cache),
policy: Some(InputRefreshPolicy::FORCE),
quality_guard_threshold: Some(95),
..PersistedPlaylistUpdateClusterSnapshot::default()
}),
},
);
input_cache::save_input_status(&path, &original);
let (release, other_input_finished) = tokio::sync::oneshot::channel();
let delayed_ctx = ctx.clone();
// Source A already holds its shared-input result, but cannot drain
// its sorted result list until another input job has completed.
let delayed_source = async move {
let source_results = vec![delayed];
other_input_finished.await.unwrap();
for result in source_results {
persist_input_job_result(&delayed_ctx, &result).await;
report_input_job_completion(
&delayed_ctx.events,
&delayed_ctx.run_id,
delayed_ctx.execution_order,
&result,
);
}
};
let first_source = async {
persist_input_job_result(&ctx, &first).await;
report_input_job_completion(&ctx.events, &ctx.run_id, ctx.execution_order, &first);
let mut persisted = input_cache::load_input_status(&path);
assert_eq!(persisted.last_input_update.unwrap().state, first.update_state());
// Distinct sentinel proves a delayed weaker result does not
// advance the stronger completion's timestamp, without sleeps.
persisted.last_input_update.as_mut().unwrap().timestamp = 456;
input_cache::save_input_status(&path, &persisted);
release.send(()).unwrap();
};
tokio::join!(delayed_source, first_source);
let final_status = input_cache::load_input_status(&path);
let completion = final_status.last_input_update.unwrap();
assert_eq!(
completion.state,
PlaylistUpdateState::Failure,
"weaker={weaker:?}, failure_first={failure_first}"
);
if failure_first {
assert_eq!(completion.timestamp, 456);
}
assert_eq!(final_status.clusters, original.clusters);
let states: Vec<_> = events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress) => progress.state,
_ => None,
})
.collect();
assert_eq!(
states,
if failure_first {
vec![PlaylistUpdateState::Failure, weaker]
} else {
vec![weaker, PlaylistUpdateState::Failure]
}
);
let mut next = processing_context(temp.path());
next.run_id = "next-successful-run".into();
next.execution_order = 2.into();
let success = acquired_result(PlaylistUpdateState::Success);
persist_input_job_result(&next, &success).await;
let replacement = input_cache::load_input_status(&path);
assert_eq!(replacement.last_input_update.unwrap().state, PlaylistUpdateState::Success);
assert_eq!(replacement.clusters, original.clusters);
}
}
}
#[tokio::test]
async fn playlist_update_status_parent_download_persists_staged_own_per_cluster_success_and_failure() {
for fails in [false, true] {
let temp = tempfile::tempdir().unwrap();
let mut responses = fixture_responses([1, 0, 0]);
if fails {
responses.insert("get_live_streams".into(), "not JSON".into());
}
let server = TestXtreamServer::start_with_responses(responses);
let events = CollectSink::default();
let ctx = processing_context_with_events(temp.path(), events.clone());
let parent_file = temp.path().join("parent.m3u");
std::fs::write(&parent_file, "#EXTM3U\n#EXTINF:-1,Parent\nhttp://stream.example/parent\n").unwrap();
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_file.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged =
(*test_input(&server.base_url, ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 }, 0, &[]))
.clone();
staged.id = 8;
staged.name = "own-staged".intern();
staged.input_type = InputType::Staged;
staged.staged_type = shared::model::StagedInputType::Xtream;
staged.staged = Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
});
staged.options = Some(ConfigInputOptions::from(&ConfigInputOptionsDto {
skip_vod: true,
skip_series: true,
update_quality: ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 },
..ConfigInputOptionsDto::default()
}));
staged.resolve_staged_download_type(); // Same resolver used when SourcesConfig is built.
let staged = Arc::new(staged);
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone()],
..SourcesConfig::default()
}));
let staged_path = input_storage_path(&ctx, &staged).await;
// Referencing or directly skipping a staged input is not an acquisition.
let skipped = download_input(&ctx, &staged, false).await;
assert!(skipped.input_telemetry.is_none());
assert!(input_cache::load_input_status(&staged_path).last_input_update.is_none());
assert!(staged_completion::input_completions(&events, staged.id).is_empty());
let result = download_input(&ctx, &parent, false).await;
let status = input_cache::load_input_status(&staged_path);
assert!(!status.clusters.contains_key("default"));
assert_eq!(status.clusters.len(), 1);
let completion = status.last_input_update.expect("actual indirect staged acquisition has its own completion");
assert_eq!(completion.state, if fails { PlaylistUpdateState::Failure } else { PlaylistUpdateState::Success });
assert!(completion.timestamp > 0);
let completions = staged_completion::input_completions(&events, staged.id);
assert_eq!(completions.len(), 1);
assert_eq!(completions[0].state, Some(completion.state));
assert_eq!(completions[0].run_id, Some(ctx.run_id.clone()));
assert_eq!(completions[0].execution_order, Some(ctx.execution_order));
let snapshot = status.clusters["live"].last_update.unwrap();
assert_eq!(snapshot.source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(snapshot.policy, None);
if fails {
assert_eq!(snapshot.quality_guard_threshold, Some(95));
assert_eq!(snapshot.quality, None);
} else {
assert_eq!(snapshot.quality_guard_threshold, None, "the evaluation already owns its threshold");
let quality = snapshot.quality.unwrap();
assert_eq!(quality.threshold, 95);
assert_eq!(quality.decision, PersistedPlaylistUpdateQualityDecision::Accepted);
assert_eq!(quality.achieved_quality, None, "bootstrap has no invented Quality");
}
assert_eq!(result.errors.is_empty(), !fails);
let parent_path = input_storage_path(&ctx, &parent).await;
assert_ne!(parent_path, staged_path);
assert!(
input_cache::load_input_status(&parent_path).last_input_update.is_none(),
"staged result does not invent a parent job completion"
);
if !fails {
let bytes = std::fs::read(staged_path.join(input_cache::STATUS_FILE)).unwrap();
let reuse = download_input(&ctx, &parent, false).await;
assert!(reuse.input_telemetry.is_none());
let mut staged_reuse = download_input(&ctx, &staged, true).await;
assert!(staged_reuse.input_telemetry.is_none());
super::super::input_status::complete_staged_input(&ctx, &staged, &mut staged_reuse).await;
assert_eq!(std::fs::read(staged_path.join(input_cache::STATUS_FILE)).unwrap(), bytes);
assert_eq!(staged_completion::input_completions(&events, staged.id), completions);
}
let requests = server.finish();
assert_eq!(
requests.iter().filter(|action| action.as_str() == "get_live_streams").count(),
1,
"no extra staged acquisition on parent reuse"
);
}
}
#[tokio::test]
async fn playlist_update_status_completion_replaces_one_result_without_touching_cache_or_cluster_snapshots() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let input_name: Arc<str> = "result-input".intern();
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &input_name).await;
let mut original = input_cache::InputStatus::default();
input_cache::update_cluster_status(&mut original, "default", input_cache::ClusterState::Ok);
original.clusters.get_mut("default").unwrap().timestamp = 1; // Expired cache must stay expired.
input_cache::replace_cluster_snapshot(
&mut original,
"default",
PersistedPlaylistUpdateClusterSnapshot {
source: Some(PlaylistUpdateDataSource::Cache),
..PersistedPlaylistUpdateClusterSnapshot::default()
},
);
input_cache::save_input_status(&path, &original);
for state in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial, PlaylistUpdateState::Failure] {
persist_input_completion(&ctx, 7, &input_name, state).await;
let reloaded = input_cache::load_input_status(&path);
let result = reloaded.last_input_update.unwrap();
assert_eq!(result.state, state);
assert!(result.timestamp > 1);
assert_eq!(reloaded.clusters, original.clusters);
assert!(!input_cache::is_cache_valid(&reloaded, "default", 3600));
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap()).unwrap();
assert!(json["last_input_update"].is_object());
assert_eq!(json.as_object().unwrap().len(), 2, "only clusters + one result, no history");
}
}
#[tokio::test]
async fn playlist_update_status_in_run_reuse_preserves_original_partial_result_and_timestamp() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
let mut result = super::super::playlist_update_run_result(7, InputJobState::Ready, vec![], true);
result.input_telemetry = Some(PlaylistUpdateInputTelemetry {
refresh_policy: InputRefreshPolicy::NORMAL,
source: Some(PlaylistUpdateDataSource::Provider),
clusters: Vec::new(),
});
persist_input_job_result(&ctx, &result).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &result.input_name).await;
let mut original = input_cache::load_input_status(&path);
assert_eq!(original.last_input_update.unwrap().state, PlaylistUpdateState::Partial);
original.last_input_update.as_mut().unwrap().timestamp = 123;
input_cache::save_input_status(&path, &original);
let before = std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap();
result.had_quality_rejections = false;
result.input_telemetry = None; // Same existing absence of acquisition facts as a real in-run reuse.
persist_input_job_result(&ctx, &result).await;
assert_eq!(std::fs::read(path.join(input_cache::STATUS_FILE)).unwrap(), before);
result.state = InputJobState::Failed; // A genuine technical completion must still replace the prior result.
persist_input_job_result(&ctx, &result).await;
assert_eq!(input_cache::load_input_status(&path).last_input_update.unwrap().state, PlaylistUpdateState::Failure);
}
#[tokio::test]
async fn playlist_update_status_completion_covers_every_input_type_without_artificial_clusters() {
let temp = tempfile::tempdir().unwrap();
let ctx = processing_context(temp.path());
for (id, input_type) in [
InputType::Xtream,
InputType::XtreamBatch,
InputType::Stalker,
InputType::StalkerBatch,
InputType::M3u,
InputType::M3uBatch,
InputType::Library,
InputType::Plex,
InputType::Emby,
InputType::Jellyfin,
InputType::Staged,
]
.into_iter()
.enumerate()
{
let input = ConfigInput {
id: u16::try_from(id).unwrap(),
input_type,
name: format!("input-{id}").intern(),
..ConfigInput::default()
};
let result = panicked_input_job(id, &input);
persist_input_job_result(&ctx, &result).await;
let path = input_cache::resolve_input_storage_path(&temp.path().to_string_lossy(), &input.name).await;
let persisted = input_cache::load_input_status(&path);
assert!(persisted.clusters.is_empty());
assert!(
matches!(persisted.last_input_update, Some(PersistedPlaylistUpdateInputResult {state: PlaylistUpdateState::Failure, timestamp}) if timestamp > 0)
);
}
}
@@ -0,0 +1,160 @@
use super::*;
pub(super) fn input_completions(events: &CollectSink, input_id: u16) -> Vec<PlaylistUpdateProgressEvent> {
events
.0
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(input_id) && progress.state.is_some() =>
{
Some(progress.clone())
}
_ => None,
})
.collect()
}
async fn staged_run_context(root: &Path, base_url: &str) -> (PlaylistProcessingContext<CollectSink>, Arc<ConfigInput>) {
let ctx = processing_context_with_events(root, CollectSink::default());
let parent_file = root.join("parent.m3u");
std::fs::write(&parent_file, "#EXTM3U\n#EXTINF:-1,Parent\nhttp://stream.example/parent\n").unwrap();
let parent = Arc::new(ConfigInput {
id: 7,
name: "parent".intern(),
input_type: InputType::M3u,
url: parent_file.to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
let mut staged = (*test_input(
base_url,
ConfigInputUpdateQualityDto { live: 95, vod: 0, series: 0 },
0,
&[XtreamCluster::Video, XtreamCluster::Series],
))
.clone();
staged.id = 8;
staged.name = "staged".intern();
staged.input_type = InputType::Staged;
staged.staged_type = shared::model::StagedInputType::Xtream;
staged.staged = Some(tuliprox_core::model::ConfigInputStaged {
for_input: Some(parent.name.clone()),
clusters: ClusterFlags::Live,
});
staged.resolve_staged_download_type();
let staged = Arc::new(staged);
seed_live_baseline(&ctx, &staged, 100).await;
let failed_companion = Arc::new(ConfigInput {
id: 9,
name: "failed-companion".intern(),
input_type: InputType::M3u,
url: root.join("missing.m3u").to_string_lossy().into_owned(),
enabled: true,
..ConfigInput::default()
});
ctx.config.sources.store(Arc::new(SourcesConfig {
inputs: vec![parent.clone(), staged.clone(), failed_companion.clone()],
sources: vec![ConfigSource {
inputs: vec![parent.name.clone(), failed_companion.name.clone()],
targets: vec![xtream_target(true)],
}],
..SourcesConfig::default()
}));
(ctx, staged)
}
fn normalized_trace(
events: &[EventMessage],
persisted: shared::model::PersistedPlaylistUpdateInputResult,
) -> serde_json::Value {
let summary = events
.iter()
.find_map(|event| match event {
EventMessage::PlaylistUpdate(summary) => Some(summary),
_ => None,
})
.unwrap();
assert_eq!(summary.run_id, Some("staged-completion-run".into()));
assert!(summary.execution_order.is_some());
assert_eq!(summary.state, PlaylistUpdateState::Failure);
let progress: Vec<_> = events
.iter()
.filter_map(|event| match event {
EventMessage::PlaylistUpdateProgress(progress)
if progress.input_id == Some(8) || progress.state.is_some() =>
{
assert_eq!(progress.run_id, summary.run_id);
assert_eq!(progress.execution_order, summary.execution_order);
let mut progress = progress.clone();
// Only the globally allocated test-run order is normalized. All
// input identities, states, messages and telemetry remain exact.
progress.execution_order = Some(1.into());
Some(progress)
}
_ => None,
})
.collect();
assert!(persisted.timestamp > 0);
serde_json::json!({
"progress": progress,
"completed": {"run_id": summary.run_id, "execution_order": 1, "state": summary.state},
"persisted_input_result": {"state": persisted.state, "timestamp": 1}
})
}
#[tokio::test]
async fn playlist_update_status_staged_reporter_trace_matches_persistence_before_foreign_run_failure() {
let mut traces = Vec::new();
for expected in [PlaylistUpdateState::Partial, PlaylistUpdateState::Success, PlaylistUpdateState::Failure] {
let temp = tempfile::tempdir().unwrap();
let mut responses = fixture_responses([if expected == PlaylistUpdateState::Success { 100 } else { 1 }, 0, 0]);
if expected == PlaylistUpdateState::Failure {
responses.insert("get_live_streams".into(), "not JSON".into());
}
let server = TestXtreamServer::start_with_responses(responses);
let (ctx, staged) = staged_run_context(temp.path(), &server.base_url).await;
exec_processing(ProcessingRun::for_run(
"staged-completion-run".into(),
ctx.client.clone(),
ctx.config.clone(),
ctx.user_targets.clone(),
ctx.events.clone(),
))
.await;
let path = input_storage_path(&ctx, &staged).await;
let persisted = input_cache::load_input_status(&path);
assert!(!persisted.clusters.contains_key("default"));
assert_eq!(persisted.last_input_update.unwrap().state, expected);
let completions = input_completions(&ctx.events, staged.id);
assert_eq!(completions.len(), 1, "real indirect staged input must emit one correlated completion");
assert_eq!(completions[0].state, Some(expected));
let telemetry = completions[0].input_telemetry.as_ref().unwrap();
assert_eq!(telemetry.refresh_policy, InputRefreshPolicy::NORMAL);
assert_eq!(telemetry.source, None);
assert_eq!(telemetry.clusters[0].source, Some(PlaylistUpdateDataSource::Provider));
assert_eq!(telemetry.clusters[0].threshold, Some(95));
if expected == PlaylistUpdateState::Partial {
assert_eq!(telemetry.clusters[0].decision, Some(PlaylistUpdateClusterDecision::Rejected));
assert_eq!(telemetry.clusters[0].active_count, Some(100));
}
let emitted = ctx.events.0.lock().unwrap().clone();
let own_index = emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdateProgress(p) if p.input_id == Some(8) && p.state.is_some())).unwrap();
let parent_index = emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdateProgress(p) if p.input_id == Some(7) && p.state.is_some())).unwrap();
let completed_index =
emitted.iter().position(|event| matches!(event, EventMessage::PlaylistUpdate(_))).unwrap();
assert!(own_index < parent_index && parent_index < completed_index);
assert_eq!(input_completions(&ctx.events, 9)[0].state, Some(PlaylistUpdateState::Failure));
traces.push(normalized_trace(&emitted, persisted.last_input_update.unwrap()));
assert_eq!(server.finish().iter().filter(|action| action.as_str() == "get_live_streams").count(), 1);
}
let actual = serde_json::Value::Array(traces);
let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../frontend/src/model/test_data/staged_completions.json"
)))
.unwrap();
assert_eq!(actual, fixture, "actual reporter trace: {}", serde_json::to_string_pretty(&actual).unwrap());
}
@@ -0,0 +1,134 @@
use super::{
collect_target_task_result, stalker_checkpoint_message, target_mutated_resources, target_waiting_message,
wait_for_target_finalizer_slot, with_sequential_group, TargetJobResult, MAX_CONCURRENT_TARGET_FINALIZERS,
};
use shared::model::{ConfigTargetDto, PipelineStats};
use std::{sync::Arc, time::Duration};
use tokio::task::JoinSet;
use tuliprox_core::model::{Config, ConfigTarget};
#[tokio::test]
async fn parallel_input_scheduler_serializes_equal_groups_and_overlaps_distinct_groups() {
use std::sync::atomic::{AtomicUsize, Ordering};
async fn observe(active: &AtomicUsize, maximum: &AtomicUsize) {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(current, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(20)).await;
active.fetch_sub(1, Ordering::SeqCst);
}
let locks = tuliprox_core::utils::FileLockManager::default();
let active = AtomicUsize::new(0);
let maximum = AtomicUsize::new(0);
tokio::join!(
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
);
assert_eq!(maximum.load(Ordering::SeqCst), 1);
maximum.store(0, Ordering::SeqCst);
tokio::join!(
with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)),
with_sequential_group(&locks, Some(8), true, observe(&active, &maximum)),
);
assert_eq!(maximum.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn parallel_input_scheduler_releases_group_after_abort() {
let locks = Arc::new(tuliprox_core::utils::FileLockManager::default());
let task_locks = Arc::clone(&locks);
let task = tokio::spawn(async move {
with_sequential_group(&task_locks, Some(7), true, std::future::pending::<()>()).await;
});
tokio::task::yield_now().await;
task.abort();
let _ = task.await;
tokio::time::timeout(Duration::from_secs(1), with_sequential_group(&locks, Some(7), true, std::future::ready(())))
.await
.expect("aborting an input job must release its sequential group");
}
#[test]
fn input_progress_message_contains_each_target_and_blocking_input() {
let targets = ["target-a", "target-b"];
let inputs = ["input-a", "input-b"];
let messages: Vec<_> = targets
.iter()
.flat_map(|target| inputs.iter().map(move |input| target_waiting_message(target, input)))
.collect();
assert_eq!(messages.len(), 4);
for target in targets {
for input in inputs {
assert!(messages.contains(&format!("Target '{target}' is waiting for input '{input}'")));
}
}
assert!(stalker_checkpoint_message("portal-a").contains("portal-a"));
}
#[tokio::test]
async fn parallel_target_pipeline_bounds_active_finalizers() {
use std::sync::atomic::{AtomicUsize, Ordering};
let active = Arc::new(AtomicUsize::new(0));
let maximum = Arc::new(AtomicUsize::new(0));
let mut tasks = JoinSet::new();
let mut results = Vec::new();
let mut errors = Vec::new();
for index in 0..6 {
wait_for_target_finalizer_slot(&mut tasks, &mut results, &mut errors).await;
let active = Arc::clone(&active);
let maximum = Arc::clone(&maximum);
tasks.spawn(async move {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(current, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(10)).await;
active.fetch_sub(1, Ordering::SeqCst);
TargetJobResult {
index,
name: format!("target-{index}"),
result: Ok(()),
errors: Vec::new(),
processing: PipelineStats::default(),
}
});
}
while let Some(result) = tasks.join_next().await {
collect_target_task_result(result, &mut results, &mut errors);
}
assert!(maximum.load(Ordering::SeqCst) <= MAX_CONCURRENT_TARGET_FINALIZERS);
assert_eq!(results.len(), 6);
assert!(errors.is_empty());
}
#[test]
fn parallel_target_pipeline_normalizes_conflicting_output_resources() {
let config = Config { storage_dir: "/tmp/tuliprox-target-resources".to_string(), ..Config::default() };
let mut spaced = ConfigTarget::from(&ConfigTargetDto::default());
spaced.name = "A B".to_string();
let mut underscored = ConfigTarget::from(&ConfigTargetDto::default());
underscored.name = "A_B".to_string();
assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)));
spaced.name = "one".to_string();
spaced.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
filename: Some("out/../x.m3u".to_string()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
underscored.name = "two".to_string();
underscored.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput {
filename: Some("x.m3u".to_string()),
include_type_in_url: false,
mask_redirect_url: false,
filter: None,
})];
assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)));
}
@@ -0,0 +1,874 @@
use super::{
apply_staged_overlay_groups, build_curated_playlist_views, catalog_membership, catalog_test_item,
complete_catalog_evaluation, should_apply_staged_overlay, target_watch_view, test_group, PlaylistDownloadResult,
};
use shared::{
model::{
ClusterFlags, InputType, PlaylistGroup, PlaylistItemType, StagedInputType, StreamProperties, TraktApiConfigDto,
TraktCatalogSelection, TraktConfigDto, TraktContentType, TraktListConfigDto, UUIDType, XtreamCluster,
},
utils::Internable,
};
use tuliprox_core::model::{ConfigInput, ConfigInputFlags, ConfigInputOptions, CurationConfig, TraktConfig};
use tuliprox_curation::CurationMediaKind;
#[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_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.
pub(in crate::processor::playlist::tests) 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::super::apply_staged_overlay_groups(
&provider,
StagedInputType::M3u,
ClusterFlags::Live,
vec![provider_sports],
vec![staged_new],
);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "New");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "300");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_m3u_replaces_selected_xtream_groups_and_keeps_other_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_news = test_group(XtreamCluster::Live, "Provider News", "provider");
provider_news.id = 5;
provider_news.channels[0].header.id = "100".intern();
let mut provider_sports = test_group(XtreamCluster::Live, "Provider Sports", "provider");
provider_sports.id = 7;
provider_sports.channels[0].header.id = "200".intern();
provider_sports.channels[0].header.url = "http://provider.example/direct/200.ts".intern();
let mut provider_kids = test_group(XtreamCluster::Live, "Provider Kids", "provider");
provider_kids.id = 9;
provider_kids.channels[0].header.id = "300".intern();
let provider_vod = test_group(XtreamCluster::Video, "Provider VOD", "provider");
let mut staged_custom = test_group(XtreamCluster::Live, "My Channels", "staged");
staged_custom.channels[0].header.id = "200".intern();
let mut staged_news = test_group(XtreamCluster::Live, "My News", "staged");
staged_news.channels[0].header.id = "100".intern();
let groups = super::super::apply_staged_overlay_groups(
&provider,
StagedInputType::M3u,
ClusterFlags::Live,
vec![provider_news, provider_sports, provider_kids, provider_vod],
vec![staged_custom, staged_news],
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].title.as_ref(), "My Channels");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "200");
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
assert_eq!(groups[0].channels[0].header.url.as_ref(), "http://provider.example/direct/200.ts");
assert_eq!(groups[1].title.as_ref(), "My News");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "100");
assert_eq!(groups[2].title.as_ref(), "Provider VOD");
}
#[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);
assert!(!should_apply_staged_overlay(&result));
}
#[test]
fn xtream_base_and_selector_category_projection_are_independent() {
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011");
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
],
xtream_cluster: XtreamCluster::Video,
}];
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
let dto = TraktConfigDto {
enabled: true,
catalog_selection: TraktCatalogSelection::Curated,
include_xtream_base_categories: false,
api: TraktApiConfigDto::default(),
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Curated".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
charts: Vec::new(),
};
let config = TraktConfig::from(&dto);
let views =
build_curated_playlist_views(playlist.clone(), &evaluation, &CurationConfig::from(&config), false, true);
assert_eq!(views.base.len(), 1);
assert_eq!(views.base[0].channels.len(), 1);
assert!(
target_watch_view(&views.base, views.xtream.as_deref()).iter().any(|group| group.title.as_ref() == "Curated"),
"configured Trakt watches observe the Xtream category appearance"
);
let xtream = views.xtream.expect("complete curation has an Xtream view");
assert_eq!(xtream.len(), 1);
assert_eq!(xtream[0].title.as_ref(), "Curated");
assert_eq!(xtream[0].channels.len(), 1);
assert_ne!(xtream[0].channels[0].header.uuid, selected_uuid);
let mut compatible_dto = dto;
compatible_dto.catalog_selection = TraktCatalogSelection::Full;
compatible_dto.include_xtream_base_categories = true;
compatible_dto.lists[0].create_xtream_category = false;
compatible_dto.lists[0].category_name = None;
let compatible = build_curated_playlist_views(
playlist,
&evaluation,
&CurationConfig::from(&TraktConfig::from(&compatible_dto)),
false,
true,
);
assert_eq!(compatible.base[0].channels.len(), 2);
assert_eq!(compatible.xtream.expect("Xtream view").len(), 1, "selection-only selector creates no category");
}
@@ -0,0 +1,141 @@
use super::{create_input_stat, InputJobResult, InputJobState};
use shared::{
error::TuliproxError,
model::{
ClusterFlags, EventMessage, EventSink, InputType, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, StagedInputType, UUIDType, XtreamCluster,
},
utils::Internable,
};
use std::sync::Arc;
use tuliprox_core::model::ConfigInput;
use tuliprox_curation::{
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
};
pub(in crate::processor::playlist::tests) fn apply_staged_overlay_groups(
provider: &ConfigInput,
clusters: ClusterFlags,
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<PlaylistGroup>,
) -> Vec<PlaylistGroup> {
super::super::apply_staged_overlay_groups(
provider,
StagedInputType::Xtream,
clusters,
provider_groups,
staged_groups,
)
}
#[derive(Clone, Default)]
pub(in crate::processor::playlist::tests) struct PlaylistRunCollectSink(
pub(in crate::processor::playlist::tests) Arc<std::sync::Mutex<Vec<EventMessage>>>,
);
impl EventSink for PlaylistRunCollectSink {
fn emit(&self, event: EventMessage) {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
}
}
pub(in crate::processor::playlist::tests) fn playlist_update_run_result(
input_id: u16,
state: InputJobState,
errors: Vec<TuliproxError>,
had_quality_rejections: bool,
) -> InputJobResult {
let input_name = format!("input-{input_id}").intern();
InputJobResult {
index: usize::from(input_id),
input_id,
input_name: Arc::clone(&input_name),
state,
source: None,
epg: None,
stat: create_input_stat(0, 0, errors.len(), InputType::Xtream, &input_name, 0),
errors,
accepted_empty_clusters: ClusterFlags::empty(),
had_quality_rejections,
input_telemetry: None,
}
}
pub(in crate::processor::playlist::tests) fn test_group(
cluster: XtreamCluster,
item_name: &str,
input_name: &str,
) -> PlaylistGroup {
PlaylistGroup {
id: 1,
title: item_name.intern(),
xtream_cluster: cluster,
channels: vec![PlaylistItem {
header: PlaylistItemHeader {
name: item_name.intern(),
input_name: input_name.intern(),
xtream_cluster: cluster,
item_type: match cluster {
XtreamCluster::Live => PlaylistItemType::Live,
XtreamCluster::Video => PlaylistItemType::Video,
XtreamCluster::Series => PlaylistItemType::Series,
},
..Default::default()
},
}],
}
}
pub(in crate::processor::playlist::tests) fn catalog_test_item(
title: &str,
uuid: UUIDType,
item_type: PlaylistItemType,
cluster: XtreamCluster,
parent_code: Option<&str>,
) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
id: title.intern(),
name: title.intern(),
title: title.intern(),
group: match cluster {
XtreamCluster::Live => "Live".intern(),
XtreamCluster::Video => "Movies".intern(),
XtreamCluster::Series => "Series".intern(),
},
uuid,
item_type,
xtream_cluster: cluster,
parent_code: parent_code.unwrap_or_default().intern(),
..PlaylistItemHeader::default()
},
}
}
pub(in crate::processor::playlist::tests) fn complete_catalog_evaluation(
memberships: Vec<CurationMembership>,
) -> CurationEvaluation {
CurationEvaluation {
selectors: vec![CurationSelectorSummary {
key: CurationSelectorKey(0),
reference_count: memberships.len(),
membership_count: memberships.len(),
}],
memberships,
}
}
pub(in crate::processor::playlist::tests) fn catalog_membership(
uuid: UUIDType,
media_kind: CurationMediaKind,
order: usize,
) -> CurationMembership {
CurationMembership {
selector_key: CurationSelectorKey(0),
subject_uuid: uuid,
media_kind,
rank: Some(u32::try_from(order + 1).expect("test rank")),
title_tiebreak: format!("item-{order}"),
candidate_order: order,
}
}