diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index ae21af8ed..4a270c4a6 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -9,6 +9,7 @@ use crate::api::model::{ProviderAllocation, UserSession}; use crate::auth::Fingerprint; use crate::model::{ConfigInput, InputSource}; use crate::model::{ConfigTarget, ProxyUserCredentials}; +use crate::model::ConfigInputFlags; use crate::processing::parser::hls::{ get_hls_session_token_and_url_from_token, rewrite_hls, RewriteHlsProps, }; @@ -23,6 +24,7 @@ use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionP use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, Internable, CUSTOM_VIDEO_PREFIX, HLS_EXT}; use std::sync::Arc; use crate::utils::request::is_file_url; +use url::Url; const PLAYLIST_TEMPLATE: &str = r"#EXTM3U #EXT-X-VERSION:3 @@ -49,6 +51,31 @@ fn hls_response(hls_content: String) -> impl IntoResponse + Send { .body(hls_content)) } +fn normalize_xtream_live_hls_url(hls_url: &str, input: &ConfigInput) -> String { + if !input.input_type.is_xtream() || !input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix) { + return hls_url.to_string(); + } + + let (Some(username), Some(password)) = (input.username.as_deref(), input.password.as_deref()) else { + return hls_url.to_string(); + }; + + let Ok(mut parsed) = Url::parse(hls_url) else { + return hls_url.to_string(); + }; + let Some(segments) = parsed.path_segments() else { + return hls_url.to_string(); + }; + + let parts: Vec<&str> = segments.collect(); + if parts.len() >= 3 && parts[0] == username && parts[1] == password { + parsed.set_path(&format!("/live/{}", parts.join("/"))); + return parsed.to_string(); + } + + hls_url.to_string() +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub(in crate::api) async fn handle_hls_stream_request( fingerprint: &Fingerprint, @@ -65,7 +92,15 @@ pub(in crate::api) async fn handle_hls_stream_request( return axum::http::StatusCode::BAD_REQUEST.into_response(); } - let url = replace_url_extension(hls_url, HLS_EXT); + let normalized_hls_url = normalize_xtream_live_hls_url(hls_url, input); + if normalized_hls_url != hls_url { + debug_if_enabled!( + "Normalized xtream hls url from {} to {}", + sanitize_sensitive_info(hls_url), + sanitize_sensitive_info(&normalized_hls_url) + ); + } + let url = replace_url_extension(&normalized_hls_url, HLS_EXT); let server_info = app_state.app_config.get_user_server_info(user); let (request_url, session_token) = match user_session { diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 339d3f48d..1494e1adf 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -12,7 +12,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use url::Url; create_bitset!( @@ -87,6 +87,9 @@ impl ConfigInputOptions { pub fn has_all_flags(&self, flags: ConfigInputFlagsSet) -> bool { self.flags.contains_all(flags) } + + #[inline] + pub fn defaults() -> &'static Self { &DEFAULT_CONFIG_INPUT_OPTIONS } } impl From<&ConfigInputOptionsDto> for ConfigInputOptions { fn from(dto: &ConfigInputOptionsDto) -> Self { @@ -116,6 +119,9 @@ impl From<&ConfigInputOptionsDto> for ConfigInputOptions { } } +static DEFAULT_CONFIG_INPUT_OPTIONS: LazyLock = + LazyLock::new(|| ConfigInputOptions::from(&ConfigInputOptionsDto::default())); + pub struct InputUserInfo { pub base_url: String, pub username: String, @@ -236,35 +242,54 @@ pub struct ConfigInput { impl ConfigInput { #[inline] pub fn has_flag(&self, flag: ConfigInputFlags) -> bool { - self.options.as_ref().is_some_and(|o| o.has_flag(flag)) + self.has_flag_or(flag, false) } #[inline] + /// Returns `default` when `self.options` is `None`; unlike `has_flag`, which returns + /// `false` for missing options. For `ConfigInput::default()` without `prepare()`, use + /// this `_or` variant when an explicit fallback is required. pub fn has_flag_or(&self, flag: ConfigInputFlags, default: bool) -> bool { self.options.as_ref().map_or(default, |o| o.has_flag(flag)) } #[inline] pub fn has_any_flags(&self, flags: ConfigInputFlagsSet) -> bool { - self.options.as_ref().is_some_and(|o| o.has_any_flags(flags)) + self.has_any_flags_or(flags, false) } #[inline] + /// Returns `default` when `self.options` is `None`; unlike `has_any_flags`, which returns + /// `false` for missing options. For `ConfigInput::default()` without `prepare()`, use + /// this `_or` variant when an explicit fallback is required. pub fn has_any_flags_or(&self, flags: ConfigInputFlagsSet, default: bool) -> bool { self.options.as_ref().map_or(default, |o| o.has_any_flags(flags)) } #[inline] pub fn has_all_flags(&self, flags: ConfigInputFlagsSet) -> bool { - self.options.as_ref().is_some_and(|o| o.has_all_flags(flags)) + self.options + .as_ref() + .unwrap_or(ConfigInputOptions::defaults()) + .has_all_flags(flags) } #[inline] + /// Returns `default` when `self.options` is `None`; unlike `has_all_flags`, which uses + /// `ConfigInputOptions::defaults()`. For `ConfigInput::default()` without `prepare()`, + /// prefer this `_or` variant when an explicit fallback is required. pub fn has_all_flags_or(&self, flags: ConfigInputFlagsSet, default: bool) -> bool { self.options.as_ref().map_or(default, |o| o.has_all_flags(flags)) } pub fn prepare(&mut self, provider_configs: &[Arc]) -> Result, TuliproxError> { + // Defensive fallback: From<&ConfigInputDto> for ConfigInput sets options, but ConfigInput can + // still be built via Default::default(), batch/internal/test paths, so prepare() normalizes + // missing options with ConfigInputOptions::defaults(). + if self.options.is_none() { + self.options = Some(ConfigInputOptions::defaults().clone()); + } + let mut used_provider_configs: Vec> = vec![]; let batch_file_path = self.prepare_batch(); self.name = self.name.trim().intern(); @@ -487,6 +512,11 @@ impl ConfigInput { macros::from_impl!(ConfigInput); impl From<&ConfigInputDto> for ConfigInput { fn from(dto: &ConfigInputDto) -> Self { + let options = dto + .options + .as_ref() + .map_or_else(|| ConfigInputOptions::defaults().clone(), ConfigInputOptions::from); + Self { id: dto.id, name: dto.name.clone(), @@ -498,7 +528,7 @@ impl From<&ConfigInputDto> for ConfigInput { password: dto.password.clone(), persist: dto.persist.clone(), enabled: dto.enabled, - options: dto.options.as_ref().map(ConfigInputOptions::from), + options: Some(options), aliases: dto.aliases.as_ref().map(|list| list.iter().map(ConfigInputAlias::from).collect()), priority: dto.priority, max_connections: dto.max_connections, diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index bb047b669..e57e74dcd 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -244,15 +244,8 @@ where input.username.as_deref().unwrap_or("").to_string(), input.password.as_deref().unwrap_or("").to_string(), ); - let (live_stream_use_prefix, live_stream_without_extension) = input.options.as_ref().map_or( - (false, false), - |o| { - ( - o.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix), - o.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension), - ) - }, - ); + let live_stream_use_prefix = input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix); + let live_stream_without_extension = input.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension); // Map categories for lookup let group_map: IndexMap> = xtream_categories.iter().map(|c| (c.category_id, c.category_name.clone())).collect(); diff --git a/frontend/src/app/components/particle_flow_background.rs b/frontend/src/app/components/particle_flow_background.rs index d25428d8a..30d069a04 100644 --- a/frontend/src/app/components/particle_flow_background.rs +++ b/frontend/src/app/components/particle_flow_background.rs @@ -611,7 +611,7 @@ pub fn ParticleFlowBackground() -> Html { })); let theme_observer = win.document().and_then(|doc| { - MutationObserver::new(on_theme_change.as_ref().unchecked_ref()).ok().and_then(|observer| { + MutationObserver::new(on_theme_change.as_ref().unchecked_ref()).ok().inspect(|observer| { let options = MutationObserverInit::new(); options.set_attributes(true); let attribute_filter = Array::new(); @@ -625,7 +625,6 @@ pub fn ParticleFlowBackground() -> Html { if let Some(body) = doc.body() { let _ = observer.observe_with_options(&body, &options); } - Some(observer) }) });