mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-06 07:52:18 +02:00
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:
co-authored by
Juanjo Presa
euzu
parent
2297b3700a
commit
8fa8a3601b
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user