From c1f0063e0720d4ea97135c0c78c15390f6bd5c88 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:55:51 +0200 Subject: [PATCH] Feature/show local time (#773) * Fixed sanitizing url scheme batch and provder * local date in Stream history * Fixed EPG programme time display to render in local timezone for improved readability. * Improved stream history date range filtering to accurately use local date boundaries. * Enhanced credential sanitization for stream URLs to properly mask sensitive information. * Fix EPG path selection for mixed Xtream/M3U targets * Fix EPG selection and complete Xtream series_info for input/custom requests * Add configurable interner GC tuning --- .gitignore | 2 + CHANGELOG.md | 9 + backend/src/api/endpoints/v1_api_playlist.rs | 76 ++++++++- backend/src/api/endpoints/xmltv_api.rs | 154 +++++++++++++++--- backend/src/api/endpoints/xtream_api.rs | 7 +- backend/src/api/scheduler.rs | 18 +- backend/src/model/config/base.rs | 4 + backend/src/runtime_config_report.rs | 2 + backend/src/utils/network/request.rs | 54 +++--- config/config.yml | 2 + docs/src/configuration/config.md | 4 + frontend/public/assets/i18n/en.json | 4 + .../app/components/config/main_config_view.rs | 8 + .../dashboard/stream_display/item.rs | 26 ++- .../dashboard/stream_history_view.rs | 31 ++-- frontend/src/utils/format.rs | 154 +++++++++++++++++- shared/src/model/config/base.rs | 53 +++++- shared/src/utils/constants.rs | 5 +- shared/src/utils/default_utils.rs | 12 +- shared/src/utils/request.rs | 21 ++- 20 files changed, 541 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 093b7c07b..b6480f0b2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,7 @@ backend/Cargo.lock # Ignore ai documentation files .c[lor][adu]* +/.dual-graph* +/graphify* .dual-graph/ .dual-graph-context/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a168d67..d7eb6f4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,6 +301,12 @@ ## 🐛 Fixes +- **EPG Output Selection In Mixed Targets**: Fixed ambiguous EPG file selection when a target exposes both Xtream and M3U outputs. + - Web UI playlist EPG and stream EPG APIs now explicitly prefer M3U EPG data and fall back to Xtream when M3U EPG is unavailable. + - Xtream short-EPG now explicitly resolves Xtream EPG data. +- **Playlist Series Info For Input/Custom Xtream**: Completed `series_info` handling for input-based and custom Xtream playlist requests. + - Input and custom Xtream requests now resolve series details via provider `series_id` instead of returning empty (`204`) responses. + - Target-based `series_info` behavior is unchanged. - **Async Local File Serving**: The local-file stream handler now canonicalizes paths with `tokio::fs::canonicalize` instead of the blocking `std` call, so the async runtime is no longer blocked while resolving the file path. - **Template Expansion Efficiency**: Optimized `template.yml` / `template.d` multi-template expansion so sequence-style templates no longer @@ -321,6 +327,9 @@ ## ⚙️ New Settings +- **config.yml (main)**: + - Added `interner_gc_interval_secs`: interval in seconds between background string interner GC checks. + - Added `interner_gc_min_pool_size`: minimum interned-string pool size required before background interner GC runs. - **config.yml (`reverse_proxy.stream`)**: - Added `admission_strategies` (optional list): ordered list of admission strategy rules. Available strategies: `evict_user_same_ip_oldest`, `evict_user_same_ip_latest`, `evict_user_oldest`, `evict_user_latest`, diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index c475af4db..7d1682f48 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -1,6 +1,6 @@ use crate::api::api_utils::resource_response; use crate::{api::{ - api_utils::{create_api_proxy_user, json_or_bin_response, try_option_bad_request, try_result_bad_request}, + api_utils::{create_api_proxy_user, json_or_bin_response, try_option_bad_request, try_result_bad_request, try_unwrap_body}, endpoints::{ api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}, extract_accept_header::ExtractAcceptHeader, @@ -9,7 +9,7 @@ use crate::{api::{ xtream_api::{xtream_get_stream_info_response, xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest}, }, model::AppState, -}, auth::{create_access_token, permission_layer, verify_access_token}, model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions}, processing::parser::xmltv::merge_epg_channels_by_priority, repository::{m3u_get_item_for_stream_id, xtream_get_item_for_stream_id}, utils::{epg::get_input_raw_epg_file_path, file_exists_async}}; +}, auth::{create_access_token, permission_layer, verify_access_token}, model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions, InputSource}, processing::parser::xmltv::merge_epg_channels_by_priority, repository::{m3u_get_item_for_stream_id, xtream_get_item_for_stream_id}, utils::{epg::get_input_raw_epg_file_path, file_exists_async, xtream}}; use axum::{response::IntoResponse, Router}; use log::{debug, error}; use serde_json::json; @@ -314,10 +314,12 @@ create_player_api_for_cluster!(playlist_content_vod, XtreamCluster::Video); create_player_api_for_cluster!(playlist_content_series, XtreamCluster::Series); async fn playlist_series_info( - axum::extract::Path((virtual_id, _provider_id)): axum::extract::Path<(String, String)>, + axum::extract::Path((virtual_id, provider_id)): axum::extract::Path<(String, String)>, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_req): axum::extract::Json, ) -> impl IntoResponse + Send { + let provider_id = provider_id.trim().parse::().ok(); + match playlist_req { PlaylistRequest::Target(target_id) => { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { @@ -340,14 +342,58 @@ async fn playlist_series_info( PlaylistRequest::Input(input_name) => { if let Some(input) = app_state.app_config.get_input_by_name(&input_name.intern()) { if matches!(input.input_type, InputType::Xtream | InputType::XtreamBatch) { - // TODO: Implement series info retrieval for input-based requests - debug!("TODO: Implement series info retrieval for input-based requests"); + // We cannot call `xtream_get_stream_info_response` directly here because that path + // depends on target-local virtual-id mapping (`xtream_get_item_for_stream_id`). + // Input/custom requests only provide provider_id, so we resolve series info from + // the upstream Xtream API using provider_id. + if let Some(provider_id) = provider_id { + if let Some(info_url) = xtream::get_xtream_player_api_info_url( + input.as_ref(), + XtreamCluster::Series, + provider_id, + ) { + let Ok(resolved_url) = input.resolve_url(&info_url) else { + return axum::http::StatusCode::NO_CONTENT.into_response(); + }; + let input_source = InputSource::from(input.as_ref()).with_url(resolved_url.to_string()); + if let Ok(content) = xtream::get_xtream_stream_info_content( + &app_state.app_config, + &app_state.http_client.load(), + &input_source, + false, + ) + .await + { + return try_unwrap_body!(axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .body(axum::body::Body::from(content))); + } + } + } } } } - PlaylistRequest::CustomXtream(_xtream) => { - // TODO: Implement series info retrieval for custom Xtream requests - debug!("TODO: Implement series info retrieval for custom Xtream requests"); + PlaylistRequest::CustomXtream(xtream_req) => { + if let Some(provider_id) = provider_id { + let input = create_config_input_for_xtream(&xtream_req.username, &xtream_req.password, &xtream_req.url); + if let Some(info_url) = xtream::get_xtream_player_api_info_url(&input, XtreamCluster::Series, provider_id) { + let input_source = InputSource::from(&input).with_url(info_url); + if let Ok(content) = xtream::get_xtream_stream_info_content( + &app_state.app_config, + &app_state.http_client.load(), + &input_source, + false, + ) + .await + { + return try_unwrap_body!(axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .body(axum::body::Body::from(content))); + } + } + } } PlaylistRequest::CustomM3u(_) => {} } @@ -433,7 +479,19 @@ async fn playlist_epg( PlaylistEpgRequest::Target(target_id) => { if let Some(target) = app_state.app_config.get_target_by_id(target_id) { let config = &app_state.app_config.config.load(); - if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) { + let epg_path = crate::api::endpoints::xmltv_api::get_epg_path_for_target_by_type( + config, + &target, + TargetType::Xtream, + ) + .or_else(|| { + crate::api::endpoints::xmltv_api::get_epg_path_for_target_by_type( + config, + &target, + TargetType::M3u, + ) + }); + if let Some(epg_path) = epg_path { return serve_epg_web_ui(&app_state, accept.as_deref(), &epg_path, &target).await; } } diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index c1ee4952a..29524e1bb 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -9,7 +9,7 @@ use crate::{ model::{AppState, UserApiRequest, UserApiRequestQueryOrBody}, }, auth::Fingerprint, - model::{Config, ConfigTarget, ProxyUserCredentials, TargetOutput, EPG_ATTRIB_ID, EPG_TAG_CHANNEL}, + model::{Config, ConfigTarget, ProxyUserCredentials, EPG_ATTRIB_ID, EPG_TAG_CHANNEL}, repository::{ epg_query_channels, get_target_storage_path, m3u_get_epg_file_path_for_target, storage_const, xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTreeQuery, LockedReceiverStream, XML_PREAMBLE, @@ -28,7 +28,7 @@ use shared::{ concat_string, model::{ EpgChannel, EpgProgramme, EpgProgrammeDto, ShortEpgDto, ShortEpgResultDto, StreamEpgEntry, StreamEpgItemRequest, - StreamEpgRequest, StreamEpgResponse, + StreamEpgRequest, StreamEpgResponse, TargetType, }, utils::{concat_path, concat_path_leading_slash, obfuscate_text, Internable}, }; @@ -64,29 +64,41 @@ pub(in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigTa // TODO if we share the same virtual_id for epg, can we store an epg file for the target ? for output in &target.output { - match output { - TargetOutput::Xtream(_) => { - if let Some(storage_path) = xtream_get_storage_path(config, &target.name) { - return get_epg_path_for_target_of_type( - &target.name, - xtream_get_epg_file_path_for_target(&storage_path), - ); - } - } - TargetOutput::M3u(_) => { - if let Some(target_path) = get_target_storage_path(config, &target.name) { - return get_epg_path_for_target_of_type( - &target.name, - m3u_get_epg_file_path_for_target(&target_path), - ); - } - } - TargetOutput::Strm(_) | TargetOutput::HdHomeRun(_) => {} + if let Some(epg_path) = get_epg_path_for_target_by_type(config, target, TargetType::from(output)) { + return Some(epg_path); } } None } +pub(in crate::api) fn get_epg_path_for_target_by_type( + config: &Config, + target: &ConfigTarget, + output_type: TargetType, +) -> Option { + match output_type { + TargetType::Xtream => { + if let Some(storage_path) = xtream_get_storage_path(config, &target.name) { + return get_epg_path_for_target_of_type( + &target.name, + xtream_get_epg_file_path_for_target(&storage_path), + ); + } + None + } + TargetType::M3u => { + if let Some(target_path) = get_target_storage_path(config, &target.name) { + return get_epg_path_for_target_of_type( + &target.name, + m3u_get_epg_file_path_for_target(&target_path), + ); + } + None + } + TargetType::Strm | TargetType::HdHomeRun => None, + } +} + pub async fn serve_epg( app_state: &Arc, epg_path: &Path, @@ -662,7 +674,9 @@ pub(crate) async fn stream_epg_api( return stream_epg_bad_request(&format!("unknown target_id: {target_id}")); }; - let Some(epg_path) = get_epg_path_for_target(config.as_ref(), &target) else { + let epg_path = get_epg_path_for_target_by_type(config.as_ref(), &target, TargetType::M3u) + .or_else(|| get_epg_path_for_target_by_type(config.as_ref(), &target, TargetType::Xtream)); + let Some(epg_path) = epg_path else { entries.extend(empty_stream_epg_entries(target_id, &items)); continue; }; @@ -774,12 +788,106 @@ pub fn xmltv_api_register() -> axum::Router> { #[cfg(test)] mod tests { - use super::{group_stream_epg_items, rewrite_epg_channel_resource_url, stream_epg_programmes_for_channel, stream_epg_reference_map}; + use super::{ + get_epg_path_for_target, + get_epg_path_for_target_by_type, + group_stream_epg_items, + rewrite_epg_channel_resource_url, + stream_epg_programmes_for_channel, + stream_epg_reference_map, + }; + use crate::model::{Config, ConfigTarget, M3uTargetOutput, TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput}; use crate::utils::{EpgProcessingOptions, EpgTimeShift}; + use arc_swap::ArcSwapOption; use shared::{ - model::{EpgChannel, EpgProgramme, StreamEpgItemRequest}, + foundation::Filter, + model::{EpgChannel, EpgProgramme, ProcessingOrder, StreamEpgItemRequest, TargetType}, utils::{concat_path, obfuscate_text, Internable}, }; + use std::fs; + use std::sync::Arc; + use tempfile::tempdir; + + fn test_target_with_xtream_and_m3u() -> ConfigTarget { + ConfigTarget { + id: 1, + enabled: true, + name: "mixed-target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![ + TargetOutput::Xtream(XtreamTargetOutput { + flags: XtreamTargetFlagsSet::new(), + trakt: None, + filter: None, + }), + TargetOutput::M3u(M3uTargetOutput { + filename: None, + include_type_in_url: false, + mask_redirect_url: false, + filter: None, + }), + ], + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::new(None)), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + } + } + + fn test_config_with_storage(storage_dir: &str) -> Config { + Config { + storage_dir: storage_dir.to_string(), + ..Default::default() + } + } + + #[test] + fn get_epg_path_for_target_by_type_prefers_requested_output() { + let dir = tempdir().expect("temp dir"); + let config = test_config_with_storage(dir.path().to_string_lossy().as_ref()); + let target = test_target_with_xtream_and_m3u(); + + let xtream_storage = crate::repository::xtream_get_storage_path(&config, &target.name).expect("xtream storage"); + let m3u_storage = crate::repository::get_target_storage_path(&config, &target.name).expect("m3u storage"); + let xtream_epg = crate::repository::xtream_get_epg_file_path_for_target(&xtream_storage); + let m3u_epg = crate::repository::m3u_get_epg_file_path_for_target(&m3u_storage); + + fs::create_dir_all(xtream_storage).expect("create xtream dir"); + fs::create_dir_all(m3u_epg.parent().expect("m3u epg parent")).expect("create m3u dir"); + fs::write(&xtream_epg, b"xtream").expect("write xtream epg"); + fs::write(&m3u_epg, b"m3u").expect("write m3u epg"); + + let picked_xtream = get_epg_path_for_target_by_type(&config, &target, TargetType::Xtream).expect("xtream path"); + let picked_m3u = get_epg_path_for_target_by_type(&config, &target, TargetType::M3u).expect("m3u path"); + + assert_eq!(picked_xtream, xtream_epg); + assert_eq!(picked_m3u, m3u_epg); + } + + #[test] + fn get_epg_path_for_target_keeps_output_order_fallback() { + let dir = tempdir().expect("temp dir"); + let config = test_config_with_storage(dir.path().to_string_lossy().as_ref()); + let target = test_target_with_xtream_and_m3u(); + + let xtream_storage = crate::repository::xtream_get_storage_path(&config, &target.name).expect("xtream storage"); + let m3u_storage = crate::repository::get_target_storage_path(&config, &target.name).expect("m3u storage"); + let xtream_epg = crate::repository::xtream_get_epg_file_path_for_target(&xtream_storage); + let m3u_epg = crate::repository::m3u_get_epg_file_path_for_target(&m3u_storage); + + fs::create_dir_all(xtream_storage).expect("create xtream dir"); + fs::create_dir_all(m3u_epg.parent().expect("m3u epg parent")).expect("create m3u dir"); + fs::write(&xtream_epg, b"xtream").expect("write xtream epg"); + fs::write(&m3u_epg, b"m3u").expect("write m3u epg"); + + let picked = get_epg_path_for_target(&config, &target).expect("fallback path"); + assert_eq!(picked, xtream_epg); + } #[test] fn test_group_stream_epg_items_rejects_empty_requests() { diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index bb92e23a3..d13e68bdd 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -17,7 +17,7 @@ use crate::{ }, endpoints::{ hls_api::handle_hls_stream_request, - xmltv_api::{get_empty_epg_response, get_epg_path_for_target, serve_short_epg}, + xmltv_api::{get_empty_epg_response, get_epg_path_for_target_by_type, serve_short_epg}, }, model::{ create_custom_video_stream_response, AppState, CustomVideoStreamType, UserApiRequest, @@ -1082,7 +1082,10 @@ async fn xtream_get_short_epg( if let Ok(pli) = xtream_get_item_for_stream_id(virtual_id, app_state, target, None).await { let config = &app_state.app_config.config.load(); - if let (Some(epg_path), Some(channel_id)) = (get_epg_path_for_target(config, target), &pli.epg_channel_id) { + if let (Some(epg_path), Some(channel_id)) = ( + get_epg_path_for_target_by_type(config, target, TargetType::Xtream), + &pli.epg_channel_id, + ) { if file_exists_async(&epg_path).await { return serve_short_epg( app_state, diff --git a/backend/src/api/scheduler.rs b/backend/src/api/scheduler.rs index b0050397f..b17c69f8d 100644 --- a/backend/src/api/scheduler.rs +++ b/backend/src/api/scheduler.rs @@ -294,23 +294,23 @@ pub fn get_process_targets( Arc::clone(process_targets) } -// TODO Consider making the GC interval configurable. -// The 180-second interval is hardcoded. For deployments with different memory/performance characteristics, a configurable interval might be useful. - -/// Minimum number of interned strings required before GC runs. -/// Below this threshold the write-lock overhead outweighs the benefit. -const INTERNER_GC_MIN_POOL_SIZE: usize = 100; - pub fn exec_interner_prune(app_state: &Arc) { let app_state = Arc::clone(app_state); tokio::spawn({ async move { loop { - tokio::time::sleep(Duration::from_secs(180)).await; + let (interval_secs, min_pool_size) = { + let config = app_state.app_config.config.load(); + ( + u64::from(config.interner_gc_interval_secs), + usize::try_from(config.interner_gc_min_pool_size).unwrap_or(usize::MAX), + ) + }; + tokio::time::sleep(Duration::from_secs(interval_secs)).await; // Skip GC entirely when the pool is too small — acquiring a write // lock on the global interner briefly blocks all concurrent interns, // so the cost only pays off when there are enough strings to clean. - if interner_len() < INTERNER_GC_MIN_POOL_SIZE { + if interner_len() < min_pool_size { continue; } if let Some(permit) = app_state.update_guard.try_playlist() { diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index d50f01b4a..1d0ecf97b 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -79,6 +79,8 @@ pub struct Config { pub log: Option, pub user_access_control: bool, pub connect_timeout_secs: u32, + pub interner_gc_interval_secs: u32, + pub interner_gc_min_pool_size: u32, pub sleep_timer_mins: Option, pub update_on_boot: bool, pub config_hot_reload: bool, @@ -310,6 +312,8 @@ impl From<&ConfigDto> for Config { log: dto.log.as_ref().map(Into::into), user_access_control: dto.user_access_control, connect_timeout_secs: dto.connect_timeout_secs, + interner_gc_interval_secs: dto.interner_gc_interval_secs, + interner_gc_min_pool_size: dto.interner_gc_min_pool_size, sleep_timer_mins: dto.sleep_timer_mins, update_on_boot: dto.update_on_boot, config_hot_reload: dto.config_hot_reload, diff --git a/backend/src/runtime_config_report.rs b/backend/src/runtime_config_report.rs index 49095111d..ee3c95145 100644 --- a/backend/src/runtime_config_report.rs +++ b/backend/src/runtime_config_report.rs @@ -101,6 +101,8 @@ fn runtime_config_to_dto(config: &Config) -> ConfigDto { log: config.log.as_ref().map(LogConfigDto::from), user_access_control: config.user_access_control, connect_timeout_secs: config.connect_timeout_secs, + interner_gc_interval_secs: config.interner_gc_interval_secs, + interner_gc_min_pool_size: config.interner_gc_min_pool_size, sleep_timer_mins: config.sleep_timer_mins, update_on_boot: config.update_on_boot, config_hot_reload: config.config_hot_reload, diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index a08a16960..710145e01 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -1933,50 +1933,50 @@ mod tests { #[test] fn test_keep_vhost_false_uses_ip_host_header_for_http() { - let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10"]); + let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1"]); let url = Url::parse("http://example.com:8080/stream").expect("url parse should work"); let target = resolve_attempt_target(&url, Some(&provider)); - assert_eq!(target.effective_url.host_str(), Some("203.0.113.10")); - assert_eq!(target.host_header.as_deref(), Some("203.0.113.10:8080")); + assert_eq!(target.effective_url.host_str(), Some("192.168.0.1")); + assert_eq!(target.host_header.as_deref(), Some("192.168.0.1:8080")); } #[test] fn test_keep_vhost_true_uses_hostname_host_header_for_http() { - let provider = make_provider_with_dns(true, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10"]); + let provider = make_provider_with_dns(true, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1"]); let url = Url::parse("http://example.com:8080/stream").expect("url parse should work"); let target = resolve_attempt_target(&url, Some(&provider)); - assert_eq!(target.effective_url.host_str(), Some("203.0.113.10")); + assert_eq!(target.effective_url.host_str(), Some("192.168.0.1")); assert_eq!(target.host_header.as_deref(), Some("example.com:8080")); } #[test] fn test_preview_request_diagnostics_for_logging_includes_effective_target_and_host_details() { - let provider = make_provider_with_dns(true, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10"]); + let provider = make_provider_with_dns(true, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1"]); let url = Url::parse("http://example.com:8080/stream").expect("url parse should work"); let diagnostics = preview_request_diagnostics_for_logging(&url, Some(&provider)); assert_eq!( diagnostics, - "request_url=http://***/stream, effective_url=http://***/stream, host_header=example.com:8080, connect_ip=113.***" + "request_url=http://***/stream, effective_url=http://***/stream, host_header=example.com:8080, connect_ip=0.***" ); } #[test] fn test_preview_request_diagnostics_for_logging_sanitizes_each_stream_url() -> Result<(), url::ParseError> { - let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10"]); - let url = Url::parse("http://xxx-bldkde.net/live/MQ12FK/YH56CT/1092671.ts")?; + let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1"]); + let url = Url::parse("http://example.com/live/abcd/efgh/1092671.ts")?; let diagnostics = preview_request_diagnostics_for_logging(&url, Some(&provider)); - assert!(!diagnostics.contains("xxx-bldkde.net")); - assert!(!diagnostics.contains("MQ12FK")); - assert!(!diagnostics.contains("YH56CT")); + assert!(!diagnostics.contains("example.com")); + assert!(!diagnostics.contains("abcd")); + assert!(!diagnostics.contains("efgh")); assert_eq!( diagnostics, - "request_url=http://***/live/***1092671.ts, effective_url=http://***/live/***1092671.ts" + "request_url=http://***/live/***/1092671.ts, effective_url=http://***/live/***/1092671.ts, host_header=0.***, connect_ip=0.***" ); Ok(()) } @@ -1996,19 +1996,19 @@ mod tests { #[test] fn test_https_attempt_keeps_hostname_and_sets_sni() { - let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10"]); + let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1"]); let url = Url::parse("https://example.com/live").expect("url parse should work"); let target = resolve_attempt_target(&url, Some(&provider)); assert_eq!(target.effective_url.host_str(), Some("example.com")); assert_eq!(target.sni_host.as_deref(), Some("example.com")); - assert_eq!(target.connect_ip.map(|ip| ip.to_string()), Some("203.0.113.10".to_string())); - assert_eq!(target.host_header.as_deref(), Some("203.0.113.10")); + assert_eq!(target.connect_ip.map(|ip| ip.to_string()), Some("192.168.0.1".to_string())); + assert_eq!(target.host_header.as_deref(), Some("192.168.0.1")); } #[test] fn test_try_next_ip_policy_uses_next_ip_until_exhausted() { - let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10", "203.0.113.11"]); + let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1", "192.168.0.2"]); let url = Url::parse("http://example.com/live").expect("url parse should work"); let mut tried = HashSet::new(); @@ -2021,27 +2021,27 @@ mod tests { #[test] fn test_preview_request_target_for_logging_does_not_advance_dns_rotation() { - let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10", "203.0.113.11"]); + let provider = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1", "192.168.0.2"]); let url = Url::parse("http://example.com/live").expect("url parse should work"); let preview = preview_request_target_for_logging(&url, Some(&provider)); let first = resolve_attempt_target(&url, Some(&provider)); let second = resolve_attempt_target(&url, Some(&provider)); - assert_eq!(preview, "http://203.0.113.10/live"); - assert_eq!(first.connect_ip.map(|ip| ip.to_string()), Some("203.0.113.10".to_string())); - assert_eq!(second.connect_ip.map(|ip| ip.to_string()), Some("203.0.113.11".to_string())); + assert_eq!(preview, "http://192.168.0.1/live"); + assert_eq!(first.connect_ip.map(|ip| ip.to_string()), Some("192.168.0.1".to_string())); + assert_eq!(second.connect_ip.map(|ip| ip.to_string()), Some("192.168.0.2".to_string())); - let provider_https = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.10", "203.0.113.11"]); + let provider_https = make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1", "192.168.0.2"]); let https_url = Url::parse("https://example.com/live").expect("url parse should work"); let https_preview = preview_request_target_for_logging(&https_url, Some(&provider_https)); let https_first = resolve_attempt_target(&https_url, Some(&provider_https)); let https_second = resolve_attempt_target(&https_url, Some(&provider_https)); - assert_eq!(https_preview, "https://example.com/live (connect_ip=203.0.113.10)"); - assert_eq!(https_first.connect_ip.map(|ip| ip.to_string()), Some("203.0.113.10".to_string())); - assert_eq!(https_second.connect_ip.map(|ip| ip.to_string()), Some("203.0.113.11".to_string())); + assert_eq!(https_preview, "https://example.com/live (connect_ip=192.168.0.1)"); + assert_eq!(https_first.connect_ip.map(|ip| ip.to_string()), Some("192.168.0.1".to_string())); + assert_eq!(https_second.connect_ip.map(|ip| ip.to_string()), Some("192.168.0.2".to_string())); } #[test] @@ -2359,7 +2359,7 @@ mod tests { let url = Url::parse(format!("http://example.com:{}/ok", addr.port()).as_str()).expect("url parse should work"); let provider_rotate = - make_provider_with_dns(false, OnConnectErrorPolicy::RotateProviderUrl, vec!["203.0.113.1", "127.0.0.1"]); + make_provider_with_dns(false, OnConnectErrorPolicy::RotateProviderUrl, vec!["192.168.0.1", "127.0.0.1"]); let result_rotate = send_with_retry_and_provider(&app_config, &url, Some(&provider_rotate), false, |resolved_url| { client.get(resolved_url.clone()) }) @@ -2367,7 +2367,7 @@ mod tests { assert!(result_rotate.is_err(), "without try_next_ip policy the request should fail"); let provider_try_next = - make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["203.0.113.1", "127.0.0.1"]); + make_provider_with_dns(false, OnConnectErrorPolicy::TryNextIp, vec!["192.168.0.1", "127.0.0.1"]); let result_try_next = send_with_retry_and_provider(&app_config, &url, Some(&provider_try_next), false, |resolved_url| { client.get(resolved_url.clone()) diff --git a/config/config.yml b/config/config.yml index 7e4a36a45..33f5640ab 100644 --- a/config/config.yml +++ b/config/config.yml @@ -8,6 +8,8 @@ working_dir: ${env:TULIPROX_HOME}/data backup_dir: ${env:TULIPROX_HOME}/backup custom_stream_response_path: ${env:TULIPROX_HOME}/resources connect_timeout_secs: 10 +interner_gc_interval_secs: 180 +interner_gc_min_pool_size: 100 # Used when no `User-Agent` is provided by the client request and none is set in input headers. # If omitted, the built-in default is used. default_user_agent: "VLC/3.0.16 LibVLC/3.0.16" diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 35bd82598..de91bf17b 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -20,6 +20,8 @@ config_hot_reload: false accept_insecure_ssl_certificates: false sleep_timer_mins: null connect_timeout_secs: 10 +interner_gc_interval_secs: 180 +interner_gc_min_pool_size: 100 user_access_control: false custom_stream_response_path: null custom_stream_response_timeout_secs: 0 @@ -55,6 +57,8 @@ metadata_update: | `accept_insecure_ssl_certificates` | Bool | No | `false` | Set to `true` if your upstream provider uses expired, self-signed, or improperly configured HTTPS certificates. Otherwise, the HTTP client drops the connection securely. | | `sleep_timer_mins` | Int | No | `null` | Automatic kill-switch for proxied streams. Forcibly terminates active stream connections after X minutes (Protects against users falling asleep with the TV on). | | `connect_timeout_secs` | Int | No | `10` | Maximum time (in seconds) Tuliprox waits to establish the initial TCP connection to a provider. `0` disables the timeout and the connection attempt continues until the provider closes it or a network timeout occurs (Warning: risk of hanging threads!). | +| `interner_gc_interval_secs` | Int | No | `180` | Interval in seconds between background string interner garbage-collection checks. Lower values reclaim unused interned strings sooner, but run the GC gate more often. Higher values reduce GC overhead, but can retain more memory for longer. Must be greater than `0`. | +| `interner_gc_min_pool_size` | Int | No | `100` | Minimum number of interned strings required before the background interner GC runs. This avoids paying the interner write-lock cost when the pool is very small. Lower values favor aggressive cleanup; higher values favor throughput and lower lock contention at the cost of extra retained memory. | | `user_access_control` | Bool | No | `false` | **Security:** If `true`, Tuliprox actively enforces `status` (Active/Banned), `exp_date`, and `max_connections` constraints for users defined in `api-proxy.yml`. If false, those fields are ignored. | | `custom_stream_response_path` | String | No | `null` | Directory path where Tuliprox looks for custom fallback `.ts` files. See section [Custom Stream Response](#custom-stream-responses-fallback-videos) for exact filenames. | | `custom_stream_response_timeout_secs` | Int | No | `0` | Hard timeout (in seconds) that forces the fallback video stream to terminate to prevent infinite bandwidth usage. `0` means endless loop. | diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 7f33fc528..65813f85c 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -484,6 +484,8 @@ "BACKUP_DIR": "Filesystem directory used for configuration backups.", "CONFIG_HOT_RELOAD": "If true, config files are reloaded automatically on change.", "CONNECT_TIMEOUT_SECS": "Maximum connect timeout in seconds.", + "INTERNER_GC_INTERVAL_SECS": "Interval in seconds between background string interner garbage-collection checks.", + "INTERNER_GC_MIN_POOL_SIZE": "Minimum number of interned strings required before the background interner GC runs.", "CUSTOM_STREAM_RESPONSE_PATH": "Path to a directory containing custom error response files for stream failures.", "CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS": "Maximum duration in seconds for serving custom stream response video. `0` disables timeout.", "DEFAULT_USER_AGENT": "The User-Agent string used for outgoing requests if not specified elsewhere.", @@ -882,6 +884,8 @@ "CONFIGURATION": "Configuration", "CONFIG_HOT_RELOAD": "Config Hot Reload", "CONNECT_TIMEOUT_SECS": "Connect Timeout (secs)", + "INTERNER_GC_INTERVAL_SECS": "Interner GC Interval (secs)", + "INTERNER_GC_MIN_POOL_SIZE": "Interner GC Min Pool Size", "CONTENT_SECURITY_POLICY": "Content Security Policy", "CONTENT_TYPE": "Content Type", "COPY_CREDENTIALS": "Copy Credentials", diff --git a/frontend/src/app/components/config/main_config_view.rs b/frontend/src/app/components/config/main_config_view.rs index 269919f77..25d08dcf1 100644 --- a/frontend/src/app/components/config/main_config_view.rs +++ b/frontend/src/app/components/config/main_config_view.rs @@ -28,6 +28,8 @@ const LABEL_BACKUP_DIR: &str = "LABEL.BACKUP_DIR"; const LABEL_USER_CONFIG_DIR: &str = "LABEL.USER_CONFIG_DIR"; const LABEL_SLEEP_TIMER_MINS: &str = "LABEL.SLEEP_TIMER_MINS"; const LABEL_CONNECT_TIMEOUT_SECS: &str = "LABEL.CONNECT_TIMEOUT_SECS"; +const LABEL_INTERNER_GC_INTERVAL_SECS: &str = "LABEL.INTERNER_GC_INTERVAL_SECS"; +const LABEL_INTERNER_GC_MIN_POOL_SIZE: &str = "LABEL.INTERNER_GC_MIN_POOL_SIZE"; const LABEL_CUSTOM_STREAM_RESPONSE_PATH: &str = "LABEL.CUSTOM_STREAM_RESPONSE_PATH"; const LABEL_CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS: &str = "LABEL.CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS"; const LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES: &str = "LABEL.ACCEPT_INSECURE_SSL_CERTIFICATES"; @@ -50,6 +52,8 @@ generate_form_reducer!( UserConfigDir => user_config_dir: Option, SleepTimerMins => sleep_timer_mins: Option, ConnectTimeoutSecs => connect_timeout_secs: u32, + InternerGcIntervalSecs => interner_gc_interval_secs: u32, + InternerGcMinPoolSize => interner_gc_min_pool_size: u32, CustomStreamResponsePath => custom_stream_response_path: Option, CustomStreamResponseTimeoutSecs => custom_stream_response_timeout_secs: u32, } @@ -107,6 +111,8 @@ pub fn MainConfigView() -> Html { { config_field_optional!(form_state.form, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir) } { config_field_optional!(form_state.form, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins) } { config_field!(form_state.form, translate.t(LABEL_CONNECT_TIMEOUT_SECS), connect_timeout_secs) } + { config_field!(form_state.form, translate.t(LABEL_INTERNER_GC_INTERVAL_SECS), interner_gc_interval_secs) } + { config_field!(form_state.form, translate.t(LABEL_INTERNER_GC_MIN_POOL_SIZE), interner_gc_min_pool_size) } { config_field_optional!(form_state.form, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_PATH), custom_stream_response_path) } { config_field!(form_state.form, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS), custom_stream_response_timeout_secs) } @@ -130,6 +136,8 @@ pub fn MainConfigView() -> Html { { edit_field_text_option!(form_state, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir, MainConfigFormAction::UserConfigDir) } { edit_field_number_option_u32!(form_state, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins, MainConfigFormAction::SleepTimerMins) } { edit_field_number!(form_state, translate.t(LABEL_CONNECT_TIMEOUT_SECS), connect_timeout_secs, MainConfigFormAction::ConnectTimeoutSecs) } + { edit_field_number!(form_state, translate.t(LABEL_INTERNER_GC_INTERVAL_SECS), interner_gc_interval_secs, MainConfigFormAction::InternerGcIntervalSecs) } + { edit_field_number!(form_state, translate.t(LABEL_INTERNER_GC_MIN_POOL_SIZE), interner_gc_min_pool_size, MainConfigFormAction::InternerGcMinPoolSize) } { edit_field_text_option!(form_state, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_PATH), custom_stream_response_path, MainConfigFormAction::CustomStreamResponsePath) } { edit_field_number!(form_state, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS), custom_stream_response_timeout_secs, MainConfigFormAction::CustomStreamResponseTimeoutSecs) } diff --git a/frontend/src/app/components/dashboard/stream_display/item.rs b/frontend/src/app/components/dashboard/stream_display/item.rs index bed2633db..93e8261d8 100644 --- a/frontend/src/app/components/dashboard/stream_display/item.rs +++ b/frontend/src/app/components/dashboard/stream_display/item.rs @@ -6,7 +6,7 @@ use crate::{ app::components::{country::display_country_code, AppIcon, Chip, Country, RevealContent, ToggleSwitch}, i18n::use_translation, services::PlaylistService, - utils::format_duration, + utils::{format_duration, format_ts}, }; use shared::{ model::{ @@ -20,10 +20,22 @@ use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use yew::prelude::*; -fn only_time(ts: &str) -> String { - match ts.split_once(' ') { - Some((_date, time)) => time.to_string(), - None => ts.to_string(), +/// Render an EPG start time in the browser's local timezone as `HH:MM`. +/// Takes the raw UTC timestamp from `EpgProgrammeDto::start_timestamp` +/// rather than the backend's pre-formatted display string, so the time +/// shown matches the user's wall clock regardless of the timeshift +/// configured for the stream's cluster. +fn only_time(ts: i64) -> String { + if ts <= 0 { + return String::new(); + } + // Use the shared local-time formatter and keep just the `HH:MM` + // portion. Seconds are dropped to match the compact EPG card layout + // and stay consistent with the full EPG view's time columns. + let full = format_ts(ts as u64); + match full.split_once(' ') { + Some((_, time)) => time.get(..5).map_or_else(String::new, str::to_string), + None => String::new(), } } @@ -332,14 +344,14 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html { if !current.title.is_empty() {
- {only_time(¤t.start)} + {only_time(current.start_timestamp)} {" "} {¤t.title} if let Some(next) = next_opt.as_ref() { {" ↦ "} - {only_time(&next.start)} + {only_time(next.start_timestamp)} {" "} {&next.title} diff --git a/frontend/src/app/components/dashboard/stream_history_view.rs b/frontend/src/app/components/dashboard/stream_history_view.rs index 72d941dab..d6bcf791d 100644 --- a/frontend/src/app/components/dashboard/stream_history_view.rs +++ b/frontend/src/app/components/dashboard/stream_history_view.rs @@ -5,7 +5,10 @@ use crate::{ }, hooks::use_service_context, i18n::use_translation, - utils::{format_bytes, format_duration, format_ts, get_local_storage_item, set_local_storage_item}, + utils::{ + format_bytes, format_duration, format_local_day_boundary_utc, format_ts, get_local_storage_item, + set_local_storage_item, + }, }; use futures::join; use shared::{ @@ -60,14 +63,20 @@ struct QosDetailRow { } fn today_start_ts() -> i64 { - let now = chrono::Utc::now(); - let today = now.date_naive(); - today.and_hms_opt(0, 0, 0).map(|dt| dt.and_utc().timestamp()).unwrap_or(0) + #[cfg(target_arch = "wasm32")] + let today = { + let now = js_sys::Date::new_0(); + i32::try_from(now.get_full_year()) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, now.get_month() + 1, now.get_date())) + }; + #[cfg(not(target_arch = "wasm32"))] + let today = Some(chrono::Utc::now().date_naive()); + + today.and_then(|date| date.and_hms_opt(0, 0, 0)).map(|dt| dt.and_utc().timestamp()).unwrap_or(0) } -fn ts_to_date_str(ts: i64) -> String { - chrono::DateTime::from_timestamp(ts, 0).map_or_else(String::new, |dt| dt.format("%Y-%m-%d").to_string()) -} +fn ts_to_filter_str(ts: i64, end_of_day: bool) -> String { format_local_day_boundary_utc(ts, end_of_day) } fn optional_record_text_str(value: Option<&str>) -> &str { value.unwrap_or("-") } fn optional_record_text(value: Option) -> String { value.unwrap_or_else(|| String::from("-")) } @@ -263,8 +272,8 @@ pub fn StreamHistoryView() -> Html { let paged_response = paged_response.clone(); let loading = loading.clone(); let request_id = request_id.clone(); - let from_str = (*from_date).map(ts_to_date_str); - let to_str = (*to_date).map(ts_to_date_str); + let from_str = (*from_date).map(|ts| ts_to_filter_str(ts, false)); + let to_str = (*to_date).map(|ts| ts_to_filter_str(ts, true)); let (search_text, search_mode, search_fields) = search_request_parts(&request); let next_request_id = { let mut current = request_id.borrow_mut(); @@ -317,8 +326,8 @@ pub fn StreamHistoryView() -> Html { let selected_qos_snapshot = selected_qos_snapshot.clone(); let loading = loading.clone(); let request_id = request_id.clone(); - let from_str = (*from_date).map(ts_to_date_str); - let to_str = (*to_date).map(ts_to_date_str); + let from_str = (*from_date).map(|ts| ts_to_filter_str(ts, false)); + let to_str = (*to_date).map(|ts| ts_to_filter_str(ts, true)); let next_request_id = { let mut current = request_id.borrow_mut(); *current = current.saturating_add(1); diff --git a/frontend/src/utils/format.rs b/frontend/src/utils/format.rs index c1c8eec65..f80606fa5 100644 --- a/frontend/src/utils/format.rs +++ b/frontend/src/utils/format.rs @@ -33,10 +33,51 @@ pub fn format_bandwidth(rate_kbps: u32) -> String { format_rate_unit(rate_kbps, #[inline] pub fn format_transferred(total_kb: u32) -> String { format_rate_unit(total_kb, 2, "") } +/// Format a UTC unix timestamp as "YYYY-MM-DD HH:MM:SS" in the browser's local timezone. pub fn format_ts(ts: u64) -> String { - chrono::DateTime::from_timestamp(ts as i64, 0) - .map_or_else(|| ts.to_string(), |dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ts as f64 * 1000.0)); + if !date.get_time().is_finite() { + return ts.to_string(); + } + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + date.get_full_year(), + date.get_month() + 1, + date.get_date(), + date.get_hours(), + date.get_minutes(), + date.get_seconds() + ) } + +/// Convert a calendar date encoded as a UTC-midnight timestamp to the matching +/// local day boundary, then serialize that instant as UTC for the backend. +pub fn format_local_day_boundary_utc(ts: i64, end_of_day: bool) -> String { + let Some(date) = chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.date_naive()) else { + return String::new(); + }; + + let Ok(year) = u32::try_from(chrono::Datelike::year(&date)) else { + return String::new(); + }; + let (hour, minute, second) = if end_of_day { (23, 59, 59) } else { (0, 0, 0) }; + let local = js_sys::Date::new_with_year_month_day_hr_min_sec( + year, + chrono::Datelike::month0(&date).cast_signed(), + chrono::Datelike::day(&date).cast_signed(), + hour, + minute, + second, + ); + let utc_millis = local.get_time(); + if !utc_millis.is_finite() { + return String::new(); + } + let utc_ts = (utc_millis / 1000.0) as i64; + chrono::DateTime::from_timestamp(utc_ts, 0) + .map_or_else(String::new, |dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) +} + pub fn format_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; @@ -54,8 +95,22 @@ pub fn format_bytes(bytes: u64) -> String { #[cfg(test)] mod tests { + #[cfg(target_arch = "wasm32")] + use super::format_local_day_boundary_utc; use super::{format_bandwidth, format_transferred}; + fn format_local_with_offset(ts: i64, js_offset_minutes_west: i32, fmt: &str) -> String { + let utc = match chrono::DateTime::from_timestamp(ts, 0) { + Some(dt) => dt, + None => return ts.to_string(), + }; + let east_secs = -(js_offset_minutes_west as i64) * 60; + match i32::try_from(east_secs).ok().and_then(chrono::FixedOffset::east_opt) { + Some(offset) => utc.with_timezone(&offset).format(fmt).to_string(), + None => utc.format(fmt).to_string(), + } + } + #[test] fn format_bandwidth_below_kb_threshold_uses_kbps() { assert_eq!(format_bandwidth(1), "1 KB/s"); @@ -105,4 +160,99 @@ mod tests { fn format_transferred_zero_shows_dash() { assert_eq!(format_transferred(0), "-"); } + + // 2026-04-12 12:30:45 UTC + fn ref_ts() -> i64 { utc_ts(2026, 4, 12, 12, 30, 45) } + + fn utc_ts(year: i32, month: u32, day: u32, h: u32, m: u32, s: u32) -> i64 { + chrono::NaiveDate::from_ymd_opt(year, month, day) + .and_then(|d| d.and_hms_opt(h, m, s)) + .map(|dt| dt.and_utc().timestamp()) + .expect("test timestamp must be a valid date") + } + + #[test] + fn format_local_with_offset_utc_passes_through_unchanged() { + // 0 minutes west of UTC = UTC + assert_eq!(format_local_with_offset(ref_ts(), 0, "%Y-%m-%d %H:%M:%S"), "2026-04-12 12:30:45"); + } + + #[test] + fn format_local_with_offset_cest_shifts_two_hours_east() { + // Berlin in summer: getTimezoneOffset returns -120 (120 min west), so + // local is 2h ahead of UTC. + assert_eq!(format_local_with_offset(ref_ts(), -120, "%Y-%m-%d %H:%M:%S"), "2026-04-12 14:30:45"); + } + + #[test] + fn format_local_with_offset_est_shifts_five_hours_west() { + // New York in winter: getTimezoneOffset returns +300 (300 min west), + // so local is 5h behind UTC. + assert_eq!(format_local_with_offset(ref_ts(), 300, "%Y-%m-%d %H:%M:%S"), "2026-04-12 07:30:45"); + } + + #[test] + fn format_local_with_offset_handles_date_rollover_east() { + // 23:30 UTC + 2h east rolls over to the next local day. + let late_ts = utc_ts(2026, 4, 12, 23, 30, 0); + assert_eq!(format_local_with_offset(late_ts, -120, "%Y-%m-%d %H:%M:%S"), "2026-04-13 01:30:00"); + } + + #[test] + fn format_local_with_offset_handles_date_rollover_west() { + // 01:30 UTC - 5h west rolls back to the previous local day. + let early_ts = utc_ts(2026, 4, 12, 1, 30, 0); + assert_eq!(format_local_with_offset(early_ts, 300, "%Y-%m-%d %H:%M:%S"), "2026-04-11 20:30:00"); + } + + #[test] + fn format_local_with_offset_falls_back_to_utc_on_out_of_range_offset() { + // An offset that overflows i32 should fall back to UTC, not panic. + assert_eq!(format_local_with_offset(ref_ts(), i32::MAX, "%Y-%m-%d %H:%M:%S"), "2026-04-12 12:30:45"); + // Likewise, an offset that fits in i32 but is outside chrono's ±14h + // window falls back to UTC. + assert_eq!(format_local_with_offset(ref_ts(), 60 * 24, "%Y-%m-%d %H:%M:%S"), "2026-04-12 12:30:45"); + } + + #[test] + fn format_local_with_offset_invalid_timestamp_returns_raw_int() { + // 0 is a valid timestamp (1970-01-01), so use a clearly out-of-range value. + let bad = i64::MIN / 2; + let out = format_local_with_offset(bad, 0, "%Y-%m-%d %H:%M:%S"); + assert_eq!(out, bad.to_string()); + } + + #[test] + fn format_local_with_offset_date_only_format() { + assert_eq!(format_local_with_offset(ref_ts(), -120, "%Y-%m-%d"), "2026-04-12"); + // Late-evening UTC becomes the next local day in CEST. + let late_ts = utc_ts(2026, 4, 12, 23, 30, 0); + assert_eq!(format_local_with_offset(late_ts, -120, "%Y-%m-%d"), "2026-04-13"); + } + + #[test] + #[cfg(target_arch = "wasm32")] + fn format_local_day_boundary_utc_uses_calendar_date() { + let date_key = utc_ts(2026, 3, 23, 0, 0, 0); + // The function extracts the calendar date from the UTC timestamp, then + // computes the local day boundary for that date and serializes it as UTC. + let utc = chrono::DateTime::from_timestamp(date_key, 0).expect("valid timestamp"); + let local_date = utc.with_timezone(&chrono::Local).date_naive(); + let start_local = local_date + .and_hms_opt(0, 0, 0) + .and_then(|ndt| chrono::Local.from_local_datetime(&ndt).earliest()) + .expect("valid local datetime"); + let end_local = local_date + .and_hms_opt(23, 59, 59) + .and_then(|ndt| chrono::Local.from_local_datetime(&ndt).earliest()) + .expect("valid local datetime"); + assert_eq!( + format_local_day_boundary_utc(date_key, false), + start_local.with_timezone(&chrono::Utc).format("%Y-%m-%d %H:%M:%S").to_string() + ); + assert_eq!( + format_local_day_boundary_utc(date_key, true), + end_local.with_timezone(&chrono::Utc).format("%Y-%m-%d %H:%M:%S").to_string() + ); + } } diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index 6aa5efd9a..07a6ec390 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -7,14 +7,15 @@ use crate::{ }, utils::{ default_connect_timeout_secs, default_custom_stream_response_path, default_default_user_agent, - default_main_backup_dir, default_main_mapping_path, default_main_storage_dir, default_main_template_path, - default_main_user_config_dir, default_supported_video_extensions, is_blank_optional_string, - is_blank_or_default_backup_dir, is_blank_or_default_custom_stream_response_path, - is_blank_or_default_mapping_path, is_blank_or_default_storage_dir, is_blank_or_default_template_path, - is_blank_or_default_user_config_dir, is_default_connect_timeout_secs, is_false, - is_none_or_empty_metadata_update, is_none_or_empty_video, is_zero_u32, normalize_optional_config_file_path, - normalize_optional_dir, DEFAULT_BACKUP_DIR, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH, DEFAULT_STORAGE_DIR, - DEFAULT_USER_CONFIG_DIR, MAPPING_FILE, TEMPLATE_FILE, + default_interner_gc_interval_secs, default_interner_gc_min_pool_size, default_main_backup_dir, + default_main_mapping_path, default_main_storage_dir, default_main_template_path, default_main_user_config_dir, + default_supported_video_extensions, is_blank_optional_string, is_blank_or_default_backup_dir, + is_blank_or_default_custom_stream_response_path, is_blank_or_default_mapping_path, + is_blank_or_default_storage_dir, is_blank_or_default_template_path, is_blank_or_default_user_config_dir, + is_default_connect_timeout_secs, is_default_interner_gc_interval_secs, is_default_interner_gc_min_pool_size, + is_false, is_none_or_empty_metadata_update, is_none_or_empty_video, is_zero_u32, + normalize_optional_config_file_path, normalize_optional_dir, DEFAULT_BACKUP_DIR, + DEFAULT_CUSTOM_STREAM_RESPONSE_PATH, DEFAULT_STORAGE_DIR, DEFAULT_USER_CONFIG_DIR, MAPPING_FILE, TEMPLATE_FILE, }, }; @@ -56,6 +57,16 @@ pub struct ConfigDto { pub user_access_control: bool, #[serde(default = "default_connect_timeout_secs", skip_serializing_if = "is_default_connect_timeout_secs")] pub connect_timeout_secs: u32, + #[serde( + default = "default_interner_gc_interval_secs", + skip_serializing_if = "is_default_interner_gc_interval_secs" + )] + pub interner_gc_interval_secs: u32, + #[serde( + default = "default_interner_gc_min_pool_size", + skip_serializing_if = "is_default_interner_gc_min_pool_size" + )] + pub interner_gc_min_pool_size: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub sleep_timer_mins: Option, #[serde(default, skip_serializing_if = "is_false")] @@ -101,6 +112,8 @@ impl Default for ConfigDto { log: None, user_access_control: false, connect_timeout_secs: default_connect_timeout_secs(), + interner_gc_interval_secs: default_interner_gc_interval_secs(), + interner_gc_min_pool_size: default_interner_gc_min_pool_size(), sleep_timer_mins: None, update_on_boot: false, config_hot_reload: false, @@ -145,6 +158,16 @@ pub struct MainConfigDto { pub disk_based_processing: bool, #[serde(default = "default_connect_timeout_secs", skip_serializing_if = "is_default_connect_timeout_secs")] pub connect_timeout_secs: u32, + #[serde( + default = "default_interner_gc_interval_secs", + skip_serializing_if = "is_default_interner_gc_interval_secs" + )] + pub interner_gc_interval_secs: u32, + #[serde( + default = "default_interner_gc_min_pool_size", + skip_serializing_if = "is_default_interner_gc_min_pool_size" + )] + pub interner_gc_min_pool_size: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub sleep_timer_mins: Option, #[serde(default, skip_serializing_if = "is_false")] @@ -170,6 +193,8 @@ impl Default for MainConfigDto { custom_stream_response_timeout_secs: 0, user_access_control: false, connect_timeout_secs: default_connect_timeout_secs(), + interner_gc_interval_secs: default_interner_gc_interval_secs(), + interner_gc_min_pool_size: default_interner_gc_min_pool_size(), sleep_timer_mins: None, update_on_boot: false, config_hot_reload: false, @@ -193,6 +218,8 @@ impl From<&ConfigDto> for MainConfigDto { custom_stream_response_timeout_secs: config.custom_stream_response_timeout_secs, user_access_control: config.user_access_control, connect_timeout_secs: config.connect_timeout_secs, + interner_gc_interval_secs: config.interner_gc_interval_secs, + interner_gc_min_pool_size: config.interner_gc_min_pool_size, sleep_timer_mins: config.sleep_timer_mins, update_on_boot: config.update_on_boot, config_hot_reload: config.config_hot_reload, @@ -250,6 +277,12 @@ impl ConfigDto { return Err(TuliproxError::ConfigBase("`sleep_timer_mins` must be > 0 when specified".to_string())); } } + if self.interner_gc_interval_secs == 0 { + return Err(TuliproxError::ConfigBase("`interner_gc_interval_secs` must be > 0".to_string())); + } + if self.interner_gc_min_pool_size == 0 { + return Err(TuliproxError::ConfigBase("`interner_gc_min_pool_size` must be > 0".to_string())); + } self.prepare_web()?; self.prepare_hdhomerun(include_computed)?; @@ -364,6 +397,8 @@ impl ConfigDto { self.custom_stream_response_timeout_secs = main_config.custom_stream_response_timeout_secs; self.user_access_control = main_config.user_access_control; self.connect_timeout_secs = main_config.connect_timeout_secs; + self.interner_gc_interval_secs = main_config.interner_gc_interval_secs; + self.interner_gc_min_pool_size = main_config.interner_gc_min_pool_size; self.sleep_timer_mins = main_config.sleep_timer_mins; self.update_on_boot = main_config.update_on_boot; self.config_hot_reload = main_config.config_hot_reload; @@ -387,6 +422,8 @@ mod tests { fn default_uses_connect_timeout_default_value() { let cfg = ConfigDto::default(); assert_eq!(cfg.connect_timeout_secs, default_connect_timeout_secs()); + assert_eq!(cfg.interner_gc_interval_secs, default_interner_gc_interval_secs()); + assert_eq!(cfg.interner_gc_min_pool_size, default_interner_gc_min_pool_size()); } #[test] diff --git a/shared/src/utils/constants.rs b/shared/src/utils/constants.rs index dd8daaf89..ce6987a25 100644 --- a/shared/src/utils/constants.rs +++ b/shared/src/utils/constants.rs @@ -134,7 +134,10 @@ pub static CONSTANTS: LazyLock = LazyLock::new(|| { re_credentials: Regex::new(r"((username|password|token|api_key)=)[^&]*").unwrap(), re_ipv4: Regex::new(r"\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b").unwrap(), re_ipv6: Regex::new(r"\b([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b|\b([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::ffff:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b").unwrap(), - re_stream_url: Regex::new(r"(?i)^(?Phttps?://)[^/]+/(?Plive|video|movie|series|m3u-stream|resource)/[^/]+/[^/]+/").unwrap(), + re_stream_url: Regex::new( + r"(?i)^(?P(?:https?|batch|provider)://)[^/]+/(?Plive|video|movie|series|m3u-stream|resource)/[^/]+/[^/]+/" + ) + .unwrap(), re_url: Regex::new(r"(.*://).*?/(.*)").unwrap(), re_password: Regex::new(r"password:\s*(\w+)").unwrap(), re_base_href_tag: Regex::new(r"(?is)]*>").unwrap(), diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index d0aec5174..47900e820 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -65,17 +65,19 @@ default_eq_fns!( default_grace_period_timeout_secs, is_default_grace_period_timeout_secs, u64, 4; default_hls_session_ttl_secs, is_default_hls_session_ttl_secs, u64, 15; default_catchup_session_ttl_secs, is_default_catchup_session_ttl_secs, u64, 45; + default_connect_timeout_secs, is_default_connect_timeout_secs, u32, 6; + default_resource_retry_attempts, is_default_resource_retry_attempts, u32, 3; + default_resource_retry_backoff_ms, is_default_resource_retry_backoff_ms, u64, 250; + default_interner_gc_interval_secs, is_default_interner_gc_interval_secs, u32, 180; + default_interner_gc_min_pool_size, is_default_interner_gc_min_pool_size, u32, 100; ); pub const fn default_panel_api_provision_timeout_secs() -> u64 { 65 } pub const fn default_panel_api_provision_probe_interval_secs() -> u64 { 15 } pub const fn default_panel_api_provision_cooldown_secs() -> u64 { 0 } pub const fn default_panel_api_alias_pool_min() -> u16 { 1 } pub const fn default_panel_api_alias_pool_max() -> u16 { 1 } -default_eq_fns!( - default_connect_timeout_secs, is_default_connect_timeout_secs, u32, 6; - default_resource_retry_attempts, is_default_resource_retry_attempts, u32, 3; - default_resource_retry_backoff_ms, is_default_resource_retry_backoff_ms, u64, 250; -); + +//_---------- pub const fn default_resource_retry_backoff_multiplier() -> f64 { 1.0 } pub const F64_DEFAULT_EPSILON: f64 = 1e-9; pub const fn is_default_resource_retry_backoff_multiplier(v: &f64) -> bool { diff --git a/shared/src/utils/request.rs b/shared/src/utils/request.rs index d1db6bf97..208b71500 100644 --- a/shared/src/utils/request.rs +++ b/shared/src/utils/request.rs @@ -29,7 +29,7 @@ pub fn sanitize_sensitive_info(query: &str) -> Cow<'_, str> { (&CONSTANTS.re_credentials, "$1***"), (&CONSTANTS.re_ipv4, "$1***"), (&CONSTANTS.re_ipv6, "$1***"), - (&CONSTANTS.re_stream_url, "$1***/$2/***"), + (&CONSTANTS.re_stream_url, "$1***/$2/***/"), (&CONSTANTS.re_url, "$1***/$2"), (&CONSTANTS.re_password, "$1***"), ] { @@ -179,3 +179,22 @@ pub fn parse_provider_scheme_url_parts(stream_url: &str) -> Result<(&str, &str), Ok((host, path)) } + +#[cfg(test)] +mod tests { + use super::{sanitize_sensitive_info, set_sanitize_sensitive_info}; + + #[test] + fn sanitize_sensitive_info_masks_xtream_path_credentials_for_all_supported_schemes() { + let previous = super::is_sanitize_sensitive_info_enabled(); + set_sanitize_sensitive_info(true); + + for scheme in ["http", "https", "provider", "batch"] { + let input = format!("{scheme}://example/live/myuser/mypass/15373.ts"); + let expected = format!("{scheme}://***/live/***/15373.ts"); + assert_eq!(sanitize_sensitive_info(&input), expected); + } + + set_sanitize_sensitive_info(previous); + } +}