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

* test: characterize target curation transition

* refactor: model complete curation outcomes

* feat: configure target-wide curation projection

* feat: apply target-wide curation selections

* docs: explain target-wide Trakt curation

* fix: preserve source-less Trakt policy edits

* target wide curaton selection

* target wide curaton selection

---------

Co-authored-by: Juanjo Presa <juanjop@gmail.com>
Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
Juanjo Presa
2026-09-22 16:52:11 +02:00
committed by GitHub
co-authored by Juanjo Presa euzu
parent 2297b3700a
commit 8fa8a3601b
25 changed files with 3287 additions and 505 deletions
+19 -4
View File
@@ -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 <PATH>` (or `-e`), `TULIPROX_ENV_FILE`, then `<config_file_dir>/.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
+22
View File
@@ -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();
+22 -6
View File
@@ -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<String>,
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<String>,
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<TraktListConfig>,
pub charts: Vec<TraktChartConfig>,
@@ -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,
}],
+318 -57
View File
@@ -17,12 +17,87 @@ use strsim::normalized_levenshtein;
static TRAILING_TITLE_YEAR: LazyLock<Regex> =
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<u32>,
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<CurationMembership> },
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<CurationSelectorSummary>,
pub memberships: Vec<CurationMembership>,
}
/// Diagnostic selector outcomes for a run that cannot be published.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurationFailure {
pub selector_outcomes: Vec<SelectorOutcome>,
}
/// 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<Self> {
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<u32>, reference_year: Option<u32>)
fn find_best_fuzzy_match_for_item<'playlist, 'reference>(
candidate: &PlaylistCandidate<'playlist>,
references: &'reference [CuratedMediaReference],
specification: &CurationCategorySpec<'_>,
specification: CurationSelectorSpec,
threshold: f64,
) -> Option<MatchResult<'playlist, 'reference>> {
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<MatchResult<'playlist, 'reference>> {
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<PlaylistGroup> {
fn find_matches<'playlist, 'reference>(
references: &'reference [CuratedMediaReference],
playlist: &'playlist [PlaylistGroup],
specification: CurationSelectorSpec,
) -> Vec<MatchResult<'playlist, 'reference>> {
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<MatchResult<'_, '_>>,
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<UUIDType, &'playlist PlaylistItem>,
series_children_by_parent_code: HashMap<Arc<str>, 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<PlaylistGroup> {
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<Item = &'membership CurationMembership>,
projection_catalog: &CurationProjectionCatalog<'_>,
specification: &CurationCategorySpec<'_>,
) -> Vec<PlaylistGroup> {
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<Arc<str>, Vec<&PlaylistItem>>,
) -> Vec<PlaylistGroup> {
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<XtreamCluster, Vec<PlaylistItem>> = 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::<Vec<_>>(), [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<'_> {
+5 -1
View File
@@ -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};
+176 -27
View File
@@ -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<Vec<TraktListItem>, TuliproxError> {
) -> Result<Vec<TraktListItem>, 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<Vec<TraktListItem>, TuliproxError> {
) -> Result<Vec<TraktListItem>, 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<Vec<TraktListItem>, TuliproxError>
) -> Result<Vec<TraktListItem>, TraktFetchFailure>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<TraktListItemsPage, TuliproxError>>,
@@ -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<TraktListItem> = 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<u32>), TuliproxError> {
) -> Result<(String, Option<u32>, Option<u32>), 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<TraktListItem>,
page_count: u32,
page_count: Option<u32>,
item_count: Option<u32>,
}
@@ -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::<TraktListItem>(&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::<TraktListItem>(&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::<TraktListItem>(&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::<TraktListItem>(&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,
}
+346 -125
View File
@@ -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<Vec<PlaylistGroup>> {
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<PlaylistGroup> {
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<PlaylistGroup>,
) {
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<SelectorOutcome>) -> 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<PlaylistGroup> {
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<PlaylistGroup>,
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<TraktListItem>) -> Vec<CuratedMediaReference> {
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::<Vec<_>>(),
[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<PlaylistGroup> {
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<AtomicUsize>) -> (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,
}
@@ -140,6 +140,25 @@ mod tests {
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
}
#[test]
fn post_merge_dedup_selects_the_eligible_winner_before_curation_projection() {
let mut playlist =
vec![make_group("Base", vec![make_item("Movie HD")]), make_group("Trending", vec![make_item("Movie 4K")])];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
// Target-wide curation evaluates only this surviving winner.
assert_eq!(removed, 1);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Trending");
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Movie 4K");
}
#[test]
fn dedup_keep_first_preserves_playlist_order_winner() {
let mut playlist = vec![make_group("G", vec![make_item("News HD"), make_item("News [FHD]")])];
@@ -15,7 +15,7 @@ use crate::{
};
use futures::{FutureExt, StreamExt};
use indexmap::IndexMap;
use log::{debug, error, info, log_enabled, trace, warn, Level};
use log::{debug, error, info, log_enabled, warn, Level};
use path_clean::PathClean;
use shared::{
concat_string,
@@ -26,11 +26,12 @@ use shared::{
ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet,
InputRefreshOverride, InputRefreshPolicy, InputStats, InputType, InputUpdateAction, InputUpdateRequest,
MappingStage, PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateQualityDecision,
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistGroup,
PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision, PlaylistUpdateClusterTelemetry,
PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId,
PlaylistUpdateRunOrder, PlaylistUpdateState, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats,
StreamProperties, TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistEntry,
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision,
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, TargetStats, UUIDType,
WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
},
utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable},
};
@@ -50,11 +51,14 @@ use tuliprox_core::{
is_valid, retain_filtered_playlist, AppConfig, ClusterForceUpdate, ClusterUpdateRejection, CompiledMapping,
ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg,
FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig,
TransformStage, UpdateGuard, UpdateTask,
TraktConfig, TransformStage, UpdateGuard, UpdateTask,
},
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback},
};
use tuliprox_curation::curate_trakt_categories;
use tuliprox_curation::{
evaluate_trakt_curation, project_trakt_categories, CurationEvaluation, CurationFailure, CurationRunOutcome,
SelectorOutcome,
};
use tuliprox_iptv::{
epg::{CountingEpgSink, EpgFetchRequest, EpgProvider},
error::ProviderErrorKind,
@@ -65,8 +69,8 @@ use tuliprox_iptv::{
xtream,
};
use tuliprox_repository::{
load_input_playlist, persist_input_playlist_with_options, persist_playlist, CategoryKey,
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistSource, PlaylistStorageState,
load_input_playlist, persist_input_playlist_with_options, persist_playlist_views, CategoryKey,
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState,
TargetPlaylistPersistOptions,
};
use tuliprox_session::ActiveProviderManager;
@@ -399,6 +399,70 @@ pub(crate) fn spill_epg_to_disk(sources: Vec<Epg>) -> Result<Option<Epg>, Tulipr
}
}
fn finalization_step_label(view: &str, stage: &str) -> String {
if view.is_empty() {
stage.to_string()
} else {
format!("{view} {stage}")
}
}
pub(crate) fn prepare_eligible_catalog(
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
step: &mut StepMeasure,
) -> Vec<PlaylistGroup> {
let mut playlist = flatten_groups(playlist);
step.tick("playlist merge");
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist);
if removed > 0 {
info!("Deduplicated {removed} eligible channels for target {}", target.name);
}
step.tick("playlist dedup");
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
}
playlist
}
fn finalize_playlist_view(
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
step: &mut StepMeasure,
view: &str,
) -> Vec<PlaylistGroup> {
let mut playlist = flatten_groups(playlist);
step.tick(&finalization_step_label(view, "appearance merge"));
for stage in FINALIZATION_ORDER.into_iter().skip(2) {
match stage {
FinalizationStage::Merge | FinalizationStage::Deduplicate => {
unreachable!("eligibility merge and deduplication are completed before curation")
}
FinalizationStage::Sort => {
if sort_playlist(target, &mut playlist) {
step.tick(&finalization_step_label(view, "playlist sort"));
log_memory_snapshot(format!("target '{}' {view} after_playlist_sort", target.name).as_str());
}
}
FinalizationStage::AssignChannelNumbers => {
assign_channel_no_playlist(&mut playlist);
step.tick(&finalization_step_label(view, "assigning channel numbers"));
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_numbers", target.name).as_str());
}
FinalizationStage::AssignCounters => {
map_playlist_counter(target, &mut playlist);
step.tick(&finalization_step_label(view, "assigning channel counter"));
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_counter", target.name).as_str());
}
}
}
apply_persist_filter(target, &mut playlist);
playlist
}
pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: Arc<PlaylistProcessingContext<E, M>>,
prepared: PreparedTarget,
@@ -420,95 +484,83 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
{
step.stop("");
info!("Playlist is empty: {}", target.name);
(Ok(()), errors)
} else {
// Process Trakt categories
if trakt_playlist(&ctx.client, target, &mut new_playlist).await {
step.tick("trakt categories");
log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str());
}
let mut flat_new_playlist = flatten_groups(new_playlist);
step.tick("playlist merge");
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
for stage in FINALIZATION_ORDER.into_iter().skip(1) {
match stage {
FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"),
FinalizationStage::Deduplicate => {
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
let removed =
crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist);
if removed > 0 {
info!("Deduplicated {removed} channels for target {}", target.name);
}
step.tick("playlist dedup");
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
}
}
FinalizationStage::Sort => {
if sort_playlist(target, &mut flat_new_playlist) {
step.tick("playlist sort");
log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str());
}
}
FinalizationStage::AssignChannelNumbers => {
assign_channel_no_playlist(&mut flat_new_playlist);
step.tick("assigning channel numbers");
log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str());
}
FinalizationStage::AssignCounters => {
map_playlist_counter(target, &mut flat_new_playlist);
step.tick("assigning channel counter");
log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str());
}
}
}
apply_persist_filter(target, &mut flat_new_playlist);
retain_epg_referenced_by_groups(&flat_new_playlist, &mut new_epg);
let merged_epg = if ctx.config.config.load().disk_based_processing {
// Per-source drain to disk, then multi-way merge. Errors are pushed
// to `errors` rather than `?` because the function returns
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
// surface tempdir / write / merge failures — the user opted in to
// disk spilling, and silently falling back to the in-memory path
// can OOM on large feeds. When the spill itself fails we skip the
// persist step entirely: continuing with `merged_epg = None` would
// overwrite the existing on-disk EPG with nothing and discard the
// previously persisted artifact on a transient error.
match spill_epg_to_disk(new_epg) {
Ok(epg) => epg,
Err(err) => {
let result_error = TuliproxError::new(err.kind(), err.message());
errors.push(err);
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
return (Err(vec![result_error]), errors);
}
}
} else {
flatten_tvguide(new_epg)
};
let library_empty = library_empty.for_filtered_playlist(&flat_new_playlist);
let result = persist_playlist(
&ctx.config,
&mut flat_new_playlist,
merged_epg.as_ref(),
target,
ctx.playlist_state.as_ref(),
TargetPlaylistPersistOptions { accepted_empty_clusters: prepared.accepted_empty_clusters, library_empty },
)
.await;
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await {
step.tick("group watches");
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
}
step.stop("Persisting playlists");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
(result, errors)
return (Ok(()), errors);
}
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
let views = match prepare_target_playlist_views(&ctx.client, target, eligible_catalog).await {
Ok(views) => views,
Err(error) => {
step.stop("Curation failed; skipping persist to preserve finalized artifacts");
return (Err(vec![error]), errors);
}
};
if views.base.is_empty()
&& views.xtream.is_none()
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
&& prepared.accepted_empty_clusters.is_empty()
&& !library_empty.replaces_empty_target()
{
step.stop("");
info!("Playlist is empty: {}", target.name);
return (Ok(()), errors);
}
if views.publication_plan != PlaylistPublicationPlan::Ordinary {
step.tick("target curation");
log_memory_snapshot(format!("target '{}' after_curation", target.name).as_str());
}
let mut finalized_base = finalize_playlist_view(target, views.base, &mut step, "base");
let mut finalized_xtream =
views.xtream.map(|playlist| finalize_playlist_view(target, playlist, &mut step, "Xtream"));
retain_epg_referenced_by_groups(&finalized_base, &mut new_epg);
let merged_epg = if ctx.config.config.load().disk_based_processing {
// Per-source drain to disk, then multi-way merge. Errors are pushed
// to `errors` rather than `?` because the function returns
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
// surface tempdir / write / merge failures — the user opted in to
// disk spilling, and silently falling back to the in-memory path
// can OOM on large feeds. When the spill itself fails we skip the
// persist step entirely: continuing with `merged_epg = None` would
// overwrite the existing on-disk EPG with nothing and discard the
// previously persisted artifact on a transient error.
match spill_epg_to_disk(new_epg) {
Ok(epg) => epg,
Err(err) => {
let result_error = TuliproxError::new(err.kind(), err.message());
errors.push(err);
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
return (Err(vec![result_error]), errors);
}
}
} else {
flatten_tvguide(new_epg)
};
let library_empty = library_empty.for_filtered_playlist(&finalized_base);
let result = persist_playlist_views(
&ctx.config,
&mut finalized_base,
finalized_xtream.as_deref_mut(),
merged_epg.as_ref(),
target,
ctx.playlist_state.as_ref(),
TargetPlaylistPersistOptions {
accepted_empty_clusters: prepared.accepted_empty_clusters,
library_empty,
publication_plan: views.publication_plan,
},
)
.await;
let watch_playlist = target_watch_view(&finalized_base, finalized_xtream.as_deref());
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, watch_playlist).await {
step.tick("group watches");
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
}
step.stop("Persisting playlists");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
(result, errors)
}
pub(crate) async fn playlist_resolve<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
@@ -802,23 +854,165 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
}
}
pub(crate) async fn trakt_playlist(
#[derive(Debug)]
pub(crate) struct TargetPlaylistViews {
pub(crate) base: Vec<PlaylistGroup>,
pub(crate) xtream: Option<Vec<PlaylistGroup>>,
pub(crate) publication_plan: PlaylistPublicationPlan,
}
pub(crate) fn build_curated_playlist_views(
playlist: Vec<PlaylistGroup>,
evaluation: &CurationEvaluation,
trakt_config: &TraktConfig,
appearance_filter_configured: bool,
) -> TargetPlaylistViews {
let curated_catalog = trakt_config.catalog_selection.is_curated();
let mut categories = project_trakt_categories(evaluation, &playlist, trakt_config);
let base = select_target_catalog(playlist, evaluation, curated_catalog);
let mut xtream = if trakt_config.include_xtream_base_categories { base.clone() } else { live_only(&base) };
xtream.append(&mut categories);
TargetPlaylistViews {
base,
xtream: Some(xtream),
publication_plan: PlaylistPublicationPlan::complete_curation_with_filter(
curated_catalog,
!trakt_config.include_xtream_base_categories,
appearance_filter_configured,
),
}
}
pub(crate) async fn prepare_target_playlist_views(
client: &reqwest::Client,
target: &ConfigTarget,
playlist: &mut Vec<PlaylistGroup>,
) -> bool {
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
trace!("No Trakt configuration found for target {}", target.name);
return false;
playlist: Vec<PlaylistGroup>,
) -> Result<TargetPlaylistViews, TuliproxError> {
let Some(trakt_config) = target.get_xtream_output().and_then(|xtream| xtream.trakt.as_ref()) else {
return Ok(TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
});
};
let Some(trakt_categories) = curate_trakt_categories(client, playlist, &target.name, trakt_config).await else {
return false;
};
if !trakt_categories.is_empty() {
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
playlist.extend(trakt_categories);
match evaluate_trakt_curation(client, &playlist, &target.name, trakt_config).await {
CurationRunOutcome::NotConfigured => Ok(TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
}),
CurationRunOutcome::Failed(failure) => Err(curation_failure_error(&target.name, &failure)),
CurationRunOutcome::Complete(evaluation) => {
let views =
build_curated_playlist_views(playlist, &evaluation, trakt_config, target.filter.persist.is_some());
info!(
"Target '{}' curation completed with {} memberships and {} Xtream groups",
target.name,
evaluation.memberships.len(),
views.xtream.as_deref().map_or(0, <[PlaylistGroup]>::len)
);
Ok(views)
}
}
true
}
fn curation_failure_error(target_name: &str, failure: &CurationFailure) -> TuliproxError {
let mut complete = 0usize;
let mut incomplete = 0usize;
let mut unavailable = 0usize;
for outcome in &failure.selector_outcomes {
match outcome {
SelectorOutcome::Complete { .. } => complete += 1,
SelectorOutcome::Incomplete { .. } => incomplete += 1,
SelectorOutcome::Unavailable { .. } => unavailable += 1,
}
}
TuliproxError::RepositoryPlaylist(format!(
"Target '{target_name}' curation refresh failed (complete selectors: {complete}, incomplete: {incomplete}, unavailable: {unavailable}); existing finalized artifacts were retained"
))
}
fn item_subject_uuid(item: &PlaylistItem) -> UUIDType {
if item.header.uuid == UUIDType::default() {
item.get_uuid()
} else {
item.header.uuid
}
}
fn selected_series_parent_keys(playlist: &[PlaylistGroup], selected_subjects: &HashSet<UUIDType>) -> HashSet<Arc<str>> {
let mut parent_keys = HashSet::new();
for item in playlist.iter().flat_map(|group| &group.channels) {
if !matches!(item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|| !selected_subjects.contains(&item_subject_uuid(item))
{
continue;
}
parent_keys.insert(item.get_uuid().intern());
if item.header.uuid != UUIDType::default() {
parent_keys.insert(item.header.uuid.intern());
}
if item.header.virtual_id.get() != 0 {
parent_keys.insert(item.header.virtual_id.get().to_string().intern());
}
if item.header.item_type == PlaylistItemType::LocalSeriesInfo && !item.header.id.is_empty() {
parent_keys.insert(Arc::clone(&item.header.id));
}
}
parent_keys
}
pub(crate) fn select_target_catalog(
mut playlist: Vec<PlaylistGroup>,
evaluation: &CurationEvaluation,
enabled: bool,
) -> Vec<PlaylistGroup> {
if !enabled {
return playlist;
}
let selected_subjects =
evaluation.memberships.iter().map(|membership| membership.subject_uuid).collect::<HashSet<_>>();
let selected_parent_keys = selected_series_parent_keys(&playlist, &selected_subjects);
for group in &mut playlist {
group.channels.retain(|item| {
if item.header.xtream_cluster == XtreamCluster::Live {
return true;
}
match item.header.item_type {
PlaylistItemType::Video
| PlaylistItemType::LocalVideo
| PlaylistItemType::SeriesInfo
| PlaylistItemType::LocalSeriesInfo => selected_subjects.contains(&item_subject_uuid(item)),
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
selected_parent_keys.contains(&item.header.parent_code)
}
_ => false,
}
});
}
playlist.retain(|group| group.xtream_cluster == XtreamCluster::Live || !group.channels.is_empty());
playlist
}
fn live_only(playlist: &[PlaylistGroup]) -> Vec<PlaylistGroup> {
playlist
.iter()
.filter_map(|group| {
let mut live_group = group.clone();
live_group.channels.retain(|item| item.header.xtream_cluster == XtreamCluster::Live);
(group.xtream_cluster == XtreamCluster::Live || !live_group.channels.is_empty()).then_some(live_group)
})
.collect()
}
pub(crate) fn target_watch_view<'playlist>(
base: &'playlist [PlaylistGroup],
xtream: Option<&'playlist [PlaylistGroup]>,
) -> &'playlist [PlaylistGroup] {
xtream.unwrap_or(base)
}
pub(crate) async fn process_watch<E: EventSink>(
@@ -4,12 +4,17 @@ use shared::{
foundation::{get_filter, MapperScript, ValueProvider},
model::{
ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor,
ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
PlaylistUpdateRunOrder, XtreamCluster, XtreamPlaylistItem,
ItemField, M3uPlaylistItem, M3uTargetOutputDto, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, PlaylistUpdateRunOrder, TargetOutputDto, TraktApiConfigDto, TraktCatalogSelection,
TraktConfigDto, TraktContentType, TraktListConfigDto, UUIDType, XtreamCluster, XtreamPlaylistItem,
XtreamTargetOutputDto,
},
utils::Internable,
};
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias};
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias, TraktConfig};
use tuliprox_curation::{
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
};
#[derive(Clone, Default)]
struct PlaylistRunCollectSink(Arc<std::sync::Mutex<Vec<EventMessage>>>);
@@ -1774,15 +1779,822 @@ match {
}
}
#[test]
fn current_target_finalization_order_merges_before_dedup_and_presentation() {
assert_eq!(
FINALIZATION_ORDER,
[
FinalizationStage::Merge,
FinalizationStage::Deduplicate,
FinalizationStage::Sort,
FinalizationStage::AssignChannelNumbers,
FinalizationStage::AssignCounters,
]
);
}
#[test]
fn curation_eligible_catalog_is_merged_and_deduplicated_before_matching() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.execution_plan.post_merge_content_dedup = Some(shared::model::DeduplicateConfig::default());
let losing_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000051");
let winning_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000052");
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Movie HD",
losing_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "movies".intern(),
channels: vec![catalog_test_item(
"Movie 4K",
winning_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
},
];
let mut step = StepMeasure::new("test", |_, _| {});
let eligible = prepare_eligible_catalog(&target, playlist, &mut step);
assert_eq!(eligible.len(), 1);
assert_eq!(eligible[0].channels.len(), 1);
assert_eq!(eligible[0].channels[0].header.uuid, winning_uuid);
}
#[test]
fn persist_filter_can_select_a_generated_curation_group() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter.persist = Some(get_filter(r#"Group = "Trending""#, None).expect("persist filter"));
let mut playlist = vec![
PlaylistGroup {
id: 1,
title: "Base".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "Trending".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Trending".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
];
apply_persist_filter(&target, &mut playlist);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Trending");
}
#[test]
fn persist_filter_can_select_a_base_group() {
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
target.filter.persist = Some(get_filter(r#"Group = "Base""#, None).expect("persist filter"));
let mut playlist = vec![
PlaylistGroup {
id: 1,
title: "Base".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "Curated".intern(),
channels: vec![PlaylistItem {
header: PlaylistItemHeader { group: "Curated".intern(), ..Default::default() },
}],
xtream_cluster: XtreamCluster::Video,
},
];
apply_persist_filter(&target, &mut playlist);
assert_eq!(playlist.len(), 1);
assert_eq!(playlist[0].title.as_ref(), "Base");
}
#[tokio::test]
async fn trakt_finalization_stage_is_a_noop_without_xtream_configuration() {
async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
let target = ConfigTarget::from(&ConfigTargetDto::default());
let mut playlist = Vec::new();
let applied = trakt_playlist(&reqwest::Client::new(), &target, &mut playlist).await;
let views = prepare_target_playlist_views(&reqwest::Client::new(), &target, Vec::new())
.await
.expect("unconfigured curation should not fail");
assert!(!applied);
assert!(playlist.is_empty());
assert!(views.base.is_empty());
assert!(views.xtream.is_none());
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
#[tokio::test]
async fn unavailable_required_selector_returns_target_failure_instead_of_base_fallback() {
let target = ConfigTarget::from(&ConfigTargetDto {
name: "curation-failure".to_string(),
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
})],
..ConfigTargetDto::default()
});
let base = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Base movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000061"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let error = prepare_target_playlist_views(&reqwest::Client::new(), &target, base)
.await
.expect_err("missing credentials must stop target publication");
assert!(error.message().contains("existing finalized artifacts were retained"));
}
mod curation_effect_gate {
use super::*;
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::model::{ConfigPaths, NoopSink};
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use tempfile::tempdir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::JoinHandle,
};
use tuliprox_core::{
model::{ApiProxyConfig, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
fn app_config(storage_dir: &Path) -> Arc<AppConfig> {
Arc::new(AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config {
storage_dir: storage_dir.to_string_lossy().into_owned(),
..Config::default()
})),
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
hdhomerun: Arc::new(ArcSwapOption::<HdHomeRunConfig>::default()),
api_proxy: Arc::new(ArcSwapOption::<ApiProxyConfig>::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::<CustomStreamResponse>::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
})
}
fn processing_context(
app_config: Arc<AppConfig>,
playlist_state: Option<Arc<PlaylistStorageState>>,
) -> PlaylistProcessingContext<NoopSink> {
PlaylistProcessingContext {
client: reqwest::Client::new(),
run_id: "curation-effect-gate-run".into(),
execution_order: PlaylistUpdateRunOrder::from(1),
config: app_config,
user_targets: Arc::new(ProcessTargets {
enabled: false,
inputs: Vec::new(),
targets: Vec::new(),
target_names: Vec::new(),
}),
events: NoopSink,
playlist_state,
disabled_headers: None,
processed_inputs: Arc::new(Mutex::new(HashSet::new())),
input_completions: Arc::new(Mutex::new(HashMap::new())),
input_locks: Arc::new(Mutex::new(HashMap::new())),
provider_manager: None,
metadata_manager: None,
pre_processed_inputs: None,
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
input_refresh: None,
library_update_mode: LibraryUpdateMode::ExistingCatalog,
}
}
fn curation_persist_options(publication_plan: PlaylistPublicationPlan) -> TargetPlaylistPersistOptions {
TargetPlaylistPersistOptions { publication_plan, ..TargetPlaylistPersistOptions::default() }
}
fn file_snapshot(root: &Path) -> BTreeMap<std::path::PathBuf, Vec<u8>> {
fn collect(root: &Path, path: &Path, snapshot: &mut BTreeMap<std::path::PathBuf, Vec<u8>>) {
let Ok(entries) = std::fs::read_dir(path) else { return };
for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_dir() {
collect(root, &entry_path, snapshot);
} else {
snapshot.insert(
entry_path.strip_prefix(root).expect("snapshot path under root").to_path_buf(),
std::fs::read(&entry_path).expect("snapshot file"),
);
}
}
}
let mut snapshot = BTreeMap::new();
collect(root, root, &mut snapshot);
snapshot
}
async fn empty_trakt_server() -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind Trakt test server");
let address = listener.local_addr().expect("Trakt test address");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept Trakt request");
let mut request = Vec::new();
loop {
let mut buffer = [0u8; 1024];
let read = stream.read(&mut buffer).await.expect("read Trakt request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n[]")
.await
.expect("write Trakt response");
});
(format!("http://{address}"), server)
}
#[tokio::test]
async fn unavailable_curation_retains_seeded_artifacts_cache_and_watch_state() {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let playlist_state = Arc::new(PlaylistStorageState::new());
let target = ConfigTarget::from(&ConfigTargetDto {
name: "curation-effect-gate".to_string(),
output: vec![
TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
}),
TargetOutputDto::M3u(M3uTargetOutputDto {
filename: Some("curation-effect-gate.m3u".to_string()),
..M3uTargetOutputDto::default()
}),
],
watch: Some(vec![".*".to_string()]),
use_memory_cache: true,
..ConfigTargetDto::default()
});
let mut seeded = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Seeded movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000071"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let seeded_result = persist_playlist_views(
&app_config,
&mut seeded,
None,
None,
&target,
Some(&playlist_state),
curation_persist_options(PlaylistPublicationPlan::Ordinary),
)
.await;
assert!(seeded_result.is_ok(), "seed persist failed: {seeded_result:?}");
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
let before_files = file_snapshot(directory.path());
let before_cache_len = playlist_state
.data
.read()
.await
.get(&target.name)
.and_then(|storage| storage.xtream.as_ref())
.map_or(0, |storage| storage.vod.len());
let context = processing_context(Arc::clone(&app_config), Some(Arc::clone(&playlist_state)));
let prepared = PreparedTarget {
target,
playlist: Vec::new(),
epg: Vec::new(),
processing: PipelineStats::default(),
accepted_empty_clusters: ClusterFlags::Vod,
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
};
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
assert!(result.is_err());
assert!(errors.is_empty());
assert_eq!(file_snapshot(directory.path()), before_files);
let after_cache_len = playlist_state
.data
.read()
.await
.get("curation-effect-gate")
.and_then(|storage| storage.xtream.as_ref())
.map_or(0, |storage| storage.vod.len());
assert_eq!(after_cache_len, before_cache_len);
}
#[tokio::test]
async fn complete_empty_curation_publishes_empty_watch_group_state() {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let (base_url, server) = empty_trakt_server().await;
let target_name = "curation-empty-watch";
let target = ConfigTarget::from(&ConfigTargetDto {
name: target_name.to_string(),
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
trakt: Some(TraktConfigDto {
catalog_selection: TraktCatalogSelection::Curated,
api: TraktApiConfigDto {
api_key: "test-client-id".to_string(),
version: "2".to_string(),
url: base_url,
user_agent: "tuliprox-test".to_string(),
},
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Watchlist".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
..TraktConfigDto::default()
}),
..XtreamTargetOutputDto::default()
})],
watch: Some(vec![".*".to_string()]),
..ConfigTargetDto::default()
});
let mut seeded = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![catalog_test_item(
"Seeded movie",
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000072"),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)],
xtream_cluster: XtreamCluster::Video,
}];
let seed_result = persist_playlist_views(
&app_config,
&mut seeded,
None,
None,
&target,
None,
curation_persist_options(PlaylistPublicationPlan::Ordinary),
)
.await;
assert!(seed_result.is_ok(), "seed persist failed: {seed_result:?}");
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
let watch_index = directory.path().join(format!("{target_name}.groups.bin"));
let before: BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(&watch_index).expect("seeded watch index"))
.expect("decode seeded watch index");
assert_eq!(before.len(), 1);
let context = processing_context(Arc::clone(&app_config), None);
let prepared = PreparedTarget {
target,
playlist: seeded,
epg: Vec::new(),
processing: PipelineStats::default(),
accepted_empty_clusters: ClusterFlags::empty(),
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
};
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
server.await.expect("Trakt server should finish");
assert!(result.is_ok(), "complete empty finalization failed: {result:?}");
assert!(errors.is_empty());
let after: BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(watch_index).expect("empty watch index"))
.expect("decode empty watch index");
assert!(after.is_empty());
}
}
fn catalog_test_item(
title: &str,
uuid: UUIDType,
item_type: PlaylistItemType,
cluster: XtreamCluster,
parent_code: Option<&str>,
) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
id: title.intern(),
name: title.intern(),
title: title.intern(),
group: match cluster {
XtreamCluster::Live => "Live".intern(),
XtreamCluster::Video => "Movies".intern(),
XtreamCluster::Series => "Series".intern(),
},
uuid,
item_type,
xtream_cluster: cluster,
parent_code: parent_code.unwrap_or_default().intern(),
..PlaylistItemHeader::default()
},
}
}
fn complete_catalog_evaluation(memberships: Vec<CurationMembership>) -> CurationEvaluation {
CurationEvaluation {
selectors: vec![CurationSelectorSummary {
key: CurationSelectorKey(0),
reference_count: memberships.len(),
membership_count: memberships.len(),
}],
memberships,
}
}
fn catalog_membership(uuid: UUIDType, media_kind: CurationMediaKind, order: usize) -> CurationMembership {
CurationMembership {
selector_key: CurationSelectorKey(0),
subject_uuid: uuid,
media_kind,
rank: Some(u32::try_from(order + 1).expect("test rank")),
title_tiebreak: format!("item-{order}"),
candidate_order: order,
}
}
#[test]
fn catalog_selection_preserves_live_and_selected_series_children() {
let live_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
let selected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002");
let rejected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000003");
let series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000004");
let episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000005");
let rejected_series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000006");
let rejected_episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000007");
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![catalog_test_item(
"Live channel",
live_uuid,
PlaylistItemType::Live,
XtreamCluster::Live,
None,
)],
xtream_cluster: XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Movies".intern(),
channels: vec![
catalog_test_item(
"Selected movie",
selected_movie_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
),
catalog_test_item(
"Rejected movie",
rejected_movie_uuid,
PlaylistItemType::Video,
XtreamCluster::Video,
None,
),
],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 3,
title: "Series".intern(),
channels: vec![
catalog_test_item(
"Selected series",
series_uuid,
PlaylistItemType::SeriesInfo,
XtreamCluster::Series,
None,
),
catalog_test_item(
"Selected episode",
episode_uuid,
PlaylistItemType::Series,
XtreamCluster::Series,
Some(&series_uuid.to_string()),
),
catalog_test_item(
"Rejected series",
rejected_series_uuid,
PlaylistItemType::SeriesInfo,
XtreamCluster::Series,
None,
),
catalog_test_item(
"Rejected episode",
rejected_episode_uuid,
PlaylistItemType::Series,
XtreamCluster::Series,
Some(&rejected_series_uuid.to_string()),
),
],
xtream_cluster: XtreamCluster::Series,
},
];
let evaluation = complete_catalog_evaluation(vec![
catalog_membership(selected_movie_uuid, CurationMediaKind::Movie, 0),
catalog_membership(series_uuid, CurationMediaKind::Series, 1),
]);
let selected = select_target_catalog(playlist.clone(), &evaluation, true);
let titles =
selected.iter().flat_map(|group| &group.channels).map(|item| item.header.title.as_ref()).collect::<Vec<_>>();
assert_eq!(titles, ["Live channel", "Selected movie", "Selected series", "Selected episode"]);
assert_eq!(
select_target_catalog(playlist.clone(), &evaluation, false).iter().flat_map(|group| &group.channels).count(),
7
);
assert_eq!(selected[0].channels[0].header.uuid, live_uuid, "target-wide selection must not rewrite Live");
let remote_empty = select_target_catalog(playlist, &complete_catalog_evaluation(Vec::new()), true);
assert_eq!(remote_empty.len(), 1);
assert_eq!(remote_empty[0].xtream_cluster, XtreamCluster::Live);
assert_eq!(remote_empty[0].channels[0].header.uuid, live_uuid);
}
#[test]
fn xtream_base_and_selector_category_projection_are_independent() {
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011");
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
],
xtream_cluster: XtreamCluster::Video,
}];
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
let dto = TraktConfigDto {
enabled: true,
catalog_selection: TraktCatalogSelection::Curated,
include_xtream_base_categories: false,
api: TraktApiConfigDto::default(),
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Curated".to_string()),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
charts: Vec::new(),
};
let config = TraktConfig::from(&dto);
let views = build_curated_playlist_views(playlist.clone(), &evaluation, &config, false);
assert_eq!(views.base.len(), 1);
assert_eq!(views.base[0].channels.len(), 1);
assert!(
target_watch_view(&views.base, views.xtream.as_deref()).iter().any(|group| group.title.as_ref() == "Curated"),
"configured Trakt watches observe the Xtream category appearance"
);
let xtream = views.xtream.expect("complete curation has an Xtream view");
assert_eq!(xtream.len(), 1);
assert_eq!(xtream[0].title.as_ref(), "Curated");
assert_eq!(xtream[0].channels.len(), 1);
assert_ne!(xtream[0].channels[0].header.uuid, selected_uuid);
let mut compatible_dto = dto;
compatible_dto.catalog_selection = TraktCatalogSelection::Full;
compatible_dto.include_xtream_base_categories = true;
compatible_dto.lists[0].create_xtream_category = false;
compatible_dto.lists[0].category_name = None;
let compatible = build_curated_playlist_views(playlist, &evaluation, &TraktConfig::from(&compatible_dto), false);
assert_eq!(compatible.base[0].channels.len(), 2);
assert_eq!(compatible.xtream.expect("Xtream view").len(), 1, "selection-only selector creates no category");
}
#[test]
fn curation_policy_truth_table_covers_a_through_h() {
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000041");
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000042");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
],
xtream_cluster: XtreamCluster::Video,
}];
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
let cases = [
("A", TraktCatalogSelection::Full, true, true),
("B", TraktCatalogSelection::Curated, true, true),
("C", TraktCatalogSelection::Curated, true, false),
("D", TraktCatalogSelection::Curated, false, true),
("E", TraktCatalogSelection::Full, true, false),
("F", TraktCatalogSelection::Full, false, true),
("G", TraktCatalogSelection::Full, false, false),
("H", TraktCatalogSelection::Curated, false, false),
];
for (case, catalog_selection, include_base, create_category) in cases {
let config = TraktConfig::from(&TraktConfigDto {
enabled: true,
catalog_selection,
include_xtream_base_categories: include_base,
api: TraktApiConfigDto::default(),
lists: vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
category_name: Some("Curated".to_string()),
create_xtream_category: create_category,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
}],
charts: Vec::new(),
});
let views = build_curated_playlist_views(playlist.clone(), &evaluation, &config, false);
let base_items = views.base.iter().flat_map(|group| &group.channels).collect::<Vec<_>>();
let expected_base_count = if catalog_selection == TraktCatalogSelection::Full { 2 } else { 1 };
assert_eq!(base_items.len(), expected_base_count, "case {case} selected catalog");
assert!(base_items.iter().any(|item| item.header.uuid == selected_uuid), "case {case} selected subject");
let xtream = views.xtream.expect("complete curation Xtream view");
let base_groups = xtream.iter().filter(|group| group.title.as_ref() == "Movies").count();
let category_groups = xtream.iter().filter(|group| group.title.as_ref() == "Curated").count();
assert_eq!(base_groups, usize::from(include_base), "case {case} base appearance");
assert_eq!(category_groups, usize::from(create_category), "case {case} category appearance");
let expected_unfiltered_items = usize::from(include_base) * expected_base_count + usize::from(create_category);
assert_eq!(
xtream.iter().map(|group| group.channels.len()).sum::<usize>(),
expected_unfiltered_items,
"case {case} unfiltered Xtream catalog"
);
if let Some(alias) =
xtream.iter().find(|group| group.title.as_ref() == "Curated").and_then(|group| group.channels.first())
{
assert_ne!(alias.header.uuid, selected_uuid, "case {case} alias identity");
}
}
}
#[test]
fn large_catalog_projection_smoke_keeps_two_explicit_views_bounded() {
const CATALOG_SIZE: usize = 10_000;
const SELECTOR_COUNT: usize = 4;
const MEMBERSHIP_STRIDE: usize = 10;
let uuid_for = |index: usize| {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&u64::try_from(index + 1).expect("test index").to_be_bytes());
UUIDType(bytes)
};
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: (0..CATALOG_SIZE)
.map(|index| {
catalog_test_item(
&format!("Movie {index}"),
uuid_for(index),
PlaylistItemType::Video,
XtreamCluster::Video,
None,
)
})
.collect(),
xtream_cluster: XtreamCluster::Video,
}];
let mut memberships = Vec::with_capacity(CATALOG_SIZE / MEMBERSHIP_STRIDE * SELECTOR_COUNT);
let mut selectors = Vec::with_capacity(SELECTOR_COUNT);
for selector in 0..SELECTOR_COUNT {
let key = CurationSelectorKey(selector);
let start = memberships.len();
for index in (selector..CATALOG_SIZE).step_by(MEMBERSHIP_STRIDE) {
memberships.push(CurationMembership {
selector_key: key,
subject_uuid: uuid_for(index),
media_kind: CurationMediaKind::Movie,
rank: Some(u32::try_from(index).expect("test rank")),
title_tiebreak: format!("movie-{index}"),
candidate_order: index,
});
}
selectors.push(CurationSelectorSummary {
key,
reference_count: memberships.len() - start,
membership_count: memberships.len() - start,
});
}
let evaluation = CurationEvaluation { selectors, memberships };
let config = TraktConfig::from(&TraktConfigDto {
lists: (0..SELECTOR_COUNT)
.map(|selector| TraktListConfigDto {
user: "alice".to_string(),
list_slug: format!("list-{selector}"),
category_name: Some(format!("Curated {selector}")),
create_xtream_category: true,
content_type: TraktContentType::Vod,
tmdb_only: true,
fuzzy_match_threshold: 100,
})
.collect(),
..TraktConfigDto::default()
});
let views = build_curated_playlist_views(playlist, &evaluation, &config, false);
assert_eq!(views.base[0].channels.len(), CATALOG_SIZE);
let xtream = views.xtream.expect("Xtream view");
assert_eq!(xtream.len(), SELECTOR_COUNT + 1);
assert_eq!(xtream.iter().map(|group| group.channels.len()).sum::<usize>(), CATALOG_SIZE + 4_000);
}
#[cfg(test)]
+2 -2
View File
@@ -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(());
}
+613 -78
View File
@@ -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<Option<Vec<PlaylistGroup>>> {
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<AppConfig>,
fn prepare_target_playlist_for_persistence(
playlist: &mut [PlaylistGroup],
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
) -> Result<(), Vec<TuliproxError>> {
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<AppConfig>,
playlist: &mut [PlaylistGroup],
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
persistence_mode: TargetPersistenceMode,
) -> Result<(), Vec<TuliproxError>> {
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::<Arc<str>, Vec<LocalEpisodeKey>>::new();
let mut provider_series = HashMap::<Arc<str>, Vec<ProviderEpisodeKey>>::new();
let mut media_server_series = HashMap::<Arc<str>, Vec<SeriesStreamDetailEpisodeProperties>>::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::<Arc<str>, Vec<LocalEpisodeKey>>::new();
let mut provider_series = HashMap::<Arc<str>, Vec<ProviderEpisodeKey>>::new();
let mut media_server_series = HashMap::<Arc<str>, Vec<SeriesStreamDetailEpisodeProperties>>::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<AppConfig>,
playlist: &mut [PlaylistGroup],
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
) -> Result<(), Vec<TuliproxError>> {
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<AppConfig>,
base_playlist: &mut [PlaylistGroup],
xtream_playlist: Option<&mut [PlaylistGroup]>,
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
) -> Result<(), Vec<TuliproxError>> {
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<AppConfig>,
playlist: &mut [PlaylistGroup],
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
persistence_mode: TargetPersistenceMode,
) -> Result<(), Vec<TuliproxError>> {
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<AppConfig>,
base_playlist: &mut [PlaylistGroup],
mut xtream_playlist: Option<&mut [PlaylistGroup]>,
epg: Option<&Epg>,
target: &ConfigTarget,
playlist_state: Option<&Arc<PlaylistStorageState>>,
options: TargetPlaylistPersistOptions,
persistence_mode: TargetPersistenceMode,
) -> Result<(), Vec<TuliproxError>> {
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<TargetOutput>) -> 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<AppConfig> {
Arc::new(AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config {
storage_dir: storage_dir.to_string_lossy().into_owned(),
..Config::default()
})),
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
hdhomerun: Arc::new(ArcSwapOption::<HdHomeRunConfig>::default()),
api_proxy: Arc::new(ArcSwapOption::<ApiProxyConfig>::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::<CustomStreamResponse>::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
})
}
fn 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<std::path::PathBuf> {
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");
+2 -2
View File
@@ -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(());
}
+6 -1
View File
@@ -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"
+8 -4
View File
@@ -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
+56 -32
View File
@@ -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.
+6
View File
@@ -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": "نوع الرسم البياني",
+6
View File
@@ -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",
+6
View File
@@ -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": "Списки",
@@ -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<TraktListConfigDto>,
charts: Vec<TraktChartConfigDto>,
) -> Option<TraktConfigDto> {
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::<TraktListConfigDto>::new);
let trakt_charts_state = use_state(Vec::<TraktChartConfigDto>::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::<usize>);
@@ -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 {
<TraktChartItemForm
@@ -438,12 +478,42 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> 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! {
<Select
name={"trakt_catalog_selection"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
if let Some(selection) = selection_parse_first::<TraktCatalogSelection>(&selections) {
selection_form.dispatch(TraktConfigFormAction::CatalogSelection(selection));
}
})}
options={catalog_selection_options.clone()}
/>
}
})}
{ edit_field_bool!(trakt_form, translate.t(LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES), include_xtream_base_categories, TraktConfigFormAction::IncludeXtreamBaseCategories) }
</>
}
} else {
html! { { config_field_bool!(trakt_form.form, translate.t(LABEL_ENABLED), enabled) } }
html! {
<>
{ config_field_bool!(trakt_form.form, translate.t(LABEL_ENABLED), enabled) }
{ config_field_custom!(
translate.t(LABEL_TRAKT_CATALOG_SELECTION),
translate.t(trakt_catalog_selection_label_key(trakt_form.form.catalog_selection))
) }
{ config_field_bool!(trakt_form.form, translate.t(LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES), include_xtream_base_categories) }
</>
}
}}
<div class="tp__form-section">
<h3>{translate.t(LABEL_API_CONFIGURATION)}</h3>
@@ -486,7 +556,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
</div>
}
<div class="tp__form-list__item-content">
<span>{trakt_list_summary(item.1)}</span>
<span>{trakt_list_summary(item.1, &translate.t(LABEL_TRAKT_SELECTION_ONLY))}</span>
</div>
</div>
}
@@ -525,7 +595,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
</div>
}
<div class="tp__form-list__item-content">
<span>{trakt_chart_summary(item.1)}</span>
<span>{trakt_chart_summary(item.1, &translate.t(LABEL_TRAKT_SELECTION_ONLY))}</span>
</div>
</div>
}
@@ -599,6 +669,8 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
let trakt_charts = (*trakt_charts_state).clone();
output.trakt = build_trakt_output_config(
trakt_state.data().enabled,
trakt_state.data().catalog_selection,
trakt_state.data().include_xtream_base_categories,
trakt_api_state.data().clone(),
trakt_lists,
trakt_charts,
@@ -645,9 +717,120 @@ mod tests {
use shared::model::{TraktChartKind, TraktChartType};
#[test]
fn build_trakt_output_config_returns_none_when_empty() {
let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), Vec::new());
assert!(result.is_none());
fn build_trakt_output_config_returns_none_for_source_less_default_policy() {
let api = TraktApiConfigDto {
api_key: "draft-client-id".to_string(),
version: "draft-version".to_string(),
url: "draft-url".to_string(),
user_agent: "draft-agent".to_string(),
};
for enabled in [true, false] {
let result = build_trakt_output_config(
enabled,
TraktCatalogSelection::Full,
true,
api.clone(),
Vec::new(),
Vec::new(),
);
assert!(result.is_none());
}
}
#[test]
fn build_trakt_output_config_preserves_source_less_curated_policy() {
let api = TraktApiConfigDto {
api_key: "draft-client-id".to_string(),
version: "draft-version".to_string(),
url: "draft-url".to_string(),
user_agent: "draft-agent".to_string(),
};
let result =
build_trakt_output_config(true, TraktCatalogSelection::Curated, true, api.clone(), Vec::new(), Vec::new())
.expect("source-less curated policy should be preserved");
assert_eq!(
result,
TraktConfigDto {
enabled: true,
catalog_selection: TraktCatalogSelection::Curated,
include_xtream_base_categories: true,
api,
lists: Vec::new(),
charts: Vec::new(),
}
);
}
#[test]
fn build_trakt_output_config_preserves_source_less_base_category_policy() {
let result = build_trakt_output_config(
true,
TraktCatalogSelection::Full,
false,
TraktApiConfigDto::default(),
Vec::new(),
Vec::new(),
)
.expect("source-less base-category policy should be preserved");
assert!(!result.include_xtream_base_categories);
}
#[test]
fn preserved_enabled_source_less_trakt_policy_reaches_preparation() {
let mut result = build_trakt_output_config(
true,
TraktCatalogSelection::Curated,
true,
TraktApiConfigDto::default(),
Vec::new(),
Vec::new(),
)
.expect("source-less curated policy should be preserved");
assert!(result.prepare().is_err());
}
#[test]
fn preserved_disabled_source_less_trakt_policy_prepares_successfully() {
let mut result = build_trakt_output_config(
false,
TraktCatalogSelection::Curated,
true,
TraktApiConfigDto::default(),
Vec::new(),
Vec::new(),
)
.expect("disabled source-less policy should be preserved");
assert!(result.prepare().is_ok());
assert_eq!(result.catalog_selection, TraktCatalogSelection::Curated);
}
#[test]
fn build_trakt_output_config_keeps_lists_without_charts() {
let lists = vec![TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
create_xtream_category: false,
..TraktListConfigDto::default()
}];
let result = build_trakt_output_config(
true,
TraktCatalogSelection::Full,
true,
TraktApiConfigDto::default(),
lists.clone(),
Vec::new(),
)
.expect("lists-only trakt config");
assert_eq!(result.lists, lists);
assert!(result.charts.is_empty());
}
#[test]
@@ -655,18 +838,46 @@ mod tests {
let charts = vec![TraktChartConfigDto {
kind: TraktChartKind::Movies,
chart: TraktChartType::Trending,
category_name: "Trending Movies".to_string(),
category_name: Some("Trending Movies".to_string()),
create_xtream_category: false,
tmdb_only: true,
fuzzy_match_threshold: 90,
}];
let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), charts.clone())
.expect("charts-only trakt config");
let result = build_trakt_output_config(
true,
TraktCatalogSelection::Curated,
false,
TraktApiConfigDto::default(),
Vec::new(),
charts.clone(),
)
.expect("charts-only trakt config");
assert_eq!(result.catalog_selection, TraktCatalogSelection::Curated);
assert!(!result.include_xtream_base_categories);
assert!(result.lists.is_empty());
assert_eq!(result.charts, charts);
}
#[test]
fn selector_summary_and_defaults_expose_independent_policy() {
let chart = TraktChartConfigDto {
category_name: Some("Preserved category".to_string()),
create_xtream_category: false,
..TraktChartConfigDto::default()
};
let summary = trakt_chart_summary(&chart, "Selection only");
assert!(summary.contains("Selection only"));
assert!(!summary.contains("Preserved category"));
let defaults = TraktConfigDto::default();
assert_eq!(defaults.catalog_selection, TraktCatalogSelection::Full);
assert!(defaults.include_xtream_base_categories);
assert!(TraktChartConfigDto::default().create_xtream_category);
}
#[test]
fn upsert_vec_item_replaces_existing_entry() {
let mut items = vec!["a".to_string(), "b".to_string()];
@@ -1,13 +1,14 @@
use crate::{
app::components::{build_options, select::Select, selection_parse_first, Card, DropDownSelection, TextButton},
config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8, edit_field_text,
generate_form_reducer,
config_field_bool, config_field_child, config_field_custom, config_field_optional, edit_field_bool,
edit_field_number_u8, edit_field_text_option, generate_form_reducer,
i18n::use_translation,
};
use shared::model::{TraktChartConfigDto, TraktChartKind, TraktChartType};
use yew::{component, html, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle};
const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME";
const LABEL_TRAKT_CREATE_XTREAM_CATEGORY: &str = "LABEL.TRAKT_CREATE_XTREAM_CATEGORY";
const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY";
const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD";
const LABEL_TRAKT_CHART_KIND: &str = "LABEL.TRAKT_CHART_KIND";
@@ -37,7 +38,8 @@ generate_form_reducer!(
fields {
Kind => kind: TraktChartKind,
Chart => chart: TraktChartType,
CategoryName => category_name: String,
CategoryName => category_name: Option<String>,
CreateXtreamCategory => create_xtream_category: bool,
TmdbOnly => tmdb_only: bool,
FuzzyMatchThreshold => fuzzy_match_threshold: u8,
}
@@ -51,6 +53,14 @@ pub struct TraktChartItemFormProps {
pub initial: Option<TraktChartConfigDto>,
#[prop_or(false)]
pub readonly: bool,
#[prop_or(true)]
pub validate_category_name: bool,
}
fn can_submit_chart(data: &TraktChartConfigDto, validate_category_name: bool) -> bool {
!validate_category_name
|| !data.create_xtream_category
|| data.category_name.as_deref().is_some_and(|name| !name.trim().is_empty())
}
#[component]
@@ -80,9 +90,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
let handle_submit = {
let form_state = form_state.clone();
let on_submit = props.on_submit.clone();
let validate_category_name = props.validate_category_name;
Callback::from(move |_| {
let data = form_state.form.clone();
if !data.category_name.trim().is_empty() {
if can_submit_chart(&data, validate_category_name) {
on_submit.emit(data);
}
})
@@ -97,7 +108,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
if props.readonly {
{ config_field_custom!(translate.t(LABEL_TRAKT_CHART_KIND), translate.t(trakt_chart_kind_label_key(form_state.form.kind))) }
{ config_field_custom!(translate.t(LABEL_TRAKT_CHART_TYPE), translate.t(trakt_chart_type_label_key(form_state.form.chart))) }
{ config_field_custom!(translate.t(LABEL_TRAKT_CATEGORY_NAME), form_state.form.category_name.clone()) }
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category) }
if form_state.form.create_xtream_category {
{ config_field_optional!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
}
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only) }
{ config_field_custom!(translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), form_state.form.fuzzy_match_threshold.to_string()) }
} else {
@@ -131,7 +145,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
/>
}
})}
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktChartFormAction::CategoryName) }
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category, TraktChartFormAction::CreateXtreamCategory) }
if form_state.form.create_xtream_category {
{ edit_field_text_option!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktChartFormAction::CategoryName) }
}
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only, TraktChartFormAction::TmdbOnly) }
{ edit_field_number_u8!(form_state, translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), fuzzy_match_threshold, TraktChartFormAction::FuzzyMatchThreshold) }
}
@@ -157,3 +174,24 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
</Card>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chart_submit_validation_matches_conditional_category_contract() {
let mut chart = TraktChartConfigDto::default();
assert!(chart.create_xtream_category);
assert!(!can_submit_chart(&chart, true));
chart.category_name = Some("Trending".to_string());
assert!(can_submit_chart(&chart, true));
chart.category_name = None;
chart.create_xtream_category = false;
assert!(can_submit_chart(&chart, true));
chart.create_xtream_category = true;
assert!(can_submit_chart(&chart, false), "disabled Trakt keeps incomplete edits");
}
}
@@ -1,7 +1,7 @@
use crate::{
app::components::{build_options, select::Select, selection_parse_first, Card, DropDownSelection, TextButton},
config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8,
edit_field_text, generate_form_reducer,
config_field, config_field_bool, config_field_child, config_field_custom, config_field_optional, edit_field_bool,
edit_field_number_u8, edit_field_text, edit_field_text_option, generate_form_reducer,
i18n::use_translation,
};
use shared::model::{TraktContentType, TraktListConfigDto};
@@ -10,6 +10,7 @@ use yew::{component, html, use_memo, use_reducer, Callback, Html, Properties, Us
const LABEL_TRAKT_USER: &str = "LABEL.TRAKT_USER";
const LABEL_TRAKT_LIST_SLUG: &str = "LABEL.TRAKT_LIST_SLUG";
const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME";
const LABEL_TRAKT_CREATE_XTREAM_CATEGORY: &str = "LABEL.TRAKT_CREATE_XTREAM_CATEGORY";
const LABEL_TRAKT_CONTENT_TYPE: &str = "LABEL.TRAKT_CONTENT_TYPE";
const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY";
const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD";
@@ -31,7 +32,8 @@ generate_form_reducer!(
fields {
User => user: String,
ListSlug => list_slug: String,
CategoryName => category_name: String,
CategoryName => category_name: Option<String>,
CreateXtreamCategory => create_xtream_category: bool,
ContentType => content_type: TraktContentType,
TmdbOnly => tmdb_only: bool,
FuzzyMatchThreshold => fuzzy_match_threshold: u8,
@@ -46,6 +48,15 @@ pub struct TraktListItemFormProps {
pub initial: Option<TraktListConfigDto>,
#[prop_or(false)]
pub readonly: bool,
#[prop_or(true)]
pub validate_category_name: bool,
}
fn can_submit_list(data: &TraktListConfigDto, validate_category_name: bool) -> bool {
let category_is_valid = !validate_category_name
|| !data.create_xtream_category
|| data.category_name.as_deref().is_some_and(|name| !name.trim().is_empty());
!data.user.trim().is_empty() && !data.list_slug.trim().is_empty() && category_is_valid
}
#[component]
@@ -56,7 +67,8 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
form: props.initial.clone().unwrap_or_else(|| TraktListConfigDto {
user: String::new(),
list_slug: String::new(),
category_name: String::new(),
category_name: None,
create_xtream_category: true,
content_type: TraktContentType::Both,
tmdb_only: false,
fuzzy_match_threshold: 80,
@@ -78,12 +90,10 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
let handle_submit = {
let form_state = form_state.clone();
let on_submit = props.on_submit.clone();
let validate_category_name = props.validate_category_name;
Callback::from(move |_| {
let data = form_state.form.clone();
if !data.user.trim().is_empty()
&& !data.list_slug.trim().is_empty()
&& !data.category_name.trim().is_empty()
{
if can_submit_list(&data, validate_category_name) {
on_submit.emit(data);
}
})
@@ -101,12 +111,18 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
if props.readonly {
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_USER), user) }
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug) }
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category) }
if form_state.form.create_xtream_category {
{ config_field_optional!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
}
} else {
<>
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_USER), user, TraktListFormAction::User) }
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug, TraktListFormAction::ListSlug) }
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) }
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category, TraktListFormAction::CreateXtreamCategory) }
if form_state.form.create_xtream_category {
{ edit_field_text_option!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) }
}
</>
}
@@ -166,3 +182,28 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
</Card>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_submit_validation_matches_conditional_category_contract() {
let mut list = TraktListConfigDto {
user: "alice".to_string(),
list_slug: "watchlist".to_string(),
..TraktListConfigDto::default()
};
assert!(list.create_xtream_category);
assert!(!can_submit_list(&list, true));
list.category_name = Some("Watchlist".to_string());
assert!(can_submit_list(&list, true));
list.category_name = None;
list.create_xtream_category = false;
assert!(can_submit_list(&list, true));
list.create_xtream_category = true;
assert!(can_submit_list(&list, false), "disabled Trakt keeps incomplete edits");
}
}
+1 -1
View File
@@ -282,7 +282,7 @@ impl Prepare for XtreamTargetOutputDto {
self.t_filter = Some(get_filter(raw_filter, templates)?);
}
if let Some(trakt) = &mut self.trakt {
trakt.prepare();
trakt.prepare()?;
}
Ok(())
}
+198 -9
View File
@@ -1,6 +1,9 @@
use crate::defaults::{
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL,
TRAKT_API_VERSION,
use crate::{
defaults::{
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL,
TRAKT_API_VERSION,
},
error::TuliproxError,
};
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
@@ -30,6 +33,21 @@ pub struct TraktApiConfigDto {
pub user_agent: String,
}
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum TraktCatalogSelection {
#[default]
Full,
Curated,
}
impl TraktCatalogSelection {
pub const fn is_full(&self) -> bool { matches!(self, Self::Full) }
pub const fn is_curated(self) -> bool { matches!(self, Self::Curated) }
}
impl TraktApiConfigDto {
pub fn prepare(&mut self) {
self.api_key = self.api_key.trim().to_string();
@@ -47,7 +65,10 @@ impl TraktApiConfigDto {
pub struct TraktListConfigDto {
pub user: String,
pub list_slug: String,
pub category_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category_name: Option<String>,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub create_xtream_category: bool,
pub content_type: TraktContentType,
#[serde(default, skip_serializing_if = "is_false")]
pub tmdb_only: bool,
@@ -92,7 +113,10 @@ pub enum TraktChartType {
pub struct TraktChartConfigDto {
pub kind: TraktChartKind,
pub chart: TraktChartType,
pub category_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category_name: Option<String>,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub create_xtream_category: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub tmdb_only: bool,
#[serde(default = "default_trakt_fuzzy_threshold")]
@@ -104,7 +128,8 @@ impl Default for TraktChartConfigDto {
Self {
kind: TraktChartKind::default(),
chart: TraktChartType::default(),
category_name: String::new(),
category_name: None,
create_xtream_category: true,
tmdb_only: false,
fuzzy_match_threshold: default_trakt_fuzzy_threshold(),
}
@@ -116,7 +141,8 @@ impl Default for TraktListConfigDto {
TraktListConfigDto {
user: String::new(),
list_slug: String::new(),
category_name: String::new(),
category_name: None,
create_xtream_category: true,
content_type: TraktContentType::default(),
tmdb_only: false,
fuzzy_match_threshold: default_trakt_fuzzy_threshold(),
@@ -129,6 +155,10 @@ impl Default for TraktListConfigDto {
pub struct TraktConfigDto {
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "TraktCatalogSelection::is_full")]
pub catalog_selection: TraktCatalogSelection,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub include_xtream_base_categories: bool,
#[serde(default)]
pub api: TraktApiConfigDto,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -139,12 +169,69 @@ pub struct TraktConfigDto {
impl Default for TraktConfigDto {
fn default() -> Self {
Self { enabled: true, api: TraktApiConfigDto::default(), lists: Vec::new(), charts: Vec::new() }
Self {
enabled: true,
catalog_selection: TraktCatalogSelection::Full,
include_xtream_base_categories: true,
api: TraktApiConfigDto::default(),
lists: Vec::new(),
charts: Vec::new(),
}
}
}
fn prepare_selector_category(
category_name: &mut Option<String>,
create_xtream_category: bool,
validate: bool,
selector_name: &str,
) -> Result<(), TuliproxError> {
*category_name = category_name.take().map(|name| name.trim().to_string()).filter(|name| !name.is_empty());
if validate && create_xtream_category && category_name.is_none() {
return Err(TuliproxError::Config(format!(
"Trakt {selector_name} category_name is required when create_xtream_category is true"
)));
}
Ok(())
}
impl TraktConfigDto {
pub fn prepare(&mut self) { self.api.prepare(); }
pub fn has_selectors(&self) -> bool { !self.lists.is_empty() || !self.charts.is_empty() }
/// Returns whether this is the compatible all-default, source-less no-op.
///
/// Enabled and API edit fields do not affect this classification.
pub fn is_source_less_noop(&self) -> bool {
!self.has_selectors()
&& self.catalog_selection == TraktCatalogSelection::Full
&& self.include_xtream_base_categories
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
self.api.prepare();
for selector in &mut self.lists {
prepare_selector_category(
&mut selector.category_name,
selector.create_xtream_category,
self.enabled,
"list",
)?;
}
for selector in &mut self.charts {
prepare_selector_category(
&mut selector.category_name,
selector.create_xtream_category,
self.enabled,
"chart",
)?;
}
if self.enabled && !self.has_selectors() && !self.is_source_less_noop() {
return Err(TuliproxError::Config(
"Enabled Trakt curation with non-default policy requires at least one list or chart".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
@@ -210,14 +297,116 @@ mod tests {
)
.expect("charts-only Trakt config should deserialize");
assert_eq!(config.catalog_selection, TraktCatalogSelection::Full);
assert!(config.include_xtream_base_categories);
assert!(config.lists.is_empty());
assert_eq!(config.charts.len(), 1);
assert_eq!(config.charts[0].kind, TraktChartKind::Movies);
assert_eq!(config.charts[0].kind.content_type(), TraktContentType::Vod);
assert_eq!(config.charts[0].chart, TraktChartType::Trending);
assert_eq!(config.charts[0].category_name.as_deref(), Some("Trending Movies"));
assert!(config.charts[0].create_xtream_category);
assert_eq!(config.charts[0].fuzzy_match_threshold, default_trakt_fuzzy_threshold());
}
#[test]
fn selector_category_is_optional_and_projection_switches_are_independent() {
let mut config = serde_json::from_str::<TraktConfigDto>(
r#"{"catalog_selection":"curated","include_xtream_base_categories":false,"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both","create_xtream_category":false}]}"#,
)
.expect("selection-only Trakt selector should deserialize");
config.prepare().expect("selection-only selector should prepare");
assert_eq!(config.catalog_selection, TraktCatalogSelection::Curated);
assert!(!config.include_xtream_base_categories);
assert_eq!(config.lists.len(), 1);
assert!(!config.lists[0].create_xtream_category);
assert!(config.lists[0].category_name.is_none());
}
#[test]
fn category_name_is_conditionally_required_during_preparation() {
let mut required = serde_json::from_str::<TraktConfigDto>(
r#"{"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both"}]}"#,
)
.expect("selector DTO");
assert!(required.prepare().is_err());
required.lists[0].create_xtream_category = false;
assert!(required.prepare().is_ok());
let mut chart = serde_json::from_str::<TraktConfigDto>(r#"{"charts":[{"kind":"movies","chart":"trending"}]}"#)
.expect("chart selector DTO");
assert!(chart.prepare().is_err());
chart.charts[0].create_xtream_category = false;
assert!(chart.prepare().is_ok());
}
#[test]
fn disabled_config_can_retain_incomplete_selector_edits() {
let mut config = serde_json::from_str::<TraktConfigDto>(
r#"{"enabled":false,"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both"}]}"#,
)
.expect("disabled selector DTO");
assert!(config.prepare().is_ok());
assert!(config.lists[0].category_name.is_none());
assert!(config.lists[0].create_xtream_category);
}
#[test]
fn source_less_non_default_trakt_policy_axes_are_rejected_only_when_enabled() {
let configs = [
TraktConfigDto { catalog_selection: TraktCatalogSelection::Curated, ..TraktConfigDto::default() },
TraktConfigDto { include_xtream_base_categories: false, ..TraktConfigDto::default() },
];
for mut enabled in configs {
assert!(enabled.prepare().is_err());
enabled.enabled = false;
assert!(enabled.prepare().is_ok());
}
}
#[test]
fn old_yaml_shape_keeps_case_a_defaults_without_serializing_new_fields() {
let config = serde_json::from_str::<TraktConfigDto>(
r#"{"charts":[{"kind":"movies","chart":"trending","category_name":"Trending"}]}"#,
)
.expect("old config shape");
let serialized = serde_json::to_value(&config).expect("serialize compatible config");
assert_eq!(config.catalog_selection, TraktCatalogSelection::Full);
assert!(config.include_xtream_base_categories);
assert!(config.charts[0].create_xtream_category);
assert!(serialized.get("catalog_selection").is_none());
assert!(serialized.get("include_xtream_base_categories").is_none());
assert!(serialized["charts"][0].get("create_xtream_category").is_none());
}
#[test]
fn yaml_accepts_explicit_a_through_d_policy_controls() {
let cases = [
("A", "full", true, true),
("B", "curated", true, true),
("C", "curated", true, false),
("D", "curated", false, true),
];
for (case, selection, include_base, create_category) in cases {
let yaml = format!(
"catalog_selection: {selection}\ninclude_xtream_base_categories: {include_base}\nlists:\n - user: alice\n list_slug: watchlist\n category_name: Watchlist\n create_xtream_category: {create_category}\n content_type: vod\n"
);
let mut config = serde_saphyr::from_str::<TraktConfigDto>(&yaml).expect("A-D YAML should deserialize");
config.prepare().expect("A-D YAML should prepare");
assert_eq!(config.catalog_selection.to_string(), selection, "case {case}");
assert_eq!(config.include_xtream_base_categories, include_base, "case {case}");
assert_eq!(config.lists[0].create_xtream_category, create_category, "case {case}");
}
}
#[test]
fn trakt_chart_kind_accepts_show_aliases() {
let config = serde_json::from_str::<TraktConfigDto>(