From f0267e21c5d4bec39ccd457e0f40f87711b586e2 Mon Sep 17 00:00:00 2001 From: euzu Date: Sun, 20 Apr 2025 10:17:49 +0200 Subject: [PATCH] Redirect Proxy cycle provider for m3u playlist --- CHANGELOG.md | 1 + README.md | 5 +-- src/api/api_utils.rs | 39 +++++++++++++++++++----- src/api/model/active_provider_manager.rs | 2 ++ src/model/config.rs | 29 ++++++++++++++++-- src/repository/m3u_playlist_iterator.rs | 1 - src/utils/file/config_reader.rs | 1 + src/utils/network/request.rs | 1 - 8 files changed, 65 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cf491c0..79d8f1e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ url: ['http://localhost:3001/xmltv.php?epg_id=1', 'http://localhost:3001/xmltv.p - Added `estrip` to input for auto epg matching, if not given `["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw"]` is default When no matching epg_id is found, the display name is used to match a channel name. The given strings are stripped to get a better match. - Fixed chno assignment issue +- Redirect Proxy provider cycle implemented (m3u playlist only cycles when output param `mask_redirect_url` is set). # 2.2.5 (2025-03-27) - fixed web ui playlist regexp search diff --git a/README.md b/README.md index eaf80548b..049fd1056 100644 --- a/README.md +++ b/README.md @@ -699,8 +699,9 @@ Target options are: If `kodi_style` set to `true` the property `#KODIPROP:seekable=true|false` is added. If `strm_props` is not given `#KODIPROP:inputstream=inputstream.ffmpeg`, `"#KODIPROP:http-reconnect=true` are set too for `kody_style`. `m3u` output has additional options -- `m3u_include_type_in_url`, default false, if true adds the stream type `live`, `movie`, `series` to the url of the stream. -- `m3u_mask_redirect_url`, default false, if true uses urls from `api_proxy.yml` for user in proxy mode `redirect`. +- `include_type_in_url`, default false, if true adds the stream type `live`, `movie`, `series` to the url of the stream. +- `mask_redirect_url`, default false, if true uses urls from `api_proxy.yml` for user in proxy mode `redirect`. +Needs to be set `true` if you have multiple provider and want to cycle in redirect mode. `xtream` output has additional options - `skip_live_direct_source` if true the direct_source property from provider for live is ignored diff --git a/src/api/api_utils.rs b/src/api/api_utils.rs index 08b20e126..7df728f92 100644 --- a/src/api/api_utils.rs +++ b/src/api/api_utils.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use crate::api::model::active_provider_manager::{ProviderAllocation, ProviderConfig, ProviderConnectionGuard}; use crate::api::model::app_state::AppState; use crate::api::model::model_utils::get_stream_response_with_headers; @@ -9,6 +10,12 @@ use crate::api::model::streams::provider_stream; use crate::api::model::streams::provider_stream::{create_custom_video_stream_response, create_provider_connections_exhausted_stream, CustomVideoStreamType}; use crate::api::model::streams::provider_stream_factory::BufferStreamOptions; use crate::api::model::streams::shared_stream_manager::SharedStreamManager; +use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, XtreamApiStreamContext}; +use crate::api::model::stream::{BoxedProviderStream, ProviderStreamInfo, ProviderStreamResponse}; +use crate::api::model::streams::throttled_stream::ThrottledStream; +use crate::tools::atomic_once_flag::AtomicOnceFlag; +use crate::utils::constants::{DASH_EXT, HLS_EXT}; +use crate::utils::default_utils::default_grace_period_millis; use crate::auth::authenticator::Claims; use crate::model::api_proxy::{ProxyType, ProxyUserCredentials, UserConnectionPermission}; use crate::model::config::{ConfigInput, ConfigTarget, InputFetchMethod, TargetType}; @@ -71,12 +78,6 @@ macro_rules! try_result_bad_request { pub use try_option_bad_request; pub use try_result_bad_request; -use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, XtreamApiStreamContext}; -use crate::api::model::stream::{BoxedProviderStream, ProviderStreamInfo, ProviderStreamResponse}; -use crate::api::model::streams::throttled_stream::ThrottledStream; -use crate::tools::atomic_once_flag::AtomicOnceFlag; -use crate::utils::constants::{DASH_EXT, HLS_EXT}; -use crate::utils::default_utils::default_grace_period_millis; #[allow(clippy::missing_panics_doc)] pub async fn serve_file(file_path: &Path, mime_type: mime::Mime) -> impl axum::response::IntoResponse + Send { @@ -171,6 +172,25 @@ fn get_stream_alternative_url(stream_url: &str, input: &ConfigInput, alias_input modified.replace(&input_user_info.password, &alt_input_user_info.password) } +async fn get_redirect_alternative_url<'a>(app_state: &AppState, redirect_url: &'a str, input: &ConfigInput) -> Cow<'a, str> { + if let Some((base_url, username, password)) = input.get_matched_config_by_url(redirect_url) { + if let Some(provider_cfg) = app_state.active_provider.get_next_provider(&input.name).await { + let mut new_url = redirect_url.replacen(base_url, provider_cfg.url.as_str(), 1); + if let (Some(old_username), Some(old_password)) = (username, password) { + if let (Some(new_username), Some(new_password)) = (provider_cfg.username.as_ref(), provider_cfg.password.as_ref()) { + new_url = new_url.replacen(old_username, new_username, 1); + new_url = new_url.replacen(old_password, new_password, 1); + return Cow::Owned(new_url); + } + // one has credentials the other not, something not right + return Cow::Borrowed(redirect_url); + } + return Cow::Owned(new_url); + } + } + Cow::Borrowed(redirect_url) +} + type StreamUrl = String; type ProviderName = String; @@ -365,8 +385,11 @@ where if redirect_request || is_dash_request { let redirect_url = if is_hls_request { &replace_url_extension(provider_url, HLS_EXT) } else { provider_url }; let redirect_url = if is_dash_request { &replace_url_extension(redirect_url, DASH_EXT) } else { redirect_url }; - // TODO alias processing, redirect to different aliases - debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(redirect_url)); + if let Some(input) = params.input { + let redirect_url = get_redirect_alternative_url(app_state, redirect_url, input).await; + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(&redirect_url)); + return Some(redirect(&redirect_url).into_response()); + } return Some(redirect(redirect_url.as_str()).into_response()); } } else if params.target_type == TargetType::Xtream { diff --git a/src/api/model/active_provider_manager.rs b/src/api/model/active_provider_manager.rs index 383a36e43..7eea896e3 100644 --- a/src/api/model/active_provider_manager.rs +++ b/src/api/model/active_provider_manager.rs @@ -716,6 +716,7 @@ mod tests { headers: Default::default(), options: None, method: InputFetchMethod::default(), + t_base_url: String::default(), } } @@ -729,6 +730,7 @@ mod tests { password: Some("alias_pass".to_string()), priority, max_connections, + t_base_url: String::default(), } } diff --git a/src/model/config.rs b/src/model/config.rs index 313ec7b49..8143afa79 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -826,6 +826,8 @@ pub struct ConfigInputAlias { pub priority: i16, #[serde(default)] pub max_connections: u16, + #[serde(skip)] + pub t_base_url: String, } @@ -840,6 +842,9 @@ impl ConfigInputAlias { if self.url.is_empty() { return Err(info_err!("url for input is mandatory".to_string())); } + if let Some(base_url) = get_base_url_from_str(&self.url) { + self.t_base_url = base_url; + } self.username = get_trimmed_string(&self.username); self.password = get_trimmed_string(&self.password); check_input_credentials!(self, input_type); @@ -1038,6 +1043,8 @@ pub struct ConfigInput { pub max_connections: u16, #[serde(default)] pub method: InputFetchMethod, + #[serde(skip)] + pub t_base_url: String, } impl ConfigInput { @@ -1056,6 +1063,9 @@ impl ConfigInput { self.password = get_trimmed_string(&self.password); check_input_credentials!(self, self.input_type); self.persist = get_trimmed_string(&self.persist); + if let Some(base_url) = get_base_url_from_str(&self.url) { + self.t_base_url = base_url; + } if let Some(epg) = self.epg.as_mut() { let _ = epg.prepare(include_computed); @@ -1068,8 +1078,8 @@ impl ConfigInput { if username.is_none() || password.is_none() { warn!("auto_epg is enabled for input {}, but no credentials could be extracted", self.name); - } else if let Some(base_url) = get_base_url_from_str(&self.url) { - let provider_epg_url = format!("{base_url}/xmltv.php?username={}&password={}", username.unwrap_or_default(), password.unwrap_or_default()); + } else if !self.t_base_url.is_empty() { + let provider_epg_url = format!("{}/xmltv.php?username={}&password={}", self.t_base_url, username.unwrap_or_default(), password.unwrap_or_default()); if !epg.t_urls.contains(&provider_epg_url) { debug!("Added provider epg url {provider_epg_url} for input {}", self.name); epg.t_urls.push(provider_epg_url); @@ -1137,6 +1147,21 @@ impl ConfigInput { pub fn get_user_info(&self) -> Option { InputUserInfo::new(self.input_type, self.username.as_deref(), self.password.as_deref(), &self.url) } + + pub fn get_matched_config_by_url<'a>(&'a self, url: &str) -> Option<(&'a str, Option<&'a String>, Option<&'a String>)> { + if url.contains(&self.t_base_url) { + return Some((&self.t_base_url, self.username.as_ref(), self.password.as_ref())); + } + + if let Some(aliases) = &self.aliases { + for alias in aliases { + if url.contains(&alias.t_base_url) { + return Some((&alias.t_base_url, alias.username.as_ref(), alias.password.as_ref())); + } + } + } + None + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] diff --git a/src/repository/m3u_playlist_iterator.rs b/src/repository/m3u_playlist_iterator.rs index 607aeb233..3a0aa6e30 100644 --- a/src/repository/m3u_playlist_iterator.rs +++ b/src/repository/m3u_playlist_iterator.rs @@ -43,7 +43,6 @@ impl M3uPlaylistIterator { .map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}")))?; let filter = user_get_bouquet_filter(cfg, &user.username, None, TargetType::M3u, XtreamCluster::Live).await; - // TODO m3u bouquet filter let server_info = cfg.get_user_server_info(user).await; Ok(Self { diff --git a/src/utils/file/config_reader.rs b/src/utils/file/config_reader.rs index bd2d03c20..103a33522 100644 --- a/src/utils/file/config_reader.rs +++ b/src/utils/file/config_reader.rs @@ -303,6 +303,7 @@ pub fn csv_read_inputs_from_reader(batch_input_type: InputType, reader: impl Buf password: None, priority: 0, max_connections: 1, + t_base_url: String::default(), }; let columns: Vec<&str> = line.split(CSV_SEPARATOR).collect(); diff --git a/src/utils/network/request.rs b/src/utils/network/request.rs index 2f31d77bd..f4d9ff99e 100644 --- a/src/utils/network/request.rs +++ b/src/utils/network/request.rs @@ -496,7 +496,6 @@ pub fn get_base_url_from_str(url: &str) -> Option { } } - #[cfg(test)] mod tests { use crate::utils::network::request::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info};