diff --git a/CHANGELOG.md b/CHANGELOG.md index 1020a128e..191dcdc8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,21 @@ to observe, `=2` to also restart the process on a confirmed stall), and exposes its state through the `/healthcheck` `runtime` object. +- **Trakt curation can now select one target-wide VOD/Series catalog and project Xtream categories independently.** + `output[].trakt.catalog_selection` accepts `full` (the compatibility default) or `curated`; + `include_xtream_base_categories` defaults to `true`; and each list/chart has a default-true + `create_xtream_category`. Existing YAML therefore keeps the full catalog, normal Xtream categories, and current + category-scoped alias IDs. Selection-only selectors may omit `category_name`, while category-producing selectors + still require it. The Source Editor exposes the same controls in all supported locales. + - Curation now evaluates exact surviving target UUIDs after favourites, group merge, and post-merge content + deduplication. M3U and STRM receive selected normal entries rather than Xtream aliases, while Xtream watch behavior + continues to observe its category view. + - Every enabled list/chart is required for a refresh. Partial selector success, missing/invalid credentials, request + failures, malformed responses, and incomplete pagination now fail that target before IDs, persistence, cache, or + watch effects instead of publishing a partial/base fallback. + - A complete empty or no-match result under `catalog_selection: curated` intentionally clears managed VOD/Series + Xtream, M3U, and STRM state while preserving Live. Ordinary or failed empty refreshes retain previous artifacts. + - **`.env` file support for secrets and environment variables:** Tuliprox now automatically loads environment variables from a `.env` file at startup. - **Discovery order:** searches `--env-file ` (or `-e`), `TULIPROX_ENV_FILE`, then `/.env` @@ -1524,10 +1539,10 @@ ## 🛠 Maintenance -- **Playlist curation now has a dedicated capability boundary**: matching, ordering, and virtual-category projection - live in the source-neutral `tuliprox-curation` crate, while Trakt HTTP/JSON handling translates records at the edge. - Existing `output[].trakt` configuration, category identity, matching behavior, and partial-success semantics remain - unchanged. +- **Playlist curation now has a dedicated capability boundary**: matching and ordered membership evaluation live in the + source-neutral `tuliprox-curation` kernel, while Trakt HTTP/JSON handling translates records at the edge and the + category-scoped compatibility projector remains separate from membership identity. Existing category identity and + matching rules remain unchanged; the target-wide selection entry above documents the intentional outcome changes. - **`AdmissionRequest` bundles the request-scoped admission arguments**: five functions each threaded the same ten positional parameters, three of them consecutive bare `bool`s (`use_session_admission`, then diff --git a/backend/core/src/model/config/target.rs b/backend/core/src/model/config/target.rs index e1e3a7b31..dd95ba777 100644 --- a/backend/core/src/model/config/target.rs +++ b/backend/core/src/model/config/target.rs @@ -433,6 +433,28 @@ mod tests { ); } + #[test] + fn hdhomerun_keeps_its_configured_underlying_output_delegation() { + let dto = ConfigTargetDto { + output: vec![ + TargetOutputDto::Xtream(XtreamTargetOutputDto::default()), + TargetOutputDto::M3u(M3uTargetOutputDto::default()), + TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto { + device: "device".to_string(), + username: "viewer".to_string(), + use_output: Some(TargetType::Xtream), + }), + ], + ..ConfigTargetDto::default() + }; + + let target = ConfigTarget::from(&dto); + + assert_eq!(target.get_hdhomerun_output().and_then(|output| output.use_output), Some(TargetType::Xtream)); + assert!(target.has_output(TargetType::Xtream)); + assert!(target.has_output(TargetType::M3u)); + } + #[test] fn execution_plan_preserves_both_deduplication_passes() { let deduplicate = DeduplicateConfig::default(); diff --git a/backend/core/src/model/config/trakt.rs b/backend/core/src/model/config/trakt.rs index 8d18cd153..d66ab5b5e 100644 --- a/backend/core/src/model/config/trakt.rs +++ b/backend/core/src/model/config/trakt.rs @@ -1,7 +1,7 @@ use crate::model::macros; use shared::model::{ - TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, TraktContentType, - TraktListConfigDto, + TraktApiConfigDto, TraktCatalogSelection, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, + TraktContentType, TraktListConfigDto, }; #[derive(Debug, Clone)] @@ -39,7 +39,8 @@ impl From<&TraktApiConfig> for TraktApiConfigDto { pub struct TraktListConfig { pub user: String, pub list_slug: String, - pub category_name: String, + pub category_name: Option, + pub create_xtream_category: bool, pub content_type: TraktContentType, pub tmdb_only: bool, pub fuzzy_match_threshold: u8, // Percentage (0-100) @@ -52,6 +53,7 @@ impl From<&TraktListConfigDto> for TraktListConfig { user: dto.user.clone(), list_slug: dto.list_slug.clone(), category_name: dto.category_name.clone(), + create_xtream_category: dto.create_xtream_category, content_type: dto.content_type, tmdb_only: dto.tmdb_only, fuzzy_match_threshold: dto.fuzzy_match_threshold, @@ -65,6 +67,7 @@ impl From<&TraktListConfig> for TraktListConfigDto { user: instance.user.clone(), list_slug: instance.list_slug.clone(), category_name: instance.category_name.clone(), + create_xtream_category: instance.create_xtream_category, content_type: instance.content_type, tmdb_only: instance.tmdb_only, fuzzy_match_threshold: instance.fuzzy_match_threshold, @@ -76,7 +79,8 @@ impl From<&TraktListConfig> for TraktListConfigDto { pub struct TraktChartConfig { pub kind: TraktChartKind, pub chart: TraktChartType, - pub category_name: String, + pub category_name: Option, + pub create_xtream_category: bool, pub tmdb_only: bool, pub fuzzy_match_threshold: u8, // Percentage (0-100) } @@ -88,6 +92,7 @@ impl From<&TraktChartConfigDto> for TraktChartConfig { kind: dto.kind, chart: dto.chart, category_name: dto.category_name.clone(), + create_xtream_category: dto.create_xtream_category, tmdb_only: dto.tmdb_only, fuzzy_match_threshold: dto.fuzzy_match_threshold, } @@ -100,6 +105,7 @@ impl From<&TraktChartConfig> for TraktChartConfigDto { kind: instance.kind, chart: instance.chart, category_name: instance.category_name.clone(), + create_xtream_category: instance.create_xtream_category, tmdb_only: instance.tmdb_only, fuzzy_match_threshold: instance.fuzzy_match_threshold, } @@ -109,6 +115,8 @@ impl From<&TraktChartConfig> for TraktChartConfigDto { #[derive(Debug, Clone)] pub struct TraktConfig { pub enabled: bool, + pub catalog_selection: TraktCatalogSelection, + pub include_xtream_base_categories: bool, pub api: TraktApiConfig, pub lists: Vec, pub charts: Vec, @@ -119,6 +127,8 @@ impl From<&TraktConfigDto> for TraktConfig { fn from(dto: &TraktConfigDto) -> Self { Self { enabled: dto.enabled, + catalog_selection: dto.catalog_selection, + include_xtream_base_categories: dto.include_xtream_base_categories, api: TraktApiConfig::from(&dto.api), lists: dto.lists.iter().map(Into::into).collect(), charts: dto.charts.iter().map(Into::into).collect(), @@ -129,6 +139,8 @@ impl From<&TraktConfig> for TraktConfigDto { fn from(dto: &TraktConfig) -> Self { Self { enabled: dto.enabled, + catalog_selection: dto.catalog_selection, + include_xtream_base_categories: dto.include_xtream_base_categories, api: TraktApiConfigDto::from(&dto.api), lists: dto.lists.iter().map(TraktListConfigDto::from).collect(), charts: dto.charts.iter().map(TraktChartConfigDto::from).collect(), @@ -144,6 +156,8 @@ mod tests { fn resolved_trakt_config_round_trips_through_the_compatible_dto() { let dto = TraktConfigDto { enabled: true, + catalog_selection: TraktCatalogSelection::Full, + include_xtream_base_categories: true, api: TraktApiConfigDto { api_key: "client-id".to_string(), version: "2".to_string(), @@ -153,7 +167,8 @@ mod tests { lists: vec![TraktListConfigDto { user: "alice".to_string(), list_slug: "watchlist".to_string(), - category_name: "Watchlist".to_string(), + category_name: Some("Watchlist".to_string()), + create_xtream_category: true, content_type: TraktContentType::Vod, tmdb_only: false, fuzzy_match_threshold: 80, @@ -161,7 +176,8 @@ mod tests { charts: vec![TraktChartConfigDto { kind: TraktChartKind::Shows, chart: TraktChartType::Popular, - category_name: "Popular Shows".to_string(), + category_name: Some("Popular Shows".to_string()), + create_xtream_category: false, tmdb_only: true, fuzzy_match_threshold: 90, }], diff --git a/backend/curation/src/kernel.rs b/backend/curation/src/kernel.rs index 42c966afe..674e8eaf4 100644 --- a/backend/curation/src/kernel.rs +++ b/backend/curation/src/kernel.rs @@ -17,12 +17,87 @@ use strsim::normalized_levenshtein; static TRAILING_TITLE_YEAR: LazyLock = LazyLock::new(|| Regex::new(r"\(?(\d{4})\)?$").expect("curation title-year regex must compile")); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CurationMediaKind { +/// Media kind understood by the source-neutral curation kernel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CurationMediaKind { Movie, Series, } +/// Stable selector identity within one target curation run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CurationSelectorKey(pub usize); + +/// Stable reason for a selector that returned only a partial snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurationIncompleteReason { + Interrupted, + PaginationTruncated, +} + +/// Stable reason for a selector that could not produce a snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurationUnavailableReason { + Configuration, + Source, +} + +/// One exact target subject selected by one source selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurationMembership { + pub selector_key: CurationSelectorKey, + pub subject_uuid: UUIDType, + pub media_kind: CurationMediaKind, + pub rank: Option, + pub title_tiebreak: String, + pub candidate_order: usize, +} + +/// Outcome of evaluating one required selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectorOutcome { + Complete { key: CurationSelectorKey, reference_count: usize, memberships: Vec }, + Incomplete { key: CurationSelectorKey, reason: CurationIncompleteReason }, + Unavailable { key: CurationSelectorKey, reason: CurationUnavailableReason }, +} + +impl SelectorOutcome { + pub const fn key(&self) -> CurationSelectorKey { + match self { + Self::Complete { key, .. } | Self::Incomplete { key, .. } | Self::Unavailable { key, .. } => *key, + } + } +} + +/// Complete selector summary without duplicating membership storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CurationSelectorSummary { + pub key: CurationSelectorKey, + pub reference_count: usize, + pub membership_count: usize, +} + +/// Trusted result produced only when every required selector completed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurationEvaluation { + pub selectors: Vec, + pub memberships: Vec, +} + +/// Diagnostic selector outcomes for a run that cannot be published. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CurationFailure { + pub selector_outcomes: Vec, +} + +/// Target-facing curation run outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CurationRunOutcome { + NotConfigured, + Complete(CurationEvaluation), + Failed(CurationFailure), +} + impl CurationMediaKind { const fn from_playlist_item_type(item_type: PlaylistItemType) -> Option { match item_type { @@ -87,6 +162,12 @@ impl CurationMatchPolicy { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct CurationSelectorSpec { + pub(crate) media_scope: CurationMediaScope, + pub(crate) match_policy: CurationMatchPolicy, +} + #[derive(Debug, Clone, Copy)] pub(crate) enum ProjectionIdentityStrategy<'a> { LegacyCategoryScoped { namespace: &'a str }, @@ -105,8 +186,7 @@ impl ProjectionIdentityStrategy<'_> { #[derive(Debug, Clone, Copy)] pub(crate) struct CurationCategorySpec<'a> { pub(crate) name: &'a str, - pub(crate) media_scope: CurationMediaScope, - pub(crate) match_policy: CurationMatchPolicy, + pub(crate) selector: CurationSelectorSpec, pub(crate) projection_identity: ProjectionIdentityStrategy<'a>, } @@ -144,6 +224,8 @@ struct PlaylistCandidate<'a> { struct MatchResult<'playlist, 'reference> { playlist_item: &'playlist PlaylistItem, reference: &'reference CuratedMediaReference, + media_kind: CurationMediaKind, + candidate_order: usize, } pub(crate) fn normalize_title_for_matching(title: &str) -> String { @@ -190,7 +272,7 @@ fn calculate_year_bonus(playlist_year: Option, reference_year: Option) fn find_best_fuzzy_match_for_item<'playlist, 'reference>( candidate: &PlaylistCandidate<'playlist>, references: &'reference [CuratedMediaReference], - specification: &CurationCategorySpec<'_>, + specification: CurationSelectorSpec, threshold: f64, ) -> Option> { let mut best_match: Option<(&CuratedMediaReference, f64)> = None; @@ -221,7 +303,12 @@ fn find_best_fuzzy_match_for_item<'playlist, 'reference>( candidate.item.header.title, reference.title ); - return Some(MatchResult { playlist_item: candidate.item, reference }); + return Some(MatchResult { + playlist_item: candidate.item, + reference, + media_kind: candidate.kind, + candidate_order: 0, + }); } None @@ -230,7 +317,7 @@ fn find_best_fuzzy_match_for_item<'playlist, 'reference>( fn find_best_match_for_item<'playlist, 'reference>( candidate: &PlaylistCandidate<'playlist>, references: &'reference [CuratedMediaReference], - specification: &CurationCategorySpec<'_>, + specification: CurationSelectorSpec, ) -> Option> { if let Some(playlist_tmdb_id) = candidate.tmdb_id { for reference in references.iter().filter(|reference| { @@ -238,7 +325,12 @@ fn find_best_match_for_item<'playlist, 'reference>( }) { if Some(playlist_tmdb_id) == reference.tmdb_id { trace!("TMDB exact curation match: '{}' (TMDB: {})", candidate.item.header.title, playlist_tmdb_id); - return Some(MatchResult { playlist_item: candidate.item, reference }); + return Some(MatchResult { + playlist_item: candidate.item, + reference, + media_kind: candidate.kind, + candidate_order: 0, + }); } } } @@ -247,11 +339,11 @@ fn find_best_match_for_item<'playlist, 'reference>( find_best_fuzzy_match_for_item(candidate, references, specification, threshold) } -pub(crate) fn curate_category( - references: &[CuratedMediaReference], - playlist: &[PlaylistGroup], - specification: &CurationCategorySpec<'_>, -) -> Vec { +fn find_matches<'playlist, 'reference>( + references: &'reference [CuratedMediaReference], + playlist: &'playlist [PlaylistGroup], + specification: CurationSelectorSpec, +) -> Vec> { let reference_count = references.iter().filter(|reference| specification.media_scope.includes_reference(reference.kind)).count(); debug!( @@ -260,6 +352,7 @@ pub(crate) fn curate_category( ); let mut matches = Vec::new(); + let mut candidate_order = 0usize; for playlist_group in playlist { for channel in &playlist_group.channels { if specification.media_scope.includes_cluster(channel.header.xtream_cluster) @@ -275,41 +368,120 @@ pub(crate) fn curate_category( year: extract_year_from_title(&channel.header.title), tmdb_id: channel.get_tmdb_id(), }; - if let Some(matched) = find_best_match_for_item(&candidate, references, specification) { + if let Some(mut matched) = find_best_match_for_item(&candidate, references, specification) { + matched.candidate_order = candidate_order; matches.push(matched); } + candidate_order += 1; } } } - - let series_children = series_children_by_parent_code(playlist); - create_category_from_matches(matches, specification, &series_children) + matches } -fn create_category_from_matches( - mut matches: Vec>, +fn sort_matches(matches: &mut [MatchResult<'_, '_>]) { + matches.sort_by(|left, right| { + (left.reference.rank.unwrap_or(9999), left.reference.title.to_lowercase(), left.candidate_order).cmp(&( + right.reference.rank.unwrap_or(9999), + right.reference.title.to_lowercase(), + right.candidate_order, + )) + }); +} + +fn subject_uuid(item: &PlaylistItem) -> UUIDType { + if item.header.uuid == UUIDType::default() { + item.get_uuid() + } else { + item.header.uuid + } +} + +pub(crate) fn evaluate_selector( + key: CurationSelectorKey, + references: &[CuratedMediaReference], + playlist: &[PlaylistGroup], + specification: CurationSelectorSpec, +) -> SelectorOutcome { + let reference_count = + references.iter().filter(|reference| specification.media_scope.includes_reference(reference.kind)).count(); + let mut matches = find_matches(references, playlist, specification); + sort_matches(&mut matches); + let memberships = matches + .into_iter() + .map(|matched| CurationMembership { + selector_key: key, + subject_uuid: subject_uuid(matched.playlist_item), + media_kind: matched.media_kind, + rank: matched.reference.rank, + title_tiebreak: matched.reference.title.to_lowercase(), + candidate_order: matched.candidate_order, + }) + .collect(); + SelectorOutcome::Complete { key, reference_count, memberships } +} + +pub(crate) struct CurationProjectionCatalog<'playlist> { + items_by_uuid: HashMap, + series_children_by_parent_code: HashMap, Vec<&'playlist PlaylistItem>>, +} + +impl<'playlist> CurationProjectionCatalog<'playlist> { + pub(crate) fn new(playlist: &'playlist [PlaylistGroup]) -> Self { + let items_by_uuid = playlist + .iter() + .flat_map(|group| &group.channels) + .filter(|item| CurationMediaKind::from_playlist_item_type(item.header.item_type).is_some()) + .map(|item| (subject_uuid(item), item)) + .collect(); + Self { items_by_uuid, series_children_by_parent_code: series_children_by_parent_code(playlist) } + } +} + +#[cfg(test)] +pub(crate) fn curate_category( + references: &[CuratedMediaReference], + playlist: &[PlaylistGroup], + specification: &CurationCategorySpec<'_>, +) -> Vec { + let mut matches = find_matches(references, playlist, specification.selector); + sort_matches(&mut matches); + let items = matches.into_iter().map(|matched| matched.playlist_item).collect(); + let projection_catalog = CurationProjectionCatalog::new(playlist); + create_category_from_items(items, specification, &projection_catalog.series_children_by_parent_code) +} + +pub(crate) fn project_memberships<'membership>( + memberships: impl IntoIterator, + projection_catalog: &CurationProjectionCatalog<'_>, + specification: &CurationCategorySpec<'_>, +) -> Vec { + let items = memberships + .into_iter() + .filter(|membership| specification.selector.media_scope.includes_reference(membership.media_kind)) + .filter_map(|membership| projection_catalog.items_by_uuid.get(&membership.subject_uuid).copied()) + .collect(); + create_category_from_items(items, specification, &projection_catalog.series_children_by_parent_code) +} + +fn create_category_from_items( + items: Vec<&PlaylistItem>, specification: &CurationCategorySpec<'_>, series_children_by_parent_code: &HashMap, Vec<&PlaylistItem>>, ) -> Vec { - if matches.is_empty() { + if items.is_empty() { return Vec::new(); } - matches.sort_by(|left, right| { - (left.reference.rank.unwrap_or(9999), left.reference.title.to_lowercase()) - .cmp(&(right.reference.rank.unwrap_or(9999), right.reference.title.to_lowercase())) - }); - let group_title = specification.name.intern(); let mut matched_items_by_cluster: IndexMap> = IndexMap::new(); - for matched in matches { - let projected_item = clone_item_for_category(matched.playlist_item, specification, &group_title); + for item in items { + let projected_item = clone_item_for_category(item, specification, &group_title); let parent_uuid = projected_item.header.uuid.intern(); let is_series_info = matches!(projected_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo); - let child_lookup_keys = - if is_series_info { series_info_child_lookup_keys(matched.playlist_item) } else { Vec::new() }; + let child_lookup_keys = if is_series_info { series_info_child_lookup_keys(item) } else { Vec::new() }; let cluster = projected_item.header.xtream_cluster; matched_items_by_cluster.entry(cluster).or_default().push(projected_item); @@ -418,7 +590,7 @@ mod tests { ]; let candidate = candidate(&playlist_item); - let matched = find_best_match_for_item(&candidate, &references, &specification("Featured", false)) + let matched = find_best_match_for_item(&candidate, &references, specification("Featured", false).selector) .expect("TMDB identity should take precedence"); assert_eq!(matched.reference.tmdb_id, Some(222)); @@ -430,10 +602,10 @@ mod tests { let without_tmdb = video_item("The Captive", None); let matching_tmdb = video_item("Cautivos", Some(456)); let references = vec![reference(CurationMediaKind::Movie, "The Captive", Some(1915), Some(456), Some(1))]; - let exact_only = specification("Featured", true); + let exact_only = specification("Featured", true).selector; - assert!(find_best_match_for_item(&candidate(&without_tmdb), &references, &exact_only).is_none()); - assert!(find_best_match_for_item(&candidate(&matching_tmdb), &references, &exact_only).is_some()); + assert!(find_best_match_for_item(&candidate(&without_tmdb), &references, exact_only).is_none()); + assert!(find_best_match_for_item(&candidate(&matching_tmdb), &references, exact_only).is_some()); } #[test] @@ -442,10 +614,10 @@ mod tests { let candidate = candidate(&playlist_item); let matching_year = vec![reference(CurationMediaKind::Movie, "The Matrix", Some(1999), None, Some(1))]; let different_year = vec![reference(CurationMediaKind::Movie, "The Matrix", Some(2000), None, Some(2))]; - let specification = specification("Featured", false); + let specification = specification("Featured", false).selector; - assert!(find_best_match_for_item(&candidate, &matching_year, &specification).is_some()); - assert!(find_best_match_for_item(&candidate, &different_year, &specification).is_none()); + assert!(find_best_match_for_item(&candidate, &matching_year, specification).is_some()); + assert!(find_best_match_for_item(&candidate, &different_year, specification).is_none()); } #[test] @@ -456,9 +628,12 @@ mod tests { reference(CurationMediaKind::Movie, "The Matrix", None, Some(222), Some(2)), ]; - let matched = - find_best_match_for_item(&candidate(&playlist_item), &references, &specification("Featured", false)) - .expect("a perfect fuzzy title should match"); + let matched = find_best_match_for_item( + &candidate(&playlist_item), + &references, + specification("Featured", false).selector, + ) + .expect("a perfect fuzzy title should match"); assert_eq!(matched.reference.tmdb_id, Some(111)); } @@ -471,15 +646,18 @@ mod tests { reference(CurationMediaKind::Movie, "matrixx", None, Some(222), Some(2)), ]; - let matched = - find_best_match_for_item(&candidate(&playlist_item), &references, &fuzzy_specification("Featured", 80)) - .expect("the best reference above an 80 percent threshold should match"); + let matched = find_best_match_for_item( + &candidate(&playlist_item), + &references, + fuzzy_specification("Featured", 80).selector, + ) + .expect("the best reference above an 80 percent threshold should match"); assert_eq!(matched.reference.tmdb_id, Some(222)); assert!(find_best_match_for_item( &candidate(&playlist_item), &references, - &fuzzy_specification("Featured", 90), + fuzzy_specification("Featured", 90).selector, ) .is_none()); } @@ -504,6 +682,74 @@ mod tests { assert_eq!(titles, ["Alpha", "zebra", "Gamma"]); } + #[test] + fn selector_outcomes_distinguish_remote_empty_no_local_match_and_matches() { + let playlist = vec![PlaylistGroup { + id: 1, + title: "Movies".intern(), + channels: vec![video_item("Matched", Some(7))], + xtream_cluster: XtreamCluster::Video, + }]; + let key = CurationSelectorKey(3); + let specification = specification("Ignored by membership evaluation", true).selector; + + let SelectorOutcome::Complete { reference_count, memberships, .. } = + evaluate_selector(key, &[], &playlist, specification) + else { + panic!("an empty source is complete") + }; + assert_eq!(reference_count, 0); + assert!(memberships.is_empty()); + + let no_match = vec![reference(CurationMediaKind::Movie, "Missing", None, Some(8), Some(1))]; + let SelectorOutcome::Complete { reference_count, memberships, .. } = + evaluate_selector(key, &no_match, &playlist, specification) + else { + panic!("a complete source with no local match is complete") + }; + assert_eq!(reference_count, 1); + assert!(memberships.is_empty()); + + let matched = vec![reference(CurationMediaKind::Movie, "Matched", None, Some(7), Some(1))]; + let SelectorOutcome::Complete { reference_count, memberships, .. } = + evaluate_selector(key, &matched, &playlist, specification) + else { + panic!("matching source is complete") + }; + assert_eq!(reference_count, 1); + assert_eq!(memberships.len(), 1); + assert_eq!(memberships[0].selector_key, key); + assert_eq!(memberships[0].media_kind, CurationMediaKind::Movie); + } + + #[test] + fn membership_identity_keeps_distinct_exact_alias_subjects() { + let mut base = video_item("Same", Some(7)); + base.header.uuid = hash_string("base-subject"); + let mut favourite = video_item("Same", Some(7)); + favourite.header.uuid = hash_string("favourite-subject"); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Movies".intern(), + channels: vec![base, favourite], + xtream_cluster: XtreamCluster::Video, + }]; + let references = vec![reference(CurationMediaKind::Movie, "Same", None, Some(7), Some(1))]; + + let SelectorOutcome::Complete { memberships, .. } = evaluate_selector( + CurationSelectorKey(0), + &references, + &playlist, + specification("Membership", true).selector, + ) else { + panic!("selector should complete") + }; + + assert_eq!(memberships.len(), 2); + assert_eq!(memberships.iter().map(|membership| membership.candidate_order).collect::>(), [0, 1]); + assert_ne!(memberships[0].subject_uuid, memberships[1].subject_uuid); + } + #[test] fn projected_categories_remain_grouped_by_playlist_cluster() { let playlist = vec![ @@ -524,8 +770,13 @@ mod tests { reference(CurationMediaKind::Movie, "Movie", None, Some(1), Some(1)), reference(CurationMediaKind::Series, "Show", None, Some(2), Some(2)), ]; - let specification = - CurationCategorySpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true) }; + let specification = CurationCategorySpec { + selector: CurationSelectorSpec { + media_scope: CurationMediaScope::Both, + ..specification("Mixed", true).selector + }, + ..specification("Mixed", true) + }; let categories = curate_category(&references, &playlist, &specification); @@ -545,11 +796,11 @@ mod tests { reference(CurationMediaKind::Movie, "Movie Reference", None, Some(42), Some(2)), ]; let exact_specification = - CurationCategorySpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true) }; + CurationSelectorSpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true).selector }; - let movie_match = find_best_match_for_item(&candidate(&movie), &exact_references, &exact_specification) + let movie_match = find_best_match_for_item(&candidate(&movie), &exact_references, exact_specification) .expect("movie candidate should match the movie reference"); - let series_match = find_best_match_for_item(&candidate(&series), &exact_references, &exact_specification) + let series_match = find_best_match_for_item(&candidate(&series), &exact_references, exact_specification) .expect("series candidate should match the series reference"); assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie); @@ -559,12 +810,14 @@ mod tests { reference(CurationMediaKind::Series, "Shared Title", None, None, Some(1)), reference(CurationMediaKind::Movie, "Shared Title", None, None, Some(2)), ]; - let fuzzy_specification = - CurationCategorySpec { media_scope: CurationMediaScope::Both, ..fuzzy_specification("Mixed", 100) }; + let fuzzy_specification = CurationSelectorSpec { + media_scope: CurationMediaScope::Both, + ..fuzzy_specification("Mixed", 100).selector + }; - let movie_match = find_best_match_for_item(&candidate(&movie), &fuzzy_references, &fuzzy_specification) + let movie_match = find_best_match_for_item(&candidate(&movie), &fuzzy_references, fuzzy_specification) .expect("movie candidate should fuzzy-match the movie reference"); - let series_match = find_best_match_for_item(&candidate(&series), &fuzzy_references, &fuzzy_specification) + let series_match = find_best_match_for_item(&candidate(&series), &fuzzy_references, fuzzy_specification) .expect("series candidate should fuzzy-match the series reference"); assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie); @@ -658,8 +911,10 @@ mod tests { if exact_only { CurationCategorySpec { name: category_name, - media_scope: CurationMediaScope::Movies, - match_policy: CurationMatchPolicy::ExactTmdbOnly, + selector: CurationSelectorSpec { + media_scope: CurationMediaScope::Movies, + match_policy: CurationMatchPolicy::ExactTmdbOnly, + }, projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" }, } } else { @@ -670,14 +925,20 @@ mod tests { fn fuzzy_specification(category_name: &str, threshold_percent: u8) -> CurationCategorySpec<'_> { CurationCategorySpec { name: category_name, - media_scope: CurationMediaScope::Movies, - match_policy: CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent }, + selector: CurationSelectorSpec { + media_scope: CurationMediaScope::Movies, + match_policy: CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent }, + }, projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" }, } } fn series_specification(category_name: &str, exact_only: bool) -> CurationCategorySpec<'_> { - CurationCategorySpec { media_scope: CurationMediaScope::Series, ..specification(category_name, exact_only) } + let specification = specification(category_name, exact_only); + CurationCategorySpec { + selector: CurationSelectorSpec { media_scope: CurationMediaScope::Series, ..specification.selector }, + ..specification + } } fn candidate(item: &PlaylistItem) -> PlaylistCandidate<'_> { diff --git a/backend/curation/src/lib.rs b/backend/curation/src/lib.rs index e81a1d29d..30efee45f 100644 --- a/backend/curation/src/lib.rs +++ b/backend/curation/src/lib.rs @@ -11,4 +11,8 @@ mod kernel; mod trakt; -pub use trakt::curate_trakt_categories; +pub use kernel::{ + CurationEvaluation, CurationFailure, CurationIncompleteReason, CurationMediaKind, CurationMembership, + CurationRunOutcome, CurationSelectorKey, CurationSelectorSummary, CurationUnavailableReason, SelectorOutcome, +}; +pub use trakt::{evaluate_trakt_curation, project_trakt_categories}; diff --git a/backend/curation/src/trakt/client.rs b/backend/curation/src/trakt/client.rs index eb1ae316d..9633813b1 100644 --- a/backend/curation/src/trakt/client.rs +++ b/backend/curation/src/trakt/client.rs @@ -15,6 +15,32 @@ use tuliprox_core::model::{TraktApiConfig, TraktChartConfig, TraktListConfig}; const TRAKT_PAGE_LIMIT: u32 = 100; const TRAKT_MAX_PAGES: u32 = 100; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TraktFetchFailureKind { + Interrupted, + PaginationTruncated, + Unavailable, +} + +#[derive(Debug)] +pub(super) struct TraktFetchFailure { + pub(super) kind: TraktFetchFailureKind, + error: TuliproxError, +} + +impl TraktFetchFailure { + fn pagination_truncated(message: String) -> Self { + Self { kind: TraktFetchFailureKind::PaginationTruncated, error: TuliproxError::RepositoryTrakt(message) } + } + + fn from_page_error(page: u32, error: TuliproxError) -> Self { + let kind = if page > 1 { TraktFetchFailureKind::Interrupted } else { TraktFetchFailureKind::Unavailable }; + Self { kind, error } + } + + pub(super) fn message(&self) -> &str { self.error.message() } +} + pub(super) struct TraktClient { client: reqwest::Client, api_config: TraktApiConfig, @@ -68,7 +94,7 @@ impl TraktClient { pub(super) async fn get_chart_items( &self, chart_config: &TraktChartConfig, - ) -> Result, TuliproxError> { + ) -> Result, TraktFetchFailure> { let id_label = format!("{}:{}", chart_config.kind, chart_config.chart); self.paginate_items( "chart", @@ -81,7 +107,7 @@ impl TraktClient { pub(super) async fn get_list_items( &self, list_config: &TraktListConfig, - ) -> Result, TuliproxError> { + ) -> Result, TraktFetchFailure> { let id_label = format!("{}:{}", list_config.user, list_config.list_slug); self.paginate_items("list", id_label, |page| async move { self.get_list_items_page(list_config, page).await }) .await @@ -92,7 +118,7 @@ impl TraktClient { kind_label: &'static str, id_label: String, mut fetch_page: F, - ) -> Result, TuliproxError> + ) -> Result, TraktFetchFailure> where F: FnMut(u32) -> Fut, Fut: std::future::Future>, @@ -101,29 +127,59 @@ impl TraktClient { let mut page = 1; let mut items = Vec::new(); + let mut expected_page_count = None; + let mut expected_item_count = None; loop { - let mut page_items = fetch_page(page).await?; - let page_count = page_items.page_count; - let item_count = page_items.item_count; - debug!( - "Fetched Trakt {kind_label} {id_label} page {page}/{page_count} with {} items", - page_items.items.len() - ); - let is_last_page = page >= page_count || page >= TRAKT_MAX_PAGES || page_items.items.is_empty(); + let mut page_items = + fetch_page(page).await.map_err(|error| TraktFetchFailure::from_page_error(page, error))?; + let reported_page_count = page_items.page_count.filter(|count| *count > 0); + let page_count = if page == 1 { + expected_page_count = reported_page_count; + expected_item_count = page_items.item_count; + reported_page_count.unwrap_or(page) + } else { + let Some(page_count) = expected_page_count else { + return Err(TraktFetchFailure::pagination_truncated(format!( + "Trakt {kind_label} {id_label} pagination metadata was missing before page {page}" + ))); + }; + if reported_page_count != Some(page_count) || page_items.item_count != expected_item_count { + return Err(TraktFetchFailure::pagination_truncated(format!( + "Trakt {kind_label} {id_label} pagination metadata changed or disappeared at page {page}" + ))); + } + page_count + }; + if page_count < page { + return Err(TraktFetchFailure::pagination_truncated(format!( + "Trakt {kind_label} {id_label} reported {page_count} pages while fetching page {page}" + ))); + } + let fetched_count = page_items.items.len(); + debug!("Fetched Trakt {kind_label} {id_label} page {page}/{page_count} with {fetched_count} items"); items.append(&mut page_items.items); - if is_last_page { - if page >= TRAKT_MAX_PAGES && page < page_count { - debug!( - "Stopped Trakt {kind_label} {id_label} after {TRAKT_MAX_PAGES} pages; reported page count was {page_count}" - ); + + if page >= page_count { + if expected_item_count.is_some_and(|count| usize::try_from(count).ok() != Some(items.len())) { + return Err(TraktFetchFailure::pagination_truncated(format!( + "Trakt {kind_label} {id_label} snapshot was incomplete: fetched {} items but the source reported {}", + items.len(), + expected_item_count.unwrap_or_default() + ))); } info!( "Successfully fetched {} items from Trakt {kind_label} {id_label}{}", items.len(), - item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default() + expected_item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default() ); return Ok(items); } + + if page >= TRAKT_MAX_PAGES || fetched_count == 0 { + return Err(TraktFetchFailure::pagination_truncated(format!( + "Trakt {kind_label} {id_label} snapshot was incomplete at page {page} of {page_count}" + ))); + } page += 1; } } @@ -136,8 +192,7 @@ impl TraktClient { let url = self.build_list_url(&list_config.user, &list_config.list_slug); let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); let list_id = format!("{}:{}", list_config.user, list_config.list_slug); - let (response_text, page_count, item_count) = - self.fetch_trakt_page(request_url, "list", &list_id, page).await?; + let (response_text, page_count, item_count) = self.fetch_trakt_page(request_url, "list", &list_id).await?; let items: Vec = serde_json::from_str(&response_text).map_err(|error: serde_json::Error| { TuliproxError::Config(format!("Failed to parse Trakt response: {error}")) })?; @@ -153,8 +208,7 @@ impl TraktClient { let url = self.build_chart_url(chart_config); let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); let chart_id = format!("{}:{}", chart_config.kind, chart_config.chart); - let (response_text, page_count, item_count) = - self.fetch_trakt_page(request_url, "chart", &chart_id, page).await?; + let (response_text, page_count, item_count) = self.fetch_trakt_page(request_url, "chart", &chart_id).await?; let items = parse_chart_items(&response_text, chart_config, page) .map_err(|error| TuliproxError::Config(format!("Failed to parse Trakt chart response: {error}")))?; @@ -166,8 +220,7 @@ impl TraktClient { request_url: String, resource_kind: &str, resource_id: &str, - page: u32, - ) -> Result<(String, u32, Option), TuliproxError> { + ) -> Result<(String, Option, Option), TuliproxError> { let response = self.client.get(&request_url).headers(self.headers.clone()).send().await.map_err(|err| { TuliproxError::Config(format!("Failed to fetch Trakt {resource_kind} {request_url}: {err}")) })?; @@ -176,7 +229,7 @@ impl TraktClient { handle_trakt_api_error(response.status(), resource_kind, resource_id)?; } - let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page); + let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count"); let item_count = parse_trakt_pagination_header(response.headers(), "x-pagination-item-count"); let response_text = response.text().await.map_err(|error: reqwest::Error| { TuliproxError::Config(format!("Failed to read Trakt response: {error}")) @@ -188,7 +241,7 @@ impl TraktClient { struct TraktListItemsPage { items: Vec, - page_count: u32, + page_count: Option, item_count: Option, } @@ -328,7 +381,8 @@ mod tests { let list_config = TraktListConfig { user: "user".to_string(), list_slug: "list".to_string(), - category_name: "category".to_string(), + category_name: Some("category".to_string()), + create_xtream_category: true, content_type: TraktContentType::Vod, tmdb_only: false, fuzzy_match_threshold: 90, @@ -341,6 +395,100 @@ mod tests { assert_eq!(requests.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn pagination_at_the_safety_cap_is_reported_as_incomplete() { + let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key")) + .expect("client"); + + let error = client + .paginate_items("list", "large-list".to_string(), |page| async move { + let item = serde_json::from_str::(&trakt_movie_json(page)) + .expect("test Trakt item should parse"); + Ok(TraktListItemsPage { + items: vec![item], + page_count: Some(TRAKT_MAX_PAGES + 1), + item_count: Some(TRAKT_MAX_PAGES + 1), + }) + }) + .await + .expect_err("a bounded prefix must not be accepted as an authoritative snapshot"); + + assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated); + assert!(error.message().contains("incomplete")); + } + + #[tokio::test] + async fn interrupted_later_page_is_not_reported_as_source_unavailable() { + let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key")) + .expect("client"); + + let error = client + .paginate_items("list", "interrupted-list".to_string(), |page| async move { + if page == 1 { + let item = serde_json::from_str::(&trakt_movie_json(page)) + .expect("test Trakt item should parse"); + Ok(TraktListItemsPage { items: vec![item], page_count: Some(2), item_count: Some(2) }) + } else { + Err(TuliproxError::RepositoryTrakt("later page failed".to_string())) + } + }) + .await + .expect_err("interrupted pagination must fail"); + + assert_eq!(error.kind, TraktFetchFailureKind::Interrupted); + } + + #[tokio::test] + async fn inconsistent_reported_item_count_is_incomplete() { + let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key")) + .expect("client"); + + let error = client + .paginate_items("list", "short-list".to_string(), |page| async move { + let item = serde_json::from_str::(&trakt_movie_json(page)) + .expect("test Trakt item should parse"); + Ok(TraktListItemsPage { items: vec![item], page_count: Some(1), item_count: Some(2) }) + }) + .await + .expect_err("reported count mismatch must fail"); + + assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated); + } + + #[tokio::test] + async fn zero_page_count_allows_an_empty_first_page_snapshot() { + let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key")) + .expect("client"); + + let items = client + .paginate_items("list", "empty-list".to_string(), |_| async { + Ok(TraktListItemsPage { items: Vec::new(), page_count: Some(0), item_count: Some(0) }) + }) + .await + .expect("a zero page count should represent an empty snapshot"); + + assert!(items.is_empty()); + } + + #[tokio::test] + async fn missing_pagination_metadata_on_later_page_is_incomplete() { + let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key")) + .expect("client"); + + let error = client + .paginate_items("list", "missing-pagination".to_string(), |page| async move { + let item = serde_json::from_str::(&trakt_movie_json(page)) + .expect("test Trakt item should parse"); + let (page_count, item_count) = if page == 1 { (Some(3), Some(3)) } else { (None, None) }; + Ok(TraktListItemsPage { items: vec![item], page_count, item_count }) + }) + .await + .expect_err("missing pagination metadata must not publish a partial snapshot"); + + assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated); + assert!(error.message().contains("pagination metadata changed or disappeared")); + } + #[tokio::test] async fn get_chart_items_fetches_public_trending_movies() { let requests = Arc::new(Mutex::new(Vec::new())); @@ -474,7 +622,8 @@ mod tests { TraktChartConfig { kind, chart, - category_name: "category".to_string(), + category_name: Some("category".to_string()), + create_xtream_category: true, tmdb_only: false, fuzzy_match_threshold: 90, } diff --git a/backend/curation/src/trakt/mod.rs b/backend/curation/src/trakt/mod.rs index c74a86b8d..7008ee612 100644 --- a/backend/curation/src/trakt/mod.rs +++ b/backend/curation/src/trakt/mod.rs @@ -3,10 +3,12 @@ mod errors; mod model; use crate::kernel::{ - curate_category, CuratedMediaReference, CurationCategorySpec, CurationMatchPolicy, CurationMediaScope, - ProjectionIdentityStrategy, + evaluate_selector, project_memberships, CuratedMediaReference, CurationCategorySpec, CurationEvaluation, + CurationFailure, CurationIncompleteReason, CurationMatchPolicy, CurationMediaScope, CurationProjectionCatalog, + CurationRunOutcome, CurationSelectorKey, CurationSelectorSpec, CurationSelectorSummary, CurationUnavailableReason, + ProjectionIdentityStrategy, SelectorOutcome, }; -use client::TraktClient; +use client::{TraktClient, TraktFetchFailureKind}; use log::{debug, info, warn}; use model::TraktListItem; use shared::model::{PlaylistGroup, TraktContentType}; @@ -16,33 +18,160 @@ use tuliprox_core::model::{TraktChartConfig, TraktConfig, TraktListConfig}; // deliberately supplied by the adapter rather than treated as canonical media identity. const LEGACY_TRAKT_CATEGORY_NAMESPACE: &str = "trakt-category"; -/// Curate virtual playlist categories from the configured Trakt lists and charts. +/// Evaluate every configured Trakt selector into exact target memberships. /// -/// Disabled or source-less configuration is a no-op. Individual list/chart -/// failures are logged and isolated so successful sources still contribute. -pub async fn curate_trakt_categories( +/// No partial selector result is admitted into [`CurationRunOutcome::Complete`]. +pub async fn evaluate_trakt_curation( http_client: &reqwest::Client, playlist: &[PlaylistGroup], target_name: &str, trakt_config: &TraktConfig, -) -> Option> { - if !trakt_config.enabled { - return None; - } - if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() { - debug!("No Trakt lists or charts configured for target {target_name}"); - return None; +) -> CurationRunOutcome { + if !trakt_config.enabled || (trakt_config.lists.is_empty() && trakt_config.charts.is_empty()) { + return CurationRunOutcome::NotConfigured; } + let selector_count = trakt_config.lists.len() + trakt_config.charts.len(); let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) { Ok(processor) => processor, Err(error) => { - warn!("Skipping Trakt curation for target '{target_name}': {}", error.message()); - return None; + warn!("Trakt curation is unavailable for target '{target_name}': {}", error.message()); + return CurationRunOutcome::Failed(CurationFailure { + selector_outcomes: (0..selector_count) + .map(|ordinal| SelectorOutcome::Unavailable { + key: selector_key(ordinal), + reason: CurationUnavailableReason::Configuration, + }) + .collect(), + }); } }; - Some(processor.process(playlist, target_name, trakt_config).await) + info!( + "Evaluating {} Trakt lists and {} Trakt charts for target {target_name}", + trakt_config.lists.len(), + trakt_config.charts.len() + ); + let mut selector_outcomes = Vec::with_capacity(selector_count); + + for (ordinal, list_config) in trakt_config.lists.iter().enumerate() { + let key = selector_key(ordinal); + let source_label = format!("{}:{}", list_config.user, list_config.list_slug); + let outcome = match processor.client.get_list_items(list_config).await { + Ok(items) => { + debug!("Evaluating Trakt list {source_label} with {} items", items.len()); + let references = translate_items(items); + evaluate_selector(key, &references, playlist, list_selector_spec(list_config)) + } + Err(error) => { + warn!("Failed to fetch Trakt list {source_label}: {}", error.message()); + failed_selector_outcome(key, error.kind) + } + }; + selector_outcomes.push(outcome); + } + + for (chart_index, chart_config) in trakt_config.charts.iter().enumerate() { + let ordinal = trakt_config.lists.len() + chart_index; + let key = selector_key(ordinal); + let source_label = format!("{}:{}", chart_config.kind, chart_config.chart); + let outcome = match processor.client.get_chart_items(chart_config).await { + Ok(items) => { + debug!("Evaluating Trakt chart {source_label} with {} items", items.len()); + let references = translate_items(items); + evaluate_selector(key, &references, playlist, chart_selector_spec(chart_config)) + } + Err(error) => { + warn!("Failed to fetch Trakt chart {source_label}: {}", error.message()); + failed_selector_outcome(key, error.kind) + } + }; + selector_outcomes.push(outcome); + } + + complete_evaluation(selector_outcomes) +} + +const fn selector_key(ordinal: usize) -> CurationSelectorKey { CurationSelectorKey(ordinal) } + +fn failed_selector_outcome(key: CurationSelectorKey, failure: TraktFetchFailureKind) -> SelectorOutcome { + match failure { + TraktFetchFailureKind::Interrupted => { + SelectorOutcome::Incomplete { key, reason: CurationIncompleteReason::Interrupted } + } + TraktFetchFailureKind::PaginationTruncated => { + SelectorOutcome::Incomplete { key, reason: CurationIncompleteReason::PaginationTruncated } + } + TraktFetchFailureKind::Unavailable => { + SelectorOutcome::Unavailable { key, reason: CurationUnavailableReason::Source } + } + } +} + +/// Project a complete neutral evaluation into configured Xtream categories. +pub fn project_trakt_categories( + evaluation: &CurationEvaluation, + playlist: &[PlaylistGroup], + trakt_config: &TraktConfig, +) -> Vec { + let mut categories = Vec::new(); + let projection_catalog = CurationProjectionCatalog::new(playlist); + for (ordinal, list_config) in trakt_config.lists.iter().enumerate() { + append_selector_projection( + selector_key(ordinal), + list_config.category_name.as_deref(), + list_config.create_xtream_category, + evaluation, + &projection_catalog, + &list_category_spec(list_config), + &mut categories, + ); + } + for (chart_index, chart_config) in trakt_config.charts.iter().enumerate() { + append_selector_projection( + selector_key(trakt_config.lists.len() + chart_index), + chart_config.category_name.as_deref(), + chart_config.create_xtream_category, + evaluation, + &projection_catalog, + &chart_category_spec(chart_config), + &mut categories, + ); + } + categories +} + +fn append_selector_projection( + key: CurationSelectorKey, + category_name: Option<&str>, + create_xtream_category: bool, + evaluation: &CurationEvaluation, + projection_catalog: &CurationProjectionCatalog<'_>, + specification: &CurationCategorySpec<'_>, + categories: &mut Vec, +) { + if !create_xtream_category || category_name.is_none_or(|name| name.trim().is_empty()) { + return; + } + let memberships = evaluation.memberships.iter().filter(|membership| membership.selector_key == key); + categories.extend(project_memberships(memberships, projection_catalog, specification)); +} + +fn complete_evaluation(selector_outcomes: Vec) -> CurationRunOutcome { + if selector_outcomes.iter().any(|outcome| !matches!(outcome, SelectorOutcome::Complete { .. })) { + return CurationRunOutcome::Failed(CurationFailure { selector_outcomes }); + } + + let mut selectors = Vec::with_capacity(selector_outcomes.len()); + let mut memberships = Vec::new(); + for outcome in selector_outcomes { + let SelectorOutcome::Complete { key, reference_count, memberships: mut selector_memberships } = outcome else { + unreachable!("all selector outcomes were checked as complete") + }; + selectors.push(CurationSelectorSummary { key, reference_count, membership_count: selector_memberships.len() }); + memberships.append(&mut selector_memberships); + } + CurationRunOutcome::Complete(CurationEvaluation { selectors, memberships }) } struct TraktCategoriesProcessor { @@ -54,92 +183,39 @@ impl TraktCategoriesProcessor { let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?; Ok(Self { client }) } - - async fn process( - &self, - playlist: &[PlaylistGroup], - target_name: &str, - trakt_config: &TraktConfig, - ) -> Vec { - info!( - "Processing {} Trakt lists and {} Trakt charts for target {target_name}", - trakt_config.lists.len(), - trakt_config.charts.len() - ); - let mut new_categories = Vec::new(); - let mut total_matches = 0; - - for list_config in &trakt_config.lists { - let source_label = format!("{}:{}", list_config.user, list_config.list_slug); - let specification = list_category_spec(list_config); - - match self.client.get_list_items(list_config).await { - Ok(items) => { - debug!("Processing Trakt list {source_label} with {} items", items.len()); - let references = translate_items(items); - append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches); - } - Err(error) => warn!("Failed to fetch Trakt list {source_label}: {}", error.message()), - } - } - - for chart_config in &trakt_config.charts { - let source_label = format!("{}:{}", chart_config.kind, chart_config.chart); - let specification = chart_category_spec(chart_config); - - match self.client.get_chart_items(chart_config).await { - Ok(items) => { - debug!("Processing Trakt chart {source_label} with {} items", items.len()); - let references = translate_items(items); - append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches); - } - Err(error) => warn!("Failed to fetch Trakt chart {source_label}: {}", error.message()), - } - } - - info!( - "Trakt processing complete: created {} categories with {total_matches} total matches", - new_categories.len() - ); - new_categories - } -} - -fn append_categories( - references: &[CuratedMediaReference], - playlist: &[PlaylistGroup], - specification: &CurationCategorySpec<'_>, - categories: &mut Vec, - total_matches: &mut usize, -) { - for category in curate_category(references, playlist, specification) { - if !category.channels.is_empty() { - *total_matches += category.channels.len(); - let category_len = category.channels.len(); - categories.push(category); - debug!("Created Trakt category '{}' with {category_len} items", specification.name); - } - } } fn translate_items(items: Vec) -> Vec { items.into_iter().filter_map(TraktListItem::into_curated_reference).collect() } +fn list_selector_spec(config: &TraktListConfig) -> CurationSelectorSpec { + selector_spec(config.content_type, config.tmdb_only, config.fuzzy_match_threshold) +} + +fn chart_selector_spec(config: &TraktChartConfig) -> CurationSelectorSpec { + selector_spec(config.kind.content_type(), config.tmdb_only, config.fuzzy_match_threshold) +} + fn list_category_spec(config: &TraktListConfig) -> CurationCategorySpec<'_> { - category_spec(&config.category_name, config.content_type, config.tmdb_only, config.fuzzy_match_threshold) + category_spec( + config.category_name.as_deref().unwrap_or_default(), + config.content_type, + config.tmdb_only, + config.fuzzy_match_threshold, + ) } fn chart_category_spec(config: &TraktChartConfig) -> CurationCategorySpec<'_> { - category_spec(&config.category_name, config.kind.content_type(), config.tmdb_only, config.fuzzy_match_threshold) + category_spec( + config.category_name.as_deref().unwrap_or_default(), + config.kind.content_type(), + config.tmdb_only, + config.fuzzy_match_threshold, + ) } -fn category_spec( - category_name: &str, - content_type: TraktContentType, - tmdb_only: bool, - fuzzy_match_threshold: u8, -) -> CurationCategorySpec<'_> { +fn selector_spec(content_type: TraktContentType, tmdb_only: bool, fuzzy_match_threshold: u8) -> CurationSelectorSpec { let media_scope = match content_type { TraktContentType::Vod => CurationMediaScope::Movies, TraktContentType::Series => CurationMediaScope::Series, @@ -150,10 +226,18 @@ fn category_spec( } else { CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent: fuzzy_match_threshold } }; + CurationSelectorSpec { media_scope, match_policy } +} + +fn category_spec( + category_name: &str, + content_type: TraktContentType, + tmdb_only: bool, + fuzzy_match_threshold: u8, +) -> CurationCategorySpec<'_> { CurationCategorySpec { name: category_name, - media_scope, - match_policy, + selector: selector_spec(content_type, tmdb_only, fuzzy_match_threshold), projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: LEGACY_TRAKT_CATEGORY_NAMESPACE, }, @@ -167,8 +251,8 @@ mod tests { use shared::{ model::{ EpisodeStreamProperties, FieldGet, HeaderField, PlaylistItem, PlaylistItemHeader, PlaylistItemType, - SeriesStreamProperties, StreamProperties, TraktChartKind, TraktChartType, VideoStreamProperties, VirtualId, - XtreamCluster, + SeriesStreamProperties, StreamProperties, TraktCatalogSelection, TraktChartKind, TraktChartType, + VideoStreamProperties, VirtualId, XtreamCluster, }, utils::{hash_string, Internable}, }; @@ -183,19 +267,63 @@ mod tests { }; use tuliprox_core::model::TraktApiConfig; + #[test] + fn complete_evaluation_preserves_selector_order_and_overlapping_subjects() { + let subject_uuid = hash_string("overlapping-subject"); + let outcome = complete_evaluation(vec![ + SelectorOutcome::Complete { + key: CurationSelectorKey(0), + reference_count: 1, + memberships: vec![crate::kernel::CurationMembership { + selector_key: CurationSelectorKey(0), + subject_uuid, + media_kind: crate::kernel::CurationMediaKind::Movie, + rank: Some(1), + title_tiebreak: "same".to_string(), + candidate_order: 0, + }], + }, + SelectorOutcome::Complete { + key: CurationSelectorKey(1), + reference_count: 1, + memberships: vec![crate::kernel::CurationMembership { + selector_key: CurationSelectorKey(1), + subject_uuid, + media_kind: crate::kernel::CurationMediaKind::Movie, + rank: Some(1), + title_tiebreak: "same".to_string(), + candidate_order: 0, + }], + }, + ]); + + let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("all selectors completed") }; + assert_eq!( + evaluation.selectors.iter().map(|summary| summary.key).collect::>(), + [CurationSelectorKey(0), CurationSelectorKey(1),] + ); + assert_eq!(evaluation.memberships.len(), 2); + assert_eq!(evaluation.memberships[0].subject_uuid, evaluation.memberships[1].subject_uuid); + assert_ne!(evaluation.memberships[0].selector_key, evaluation.memberships[1].selector_key); + } + #[tokio::test] - async fn configured_curation_without_a_usable_client_id_makes_no_request() { - for client_id in ["", " \t\r\n ", "sensitive-client-id\ninjected-header"] { - let requests = Arc::new(AtomicUsize::new(0)); - let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; - let config = trakt_config(client_id, base_url, true, vec![remote_list_config("Missing")], Vec::new()); + async fn missing_credentials_make_every_required_selector_unavailable_without_a_request() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + let config = + trakt_config("", base_url, true, vec![remote_list_config("List")], vec![remote_chart_config("Chart")]); - let result = curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &config).await; + let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &config).await; - assert!(result.is_none()); - assert_eq!(requests.load(Ordering::SeqCst), 0); - server.abort(); - } + let CurationRunOutcome::Failed(failure) = outcome else { panic!("missing credentials must fail the run") }; + assert_eq!(failure.selector_outcomes.len(), 2); + assert!(failure.selector_outcomes.iter().all(|outcome| matches!( + outcome, + SelectorOutcome::Unavailable { reason: CurationUnavailableReason::Configuration, .. } + ))); + assert_eq!(requests.load(Ordering::SeqCst), 0); + server.abort(); } #[tokio::test] @@ -205,21 +333,69 @@ mod tests { let disabled = trakt_config("", base_url.clone(), false, vec![remote_list_config("Disabled")], Vec::new()); let source_less = trakt_config("", base_url, true, Vec::new(), Vec::new()); - assert!(curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &disabled).await.is_none()); - assert!(curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &source_less).await.is_none()); + assert_eq!( + evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &disabled).await, + CurationRunOutcome::NotConfigured + ); + assert_eq!( + evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &source_less).await, + CurationRunOutcome::NotConfigured + ); assert_eq!(requests.load(Ordering::SeqCst), 0); server.abort(); } #[tokio::test] - async fn failed_list_does_not_suppress_successful_chart() { + async fn complete_remote_empty_remains_distinct_from_not_configured() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + let config = trakt_config("test-client-id", base_url, true, vec![remote_list_config("Empty")], Vec::new()); + + let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &config).await; + + let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("empty response must complete") }; + assert_eq!(evaluation.selectors.len(), 1); + assert_eq!(evaluation.selectors[0].reference_count, 0); + assert_eq!(evaluation.selectors[0].membership_count, 0); + assert!(evaluation.memberships.is_empty()); + server.abort(); + } + + #[tokio::test] + async fn complete_remote_match_produces_exact_subject_membership() { + let body = r#"[{"id":1,"rank":1,"listed_at":"2026-01-01T00:00:00.000Z","type":"movie","movie":{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}}]"#; + let (base_url, server) = spawn_single_response_trakt_server(body).await; + let config = trakt_config("test-client-id", base_url, true, vec![remote_list_config("Matched")], Vec::new()); + let mut item = video_item("Movie 1", Some(11)); + item.header.uuid = hash_string("exact-target-subject"); + let subject_uuid = item.header.uuid; + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![item], + xtream_cluster: XtreamCluster::Video, + }]; + + let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &playlist, "test-target", &config).await; + + let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("selector should complete") }; + assert_eq!(evaluation.memberships.len(), 1); + assert_eq!(evaluation.memberships[0].subject_uuid, subject_uuid); + server.await.expect("test server should finish"); + } + + #[tokio::test] + async fn one_failed_selector_prevents_complete_run_even_when_another_succeeds() { let requests = Arc::new(Mutex::new(Vec::new())); let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await; + let mut selection_only = remote_list_config("Preserved name"); + selection_only.create_xtream_category = false; + selection_only.category_name = None; let config = trakt_config( "test-client-id", base_url, true, - vec![remote_list_config("Unavailable List")], + vec![selection_only], vec![remote_chart_config("Available Chart")], ); let playlist = vec![PlaylistGroup { @@ -229,15 +405,14 @@ mod tests { xtream_cluster: XtreamCluster::Video, }]; - let categories = curate_trakt_categories(&reqwest::Client::new(), &playlist, "test-target", &config) - .await - .expect("configured sources should produce a result"); + let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &playlist, "test-target", &config).await; - assert_eq!(categories.len(), 1); - assert_eq!(categories[0].title.as_ref(), "Available Chart"); - assert_eq!(categories[0].channels.len(), 1); - assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie 1"); - assert_eq!(requests.lock().expect("requests").len(), 2); + let CurationRunOutcome::Failed(failure) = outcome else { panic!("partial success must fail the run") }; + assert!(matches!(failure.selector_outcomes[0], SelectorOutcome::Unavailable { .. })); + assert!(matches!( + &failure.selector_outcomes[1], + SelectorOutcome::Complete { memberships, .. } if memberships.len() == 1 + )); server.await.expect("test server should finish"); } @@ -271,10 +446,22 @@ mod tests { let featured_config = remote_list_config("Featured"); let renoir_config = remote_list_config("Renoir"); - let featured = curate_category(&references, &playlist, &list_category_spec(&featured_config)); - let renoir = curate_category(&references, &playlist, &list_category_spec(&renoir_config)); - let featured_item = &featured[0].channels[0]; - let renoir_item = &renoir[0].channels[0]; + let CurationRunOutcome::Complete(evaluation) = complete_evaluation(vec![ + evaluate_selector(CurationSelectorKey(0), &references, &playlist, list_selector_spec(&featured_config)), + evaluate_selector(CurationSelectorKey(1), &references, &playlist, list_selector_spec(&renoir_config)), + ]) else { + panic!("both local selectors should complete") + }; + let config = trakt_config( + "test-client-id", + "http://example.invalid".to_string(), + true, + vec![featured_config, renoir_config], + Vec::new(), + ); + let categories = project_trakt_categories(&evaluation, &playlist, &config); + let featured_item = &categories[0].channels[0]; + let renoir_item = &categories[1].channels[0]; assert_eq!(featured_item.header.group.as_ref(), "Featured"); assert_eq!(renoir_item.header.group.as_ref(), "Renoir"); @@ -301,7 +488,7 @@ mod tests { let references = translate_items(vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]); let config = TraktListConfig { content_type: TraktContentType::Series, ..remote_list_config("Trending") }; - let categories = curate_category(&references, &playlist, &list_category_spec(&config)); + let categories = project_list_references(&references, &playlist, &config); let cloned_series = categories[0] .channels .iter() @@ -336,7 +523,7 @@ mod tests { }]; let references = translate_items(vec![trakt_list_movie("Clean Title", None, Some(1), 1)]); - let categories = curate_category(&references, &playlist, &list_category_spec(&remote_list_config("Featured"))); + let categories = project_list_references(&references, &playlist, &remote_list_config("Featured")); assert_eq!( categories[0].channels[0].header.get(HeaderField::Caption).expect("quality caption").as_cow(), @@ -344,6 +531,36 @@ mod tests { ); } + fn project_list_references( + references: &[CuratedMediaReference], + playlist: &[PlaylistGroup], + list_config: &TraktListConfig, + ) -> Vec { + let outcome = evaluate_selector(CurationSelectorKey(0), references, playlist, list_selector_spec(list_config)); + let CurationRunOutcome::Complete(evaluation) = complete_evaluation(vec![outcome]) else { + panic!("local selector evaluation should complete") + }; + let config = trakt_config( + "test-client-id", + "http://example.invalid".to_string(), + true, + vec![list_config.clone()], + Vec::new(), + ); + project_trakt_categories(&evaluation, playlist, &config) + } + + async fn spawn_single_response_trakt_server(body: &'static str) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept test request"); + let _ = read_request(&mut stream).await; + write_response(&mut stream, "200 OK", body).await; + }); + (format!("http://{addr}"), server) + } + async fn spawn_counting_trakt_server(requests: Arc) -> (String, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); let addr = listener.local_addr().expect("local addr"); @@ -415,6 +632,8 @@ mod tests { ) -> TraktConfig { TraktConfig { enabled, + catalog_selection: TraktCatalogSelection::Full, + include_xtream_base_categories: true, api: TraktApiConfig { api_key: client_id.to_string(), version: "2".to_string(), @@ -430,7 +649,8 @@ mod tests { TraktListConfig { user: "test-user".to_string(), list_slug: "test-list".to_string(), - category_name: category_name.to_string(), + category_name: Some(category_name.to_string()), + create_xtream_category: true, content_type: TraktContentType::Vod, tmdb_only: true, fuzzy_match_threshold: 100, @@ -441,7 +661,8 @@ mod tests { TraktChartConfig { kind: TraktChartKind::Movies, chart: TraktChartType::Popular, - category_name: category_name.to_string(), + category_name: Some(category_name.to_string()), + create_xtream_category: true, tmdb_only: true, fuzzy_match_threshold: 100, } diff --git a/backend/processing/src/processor/deduplicate.rs b/backend/processing/src/processor/deduplicate.rs index c2f705360..411dfd42d 100644 --- a/backend/processing/src/processor/deduplicate.rs +++ b/backend/processing/src/processor/deduplicate.rs @@ -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]")])]; diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 5531e32f0..279b1c59c 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -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; diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs index e4346fd0e..43eb6e745 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -399,6 +399,70 @@ pub(crate) fn spill_epg_to_disk(sources: Vec) -> Result, 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, + step: &mut StepMeasure, +) -> Vec { + 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, + step: &mut StepMeasure, + view: &str, +) -> Vec { + 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( ctx: Arc>, prepared: PreparedTarget, @@ -420,95 +484,83 @@ pub(crate) async fn finalize_prepared_target 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)`, 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)`, 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( @@ -802,23 +854,165 @@ pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Opt } } -pub(crate) async fn trakt_playlist( +#[derive(Debug)] +pub(crate) struct TargetPlaylistViews { + pub(crate) base: Vec, + pub(crate) xtream: Option>, + pub(crate) publication_plan: PlaylistPublicationPlan, +} + +pub(crate) fn build_curated_playlist_views( + playlist: Vec, + 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, -) -> 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, +) -> Result { + 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) -> HashSet> { + 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, + evaluation: &CurationEvaluation, + enabled: bool, +) -> Vec { + if !enabled { + return playlist; + } + + let selected_subjects = + evaluation.memberships.iter().map(|membership| membership.subject_uuid).collect::>(); + 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 { + 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( diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index f2473f453..b274dab32 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -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>>); @@ -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 { + 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::::default()), + api_proxy: Arc::new(ArcSwapOption::::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::::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + }) + } + + fn processing_context( + app_config: Arc, + playlist_state: Option>, + ) -> PlaylistProcessingContext { + 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> { + fn collect(root: &Path, path: &Path, snapshot: &mut BTreeMap>) { + 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> = + 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> = + 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) -> 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::>(); + + 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::>(); + 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::(), + 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::(), CATALOG_SIZE + 4_000); } #[cfg(test)] diff --git a/backend/repository/src/m3u_repository.rs b/backend/repository/src/m3u_repository.rs index 6836b61d7..13841fc78 100644 --- a/backend/repository/src/m3u_repository.rs +++ b/backend/repository/src/m3u_repository.rs @@ -264,9 +264,9 @@ pub async fn m3u_write_playlist( target_output: &M3uTargetOutput, target_path: &Path, new_playlist: &[PlaylistGroup], - library_empty: crate::LibraryEmptyPublication, + allow_empty_publication: bool, ) -> Result<(), TuliproxError> { - if new_playlist.is_empty() && !library_empty.replaces_empty_target() { + if new_playlist.is_empty() && !allow_empty_publication { return Ok(()); } diff --git a/backend/repository/src/playlist_repository.rs b/backend/repository/src/playlist_repository.rs index 27e2bc58e..0098c433d 100644 --- a/backend/repository/src/playlist_repository.rs +++ b/backend/repository/src/playlist_repository.rs @@ -103,16 +103,108 @@ impl LibraryEmptyPublication { } } -/// Target persistence behavior authorized by successful input results, not by Quality inference. +/// Output replacement authority derived only from a complete curation run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaylistPublicationPlan { + Ordinary, + CompleteCuration { + intentionally_empty_base_vod: bool, + intentionally_empty_base_series: bool, + intentionally_empty_xtream_vod: bool, + intentionally_empty_xtream_series: bool, + }, +} + +impl PlaylistPublicationPlan { + #[must_use] + pub const fn complete_curation(curated_catalog: bool, xtream_base_suppressed: bool) -> Self { + Self::complete_curation_with_filter(curated_catalog, xtream_base_suppressed, false) + } + + #[must_use] + pub const fn complete_curation_with_filter( + curated_catalog: bool, + xtream_base_suppressed: bool, + appearance_filter_configured: bool, + ) -> Self { + Self::CompleteCuration { + intentionally_empty_base_vod: curated_catalog || appearance_filter_configured, + intentionally_empty_base_series: curated_catalog || appearance_filter_configured, + intentionally_empty_xtream_vod: curated_catalog || xtream_base_suppressed || appearance_filter_configured, + intentionally_empty_xtream_series: curated_catalog + || xtream_base_suppressed + || appearance_filter_configured, + } + } + + #[must_use] + pub const fn with_output_filter(self, output_filter_configured: bool) -> Self { + if !output_filter_configured { + return self; + } + match self { + Self::Ordinary => Self::Ordinary, + Self::CompleteCuration { .. } => Self::CompleteCuration { + intentionally_empty_base_vod: true, + intentionally_empty_base_series: true, + intentionally_empty_xtream_vod: true, + intentionally_empty_xtream_series: true, + }, + } + } + + #[must_use] + pub const fn allows_empty_xtream_cluster(self, cluster: XtreamCluster) -> bool { + matches!( + (self, cluster), + (Self::CompleteCuration { intentionally_empty_xtream_vod: true, .. }, XtreamCluster::Video) + | (Self::CompleteCuration { intentionally_empty_xtream_series: true, .. }, XtreamCluster::Series) + ) + } + + #[must_use] + pub const fn allows_empty_base_output(self) -> bool { + matches!( + self, + Self::CompleteCuration { intentionally_empty_base_vod: true, .. } + | Self::CompleteCuration { intentionally_empty_base_series: true, .. } + ) + } + + #[must_use] + pub const fn allows_any_empty_output(self) -> bool { + self.allows_empty_base_output() + || self.allows_empty_xtream_cluster(XtreamCluster::Video) + || self.allows_empty_xtream_cluster(XtreamCluster::Series) + } + + fn replacement_clusters(self) -> ClusterFlags { + let mut clusters = ClusterFlags::empty(); + if self.allows_empty_xtream_cluster(XtreamCluster::Video) { + clusters |= ClusterFlags::Vod; + } + if self.allows_empty_xtream_cluster(XtreamCluster::Series) { + clusters |= ClusterFlags::Series; + } + clusters + } +} + +/// Target persistence behavior authorized by successful input and curation results, not by Quality inference. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TargetPlaylistPersistOptions { pub accepted_empty_clusters: ClusterFlags, pub library_empty: LibraryEmptyPublication, + pub publication_plan: PlaylistPublicationPlan, } impl Default for TargetPlaylistPersistOptions { fn default() -> Self { - Self { accepted_empty_clusters: ClusterFlags::empty(), library_empty: LibraryEmptyPublication::None } + Self { + accepted_empty_clusters: ClusterFlags::empty(), + library_empty: LibraryEmptyPublication::None, + publication_plan: PlaylistPublicationPlan::Ordinary, + } } } @@ -121,7 +213,10 @@ fn validate_target_playlist_persistence( playlist_is_empty: bool, options: TargetPlaylistPersistOptions, ) -> Result<(), TuliproxError> { - if !playlist_is_empty || options.library_empty.replaces_empty_target() { + if !playlist_is_empty + || options.library_empty.replaces_empty_target() + || options.publication_plan.allows_any_empty_output() + { return Ok(()); } if options.accepted_empty_clusters.is_empty() { @@ -141,12 +236,19 @@ fn validate_target_playlist_persistence( fn prepare_target_output_playlists( target: &ConfigTarget, - playlist: &[PlaylistGroup], + base_playlist: &[PlaylistGroup], + xtream_playlist: Option<&[PlaylistGroup]>, ) -> Vec>> { target .output .iter() - .map(|output| output.filter().map(|filter| apply_filter_to_playlist(playlist, filter))) + .map(|output| { + let source_playlist = match output { + TargetOutput::Xtream(_) => xtream_playlist.unwrap_or(base_playlist), + _ => base_playlist, + }; + output.filter().map(|filter| apply_filter_to_playlist(source_playlist, filter)) + }) .collect() } @@ -169,7 +271,9 @@ fn validate_force_empty_output_filters( let Some(filtered_playlist) = prepared_output else { continue; }; - if !playlist_has_items(filtered_playlist) { + let curation_allows_empty = + options.publication_plan.with_output_filter(output.filter().is_some()).allows_empty_base_output(); + if !playlist_has_items(filtered_playlist) && !curation_allows_empty { return Err(TuliproxError::RepositoryPlaylist(format!( "Refusing to publish force-empty {output_name} output for target '{}' after its output filter; existing data was retained", target.name @@ -228,60 +332,12 @@ fn normalize_target_playlist_epg_ids(playlist: &mut [PlaylistGroup], target_opti } } -#[allow(clippy::too_many_lines)] -pub async fn persist_playlist( - app_config: &Arc, +fn prepare_target_playlist_for_persistence( playlist: &mut [PlaylistGroup], - epg: Option<&Epg>, target: &ConfigTarget, - playlist_state: Option<&Arc>, - options: TargetPlaylistPersistOptions, -) -> Result<(), Vec> { - persist_playlist_with_mode( - app_config, - playlist, - epg, - target, - playlist_state, - options, - TargetPersistenceMode::Persist, - ) - .await -} - -#[allow(clippy::too_many_lines)] -async fn persist_playlist_with_mode( - app_config: &Arc, - playlist: &mut [PlaylistGroup], - epg: Option<&Epg>, - target: &ConfigTarget, - playlist_state: Option<&Arc>, - options: TargetPlaylistPersistOptions, - persistence_mode: TargetPersistenceMode, -) -> Result<(), Vec> { - let playlist_is_empty = !playlist_has_items(playlist); - if let Err(error) = validate_target_playlist_persistence(target, playlist_is_empty, options) { - return Err(vec![error]); - } - let mut errors = vec![]; - let config = &app_config.config.load(); - let target_path = match ensure_target_storage_path(config, &target.name).await { - Ok(path) => path, - Err(err) => return Err(vec![err]), - }; - - let (mut target_id_mapping, file_lock) = - match get_target_id_mapping(app_config, &target_path, target.use_memory_cache).await { - Ok(result) => result, - Err(err) => return Err(vec![err]), - }; - - let mut local_library_series = HashMap::, Vec>::new(); - let mut provider_series = HashMap::, Vec>::new(); - let mut media_server_series = HashMap::, Vec>::new(); - + target_id_mapping: &mut TargetIdMapping, +) { let mut source_ordinal: u32 = 0; - // Virtual IDs assignment for group in playlist.iter_mut() { for channel in &mut group.channels { let header = &mut channel.header; @@ -314,8 +370,11 @@ async fn persist_playlist_with_mode( } } - rewrite_series_episode_parent_virtual_ids(playlist, &mut target_id_mapping); + rewrite_series_episode_parent_virtual_ids(playlist, target_id_mapping); + let mut local_library_series = HashMap::, Vec>::new(); + let mut provider_series = HashMap::, Vec>::new(); + let mut media_server_series = HashMap::, Vec>::new(); for group in playlist.iter_mut() { for channel in &mut group.channels { let header = &mut channel.header; @@ -332,13 +391,102 @@ async fn persist_playlist_with_mode( materialize_media_server_series_info_episodes(playlist, &media_server_series); rewrite_series_info_episode_virtual_id(playlist, &local_library_series, &provider_series); - drop(local_library_series); - drop(provider_series); - drop(media_server_series); - normalize_target_playlist_epg_ids(playlist, target.options.as_ref()); +} - let mut prepared_outputs = prepare_target_output_playlists(target, playlist); +#[allow(clippy::too_many_lines)] +pub async fn persist_playlist( + app_config: &Arc, + playlist: &mut [PlaylistGroup], + epg: Option<&Epg>, + target: &ConfigTarget, + playlist_state: Option<&Arc>, + options: TargetPlaylistPersistOptions, +) -> Result<(), Vec> { + persist_playlist_with_mode( + app_config, + playlist, + epg, + target, + playlist_state, + options, + TargetPersistenceMode::Persist, + ) + .await +} + +#[allow(clippy::too_many_lines)] +pub async fn persist_playlist_views( + app_config: &Arc, + base_playlist: &mut [PlaylistGroup], + xtream_playlist: Option<&mut [PlaylistGroup]>, + epg: Option<&Epg>, + target: &ConfigTarget, + playlist_state: Option<&Arc>, + options: TargetPlaylistPersistOptions, +) -> Result<(), Vec> { + persist_playlist_views_with_mode( + app_config, + base_playlist, + xtream_playlist, + epg, + target, + playlist_state, + options, + TargetPersistenceMode::Persist, + ) + .await +} + +#[allow(clippy::too_many_lines)] +async fn persist_playlist_with_mode( + app_config: &Arc, + playlist: &mut [PlaylistGroup], + epg: Option<&Epg>, + target: &ConfigTarget, + playlist_state: Option<&Arc>, + options: TargetPlaylistPersistOptions, + persistence_mode: TargetPersistenceMode, +) -> Result<(), Vec> { + persist_playlist_views_with_mode(app_config, playlist, None, epg, target, playlist_state, options, persistence_mode) + .await +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +async fn persist_playlist_views_with_mode( + app_config: &Arc, + base_playlist: &mut [PlaylistGroup], + mut xtream_playlist: Option<&mut [PlaylistGroup]>, + epg: Option<&Epg>, + target: &ConfigTarget, + playlist_state: Option<&Arc>, + options: TargetPlaylistPersistOptions, + persistence_mode: TargetPersistenceMode, +) -> Result<(), Vec> { + let playlist_is_empty = !playlist_has_items(base_playlist) + && xtream_playlist.as_deref().is_none_or(|playlist| !playlist_has_items(playlist)); + if let Err(error) = validate_target_playlist_persistence(target, playlist_is_empty, options) { + return Err(vec![error]); + } + let mut errors = vec![]; + let config = &app_config.config.load(); + let target_path = match ensure_target_storage_path(config, &target.name).await { + Ok(path) => path, + Err(err) => return Err(vec![err]), + }; + + let (mut target_id_mapping, file_lock) = + match get_target_id_mapping(app_config, &target_path, target.use_memory_cache).await { + Ok(result) => result, + Err(err) => return Err(vec![err]), + }; + + prepare_target_playlist_for_persistence(base_playlist, target, &mut target_id_mapping); + if let Some(xtream_view) = xtream_playlist.as_deref_mut() { + prepare_target_playlist_for_persistence(xtream_view, target, &mut target_id_mapping); + } + + let mut prepared_outputs = prepare_target_output_playlists(target, base_playlist, xtream_playlist.as_deref()); if let Err(error) = validate_force_empty_output_filters(target, &prepared_outputs, options) { target_id_mapping.discard_unpersisted_changes(); drop(target_id_mapping); @@ -347,12 +495,20 @@ async fn persist_playlist_with_mode( } for (output, prepared_output) in target.output.iter().zip(&mut prepared_outputs) { + let output_publication_plan = options.publication_plan.with_output_filter(output.filter().is_some()); + let source_playlist = match output { + TargetOutput::Xtream(_) => xtream_playlist.as_deref_mut().unwrap_or(base_playlist), + _ => &mut *base_playlist, + }; let pl: &mut [PlaylistGroup] = if let Some(filtered_playlist) = prepared_output.as_mut() { filtered_playlist.as_mut_slice() } else { - &mut *playlist + source_playlist }; let library_empty = options.library_empty.for_filtered_playlist(pl); + let curation_empty_clusters = output_publication_plan.replacement_clusters(); + let allows_empty_base = + library_empty.replaces_empty_target() || output_publication_plan.allows_empty_base_output(); let result = match output { TargetOutput::Xtream(_xtream_output) => { @@ -360,23 +516,31 @@ async fn persist_playlist_with_mode( app_config, target, pl, - options.accepted_empty_clusters | library_empty.replacement_clusters(), + options.accepted_empty_clusters | library_empty.replacement_clusters() | curation_empty_clusters, persistence_mode, ) .await } TargetOutput::M3u(m3u_output) => { - m3u_write_playlist(app_config, target, m3u_output, &target_path, pl, library_empty).await + m3u_write_playlist(app_config, target, m3u_output, &target_path, pl, allows_empty_base).await } TargetOutput::Strm(strm_output) => { - write_strm_playlist(app_config, target, strm_output, pl, library_empty).await + write_strm_playlist(app_config, target, strm_output, pl, allows_empty_base).await } TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), }; match result { Ok(()) => { - if !pl.is_empty() { + let allows_empty_output = match output { + TargetOutput::Xtream(_) => { + !curation_empty_clusters.is_empty() + || !library_empty.replacement_clusters().is_empty() + || !options.accepted_empty_clusters.is_empty() + } + _ => allows_empty_base, + }; + if !pl.is_empty() || allows_empty_output { let epg_pl: &[PlaylistGroup] = pl; if let Err(err) = epg_write_for_target(config, target, &target_path, epg, output, Some(epg_pl)).await @@ -1205,24 +1369,29 @@ mod tests { use super::{ assign_local_series_info_episode_key, assign_media_server_series_info_episode, get_input_media_server_playlist_file_path, materialize_media_server_series_info_episodes, - normalize_target_playlist_epg_ids, persist_playlist_with_mode, playlist_has_items, + normalize_target_playlist_epg_ids, persist_playlist_views, persist_playlist_with_mode, playlist_has_items, rewrite_local_series_info_episode_virtual_id, rewrite_series_episode_parent_virtual_ids, rewrite_series_info_episode_virtual_id, skipped_clusters, validate_target_playlist_persistence, - LocalEpisodeKey, ProviderEpisodeKey, TargetCacheReloadStage, TargetPersistenceMode, + LocalEpisodeKey, PlaylistPublicationPlan, ProviderEpisodeKey, TargetCacheReloadStage, TargetPersistenceMode, TargetPlaylistPersistOptions, }; - use crate::{load_xtream_target_storage, BPlusTreeQuery, PlaylistStorageState, TargetIdMapping, VirtualIdRecord}; + use crate::{ + get_series_cat_collection_path, get_target_storage_path, get_vod_cat_collection_path, load_m3u_target_storage, + load_xtream_target_storage, strm_get_file_paths, xtream_get_storage_path, BPlusTreeQuery, PlaylistStorageState, + TargetIdMapping, VirtualIdRecord, + }; use arc_swap::{ArcSwap, ArcSwapOption}; use shared::{ foundation::get_filter, model::{ - ClusterFlags, ConfigPaths, ConfigTargetOptions, EpgOutputOptions, EpisodeStreamProperties, M3uPlaylistItem, - PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProcessingOrder, - SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, - SeriesStreamProperties, StreamProperties, StrmExportStyle, UUIDType, VirtualId, XtreamCluster, - XtreamPlaylistItem, + ClusterFlags, ConfigPaths, ConfigTargetDto, ConfigTargetOptions, EpgOutputOptions, EpisodeStreamProperties, + M3uPlaylistItem, M3uTargetOutputDto, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, + PlaylistItemType, ProcessingOrder, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, + SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, StrmExportStyle, + StrmTargetOutputDto, TargetOutputDto, UUIDType, VirtualId, XtreamCluster, XtreamPlaylistItem, + XtreamTargetOutputDto, }, - utils::Internable, + utils::{hash_string_as_hex, Internable}, }; use std::{collections::HashMap, path::Path, sync::Arc}; use tuliprox_core::{ @@ -1231,7 +1400,7 @@ mod tests { MediaToolCapabilities, SourcesConfig, StagedFilter, StrmTargetFlagsSet, StrmTargetOutput, TargetExecutionPlan, TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput, }, - utils::FileLockManager, + utils::{normalize_string_path, FileLockManager}, }; fn target_with_outputs(output: Vec) -> ConfigTarget { @@ -1621,6 +1790,372 @@ mod tests { } use tempfile::tempdir; + #[test] + fn complete_curation_empty_authorization_is_projection_and_cluster_scoped() { + let compatibility = PlaylistPublicationPlan::complete_curation(false, false); + assert!(!compatibility.allows_any_empty_output()); + + let suppressed_xtream_base = PlaylistPublicationPlan::complete_curation(false, true); + assert!(!suppressed_xtream_base.allows_empty_base_output()); + assert!(!suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Live)); + assert!(suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Video)); + assert!(suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Series)); + + let persist_filtered = PlaylistPublicationPlan::complete_curation_with_filter(false, false, true); + assert!(persist_filtered.allows_empty_base_output()); + assert!(persist_filtered.allows_empty_xtream_cluster(XtreamCluster::Video)); + let output_filtered = compatibility.with_output_filter(true); + assert!(output_filtered.allows_empty_base_output()); + assert_eq!(PlaylistPublicationPlan::Ordinary.with_output_filter(true), PlaylistPublicationPlan::Ordinary); + } + + fn curation_persist_options(publication_plan: PlaylistPublicationPlan) -> TargetPlaylistPersistOptions { + TargetPlaylistPersistOptions { publication_plan, ..TargetPlaylistPersistOptions::default() } + } + + fn target_test_app_config(storage_dir: &Path) -> Arc { + 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::::default()), + api_proxy: Arc::new(ArcSwapOption::::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::::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + }) + } + + fn mixed_output_target() -> ConfigTarget { + let mut xtream = XtreamTargetOutputDto::default(); + xtream.t_filter = + Some(shared::foundation::get_filter(r#"Group = "Curated alias""#, None).expect("Xtream output filter")); + let mut m3u = M3uTargetOutputDto { filename: Some("curated.m3u".to_string()), ..Default::default() }; + m3u.t_filter = + Some(shared::foundation::get_filter(r#"Group = "Base movie""#, None).expect("M3U output filter")); + ConfigTarget::from(&ConfigTargetDto { + name: "curated-output-test".to_string(), + output: vec![ + TargetOutputDto::Xtream(xtream), + TargetOutputDto::M3u(m3u), + TargetOutputDto::Strm(StrmTargetOutputDto { + directory: "strm".to_string(), + flat: true, + cleanup: true, + ..StrmTargetOutputDto::default() + }), + ], + use_memory_cache: true, + ..ConfigTargetDto::default() + }) + } + + fn target_video_group(title: &str, uuid: UUIDType) -> PlaylistGroup { + PlaylistGroup { + id: 1, + title: title.intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + id: "1".intern(), + name: title.intern(), + title: title.intern(), + group: title.intern(), + url: format!("http://example.invalid/{title}").intern(), + uuid, + item_type: PlaylistItemType::Video, + xtream_cluster: XtreamCluster::Video, + ..PlaylistItemHeader::default() + }, + }], + xtream_cluster: XtreamCluster::Video, + } + } + + fn strm_files_below(path: &Path) -> Vec { + let mut files = Vec::new(); + let Ok(entries) = std::fs::read_dir(path) else { return files }; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + files.extend(strm_files_below(&entry_path)); + } else if entry_path.extension().is_some_and(|extension| extension == "strm") { + files.push(entry_path); + } + } + files + } + + #[tokio::test] + async fn intentional_empty_output_views_clear_managed_artifacts_without_alias_leakage() { + let directory = tempdir().expect("tempdir"); + let app_config = target_test_app_config(directory.path()); + let target = mixed_output_target(); + let playlist_state = Arc::new(crate::PlaylistStorageState::new()); + let mut standard = + vec![target_video_group("Base movie", UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000021"))]; + let mut xtream = vec![target_video_group( + "Curated alias", + UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000022"), + )]; + + let first = persist_playlist_views( + &app_config, + &mut standard, + Some(&mut xtream), + None, + &target, + Some(&playlist_state), + curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)), + ) + .await; + assert!(first.is_ok(), "initial mixed-output persist failed: {first:?}"); + + let m3u = load_m3u_target_storage(&app_config, &target).await.expect("M3U storage"); + let xtream = load_xtream_target_storage(&app_config, &target).await.expect("Xtream storage"); + assert_eq!(m3u.len(), 1); + assert_eq!(m3u.iter().next().expect("M3U item").1.title.as_ref(), "Base movie"); + assert_eq!(xtream.vod.len(), 1); + let xtream_item = xtream.vod.iter().next().expect("Xtream item").1; + assert_eq!(xtream_item.title.as_ref(), "Curated alias"); + assert_ne!(xtream_item.category_id, 0, "base-suppressed unfiltered rows remain category-backed aliases"); + { + let cache = playlist_state.data.read().await; + let cached = cache.get(&target.name).expect("target cache"); + assert_eq!(cached.m3u.as_ref().expect("M3U cache").len(), 1); + assert_eq!(cached.xtream.as_ref().expect("Xtream cache").vod.len(), 1); + } + let m3u_text_path = directory.path().join("curated.m3u"); + assert!(std::fs::read_to_string(&m3u_text_path).expect("M3U text").contains("Base movie")); + let strm_root = directory.path().join("strm"); + let strm_files = strm_files_below(&strm_root); + assert_eq!(strm_files.len(), 1); + assert!(std::fs::read_to_string(&strm_files[0]).expect("STRM content").contains("Base movie")); + + let mut empty_standard = Vec::new(); + let mut empty_xtream = Vec::new(); + let retained = persist_playlist_views( + &app_config, + &mut empty_standard, + Some(&mut empty_xtream), + None, + &target, + Some(&playlist_state), + curation_persist_options(PlaylistPublicationPlan::Ordinary), + ) + .await; + assert!(retained.is_err(), "untrusted empty input must retain the published snapshot"); + assert_eq!(load_m3u_target_storage(&app_config, &target).await.expect("retained M3U").len(), 1); + assert_eq!(load_xtream_target_storage(&app_config, &target).await.expect("retained Xtream").vod.len(), 1); + { + let cache = playlist_state.data.read().await; + let cached = cache.get(&target.name).expect("retained target cache"); + assert_eq!(cached.m3u.as_ref().expect("retained M3U cache").len(), 1); + assert_eq!(cached.xtream.as_ref().expect("retained Xtream cache").vod.len(), 1); + } + assert_eq!(strm_files_below(&strm_root).len(), 1, "untrusted empty refresh must retain STRM files"); + + let published = persist_playlist_views( + &app_config, + &mut empty_standard, + Some(&mut empty_xtream), + None, + &target, + Some(&playlist_state), + curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)), + ) + .await; + assert!(published.is_ok(), "trusted empty snapshot failed: {published:?}"); + assert!(load_m3u_target_storage(&app_config, &target).await.expect("empty M3U").is_empty()); + assert_eq!(std::fs::read_to_string(&m3u_text_path).expect("empty M3U text"), "#EXTM3U\n"); + let empty_xtream = load_xtream_target_storage(&app_config, &target).await.expect("empty Xtream"); + assert!(empty_xtream.live.is_empty()); + assert!(empty_xtream.vod.is_empty()); + assert!(empty_xtream.series.is_empty()); + let target_storage = { + let config = app_config.config.load(); + get_target_storage_path(&config, &target.name).expect("target storage") + }; + let xtream_storage = { + let config = app_config.config.load(); + xtream_get_storage_path(&config, &target.name).expect("Xtream storage path") + }; + assert_eq!( + std::fs::read_to_string(get_vod_cat_collection_path(&xtream_storage)).expect("empty VOD categories"), + "[]" + ); + assert_eq!( + std::fs::read_to_string(get_series_cat_collection_path(&xtream_storage)).expect("empty series categories"), + "[]" + ); + let strm_index = strm_get_file_paths(&hash_string_as_hex(&normalize_string_path("strm")), &target_storage); + assert!(std::fs::read_to_string(strm_index).expect("empty STRM index").is_empty()); + { + let cache = playlist_state.data.read().await; + let cached = cache.get(&target.name).expect("empty target cache"); + assert!(cached.m3u.as_ref().expect("empty M3U cache").is_empty()); + assert!(cached.xtream.as_ref().expect("empty Xtream cache").vod.is_empty()); + } + assert!(strm_files_below(&strm_root).is_empty(), "trusted empty refresh must clean stale STRM files"); + } + + #[tokio::test] + async fn intentional_empty_strm_cleanup_false_removes_indexed_files_but_keeps_unmanaged_files() { + let directory = tempdir().expect("tempdir"); + let app_config = target_test_app_config(directory.path()); + let target = ConfigTarget::from(&ConfigTargetDto { + name: "curated-strm-retention-test".to_string(), + output: vec![TargetOutputDto::Strm(StrmTargetOutputDto { + directory: "strm-retained".to_string(), + flat: true, + cleanup: false, + ..StrmTargetOutputDto::default() + })], + ..ConfigTargetDto::default() + }); + let mut seeded = vec![target_video_group( + "Retained movie", + UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000029"), + )]; + let initial = persist_playlist_views( + &app_config, + &mut seeded, + None, + None, + &target, + None, + curation_persist_options(PlaylistPublicationPlan::Ordinary), + ) + .await; + assert!(initial.is_ok(), "initial STRM persist failed: {initial:?}"); + let strm_root = directory.path().join("strm-retained"); + let managed_files = strm_files_below(&strm_root); + assert_eq!(managed_files.len(), 1); + let managed_file = managed_files[0].clone(); + let unmanaged_file = strm_root.join("unmanaged.strm"); + std::fs::write(&unmanaged_file, "unmanaged").expect("unmanaged STRM fixture"); + + let mut empty = Vec::new(); + let published = persist_playlist_views( + &app_config, + &mut empty, + None, + None, + &target, + None, + curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)), + ) + .await; + assert!(published.is_ok(), "empty STRM persist failed: {published:?}"); + + assert!(!managed_file.exists(), "cleanup=false removes files tracked by the managed index"); + assert!(unmanaged_file.exists(), "cleanup=false does not scan and remove unmanaged files"); + let target_storage = { + let config = app_config.config.load(); + get_target_storage_path(&config, &target.name).expect("target storage") + }; + let index = strm_get_file_paths(&hash_string_as_hex(&normalize_string_path("strm-retained")), &target_storage); + assert!(std::fs::read_to_string(index).expect("empty STRM index").is_empty()); + } + + #[tokio::test] + async fn intentional_empty_curation_never_authorizes_empty_live_cluster_replacement() { + let directory = tempdir().expect("tempdir"); + let app_config = target_test_app_config(directory.path()); + let target = ConfigTarget::from(&ConfigTargetDto { + name: "curated-live-retention-test".to_string(), + output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto::default())], + ..ConfigTargetDto::default() + }); + let mut live = vec![ + PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + id: "1".intern(), + name: "Live channel".intern(), + title: "Live channel".intern(), + group: "Live".intern(), + url: "http://example.invalid/live".intern(), + uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000031"), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..PlaylistItemHeader::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }, + target_video_group( + "Previously published movie", + UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000032"), + ), + ]; + + let initial = persist_playlist_views( + &app_config, + &mut live, + None, + None, + &target, + None, + curation_persist_options(PlaylistPublicationPlan::Ordinary), + ) + .await; + assert!(initial.is_ok(), "initial Live persist failed: {initial:?}"); + + let mut ordinary_live_only = vec![live[0].clone()]; + let ordinary = persist_playlist_views( + &app_config, + &mut ordinary_live_only, + None, + None, + &target, + None, + curation_persist_options(PlaylistPublicationPlan::Ordinary), + ) + .await; + assert!(ordinary.is_ok(), "ordinary Live-only persist failed: {ordinary:?}"); + let ordinary_storage = load_xtream_target_storage(&app_config, &target).await.expect("ordinary storage"); + assert_eq!(ordinary_storage.live.len(), 1); + assert_eq!(ordinary_storage.vod.len(), 1, "ordinary refresh retains an absent cluster"); + + let mut empty_standard = Vec::new(); + let mut empty_xtream = Vec::new(); + let curated_empty = persist_playlist_views( + &app_config, + &mut empty_standard, + Some(&mut empty_xtream), + None, + &target, + None, + curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)), + ) + .await; + assert!(curated_empty.is_ok(), "curated empty persist failed: {curated_empty:?}"); + + let storage = load_xtream_target_storage(&app_config, &target).await.expect("Xtream storage"); + assert_eq!(storage.live.len(), 1); + assert!(storage.vod.is_empty()); + assert!(storage.series.is_empty()); + } + #[test] fn media_server_playlist_file_path_uses_separate_prefix() { let dir = tempdir().expect("tempdir"); diff --git a/backend/repository/src/strm_repository.rs b/backend/repository/src/strm_repository.rs index ea2d2b156..62aacbec2 100644 --- a/backend/repository/src/strm_repository.rs +++ b/backend/repository/src/strm_repository.rs @@ -807,9 +807,9 @@ pub async fn write_strm_playlist( target: &ConfigTarget, target_output: &StrmTargetOutput, new_playlist: &mut [PlaylistGroup], - library_empty: crate::LibraryEmptyPublication, + allow_empty_publication: bool, ) -> Result<(), TuliproxError> { - if new_playlist.is_empty() && !library_empty.replaces_empty_target() { + if new_playlist.is_empty() && !allow_empty_publication { return Ok(()); } diff --git a/config/source.yml b/config/source.yml index f13d52004..c5c77f0a4 100644 --- a/config/source.yml +++ b/config/source.yml @@ -45,12 +45,16 @@ sources: # Set enabled to true after configuring api.api_key with your Trakt Client ID. # See the documentation for the TRAKT_CLIENT_ID environment-variable example. enabled: false + # "full" preserves the entire catalog (default); "curated" keeps selected VOD/Series plus all Live. + catalog_selection: curated + include_xtream_base_categories: true api: api_key: "" lists: - user: "linaspurinis" list_slug: "top-watched-movies-of-the-week" category_name: "📈 Top Weekly Movies" + create_xtream_category: true content_type: "vod" fuzzy_match_threshold: 80 - user: "garycrawfordgc" @@ -60,7 +64,8 @@ sources: fuzzy_match_threshold: 75 - user: "trakt" list_slug: "trending" - category_name: "🔥 Trending" + # Selection-only selectors contribute memberships without creating an Xtream alias category. + create_xtream_category: false content_type: "both" fuzzy_match_threshold: 80 - user: "mmozeiko" diff --git a/docs/src/configuration/mapping-dsl.md b/docs/src/configuration/mapping-dsl.md index ea6bfd564..4c914a53c 100644 --- a/docs/src/configuration/mapping-dsl.md +++ b/docs/src/configuration/mapping-dsl.md @@ -105,15 +105,19 @@ Mapper blocks with `stage: after_epg` Collect all processed inputs for the target | v -Favourites and Trakt categories +Configured favourite aliases -> Merge groups -> Post-merge content deduplication | v -Merge groups -> Sort -> Assign channel numbers -> Mapping counters -> Watches -> Persistence +Evaluate required Trakt selectors -> Select catalog -> Derive base and Xtream appearances + | + v +Sort -> Assign channel numbers -> Mapping counters -> Persist/output filters -> Persistence -> Watches ``` `processing_order` controls only the order of filter (`F`), rename (`R`), and the default mapping position (`M`). -`after_epg` is a separate stage outside that configurable pipe. Mapping counters are also outside the pipe and always -run on the consolidated target playlist after merging, sorting, and initial channel-number assignment. +`after_epg` is a separate stage outside that configurable pipe. Mapping counters are also outside the pipe and run on +each finalized appearance after the target has merged and deduplicated the curation-eligible catalog, then sorted and +assigned initial channel numbers. Trakt curation never changes which mapper stage runs. The configured order and mapping-stage indexes are compiled when configuration is loaded. During a refresh, the F/R/M pipe materializes each input into one item buffer, preserves the historical grouping order between stages, and rebuilds diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index cf019e025..f50596e93 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1392,8 +1392,9 @@ filter: persist: 'EpgId IS NOT EMPTY' ``` -`processing` runs at the normal `F` position. `persist` runs after EPG matching, smart matching, all mappings, merge, -favourites/Trakt, deduplication, sorting, channel numbering, and counters, immediately before watch evaluation and target +`processing` runs at the normal `F` position. The target then applies favourites, merges groups, performs post-merge +content deduplication, evaluates any configured Trakt selectors, and derives the base and Xtream appearance views. +`persist` runs after each view is sorted, numbered, and counted, immediately before output filters, watch evaluation, and persistence. Output-level filters remain plain strings and have no configurable stage. You can define complex strings or regex patterns exactly once in [template.yml](./template.md) @@ -1797,6 +1798,8 @@ output: update_strategy: instant trakt: enabled: true + catalog_selection: curated + include_xtream_base_categories: true api: # Despite the compatible field name, this value is the Trakt Client ID. api_key: "${env:TRAKT_CLIENT_ID}" @@ -1807,6 +1810,7 @@ output: - user: "gary" list_slug: "latest-tv" category_name: "Trending TV" + create_xtream_category: true content_type: series fuzzy_match_threshold: 80 ``` @@ -1820,7 +1824,7 @@ output: | `skip_video_direct_source` | Bool | No | `true` | If `true`, Tuliprox ignores provider `direct_source` values for movies/VOD. This improves consistency across clients that otherwise may bypass Tuliprox for video playback. | | `skip_series_direct_source` | Bool | No | `true` | If `true`, Tuliprox ignores provider `direct_source` values for series entries. This ensures Tuliprox stays in control of series playback URL generation and proxy behavior. | | `update_strategy` | Enum | No | `instant` | `instant` writes changes immediately, while `bundled` batches write operations. This directly trades off freshness versus disk I/O load during background metadata enrichment and output maintenance. | -| `trakt` | Object | No | | Trakt.tv integration block. Tuliprox can fetch Trakt lists, fuzzy-match them against playlist entries, and inject matched VOD or series entries into generated virtual categories. | +| `trakt` | Object | No | | Trakt.tv target-wide catalog selection and Xtream category-projection block. | | `filter` | String | No | | Optional output-level filter for the Xtream export only. Useful when the same target should expose different subsets to different output formats. | > **Note:** IPTV players vary in how they resolve streams: some use the direct-source attribute, while others @@ -1835,9 +1839,9 @@ output: #### `trakt` Object in Xtream Output -Trakt.tv is an online platform for tracking, organizing, and discovering movies and TV shows. -Tuliprox can query Trakt lists and match playlist entries using Jaro-Winkler-style fuzzy matching. -Matching entries are then added to new virtual categories inside the Xtream output. +Trakt.tv is an online platform for tracking, organizing, and discovering movies and TV shows. Tuliprox evaluates every +configured list and chart against the target's merged, deduplicated VOD and series catalog. The resulting exact-entry +memberships can select the target-wide catalog and independently create compatibility alias categories in Xtream. You can define a `Trakt` config like @@ -1862,6 +1866,8 @@ sources: skip_series_direct_source: true trakt: enabled: true + catalog_selection: curated + include_xtream_base_categories: true api: # Despite the compatible field name, this value is the Trakt Client ID. api_key: "${env:TRAKT_CLIENT_ID}" @@ -1872,17 +1878,19 @@ sources: - user: "linaspurinis" list_slug: "top-watched-movies-of-the-week" category_name: "📈 Top Weekly Movies" + create_xtream_category: true content_type: vod fuzzy_match_threshold: 80 - user: "garycrawfordgc" list_slug: "latest-tv-shows" - category_name: "📺 Latest TV Shows" + create_xtream_category: false content_type: series fuzzy_match_threshold: 80 charts: - kind: movies chart: trending category_name: "🔥 Trending Movies" + create_xtream_category: true tmdb_only: true - kind: shows chart: popular @@ -1890,9 +1898,10 @@ sources: tmdb_only: true ``` -This configuration creates additional virtual categories populated with matched entries from the configured Trakt user -lists and public Trakt charts. Define `TRAKT_CLIENT_ID` in the environment of the Tuliprox process before enabling the -block. +This example selects one catalog for the target: all Live entries plus VOD/series roots selected by at least one Trakt +selector, including the selected series' episode closure. The first list and both charts also create named Xtream alias +categories. The second list is selection-only and therefore needs no `category_name`. M3U and STRM outputs on the same +target receive selected normal entries, never those Xtream aliases. The serialized field remains `api.api_key` for configuration compatibility, but its value is the Client ID of your Trakt API application and is sent in the `trakt-api-key` header. Tuliprox does not bundle a Client ID and never falls @@ -1900,31 +1909,46 @@ back to another identity. Creating Trakt API applications currently requires act response only means that Trakt denied the request; check both the configured Client ID and access to the requested resource rather than assuming that every `403` proves a particular account state. -If lists or charts are configured while the Client ID is blank or cannot be used as an HTTP header, Tuliprox makes no -Trakt request, logs one target-scoped warning, and skips only optional Trakt curation. The rest of target processing -continues. A disabled block, or a block with no lists or charts, remains a silent no-op. +Every enabled list and chart is required for one refresh. A missing or invalid Client ID makes no request; credential, +transport, status, JSON, interrupted-pagination, or truncated-pagination failures stop that target before IDs, files, +caches, or watches are changed. A complete response with zero references, or with no local matches, remains successful. +With `catalog_selection: curated`, that complete result clears VOD/Series while preserving Live. A disabled block, or a +source-less block whose policies all use their defaults, remains a no-op. Input quality/force decisions are resolved +before this boundary: curation cannot make an unauthorized empty input publishable, and even force-authorized emptiness +waits for every required selector to complete. After a complete curation result enters publication, independent writer +failures retain the existing best-effort cross-output behavior and are reported as writer failures rather than as Trakt +source failures. ##### Trakt Parameters -| Parameter | Type | Required | Default | Technical Impact & Background | -| :------------------------------- | :------ | :------: | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `enabled` | Bool | No | `true` | Enables Trakt curation. Keep it `false` until an explicit Client ID is configured. | -| `api.api_key` | String | Yes | | Compatible field that stores the Trakt Client ID. There is no bundled fallback; use an explicit value such as `${env:TRAKT_CLIENT_ID}`. | -| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. | -| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. | -| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. | -| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. | -| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. | -| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. | -| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. | -| `lists[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted for this list, disabling title/year fuzzy fallback and reducing false positives. | -| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. | -| `charts[]` | List | No | `[]` | Public Trakt chart definitions. Unlike `lists[]`, these are system charts and do not have a user/list owner. | -| `charts[].kind` | Enum | Yes | | `movies` or `shows`. Aliases such as `movie`, `vod`, `show`, `series`, and `tvshows` are accepted. | -| `charts[].chart` | Enum | Yes | | Public chart to fetch. MVP supports `trending` and `popular`. | -| `charts[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. | -| `charts[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted. This is recommended for dynamic charts to avoid fuzzy false positives. | -| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for chart title matching when `tmdb_only` is not enabled. | +| Parameter | Type | Required | Default | Technical Impact & Background | +| :-------------------------------- | :------ | :---------: | :--------------------- | :-------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | Bool | No | `true` | Enables Trakt curation. Disabled blocks may retain incomplete editor values without running selectors. | +| `catalog_selection` | Enum | No | `full` | `full` keeps the complete eligible target catalog; `curated` keeps all Live plus selected VOD/Series and series children. | +| `include_xtream_base_categories` | Bool | No | `true` | Includes normal VOD/Series categories in Xtream independently of catalog selection and alias-category creation. | +| `api.api_key` | String | Yes | | Compatible field containing the explicit Trakt Client ID, for example `${env:TRAKT_CLIENT_ID}`. | +| `api.version` | String | No | `"2"` | API version header value. | +| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. | +| `api.user_agent` | String | No | | Optional `User-Agent` for Trakt requests. | +| `lists[].user` | String | Yes | | Trakt username owning the list. | +| `lists[].list_slug` | String | Yes | | Trakt list slug within the user's namespace. | +| `lists[].create_xtream_category` | Bool | No | `true` | Creates a category-scoped Xtream alias projection. `false` keeps the list as a required selection-only selector. | +| `lists[].category_name` | String | Conditional | | Required and non-blank only when `create_xtream_category` is `true`; a saved value is preserved while creation is disabled. | +| `lists[].content_type` | Enum | Yes | | `vod`, `series`, or `both`; direct matching uses VOD/series roots, never episode titles. | +| `lists[].tmdb_only` | Bool | No | `false` | Accepts only exact TMDB-id matches, disabling title/year fuzzy fallback. | +| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy title-match threshold when `tmdb_only` is not enabled. | +| `charts[]` | List | No | `[]` | Public, non-OAuth Trakt chart definitions. | +| `charts[].kind` | Enum | Yes | | `movies` or `shows`; the documented singular/VOD/series aliases remain accepted. | +| `charts[].chart` | Enum | Yes | | Public chart; currently `trending` or `popular`. | +| `charts[].create_xtream_category` | Bool | No | `true` | Creates the chart's named Xtream alias; `false` leaves it as a required selection-only selector. | +| `charts[].category_name` | String | Conditional | | Required and non-blank only when `create_xtream_category` is `true`. | +| `charts[].tmdb_only` | Bool | No | `false` | Accepts only exact TMDB-id matches. | +| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy title-match threshold when `tmdb_only` is not enabled. | + +Existing configurations omit all three new policy controls and therefore retain Case A behavior: `full` catalog, +Xtream base categories, and one category for every configured selector. Filters at `filter.persist` and inside output +blocks run on their concrete post-projection appearance; they can hide an appearance but cannot reintroduce a subject +that was absent from the eligible catalog. The `charts[]` MVP intentionally supports only public, non-OAuth Trakt charts. User-specific recommendations and account-scoped history feeds are not fetched by this block. diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index a87528388..ad5bcc812 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -1612,6 +1612,12 @@ "TOKEN": "رمز", "TOKEN_TTL_MINS": "TTL الرمز (دقائق)", "TRAKT": "Trakt", + "TRAKT_CATALOG_SELECTION": "كتالوج VOD والمسلسلات", + "TRAKT_CATALOG_SELECTION_FULL": "الكتالوج الكامل", + "TRAKT_CATALOG_SELECTION_CURATED": "الكتالوج المنسق", + "TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "تضمين فئات Xtream الأساسية", + "TRAKT_CREATE_XTREAM_CATEGORY": "إنشاء فئة Xtream", + "TRAKT_SELECTION_ONLY": "اختيار فقط", "TRAKT_CATEGORY_NAME": "اسم الفئة", "TRAKT_CHARTS": "الرسوم البيانية", "TRAKT_CHART_KIND": "نوع الرسم البياني", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 403626c8a..99450b595 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -1696,6 +1696,12 @@ "TOKEN": "Token", "TOKEN_TTL_MINS": "Token ttl (mins)", "TRAKT": "Trakt", + "TRAKT_CATALOG_SELECTION": "VOD and series catalog", + "TRAKT_CATALOG_SELECTION_FULL": "Full catalog", + "TRAKT_CATALOG_SELECTION_CURATED": "Curated catalog", + "TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "Include Xtream base categories", + "TRAKT_CREATE_XTREAM_CATEGORY": "Create Xtream category", + "TRAKT_SELECTION_ONLY": "Selection only", "TRAKT_CATEGORY_NAME": "Category name", "TRAKT_CHARTS": "Charts", "TRAKT_CHART_KIND": "Chart kind", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 16f00e41f..3d3ddf517 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -1520,6 +1520,12 @@ "TOKEN": "Токен", "TOKEN_TTL_MINS": "TTL токена (мин)", "TRAKT": "Trakt", + "TRAKT_CATALOG_SELECTION": "Каталог VOD и сериалов", + "TRAKT_CATALOG_SELECTION_FULL": "Полный каталог", + "TRAKT_CATALOG_SELECTION_CURATED": "Курируемый каталог", + "TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "Включать базовые категории Xtream", + "TRAKT_CREATE_XTREAM_CATEGORY": "Создавать категорию Xtream", + "TRAKT_SELECTION_ONLY": "Только отбор", "TRAKT_CATEGORY_NAME": "Имя категории", "TRAKT_FUZZY_MATCH_THRESHOLD": "Порог нечеткого сопоставления", "TRAKT_LISTS": "Списки", diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index 21a45bd0d..93ac512b7 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -1,7 +1,8 @@ use crate::{ app::components::{ - config::HasFormData, BlockId, BlockInstance, Card, EditMode, FilterInput, IconButton, Panel, - SourceEditorContext, TextButton, TitledCard, TraktChartItemForm, TraktListItemForm, + build_options, config::HasFormData, select::Select, selection_parse_first, BlockId, BlockInstance, Card, + DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton, TitledCard, + TraktChartItemForm, TraktListItemForm, }, config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_text, generate_form_reducer, @@ -11,15 +12,16 @@ use shared::{ concat_string, error::TuliproxError, model::{ - TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktConfigDto, TraktListConfigDto, - XtreamTargetOutputDto, + TargetOutputDto, TraktApiConfigDto, TraktCatalogSelection, TraktChartConfigDto, TraktConfigDto, + TraktListConfigDto, XtreamTargetOutputDto, }, utils::Internable, }; use std::{fmt::Display, rc::Rc, str::FromStr, sync::Arc}; use web_sys::MouseEvent; use yew::{ - component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle, + component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, + UseReducerHandle, }; const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE"; @@ -39,6 +41,11 @@ const LABEL_USER_AGENT: &str = "LABEL.API_USER_AGENT"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; const LABEL_TRAKT: &str = "LABEL.TRAKT"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; +const LABEL_TRAKT_CATALOG_SELECTION: &str = "LABEL.TRAKT_CATALOG_SELECTION"; +const LABEL_TRAKT_CATALOG_SELECTION_FULL: &str = "LABEL.TRAKT_CATALOG_SELECTION_FULL"; +const LABEL_TRAKT_CATALOG_SELECTION_CURATED: &str = "LABEL.TRAKT_CATALOG_SELECTION_CURATED"; +const LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES: &str = "LABEL.TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES"; +const LABEL_TRAKT_SELECTION_ONLY: &str = "LABEL.TRAKT_SELECTION_ONLY"; #[derive(Copy, Clone, PartialEq, Eq)] enum XtreamOutputFormPage { @@ -91,6 +98,8 @@ generate_form_reducer!( action_name: TraktConfigFormAction, fields { Enabled => enabled: bool, + CatalogSelection => catalog_selection: TraktCatalogSelection, + IncludeXtreamBaseCategories => include_xtream_base_categories: bool, } ); @@ -126,14 +135,24 @@ pub struct XtreamTargetOutputViewProps { fn build_trakt_output_config( enabled: bool, + catalog_selection: TraktCatalogSelection, + include_xtream_base_categories: bool, api: TraktApiConfigDto, lists: Vec, charts: Vec, ) -> Option { - if lists.is_empty() && charts.is_empty() { + let candidate = TraktConfigDto { enabled, catalog_selection, include_xtream_base_categories, api, lists, charts }; + if candidate.is_source_less_noop() { None } else { - Some(TraktConfigDto { enabled, api, lists, charts }) + Some(candidate) + } +} + +fn trakt_catalog_selection_label_key(selection: TraktCatalogSelection) -> &'static str { + match selection { + TraktCatalogSelection::Full => LABEL_TRAKT_CATALOG_SELECTION_FULL, + TraktCatalogSelection::Curated => LABEL_TRAKT_CATALOG_SELECTION_CURATED, } } @@ -144,19 +163,29 @@ fn append_trakt_matching_summary_suffix(mut summary: String, tmdb_only: bool) -> summary } -fn trakt_list_summary(item: &TraktListConfigDto) -> String { +fn trakt_list_summary(item: &TraktListConfigDto, selection_only: &str) -> String { append_trakt_matching_summary_suffix( format!( "{} / {} - {} ({}, {}%)", - item.user, item.list_slug, item.category_name, item.content_type, item.fuzzy_match_threshold + item.user, + item.list_slug, + if item.create_xtream_category { item.category_name.as_deref().unwrap_or("-") } else { selection_only }, + item.content_type, + item.fuzzy_match_threshold ), item.tmdb_only, ) } -fn trakt_chart_summary(item: &TraktChartConfigDto) -> String { +fn trakt_chart_summary(item: &TraktChartConfigDto, selection_only: &str) -> String { append_trakt_matching_summary_suffix( - format!("{}/{} - {} ({}%)", item.kind, item.chart, item.category_name, item.fuzzy_match_threshold), + format!( + "{}/{} - {} ({}%)", + item.kind, + item.chart, + if item.create_xtream_category { item.category_name.as_deref().unwrap_or("-") } else { selection_only }, + item.fuzzy_match_threshold + ), item.tmdb_only, ) } @@ -193,6 +222,16 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let trakt_lists_state = use_state(Vec::::new); let trakt_charts_state = use_state(Vec::::new); + let catalog_selection_options = use_memo(trakt_state.form.catalog_selection, { + let translate = translate.clone(); + move |selection| { + build_options( + [TraktCatalogSelection::Full, TraktCatalogSelection::Curated], + selection, + |value| html! { translate.t(trakt_catalog_selection_label_key(*value)) }, + ) + } + }); let show_trakt_list_form_state = use_state(|| false); let show_trakt_chart_form_state = use_state(|| false); let editing_trakt_list_index_state = use_state(|| None::); @@ -431,6 +470,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { on_cancel={handle_close_trakt_list_form} initial={initial_trakt_list} readonly={!props.allow_write} + validate_category_name={trakt_form.form.enabled} /> } else if *show_trakt_chart_form { Html { on_cancel={handle_close_trakt_chart_form} initial={initial_trakt_chart} readonly={!props.allow_write} + validate_category_name={trakt_form.form.enabled} /> } else { { if props.allow_write { - html! { { edit_field_bool!(trakt_form, translate.t(LABEL_ENABLED), enabled, TraktConfigFormAction::Enabled) } } + html! { + <> + { edit_field_bool!(trakt_form, translate.t(LABEL_ENABLED), enabled, TraktConfigFormAction::Enabled) } + { config_field_child!(translate.t(LABEL_TRAKT_CATALOG_SELECTION), "OUTPUT_XTREAM_FORM.TRAKT_CATALOG_SELECTION", { + let selection_form = trakt_form.clone(); + html! { +