From f571e6781b8fa995a002219a7506f3bb7e429a9b Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 4 Jan 2026 09:55:50 +0100 Subject: [PATCH 1/5] Fix stream headers --- CHANGELOG.md | 1 + README.md | 6 ++---- backend/src/api/api_utils.rs | 21 ++++++++++--------- backend/src/api/model/model_utils.rs | 4 ++-- .../model/streams/provider_stream_factory.rs | 20 ++++++------------ 5 files changed, 22 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed25ce6c9..5ed06ad37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Removed `forced_retry_interval_secs`. - **Input Batch Changes**: `name` attribute is now mandatory for input type batch to ensure stable playlist UUIDs. - **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. +- **EpgSmartMatch**: Field `name_prefix` syntax needs to be changed from `name_prefix: !suffix "."` to `name_prefix: { suffix: "." }`. ## 🌟 New Features - **Discord Notifications**: Support for Discord notifications via webhooks with optional Handlebars templates. diff --git a/README.md b/README.md index a5314e101..315f2788b 100644 --- a/README.md +++ b/README.md @@ -885,7 +885,7 @@ epg: fuzzy_matching: true match_threshold: 80 best_match_threshold: 99 - name_prefix: !suffix "." + name_prefix: { suffix: "." } name_prefix_separator: [':', '|', '-'] strip : ["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw"] normalize_regex: '[^a-zA-Z0-9\-]' @@ -900,7 +900,7 @@ The fuzzy matching tries to guess the EPG ID for a given channel. Some keys are When looking at playlists, it's common for a country prefix to be included in the name, such as `US:` or `FR|`. The `name_prefix_separator` defines the possible separator characters used to identify this part. For EPG IDs, the country code is typically added as a suffix, like cnn.us. This is controlled by the name_prefix attribute. -The `!suffix '.'` setting means: if a prefix is found, append it to the name using the given separator character (in this case, a dot). +The `{suffix: '.'}` setting means: if a prefix is found, append it to the name using the given separator character (in this case, a dot). Example input config for `m3u` ```yaml @@ -931,8 +931,6 @@ inputs: persist: 'playlist_1_1{}.m3u' headers: User-Agent: "Mozilla/5.0 (AppleTV; U; CPU OS 14_2 like Mac OS X; en-us) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Safari/605.1.15" - Accept: application/json - Accept-Encoding: gzip url: 'http://localhost:8080' username: test password: test diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 2f862a030..71203fa86 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,13 +1,10 @@ use crate::BUILD_TIMESTAMP; use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; -use crate::api::model::{ - create_channel_unavailable_stream, create_custom_video_stream_response, - create_provider_connections_exhausted_stream, create_provider_stream, - get_stream_response_with_headers, ActiveClientStream, AppState, - CustomVideoStreamType, - ProviderStreamFactoryOptions, SharedStreamManager, - StreamError, ThrottledStream, UserApiRequest, -}; +use crate::api::model::{create_channel_unavailable_stream, create_custom_video_stream_response, + create_provider_connections_exhausted_stream, create_provider_stream, + get_stream_response_with_headers, ActiveClientStream, AppState, + CustomVideoStreamType, ProviderStreamFactoryOptions, + SharedStreamManager, StreamError, ThrottledStream, UserApiRequest}; use crate::api::model::{tee_stream, UserSession}; use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; use crate::api::panel_api::try_provision_account_on_exhausted; @@ -1168,10 +1165,14 @@ pub async fn local_stream_response( headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); } headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - headers.insert(header::CONTENT_LENGTH, HeaderValue::from_str(&content_length.to_string()).unwrap_or_else(|_| HeaderValue::from_static("0"))); + if let Ok(header_value) = HeaderValue::from_str(&content_length.to_string()) { + headers.insert(header::CONTENT_LENGTH, header_value); + } if range.is_some() { - headers.insert(header::CONTENT_RANGE, HeaderValue::from_str(&format!("bytes {start}-{end}/{file_size}")).unwrap_or_else(|_| HeaderValue::from_static("bytes=0-"))); + if let Ok(header_value) = HeaderValue::from_str(&format!("bytes {start}-{end}/{file_size}")) { + headers.insert(header::CONTENT_RANGE, header_value); + } } response diff --git a/backend/src/api/model/model_utils.rs b/backend/src/api/model/model_utils.rs index 31b9e1674..718d4be61 100644 --- a/backend/src/api/model/model_utils.rs +++ b/backend/src/api/model/model_utils.rs @@ -11,7 +11,7 @@ pub fn get_response_headers(headers: &HeaderMap) -> Vec<(String, String)> { value.to_str().ok().map(|v| (key.to_string(), v.to_string())) }) .collect(); - response_headers.push((axum::http::header::CONNECTION.as_str().to_string(), "keep-alive".to_string())); + response_headers.push((axum::http::header::CONNECTION.as_str().to_string(), "close".to_string())); response_headers } @@ -32,7 +32,7 @@ pub fn get_stream_response_with_headers(custom: Option<(Vec<(String, String)>, S let default_headers = vec![ ("content-type", "application/octet-stream"), - //("connection", "keep-alive"), + ("connection", "close"), ]; for (key, value) in default_headers { diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 4264b5d3a..08f8a8a9e 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -206,20 +206,12 @@ fn prepare_client( } } - // if !headers.contains_key(axum::http::header::HOST) { - // if let Some(host_header) = get_host_and_optional_port(url) { - // if let Ok(header_value) = axum::http::header::HeaderValue::from_str(&host_header) { - // headers.insert(axum::http::header::HOST, header_value); - // } - // } - // } - - if !headers.contains_key(axum::http::header::CONNECTION) { - headers.insert( - axum::http::header::CONNECTION, - axum::http::header::HeaderValue::from_static("keep-alive"), - ); - } + // Force Connection: close so the provider releases its slot immediately when the stream ends. + // This prevents 509 errors from providers counting idle pooled connections against limits. + headers.insert( + axum::http::header::CONNECTION, + axum::http::header::HeaderValue::from_static("close"), + ); if !headers.contains_key(axum::http::header::USER_AGENT) { headers.insert( From e5e94a5f283f82e4951390e1841b500c8cd5edcf Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 4 Jan 2026 10:54:41 +0100 Subject: [PATCH 2/5] Fix stream session poisoning and correct headers handling --- backend/src/api/api_utils.rs | 68 ++++++-- .../model/streams/provider_stream_factory.rs | 163 +++++++++++------- backend/src/utils/network/request.rs | 61 ++++++- shared/src/utils/constants.rs | 1 + 4 files changed, 215 insertions(+), 78 deletions(-) diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 71203fa86..f4817bc94 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -760,6 +760,10 @@ pub async fn force_provider_stream_response( let connection_permission = UserConnectionPermission::Allowed; let item_type = stream_channel.item_type; + // Release the existing provider connection for this session before acquiring a new one. + // This is critical for users with a connection limit of 1 to avoid "Provider exhausted" or provider-side 502/509 errors during seeking. + app_state.connection_manager.release_provider_connection(&user_session.addr).await; + let stream_details = create_stream_response_details( app_state, &stream_options, @@ -931,13 +935,29 @@ pub async fn stream_response( axum::http::StatusCode::BAD_REQUEST.into_response() } } else { - let session_url = provider_response - .as_ref() - .and_then(|(_, _, u, _)| u.as_ref()) - .map_or_else( - || Cow::Borrowed(stream_url), - |url| Cow::Owned(url.to_string()), - ); + // Previously, we would always check if the provider redirected the request. + // If the provider redirected a movie from /movie/... to a temporary /live/... URL, + // We would save that redirected URL in your session. + // When we tried to seek or pause/resume, we would use that saved /live/ URL. + // However, providers often make these redirect links ephemeral or restricted—they + // might not support seeking, or they might trigger a 509 error if accessed again. + // For Movies/Series: We now ignore the redirect and always save the original, + // canonical URL (the one starting with /movie/) in your session. + // This ensures that every time you seek, we start "fresh" with the correct provider handshake, + // preventing the session from being "poisoned" by a temporary redirect. + // For everything else (Live): It continues to work as before, using the redirected URL if available, + // which is often desirable for live streams to stay on the same edge server. + let session_url = if matches!(item_type, PlaylistItemType::Catchup | PlaylistItemType::Video | PlaylistItemType::LocalVideo | PlaylistItemType::Series | PlaylistItemType::LocalSeries) { + Cow::Borrowed(stream_url) + } else { + provider_response + .as_ref() + .and_then(|(_, _, u, _)| u.as_ref()) + .map_or_else( + || Cow::Borrowed(stream_url), + |url| Cow::Owned(url.to_string()), + ) + }; if log_enabled!(log::Level::Debug) { if session_url.eq(&stream_url) { debug!("Streaming stream request from {}", sanitize_sensitive_info(stream_url) @@ -1508,9 +1528,6 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> .map(ToString::to_string); if let Some(range) = range { - // if range.starts_with("bytes=0-") { - // return false; - // } if range.starts_with("bytes=") { return true; } @@ -1542,3 +1559,34 @@ pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> imp pub fn create_session_fingerprint(fingerprint: &str, username: &str, virtual_id: u32) -> String { format!("{fingerprint}|{username}|{virtual_id}") } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderMap; + use shared::model::XtreamCluster; + + #[tokio::test] + async fn test_is_seek_request() { + let mut headers = HeaderMap::new(); + + // No range header + assert!(!is_seek_request(XtreamCluster::Video, &headers).await); + + // Range: bytes=0- (Should be true now to allow session takeover on restart) + headers.insert("range", "bytes=0-".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers).await); + + // Range: bytes=100- (Should be true) + headers.insert("range", "bytes=100-".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers).await); + + // Range: bytes=100-200 (Should be true) + headers.insert("range", "bytes=100-200".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers).await); + + // Live cluster should always return false + headers.insert("range", "bytes=100-".parse().unwrap()); + assert!(!is_seek_request(XtreamCluster::Live, &headers).await); + } +} diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 08f8a8a9e..7d47c10ba 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -39,6 +39,7 @@ pub struct ProviderStreamFactoryOptions { url: Url, headers: HeaderMap, range_bytes: Arc>, + range_requested: bool, reconnect_flag: Arc, } @@ -61,15 +62,18 @@ impl ProviderStreamFactoryOptions { }; let filter_header = get_header_filter_for_item_type(item_type); let mut req_headers = get_headers_from_request(req_headers, &filter_header); - // we need the range bytes from client request for seek ing to the right position - let range_start_bytes = get_request_range_start_bytes(&req_headers); + let requested_range = get_request_range_start_bytes(&req_headers); req_headers.remove("range"); // We merge configured input headers with the headers from the request. let headers = get_request_headers(input_headers, Some(&req_headers), disabled_headers); let url = stream_url.clone(); - let range_bytes = Arc::new(range_start_bytes.map(AtomicUsize::new)); + let range_bytes = if matches!(item_type, PlaylistItemType::Live | PlaylistItemType::LiveUnknown) { + Arc::new(requested_range.map(AtomicUsize::new)) + } else { + Arc::new(Some(AtomicUsize::new(requested_range.unwrap_or(0)))) + }; Self { // item_type, @@ -83,6 +87,7 @@ impl ProviderStreamFactoryOptions { url, headers, range_bytes, + range_requested: requested_range.is_some(), } } @@ -158,6 +163,11 @@ impl ProviderStreamFactoryOptions { self.reconnect_flag.is_active() } + #[inline] + pub fn was_range_requested(&self) -> bool { + self.range_requested + } + } fn get_request_range_start_bytes(req_headers: &HashMap>) -> Option { @@ -221,11 +231,15 @@ fn prepare_client( } let partial = if let Some(range) = range_start { - let range_header = format!("bytes={range}-"); - if let Ok(header_value) = axum::http::header::HeaderValue::from_str(&range_header) { - headers.insert(RANGE, header_value); + if range > 0 || stream_options.was_range_requested() { + let range_header = format!("bytes={range}-"); + if let Ok(header_value) = axum::http::header::HeaderValue::from_str(&range_header) { + headers.insert(RANGE, header_value); + } + true + } else { + false } - true } else { false }; @@ -362,7 +376,7 @@ async fn get_provider_stream( } Err(status) => { debug!("Provider stream response error status response : {status}"); - if matches!(status, StatusCode::FORBIDDEN | StatusCode::SERVICE_UNAVAILABLE | StatusCode::UNAUTHORIZED) { + if matches!(status, StatusCode::FORBIDDEN | StatusCode::SERVICE_UNAVAILABLE | StatusCode::UNAUTHORIZED | StatusCode::RANGE_NOT_SATISFIABLE) { warn!("The stream could be unavailable. ({status}) {}",sanitize_sensitive_info(stream_options.get_url().as_str())); break; } @@ -520,56 +534,83 @@ pub async fn create_provider_stream( } } -// #[cfg(test)] -// mod tests { -// use crate::api::model::streams::provider_stream_factory::PlaylistItemType; -// use crate::api::model::streams::provider_stream_factory::{create_provider_stream, BufferStreamOptions}; -// use actix_web::test; -// use actix_web::test::TestRequest; -// use actix_web::web; -// use actix_web::App; -// use actix_web::{HttpRequest, HttpResponse}; -// use futures::StreamExt; -// use std::sync::Arc; -// use crate::model::Config; -// -// #[tokio::test] -// async fn test_stream() { -// let app = App::new().route("/test", web::get().to(test_stream_handler)); -// let server = test::init_service(app).await; -// let req = TestRequest::get().uri("/test").to_request(); -// let _response = test::call_service(&server, req).await; -// } -// async fn test_stream_handler(req: axum::http::Request) -> impl axum::response::IntoResponse + Send { -// let cfg = Config::default(); -// let mut counter = 5; -// let client = Arc::new(reqwest::Client::new()); -// let url = url::Url::parse("https://info.cern.ch/hypertext/WWW/TheProject.html").unwrap(); -// let input = None; -// -// let options = BufferStreamOptions::new(PlaylistItemType::Live, true, true, 0, false); -// let value = create_provider_stream(&cfg, &client, &url, &req, input, options); -// let mut values = value.await; -// 'outer: while let Some((ref mut stream, info)) = values.as_mut() { -// if info.is_some() { -// println!("{:?}", info.as_ref().unwrap()); -// } -// while let Some(result) = stream.next().await { -// match result { -// Ok(bytes) => { -// println!("Received {} bytes {bytes:?}", bytes.len()); -// counter -= 1; -// if counter < 0 { -// break 'outer; -// } -// } -// Err(err) => { -// eprintln!("Error occurred: {}", err); -// break 'outer; -// } -// } -// } -// } -// HttpResponse::Ok().finish() -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use shared::model::PlaylistItemType; + use axum::http::HeaderMap; + + #[test] + fn test_provider_stream_factory_options_range_logic() { + let addr = "127.0.0.1:8080".parse().unwrap(); + let stream_url = Url::parse("http://example.com/stream").unwrap(); + let stream_options = StreamOptions { + stream_retry: true, + buffer_enabled: true, + buffer_size: 1024, + pipe_provider_stream: false, + }; + let disabled_headers = None; + + // Case 1: VOD, no initial range requested + let mut req_headers = HeaderMap::new(); + let options = ProviderStreamFactoryOptions::new( + addr, + PlaylistItemType::Video, + false, + &stream_options, + &stream_url, + &req_headers, + None, + disabled_headers, + ); + assert!(!options.was_range_requested()); + assert_eq!(options.get_total_bytes_send(), Some(0)); // Should track even if not requested + + // Case 2: VOD, range requested + req_headers.insert("Range", "bytes=100-".parse().unwrap()); + let options = ProviderStreamFactoryOptions::new( + addr, + PlaylistItemType::Video, + false, + &stream_options, + &stream_url, + &req_headers, + None, + disabled_headers, + ); + assert!(options.was_range_requested()); + assert_eq!(options.get_total_bytes_send(), Some(100)); + + // Case 3: Live, no initial range requested + let req_headers = HeaderMap::new(); + let options = ProviderStreamFactoryOptions::new( + addr, + PlaylistItemType::Live, + false, + &stream_options, + &stream_url, + &req_headers, + None, + disabled_headers, + ); + assert!(!options.was_range_requested()); + assert_eq!(options.get_total_bytes_send(), None); // Should NOT track + + // Case 4: Live, range requested (should be stripped) + let mut req_headers = HeaderMap::new(); + req_headers.insert("Range", "bytes=100-".parse().unwrap()); + let options = ProviderStreamFactoryOptions::new( + addr, + PlaylistItemType::Live, + false, + &stream_options, + &stream_url, + &req_headers, + None, + disabled_headers, + ); + assert!(!options.was_range_requested()); // Stripped by filter + assert_eq!(options.get_total_bytes_send(), None); + } +} diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index faf101237..27a07c6ae 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -2,7 +2,7 @@ use futures::{StreamExt, TryStreamExt}; use log::{debug, error, log_enabled, trace, Level}; use reqwest::header::CONTENT_ENCODING; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -221,6 +221,10 @@ pub fn get_client_request pub fn get_request_headers(request_headers: Option<&HashMap>, custom_headers: Option<&HashMap, S>>, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> HeaderMap { let mut headers = HeaderMap::default(); + let mut has_user_agent = false; + + // 1. First, we process the configured request headers (from input config). + // These should have the highest priority. if let Some(req_headers) = request_headers { for (key, value) in req_headers { if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value.as_bytes())) { @@ -228,36 +232,49 @@ pub fn get_request_headers(request_header if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key.as_str())) { continue; } + if key == axum::http::header::USER_AGENT { + has_user_agent = true; + } headers.insert(key, value); } } } } + + // 2. Next, we process custom headers (from the client request). + // These are only added if they don't already exist in the headers map (i.e., not overridden by config). if let Some(custom) = custom_headers { - let header_keys: HashSet = headers.keys().map(|k| k.as_str().to_lowercase()).collect(); for (key, value) in custom { let key_lc = key.to_lowercase(); if filter_request_header(key_lc.as_str()) { if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key_lc.as_str())) { continue; } - if header_keys.contains(key_lc.as_str()) { - // debug_if_enabled!("Ignoring request header '{}={}'", key_lc, String::from_utf8_lossy(value)); - } else if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value)) { - headers.insert(key, value); + if let (Ok(name), Ok(val)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value)) { + // Only insert if not already present (config takes precedence) + if !headers.contains_key(&name) { + if name == axum::http::header::USER_AGENT { + has_user_agent = true; + } + headers.insert(name, val); + } } } } } + if log_enabled!(Level::Trace) { let he: HashMap = headers.iter().map(|(k, v)| (k.to_string(), String::from_utf8_lossy(v.as_bytes()).to_string())).collect(); if !he.is_empty() { trace!("Request headers {he:?}"); } } - if !headers.contains_key(axum::http::header::USER_AGENT) { + + // 3. Finally, if no User-Agent was provided by config OR client, use the default. + if !has_user_agent { headers.insert(axum::http::header::USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT)); } + headers } @@ -671,5 +688,35 @@ mod tests { let expected = "http://my.provider.com:8080"; assert_eq!(get_base_url_from_str(url).unwrap(), expected); } + + #[test] + fn test_get_request_headers_prioritization() { + use super::{get_request_headers, DEFAULT_USER_AGENT}; + use std::collections::HashMap; + use axum::http::header::USER_AGENT; + + // Case 1: No headers provided -> Default UA + let headers = get_request_headers::(None, None, None); + assert_eq!(headers.get(USER_AGENT).unwrap(), DEFAULT_USER_AGENT); + + // Case 2: Only client header -> Client UA + let mut client_headers = HashMap::new(); + client_headers.insert("User-Agent".to_string(), b"Client-UA".to_vec()); + let headers = get_request_headers(None, Some(&client_headers), None); + assert_eq!(headers.get(USER_AGENT).unwrap(), "Client-UA"); + + // Case 3: Both config and client -> Config UA overrides + let mut config_headers = HashMap::new(); + config_headers.insert("User-Agent".to_string(), "Config-UA".to_string()); + let headers = get_request_headers(Some(&config_headers), Some(&client_headers), None); + assert_eq!(headers.get(USER_AGENT).unwrap(), "Config-UA"); + + // Case 4: Other headers also prioritized + config_headers.insert("X-Test".to_string(), "From-Config".to_string()); + let mut client_headers = HashMap::new(); + client_headers.insert("X-Test".to_string(), b"From-Client".to_vec()); + let headers = get_request_headers(Some(&config_headers), Some(&client_headers), None); + assert_eq!(headers.get("X-Test").unwrap(), "From-Config"); + } } diff --git a/shared/src/utils/constants.rs b/shared/src/utils/constants.rs index 58c059c58..19ec160a0 100644 --- a/shared/src/utils/constants.rs +++ b/shared/src/utils/constants.rs @@ -41,6 +41,7 @@ const SUPPORTED_RESPONSE_HEADERS: &[&str] = &[ "access-control-allow-origin", "access-control-allow-credentials", "icy-metadata", + "icy-metaint", "referer", "last-modified", "cache-control", From fae1f35bcd9a3b0a8bc6dcb7e0307451cc17589c Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 4 Jan 2026 11:11:41 +0100 Subject: [PATCH 3/5] Fix stream session poisoning and correct headers handling --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed06ad37..a647ba526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ - **XTream Fixes**: Fixed series/catch-up lookups using `series-info virtual_id`. - **Cloudflare Header**: Added `cloudflare_header` to reverse proxy `disable_header` settings. - **Kick Seconds**: `kick_secs` added to `config.yml web_ui` config. +- **Improved connection handling** for users with strict connection limits during streaming operations. +- **Fixed streaming response handling** for specific content types. +- **Enhanced validation of response headers** to prevent invalid values. +- **Corrected request header prioritization logic**. # 3.2.0 (2025-11-14) - Added `name` attribute to Staged Input. From a5db614d258b7205aef124d869a29298b36f1988 Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 4 Jan 2026 12:49:34 +0100 Subject: [PATCH 4/5] Fixed input storage path --- backend/src/processing/input_cache.rs | 2 +- backend/src/repository/storage.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/processing/input_cache.rs b/backend/src/processing/input_cache.rs index 410f75145..64f5b011b 100644 --- a/backend/src/processing/input_cache.rs +++ b/backend/src/processing/input_cache.rs @@ -33,7 +33,7 @@ pub fn resolve_input_storage_path(working_dir: &str, input_name: &str) -> PathBu let sanitized_name: String = input_name.chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); - Path::new(working_dir).join("data").join(format!("input_{sanitized_name}")) + Path::new(working_dir).join(format!("input_{sanitized_name}")) } } diff --git a/backend/src/repository/storage.rs b/backend/src/repository/storage.rs index 87f149aaa..9ead7b5fc 100644 --- a/backend/src/repository/storage.rs +++ b/backend/src/repository/storage.rs @@ -32,7 +32,7 @@ pub fn get_input_storage_path(input_name: &str, working_dir: &str) -> std::io::R .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); let name = format!("input_{sanitized_name}"); - let path = Path::new(working_dir).join("data").join(name); + let path = Path::new(working_dir).join(name); // Create the directory and return the path or propagate the error std::fs::create_dir_all(&path).map(|()| path) } From 86f96baa6333a90768bca3d128673c34a980acb1 Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 4 Jan 2026 12:51:58 +0100 Subject: [PATCH 5/5] incremented version to 3.2.34 --- Cargo.lock | 6 +++--- backend/Cargo.toml | 2 +- frontend/Cargo.toml | 4 ++-- shared/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 430781849..ad274c2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.33" +version = "3.2.34" dependencies = [ "anyhow", "base64", @@ -3992,7 +3992,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.33" +version = "3.2.34" dependencies = [ "base64", "bitflags 2.10.0", @@ -4566,7 +4566,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.33" +version = "3.2.34" dependencies = [ "arc-swap", "async-compression", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8d674e40b..59010ad01 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.33" +version = "3.2.34" edition = "2021" rust-version = "1.87.0" diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index f97a9289c..9f7e36ded 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.33" +version = "3.2.34" edition = "2021" rust-version = "1.87.0" [dependencies] -shared = { version = "3.2.33", path = "../shared" } +shared = { version = "3.2.34", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/shared/Cargo.toml b/shared/Cargo.toml index ced6cd668..cdc3b2b45 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.33" +version = "3.2.34" edition = "2021" rust-version = "1.87.0"