mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-15 20:32:27 +02:00
Merge remote-tracking branch 'github/develop' into develop
This commit is contained in:
@@ -57,5 +57,7 @@ backend/Cargo.lock
|
||||
|
||||
# Ignore ai documentation files
|
||||
.c[lor][adu]*
|
||||
/.dual-graph*
|
||||
/graphify*
|
||||
.dual-graph/
|
||||
.dual-graph-context/
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let provider_id = provider_id.trim().parse::<u32>().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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<AppState>,
|
||||
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<Arc<AppState>> {
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppState>) {
|
||||
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() {
|
||||
|
||||
@@ -79,6 +79,8 @@ pub struct Config {
|
||||
pub log: Option<LogConfig>,
|
||||
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<u32>,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String>,
|
||||
SleepTimerMins => sleep_timer_mins: Option<u32>,
|
||||
ConnectTimeoutSecs => connect_timeout_secs: u32,
|
||||
InternerGcIntervalSecs => interner_gc_interval_secs: u32,
|
||||
InternerGcMinPoolSize => interner_gc_min_pool_size: u32,
|
||||
CustomStreamResponsePath => custom_stream_response_path: Option<String>,
|
||||
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) }
|
||||
</>
|
||||
|
||||
@@ -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() {
|
||||
<div class="tp__stream-display__epg">
|
||||
<span class="tp__stream-display__epg-now">
|
||||
<span class="tp__stream-display__epg-time">{only_time(¤t.start)}</span>
|
||||
<span class="tp__stream-display__epg-time">{only_time(current.start_timestamp)}</span>
|
||||
{" "}
|
||||
<span class="tp__stream-display__epg-title">{¤t.title}</span>
|
||||
</span>
|
||||
if let Some(next) = next_opt.as_ref() {
|
||||
<span class="tp__stream-display__epg-next">
|
||||
<span class="tp__stream-display__epg-separator">{" ↦ "}</span>
|
||||
<span class="tp__stream-display__epg-time">{only_time(&next.start)}</span>
|
||||
<span class="tp__stream-display__epg-time">{only_time(next.start_timestamp)}</span>
|
||||
{" "}
|
||||
<span class="tp__stream-display__epg-title">{&next.title}</span>
|
||||
</span>
|
||||
|
||||
@@ -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>) -> 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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
#[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<u32>,
|
||||
#[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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user