Merge pull request #304 from euzu/feature/trakt_list

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