Merge remote-tracking branch 'github/develop' into fix/user_session

This commit is contained in:
euzu
2026-09-11 07:54:51 +02:00
35 changed files with 1866 additions and 1098 deletions
+12 -1
View File
@@ -969,6 +969,12 @@
`4096`); mandatory HLS cleanup uses a reserved control lane. Graceful shutdown closes admission, releases active
claims and provider leases, and waits for owned streaming and cleanup workers to finish.
- **Trakt curation now requires an explicitly configured Client ID.** Tuliprox no longer bundles or falls back to a
shared Client ID. Blank or header-invalid `trakt.api.api_key` values now produce one target-scoped warning and skip
only optional Trakt curation without making an HTTP request; other target processing continues. Trakt `401`, `403`,
`404`, and `429` responses now have actionable, resource-aware messages, while independently successful lists and
charts remain available.
- **Empty playlist updates no longer replace previously published input or target data.** A completely empty refresh is
treated as a failed update and keeps the last usable playlist and its virtual-ID mapping intact. This prevents
transient provider/download failures from making channels disappear or assigning different IDs when service
@@ -1394,6 +1400,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
@@ -2007,7 +2018,7 @@ and assigns it to the variable `station_prefix`.
resolve_vod: false
trakt:
api:
key: <my private trakt api key>
key: <my Trakt Client ID>
version: 2
lists:
- user: "linaspurinis"
Generated
+37 -20
View File
@@ -1276,7 +1276,7 @@ checksum = "431a4c31778fde52b4400de34975f219eeca55cc829a9de157cd743a5b230ecb"
[[package]]
name = "frontend"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -3776,7 +3776,7 @@ dependencies = [
[[package]]
name = "shared"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"base64 0.23.1",
"bitflags 2.13.1",
@@ -4377,7 +4377,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tuliprox"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"aes",
"arc-swap",
@@ -4456,7 +4456,7 @@ dependencies = [
[[package]]
name = "tuliprox-auth"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"axum",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "tuliprox-btree"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"crc32fast",
"log",
@@ -4497,7 +4497,7 @@ dependencies = [
[[package]]
name = "tuliprox-config-loader"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"blake3",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]]
name = "tuliprox-core"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"aes",
"arc-swap",
@@ -4538,7 +4538,6 @@ dependencies = [
"ipnet",
"libc",
"log",
"mime",
"parking_lot",
"paste",
"path-clean",
@@ -4566,9 +4565,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "tuliprox-curation"
version = "3.3.109"
dependencies = [
"indexmap",
"log",
"mime",
"regex",
"reqwest",
"serde",
"serde_json",
"shared",
"strsim",
"tokio",
"tuliprox-core",
]
[[package]]
name = "tuliprox-dvr"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"chrono",
@@ -4593,7 +4609,7 @@ dependencies = [
[[package]]
name = "tuliprox-hls"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"async-compression",
@@ -4630,7 +4646,7 @@ dependencies = [
[[package]]
name = "tuliprox-iptv"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -4655,7 +4671,7 @@ dependencies = [
[[package]]
name = "tuliprox-library"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"blake3",
"bytes",
@@ -4682,7 +4698,7 @@ dependencies = [
[[package]]
name = "tuliprox-media-server"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"bytes",
"futures",
@@ -4701,7 +4717,7 @@ dependencies = [
[[package]]
name = "tuliprox-messaging"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"chrono",
@@ -4723,7 +4739,7 @@ dependencies = [
[[package]]
name = "tuliprox-metadata"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"chrono",
@@ -4746,7 +4762,7 @@ dependencies = [
[[package]]
name = "tuliprox-mpegts"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"aes",
"bytes",
@@ -4760,7 +4776,7 @@ dependencies = [
[[package]]
name = "tuliprox-parser"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"chrono",
"chrono-tz",
@@ -4780,7 +4796,7 @@ dependencies = [
[[package]]
name = "tuliprox-processing"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"chrono",
@@ -4806,6 +4822,7 @@ dependencies = [
"tokio",
"tokio-util",
"tuliprox-core",
"tuliprox-curation",
"tuliprox-iptv",
"tuliprox-library",
"tuliprox-media-server",
@@ -4816,7 +4833,7 @@ dependencies = [
[[package]]
name = "tuliprox-repository"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"base64 0.23.1",
@@ -4853,7 +4870,7 @@ dependencies = [
[[package]]
name = "tuliprox-session"
version = "3.3.107"
version = "3.3.109"
dependencies = [
"arc-swap",
"axum",
+2 -1
View File
@@ -3,7 +3,7 @@ members = ["backend/*", "frontend", "shared"]
resolver = "2"
[workspace.package]
version = "3.3.107"
version = "3.3.109"
edition = "2021"
rust-version = "1.95.0"
@@ -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" }
+3 -3
View File
@@ -3947,9 +3947,9 @@ pub(crate) fn create_playback_session_fingerprint(
extension: Option<&str>,
) -> String {
// This scopes the session identity, not the session address-tracking policy.
// Adaptive playlist starts need a per-initial-socket token so two players behind
// the same IP/UA can watch the same HLS/DASH stream independently. The created
// UserSession itself can still be non-socket-bound.
// Adaptive and seekable playback use the stable client fingerprint so follow-up
// requests on new sockets reuse the same logical session. Plain live playback
// remains socket-bound so a separate connection creates a separate session.
let session_bound = is_session_based_playback(item_type, extension);
let socket_bound = !session_bound && is_socket_bound_playback_session(item_type, extension);
create_session_fingerprint(fingerprint, username, virtual_id, socket_bound)
-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::*;
-15
View File
@@ -1,15 +0,0 @@
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, user: &str, list_slug: &str) -> Result<(), TuliproxError> {
match status.as_u16() {
404 => Err(TuliproxError::RepositoryTrakt(format!("Trakt list not found: {user}:{list_slug}"))),
401 => Err(TuliproxError::RepositoryTrakt("Trakt API key is invalid or expired".to_string())),
429 => Err(TuliproxError::RepositoryTrakt("Trakt API rate limit exceeded".to_string())),
_ => Err(TuliproxError::RepositoryTrakt(format!(
"Trakt API error {status}: {}",
status.canonical_reason().unwrap_or("Unknown")
))),
}
}
-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,50 +1,60 @@
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, TRAKT_API_KEY},
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, api_config: TraktApiConfig) -> Self {
let headers = Self::create_headers(&api_config);
Self { client, api_config, headers }
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 })
}
fn create_headers(api_config: &TraktApiConfig) -> HeaderMap {
let mut headers = HeaderMap::new();
fn create_headers(api_config: &TraktApiConfig) -> Result<HeaderMap, TuliproxError> {
if api_config.api_key.is_empty() {
return Err(TuliproxError::Config(
"Trakt Client ID is missing; configure trakt.api.api_key before enabling Trakt lists or charts",
));
}
let mut client_id = HeaderValue::from_str(api_config.api_key.as_str()).map_err(|_| {
TuliproxError::Config(
"Trakt Client ID contains characters that cannot be used in an HTTP header; update trakt.api.api_key",
)
})?;
client_id.set_sensitive(true);
let mut headers = HeaderMap::new();
headers.insert(reqwest::header::CONTENT_TYPE, HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()));
headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_str(api_config.user_agent.as_str())
.unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_USER_AGENT)),
);
headers.insert(
"trakt-api-key",
HeaderValue::from_str(api_config.api_key.as_str())
.unwrap_or_else(|_| HeaderValue::from_static(TRAKT_API_KEY)),
);
headers.insert("trakt-api-key", client_id);
headers.insert(
"trakt-api-version",
HeaderValue::from_str(api_config.version.as_str()).unwrap_or_else(|_| HeaderValue::from_static("2")),
);
headers
Ok(headers)
}
fn build_list_url(&self, user: &str, list_slug: &str) -> String {
@@ -55,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",
@@ -65,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,
@@ -122,13 +135,12 @@ impl TraktClient {
) -> Result<TraktListItemsPage, TuliproxError> {
let url = self.build_list_url(&list_config.user, &list_config.list_slug);
let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}");
let list_id = format!("{}:{}", list_config.user, list_config.list_slug);
let (response_text, page_count, item_count) =
self.fetch_trakt_page(request_url, "list", (&list_config.user, &list_config.list_slug), 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);
self.fetch_trakt_page(request_url, "list", &list_id, page).await?;
let items: Vec<TraktListItem> = serde_json::from_str(&response_text).map_err(|error: serde_json::Error| {
TuliproxError::Config(format!("Failed to parse Trakt response: {error}"))
})?;
Ok(TraktListItemsPage { items, page_count, item_count })
}
@@ -142,29 +154,26 @@ impl TraktClient {
let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}");
let chart_id = format!("{}:{}", chart_config.kind, chart_config.chart);
let (response_text, page_count, item_count) =
self.fetch_trakt_page(request_url, "chart", ("charts", &chart_id), page).await?;
self.fetch_trakt_page(request_url, "chart", &chart_id, page).await?;
let items = parse_chart_items(&response_text, chart_config, page)
.map_err(|error| TuliproxError::Config(format!("Failed to parse Trakt chart response: {error}")))?;
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,
error_label: &str,
error_id: (&str, &str),
resource_kind: &str,
resource_id: &str,
page: u32,
) -> Result<(String, u32, Option<u32>), TuliproxError> {
let response = self.client.get(&request_url).headers(self.headers.clone()).send().await.map_err(|err| {
TuliproxError::Config(format!("Failed to fetch Trakt {error_label} {request_url}: {err}"))
TuliproxError::Config(format!("Failed to fetch Trakt {resource_kind} {request_url}: {err}"))
})?;
if !response.status().is_success() {
handle_trakt_api_error(response.status(), error_id.0, error_id.1)?;
handle_trakt_api_error(response.status(), resource_kind, resource_id)?;
}
let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page);
@@ -190,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()
@@ -198,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()
@@ -206,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()
@@ -214,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()
@@ -229,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())
}
@@ -246,7 +245,8 @@ fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Opt
#[cfg(test)]
mod tests {
use super::*;
use shared::model::{TraktChartKind, TraktChartType, TraktContentType};
use reqwest::StatusCode;
use shared::model::TraktContentType;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
@@ -256,19 +256,75 @@ mod tests {
net::TcpListener,
};
#[test]
fn trakt_client_rejects_blank_client_id_before_use() {
for client_id in ["", " \t\r\n "] {
let result =
TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), client_id));
let Err(error) = result else { panic!("blank Client ID should be rejected") };
assert!(error.message().contains("Trakt Client ID is missing"));
assert!(error.message().contains("trakt.api.api_key"));
}
}
#[test]
fn trakt_client_rejects_invalid_client_id_without_echoing_it() {
let invalid_client_id = "sensitive-client-id\ninjected-header";
let result =
TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), invalid_client_id));
let Err(error) = result else { panic!("header-invalid Client ID should be rejected") };
assert!(error.message().contains("Trakt Client ID"));
assert!(error.message().contains("trakt.api.api_key"));
assert!(!error.message().contains("sensitive-client-id"));
assert!(!error.message().contains("injected-header"));
}
#[tokio::test]
async fn valid_client_id_is_trimmed_and_sent_in_trakt_api_key_header() {
let requests = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_single_response_trakt_server("[]", Arc::clone(&requests)).await;
let client = TraktClient::new(reqwest::Client::new(), api_config(base_url, " user-supplied-client-id "))
.expect("valid Client ID should construct a Trakt client");
client
.get_chart_items(&chart_config(TraktChartKind::Movies, TraktChartType::Popular))
.await
.expect("chart request should succeed");
assert!(client.headers.get("trakt-api-key").expect("Client ID header").is_sensitive());
let requests = requests.lock().expect("requests");
assert_eq!(request_header(&requests[0], "trakt-api-key"), Some("user-supplied-client-id"));
}
#[tokio::test]
async fn unsuccessful_status_is_translated_before_plain_text_body_parsing() {
let requests = Arc::new(Mutex::new(Vec::new()));
let base_url = spawn_status_response_trakt_server(
StatusCode::FORBIDDEN,
"remote response body must not be logged",
Arc::clone(&requests),
)
.await;
let client = client(base_url);
let error = client
.get_chart_items(&chart_config(TraktChartKind::Movies, TraktChartType::Trending))
.await
.expect_err("403 should fail");
assert!(error.message().contains("Trakt denied the request"));
assert!(!error.message().contains("remote response body"));
assert_eq!(requests.lock().expect("requests").len(), 1);
}
#[tokio::test]
async fn get_list_items_follows_trakt_pagination_headers() {
let requests = Arc::new(AtomicUsize::new(0));
let base_url = spawn_paged_trakt_server(Arc::clone(&requests)).await;
let client = TraktClient::new(
reqwest::Client::new(),
TraktApiConfig {
api_key: "test-key".to_string(),
version: "2".to_string(),
url: base_url,
user_agent: "tuliprox-test".to_string(),
},
);
let client = TraktClient::new(reqwest::Client::new(), api_config(base_url, "test-key"))
.expect("valid Client ID should construct a Trakt client");
let list_config = TraktListConfig {
user: "user".to_string(),
list_slug: "list".to_string(),
@@ -281,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);
}
@@ -301,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 "));
}
@@ -321,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 "));
}
@@ -359,6 +412,14 @@ mod tests {
}
async fn spawn_single_response_trakt_server(body: &'static str, requests: Arc<Mutex<Vec<String>>>) -> String {
spawn_status_response_trakt_server(StatusCode::OK, body, requests).await
}
async fn spawn_status_response_trakt_server(
status: StatusCode,
body: &'static str,
requests: Arc<Mutex<Vec<String>>>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
@@ -377,8 +438,11 @@ mod tests {
}
requests.lock().expect("requests").push(String::from_utf8_lossy(&request_bytes).to_string());
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(), body
"HTTP/1.1 {} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown"),
body.len(),
body
);
stream.write_all(response.as_bytes()).await.expect("write response");
});
@@ -386,15 +450,24 @@ mod tests {
}
fn client(base_url: String) -> TraktClient {
TraktClient::new(
reqwest::Client::new(),
TraktApiConfig {
api_key: "test-key".to_string(),
version: "2".to_string(),
url: base_url,
user_agent: "tuliprox-test".to_string(),
},
)
TraktClient::new(reqwest::Client::new(), api_config(base_url, "test-key"))
.expect("valid Client ID should construct a Trakt client")
}
fn api_config(base_url: String, client_id: &str) -> TraktApiConfig {
TraktApiConfig {
api_key: client_id.to_string(),
version: "2".to_string(),
url: base_url,
user_agent: "tuliprox-test".to_string(),
}
}
fn request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
fn chart_config(kind: TraktChartKind, chart: TraktChartType) -> TraktChartConfig {
+76
View File
@@ -0,0 +1,76 @@
use reqwest::StatusCode;
use shared::error::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",
)),
403 => Err(TuliproxError::RepositoryTrakt(
"Trakt denied the request (HTTP 403 Forbidden); check the configured Client ID and resource access; creating Trakt API applications currently requires active VIP membership",
)),
404 => Err(TuliproxError::RepositoryTrakt(format!(
"Trakt {resource_kind} not found: {resource_id}"
))),
429 => Err(TuliproxError::RepositoryTrakt(
"Trakt API rate limit exceeded (HTTP 429 Too Many Requests); retry later",
)),
_ => Err(TuliproxError::RepositoryTrakt(format!(
"Trakt API error {status}: {}",
status.canonical_reason().unwrap_or("Unknown")
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn translated_error(status: StatusCode, resource_kind: &str, resource_id: &str) -> TuliproxError {
handle_trakt_api_error(status, resource_kind, resource_id)
.expect_err("unsuccessful status should be translated")
}
#[test]
fn trakt_api_errors_translate_unauthorized_client_id() {
let message = translated_error(StatusCode::UNAUTHORIZED, "list", "alice:watchlist").message().to_string();
assert!(message.contains("401"));
assert!(message.contains("Client ID"));
assert!(message.contains("trakt.api.api_key"));
assert!(!message.contains("alice:watchlist"));
}
#[test]
fn trakt_api_errors_translate_forbidden_without_overstating_vip_cause() {
let message = translated_error(StatusCode::FORBIDDEN, "chart", "movies:trending").message().to_string();
assert!(message.contains("403"));
assert!(message.contains("Trakt denied the request"));
assert!(message.contains("configured Client ID"));
assert!(message.contains("resource access"));
assert!(message.contains("creating Trakt API applications currently requires active VIP"));
}
#[test]
fn trakt_api_errors_distinguish_list_and_chart_not_found() {
let list_message = translated_error(StatusCode::NOT_FOUND, "list", "alice:watchlist").message().to_string();
let chart_message = translated_error(StatusCode::NOT_FOUND, "chart", "movies:trending").message().to_string();
assert_eq!(list_message, "Trakt list not found: alice:watchlist");
assert_eq!(chart_message, "Trakt chart not found: movies:trending");
}
#[test]
fn trakt_api_errors_translate_rate_limit() {
let message = translated_error(StatusCode::TOO_MANY_REQUESTS, "chart", "shows:popular").message().to_string();
assert!(message.contains("429"));
assert!(message.contains("rate limit"));
assert!(message.contains("retry later"));
}
}
+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;
-689
View File
@@ -1,689 +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) -> Self {
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone());
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);
}
let processor = TraktCategoriesProcessor::new(http_client, trakt_config);
processor.process_trakt_categories(playlist, target, trakt_config).await
}
#[cfg(test)]
mod tests {
use super::*;
use shared::model::{
EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, TraktContentType,
VideoStreamProperties,
};
#[test]
pub fn test_quality() {
let quality = extract_quality("Hello HD UHD 720p");
assert!(quality.is_some());
assert_eq!("UHD", quality.unwrap());
}
#[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());
}
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
+5
View File
@@ -42,6 +42,11 @@ sources:
skip_video_direct_source: true
skip_series_direct_source: true
trakt:
# Set enabled to true after configuring api.api_key with your Trakt Client ID.
# See the documentation for the TRAKT_CLIENT_ID environment-variable example.
enabled: false
api:
api_key: ""
lists:
- user: "linaspurinis"
list_slug: "top-watched-movies-of-the-week"
+37 -21
View File
@@ -1661,8 +1661,10 @@ output:
skip_series_direct_source: true
update_strategy: instant
trakt:
enabled: true
api:
api_key: "YOUR_API_KEY"
# Despite the compatible field name, this value is the Trakt Client ID.
api_key: "${env:TRAKT_CLIENT_ID}"
version: "2"
url: "https://api.trakt.tv"
user_agent: "Mozilla/5.0"
@@ -1724,8 +1726,10 @@ sources:
skip_video_direct_source: true
skip_series_direct_source: true
trakt:
enabled: true
api:
api_key: "YOUR_API_KEY"
# Despite the compatible field name, this value is the Trakt Client ID.
api_key: "${env:TRAKT_CLIENT_ID}"
version: "2"
url: "https://api.trakt.tv"
user_agent: "Mozilla/5.0"
@@ -1752,28 +1756,40 @@ sources:
```
This configuration creates additional virtual categories populated with matched entries from the configured Trakt user
lists and public Trakt charts.
lists and public Trakt charts. Define `TRAKT_CLIENT_ID` in the environment of the Tuliprox process before enabling the
block.
The serialized field remains `api.api_key` for configuration compatibility, but its value is the Client ID of your
Trakt API application and is sent in the `trakt-api-key` header. Tuliprox does not bundle a Client ID and never falls
back to another identity. Creating Trakt API applications currently requires active VIP membership. A `403 Forbidden`
response only means that Trakt denied the request; check both the configured Client ID and access to the requested
resource rather than assuming that every `403` proves a particular account state.
If lists or charts are configured while the Client ID is blank or cannot be used as an HTTP header, Tuliprox makes no
Trakt request, logs one target-scoped warning, and skips only optional Trakt curation. The rest of target processing
continues. A disabled block, or a block with no lists or charts, remains a silent no-op.
##### Trakt Parameters
| Parameter | Type | Required | Default | Technical Impact & Background |
| :------------------------------- | :------ | :------: | :--------------------- | :----------------------------------------------------------------------------------------------------------------------------------------- |
| `api.api_key` | String | Yes | | Trakt API key used for authenticated access. Without a valid key, Tuliprox cannot fetch remote list content. |
| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. |
| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. |
| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. |
| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. |
| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. |
| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. |
| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. |
| `lists[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted for this list, disabling title/year fuzzy fallback and reducing false positives. |
| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. |
| `charts[]` | List | No | `[]` | Public Trakt chart definitions. Unlike `lists[]`, these are system charts and do not have a user/list owner. |
| `charts[].kind` | Enum | Yes | | `movies` or `shows`. Aliases such as `movie`, `vod`, `show`, `series`, and `tvshows` are accepted. |
| `charts[].chart` | Enum | Yes | | Public chart to fetch. MVP supports `trending` and `popular`. |
| `charts[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. |
| `charts[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted. This is recommended for dynamic charts to avoid fuzzy false positives. |
| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for chart title matching when `tmdb_only` is not enabled. |
| Parameter | Type | Required | Default | Technical Impact & Background |
| :------------------------------- | :------ | :------: | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | Bool | No | `true` | Enables Trakt curation. Keep it `false` until an explicit Client ID is configured. |
| `api.api_key` | String | Yes | | Compatible field that stores the Trakt Client ID. There is no bundled fallback; use an explicit value such as `${env:TRAKT_CLIENT_ID}`. |
| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. |
| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. |
| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. |
| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. |
| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. |
| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. |
| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. |
| `lists[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted for this list, disabling title/year fuzzy fallback and reducing false positives. |
| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. |
| `charts[]` | List | No | `[]` | Public Trakt chart definitions. Unlike `lists[]`, these are system charts and do not have a user/list owner. |
| `charts[].kind` | Enum | Yes | | `movies` or `shows`. Aliases such as `movie`, `vod`, `show`, `series`, and `tvshows` are accepted. |
| `charts[].chart` | Enum | Yes | | Public chart to fetch. MVP supports `trending` and `popular`. |
| `charts[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. |
| `charts[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted. This is recommended for dynamic charts to avoid fuzzy false positives. |
| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for chart title matching when `tmdb_only` is not enabled. |
The `charts[]` MVP intentionally supports only public, non-OAuth Trakt charts. User-specific recommendations and
account-scoped history feeds are not fetched by this block.
+1 -1
View File
@@ -8,7 +8,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
shared = { version = "3.3.107", path = "../shared" }
shared = { version = "3.3.109", path = "../shared" }
chrono.workspace = true
yew = "0.23.0"
yew-router = "0.20.0"
+1
View File
@@ -953,6 +953,7 @@
"API_CONFIG": "API",
"API_CONFIGURATION": "تكوين API",
"API_KEY": "مفتاح API",
"TRAKT_CLIENT_ID": "معرّف عميل Trakt",
"API_PROXY": "وكيل API",
"API_PROXY_CONFIG": "تكوين وكيل API",
"API_URL": "عنوان URL لـ API",
+1
View File
@@ -956,6 +956,7 @@
"API_CONFIG": "API",
"API_CONFIGURATION": "API Configuration",
"API_KEY": "API-Key",
"TRAKT_CLIENT_ID": "Trakt Client ID",
"API_PROXY": "Api-Proxy",
"API_PROXY_CONFIG": "Api Proxy Config",
"API_URL": "API-URL",
+1
View File
@@ -912,6 +912,7 @@
"API_CONFIG": "API",
"API_CONFIGURATION": "Конфигурация API",
"API_KEY": "API-ключ",
"TRAKT_CLIENT_ID": "Идентификатор клиента Trakt",
"API_PROXY": "Api-прокси",
"API_PROXY_CONFIG": "Конфигурация Api-прокси",
"API_URL": "URL API",
@@ -27,7 +27,7 @@ const LABEL_LIVE: &str = "LABEL.LIVE";
const LABEL_VOD: &str = "LABEL.VOD";
const LABEL_SERIES: &str = "LABEL.SERIES";
const LABEL_FILTER: &str = "LABEL.FILTER";
const LABEL_TRAKT_API_KEY: &str = "LABEL.API_KEY";
const LABEL_TRAKT_CLIENT_ID: &str = "LABEL.TRAKT_CLIENT_ID";
const LABEL_TRAKT_API_VERSION: &str = "LABEL.API_VERSION";
const LABEL_TRAKT_API_URL: &str = "LABEL.API_URL";
const LABEL_TRAKT_LISTS: &str = "LABEL.TRAKT_LISTS";
@@ -449,14 +449,14 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
<h3>{translate.t(LABEL_API_CONFIGURATION)}</h3>
if props.allow_write {
<>
{ edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_KEY), api_key, TraktApiConfigFormAction::ApiKey) }
{ edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_CLIENT_ID), api_key, TraktApiConfigFormAction::ApiKey) }
{ edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) }
{ edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) }
{ edit_field_text!(trakt_api_form, translate.t(LABEL_USER_AGENT), user_agent, TraktApiConfigFormAction::UserAgent) }
</>
} else {
<>
{ config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_KEY), api_key) }
{ config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_CLIENT_ID), api_key) }
{ config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_VERSION), version) }
{ config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_URL), url) }
{ config_field!(trakt_api_form.form, translate.t(LABEL_USER_AGENT), user_agent) }
-2
View File
@@ -1,10 +1,8 @@
//! Trakt defaults.
pub const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3";
pub const TRAKT_API_VERSION: &str = "2";
pub const TRAKT_API_URL: &str = "https://api.trakt.tv";
pub fn default_trakt_api_key() -> String { String::from(TRAKT_API_KEY) }
pub fn default_trakt_api_version() -> String { String::from(TRAKT_API_VERSION) }
pub fn default_trakt_api_url() -> String { String::from(TRAKT_API_URL) }
pub fn default_trakt_fuzzy_threshold() -> u8 { 80 }
+24 -4
View File
@@ -1,6 +1,6 @@
use crate::defaults::{
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY,
TRAKT_API_URL, TRAKT_API_VERSION,
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL,
TRAKT_API_VERSION,
};
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
@@ -32,8 +32,7 @@ pub struct TraktApiConfigDto {
impl TraktApiConfigDto {
pub fn prepare(&mut self) {
let key = self.api_key.trim();
self.api_key = String::from(if key.is_empty() { TRAKT_API_KEY } else { key });
self.api_key = self.api_key.trim().to_string();
let version = self.version.trim();
self.version = String::from(if version.is_empty() { TRAKT_API_VERSION } else { version });
let url = self.url.trim();
@@ -152,6 +151,27 @@ impl TraktConfigDto {
mod tests {
use super::*;
#[test]
fn trakt_api_prepare_keeps_blank_client_id_absent() {
for client_id in ["", " \t\r\n "] {
let mut config = TraktApiConfigDto { api_key: client_id.to_string(), ..TraktApiConfigDto::default() };
config.prepare();
assert!(config.api_key.is_empty(), "blank Client ID should remain absent");
}
}
#[test]
fn trakt_api_prepare_trims_supplied_client_id() {
let mut config =
TraktApiConfigDto { api_key: " user-supplied-client-id ".to_string(), ..TraktApiConfigDto::default() };
config.prepare();
assert_eq!(config.api_key, "user-supplied-client-id");
}
#[test]
fn trakt_content_type_parsing_and_display_remain_stable() {
assert_eq!("vod".parse::<TraktContentType>().ok(), Some(TraktContentType::Vod));
-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(),