From e19cf18e45ca2b3816f9d0187237901486666d97 Mon Sep 17 00:00:00 2001 From: euzu Date: Tue, 17 Jun 2025 13:06:52 +0200 Subject: [PATCH] Trakt switched to levenshtein instead jaro_winkler --- config/source.yml | 2 +- src/model/config_target.rs | 22 +++- src/model/trakt.rs | 55 ++++---- src/processing/processor/playlist.rs | 18 +-- src/processing/processor/trakt.rs | 188 +++++++++++++++------------ src/utils/mod.rs | 13 ++ src/utils/trakt/client.rs | 39 +----- src/utils/trakt/mod.rs | 11 +- 8 files changed, 195 insertions(+), 153 deletions(-) diff --git a/config/source.yml b/config/source.yml index 0a49ce6e3..6c9b8aede 100644 --- a/config/source.yml +++ b/config/source.yml @@ -33,7 +33,7 @@ sources: skip_series_direct_source: false resolve_series: false resolve_vod: false - trakt_lists: + trakt: lists: - user: "linaspurinis" list_slug: "top-watched-movies-of-the-week" diff --git a/src/model/config_target.rs b/src/model/config_target.rs index 58277b539..6a899c1b8 100644 --- a/src/model/config_target.rs +++ b/src/model/config_target.rs @@ -125,10 +125,18 @@ pub struct XtreamTargetOutput { #[serde(default = "default_resolve_delay_secs")] pub resolve_vod_delay: u16, #[serde(default, skip_serializing_if = "Option::is_none")] - pub trakt_lists: Option, + pub trakt: Option, } +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)] pub struct M3uTargetOutput { @@ -175,6 +183,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 { @@ -222,6 +241,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; diff --git a/src/model/trakt.rs b/src/model/trakt.rs index 9e13fe367..2d087cf0b 100644 --- a/src/model/trakt.rs +++ b/src/model/trakt.rs @@ -2,27 +2,33 @@ 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 {} - -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" - } +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)] +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)] pub struct TraktListConfig { @@ -35,10 +41,6 @@ pub struct TraktListConfig { 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 { @@ -55,10 +57,17 @@ impl Default for TraktContentType { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraktConfig { + #[serde(default)] pub api: TraktApiConfig, pub lists: Vec, } +impl TraktConfig { + pub fn prepare(&mut self) { + self.api.prepare(); + } +} + // API Response structures #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraktListItem { diff --git a/src/processing/processor/playlist.rs b/src/processing/processor/playlist.rs index d92329e1e..8f0e0a824 100644 --- a/src/processing/processor/playlist.rs +++ b/src/processing/processor/playlist.rs @@ -463,14 +463,20 @@ async fn process_playlist_for_target(client: Arc, } 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"); @@ -478,10 +484,6 @@ async fn process_playlist_for_target(client: Arc, step.tick("Assigned channel counter"); map_playlist_counter(target, &mut flat_new_playlist); - // Process Trakt categories - step.tick("Processing Trakt categories"); - trakt_playlist(&client, target, errors, &mut flat_new_playlist).await; - step.tick("Processed group watches"); process_watch(&client, target, cfg, &flat_new_playlist); step.tick("Persisting playlists"); @@ -491,12 +493,12 @@ 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 { +async fn trakt_playlist(client: &Arc, target: &ConfigTarget, errors: &mut Vec, playlist: &mut Vec) { + 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()); - flat_new_playlist.extend(trakt_categories); + playlist.extend(trakt_categories); } } Err(trakt_errors) => { diff --git a/src/processing/processor/trakt.rs b/src/processing/processor/trakt.rs index c3e88eb15..06686c334 100644 --- a/src/processing/processor/trakt.rs +++ b/src/processing/processor/trakt.rs @@ -5,9 +5,11 @@ 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; +use strsim::{normalized_levenshtein}; +use log::{debug, info, trace, warn}; +use crate::utils::{trace_if_enabled, with}; + /// Utility functions for content type compatibility fn should_include_item(item: &TraktListItem, content_type: &TraktContentType) -> bool { @@ -44,62 +46,75 @@ fn extract_tmdb_id_from_playlist_item(item: &PlaylistItem) -> Option { } 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 + 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; } } + 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, trakt_items: &'a [TraktMatchItem], list_config: &'a TraktListConfig) -> Option> { +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 = normalize_title_for_matching(&channel.header.title); - let playlist_year = extract_year_from_title(&channel.header.title); + 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 = jaro_winkler(&normalized_playlist_title, &trakt_item.normalized_title); + let title_score = normalized_levenshtein(&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; + 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); + // 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 { + // 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)); } - } else { - best_match = Some((trakt_item, combined_score)); + // early exit strategy + if combined_score >= 99.0 { + break; + } } } } @@ -111,11 +126,10 @@ fn find_best_fuzzy_match_for_item<'a>(channel: &'a PlaylistItem, trakt_items: &' MatchType::FuzzyTitle }; - debug!("Fuzzy match: '{}' -> '{}' (final: {combined_score:.3}, type: {match_type:?})", - channel.header.title, trakt_item.title); + 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, + playlist_item: channel.0, trakt_item, match_score: combined_score, match_type: match_type.clone(), @@ -126,17 +140,17 @@ fn find_best_fuzzy_match_for_item<'a>(channel: &'a PlaylistItem, trakt_items: &' } fn find_best_match_for_item<'a>( - channel: &'a PlaylistItem, + 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) = extract_tmdb_id_from_playlist_item(channel) { + 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.header.title, playlist_tmdb_id); + trace!("TMDB exact match: '{}' (TMDB: {})", channel.0.header.title, playlist_tmdb_id); return Some(TraktMatchResult { - playlist_item: channel, + playlist_item: channel.0, trakt_item, match_score: 1.0, match_type: MatchType::TmdbExact, @@ -148,7 +162,6 @@ fn find_best_match_for_item<'a>( find_best_fuzzy_match_for_item(channel, trakt_items, list_config) } - fn create_category_from_matches<'a>( matches: Vec>, list_config: &'a TraktListConfig, @@ -163,15 +176,24 @@ fn create_category_from_matches<'a>( a.trakt_item.rank.unwrap_or(9999).cmp(&b.trakt_item.rank.unwrap_or(9999)) }); + 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) - 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()); + with!(mut modified_item.header => header { + header.title.clone_from(&match_result.trakt_item.title.to_string()); + // Synchronize name with title so both fields show the same value + header.name.clone_from(&match_result.trakt_item.title.to_string()); + 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, @@ -181,11 +203,9 @@ fn create_category_from_matches<'a>( } }; - if matched_items.is_empty() { return None; } - Some(PlaylistGroup { id: 0, - title: list_config.category_name.clone(), + title: String::from(group_title), channels: matched_items, xtream_cluster: cluster, }) @@ -208,7 +228,12 @@ fn match_trakt_items_with_playlist<'a>( 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)); + 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); + } } } } @@ -228,7 +253,7 @@ impl TraktCategoriesProcessor { pub async fn process_trakt_categories( &self, - playlist: &mut [PlaylistGroup], + playlist: &[PlaylistGroup], target: &ConfigTarget, trakt_config: &TraktConfig, ) -> Result, Vec> { @@ -238,40 +263,33 @@ impl TraktCategoriesProcessor { } 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()); + 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); - info!("Created Trakt category '{}' with {} items", - list_config.category_name, category_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); + } } } - } else { - warn!("No items found for Trakt list {cache_key}"); + Err(err) => { + warn!("Failed to fetch Trakt list {cache_key}: {}", err.message); + } } } - info!("Trakt processing complete: created {} categories with {} total matches", - new_categories.len(), total_matches); + + info!("Trakt processing complete: created {} categories with {total_matches} total matches", + new_categories.len()); Ok(new_categories) } @@ -279,11 +297,11 @@ impl TraktCategoriesProcessor { } pub async fn process_trakt_categories_for_target( http_client: Arc, - playlist: &mut [PlaylistGroup], + playlist: &[PlaylistGroup], target: &ConfigTarget, ) -> Result, Vec> { - let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt_lists.as_ref()) else { + 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![]); }; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ae86ce386..8ebaa34d7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -45,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::*; diff --git a/src/utils/trakt/client.rs b/src/utils/trakt/client.rs index cb043addb..a82eeddc2 100644 --- a/src/utils/trakt/client.rs +++ b/src/utils/trakt/client.rs @@ -1,9 +1,8 @@ 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::{debug, info, warn}; +use log::{debug, info}; use crate::tuliprox_error::{info_err, TuliproxErrorKind}; use super::errors::{handle_trakt_api_error}; @@ -26,23 +25,23 @@ impl TraktClient { 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_static(api_config.get_api_key())); - headers.insert("trakt-api-version", HeaderValue::from_static(api_config.get_api_version())); + 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.get_base_url()) + format!("{}/users/{user}/lists/{list_slug}/items", self.api_config.url) } pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result, 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()) @@ -67,28 +66,4 @@ impl TraktClient { 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/mod.rs b/src/utils/trakt/mod.rs index 5a61ab4cc..a52b000e0 100644 --- a/src/utils/trakt/mod.rs +++ b/src/utils/trakt/mod.rs @@ -8,15 +8,20 @@ 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()); } } - - result + + if CONSTANTS.re_trakt_year.is_match(&result) { + result[..result.len() - 4].to_string() + } else { + result + } } /// Extract year from title using cached regex pattern - optimized version