mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-27 09:12:35 +01:00
feat: add target-wide curated catalog selection (#864)
* test: characterize target curation transition * refactor: model complete curation outcomes * feat: configure target-wide curation projection * feat: apply target-wide curation selections * docs: explain target-wide Trakt curation * fix: preserve source-less Trakt policy edits * target wide curaton selection * target wide curaton selection --------- Co-authored-by: Juanjo Presa <juanjop@gmail.com> Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
co-authored by
Juanjo Presa
euzu
parent
2297b3700a
commit
8fa8a3601b
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user