refactor: extract source-neutral playlist curation (#860)

* refactor: extract source-neutral playlist curation

* fix: keep curation matches within media kind

---------

Co-authored-by: Juanjo Presa <juanjop@gmail.com>
This commit is contained in:
Juanjo Presa
2026-09-09 20:40:53 +02:00
committed by GitHub
co-authored by Juanjo Presa
parent 019fb7fde2
commit 145b082a4f
24 changed files with 1574 additions and 1200 deletions
+5
View File
@@ -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
Generated
+18 -1
View File
@@ -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",
+1
View File
@@ -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" }
-1
View File
@@ -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
-2
View File
@@ -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::*;
+38 -40
View File
@@ -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);
}
}
-132
View File
@@ -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<u32>,
pub listed_at: String,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub notes: Option<String>,
#[serde(rename = "type")]
pub item_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub movie: Option<TraktMovie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show: Option<TraktShow>,
#[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<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraktShow {
pub ids: TraktIds,
pub title: String,
pub year: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraktIds {
pub trakt: u32,
pub slug: String,
pub tvdb: Option<u32>,
pub imdb: Option<String>,
pub tmdb: Option<u32>,
pub tvrage: Option<u32>,
}
// Internal matching structures
#[derive(Debug, Clone)]
pub struct TraktMatchItem<'a> {
pub title: &'a str,
pub normalized_title: String,
pub year: Option<u32>,
pub tmdb_id: Option<u32>,
pub trakt_id: u32,
pub content_type: TraktContentType,
pub rank: Option<u32>,
}
impl<'a> TraktMatchItem<'a> {
pub fn from_trakt_list_item(item: &'a TraktListItem) -> Option<Self> {
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,
}
}
}
-2
View File
@@ -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::*;
-64
View File
@@ -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<u32> {
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::<u32>() {
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);
}
}
+24
View File
@@ -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"] }
+771
View File
@@ -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<Regex> =
LazyLock::new(|| Regex::new(r"\(?(\d{4})\)?$").expect("curation title-year regex must compile"));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CurationMediaKind {
Movie,
Series,
}
impl CurationMediaKind {
const fn from_playlist_item_type(item_type: PlaylistItemType) -> Option<Self> {
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<f64> {
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<u32>,
pub(crate) tmdb_id: Option<u32>,
pub(crate) rank: Option<u32>,
}
impl CuratedMediaReference {
pub(crate) fn new(
kind: CurationMediaKind,
title: String,
year: Option<u32>,
tmdb_id: Option<u32>,
rank: Option<u32>,
) -> 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<u32>,
tmdb_id: Option<u32>,
}
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<u32> {
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::<u32>() {
if (1900..=2100).contains(&year) {
return Some(year);
}
}
}
}
None
}
fn calculate_year_bonus(playlist_year: Option<u32>, reference_year: Option<u32>) -> 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<MatchResult<'playlist, 'reference>> {
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<MatchResult<'playlist, 'reference>> {
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<PlaylistGroup> {
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<MatchResult<'_, '_>>,
specification: &CurationCategorySpec<'_>,
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&PlaylistItem>>,
) -> Vec<PlaylistGroup> {
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<XtreamCluster, Vec<PlaylistItem>> = 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<str>,
) -> 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<Arc<str>> {
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<Arc<str>, Vec<&PlaylistItem>> {
let mut children = HashMap::<Arc<str>, 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::<Vec<_>>();
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<u32>,
tmdb_id: Option<u32>,
rank: Option<u32>,
) -> CuratedMediaReference {
CuratedMediaReference::new(kind, title.to_string(), year, tmdb_id, rank)
}
fn video_item(title: &str, tmdb: Option<u32>) -> 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<u32>) -> 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<str>, 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()
},
}
}
}
+14
View File
@@ -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;
@@ -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<Self, TuliproxError> {
pub(super) fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result<Self, TuliproxError> {
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<Vec<TraktListItem>, TuliproxError> {
pub(super) async fn get_chart_items(
&self,
chart_config: &TraktChartConfig,
) -> Result<Vec<TraktListItem>, 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<Vec<TraktListItem>, TuliproxError> {
pub(super) async fn get_list_items(
&self,
list_config: &TraktListConfig,
) -> Result<Vec<TraktListItem>, 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<F, Fut>(
&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<TraktListItem> =
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<TraktListItem> = 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<Vec<TraktListItem>, 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::<Vec<TraktMovie>>(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::<Vec<TraktTrendingMovieItem>>(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::<Vec<TraktShow>>(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::<Vec<TraktTrendingShowItem>>(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<u32> {
headers.get(name).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::<u32>().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 "));
}
@@ -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",
+545
View File
@@ -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<Vec<PlaylistGroup>> {
if !trakt_config.enabled {
return None;
}
if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() {
debug!("No Trakt lists or charts configured for target {target_name}");
return None;
}
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<Self, shared::error::TuliproxError> {
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?;
Ok(Self { client })
}
async fn process(
&self,
playlist: &[PlaylistGroup],
target_name: &str,
trakt_config: &TraktConfig,
) -> Vec<PlaylistGroup> {
info!(
"Processing {} Trakt lists and {} Trakt charts for target {target_name}",
trakt_config.lists.len(),
trakt_config.charts.len()
);
let mut new_categories = Vec::new();
let mut total_matches = 0;
for list_config in &trakt_config.lists {
let source_label = format!("{}:{}", list_config.user, list_config.list_slug);
let specification = list_category_spec(list_config);
match self.client.get_list_items(list_config).await {
Ok(items) => {
debug!("Processing Trakt list {source_label} with {} items", items.len());
let references = translate_items(items);
append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches);
}
Err(error) => warn!("Failed to fetch Trakt list {source_label}: {}", error.message()),
}
}
for chart_config in &trakt_config.charts {
let source_label = format!("{}:{}", chart_config.kind, chart_config.chart);
let specification = chart_category_spec(chart_config);
match self.client.get_chart_items(chart_config).await {
Ok(items) => {
debug!("Processing Trakt chart {source_label} with {} items", items.len());
let references = translate_items(items);
append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches);
}
Err(error) => warn!("Failed to fetch Trakt chart {source_label}: {}", error.message()),
}
}
info!(
"Trakt processing complete: created {} categories with {total_matches} total matches",
new_categories.len()
);
new_categories
}
}
fn append_categories(
references: &[CuratedMediaReference],
playlist: &[PlaylistGroup],
specification: &CurationCategorySpec<'_>,
categories: &mut Vec<PlaylistGroup>,
total_matches: &mut usize,
) {
for category in curate_category(references, playlist, specification) {
if !category.channels.is_empty() {
*total_matches += category.channels.len();
let category_len = category.channels.len();
categories.push(category);
debug!("Created Trakt category '{}' with {category_len} items", specification.name);
}
}
}
fn translate_items(items: Vec<TraktListItem>) -> Vec<CuratedMediaReference> {
items.into_iter().filter_map(TraktListItem::into_curated_reference).collect()
}
fn list_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<AtomicUsize>) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
let addr = listener.local_addr().expect("local addr");
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<Mutex<Vec<String>>>) -> (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<TraktListConfig>,
charts: Vec<TraktChartConfig>,
) -> 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<u32>) -> 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<u32>) -> 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<str>, 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<u32>, tmdb_id: Option<u32>, 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<u32>, tmdb_id: Option<u32>, 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<u32>, trakt_id: u32) -> TraktIds {
TraktIds { trakt: trakt_id, slug: title.to_string(), tvdb: None, imdb: None, tmdb: tmdb_id, tvrage: None }
}
}
+91
View File
@@ -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<u32>,
pub(super) listed_at: String,
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
pub(super) notes: Option<String>,
#[serde(rename = "type")]
pub(super) item_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) movie: Option<TraktMovie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) show: Option<TraktShow>,
}
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<CuratedMediaReference> {
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<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TraktShow {
pub(super) ids: TraktIds,
pub(super) title: String,
pub(super) year: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct TraktIds {
pub(super) trakt: u32,
pub(super) slug: String,
pub(super) tvdb: Option<u32>,
pub(super) imdb: Option<String>,
pub(super) tmdb: Option<u32>,
pub(super) tvrage: Option<u32>,
}
#[derive(Deserialize)]
pub(super) struct TraktTrendingMovieItem {
pub(super) movie: TraktMovie,
}
#[derive(Deserialize)]
pub(super) struct TraktTrendingShowItem {
pub(super) show: TraktShow,
}
+1
View File
@@ -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 }
-1
View File
@@ -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::{
@@ -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,
@@ -387,7 +387,7 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
(Ok(()), errors)
} else {
// Process Trakt categories
if trakt_playlist(&ctx.client, target, &mut errors, &mut new_playlist).await {
if trakt_playlist(&ctx.client, target, &mut new_playlist).await {
step.tick("trakt categories");
log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str());
}
@@ -767,23 +767,18 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
pub(crate) async fn trakt_playlist(
client: &reqwest::Client,
target: &ConfigTarget,
errors: &mut Vec<TuliproxError>,
playlist: &mut Vec<PlaylistGroup>,
) -> 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
}
@@ -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;
-895
View File
@@ -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<u32>, trakt_year: Option<u32>) -> 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<u32>, Option<u32>),
trakt_items: &'a [TraktMatchItem],
category_config: &'a TraktCategoryConfig,
) -> Option<TraktMatchResult<'a>> {
// 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<u32>, Option<u32>),
trakt_items: &'a [TraktMatchItem<'a>],
category_config: &'a TraktCategoryConfig,
) -> Option<TraktMatchResult<'a>> {
// 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<TraktMatchResult<'a>>,
category_config: &'a TraktCategoryConfig,
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&'a PlaylistItem>>,
) -> Vec<PlaylistGroup> {
if matches.is_empty() {
return vec![];
}
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = 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<str>) -> 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<Arc<str>> {
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<Arc<str>, Vec<&PlaylistItem>> {
let mut children = HashMap::<Arc<str>, 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<PlaylistGroup> {
let trakt_match_items: Vec<TraktMatchItem<'a>> = 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<Self, TuliproxError> {
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<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
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<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
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<AtomicUsize>) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
let addr = listener.local_addr().expect("local addr");
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<Mutex<Vec<String>>>) -> (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<TraktListConfigDto>,
charts: Vec<TraktChartConfigDto>,
) -> 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<u32>) -> 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<u32>) -> 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<str>, 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<u32>,
tmdb_id: Option<u32>,
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<u32>, tmdb_id: Option<u32>, 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<u32>, tmdb_id: Option<u32>, 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<u32>, 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,
}
}
}
+7 -1
View File
@@ -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
-2
View File
@@ -79,7 +79,6 @@ pub struct Constants {
pub export_style_config: ExportStyleConfig,
pub country_codes: HashSet<&'static str>,
pub allowed_output_formats: Vec<String>,
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<Constants> = 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::<HashSet<&str>>(),
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(),