diff --git a/src/model/trakt.rs b/src/model/trakt.rs index b3cedd2f4..9e13fe367 100644 --- a/src/model/trakt.rs +++ b/src/model/trakt.rs @@ -1,20 +1,23 @@ use serde::{Deserialize, Serialize}; +use crate::model::{PlaylistItem}; +use crate::utils::trakt::normalize_title_for_matching; #[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Default)] -pub struct TraktApiConfig { - -} +pub struct TraktApiConfig {} impl TraktApiConfig { + #[inline] pub fn get_api_key(&self) -> &'static str { "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3" } + #[inline] pub fn get_api_version(&self) -> &'static str { "2" } + #[inline] pub fn get_base_url(&self) -> &'static str { "https://api.trakt.tv" } @@ -59,28 +62,40 @@ pub struct TraktConfig { // API Response structures #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraktListItem { - pub rank: Option, pub id: u64, + pub rank: Option, pub listed_at: String, pub notes: Option, #[serde(rename = "type")] pub item_type: String, pub movie: Option, pub show: Option, + #[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, - pub ids: TraktIds, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraktShow { + pub ids: TraktIds, pub title: String, pub year: Option, - pub ids: TraktIds, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -95,8 +110,9 @@ pub struct TraktIds { // Internal matching structures #[derive(Debug, Clone)] -pub struct TraktMatchItem { - pub title: String, +pub struct TraktMatchItem<'a> { + pub title: &'a str, + pub normalized_title: String, pub year: Option, pub tmdb_id: Option, pub trakt_id: u32, @@ -104,60 +120,42 @@ pub struct TraktMatchItem { pub rank: Option, } -impl From<&TraktListItem> for TraktMatchItem { - fn from(item: &TraktListItem) -> Self { +impl<'a> TraktMatchItem<'a> { + pub fn from_trakt_list_item(item: &'a TraktListItem) -> Option { match item.item_type.as_str() { "movie" => { - if let Some(movie) = &item.movie { - TraktMatchItem { - title: movie.title.clone(), + 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, - } - } else { - TraktMatchItem::default() - } + }) } "show" => { - if let Some(show) = &item.show { - TraktMatchItem { - title: show.title.clone(), + 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, - } - } else { - TraktMatchItem::default() - } + }) } - _ => TraktMatchItem::default(), + _ => None, } } } -impl Default for TraktMatchItem { - fn default() -> Self { - Self { - title: String::new(), - year: None, - tmdb_id: None, - trakt_id: 0, - content_type: TraktContentType::Both, - rank: None, - } - } -} // Matching results #[derive(Debug, Clone)] -pub struct TraktMatchResult { - pub playlist_item_uuid: String, - pub trakt_item: TraktMatchItem, +pub struct TraktMatchResult<'a> { + pub playlist_item: &'a PlaylistItem, + pub trakt_item: &'a TraktMatchItem<'a>, pub match_score: f64, pub match_type: MatchType, } diff --git a/src/processing/processor/mod.rs b/src/processing/processor/mod.rs index e9f76446c..042ac9a26 100644 --- a/src/processing/processor/mod.rs +++ b/src/processing/processor/mod.rs @@ -5,7 +5,7 @@ mod xtream_vod; mod xtream_series; pub mod epg; mod sort; -pub mod trakt_categories; +pub mod trakt; #[macro_export] macro_rules! handle_error { diff --git a/src/processing/processor/playlist.rs b/src/processing/processor/playlist.rs index eb0496018..d92329e1e 100644 --- a/src/processing/processor/playlist.rs +++ b/src/processing/processor/playlist.rs @@ -17,7 +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_categories::process_trakt_categories_for_target; +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; @@ -25,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; @@ -479,18 +480,7 @@ async fn process_playlist_for_target(client: Arc, // Process Trakt categories step.tick("Processing Trakt categories"); - match process_trakt_categories_for_target(Arc::clone(&client), &mut flat_new_playlist, target).await { - Ok(trakt_categories) => { - if !trakt_categories.is_empty() { - info!("Adding {} Trakt categories to playlist", trakt_categories.len()); - flat_new_playlist.extend(trakt_categories); - } - } - Err(trakt_errors) => { - warn!("Trakt processing failed with {} errors", trakt_errors.len()); - errors.extend(trakt_errors); - } - } + trakt_playlist(&client, target, errors, &mut flat_new_playlist).await; step.tick("Processed group watches"); process_watch(&client, target, cfg, &flat_new_playlist); @@ -501,6 +491,21 @@ async fn process_playlist_for_target(client: Arc, } } +async fn trakt_playlist(client: &Arc, target: &ConfigTarget, errors: &mut Vec, flat_new_playlist: &mut Vec) { + match process_trakt_categories_for_target(Arc::clone(client), flat_new_playlist, target).await { + Ok(trakt_categories) => { + if !trakt_categories.is_empty() { + info!("Adding {} Trakt categories to playlist", trakt_categories.len()); + flat_new_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) -> (Vec, Vec) { let mut new_playlist = vec![]; let mut new_epg = vec![]; diff --git a/src/processing/processor/trakt.rs b/src/processing/processor/trakt.rs new file mode 100644 index 000000000..c3e88eb15 --- /dev/null +++ b/src/processing/processor/trakt.rs @@ -0,0 +1,293 @@ +use crate::model::{ConfigTarget, 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; +use log::{debug, info, trace, warn}; +use std::sync::Arc; +use strsim::jaro_winkler; + +/// 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 { + 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::>(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, trakt_year: Option) -> f64 { + 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, trakt_items: &'a [TraktMatchItem], list_config: &'a TraktListConfig) -> Option> { + // Try fuzzy matching if no exact match found + let normalized_playlist_title = normalize_title_for_matching(&channel.header.title); + let playlist_year = extract_year_from_title(&channel.header.title); + 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 = jaro_winkler(&normalized_playlist_title, &trakt_item.normalized_title); + + // 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)); + } + } + } + + 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 + }; + + debug!("Fuzzy match: '{}' -> '{}' (final: {combined_score:.3}, type: {match_type:?})", + channel.header.title, trakt_item.title); + + return Some(TraktMatchResult { + playlist_item: channel, + trakt_item, + match_score: combined_score, + match_type: match_type.clone(), + }); + } + + None +} + +fn find_best_match_for_item<'a>( + channel: &'a PlaylistItem, + trakt_items: &'a [TraktMatchItem<'a>], + list_config: &'a TraktListConfig, +) -> Option> { + // Try TMDB exact matching first + if let Some(playlist_tmdb_id) = extract_tmdb_id_from_playlist_item(channel) { + for trakt_item in trakt_items { + if Some(playlist_tmdb_id) == trakt_item.tmdb_id { + trace!("TMDB exact match: '{}' (TMDB: {})", channel.header.title, playlist_tmdb_id); + return Some(TraktMatchResult { + playlist_item: channel, + 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>, + list_config: &'a TraktListConfig, +) -> Option { + if matches.is_empty() { return None; } + + let mut matched_items = Vec::new(); + + let mut sorted_matches = matches; + // Simple sort by rank only + sorted_matches.sort_by(|a, b| { + a.trakt_item.rank.unwrap_or(9999).cmp(&b.trakt_item.rank.unwrap_or(9999)) + }); + + 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) + modified_item.header.title.clone_from(&match_result.trakt_item.title.to_string()); + // Synchronize name with title so both fields show the same value + modified_item.header.name.clone_from(&match_result.trakt_item.title.to_string()); + matched_items.push(modified_item); + } + + 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) + } + }; + + if matched_items.is_empty() { return None; } + + Some(PlaylistGroup { + id: 0, + title: list_config.category_name.clone(), + 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 { + let trakt_match_items: Vec> = 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) { + matches.extend(find_best_match_for_item(channel, &trakt_match_items, list_config)); + } + } + } + + create_category_from_matches(matches, list_config) +} + +pub struct TraktCategoriesProcessor { + client: TraktClient, +} + +impl TraktCategoriesProcessor { + pub fn new(http_client: Arc, trakt_config: &TraktConfig) -> Self { + let client = TraktClient::new(http_client, trakt_config.api.clone()); + Self { client } + } + + pub async fn process_trakt_categories( + &self, + playlist: &mut [PlaylistGroup], + target: &ConfigTarget, + trakt_config: &TraktConfig, + ) -> Result, Vec> { + 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 trakt_lists = match self.client.get_all_lists(&trakt_config.lists).await { + Ok(lists) => lists, + Err(errors) => { + warn!("Failed to fetch some Trakt lists: {} errors", errors.len()); + return Err(errors); + } + }; + + 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); + + if let Some(trakt_items) = trakt_lists.get(&cache_key) { + info!("Processing Trakt list {} with {} items", cache_key, 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); + info!("Created Trakt category '{}' with {} items", + list_config.category_name, category_len); + } + } + } else { + warn!("No items found for Trakt list {cache_key}"); + } + } + + info!("Trakt processing complete: created {} categories with {} total matches", + new_categories.len(), total_matches); + + Ok(new_categories) + } + +} +pub async fn process_trakt_categories_for_target( + http_client: Arc, + playlist: &mut [PlaylistGroup], + target: &ConfigTarget, +) -> Result, Vec> { + + let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt_lists.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 +} \ No newline at end of file diff --git a/src/processing/processor/trakt_categories.rs b/src/processing/processor/trakt_categories.rs deleted file mode 100644 index b8d9e3b56..000000000 --- a/src/processing/processor/trakt_categories.rs +++ /dev/null @@ -1,427 +0,0 @@ -use crate::model::{ConfigTarget, 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; -use log::{debug, info, warn}; -use rayon::prelude::*; -use std::collections::HashMap; -use std::sync::Arc; -use strsim::jaro_winkler; - -/// Utility functions for content type compatibility -fn should_include_item(item: &TraktMatchItem, 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 { - 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::>(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 -} - -/// Create playlist index for faster lookups -fn create_playlist_index(playlist: &[PlaylistGroup]) -> HashMap { - let mut playlist_index = HashMap::new(); - - for group in playlist { - for item in &group.channels { - playlist_index.insert(format!("{:?}", item.header.uuid), item); - } - } - - playlist_index -} - -pub struct TraktCategoriesProcessor { - client: TraktClient, -} - -impl TraktCategoriesProcessor { - pub fn new(http_client: Arc, trakt_config: &TraktConfig) -> Result { - let client = TraktClient::new(http_client, trakt_config.api.clone())?; - Ok(Self { client }) - } - - pub async fn process_trakt_categories( - &self, - playlist: &mut [PlaylistGroup], - target: &ConfigTarget, - trakt_config: &TraktConfig, - ) -> Result, Vec> { - 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 trakt_lists = match self.client.get_all_lists(&trakt_config.lists).await { - Ok(lists) => lists, - Err(errors) => { - warn!("Failed to fetch some Trakt lists: {} errors", errors.len()); - return Err(errors); - } - }; - - 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); - - if let Some(trakt_items) = trakt_lists.get(&cache_key) { - info!("Processing Trakt list {} with {} items", cache_key, trakt_items.len()); - - let matches = Self::match_trakt_items_with_playlist( - trakt_items, - playlist, - list_config, - ); - - if !matches.is_empty() { - let category = Self::create_category_from_matches( - matches, - playlist, - list_config, - ); - - if !category.channels.is_empty() { - total_matches += category.channels.len(); - let category_len = category.channels.len(); - new_categories.push(category); - info!("Created Trakt category '{}' with {} items", - list_config.category_name, category_len); - } - } - } else { - warn!("No items found for Trakt list {cache_key}"); - } - } - - info!("Trakt processing complete: created {} categories with {} total matches", - new_categories.len(), total_matches); - - Ok(new_categories) - } - - fn match_trakt_items_with_playlist( - trakt_items: &[TraktListItem], - playlist: &[PlaylistGroup], - list_config: &TraktListConfig, - ) -> Vec { - // Convert Trakt items into a matching structure - let trakt_match_items: Vec = trakt_items - .iter() - .map(TraktMatchItem::from) - .filter(|item| should_include_item(item, &list_config.content_type)) - .collect(); - - info!("Matching {} Trakt items against playlist for content type {:?}", - trakt_match_items.len(), list_config.content_type); - - // Collect all playlist items that are compatible with the content type - let mut compatible_items: Vec<&PlaylistItem> = Vec::new(); - for group in playlist { - for channel in &group.channels { - if is_compatible_content_type(channel.header.xtream_cluster, &list_config.content_type) { - compatible_items.push(channel); - } - } - } - - info!("Processing {} compatible playlist items", compatible_items.len()); - - // Process all items without truncation - let all_matches: Vec = if compatible_items.len() > 1000 { - compatible_items - .par_iter() - .flat_map(|channel| { - Self::find_matches_for_channel(channel, &trakt_match_items, list_config) - }) - .collect() - } else { - compatible_items - .iter() - .flat_map(|channel| { - Self::find_matches_for_channel(channel, &trakt_match_items, list_config) - }) - .collect() - }; - - // Now deduplicate by keeping only the best matches - let total_matches = all_matches.len(); - let best_matches = Self::select_best_matches(all_matches, playlist); - - let display_key = format!("{}:{}", list_config.user, list_config.list_slug); - info!("Found {} unique matches (from {} total) for Trakt list {}", - best_matches.len(), total_matches, display_key); - - best_matches - } - - fn find_matches_for_channel( - channel: &PlaylistItem, - trakt_items: &[TraktMatchItem], - list_config: &TraktListConfig, - ) -> Vec { - let mut matches = Vec::new(); - let playlist_uuid = format!("{:?}", channel.header.uuid); - - // Try TMDB exact matching first - if let Some(playlist_tmdb_id) = extract_tmdb_id_from_playlist_item(channel) { - for trakt_item in trakt_items { - if let Some(trakt_tmdb_id) = trakt_item.tmdb_id { - if playlist_tmdb_id == trakt_tmdb_id { - matches.push(TraktMatchResult { - playlist_item_uuid: playlist_uuid.clone(), - trakt_item: trakt_item.clone(), - match_score: 1.0, - match_type: MatchType::TmdbExact, - }); - debug!("TMDB exact match: '{}' (TMDB: {})", channel.header.title, playlist_tmdb_id); - } - } - } - } - - // Try fuzzy matching if no exact match found - if matches.is_empty() { - let normalized_playlist_title = normalize_title_for_matching(&channel.header.title); - let playlist_year = extract_year_from_title(&channel.header.title); - 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 normalized_trakt_title = normalize_title_for_matching(&trakt_item.title); - let title_score = jaro_winkler(&normalized_playlist_title, &normalized_trakt_title); - - // Calculate year bonus - let year_bonus = Self::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.clone(), combined_score)); - } - } else { - best_match = Some((trakt_item.clone(), combined_score)); - } - } - } - - 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 - }; - - matches.push(TraktMatchResult { - playlist_item_uuid: playlist_uuid.clone(), - trakt_item: trakt_item.clone(), - match_score: combined_score, - match_type: match_type.clone(), - }); - - debug!("Fuzzy match: '{}' -> '{}' (final: {:.3}, type: {:?})", - channel.header.title, - trakt_item.title, - combined_score, - match_type); - } - } - - matches - } - - fn calculate_year_bonus(playlist_year: Option, trakt_year: Option) -> f64 { - 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 create_category_from_matches( - matches: Vec, - playlist: &[PlaylistGroup], - list_config: &TraktListConfig, - ) -> PlaylistGroup { - let mut matched_items = Vec::new(); - - // Create playlist index for faster lookups using centralized function - let playlist_index = create_playlist_index(playlist); - - let mut sorted_matches = matches; - // Simple sort by rank only - sorted_matches.sort_by(|a, b| { - a.trakt_item.rank.unwrap_or(9999).cmp(&b.trakt_item.rank.unwrap_or(9999)) - }); - - for match_result in sorted_matches { - if let Some(playlist_item) = playlist_index.get(&match_result.playlist_item_uuid) { - let mut modified_item = (*playlist_item).clone(); // Clone the referenced item - // Use the (possibly numbered) title from the match result (which now contains the original playlist title) - modified_item.header.title.clone_from(&match_result.trakt_item.title); - // Synchronize name with title so both fields show the same value - modified_item.header.name.clone_from(&match_result.trakt_item.title); - matched_items.push(modified_item); - } - } - - 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) - } - }; - - PlaylistGroup { - id: 0, - title: list_config.category_name.clone(), - channels: matched_items, - xtream_cluster: cluster, - } - } - - fn select_best_matches(all_matches: Vec, playlist: &[PlaylistGroup]) -> Vec { - use std::collections::HashMap; - - // Create playlist index for faster lookups - let playlist_index = create_playlist_index(playlist); - - // Group matches by Trakt title to handle multiple copies of the same content with different qualities - let mut title_groups: HashMap> = HashMap::new(); - - // Group all matches by Trakt title (normalized) - for match_result in all_matches { - let normalized_title = normalize_title_for_matching(&match_result.trakt_item.title); - title_groups.entry(normalized_title).or_default().push(match_result); - } - - let mut final_matches = Vec::new(); - let mut used_playlist_items: std::collections::HashSet = std::collections::HashSet::new(); - - // For each Trakt title, select the best matches and number duplicates - for (_title, mut matches) in title_groups { - // Sort matches by score descending - matches.sort_by(|a, b| b.match_score.partial_cmp(&a.match_score).unwrap_or(std::cmp::Ordering::Equal)); - - let mut selected_matches = Vec::new(); - - for match_result in matches { - let playlist_uuid = match_result.playlist_item_uuid.clone(); - - // Skip if this playlist item is already used - if used_playlist_items.contains(&playlist_uuid) { - continue; - } - - // Add this match to our selected list - selected_matches.push(match_result); - used_playlist_items.insert(playlist_uuid); - } - - // If we have multiple matches for the same Trakt content title, number them - if selected_matches.len() > 1 { - for (index, mut match_result) in selected_matches.into_iter().enumerate() { - // Get the original playlist item title for numbering - if let Some(playlist_item) = playlist_index.get(&match_result.playlist_item_uuid) { - // Use the original playlist title with numbering instead of Trakt title - match_result.trakt_item.title = format!("{} #{}", playlist_item.header.title, index + 1); - } else { - // Fallback to Trakt title with numbering if playlist item not found - match_result.trakt_item.title = format!("{} #{}", match_result.trakt_item.title, index + 1); - } - - final_matches.push(match_result); - } - } else if let Some(mut match_result) = selected_matches.into_iter().next() { - // Single match, use original playlist title without numbering - if let Some(playlist_item) = playlist_index.get(&match_result.playlist_item_uuid) { - match_result.trakt_item.title.clone_from(&playlist_item.header.title); - } - final_matches.push(match_result); - } - } - - final_matches - } -} - -pub async fn process_trakt_categories_for_target( - http_client: Arc, - playlist: &mut [PlaylistGroup], - target: &ConfigTarget, -) -> Result, Vec> { - - let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt_lists.as_ref()) else { - debug!("No Trakt configuration found for target {}", target.name); - return Ok(vec![]); - }; - - - let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) { - Ok(p) => p, - Err(e) => return Err(vec![e]), - }; - processor.process_trakt_categories(playlist, target, trakt_config).await -} \ No newline at end of file diff --git a/src/utils/constants.rs b/src/utils/constants.rs index 2c95d4b73..6b65cf66a 100644 --- a/src/utils/constants.rs +++ b/src/utils/constants.rs @@ -90,6 +90,7 @@ pub struct Constants { pub export_style_config: ExportStyleConfig, pub country_codes: HashSet<&'static str>, pub allowed_output_formats: Vec, + pub re_trakt_year: Regex, } pub static CONSTANTS: LazyLock = LazyLock::new(|| @@ -119,7 +120,6 @@ pub static CONSTANTS: LazyLock = 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 +136,6 @@ pub static CONSTANTS: LazyLock = LazyLock::new(|| "tt", "tn", "tr", "tm", "tv", "ug", "ua", "ae", "gb", "us", "uy", "uz", "vu", "va", "ve", "vn", "ye", "zm", "zw", ].into_iter().collect::>(), + re_trakt_year: Regex::new(r"\(?(\d{4})\)?$").unwrap(), } ); diff --git a/src/utils/trakt/client.rs b/src/utils/trakt/client.rs index b1d81aee2..cb043addb 100644 --- a/src/utils/trakt/client.rs +++ b/src/utils/trakt/client.rs @@ -3,9 +3,9 @@ use crate::tuliprox_error::TuliproxError; use reqwest::header::{HeaderMap, HeaderValue}; use std::collections::HashMap; use std::sync::Arc; -use log::{info, warn}; - -use super::errors::{handle_trakt_api_error, create_fetch_error, create_parse_error, create_read_error, create_header_error}; +use log::{debug, info, warn}; +use crate::tuliprox_error::{info_err, TuliproxErrorKind}; +use super::errors::{handle_trakt_api_error}; pub struct TraktClient { client: Arc, @@ -15,40 +15,31 @@ pub struct TraktClient { } impl TraktClient { - pub fn new(client: Arc, api_config: TraktApiConfig) -> Result { - let headers = Self::create_headers(&api_config)?; - Ok(Self { + pub fn new(client: Arc, api_config: TraktApiConfig) -> Self { + let headers = Self::create_headers(&api_config); + Self { client, api_config, headers, - }) + } } - fn create_headers(api_config: &TraktApiConfig) -> Result { + fn create_headers(api_config: &TraktApiConfig) -> axum::http::HeaderMap { let mut headers = HeaderMap::new(); - headers.insert( - "Content-Type", - HeaderValue::from_static("application/json") - ); - - let api_key_header = HeaderValue::from_str(api_config.get_api_key()) - .map_err(|e| create_header_error("Trakt API key", &e))?; - headers.insert("trakt-api-key", api_key_header); - - let api_version_header = HeaderValue::from_str(api_config.get_api_version()) - .map_err(|e| create_header_error("API version", &e))?; - headers.insert("trakt-api-version", api_version_header); + headers.insert(reqwest::header::CONTENT_TYPE, HeaderValue::from_static(mime::APPLICATION_JSON.as_ref())); + headers.insert("trakt-api-key", HeaderValue::from_static(api_config.get_api_key())); + headers.insert("trakt-api-version", HeaderValue::from_static(api_config.get_api_version())); - Ok(headers) + headers } fn build_list_url(&self, user: &str, list_slug: &str) -> String { - format!("{}/users/{}/lists/{}/items", self.api_config.get_base_url(), user, list_slug) + format!("{}/users/{user}/lists/{list_slug}/items", self.api_config.get_base_url()) } pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result, TuliproxError> { - info!("Fetching Trakt list {}:{}", list_config.user, list_config.list_slug); + debug!("Fetching Trakt list {}:{}", list_config.user, list_config.list_slug); let url = self.build_list_url(&list_config.user, &list_config.list_slug); @@ -57,23 +48,21 @@ impl TraktClient { .headers(self.headers.clone()) .send() .await - .map_err(|e| create_fetch_error(&url, &e))?; + .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)?; - unreachable!() } let response_text = response .text() .await - .map_err(|error: reqwest::Error| create_read_error(&error))?; + .map_err(|error: reqwest::Error| info_err!(format!("Failed to read Trakt response: {error}")))?; - let items: Vec = serde_json::from_str(&response_text) - .map_err(|error: serde_json::Error| create_parse_error(&error))?; - - info!("Successfully fetched {} items from Trakt list {}:{}", - items.len(), list_config.user, list_config.list_slug); + let mut items: Vec = 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) } diff --git a/src/utils/trakt/errors.rs b/src/utils/trakt/errors.rs index a2b85eac7..d4d320bb5 100644 --- a/src/utils/trakt/errors.rs +++ b/src/utils/trakt/errors.rs @@ -4,57 +4,9 @@ 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") - ) + 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")) } } - -/// Create error for failed Trakt API request -pub fn create_fetch_error(url: &str, error: &reqwest::Error) -> TuliproxError { - TuliproxError::new( - TuliproxErrorKind::Notify, - format!("Failed to fetch Trakt list {url}: {error}") - ) -} - -/// Create error for failed response parsing -pub fn create_parse_error(error: &serde_json::Error) -> TuliproxError { - TuliproxError::new( - TuliproxErrorKind::Notify, - format!("Failed to parse Trakt response: {error}") - ) -} - -/// Create error for failed response reading -pub fn create_read_error(error: &reqwest::Error) -> TuliproxError { - TuliproxError::new( - TuliproxErrorKind::Notify, - format!("Failed to read Trakt response: {error}") - ) -} - -/// Create error for invalid header values -pub fn create_header_error(field: &str, error: &reqwest::header::InvalidHeaderValue) -> TuliproxError { - TuliproxError::new( - TuliproxErrorKind::Notify, - format!("Invalid {field}: {error}") - ) -} \ No newline at end of file diff --git a/src/utils/trakt/mod.rs b/src/utils/trakt/mod.rs index 63f2c6ac7..5a61ab4cc 100644 --- a/src/utils/trakt/mod.rs +++ b/src/utils/trakt/mod.rs @@ -3,16 +3,7 @@ pub mod client; pub mod errors; use deunicode::deunicode; -use std::sync::OnceLock; - -static YEAR_REGEX: OnceLock = OnceLock::new(); - -/// Get cached year regex pattern -fn get_year_regex() -> &'static regex::Regex { - YEAR_REGEX.get_or_init(|| { - regex::Regex::new(r"\(?(\d{4})\)?$").unwrap() - }) -} +use crate::utils::CONSTANTS; /// Normalize title for matching - optimized version with reduced allocations pub fn normalize_title_for_matching(title: &str) -> String { @@ -30,9 +21,7 @@ pub fn normalize_title_for_matching(title: &str) -> String { /// Extract year from title using cached regex pattern - optimized version pub fn extract_year_from_title(title: &str) -> Option { - let year_regex = get_year_regex(); - - if let Some(captures) = year_regex.captures(title) { + if let Some(captures) = CONSTANTS.re_trakt_year.captures(title) { if let Some(year_str) = captures.get(1) { if let Ok(year) = year_str.as_str().parse::() { if (1900..=2100).contains(&year) {