mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 05:42:16 +02:00
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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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("' ");
|
||||
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;
|
||||
+53
@@ -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()));
|
||||
}
|
||||
+145
@@ -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);
|
||||
}
|
||||
+311
@@ -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"
|
||||
);
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+45
@@ -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
+509
@@ -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"]);
|
||||
}
|
||||
+700
@@ -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}");
|
||||
}
|
||||
+393
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+160
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user