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
+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>(