diff --git a/config/source.yml b/config/source.yml index e7acfd439..0a49ce6e3 100644 --- a/config/source.yml +++ b/config/source.yml @@ -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: false + skip_series_direct_source: false + resolve_series: false + resolve_vod: false + trakt_lists: + 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 } diff --git a/src/model/config_target.rs b/src/model/config_target.rs index 6ca3fe41f..58277b539 100644 --- a/src/model/config_target.rs +++ b/src/model/config_target.rs @@ -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,6 +124,8 @@ 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_lists: Option, } diff --git a/src/model/mod.rs b/src/model/mod.rs index 9f071c6bf..c3d9e33c8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -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::*; diff --git a/src/model/trakt.rs b/src/model/trakt.rs new file mode 100644 index 000000000..b3cedd2f4 --- /dev/null +++ b/src/model/trakt.rs @@ -0,0 +1,170 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Default)] +pub struct TraktApiConfig { + +} + +impl TraktApiConfig { + pub fn get_api_key(&self) -> &'static str { + "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3" + } + + pub fn get_api_version(&self) -> &'static str { + "2" + } + + pub fn get_base_url(&self) -> &'static str { + "https://api.trakt.tv" + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktListConfig { + pub user: String, + pub list_slug: String, + pub category_name: String, + #[serde(default)] + pub content_type: TraktContentType, + #[serde(default = "default_fuzzy_threshold")] + pub fuzzy_match_threshold: u8, // Percentage (0-100) +} + +fn default_fuzzy_threshold() -> u8 { + 80 +} + +#[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)] +pub struct TraktConfig { + pub api: TraktApiConfig, + pub lists: Vec, +} + +// API Response structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktListItem { + pub rank: Option, + pub id: u64, + pub listed_at: String, + pub notes: Option, + #[serde(rename = "type")] + pub item_type: String, + pub movie: Option, + pub show: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktMovie { + pub title: String, + pub year: Option, + pub ids: TraktIds, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktShow { + pub title: String, + pub year: Option, + pub ids: TraktIds, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraktIds { + pub trakt: u32, + pub slug: String, + pub tvdb: Option, + pub imdb: Option, + pub tmdb: Option, + pub tvrage: Option, +} + +// Internal matching structures +#[derive(Debug, Clone)] +pub struct TraktMatchItem { + pub title: String, + pub year: Option, + pub tmdb_id: Option, + pub trakt_id: u32, + pub content_type: TraktContentType, + pub rank: Option, +} + +impl From<&TraktListItem> for TraktMatchItem { + fn from(item: &TraktListItem) -> Self { + match item.item_type.as_str() { + "movie" => { + if let Some(movie) = &item.movie { + TraktMatchItem { + title: movie.title.clone(), + 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(), + 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(), + } + } +} + +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 match_score: f64, + pub match_type: MatchType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchType { + TmdbExact, + FuzzyTitle, + FuzzyTitleYear, +} \ No newline at end of file diff --git a/src/processing/processor/mod.rs b/src/processing/processor/mod.rs index 5f136855c..e9f76446c 100644 --- a/src/processing/processor/mod.rs +++ b/src/processing/processor/mod.rs @@ -5,6 +5,7 @@ mod xtream_vod; mod xtream_series; pub mod epg; mod sort; +pub mod trakt_categories; #[macro_export] macro_rules! handle_error { diff --git a/src/processing/processor/playlist.rs b/src/processing/processor/playlist.rs index 28b41e9b2..eb0496018 100644 --- a/src/processing/processor/playlist.rs +++ b/src/processing/processor/playlist.rs @@ -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_categories::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; @@ -475,6 +476,22 @@ async fn process_playlist_for_target(client: Arc, assign_channel_no_playlist(&mut flat_new_playlist); step.tick("Assigned channel counter"); map_playlist_counter(target, &mut flat_new_playlist); + + // 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); + } + } + step.tick("Processed group watches"); process_watch(&client, target, cfg, &flat_new_playlist); step.tick("Persisting playlists"); diff --git a/src/processing/processor/trakt_categories.rs b/src/processing/processor/trakt_categories.rs new file mode 100644 index 000000000..b8d9e3b56 --- /dev/null +++ b/src/processing/processor/trakt_categories.rs @@ -0,0 +1,427 @@ +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/repository/kodi_repository.rs b/src/repository/kodi_repository.rs index 3a9a8eb89..9b922aaec 100644 --- a/src/repository/kodi_repository.rs +++ b/src/repository/kodi_repository.rs @@ -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::() { - 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}]") } }; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 978a743b3..ae86ce386 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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 { diff --git a/src/utils/trakt/client.rs b/src/utils/trakt/client.rs new file mode 100644 index 000000000..b1d81aee2 --- /dev/null +++ b/src/utils/trakt/client.rs @@ -0,0 +1,105 @@ +use crate::model::{TraktApiConfig, TraktListConfig, TraktListItem}; +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}; + +pub struct TraktClient { + client: Arc, + api_config: TraktApiConfig, + // Pre-computed headers to avoid recreating them each time + headers: HeaderMap, +} + +impl TraktClient { + pub fn new(client: Arc, api_config: TraktApiConfig) -> Result { + let headers = Self::create_headers(&api_config)?; + Ok(Self { + client, + api_config, + headers, + }) + } + + fn create_headers(api_config: &TraktApiConfig) -> Result { + 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); + + Ok(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) + } + + pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result, TuliproxError> { + info!("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(|e| create_fetch_error(&url, &e))?; + + 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))?; + + 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); + + Ok(items) + } + + pub async fn get_all_lists(&self, list_configs: &[TraktListConfig]) -> Result>, Vec> { + let mut results = HashMap::new(); + let mut errors = Vec::new(); + + for list_config in list_configs { + let cache_key = format!("{}:{}", list_config.user, list_config.list_slug); + + match self.get_list_items(list_config).await { + Ok(items) => { + results.insert(cache_key, items); + } + Err(err) => { + warn!("Failed to fetch Trakt list {}: {}", cache_key, err.message); + errors.push(err); + } + } + } + + if results.is_empty() && !errors.is_empty() { + Err(errors) + } else { + Ok(results) + } + } +} \ No newline at end of file diff --git a/src/utils/trakt/errors.rs b/src/utils/trakt/errors.rs new file mode 100644 index 000000000..a2b85eac7 --- /dev/null +++ b/src/utils/trakt/errors.rs @@ -0,0 +1,60 @@ +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") + ) + } +} + +/// 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 new file mode 100644 index 000000000..63f2c6ac7 --- /dev/null +++ b/src/utils/trakt/mod.rs @@ -0,0 +1,70 @@ +// Common utilities for Trakt functionality +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() + }) +} + +/// 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()); + } + } + + result +} + +/// 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(year_str) = captures.get(1) { + if let Ok(year) = year_str.as_str().parse::() { + 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); + } +} \ No newline at end of file