From 331a869859c60ef3bae4349f35455b75b1899c09 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:35:57 +0200 Subject: [PATCH] Fix M3U alias credential rewriting on provider fallback (#852) * Fix M3U alias credential rewriting on provider fallback Rewrite opaque authentication query parameters when stream allocation switches from the primary M3U provider to an alias. Support unambiguous cross-key mappings such as token to api_key while preserving unrelated query parameters and rejecting ambiguous mappings. Add regression coverage for URL rewriting and provider allocation. --- backend/app/src/api/api_utils/mod.rs | 53 ++++--- backend/app/src/api/api_utils/tests.rs | 83 ++++++++++- .../src/processor/playlist/ingest.rs | 44 ++++++ .../src/processor/playlist/tests.rs | 133 +++++++++++++++++- backend/repository/src/m3u_repository.rs | 109 +++++++++++++- shared/src/utils/m3u_url.rs | 120 ++++++++++++++++ shared/src/utils/mod.rs | 2 + 7 files changed, 513 insertions(+), 31 deletions(-) create mode 100644 shared/src/utils/m3u_url.rs diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index 6d5251b5a..075691f4a 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -30,6 +30,7 @@ use crate::{ parser::hls::{rewrite_hls, RewriteHlsProps}, processor::re_resolve_stalker_url, }, + repository::load_input_m3u_stream_url, utils::{ async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request, request::{content_type_from_ext, parse_range, send_with_retry_and_provider}, @@ -57,8 +58,8 @@ use shared::{ }, utils::{ bin_serialize, current_time_secs, extract_extension_from_url, get_credentials_from_url, human_readable_kbps, - is_sanitize_sensitive_info_enabled, replace_url_extension, sanitize_sensitive_info, trim_slash, Internable, - CONTENT_TYPE_CBOR, CONTENT_TYPE_JSON, + is_account_query_key, is_sanitize_sensitive_info_enabled, replace_url_extension, sanitize_sensitive_info, + trim_slash, Internable, CONTENT_TYPE_CBOR, CONTENT_TYPE_JSON, }, }; use smallvec::SmallVec; @@ -769,24 +770,6 @@ fn get_stream_alternative_url_m3u( Some(stream_url.to_string()) } -fn is_account_query_key(key: &str) -> bool { - let key = key.to_ascii_lowercase(); - matches!( - key.as_str(), - "token" - | "access_token" - | "auth_token" - | "key" - | "apikey" - | "api_key" - | "accesskey" - | "access_key" - | "auth" - | "authorization" - ) || key.ends_with("_token") - || key.ends_with("_key") -} - fn find_input_account_by_query_signature<'a>(stream_url: &str, input: &'a ConfigInput) -> Option<&'a str> { if stream_url_account_query_matches(stream_url, &input.url) { return Some(&input.url); @@ -1101,15 +1084,33 @@ fn stream_url_has_account_signature(stream_url: &str, user_info: &crate::model:: false } -fn select_provider_stream_url( +async fn select_provider_stream_url( stream_url: &str, input: &ConfigInput, provider_cfg: &Arc, accept_requested_stream_url: bool, + app_config: &Arc, ) -> Option<(Arc, String)> { if accept_requested_stream_url { return Some((provider_cfg.name.clone(), stream_url.to_string())); } + + if provider_cfg.input_type.is_m3u() { + match load_input_m3u_stream_url(app_config, &provider_cfg.name, stream_url).await { + Ok(Some(provider_stream_url)) => { + return Some((provider_cfg.name.clone(), provider_stream_url.to_string())); + } + Ok(None) => {} + Err(err) => { + debug_if_enabled!( + "Failed to resolve M3U stream URL for provider {}: {}", + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info(&err.to_string()) + ); + } + } + } + if stream_url_matches_provider(stream_url, provider_cfg) { Some((provider_cfg.name.clone(), stream_url.to_string())) } else { @@ -1261,8 +1262,14 @@ async fn resolve_streaming_strategy( ProviderAllocation::Available(ref provider_cfg) | ProviderAllocation::GracePeriod(ref provider_cfg) => { // Keep the URL only when it already targets the selected provider account. Hot reload can leave old // alias URLs in persisted playlists until the next processing run. - if let Some((selected_provider_name, url)) = - select_provider_stream_url(stream_url, input, provider_cfg, accept_requested_stream_url) + if let Some((selected_provider_name, url)) = select_provider_stream_url( + stream_url, + input, + provider_cfg, + accept_requested_stream_url, + &app_state.app_config, + ) + .await { debug_if_enabled!( "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index eb09744eb..da245b276 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -521,8 +521,8 @@ fn get_stream_alternative_url_rejects_partial_source_credential_mapping() { ); } -#[test] -fn select_provider_stream_url_rewrites_opaque_m3u_token_for_allocated_alias() { +#[tokio::test] +async fn select_provider_stream_url_rewrites_opaque_m3u_token_for_allocated_alias() { let input = ConfigInput { name: "provider-a".intern(), url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(), @@ -546,12 +546,20 @@ fn select_provider_stream_url_rewrites_opaque_m3u_token_for_allocated_alias() { InputType::M3u, ); + let temp = tempfile::tempdir().expect("temp dir should be created"); + let app_config = create_test_dual_provider_app_config(); + let mut config = (*app_config.config.load_full()).clone(); + config.storage_dir = temp.path().to_string_lossy().into_owned(); + app_config.config.store(Arc::new(config)); + let app_config = Arc::new(app_config); let selected = select_provider_stream_url( "http://stream.example/channel/segment.ts?token=provider-a-token", &input, &alias, false, - ); + &app_config, + ) + .await; assert_eq!( selected, @@ -559,6 +567,75 @@ fn select_provider_stream_url_rewrites_opaque_m3u_token_for_allocated_alias() { ); } +#[tokio::test] +async fn select_provider_stream_url_uses_persisted_alias_url_for_independent_stream_token() { + use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader}; + use tuliprox_repository::{get_input_m3u_playlist_file_path, get_input_storage_path, persist_input_m3u_playlist}; + + let temp = tempfile::tempdir().expect("temp dir should be created"); + let app_config = create_test_dual_provider_app_config(); + let mut config = (*app_config.config.load_full()).clone(); + config.storage_dir = temp.path().to_string_lossy().into_owned(); + app_config.config.store(Arc::new(config)); + let app_config = Arc::new(app_config); + + let input = ConfigInput { + name: "primary-account".intern(), + url: "http://playlist.example/list.m3u?access_key=primary-playlist-key".to_string(), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias_input = ConfigInput { + name: "backup-account".intern(), + url: "http://playlist.example/list.m3u?access_key=backup-playlist-key".to_string(), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias = Arc::new(RuntimeProviderConfig::new( + &alias_input, + Arc::new(RwLock::new(ProviderConfigConnection::default())), + Arc::new(|_, _| {}), + )); + + let storage_path = get_input_storage_path(&alias.name, &app_config.config.load().storage_dir) + .await + .expect("alias storage should be created"); + let playlist_path = get_input_m3u_playlist_file_path(&storage_path, &alias.name); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + id: "channel-323".intern(), + input_stream_id: "channel-323".intern(), + url: "http://stream.example:4000/323/mono.m3u8?token=backup-stream-token".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..PlaylistItemHeader::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }]; + persist_input_m3u_playlist(&app_config, &playlist_path, &playlist).await.expect("alias playlist should persist"); + + let selected = select_provider_stream_url( + "http://stream.example:4000/323/mono.m3u8?token=primary-stream-token", + &input, + &alias, + false, + &app_config, + ) + .await; + + assert_eq!( + selected, + Some(( + "backup-account".intern(), + "http://stream.example:4000/323/mono.m3u8?token=backup-stream-token".to_string(), + )) + ); +} + #[test] fn get_stream_alternative_url_rewrites_timeshift_path_credentials_for_alias_account() { let input = ConfigInput { diff --git a/backend/processing/src/processor/playlist/ingest.rs b/backend/processing/src/processor/playlist/ingest.rs index a6c5ad9c7..93818b89b 100644 --- a/backend/processing/src/processor/playlist/ingest.rs +++ b/backend/processing/src/processor/playlist/ingest.rs @@ -642,6 +642,11 @@ pub(crate) async fn download_input( + ctx: &PlaylistProcessingContext, + input: &ConfigInput, +) -> Vec { + let Some(aliases) = input.get_enabled_aliases() else { return vec![] }; + let mut errors = Vec::new(); + + for alias in aliases { + if ctx.is_input_downloaded(&alias.name).await { + continue; + } + + let mut alias_input = input.as_input(alias); + // A user-provided raw-playlist persist path belongs to the primary input. Alias + // snapshots use their own internal storage so accounts never overwrite each other. + alias_input.persist = None; + alias_input.epg = None; + let alias_input = Arc::new(alias_input); + + let (mut alias_errors, mut alias_playlist, storage_error, partial) = + Box::pin(download_input(ctx, &alias_input, false)).await; + let alias_had_errors = !alias_errors.is_empty() || storage_error.is_some(); + errors.append(&mut alias_errors); + if let Some(storage_error) = storage_error { + errors.push(storage_error); + } + if partial { + errors.push(TuliproxError::RepositoryPlaylist(format!( + "M3U alias '{}' returned a partial playlist", + alias.name + ))); + } else if alias_playlist.is_empty() && !alias_had_errors { + errors.push(TuliproxError::RepositoryPlaylist(format!("M3U alias '{}' playlist is empty", alias.name))); + } + } + + errors +} + pub(crate) fn create_broadcast_callback(events: &E) -> StepMeasureCallback { let events = events.clone(); Box::new(move |context: &str, msg: &str| { diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index cbf9e2fbc..c5953f016 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -9,7 +9,7 @@ use shared::{ }, utils::Internable, }; -use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config}; +use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias}; fn serialize_without_trailing_fields(value: &T, trailing_fields: &[u8]) -> Vec { let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize"); @@ -896,6 +896,137 @@ mod mapping_stage { } } + #[tokio::test] + async fn m3u_alias_playlist_is_downloaded_and_indexed_separately() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let primary_playlist_path = temp.path().join("primary.m3u"); + let alias_playlist_path = temp.path().join("backup.m3u"); + tokio::fs::write( + &primary_playlist_path, + "#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=primary-stream-token\n", + ) + .await + .expect("primary fixture should be written"); + tokio::fs::write( + &alias_playlist_path, + "#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=backup-stream-token\n", + ) + .await + .expect("alias fixture should be written"); + + let ctx = processing_context(); + let config = + Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() }; + ctx.config.config.store(Arc::new(config)); + let input = Arc::new(ConfigInput { + id: 1, + name: "primary-account".intern(), + input_type: InputType::M3u, + url: primary_playlist_path.to_string_lossy().into_owned(), + enabled: true, + aliases: Some(vec![ConfigInputAlias { + id: 2, + name: "backup-account".intern(), + url: alias_playlist_path.to_string_lossy().into_owned(), + username: None, + password: None, + priority: 1, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }); + + let (errors, mut primary_playlist, storage_error, partial) = download_input(&ctx, &input, false).await; + + assert!(errors.is_empty(), "unexpected download errors: {errors:?}"); + assert!(storage_error.is_none(), "unexpected primary storage error: {storage_error:?}"); + assert!(!partial); + assert!(!primary_playlist.is_empty()); + let alias_url = tuliprox_repository::load_input_m3u_stream_url( + &ctx.config, + &"backup-account".intern(), + "http://stream.example:4000/323/mono.m3u8?token=primary-stream-token", + ) + .await + .expect("alias URL lookup should succeed"); + assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=backup-stream-token")); + } + + #[tokio::test] + async fn failed_m3u_alias_is_retried_after_primary_input_is_processed() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let primary_playlist_path = temp.path().join("main.m3u"); + let alias_playlist_path = temp.path().join("retry.m3u"); + tokio::fs::write( + &primary_playlist_path, + "#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=main-stream-token\n", + ) + .await + .expect("primary fixture should be written"); + + let ctx = processing_context(); + let config = + Config { storage_dir: temp.path().join("storage").to_string_lossy().into_owned(), ..Config::default() }; + ctx.config.config.store(Arc::new(config)); + let input = Arc::new(ConfigInput { + id: 1, + name: "main-account".intern(), + input_type: InputType::M3u, + url: primary_playlist_path.to_string_lossy().into_owned(), + enabled: true, + aliases: Some(vec![ConfigInputAlias { + id: 2, + name: "retry-account".intern(), + url: alias_playlist_path.to_string_lossy().into_owned(), + username: None, + password: None, + priority: 1, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }); + + let (first_errors, mut first_playlist, first_storage_error, first_partial) = + download_input(&ctx, &input, false).await; + + assert!(!first_errors.is_empty(), "missing alias should report an error"); + assert!(first_storage_error.is_none(), "primary storage should succeed: {first_storage_error:?}"); + assert!(!first_partial); + assert!(!first_playlist.is_empty()); + assert!(ctx.is_input_downloaded("main-account").await); + assert!(!ctx.is_input_downloaded("retry-account").await); + + tokio::fs::write( + &alias_playlist_path, + "#EXTM3U\n#EXTINF:-1 tvg-id=\"323\",Channel\nhttp://stream.example:4000/323/mono.m3u8?token=retry-stream-token\n", + ) + .await + .expect("alias fixture should be written"); + + let (second_errors, mut second_playlist, second_storage_error, second_partial) = + download_input(&ctx, &input, false).await; + + assert!(second_errors.is_empty(), "unexpected retry errors: {second_errors:?}"); + assert!(second_storage_error.is_none(), "primary storage should remain readable: {second_storage_error:?}"); + assert!(!second_partial); + assert!(!second_playlist.is_empty()); + assert!(ctx.is_input_downloaded("retry-account").await); + let alias_url = tuliprox_repository::load_input_m3u_stream_url( + &ctx.config, + &"retry-account".intern(), + "http://stream.example:4000/323/mono.m3u8?token=main-stream-token", + ) + .await + .expect("retried alias URL lookup should succeed"); + assert_eq!(alias_url.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=retry-stream-token")); + } + #[test] fn persist_filter_runs_after_after_epg_mapping() { let runtime = Runtime::new().expect("runtime"); diff --git a/backend/repository/src/m3u_repository.rs b/backend/repository/src/m3u_repository.rs index eed65702f..df4956aa6 100644 --- a/backend/repository/src/m3u_repository.rs +++ b/backend/repository/src/m3u_repository.rs @@ -5,7 +5,7 @@ use crate::{ m3u_playlist_iterator::M3uPlaylistM3uTextIterator, playlist_backend::{ensure_storage_path, iter_raw_playlist, M3u, PlaylistBackend}, playlist_repository::get_input_m3u_playlist_file_path, - storage::{get_input_storage_path, get_target_storage_path}, + storage::{build_input_storage_path, get_input_storage_path, get_target_storage_path}, storage_const, xtream_repository::CategoryKey, LockedReceiverStream, @@ -19,7 +19,7 @@ use shared::{ LiveStreamProperties, M3uPlaylistItem, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, XtreamCluster, }, - utils::PROVIDER_SCHEME_PREFIX, + utils::{m3u_stream_url_identity, PROVIDER_SCHEME_PREFIX}, }; use std::{ collections::HashMap, @@ -393,6 +393,7 @@ pub async fn persist_input_m3u_playlist( ) -> Result<(), TuliproxError> { let file_lock = app_config.file_locks.write_lock(m3u_path).await; let m3u_path_clone = m3u_path.to_path_buf(); + let stream_url_index_path = get_m3u_stream_url_index_file_path(m3u_path); let mut playlist_items: Vec = playlist.iter().flat_map(|pg| &pg.channels).map(M3uPlaylistItem::from).collect(); @@ -410,6 +411,28 @@ pub async fn persist_input_m3u_playlist( tree.insert(m3u.provider_id.clone(), m3u.clone()); } tree.store(&m3u_path_clone).map_err(|err| cant_write_result!(RepositoryM3u, "m3u", &m3u_path_clone, err))?; + + let mut indexed_urls: HashMap, Option>> = HashMap::new(); + for item in &playlist_items { + let Some(identity) = m3u_stream_url_identity(&item.url) else { continue }; + if let Some(indexed_url) = indexed_urls.get_mut(identity.as_str()) { + if indexed_url.as_ref().is_some_and(|url| url.as_ref() != item.url.as_ref()) { + *indexed_url = None; + } + } else { + indexed_urls.insert(identity.into(), Some(Arc::clone(&item.url))); + } + } + + let mut stream_url_index = BPlusTree::new(); + for (identity, url) in indexed_urls { + if let Some(url) = url { + stream_url_index.insert(identity, url); + } + } + stream_url_index + .store(&stream_url_index_path) + .map_err(|err| cant_write_result!(RepositoryM3u, "m3u stream URL index", &stream_url_index_path, err))?; Ok(()) }) .await @@ -420,6 +443,42 @@ pub async fn persist_input_m3u_playlist( Ok(()) } +pub fn get_m3u_stream_url_index_file_path(m3u_path: &Path) -> PathBuf { + let mut path = m3u_path.to_path_buf(); + path.set_extension("stream_urls.db"); + path +} + +pub async fn load_input_m3u_stream_url( + app_config: &Arc, + input_name: &Arc, + requested_stream_url: &str, +) -> Result>, TuliproxError> { + let Some(identity) = m3u_stream_url_identity(requested_stream_url) else { return Ok(None) }; + let cfg = app_config.config.load(); + let storage_path = build_input_storage_path(input_name, &cfg.storage_dir); + let m3u_path = get_input_m3u_playlist_file_path(&storage_path, input_name); + let index_path = get_m3u_stream_url_index_file_path(&m3u_path); + if !file_exists_async(&index_path).await { + return Ok(None); + } + + // The playlist writer publishes the playlist and its URL index while holding the + // playlist-path lock, so readers use the same lock as their generation boundary. + let file_lock = app_config.file_locks.read_lock(&m3u_path).await; + task::spawn_blocking(move || { + let _guard = file_lock; + let mut query = BPlusTreeQuery::, Arc>::try_new(&index_path).map_err(|err| { + TuliproxError::RepositoryM3u(format!("failed to open M3U stream URL index {}: {err}", index_path.display())) + })?; + query.query(&identity.into()).map_err(|err| { + TuliproxError::RepositoryM3u(format!("failed to read M3U stream URL index {}: {err}", index_path.display())) + }) + }) + .await + .map_err(|err| TuliproxError::RepositoryM3u(format!("failed to join M3U stream URL lookup task: {err}")))? +} + fn merge_preserved_m3u_live_metadata(m3u_path: &Path, playlist_items: &mut [M3uPlaylistItem]) -> Result<(), String> { if !m3u_path.exists() { return Ok(()); @@ -502,8 +561,8 @@ pub async fn load_input_m3u_playlist( #[cfg(test)] mod tests { - use super::{persist_input_m3u_playlist, replace_m3u_url_line}; - use crate::BPlusTreeQuery; + use super::{load_input_m3u_stream_url, persist_input_m3u_playlist, replace_m3u_url_line}; + use crate::{get_input_m3u_playlist_file_path, get_input_storage_path, BPlusTreeQuery}; use arc_swap::{ArcSwap, ArcSwapOption}; use shared::{ model::{ @@ -624,4 +683,46 @@ mod tests { assert_eq!(properties.last_success_timestamp, Some(90)); assert_eq!(properties.bitrate, 2_500_000); } + + #[tokio::test] + async fn persisted_stream_url_index_resolves_account_specific_alias_token() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let app_config = test_app_config(); + let config = Config { storage_dir: temp.path().to_string_lossy().into_owned(), ..Config::default() }; + app_config.config.store(Arc::new(config)); + + let alias_name = "backup-account".intern(); + let storage_path = get_input_storage_path(&alias_name, &app_config.config.load().storage_dir) + .await + .expect("alias storage should be created"); + let playlist_path = get_input_m3u_playlist_file_path(&storage_path, &alias_name); + let playlist = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + id: "channel-323".intern(), + input_stream_id: "channel-323".intern(), + url: "http://stream.example:4000/323/mono.m3u8?token=backup-stream-token".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..PlaylistItemHeader::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }]; + persist_input_m3u_playlist(&app_config, &playlist_path, &playlist) + .await + .expect("alias playlist should persist"); + + let resolved = load_input_m3u_stream_url( + &app_config, + &alias_name, + "http://stream.example:4000/323/mono.m3u8?token=primary-stream-token", + ) + .await + .expect("alias URL lookup should succeed"); + + assert_eq!(resolved.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?token=backup-stream-token")); + } } diff --git a/shared/src/utils/m3u_url.rs b/shared/src/utils/m3u_url.rs new file mode 100644 index 000000000..ea6c5884f --- /dev/null +++ b/shared/src/utils/m3u_url.rs @@ -0,0 +1,120 @@ +use url::Url; + +/// Returns whether a query key commonly carries account-specific credentials. +pub fn is_account_query_key(key: &str) -> bool { + const ACCOUNT_QUERY_KEYS: &[&str] = &[ + "token", + "access_token", + "auth_token", + "key", + "apikey", + "api_key", + "accesskey", + "access_key", + "auth", + "authorization", + "user", + "username", + "usr", + "login", + "pass", + "password", + "pwd", + "session", + "session_id", + "device_key", + "mac", + "sig", + "signature", + ]; + + let ends_with_ignore_ascii_case = |suffix: &str| { + key.get(key.len().saturating_sub(suffix.len())..).is_some_and(|tail| tail.eq_ignore_ascii_case(suffix)) + }; + + ACCOUNT_QUERY_KEYS.iter().any(|candidate| key.eq_ignore_ascii_case(candidate)) + || ends_with_ignore_ascii_case("_token") + || ends_with_ignore_ascii_case("_key") +} + +/// Builds the account-independent identity used to correlate equivalent M3U stream URLs. +/// +/// Account credentials are removed from the URL authority and query while all other URL +/// components remain part of the identity. Query pairs are sorted so provider accounts may +/// return the same channel parameters in a different order. +pub fn m3u_stream_url_identity(stream_url: &str) -> Option { + let mut url = Url::parse(stream_url).ok()?; + if url.cannot_be_a_base() || url.host_str().is_none() { + return None; + } + + url.set_username("").ok()?; + url.set_password(None).ok()?; + url.set_fragment(None); + + let mut query_pairs: Vec<_> = url + .query_pairs() + .filter(|(key, _)| !is_account_query_key(key)) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + query_pairs.sort_unstable(); + + url.set_query(None); + if !query_pairs.is_empty() { + url.query_pairs_mut().extend_pairs(query_pairs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + } + + Some(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_ignores_account_tokens_but_preserves_channel_parameters() { + let primary = m3u_stream_url_identity( + "http://stream.example:4000/323/mono.m3u8?token=primary-stream-token&quality=hd&lang=en", + ); + let alias = m3u_stream_url_identity( + "http://stream.example:4000/323/mono.m3u8?lang=en&access_key=alias-stream-token&quality=hd", + ); + + assert_eq!(primary, alias); + assert_eq!(primary.as_deref(), Some("http://stream.example:4000/323/mono.m3u8?lang=en&quality=hd")); + } + + #[test] + fn identity_keeps_authority_and_path_distinctions() { + let first = m3u_stream_url_identity("http://stream.example:4000/323/mono.m3u8?token=one"); + let other_port = m3u_stream_url_identity("http://stream.example:5000/323/mono.m3u8?token=two"); + let other_path = m3u_stream_url_identity("http://stream.example:4000/324/mono.m3u8?token=two"); + + assert_ne!(first, other_port); + assert_ne!(first, other_path); + } + + #[test] + fn account_query_key_matching_is_case_insensitive_and_utf8_safe() { + assert!(is_account_query_key("UserName")); + assert!(is_account_query_key("PASSWORD")); + assert!(is_account_query_key("provider_ToKeN")); + assert!(is_account_query_key("device_KEY")); + assert!(is_account_query_key("ä_key")); + assert!(!is_account_query_key("€AB")); + assert!(!is_account_query_key("monokey")); + } + + #[test] + fn identity_ignores_query_username_and_password() { + let primary = m3u_stream_url_identity( + "http://stream.example/323/mono.m3u8?username=primary-user&password=primary-pass&quality=hd", + ); + let alias = m3u_stream_url_identity( + "http://stream.example/323/mono.m3u8?PASSWORD=alias-pass&quality=hd&UserName=alias-user", + ); + + assert_eq!(primary, alias); + assert_eq!(primary.as_deref(), Some("http://stream.example/323/mono.m3u8?quality=hd")); + } +} diff --git a/shared/src/utils/mod.rs b/shared/src/utils/mod.rs index e6ec7db6a..94937ce6c 100644 --- a/shared/src/utils/mod.rs +++ b/shared/src/utils/mod.rs @@ -5,6 +5,7 @@ pub mod flags; mod hash_utils; mod hdhomerun_utils; mod json_utils; +mod m3u_url; mod net_utils; mod number_utils; mod recording_filename; @@ -25,6 +26,7 @@ pub use self::{ hash_utils::*, hdhomerun_utils::*, json_utils::*, + m3u_url::*, net_utils::*, number_utils::*, recording_filename::*,