mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-31 11:12:36 +01:00
Merge pull request #304 from euzu/feature/trakt_list
Feature/trakt list
This commit is contained in:
+26
-3
@@ -13,12 +13,35 @@
|
||||
If we assume the variable `station` contains the value `WINK`,
|
||||
this script receives the template with the concatenated name `US_WINK_PREFIX` which should be defined in `templates` section,
|
||||
and assigns it to the variable `station_prefix`.
|
||||
- Enhanced the STRM export functionality by adding configurable naming styles and an optional flat directory structure for Kodi,Plex,Jellyfin,Emby.
|
||||
|
||||
# 3.1.4 (???)
|
||||
- Extended STRM export functionality with:
|
||||
- Support for various media tools (Kodi, Plex, Emby, Jellyfin), with consideration for recommended naming conventions and file organization.
|
||||
- Optional flat directory structure via 'flat' parameter (nested folder structures are not supported by some media scanners).
|
||||
- Added Trakt support for XC targets
|
||||
```yaml
|
||||
- name: iptv-trakt-example
|
||||
output:
|
||||
- type: xtream
|
||||
skip_live_direct_source: true
|
||||
skip_video_direct_source: true
|
||||
skip_series_direct_source: true
|
||||
resolve_series: false
|
||||
resolve_vod: false
|
||||
trakt:
|
||||
api:
|
||||
key: <my private trakt api key>
|
||||
version: 2
|
||||
lists:
|
||||
- user: "linaspurinis"
|
||||
list_slug: "top-watched-movies-of-the-week"
|
||||
category_name: "📈 Top Weekly Movies"
|
||||
content_type: "vod"
|
||||
fuzzy_match_threshold: 80
|
||||
- user: "garycrawfordgc"
|
||||
list_slug: "latest-tv-shows"
|
||||
category_name: "📺 Latest TV Shows"
|
||||
content_type: "series"
|
||||
fuzzy_match_threshold: 80
|
||||
```
|
||||
|
||||
# 3.1.3 (2025-06-06)
|
||||
- Fixed xtream codes series info duplicate fields problem.
|
||||
|
||||
@@ -25,3 +25,30 @@ sources:
|
||||
options: { ignore_logo: false, share_live_streams: true }
|
||||
mapping:
|
||||
- iptv-org
|
||||
- name: iptv-trakt-example
|
||||
output:
|
||||
- type: xtream
|
||||
skip_live_direct_source: true
|
||||
skip_video_direct_source: true
|
||||
skip_series_direct_source: true
|
||||
resolve_series: false
|
||||
resolve_vod: false
|
||||
trakt:
|
||||
lists:
|
||||
- user: "linaspurinis"
|
||||
list_slug: "top-watched-movies-of-the-week"
|
||||
category_name: "📈 Top Weekly Movies"
|
||||
content_type: "vod"
|
||||
fuzzy_match_threshold: 80
|
||||
- user: "garycrawfordgc"
|
||||
list_slug: "latest-tv-shows"
|
||||
category_name: "📺 Latest TV Shows"
|
||||
content_type: "series"
|
||||
fuzzy_match_threshold: 75
|
||||
- user: "trakt"
|
||||
list_slug: "trending"
|
||||
category_name: "🔥 Trending"
|
||||
content_type: "both"
|
||||
fuzzy_match_threshold: 80
|
||||
filter: "!final_channel_lineup!"
|
||||
options: { ignore_logo: false, share_live_streams: true }
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::model::config_sort::ConfigSort;
|
||||
use crate::model::mapping::Mapping;
|
||||
use crate::model::processing_order::ProcessingOrder;
|
||||
use crate::model::PlaylistItemType;
|
||||
use crate::model::trakt::TraktConfig;
|
||||
use crate::utils::{default_as_default, default_as_true, default_resolve_delay_secs};
|
||||
use enum_iterator::Sequence;
|
||||
use std::fmt::Display;
|
||||
@@ -123,8 +124,17 @@ pub struct XtreamTargetOutput {
|
||||
pub resolve_vod: bool,
|
||||
#[serde(default = "default_resolve_delay_secs")]
|
||||
pub resolve_vod_delay: u16,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trakt: Option<TraktConfig>,
|
||||
}
|
||||
|
||||
impl XtreamTargetOutput {
|
||||
pub fn prepare(&mut self) {
|
||||
if let Some(trakt) = &mut self.trakt {
|
||||
trakt.prepare();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -172,6 +182,17 @@ pub enum TargetOutput {
|
||||
HdHomeRun(HdHomeRunTargetOutput),
|
||||
}
|
||||
|
||||
impl TargetOutput {
|
||||
pub fn prepare(&mut self) {
|
||||
match self {
|
||||
TargetOutput::Xtream(output) => output.prepare(),
|
||||
TargetOutput::M3u(_)
|
||||
| TargetOutput::Strm(_)
|
||||
| TargetOutput::HdHomeRun(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigTarget {
|
||||
@@ -219,6 +240,7 @@ impl ConfigTarget {
|
||||
let mut hdhomerun_needs_xtream = false;
|
||||
|
||||
for target_output in &mut self.output {
|
||||
target_output.prepare();
|
||||
match target_output {
|
||||
TargetOutput::Xtream(_) => {
|
||||
xtream_cnt += 1;
|
||||
|
||||
@@ -27,6 +27,7 @@ mod config_web_auth;
|
||||
mod config_log;
|
||||
mod config_healthcheck;
|
||||
mod config_cache;
|
||||
mod trakt;
|
||||
|
||||
// Re-export modules for easier access
|
||||
pub use self::config_cache::*;
|
||||
@@ -58,3 +59,4 @@ pub use self::config_download::*;
|
||||
pub use self::config_web_auth::*;
|
||||
pub use self::config_log::*;
|
||||
pub use self::config_healthcheck::*;
|
||||
pub use self::trakt::*;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::model::{PlaylistItem};
|
||||
use crate::utils::trakt::normalize_title_for_matching;
|
||||
|
||||
const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3";
|
||||
const TRAKT_API_VERSION: &str = "2";
|
||||
const TRAKT_API_URL: &str = "https://api.trakt.tv";
|
||||
fn default_fuzzy_threshold() -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraktApiConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) key: String,
|
||||
#[serde(default)]
|
||||
pub(crate) version: String,
|
||||
#[serde(default)]
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
impl TraktApiConfig {
|
||||
pub fn prepare(&mut self) {
|
||||
let key = self.key.trim();
|
||||
self.key = String::from(if key.is_empty() { TRAKT_API_KEY } else { key });
|
||||
let version = self.version.trim();
|
||||
self.version = String::from(if version.is_empty() { TRAKT_API_VERSION } else { version });
|
||||
let url = self.url.trim();
|
||||
self.url = String::from(if url.is_empty() { TRAKT_API_URL } else { url });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraktListConfig {
|
||||
pub user: String,
|
||||
pub list_slug: String,
|
||||
pub category_name: String,
|
||||
pub content_type: TraktContentType,
|
||||
#[serde(default = "default_fuzzy_threshold")]
|
||||
pub fuzzy_match_threshold: u8, // Percentage (0-100)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TraktContentType {
|
||||
Vod,
|
||||
Series,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Default for TraktContentType {
|
||||
fn default() -> Self {
|
||||
Self::Both
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraktConfig {
|
||||
#[serde(default)]
|
||||
pub api: TraktApiConfig,
|
||||
pub lists: Vec<TraktListConfig>,
|
||||
}
|
||||
|
||||
impl TraktConfig {
|
||||
pub fn prepare(&mut self) {
|
||||
self.api.prepare();
|
||||
}
|
||||
}
|
||||
|
||||
// API Response structures
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TraktListItem {
|
||||
pub id: u64,
|
||||
pub rank: Option<u32>,
|
||||
pub listed_at: String,
|
||||
pub notes: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub movie: Option<TraktMovie>,
|
||||
pub show: Option<TraktShow>,
|
||||
#[serde(skip)]
|
||||
pub content_type: TraktContentType,
|
||||
}
|
||||
|
||||
impl TraktListItem {
|
||||
pub fn prepare(&mut self) {
|
||||
self.content_type = match self.item_type.as_str() {
|
||||
"movie" => TraktContentType::Vod,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MatchType {
|
||||
TmdbExact,
|
||||
FuzzyTitle,
|
||||
FuzzyTitleYear,
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod xtream_vod;
|
||||
mod xtream_series;
|
||||
pub mod epg;
|
||||
mod sort;
|
||||
pub mod trakt;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! handle_error {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::model::{FetchedPlaylist, FieldGetAccessor, FieldSetAccessor, Playlist
|
||||
use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats};
|
||||
use crate::processing::playlist_watch::process_group_watch;
|
||||
use crate::processing::processor::xtream_series::playlist_resolve_series;
|
||||
use crate::processing::processor::trakt::process_trakt_categories_for_target;
|
||||
use crate::repository::playlist_repository::persist_playlist;
|
||||
use crate::tuliprox_error::{get_errors_notify_message, notify_err, TuliproxError, TuliproxErrorKind};
|
||||
use crate::utils::debug_if_enabled;
|
||||
@@ -24,6 +25,7 @@ use crate::utils::default_as_default;
|
||||
use deunicode::deunicode;
|
||||
use log::{debug, error, info, log_enabled, trace, warn, Level};
|
||||
use std::time::Instant;
|
||||
use reqwest::Client;
|
||||
use crate::model::Epg;
|
||||
use crate::processing::parser::xmltv::flatten_tvguide;
|
||||
use crate::processing::processor::epg::process_playlist_epg;
|
||||
@@ -461,20 +463,27 @@ async fn process_playlist_for_target(client: Arc<reqwest::Client>,
|
||||
}
|
||||
|
||||
step.tick("Processed epg");
|
||||
let (new_epg, new_playlist) = process_epg(&mut processed_fetched_playlists);
|
||||
let (new_epg, mut new_playlist) = process_epg(&mut processed_fetched_playlists);
|
||||
|
||||
if new_playlist.is_empty() {
|
||||
info!("Playlist is empty: {}", &target.name);
|
||||
Ok(())
|
||||
} else {
|
||||
|
||||
// Process Trakt categories
|
||||
step.tick("Processing Trakt categories");
|
||||
trakt_playlist(&client, target, errors, &mut new_playlist).await;
|
||||
|
||||
step.tick("Merged playlists");
|
||||
let mut flat_new_playlist = flatten_groups(new_playlist);
|
||||
|
||||
step.tick("Sorted playlists");
|
||||
sort_playlist(target, &mut flat_new_playlist);
|
||||
step.tick("Assigned channel number");
|
||||
assign_channel_no_playlist(&mut flat_new_playlist);
|
||||
step.tick("Assigned channel counter");
|
||||
map_playlist_counter(target, &mut flat_new_playlist);
|
||||
|
||||
step.tick("Processed group watches");
|
||||
process_watch(&client, target, cfg, &flat_new_playlist);
|
||||
step.tick("Persisting playlists");
|
||||
@@ -484,6 +493,21 @@ async fn process_playlist_for_target(client: Arc<reqwest::Client>,
|
||||
}
|
||||
}
|
||||
|
||||
async fn trakt_playlist(client: &Arc<Client>, target: &ConfigTarget, errors: &mut Vec<TuliproxError>, playlist: &mut Vec<PlaylistGroup>) {
|
||||
match process_trakt_categories_for_target(Arc::clone(client), playlist, target).await {
|
||||
Ok(trakt_categories) => {
|
||||
if !trakt_categories.is_empty() {
|
||||
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
|
||||
playlist.extend(trakt_categories);
|
||||
}
|
||||
}
|
||||
Err(trakt_errors) => {
|
||||
warn!("Trakt processing failed with {} errors", trakt_errors.len());
|
||||
errors.extend(trakt_errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_epg(processed_fetched_playlists: &mut Vec<FetchedPlaylist>) -> (Vec<Epg>, Vec<PlaylistGroup>) {
|
||||
let mut new_playlist = vec![];
|
||||
let mut new_epg = vec![];
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
use std::borrow::Cow;
|
||||
use crate::model::{ConfigTarget, FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, XtreamCluster};
|
||||
use crate::model::{MatchType, TraktConfig, TraktContentType, TraktListConfig, TraktListItem, TraktMatchItem, TraktMatchResult};
|
||||
use crate::tuliprox_error::TuliproxError;
|
||||
use crate::utils::trakt::client::TraktClient;
|
||||
use crate::utils::trakt::extract_year_from_title;
|
||||
use crate::utils::trakt::normalize_title_for_matching;
|
||||
use crate::utils::{get_u32_from_serde_value, CONSTANTS};
|
||||
use crate::utils::{trace_if_enabled, with};
|
||||
use log::{debug, info, trace, warn};
|
||||
use std::sync::Arc;
|
||||
use strsim::normalized_levenshtein;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract TMDB ID from playlist item
|
||||
fn extract_tmdb_id_from_playlist_item(item: &PlaylistItem) -> Option<u32> {
|
||||
if let Some(additional_props) = &item.header.additional_properties {
|
||||
if let Some(props_str) = additional_props.as_str() {
|
||||
if let Ok(props) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(props_str) {
|
||||
if let Some(tmdb_value) = props.get("tmdb") {
|
||||
return get_u32_from_serde_value(tmdb_value);
|
||||
}
|
||||
if let Some(tmdb_id_value) = props.get("tmdb_id") {
|
||||
return get_u32_from_serde_value(tmdb_id_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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.15;
|
||||
} else {
|
||||
return -0.15;
|
||||
}
|
||||
}
|
||||
0.0
|
||||
// match (playlist_year, trakt_year) {
|
||||
// (Some(p_year), Some(t_year)) => {
|
||||
// if p_year == t_year {
|
||||
// // Perfect year match gets substantial bonus
|
||||
// 0.15
|
||||
// } else {
|
||||
// let year_diff = p_year.abs_diff(t_year);
|
||||
//
|
||||
// if year_diff <= 1 {
|
||||
// // 1-year difference gets small bonus (could be release date differences)
|
||||
// 0.05
|
||||
// } else if year_diff <= 3 {
|
||||
// // 2-3 years difference gets small penalty
|
||||
// -0.05
|
||||
// } else {
|
||||
// // More than 3 years difference gets larger penalty
|
||||
// -0.15
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// (Some(_), None) | (None, Some(_)) => {
|
||||
// // One has year, other doesn't - small penalty
|
||||
// -0.05
|
||||
// }
|
||||
// (None, None) => {
|
||||
// // Neither has year - no bonus/penalty
|
||||
// 0.0
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>), trakt_items: &'a [TraktMatchItem], list_config: &'a TraktListConfig) -> 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(list_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>],
|
||||
list_config: &'a TraktListConfig,
|
||||
) -> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
find_best_fuzzy_match_for_item(channel, trakt_items, list_config)
|
||||
}
|
||||
|
||||
fn create_category_from_matches<'a>(
|
||||
matches: Vec<TraktMatchResult<'a>>,
|
||||
list_config: &'a TraktListConfig,
|
||||
) -> Option<PlaylistGroup> {
|
||||
if matches.is_empty() { return None; }
|
||||
|
||||
let mut matched_items = Vec::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 = &list_config.category_name;
|
||||
|
||||
for match_result in sorted_matches {
|
||||
let mut modified_item = match_result.playlist_item.clone();
|
||||
// Use the (possibly numbered) title from the match result (which now contains the original playlist title)
|
||||
with!(mut modified_item.header => header {
|
||||
// Synchronize name with title so both fields show the same value
|
||||
// header.title.clone_from(&match_result.trakt_item.title.to_string());
|
||||
// header.name.clone_from(&match_result.trakt_item.title.to_string());
|
||||
let title = header.get_field("caption").unwrap_or_else(|| Cow::Borrowed(&header.title));
|
||||
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_field("caption", &caption);
|
||||
}
|
||||
}
|
||||
header.group = String::from(group_title);
|
||||
header.gen_uuid();
|
||||
});
|
||||
matched_items.push(modified_item);
|
||||
}
|
||||
|
||||
if matched_items.is_empty() { return None; }
|
||||
|
||||
|
||||
let cluster = match list_config.content_type {
|
||||
TraktContentType::Vod => XtreamCluster::Video,
|
||||
TraktContentType::Series => XtreamCluster::Series,
|
||||
TraktContentType::Both => {
|
||||
matched_items.first()
|
||||
.map_or(XtreamCluster::Video, |item| item.header.xtream_cluster)
|
||||
}
|
||||
};
|
||||
|
||||
Some(PlaylistGroup {
|
||||
id: 0,
|
||||
title: String::from(group_title),
|
||||
channels: matched_items,
|
||||
xtream_cluster: cluster,
|
||||
})
|
||||
}
|
||||
|
||||
fn match_trakt_items_with_playlist<'a>(
|
||||
trakt_items: &'a [TraktListItem],
|
||||
playlist: &'a [PlaylistGroup],
|
||||
list_config: &'a TraktListConfig,
|
||||
) -> Option<PlaylistGroup> {
|
||||
let trakt_match_items: Vec<TraktMatchItem<'a>> = trakt_items
|
||||
.iter()
|
||||
.filter(|item| should_include_item(item, &list_config.content_type))
|
||||
.filter_map(TraktMatchItem::from_trakt_list_item)
|
||||
.collect();
|
||||
|
||||
debug!("Matching {} Trakt items against playlist for content type {:?}", trakt_match_items.len(), list_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, &list_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 = extract_tmdb_id_from_playlist_item(channel);
|
||||
if let Some(matched) = find_best_match_for_item((channel, normalized_title, channel_year, channel_tmdb_id), &trakt_match_items, list_config) {
|
||||
matches.push(matched);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create_category_from_matches(matches, list_config)
|
||||
}
|
||||
|
||||
pub struct TraktCategoriesProcessor {
|
||||
client: TraktClient,
|
||||
}
|
||||
|
||||
impl TraktCategoriesProcessor {
|
||||
pub fn new(http_client: Arc<reqwest::Client>, trakt_config: &TraktConfig) -> Self {
|
||||
let client = TraktClient::new(http_client, trakt_config.api.clone());
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn process_trakt_categories(
|
||||
&self,
|
||||
playlist: &[PlaylistGroup],
|
||||
target: &ConfigTarget,
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Result<Vec<PlaylistGroup>, Vec<TuliproxError>> {
|
||||
if trakt_config.lists.is_empty() {
|
||||
debug!("No Trakt lists configured for target {}", target.name);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
info!("Processing {} Trakt lists for target {}", trakt_config.lists.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);
|
||||
|
||||
match self.client.get_list_items(list_config).await {
|
||||
Ok(trakt_items) => {
|
||||
debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len());
|
||||
|
||||
if let Some(category) = match_trakt_items_with_playlist(&trakt_items, playlist, list_config) {
|
||||
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", list_config.category_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to fetch Trakt list {cache_key}: {}", err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("Trakt processing complete: created {} categories with {total_matches} total matches",
|
||||
new_categories.len());
|
||||
|
||||
Ok(new_categories)
|
||||
}
|
||||
}
|
||||
pub async fn process_trakt_categories_for_target(
|
||||
http_client: Arc<reqwest::Client>,
|
||||
playlist: &[PlaylistGroup],
|
||||
target: &ConfigTarget,
|
||||
) -> Result<Vec<PlaylistGroup>, Vec<TuliproxError>> {
|
||||
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
|
||||
debug!("No Trakt configuration found for target {}", target.name);
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let processor = TraktCategoriesProcessor::new(http_client, trakt_config);
|
||||
processor.process_trakt_categories(playlist, target, trakt_config).await
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn test_quality() {
|
||||
let quality = extract_quality("Hello HD UHD 720p");
|
||||
assert_eq!(true, quality.is_some());
|
||||
assert_eq!("UHD", quality.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ fn sanitize_for_filename(text: &str, underscore_whitespace: bool) -> String {
|
||||
while sanitized.starts_with('.') {
|
||||
sanitized.remove(0);
|
||||
}
|
||||
|
||||
|
||||
// 4. Final check: If sanitization resulted in an empty string, return a default.
|
||||
if sanitized.is_empty() {
|
||||
EMPTY_FILENAME_REPLACEMENT.to_string()
|
||||
@@ -129,7 +129,7 @@ fn style_rename_year<'a>(
|
||||
for caps in style.year.captures_iter(name) {
|
||||
if let Some(year_match) = caps.get(1) {
|
||||
if let Ok(year) = year_match.as_str().parse::<u32>() {
|
||||
if (1900..=cur_year).contains(&year) {
|
||||
if (1900..=cur_year).contains(&year) {
|
||||
years.push(year);
|
||||
let match_start = caps.get(0).unwrap().start();
|
||||
let match_end = caps.get(0).unwrap().end();
|
||||
@@ -583,7 +583,7 @@ fn format_for_emby(
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
|
||||
|
||||
// Emby/Jellyfin standard: uppercase 'S' and hyphens.
|
||||
let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}");
|
||||
let season_folder = format!("Season{separator}{season_num:02}");
|
||||
@@ -639,14 +639,14 @@ fn format_for_jellyfin(
|
||||
let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref());
|
||||
let sanitized_series_name = sanitize_for_filename(name.trim(), false);
|
||||
let year_string = year.map_or(String::new(), |y| format!("{separator}({y})"));
|
||||
|
||||
|
||||
let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}");
|
||||
|
||||
let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref());
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
|
||||
|
||||
// Emby/Jellyfin standard: uppercase 'S' and hyphens.
|
||||
let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}");
|
||||
let season_folder = format!("Season{separator}{season_num:02}");
|
||||
@@ -690,7 +690,7 @@ async fn style_based_rename(
|
||||
InputTmdbIndexValue::Video(r) => r.tmdb_id,
|
||||
InputTmdbIndexValue::Series(e) => e.tmdb_id,
|
||||
});
|
||||
|
||||
|
||||
// Dispatch the call to the responsible function based on the style.
|
||||
match style {
|
||||
ExportStyle::Kodi => format_for_kodi(strm_item_info, tmdb_id, separator, flat),
|
||||
@@ -725,14 +725,14 @@ async fn prepare_strm_files(
|
||||
let strm_item_info = extract_item_info(pli);
|
||||
|
||||
let (dir_path, strm_file_name) = style_based_rename(
|
||||
cfg,
|
||||
&strm_item_info,
|
||||
&mut input_tmdb_indexes,
|
||||
style,
|
||||
underscore_whitespace,
|
||||
flat,
|
||||
).await;
|
||||
|
||||
cfg,
|
||||
&strm_item_info,
|
||||
&mut input_tmdb_indexes,
|
||||
style,
|
||||
underscore_whitespace,
|
||||
flat,
|
||||
).await;
|
||||
|
||||
let filename = Arc::new(strm_file_name);
|
||||
if all_filenames.contains(&filename) {
|
||||
collisions.insert(Arc::clone(&filename));
|
||||
@@ -769,13 +769,13 @@ async fn prepare_strm_files(
|
||||
let new_filename = match style {
|
||||
// Plex, Emby, and Kodi all follow the `Filename - Suffix` pattern.
|
||||
ExportStyle::Plex | ExportStyle::Emby | ExportStyle::Kodi => {
|
||||
format!("{}{}{}", base_filename, version_separator, version_label)
|
||||
format!("{base_filename}{version_separator}{version_label}")
|
||||
}
|
||||
|
||||
|
||||
// Jellyfin also follows this pattern, but explicitly shows an option for " - [Label]".
|
||||
// Using brackets makes the version distinct and is a clean implementation.
|
||||
ExportStyle::Jellyfin => {
|
||||
format!("{}{}[{}]", base_filename, version_separator, version_label)
|
||||
format!("{base_filename}{version_separator}[{version_label}]")
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ 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 static CONSTANTS: LazyLock<Constants> = LazyLock::new(||
|
||||
@@ -119,7 +121,6 @@ pub static CONSTANTS: LazyLock<Constants> = LazyLock::new(||
|
||||
whitespace: Regex::new(r"\s+").unwrap(),
|
||||
alphanumeric: Regex::new(r"[^\w\s]").unwrap(),
|
||||
},
|
||||
|
||||
allowed_output_formats: Vec::from(["m3u8".to_string(), "ts".to_string()]),
|
||||
country_codes: vec![
|
||||
"af", "al", "dz", "ad", "ao", "ag", "ar", "am", "au", "at", "az", "bs", "bh", "bd", "bb", "by",
|
||||
@@ -136,5 +137,7 @@ pub static CONSTANTS: LazyLock<Constants> = LazyLock::new(||
|
||||
"tt", "tn", "tr", "tm", "tv", "ug", "ua", "ae", "gb", "us", "uy", "uz", "vu", "va", "ve", "vn",
|
||||
"ye", "zm", "zw",
|
||||
].into_iter().collect::<HashSet<&str>>(),
|
||||
re_trakt_year: Regex::new(r"\(?(\d{4})\)?$").unwrap(),
|
||||
re_quality: Regex::new(r"(?i)\b(4K|UHD|8K|2160p?|1080p?|720p?|480p?|BLURAY|HDTV|DVDRIP|CAM|TS|HDR|DV|SDR)\b").unwrap(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ mod crypto_utils;
|
||||
mod constants;
|
||||
mod step_measure;
|
||||
pub mod logging;
|
||||
pub mod trakt;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! debug_if_enabled {
|
||||
@@ -44,8 +45,21 @@ macro_rules! trace_if_enabled {
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! with {
|
||||
(mut $target:expr => $alias:ident $block:block) => {{
|
||||
let $alias = &mut $target;
|
||||
$block
|
||||
}};
|
||||
($target:expr => $alias:ident $block:block) => {{
|
||||
let $alias = &$target;
|
||||
$block
|
||||
}};
|
||||
}
|
||||
|
||||
pub use debug_if_enabled;
|
||||
pub use trace_if_enabled;
|
||||
pub use with;
|
||||
|
||||
pub use self::default_utils::*;
|
||||
pub use self::string_utils::*;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::model::{TraktApiConfig, TraktListConfig, TraktListItem};
|
||||
use crate::tuliprox_error::TuliproxError;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use std::sync::Arc;
|
||||
use log::{debug, info};
|
||||
use crate::tuliprox_error::{info_err, TuliproxErrorKind};
|
||||
use super::errors::{handle_trakt_api_error};
|
||||
|
||||
pub struct TraktClient {
|
||||
client: Arc<reqwest::Client>,
|
||||
api_config: TraktApiConfig,
|
||||
// Pre-computed headers to avoid recreating them each time
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl TraktClient {
|
||||
pub fn new(client: Arc<reqwest::Client>, api_config: TraktApiConfig) -> Self {
|
||||
let headers = Self::create_headers(&api_config);
|
||||
Self {
|
||||
client,
|
||||
api_config,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_headers(api_config: &TraktApiConfig) -> axum::http::HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
headers.insert(reqwest::header::CONTENT_TYPE, HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()));
|
||||
headers.insert("trakt-api-key", HeaderValue::from_str(api_config.key.as_str()).unwrap_or_else(|_| HeaderValue::from_static("")));
|
||||
headers.insert("trakt-api-version", HeaderValue::from_str(api_config.version.as_str()).unwrap_or_else(|_| HeaderValue::from_static("")));
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_list_url(&self, user: &str, list_slug: &str) -> String {
|
||||
format!("{}/users/{user}/lists/{list_slug}/items", self.api_config.url)
|
||||
}
|
||||
|
||||
pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
debug!("Fetching Trakt list {}:{}", list_config.user, list_config.list_slug);
|
||||
|
||||
let url = self.build_list_url(&list_config.user, &list_config.list_slug);
|
||||
|
||||
let response = self.client
|
||||
.get(&url)
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| info_err!(format!("Failed to fetch Trakt list {url}: {err}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
handle_trakt_api_error(response.status(), &list_config.user, &list_config.list_slug)?;
|
||||
}
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error: reqwest::Error| info_err!(format!("Failed to read Trakt response: {error}")))?;
|
||||
|
||||
let mut items: Vec<TraktListItem> = serde_json::from_str(&response_text)
|
||||
.map_err(|error: serde_json::Error| info_err!(format!("Failed to parse Trakt response: {error}")))?;
|
||||
items.iter_mut().for_each(TraktListItem::prepare);
|
||||
info!("Successfully fetched {} items from Trakt list {}:{}", items.len(), list_config.user, list_config.list_slug);
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::tuliprox_error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Handle Trakt API response status and convert to appropriate error
|
||||
pub fn handle_trakt_api_error(status: StatusCode, user: &str, list_slug: &str) -> Result<(), TuliproxError> {
|
||||
match status.as_u16() {
|
||||
404 => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Trakt list not found: {user}:{list_slug}"),
|
||||
401 => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Trakt API key is invalid or expired"),
|
||||
429 => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Trakt API rate limit exceeded"),
|
||||
_ => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Trakt API error {status}: {}", status.canonical_reason().unwrap_or("Unknown"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Common utilities for Trakt functionality
|
||||
pub mod client;
|
||||
pub mod errors;
|
||||
|
||||
use deunicode::deunicode;
|
||||
use crate::utils::CONSTANTS;
|
||||
|
||||
/// Normalize title for matching - optimized version with reduced allocations
|
||||
pub fn normalize_title_for_matching(title: &str) -> String {
|
||||
let normalized = deunicode(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user