use std::borrow::Cow; use crate::model::{ConfigTarget, FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, XtreamCluster}; use crate::model::{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 { 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 { if let (Some(p_year), Some(t_year)) = (playlist_year, trakt_year) { if p_year == t_year { // Perfect year match gets substantial bonus return 0.5; } return -0.5; } 0.0 } fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option, Option), trakt_items: &'a [TraktMatchItem], list_config: &'a TraktListConfig) -> Option> { // 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, Option), trakt_items: &'a [TraktMatchItem<'a>], list_config: &'a TraktListConfig, ) -> Option> { // 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>, list_config: &'a TraktListConfig, ) -> Option { 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 { 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) { 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, 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> { 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, playlist: &[PlaylistGroup], target: &ConfigTarget, ) -> Result, Vec> { 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()); } }