diff --git a/CHANGELOG.md b/CHANGELOG.md index bab05c32b..db250f058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -934,6 +934,12 @@ ## 🐛 Fixes +- **Trakt curation now requires an explicitly configured Client ID.** Tuliprox no longer bundles or falls back to a + shared Client ID. Blank or header-invalid `trakt.api.api_key` values now produce one target-scoped warning and skip + only optional Trakt curation without making an HTTP request; other target processing continues. Trakt `401`, `403`, + `404`, and `429` responses now have actionable, resource-aware messages, while independently successful lists and + charts remain available. + - **Empty playlist updates no longer replace previously published input or target data.** A completely empty refresh is treated as a failed update and keeps the last usable playlist and its virtual-ID mapping intact. This prevents transient provider/download failures from making channels disappear or assigning different IDs when service @@ -1972,7 +1978,7 @@ and assigns it to the variable `station_prefix`. resolve_vod: false trakt: api: - key: + key: version: 2 lists: - user: "linaspurinis" diff --git a/backend/core/src/utils/trakt/client.rs b/backend/core/src/utils/trakt/client.rs index 3ae3d891c..e1d9a78db 100644 --- a/backend/core/src/utils/trakt/client.rs +++ b/backend/core/src/utils/trakt/client.rs @@ -3,11 +3,7 @@ use crate::model::{TraktApiConfig, TraktChartConfig, TraktListConfig, TraktListI use log::{debug, info}; use reqwest::header::{HeaderMap, HeaderValue}; use serde::Deserialize; -use shared::{ - defaults::{DEFAULT_USER_AGENT, TRAKT_API_KEY}, - error::TuliproxError, - utils::trim_last_slash, -}; +use shared::{defaults::DEFAULT_USER_AGENT, error::TuliproxError, utils::trim_last_slash}; const TRAKT_PAGE_LIMIT: u32 = 100; const TRAKT_MAX_PAGES: u32 = 100; @@ -20,31 +16,39 @@ pub struct TraktClient { } impl TraktClient { - pub fn new(client: reqwest::Client, api_config: TraktApiConfig) -> Self { - let headers = Self::create_headers(&api_config); - Self { client, api_config, headers } + pub fn new(client: reqwest::Client, mut api_config: TraktApiConfig) -> Result { + api_config.api_key = api_config.api_key.trim().to_string(); + let headers = Self::create_headers(&api_config)?; + Ok(Self { client, api_config, headers }) } - fn create_headers(api_config: &TraktApiConfig) -> HeaderMap { - let mut headers = HeaderMap::new(); + fn create_headers(api_config: &TraktApiConfig) -> Result { + if api_config.api_key.is_empty() { + return Err(TuliproxError::Config( + "Trakt Client ID is missing; configure trakt.api.api_key before enabling Trakt lists or charts", + )); + } + let mut client_id = HeaderValue::from_str(api_config.api_key.as_str()).map_err(|_| { + TuliproxError::Config( + "Trakt Client ID contains characters that cannot be used in an HTTP header; update trakt.api.api_key", + ) + })?; + client_id.set_sensitive(true); + let mut headers = HeaderMap::new(); headers.insert(reqwest::header::CONTENT_TYPE, HeaderValue::from_static(mime::APPLICATION_JSON.as_ref())); headers.insert( reqwest::header::USER_AGENT, HeaderValue::from_str(api_config.user_agent.as_str()) .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_USER_AGENT)), ); - headers.insert( - "trakt-api-key", - HeaderValue::from_str(api_config.api_key.as_str()) - .unwrap_or_else(|_| HeaderValue::from_static(TRAKT_API_KEY)), - ); + headers.insert("trakt-api-key", client_id); headers.insert( "trakt-api-version", HeaderValue::from_str(api_config.version.as_str()).unwrap_or_else(|_| HeaderValue::from_static("2")), ); - headers + Ok(headers) } fn build_list_url(&self, user: &str, list_slug: &str) -> String { @@ -122,8 +126,9 @@ impl TraktClient { ) -> Result { let url = self.build_list_url(&list_config.user, &list_config.list_slug); let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); + let list_id = format!("{}:{}", list_config.user, list_config.list_slug); let (response_text, page_count, item_count) = - self.fetch_trakt_page(request_url, "list", (&list_config.user, &list_config.list_slug), page).await?; + self.fetch_trakt_page(request_url, "list", &list_id, page).await?; 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}")) @@ -142,7 +147,7 @@ impl TraktClient { let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}"); let chart_id = format!("{}:{}", chart_config.kind, chart_config.chart); let (response_text, page_count, item_count) = - self.fetch_trakt_page(request_url, "chart", ("charts", &chart_id), page).await?; + self.fetch_trakt_page(request_url, "chart", &chart_id, page).await?; let items = parse_chart_items(&response_text, chart_config, page) .map_err(|error| TuliproxError::Config(format!("Failed to parse Trakt chart response: {error}")))?; @@ -155,16 +160,16 @@ impl TraktClient { async fn fetch_trakt_page( &self, request_url: String, - error_label: &str, - error_id: (&str, &str), + resource_kind: &str, + resource_id: &str, page: u32, ) -> Result<(String, u32, Option), TuliproxError> { let response = self.client.get(&request_url).headers(self.headers.clone()).send().await.map_err(|err| { - TuliproxError::Config(format!("Failed to fetch Trakt {error_label} {request_url}: {err}")) + TuliproxError::Config(format!("Failed to fetch Trakt {resource_kind} {request_url}: {err}")) })?; if !response.status().is_success() { - handle_trakt_api_error(response.status(), error_id.0, error_id.1)?; + handle_trakt_api_error(response.status(), resource_kind, resource_id)?; } let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page); @@ -246,6 +251,7 @@ fn parse_trakt_pagination_header(headers: &HeaderMap, name: &'static str) -> Opt #[cfg(test)] mod tests { use super::*; + use reqwest::StatusCode; use shared::model::{TraktChartKind, TraktChartType, TraktContentType}; use std::sync::{ atomic::{AtomicUsize, Ordering}, @@ -256,19 +262,75 @@ mod tests { net::TcpListener, }; + #[test] + fn trakt_client_rejects_blank_client_id_before_use() { + for client_id in ["", " \t\r\n "] { + let result = + TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), client_id)); + let Err(error) = result else { panic!("blank Client ID should be rejected") }; + + assert!(error.message().contains("Trakt Client ID is missing")); + assert!(error.message().contains("trakt.api.api_key")); + } + } + + #[test] + fn trakt_client_rejects_invalid_client_id_without_echoing_it() { + let invalid_client_id = "sensitive-client-id\ninjected-header"; + let result = + TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), invalid_client_id)); + let Err(error) = result else { panic!("header-invalid Client ID should be rejected") }; + + assert!(error.message().contains("Trakt Client ID")); + assert!(error.message().contains("trakt.api.api_key")); + assert!(!error.message().contains("sensitive-client-id")); + assert!(!error.message().contains("injected-header")); + } + + #[tokio::test] + async fn valid_client_id_is_trimmed_and_sent_in_trakt_api_key_header() { + let requests = Arc::new(Mutex::new(Vec::new())); + let base_url = spawn_single_response_trakt_server("[]", Arc::clone(&requests)).await; + let client = TraktClient::new(reqwest::Client::new(), api_config(base_url, " user-supplied-client-id ")) + .expect("valid Client ID should construct a Trakt client"); + + client + .get_chart_items(&chart_config(TraktChartKind::Movies, TraktChartType::Popular)) + .await + .expect("chart request should succeed"); + + assert!(client.headers.get("trakt-api-key").expect("Client ID header").is_sensitive()); + let requests = requests.lock().expect("requests"); + assert_eq!(request_header(&requests[0], "trakt-api-key"), Some("user-supplied-client-id")); + } + + #[tokio::test] + async fn unsuccessful_status_is_translated_before_plain_text_body_parsing() { + let requests = Arc::new(Mutex::new(Vec::new())); + let base_url = spawn_status_response_trakt_server( + StatusCode::FORBIDDEN, + "remote response body must not be logged", + Arc::clone(&requests), + ) + .await; + let client = client(base_url); + + let error = client + .get_chart_items(&chart_config(TraktChartKind::Movies, TraktChartType::Trending)) + .await + .expect_err("403 should fail"); + + assert!(error.message().contains("Trakt denied the request")); + assert!(!error.message().contains("remote response body")); + assert_eq!(requests.lock().expect("requests").len(), 1); + } + #[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 client = TraktClient::new(reqwest::Client::new(), api_config(base_url, "test-key")) + .expect("valid Client ID should construct a Trakt client"); let list_config = TraktListConfig { user: "user".to_string(), list_slug: "list".to_string(), @@ -359,6 +421,14 @@ mod tests { } async fn spawn_single_response_trakt_server(body: &'static str, requests: Arc>>) -> String { + spawn_status_response_trakt_server(StatusCode::OK, body, requests).await + } + + async fn spawn_status_response_trakt_server( + status: StatusCode, + 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 { @@ -377,8 +447,11 @@ mod tests { } 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 + "HTTP/1.1 {} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown"), + body.len(), + body ); stream.write_all(response.as_bytes()).await.expect("write response"); }); @@ -386,15 +459,24 @@ mod tests { } 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(), - }, - ) + TraktClient::new(reqwest::Client::new(), api_config(base_url, "test-key")) + .expect("valid Client ID should construct a Trakt client") + } + + fn api_config(base_url: String, client_id: &str) -> TraktApiConfig { + TraktApiConfig { + api_key: client_id.to_string(), + version: "2".to_string(), + url: base_url, + user_agent: "tuliprox-test".to_string(), + } + } + + fn request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (header_name, value) = line.split_once(':')?; + header_name.eq_ignore_ascii_case(name).then(|| value.trim()) + }) } fn chart_config(kind: TraktChartKind, chart: TraktChartType) -> TraktChartConfig { diff --git a/backend/core/src/utils/trakt/errors.rs b/backend/core/src/utils/trakt/errors.rs index d53724f33..c76b1e863 100644 --- a/backend/core/src/utils/trakt/errors.rs +++ b/backend/core/src/utils/trakt/errors.rs @@ -2,14 +2,72 @@ use reqwest::StatusCode; use shared::error::TuliproxError; /// 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> { +pub fn handle_trakt_api_error(status: StatusCode, resource_kind: &str, resource_id: &str) -> Result<(), TuliproxError> { match status.as_u16() { - 404 => Err(TuliproxError::RepositoryTrakt(format!("Trakt list not found: {user}:{list_slug}"))), - 401 => Err(TuliproxError::RepositoryTrakt("Trakt API key is invalid or expired".to_string())), - 429 => Err(TuliproxError::RepositoryTrakt("Trakt API rate limit exceeded".to_string())), + 401 => Err(TuliproxError::RepositoryTrakt( + "Trakt rejected the configured Client ID (HTTP 401 Unauthorized); check trakt.api.api_key", + )), + 403 => Err(TuliproxError::RepositoryTrakt( + "Trakt denied the request (HTTP 403 Forbidden); check the configured Client ID and resource access; creating Trakt API applications currently requires active VIP membership", + )), + 404 => Err(TuliproxError::RepositoryTrakt(format!( + "Trakt {resource_kind} not found: {resource_id}" + ))), + 429 => Err(TuliproxError::RepositoryTrakt( + "Trakt API rate limit exceeded (HTTP 429 Too Many Requests); retry later", + )), _ => Err(TuliproxError::RepositoryTrakt(format!( "Trakt API error {status}: {}", status.canonical_reason().unwrap_or("Unknown") ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn translated_error(status: StatusCode, resource_kind: &str, resource_id: &str) -> TuliproxError { + handle_trakt_api_error(status, resource_kind, resource_id) + .expect_err("unsuccessful status should be translated") + } + + #[test] + fn trakt_api_errors_translate_unauthorized_client_id() { + let message = translated_error(StatusCode::UNAUTHORIZED, "list", "alice:watchlist").message().to_string(); + + assert!(message.contains("401")); + assert!(message.contains("Client ID")); + assert!(message.contains("trakt.api.api_key")); + assert!(!message.contains("alice:watchlist")); + } + + #[test] + fn trakt_api_errors_translate_forbidden_without_overstating_vip_cause() { + let message = translated_error(StatusCode::FORBIDDEN, "chart", "movies:trending").message().to_string(); + + assert!(message.contains("403")); + assert!(message.contains("Trakt denied the request")); + assert!(message.contains("configured Client ID")); + assert!(message.contains("resource access")); + assert!(message.contains("creating Trakt API applications currently requires active VIP")); + } + + #[test] + fn trakt_api_errors_distinguish_list_and_chart_not_found() { + let list_message = translated_error(StatusCode::NOT_FOUND, "list", "alice:watchlist").message().to_string(); + let chart_message = translated_error(StatusCode::NOT_FOUND, "chart", "movies:trending").message().to_string(); + + assert_eq!(list_message, "Trakt list not found: alice:watchlist"); + assert_eq!(chart_message, "Trakt chart not found: movies:trending"); + } + + #[test] + fn trakt_api_errors_translate_rate_limit() { + let message = translated_error(StatusCode::TOO_MANY_REQUESTS, "chart", "shows:popular").message().to_string(); + + assert!(message.contains("429")); + assert!(message.contains("rate limit")); + assert!(message.contains("retry later")); + } +} diff --git a/backend/processing/src/processor/trakt.rs b/backend/processing/src/processor/trakt.rs index e53f29772..93f8c5622 100644 --- a/backend/processing/src/processor/trakt.rs +++ b/backend/processing/src/processor/trakt.rs @@ -307,9 +307,9 @@ pub struct TraktCategoriesProcessor { } impl TraktCategoriesProcessor { - pub fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Self { - let client = TraktClient::new(http_client.clone(), trakt_config.api.clone()); - Self { client } + pub fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Result { + let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?; + Ok(Self { client }) } pub async fn process_trakt_categories( @@ -406,8 +406,18 @@ pub async fn process_trakt_categories_for_target( if !trakt_config.enabled { return Ok(None); } + 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); + } - let processor = TraktCategoriesProcessor::new(http_client, trakt_config); + let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) { + Ok(processor) => processor, + Err(error) => { + warn!("Skipping Trakt curation for target '{}': {}", target.name, error.message()); + return Ok(None); + } + }; processor.process_trakt_categories(playlist, target, trakt_config).await } @@ -415,8 +425,18 @@ pub async fn process_trakt_categories_for_target( mod tests { use super::*; use shared::model::{ - EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, TraktContentType, - VideoStreamProperties, + ConfigTargetDto, EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, + TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, + TraktContentType, TraktListConfigDto, VideoStreamProperties, XtreamTargetOutputDto, + }; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + task::JoinHandle, }; #[test] @@ -426,6 +446,82 @@ mod tests { assert_eq!("UHD", quality.unwrap()); } + #[tokio::test] + async fn configured_trakt_curation_without_client_id_makes_no_request() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + let target = trakt_target("", base_url, true, vec![remote_list_config("Missing")], Vec::new()); + + let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) + .await + .expect("missing Client ID should not fail target processing"); + + assert!(result.is_none()); + assert_eq!(requests.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn disabled_trakt_config_with_sources_makes_no_request() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + let target = trakt_target("", base_url, false, vec![remote_list_config("Disabled")], Vec::new()); + + let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) + .await + .expect("disabled Trakt config should be a no-op"); + + assert!(result.is_none()); + assert_eq!(requests.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn trakt_config_without_lists_or_charts_makes_no_request() { + let requests = Arc::new(AtomicUsize::new(0)); + let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await; + let target = trakt_target("", base_url, true, Vec::new(), Vec::new()); + + let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target) + .await + .expect("empty Trakt config should be a no-op"); + + assert!(result.is_none()); + assert_eq!(requests.load(Ordering::SeqCst), 0); + server.abort(); + } + + #[tokio::test] + async fn failed_list_does_not_suppress_successful_chart() { + let requests = Arc::new(Mutex::new(Vec::new())); + let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await; + let target = trakt_target( + "test-client-id", + base_url, + true, + vec![remote_list_config("Unavailable List")], + vec![remote_chart_config("Available Chart")], + ); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Original".intern(), + channels: vec![video_item("Movie 1", Some(11))], + xtream_cluster: XtreamCluster::Video, + }]; + + let categories = process_trakt_categories_for_target(&reqwest::Client::new(), &playlist, &target) + .await + .expect("a failed Trakt source should not fail target processing") + .expect("configured Trakt sources should produce a result"); + + assert_eq!(categories.len(), 1); + assert_eq!(categories[0].title.as_ref(), "Available Chart"); + assert_eq!(categories[0].channels.len(), 1); + assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie 1"); + assert_eq!(requests.lock().expect("requests").len(), 2); + server.await.expect("test server should finish"); + } + #[test] fn tmdb_only_list_skips_title_fallback_matches() { let playlist_item = video_item("The Captive", None); @@ -537,6 +633,116 @@ mod tests { assert!(categories.is_empty()); } + async fn spawn_counting_trakt_server(requests: Arc) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let _ = read_request(&mut stream).await; + requests.fetch_add(1, Ordering::SeqCst); + write_response(&mut stream, "200 OK", "[]").await; + } + }); + (format!("http://{addr}"), server) + } + + async fn spawn_partial_success_trakt_server(requests: Arc>>) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept test request"); + let request = read_request(&mut stream).await; + let is_list_request = request.contains("/users/test-user/lists/test-list/items"); + requests.lock().expect("requests").push(request); + if is_list_request { + write_response(&mut stream, "403 Forbidden", "response body must not affect the next source").await; + } else { + write_response( + &mut stream, + "200 OK", + r#"[{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}]"#, + ) + .await; + } + } + }); + (format!("http://{addr}"), server) + } + + async fn read_request(stream: &mut TcpStream) -> String { + 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; + } + } + String::from_utf8_lossy(&request_bytes).to_string() + } + + async fn write_response(stream: &mut TcpStream, status: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.expect("write response"); + } + + fn trakt_target( + client_id: &str, + url: String, + enabled: bool, + lists: Vec, + charts: Vec, + ) -> ConfigTarget { + ConfigTarget::from(&ConfigTargetDto { + name: "test-target".to_string(), + output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto { + trakt: Some(TraktConfigDto { + enabled, + api: TraktApiConfigDto { + api_key: client_id.to_string(), + version: "2".to_string(), + url, + user_agent: "tuliprox-test".to_string(), + }, + lists, + charts, + }), + ..XtreamTargetOutputDto::default() + })], + ..ConfigTargetDto::default() + }) + } + + fn remote_list_config(category_name: &str) -> TraktListConfigDto { + TraktListConfigDto { + user: "test-user".to_string(), + list_slug: "test-list".to_string(), + category_name: category_name.to_string(), + content_type: TraktContentType::Vod, + tmdb_only: true, + fuzzy_match_threshold: 100, + } + } + + fn remote_chart_config(category_name: &str) -> TraktChartConfigDto { + TraktChartConfigDto { + kind: TraktChartKind::Movies, + chart: TraktChartType::Popular, + category_name: category_name.to_string(), + tmdb_only: true, + fuzzy_match_threshold: 100, + } + } + fn list_config(tmdb_only: bool) -> TraktCategoryConfig { named_list_config("category", tmdb_only) } fn named_list_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig { diff --git a/config/source.yml b/config/source.yml index 83829ec7a..f13d52004 100644 --- a/config/source.yml +++ b/config/source.yml @@ -42,6 +42,11 @@ sources: skip_video_direct_source: true skip_series_direct_source: true trakt: + # Set enabled to true after configuring api.api_key with your Trakt Client ID. + # See the documentation for the TRAKT_CLIENT_ID environment-variable example. + enabled: false + api: + api_key: "" lists: - user: "linaspurinis" list_slug: "top-watched-movies-of-the-week" diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index faefc873e..2bc99ce05 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1661,8 +1661,10 @@ output: skip_series_direct_source: true update_strategy: instant trakt: + enabled: true api: - api_key: "YOUR_API_KEY" + # Despite the compatible field name, this value is the Trakt Client ID. + api_key: "${env:TRAKT_CLIENT_ID}" version: "2" url: "https://api.trakt.tv" user_agent: "Mozilla/5.0" @@ -1724,8 +1726,10 @@ sources: skip_video_direct_source: true skip_series_direct_source: true trakt: + enabled: true api: - api_key: "YOUR_API_KEY" + # Despite the compatible field name, this value is the Trakt Client ID. + api_key: "${env:TRAKT_CLIENT_ID}" version: "2" url: "https://api.trakt.tv" user_agent: "Mozilla/5.0" @@ -1752,28 +1756,40 @@ sources: ``` This configuration creates additional virtual categories populated with matched entries from the configured Trakt user -lists and public Trakt charts. +lists and public Trakt charts. Define `TRAKT_CLIENT_ID` in the environment of the Tuliprox process before enabling the +block. + +The serialized field remains `api.api_key` for configuration compatibility, but its value is the Client ID of your +Trakt API application and is sent in the `trakt-api-key` header. Tuliprox does not bundle a Client ID and never falls +back to another identity. Creating Trakt API applications currently requires active VIP membership. A `403 Forbidden` +response only means that Trakt denied the request; check both the configured Client ID and access to the requested +resource rather than assuming that every `403` proves a particular account state. + +If lists or charts are configured while the Client ID is blank or cannot be used as an HTTP header, Tuliprox makes no +Trakt request, logs one target-scoped warning, and skips only optional Trakt curation. The rest of target processing +continues. A disabled block, or a block with no lists or charts, remains a silent no-op. ##### 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[].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. | +| Parameter | Type | Required | Default | Technical Impact & Background | +| :------------------------------- | :------ | :------: | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | Bool | No | `true` | Enables Trakt curation. Keep it `false` until an explicit Client ID is configured. | +| `api.api_key` | String | Yes | | Compatible field that stores the Trakt Client ID. There is no bundled fallback; use an explicit value such as `${env:TRAKT_CLIENT_ID}`. | +| `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. diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 07487eb11..840cd7429 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -953,6 +953,7 @@ "API_CONFIG": "API", "API_CONFIGURATION": "تكوين API", "API_KEY": "مفتاح API", + "TRAKT_CLIENT_ID": "معرّف عميل Trakt", "API_PROXY": "وكيل API", "API_PROXY_CONFIG": "تكوين وكيل API", "API_URL": "عنوان URL لـ API", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 1047def7e..87bc00511 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -956,6 +956,7 @@ "API_CONFIG": "API", "API_CONFIGURATION": "API Configuration", "API_KEY": "API-Key", + "TRAKT_CLIENT_ID": "Trakt Client ID", "API_PROXY": "Api-Proxy", "API_PROXY_CONFIG": "Api Proxy Config", "API_URL": "API-URL", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 723d2724b..65955b918 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -912,6 +912,7 @@ "API_CONFIG": "API", "API_CONFIGURATION": "Конфигурация API", "API_KEY": "API-ключ", + "TRAKT_CLIENT_ID": "Идентификатор клиента Trakt", "API_PROXY": "Api-прокси", "API_PROXY_CONFIG": "Конфигурация Api-прокси", "API_URL": "URL API", 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 c87bf4a69..21a45bd0d 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -27,7 +27,7 @@ const LABEL_LIVE: &str = "LABEL.LIVE"; const LABEL_VOD: &str = "LABEL.VOD"; const LABEL_SERIES: &str = "LABEL.SERIES"; const LABEL_FILTER: &str = "LABEL.FILTER"; -const LABEL_TRAKT_API_KEY: &str = "LABEL.API_KEY"; +const LABEL_TRAKT_CLIENT_ID: &str = "LABEL.TRAKT_CLIENT_ID"; 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"; @@ -449,14 +449,14 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {

