mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-13 03:12:19 +02:00
refactor: extract source-neutral playlist curation (#860)
* refactor: extract source-neutral playlist curation * fix: keep curation matches within media kind --------- Co-authored-by: Juanjo Presa <juanjop@gmail.com>
This commit is contained in:
co-authored by
Juanjo Presa
parent
019fb7fde2
commit
145b082a4f
@@ -1365,6 +1365,11 @@
|
||||
|
||||
## 🛠 Maintenance
|
||||
|
||||
- **Playlist curation now has a dedicated capability boundary**: matching, ordering, and virtual-category projection
|
||||
live in the source-neutral `tuliprox-curation` crate, while Trakt HTTP/JSON handling translates records at the edge.
|
||||
Existing `output[].trakt` configuration, category identity, matching behavior, and partial-success semantics remain
|
||||
unchanged.
|
||||
|
||||
- **`AdmissionRequest` bundles the request-scoped admission arguments**: five functions each threaded the same ten
|
||||
positional parameters, three of them consecutive bare `bool`s (`use_session_admission`, then
|
||||
`activate_unbound_session` a slot later). Call sites read `..., true, Some(session_token), true, guard)` — a shape
|
||||
|
||||
Generated
+18
-1
@@ -4475,7 +4475,6 @@ dependencies = [
|
||||
"ipnet",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
"parking_lot",
|
||||
"paste",
|
||||
"path-clean",
|
||||
@@ -4503,6 +4502,23 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tuliprox-curation"
|
||||
version = "3.3.108"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"log",
|
||||
"mime",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"strsim",
|
||||
"tokio",
|
||||
"tuliprox-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tuliprox-dvr"
|
||||
version = "3.3.108"
|
||||
@@ -4743,6 +4759,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tuliprox-core",
|
||||
"tuliprox-curation",
|
||||
"tuliprox-iptv",
|
||||
"tuliprox-library",
|
||||
"tuliprox-media-server",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Playlist curation capability.
|
||||
//!
|
||||
//! This crate owns the trusted, source-neutral matching and virtual-category
|
||||
//! projection kernel, plus the concrete edge adapter that translates foreign
|
||||
//! source data before invoking that kernel. Trakt is currently the only adapter.
|
||||
//!
|
||||
//! Serialized configuration remains in `shared`, resolved configuration remains
|
||||
//! in `tuliprox-core`, and target-stage orchestration and persistence remain in
|
||||
//! their existing processing and repository crates.
|
||||
|
||||
mod kernel;
|
||||
mod trakt;
|
||||
|
||||
pub use trakt::curate_trakt_categories;
|
||||
@@ -1,22 +1,28 @@
|
||||
use super::errors::handle_trakt_api_error;
|
||||
use crate::model::{TraktApiConfig, TraktChartConfig, TraktListConfig, TraktListItem, TraktMovie, TraktShow};
|
||||
use super::{
|
||||
errors::handle_trakt_api_error,
|
||||
model::{TraktListItem, TraktMovie, TraktShow, TraktTrendingMovieItem, TraktTrendingShowItem},
|
||||
};
|
||||
use log::{debug, info};
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde::Deserialize;
|
||||
use shared::{defaults::DEFAULT_USER_AGENT, error::TuliproxError, utils::trim_last_slash};
|
||||
use shared::{
|
||||
defaults::DEFAULT_USER_AGENT,
|
||||
error::TuliproxError,
|
||||
model::{TraktChartKind, TraktChartType},
|
||||
utils::trim_last_slash,
|
||||
};
|
||||
use tuliprox_core::model::{TraktApiConfig, TraktChartConfig, TraktListConfig};
|
||||
|
||||
const TRAKT_PAGE_LIMIT: u32 = 100;
|
||||
const TRAKT_MAX_PAGES: u32 = 100;
|
||||
|
||||
pub struct TraktClient {
|
||||
pub(super) struct TraktClient {
|
||||
client: reqwest::Client,
|
||||
api_config: TraktApiConfig,
|
||||
// Pre-computed headers to avoid recreating them each time
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl TraktClient {
|
||||
pub fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result<Self, TuliproxError> {
|
||||
pub(super) fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result<Self, TuliproxError> {
|
||||
api_config.api_key = api_config.api_key.trim().to_string();
|
||||
let headers = Self::create_headers(&api_config)?;
|
||||
Ok(Self { client, api_config, headers })
|
||||
@@ -59,7 +65,10 @@ impl TraktClient {
|
||||
format!("{}/{}/{}", trim_last_slash(&self.api_config.url), chart_config.kind, chart_config.chart)
|
||||
}
|
||||
|
||||
pub async fn get_chart_items(&self, chart_config: &TraktChartConfig) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
pub(super) async fn get_chart_items(
|
||||
&self,
|
||||
chart_config: &TraktChartConfig,
|
||||
) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
let id_label = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
self.paginate_items(
|
||||
"chart",
|
||||
@@ -69,15 +78,15 @@ impl TraktClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
pub(super) async fn get_list_items(
|
||||
&self,
|
||||
list_config: &TraktListConfig,
|
||||
) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
let id_label = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
self.paginate_items("list", id_label, |page| async move { self.get_list_items_page(list_config, page).await })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shared body of `get_chart_items` and `get_list_items`.
|
||||
/// Walks Trakt's paginated response one page at a time via `fetch_page`,
|
||||
/// logging per-page progress with `kind_label` and `id_label` for context.
|
||||
async fn paginate_items<F, Fut>(
|
||||
&self,
|
||||
kind_label: &'static str,
|
||||
@@ -129,11 +138,9 @@ impl TraktClient {
|
||||
let list_id = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
let (response_text, page_count, item_count) =
|
||||
self.fetch_trakt_page(request_url, "list", &list_id, page).await?;
|
||||
let mut items: Vec<TraktListItem> =
|
||||
serde_json::from_str(&response_text).map_err(|error: serde_json::Error| {
|
||||
TuliproxError::Config(format!("Failed to parse Trakt response: {error}"))
|
||||
})?;
|
||||
items.iter_mut().for_each(TraktListItem::prepare);
|
||||
let items: Vec<TraktListItem> = serde_json::from_str(&response_text).map_err(|error: serde_json::Error| {
|
||||
TuliproxError::Config(format!("Failed to parse Trakt response: {error}"))
|
||||
})?;
|
||||
|
||||
Ok(TraktListItemsPage { items, page_count, item_count })
|
||||
}
|
||||
@@ -154,9 +161,6 @@ impl TraktClient {
|
||||
Ok(TraktListItemsPage { items, page_count, item_count })
|
||||
}
|
||||
|
||||
/// Shared body of `get_list_items_page` / `get_chart_items_page`.
|
||||
/// Issues the GET, validates the status, parses the pagination headers, and
|
||||
/// returns the raw response body. Per-type item parsing stays with the caller.
|
||||
async fn fetch_trakt_page(
|
||||
&self,
|
||||
request_url: String,
|
||||
@@ -195,7 +199,7 @@ fn parse_chart_items(
|
||||
) -> Result<Vec<TraktListItem>, serde_json::Error> {
|
||||
let rank_base = page.saturating_sub(1).saturating_mul(TRAKT_PAGE_LIMIT);
|
||||
match (chart_config.kind, chart_config.chart) {
|
||||
(shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Popular) => {
|
||||
(TraktChartKind::Movies, TraktChartType::Popular) => {
|
||||
let items = serde_json::from_str::<Vec<TraktMovie>>(response_text)?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
@@ -203,7 +207,7 @@ fn parse_chart_items(
|
||||
.map(|(index, movie)| TraktListItem::from_movie_chart(movie, chart_rank(rank_base, index)))
|
||||
.collect())
|
||||
}
|
||||
(shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Trending) => {
|
||||
(TraktChartKind::Movies, TraktChartType::Trending) => {
|
||||
let items = serde_json::from_str::<Vec<TraktTrendingMovieItem>>(response_text)?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
@@ -211,7 +215,7 @@ fn parse_chart_items(
|
||||
.map(|(index, item)| TraktListItem::from_movie_chart(item.movie, chart_rank(rank_base, index)))
|
||||
.collect())
|
||||
}
|
||||
(shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Popular) => {
|
||||
(TraktChartKind::Shows, TraktChartType::Popular) => {
|
||||
let items = serde_json::from_str::<Vec<TraktShow>>(response_text)?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
@@ -219,7 +223,7 @@ fn parse_chart_items(
|
||||
.map(|(index, show)| TraktListItem::from_show_chart(show, chart_rank(rank_base, index)))
|
||||
.collect())
|
||||
}
|
||||
(shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Trending) => {
|
||||
(TraktChartKind::Shows, TraktChartType::Trending) => {
|
||||
let items = serde_json::from_str::<Vec<TraktTrendingShowItem>>(response_text)?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
@@ -234,16 +238,6 @@ fn chart_rank(rank_base: u32, index: usize) -> u32 {
|
||||
rank_base.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)).saturating_add(1)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TraktTrendingMovieItem {
|
||||
movie: TraktMovie,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TraktTrendingShowItem {
|
||||
show: TraktShow,
|
||||
}
|
||||
|
||||
fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Option<u32> {
|
||||
headers.get(name).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::<u32>().ok())
|
||||
}
|
||||
@@ -252,7 +246,7 @@ fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Opt
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reqwest::StatusCode;
|
||||
use shared::model::{TraktChartKind, TraktChartType, TraktContentType};
|
||||
use shared::model::TraktContentType;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc, Mutex,
|
||||
@@ -343,8 +337,7 @@ mod tests {
|
||||
let items = client.get_list_items(&list_config).await.expect("paged list should load");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].content_type, TraktContentType::Vod);
|
||||
assert_eq!(items[1].content_type, TraktContentType::Vod);
|
||||
assert!(items.iter().all(|item| item.item_type == "movie"));
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
@@ -363,7 +356,6 @@ mod tests {
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].rank, Some(1));
|
||||
assert_eq!(items[0].content_type, TraktContentType::Vod);
|
||||
assert_eq!(items[0].movie.as_ref().expect("movie").ids.tmdb, Some(11));
|
||||
assert!(requests.lock().expect("requests")[0].contains("GET /movies/trending?page=1&limit=100 "));
|
||||
}
|
||||
@@ -383,7 +375,6 @@ mod tests {
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].rank, Some(1));
|
||||
assert_eq!(items[0].content_type, TraktContentType::Series);
|
||||
assert_eq!(items[0].show.as_ref().expect("show").ids.tmdb, Some(22));
|
||||
assert!(requests.lock().expect("requests")[0].contains("GET /shows/popular?page=1&limit=100 "));
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
use reqwest::StatusCode;
|
||||
use shared::error::TuliproxError;
|
||||
|
||||
/// Handle Trakt API response status and convert to appropriate error
|
||||
pub fn handle_trakt_api_error(status: StatusCode, resource_kind: &str, resource_id: &str) -> Result<(), TuliproxError> {
|
||||
pub(super) fn handle_trakt_api_error(
|
||||
status: StatusCode,
|
||||
resource_kind: &str,
|
||||
resource_id: &str,
|
||||
) -> Result<(), TuliproxError> {
|
||||
match status.as_u16() {
|
||||
401 => Err(TuliproxError::RepositoryTrakt(
|
||||
"Trakt rejected the configured Client ID (HTTP 401 Unauthorized); check trakt.api.api_key",
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,895 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
use log::{debug, info, trace, warn};
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{
|
||||
FieldGet, FieldSet, HeaderField, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType,
|
||||
TraktContentType, UUIDType, XtreamCluster,
|
||||
},
|
||||
utils::{hash_string, Internable, CONSTANTS},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use strsim::normalized_levenshtein;
|
||||
use tuliprox_core::{
|
||||
model::{ConfigTarget, TraktCategoryConfig, TraktConfig, TraktListItem, TraktMatchItem, TraktMatchResult},
|
||||
utils::{extract_year_from_title, normalize_title_for_matching, trace_if_enabled, with, TraktClient},
|
||||
};
|
||||
|
||||
fn extract_quality(value: &str) -> Option<&str> {
|
||||
if let Some(caps) = CONSTANTS.re_quality.captures(value) {
|
||||
if let Some(val) = caps.get(0) {
|
||||
return Some(val.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Utility functions for content type compatibility
|
||||
fn should_include_item(item: &TraktListItem, content_type: TraktContentType) -> bool {
|
||||
match content_type {
|
||||
TraktContentType::Vod => item.content_type == TraktContentType::Vod,
|
||||
TraktContentType::Series => item.content_type == TraktContentType::Series,
|
||||
TraktContentType::Both => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContentType) -> bool {
|
||||
match content_type {
|
||||
TraktContentType::Vod => cluster == XtreamCluster::Video,
|
||||
TraktContentType::Series => cluster == XtreamCluster::Series,
|
||||
TraktContentType::Both => matches!(cluster, XtreamCluster::Video | XtreamCluster::Series),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_matchable_playlist_item(item_type: PlaylistItemType, content_type: TraktContentType) -> bool {
|
||||
match content_type {
|
||||
TraktContentType::Vod => item_type.is_video(),
|
||||
TraktContentType::Series => {
|
||||
matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
}
|
||||
TraktContentType::Both => matches!(
|
||||
item_type,
|
||||
PlaylistItemType::Video
|
||||
| PlaylistItemType::LocalVideo
|
||||
| PlaylistItemType::SeriesInfo
|
||||
| PlaylistItemType::LocalSeriesInfo
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_year_bonus(playlist_year: Option<u32>, trakt_year: Option<u32>) -> f64 {
|
||||
if let (Some(p_year), Some(t_year)) = (playlist_year, trakt_year) {
|
||||
if p_year == t_year {
|
||||
// Perfect year match gets substantial bonus
|
||||
return 0.5;
|
||||
}
|
||||
return -0.5;
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn find_best_fuzzy_match_for_item<'a>(
|
||||
channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>),
|
||||
trakt_items: &'a [TraktMatchItem],
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
) -> Option<TraktMatchResult<'a>> {
|
||||
// Try fuzzy matching if no exact match found
|
||||
let normalized_playlist_title = channel.1;
|
||||
let playlist_year = channel.2;
|
||||
let threshold = f64::from(category_config.fuzzy_match_threshold) / 100.0;
|
||||
let mut best_match: Option<(&TraktMatchItem, f64)> = None;
|
||||
|
||||
for trakt_item in trakt_items {
|
||||
let title_score = normalized_levenshtein(&normalized_playlist_title, &trakt_item.normalized_title);
|
||||
|
||||
if title_score >= threshold {
|
||||
// Calculate year bonus
|
||||
let year_bonus = calculate_year_bonus(playlist_year, trakt_item.year);
|
||||
let mut combined_score = title_score + year_bonus;
|
||||
|
||||
// Clamp score to [0.0, 1.0]
|
||||
combined_score = combined_score.clamp(0.0, 1.0);
|
||||
|
||||
// Check if this is the best match so far and meets threshold
|
||||
if combined_score >= threshold {
|
||||
if let Some((_, current_best_score)) = &best_match {
|
||||
if combined_score > *current_best_score {
|
||||
best_match = Some((trakt_item, combined_score));
|
||||
}
|
||||
} else {
|
||||
best_match = Some((trakt_item, combined_score));
|
||||
}
|
||||
// early exit strategy
|
||||
if combined_score >= 0.99 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((trakt_item, combined_score)) = best_match {
|
||||
// let match_type = if playlist_year.is_some() && trakt_item.year.is_some() {
|
||||
// MatchType::FuzzyTitleYear
|
||||
// } else {
|
||||
// MatchType::FuzzyTitle
|
||||
// };
|
||||
|
||||
trace_if_enabled!(
|
||||
"Fuzzy match: '{}' -> '{}' (final: {combined_score:.3}", /*, type: {match_type:?})"*/
|
||||
channel.0.header.title,
|
||||
trakt_item.title
|
||||
);
|
||||
|
||||
return Some(TraktMatchResult {
|
||||
playlist_item: channel.0,
|
||||
trakt_item,
|
||||
match_score: combined_score,
|
||||
// match_type: match_type.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_best_match_for_item<'a>(
|
||||
channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>),
|
||||
trakt_items: &'a [TraktMatchItem<'a>],
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
) -> Option<TraktMatchResult<'a>> {
|
||||
// Try TMDB exact matching first
|
||||
if let Some(playlist_tmdb_id) = channel.3 {
|
||||
for trakt_item in trakt_items {
|
||||
if Some(playlist_tmdb_id) == trakt_item.tmdb_id {
|
||||
trace!("TMDB exact match: '{}' (TMDB: {})", channel.0.header.title, playlist_tmdb_id);
|
||||
return Some(TraktMatchResult {
|
||||
playlist_item: channel.0,
|
||||
trakt_item,
|
||||
match_score: 1.0,
|
||||
// match_type: MatchType::TmdbExact,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if category_config.tmdb_only {
|
||||
return None;
|
||||
}
|
||||
|
||||
find_best_fuzzy_match_for_item(channel, trakt_items, category_config)
|
||||
}
|
||||
|
||||
fn create_category_from_matches<'a>(
|
||||
matches: Vec<TraktMatchResult<'a>>,
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&'a PlaylistItem>>,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
if matches.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = IndexMap::new();
|
||||
|
||||
let mut sorted_matches = matches;
|
||||
sorted_matches.sort_by(|a, b| {
|
||||
(a.trakt_item.rank.unwrap_or(9999), a.trakt_item.title.to_lowercase())
|
||||
.cmp(&(b.trakt_item.rank.unwrap_or(9999), b.trakt_item.title.to_lowercase()))
|
||||
});
|
||||
|
||||
let group_title = category_config.category_name.as_str().intern();
|
||||
|
||||
for match_result in sorted_matches {
|
||||
let modified_item = clone_item_for_trakt_category(
|
||||
match_result.playlist_item,
|
||||
category_config.category_name.as_str(),
|
||||
&group_title,
|
||||
);
|
||||
let parent_uuid = modified_item.header.uuid.intern();
|
||||
let is_series_info =
|
||||
matches!(modified_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo);
|
||||
let child_lookup_keys =
|
||||
if is_series_info { series_info_child_lookup_keys(match_result.playlist_item) } else { Vec::new() };
|
||||
let cluster = modified_item.header.xtream_cluster;
|
||||
matched_items_by_cluster.entry(cluster).or_default().push(modified_item);
|
||||
|
||||
if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) {
|
||||
for child in children {
|
||||
let mut child =
|
||||
clone_item_for_trakt_category(child, category_config.category_name.as_str(), &group_title);
|
||||
child.header.parent_code = parent_uuid.clone();
|
||||
matched_items_by_cluster.entry(child.header.xtream_cluster).or_default().push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched_items_by_cluster
|
||||
.into_iter()
|
||||
.map(|(cluster, channels)| PlaylistGroup {
|
||||
id: 0,
|
||||
title: group_title.clone(),
|
||||
channels,
|
||||
xtream_cluster: cluster,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group_title: &Arc<str>) -> PlaylistItem {
|
||||
let mut modified_item = item.clone();
|
||||
let source_uuid = if modified_item.header.uuid == UUIDType::default() {
|
||||
modified_item.get_uuid()
|
||||
} else {
|
||||
modified_item.header.uuid
|
||||
};
|
||||
|
||||
with!(mut modified_item.header => header {
|
||||
let title = header
|
||||
.get(HeaderField::Caption)
|
||||
.map_or_else(|| Arc::clone(&header.title), |value| value.to_arc());
|
||||
if extract_quality(&title).is_none() {
|
||||
if let Some(quality) = extract_quality(&header.group) {
|
||||
let mut caption = String::with_capacity(title.len() + 6);
|
||||
caption.push('[');
|
||||
caption.push_str(quality);
|
||||
caption.push_str("] ");
|
||||
caption.push_str(&title);
|
||||
header.set(HeaderField::Caption, &caption);
|
||||
}
|
||||
}
|
||||
header.group = group_title.clone();
|
||||
header.uuid = hash_string(&format!("trakt-category:{category_name}:{source_uuid}"));
|
||||
});
|
||||
|
||||
modified_item
|
||||
}
|
||||
|
||||
fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec<Arc<str>> {
|
||||
match series_info.header.item_type {
|
||||
PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()],
|
||||
PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap<Arc<str>, Vec<&PlaylistItem>> {
|
||||
let mut children = HashMap::<Arc<str>, Vec<&PlaylistItem>>::new();
|
||||
for playlist_group in playlist {
|
||||
for channel in &playlist_group.channels {
|
||||
if channel.header.item_type.is_series() && !channel.header.parent_code.is_empty() {
|
||||
children.entry(channel.header.parent_code.clone()).or_default().push(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
children
|
||||
}
|
||||
|
||||
fn match_trakt_items_with_playlist<'a>(
|
||||
trakt_items: &'a [TraktListItem],
|
||||
playlist: &'a [PlaylistGroup],
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let trakt_match_items: Vec<TraktMatchItem<'a>> = trakt_items
|
||||
.iter()
|
||||
.filter(|item| should_include_item(item, category_config.content_type))
|
||||
.filter_map(TraktMatchItem::from_trakt_list_item)
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Matching {} Trakt items against playlist for content type {:?}",
|
||||
trakt_match_items.len(),
|
||||
category_config.content_type
|
||||
);
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for playlist_group in playlist {
|
||||
for channel in &playlist_group.channels {
|
||||
if is_compatible_content_type(channel.header.xtream_cluster, category_config.content_type)
|
||||
&& is_matchable_playlist_item(channel.header.item_type, category_config.content_type)
|
||||
{
|
||||
let normalized_title = normalize_title_for_matching(&channel.header.title);
|
||||
let channel_year = extract_year_from_title(&channel.header.title);
|
||||
let channel_tmdb_id = channel.get_tmdb_id();
|
||||
if let Some(matched) = find_best_match_for_item(
|
||||
(channel, normalized_title, channel_year, channel_tmdb_id),
|
||||
&trakt_match_items,
|
||||
category_config,
|
||||
) {
|
||||
matches.push(matched);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let series_children_by_parent_code = series_children_by_parent_code(playlist);
|
||||
create_category_from_matches(matches, category_config, &series_children_by_parent_code)
|
||||
}
|
||||
|
||||
pub struct TraktCategoriesProcessor {
|
||||
client: TraktClient,
|
||||
}
|
||||
|
||||
impl TraktCategoriesProcessor {
|
||||
pub fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Result<Self, TuliproxError> {
|
||||
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
pub async fn process_trakt_categories(
|
||||
&self,
|
||||
playlist: &[PlaylistGroup],
|
||||
target: &ConfigTarget,
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Result<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
|
||||
if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() {
|
||||
debug!("No Trakt lists or charts configured for target {}", target.name);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processing {} Trakt lists and {} Trakt charts for target {}",
|
||||
trakt_config.lists.len(),
|
||||
trakt_config.charts.len(),
|
||||
target.name
|
||||
);
|
||||
let mut new_categories = Vec::new();
|
||||
let mut total_matches = 0;
|
||||
|
||||
for list_config in &trakt_config.lists {
|
||||
let cache_key = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
let category_config = TraktCategoryConfig::from(list_config);
|
||||
|
||||
match self.client.get_list_items(list_config).await {
|
||||
Ok(trakt_items) => {
|
||||
debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len());
|
||||
|
||||
let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config);
|
||||
for category in categories {
|
||||
if !category.channels.is_empty() {
|
||||
total_matches += category.channels.len();
|
||||
let category_len = category.channels.len();
|
||||
new_categories.push(category);
|
||||
debug!(
|
||||
"Created Trakt category '{}' with {category_len} items",
|
||||
category_config.category_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to fetch Trakt list {cache_key}: {}", err.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for chart_config in &trakt_config.charts {
|
||||
let cache_key = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
let category_config = TraktCategoryConfig::from(chart_config);
|
||||
|
||||
match self.client.get_chart_items(chart_config).await {
|
||||
Ok(trakt_items) => {
|
||||
debug!("Processing Trakt chart {cache_key} with {} items", trakt_items.len());
|
||||
|
||||
let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config);
|
||||
for category in categories {
|
||||
if !category.channels.is_empty() {
|
||||
total_matches += category.channels.len();
|
||||
let category_len = category.channels.len();
|
||||
new_categories.push(category);
|
||||
debug!(
|
||||
"Created Trakt category '{}' with {category_len} items",
|
||||
category_config.category_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to fetch Trakt chart {cache_key}: {}", err.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Trakt processing complete: created {} categories with {total_matches} total matches",
|
||||
new_categories.len()
|
||||
);
|
||||
|
||||
Ok(Some(new_categories))
|
||||
}
|
||||
}
|
||||
pub async fn process_trakt_categories_for_target(
|
||||
http_client: &reqwest::Client,
|
||||
playlist: &[PlaylistGroup],
|
||||
target: &ConfigTarget,
|
||||
) -> Result<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
|
||||
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
|
||||
trace!("No Trakt configuration found for target {}", target.name);
|
||||
return Ok(None);
|
||||
};
|
||||
if !trakt_config.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() {
|
||||
debug!("No Trakt lists or charts configured for target {}", target.name);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) {
|
||||
Ok(processor) => processor,
|
||||
Err(error) => {
|
||||
warn!("Skipping Trakt curation for target '{}': {}", target.name, error.message());
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
processor.process_trakt_categories(playlist, target, trakt_config).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::model::{
|
||||
ConfigTargetDto, EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties,
|
||||
TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto,
|
||||
TraktContentType, TraktListConfigDto, VideoStreamProperties, XtreamTargetOutputDto,
|
||||
};
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Mutex,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
#[test]
|
||||
pub fn test_quality() {
|
||||
let quality = extract_quality("Hello HD UHD 720p");
|
||||
assert!(quality.is_some());
|
||||
assert_eq!("UHD", quality.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_trakt_curation_without_client_id_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, true, vec![remote_list_config("Missing")], Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("missing Client ID should not fail target processing");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_trakt_config_with_sources_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, false, vec![remote_list_config("Disabled")], Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("disabled Trakt config should be a no-op");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trakt_config_without_lists_or_charts_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, true, Vec::new(), Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("empty Trakt config should be a no-op");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_list_does_not_suppress_successful_chart() {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target(
|
||||
"test-client-id",
|
||||
base_url,
|
||||
true,
|
||||
vec![remote_list_config("Unavailable List")],
|
||||
vec![remote_chart_config("Available Chart")],
|
||||
);
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Original".intern(),
|
||||
channels: vec![video_item("Movie 1", Some(11))],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
|
||||
let categories = process_trakt_categories_for_target(&reqwest::Client::new(), &playlist, &target)
|
||||
.await
|
||||
.expect("a failed Trakt source should not fail target processing")
|
||||
.expect("configured Trakt sources should produce a result");
|
||||
|
||||
assert_eq!(categories.len(), 1);
|
||||
assert_eq!(categories[0].title.as_ref(), "Available Chart");
|
||||
assert_eq!(categories[0].channels.len(), 1);
|
||||
assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie 1");
|
||||
assert_eq!(requests.lock().expect("requests").len(), 2);
|
||||
server.await.expect("test server should finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmdb_only_list_skips_title_fallback_matches() {
|
||||
let playlist_item = video_item("The Captive", None);
|
||||
let trakt_items = vec![trakt_movie("The Captive", Some(1915), Some(123), 1)];
|
||||
let list_config = list_config(true);
|
||||
|
||||
let matched = find_best_match_for_item(
|
||||
(&playlist_item, normalize_title_for_matching("The Captive"), None, playlist_item.get_tmdb_id()),
|
||||
&trakt_items,
|
||||
&list_config,
|
||||
);
|
||||
|
||||
assert!(matched.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmdb_only_list_keeps_tmdb_exact_matches() {
|
||||
let playlist_item = video_item("Cautivos", Some(456));
|
||||
let trakt_items = vec![trakt_movie("The Captive", Some(2014), Some(456), 1)];
|
||||
let list_config = list_config(true);
|
||||
|
||||
let matched = find_best_match_for_item(
|
||||
(&playlist_item, normalize_title_for_matching("Cautivos"), None, playlist_item.get_tmdb_id()),
|
||||
&trakt_items,
|
||||
&list_config,
|
||||
);
|
||||
|
||||
assert!(matched.is_some());
|
||||
assert_eq!(matched.expect("tmdb match").trakt_item.tmdb_id, Some(456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_playlist_item_can_appear_in_multiple_trakt_categories() {
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Original".intern(),
|
||||
channels: vec![video_item("The Smashing Machine", Some(760_329))],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let trakt_items = vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 1)];
|
||||
let a24_config = named_list_config("▸ A24", true);
|
||||
let renoir_config = named_list_config("▸ Cines Renoir", true);
|
||||
|
||||
let a24 = match_trakt_items_with_playlist(&trakt_items, &playlist, &a24_config);
|
||||
let renoir = match_trakt_items_with_playlist(&trakt_items, &playlist, &renoir_config);
|
||||
|
||||
assert_eq!(a24.len(), 1);
|
||||
assert_eq!(renoir.len(), 1);
|
||||
let a24_item = &a24[0].channels[0];
|
||||
let renoir_item = &renoir[0].channels[0];
|
||||
assert_eq!(a24_item.header.group.as_ref(), "▸ A24");
|
||||
assert_eq!(renoir_item.header.group.as_ref(), "▸ Cines Renoir");
|
||||
assert_ne!(a24_item.header.uuid, renoir_item.header.uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trakt_series_categories_clone_episode_children() {
|
||||
let mut series = series_item("Slow Horses", Some(12345));
|
||||
let source_parent_code = series.get_uuid().intern();
|
||||
series.header.uuid = series.get_uuid();
|
||||
let episode = episode_item("Old Scores", &source_parent_code, 7001);
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Media Server Series".intern(),
|
||||
channels: vec![series, episode],
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
}];
|
||||
let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)];
|
||||
let config = named_series_config("Trending", true);
|
||||
|
||||
let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config);
|
||||
|
||||
assert_eq!(categories.len(), 1);
|
||||
assert_eq!(categories[0].channels.len(), 2);
|
||||
let cloned_series = categories[0]
|
||||
.channels
|
||||
.iter()
|
||||
.find(|item| item.header.item_type == PlaylistItemType::SeriesInfo)
|
||||
.expect("series info clone");
|
||||
let cloned_episode = categories[0]
|
||||
.channels
|
||||
.iter()
|
||||
.find(|item| item.header.item_type == PlaylistItemType::Series)
|
||||
.expect("episode clone");
|
||||
assert_eq!(cloned_series.header.group.as_ref(), "Trending");
|
||||
assert_eq!(cloned_episode.header.group.as_ref(), "Trending");
|
||||
assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trakt_series_matching_ignores_episode_rows() {
|
||||
let episode = episode_item("Slow Horses", &"series-parent".intern(), 7001);
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Media Server Series".intern(),
|
||||
channels: vec![episode],
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
}];
|
||||
let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)];
|
||||
let config = TraktCategoryConfig {
|
||||
category_name: "Trending".to_string(),
|
||||
content_type: TraktContentType::Series,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: 100,
|
||||
};
|
||||
|
||||
let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config);
|
||||
|
||||
assert!(categories.is_empty());
|
||||
}
|
||||
|
||||
async fn spawn_counting_trakt_server(requests: Arc<AtomicUsize>) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else { return };
|
||||
let _ = read_request(&mut stream).await;
|
||||
requests.fetch_add(1, Ordering::SeqCst);
|
||||
write_response(&mut stream, "200 OK", "[]").await;
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), server)
|
||||
}
|
||||
|
||||
async fn spawn_partial_success_trakt_server(requests: Arc<Mutex<Vec<String>>>) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept test request");
|
||||
let request = read_request(&mut stream).await;
|
||||
let is_list_request = request.contains("/users/test-user/lists/test-list/items");
|
||||
requests.lock().expect("requests").push(request);
|
||||
if is_list_request {
|
||||
write_response(&mut stream, "403 Forbidden", "response body must not affect the next source").await;
|
||||
} else {
|
||||
write_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
r#"[{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}]"#,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), server)
|
||||
}
|
||||
|
||||
async fn read_request(stream: &mut TcpStream) -> String {
|
||||
let mut request_bytes = Vec::new();
|
||||
loop {
|
||||
let mut buffer = [0; 1024];
|
||||
let read = stream.read(&mut buffer).await.expect("read request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request_bytes.extend_from_slice(&buffer[..read]);
|
||||
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&request_bytes).to_string()
|
||||
}
|
||||
|
||||
async fn write_response(stream: &mut TcpStream, status: &str, body: &str) {
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.expect("write response");
|
||||
}
|
||||
|
||||
fn trakt_target(
|
||||
client_id: &str,
|
||||
url: String,
|
||||
enabled: bool,
|
||||
lists: Vec<TraktListConfigDto>,
|
||||
charts: Vec<TraktChartConfigDto>,
|
||||
) -> ConfigTarget {
|
||||
ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "test-target".to_string(),
|
||||
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
|
||||
trakt: Some(TraktConfigDto {
|
||||
enabled,
|
||||
api: TraktApiConfigDto {
|
||||
api_key: client_id.to_string(),
|
||||
version: "2".to_string(),
|
||||
url,
|
||||
user_agent: "tuliprox-test".to_string(),
|
||||
},
|
||||
lists,
|
||||
charts,
|
||||
}),
|
||||
..XtreamTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_list_config(category_name: &str) -> TraktListConfigDto {
|
||||
TraktListConfigDto {
|
||||
user: "test-user".to_string(),
|
||||
list_slug: "test-list".to_string(),
|
||||
category_name: category_name.to_string(),
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_chart_config(category_name: &str) -> TraktChartConfigDto {
|
||||
TraktChartConfigDto {
|
||||
kind: TraktChartKind::Movies,
|
||||
chart: TraktChartType::Popular,
|
||||
category_name: category_name.to_string(),
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_config(tmdb_only: bool) -> TraktCategoryConfig { named_list_config("category", tmdb_only) }
|
||||
|
||||
fn named_list_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig {
|
||||
TraktCategoryConfig {
|
||||
category_name: category_name.to_string(),
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn named_series_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig {
|
||||
TraktCategoryConfig {
|
||||
category_name: category_name.to_string(),
|
||||
content_type: TraktContentType::Series,
|
||||
tmdb_only,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn video_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
title: title.intern(),
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
item_type: PlaylistItemType::Video,
|
||||
additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties {
|
||||
name: title.intern(),
|
||||
tmdb,
|
||||
..VideoStreamProperties::default()
|
||||
}))),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn series_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: format!("series-{title}").intern(),
|
||||
input_name: "input".intern(),
|
||||
title: title.intern(),
|
||||
name: title.intern(),
|
||||
url: format!("media-server://unavailable/server/shows/{title}").intern(),
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties {
|
||||
name: title.intern(),
|
||||
tmdb,
|
||||
..SeriesStreamProperties::default()
|
||||
}))),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn episode_item(title: &str, parent_code: &Arc<str>, virtual_id: u32) -> PlaylistItem {
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: hash_string(&format!("episode:{title}:{virtual_id}")),
|
||||
id: format!("episode-{virtual_id}").intern(),
|
||||
input_name: "input".intern(),
|
||||
parent_code: parent_code.clone(),
|
||||
title: title.intern(),
|
||||
name: title.intern(),
|
||||
url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(),
|
||||
virtual_id: shared::model::VirtualId::new(virtual_id),
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
item_type: PlaylistItemType::Series,
|
||||
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
|
||||
episode_id: virtual_id,
|
||||
episode: 1,
|
||||
season: 1,
|
||||
added: None,
|
||||
release_date: None,
|
||||
series_release_date: None,
|
||||
tmdb: None,
|
||||
movie_image: "".intern(),
|
||||
container_extension: "mkv".intern(),
|
||||
video: None,
|
||||
audio: None,
|
||||
plot: None,
|
||||
}))),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn trakt_movie(
|
||||
title: &'static str,
|
||||
year: Option<u32>,
|
||||
tmdb_id: Option<u32>,
|
||||
trakt_id: u32,
|
||||
) -> TraktMatchItem<'static> {
|
||||
TraktMatchItem {
|
||||
title,
|
||||
normalized_title: normalize_title_for_matching(title),
|
||||
year,
|
||||
tmdb_id,
|
||||
trakt_id,
|
||||
content_type: TraktContentType::Vod,
|
||||
rank: Some(trakt_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn trakt_list_movie(title: &str, year: Option<u32>, tmdb_id: Option<u32>, trakt_id: u32) -> TraktListItem {
|
||||
TraktListItem {
|
||||
id: u64::from(trakt_id),
|
||||
rank: Some(trakt_id),
|
||||
listed_at: String::new(),
|
||||
notes: None,
|
||||
item_type: "movie".to_string(),
|
||||
movie: Some(tuliprox_core::model::TraktMovie {
|
||||
ids: trakt_ids(title, tmdb_id, trakt_id),
|
||||
title: title.to_string(),
|
||||
year,
|
||||
}),
|
||||
show: None,
|
||||
content_type: TraktContentType::Vod,
|
||||
}
|
||||
}
|
||||
|
||||
fn trakt_list_show(title: &str, year: Option<u32>, tmdb_id: Option<u32>, trakt_id: u32) -> TraktListItem {
|
||||
TraktListItem {
|
||||
id: u64::from(trakt_id),
|
||||
rank: Some(trakt_id),
|
||||
listed_at: String::new(),
|
||||
notes: None,
|
||||
item_type: "show".to_string(),
|
||||
movie: None,
|
||||
show: Some(tuliprox_core::model::TraktShow {
|
||||
ids: trakt_ids(title, tmdb_id, trakt_id),
|
||||
title: title.to_string(),
|
||||
year,
|
||||
}),
|
||||
content_type: TraktContentType::Series,
|
||||
}
|
||||
}
|
||||
|
||||
fn trakt_ids(title: &str, tmdb_id: Option<u32>, trakt_id: u32) -> tuliprox_core::model::TraktIds {
|
||||
tuliprox_core::model::TraktIds {
|
||||
trakt: trakt_id,
|
||||
slug: title.to_string(),
|
||||
tvdb: None,
|
||||
imdb: None,
|
||||
tmdb: tmdb_id,
|
||||
tvrage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user