From 585efcca470e4d217286bec9e82dfe204950f6c9 Mon Sep 17 00:00:00 2001 From: Juanjo Presa Date: Thu, 4 Jun 2026 20:07:30 +0200 Subject: [PATCH] feat: improve Trakt list curation matching (#747) * feat: paginate Trakt list fetching * feat: allow TMDB-only Trakt list matching * chore: address Trakt review comments * feat: add public Trakt charts * fix: keep Trakt category clones distinct * fix: avoid truncating Trakt chart ranks * fix: keep Trakt series category episodes * trakt improvements --- CHANGELOG.md | 4 + backend/src/model/config/trakt.rs | 79 ++- backend/src/model/config/trakt_api.rs | 26 + backend/src/processing/processor/trakt.rs | 455 ++++++++++++++++-- backend/src/repository/playlist_repository.rs | 130 +++-- backend/src/utils/trakt/client.rs | 368 +++++++++++++- docs/src/configuration/source.md | 45 +- frontend/public/assets/i18n/en.json | 13 + .../source_editor/_source_editor_form.scss | 10 + .../src/app/components/source_editor/mod.rs | 2 + .../source_editor/output_xtream_form.rs | 315 ++++++++++-- .../source_editor/trakt_chart_item_form.rs | 181 +++++++ .../source_editor/trakt_list_item_form.rs | 67 ++- shared/src/model/config/trakt.rs | 172 +++++-- 14 files changed, 1670 insertions(+), 197 deletions(-) create mode 100644 frontend/src/app/components/source_editor/trakt_chart_item_form.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d84bf120..cca68e7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ ## 🌟 New Features +- **Trakt Charts**: Xtream Trakt integration can now build virtual categories from public Trakt charts via `trakt.charts[]`. + - MVP supports `movies/shows` with `trending` and `popular`. + - User-owned Trakt lists remain configured separately under `trakt.lists[]`. + - **Session Expiry Handling**: - The Web UI now schedules a client-side logout when the JWT expires, showing a notification and returning the user to the login screen instead of silently failing with 401 errors. diff --git a/backend/src/model/config/trakt.rs b/backend/src/model/config/trakt.rs index 79ea8b6e5..5a455255b 100644 --- a/backend/src/model/config/trakt.rs +++ b/backend/src/model/config/trakt.rs @@ -1,4 +1,7 @@ -use shared::model::{PlaylistItem, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto}; +use shared::model::{ + PlaylistItem, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, + TraktContentType, TraktListConfigDto, +}; use crate::model::config::trakt_api::TraktMatchItem; use crate::model::macros; @@ -39,6 +42,7 @@ pub struct TraktListConfig { pub list_slug: String, pub category_name: String, pub content_type: TraktContentType, + pub tmdb_only: bool, pub fuzzy_match_threshold: u8, // Percentage (0-100) } @@ -50,7 +54,8 @@ impl From<&TraktListConfigDto> for TraktListConfig { list_slug: dto.list_slug.clone(), category_name: dto.category_name.clone(), content_type: dto.content_type, - fuzzy_match_threshold: dto.fuzzy_match_threshold + tmdb_only: dto.tmdb_only, + fuzzy_match_threshold: dto.fuzzy_match_threshold, } } } @@ -62,7 +67,72 @@ impl From<&TraktListConfig> for TraktListConfigDto { list_slug: instance.list_slug.clone(), category_name: instance.category_name.clone(), content_type: instance.content_type, - fuzzy_match_threshold: instance.fuzzy_match_threshold + tmdb_only: instance.tmdb_only, + fuzzy_match_threshold: instance.fuzzy_match_threshold, + } + } +} + +#[derive(Debug, Clone)] +pub struct TraktChartConfig { + pub kind: TraktChartKind, + pub chart: TraktChartType, + pub category_name: String, + pub tmdb_only: bool, + pub fuzzy_match_threshold: u8, // Percentage (0-100) +} + +macros::from_impl!(TraktChartConfig); +impl From<&TraktChartConfigDto> for TraktChartConfig { + fn from(dto: &TraktChartConfigDto) -> Self { + Self { + kind: dto.kind, + chart: dto.chart, + category_name: dto.category_name.clone(), + tmdb_only: dto.tmdb_only, + fuzzy_match_threshold: dto.fuzzy_match_threshold, + } + } +} + +impl From<&TraktChartConfig> for TraktChartConfigDto { + fn from(instance: &TraktChartConfig) -> Self { + Self { + kind: instance.kind, + chart: instance.chart, + category_name: instance.category_name.clone(), + tmdb_only: instance.tmdb_only, + fuzzy_match_threshold: instance.fuzzy_match_threshold, + } + } +} + +#[derive(Debug, Clone)] +pub struct TraktCategoryConfig { + pub category_name: String, + pub content_type: TraktContentType, + pub tmdb_only: bool, + pub fuzzy_match_threshold: u8, // Percentage (0-100) +} + +impl From<&TraktListConfig> for TraktCategoryConfig { + fn from(config: &TraktListConfig) -> Self { + Self { + category_name: config.category_name.clone(), + content_type: config.content_type, + tmdb_only: config.tmdb_only, + fuzzy_match_threshold: config.fuzzy_match_threshold, + } + } +} + +impl From<&TraktChartConfig> for TraktCategoryConfig { + fn from(config: &TraktChartConfig) -> Self { + Self { + category_name: config.category_name.clone(), + content_type: config.kind.content_type(), + tmdb_only: config.tmdb_only, + fuzzy_match_threshold: config.fuzzy_match_threshold, } } } @@ -72,6 +142,7 @@ pub struct TraktConfig { pub enabled: bool, pub api: TraktApiConfig, pub lists: Vec, + pub charts: Vec, } macros::from_impl!(TraktConfig); @@ -81,6 +152,7 @@ impl From<&TraktConfigDto> for TraktConfig { enabled: dto.enabled, api: TraktApiConfig::from(&dto.api), lists: dto.lists.iter().map(Into::into).collect(), + charts: dto.charts.iter().map(Into::into).collect(), } } } @@ -90,6 +162,7 @@ impl From<&TraktConfig> for TraktConfigDto { enabled: dto.enabled, api: TraktApiConfigDto::from(&dto.api), lists: dto.lists.iter().map(TraktListConfigDto::from).collect(), + charts: dto.charts.iter().map(TraktChartConfigDto::from).collect(), } } } diff --git a/backend/src/model/config/trakt_api.rs b/backend/src/model/config/trakt_api.rs index d1434db3c..f048825df 100644 --- a/backend/src/model/config/trakt_api.rs +++ b/backend/src/model/config/trakt_api.rs @@ -23,6 +23,32 @@ pub struct TraktListItem { } impl TraktListItem { + pub fn from_movie_chart(movie: TraktMovie, rank: u32) -> Self { + Self { + id: u64::from(movie.ids.trakt), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "movie".to_string(), + movie: Some(movie), + show: None, + content_type: TraktContentType::Vod, + } + } + + pub fn from_show_chart(show: TraktShow, rank: u32) -> Self { + Self { + id: u64::from(show.ids.trakt), + rank: Some(rank), + listed_at: String::new(), + notes: None, + item_type: "show".to_string(), + movie: None, + show: Some(show), + content_type: TraktContentType::Series, + } + } + pub fn prepare(&mut self) { self.content_type = match self.item_type.as_str() { "movie" => if self.movie.is_some() { TraktContentType::Vod } else { TraktContentType::Both }, diff --git a/backend/src/processing/processor/trakt.rs b/backend/src/processing/processor/trakt.rs index a361932b6..aaf8201b4 100644 --- a/backend/src/processing/processor/trakt.rs +++ b/backend/src/processing/processor/trakt.rs @@ -1,13 +1,16 @@ use crate::model::{ConfigTarget, TraktListItem, TraktMatchItem}; -use crate::model::{TraktConfig, TraktListConfig, TraktMatchResult}; +use crate::model::{TraktCategoryConfig, TraktConfig, TraktMatchResult}; use crate::utils::{extract_year_from_title, normalize_title_for_matching, TraktClient}; use crate::utils::{trace_if_enabled, with}; use log::{debug, info, trace, warn}; use shared::error::TuliproxError; -use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster}; -use shared::utils::{Internable, CONSTANTS}; +use shared::model::{ + FieldGetAccessor, FieldSetAccessor, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, + TraktContentType, UUIDType, XtreamCluster, +}; +use shared::utils::{hash_string, Internable, CONSTANTS}; use indexmap::IndexMap; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use strsim::normalized_levenshtein; fn extract_quality(value: &str) -> Option<&str> { @@ -37,6 +40,20 @@ fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContent } } +fn is_matchable_playlist_item(item_type: PlaylistItemType, content_type: TraktContentType) -> bool { + match content_type { + TraktContentType::Vod => matches!(item_type, PlaylistItemType::Video | PlaylistItemType::LocalVideo), + TraktContentType::Series => matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo), + TraktContentType::Both => matches!( + item_type, + PlaylistItemType::Video + | PlaylistItemType::LocalVideo + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo + ), + } +} + 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 { @@ -48,11 +65,11 @@ fn calculate_year_bonus(playlist_year: Option, trakt_year: Option) -> 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> { +fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option, Option), trakt_items: &'a [TraktMatchItem], category_config: &'a TraktCategoryConfig) -> 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 threshold = f64::from(category_config.fuzzy_match_threshold) / 100.0; let mut best_match: Option<(&TraktMatchItem, f64)> = None; for trakt_item in trakt_items { @@ -106,7 +123,7 @@ fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option fn find_best_match_for_item<'a>( channel: (&'a PlaylistItem, String, Option, Option), trakt_items: &'a [TraktMatchItem<'a>], - list_config: &'a TraktListConfig, + category_config: &'a TraktCategoryConfig, ) -> Option> { // Try TMDB exact matching first if let Some(playlist_tmdb_id) = channel.3 { @@ -123,12 +140,17 @@ fn find_best_match_for_item<'a>( } } - find_best_fuzzy_match_for_item(channel, trakt_items, list_config) + if category_config.tmdb_only { + return None; + } + + find_best_fuzzy_match_for_item(channel, trakt_items, category_config) } fn create_category_from_matches<'a>( matches: Vec>, - list_config: &'a TraktListConfig, + category_config: &'a TraktCategoryConfig, + series_children_by_parent_code: &HashMap, Vec<&'a PlaylistItem>>, ) -> Vec { if matches.is_empty() { return vec![]; } @@ -145,26 +167,34 @@ fn create_category_from_matches<'a>( )) }); - let group_title = list_config.category_name.as_str().intern(); + let group_title = category_config.category_name.as_str().intern(); for match_result in sorted_matches { - let mut modified_item = match_result.playlist_item.clone(); - with!(mut modified_item.header => header { - let title = header.get_field("caption").unwrap_or_else(|| Arc::clone(&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); - } + let modified_item = clone_item_for_trakt_category( + match_result.playlist_item, + category_config.category_name.as_str(), + &group_title, + ); + let parent_uuid = modified_item.header.uuid.intern(); + let is_series_info = matches!( + modified_item.header.item_type, + PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo + ); + let child_lookup_keys = if is_series_info { + series_info_child_lookup_keys(match_result.playlist_item) + } else { + Vec::new() + }; + let cluster = modified_item.header.xtream_cluster; + matched_items_by_cluster.entry(cluster).or_default().push(modified_item); + + if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) { + for child in children { + let mut child = clone_item_for_trakt_category(child, category_config.category_name.as_str(), &group_title); + child.header.parent_code = parent_uuid.clone(); + matched_items_by_cluster.entry(child.header.xtream_cluster).or_default().push(child); } - header.group = group_title.clone(); - header.gen_uuid(); - matched_items_by_cluster.entry(header.xtream_cluster).or_default().push(modified_item); - }); + } } matched_items_by_cluster.into_iter().map(|(cluster, channels)| { @@ -177,34 +207,86 @@ fn create_category_from_matches<'a>( }).collect() } +fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group_title: &Arc) -> PlaylistItem { + let mut modified_item = item.clone(); + let source_uuid = if modified_item.header.uuid == UUIDType::default() { + modified_item.get_uuid() + } else { + modified_item.header.uuid + }; + + with!(mut modified_item.header => header { + let title = header.get_field("caption").unwrap_or_else(|| Arc::clone(&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 = group_title.clone(); + header.uuid = hash_string(&format!("trakt-category:{category_name}:{source_uuid}")); + }); + + modified_item +} + +fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec> { + match series_info.header.item_type { + PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()], + PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()], + _ => Vec::new(), + } +} + +fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap, Vec<&PlaylistItem>> { + let mut children = HashMap::, Vec<&PlaylistItem>>::new(); + for playlist_group in playlist { + for channel in &playlist_group.channels { + if matches!(channel.header.item_type, PlaylistItemType::Series | PlaylistItemType::LocalSeries) + && !channel.header.parent_code.is_empty() + { + children.entry(channel.header.parent_code.clone()).or_default().push(channel); + } + } + } + children +} + fn match_trakt_items_with_playlist<'a>( trakt_items: &'a [TraktListItem], playlist: &'a [PlaylistGroup], - list_config: &'a TraktListConfig, + category_config: &'a TraktCategoryConfig, ) -> Vec { let trakt_match_items: Vec> = trakt_items .iter() - .filter(|item| should_include_item(item, list_config.content_type)) + .filter(|item| should_include_item(item, category_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); + debug!("Matching {} Trakt items against playlist for content type {:?}", trakt_match_items.len(), category_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) { + if is_compatible_content_type(channel.header.xtream_cluster, category_config.content_type) + && is_matchable_playlist_item(channel.header.item_type, category_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 = channel.get_tmdb_id(); - if let Some(matched) = find_best_match_for_item((channel, normalized_title, channel_year, channel_tmdb_id), &trakt_match_items, list_config) { + if let Some(matched) = find_best_match_for_item((channel, normalized_title, channel_year, channel_tmdb_id), &trakt_match_items, category_config) { matches.push(matched); } } } } - create_category_from_matches(matches, list_config) + let series_children_by_parent_code = series_children_by_parent_code(playlist); + create_category_from_matches(matches, category_config, &series_children_by_parent_code) } pub struct TraktCategoriesProcessor { @@ -223,29 +305,35 @@ impl TraktCategoriesProcessor { target: &ConfigTarget, trakt_config: &TraktConfig, ) -> Result>, Vec> { - if trakt_config.lists.is_empty() { - debug!("No Trakt lists configured for target {}", target.name); + if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() { + debug!("No Trakt lists or charts configured for target {}", target.name); return Ok(None); } - info!("Processing {} Trakt lists for target {}", trakt_config.lists.len(), target.name); + info!( + "Processing {} Trakt lists and {} Trakt charts for target {}", + trakt_config.lists.len(), + trakt_config.charts.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); + let category_config = TraktCategoryConfig::from(list_config); match self.client.get_list_items(list_config).await { Ok(trakt_items) => { debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len()); - let categories = match_trakt_items_with_playlist(&trakt_items, playlist, list_config); + let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config); for category in categories { 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); + debug!("Created Trakt category '{}' with {category_len} items", category_config.category_name); } } } @@ -255,9 +343,34 @@ impl TraktCategoriesProcessor { } } + for chart_config in &trakt_config.charts { + let cache_key = format!("{}:{}", chart_config.kind, chart_config.chart); + let category_config = TraktCategoryConfig::from(chart_config); - info!("Trakt processing complete: created {} categories with {total_matches} total matches", - new_categories.len()); + match self.client.get_chart_items(chart_config).await { + Ok(trakt_items) => { + debug!("Processing Trakt chart {cache_key} with {} items", trakt_items.len()); + + let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config); + for category in categories { + 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", category_config.category_name); + } + } + } + Err(err) => { + warn!("Failed to fetch Trakt chart {cache_key}: {}", err.message()); + } + } + } + + info!( + "Trakt processing complete: created {} categories with {total_matches} total matches", + new_categories.len() + ); Ok(Some(new_categories)) } @@ -283,6 +396,10 @@ pub async fn process_trakt_categories_for_target( #[cfg(test)] mod tests { use super::*; + use shared::model::{ + EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, TraktContentType, + VideoStreamProperties, + }; #[test] pub fn test_quality() { @@ -290,4 +407,262 @@ mod tests { assert!(quality.is_some()); assert_eq!("UHD", quality.unwrap()); } -} \ No newline at end of file + + #[test] + fn tmdb_only_list_skips_title_fallback_matches() { + let playlist_item = video_item("The Captive", None); + let trakt_items = vec![trakt_movie("The Captive", Some(1915), Some(123), 1)]; + let list_config = list_config(true); + + let matched = find_best_match_for_item( + (&playlist_item, normalize_title_for_matching("The Captive"), None, playlist_item.get_tmdb_id()), + &trakt_items, + &list_config, + ); + + assert!(matched.is_none()); + } + + #[test] + fn tmdb_only_list_keeps_tmdb_exact_matches() { + let playlist_item = video_item("Cautivos", Some(456)); + let trakt_items = vec![trakt_movie("The Captive", Some(2014), Some(456), 1)]; + let list_config = list_config(true); + + let matched = find_best_match_for_item( + (&playlist_item, normalize_title_for_matching("Cautivos"), None, playlist_item.get_tmdb_id()), + &trakt_items, + &list_config, + ); + + assert!(matched.is_some()); + assert_eq!(matched.expect("tmdb match").trakt_item.tmdb_id, Some(456)); + } + + #[test] + fn same_playlist_item_can_appear_in_multiple_trakt_categories() { + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![video_item("The Smashing Machine", Some(760_329))], + xtream_cluster: XtreamCluster::Video, + }]; + let trakt_items = vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 1)]; + let a24_config = named_list_config("▸ A24", true); + let renoir_config = named_list_config("▸ Cines Renoir", true); + + let a24 = match_trakt_items_with_playlist(&trakt_items, &playlist, &a24_config); + let renoir = match_trakt_items_with_playlist(&trakt_items, &playlist, &renoir_config); + + assert_eq!(a24.len(), 1); + assert_eq!(renoir.len(), 1); + let a24_item = &a24[0].channels[0]; + let renoir_item = &renoir[0].channels[0]; + assert_eq!(a24_item.header.group.as_ref(), "▸ A24"); + assert_eq!(renoir_item.header.group.as_ref(), "▸ Cines Renoir"); + assert_ne!(a24_item.header.uuid, renoir_item.header.uuid); + } + + #[test] + fn trakt_series_categories_clone_episode_children() { + let mut series = series_item("Slow Horses", Some(12345)); + let source_parent_code = series.get_uuid().intern(); + series.header.uuid = series.get_uuid(); + let episode = episode_item("Old Scores", &source_parent_code, 7001); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Media Server Series".intern(), + channels: vec![series, episode], + xtream_cluster: XtreamCluster::Series, + }]; + let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]; + let config = named_series_config("Trending", true); + + let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config); + + assert_eq!(categories.len(), 1); + assert_eq!(categories[0].channels.len(), 2); + let cloned_series = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::SeriesInfo) + .expect("series info clone"); + let cloned_episode = categories[0] + .channels + .iter() + .find(|item| item.header.item_type == PlaylistItemType::Series) + .expect("episode clone"); + assert_eq!(cloned_series.header.group.as_ref(), "Trending"); + assert_eq!(cloned_episode.header.group.as_ref(), "Trending"); + assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern()); + } + + #[test] + fn trakt_series_matching_ignores_episode_rows() { + let episode = episode_item("Slow Horses", &"series-parent".intern(), 7001); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Media Server Series".intern(), + channels: vec![episode], + xtream_cluster: XtreamCluster::Series, + }]; + let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]; + let config = TraktCategoryConfig { + category_name: "Trending".to_string(), + content_type: TraktContentType::Series, + tmdb_only: false, + fuzzy_match_threshold: 100, + }; + + let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config); + + assert!(categories.is_empty()); + } + + fn list_config(tmdb_only: bool) -> TraktCategoryConfig { + named_list_config("category", tmdb_only) + } + + fn named_list_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig { + TraktCategoryConfig { + category_name: category_name.to_string(), + content_type: TraktContentType::Vod, + tmdb_only, + fuzzy_match_threshold: 100, + } + } + + fn named_series_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig { + TraktCategoryConfig { + category_name: category_name.to_string(), + content_type: TraktContentType::Series, + tmdb_only, + fuzzy_match_threshold: 100, + } + } + + fn video_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + title: title.intern(), + xtream_cluster: XtreamCluster::Video, + item_type: PlaylistItemType::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + name: title.intern(), + tmdb, + ..VideoStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn series_item(title: &str, tmdb: Option) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + id: format!("series-{title}").intern(), + input_name: "input".intern(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://unavailable/server/shows/{title}").intern(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::SeriesInfo, + additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { + name: title.intern(), + tmdb, + ..SeriesStreamProperties::default() + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn episode_item(title: &str, parent_code: &Arc, virtual_id: u32) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + uuid: hash_string(&format!("episode:{title}:{virtual_id}")), + id: format!("episode-{virtual_id}").intern(), + input_name: "input".intern(), + parent_code: parent_code.clone(), + title: title.intern(), + name: title.intern(), + url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), + virtual_id, + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::Series, + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { + episode_id: virtual_id, + episode: 1, + season: 1, + added: None, + release_date: None, + series_release_date: None, + tmdb: None, + movie_image: "".intern(), + container_extension: "mkv".intern(), + video: None, + audio: None, + plot: None + }))), + ..PlaylistItemHeader::default() + }, + } + } + + fn trakt_movie(title: &'static str, year: Option, tmdb_id: Option, trakt_id: u32) -> TraktMatchItem<'static> { + TraktMatchItem { + title, + normalized_title: normalize_title_for_matching(title), + year, + tmdb_id, + trakt_id, + content_type: TraktContentType::Vod, + rank: Some(trakt_id), + } + } + + fn trakt_list_movie(title: &str, year: Option, tmdb_id: Option, trakt_id: u32) -> TraktListItem { + TraktListItem { + id: u64::from(trakt_id), + rank: Some(trakt_id), + listed_at: String::new(), + notes: None, + item_type: "movie".to_string(), + movie: Some(crate::model::TraktMovie { + ids: trakt_ids(title, tmdb_id, trakt_id), + title: title.to_string(), + year, + }), + show: None, + content_type: TraktContentType::Vod, + } + } + + fn trakt_list_show(title: &str, year: Option, tmdb_id: Option, trakt_id: u32) -> TraktListItem { + TraktListItem { + id: u64::from(trakt_id), + rank: Some(trakt_id), + listed_at: String::new(), + notes: None, + item_type: "show".to_string(), + movie: None, + show: Some(crate::model::TraktShow { + ids: trakt_ids(title, tmdb_id, trakt_id), + title: title.to_string(), + year, + }), + content_type: TraktContentType::Series, + } + } + + fn trakt_ids(title: &str, tmdb_id: Option, trakt_id: u32) -> crate::model::TraktIds { + crate::model::TraktIds { + trakt: trakt_id, + slug: title.to_string(), + tvdb: None, + imdb: None, + tmdb: tmdb_id, + tvrage: None, + } + } +} diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 65042c70c..d761de392 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -19,7 +19,7 @@ use crate::utils; use log::{info, warn}; use shared::error::{ TuliproxError}; use shared::model::xtream_const::XTREAM_CLUSTER; -use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, StreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, StreamProperties, UUIDType, VirtualId, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{is_dash_url, is_hls_url, Internable}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -265,6 +265,45 @@ fn non_blank_arc(value: &Arc) -> Option> { (!value.trim().is_empty()).then(|| Arc::clone(value)) } +fn source_series_info_episode_key(channel: &PlaylistItem) -> Option> { + match channel.header.item_type { + PlaylistItemType::SeriesInfo => Some(channel.get_uuid().intern()), + PlaylistItemType::LocalSeriesInfo => Some(channel.header.id.clone()), + _ => None, + } +} + +fn header_uuid_episode_key(header: &PlaylistItemHeader) -> Option> { + (header.uuid != UUIDType::default()).then(|| header.uuid.intern()) +} + +fn push_unique_key(keys: &mut Vec>, key: Arc) { + if !key.is_empty() && !keys.iter().any(|existing| existing.as_ref() == key.as_ref()) { + keys.push(key); + } +} + +fn series_info_episode_lookup_keys(channel: &PlaylistItem) -> Vec> { + let mut keys = Vec::with_capacity(2); + if let Some(alias_key) = header_uuid_episode_key(&channel.header) { + push_unique_key(&mut keys, alias_key); + } + if let Some(source_key) = source_series_info_episode_key(channel) { + push_unique_key(&mut keys, source_key); + } + keys +} + +fn series_info_parent_keys(channel: &PlaylistItem) -> Vec<(Arc, bool)> { + let Some(source_key) = source_series_info_episode_key(channel) else { return Vec::new() }; + let Some(alias_key) = header_uuid_episode_key(&channel.header) else { return vec![(source_key, true)] }; + if alias_key.as_ref() == source_key.as_ref() { + vec![(source_key, true)] + } else { + vec![(alias_key, true), (source_key, false)] + } +} + #[allow(clippy::implicit_hasher)] fn materialize_media_server_series_info_episodes( playlist: &mut [PlaylistGroup], @@ -276,12 +315,12 @@ fn materialize_media_server_series_info_episodes( for group in playlist.iter_mut() { for channel in &mut group.channels { - let header = &mut channel.header; - if !is_media_server_series_info_header(header) { + if !is_media_server_series_info_header(&channel.header) { continue; } - let Some(episodes) = media_server_series.get(&header.get_uuid().intern()) else { continue }; - let Some(StreamProperties::Series(series)) = header.additional_properties.as_mut() else { continue }; + let lookup_keys = series_info_episode_lookup_keys(channel); + let Some(episodes) = lookup_keys.iter().find_map(|key| media_server_series.get(key)) else { continue }; + let Some(StreamProperties::Series(series)) = channel.header.additional_properties.as_mut() else { continue }; let details = series.details.get_or_insert(SeriesStreamDetailProperties { year: None, seasons: None, @@ -296,19 +335,17 @@ fn materialize_media_server_series_info_episodes( #[allow(clippy::implicit_hasher)] fn rewrite_local_series_info_episode_virtual_id(pli: &mut PlaylistItem, local_library_series: &HashMap, Vec>) { - let header = &mut pli.header; - // local_library_series key is the Series UUID. - // For LocalSeriesInfo items (series description), header.id is the Series UUID. - // For LocalSeries items (episodes), header.parent_code is the Series UUID. - // We need to use the correct key based on item type. - let lookup_key = if header.item_type == PlaylistItemType::LocalSeries { - header.parent_code.clone() + // local_library_series keys are the Series UUID or a category alias UUID. + // For LocalSeriesInfo items, header.id is the source Series UUID; category + // aliases use header.uuid so cloned episode rows can point at the alias. + let lookup_keys = if pli.header.item_type == PlaylistItemType::LocalSeries { + vec![pli.header.parent_code.clone()] } else { - header.id.clone() + series_info_episode_lookup_keys(pli) }; - if let Some(episode_keys) = local_library_series.get(&*lookup_key) { - if let Some(StreamProperties::Series(series)) = header.additional_properties.as_mut() { + if let Some(episode_keys) = lookup_keys.iter().find_map(|key| local_library_series.get(key)) { + if let Some(StreamProperties::Series(series)) = pli.header.additional_properties.as_mut() { if let Some(episodes) = series.details.as_mut().and_then(|d| d.episodes.as_mut()) { @@ -330,17 +367,40 @@ pub fn rewrite_provider_series_info_episode_virtual_id

(pli: &mut P, provider_ where P: PlaylistEntry, { - if let Some(episode_keys) = provider_series.get(&pli.get_uuid().intern()) { - if let Some(StreamProperties::Series(series)) = pli.get_additional_properties_mut() { - if let Some(episodes) = - series.details.as_mut().and_then(|d| d.episodes.as_mut()) - { - for episode in episodes.iter_mut() { - for episode_key in episode_keys { - if episode.id == episode_key.provider_id { - episode.id = episode_key.virtual_id; - break; - } + let lookup_key = pli.get_uuid().intern(); + if let Some(episode_keys) = provider_series.get(&lookup_key) { + if let Some(properties) = pli.get_additional_properties_mut() { + apply_provider_episode_keys(properties, episode_keys); + } + } +} + +#[allow(clippy::implicit_hasher)] +fn rewrite_provider_playlist_item_series_info_episode_virtual_id( + pli: &mut PlaylistItem, + provider_series: &HashMap, Vec>, +) { + let lookup_keys = series_info_episode_lookup_keys(pli); + if let Some(episode_keys) = lookup_keys.iter().find_map(|key| provider_series.get(key)) { + if let Some(properties) = pli.get_additional_properties_mut() { + apply_provider_episode_keys(properties, episode_keys); + } + } +} + +fn apply_provider_episode_keys( + properties: &mut StreamProperties, + episode_keys: &[ProviderEpisodeKey], +) { + if let StreamProperties::Series(series) = properties { + if let Some(episodes) = + series.details.as_mut().and_then(|d| d.episodes.as_mut()) + { + for episode in episodes.iter_mut() { + for episode_key in episode_keys { + if episode.id == episode_key.provider_id { + episode.id = episode_key.virtual_id; + break; } } } @@ -358,7 +418,7 @@ fn rewrite_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], for channel in &mut group.channels { let item_type = channel.header.item_type; if item_type == PlaylistItemType::SeriesInfo { - rewrite_provider_series_info_episode_virtual_id(channel, provider_series); + rewrite_provider_playlist_item_series_info_episode_virtual_id(channel, provider_series); } else if item_type == PlaylistItemType::LocalSeriesInfo { rewrite_local_series_info_episode_virtual_id(channel, local_library_series); } else if item_type == PlaylistItemType::LocalSeries { @@ -371,16 +431,14 @@ fn rewrite_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], fn rewrite_series_episode_parent_virtual_ids(playlist: &mut [PlaylistGroup], target_id_mapping: &mut TargetIdMapping) { let mut series_parent_virtual_ids = HashMap::, u32>::new(); - for group in playlist.iter() { + for group in playlist.iter() { for channel in &group.channels { - let parent_key = match channel.header.item_type { - PlaylistItemType::SeriesInfo => Some(channel.get_uuid().intern()), - PlaylistItemType::LocalSeriesInfo => Some(channel.header.id.clone()), - _ => None, - }; - - if let Some(parent_key) = parent_key { - series_parent_virtual_ids.insert(parent_key, channel.header.virtual_id); + for (parent_key, overwrite) in series_info_parent_keys(channel) { + if overwrite { + series_parent_virtual_ids.insert(parent_key, channel.header.virtual_id); + } else { + series_parent_virtual_ids.entry(parent_key).or_insert(channel.header.virtual_id); + } } } } diff --git a/backend/src/utils/trakt/client.rs b/backend/src/utils/trakt/client.rs index 794286eb8..df0b4a3c6 100644 --- a/backend/src/utils/trakt/client.rs +++ b/backend/src/utils/trakt/client.rs @@ -1,9 +1,12 @@ -use crate::model::{TraktApiConfig, TraktListConfig, TraktListItem}; -use shared::error::TuliproxError; -use reqwest::header::{HeaderMap, HeaderValue}; +use super::errors::handle_trakt_api_error; +use crate::model::{TraktApiConfig, TraktChartConfig, TraktListConfig, TraktListItem, TraktMovie, TraktShow}; use log::{debug, info}; -use shared::utils::{trim_last_slash, DEFAULT_USER_AGENT, TRAKT_API_KEY}; -use super::errors::{handle_trakt_api_error}; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::Deserialize; +use shared::{error::TuliproxError, utils::{trim_last_slash, DEFAULT_USER_AGENT, TRAKT_API_KEY}}; + +const TRAKT_PAGE_LIMIT: u32 = 100; +const TRAKT_MAX_PAGES: u32 = 100; pub struct TraktClient { client: reqwest::Client, @@ -37,12 +40,94 @@ impl TraktClient { format!("{}/users/{user}/lists/{list_slug}/items", trim_last_slash(&self.api_config.url)) } + fn build_chart_url(&self, chart_config: &TraktChartConfig) -> String { + format!("{}/{}/{}", trim_last_slash(&self.api_config.url), chart_config.kind, chart_config.chart) + } + + pub async fn get_chart_items(&self, chart_config: &TraktChartConfig) -> Result, TuliproxError> { + debug!("Fetching Trakt chart {}:{}", chart_config.kind, chart_config.chart); + + let mut page = 1; + let mut items = Vec::new(); + loop { + let mut page_items = self.get_chart_items_page(chart_config, page).await?; + let page_count = page_items.page_count; + let item_count = page_items.item_count; + debug!( + "Fetched Trakt chart {}:{} page {page}/{page_count} with {} items", + chart_config.kind, + chart_config.chart, + page_items.items.len() + ); + let is_last_page = page >= page_count || page >= TRAKT_MAX_PAGES || page_items.items.is_empty(); + items.append(&mut page_items.items); + if is_last_page { + if page >= TRAKT_MAX_PAGES && page < page_count { + debug!( + "Stopped Trakt chart {}:{} after {TRAKT_MAX_PAGES} pages; reported page count was {page_count}", + chart_config.kind, chart_config.chart + ); + } + info!( + "Successfully fetched {} items from Trakt chart {}:{}{}", + items.len(), + chart_config.kind, + chart_config.chart, + item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default() + ); + return Ok(items); + } + page += 1; + } + } + pub async fn get_list_items(&self, list_config: &TraktListConfig) -> Result, TuliproxError> { debug!("Fetching Trakt list {}:{}", list_config.user, list_config.list_slug); + let mut page = 1; + let mut items = Vec::new(); + loop { + let mut page_items = self.get_list_items_page(list_config, page).await?; + let page_count = page_items.page_count; + let item_count = page_items.item_count; + debug!( + "Fetched Trakt list {}:{} page {page}/{page_count} with {} items", + list_config.user, + list_config.list_slug, + page_items.items.len() + ); + let is_last_page = page >= page_count || page >= TRAKT_MAX_PAGES || page_items.items.is_empty(); + items.append(&mut page_items.items); + if is_last_page { + if page >= TRAKT_MAX_PAGES && page < page_count { + debug!( + "Stopped Trakt list {}:{} after {TRAKT_MAX_PAGES} pages; reported page count was {page_count}", + list_config.user, list_config.list_slug + ); + } + info!( + "Successfully fetched {} items from Trakt list {}:{}{}", + items.len(), + list_config.user, + list_config.list_slug, + item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default() + ); + return Ok(items); + } + page += 1; + } + } + + async fn get_list_items_page( + &self, + list_config: &TraktListConfig, + page: u32, + ) -> Result { let url = self.build_list_url(&list_config.user, &list_config.list_slug); - let response = self.client - .get(&url) + let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); + let response = self + .client + .get(&request_url) .headers(self.headers.clone()) .send() .await @@ -52,6 +137,8 @@ impl TraktClient { handle_trakt_api_error(response.status(), &list_config.user, &list_config.list_slug)?; } + let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page); + let item_count = parse_trakt_pagination_header(response.headers(), "x-pagination-item-count"); let response_text = response .text() .await @@ -60,9 +147,270 @@ impl TraktClient { let mut items: Vec = serde_json::from_str(&response_text) .map_err(|error: serde_json::Error| TuliproxError::Config(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) + Ok(TraktListItemsPage { items, page_count, item_count }) } -} + async fn get_chart_items_page( + &self, + chart_config: &TraktChartConfig, + page: u32, + ) -> Result { + let url = self.build_chart_url(chart_config); + let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); + let response = self + .client + .get(&request_url) + .headers(self.headers.clone()) + .send() + .await + .map_err(|err| TuliproxError::Config(format!("Failed to fetch Trakt chart {url}: {err}")))?; + + if !response.status().is_success() { + handle_trakt_api_error(response.status(), "charts", &format!("{}:{}", chart_config.kind, chart_config.chart))?; + } + + let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page); + let item_count = parse_trakt_pagination_header(response.headers(), "x-pagination-item-count"); + let response_text = response + .text() + .await + .map_err(|error: reqwest::Error| TuliproxError::Config(format!("Failed to read Trakt response: {error}")))?; + + let items = parse_chart_items(&response_text, chart_config, page) + .map_err(|error| TuliproxError::Config(format!("Failed to parse Trakt chart response: {error}")))?; + + Ok(TraktListItemsPage { items, page_count, item_count }) + } +} + +struct TraktListItemsPage { + items: Vec, + page_count: u32, + item_count: Option, +} + +fn parse_chart_items( + response_text: &str, + chart_config: &TraktChartConfig, + page: u32, +) -> Result, serde_json::Error> { + let rank_base = page.saturating_sub(1).saturating_mul(TRAKT_PAGE_LIMIT); + match (chart_config.kind, chart_config.chart) { + (shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Popular) => { + let items = serde_json::from_str::>(response_text)?; + Ok(items + .into_iter() + .enumerate() + .map(|(index, movie)| TraktListItem::from_movie_chart(movie, chart_rank(rank_base, index))) + .collect()) + } + (shared::model::TraktChartKind::Movies, shared::model::TraktChartType::Trending) => { + let items = serde_json::from_str::>(response_text)?; + Ok(items + .into_iter() + .enumerate() + .map(|(index, item)| TraktListItem::from_movie_chart(item.movie, chart_rank(rank_base, index))) + .collect()) + } + (shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Popular) => { + let items = serde_json::from_str::>(response_text)?; + Ok(items + .into_iter() + .enumerate() + .map(|(index, show)| TraktListItem::from_show_chart(show, chart_rank(rank_base, index))) + .collect()) + } + (shared::model::TraktChartKind::Shows, shared::model::TraktChartType::Trending) => { + let items = serde_json::from_str::>(response_text)?; + Ok(items + .into_iter() + .enumerate() + .map(|(index, item)| TraktListItem::from_show_chart(item.show, chart_rank(rank_base, index))) + .collect()) + } + } +} + +fn chart_rank(rank_base: u32, index: usize) -> u32 { + rank_base.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)).saturating_add(1) +} + +#[derive(Deserialize)] +struct TraktTrendingMovieItem { + movie: TraktMovie, +} + +#[derive(Deserialize)] +struct TraktTrendingShowItem { + show: TraktShow, +} + +fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Option { + headers.get(name).and_then(|value| value.to_str().ok()).and_then(|value| value.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{TraktChartKind, TraktChartType, TraktContentType}; + use std::sync::{atomic::{AtomicUsize, Ordering}, Arc, Mutex}; + use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener}; + + #[tokio::test] + async fn get_list_items_follows_trakt_pagination_headers() { + let requests = Arc::new(AtomicUsize::new(0)); + let base_url = spawn_paged_trakt_server(Arc::clone(&requests)).await; + let client = TraktClient::new( + reqwest::Client::new(), + TraktApiConfig { + api_key: "test-key".to_string(), + version: "2".to_string(), + url: base_url, + user_agent: "tuliprox-test".to_string(), + }, + ); + let list_config = TraktListConfig { + user: "user".to_string(), + list_slug: "list".to_string(), + category_name: "category".to_string(), + content_type: TraktContentType::Vod, + tmdb_only: false, + fuzzy_match_threshold: 90, + }; + + let items = client.get_list_items(&list_config).await.expect("paged list should load"); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].content_type, TraktContentType::Vod); + assert_eq!(items[1].content_type, TraktContentType::Vod); + assert_eq!(requests.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn get_chart_items_fetches_public_trending_movies() { + let requests = Arc::new(Mutex::new(Vec::new())); + let base_url = spawn_single_response_trakt_server( + r#"[{"watchers":42,"movie":{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}}]"#, + Arc::clone(&requests), + ) + .await; + let client = client(base_url); + let chart_config = chart_config(TraktChartKind::Movies, TraktChartType::Trending); + + let items = client.get_chart_items(&chart_config).await.expect("trending movie chart should load"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].rank, Some(1)); + assert_eq!(items[0].content_type, TraktContentType::Vod); + assert_eq!(items[0].movie.as_ref().expect("movie").ids.tmdb, Some(11)); + assert!(requests.lock().expect("requests")[0].contains("GET /movies/trending?page=1&limit=100 ")); + } + + #[tokio::test] + async fn get_chart_items_fetches_public_popular_shows() { + let requests = Arc::new(Mutex::new(Vec::new())); + let base_url = spawn_single_response_trakt_server( + r#"[{"title":"Show 1","year":2026,"ids":{"trakt":2,"slug":"show-1","tvdb":null,"imdb":null,"tmdb":22,"tvrage":null}}]"#, + Arc::clone(&requests), + ) + .await; + let client = client(base_url); + let chart_config = chart_config(TraktChartKind::Shows, TraktChartType::Popular); + + let items = client.get_chart_items(&chart_config).await.expect("popular show chart should load"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].rank, Some(1)); + assert_eq!(items[0].content_type, TraktContentType::Series); + assert_eq!(items[0].show.as_ref().expect("show").ids.tmdb, Some(22)); + assert!(requests.lock().expect("requests")[0].contains("GET /shows/popular?page=1&limit=100 ")); + } + + async fn spawn_paged_trakt_server(requests: Arc) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { break }; + let mut request_bytes = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let read = stream.read(&mut buffer).await.expect("read request"); + if read == 0 { + break; + } + request_bytes.extend_from_slice(&buffer[..read]); + if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&request_bytes); + let page = if request.contains("page=2") { 2 } else { 1 }; + requests.fetch_add(1, Ordering::SeqCst); + let body = format!("[{}]", trakt_movie_json(page)); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nx-pagination-page-count: 2\r\nx-pagination-item-count: 2\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), body + ); + stream.write_all(response.as_bytes()).await.expect("write response"); + } + }); + format!("http://{addr}") + } + + async fn spawn_single_response_trakt_server(body: &'static str, requests: Arc>>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let mut request_bytes = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let read = stream.read(&mut buffer).await.expect("read request"); + if read == 0 { + break; + } + request_bytes.extend_from_slice(&buffer[..read]); + if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + requests.lock().expect("requests").push(String::from_utf8_lossy(&request_bytes).to_string()); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), body + ); + stream.write_all(response.as_bytes()).await.expect("write response"); + }); + format!("http://{addr}") + } + + fn client(base_url: String) -> TraktClient { + TraktClient::new( + reqwest::Client::new(), + TraktApiConfig { + api_key: "test-key".to_string(), + version: "2".to_string(), + url: base_url, + user_agent: "tuliprox-test".to_string(), + }, + ) + } + + fn chart_config(kind: TraktChartKind, chart: TraktChartType) -> TraktChartConfig { + TraktChartConfig { + kind, + chart, + category_name: "category".to_string(), + tmdb_only: false, + fuzzy_match_threshold: 90, + } + } + + fn trakt_movie_json(page: u32) -> String { + format!( + r#"{{"id":{page},"rank":{page},"listed_at":"2026-01-01T00:00:00.000Z","type":"movie","movie":{{"title":"Movie {page}","year":2026,"ids":{{"trakt":{page},"slug":"movie-{page}","tvdb":null,"imdb":null,"tmdb":{page},"tvrage":null}}}}}}"#, + ) + } +} diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index cd3533269..eb4d7b474 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1058,24 +1058,43 @@ sources: category_name: "📺 Latest TV Shows" content_type: series fuzzy_match_threshold: 80 + charts: + - kind: movies + chart: trending + category_name: "🔥 Trending Movies" + tmdb_only: true + - kind: shows + chart: popular + category_name: "⭐ Popular Shows" + tmdb_only: true ``` -This configuration creates two additional virtual categories populated with matched entries from the configured Trakt -lists. +This configuration creates additional virtual categories populated with matched entries from the configured Trakt user +lists and public Trakt charts. ##### Trakt Parameters -| Parameter | Type | Required | Default | Technical Impact & Background | -|:--------------------------------|:--------|:--------:|:-----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------| -| `api.api_key` | String | Yes | | Trakt API key used for authenticated access. Without a valid key, Tuliprox cannot fetch remote list content. | -| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. | -| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. | -| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. | -| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. | -| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. | -| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. | -| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. | -| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. | +| Parameter | Type | Required | Default | Technical Impact & Background | +| :------------------------------- | :------ | :------: | :--------------------- | :----------------------------------------------------------------------------------------------------------------------------------------- | +| `api.api_key` | String | Yes | | Trakt API key used for authenticated access. Without a valid key, Tuliprox cannot fetch remote list content. | +| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. | +| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. | +| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. | +| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. | +| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. | +| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. | +| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. | +| `lists[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted for this list, disabling title/year fuzzy fallback and reducing false positives. | +| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. | +| `charts[]` | List | No | `[]` | Public Trakt chart definitions. Unlike `lists[]`, these are system charts and do not have a user/list owner. | +| `charts[].kind` | Enum | Yes | | `movies` or `shows`. Aliases such as `movie`, `vod`, `show`, `series`, and `tvshows` are accepted. | +| `charts[].chart` | Enum | Yes | | Public chart to fetch. MVP supports `trending` and `popular`. | +| `charts[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. | +| `charts[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted. This is recommended for dynamic charts to avoid fuzzy false positives. | +| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for chart title matching when `tmdb_only` is not enabled. | + +The `charts[]` MVP intentionally supports only public, non-OAuth Trakt charts. User-specific recommendations and +account-scoped history feeds are not fetched by this block. ### 2. Type `m3u` diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 736105a6b..7f33fc528 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -822,6 +822,7 @@ "ADD_QUALITY_TO_FILENAME": "Quality", "ADD_SERVER": "Add Server", "ADD_STRIP_ENTRY": "Add Strip Entry", + "ADD_TRAKT_CHART": "Add a Trakt chart", "ADD_TRAKT_LIST": "Add a Trakt list", "ADD_URL": "Add Url", "ADD_WATCH": "Add Watch", @@ -1348,9 +1349,21 @@ "TOKEN_TTL_MINS": "Token ttl (mins)", "TRAKT": "Trakt", "TRAKT_CATEGORY_NAME": "Category name", + "TRAKT_CHARTS": "Charts", + "TRAKT_CHART_KIND": "Chart kind", + "TRAKT_CHART_KIND_MOVIES": "Movies", + "TRAKT_CHART_KIND_SHOWS": "Shows", + "TRAKT_CHART_TYPE": "Chart type", + "TRAKT_CHART_TYPE_POPULAR": "Popular", + "TRAKT_CHART_TYPE_TRENDING": "Trending", "TRAKT_FUZZY_MATCH_THRESHOLD": "Fuzzy matchthreshold", + "TRAKT_TMDB_ONLY": "TMDB only", "TRAKT_LISTS": "Lists", "TRAKT_LIST_SLUG": "List Slug", + "TRAKT_CONTENT_TYPE": "Trakt content type", + "TRAKT_CONTENT_TYPE_BOTH": "Both", + "TRAKT_CONTENT_TYPE_SERIES": "Series", + "TRAKT_CONTENT_TYPE_VOD": "Vod", "TRAKT_USER": "Trakt User", "TRANSFERRED": "Transferred", "TUNER_COUNT": "Tuner Count", diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 86a0870de..5fbc2ce28 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -65,6 +65,16 @@ $form-width: 520px; &-toolbar { display: flex; flex-flow: row nowrap; + gap: 0; + } + + &-edit { + &:focus, + &:hover { + color: var(--icon-button-hover-color); + fill: var(--icon-button-hover-color); + background-color: var(--icon-button-hover-background-color); + } } &-content{ diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index da1ef6e53..1f9390087 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -16,6 +16,7 @@ mod provider_item_form; mod rules; mod sidebar; mod target_form; +mod trakt_chart_item_form; mod trakt_list_item_form; pub(crate) use alias_item_form::*; @@ -35,4 +36,5 @@ pub(crate) use provider_item_form::*; pub(crate) use rules::*; pub(crate) use sidebar::*; pub(crate) use target_form::*; +pub(crate) use trakt_chart_item_form::*; pub(crate) use trakt_list_item_form::*; diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index 82be6e97a..0e5642e64 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -1,7 +1,7 @@ use crate::{ app::components::{ config::HasFormData, BlockId, BlockInstance, Card, EditMode, FilterInput, IconButton, Panel, - SourceEditorContext, TextButton, TitledCard, TraktListItemForm, + SourceEditorContext, TextButton, TitledCard, TraktChartItemForm, TraktListItemForm, }, config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_text, generate_form_reducer, @@ -11,7 +11,8 @@ use shared::{ concat_string, error::TuliproxError, model::{ - TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto, + TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktConfigDto, TraktListConfigDto, + XtreamTargetOutputDto, }, utils::Internable, }; @@ -31,6 +32,8 @@ const LABEL_TRAKT_API_VERSION: &str = "LABEL.API_VERSION"; const LABEL_TRAKT_API_URL: &str = "LABEL.API_URL"; const LABEL_TRAKT_LISTS: &str = "LABEL.TRAKT_LISTS"; const LABEL_ADD_TRAKT_LIST: &str = "LABEL.ADD_TRAKT_LIST"; +const LABEL_TRAKT_CHARTS: &str = "LABEL.TRAKT_CHARTS"; +const LABEL_ADD_TRAKT_CHART: &str = "LABEL.ADD_TRAKT_CHART"; const LABEL_API_CONFIGURATION: &str = "LABEL.API_CONFIGURATION"; const LABEL_USER_AGENT: &str = "LABEL.API_USER_AGENT"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; @@ -121,6 +124,55 @@ pub struct XtreamTargetOutputViewProps { pub(crate) allow_write: bool, } +fn build_trakt_output_config( + enabled: bool, + api: TraktApiConfigDto, + lists: Vec, + charts: Vec, +) -> Option { + if lists.is_empty() && charts.is_empty() { + None + } else { + Some(TraktConfigDto { enabled, api, lists, charts }) + } +} + +fn trakt_list_summary(item: &TraktListConfigDto) -> String { + let mut summary = format!( + "{} / {} - {} ({}, {}%)", + item.user, item.list_slug, item.category_name, item.content_type, item.fuzzy_match_threshold + ); + if item.tmdb_only { + summary.push_str(", TMDB only"); + } + summary +} + +fn trakt_chart_summary(item: &TraktChartConfigDto) -> String { + let mut summary = + format!("{}/{} - {} ({}%)", item.kind, item.chart, item.category_name, item.fuzzy_match_threshold); + if item.tmdb_only { + summary.push_str(", TMDB only"); + } + summary +} + +fn upsert_vec_item(items: &mut Vec, index: Option, item: T) { + if let Some(index) = index.filter(|idx| *idx < items.len()) { + items[index] = item; + } else { + items.push(item); + } +} + +fn adjust_edit_index_after_remove(current: Option, removed: usize) -> Option { + match current { + Some(index) if index == removed => None, + Some(index) if index > removed => Some(index - 1), + _ => current, + } +} + #[component] pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let translate = use_translation(); @@ -135,11 +187,12 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let trakt_api_state: UseReducerHandle = use_reducer(|| TraktApiConfigFormState { form: TraktApiConfigDto::default(), modified: false }); - // State for Trakt lists let trakt_lists_state = use_state(Vec::::new); - - // State for showing trakt list form + let trakt_charts_state = use_state(Vec::::new); let show_trakt_list_form_state = use_state(|| false); + let show_trakt_chart_form_state = use_state(|| false); + let editing_trakt_list_index_state = use_state(|| None::); + let editing_trakt_chart_index_state = use_state(|| None::); let view_visible = use_state(|| XtreamOutputFormPage::Main); @@ -157,6 +210,11 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let trakt_state = trakt_state.clone(); let trakt_api_state = trakt_api_state.clone(); let trakt_lists_state = trakt_lists_state.clone(); + let trakt_charts_state = trakt_charts_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + let show_trakt_list_form_state = show_trakt_list_form_state.clone(); + let show_trakt_chart_form_state = show_trakt_chart_form_state.clone(); let config_output = props.output.clone(); @@ -169,17 +227,24 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { trakt_state.dispatch(TraktConfigFormAction::SetAll(trakt.clone())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(trakt.api.clone())); trakt_lists_state.set(trakt.lists.clone()); + trakt_charts_state.set(trakt.charts.clone()); } else { trakt_state.dispatch(TraktConfigFormAction::SetAll(TraktConfigDto::default())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); trakt_lists_state.set(Vec::new()); + trakt_charts_state.set(Vec::new()); } } else { output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(XtreamTargetOutputDto::default())); trakt_state.dispatch(TraktConfigFormAction::SetAll(TraktConfigDto::default())); trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); trakt_lists_state.set(Vec::new()); + trakt_charts_state.set(Vec::new()); } + editing_trakt_list_index_state.set(None); + editing_trakt_chart_index_state.set(None); + show_trakt_list_form_state.set(false); + show_trakt_chart_form_state.set(false); || () }); } @@ -187,42 +252,121 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let handle_add_trakt_list_item = { let trakt_list = trakt_lists_state.clone(); let show_trakt_list_form = show_trakt_list_form_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); Callback::from(move |item: TraktListConfigDto| { let mut items = (*trakt_list).clone(); - items.push(item); + upsert_vec_item(&mut items, *editing_trakt_list_index_state, item); trakt_list.set(items); + editing_trakt_list_index_state.set(None); show_trakt_list_form.set(false); }) }; let handle_remove_trakt_list_item = { let trakt_list = trakt_lists_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); Callback::from(move |(idx, _e): (String, MouseEvent)| { if let Ok(index) = idx.parse::() { let mut items = (*trakt_list).clone(); if index < items.len() { items.remove(index); trakt_list.set(items); + editing_trakt_list_index_state + .set(adjust_edit_index_after_remove(*editing_trakt_list_index_state, index)); } } }) }; + let handle_edit_trakt_list_item = { + let show_trakt_list_form = show_trakt_list_form_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); + Callback::from(move |(idx, _e): (String, MouseEvent)| { + if let Ok(index) = idx.parse::() { + editing_trakt_list_index_state.set(Some(index)); + show_trakt_list_form.set(true); + } + }) + }; + + let handle_add_trakt_chart_item = { + let trakt_charts = trakt_charts_state.clone(); + let show_trakt_chart_form = show_trakt_chart_form_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + + Callback::from(move |item: TraktChartConfigDto| { + let mut items = (*trakt_charts).clone(); + upsert_vec_item(&mut items, *editing_trakt_chart_index_state, item); + trakt_charts.set(items); + editing_trakt_chart_index_state.set(None); + show_trakt_chart_form.set(false); + }) + }; + + let handle_remove_trakt_chart_item = { + let trakt_charts = trakt_charts_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + Callback::from(move |(idx, _e): (String, MouseEvent)| { + if let Ok(index) = idx.parse::() { + let mut items = (*trakt_charts).clone(); + if index < items.len() { + items.remove(index); + trakt_charts.set(items); + editing_trakt_chart_index_state + .set(adjust_edit_index_after_remove(*editing_trakt_chart_index_state, index)); + } + } + }) + }; + + let handle_edit_trakt_chart_item = { + let show_trakt_chart_form = show_trakt_chart_form_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + Callback::from(move |(idx, _e): (String, MouseEvent)| { + if let Ok(index) = idx.parse::() { + editing_trakt_chart_index_state.set(Some(index)); + show_trakt_chart_form.set(true); + } + }) + }; + let handle_close_trakt_list_form = { let show_trakt_list_form = show_trakt_list_form_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); Callback::from(move |()| { + editing_trakt_list_index_state.set(None); show_trakt_list_form.set(false); }) }; + let handle_close_trakt_chart_form = { + let show_trakt_chart_form = show_trakt_chart_form_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + Callback::from(move |()| { + editing_trakt_chart_index_state.set(None); + show_trakt_chart_form.set(false); + }) + }; + let handle_show_trakt_list_form = { let show_trakt_list_form = show_trakt_list_form_state.clone(); + let editing_trakt_list_index_state = editing_trakt_list_index_state.clone(); Callback::from(move |_name| { + editing_trakt_list_index_state.set(None); show_trakt_list_form.set(true); }) }; + let handle_show_trakt_chart_form = { + let show_trakt_chart_form = show_trakt_chart_form_state.clone(); + let editing_trakt_chart_index_state = editing_trakt_chart_index_state.clone(); + Callback::from(move |_name| { + editing_trakt_chart_index_state.set(None); + show_trakt_chart_form.set(true); + }) + }; + let render_output = || { let output_form_state_1 = output_form_state.clone(); if !props.allow_write { @@ -265,9 +409,15 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let render_trakt = || { let trakt_lists = trakt_lists_state.clone(); + let trakt_charts = trakt_charts_state.clone(); let trakt_form = trakt_state.clone(); let trakt_api_form = trakt_api_state.clone(); let show_trakt_list_form = show_trakt_list_form_state.clone(); + let show_trakt_chart_form = show_trakt_chart_form_state.clone(); + let editing_trakt_list_index = *editing_trakt_list_index_state; + let editing_trakt_chart_index = *editing_trakt_chart_index_state; + let initial_trakt_list = editing_trakt_list_index.and_then(|index| (*trakt_lists).get(index).cloned()); + let initial_trakt_chart = editing_trakt_chart_index.and_then(|index| (*trakt_charts).get(index).cloned()); html! { @@ -275,6 +425,14 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { + } else if *show_trakt_chart_form { + } else { @@ -311,28 +469,20 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { for item in (*trakt_lists_list).iter().enumerate() {

if props.allow_write { - +
+ + +
}
- - {&item.1.user} - {" / "} - {&item.1.list_slug} - {" - "} - {&item.1.category_name} - {" ("} - {match item.1.content_type { - TraktContentType::Vod => "Vod", - TraktContentType::Series => "Series", - TraktContentType::Both => "Both", - }} - {", "} - {item.1.fuzzy_match_threshold} - {"%)"} - + {trakt_list_summary(item.1)}
} @@ -350,6 +500,45 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { } })} + { config_field_child!(translate.t(LABEL_TRAKT_CHARTS), "OUTPUT_XTREAM_FORM.TRAKT_CHARTS", { + let trakt_charts_list = trakt_charts.clone(); + html! { +
+
+ for item in (*trakt_charts_list).iter().enumerate() { +
+ if props.allow_write { +
+ + +
+ } +
+ {trakt_chart_summary(item.1)} +
+
+ } +
+ + if props.allow_write { + + } +
+ } + })} } } @@ -370,7 +559,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { } }; - let button_disabled = *show_trakt_list_form_state; + let button_disabled = *show_trakt_list_form_state || *show_trakt_chart_form_state; let render_sidebar = || { let main_class = format!( @@ -397,21 +586,19 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let trakt_state = trakt_state.clone(); let trakt_api_state = trakt_api_state.clone(); let trakt_lists_state = trakt_lists_state.clone(); + let trakt_charts_state = trakt_charts_state.clone(); let block_id = props.block_id; Callback::from(move |_| { let mut output = output_form_state.data().clone(); - // Handle Trakt configuration let trakt_lists = (*trakt_lists_state).clone(); - output.trakt = if trakt_lists.is_empty() { - None - } else { - Some(TraktConfigDto { - enabled: trakt_state.data().enabled, - api: trakt_api_state.data().clone(), - lists: trakt_lists, - }) - }; + let trakt_charts = (*trakt_charts_state).clone(); + output.trakt = build_trakt_output_config( + trakt_state.data().enabled, + trakt_api_state.data().clone(), + trakt_lists, + trakt_charts, + ); source_editor_ctx .on_form_change @@ -447,3 +634,57 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { } } + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{TraktChartKind, TraktChartType}; + + #[test] + fn build_trakt_output_config_returns_none_when_empty() { + let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), Vec::new()); + assert!(result.is_none()); + } + + #[test] + fn build_trakt_output_config_keeps_charts_without_lists() { + let charts = vec![TraktChartConfigDto { + kind: TraktChartKind::Movies, + chart: TraktChartType::Trending, + category_name: "Trending Movies".to_string(), + tmdb_only: true, + fuzzy_match_threshold: 90, + }]; + + let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), charts.clone()) + .expect("charts-only trakt config"); + + assert!(result.lists.is_empty()); + assert_eq!(result.charts, charts); + } + + #[test] + fn upsert_vec_item_replaces_existing_entry() { + let mut items = vec!["a".to_string(), "b".to_string()]; + upsert_vec_item(&mut items, Some(1), "c".to_string()); + assert_eq!(items, vec!["a", "c"]); + } + + #[test] + fn upsert_vec_item_appends_when_index_missing() { + let mut items = vec!["a".to_string()]; + upsert_vec_item(&mut items, Some(5), "b".to_string()); + assert_eq!(items, vec!["a", "b"]); + } + + #[test] + fn adjust_edit_index_after_remove_clears_exact_match() { + assert_eq!(adjust_edit_index_after_remove(Some(2), 2), None); + } + + #[test] + fn adjust_edit_index_after_remove_shifts_later_item_left() { + assert_eq!(adjust_edit_index_after_remove(Some(2), 0), Some(1)); + assert_eq!(adjust_edit_index_after_remove(Some(3), 1), Some(2)); + } +} diff --git a/frontend/src/app/components/source_editor/trakt_chart_item_form.rs b/frontend/src/app/components/source_editor/trakt_chart_item_form.rs new file mode 100644 index 000000000..3a9e5559f --- /dev/null +++ b/frontend/src/app/components/source_editor/trakt_chart_item_form.rs @@ -0,0 +1,181 @@ +use crate::{ + app::components::{select::Select, Card, DropDownOption, DropDownSelection, TextButton}, + config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8, edit_field_text, + generate_form_reducer, + i18n::use_translation, +}; +use shared::model::{TraktChartConfigDto, TraktChartKind, TraktChartType}; +use yew::{component, html, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle}; + +const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME"; +const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY"; +const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD"; +const LABEL_TRAKT_CHART_KIND: &str = "LABEL.TRAKT_CHART_KIND"; +const LABEL_TRAKT_CHART_TYPE: &str = "LABEL.TRAKT_CHART_TYPE"; +const LABEL_TRAKT_CHART_KIND_MOVIES: &str = "LABEL.TRAKT_CHART_KIND_MOVIES"; +const LABEL_TRAKT_CHART_KIND_SHOWS: &str = "LABEL.TRAKT_CHART_KIND_SHOWS"; +const LABEL_TRAKT_CHART_TYPE_TRENDING: &str = "LABEL.TRAKT_CHART_TYPE_TRENDING"; +const LABEL_TRAKT_CHART_TYPE_POPULAR: &str = "LABEL.TRAKT_CHART_TYPE_POPULAR"; + +fn trakt_chart_kind_label_key(kind: TraktChartKind) -> &'static str { + match kind { + TraktChartKind::Movies => LABEL_TRAKT_CHART_KIND_MOVIES, + TraktChartKind::Shows => LABEL_TRAKT_CHART_KIND_SHOWS, + } +} + +fn trakt_chart_type_label_key(chart: TraktChartType) -> &'static str { + match chart { + TraktChartType::Trending => LABEL_TRAKT_CHART_TYPE_TRENDING, + TraktChartType::Popular => LABEL_TRAKT_CHART_TYPE_POPULAR, + } +} + +generate_form_reducer!( + state: TraktChartFormState { form: TraktChartConfigDto }, + action_name: TraktChartFormAction, + fields { + Kind => kind: TraktChartKind, + Chart => chart: TraktChartType, + CategoryName => category_name: String, + TmdbOnly => tmdb_only: bool, + FuzzyMatchThreshold => fuzzy_match_threshold: u8, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct TraktChartItemFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, + #[prop_or(false)] + pub readonly: bool, +} + +#[component] +pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html { + let translate = use_translation(); + + let form_state: UseReducerHandle = + use_reducer(|| TraktChartFormState { form: props.initial.clone().unwrap_or_default(), modified: false }); + + let kind_options = use_memo(form_state.form.kind, { + let translate = translate.clone(); + move |kind| { + vec![ + DropDownOption { + id: TraktChartKind::Movies.to_string(), + label: html! { translate.t(LABEL_TRAKT_CHART_KIND_MOVIES) }, + selected: kind == &TraktChartKind::Movies, + }, + DropDownOption { + id: TraktChartKind::Shows.to_string(), + label: html! { translate.t(LABEL_TRAKT_CHART_KIND_SHOWS) }, + selected: kind == &TraktChartKind::Shows, + }, + ] + } + }); + let chart_options = use_memo(form_state.form.chart, { + let translate = translate.clone(); + move |chart| { + vec![ + DropDownOption { + id: TraktChartType::Trending.to_string(), + label: html! { translate.t(LABEL_TRAKT_CHART_TYPE_TRENDING) }, + selected: chart == &TraktChartType::Trending, + }, + DropDownOption { + id: TraktChartType::Popular.to_string(), + label: html! { translate.t(LABEL_TRAKT_CHART_TYPE_POPULAR) }, + selected: chart == &TraktChartType::Popular, + }, + ] + } + }); + + let handle_submit = { + let form_state = form_state.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + let data = form_state.form.clone(); + if !data.category_name.trim().is_empty() { + on_submit.emit(data); + } + }) + }; + let handle_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| on_cancel.emit(())) + }; + + html! { + + if props.readonly { + { config_field_custom!(translate.t(LABEL_TRAKT_CHART_KIND), translate.t(trakt_chart_kind_label_key(form_state.form.kind))) } + { config_field_custom!(translate.t(LABEL_TRAKT_CHART_TYPE), translate.t(trakt_chart_type_label_key(form_state.form.chart))) } + { config_field_custom!(translate.t(LABEL_TRAKT_CATEGORY_NAME), form_state.form.category_name.clone()) } + { config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only) } + { config_field_custom!(translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), form_state.form.fuzzy_match_threshold.to_string()) } + } else { + { config_field_child!(translate.t(LABEL_TRAKT_CHART_KIND), "TRAKT_CHART_FORM.TRAKT_CHART_KIND", { + let form_state_kind = form_state.clone(); + html! { + () { + form_state_chart.dispatch(TraktChartFormAction::Chart(chart)); + } + } + })} + options={chart_options.clone()} + /> + } + })} + { edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktChartFormAction::CategoryName) } + { edit_field_bool!(form_state, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only, TraktChartFormAction::TmdbOnly) } + { edit_field_number_u8!(form_state, translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), fuzzy_match_threshold, TraktChartFormAction::FuzzyMatchThreshold) } + } + +
+ + if !props.readonly { + + } +
+
+ } +} diff --git a/frontend/src/app/components/source_editor/trakt_list_item_form.rs b/frontend/src/app/components/source_editor/trakt_list_item_form.rs index 1414ecfa6..ec4cf18ed 100644 --- a/frontend/src/app/components/source_editor/trakt_list_item_form.rs +++ b/frontend/src/app/components/source_editor/trakt_list_item_form.rs @@ -1,7 +1,7 @@ use crate::{ app::components::{select::Select, Card, DropDownOption, DropDownSelection, TextButton}, - config_field, config_field_child, config_field_custom, edit_field_number_u8, edit_field_text, - generate_form_reducer, + config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8, + edit_field_text, generate_form_reducer, i18n::use_translation, }; use shared::model::{TraktContentType, TraktListConfigDto}; @@ -11,7 +11,19 @@ const LABEL_TRAKT_USER: &str = "LABEL.TRAKT_USER"; const LABEL_TRAKT_LIST_SLUG: &str = "LABEL.TRAKT_LIST_SLUG"; const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME"; const LABEL_TRAKT_CONTENT_TYPE: &str = "LABEL.TRAKT_CONTENT_TYPE"; +const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY"; const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD"; +const LABEL_TRAKT_CONTENT_TYPE_VOD: &str = "LABEL.TRAKT_CONTENT_TYPE_VOD"; +const LABEL_TRAKT_CONTENT_TYPE_SERIES: &str = "LABEL.TRAKT_CONTENT_TYPE_SERIES"; +const LABEL_TRAKT_CONTENT_TYPE_BOTH: &str = "LABEL.TRAKT_CONTENT_TYPE_BOTH"; + +fn trakt_content_type_label_key(content_type: TraktContentType) -> &'static str { + match content_type { + TraktContentType::Vod => LABEL_TRAKT_CONTENT_TYPE_VOD, + TraktContentType::Series => LABEL_TRAKT_CONTENT_TYPE_SERIES, + TraktContentType::Both => LABEL_TRAKT_CONTENT_TYPE_BOTH, + } +} generate_form_reducer!( state: TraktListFormState { form: TraktListConfigDto }, @@ -21,6 +33,7 @@ generate_form_reducer!( ListSlug => list_slug: String, CategoryName => category_name: String, ContentType => content_type: TraktContentType, + TmdbOnly => tmdb_only: bool, FuzzyMatchThreshold => fuzzy_match_threshold: u8, } ); @@ -45,30 +58,34 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html { list_slug: String::new(), category_name: String::new(), content_type: TraktContentType::Both, + tmdb_only: false, fuzzy_match_threshold: 80, }), modified: false, }); - let content_type_options = use_memo(form_state.form.content_type, |content_type| { - let default_ct = content_type; - vec![ - DropDownOption { - id: "vod".to_string(), - label: html! { "Vod" }, - selected: default_ct == &TraktContentType::Vod, - }, - DropDownOption { - id: "series".to_string(), - label: html! { "Series" }, - selected: default_ct == &TraktContentType::Series, - }, - DropDownOption { - id: "both".to_string(), - label: html! { "Both" }, - selected: default_ct == &TraktContentType::Both, - }, - ] + let content_type_options = use_memo(form_state.form.content_type, { + let translate = translate.clone(); + move |content_type| { + let default_ct = content_type; + vec![ + DropDownOption { + id: TraktContentType::Vod.to_string(), + label: html! { translate.t(LABEL_TRAKT_CONTENT_TYPE_VOD) }, + selected: default_ct == &TraktContentType::Vod, + }, + DropDownOption { + id: TraktContentType::Series.to_string(), + label: html! { translate.t(LABEL_TRAKT_CONTENT_TYPE_SERIES) }, + selected: default_ct == &TraktContentType::Series, + }, + DropDownOption { + id: TraktContentType::Both.to_string(), + label: html! { translate.t(LABEL_TRAKT_CONTENT_TYPE_BOTH) }, + selected: default_ct == &TraktContentType::Both, + }, + ] + } }); let handle_submit = { @@ -107,7 +124,7 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html { } if props.readonly { - { config_field_custom!(translate.t(LABEL_TRAKT_CONTENT_TYPE), form_state.form.content_type.to_string()) } + { config_field_custom!(translate.t(LABEL_TRAKT_CONTENT_TYPE), translate.t(trakt_content_type_label_key(form_state.form.content_type))) } } else { { config_field_child!(translate.t(LABEL_TRAKT_CONTENT_TYPE), "TRAKT_LIST_FORM.TRAKT_CONTENT_TYPE", { let form_state_ct = form_state.clone(); @@ -128,6 +145,12 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html { })} } + if props.readonly { + { config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only) } + } else { + { edit_field_bool!(form_state, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only, TraktListFormAction::TmdbOnly) } + } + if props.readonly { { config_field_custom!( translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), diff --git a/shared/src/model/config/trakt.rs b/shared/src/model/config/trakt.rs index 870822f88..f5e67c656 100644 --- a/shared/src/model/config/trakt.rs +++ b/shared/src/model/config/trakt.rs @@ -1,49 +1,22 @@ -use crate::{ - error::TuliproxError, - utils::{ - default_as_true, default_trakt_fuzzy_threshold, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, TRAKT_API_URL, - TRAKT_API_VERSION, - }, +use crate::utils::{ + default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, + TRAKT_API_URL, TRAKT_API_VERSION, }; use serde::{Deserialize, Serialize}; -use std::{fmt, str::FromStr}; +use strum_macros::{Display, EnumString}; -#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)] #[serde(rename_all = "lowercase")] pub enum TraktContentType { + #[strum(serialize = "vod")] Vod, + #[strum(serialize = "series")] Series, #[default] + #[strum(serialize = "both")] Both, } -impl fmt::Display for TraktContentType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - TraktContentType::Vod => "Vod", - TraktContentType::Series => "Series", - TraktContentType::Both => "Both", - } - ) - } -} - -impl FromStr for TraktContentType { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "vod" => Ok(TraktContentType::Vod), - "series" => Ok(TraktContentType::Series), - "both" => Ok(TraktContentType::Both), - _ => Err(TuliproxError::Config(format!("Invalid TraktContentType: {}", s))), - } - } -} - #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct TraktApiConfigDto { @@ -77,10 +50,68 @@ pub struct TraktListConfigDto { pub list_slug: String, pub category_name: String, pub content_type: TraktContentType, + #[serde(default, skip_serializing_if = "is_false")] + pub tmdb_only: bool, #[serde(default = "default_trakt_fuzzy_threshold")] pub fuzzy_match_threshold: u8, // Percentage (0-100) } +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum TraktChartKind { + #[default] + #[serde(alias = "movie", alias = "vod")] + // `to_string` defines the canonical emitted value; `serialize` adds accepted parse aliases. + #[strum(to_string = "movies", serialize = "movies", serialize = "movie", serialize = "vod")] + Movies, + #[serde(alias = "show", alias = "series", alias = "tvshows")] + #[strum(to_string = "shows", serialize = "shows", serialize = "show", serialize = "series", serialize = "tvshows")] + Shows, +} + +impl TraktChartKind { + pub const fn content_type(self) -> TraktContentType { + match self { + Self::Movies => TraktContentType::Vod, + Self::Shows => TraktContentType::Series, + } + } +} + +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum TraktChartType { + #[default] + Trending, + Popular, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TraktChartConfigDto { + pub kind: TraktChartKind, + pub chart: TraktChartType, + pub category_name: String, + #[serde(default, skip_serializing_if = "is_false")] + pub tmdb_only: bool, + #[serde(default = "default_trakt_fuzzy_threshold")] + pub fuzzy_match_threshold: u8, // Percentage (0-100) +} + +impl Default for TraktChartConfigDto { + fn default() -> Self { + Self { + kind: TraktChartKind::default(), + chart: TraktChartType::default(), + category_name: String::new(), + tmdb_only: false, + fuzzy_match_threshold: default_trakt_fuzzy_threshold(), + } + } +} + impl Default for TraktListConfigDto { fn default() -> Self { TraktListConfigDto { @@ -88,6 +119,7 @@ impl Default for TraktListConfigDto { list_slug: String::new(), category_name: String::new(), content_type: TraktContentType::default(), + tmdb_only: false, fuzzy_match_threshold: default_trakt_fuzzy_threshold(), } } @@ -100,13 +132,81 @@ pub struct TraktConfigDto { pub enabled: bool, #[serde(default)] pub api: TraktApiConfigDto, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lists: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub charts: Vec, } impl Default for TraktConfigDto { - fn default() -> Self { Self { enabled: true, api: TraktApiConfigDto::default(), lists: Vec::new() } } + fn default() -> Self { + Self { enabled: true, api: TraktApiConfigDto::default(), lists: Vec::new(), charts: Vec::new() } + } } impl TraktConfigDto { pub fn prepare(&mut self) { self.api.prepare(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trakt_content_type_parsing_and_display_remain_stable() { + assert_eq!("vod".parse::().ok(), Some(TraktContentType::Vod)); + assert_eq!("series".parse::().ok(), Some(TraktContentType::Series)); + assert_eq!("both".parse::().ok(), Some(TraktContentType::Both)); + assert!("SERIES".parse::().is_err(), "should not accept SERIES"); + assert_eq!(TraktContentType::Vod.to_string(), "vod"); + assert_eq!(TraktContentType::Series.to_string(), "series"); + assert_eq!(TraktContentType::Both.to_string(), "both"); + } + + #[test] + fn trakt_chart_kind_parsing_aliases_and_display_remain_stable() { + assert_eq!("movies".parse::().ok(), Some(TraktChartKind::Movies)); + assert_eq!("movie".parse::().ok(), Some(TraktChartKind::Movies)); + assert_eq!("VOD".parse::().ok(), Some(TraktChartKind::Movies)); + assert_eq!("shows".parse::().ok(), Some(TraktChartKind::Shows)); + assert_eq!("series".parse::().ok(), Some(TraktChartKind::Shows)); + assert_eq!("tvshows".parse::().ok(), Some(TraktChartKind::Shows)); + assert_eq!(TraktChartKind::Movies.to_string(), "movies"); + assert_eq!(TraktChartKind::Shows.to_string(), "shows"); + } + + #[test] + fn trakt_chart_type_parsing_and_display_remain_stable() { + assert_eq!("trending".parse::().ok(), Some(TraktChartType::Trending)); + assert_eq!("POPULAR".parse::().ok(), Some(TraktChartType::Popular)); + assert_eq!(TraktChartType::Trending.to_string(), "trending"); + assert_eq!(TraktChartType::Popular.to_string(), "popular"); + } + + #[test] + fn trakt_config_accepts_charts_without_user_lists() { + let config = serde_json::from_str::( + r#"{"charts":[{"kind":"movies","chart":"trending","category_name":"Trending Movies","tmdb_only":true}]}"#, + ) + .expect("charts-only Trakt config should deserialize"); + + assert!(config.lists.is_empty()); + assert_eq!(config.charts.len(), 1); + assert_eq!(config.charts[0].kind, TraktChartKind::Movies); + assert_eq!(config.charts[0].kind.content_type(), TraktContentType::Vod); + assert_eq!(config.charts[0].chart, TraktChartType::Trending); + assert_eq!(config.charts[0].fuzzy_match_threshold, default_trakt_fuzzy_threshold()); + } + + #[test] + fn trakt_chart_kind_accepts_show_aliases() { + let config = serde_json::from_str::( + r#"{"charts":[{"kind":"series","chart":"popular","category_name":"Popular Shows"}]}"#, + ) + .expect("series alias should deserialize as show charts"); + + assert_eq!(config.charts[0].kind, TraktChartKind::Shows); + assert_eq!(config.charts[0].kind.content_type(), TraktContentType::Series); + assert_eq!(config.charts[0].chart, TraktChartType::Popular); + } +}