{translate.t(LABEL_API_CONFIGURATION)}

if props.allow_write { <> - { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_KEY), api_key, TraktApiConfigFormAction::ApiKey) } + { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_CLIENT_ID), api_key, TraktApiConfigFormAction::ApiKey) } { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) } { edit_field_text!(trakt_api_form, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) } { edit_field_text!(trakt_api_form, translate.t(LABEL_USER_AGENT), user_agent, TraktApiConfigFormAction::UserAgent) } } else { <> - { config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_KEY), api_key) } + { config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_CLIENT_ID), api_key) } { config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_VERSION), version) } { config_field!(trakt_api_form.form, translate.t(LABEL_TRAKT_API_URL), url) } { config_field!(trakt_api_form.form, translate.t(LABEL_USER_AGENT), user_agent) } diff --git a/shared/src/defaults/trakt.rs b/shared/src/defaults/trakt.rs index ed5d28a1c..d78b76232 100644 --- a/shared/src/defaults/trakt.rs +++ b/shared/src/defaults/trakt.rs @@ -1,10 +1,8 @@ //! Trakt defaults. -pub const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3"; pub const TRAKT_API_VERSION: &str = "2"; pub const TRAKT_API_URL: &str = "https://api.trakt.tv"; -pub fn default_trakt_api_key() -> String { String::from(TRAKT_API_KEY) } pub fn default_trakt_api_version() -> String { String::from(TRAKT_API_VERSION) } pub fn default_trakt_api_url() -> String { String::from(TRAKT_API_URL) } pub fn default_trakt_fuzzy_threshold() -> u8 { 80 } diff --git a/shared/src/model/config/trakt.rs b/shared/src/model/config/trakt.rs index fb1da49a2..24373cc72 100644 --- a/shared/src/model/config/trakt.rs +++ b/shared/src/model/config/trakt.rs @@ -1,6 +1,6 @@ use crate::defaults::{ - default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, - TRAKT_API_URL, TRAKT_API_VERSION, + default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL, + TRAKT_API_VERSION, }; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; @@ -32,8 +32,7 @@ pub struct TraktApiConfigDto { impl TraktApiConfigDto { pub fn prepare(&mut self) { - let key = self.api_key.trim(); - self.api_key = String::from(if key.is_empty() { TRAKT_API_KEY } else { key }); + self.api_key = self.api_key.trim().to_string(); let version = self.version.trim(); self.version = String::from(if version.is_empty() { TRAKT_API_VERSION } else { version }); let url = self.url.trim(); @@ -152,6 +151,27 @@ impl TraktConfigDto { mod tests { use super::*; + #[test] + fn trakt_api_prepare_keeps_blank_client_id_absent() { + for client_id in ["", " \t\r\n "] { + let mut config = TraktApiConfigDto { api_key: client_id.to_string(), ..TraktApiConfigDto::default() }; + + config.prepare(); + + assert!(config.api_key.is_empty(), "blank Client ID should remain absent"); + } + } + + #[test] + fn trakt_api_prepare_trims_supplied_client_id() { + let mut config = + TraktApiConfigDto { api_key: " user-supplied-client-id ".to_string(), ..TraktApiConfigDto::default() }; + + config.prepare(); + + assert_eq!(config.api_key, "user-supplied-client-id"); + } + #[test] fn trakt_content_type_parsing_and_display_remain_stable() { assert_eq!("vod".parse::().ok(), Some(TraktContentType::Vod));