diff --git a/CHANGELOG.md b/CHANGELOG.md index db250f058..ace9cc9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1365,6 +1365,11 @@ ## 🛠 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. + - **`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 `activate_unbound_session` a slot later). Call sites read `..., true, Some(session_token), true, guard)` — a shape diff --git a/Cargo.lock b/Cargo.lock index 525f73681..9bc227432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4475,7 +4475,6 @@ dependencies = [ "ipnet", "libc", "log", - "mime", "parking_lot", "paste", "path-clean", @@ -4503,6 +4502,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "tuliprox-curation" +version = "3.3.108" +dependencies = [ + "indexmap", + "log", + "mime", + "regex", + "reqwest", + "serde", + "serde_json", + "shared", + "strsim", + "tokio", + "tuliprox-core", +] + [[package]] name = "tuliprox-dvr" version = "3.3.108" @@ -4743,6 +4759,7 @@ dependencies = [ "tokio", "tokio-util", "tuliprox-core", + "tuliprox-curation", "tuliprox-iptv", "tuliprox-library", "tuliprox-media-server", diff --git a/Cargo.toml b/Cargo.toml index 0668afe23..31d63830c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ tuliprox-session = { version = "3", path = "backend/session" } tuliprox-media-server = { version = "3", path = "backend/media-server" } tuliprox-repository = { version = "3", path = "backend/repository" } tuliprox-core = { version = "3", path = "backend/core" } +tuliprox-curation = { version = "3", path = "backend/curation" } tuliprox-library = { version = "3", path = "backend/library" } serde = { version = "1.0.229", features = ["derive", "rc"] } serde_json = { version = "1.0.151" } diff --git a/backend/core/Cargo.toml b/backend/core/Cargo.toml index 1f7d9673f..ff9ca3d46 100644 --- a/backend/core/Cargo.toml +++ b/backend/core/Cargo.toml @@ -30,7 +30,6 @@ flate2 = "1.1.9" futures.workspace = true indexmap.workspace = true ipnet = "2" -mime = "0.3.17" log.workspace = true parking_lot = "0.12.5" paste.workspace = true diff --git a/backend/core/src/model/config/mod.rs b/backend/core/src/model/config/mod.rs index b1d2faa7e..85962dfae 100644 --- a/backend/core/src/model/config/mod.rs +++ b/backend/core/src/model/config/mod.rs @@ -31,7 +31,6 @@ mod stream; mod stream_history; mod target; mod trakt; -mod trakt_api; mod video_download; mod web_auth; mod web_ui; @@ -67,7 +66,6 @@ pub use stream::*; pub use stream_history::*; pub use target::*; pub use trakt::*; -pub use trakt_api::*; pub use video_download::*; pub use web_auth::*; pub use web_ui::*; diff --git a/backend/core/src/model/config/trakt.rs b/backend/core/src/model/config/trakt.rs index 7d24bdc68..8d18cd153 100644 --- a/backend/core/src/model/config/trakt.rs +++ b/backend/core/src/model/config/trakt.rs @@ -1,7 +1,7 @@ -use crate::model::{config::trakt_api::TraktMatchItem, macros}; +use crate::model::macros; use shared::model::{ - PlaylistItem, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, - TraktContentType, TraktListConfigDto, + TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, TraktContentType, + TraktListConfigDto, }; #[derive(Debug, Clone)] @@ -106,36 +106,6 @@ impl From<&TraktChartConfig> for TraktChartConfigDto { } } -#[derive(Debug, Clone)] -pub struct TraktCategoryConfig { - pub category_name: String, - pub content_type: TraktContentType, - pub tmdb_only: bool, - pub fuzzy_match_threshold: u8, // Percentage (0-100) -} - -impl From<&TraktListConfig> for TraktCategoryConfig { - fn from(config: &TraktListConfig) -> Self { - Self { - category_name: config.category_name.clone(), - content_type: config.content_type, - tmdb_only: config.tmdb_only, - fuzzy_match_threshold: config.fuzzy_match_threshold, - } - } -} - -impl From<&TraktChartConfig> for TraktCategoryConfig { - fn from(config: &TraktChartConfig) -> Self { - Self { - category_name: config.category_name.clone(), - content_type: config.kind.content_type(), - tmdb_only: config.tmdb_only, - fuzzy_match_threshold: config.fuzzy_match_threshold, - } - } -} - #[derive(Debug, Clone)] pub struct TraktConfig { pub enabled: bool, @@ -166,11 +136,39 @@ impl From<&TraktConfig> for TraktConfigDto { } } -// Matching results -#[derive(Debug, Clone)] -pub struct TraktMatchResult<'a> { - pub playlist_item: &'a PlaylistItem, - pub trakt_item: &'a TraktMatchItem<'a>, - pub match_score: f64, - // pub match_type: MatchType, +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_trakt_config_round_trips_through_the_compatible_dto() { + let dto = TraktConfigDto { + enabled: true, + api: TraktApiConfigDto { + api_key: "client-id".to_string(), + version: "2".to_string(), + url: "https://api.trakt.tv".to_string(), + user_agent: "agent".to_string(), + }, + lists: vec![TraktListConfigDto { + user: "alice".to_string(), + list_slug: "watchlist".to_string(), + category_name: "Watchlist".to_string(), + content_type: TraktContentType::Vod, + tmdb_only: false, + fuzzy_match_threshold: 80, + }], + charts: vec![TraktChartConfigDto { + kind: TraktChartKind::Shows, + chart: TraktChartType::Popular, + category_name: "Popular Shows".to_string(), + tmdb_only: true, + fuzzy_match_threshold: 90, + }], + }; + + let resolved = TraktConfig::from(&dto); + + assert_eq!(TraktConfigDto::from(&resolved), dto); + } } diff --git a/backend/core/src/model/config/trakt_api.rs b/backend/core/src/model/config/trakt_api.rs deleted file mode 100644 index 6d30fabf8..000000000 --- a/backend/core/src/model/config/trakt_api.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::utils::normalize_title_for_matching; -use serde::{Deserialize, Serialize}; -use shared::{model::TraktContentType, utils::is_blank_optional_string}; - -// Trakt API Response structures -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktListItem { - pub id: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rank: Option, - pub listed_at: String, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] - pub notes: Option, - #[serde(rename = "type")] - pub item_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub movie: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub show: Option, - #[serde(skip)] - pub content_type: TraktContentType, -} - -impl TraktListItem { - pub fn from_movie_chart(movie: TraktMovie, rank: u32) -> Self { - Self { - id: u64::from(movie.ids.trakt), - rank: Some(rank), - listed_at: String::new(), - notes: None, - item_type: "movie".to_string(), - movie: Some(movie), - show: None, - content_type: TraktContentType::Vod, - } - } - - pub fn from_show_chart(show: TraktShow, rank: u32) -> Self { - Self { - id: u64::from(show.ids.trakt), - rank: Some(rank), - listed_at: String::new(), - notes: None, - item_type: "show".to_string(), - movie: None, - show: Some(show), - content_type: TraktContentType::Series, - } - } - - pub fn prepare(&mut self) { - self.content_type = match self.item_type.as_str() { - "movie" => { - if self.movie.is_some() { - TraktContentType::Vod - } else { - TraktContentType::Both - } - } - "show" => { - if self.show.is_some() { - TraktContentType::Series - } else { - TraktContentType::Both - } - } - _ => TraktContentType::Both, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktMovie { - pub ids: TraktIds, - pub title: String, - pub year: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktShow { - pub ids: TraktIds, - pub title: String, - pub year: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TraktIds { - pub trakt: u32, - pub slug: String, - pub tvdb: Option, - pub imdb: Option, - pub tmdb: Option, - pub tvrage: Option, -} - -// Internal matching structures -#[derive(Debug, Clone)] -pub struct TraktMatchItem<'a> { - pub title: &'a str, - pub normalized_title: String, - pub year: Option, - pub tmdb_id: Option, - pub trakt_id: u32, - pub content_type: TraktContentType, - pub rank: Option, -} - -impl<'a> TraktMatchItem<'a> { - pub fn from_trakt_list_item(item: &'a TraktListItem) -> Option { - match item.item_type.as_str() { - "movie" => item.movie.as_ref().map(|movie| TraktMatchItem { - title: movie.title.as_str(), - normalized_title: normalize_title_for_matching(movie.title.as_str()), - year: movie.year, - tmdb_id: movie.ids.tmdb, - trakt_id: movie.ids.trakt, - content_type: TraktContentType::Vod, - rank: item.rank, - }), - "show" => item.show.as_ref().map(|show| TraktMatchItem { - title: show.title.as_str(), - normalized_title: normalize_title_for_matching(show.title.as_str()), - year: show.year, - tmdb_id: show.ids.tmdb, - trakt_id: show.ids.trakt, - content_type: TraktContentType::Series, - rank: item.rank, - }), - _ => None, - } - } -} diff --git a/backend/core/src/utils/mod.rs b/backend/core/src/utils/mod.rs index 43652026d..981790d0f 100644 --- a/backend/core/src/utils/mod.rs +++ b/backend/core/src/utils/mod.rs @@ -22,7 +22,6 @@ mod step_measure; mod sys_utils; mod telegram; mod time_utils; -mod trakt; #[macro_export] macro_rules! debug_if_enabled { @@ -87,7 +86,6 @@ pub use self::{ sys_utils::*, telegram::*, time_utils::*, - trakt::*, }; pub use debug_if_enabled; pub use shared::utils::*; diff --git a/backend/core/src/utils/trakt/mod.rs b/backend/core/src/utils/trakt/mod.rs deleted file mode 100644 index 23051afe2..000000000 --- a/backend/core/src/utils/trakt/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Common utilities for Trakt functionality -mod client; -mod errors; - -pub use self::client::*; -use shared::utils::{deunicode_string, CONSTANTS}; - -/// Normalize title for matching - optimized version with reduced allocations -pub fn normalize_title_for_matching(title: &str) -> String { - let normalized = deunicode_string(title.trim()); - - let mut result = String::with_capacity(normalized.len()); - - for ch in normalized.chars() { - if ch.is_alphanumeric() { - result.push(ch.to_ascii_lowercase()); - } - } - - if CONSTANTS.re_trakt_year.is_match(&result) { - CONSTANTS.re_trakt_year.replace(&result, "").into_owned() - } else { - result - } -} - -/// Extract year from title using cached regex pattern - optimized version -pub fn extract_year_from_title(title: &str) -> Option { - if let Some(captures) = CONSTANTS.re_trakt_year.captures(title) { - if let Some(year_str) = captures.get(1) { - if let Ok(year) = year_str.as_str().parse::() { - if (1900..=2100).contains(&year) { - return Some(year); - } - } - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_normalize_title() { - assert_eq!(normalize_title_for_matching("The Matrix"), "thematrix"); - assert_eq!(normalize_title_for_matching("Spider-Man: No Way Home"), "spidermannowayhome"); - assert_eq!(normalize_title_for_matching("Élite"), "elite"); - } - - #[test] - fn test_extract_year() { - let year = extract_year_from_title("The Matrix (1999)"); - assert_eq!(year, Some(1999)); - - let year = extract_year_from_title("Avengers Endgame 2019"); - assert_eq!(year, Some(2019)); - - let year = extract_year_from_title("Just a Title"); - assert_eq!(year, None); - } -} diff --git a/backend/curation/Cargo.toml b/backend/curation/Cargo.toml new file mode 100644 index 000000000..e2c3d0bcb --- /dev/null +++ b/backend/curation/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tuliprox-curation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Source-neutral playlist curation with a Trakt edge adapter for tuliprox" + +[lints] +workspace = true + +[dependencies] +shared = { workspace = true } +tuliprox-core = { workspace = true } +indexmap.workspace = true +log.workspace = true +mime = "0.3.17" +regex.workspace = true +reqwest = { version = "0.13.4", features = ["json", "rustls"] } +serde.workspace = true +serde_json.workspace = true +strsim = "0.11.1" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util"] } diff --git a/backend/curation/src/kernel.rs b/backend/curation/src/kernel.rs new file mode 100644 index 000000000..42c966afe --- /dev/null +++ b/backend/curation/src/kernel.rs @@ -0,0 +1,771 @@ +use indexmap::IndexMap; +use log::{debug, trace}; +use regex::Regex; +use shared::{ + model::{ + FieldGet, FieldSet, HeaderField, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType, + XtreamCluster, + }, + utils::{deunicode_string, hash_string, Internable, CONSTANTS}, +}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; +use strsim::normalized_levenshtein; + +static TRAILING_TITLE_YEAR: LazyLock = + LazyLock::new(|| Regex::new(r"\(?(\d{4})\)?$").expect("curation title-year regex must compile")); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CurationMediaKind { + Movie, + Series, +} + +impl CurationMediaKind { + const fn from_playlist_item_type(item_type: PlaylistItemType) -> Option { + match item_type { + PlaylistItemType::Video | PlaylistItemType::LocalVideo => Some(Self::Movie), + PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => Some(Self::Series), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CurationMediaScope { + Movies, + Series, + Both, +} + +impl CurationMediaScope { + const fn includes_reference(self, kind: CurationMediaKind) -> bool { + match self { + Self::Movies => matches!(kind, CurationMediaKind::Movie), + Self::Series => matches!(kind, CurationMediaKind::Series), + Self::Both => true, + } + } + + const fn includes_cluster(self, cluster: XtreamCluster) -> bool { + match self { + Self::Movies => matches!(cluster, XtreamCluster::Video), + Self::Series => matches!(cluster, XtreamCluster::Series), + Self::Both => matches!(cluster, XtreamCluster::Video | XtreamCluster::Series), + } + } + + fn includes_playlist_item(self, item_type: PlaylistItemType) -> bool { + match self { + Self::Movies => item_type.is_video(), + Self::Series => matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo), + Self::Both => matches!( + item_type, + PlaylistItemType::Video + | PlaylistItemType::LocalVideo + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CurationMatchPolicy { + ExactTmdbOnly, + ExactTmdbThenFuzzy { threshold_percent: u8 }, +} + +impl CurationMatchPolicy { + fn fuzzy_threshold(self) -> Option { + match self { + Self::ExactTmdbOnly => None, + Self::ExactTmdbThenFuzzy { threshold_percent } => Some(f64::from(threshold_percent) / 100.0), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum ProjectionIdentityStrategy<'a> { + LegacyCategoryScoped { namespace: &'a str }, +} + +impl ProjectionIdentityStrategy<'_> { + fn projected_uuid(self, category_name: &str, source_uuid: UUIDType) -> UUIDType { + match self { + Self::LegacyCategoryScoped { namespace } => { + hash_string(&format!("{namespace}:{category_name}:{source_uuid}")) + } + } + } +} + +#[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) projection_identity: ProjectionIdentityStrategy<'a>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CuratedMediaReference { + pub(crate) kind: CurationMediaKind, + pub(crate) title: String, + normalized_title: String, + pub(crate) year: Option, + pub(crate) tmdb_id: Option, + pub(crate) rank: Option, +} + +impl CuratedMediaReference { + pub(crate) fn new( + kind: CurationMediaKind, + title: String, + year: Option, + tmdb_id: Option, + rank: Option, + ) -> Self { + let normalized_title = normalize_title_for_matching(&title); + Self { kind, title, normalized_title, year, tmdb_id, rank } + } +} + +struct PlaylistCandidate<'a> { + item: &'a PlaylistItem, + kind: CurationMediaKind, + normalized_title: String, + year: Option, + tmdb_id: Option, +} + +struct MatchResult<'playlist, 'reference> { + playlist_item: &'playlist PlaylistItem, + reference: &'reference CuratedMediaReference, +} + +pub(crate) fn normalize_title_for_matching(title: &str) -> String { + let normalized = deunicode_string(title.trim()); + let mut result = String::with_capacity(normalized.len()); + + for ch in normalized.chars() { + if ch.is_alphanumeric() { + result.push(ch.to_ascii_lowercase()); + } + } + + if TRAILING_TITLE_YEAR.is_match(&result) { + TRAILING_TITLE_YEAR.replace(&result, "").into_owned() + } else { + result + } +} + +pub(crate) fn extract_year_from_title(title: &str) -> Option { + if let Some(captures) = TRAILING_TITLE_YEAR.captures(title) { + if let Some(year_str) = captures.get(1) { + if let Ok(year) = year_str.as_str().parse::() { + if (1900..=2100).contains(&year) { + return Some(year); + } + } + } + } + + None +} + +fn calculate_year_bonus(playlist_year: Option, reference_year: Option) -> f64 { + if let (Some(playlist_year), Some(reference_year)) = (playlist_year, reference_year) { + if playlist_year == reference_year { + return 0.5; + } + return -0.5; + } + 0.0 +} + +fn find_best_fuzzy_match_for_item<'playlist, 'reference>( + candidate: &PlaylistCandidate<'playlist>, + references: &'reference [CuratedMediaReference], + specification: &CurationCategorySpec<'_>, + threshold: f64, +) -> Option> { + let mut best_match: Option<(&CuratedMediaReference, f64)> = None; + + for reference in references.iter().filter(|reference| { + specification.media_scope.includes_reference(reference.kind) && reference.kind == candidate.kind + }) { + let title_score = normalized_levenshtein(&candidate.normalized_title, &reference.normalized_title); + + if title_score >= threshold { + let year_bonus = calculate_year_bonus(candidate.year, reference.year); + let combined_score = (title_score + year_bonus).clamp(0.0, 1.0); + + if combined_score >= threshold { + if best_match.is_none_or(|(_, current_best_score)| combined_score > current_best_score) { + best_match = Some((reference, combined_score)); + } + if combined_score >= 0.99 { + break; + } + } + } + } + + if let Some((reference, combined_score)) = best_match { + trace!( + "Fuzzy curation match: '{}' -> '{}' (final: {combined_score:.3})", + candidate.item.header.title, + reference.title + ); + return Some(MatchResult { playlist_item: candidate.item, reference }); + } + + None +} + +fn find_best_match_for_item<'playlist, 'reference>( + candidate: &PlaylistCandidate<'playlist>, + references: &'reference [CuratedMediaReference], + specification: &CurationCategorySpec<'_>, +) -> Option> { + if let Some(playlist_tmdb_id) = candidate.tmdb_id { + for reference in references.iter().filter(|reference| { + specification.media_scope.includes_reference(reference.kind) && reference.kind == candidate.kind + }) { + 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 }); + } + } + } + + let threshold = specification.match_policy.fuzzy_threshold()?; + find_best_fuzzy_match_for_item(candidate, references, specification, threshold) +} + +pub(crate) fn curate_category( + references: &[CuratedMediaReference], + playlist: &[PlaylistGroup], + specification: &CurationCategorySpec<'_>, +) -> Vec { + let reference_count = + references.iter().filter(|reference| specification.media_scope.includes_reference(reference.kind)).count(); + debug!( + "Matching {reference_count} curated media references against playlist for media scope {:?}", + specification.media_scope + ); + + let mut matches = Vec::new(); + for playlist_group in playlist { + for channel in &playlist_group.channels { + if specification.media_scope.includes_cluster(channel.header.xtream_cluster) + && specification.media_scope.includes_playlist_item(channel.header.item_type) + { + let Some(kind) = CurationMediaKind::from_playlist_item_type(channel.header.item_type) else { + continue; + }; + let candidate = PlaylistCandidate { + item: channel, + kind, + normalized_title: normalize_title_for_matching(&channel.header.title), + 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) { + matches.push(matched); + } + } + } + } + + let series_children = series_children_by_parent_code(playlist); + create_category_from_matches(matches, specification, &series_children) +} + +fn create_category_from_matches( + mut matches: Vec>, + specification: &CurationCategorySpec<'_>, + series_children_by_parent_code: &HashMap, Vec<&PlaylistItem>>, +) -> Vec { + if matches.is_empty() { + return Vec::new(); + } + + matches.sort_by(|left, right| { + (left.reference.rank.unwrap_or(9999), left.reference.title.to_lowercase()) + .cmp(&(right.reference.rank.unwrap_or(9999), right.reference.title.to_lowercase())) + }); + + let group_title = specification.name.intern(); + let mut matched_items_by_cluster: IndexMap> = IndexMap::new(); + + for matched in matches { + let projected_item = clone_item_for_category(matched.playlist_item, specification, &group_title); + 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 cluster = projected_item.header.xtream_cluster; + matched_items_by_cluster.entry(cluster).or_default().push(projected_item); + + if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) { + for child in children { + let mut projected_child = clone_item_for_category(child, specification, &group_title); + projected_child.header.parent_code = parent_uuid.clone(); + matched_items_by_cluster + .entry(projected_child.header.xtream_cluster) + .or_default() + .push(projected_child); + } + } + } + + matched_items_by_cluster + .into_iter() + .map(|(cluster, channels)| PlaylistGroup { + id: 0, + title: group_title.clone(), + channels, + xtream_cluster: cluster, + }) + .collect() +} + +fn clone_item_for_category( + item: &PlaylistItem, + specification: &CurationCategorySpec<'_>, + group_title: &Arc, +) -> PlaylistItem { + let mut projected_item = item.clone(); + let source_uuid = if projected_item.header.uuid == UUIDType::default() { + projected_item.get_uuid() + } else { + projected_item.header.uuid + }; + + let header = &mut projected_item.header; + let title = header.get(HeaderField::Caption).map_or_else(|| Arc::clone(&header.title), |value| value.to_arc()); + if extract_quality(&title).is_none() { + if let Some(quality) = extract_quality(&header.group) { + let mut caption = String::with_capacity(title.len() + 6); + caption.push('['); + caption.push_str(quality); + caption.push_str("] "); + caption.push_str(&title); + header.set(HeaderField::Caption, &caption); + } + } + header.group = group_title.clone(); + header.uuid = specification.projection_identity.projected_uuid(specification.name, source_uuid); + + projected_item +} + +fn extract_quality(value: &str) -> Option<&str> { + CONSTANTS.re_quality.captures(value).and_then(|captures| captures.get(0)).map(|value| value.as_str()) +} + +fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec> { + match series_info.header.item_type { + PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()], + PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()], + _ => Vec::new(), + } +} + +fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap, Vec<&PlaylistItem>> { + let mut children = HashMap::, Vec<&PlaylistItem>>::new(); + for playlist_group in playlist { + for channel in &playlist_group.channels { + if channel.header.item_type.is_series() && !channel.header.parent_code.is_empty() { + children.entry(channel.header.parent_code.clone()).or_default().push(channel); + } + } + } + children +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{ + EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, VideoStreamProperties, + VirtualId, + }; + + #[test] + fn title_normalization_and_year_extraction_preserve_existing_rules() { + assert_eq!(normalize_title_for_matching("The Matrix"), "thematrix"); + assert_eq!(normalize_title_for_matching("Spider-Man: No Way Home"), "spidermannowayhome"); + assert_eq!(normalize_title_for_matching("Élite"), "elite"); + assert_eq!(normalize_title_for_matching("The Matrix (1999)"), "thematrix"); + assert_eq!(extract_year_from_title("The Matrix (1999)"), Some(1999)); + assert_eq!(extract_year_from_title("Avengers Endgame 2019"), Some(2019)); + assert_eq!(extract_year_from_title("Just a Title"), None); + } + + #[test] + fn exact_tmdb_match_precedes_a_perfect_fuzzy_title_match() { + let playlist_item = video_item("Same Title", Some(222)); + let references = vec![ + reference(CurationMediaKind::Movie, "Same Title", None, Some(111), Some(1)), + reference(CurationMediaKind::Movie, "Different Title", None, Some(222), Some(2)), + ]; + let candidate = candidate(&playlist_item); + + let matched = find_best_match_for_item(&candidate, &references, &specification("Featured", false)) + .expect("TMDB identity should take precedence"); + + assert_eq!(matched.reference.tmdb_id, Some(222)); + assert_eq!(matched.reference.title, "Different Title"); + } + + #[test] + fn exact_only_policy_forbids_fuzzy_fallback_but_keeps_tmdb_matches() { + 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); + + 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] + fn fuzzy_matching_preserves_year_bonus_and_penalty() { + let playlist_item = video_item("The Matrix 1999", None); + 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); + + 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] + fn fuzzy_matching_keeps_the_first_perfect_reference() { + let playlist_item = video_item("The Matrix", None); + let references = vec![ + reference(CurationMediaKind::Movie, "The Matrix", None, Some(111), Some(1)), + 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"); + + assert_eq!(matched.reference.tmdb_id, Some(111)); + } + + #[test] + fn fuzzy_threshold_and_best_match_selection_remain_percentage_based() { + let playlist_item = video_item("matrix", None); + let references = vec![ + reference(CurationMediaKind::Movie, "matri", None, Some(111), Some(1)), + 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"); + + assert_eq!(matched.reference.tmdb_id, Some(222)); + assert!(find_best_match_for_item( + &candidate(&playlist_item), + &references, + &fuzzy_specification("Featured", 90), + ) + .is_none()); + } + + #[test] + fn projected_items_are_ordered_by_rank_then_lowercase_reference_title() { + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![video_item("Gamma", Some(3)), video_item("zebra", Some(2)), video_item("Alpha", Some(1))], + xtream_cluster: XtreamCluster::Video, + }]; + let references = vec![ + reference(CurationMediaKind::Movie, "Gamma", None, Some(3), Some(2)), + reference(CurationMediaKind::Movie, "zebra", None, Some(2), Some(1)), + reference(CurationMediaKind::Movie, "Alpha", None, Some(1), Some(1)), + ]; + + let categories = curate_category(&references, &playlist, &specification("Ranked", true)); + let titles = categories[0].channels.iter().map(|item| item.header.title.as_ref()).collect::>(); + + assert_eq!(titles, ["Alpha", "zebra", "Gamma"]); + } + + #[test] + fn projected_categories_remain_grouped_by_playlist_cluster() { + let playlist = vec![ + PlaylistGroup { + id: 1, + title: "Movies".intern(), + channels: vec![video_item("Movie", Some(1))], + xtream_cluster: XtreamCluster::Video, + }, + PlaylistGroup { + id: 2, + title: "Series".intern(), + channels: vec![series_item("Show", Some(2))], + xtream_cluster: XtreamCluster::Series, + }, + ]; + let references = vec![ + 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 categories = curate_category(&references, &playlist, &specification); + + assert_eq!(categories.len(), 2); + assert_eq!(categories[0].xtream_cluster, XtreamCluster::Video); + assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie"); + assert_eq!(categories[1].xtream_cluster, XtreamCluster::Series); + assert_eq!(categories[1].channels[0].header.title.as_ref(), "Show"); + } + + #[test] + fn both_scope_matches_references_only_to_the_same_media_kind() { + let movie = video_item("Shared Title", Some(42)); + let series = series_item("Shared Title", Some(42)); + let exact_references = vec![ + reference(CurationMediaKind::Series, "Series Reference", None, Some(42), Some(1)), + reference(CurationMediaKind::Movie, "Movie Reference", None, Some(42), Some(2)), + ]; + let exact_specification = + CurationCategorySpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true) }; + + 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) + .expect("series candidate should match the series reference"); + + assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie); + assert_eq!(series_match.reference.kind, CurationMediaKind::Series); + + let fuzzy_references = vec![ + 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 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) + .expect("series candidate should fuzzy-match the series reference"); + + assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie); + assert_eq!(series_match.reference.kind, CurationMediaKind::Series); + } + + #[test] + fn movie_scope_does_not_match_series_roots_and_series_scope_ignores_episodes() { + let series = series_item("Shared Title", Some(42)); + let episode = episode_item("Shared Title", &"parent".intern(), 7001); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Series".intern(), + channels: vec![series, episode], + xtream_cluster: XtreamCluster::Series, + }]; + let movie_references = vec![reference(CurationMediaKind::Movie, "Shared Title", None, Some(42), Some(1))]; + let series_references = vec![reference(CurationMediaKind::Series, "Shared Title", None, None, Some(1))]; + + assert!(curate_category(&movie_references, &playlist, &specification("Movies", true)).is_empty()); + assert!(curate_category( + &series_references, + &[PlaylistGroup { + id: 1, + title: "Episodes".intern(), + channels: vec![playlist[0].channels[1].clone()], + xtream_cluster: XtreamCluster::Series, + }], + &series_specification("Series", false), + ) + .is_empty()); + } + + #[test] + fn projection_propagates_group_quality_only_when_caption_has_none() { + let mut without_quality = video_item("Clean Title", Some(1)); + without_quality.header.group = "Provider UHD".intern(); + let mut with_quality = video_item("Already 4K", Some(2)); + with_quality.header.group = "Provider UHD".intern(); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![without_quality, with_quality], + xtream_cluster: XtreamCluster::Video, + }]; + let references = vec![ + reference(CurationMediaKind::Movie, "Clean Title", None, Some(1), Some(1)), + reference(CurationMediaKind::Movie, "Already 4K", None, Some(2), Some(2)), + ]; + + let categories = curate_category(&references, &playlist, &specification("Featured", true)); + let clean = &categories[0].channels[0]; + let already_tagged = &categories[0].channels[1]; + + assert_eq!(clean.header.get(HeaderField::Caption).expect("quality caption").as_cow(), "[UHD] Clean Title"); + assert_eq!(already_tagged.header.get(HeaderField::Caption).expect("existing caption").as_cow(), "Already 4K"); + } + + #[test] + fn series_children_are_cloned_and_reparented_to_the_projected_root() { + let mut series = series_item("Slow Horses", Some(12345)); + series.header.uuid = hash_string("series-source-item"); + let source_parent_code = series.header.uuid.intern(); + let mut episode = episode_item("Old Scores", &source_parent_code, 7001); + episode.header.uuid = hash_string("episode-source-item"); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Series".intern(), + channels: vec![series, episode], + xtream_cluster: XtreamCluster::Series, + }]; + let references = vec![reference(CurationMediaKind::Series, "Slow Horses", Some(2022), Some(12345), Some(1))]; + + let categories = curate_category(&references, &playlist, &series_specification("Trending", true)); + let cloned_series = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::SeriesInfo) + .expect("series info clone"); + let cloned_episode = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::Series) + .expect("episode clone"); + + assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern()); + assert_ne!(cloned_episode.header.uuid, playlist[0].channels[1].header.uuid); + } + + fn specification(category_name: &str, exact_only: bool) -> CurationCategorySpec<'_> { + if exact_only { + CurationCategorySpec { + name: category_name, + media_scope: CurationMediaScope::Movies, + match_policy: CurationMatchPolicy::ExactTmdbOnly, + projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" }, + } + } else { + fuzzy_specification(category_name, 100) + } + } + + fn fuzzy_specification(category_name: &str, threshold_percent: u8) -> CurationCategorySpec<'_> { + CurationCategorySpec { + name: category_name, + 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) } + } + + fn candidate(item: &PlaylistItem) -> PlaylistCandidate<'_> { + PlaylistCandidate { + item, + kind: CurationMediaKind::from_playlist_item_type(item.header.item_type) + .expect("test candidate must be a movie or series root"), + normalized_title: normalize_title_for_matching(&item.header.title), + year: extract_year_from_title(&item.header.title), + tmdb_id: item.get_tmdb_id(), + } + } + + fn reference( + kind: CurationMediaKind, + title: &str, + year: Option, + tmdb_id: Option, + rank: Option, + ) -> CuratedMediaReference { + CuratedMediaReference::new(kind, title.to_string(), year, tmdb_id, rank) + } + + fn video_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + title: title.intern(), + xtream_cluster: XtreamCluster::Video, + item_type: PlaylistItemType::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + name: title.intern(), + tmdb, + ..VideoStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn series_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + id: format!("series-{title}").intern(), + input_name: "input".intern(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://unavailable/server/shows/{title}").intern(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::SeriesInfo, + additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { + name: title.intern(), + tmdb, + ..SeriesStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn episode_item(title: &str, parent_code: &Arc, virtual_id: u32) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + uuid: hash_string(&format!("episode:{title}:{virtual_id}")), + id: format!("episode-{virtual_id}").intern(), + input_name: "input".intern(), + parent_code: parent_code.clone(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), + virtual_id: VirtualId::new(virtual_id), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::Series, + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { + episode_id: virtual_id, + episode: 1, + season: 1, + added: None, + release_date: None, + series_release_date: None, + tmdb: None, + movie_image: "".intern(), + container_extension: "mkv".intern(), + video: None, + audio: None, + plot: None, + }))), + ..PlaylistItemHeader::default() + }, + } + } +} diff --git a/backend/curation/src/lib.rs b/backend/curation/src/lib.rs new file mode 100644 index 000000000..e81a1d29d --- /dev/null +++ b/backend/curation/src/lib.rs @@ -0,0 +1,14 @@ +//! Playlist curation capability. +//! +//! This crate owns the trusted, source-neutral matching and virtual-category +//! projection kernel, plus the concrete edge adapter that translates foreign +//! source data before invoking that kernel. Trakt is currently the only adapter. +//! +//! Serialized configuration remains in `shared`, resolved configuration remains +//! in `tuliprox-core`, and target-stage orchestration and persistence remain in +//! their existing processing and repository crates. + +mod kernel; +mod trakt; + +pub use trakt::curate_trakt_categories; diff --git a/backend/core/src/utils/trakt/client.rs b/backend/curation/src/trakt/client.rs similarity index 89% rename from backend/core/src/utils/trakt/client.rs rename to backend/curation/src/trakt/client.rs index e1d9a78db..eb1ae316d 100644 --- a/backend/core/src/utils/trakt/client.rs +++ b/backend/curation/src/trakt/client.rs @@ -1,22 +1,28 @@ -use super::errors::handle_trakt_api_error; -use crate::model::{TraktApiConfig, TraktChartConfig, TraktListConfig, TraktListItem, TraktMovie, TraktShow}; +use super::{ + errors::handle_trakt_api_error, + model::{TraktListItem, TraktMovie, TraktShow, TraktTrendingMovieItem, TraktTrendingShowItem}, +}; use log::{debug, info}; use reqwest::header::{HeaderMap, HeaderValue}; -use serde::Deserialize; -use shared::{defaults::DEFAULT_USER_AGENT, error::TuliproxError, utils::trim_last_slash}; +use shared::{ + defaults::DEFAULT_USER_AGENT, + error::TuliproxError, + model::{TraktChartKind, TraktChartType}, + utils::trim_last_slash, +}; +use tuliprox_core::model::{TraktApiConfig, TraktChartConfig, TraktListConfig}; const TRAKT_PAGE_LIMIT: u32 = 100; const TRAKT_MAX_PAGES: u32 = 100; -pub struct TraktClient { +pub(super) struct TraktClient { client: reqwest::Client, api_config: TraktApiConfig, - // Pre-computed headers to avoid recreating them each time headers: HeaderMap, } impl TraktClient { - pub fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result { + pub(super) fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result { api_config.api_key = api_config.api_key.trim().to_string(); let headers = Self::create_headers(&api_config)?; Ok(Self { client, api_config, headers }) @@ -59,7 +65,10 @@ impl TraktClient { format!("{}/{}/{}", trim_last_slash(&self.api_config.url), chart_config.kind, chart_config.chart) } - pub async fn get_chart_items(&self, chart_config: &TraktChartConfig) -> Result, TuliproxError> { + pub(super) async fn get_chart_items( + &self, + chart_config: &TraktChartConfig, + ) -> Result, TuliproxError> { let id_label = format!("{}:{}", chart_config.kind, chart_config.chart); self.paginate_items( "chart", @@ -69,15 +78,15 @@ impl TraktClient { .await } - pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result, TuliproxError> { + pub(super) async fn get_list_items( + &self, + list_config: &TraktListConfig, + ) -> Result, TuliproxError> { 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 } - /// Shared body of `get_chart_items` and `get_list_items`. - /// Walks Trakt's paginated response one page at a time via `fetch_page`, - /// logging per-page progress with `kind_label` and `id_label` for context. async fn paginate_items( &self, kind_label: &'static str, @@ -129,11 +138,9 @@ impl TraktClient { 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 mut items: Vec = - serde_json::from_str(&response_text).map_err(|error: serde_json::Error| { - TuliproxError::Config(format!("Failed to parse Trakt response: {error}")) - })?; - items.iter_mut().for_each(TraktListItem::prepare); + let items: Vec = serde_json::from_str(&response_text).map_err(|error: serde_json::Error| { + TuliproxError::Config(format!("Failed to parse Trakt response: {error}")) + })?; Ok(TraktListItemsPage { items, page_count, item_count }) } @@ -154,9 +161,6 @@ impl TraktClient { Ok(TraktListItemsPage { items, page_count, item_count }) } - /// Shared body of `get_list_items_page` / `get_chart_items_page`. - /// Issues the GET, validates the status, parses the pagination headers, and - /// returns the raw response body. Per-type item parsing stays with the caller. async fn fetch_trakt_page( &self, request_url: String, @@ -195,7 +199,7 @@ fn parse_chart_items( ) -> Result, serde_json::Error> { let rank_base = page.saturating_sub(1).saturating_mul(TRAKT_PAGE_LIMIT); match (chart_config.kind, chart_config.chart) { - (shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Popular) => { + (TraktChartKind::Movies, TraktChartType::Popular) => { let items = serde_json::from_str::>(response_text)?; Ok(items .into_iter() @@ -203,7 +207,7 @@ fn parse_chart_items( .map(|(index, movie)| TraktListItem::from_movie_chart(movie, chart_rank(rank_base, index))) .collect()) } - (shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Trending) => { + (TraktChartKind::Movies, TraktChartType::Trending) => { let items = serde_json::from_str::>(response_text)?; Ok(items .into_iter() @@ -211,7 +215,7 @@ fn parse_chart_items( .map(|(index, item)| TraktListItem::from_movie_chart(item.movie, chart_rank(rank_base, index))) .collect()) } - (shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Popular) => { + (TraktChartKind::Shows, TraktChartType::Popular) => { let items = serde_json::from_str::>(response_text)?; Ok(items .into_iter() @@ -219,7 +223,7 @@ fn parse_chart_items( .map(|(index, show)| TraktListItem::from_show_chart(show, chart_rank(rank_base, index))) .collect()) } - (shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Trending) => { + (TraktChartKind::Shows, TraktChartType::Trending) => { let items = serde_json::from_str::>(response_text)?; Ok(items .into_iter() @@ -234,16 +238,6 @@ fn chart_rank(rank_base: u32, index: usize) -> u32 { rank_base.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)).saturating_add(1) } -#[derive(Deserialize)] -struct TraktTrendingMovieItem { - movie: TraktMovie, -} - -#[derive(Deserialize)] -struct TraktTrendingShowItem { - show: TraktShow, -} - fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Option { headers.get(name).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::().ok()) } @@ -252,7 +246,7 @@ fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Opt mod tests { use super::*; use reqwest::StatusCode; - use shared::model::{TraktChartKind, TraktChartType, TraktContentType}; + use shared::model::TraktContentType; use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, Mutex, @@ -343,8 +337,7 @@ mod tests { let items = client.get_list_items(&list_config).await.expect("paged list should load"); assert_eq!(items.len(), 2); - assert_eq!(items[0].content_type, TraktContentType::Vod); - assert_eq!(items[1].content_type, TraktContentType::Vod); + assert!(items.iter().all(|item| item.item_type == "movie")); assert_eq!(requests.load(Ordering::SeqCst), 2); } @@ -363,7 +356,6 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].rank, Some(1)); - assert_eq!(items[0].content_type, TraktContentType::Vod); assert_eq!(items[0].movie.as_ref().expect("movie").ids.tmdb, Some(11)); assert!(requests.lock().expect("requests")[0].contains("GET /movies/trending?page=1&limit=100 ")); } @@ -383,7 +375,6 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].rank, Some(1)); - assert_eq!(items[0].content_type, TraktContentType::Series); assert_eq!(items[0].show.as_ref().expect("show").ids.tmdb, Some(22)); assert!(requests.lock().expect("requests")[0].contains("GET /shows/popular?page=1&limit=100 ")); } diff --git a/backend/core/src/utils/trakt/errors.rs b/backend/curation/src/trakt/errors.rs similarity index 93% rename from backend/core/src/utils/trakt/errors.rs rename to backend/curation/src/trakt/errors.rs index c76b1e863..e69c52352 100644 --- a/backend/core/src/utils/trakt/errors.rs +++ b/backend/curation/src/trakt/errors.rs @@ -1,8 +1,11 @@ use reqwest::StatusCode; use shared::error::TuliproxError; -/// Handle Trakt API response status and convert to appropriate error -pub fn handle_trakt_api_error(status: StatusCode, resource_kind: &str, resource_id: &str) -> Result<(), TuliproxError> { +pub(super) fn handle_trakt_api_error( + status: StatusCode, + resource_kind: &str, + resource_id: &str, +) -> Result<(), TuliproxError> { match status.as_u16() { 401 => Err(TuliproxError::RepositoryTrakt( "Trakt rejected the configured Client ID (HTTP 401 Unauthorized); check trakt.api.api_key", diff --git a/backend/curation/src/trakt/mod.rs b/backend/curation/src/trakt/mod.rs new file mode 100644 index 000000000..c74a86b8d --- /dev/null +++ b/backend/curation/src/trakt/mod.rs @@ -0,0 +1,545 @@ +mod client; +mod errors; +mod model; + +use crate::kernel::{ + curate_category, CuratedMediaReference, CurationCategorySpec, CurationMatchPolicy, CurationMediaScope, + ProjectionIdentityStrategy, +}; +use client::TraktClient; +use log::{debug, info, warn}; +use model::TraktListItem; +use shared::model::{PlaylistGroup, TraktContentType}; +use tuliprox_core::model::{TraktChartConfig, TraktConfig, TraktListConfig}; + +// Compatibility policy for the current Xtream projection. This namespace is +// 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. +/// +/// 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( + http_client: &reqwest::Client, + playlist: &[PlaylistGroup], + target_name: &str, + trakt_config: &TraktConfig, +) -> Option> { + if !trakt_config.enabled { + return None; + } + if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() { + debug!("No Trakt lists or charts configured for target {target_name}"); + return None; + } + + 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; + } + }; + + Some(processor.process(playlist, target_name, trakt_config).await) +} + +struct TraktCategoriesProcessor { + client: TraktClient, +} + +impl TraktCategoriesProcessor { + fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Result { + let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?; + Ok(Self { client }) + } + + async fn process( + &self, + playlist: &[PlaylistGroup], + target_name: &str, + trakt_config: &TraktConfig, + ) -> Vec { + info!( + "Processing {} Trakt lists and {} Trakt charts for target {target_name}", + trakt_config.lists.len(), + trakt_config.charts.len() + ); + let mut new_categories = Vec::new(); + let mut total_matches = 0; + + for list_config in &trakt_config.lists { + let source_label = format!("{}:{}", list_config.user, list_config.list_slug); + let specification = list_category_spec(list_config); + + match self.client.get_list_items(list_config).await { + Ok(items) => { + debug!("Processing Trakt list {source_label} with {} items", items.len()); + let references = translate_items(items); + append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches); + } + Err(error) => warn!("Failed to fetch Trakt list {source_label}: {}", error.message()), + } + } + + for chart_config in &trakt_config.charts { + let source_label = format!("{}:{}", chart_config.kind, chart_config.chart); + let specification = chart_category_spec(chart_config); + + match self.client.get_chart_items(chart_config).await { + Ok(items) => { + debug!("Processing Trakt chart {source_label} with {} items", items.len()); + let references = translate_items(items); + append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches); + } + Err(error) => warn!("Failed to fetch Trakt chart {source_label}: {}", error.message()), + } + } + + info!( + "Trakt processing complete: created {} categories with {total_matches} total matches", + new_categories.len() + ); + new_categories + } +} + +fn append_categories( + references: &[CuratedMediaReference], + playlist: &[PlaylistGroup], + specification: &CurationCategorySpec<'_>, + categories: &mut Vec, + total_matches: &mut usize, +) { + for category in curate_category(references, playlist, specification) { + if !category.channels.is_empty() { + *total_matches += category.channels.len(); + let category_len = category.channels.len(); + categories.push(category); + debug!("Created Trakt category '{}' with {category_len} items", specification.name); + } + } +} + +fn translate_items(items: Vec) -> Vec { + items.into_iter().filter_map(TraktListItem::into_curated_reference).collect() +} + +fn list_category_spec(config: &TraktListConfig) -> CurationCategorySpec<'_> { + category_spec(&config.category_name, 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) +} + +fn category_spec( + category_name: &str, + content_type: TraktContentType, + tmdb_only: bool, + fuzzy_match_threshold: u8, +) -> CurationCategorySpec<'_> { + let media_scope = match content_type { + TraktContentType::Vod => CurationMediaScope::Movies, + TraktContentType::Series => CurationMediaScope::Series, + TraktContentType::Both => CurationMediaScope::Both, + }; + let match_policy = if tmdb_only { + CurationMatchPolicy::ExactTmdbOnly + } else { + CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent: fuzzy_match_threshold } + }; + CurationCategorySpec { + name: category_name, + media_scope, + match_policy, + projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { + namespace: LEGACY_TRAKT_CATEGORY_NAMESPACE, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::trakt::model::{TraktIds, TraktMovie, TraktShow}; + use shared::{ + model::{ + EpisodeStreamProperties, FieldGet, HeaderField, PlaylistItem, PlaylistItemHeader, PlaylistItemType, + SeriesStreamProperties, StreamProperties, TraktChartKind, TraktChartType, VideoStreamProperties, VirtualId, + XtreamCluster, + }, + utils::{hash_string, Internable}, + }; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + task::JoinHandle, + }; + use tuliprox_core::model::TraktApiConfig; + + #[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()); + + let result = curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &config).await; + + assert!(result.is_none()); + assert_eq!(requests.load(Ordering::SeqCst), 0); + server.abort(); + } + } + + #[tokio::test] + async fn disabled_or_source_less_configuration_makes_no_request() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + 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!(requests.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn failed_list_does_not_suppress_successful_chart() { + let requests = Arc::new(Mutex::new(Vec::new())); + let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await; + let config = trakt_config( + "test-client-id", + base_url, + true, + vec![remote_list_config("Unavailable List")], + vec![remote_chart_config("Available Chart")], + ); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![video_item("Movie 1", Some(11))], + xtream_cluster: XtreamCluster::Video, + }]; + + let categories = curate_trakt_categories(&reqwest::Client::new(), &playlist, "test-target", &config) + .await + .expect("configured sources should produce a result"); + + 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); + server.await.expect("test server should finish"); + } + + #[test] + fn raw_records_are_translated_before_matching() { + let references = translate_items(vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 7)]); + + assert_eq!(references.len(), 1); + assert_eq!(references[0].kind, crate::kernel::CurationMediaKind::Movie); + assert_eq!(references[0].title, "The Smashing Machine"); + assert_eq!(references[0].year, Some(2025)); + assert_eq!(references[0].tmdb_id, Some(760_329)); + assert_eq!(references[0].rank, Some(7)); + } + + #[test] + fn legacy_trakt_projection_identity_is_exact_and_category_scoped() { + let mut source_item = video_item("The Smashing Machine", Some(760_329)); + source_item.header.uuid = hash_string("curation-source-item"); + assert_eq!( + source_item.header.uuid.to_string(), + "e2f49417a9bc5e05942ee77996a18ce27d2445d27a331cfd3417f11448b886e1" + ); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![source_item], + xtream_cluster: XtreamCluster::Video, + }]; + let references = translate_items(vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 1)]); + 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]; + + assert_eq!(featured_item.header.group.as_ref(), "Featured"); + assert_eq!(renoir_item.header.group.as_ref(), "Renoir"); + assert_eq!( + featured_item.header.uuid.to_string(), + "1a3fda78972a6e368ac159094c5b4d5b722630bdc7021530624ff0971c45092b" + ); + assert_ne!(featured_item.header.uuid, renoir_item.header.uuid); + } + + #[test] + fn legacy_trakt_series_projection_preserves_exact_child_identity_and_parent_linkage() { + let mut series = series_item("Slow Horses", Some(12345)); + series.header.uuid = hash_string("series-source-item"); + let source_parent_code = series.header.uuid.intern(); + let mut episode = episode_item("Old Scores", &source_parent_code, 7001); + episode.header.uuid = hash_string("episode-source-item"); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Series".intern(), + channels: vec![series, episode], + xtream_cluster: XtreamCluster::Series, + }]; + 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 cloned_series = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::SeriesInfo) + .expect("series info clone"); + let cloned_episode = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::Series) + .expect("episode clone"); + + assert_eq!( + cloned_series.header.uuid.to_string(), + "57431046882ed9f79d64a5ffdb311231389216e85f0704911104d7926def2cb3" + ); + assert_eq!( + cloned_episode.header.uuid.to_string(), + "12557067723f9dd4b74b3a845d20af2e8be18bdd6a49bf9095fb1be70c7a8cf1" + ); + assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern()); + } + + #[test] + fn quality_caption_behavior_survives_trakt_translation() { + let mut item = video_item("Clean Title", Some(1)); + item.header.group = "Provider UHD".intern(); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![item], + xtream_cluster: XtreamCluster::Video, + }]; + 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"))); + + assert_eq!( + categories[0].channels[0].header.get(HeaderField::Caption).expect("quality caption").as_cow(), + "[UHD] Clean Title" + ); + } + + async fn spawn_counting_trakt_server(requests: Arc) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let _ = read_request(&mut stream).await; + requests.fetch_add(1, Ordering::SeqCst); + write_response(&mut stream, "200 OK", "[]").await; + } + }); + (format!("http://{addr}"), server) + } + + async fn spawn_partial_success_trakt_server(requests: Arc>>) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept test request"); + let request = read_request(&mut stream).await; + let is_list_request = request.contains("/users/test-user/lists/test-list/items"); + requests.lock().expect("requests").push(request); + if is_list_request { + write_response(&mut stream, "403 Forbidden", "response body must not affect the next source").await; + } else { + write_response( + &mut stream, + "200 OK", + r#"[{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}]"#, + ) + .await; + } + } + }); + (format!("http://{addr}"), server) + } + + async fn read_request(stream: &mut TcpStream) -> String { + let mut request_bytes = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let read = stream.read(&mut buffer).await.expect("read request"); + if read == 0 { + break; + } + request_bytes.extend_from_slice(&buffer[..read]); + if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + String::from_utf8_lossy(&request_bytes).to_string() + } + + async fn write_response(stream: &mut TcpStream, status: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.expect("write response"); + } + + fn trakt_config( + client_id: &str, + url: String, + enabled: bool, + lists: Vec, + charts: Vec, + ) -> TraktConfig { + TraktConfig { + enabled, + api: TraktApiConfig { + api_key: client_id.to_string(), + version: "2".to_string(), + url, + user_agent: "tuliprox-test".to_string(), + }, + lists, + charts, + } + } + + fn remote_list_config(category_name: &str) -> TraktListConfig { + TraktListConfig { + user: "test-user".to_string(), + list_slug: "test-list".to_string(), + category_name: category_name.to_string(), + content_type: TraktContentType::Vod, + tmdb_only: true, + fuzzy_match_threshold: 100, + } + } + + fn remote_chart_config(category_name: &str) -> TraktChartConfig { + TraktChartConfig { + kind: TraktChartKind::Movies, + chart: TraktChartType::Popular, + category_name: category_name.to_string(), + tmdb_only: true, + fuzzy_match_threshold: 100, + } + } + + fn video_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + title: title.intern(), + xtream_cluster: XtreamCluster::Video, + item_type: PlaylistItemType::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + name: title.intern(), + tmdb, + ..VideoStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn series_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + id: format!("series-{title}").intern(), + input_name: "input".intern(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://unavailable/server/shows/{title}").intern(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::SeriesInfo, + additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { + name: title.intern(), + tmdb, + ..SeriesStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn episode_item(title: &str, parent_code: &Arc, virtual_id: u32) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + uuid: hash_string(&format!("episode:{title}:{virtual_id}")), + id: format!("episode-{virtual_id}").intern(), + input_name: "input".intern(), + parent_code: parent_code.clone(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), + virtual_id: VirtualId::new(virtual_id), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::Series, + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { + episode_id: virtual_id, + episode: 1, + season: 1, + added: None, + release_date: None, + series_release_date: None, + tmdb: None, + movie_image: "".intern(), + container_extension: "mkv".intern(), + video: None, + audio: None, + plot: None, + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn trakt_list_movie(title: &str, year: Option, tmdb_id: Option, rank: u32) -> TraktListItem { + TraktListItem { + id: u64::from(rank), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "movie".to_string(), + movie: Some(TraktMovie { ids: trakt_ids(title, tmdb_id, rank), title: title.to_string(), year }), + show: None, + } + } + + fn trakt_list_show(title: &str, year: Option, tmdb_id: Option, rank: u32) -> TraktListItem { + TraktListItem { + id: u64::from(rank), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "show".to_string(), + movie: None, + show: Some(TraktShow { ids: trakt_ids(title, tmdb_id, rank), title: title.to_string(), year }), + } + } + + fn trakt_ids(title: &str, tmdb_id: Option, trakt_id: u32) -> TraktIds { + TraktIds { trakt: trakt_id, slug: title.to_string(), tvdb: None, imdb: None, tmdb: tmdb_id, tvrage: None } + } +} diff --git a/backend/curation/src/trakt/model.rs b/backend/curation/src/trakt/model.rs new file mode 100644 index 000000000..542992dc6 --- /dev/null +++ b/backend/curation/src/trakt/model.rs @@ -0,0 +1,91 @@ +use crate::kernel::{CuratedMediaReference, CurationMediaKind}; +use serde::{Deserialize, Serialize}; +use shared::utils::is_blank_optional_string; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct TraktListItem { + pub(super) id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) rank: Option, + pub(super) listed_at: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub(super) notes: Option, + #[serde(rename = "type")] + pub(super) item_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) movie: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) show: Option, +} + +impl TraktListItem { + pub(super) fn from_movie_chart(movie: TraktMovie, rank: u32) -> Self { + Self { + id: u64::from(movie.ids.trakt), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "movie".to_string(), + movie: Some(movie), + show: None, + } + } + + pub(super) fn from_show_chart(show: TraktShow, rank: u32) -> Self { + Self { + id: u64::from(show.ids.trakt), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "show".to_string(), + movie: None, + show: Some(show), + } + } + + pub(super) fn into_curated_reference(self) -> Option { + match self.item_type.as_str() { + "movie" => self.movie.map(|movie| { + CuratedMediaReference::new(CurationMediaKind::Movie, movie.title, movie.year, movie.ids.tmdb, self.rank) + }), + "show" => self.show.map(|show| { + CuratedMediaReference::new(CurationMediaKind::Series, show.title, show.year, show.ids.tmdb, self.rank) + }), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct TraktMovie { + pub(super) ids: TraktIds, + pub(super) title: String, + pub(super) year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct TraktShow { + pub(super) ids: TraktIds, + pub(super) title: String, + pub(super) year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct TraktIds { + pub(super) trakt: u32, + pub(super) slug: String, + pub(super) tvdb: Option, + pub(super) imdb: Option, + pub(super) tmdb: Option, + pub(super) tvrage: Option, +} + +#[derive(Deserialize)] +pub(super) struct TraktTrendingMovieItem { + pub(super) movie: TraktMovie, +} + +#[derive(Deserialize)] +pub(super) struct TraktTrendingShowItem { + pub(super) show: TraktShow, +} diff --git a/backend/processing/Cargo.toml b/backend/processing/Cargo.toml index 7b2cd98b2..8a19ab37a 100644 --- a/backend/processing/Cargo.toml +++ b/backend/processing/Cargo.toml @@ -16,6 +16,7 @@ test-support = [] [dependencies] shared = { workspace = true } tuliprox-core = { workspace = true } +tuliprox-curation = { workspace = true } tuliprox-iptv = { workspace = true } tuliprox-library = { workspace = true } tuliprox-media-server = { workspace = true } diff --git a/backend/processing/src/processor/mod.rs b/backend/processing/src/processor/mod.rs index 0d97f33a5..114f7a975 100644 --- a/backend/processing/src/processor/mod.rs +++ b/backend/processing/src/processor/mod.rs @@ -11,7 +11,6 @@ mod providers; mod resolve_options; mod sort; mod stream_probe; -mod trakt; mod xtream_series; mod xtream_vod; pub use self::{ diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 639d3cf30..079df0181 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -9,7 +9,6 @@ use crate::{ processor::{ epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups}, sort::sort_playlist, - trakt::process_trakt_categories_for_target, xtream_series::playlist_resolve_series, xtream_vod::playlist_resolve_vod, StalkerRefreshMode, @@ -17,7 +16,7 @@ use crate::{ }; use futures::{FutureExt, StreamExt}; use indexmap::IndexMap; -use log::{debug, error, info, log_enabled, warn, Level}; +use log::{debug, error, info, log_enabled, trace, warn, Level}; use path_clean::PathClean; use shared::{ concat_string, @@ -52,6 +51,7 @@ use tuliprox_core::{ }, utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback}, }; +use tuliprox_curation::curate_trakt_categories; use tuliprox_iptv::{ epg::{CountingEpgSink, EpgFetchRequest, EpgProvider}, error::ProviderErrorKind, diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs index ed1bbec10..c96ef0a3a 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -387,7 +387,7 @@ pub(crate) async fn finalize_prepared_target, favourites_cfg: Opt pub(crate) async fn trakt_playlist( client: &reqwest::Client, target: &ConfigTarget, - errors: &mut Vec, playlist: &mut Vec, ) -> bool { - match process_trakt_categories_for_target(client, playlist, target).await { - Ok(Some(trakt_categories)) => { - if !trakt_categories.is_empty() { - info!("Adding {} Trakt categories to playlist", trakt_categories.len()); - playlist.extend(trakt_categories); - } - } - Ok(None) => { - return false; - } - Err(trakt_errors) => { - warn!("Trakt processing failed with {} errors", trakt_errors.len()); - errors.extend(trakt_errors); - } + 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; + }; + 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); } true } diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index 6714f2f79..55f9adaa5 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -1483,6 +1483,17 @@ match { } } +#[tokio::test] +async fn trakt_finalization_stage_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; + + assert!(!applied); + assert!(playlist.is_empty()); +} + #[cfg(test)] mod disk_epg_wireup_tests { use super::spill_epg_to_disk; diff --git a/backend/processing/src/processor/trakt.rs b/backend/processing/src/processor/trakt.rs deleted file mode 100644 index 93f8c5622..000000000 --- a/backend/processing/src/processor/trakt.rs +++ /dev/null @@ -1,895 +0,0 @@ -use indexmap::IndexMap; -use log::{debug, info, trace, warn}; -use shared::{ - error::TuliproxError, - model::{ - FieldGet, FieldSet, HeaderField, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, - TraktContentType, UUIDType, XtreamCluster, - }, - utils::{hash_string, Internable, CONSTANTS}, -}; -use std::{collections::HashMap, sync::Arc}; -use strsim::normalized_levenshtein; -use tuliprox_core::{ - model::{ConfigTarget, TraktCategoryConfig, TraktConfig, TraktListItem, TraktMatchItem, TraktMatchResult}, - utils::{extract_year_from_title, normalize_title_for_matching, trace_if_enabled, with, TraktClient}, -}; - -fn extract_quality(value: &str) -> Option<&str> { - if let Some(caps) = CONSTANTS.re_quality.captures(value) { - if let Some(val) = caps.get(0) { - return Some(val.as_str()); - } - } - None -} - -/// Utility functions for content type compatibility -fn should_include_item(item: &TraktListItem, content_type: TraktContentType) -> bool { - match content_type { - TraktContentType::Vod => item.content_type == TraktContentType::Vod, - TraktContentType::Series => item.content_type == TraktContentType::Series, - TraktContentType::Both => true, - } -} - -fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContentType) -> bool { - match content_type { - TraktContentType::Vod => cluster == XtreamCluster::Video, - TraktContentType::Series => cluster == XtreamCluster::Series, - TraktContentType::Both => matches!(cluster, XtreamCluster::Video | XtreamCluster::Series), - } -} - -fn is_matchable_playlist_item(item_type: PlaylistItemType, content_type: TraktContentType) -> bool { - match content_type { - TraktContentType::Vod => item_type.is_video(), - TraktContentType::Series => { - matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) - } - TraktContentType::Both => matches!( - item_type, - PlaylistItemType::Video - | PlaylistItemType::LocalVideo - | PlaylistItemType::SeriesInfo - | PlaylistItemType::LocalSeriesInfo - ), - } -} - -fn calculate_year_bonus(playlist_year: Option, trakt_year: Option) -> f64 { - if let (Some(p_year), Some(t_year)) = (playlist_year, trakt_year) { - if p_year == t_year { - // Perfect year match gets substantial bonus - return 0.5; - } - return -0.5; - } - 0.0 -} - -fn find_best_fuzzy_match_for_item<'a>( - channel: (&'a PlaylistItem, String, Option, Option), - trakt_items: &'a [TraktMatchItem], - category_config: &'a TraktCategoryConfig, -) -> Option> { - // Try fuzzy matching if no exact match found - let normalized_playlist_title = channel.1; - let playlist_year = channel.2; - let threshold = f64::from(category_config.fuzzy_match_threshold) / 100.0; - let mut best_match: Option<(&TraktMatchItem, f64)> = None; - - for trakt_item in trakt_items { - let title_score = normalized_levenshtein(&normalized_playlist_title, &trakt_item.normalized_title); - - if title_score >= threshold { - // Calculate year bonus - let year_bonus = calculate_year_bonus(playlist_year, trakt_item.year); - let mut combined_score = title_score + year_bonus; - - // Clamp score to [0.0, 1.0] - combined_score = combined_score.clamp(0.0, 1.0); - - // Check if this is the best match so far and meets threshold - if combined_score >= threshold { - if let Some((_, current_best_score)) = &best_match { - if combined_score > *current_best_score { - best_match = Some((trakt_item, combined_score)); - } - } else { - best_match = Some((trakt_item, combined_score)); - } - // early exit strategy - if combined_score >= 0.99 { - break; - } - } - } - } - - if let Some((trakt_item, combined_score)) = best_match { - // let match_type = if playlist_year.is_some() && trakt_item.year.is_some() { - // MatchType::FuzzyTitleYear - // } else { - // MatchType::FuzzyTitle - // }; - - trace_if_enabled!( - "Fuzzy match: '{}' -> '{}' (final: {combined_score:.3}", /*, type: {match_type:?})"*/ - channel.0.header.title, - trakt_item.title - ); - - return Some(TraktMatchResult { - playlist_item: channel.0, - trakt_item, - match_score: combined_score, - // match_type: match_type.clone(), - }); - } - - None -} - -fn find_best_match_for_item<'a>( - channel: (&'a PlaylistItem, String, Option, Option), - trakt_items: &'a [TraktMatchItem<'a>], - category_config: &'a TraktCategoryConfig, -) -> Option> { - // Try TMDB exact matching first - if let Some(playlist_tmdb_id) = channel.3 { - for trakt_item in trakt_items { - if Some(playlist_tmdb_id) == trakt_item.tmdb_id { - trace!("TMDB exact match: '{}' (TMDB: {})", channel.0.header.title, playlist_tmdb_id); - return Some(TraktMatchResult { - playlist_item: channel.0, - trakt_item, - match_score: 1.0, - // match_type: MatchType::TmdbExact, - }); - } - } - } - - if category_config.tmdb_only { - return None; - } - - find_best_fuzzy_match_for_item(channel, trakt_items, category_config) -} - -fn create_category_from_matches<'a>( - matches: Vec>, - category_config: &'a TraktCategoryConfig, - series_children_by_parent_code: &HashMap, Vec<&'a PlaylistItem>>, -) -> Vec { - if matches.is_empty() { - return vec![]; - } - - let mut matched_items_by_cluster: IndexMap> = IndexMap::new(); - - let mut sorted_matches = matches; - sorted_matches.sort_by(|a, b| { - (a.trakt_item.rank.unwrap_or(9999), a.trakt_item.title.to_lowercase()) - .cmp(&(b.trakt_item.rank.unwrap_or(9999), b.trakt_item.title.to_lowercase())) - }); - - let group_title = category_config.category_name.as_str().intern(); - - for match_result in sorted_matches { - let modified_item = clone_item_for_trakt_category( - match_result.playlist_item, - category_config.category_name.as_str(), - &group_title, - ); - let parent_uuid = modified_item.header.uuid.intern(); - let is_series_info = - matches!(modified_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo); - let child_lookup_keys = - if is_series_info { series_info_child_lookup_keys(match_result.playlist_item) } else { Vec::new() }; - let cluster = modified_item.header.xtream_cluster; - matched_items_by_cluster.entry(cluster).or_default().push(modified_item); - - if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) { - for child in children { - let mut child = - clone_item_for_trakt_category(child, category_config.category_name.as_str(), &group_title); - child.header.parent_code = parent_uuid.clone(); - matched_items_by_cluster.entry(child.header.xtream_cluster).or_default().push(child); - } - } - } - - matched_items_by_cluster - .into_iter() - .map(|(cluster, channels)| PlaylistGroup { - id: 0, - title: group_title.clone(), - channels, - xtream_cluster: cluster, - }) - .collect() -} - -fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group_title: &Arc) -> PlaylistItem { - let mut modified_item = item.clone(); - let source_uuid = if modified_item.header.uuid == UUIDType::default() { - modified_item.get_uuid() - } else { - modified_item.header.uuid - }; - - with!(mut modified_item.header => header { - let title = header - .get(HeaderField::Caption) - .map_or_else(|| Arc::clone(&header.title), |value| value.to_arc()); - if extract_quality(&title).is_none() { - if let Some(quality) = extract_quality(&header.group) { - let mut caption = String::with_capacity(title.len() + 6); - caption.push('['); - caption.push_str(quality); - caption.push_str("] "); - caption.push_str(&title); - header.set(HeaderField::Caption, &caption); - } - } - header.group = group_title.clone(); - header.uuid = hash_string(&format!("trakt-category:{category_name}:{source_uuid}")); - }); - - modified_item -} - -fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec> { - match series_info.header.item_type { - PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()], - PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()], - _ => Vec::new(), - } -} - -fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap, Vec<&PlaylistItem>> { - let mut children = HashMap::, Vec<&PlaylistItem>>::new(); - for playlist_group in playlist { - for channel in &playlist_group.channels { - if channel.header.item_type.is_series() && !channel.header.parent_code.is_empty() { - children.entry(channel.header.parent_code.clone()).or_default().push(channel); - } - } - } - children -} - -fn match_trakt_items_with_playlist<'a>( - trakt_items: &'a [TraktListItem], - playlist: &'a [PlaylistGroup], - category_config: &'a TraktCategoryConfig, -) -> Vec { - let trakt_match_items: Vec> = trakt_items - .iter() - .filter(|item| should_include_item(item, category_config.content_type)) - .filter_map(TraktMatchItem::from_trakt_list_item) - .collect(); - - debug!( - "Matching {} Trakt items against playlist for content type {:?}", - trakt_match_items.len(), - category_config.content_type - ); - - let mut matches = Vec::new(); - for playlist_group in playlist { - for channel in &playlist_group.channels { - if is_compatible_content_type(channel.header.xtream_cluster, category_config.content_type) - && is_matchable_playlist_item(channel.header.item_type, category_config.content_type) - { - let normalized_title = normalize_title_for_matching(&channel.header.title); - let channel_year = extract_year_from_title(&channel.header.title); - let channel_tmdb_id = channel.get_tmdb_id(); - if let Some(matched) = find_best_match_for_item( - (channel, normalized_title, channel_year, channel_tmdb_id), - &trakt_match_items, - category_config, - ) { - matches.push(matched); - } - } - } - } - - let series_children_by_parent_code = series_children_by_parent_code(playlist); - create_category_from_matches(matches, category_config, &series_children_by_parent_code) -} - -pub struct TraktCategoriesProcessor { - client: TraktClient, -} - -impl TraktCategoriesProcessor { - pub fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Result { - let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?; - Ok(Self { client }) - } - - pub async fn process_trakt_categories( - &self, - playlist: &[PlaylistGroup], - target: &ConfigTarget, - trakt_config: &TraktConfig, - ) -> Result>, Vec> { - if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() { - debug!("No Trakt lists or charts configured for target {}", target.name); - return Ok(None); - } - - info!( - "Processing {} Trakt lists and {} Trakt charts for target {}", - trakt_config.lists.len(), - trakt_config.charts.len(), - target.name - ); - let mut new_categories = Vec::new(); - let mut total_matches = 0; - - for list_config in &trakt_config.lists { - let cache_key = format!("{}:{}", list_config.user, list_config.list_slug); - let category_config = TraktCategoryConfig::from(list_config); - - match self.client.get_list_items(list_config).await { - Ok(trakt_items) => { - debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len()); - - let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config); - for category in categories { - if !category.channels.is_empty() { - total_matches += category.channels.len(); - let category_len = category.channels.len(); - new_categories.push(category); - debug!( - "Created Trakt category '{}' with {category_len} items", - category_config.category_name - ); - } - } - } - Err(err) => { - warn!("Failed to fetch Trakt list {cache_key}: {}", err.message()); - } - } - } - - for chart_config in &trakt_config.charts { - let cache_key = format!("{}:{}", chart_config.kind, chart_config.chart); - let category_config = TraktCategoryConfig::from(chart_config); - - match self.client.get_chart_items(chart_config).await { - Ok(trakt_items) => { - debug!("Processing Trakt chart {cache_key} with {} items", trakt_items.len()); - - let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config); - for category in categories { - if !category.channels.is_empty() { - total_matches += category.channels.len(); - let category_len = category.channels.len(); - new_categories.push(category); - debug!( - "Created Trakt category '{}' with {category_len} items", - category_config.category_name - ); - } - } - } - Err(err) => { - warn!("Failed to fetch Trakt chart {cache_key}: {}", err.message()); - } - } - } - - info!( - "Trakt processing complete: created {} categories with {total_matches} total matches", - new_categories.len() - ); - - Ok(Some(new_categories)) - } -} -pub async fn process_trakt_categories_for_target( - http_client: &reqwest::Client, - playlist: &[PlaylistGroup], - target: &ConfigTarget, -) -> Result>, Vec> { - 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 Ok(None); - }; - if !trakt_config.enabled { - return Ok(None); - } - if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() { - debug!("No Trakt lists or charts configured for target {}", target.name); - return Ok(None); - } - - 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 Ok(None); - } - }; - processor.process_trakt_categories(playlist, target, trakt_config).await -} - -#[cfg(test)] -mod tests { - use super::*; - use shared::model::{ - ConfigTargetDto, EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, - TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, - TraktContentType, TraktListConfigDto, VideoStreamProperties, XtreamTargetOutputDto, - }; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Mutex, - }; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::{TcpListener, TcpStream}, - task::JoinHandle, - }; - - #[test] - pub fn test_quality() { - let quality = extract_quality("Hello HD UHD 720p"); - assert!(quality.is_some()); - assert_eq!("UHD", quality.unwrap()); - } - - #[tokio::test] - async fn configured_trakt_curation_without_client_id_makes_no_request() { - let requests = Arc::new(AtomicUsize::new(0)); - let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; - let target = trakt_target("", base_url, true, vec![remote_list_config("Missing")], Vec::new()); - - let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) - .await - .expect("missing Client ID should not fail target processing"); - - assert!(result.is_none()); - assert_eq!(requests.load(Ordering::SeqCst), 0); - server.abort(); - } - - #[tokio::test] - async fn disabled_trakt_config_with_sources_makes_no_request() { - let requests = Arc::new(AtomicUsize::new(0)); - let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; - let target = trakt_target("", base_url, false, vec![remote_list_config("Disabled")], Vec::new()); - - let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) - .await - .expect("disabled Trakt config should be a no-op"); - - assert!(result.is_none()); - assert_eq!(requests.load(Ordering::SeqCst), 0); - server.abort(); - } - - #[tokio::test] - async fn trakt_config_without_lists_or_charts_makes_no_request() { - let requests = Arc::new(AtomicUsize::new(0)); - let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; - let target = trakt_target("", base_url, true, Vec::new(), Vec::new()); - - let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) - .await - .expect("empty Trakt config should be a no-op"); - - assert!(result.is_none()); - assert_eq!(requests.load(Ordering::SeqCst), 0); - server.abort(); - } - - #[tokio::test] - async fn failed_list_does_not_suppress_successful_chart() { - let requests = Arc::new(Mutex::new(Vec::new())); - let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await; - let target = trakt_target( - "test-client-id", - base_url, - true, - vec![remote_list_config("Unavailable List")], - vec![remote_chart_config("Available Chart")], - ); - let playlist = vec![PlaylistGroup { - id: 1, - title: "Original".intern(), - channels: vec![video_item("Movie 1", Some(11))], - xtream_cluster: XtreamCluster::Video, - }]; - - let categories = process_trakt_categories_for_target(&reqwest::Client::new(), &playlist, &target) - .await - .expect("a failed Trakt source should not fail target processing") - .expect("configured Trakt sources should produce a result"); - - 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); - server.await.expect("test server should finish"); - } - - #[test] - fn tmdb_only_list_skips_title_fallback_matches() { - let playlist_item = video_item("The Captive", None); - let trakt_items = vec![trakt_movie("The Captive", Some(1915), Some(123), 1)]; - let list_config = list_config(true); - - let matched = find_best_match_for_item( - (&playlist_item, normalize_title_for_matching("The Captive"), None, playlist_item.get_tmdb_id()), - &trakt_items, - &list_config, - ); - - assert!(matched.is_none()); - } - - #[test] - fn tmdb_only_list_keeps_tmdb_exact_matches() { - let playlist_item = video_item("Cautivos", Some(456)); - let trakt_items = vec![trakt_movie("The Captive", Some(2014), Some(456), 1)]; - let list_config = list_config(true); - - let matched = find_best_match_for_item( - (&playlist_item, normalize_title_for_matching("Cautivos"), None, playlist_item.get_tmdb_id()), - &trakt_items, - &list_config, - ); - - assert!(matched.is_some()); - assert_eq!(matched.expect("tmdb match").trakt_item.tmdb_id, Some(456)); - } - - #[test] - fn same_playlist_item_can_appear_in_multiple_trakt_categories() { - let playlist = vec![PlaylistGroup { - id: 1, - title: "Original".intern(), - channels: vec![video_item("The Smashing Machine", Some(760_329))], - xtream_cluster: XtreamCluster::Video, - }]; - let trakt_items = vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 1)]; - let a24_config = named_list_config("▸ A24", true); - let renoir_config = named_list_config("▸ Cines Renoir", true); - - let a24 = match_trakt_items_with_playlist(&trakt_items, &playlist, &a24_config); - let renoir = match_trakt_items_with_playlist(&trakt_items, &playlist, &renoir_config); - - assert_eq!(a24.len(), 1); - assert_eq!(renoir.len(), 1); - let a24_item = &a24[0].channels[0]; - let renoir_item = &renoir[0].channels[0]; - assert_eq!(a24_item.header.group.as_ref(), "▸ A24"); - assert_eq!(renoir_item.header.group.as_ref(), "▸ Cines Renoir"); - assert_ne!(a24_item.header.uuid, renoir_item.header.uuid); - } - - #[test] - fn trakt_series_categories_clone_episode_children() { - let mut series = series_item("Slow Horses", Some(12345)); - let source_parent_code = series.get_uuid().intern(); - series.header.uuid = series.get_uuid(); - let episode = episode_item("Old Scores", &source_parent_code, 7001); - let playlist = vec![PlaylistGroup { - id: 1, - title: "Media Server Series".intern(), - channels: vec![series, episode], - xtream_cluster: XtreamCluster::Series, - }]; - let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]; - let config = named_series_config("Trending", true); - - let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config); - - assert_eq!(categories.len(), 1); - assert_eq!(categories[0].channels.len(), 2); - let cloned_series = categories[0] - .channels - .iter() - .find(|item| item.header.item_type == PlaylistItemType::SeriesInfo) - .expect("series info clone"); - let cloned_episode = categories[0] - .channels - .iter() - .find(|item| item.header.item_type == PlaylistItemType::Series) - .expect("episode clone"); - assert_eq!(cloned_series.header.group.as_ref(), "Trending"); - assert_eq!(cloned_episode.header.group.as_ref(), "Trending"); - assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern()); - } - - #[test] - fn trakt_series_matching_ignores_episode_rows() { - let episode = episode_item("Slow Horses", &"series-parent".intern(), 7001); - let playlist = vec![PlaylistGroup { - id: 1, - title: "Media Server Series".intern(), - channels: vec![episode], - xtream_cluster: XtreamCluster::Series, - }]; - let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]; - let config = TraktCategoryConfig { - category_name: "Trending".to_string(), - content_type: TraktContentType::Series, - tmdb_only: false, - fuzzy_match_threshold: 100, - }; - - let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config); - - assert!(categories.is_empty()); - } - - async fn spawn_counting_trakt_server(requests: Arc) -> (String, JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); - let addr = listener.local_addr().expect("local addr"); - let server = tokio::spawn(async move { - loop { - let Ok((mut stream, _)) = listener.accept().await else { return }; - let _ = read_request(&mut stream).await; - requests.fetch_add(1, Ordering::SeqCst); - write_response(&mut stream, "200 OK", "[]").await; - } - }); - (format!("http://{addr}"), server) - } - - async fn spawn_partial_success_trakt_server(requests: Arc>>) -> (String, JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); - let addr = listener.local_addr().expect("local addr"); - let server = tokio::spawn(async move { - for _ in 0..2 { - let (mut stream, _) = listener.accept().await.expect("accept test request"); - let request = read_request(&mut stream).await; - let is_list_request = request.contains("/users/test-user/lists/test-list/items"); - requests.lock().expect("requests").push(request); - if is_list_request { - write_response(&mut stream, "403 Forbidden", "response body must not affect the next source").await; - } else { - write_response( - &mut stream, - "200 OK", - r#"[{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}]"#, - ) - .await; - } - } - }); - (format!("http://{addr}"), server) - } - - async fn read_request(stream: &mut TcpStream) -> String { - let mut request_bytes = Vec::new(); - loop { - let mut buffer = [0; 1024]; - let read = stream.read(&mut buffer).await.expect("read request"); - if read == 0 { - break; - } - request_bytes.extend_from_slice(&buffer[..read]); - if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } - String::from_utf8_lossy(&request_bytes).to_string() - } - - async fn write_response(stream: &mut TcpStream, status: &str, body: &str) { - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.expect("write response"); - } - - fn trakt_target( - client_id: &str, - url: String, - enabled: bool, - lists: Vec, - charts: Vec, - ) -> ConfigTarget { - ConfigTarget::from(&ConfigTargetDto { - name: "test-target".to_string(), - output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto { - trakt: Some(TraktConfigDto { - enabled, - api: TraktApiConfigDto { - api_key: client_id.to_string(), - version: "2".to_string(), - url, - user_agent: "tuliprox-test".to_string(), - }, - lists, - charts, - }), - ..XtreamTargetOutputDto::default() - })], - ..ConfigTargetDto::default() - }) - } - - fn remote_list_config(category_name: &str) -> TraktListConfigDto { - TraktListConfigDto { - user: "test-user".to_string(), - list_slug: "test-list".to_string(), - category_name: category_name.to_string(), - content_type: TraktContentType::Vod, - tmdb_only: true, - fuzzy_match_threshold: 100, - } - } - - fn remote_chart_config(category_name: &str) -> TraktChartConfigDto { - TraktChartConfigDto { - kind: TraktChartKind::Movies, - chart: TraktChartType::Popular, - category_name: category_name.to_string(), - tmdb_only: true, - fuzzy_match_threshold: 100, - } - } - - fn list_config(tmdb_only: bool) -> TraktCategoryConfig { named_list_config("category", tmdb_only) } - - fn named_list_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig { - TraktCategoryConfig { - category_name: category_name.to_string(), - content_type: TraktContentType::Vod, - tmdb_only, - fuzzy_match_threshold: 100, - } - } - - fn named_series_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig { - TraktCategoryConfig { - category_name: category_name.to_string(), - content_type: TraktContentType::Series, - tmdb_only, - fuzzy_match_threshold: 100, - } - } - - fn video_item(title: &str, tmdb: Option) -> PlaylistItem { - PlaylistItem { - header: PlaylistItemHeader { - title: title.intern(), - xtream_cluster: XtreamCluster::Video, - item_type: PlaylistItemType::Video, - additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { - name: title.intern(), - tmdb, - ..VideoStreamProperties::default() - }))), - ..PlaylistItemHeader::default() - }, - } - } - - fn series_item(title: &str, tmdb: Option) -> PlaylistItem { - PlaylistItem { - header: PlaylistItemHeader { - id: format!("series-{title}").intern(), - input_name: "input".intern(), - title: title.intern(), - name: title.intern(), - url: format!("media-server://unavailable/server/shows/{title}").intern(), - xtream_cluster: XtreamCluster::Series, - item_type: PlaylistItemType::SeriesInfo, - additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { - name: title.intern(), - tmdb, - ..SeriesStreamProperties::default() - }))), - ..PlaylistItemHeader::default() - }, - } - } - - fn episode_item(title: &str, parent_code: &Arc, virtual_id: u32) -> PlaylistItem { - PlaylistItem { - header: PlaylistItemHeader { - uuid: hash_string(&format!("episode:{title}:{virtual_id}")), - id: format!("episode-{virtual_id}").intern(), - input_name: "input".intern(), - parent_code: parent_code.clone(), - title: title.intern(), - name: title.intern(), - url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), - virtual_id: shared::model::VirtualId::new(virtual_id), - xtream_cluster: XtreamCluster::Series, - item_type: PlaylistItemType::Series, - additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { - episode_id: virtual_id, - episode: 1, - season: 1, - added: None, - release_date: None, - series_release_date: None, - tmdb: None, - movie_image: "".intern(), - container_extension: "mkv".intern(), - video: None, - audio: None, - plot: None, - }))), - ..PlaylistItemHeader::default() - }, - } - } - - fn trakt_movie( - title: &'static str, - year: Option, - tmdb_id: Option, - trakt_id: u32, - ) -> TraktMatchItem<'static> { - TraktMatchItem { - title, - normalized_title: normalize_title_for_matching(title), - year, - tmdb_id, - trakt_id, - content_type: TraktContentType::Vod, - rank: Some(trakt_id), - } - } - - fn trakt_list_movie(title: &str, year: Option, tmdb_id: Option, trakt_id: u32) -> TraktListItem { - TraktListItem { - id: u64::from(trakt_id), - rank: Some(trakt_id), - listed_at: String::new(), - notes: None, - item_type: "movie".to_string(), - movie: Some(tuliprox_core::model::TraktMovie { - ids: trakt_ids(title, tmdb_id, trakt_id), - title: title.to_string(), - year, - }), - show: None, - content_type: TraktContentType::Vod, - } - } - - fn trakt_list_show(title: &str, year: Option, tmdb_id: Option, trakt_id: u32) -> TraktListItem { - TraktListItem { - id: u64::from(trakt_id), - rank: Some(trakt_id), - listed_at: String::new(), - notes: None, - item_type: "show".to_string(), - movie: None, - show: Some(tuliprox_core::model::TraktShow { - ids: trakt_ids(title, tmdb_id, trakt_id), - title: title.to_string(), - year, - }), - content_type: TraktContentType::Series, - } - } - - fn trakt_ids(title: &str, tmdb_id: Option, trakt_id: u32) -> tuliprox_core::model::TraktIds { - tuliprox_core::model::TraktIds { - trakt: trakt_id, - slug: title.to_string(), - tvdb: None, - imdb: None, - tmdb: tmdb_id, - tvrage: None, - } - } -} diff --git a/bin/check-workspace-deps.sh b/bin/check-workspace-deps.sh index f74f5f660..f413716f5 100755 --- a/bin/check-workspace-deps.sh +++ b/bin/check-workspace-deps.sh @@ -66,11 +66,17 @@ normal tuliprox-metadata -> tuliprox-core normal tuliprox-metadata -> tuliprox-processing normal tuliprox-metadata -> tuliprox-repository normal tuliprox-metadata -> tuliprox-session +# Playlist curation owns the source-neutral matching/projection kernel and +# translates its concrete Trakt edge into that trusted representation. +normal tuliprox-curation -> shared +normal tuliprox-curation -> tuliprox-core # The playlist pipeline. It states what it needs from the background # metadata worker as a trait (`MetadataUpdateSink`) that the binary -# implements, so it does not depend on the worker itself. +# implements, so it does not depend on the worker itself. It delegates +# optional category matching/projection to the curation capability. normal tuliprox-processing -> shared normal tuliprox-processing -> tuliprox-core +normal tuliprox-processing -> tuliprox-curation normal tuliprox-processing -> tuliprox-iptv normal tuliprox-processing -> tuliprox-library normal tuliprox-processing -> tuliprox-media-server diff --git a/shared/src/utils/constants.rs b/shared/src/utils/constants.rs index c3df380bb..681b29d69 100644 --- a/shared/src/utils/constants.rs +++ b/shared/src/utils/constants.rs @@ -79,7 +79,6 @@ pub struct Constants { pub export_style_config: ExportStyleConfig, pub country_codes: HashSet<&'static str>, pub allowed_output_formats: Vec, - pub re_trakt_year: Regex, pub re_quality: Regex, pub re_classifier_year: Regex, pub re_classifier_cleanup: Regex, @@ -160,7 +159,6 @@ pub static CONSTANTS: LazyLock = LazyLock::new(|| { "to", "tt", "tn", "tr", "tm", "tv", "ug", "ua", "ae", "gb", "us", "uy", "uz", "vu", "va", "ve", "vn", "ye", "zm", "zw", "sat", "4k", ].into_iter().collect::>(), - re_trakt_year: Regex::new(r"\(?(\d{4})\)?$").unwrap(), re_quality: Regex::new(r"(?i)\b(x265|4K|UHD|8K|2160p?|1080p?|720p?|480p?|BLURAY|HDTV|DVDRIP|BRRIP|CAM|TS|HDR|DV|SDR)\b").unwrap(), re_classifier_year: Regex::new(r"[\(\[]?(\d{4})[\)\]]?").unwrap(), re_classifier_cleanup: Regex::new(r"(?i)[\s\._-]*(?:s\d+e\d+|\d+x\d+|season[\s\._-]*\d+|episode[\s\._-]*\d+).*$").unwrap(),