feat: add target-wide curated catalog selection (#864)

* test: characterize target curation transition

* refactor: model complete curation outcomes

* feat: configure target-wide curation projection

* feat: apply target-wide curation selections

* docs: explain target-wide Trakt curation

* fix: preserve source-less Trakt policy edits

* target wide curaton selection

* target wide curaton selection

---------

Co-authored-by: Juanjo Presa <juanjop@gmail.com>
Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
Juanjo Presa
2026-09-22 16:52:11 +02:00
committed by GitHub
co-authored by Juanjo Presa euzu
parent 2297b3700a
commit 8fa8a3601b
25 changed files with 3287 additions and 505 deletions
@@ -140,6 +140,25 @@ mod tests {
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
}
#[test]
fn post_merge_dedup_selects_the_eligible_winner_before_curation_projection() {
let mut playlist =
vec![make_group("Base", vec![make_item("Movie HD")]), make_group("Trending", vec![make_item("Movie 4K")])];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
// Target-wide curation evaluates only this surviving winner.
assert_eq!(removed, 1);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Trending");
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Movie 4K");
}
#[test]
fn dedup_keep_first_preserves_playlist_order_winner() {
let mut playlist = vec![make_group("G", vec![make_item("News HD"), make_item("News [FHD]")])];
@@ -15,7 +15,7 @@ use crate::{
};
use futures::{FutureExt, StreamExt};
use indexmap::IndexMap;
use log::{debug, error, info, log_enabled, trace, warn, Level};
use log::{debug, error, info, log_enabled, warn, Level};
use path_clean::PathClean;
use shared::{
concat_string,
@@ -26,11 +26,12 @@ use shared::{
ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet,
InputRefreshOverride, InputRefreshPolicy, InputStats, InputType, InputUpdateAction, InputUpdateRequest,
MappingStage, PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateQualityDecision,
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistGroup,
PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision, PlaylistUpdateClusterTelemetry,
PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId,
PlaylistUpdateRunOrder, PlaylistUpdateState, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats,
StreamProperties, TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistEntry,
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision,
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, TargetStats, UUIDType,
WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
},
utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable},
};
@@ -50,11 +51,14 @@ use tuliprox_core::{
is_valid, retain_filtered_playlist, AppConfig, ClusterForceUpdate, ClusterUpdateRejection, CompiledMapping,
ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg,
FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig,
TransformStage, UpdateGuard, UpdateTask,
TraktConfig, TransformStage, UpdateGuard, UpdateTask,
},
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback},
};
use tuliprox_curation::curate_trakt_categories;
use tuliprox_curation::{
evaluate_trakt_curation, project_trakt_categories, CurationEvaluation, CurationFailure, CurationRunOutcome,
SelectorOutcome,
};
use tuliprox_iptv::{
epg::{CountingEpgSink, EpgFetchRequest, EpgProvider},
error::ProviderErrorKind,
@@ -65,8 +69,8 @@ use tuliprox_iptv::{
xtream,
};
use tuliprox_repository::{
load_input_playlist, persist_input_playlist_with_options, persist_playlist, CategoryKey,
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistSource, PlaylistStorageState,
load_input_playlist, persist_input_playlist_with_options, persist_playlist_views, CategoryKey,
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState,
TargetPlaylistPersistOptions,
};
use tuliprox_session::ActiveProviderManager;
@@ -399,6 +399,70 @@ pub(crate) fn spill_epg_to_disk(sources: Vec<Epg>) -> Result<Option<Epg>, Tulipr
}
}
fn finalization_step_label(view: &str, stage: &str) -> String {
if view.is_empty() {
stage.to_string()
} else {
format!("{view} {stage}")
}
}
pub(crate) fn prepare_eligible_catalog(
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
step: &mut StepMeasure,
) -> Vec<PlaylistGroup> {
let mut playlist = flatten_groups(playlist);
step.tick("playlist merge");
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist);
if removed > 0 {
info!("Deduplicated {removed} eligible channels for target {}", target.name);
}
step.tick("playlist dedup");
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
}
playlist
}
fn finalize_playlist_view(
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
step: &mut StepMeasure,
view: &str,
) -> Vec<PlaylistGroup> {
let mut playlist = flatten_groups(playlist);
step.tick(&finalization_step_label(view, "appearance merge"));
for stage in FINALIZATION_ORDER.into_iter().skip(2) {
match stage {
FinalizationStage::Merge | FinalizationStage::Deduplicate => {
unreachable!("eligibility merge and deduplication are completed before curation")
}
FinalizationStage::Sort => {
if sort_playlist(target, &mut playlist) {
step.tick(&finalization_step_label(view, "playlist sort"));
log_memory_snapshot(format!("target '{}' {view} after_playlist_sort", target.name).as_str());
}
}
FinalizationStage::AssignChannelNumbers => {
assign_channel_no_playlist(&mut playlist);
step.tick(&finalization_step_label(view, "assigning channel numbers"));
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_numbers", target.name).as_str());
}
FinalizationStage::AssignCounters => {
map_playlist_counter(target, &mut playlist);
step.tick(&finalization_step_label(view, "assigning channel counter"));
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_counter", target.name).as_str());
}
}
}
apply_persist_filter(target, &mut playlist);
playlist
}
pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: Arc<PlaylistProcessingContext<E, M>>,
prepared: PreparedTarget,
@@ -420,95 +484,83 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
{
step.stop("");
info!("Playlist is empty: {}", target.name);
(Ok(()), errors)
} else {
// Process Trakt categories
if trakt_playlist(&ctx.client, target, &mut new_playlist).await {
step.tick("trakt categories");
log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str());
}
let mut flat_new_playlist = flatten_groups(new_playlist);
step.tick("playlist merge");
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
for stage in FINALIZATION_ORDER.into_iter().skip(1) {
match stage {
FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"),
FinalizationStage::Deduplicate => {
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
let removed =
crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist);
if removed > 0 {
info!("Deduplicated {removed} channels for target {}", target.name);
}
step.tick("playlist dedup");
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
}
}
FinalizationStage::Sort => {
if sort_playlist(target, &mut flat_new_playlist) {
step.tick("playlist sort");
log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str());
}
}
FinalizationStage::AssignChannelNumbers => {
assign_channel_no_playlist(&mut flat_new_playlist);
step.tick("assigning channel numbers");
log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str());
}
FinalizationStage::AssignCounters => {
map_playlist_counter(target, &mut flat_new_playlist);
step.tick("assigning channel counter");
log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str());
}
}
}
apply_persist_filter(target, &mut flat_new_playlist);
retain_epg_referenced_by_groups(&flat_new_playlist, &mut new_epg);
let merged_epg = if ctx.config.config.load().disk_based_processing {
// Per-source drain to disk, then multi-way merge. Errors are pushed
// to `errors` rather than `?` because the function returns
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
// surface tempdir / write / merge failures — the user opted in to
// disk spilling, and silently falling back to the in-memory path
// can OOM on large feeds. When the spill itself fails we skip the
// persist step entirely: continuing with `merged_epg = None` would
// overwrite the existing on-disk EPG with nothing and discard the
// previously persisted artifact on a transient error.
match spill_epg_to_disk(new_epg) {
Ok(epg) => epg,
Err(err) => {
let result_error = TuliproxError::new(err.kind(), err.message());
errors.push(err);
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
return (Err(vec![result_error]), errors);
}
}
} else {
flatten_tvguide(new_epg)
};
let library_empty = library_empty.for_filtered_playlist(&flat_new_playlist);
let result = persist_playlist(
&ctx.config,
&mut flat_new_playlist,
merged_epg.as_ref(),
target,
ctx.playlist_state.as_ref(),
TargetPlaylistPersistOptions { accepted_empty_clusters: prepared.accepted_empty_clusters, library_empty },
)
.await;
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await {
step.tick("group watches");
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
}
step.stop("Persisting playlists");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
(result, errors)
return (Ok(()), errors);
}
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
let views = match prepare_target_playlist_views(&ctx.client, target, eligible_catalog).await {
Ok(views) => views,
Err(error) => {
step.stop("Curation failed; skipping persist to preserve finalized artifacts");
return (Err(vec![error]), errors);
}
};
if views.base.is_empty()
&& views.xtream.is_none()
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
&& prepared.accepted_empty_clusters.is_empty()
&& !library_empty.replaces_empty_target()
{
step.stop("");
info!("Playlist is empty: {}", target.name);
return (Ok(()), errors);
}
if views.publication_plan != PlaylistPublicationPlan::Ordinary {
step.tick("target curation");
log_memory_snapshot(format!("target '{}' after_curation", target.name).as_str());
}
let mut finalized_base = finalize_playlist_view(target, views.base, &mut step, "base");
let mut finalized_xtream =
views.xtream.map(|playlist| finalize_playlist_view(target, playlist, &mut step, "Xtream"));
retain_epg_referenced_by_groups(&finalized_base, &mut new_epg);
let merged_epg = if ctx.config.config.load().disk_based_processing {
// Per-source drain to disk, then multi-way merge. Errors are pushed
// to `errors` rather than `?` because the function returns
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
// surface tempdir / write / merge failures — the user opted in to
// disk spilling, and silently falling back to the in-memory path
// can OOM on large feeds. When the spill itself fails we skip the
// persist step entirely: continuing with `merged_epg = None` would
// overwrite the existing on-disk EPG with nothing and discard the
// previously persisted artifact on a transient error.
match spill_epg_to_disk(new_epg) {
Ok(epg) => epg,
Err(err) => {
let result_error = TuliproxError::new(err.kind(), err.message());
errors.push(err);
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
return (Err(vec![result_error]), errors);
}
}
} else {
flatten_tvguide(new_epg)
};
let library_empty = library_empty.for_filtered_playlist(&finalized_base);
let result = persist_playlist_views(
&ctx.config,
&mut finalized_base,
finalized_xtream.as_deref_mut(),
merged_epg.as_ref(),
target,
ctx.playlist_state.as_ref(),
TargetPlaylistPersistOptions {
accepted_empty_clusters: prepared.accepted_empty_clusters,
library_empty,
publication_plan: views.publication_plan,
},
)
.await;
let watch_playlist = target_watch_view(&finalized_base, finalized_xtream.as_deref());
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, watch_playlist).await {
step.tick("group watches");
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
}
step.stop("Persisting playlists");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
(result, errors)
}
pub(crate) async fn playlist_resolve<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
@@ -802,23 +854,165 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
}
}
pub(crate) async fn trakt_playlist(
#[derive(Debug)]
pub(crate) struct TargetPlaylistViews {
pub(crate) base: Vec<PlaylistGroup>,
pub(crate) xtream: Option<Vec<PlaylistGroup>>,
pub(crate) publication_plan: PlaylistPublicationPlan,
}
pub(crate) fn build_curated_playlist_views(
playlist: Vec<PlaylistGroup>,
evaluation: &CurationEvaluation,
trakt_config: &TraktConfig,
appearance_filter_configured: bool,
) -> TargetPlaylistViews {
let curated_catalog = trakt_config.catalog_selection.is_curated();
let mut categories = project_trakt_categories(evaluation, &playlist, trakt_config);
let base = select_target_catalog(playlist, evaluation, curated_catalog);
let mut xtream = if trakt_config.include_xtream_base_categories { base.clone() } else { live_only(&base) };
xtream.append(&mut categories);
TargetPlaylistViews {
base,
xtream: Some(xtream),
publication_plan: PlaylistPublicationPlan::complete_curation_with_filter(
curated_catalog,
!trakt_config.include_xtream_base_categories,
appearance_filter_configured,
),
}
}
pub(crate) async fn prepare_target_playlist_views(
client: &reqwest::Client,
target: &ConfigTarget,
playlist: &mut Vec<PlaylistGroup>,
) -> bool {
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
trace!("No Trakt configuration found for target {}", target.name);
return false;
playlist: Vec<PlaylistGroup>,
) -> Result<TargetPlaylistViews, TuliproxError> {
let Some(trakt_config) = target.get_xtream_output().and_then(|xtream| xtream.trakt.as_ref()) else {
return Ok(TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
});
};
let Some(trakt_categories) = curate_trakt_categories(client, playlist, &target.name, trakt_config).await else {
return false;
};
if !trakt_categories.is_empty() {
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
playlist.extend(trakt_categories);
match evaluate_trakt_curation(client, &playlist, &target.name, trakt_config).await {
CurationRunOutcome::NotConfigured => Ok(TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
}),
CurationRunOutcome::Failed(failure) => Err(curation_failure_error(&target.name, &failure)),
CurationRunOutcome::Complete(evaluation) => {
let views =
build_curated_playlist_views(playlist, &evaluation, trakt_config, target.filter.persist.is_some());
info!(
"Target '{}' curation completed with {} memberships and {} Xtream groups",
target.name,
evaluation.memberships.len(),
views.xtream.as_deref().map_or(0, <[PlaylistGroup]>::len)
);
Ok(views)
}
}
true
}
fn curation_failure_error(target_name: &str, failure: &CurationFailure) -> TuliproxError {
let mut complete = 0usize;
let mut incomplete = 0usize;
let mut unavailable = 0usize;
for outcome in &failure.selector_outcomes {
match outcome {
SelectorOutcome::Complete { .. } => complete += 1,
SelectorOutcome::Incomplete { .. } => incomplete += 1,
SelectorOutcome::Unavailable { .. } => unavailable += 1,
}
}
TuliproxError::RepositoryPlaylist(format!(
"Target '{target_name}' curation refresh failed (complete selectors: {complete}, incomplete: {incomplete}, unavailable: {unavailable}); existing finalized artifacts were retained"
))
}
fn item_subject_uuid(item: &PlaylistItem) -> UUIDType {
if item.header.uuid == UUIDType::default() {
item.get_uuid()
} else {
item.header.uuid
}
}
fn selected_series_parent_keys(playlist: &[PlaylistGroup], selected_subjects: &HashSet<UUIDType>) -> HashSet<Arc<str>> {
let mut parent_keys = HashSet::new();
for item in playlist.iter().flat_map(|group| &group.channels) {
if !matches!(item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|| !selected_subjects.contains(&item_subject_uuid(item))
{
continue;
}
parent_keys.insert(item.get_uuid().intern());
if item.header.uuid != UUIDType::default() {
parent_keys.insert(item.header.uuid.intern());
}
if item.header.virtual_id.get() != 0 {
parent_keys.insert(item.header.virtual_id.get().to_string().intern());
}
if item.header.item_type == PlaylistItemType::LocalSeriesInfo && !item.header.id.is_empty() {
parent_keys.insert(Arc::clone(&item.header.id));
}
}
parent_keys
}
pub(crate) fn select_target_catalog(
mut playlist: Vec<PlaylistGroup>,
evaluation: &CurationEvaluation,
enabled: bool,
) -> Vec<PlaylistGroup> {
if !enabled {
return playlist;
}
let selected_subjects =
evaluation.memberships.iter().map(|membership| membership.subject_uuid).collect::<HashSet<_>>();
let selected_parent_keys = selected_series_parent_keys(&playlist, &selected_subjects);
for group in &mut playlist {
group.channels.retain(|item| {
if item.header.xtream_cluster == XtreamCluster::Live {
return true;
}
match item.header.item_type {
PlaylistItemType::Video
| PlaylistItemType::LocalVideo
| PlaylistItemType::SeriesInfo
| PlaylistItemType::LocalSeriesInfo => selected_subjects.contains(&item_subject_uuid(item)),
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
selected_parent_keys.contains(&item.header.parent_code)
}
_ => false,
}
});
}
playlist.retain(|group| group.xtream_cluster == XtreamCluster::Live || !group.channels.is_empty());
playlist
}
fn live_only(playlist: &[PlaylistGroup]) -> Vec<PlaylistGroup> {
playlist
.iter()
.filter_map(|group| {
let mut live_group = group.clone();
live_group.channels.retain(|item| item.header.xtream_cluster == XtreamCluster::Live);
(group.xtream_cluster == XtreamCluster::Live || !live_group.channels.is_empty()).then_some(live_group)
})
.collect()
}
pub(crate) fn target_watch_view<'playlist>(
base: &'playlist [PlaylistGroup],
xtream: Option<&'playlist [PlaylistGroup]>,
) -> &'playlist [PlaylistGroup] {
xtream.unwrap_or(base)
}
pub(crate) async fn process_watch<E: EventSink>(
@@ -4,12 +4,17 @@ use shared::{
foundation::{get_filter, MapperScript, ValueProvider},
model::{
ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor,
ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
PlaylistUpdateRunOrder, XtreamCluster, XtreamPlaylistItem,
ItemField, M3uPlaylistItem, M3uTargetOutputDto, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, PlaylistUpdateRunOrder, TargetOutputDto, TraktApiConfigDto, TraktCatalogSelection,
TraktConfigDto, TraktContentType, TraktListConfigDto, UUIDType, XtreamCluster, XtreamPlaylistItem,
XtreamTargetOutputDto,
},
utils::Internable,
};
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias};
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias, TraktConfig};
use tuliprox_curation::{
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
};
#[derive(Clone, Default)]
struct PlaylistRunCollectSink(Arc<std::sync::Mutex<Vec<EventMessage>>>);
@@ -1774,15 +1779,822 @@ match {
}
}
#[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");
}
#[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");
}
#[tokio::test]
async fn trakt_finalization_stage_is_a_noop_without_xtream_configuration() {
async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
let target = ConfigTarget::from(&ConfigTargetDto::default());
let mut playlist = Vec::new();
let applied = trakt_playlist(&reqwest::Client::new(), &target, &mut playlist).await;
let views = prepare_target_playlist_views(&reqwest::Client::new(), &target, Vec::new())
.await
.expect("unconfigured curation should not fail");
assert!(!applied);
assert!(playlist.is_empty());
assert!(views.base.is_empty());
assert!(views.xtream.is_none());
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
#[tokio::test]
async fn unavailable_required_selector_returns_target_failure_instead_of_base_fallback() {
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 error = prepare_target_playlist_views(&reqwest::Client::new(), &target, base)
.await
.expect_err("missing credentials must stop target publication");
assert!(error.message().contains("existing finalized artifacts were retained"));
}
mod curation_effect_gate {
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,
};
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()),
})
}
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() }
}
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_retains_seeded_artifacts_cache_and_watch_state() {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let playlist_state = Arc::new(PlaylistStorageState::new());
let 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()
});
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());
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_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());
}
}
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()
},
}
}
fn complete_catalog_evaluation(memberships: Vec<CurationMembership>) -> CurationEvaluation {
CurationEvaluation {
selectors: vec![CurationSelectorSummary {
key: CurationSelectorKey(0),
reference_count: memberships.len(),
membership_count: memberships.len(),
}],
memberships,
}
}
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,
}
}
#[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 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, &config, false);
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, &TraktConfig::from(&compatible_dto), false);
assert_eq!(compatible.base[0].channels.len(), 2);
assert_eq!(compatible.xtream.expect("Xtream view").len(), 1, "selection-only selector creates no category");
}
#[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, &config, false);
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, &config, false);
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);
}
#[cfg(test)]