mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-20 06:42:20 +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,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