From 7b2dd5be14002b2e2a75c3f2efee51fea0134b35 Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:03:24 +0100 Subject: [PATCH 01/27] feat: added support for panel api --- README.md | 47 ++ backend/src/api/api_utils.rs | 12 +- backend/src/api/mod.rs | 3 +- .../src/api/model/provider_lineup_manager.rs | 45 +- backend/src/api/panel_api.rs | 676 ++++++++++++++++++ backend/src/model/config/input.rs | 7 +- shared/src/model/config/input.rs | 4 + shared/src/model/config/mod.rs | 4 +- shared/src/model/config/panel_api.rs | 28 + 9 files changed, 799 insertions(+), 27 deletions(-) create mode 100644 backend/src/api/panel_api.rs create mode 100644 shared/src/model/config/panel_api.rs diff --git a/README.md b/README.md index e5d23bf01..d5e8efe48 100644 --- a/README.md +++ b/README.md @@ -826,6 +826,53 @@ Input alias definition for same provider with same content but different credent - name: test ``` +#### `panel_api` (optional) +If provider connections are exhausted, tuliprox can optionally call a provider panel API to: +- renew expired accounts first (based on `exp_date`) +- otherwise create a new alias account and persist it + +The API is configured generically via predefined query parameters; only `type: m3u` is supported. +Use the literal value `auto` to fill sensitive values at runtime: +- `api_key: auto` is replaced by `panel_api.api_key` +- in `renew_client`, `username: auto` / `password: auto` are replaced by the account being renewed + +`new_client` must not include a `user` query parameter; tuliprox expects the response to contain `username`/`password` or a `url` that contains them as query parameters. + +`panel_info` is used as a healthcheck and must return JSON with truthy `status` and `credits > 0`, e.g.: +```json +[{"status":"true","credits":"20","enabled":"1"}] +``` + +Example: +```yaml +- sources: +- inputs: + - type: xtream + name: my_provider + url: 'http://provider.net' + username: xyz + password: secret1 + panel_api: + url: 'https://panel.example.tld/api.php' + api_key: '${env:PANEL_API_KEY}' + query_parameter: + panel_info: + - { key: action, value: reseller_info } + - { key: api_key, value: auto } + new_client: + - { key: action, value: new } + - { key: type, value: m3u } + - { key: sub, value: '1' } + - { key: api_key, value: auto } + renew_client: + - { key: action, value: renew } + - { key: type, value: m3u } + - { key: username, value: auto } + - { key: password, value: auto } + - { key: sub, value: '1' } + - { key: api_key, value: auto } +``` + Input aliases can be defined as batches in csv files with `;` separator. There are 2 batch input types `xtream_batch` and `m3u_batch`. diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 8c6c7a2ad..8d522593a 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -9,6 +9,7 @@ use crate::api::model::{ StreamError, ThrottledStream, UserApiRequest, }; use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; +use crate::api::panel_api::try_provision_account_on_exhausted; use crate::model::{ConfigInput, ResourceRetryConfig}; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::lru_cache::LRUResourceCache; @@ -341,11 +342,20 @@ async fn resolve_streaming_strategy( force_provider: Option<&str>, ) -> StreamingStrategy { // allocate a provider connection - let provider_connection_handle = match force_provider { + let mut provider_connection_handle = match force_provider { Some(provider) => app_state.active_provider.force_exact_acquire_connection(provider, &fingerprint.addr).await, None => app_state.active_provider.acquire_connection(&input.name, &fingerprint.addr).await, }; + if provider_connection_handle.is_none() && force_provider.is_none() { + if try_provision_account_on_exhausted(app_state, input).await { + provider_connection_handle = app_state + .active_provider + .acquire_connection(&input.name, &fingerprint.addr) + .await; + } + } + let stream_response_params = if let Some(allocation) = provider_connection_handle.as_ref().map(|ph| &ph.allocation) { match allocation { diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 3789ade60..1741f4570 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod model; pub mod api_utils; +mod panel_api; mod scheduler; mod endpoints; pub mod main_api; @@ -7,4 +8,4 @@ mod config_watch; mod serve; pub(crate) mod hdhomerun_ssdp; pub(crate) mod hdhomerun_proprietary; -mod sys_usage; \ No newline at end of file +mod sys_usage; diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index b25455cce..e29093d2a 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -803,28 +803,29 @@ mod tests { } // Helper function to create a ConfigInput instance - fn create_config_input(id: u16, name: &str, priority: i16, max_connections: u16) -> ConfigInput { - ConfigInput { - id, - name: name.to_string(), - url: "http://example.com".to_string(), - epg: Option::default(), - username: None, - password: None, - persist: None, - enabled: true, - input_type: InputType::Xtream, // You can use a default value here - max_connections, - priority, - aliases: None, - headers: HashMap::default(), - options: None, - method: InputFetchMethod::default(), - staged: None, - exp_date: None, - t_batch_url: None, - } - } + fn create_config_input(id: u16, name: &str, priority: i16, max_connections: u16) -> ConfigInput { + ConfigInput { + id, + name: name.to_string(), + url: "http://example.com".to_string(), + epg: Option::default(), + username: None, + password: None, + persist: None, + enabled: true, + input_type: InputType::Xtream, // You can use a default value here + max_connections, + priority, + aliases: None, + headers: HashMap::default(), + options: None, + method: InputFetchMethod::default(), + staged: None, + exp_date: None, + t_batch_url: None, + panel_api: None, + } + } // Helper function to create a ConfigInputAlias instance fn create_config_input_alias(id: u16, url: &str, priority: i16, max_connections: u16) -> ConfigInputAlias { diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs new file mode 100644 index 000000000..858c4a040 --- /dev/null +++ b/backend/src/api/panel_api.rs @@ -0,0 +1,676 @@ +use crate::api::model::AppState; +use crate::model::{ConfigInput, is_input_expired}; +use crate::utils::debug_if_enabled; +use crate::utils::{get_csv_file_path, read_sources_file}; +use log::{debug, error, warn}; +use serde_json::Value; +use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; +use shared::model::{InputType, PanelApiConfigDto, PanelApiQueryParamDto}; +use shared::utils::{get_credentials_from_url, sanitize_sensitive_info, trim_last_slash}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use url::Url; + +#[derive(Debug, Clone)] +struct AccountCredentials { + name: String, + username: String, + password: String, + exp_date: Option, +} + +fn parse_boolish(value: &Value) -> bool { + match value { + Value::Bool(b) => *b, + Value::Number(n) => n.as_i64().unwrap_or(0) != 0, + Value::String(s) => matches!(s.trim().to_lowercase().as_str(), "true" | "1" | "yes" | "y" | "ok"), + _ => false, + } +} + +fn parse_i64ish(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +fn first_json_object(value: &Value) -> Option<&serde_json::Map> { + match value { + Value::Array(arr) => arr.first().and_then(|v| v.as_object()), + Value::Object(obj) => Some(obj), + _ => None, + } +} + +fn extract_username_password_from_json(obj: &serde_json::Map) -> Option<(String, String)> { + let username = obj.get("username").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()); + let password = obj.get("password").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()); + match (username, password) { + (Some(u), Some(p)) => Some((u.to_string(), p.to_string())), + _ => None, + } +} + +fn extract_username_password_from_url(url_str: &str) -> Option<(String, String)> { + Url::parse(url_str).ok().and_then(|url| { + let (u, p) = get_credentials_from_url(&url); + match (u, p) { + (Some(u), Some(p)) if !u.trim().is_empty() && !p.trim().is_empty() => Some((u, p)), + _ => None, + } + }) +} + +fn extract_base_url(url_str: &str) -> Option { + Url::parse(url_str).ok().map(|u| u.origin().ascii_serialization()) +} + +fn validate_type_is_m3u(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + let typ = params + .iter() + .find(|p| p.key.trim().eq_ignore_ascii_case("type")) + .map(|p| p.value.trim().to_string()); + match typ { + Some(v) if v.eq_ignore_ascii_case("m3u") => Ok(()), + Some(v) => create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: unsupported type={v}, only m3u is supported"), + None => create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: missing required query param 'type=m3u'"), + } +} + +fn validate_new_client_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + validate_type_is_m3u(params)?; + if params.iter().any(|p| p.key.trim().eq_ignore_ascii_case("user")) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client must not contain query param 'user'"); + } + Ok(()) +} + +fn validate_renew_client_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + validate_type_is_m3u(params)?; + let keys: HashSet = params.iter().map(|p| p.key.trim().to_lowercase()).collect(); + if !(keys.contains("username") && keys.contains("password")) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client must contain query params 'username' and 'password' (use value 'auto')"); + } + Ok(()) +} + +fn resolve_query_params( + params: &[PanelApiQueryParamDto], + api_key: Option<&str>, + creds: Option<(&str, &str)>, +) -> Result, TuliproxError> { + let mut out = Vec::with_capacity(params.len()); + for p in params { + let key = p.key.trim(); + if key.is_empty() { + continue; + } + let mut value = p.value.trim().to_string(); + if value.eq_ignore_ascii_case("auto") { + if key.eq_ignore_ascii_case("api_key") { + let Some(k) = api_key.filter(|s| !s.trim().is_empty()) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: query param {key} uses 'auto' but panel_api.api_key is missing"); + }; + value = k.to_string(); + } else if key.eq_ignore_ascii_case("username") { + let Some((u, _)) = creds else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: query param {key} uses 'auto' but no account username is available"); + }; + value = u.to_string(); + } else if key.eq_ignore_ascii_case("password") { + let Some((_, pw)) = creds else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: query param {key} uses 'auto' but no account password is available"); + }; + value = pw.to_string(); + } + } + out.push((key.to_string(), value)); + } + Ok(out) +} + +fn build_panel_url(base_url: &str, query_params: &[(String, String)]) -> Result { + let mut url = Url::parse(base_url).map_err(|e| info_err!(format!("panel_api: invalid url {base_url}: {e}")))?; + { + let mut pairs = url.query_pairs_mut(); + for (k, v) in query_params { + pairs.append_pair(k, v); + } + } + Ok(url) +} + +async fn panel_get_json(app_state: &AppState, url: Url) -> Result { + let client = app_state.http_client.load(); + let sanitized = sanitize_sensitive_info(url.as_str()); + debug_if_enabled!("panel_api request {}", sanitized); + let resp = client + .get(url) + .send() + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api read response failed: {e}")))?; + let json: Value = serde_json::from_str(&body) + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api invalid json (http {status}): {e}")))?; + Ok(json) +} + +async fn panel_healthcheck(app_state: &AppState, cfg: &PanelApiConfigDto) -> Result<(), TuliproxError> { + if cfg.query_parameter.panel_info.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: query_parameter.panel_info is missing/empty"); + } + let params = resolve_query_params(&cfg.query_parameter.panel_info, cfg.api_key.as_deref(), None)?; + let url = build_panel_url(cfg.url.as_str(), ¶ms)?; + let json = panel_get_json(app_state, url).await?; + let Some(obj) = first_json_object(&json) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: panel_info response is not a JSON object/array"); + }; + let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + let credits = obj.get("credits").and_then(parse_i64ish).unwrap_or(0); + if !status_ok || credits <= 0 { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: healthcheck failed (status={status_ok}, credits={credits})"); + } + Ok(()) +} + +async fn panel_new_client(app_state: &AppState, cfg: &PanelApiConfigDto) -> Result<(String, String, Option), TuliproxError> { + validate_new_client_params(&cfg.query_parameter.new_client)?; + let params = resolve_query_params(&cfg.query_parameter.new_client, cfg.api_key.as_deref(), None)?; + let url = build_panel_url(cfg.url.as_str(), ¶ms)?; + let json = panel_get_json(app_state, url).await?; + let Some(obj) = first_json_object(&json) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client response is not a JSON object/array"); + }; + let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + if !status_ok { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client status=false"); + } + if let Some((u, p)) = extract_username_password_from_json(obj) { + return Ok((u, p, None)); + } + if let Some(url_str) = obj.get("url").and_then(|v| v.as_str()) { + if let Some((u, p)) = extract_username_password_from_url(url_str) { + let base = extract_base_url(url_str); + return Ok((u, p, base)); + } + } + create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client response missing username/password (and no parsable url)") +} + +async fn panel_renew_client(app_state: &AppState, cfg: &PanelApiConfigDto, username: &str, password: &str) -> Result<(), TuliproxError> { + validate_renew_client_params(&cfg.query_parameter.renew_client)?; + let params = resolve_query_params( + &cfg.query_parameter.renew_client, + cfg.api_key.as_deref(), + Some((username, password)), + )?; + let url = build_panel_url(cfg.url.as_str(), ¶ms)?; + let json = panel_get_json(app_state, url).await?; + let Some(obj) = first_json_object(&json) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client response is not a JSON object/array"); + }; + let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + if !status_ok { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client status=false"); + } + Ok(()) +} + +fn derive_exp_date_from_params(params: &[PanelApiQueryParamDto]) -> Option { + use chrono::{Duration, Months, Utc}; + let now = Utc::now(); + let raw = params + .iter() + .find(|p| { + let k = p.key.trim(); + k.eq_ignore_ascii_case("sub") || k.eq_ignore_ascii_case("package") + }) + .map(|p| p.value.trim().to_string())?; + let months = raw.parse::().ok()?; + if months <= 0 { + return Some((now + Duration::days(1)).timestamp()); + } + if months == 99 { + return Some((now + Duration::days(1)).timestamp()); + } + let months_u32: u32 = months.try_into().ok()?; + now.checked_add_months(Months::new(months_u32)).map(|dt| dt.timestamp()) +} + +fn extract_account_creds_from_input(input: &ConfigInput) -> Option<(String, String)> { + if let (Some(u), Some(p)) = (input.username.as_deref(), input.password.as_deref()) { + if !u.trim().is_empty() && !p.trim().is_empty() { + return Some((u.to_string(), p.to_string())); + } + } + Url::parse(input.url.as_str()).ok().and_then(|u| { + let (uu, pp) = get_credentials_from_url(&u); + match (uu, pp) { + (Some(uu), Some(pp)) if !uu.trim().is_empty() && !pp.trim().is_empty() => Some((uu, pp)), + _ => None, + } + }) +} + +fn collect_expired_accounts(input: &ConfigInput) -> Vec { + let mut out = Vec::new(); + if is_input_expired(input.exp_date) { + if let Some((u, p)) = extract_account_creds_from_input(input) { + out.push(AccountCredentials { + name: input.name.clone(), + username: u, + password: p, + exp_date: input.exp_date, + }); + } + } + if let Some(aliases) = input.aliases.as_ref() { + for a in aliases { + if is_input_expired(a.exp_date) { + if let (Some(u), Some(p)) = (a.username.as_deref(), a.password.as_deref()) { + if !u.trim().is_empty() && !p.trim().is_empty() { + out.push(AccountCredentials { + name: a.name.clone(), + username: u.to_string(), + password: p.to_string(), + exp_date: a.exp_date, + }); + } + } + } + } + } + out.sort_by_key(|a| a.exp_date.unwrap_or(i64::MAX)); + out +} + +async fn patch_source_yml_add_alias( + source_file_path: &Path, + input_name: &str, + alias_name: &str, + base_url: &str, + username: &str, + password: &str, + exp_date: Option, +) -> Result<(), TuliproxError> { + let raw = tokio::fs::read_to_string(source_file_path) + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}")))?; + let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to parse source file yaml: {e}")))?; + + let Some(root) = doc.as_mapping_mut() else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml root is not a mapping"); + }; + let sources = root.get_mut(serde_yaml::Value::String("sources".to_string())).and_then(|v| v.as_sequence_mut()); + let Some(sources) = sources else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml missing 'sources' list"); + }; + + let mut found_input = None; + for src in sources.iter_mut() { + let Some(src_map) = src.as_mapping_mut() else { continue; }; + let Some(inputs) = src_map.get_mut(serde_yaml::Value::String("inputs".to_string())).and_then(|v| v.as_sequence_mut()) else { continue; }; + for inp in inputs.iter_mut() { + let Some(inp_map) = inp.as_mapping_mut() else { continue; }; + let name = inp_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); + if name == Some(input_name) { + found_input = Some(inp_map); + break; + } + } + if found_input.is_some() { + break; + } + } + let Some(inp_map) = found_input else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find input '{input_name}' in source.yml"); + }; + + let aliases_key = serde_yaml::Value::String("aliases".to_string()); + if !inp_map.contains_key(&aliases_key) { + inp_map.insert(aliases_key.clone(), serde_yaml::Value::Sequence(vec![])); + } + let Some(alias_seq) = inp_map.get_mut(&aliases_key).and_then(|v| v.as_sequence_mut()) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: input.aliases is not a list in source.yml"); + }; + + let mut alias_map = serde_yaml::Mapping::new(); + alias_map.insert(serde_yaml::Value::String("name".to_string()), serde_yaml::Value::String(alias_name.to_string())); + alias_map.insert(serde_yaml::Value::String("url".to_string()), serde_yaml::Value::String(base_url.to_string())); + alias_map.insert(serde_yaml::Value::String("username".to_string()), serde_yaml::Value::String(username.to_string())); + alias_map.insert(serde_yaml::Value::String("password".to_string()), serde_yaml::Value::String(password.to_string())); + alias_map.insert(serde_yaml::Value::String("max_connections".to_string()), serde_yaml::Value::Number(1.into())); + if let Some(ts) = exp_date { + alias_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(ts.into())); + } + alias_seq.push(serde_yaml::Value::Mapping(alias_map)); + + let serialized = serde_yaml::to_string(&doc) + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to serialize source.yml: {e}")))?; + tokio::fs::write(source_file_path, serialized) + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write source.yml: {e}")))?; + Ok(()) +} + +async fn patch_source_yml_update_exp_date( + source_file_path: &Path, + input_name: &str, + account_name: &str, + exp_date: i64, +) -> Result<(), TuliproxError> { + let raw = tokio::fs::read_to_string(source_file_path) + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}")))?; + let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to parse source file yaml: {e}")))?; + let Some(root) = doc.as_mapping_mut() else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml root is not a mapping"); + }; + let sources = root.get_mut(serde_yaml::Value::String("sources".to_string())).and_then(|v| v.as_sequence_mut()); + let Some(sources) = sources else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml missing 'sources' list"); + }; + for src in sources.iter_mut() { + let Some(src_map) = src.as_mapping_mut() else { continue; }; + let Some(inputs) = src_map.get_mut(serde_yaml::Value::String("inputs".to_string())).and_then(|v| v.as_sequence_mut()) else { continue; }; + for inp in inputs.iter_mut() { + let Some(inp_map) = inp.as_mapping_mut() else { continue; }; + let name = inp_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); + if name != Some(input_name) { + continue; + } + if account_name == input_name { + inp_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); + inp_map.insert(serde_yaml::Value::String("enabled".to_string()), serde_yaml::Value::Bool(true)); + } else if let Some(aliases) = inp_map.get_mut(serde_yaml::Value::String("aliases".to_string())).and_then(|v| v.as_sequence_mut()) { + for a in aliases.iter_mut() { + let Some(a_map) = a.as_mapping_mut() else { continue; }; + let a_name = a_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); + if a_name == Some(account_name) { + a_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); + } + } + } + let serialized = serde_yaml::to_string(&doc) + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to serialize source.yml: {e}")))?; + tokio::fs::write(source_file_path, serialized) + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write source.yml: {e}")))?; + return Ok(()); + } + } + create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find account '{account_name}' under input '{input_name}' in source.yml") +} + +async fn patch_batch_csv_append( + csv_path: &Path, + batch_type: InputType, + alias_name: &str, + base_url: &str, + username: &str, + password: &str, + exp_date: Option, +) -> Result<(), TuliproxError> { + let raw = tokio::fs::read_to_string(csv_path).await.unwrap_or_default(); + let mut lines: Vec = raw.lines().map(|l| l.to_string()).collect(); + let header_line_idx = lines.iter().position(|l| l.trim_start().starts_with('#')); + let header = header_line_idx + .and_then(|idx| lines.get(idx).map(|s| s.trim_start_matches('#').trim().to_string())) + .unwrap_or_else(|| match batch_type { + InputType::XtreamBatch => "name;username;password;url;max_connections;priority;exp_date".to_string(), + _ => "url;max_connections;priority;name;username;password;exp_date".to_string(), + }); + let cols: Vec = header.split(';').map(|s| s.trim().to_lowercase()).collect(); + if header_line_idx.is_none() { + lines.insert(0, format!("#{header}")); + } + let mut row: Vec = vec![String::new(); cols.len()]; + for (i, c) in cols.iter().enumerate() { + row[i] = match c.as_str() { + "name" => alias_name.to_string(), + "username" => username.to_string(), + "password" => password.to_string(), + "url" => { + if batch_type == InputType::M3uBatch { + format!( + "{}/get.php?username={}&password={}&type=m3u_plus", + trim_last_slash(base_url), + username, + password + ) + } else { + base_url.to_string() + } + } + "max_connections" => "1".to_string(), + "priority" => "0".to_string(), + "exp_date" => exp_date.map_or(String::new(), |ts| ts.to_string()), + _ => String::new(), + }; + } + lines.push(row.join(";")); + tokio::fs::write(csv_path, lines.join("\n") + "\n") + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write csv: {e}")))?; + Ok(()) +} + +async fn patch_batch_csv_update_exp_date( + csv_path: &Path, + account_name: &str, + username: &str, + password: &str, + exp_date: i64, +) -> Result<(), TuliproxError> { + let raw = tokio::fs::read_to_string(csv_path) + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read csv: {e}")))?; + let mut lines: Vec = raw.lines().map(|l| l.to_string()).collect(); + let header_line_idx = lines.iter().position(|l| l.trim_start().starts_with('#')); + let Some(header_idx) = header_line_idx else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: csv missing header line"); + }; + let header = lines[header_idx].trim_start_matches('#').trim(); + let cols: Vec = header.split(';').map(|s| s.trim().to_lowercase()).collect(); + let exp_idx = cols.iter().position(|c| c == "exp_date"); + let name_idx = cols.iter().position(|c| c == "name"); + let user_idx = cols.iter().position(|c| c == "username"); + let pass_idx = cols.iter().position(|c| c == "password"); + let url_idx = cols.iter().position(|c| c == "url"); + let Some(exp_idx) = exp_idx else { + debug!("panel_api: csv has no exp_date column; skipping exp_date persistence"); + return Ok(()); + }; + + for i in (header_idx + 1)..lines.len() { + let line = lines[i].trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields: Vec = line.split(';').map(|s| s.to_string()).collect(); + fields.resize(cols.len(), String::new()); + + let mut matches = false; + if let Some(n_idx) = name_idx { + if fields.get(n_idx).map(|s| s.trim()) == Some(account_name) { + matches = true; + } + } + if !matches { + if let (Some(u_idx), Some(p_idx)) = (user_idx, pass_idx) { + matches = fields.get(u_idx).map(|s| s.trim()) == Some(username) + && fields.get(p_idx).map(|s| s.trim()) == Some(password); + } else if let Some(u_idx) = url_idx { + if let Some(url_str) = fields.get(u_idx) { + if let Some((u, p)) = extract_username_password_from_url(url_str) { + matches = u == username && p == password; + } + } + } + } + if matches { + fields[exp_idx] = exp_date.to_string(); + lines[i] = fields.join(";"); + tokio::fs::write(csv_path, lines.join("\n") + "\n") + .await + .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write csv: {e}")))?; + return Ok(()); + } + } + warn!("panel_api: could not find batch csv row for account {}", account_name); + Ok(()) +} + +fn derive_unique_alias_name(existing: &[String], input_name: &str, username: &str) -> String { + let base = format!("{input_name}-{username}"); + if !existing.contains(&base) { + return base; + } + for i in 2..1000 { + let cand = format!("{base}-{i}"); + if !existing.contains(&cand) { + return cand; + } + } + base +} + +pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &ConfigInput) -> bool { + let Some(panel_cfg) = input.panel_api.as_ref() else { + return false; + }; + if panel_cfg.url.trim().is_empty() { + return false; + } + + let _input_lock = app_state + .app_config + .file_locks + .write_lock_str(format!("panel_api:{}", input.name).as_str()) + .await; + + if let Err(err) = panel_healthcheck(app_state, panel_cfg).await { + debug_if_enabled!("panel_api healthcheck failed: {}", sanitize_sensitive_info(err.to_string().as_str())); + return false; + } + + let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); + let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); + let sources_path = PathBuf::from(&sources_file_path); + + // prefer renew of expired accounts + let expired = collect_expired_accounts(input); + if !expired.is_empty() { + for acct in &expired { + match panel_renew_client(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()).await { + Ok(()) => { + let new_exp = derive_exp_date_from_params(&panel_cfg.query_parameter.renew_client) + .or_else(|| derive_exp_date_from_params(&panel_cfg.query_parameter.new_client)); + if let Some(new_exp) = new_exp { + if is_batch { + let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); + if let Ok(csv_path) = get_csv_file_path(batch_url) { + let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + if let Err(err) = patch_batch_csv_update_exp_date(&csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { + debug_if_enabled!("panel_api failed to persist renew exp_date to csv: {}", err); + } + } + } else { + let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; + if let Err(err) = patch_source_yml_update_exp_date(&sources_path, &input.name, &acct.name, new_exp).await { + debug_if_enabled!("panel_api failed to persist renew exp_date to source.yml: {}", err); + } + } + } + + // reload sources + provider lineup + if let Err(err) = reload_sources(app_state).await { + debug_if_enabled!("panel_api reload sources failed: {}", err); + } + return true; + } + Err(err) => { + debug_if_enabled!( + "panel_api renew failed for {}: {}", + sanitize_sensitive_info(&acct.name), + sanitize_sensitive_info(err.to_string().as_str()) + ); + continue; + } + } + } + } + + // otherwise create new client and append as alias / batch row + match panel_new_client(app_state, panel_cfg).await { + Ok((username, password, base_url_from_resp)) => { + let base_url = base_url_from_resp.unwrap_or_else(|| input.url.clone()); + let base_url = extract_base_url(base_url.as_str()).unwrap_or_else(|| base_url.clone()); + + let mut existing_names: Vec = vec![input.name.clone()]; + if let Some(aliases) = input.aliases.as_ref() { + existing_names.extend(aliases.iter().map(|a| a.name.clone())); + } + let alias_name = derive_unique_alias_name(&existing_names, &input.name, &username); + + let exp_date = derive_exp_date_from_params(&panel_cfg.query_parameter.new_client) + .or_else(|| derive_exp_date_from_params(&panel_cfg.query_parameter.renew_client)); + + if is_batch { + let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); + match get_csv_file_path(batch_url) { + Ok(csv_path) => { + let batch_type = if input.input_type == InputType::Xtream { + InputType::XtreamBatch + } else { + InputType::M3uBatch + }; + let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + if let Err(err) = patch_batch_csv_append(&csv_path, batch_type, &alias_name, &base_url, &username, &password, exp_date).await { + warn!("panel_api failed to append new account to csv: {}", err); + return false; + } + } + Err(err) => { + warn!("panel_api cannot resolve batch csv path {}: {}", sanitize_sensitive_info(batch_url), err); + return false; + } + } + } else { + let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; + if let Err(err) = patch_source_yml_add_alias(&sources_path, &input.name, &alias_name, &base_url, &username, &password, exp_date).await { + warn!("panel_api failed to persist new alias to source.yml: {}", err); + return false; + } + } + + if let Err(err) = reload_sources(app_state).await { + error!("panel_api reload sources failed: {err}"); + return false; + } + true + } + Err(err) => { + debug_if_enabled!("panel_api new_client failed: {}", sanitize_sensitive_info(err.to_string().as_str())); + false + } + } +} + +async fn reload_sources(app_state: &AppState) -> Result<(), TuliproxError> { + let paths = app_state.app_config.paths.load(); + let sources_file = paths.sources_file_path.as_str(); + let dto = read_sources_file(sources_file, true, true, None)?; + let sources = crate::model::SourcesConfig::try_from(&dto)?; + app_state.app_config.set_sources(sources)?; + app_state.active_provider.update_config(&app_state.app_config).await; + Ok(()) +} diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index e2d8a1040..8425f8170 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -3,7 +3,7 @@ use crate::utils::get_csv_file_path; use chrono::Utc; use log::warn; use shared::error::TuliproxError; -use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; +use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, PanelApiConfigDto, StagedInputDto}; use shared::utils::{get_base_url_from_str, get_credentials_from_url}; use shared::{check_input_connections, info_err, write_if_some}; use shared::check_input_credentials; @@ -142,6 +142,7 @@ pub struct ConfigInput { pub staged: Option, pub exp_date: Option, pub t_batch_url: Option, + pub panel_api: Option, } impl ConfigInput { @@ -242,6 +243,7 @@ impl ConfigInput { staged: None, exp_date: None, t_batch_url: None, + panel_api: self.panel_api.clone(), } } } @@ -268,6 +270,7 @@ impl From<&ConfigInputDto> for ConfigInput { exp_date: dto.exp_date, staged: dto.staged.as_ref().map(StagedInput::from), t_batch_url: None, + panel_api: dto.panel_api.clone(), } } } @@ -305,4 +308,4 @@ pub fn is_input_expired(exp_date: Option) -> bool { } None => false, } -} \ No newline at end of file +} diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index e2347715c..b54690243 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -1,6 +1,7 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::{EpgConfigDto}; use crate::utils::{default_as_true, get_credentials_from_url_str, get_trimmed_string, sanitize_sensitive_info, trim_last_slash, deserialize_timestamp}; +use super::PanelApiConfigDto; use crate::{check_input_credentials, check_input_connections, create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err}; use enum_iterator::Sequence; use std::collections::{HashMap, HashSet}; @@ -300,6 +301,8 @@ pub struct ConfigInputDto { pub staged: Option, #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub exp_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub panel_api: Option, } impl Default for ConfigInputDto { @@ -322,6 +325,7 @@ impl Default for ConfigInputDto { method: InputFetchMethod::default(), staged: None, exp_date: None, + panel_api: None, } } } diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index 0b10eda52..ced7ad4d6 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -12,6 +12,7 @@ mod video_download; mod schedule; mod log; mod input; +mod panel_api; mod stream; mod epg; mod reverse_proxy; @@ -53,6 +54,7 @@ pub use video_download::*; pub use schedule::*; pub use log::*; pub use input::*; +pub use panel_api::*; pub use stream::*; pub use epg_smart_match::*; pub use epg::*; @@ -69,4 +71,4 @@ pub use app_config::*; pub use config_type::*; pub use playlist_update_state::*; pub use favourites::*; -pub use crate::apply_batch_aliases; \ No newline at end of file +pub use crate::apply_batch_aliases; diff --git a/shared/src/model/config/panel_api.rs b/shared/src/model/config/panel_api.rs new file mode 100644 index 000000000..934b05d74 --- /dev/null +++ b/shared/src/model/config/panel_api.rs @@ -0,0 +1,28 @@ +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PanelApiQueryParamDto { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PanelApiQueryParametersDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub panel_info: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub new_client: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub renew_client: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PanelApiConfigDto { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, + #[serde(default)] + pub query_parameter: PanelApiQueryParametersDto, +} + From a09a7f6047483b03063aadcb196b9827a5c872a2 Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:27:18 +0100 Subject: [PATCH 02/27] docs: clarify `panel_api` usage in README Updated the README to clarify the usage of `panel_api` and related parameters. --- README.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d5e8efe48..e032e878f 100644 --- a/README.md +++ b/README.md @@ -694,6 +694,7 @@ Each input has the following attributes: - `method` can be `GET` or `POST` - `username` only mandatory for type `xtream` - `password` only mandatory for type `xtream` +- `panel_api` _optional_ for provider panel api operations - `exp_date` optional, i a date as "YYYY-MM-DD HH:MM:SS" format like `2028-11-30 12:34:12` or Unix timestamp (seconds since epoch) - `options` is optional, + `xtream_skip_live` true or false, live section can be skipped. @@ -826,7 +827,7 @@ Input alias definition for same provider with same content but different credent - name: test ``` -#### `panel_api` (optional) +#### `panel_api` If provider connections are exhausted, tuliprox can optionally call a provider panel API to: - renew expired accounts first (based on `exp_date`) - otherwise create a new alias account and persist it @@ -834,14 +835,12 @@ If provider connections are exhausted, tuliprox can optionally call a provider p The API is configured generically via predefined query parameters; only `type: m3u` is supported. Use the literal value `auto` to fill sensitive values at runtime: - `api_key: auto` is replaced by `panel_api.api_key` -- in `renew_client`, `username: auto` / `password: auto` are replaced by the account being renewed - -`new_client` must not include a `user` query parameter; tuliprox expects the response to contain `username`/`password` or a `url` that contains them as query parameters. - -`panel_info` is used as a healthcheck and must return JSON with truthy `status` and `credits > 0`, e.g.: -```json -[{"status":"true","credits":"20","enabled":"1"}] -``` +- in `client_renew`, `username: auto` / `password: auto` are replaced by the account being renewed +- `panel_info` is used as a healthcheck and must return JSON with truthy `status` and `credits > 0`, e.g.: + ```json + [{"status":"true","credits":"20","enabled":"1"}] + ``` +For `client_new`, the Panel API call would look like this in the example shown: `https://panel.example.tld/api.php?action=new&type=m3u&sub=1&api_key=1234567890` Example: ```yaml @@ -854,17 +853,22 @@ Example: password: secret1 panel_api: url: 'https://panel.example.tld/api.php' - api_key: '${env:PANEL_API_KEY}' + api_key: '1234567890' query_parameter: panel_info: - { key: action, value: reseller_info } - { key: api_key, value: auto } - new_client: + client_info: + - { key: action, value: device_info } + - { key: username, value: auto } + - { key: password, value: auto } + - { key: api_key, value: auto } + client_new: - { key: action, value: new } - { key: type, value: m3u } - { key: sub, value: '1' } - { key: api_key, value: auto } - renew_client: + client_renew: - { key: action, value: renew } - { key: type, value: m3u } - { key: username, value: auto } From ba5c9b7d28c8ef27d8721d530387edc7b159bab3 Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:48:18 +0100 Subject: [PATCH 03/27] docs: improve formatting of client_new API call example Format the Panel API call URL for better readability. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e032e878f..e0905f217 100644 --- a/README.md +++ b/README.md @@ -840,7 +840,9 @@ Use the literal value `auto` to fill sensitive values at runtime: ```json [{"status":"true","credits":"20","enabled":"1"}] ``` -For `client_new`, the Panel API call would look like this in the example shown: `https://panel.example.tld/api.php?action=new&type=m3u&sub=1&api_key=1234567890` +For `client_new`, the Panel API call would look like this in the example shown: + + ```https://panel.example.tld/api.php?action=new&type=m3u&sub=1&api_key=1234567890``` Example: ```yaml From 61f90716039ae54ea977ad10b30a5f0eab74222f Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 18:12:17 +0100 Subject: [PATCH 04/27] feat: added client_info to fetch user information --- README.md | 12 +- backend/src/api/main_api.rs | 3 + backend/src/api/panel_api.rs | 284 +++++++++++++++++++-------- shared/src/model/config/panel_api.rs | 7 +- 4 files changed, 217 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index e0905f217..9df4bce66 100644 --- a/README.md +++ b/README.md @@ -836,10 +836,9 @@ The API is configured generically via predefined query parameters; only `type: m Use the literal value `auto` to fill sensitive values at runtime: - `api_key: auto` is replaced by `panel_api.api_key` - in `client_renew`, `username: auto` / `password: auto` are replaced by the account being renewed -- `panel_info` is used as a healthcheck and must return JSON with truthy `status` and `credits > 0`, e.g.: - ```json - [{"status":"true","credits":"20","enabled":"1"}] - ``` + +`client_info` is used to fetch the exact `exp_date` (via the `expire` field) and is also executed on boot to sync `exp_date` for existing inputs/aliases. + For `client_new`, the Panel API call would look like this in the example shown: ```https://panel.example.tld/api.php?action=new&type=m3u&sub=1&api_key=1234567890``` @@ -857,11 +856,8 @@ Example: url: 'https://panel.example.tld/api.php' api_key: '1234567890' query_parameter: - panel_info: - - { key: action, value: reseller_info } - - { key: api_key, value: auto } client_info: - - { key: action, value: device_info } + - { key: action, value: client_info } - { key: username, value: auto } - { key: password, value: auto } - { key: api_key, value: auto } diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 14d6bf901..15e8fdfc8 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -12,6 +12,7 @@ use crate::api::endpoints::xtream_api::xtream_api_register; use crate::api::hdhomerun_proprietary::spawn_proprietary_tasks; use crate::api::hdhomerun_ssdp::spawn_ssdp_discover_task; use crate::api::model::{create_cache, create_http_client, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, HdHomerunAppState, PlaylistStorageState, SharedStreamManager}; +use crate::api::panel_api::sync_panel_api_exp_dates_on_boot; use crate::api::scheduler::exec_scheduler; use crate::api::serve::serve; use crate::model::{AppConfig, Config, Healthcheck, ProcessTargets, RateLimitConfig}; @@ -289,6 +290,8 @@ pub async fn start_server( &targets, ); + sync_panel_api_exp_dates_on_boot(&app_state).await; + exec_file_lock_prune(&app_state); exec_config_watch(&app_state, &cancel_token_file_watch); diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 858c4a040..40a161295 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -6,9 +6,9 @@ use log::{debug, error, warn}; use serde_json::Value; use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; use shared::model::{InputType, PanelApiConfigDto, PanelApiQueryParamDto}; -use shared::utils::{get_credentials_from_url, sanitize_sensitive_info, trim_last_slash}; -use std::collections::HashSet; +use shared::utils::{get_credentials_from_url, parse_timestamp, sanitize_sensitive_info, trim_last_slash}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use url::Url; #[derive(Debug, Clone)] @@ -28,14 +28,6 @@ fn parse_boolish(value: &Value) -> bool { } } -fn parse_i64ish(value: &Value) -> Option { - match value { - Value::Number(n) => n.as_i64(), - Value::String(s) => s.trim().parse::().ok(), - _ => None, - } -} - fn first_json_object(value: &Value) -> Option<&serde_json::Map> { match value { Value::Array(arr) => arr.first().and_then(|v| v.as_object()), @@ -79,20 +71,84 @@ fn validate_type_is_m3u(params: &[PanelApiQueryParamDto]) -> Result<(), Tuliprox } } -fn validate_new_client_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { - validate_type_is_m3u(params)?; - if params.iter().any(|p| p.key.trim().eq_ignore_ascii_case("user")) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client must not contain query param 'user'"); +fn require_api_key_param(params: &[PanelApiQueryParamDto], section: &str) -> Result<(), TuliproxError> { + let api_key = params.iter().find(|p| p.key.trim().eq_ignore_ascii_case("api_key")); + let Some(api_key) = api_key else { + return create_tuliprox_error_result!( + TuliproxErrorKind::Info, + "panel_api: {section} must contain query param 'api_key' (use value 'auto')" + ); + }; + if api_key.value.trim().is_empty() { + return create_tuliprox_error_result!( + TuliproxErrorKind::Info, + "panel_api: {section} query param 'api_key' must not be empty (use value 'auto')" + ); } Ok(()) } -fn validate_renew_client_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { - validate_type_is_m3u(params)?; - let keys: HashSet = params.iter().map(|p| p.key.trim().to_lowercase()).collect(); - if !(keys.contains("username") && keys.contains("password")) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client must contain query params 'username' and 'password' (use value 'auto')"); +fn require_username_password_params_auto(params: &[PanelApiQueryParamDto], section: &str) -> Result<(), TuliproxError> { + let username = params.iter().find(|p| p.key.trim().eq_ignore_ascii_case("username")); + let password = params.iter().find(|p| p.key.trim().eq_ignore_ascii_case("password")); + if username.is_none() || password.is_none() { + return create_tuliprox_error_result!( + TuliproxErrorKind::Info, + "panel_api: {section} must contain query params 'username' and 'password' (use value 'auto')" + ); } + if !username.is_some_and(|p| p.value.trim().eq_ignore_ascii_case("auto")) + || !password.is_some_and(|p| p.value.trim().eq_ignore_ascii_case("auto")) + { + return create_tuliprox_error_result!( + TuliproxErrorKind::Info, + "panel_api: {section} requires 'username: auto' and 'password: auto' (credentials must not be hardcoded)" + ); + } + Ok(()) +} + +fn validate_client_new_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + require_api_key_param(params, "query_parameter.client_new")?; + validate_type_is_m3u(params)?; + if params.iter().any(|p| p.key.trim().eq_ignore_ascii_case("user")) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new must not contain query param 'user'"); + } + Ok(()) +} + +fn validate_client_renew_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + require_api_key_param(params, "query_parameter.client_renew")?; + validate_type_is_m3u(params)?; + require_username_password_params_auto(params, "query_parameter.client_renew")?; + Ok(()) +} + +fn validate_client_info_params(params: &[PanelApiQueryParamDto]) -> Result<(), TuliproxError> { + require_api_key_param(params, "query_parameter.client_info")?; + require_username_password_params_auto(params, "query_parameter.client_info")?; + Ok(()) +} + +fn validate_panel_api_config(cfg: &PanelApiConfigDto) -> Result<(), TuliproxError> { + if cfg.url.trim().is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: url is missing"); + } + if cfg.api_key.as_ref().is_none_or(|k| k.trim().is_empty()) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: api_key is missing"); + } + if cfg.query_parameter.client_info.is_empty() + || cfg.query_parameter.client_new.is_empty() + || cfg.query_parameter.client_renew.is_empty() + { + return create_tuliprox_error_result!( + TuliproxErrorKind::Info, + "panel_api: query_parameter.client_info/client_new/client_renew must be configured" + ); + } + validate_client_info_params(&cfg.query_parameter.client_info)?; + validate_client_new_params(&cfg.query_parameter.client_new)?; + validate_client_renew_params(&cfg.query_parameter.client_renew)?; Ok(()) } @@ -161,35 +217,17 @@ async fn panel_get_json(app_state: &AppState, url: Url) -> Result Result<(), TuliproxError> { - if cfg.query_parameter.panel_info.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: query_parameter.panel_info is missing/empty"); - } - let params = resolve_query_params(&cfg.query_parameter.panel_info, cfg.api_key.as_deref(), None)?; +async fn panel_client_new(app_state: &AppState, cfg: &PanelApiConfigDto) -> Result<(String, String, Option), TuliproxError> { + validate_client_new_params(&cfg.query_parameter.client_new)?; + let params = resolve_query_params(&cfg.query_parameter.client_new, cfg.api_key.as_deref(), None)?; let url = build_panel_url(cfg.url.as_str(), ¶ms)?; let json = panel_get_json(app_state, url).await?; let Some(obj) = first_json_object(&json) else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: panel_info response is not a JSON object/array"); - }; - let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); - let credits = obj.get("credits").and_then(parse_i64ish).unwrap_or(0); - if !status_ok || credits <= 0 { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: healthcheck failed (status={status_ok}, credits={credits})"); - } - Ok(()) -} - -async fn panel_new_client(app_state: &AppState, cfg: &PanelApiConfigDto) -> Result<(String, String, Option), TuliproxError> { - validate_new_client_params(&cfg.query_parameter.new_client)?; - let params = resolve_query_params(&cfg.query_parameter.new_client, cfg.api_key.as_deref(), None)?; - let url = build_panel_url(cfg.url.as_str(), ¶ms)?; - let json = panel_get_json(app_state, url).await?; - let Some(obj) = first_json_object(&json) else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client response is not a JSON object/array"); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new response is not a JSON object/array"); }; let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); if !status_ok { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client status=false"); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new status=false"); } if let Some((u, p)) = extract_username_password_from_json(obj) { return Ok((u, p, None)); @@ -200,47 +238,46 @@ async fn panel_new_client(app_state: &AppState, cfg: &PanelApiConfigDto) -> Resu return Ok((u, p, base)); } } - create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: new_client response missing username/password (and no parsable url)") + create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new response missing username/password (and no parsable url)") } -async fn panel_renew_client(app_state: &AppState, cfg: &PanelApiConfigDto, username: &str, password: &str) -> Result<(), TuliproxError> { - validate_renew_client_params(&cfg.query_parameter.renew_client)?; +async fn panel_client_renew(app_state: &AppState, cfg: &PanelApiConfigDto, username: &str, password: &str) -> Result<(), TuliproxError> { + validate_client_renew_params(&cfg.query_parameter.client_renew)?; let params = resolve_query_params( - &cfg.query_parameter.renew_client, + &cfg.query_parameter.client_renew, cfg.api_key.as_deref(), Some((username, password)), )?; let url = build_panel_url(cfg.url.as_str(), ¶ms)?; let json = panel_get_json(app_state, url).await?; let Some(obj) = first_json_object(&json) else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client response is not a JSON object/array"); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_renew response is not a JSON object/array"); }; let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); if !status_ok { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: renew_client status=false"); + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_renew status=false"); } Ok(()) } -fn derive_exp_date_from_params(params: &[PanelApiQueryParamDto]) -> Option { - use chrono::{Duration, Months, Utc}; - let now = Utc::now(); - let raw = params - .iter() - .find(|p| { - let k = p.key.trim(); - k.eq_ignore_ascii_case("sub") || k.eq_ignore_ascii_case("package") - }) - .map(|p| p.value.trim().to_string())?; - let months = raw.parse::().ok()?; - if months <= 0 { - return Some((now + Duration::days(1)).timestamp()); +async fn panel_client_info(app_state: &AppState, cfg: &PanelApiConfigDto, username: &str, password: &str) -> Result, TuliproxError> { + validate_client_info_params(&cfg.query_parameter.client_info)?; + let params = resolve_query_params( + &cfg.query_parameter.client_info, + cfg.api_key.as_deref(), + Some((username, password)), + )?; + let url = build_panel_url(cfg.url.as_str(), ¶ms)?; + let json = panel_get_json(app_state, url).await?; + let Some(obj) = first_json_object(&json) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_info response is not a JSON object/array"); + }; + let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + if !status_ok { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_info status=false"); } - if months == 99 { - return Some((now + Duration::days(1)).timestamp()); - } - let months_u32: u32 = months.try_into().ok()?; - now.checked_add_months(Months::new(months_u32)).map(|dt| dt.timestamp()) + let expire = obj.get("expire").and_then(|v| v.as_str()).unwrap_or_default(); + Ok(parse_timestamp(expire).ok().flatten()) } fn extract_account_creds_from_input(input: &ConfigInput) -> Option<(String, String)> { @@ -557,8 +594,8 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co .write_lock_str(format!("panel_api:{}", input.name).as_str()) .await; - if let Err(err) = panel_healthcheck(app_state, panel_cfg).await { - debug_if_enabled!("panel_api healthcheck failed: {}", sanitize_sensitive_info(err.to_string().as_str())); + if let Err(err) = validate_panel_api_config(panel_cfg) { + debug_if_enabled!("panel_api config invalid: {}", sanitize_sensitive_info(err.to_string().as_str())); return false; } @@ -570,11 +607,13 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co let expired = collect_expired_accounts(input); if !expired.is_empty() { for acct in &expired { - match panel_renew_client(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()).await { + match panel_client_renew(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()).await { Ok(()) => { - let new_exp = derive_exp_date_from_params(&panel_cfg.query_parameter.renew_client) - .or_else(|| derive_exp_date_from_params(&panel_cfg.query_parameter.new_client)); - if let Some(new_exp) = new_exp { + let new_exp = panel_client_info(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()) + .await + .ok() + .flatten(); + if let Some(new_exp) = new_exp.or_else(|| acct.exp_date) { if is_batch { let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); if let Ok(csv_path) = get_csv_file_path(batch_url) { @@ -599,7 +638,7 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co } Err(err) => { debug_if_enabled!( - "panel_api renew failed for {}: {}", + "panel_api client_renew failed for {}: {}", sanitize_sensitive_info(&acct.name), sanitize_sensitive_info(err.to_string().as_str()) ); @@ -610,7 +649,7 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co } // otherwise create new client and append as alias / batch row - match panel_new_client(app_state, panel_cfg).await { + match panel_client_new(app_state, panel_cfg).await { Ok((username, password, base_url_from_resp)) => { let base_url = base_url_from_resp.unwrap_or_else(|| input.url.clone()); let base_url = extract_base_url(base_url.as_str()).unwrap_or_else(|| base_url.clone()); @@ -621,8 +660,10 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co } let alias_name = derive_unique_alias_name(&existing_names, &input.name, &username); - let exp_date = derive_exp_date_from_params(&panel_cfg.query_parameter.new_client) - .or_else(|| derive_exp_date_from_params(&panel_cfg.query_parameter.renew_client)); + let exp_date = panel_client_info(app_state, panel_cfg, &username, &password) + .await + .ok() + .flatten(); if is_batch { let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); @@ -659,12 +700,101 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co true } Err(err) => { - debug_if_enabled!("panel_api new_client failed: {}", sanitize_sensitive_info(err.to_string().as_str())); + debug_if_enabled!("panel_api client_new failed: {}", sanitize_sensitive_info(err.to_string().as_str())); false } } } +pub(crate) async fn sync_panel_api_exp_dates_on_boot(app_state: &Arc) { + let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); + let sources_path = PathBuf::from(&sources_file_path); + let mut any_change = false; + + let sources = app_state.app_config.sources.load(); + for source in &sources.sources { + for input in &source.inputs { + let Some(panel_cfg) = input.panel_api.as_ref() else { continue; }; + if panel_cfg.url.trim().is_empty() { + continue; + } + if let Err(err) = validate_panel_api_config(panel_cfg) { + debug_if_enabled!( + "panel_api boot sync skipped for {}: {}", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(err.to_string().as_str()) + ); + continue; + } + + let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); + let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); + let csv_path = if is_batch { get_csv_file_path(batch_url).ok() } else { None }; + + let mut accounts: Vec = vec![]; + if let Some((u, p)) = extract_account_creds_from_input(input.as_ref()) { + accounts.push(AccountCredentials { + name: input.name.clone(), + username: u, + password: p, + exp_date: input.exp_date, + }); + } + if let Some(aliases) = input.aliases.as_ref() { + for a in aliases { + if let (Some(u), Some(p)) = (a.username.as_deref(), a.password.as_deref()) { + accounts.push(AccountCredentials { + name: a.name.clone(), + username: u.to_string(), + password: p.to_string(), + exp_date: a.exp_date, + }); + } + } + } + + for acct in &accounts { + let new_exp = match panel_client_info(app_state.as_ref(), panel_cfg, &acct.username, &acct.password).await { + Ok(v) => v, + Err(err) => { + debug_if_enabled!( + "panel_api client_info failed for {}: {}", + sanitize_sensitive_info(&acct.name), + sanitize_sensitive_info(err.to_string().as_str()) + ); + None + } + }; + let Some(new_exp) = new_exp else { continue; }; + if acct.exp_date == Some(new_exp) { + continue; + } + + if let Some(csv_path) = csv_path.as_ref() { + let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + if let Err(err) = patch_batch_csv_update_exp_date(csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { + debug_if_enabled!("panel_api boot sync failed to persist exp_date to csv: {}", err); + continue; + } + } else { + let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; + if let Err(err) = patch_source_yml_update_exp_date(&sources_path, &input.name, &acct.name, new_exp).await { + debug_if_enabled!("panel_api boot sync failed to persist exp_date to source.yml: {}", err); + continue; + } + } + any_change = true; + } + } + } + + if any_change { + if let Err(err) = reload_sources(app_state).await { + debug_if_enabled!("panel_api boot sync reload sources failed: {}", err); + } + } +} + async fn reload_sources(app_state: &AppState) -> Result<(), TuliproxError> { let paths = app_state.app_config.paths.load(); let sources_file = paths.sources_file_path.as_str(); diff --git a/shared/src/model/config/panel_api.rs b/shared/src/model/config/panel_api.rs index 934b05d74..701dfb403 100644 --- a/shared/src/model/config/panel_api.rs +++ b/shared/src/model/config/panel_api.rs @@ -9,11 +9,11 @@ pub struct PanelApiQueryParamDto { #[serde(deny_unknown_fields)] pub struct PanelApiQueryParametersDto { #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub panel_info: Vec, + pub client_info: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub new_client: Vec, + pub client_new: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub renew_client: Vec, + pub client_renew: Vec, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] @@ -25,4 +25,3 @@ pub struct PanelApiConfigDto { #[serde(default)] pub query_parameter: PanelApiQueryParametersDto, } - From 30d4a07e0fd215cb936ff22e5ee97227fcc54199 Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:31:07 +0100 Subject: [PATCH 05/27] feat: added frontend view for panel api --- backend/src/api/endpoints/v1_api_config.rs | 35 +- backend/src/utils/file/config_reader.rs | 4 + frontend/public/assets/i18n/en.json | 17 + .../app/components/config/_config_view.scss | 156 ++++- .../src/app/components/config/config_page.rs | 11 +- .../app/components/config/config_update.rs | 3 +- .../src/app/components/config/config_view.rs | 114 ++-- frontend/src/app/components/config/mod.rs | 4 +- .../components/config/panel_config_view.rs | 546 ++++++++++++++++++ frontend/src/services/config_service.rs | 22 +- 10 files changed, 870 insertions(+), 42 deletions(-) create mode 100644 frontend/src/app/components/config/panel_config_view.rs diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 7834e430a..b8bc9b075 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -3,7 +3,7 @@ use crate::model::{ApiProxyConfig, InputSource}; use axum::response::IntoResponse; use axum::Router; use serde_json::json; -use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto}; +use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, SourcesConfigDto}; use std::sync::Arc; use log::error; use shared::error::TuliproxError; @@ -52,6 +52,38 @@ async fn save_config_main( } } +async fn save_config_sources( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(mut sources): axum::extract::Json, +) -> impl axum::response::IntoResponse + Send { + if let Err(err) = sources.prepare(false, None) { + return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + let paths = app_state.app_config.paths.load(); + let file_path = paths.sources_file_path.as_str(); + let config = app_state.app_config.config.load(); + let backup_dir = config.get_backup_dir(); + match utils::save_sources_config(file_path, backup_dir.as_ref(), &sources).await { + Ok(()) => {} + Err(err) => { + error!("Failed to save source.yml {err}"); + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + } + + // update runtime (hot reload semantics like panel_api reload) + match crate::model::SourcesConfig::try_from(&sources) { + Ok(src) => { + if let Err(err) = app_state.app_config.set_sources(src) { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + app_state.active_provider.update_config(&app_state.app_config).await; + axum::http::StatusCode::OK.into_response() + } + Err(err) => (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(), + } +} + async fn save_config_api_proxy_config( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(mut req_api_proxy): axum::extract::Json>, @@ -143,5 +175,6 @@ pub fn v1_api_config_register(router: Router>) -> axum::Router Result<(), TuliproxError> { + write_config_file(file_path, backup_dir, config, "source.yml").await +} + pub fn resolve_env_var(value: &str) -> String { if value.is_empty() { return String::new(); diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 9a295eb1c..60bf95658 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -330,6 +330,7 @@ "EXPORT_STYLE": "Export Style", "STRM_PROPS": "Properties", "ADD_PROPERTY": "Add Property", + "API_KEY": "API-Key", "TRAKT_API_KEY": "API-Key", "TRAKT_API_VERSION": "API-Version", "TRAKT_API_URL": "API-URL", @@ -344,6 +345,14 @@ "API_CONFIGURATION" : "API Configuration", "HEADER_NAME": "Header Name", "HEADER_VALUE": "Header Value", + "PANEL": "Panel", + "PANEL_CLIENT_INFO": "Client info", + "PANEL_CLIENT_NEW": "Client new", + "PANEL_CLIENT_RENEW": "Client renew", + "PANEL_STATUS_READY": "Ready", + "PANEL_STATUS_INVALID": "Invalid", + "PANEL_STATUS_DISABLED": "Disabled", + "VALIDATION": "Validation", "TEMPLATES": "Templates" }, "TITLE": { @@ -388,6 +397,10 @@ "SUCCESS": "Main config saved!", "FAIL": "Failed to save the main config!" }, + "SOURCES_CONFIG": { + "SUCCESS": "Sources config saved!", + "FAIL": "Failed to save the sources config!" + }, "BOUQUET": { "SUCCESS": "Successfully saved bouquet", "FAIL": "Failed to save the bouquet!" @@ -446,6 +459,10 @@ }, "WEB_UI": { "PATH": "Configuration of a path prefix, allowing it to be accessed behind a reverse proxy under a custom subpath. like https://yourdomain.com/tuliprox" + }, + "PANEL": { + "INFO": "Configure provisioning via a generic provider panel API per input. Use query param value 'auto' for api_key and credentials where required.", + "ENABLE": "Enable panel API for this input to configure provisioning." } } }, diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index 6842ef861..dfef4480c 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -257,4 +257,158 @@ box-sizing: border-box; align-items: center; } -} \ No newline at end of file +} + +.tp__panel-config-view { + &__info-text { + color: var(--modest-text-color); + line-height: 1.35; + } + + &__input-header { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--gap-default); + box-sizing: border-box; + width: 100%; + } + + &__input-title { + display: flex; + flex-flow: column; + gap: var(--gap-micro); + } + + &__input-subtitle { + color: var(--modest-text-color); + font-size: 0.9rem; + } + + &__input-status { + display: flex; + flex-flow: column; + align-items: flex-end; + gap: var(--gap-small); + } + + &__toggle { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: var(--gap-small); + } + + &__disabled-hint { + color: var(--modest-text-color); + border: 1px dashed var(--card-border-color); + border-radius: var(--border-radius); + padding: var(--padding-small); + } + + &__summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--gap-default); + width: 100%; + + @media (max-width: size.$mobile-breakpoint) { + grid-template-columns: 1fr; + } + } + + &__section { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding-top: var(--padding-default); + border-top: 1px solid var(--card-border-color); + } + + &__section-header { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + gap: var(--gap-default); + + h2 { + font-size: 1.05rem; + margin: 0; + } + } + + &__section-actions { + display: flex; + flex-flow: row nowrap; + gap: var(--gap-small); + } + + &__params { + display: flex; + flex-flow: column; + gap: var(--gap-small); + } + + &__param-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--gap-small); + align-items: flex-end; + + .tp__form-field { + min-width: 0; + } + } + + &__param-row--view { + grid-template-columns: 1fr; + align-items: stretch; + } + + &__param-remove { + margin-bottom: 0.35rem; + } + + &__param-view { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + gap: var(--gap-default); + border: 1px solid var(--card-border-color); + border-radius: var(--border-radius); + padding: var(--padding-small); + width: 100%; + + .k { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--label-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 40%; + } + + .v { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--modest-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 60%; + } + } + + &__errors { + border-top: 1px solid var(--card-border-color); + padding-top: var(--padding-default); + color: var(--warn-color); + + ul { + padding-left: 1.25rem; + margin: 0; + } + } +} diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index 023f8d5dc..09d7f4bac 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,6 +1,6 @@ use shared::error::TuliproxError; use shared::info_err; -use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, VideoConfigDto, WebUiConfigDto}; +use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto}; use std::fmt; use std::str::FromStr; @@ -15,6 +15,7 @@ pub const LABEL_HDHOMERUN_CONFIG: &str = "LABEL.HDHOMERUN_CONFIG"; pub const LABEL_PROXY_CONFIG: &str = "LABEL.PROXY"; pub const LABEL_IP_CHECK_CONFIG: &str = "LABEL.IP_CHECK"; pub const LABEL_VIDEO_CONFIG: &str = "LABEL.VIDEO"; +pub const LABEL_PANEL_CONFIG: &str = "LABEL.PANEL"; const MAIN_PAGE: &str = "main"; const API_PAGE: &str = "api"; @@ -27,6 +28,7 @@ const HDHOMERUN_PAGE: &str = "hdhomerun"; const PROXY_PAGE: &str = "proxy"; const IPCHECK_PAGE: &str = "ipcheck"; const VIDEO_PAGE: &str = "video"; +const PANEL_PAGE: &str = "panel"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum ConfigPage { @@ -41,6 +43,7 @@ pub enum ConfigPage { HdHomerun, Proxy, IpCheck, + Panel, } impl FromStr for ConfigPage { @@ -59,6 +62,7 @@ impl FromStr for ConfigPage { HDHOMERUN_PAGE => Ok(ConfigPage::HdHomerun), PROXY_PAGE => Ok(ConfigPage::Proxy), IPCHECK_PAGE => Ok(ConfigPage::IpCheck), + PANEL_PAGE => Ok(ConfigPage::Panel), _ => Err(info_err!(format!("Unknown config page: {s}"))), } } @@ -78,6 +82,7 @@ impl fmt::Display for ConfigPage { ConfigPage::HdHomerun => HDHOMERUN_PAGE, ConfigPage::Proxy => PROXY_PAGE, ConfigPage::IpCheck => IPCHECK_PAGE, + ConfigPage::Panel => PANEL_PAGE, }; write!(f, "{s}") } @@ -96,6 +101,7 @@ pub enum ConfigForm { HdHomerun(bool, HdHomeRunConfigDto), Proxy(bool, ProxyConfigDto), IpCheck(bool, IpCheckConfigDto), + Panel(bool, SourcesConfigDto), } impl ConfigForm { @@ -110,6 +116,7 @@ impl ConfigForm { | ConfigForm::ReverseProxy(true, _) | ConfigForm::HdHomerun(true, _) | ConfigForm::Proxy(true, _) - | ConfigForm::IpCheck(true, _)) + | ConfigForm::IpCheck(true, _) + | ConfigForm::Panel(true, _)) } } diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 23f63a5c3..73aff9f90 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -32,6 +32,7 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { ConfigForm::HdHomerun(_, mut hdhr_cfg) => set_config_field!(config, hdhr_cfg, hdhomerun), ConfigForm::Proxy(_, mut proxy_cfg) => set_config_field!(config, proxy_cfg, proxy), ConfigForm::IpCheck(_, mut ipcheck_cfg) => set_config_field!(config, ipcheck_cfg, ipcheck), + ConfigForm::Panel(_, _) => {} } } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index 8966e6910..efd210a34 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -1,13 +1,13 @@ -use crate::app::components::config::config_page::{ConfigForm, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG}; +use crate::app::components::config::config_page::{ConfigForm, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_PANEL_CONFIG, LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::{ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, MessagingConfigView, ProxyConfigView, ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView}; +use crate::app::components::config::{ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, MessagingConfigView, PanelConfigView, ProxyConfigView, ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView}; use crate::app::components::{Card, TabItem, TabSet, TextButton}; use crate::html_if; use std::str::FromStr; use yew::platform::spawn_local; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::ConfigDto; +use shared::model::{ConfigDto, SourcesConfigDto}; use crate::app::components::config::config_update::update_config; use crate::app::{ConfigContext}; use crate::hooks::use_service_context; @@ -44,7 +44,8 @@ fn config_form_to_config_page(form: &ConfigForm) -> ConfigPage { ConfigForm::ReverseProxy(_, _) => ConfigPage::ReverseProxy, ConfigForm::HdHomerun(_, _) => ConfigPage::HdHomerun, ConfigForm::Proxy(_, _) => ConfigPage::Proxy, - ConfigForm::IpCheck(_, _) => ConfigPage::IpCheck + ConfigForm::IpCheck(_, _) => ConfigPage::IpCheck, + ConfigForm::Panel(_, _) => ConfigPage::Panel, } } @@ -61,6 +62,7 @@ struct ConfigFormState { pub hd_homerun: Option, pub proxy: Option, pub ipcheck: Option, + pub panel: Option, } #[function_component] @@ -91,7 +93,7 @@ pub fn ConfigView() -> Html { let forms: &ConfigFormState = forms; let modified_pages = collect_modified!(forms, [ main, api, log, schedules, video, messaging, web_ui, - reverse_proxy, hd_homerun, proxy, ipcheck + reverse_proxy, hd_homerun, proxy, ipcheck, panel ]).iter() .map(config_form_to_config_page) .collect::>(); @@ -107,6 +109,7 @@ pub fn ConfigView() -> Html { (ConfigPage::HdHomerun, LABEL_HDHOMERUN_CONFIG, html! { }, "HdHomerunConfig"), (ConfigPage::Proxy, LABEL_PROXY_CONFIG, html! { }, "ProxyConfig"), (ConfigPage::IpCheck, LABEL_IP_CHECK_CONFIG, html! { }, "IpCheckConfig"), + (ConfigPage::Panel, LABEL_PANEL_CONFIG, html! { }, "Settings"), (ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { }, "VideoConfig"), ]; @@ -143,39 +146,81 @@ pub fn ConfigView() -> Html { let forms = &*get_form_state; let modified_forms: Vec = collect_modified!(forms, [ main, api, log, schedules, video, messaging, web_ui, - reverse_proxy, hd_homerun, proxy, ipcheck + reverse_proxy, hd_homerun, proxy, ipcheck, panel ]); - if !modified_forms.is_empty() { - let mut config_dto = config_ctx.config.as_ref().map_or_else(ConfigDto::default, - |app_cfg| app_cfg.config.clone()); - update_config(&mut config_dto, modified_forms); - match config_dto.prepare(false) { - Ok(_) => { - let services = services.clone(); - let translate = translate.clone(); - let set_edit_mode = set_edit_mode.clone(); - spawn_local(async move { - match services.config.save_config(config_dto).await{ - Ok(()) => { - services.toastr.success(translate.t("MESSAGES.SAVE.MAIN_CONFIG.SUCCESS")); - set_edit_mode.set(false); - let _cfg = services.config.get_server_config().await; - }, - Err(err) => { - services.toastr.error(translate.t("MESSAGES.SAVE.MAIN_CONFIG.FAIL")); - services.toastr.error(err.to_string()); - } - } - }); - } - Err(err) => { - services.toastr.error(err.to_string()); + if modified_forms.is_empty() { + set_edit_mode.set(false); + return; + } + + let mut modified_main_forms = Vec::new(); + let mut modified_sources: Option = None; + for form in modified_forms { + match form { + ConfigForm::Panel(_, sources) => modified_sources = Some(sources), + other => modified_main_forms.push(other), + } + } + + let mut modified_main_dto: Option = None; + if !modified_main_forms.is_empty() { + let mut config_dto = config_ctx + .config + .as_ref() + .map_or_else(ConfigDto::default, |app_cfg| app_cfg.config.clone()); + update_config(&mut config_dto, modified_main_forms); + if let Err(err) = config_dto.prepare(false) { + services.toastr.error(err.to_string()); + return; + } + modified_main_dto = Some(config_dto); + } + + if let Some(sources) = modified_sources.as_mut() { + if let Err(err) = sources.prepare(false, None) { + services.toastr.error(err.to_string()); + return; + } + } + + let services = services.clone(); + let translate = translate.clone(); + let set_edit_mode = set_edit_mode.clone(); + spawn_local(async move { + let mut ok = true; + + if let Some(config_dto) = modified_main_dto { + match services.config.save_config(config_dto).await { + Ok(()) => { + services.toastr.success(translate.t("MESSAGES.SAVE.MAIN_CONFIG.SUCCESS")); + } + Err(err) => { + ok = false; + services.toastr.error(translate.t("MESSAGES.SAVE.MAIN_CONFIG.FAIL")); + services.toastr.error(err.to_string()); + } } } - } else { - set_edit_mode.set(false); - } + + if let Some(sources_dto) = modified_sources { + match services.config.save_sources(sources_dto).await { + Ok(()) => { + services.toastr.success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")); + } + Err(err) => { + ok = false; + services.toastr.error(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.FAIL")); + services.toastr.error(err.to_string()); + } + } + } + + if ok { + set_edit_mode.set(false); + let _cfg = services.config.get_server_config().await; + } + }); }) }; @@ -196,6 +241,7 @@ pub fn ConfigView() -> Html { ConfigForm::HdHomerun(_, _) => new_state.hd_homerun = Some(form_data), ConfigForm::Proxy(_, _) => new_state.proxy = Some(form_data), ConfigForm::IpCheck(_, _) => new_state.ipcheck = Some(form_data), + ConfigForm::Panel(_, _) => new_state.panel = Some(form_data), }; set_form_state.set(new_state); }) diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 0c5778e08..2a3cf3791 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -16,6 +16,7 @@ mod config_page; mod hdhomerun_device_view; mod config_update; mod log_config_view; +mod panel_config_view; pub use config_view::*; pub use main_config_view::*; @@ -29,4 +30,5 @@ pub use proxy_config_view::*; pub use ipcheck_config_view::*; pub use video_config_view::*; pub use log_config_view::*; -pub use macros::*; \ No newline at end of file +pub use panel_config_view::*; +pub use macros::*; diff --git a/frontend/src/app/components/config/panel_config_view.rs b/frontend/src/app/components/config/panel_config_view.rs new file mode 100644 index 000000000..233e866d8 --- /dev/null +++ b/frontend/src/app/components/config/panel_config_view.rs @@ -0,0 +1,546 @@ +use crate::app::components::input::Input; +use crate::app::components::{Card, Chip, IconButton, ToggleSwitch}; +use crate::app::components::config::config_page::{ConfigForm, LABEL_PANEL_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::context::ConfigContext; +use crate::html_if; +use shared::model::{ConfigInputDto, PanelApiConfigDto, PanelApiQueryParamDto, SourcesConfigDto}; +use std::rc::Rc; +use yew::prelude::*; +use yew_i18n::use_translation; + +const LABEL_ENABLED: &str = "LABEL.ENABLED"; +const LABEL_URL: &str = "LABEL.URL"; +const LABEL_API_KEY: &str = "LABEL.API_KEY"; +const LABEL_STATUS_READY: &str = "LABEL.PANEL_STATUS_READY"; +const LABEL_STATUS_INVALID: &str = "LABEL.PANEL_STATUS_INVALID"; +const LABEL_STATUS_DISABLED: &str = "LABEL.PANEL_STATUS_DISABLED"; +const LABEL_VALIDATION: &str = "LABEL.VALIDATION"; +const HINT_PANEL_INFO: &str = "HINT.CONFIG.PANEL.INFO"; +const HINT_PANEL_ENABLE: &str = "HINT.CONFIG.PANEL.ENABLE"; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PanelSection { + ClientInfo, + ClientNew, + ClientRenew, +} + +impl PanelSection { + fn label_key(&self) -> &'static str { + match self { + Self::ClientInfo => "LABEL.PANEL_CLIENT_INFO", + Self::ClientNew => "LABEL.PANEL_CLIENT_NEW", + Self::ClientRenew => "LABEL.PANEL_CLIENT_RENEW", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct PanelConfigFormState { + form: SourcesConfigDto, + modified: bool, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Clone)] +enum PanelConfigFormAction { + SetAll(SourcesConfigDto), + SetEnabled { source_idx: usize, input_idx: usize, enabled: bool }, + SetPanelUrl { source_idx: usize, input_idx: usize, url: String }, + SetApiKey { source_idx: usize, input_idx: usize, api_key: String }, + AddParam { source_idx: usize, input_idx: usize, section: PanelSection }, + RemoveParam { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize }, + SetParamKey { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize, key: String }, + SetParamValue { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize, value: String }, + EnsureRequired { source_idx: usize, input_idx: usize, section: PanelSection }, +} + +fn params_mut<'a>(panel: &'a mut PanelApiConfigDto, section: PanelSection) -> &'a mut Vec { + match section { + PanelSection::ClientInfo => &mut panel.query_parameter.client_info, + PanelSection::ClientNew => &mut panel.query_parameter.client_new, + PanelSection::ClientRenew => &mut panel.query_parameter.client_renew, + } +} + +fn has_param(params: &[PanelApiQueryParamDto], key: &str) -> bool { + params.iter().any(|p| p.key.trim().eq_ignore_ascii_case(key)) +} + +fn get_param_value(params: &[PanelApiQueryParamDto], key: &str) -> Option { + params + .iter() + .find(|p| p.key.trim().eq_ignore_ascii_case(key)) + .map(|p| p.value.trim().to_string()) +} + +fn validate_panel(panel: Option<&PanelApiConfigDto>) -> Vec { + let mut errors = vec![]; + let Some(panel) = panel else { + return errors; + }; + + if panel.url.trim().is_empty() { + errors.push("Missing panel url".to_string()); + } + if panel.api_key.as_ref().is_none_or(|k| k.trim().is_empty()) { + errors.push("Missing api_key".to_string()); + } + + let sections = [ + (PanelSection::ClientInfo, &panel.query_parameter.client_info), + (PanelSection::ClientNew, &panel.query_parameter.client_new), + (PanelSection::ClientRenew, &panel.query_parameter.client_renew), + ]; + for (section, params) in sections { + if params.is_empty() { + errors.push(match section { + PanelSection::ClientInfo => "client_info: empty".to_string(), + PanelSection::ClientNew => "client_new: empty".to_string(), + PanelSection::ClientRenew => "client_renew: empty".to_string(), + }); + continue; + } + if !has_param(params, "api_key") { + errors.push(match section { + PanelSection::ClientInfo => "client_info: missing api_key param".to_string(), + PanelSection::ClientNew => "client_new: missing api_key param".to_string(), + PanelSection::ClientRenew => "client_renew: missing api_key param".to_string(), + }); + } + match section { + PanelSection::ClientNew => { + if has_param(params, "user") { + errors.push("client_new: must not include user".to_string()); + } + if get_param_value(params, "type").as_deref() != Some("m3u") { + errors.push("client_new: type must be m3u".to_string()); + } + } + PanelSection::ClientRenew => { + if get_param_value(params, "type").as_deref() != Some("m3u") { + errors.push("client_renew: type must be m3u".to_string()); + } + if get_param_value(params, "username").as_deref() != Some("auto") { + errors.push("client_renew: username must be auto".to_string()); + } + if get_param_value(params, "password").as_deref() != Some("auto") { + errors.push("client_renew: password must be auto".to_string()); + } + } + PanelSection::ClientInfo => { + if get_param_value(params, "username").as_deref() != Some("auto") { + errors.push("client_info: username must be auto".to_string()); + } + if get_param_value(params, "password").as_deref() != Some("auto") { + errors.push("client_info: password must be auto".to_string()); + } + } + } + } + errors +} + +fn ensure_required_params(params: &mut Vec, section: PanelSection) { + let ensure = |params: &mut Vec, key: &str, value: &str| { + if !has_param(params, key) { + params.push(PanelApiQueryParamDto { + key: key.to_string(), + value: value.to_string(), + }); + } + }; + ensure(params, "api_key", "auto"); + match section { + PanelSection::ClientNew => { + params.retain(|p| !p.key.trim().eq_ignore_ascii_case("user")); + ensure(params, "type", "m3u"); + } + PanelSection::ClientRenew => { + ensure(params, "type", "m3u"); + ensure(params, "username", "auto"); + ensure(params, "password", "auto"); + } + PanelSection::ClientInfo => { + ensure(params, "username", "auto"); + ensure(params, "password", "auto"); + } + } +} + +fn with_input_mut(form: &mut SourcesConfigDto, source_idx: usize, input_idx: usize, f: impl FnOnce(&mut ConfigInputDto)) { + if let Some(source) = form.sources.get_mut(source_idx) { + if let Some(input) = source.inputs.get_mut(input_idx) { + f(input); + } + } +} + +impl Reducible for PanelConfigFormState { + type Action = PanelConfigFormAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + PanelConfigFormAction::SetAll(form) => Self { form, modified: false }.into(), + PanelConfigFormAction::SetEnabled { source_idx, input_idx, enabled } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + if enabled { + if input.panel_api.is_none() { + input.panel_api = Some(PanelApiConfigDto::default()); + } + } else { + input.panel_api = None; + } + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::SetPanelUrl { source_idx, input_idx, url } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + panel.url = url; + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::SetApiKey { source_idx, input_idx, api_key } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + panel.api_key = if api_key.trim().is_empty() { None } else { Some(api_key) }; + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::AddParam { source_idx, input_idx, section } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + params_mut(panel, section).push(PanelApiQueryParamDto { key: String::new(), value: String::new() }); + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::RemoveParam { source_idx, input_idx, section, param_idx } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + if let Some(panel) = input.panel_api.as_mut() { + let params = params_mut(panel, section); + if param_idx < params.len() { + params.remove(param_idx); + } + } + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::SetParamKey { source_idx, input_idx, section, param_idx, key } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + if let Some(panel) = input.panel_api.as_mut() { + let params = params_mut(panel, section); + if let Some(p) = params.get_mut(param_idx) { + p.key = key; + } + } + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::SetParamValue { source_idx, input_idx, section, param_idx, value } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + if let Some(panel) = input.panel_api.as_mut() { + let params = params_mut(panel, section); + if let Some(p) = params.get_mut(param_idx) { + p.value = value; + } + } + }); + Self { form, modified: true }.into() + } + PanelConfigFormAction::EnsureRequired { source_idx, input_idx, section } => { + let mut form = self.form.clone(); + with_input_mut(&mut form, source_idx, input_idx, |input| { + let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + ensure_required_params(params_mut(panel, section), section); + }); + Self { form, modified: true }.into() + } + } + } +} + +fn render_param_editor( + form_state: &UseReducerHandle, + edit_mode: bool, + source_idx: usize, + input_idx: usize, + section: PanelSection, + section_title: String, + params: &[PanelApiQueryParamDto], +) -> Html { + let add_required = { + let form_state = form_state.clone(); + Callback::from(move |(name, _): (String, web_sys::MouseEvent)| { + if name == "required" { + form_state.dispatch(PanelConfigFormAction::EnsureRequired { source_idx, input_idx, section }); + } + }) + }; + + let add_param = { + let form_state = form_state.clone(); + Callback::from(move |(name, _): (String, web_sys::MouseEvent)| { + if name == "add" { + form_state.dispatch(PanelConfigFormAction::AddParam { source_idx, input_idx, section }); + } + }) + }; + + html! { +
+
+

{ section_title }

+ { html_if!(edit_mode, { +
+ + +
+ })} +
+
+ { + if params.is_empty() { + html!{
{ "—" }
} + } else { + html!{ for params.iter().enumerate().map(|(param_idx, p)| { + let on_key = { + let form_state = form_state.clone(); + Callback::from(move |value: String| { + form_state.dispatch(PanelConfigFormAction::SetParamKey { source_idx, input_idx, section, param_idx, key: value }); + }) + }; + let on_val = { + let form_state = form_state.clone(); + Callback::from(move |value: String| { + form_state.dispatch(PanelConfigFormAction::SetParamValue { source_idx, input_idx, section, param_idx, value }); + }) + }; + let on_remove = { + let form_state = form_state.clone(); + Callback::from(move |(name, _): (String, web_sys::MouseEvent)| { + if name == "rm" { + form_state.dispatch(PanelConfigFormAction::RemoveParam { source_idx, input_idx, section, param_idx }); + } + }) + }; + html!{ +
+ { + if edit_mode { + html!{ + <> + ::None} value={p.key.clone()} placeholder={Some("key".to_string())} on_change={Some(on_key)} /> + ::None} value={p.value.clone()} placeholder={Some("value".to_string())} on_change={Some(on_val)} /> + + + } + } else { + html!{ +
+ { &p.key } + { &p.value } +
+ } + } + } +
+ } + }) } + } + } +
+
+ } +} + +#[function_component] +pub fn PanelConfigView() -> Html { + let translate = use_translation(); + let config_ctx = use_context::().expect("ConfigContext not found"); + let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + + let form_state: UseReducerHandle = use_reducer(|| PanelConfigFormState { + form: SourcesConfigDto::default(), + modified: false, + }); + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.clone(), form_state.modified); + use_effect_with(deps, move |(state, modified)| { + on_form_change.emit(ConfigForm::Panel(*modified, state.form.clone())); + }); + } + + { + let form_state = form_state.clone(); + let sources_cfg = config_ctx.config.as_ref().map(|c| c.sources.clone()); + use_effect_with((sources_cfg, config_view_ctx.edit_mode.clone()), move |(sources_cfg, _mode)| { + if let Some(src) = sources_cfg { + form_state.dispatch(PanelConfigFormAction::SetAll((*src).clone())); + } else { + form_state.dispatch(PanelConfigFormAction::SetAll(SourcesConfigDto::default())); + } + || () + }); + } + + let render_input_card = |source_idx: usize, input_idx: usize, input: &ConfigInputDto| -> Html { + let panel_enabled = input.panel_api.is_some(); + let errors = validate_panel(input.panel_api.as_ref()); + let has_errors = !errors.is_empty(); + let status_chip = if panel_enabled { + if has_errors { + html! { } + } else { + html! { } + } + } else { + html! { ::None}/> } + }; + + let on_toggle = { + let form_state = form_state.clone(); + Callback::from(move |enabled: bool| { + form_state.dispatch(PanelConfigFormAction::SetEnabled { source_idx, input_idx, enabled }); + }) + }; + + let on_url = { + let form_state = form_state.clone(); + Callback::from(move |value: String| { + form_state.dispatch(PanelConfigFormAction::SetPanelUrl { source_idx, input_idx, url: value }); + }) + }; + + let on_api_key = { + let form_state = form_state.clone(); + Callback::from(move |value: String| { + form_state.dispatch(PanelConfigFormAction::SetApiKey { source_idx, input_idx, api_key: value }); + }) + }; + + let panel = input.panel_api.as_ref(); + let url_val = panel.map(|p| p.url.clone()).unwrap_or_default(); + let api_key_val = panel.and_then(|p| p.api_key.clone()).unwrap_or_default(); + let client_info = panel.map(|p| p.query_parameter.client_info.as_slice()).unwrap_or(&[]); + let client_new = panel.map(|p| p.query_parameter.client_new.as_slice()).unwrap_or(&[]); + let client_renew = panel.map(|p| p.query_parameter.client_renew.as_slice()).unwrap_or(&[]); + + html! { + +
+
+

{ &input.name }

+
{ format!("type={}", input.input_type) }
+
+
+ { status_chip } + { html_if!(*config_view_ctx.edit_mode, { +
+ { translate.t(LABEL_ENABLED) } + +
+ })} +
+
+ + { + if !panel_enabled { + html! {
{ translate.t(HINT_PANEL_ENABLE) }
} + } else if *config_view_ctx.edit_mode { + html! { + <> + + + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientInfo, translate.t(PanelSection::ClientInfo.label_key()), client_info) } + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientNew, translate.t(PanelSection::ClientNew.label_key()), client_new) } + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientRenew, translate.t(PanelSection::ClientRenew.label_key()), client_renew) } + { html_if!(has_errors, { +
+

{ translate.t(LABEL_VALIDATION) }

+
    + { for errors.iter().map(|e| html!{
  • { e }
  • }) } +
+
+ })} + + } + } else { + html!{ + <> +
+
+ + { if url_val.is_empty() { "—".to_string() } else { url_val } } +
+
+ + { if api_key_val.is_empty() { "—".to_string() } else { "••••••••".to_string() } } +
+
+ + { format!("{} params", client_info.len()) } +
+
+ + { format!("{} params", client_new.len()) } +
+
+ + { format!("{} params", client_renew.len()) } +
+
+ { html_if!(has_errors, { +
+

{ translate.t(LABEL_VALIDATION) }

+
    + { for errors.iter().map(|e| html!{
  • { e }
  • }) } +
+
+ })} + + } + } + } +
+ } + }; + + let inputs = form_state + .form + .sources + .iter() + .enumerate() + .flat_map(|(sidx, s)| s.inputs.iter().enumerate().map(move |(iidx, inp)| (sidx, iidx, inp))) + .collect::>(); + + html! { +
+
{ translate.t(LABEL_PANEL_CONFIG) }
+
+ +

{ translate.t(LABEL_PANEL_CONFIG) }

+
+ { translate.t(HINT_PANEL_INFO) } +
+
+
+
+ { for inputs.into_iter().map(|(sidx, iidx, inp)| render_input_card(sidx, iidx, inp)) } +
+
+ } +} diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index e0dce14c6..9b33771be 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -1,6 +1,6 @@ use crate::model::WebConfig; use crate::services::{get_base_href, request_get, request_post, EventService}; -use shared::model::{AppConfigDto, ConfigDto, ConfigInputDto, IpCheckDto, TargetOutputDto}; +use shared::model::{AppConfigDto, ConfigDto, ConfigInputDto, IpCheckDto, SourcesConfigDto, TargetOutputDto}; use std::cell::RefCell; use std::future::Future; use std::rc::Rc; @@ -19,6 +19,7 @@ pub struct ConfigService { config_channel: Mutable>>, is_fetching: AtomicBool, config_path: String, + sources_path: String, ip_check_path: String, batch_input_content_path: String, geoip_path: String, @@ -28,12 +29,14 @@ pub struct ConfigService { impl ConfigService { pub fn new(config: &WebConfig, event_service: Rc) -> Self { let base_href = get_base_href(); + let config_path = concat_path_leading_slash(&base_href, "api/v1/config"); Self { ui_config: Rc::new(config.clone()), server_config: RefCell::new(None), config_channel: Mutable::new(None), is_fetching: AtomicBool::new(false), - config_path: concat_path_leading_slash(&base_href, "api/v1/config"), + config_path: config_path.clone(), + sources_path: concat_path(&config_path, "sources"), ip_check_path: concat_path_leading_slash(&base_href, "api/v1/ipinfo"), batch_input_content_path: concat_path_leading_slash(&base_href, "api/v1/config/batchContent"), geoip_path: concat_path_leading_slash(&base_href, "api/v1/geoip/update"), @@ -144,6 +147,21 @@ impl ConfigService { } } + pub async fn save_sources(&self, dto: SourcesConfigDto) -> Result<(), Error> { + self.event_service.set_config_change_message_blocked(true); + match request_post::(&self.sources_path, dto, None, None).await { + Ok(_) => { + self.event_service.set_config_change_message_blocked(false); + Ok(()) + } + Err(err) => { + self.event_service.set_config_change_message_blocked(false); + error!("{err}"); + Err(err) + } + } + } + pub async fn update_geoip(&self) -> Result, Error> { request_get::<()>(&self.geoip_path, None, None).await } From 7bb8a0c4f1a65a542bd3fae716fcb4236461072a Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:38:23 +0100 Subject: [PATCH 06/27] fix: cargo clippy --- backend/src/api/api_utils.rs | 15 +- backend/src/api/panel_api.rs | 175 ++++++++++-------- .../components/config/panel_config_view.rs | 62 +++---- 3 files changed, 139 insertions(+), 113 deletions(-) diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 8d522593a..bb26d967c 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -347,13 +347,14 @@ async fn resolve_streaming_strategy( None => app_state.active_provider.acquire_connection(&input.name, &fingerprint.addr).await, }; - if provider_connection_handle.is_none() && force_provider.is_none() { - if try_provision_account_on_exhausted(app_state, input).await { - provider_connection_handle = app_state - .active_provider - .acquire_connection(&input.name, &fingerprint.addr) - .await; - } + if provider_connection_handle.is_none() + && force_provider.is_none() + && try_provision_account_on_exhausted(app_state, input).await + { + provider_connection_handle = app_state + .active_provider + .acquire_connection(&input.name, &fingerprint.addr) + .await; } let stream_response_params = diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 40a161295..a0856a4f4 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -225,7 +225,7 @@ async fn panel_client_new(app_state: &AppState, cfg: &PanelApiConfigDto) -> Resu let Some(obj) = first_json_object(&json) else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new response is not a JSON object/array"); }; - let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + let status_ok = obj.get("status").is_some_and(parse_boolish); if !status_ok { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_new status=false"); } @@ -253,7 +253,7 @@ async fn panel_client_renew(app_state: &AppState, cfg: &PanelApiConfigDto, usern let Some(obj) = first_json_object(&json) else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_renew response is not a JSON object/array"); }; - let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + let status_ok = obj.get("status").is_some_and(parse_boolish); if !status_ok { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_renew status=false"); } @@ -272,7 +272,7 @@ async fn panel_client_info(app_state: &AppState, cfg: &PanelApiConfigDto, userna let Some(obj) = first_json_object(&json) else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_info response is not a JSON object/array"); }; - let status_ok = obj.get("status").map(parse_boolish).unwrap_or(false); + let status_ok = obj.get("status").is_some_and(parse_boolish); if !status_ok { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_info status=false"); } @@ -457,7 +457,7 @@ async fn patch_batch_csv_append( exp_date: Option, ) -> Result<(), TuliproxError> { let raw = tokio::fs::read_to_string(csv_path).await.unwrap_or_default(); - let mut lines: Vec = raw.lines().map(|l| l.to_string()).collect(); + let mut lines: Vec = raw.lines().map(ToString::to_string).collect(); let header_line_idx = lines.iter().position(|l| l.trim_start().starts_with('#')); let header = header_line_idx .and_then(|idx| lines.get(idx).map(|s| s.trim_start_matches('#').trim().to_string())) @@ -469,9 +469,9 @@ async fn patch_batch_csv_append( if header_line_idx.is_none() { lines.insert(0, format!("#{header}")); } - let mut row: Vec = vec![String::new(); cols.len()]; + let mut record: Vec = vec![String::new(); cols.len()]; for (i, c) in cols.iter().enumerate() { - row[i] = match c.as_str() { + record[i] = match c.as_str() { "name" => alias_name.to_string(), "username" => username.to_string(), "password" => password.to_string(), @@ -493,7 +493,7 @@ async fn patch_batch_csv_append( _ => String::new(), }; } - lines.push(row.join(";")); + lines.push(record.join(";")); tokio::fs::write(csv_path, lines.join("\n") + "\n") .await .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write csv: {e}")))?; @@ -510,7 +510,7 @@ async fn patch_batch_csv_update_exp_date( let raw = tokio::fs::read_to_string(csv_path) .await .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read csv: {e}")))?; - let mut lines: Vec = raw.lines().map(|l| l.to_string()).collect(); + let mut lines: Vec = raw.lines().map(ToString::to_string).collect(); let header_line_idx = lines.iter().position(|l| l.trim_start().starts_with('#')); let Some(header_idx) = header_line_idx else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: csv missing header line"); @@ -532,7 +532,7 @@ async fn patch_batch_csv_update_exp_date( if line.is_empty() || line.starts_with('#') { continue; } - let mut fields: Vec = line.split(';').map(|s| s.to_string()).collect(); + let mut fields: Vec = line.split(';').map(ToString::to_string).collect(); fields.resize(cols.len(), String::new()); let mut matches = false; @@ -562,7 +562,7 @@ async fn patch_batch_csv_update_exp_date( return Ok(()); } } - warn!("panel_api: could not find batch csv row for account {}", account_name); + warn!("panel_api: could not find batch csv row for account {account_name}"); Ok(()) } @@ -580,75 +580,63 @@ fn derive_unique_alias_name(existing: &[String], input_name: &str, username: &st base } -pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &ConfigInput) -> bool { - let Some(panel_cfg) = input.panel_api.as_ref() else { - return false; - }; - if panel_cfg.url.trim().is_empty() { - return false; - } - - let _input_lock = app_state - .app_config - .file_locks - .write_lock_str(format!("panel_api:{}", input.name).as_str()) - .await; - - if let Err(err) = validate_panel_api_config(panel_cfg) { - debug_if_enabled!("panel_api config invalid: {}", sanitize_sensitive_info(err.to_string().as_str())); - return false; - } - - let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); - let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); - let sources_path = PathBuf::from(&sources_file_path); - - // prefer renew of expired accounts +async fn try_renew_expired_account( + app_state: &AppState, + input: &ConfigInput, + panel_cfg: &PanelApiConfigDto, + is_batch: bool, + sources_path: &Path, +) -> bool { let expired = collect_expired_accounts(input); - if !expired.is_empty() { - for acct in &expired { - match panel_client_renew(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()).await { - Ok(()) => { - let new_exp = panel_client_info(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()) - .await - .ok() - .flatten(); - if let Some(new_exp) = new_exp.or_else(|| acct.exp_date) { - if is_batch { - let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); - if let Ok(csv_path) = get_csv_file_path(batch_url) { - let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; - if let Err(err) = patch_batch_csv_update_exp_date(&csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { - debug_if_enabled!("panel_api failed to persist renew exp_date to csv: {}", err); - } - } - } else { - let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; - if let Err(err) = patch_source_yml_update_exp_date(&sources_path, &input.name, &acct.name, new_exp).await { - debug_if_enabled!("panel_api failed to persist renew exp_date to source.yml: {}", err); + for acct in &expired { + match panel_client_renew(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()).await { + Ok(()) => { + let refreshed_exp = panel_client_info(app_state, panel_cfg, acct.username.as_str(), acct.password.as_str()) + .await + .ok() + .flatten(); + + if let Some(new_exp) = refreshed_exp.or(acct.exp_date) { + if is_batch { + let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); + if let Ok(csv_path) = get_csv_file_path(batch_url) { + let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + if let Err(err) = patch_batch_csv_update_exp_date(&csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { + debug_if_enabled!("panel_api failed to persist renew exp_date to csv: {}", err); } } + } else { + let _src_lock = app_state.app_config.file_locks.write_lock(sources_path).await; + if let Err(err) = patch_source_yml_update_exp_date(sources_path, &input.name, &acct.name, new_exp).await { + debug_if_enabled!("panel_api failed to persist renew exp_date to source.yml: {}", err); + } } + } - // reload sources + provider lineup - if let Err(err) = reload_sources(app_state).await { - debug_if_enabled!("panel_api reload sources failed: {}", err); - } - return true; - } - Err(err) => { - debug_if_enabled!( - "panel_api client_renew failed for {}: {}", - sanitize_sensitive_info(&acct.name), - sanitize_sensitive_info(err.to_string().as_str()) - ); - continue; + if let Err(err) = reload_sources(app_state).await { + debug_if_enabled!("panel_api reload sources failed: {}", err); } + return true; + } + Err(err) => { + debug_if_enabled!( + "panel_api client_renew failed for {}: {}", + sanitize_sensitive_info(&acct.name), + sanitize_sensitive_info(err.to_string().as_str()) + ); } } } + false +} - // otherwise create new client and append as alias / batch row +async fn try_create_new_account( + app_state: &AppState, + input: &ConfigInput, + panel_cfg: &PanelApiConfigDto, + is_batch: bool, + sources_path: &Path, +) -> bool { match panel_client_new(app_state, panel_cfg).await { Ok((username, password, base_url_from_resp)) => { let base_url = base_url_from_resp.unwrap_or_else(|| input.url.clone()); @@ -675,20 +663,28 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co InputType::M3uBatch }; let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; - if let Err(err) = patch_batch_csv_append(&csv_path, batch_type, &alias_name, &base_url, &username, &password, exp_date).await { - warn!("panel_api failed to append new account to csv: {}", err); + if let Err(err) = + patch_batch_csv_append(&csv_path, batch_type, &alias_name, &base_url, &username, &password, exp_date).await + { + warn!("panel_api failed to append new account to csv: {err}"); return false; } } Err(err) => { - warn!("panel_api cannot resolve batch csv path {}: {}", sanitize_sensitive_info(batch_url), err); + warn!( + "panel_api cannot resolve batch csv path {}: {}", + sanitize_sensitive_info(batch_url), + err + ); return false; } } } else { - let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; - if let Err(err) = patch_source_yml_add_alias(&sources_path, &input.name, &alias_name, &base_url, &username, &password, exp_date).await { - warn!("panel_api failed to persist new alias to source.yml: {}", err); + let _src_lock = app_state.app_config.file_locks.write_lock(sources_path).await; + if let Err(err) = + patch_source_yml_add_alias(sources_path, &input.name, &alias_name, &base_url, &username, &password, exp_date).await + { + warn!("panel_api failed to persist new alias to source.yml: {err}"); return false; } } @@ -706,6 +702,35 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co } } +pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &ConfigInput) -> bool { + let Some(panel_cfg) = input.panel_api.as_ref() else { + return false; + }; + if panel_cfg.url.trim().is_empty() { + return false; + } + + let _input_lock = app_state + .app_config + .file_locks + .write_lock_str(format!("panel_api:{}", input.name).as_str()) + .await; + + if let Err(err) = validate_panel_api_config(panel_cfg) { + debug_if_enabled!("panel_api config invalid: {}", sanitize_sensitive_info(err.to_string().as_str())); + return false; + } + + let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); + let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); + let sources_path = PathBuf::from(&sources_file_path); + + if try_renew_expired_account(app_state, input, panel_cfg, is_batch, sources_path.as_path()).await { + return true; + } + try_create_new_account(app_state, input, panel_cfg, is_batch, sources_path.as_path()).await +} + pub(crate) async fn sync_panel_api_exp_dates_on_boot(app_state: &Arc) { let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); let sources_path = PathBuf::from(&sources_file_path); diff --git a/frontend/src/app/components/config/panel_config_view.rs b/frontend/src/app/components/config/panel_config_view.rs index 233e866d8..9af75efd7 100644 --- a/frontend/src/app/components/config/panel_config_view.rs +++ b/frontend/src/app/components/config/panel_config_view.rs @@ -21,17 +21,17 @@ const HINT_PANEL_ENABLE: &str = "HINT.CONFIG.PANEL.ENABLE"; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum PanelSection { - ClientInfo, - ClientNew, - ClientRenew, + Info, + New, + Renew, } impl PanelSection { fn label_key(&self) -> &'static str { match self { - Self::ClientInfo => "LABEL.PANEL_CLIENT_INFO", - Self::ClientNew => "LABEL.PANEL_CLIENT_NEW", - Self::ClientRenew => "LABEL.PANEL_CLIENT_RENEW", + Self::Info => "LABEL.PANEL_CLIENT_INFO", + Self::New => "LABEL.PANEL_CLIENT_NEW", + Self::Renew => "LABEL.PANEL_CLIENT_RENEW", } } } @@ -56,11 +56,11 @@ enum PanelConfigFormAction { EnsureRequired { source_idx: usize, input_idx: usize, section: PanelSection }, } -fn params_mut<'a>(panel: &'a mut PanelApiConfigDto, section: PanelSection) -> &'a mut Vec { +fn params_mut(panel: &mut PanelApiConfigDto, section: PanelSection) -> &mut Vec { match section { - PanelSection::ClientInfo => &mut panel.query_parameter.client_info, - PanelSection::ClientNew => &mut panel.query_parameter.client_new, - PanelSection::ClientRenew => &mut panel.query_parameter.client_renew, + PanelSection::Info => &mut panel.query_parameter.client_info, + PanelSection::New => &mut panel.query_parameter.client_new, + PanelSection::Renew => &mut panel.query_parameter.client_renew, } } @@ -89,28 +89,28 @@ fn validate_panel(panel: Option<&PanelApiConfigDto>) -> Vec { } let sections = [ - (PanelSection::ClientInfo, &panel.query_parameter.client_info), - (PanelSection::ClientNew, &panel.query_parameter.client_new), - (PanelSection::ClientRenew, &panel.query_parameter.client_renew), + (PanelSection::Info, &panel.query_parameter.client_info), + (PanelSection::New, &panel.query_parameter.client_new), + (PanelSection::Renew, &panel.query_parameter.client_renew), ]; for (section, params) in sections { if params.is_empty() { errors.push(match section { - PanelSection::ClientInfo => "client_info: empty".to_string(), - PanelSection::ClientNew => "client_new: empty".to_string(), - PanelSection::ClientRenew => "client_renew: empty".to_string(), + PanelSection::Info => "client_info: empty".to_string(), + PanelSection::New => "client_new: empty".to_string(), + PanelSection::Renew => "client_renew: empty".to_string(), }); continue; } if !has_param(params, "api_key") { errors.push(match section { - PanelSection::ClientInfo => "client_info: missing api_key param".to_string(), - PanelSection::ClientNew => "client_new: missing api_key param".to_string(), - PanelSection::ClientRenew => "client_renew: missing api_key param".to_string(), + PanelSection::Info => "client_info: missing api_key param".to_string(), + PanelSection::New => "client_new: missing api_key param".to_string(), + PanelSection::Renew => "client_renew: missing api_key param".to_string(), }); } match section { - PanelSection::ClientNew => { + PanelSection::New => { if has_param(params, "user") { errors.push("client_new: must not include user".to_string()); } @@ -118,7 +118,7 @@ fn validate_panel(panel: Option<&PanelApiConfigDto>) -> Vec { errors.push("client_new: type must be m3u".to_string()); } } - PanelSection::ClientRenew => { + PanelSection::Renew => { if get_param_value(params, "type").as_deref() != Some("m3u") { errors.push("client_renew: type must be m3u".to_string()); } @@ -129,7 +129,7 @@ fn validate_panel(panel: Option<&PanelApiConfigDto>) -> Vec { errors.push("client_renew: password must be auto".to_string()); } } - PanelSection::ClientInfo => { + PanelSection::Info => { if get_param_value(params, "username").as_deref() != Some("auto") { errors.push("client_info: username must be auto".to_string()); } @@ -153,16 +153,16 @@ fn ensure_required_params(params: &mut Vec, section: Pane }; ensure(params, "api_key", "auto"); match section { - PanelSection::ClientNew => { + PanelSection::New => { params.retain(|p| !p.key.trim().eq_ignore_ascii_case("user")); ensure(params, "type", "m3u"); } - PanelSection::ClientRenew => { + PanelSection::Renew => { ensure(params, "type", "m3u"); ensure(params, "username", "auto"); ensure(params, "password", "auto"); } - PanelSection::ClientInfo => { + PanelSection::Info => { ensure(params, "username", "auto"); ensure(params, "password", "auto"); } @@ -465,9 +465,9 @@ pub fn PanelConfigView() -> Html { <> - { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientInfo, translate.t(PanelSection::ClientInfo.label_key()), client_info) } - { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientNew, translate.t(PanelSection::ClientNew.label_key()), client_new) } - { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::ClientRenew, translate.t(PanelSection::ClientRenew.label_key()), client_renew) } + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::Info, translate.t(PanelSection::Info.label_key()), client_info) } + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::New, translate.t(PanelSection::New.label_key()), client_new) } + { render_param_editor(&form_state, true, source_idx, input_idx, PanelSection::Renew, translate.t(PanelSection::Renew.label_key()), client_renew) } { html_if!(has_errors, {

{ translate.t(LABEL_VALIDATION) }

@@ -491,15 +491,15 @@ pub fn PanelConfigView() -> Html { { if api_key_val.is_empty() { "—".to_string() } else { "••••••••".to_string() } }
- + { format!("{} params", client_info.len()) }
- + { format!("{} params", client_new.len()) }
- + { format!("{} params", client_renew.len()) }
From 90b6ee5dbf2ccb3f19a1691ce5a040fa91e0f88a Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:23:33 +0100 Subject: [PATCH 07/27] feat: style formatter for key value pairs --- backend/src/api/panel_api.rs | 4 +- backend/src/utils/file/config_reader.rs | 135 +++++++++++++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index a0856a4f4..22b16fd28 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -1,7 +1,7 @@ use crate::api::model::AppState; use crate::model::{ConfigInput, is_input_expired}; use crate::utils::debug_if_enabled; -use crate::utils::{get_csv_file_path, read_sources_file}; +use crate::utils::{format_sources_yaml_panel_api_query_params_flow_style, get_csv_file_path, read_sources_file}; use log::{debug, error, warn}; use serde_json::Value; use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; @@ -391,6 +391,7 @@ async fn patch_source_yml_add_alias( let serialized = serde_yaml::to_string(&doc) .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to serialize source.yml: {e}")))?; + let serialized = format_sources_yaml_panel_api_query_params_flow_style(&serialized); tokio::fs::write(source_file_path, serialized) .await .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write source.yml: {e}")))?; @@ -438,6 +439,7 @@ async fn patch_source_yml_update_exp_date( } let serialized = serde_yaml::to_string(&doc) .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to serialize source.yml: {e}")))?; + let serialized = format_sources_yaml_panel_api_query_params_flow_style(&serialized); tokio::fs::write(source_file_path, serialized) .await .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to write source.yml: {e}")))?; diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index d31f4003f..ce5a49b43 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -325,6 +325,98 @@ where .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not write file {}: {}", &path.to_str().unwrap_or("?"), err)) } +pub fn format_sources_yaml_panel_api_query_params_flow_style(yaml: &str) -> String { + let has_trailing_newline = yaml.ends_with('\n'); + let lines: Vec<&str> = yaml.split_terminator('\n').collect(); + let mut out: Vec = Vec::with_capacity(lines.len()); + + let mut panel_api_indent: Option = None; + let mut query_indent: Option = None; + let mut section_indent: Option = None; + + let mut i = 0usize; + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim_start(); + let indent = line.len().saturating_sub(trimmed.len()); + + if !trimmed.is_empty() && !trimmed.starts_with('#') { + if let Some(pi) = panel_api_indent { + if indent <= pi && trimmed != "panel_api:" { + panel_api_indent = None; + query_indent = None; + section_indent = None; + } + } + if let Some(qi) = query_indent { + if indent <= qi && trimmed != "query_parameter:" { + query_indent = None; + section_indent = None; + } + } + if let Some(si) = section_indent { + if indent < si { + section_indent = None; + } + } + + if panel_api_indent.is_none() && trimmed == "panel_api:" { + panel_api_indent = Some(indent); + } else if panel_api_indent.is_some() && query_indent.is_none() && trimmed == "query_parameter:" { + query_indent = Some(indent); + } else if query_indent.is_some() + && matches!(trimmed, "client_info:" | "client_new:" | "client_renew:") + { + section_indent = Some(indent); + } + } + + let in_target_section = panel_api_indent.is_some() && query_indent.is_some() && section_indent.is_some(); + if in_target_section + && indent == section_indent.unwrap_or(usize::MAX) + && trimmed.starts_with("- key:") + && !trimmed.starts_with("- {") + && i + 1 < lines.len() + { + let next = lines[i + 1]; + let next_trimmed = next.trim_start(); + let next_indent = next.len().saturating_sub(next_trimmed.len()); + + if next_trimmed.starts_with("value:") + && next_indent == indent.saturating_add(2) + { + let key_val = trimmed + .strip_prefix("- key:") + .unwrap_or_default() + .trim(); + let value_val = next_trimmed + .strip_prefix("value:") + .unwrap_or_default() + .trim(); + + let new_line = format!( + "{}- {{ key: {}, value: {} }}", + " ".repeat(indent), + key_val, + value_val + ); + out.push(new_line); + i += 2; + continue; + } + } + + out.push(line.to_string()); + i += 1; + } + + if has_trailing_newline { + out.join("\n") + "\n" + } else { + out.join("\n") + } +} + pub async fn save_api_proxy(file_path: &str, backup_dir: &str, config: &ApiProxyConfigDto) -> Result<(), TuliproxError> { write_config_file(file_path, backup_dir, config, "api-proxy.yml").await } @@ -334,7 +426,23 @@ pub async fn save_main_config(file_path: &str, backup_dir: &str, config: &Config } pub async fn save_sources_config(file_path: &str, backup_dir: &str, config: &SourcesConfigDto) -> Result<(), TuliproxError> { - write_config_file(file_path, backup_dir, config, "source.yml").await + let path = PathBuf::from(file_path); + let filename = path.file_name().map_or("source.yml".to_string(), |f| f.to_string_lossy().to_string()); + let backup_path = PathBuf::from(backup_dir).join(format!("{filename}_{}", Local::now().format("%Y%m%d_%H%M%S"))); + + match fs::copy(&path, &backup_path).await { + Ok(_) => {} + Err(err) => { error!("Could not backup file {}:{}", &backup_path.to_str().unwrap_or("?"), err) } + } + info!("Saving file to {}", &path.to_str().unwrap_or("?")); + + let serialized = serde_yaml::to_string(config) + .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not serialize file {}: {}", &path.to_str().unwrap_or("?"), err))?; + let serialized = format_sources_yaml_panel_api_query_params_flow_style(&serialized); + + fs::write(&path, serialized) + .await + .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not write file {}: {}", &path.to_str().unwrap_or("?"), err)) } pub fn resolve_env_var(value: &str) -> String { @@ -353,10 +461,35 @@ pub fn resolve_env_var(value: &str) -> String { #[cfg(test)] mod tests { use crate::utils::resolve_env_var; + use crate::utils::format_sources_yaml_panel_api_query_params_flow_style; #[test] fn test_resolve() { let resolved = resolve_env_var("${env:HOME}"); assert_eq!(resolved, std::env::var("HOME").unwrap()); } + + #[test] + fn test_sources_yaml_panel_api_query_params_flow_style() { + let input = r#"sources: +- inputs: + - name: demo + panel_api: + url: https://example.invalid/api.php + query_parameter: + client_info: + - key: api_key + value: auto + - key: sub + value: '1' + client_new: + - key: type + value: m3u +"#; + let out = format_sources_yaml_panel_api_query_params_flow_style(input); + assert!(out.contains("- { key: api_key, value: auto }")); + assert!(out.contains("- { key: sub, value: '1' }")); + assert!(out.contains("- { key: type, value: m3u }")); + assert!(!out.contains("- key: api_key\n")); + } } From d7d1ade12db52c41978d0895fa5ff1dfb3a99252 Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:28:03 +0100 Subject: [PATCH 08/27] fix: ensure the received exp_date is valid --- backend/src/api/panel_api.rs | 28 +++++++++++++++++++++++-- backend/src/utils/file/config_reader.rs | 4 ++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 22b16fd28..99ead3f40 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -28,6 +28,19 @@ fn parse_boolish(value: &Value) -> bool { } } +fn is_date_only_yyyy_mm_dd(value: &str) -> bool { + let value = value.trim(); + if value.len() != 10 { + return false; + } + let bytes = value.as_bytes(); + bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[8..10].iter().all(u8::is_ascii_digit) +} + fn first_json_object(value: &Value) -> Option<&serde_json::Map> { match value { Value::Array(arr) => arr.first().and_then(|v| v.as_object()), @@ -276,8 +289,19 @@ async fn panel_client_info(app_state: &AppState, cfg: &PanelApiConfigDto, userna if !status_ok { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: client_info status=false"); } - let expire = obj.get("expire").and_then(|v| v.as_str()).unwrap_or_default(); - Ok(parse_timestamp(expire).ok().flatten()) + let expire = obj.get("expire").and_then(|v| v.as_str()).unwrap_or_default().trim(); + let parsed = parse_timestamp(expire).ok().flatten(); + if parsed.is_some() { + return Ok(parsed); + } + + // Some panels return only a date ("YYYY-MM-DD") without time. Normalize to midnight. + if is_date_only_yyyy_mm_dd(expire) { + let normalized = format!("{expire} 00:00:00"); + return Ok(parse_timestamp(&normalized).ok().flatten()); + } + + Ok(None) } fn extract_account_creds_from_input(input: &ConfigInput) -> Option<(String, String)> { diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index ce5a49b43..4a046d0d9 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -471,7 +471,7 @@ mod tests { #[test] fn test_sources_yaml_panel_api_query_params_flow_style() { - let input = r#"sources: + let input = r"sources: - inputs: - name: demo panel_api: @@ -485,7 +485,7 @@ mod tests { client_new: - key: type value: m3u -"#; +"; let out = format_sources_yaml_panel_api_query_params_flow_style(input); assert!(out.contains("- { key: api_key, value: auto }")); assert!(out.contains("- { key: sub, value: '1' }")); From 0ccbd86e3013dfd44f316be3d724181264ce606e Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:13:56 +0100 Subject: [PATCH 09/27] feat: enhenced session logging -> print user from pool --- backend/src/api/api_utils.rs | 39 +++++++++++++++---- .../src/api/model/provider_lineup_manager.rs | 16 ++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index bb26d967c..02e148f9c 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -365,26 +365,51 @@ async fn resolve_streaming_strategy( let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); ProviderStreamState::Custom(stream) } - ProviderAllocation::Available(ref provider) - | ProviderAllocation::GracePeriod(ref provider) => { + ProviderAllocation::Available(ref provider_cfg) + | ProviderAllocation::GracePeriod(ref provider_cfg) => { + let allocation_kind = match allocation { + ProviderAllocation::Available(_) => "available", + ProviderAllocation::GracePeriod(_) => "grace_period", + ProviderAllocation::Exhausted => "exhausted", + }; + // force_stream_provider means we keep the url and the provider. // If force_stream_provider or the input is the same as the config we don't need to get new url - let (provider, url) = if force_provider.is_some() || provider.id == input.id { + let (selected_provider_name, url) = if force_provider.is_some() || provider_cfg.id == input.id { (input.name.clone(), stream_url.to_string()) } else { ( - provider.name.clone(), - get_stream_alternative_url(stream_url, input, provider), + provider_cfg.name.clone(), + get_stream_alternative_url(stream_url, input, provider_cfg), ) }; + if let Some(user_info) = provider_cfg.get_user_info() { + debug_if_enabled!( + "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info(&user_info.username), + allocation_kind, + sanitize_sensitive_info(&url) + ); + } else { + debug_if_enabled!( + "provider session: input={} provider_cfg={} user=? allocation={} stream_url={}", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(&provider_cfg.name), + allocation_kind, + sanitize_sensitive_info(&url) + ); + } + match allocation { ProviderAllocation::Exhausted => { let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); ProviderStreamState::Custom(stream) }, - ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(provider), url), - ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(provider), url), + ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(selected_provider_name), url), + ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(selected_provider_name), url), } } } diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index e29093d2a..6a26733bf 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -656,13 +656,15 @@ impl ProviderLineupManager { fn log_allocation(allocation: &ProviderAllocation) { - if log_enabled!(log::Level::Debug) { - match allocation { - ProviderAllocation::Exhausted => {} - ProviderAllocation::Available(ref cfg) | - ProviderAllocation::GracePeriod(ref cfg) => { - debug!("Using provider {}", cfg.name); - } + match allocation { + ProviderAllocation::Exhausted => {} + ProviderAllocation::Available(ref cfg) | ProviderAllocation::GracePeriod(ref cfg) => { + debug_if_enabled!( + "Using provider {} (pool user: {})", + sanitize_sensitive_info(&cfg.name), + cfg.get_user_info() + .map_or_else(|| "?".to_string(), |ui| sanitize_sensitive_info(&ui.username).to_string()) + ); } } } From 756177774fc141e8b503ec5520853b923d1d5f6f Mon Sep 17 00:00:00 2001 From: knylbyte <40831653+knylbyte@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:07:30 +0100 Subject: [PATCH 10/27] feat: wait until connection expire --- backend/src/api/api_utils.rs | 139 +++- .../src/api/model/active_provider_manager.rs | 33 +- backend/src/api/model/provider_config.rs | 39 +- .../src/api/model/provider_lineup_manager.rs | 24 +- backend/src/api/panel_api.rs | 17 +- backend/src/main.rs | 91 ++- backend/src/modules.rs | 5 +- frontend/src/app/components/accordion.rs | 2 +- .../src/app/components/accordion_panel.rs | 7 +- .../app/components/api_user/api_user_view.rs | 14 +- frontend/src/app/components/api_user/mod.rs | 2 +- .../src/app/components/api_user/playlist.rs | 114 ++- .../components/api_user/target_playlist.rs | 129 ++-- frontend/src/app/components/authentication.rs | 28 +- frontend/src/app/components/breadcrumbs.rs | 2 +- frontend/src/app/components/card.rs | 4 +- frontend/src/app/components/cell_value.rs | 12 +- frontend/src/app/components/chip.rs | 5 +- .../src/app/components/cluster_flags_input.rs | 38 +- frontend/src/app/components/collapse_panel.rs | 4 +- .../app/components/config/api_config_view.rs | 24 +- .../src/app/components/config/config_page.rs | 33 +- .../app/components/config/config_update.rs | 29 +- .../src/app/components/config/config_view.rs | 222 ++++-- .../components/config/config_view_context.rs | 4 +- .../config/hdhomerun_config_view.rs | 67 +- .../config/hdhomerun_device_view.rs | 19 +- .../components/config/ipcheck_config_view.rs | 52 +- .../app/components/config/log_config_view.rs | 57 +- frontend/src/app/components/config/macros.rs | 1 - .../app/components/config/main_config_view.rs | 72 +- .../config/messaging_config_view.rs | 89 ++- frontend/src/app/components/config/mod.rs | 42 +- .../components/config/panel_config_view.rs | 275 +++++-- .../components/config/proxy_config_view.rs | 59 +- .../config/reverse_proxy_config_view.rs | 261 ++++--- .../config/schedules_config_view.rs | 271 ++++--- .../components/config/video_config_view.rs | 120 +-- .../components/config/webui_config_view.rs | 98 ++- frontend/src/app/components/confirm_dialog.rs | 6 +- frontend/src/app/components/content_dialog.rs | 21 +- frontend/src/app/components/csv_table.rs | 9 +- frontend/src/app/components/custom_dialog.rs | 6 +- .../app/components/dashboard/action_card.rs | 5 +- .../components/dashboard/dashboard_view.rs | 10 +- .../dashboard/discord_action_card.rs | 11 +- .../dashboard/documentation_action_card.rs | 10 +- .../dashboard/github_action_card.rs | 11 +- .../dashboard/ipinfo_action_card.rs | 13 +- frontend/src/app/components/dashboard/mod.rs | 26 +- .../playlist_progress_status_card.rs | 15 +- .../app/components/dashboard/stats_view.rs | 57 +- .../app/components/dashboard/status_card.rs | 2 +- .../app/components/dashboard/streams_table.rs | 161 ++-- .../app/components/dashboard/streams_view.rs | 25 +- .../components/dashboard/user_action_card.rs | 18 +- .../dashboard/version_action_card.rs | 18 +- frontend/src/app/components/date_input.rs | 6 +- .../app/components/drop_down_icon_button.rs | 37 +- .../app/components/filter/filter_editor.rs | 52 +- .../src/app/components/filter/filter_input.rs | 27 +- .../src/app/components/floating_background.rs | 41 +- frontend/src/app/components/hide_content.rs | 5 +- frontend/src/app/components/home.rs | 82 +- frontend/src/app/components/icon_button.rs | 5 +- frontend/src/app/components/input.rs | 22 +- .../src/app/components/key_value_editor.rs | 44 +- .../src/app/components/loading_indicator.rs | 6 +- frontend/src/app/components/loading_screen.rs | 2 +- frontend/src/app/components/login.rs | 24 +- frontend/src/app/components/menu_item.rs | 7 +- frontend/src/app/components/mod.rs | 150 ++-- frontend/src/app/components/no_content.rs | 4 +- frontend/src/app/components/number_input.rs | 4 +- frontend/src/app/components/panel.rs | 2 +- .../app/components/playlist/assistant/mod.rs | 3 +- .../components/playlist/assistant/playlist.rs | 17 +- .../assistant/playlist_assistant_context.rs | 2 +- .../playlist/assistant/step_name.rs | 7 +- .../playlist/assistant/step_schedule.rs | 7 +- .../playlist/assistant/step_type.rs | 7 +- .../src/app/components/playlist/create.rs | 11 +- .../components/playlist/epg_config_view.rs | 139 ++-- .../playlist/epg_source_selector.rs | 125 ++-- .../src/app/components/playlist/epg_view.rs | 99 ++- .../app/components/playlist/filter_view.rs | 34 +- .../input/batch_input_content_view.rs | 8 +- .../playlist/input/input_headers.rs | 4 +- .../playlist/input/input_options.rs | 57 +- .../playlist/input/input_type_view.rs | 11 +- .../src/app/components/playlist/input/mod.rs | 14 +- .../playlist/input/staged_input_view.rs | 10 +- .../app/components/playlist/input_table.rs | 247 +++--- frontend/src/app/components/playlist/list.rs | 42 +- .../playlist/mapper_counter_view.rs | 2 +- .../components/playlist/mapper_script_view.rs | 140 +++- .../src/app/components/playlist/mappings.rs | 41 +- frontend/src/app/components/playlist/mod.rs | 84 +-- .../playlist/playlist_editor_page.rs | 18 +- .../playlist/playlist_editor_view.rs | 27 +- .../components/playlist/playlist_explorer.rs | 180 +++-- .../playlist/playlist_explorer_page.rs | 18 +- .../playlist/playlist_explorer_view.rs | 27 +- .../playlist/playlist_source_selector.rs | 175 +++-- .../playlist/playlist_update_view.rs | 39 +- .../src/app/components/playlist/processing.rs | 17 +- .../playlist/target/hdhomerun_output.rs | 2 +- .../components/playlist/target/m3u_output.rs | 23 +- .../src/app/components/playlist/target/mod.rs | 21 +- .../components/playlist/target/strm_output.rs | 26 +- .../playlist/target/target_options.rs | 35 +- .../playlist/target/target_output.rs | 2 +- .../playlist/target/target_rename.rs | 10 +- .../components/playlist/target/target_sort.rs | 115 ++- .../playlist/target/target_watch.rs | 6 +- .../playlist/target/xtream_output.rs | 45 +- .../app/components/playlist/target_table.rs | 74 +- frontend/src/app/components/popup_menu.rs | 72 +- .../src/app/components/radio_button_group.rs | 2 +- frontend/src/app/components/reveal_content.rs | 2 +- .../src/app/components/role_based_content.rs | 10 +- frontend/src/app/components/search.rs | 73 +- frontend/src/app/components/select.rs | 22 +- frontend/src/app/components/sidebar.rs | 18 +- .../source_editor/alias_item_form.rs | 16 +- .../components/source_editor/block_view.rs | 63 +- .../components/source_editor/editor_form.rs | 24 +- .../components/source_editor/editor_model.rs | 23 +- .../components/source_editor/editor_view.rs | 442 +++++++---- .../source_editor/epg_source_item_form.rs | 16 +- .../components/source_editor/input_form.rs | 93 ++- .../app/components/source_editor/layout.rs | 65 +- .../src/app/components/source_editor/mod.rs | 52 +- .../components/source_editor/output_form.rs | 90 ++- .../source_editor/output_hdhomerun_form.rs | 34 +- .../source_editor/output_m3u_form.rs | 24 +- .../source_editor/output_strm_form.rs | 55 +- .../source_editor/output_xtream_form.rs | 84 ++- .../src/app/components/source_editor/rules.rs | 19 +- .../app/components/source_editor/sidebar.rs | 14 +- .../components/source_editor/target_form.rs | 90 ++- .../source_editor/trakt_list_item_form.rs | 27 +- frontend/src/app/components/svg_icon.rs | 21 +- frontend/src/app/components/table.rs | 4 +- frontend/src/app/components/tabset.rs | 12 +- frontend/src/app/components/tag_list.rs | 9 +- frontend/src/app/components/text_button.rs | 5 +- frontend/src/app/components/theme.rs | 12 +- frontend/src/app/components/toastr.rs | 20 +- frontend/src/app/components/toggle_switch.rs | 2 +- frontend/src/app/components/userlist/edit.rs | 122 +-- frontend/src/app/components/userlist/list.rs | 19 +- .../components/userlist/max_connections.rs | 5 +- frontend/src/app/components/userlist/mod.rs | 18 +- frontend/src/app/components/userlist/page.rs | 18 +- .../components/userlist/proxy_type_input.rs | 50 +- .../components/userlist/proxy_type_view.rs | 8 +- .../userlist/proxy_user_credentials_form.rs | 150 ++-- .../app/components/userlist/user_status.rs | 11 +- .../src/app/components/userlist/user_table.rs | 124 ++- .../app/components/userlist/userlist_view.rs | 41 +- .../src/app/components/websocket_status.rs | 7 +- frontend/src/app/context.rs | 62 +- frontend/src/app/mod.rs | 149 ++-- frontend/src/error.rs | 4 +- frontend/src/hooks/mod.rs | 6 +- frontend/src/hooks/use_icon_context.rs | 16 +- frontend/src/hooks/use_server_status.rs | 157 ++-- frontend/src/hooks/use_service_context.rs | 21 +- frontend/src/lib.rs | 6 +- frontend/src/main.rs | 2 +- frontend/src/model/busy_status.rs | 5 +- frontend/src/model/dialog.rs | 19 +- frontend/src/model/event_message.rs | 10 +- frontend/src/model/explorer_source_type.rs | 4 +- frontend/src/model/mod.rs | 18 +- frontend/src/model/view_type.rs | 7 +- frontend/src/model/web_config.rs | 6 +- frontend/src/provider/dialog_provider.rs | 24 +- .../src/provider/icon_context_provider.rs | 4 +- frontend/src/provider/mod.rs | 4 +- .../src/provider/service_context_provider.rs | 9 +- frontend/src/services/auth_service.rs | 43 +- frontend/src/services/config_service.rs | 102 ++- frontend/src/services/dialog_service.rs | 22 +- frontend/src/services/event_service.rs | 12 +- frontend/src/services/mod.rs | 26 +- frontend/src/services/playlist_service.rs | 68 +- frontend/src/services/requests.rs | 85 ++- frontend/src/services/status_service.rs | 4 +- frontend/src/services/streams_service.rs | 8 +- frontend/src/services/toastr_service.rs | 2 +- frontend/src/services/user_api_service.rs | 35 +- frontend/src/services/user_service.rs | 24 +- frontend/src/services/websocket_service.rs | 163 ++-- frontend/src/utils/mod.rs | 11 +- frontend/src/utils/storage.rs | 10 +- shared/src/error/mod.rs | 2 +- shared/src/error/tuliprox_error.rs | 10 +- shared/src/foundation/filter.rs | 324 ++++---- shared/src/foundation/mapper.rs | 703 ++++++++++++------ shared/src/lib.rs | 2 +- .../model/active_user_connection_change.rs | 6 +- shared/src/model/cluster_flags.rs | 26 +- shared/src/model/config/api.rs | 2 +- shared/src/model/config/api_proxy.rs | 33 +- shared/src/model/config/api_user.rs | 31 +- shared/src/model/config/app_config.rs | 2 +- shared/src/model/config/base.rs | 48 +- shared/src/model/config/cache.rs | 22 +- shared/src/model/config/config_type.rs | 20 +- shared/src/model/config/epg.rs | 16 +- shared/src/model/config/epg_smart_match.rs | 18 +- shared/src/model/config/favourites.rs | 11 +- shared/src/model/config/geoip.rs | 7 +- shared/src/model/config/hdhomerun.rs | 76 +- shared/src/model/config/input.rs | 156 ++-- shared/src/model/config/log.rs | 6 +- shared/src/model/config/macros.rs | 26 +- shared/src/model/config/messaging.rs | 5 +- shared/src/model/config/mod.rs | 118 +-- shared/src/model/config/pattern_template.rs | 2 +- .../src/model/config/playlist_update_state.rs | 2 +- shared/src/model/config/proxy.rs | 25 +- shared/src/model/config/proxy_type.rs | 21 +- shared/src/model/config/proxy_user_status.rs | 50 +- shared/src/model/config/rate_limit.rs | 12 +- shared/src/model/config/rename.rs | 13 +- shared/src/model/config/reverse_proxy.rs | 19 +- shared/src/model/config/schedule.rs | 2 +- shared/src/model/config/sort.rs | 47 +- shared/src/model/config/source.rs | 48 +- shared/src/model/config/stream.rs | 26 +- shared/src/model/config/target.rs | 129 +++- shared/src/model/config/trakt.rs | 31 +- shared/src/model/config/video_download.rs | 27 +- shared/src/model/config/web_ui.rs | 2 +- shared/src/model/epg.rs | 7 +- shared/src/model/ip_check.rs | 3 +- shared/src/model/item_field.rs | 32 +- shared/src/model/mapping.rs | 95 ++- shared/src/model/messaging.rs | 6 +- shared/src/model/mod.rs | 74 +- shared/src/model/playlist.rs | 176 +++-- shared/src/model/playlist_categories.rs | 2 +- shared/src/model/playlist_request.rs | 34 +- shared/src/model/processing_order.rs | 28 +- shared/src/model/search_request.rs | 4 +- shared/src/model/status_check.rs | 6 +- shared/src/model/stream_info.rs | 27 +- shared/src/model/strm_export_style.rs | 25 +- shared/src/model/system_info.rs | 2 +- shared/src/model/target_type.rs | 25 +- shared/src/model/user_command.rs | 6 +- shared/src/model/web_socket.rs | 19 +- shared/src/model/webplayer_url_request.rs | 4 +- shared/src/model/xtream_const.rs | 38 +- shared/src/utils/constants.rs | 14 +- shared/src/utils/default_utils.rs | 40 +- shared/src/utils/directed_graph.rs | 52 +- shared/src/utils/hash_utils.rs | 34 +- shared/src/utils/hdhomerun_utils.rs | 10 +- shared/src/utils/json_utils.rs | 18 +- shared/src/utils/mod.rs | 31 +- shared/src/utils/net_utils.rs | 2 +- shared/src/utils/number_utils.rs | 19 +- shared/src/utils/request.rs | 31 +- shared/src/utils/serde_utils.rs | 15 +- shared/src/utils/size_utils.rs | 49 +- shared/src/utils/string_utils.rs | 16 +- shared/src/utils/time_utils.rs | 11 +- 271 files changed, 7320 insertions(+), 4416 deletions(-) diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 02e148f9c..98631e9da 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -342,19 +342,44 @@ async fn resolve_streaming_strategy( force_provider: Option<&str>, ) -> StreamingStrategy { // allocate a provider connection - let mut provider_connection_handle = match force_provider { - Some(provider) => app_state.active_provider.force_exact_acquire_connection(provider, &fingerprint.addr).await, - None => app_state.active_provider.acquire_connection(&input.name, &fingerprint.addr).await, + let mut provider_connection_handle = if let Some(provider) = force_provider { + app_state + .active_provider + .force_exact_acquire_connection(provider, &fingerprint.addr) + .await + } else { + // For panel-managed pools we must not allocate providers in grace period, otherwise + // the pool may never reach `Exhausted` and panel provisioning won't be triggered. + let allow_provider_grace = input.panel_api.is_none(); + if !allow_provider_grace { + debug_if_enabled!( + "panel_api: disabling provider grace allocations for input {}", + sanitize_sensitive_info(&input.name) + ); + } + app_state + .active_provider + .acquire_connection_with_grace_override(&input.name, &fingerprint.addr, allow_provider_grace) + .await }; if provider_connection_handle.is_none() && force_provider.is_none() && try_provision_account_on_exhausted(app_state, input).await { + debug_if_enabled!( + "panel_api: provider pool exhausted for input {}, provision succeeded; re-acquiring connection", + sanitize_sensitive_info(&input.name) + ); provider_connection_handle = app_state .active_provider - .acquire_connection(&input.name, &fingerprint.addr) + .acquire_connection_with_grace_override(&input.name, &fingerprint.addr, input.panel_api.is_none()) .await; + } else if provider_connection_handle.is_none() && force_provider.is_none() && input.panel_api.is_some() { + debug_if_enabled!( + "panel_api: provider pool exhausted for input {}, provision skipped/failed", + sanitize_sensitive_info(&input.name) + ); } let stream_response_params = @@ -467,32 +492,104 @@ async fn create_stream_response_details( .as_ref() .and_then(|r| r.stream.as_ref()) .map_or_else(default_grace_period_millis, |s| s.grace_period_millis); - let grace_period_millis = get_grace_period_millis( + let mut grace_period_millis = get_grace_period_millis( connection_permission, &streaming_strategy.provider_stream_state, config_grace_period_millis, ); + + if let ProviderStreamState::GracePeriod(ref provider_grace_check, ref request_url) = + streaming_strategy.provider_stream_state + { + tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await; + if let Some(provider_name) = provider_grace_check.as_ref() { + if app_state.active_provider.is_over_limit(provider_name).await { + debug!("Provider connections exhausted after grace period for provider: {provider_name}"); + + let provider_handle = streaming_strategy.provider_handle.take(); + app_state.connection_manager.release_provider_handle(provider_handle).await; + + // If panel_api is configured, try to recover by provisioning a new account (if needed) + // and then re-acquire without provider grace allocations. + let mut recovered = false; + if force_provider.is_none() && input.panel_api.is_some() { + debug_if_enabled!( + "panel_api: provider {} over limit after grace; attempting re-acquire/provision for input {}", + sanitize_sensitive_info(provider_name), + sanitize_sensitive_info(&input.name) + ); + + let mut new_handle = app_state + .active_provider + .acquire_connection_with_grace_override(&input.name, &fingerprint.addr, false) + .await; + + if new_handle.is_none() + && try_provision_account_on_exhausted(app_state, input).await + { + debug_if_enabled!( + "panel_api: provision succeeded after grace exhaustion for input {}; re-acquiring (no grace)", + sanitize_sensitive_info(&input.name) + ); + new_handle = app_state + .active_provider + .acquire_connection_with_grace_override(&input.name, &fingerprint.addr, false) + .await; + } + + if let Some(handle) = new_handle { + if let Some(provider_cfg) = handle.allocation.get_provider_config() { + let (selected_provider_name, url) = if provider_cfg.id == input.id { + (input.name.clone(), stream_url.to_string()) + } else { + ( + provider_cfg.name.clone(), + get_stream_alternative_url(stream_url, input, &provider_cfg), + ) + }; + streaming_strategy.provider_stream_state = + ProviderStreamState::Available(Some(selected_provider_name), url); + streaming_strategy.provider_handle = Some(handle); + recovered = true; + } else { + app_state + .connection_manager + .release_provider_handle(Some(handle)) + .await; + } + } + } + + if !recovered { + app_state + .connection_manager + .update_stream_detail( + &fingerprint.addr, + CustomVideoStreamType::ProviderConnectionsExhausted, + ) + .await; + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + streaming_strategy.provider_stream_state = ProviderStreamState::Custom(stream); + } + } + } else { + streaming_strategy.provider_stream_state = + ProviderStreamState::Available(provider_grace_check.clone(), request_url.clone()); + } + } + + // Recompute grace period after potential recovery/strategy changes. + grace_period_millis = get_grace_period_millis( + connection_permission, + &streaming_strategy.provider_stream_state, + config_grace_period_millis, + ); + let guard_provider_name = streaming_strategy .provider_handle .as_ref() .and_then(|guard| guard.allocation.get_provider_name()); - if let ProviderStreamState::GracePeriod(ref provider_grace_check, ref stream_url) = streaming_strategy.provider_stream_state { - tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await; - if let Some(provider_name) = provider_grace_check.as_ref() { - if app_state.active_provider.is_over_limit(provider_name).await { - app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted).await; - debug!("Provider connections exhausted after grace period for provider: {provider_name}"); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - streaming_strategy.provider_stream_state = ProviderStreamState::Custom(stream); - let provider_handle = streaming_strategy.provider_handle.take(); - app_state.connection_manager.release_provider_handle(provider_handle).await; - } - } else { - streaming_strategy.provider_stream_state = ProviderStreamState::Available(provider_grace_check.clone(), stream_url.clone()); - } - } - match streaming_strategy.provider_stream_state { // custom stream means we display our own stream like connection exhausted, channel-unavailable... ProviderStreamState::Custom(provider_stream) => { diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 12c7bfc7b..84842d9f3 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -74,12 +74,25 @@ impl ActiveProviderManager { self.providers.update_config(inputs, grace_period_millis, grace_period_timeout_secs).await; } - async fn acquire_connection_inner(&self, provider_or_input_name: &str, addr: &SocketAddr, force: bool) -> Option { + async fn acquire_connection_inner( + &self, + provider_or_input_name: &str, + addr: &SocketAddr, + force: bool, + allow_grace_override: Option, + ) -> Option { // Call the specific acquisition function let allocation = if force { self.providers.force_exact_acquire_connection(provider_or_input_name).await } else { - self.providers.acquire_connection(provider_or_input_name).await + match allow_grace_override { + Some(allow_grace) => { + self.providers + .acquire_connection_with_grace_override(provider_or_input_name, allow_grace) + .await + } + None => self.providers.acquire_connection(provider_or_input_name).await, + } }; match &allocation { @@ -105,12 +118,22 @@ impl ActiveProviderManager { } pub async fn force_exact_acquire_connection(&self, provider_name: &str, addr: &SocketAddr) -> Option { - self.acquire_connection_inner(provider_name, addr, true).await + self.acquire_connection_inner(provider_name, addr, true, None).await } // Returns the next available provider connection pub async fn acquire_connection(&self, input_name: &str, addr: &SocketAddr) -> Option { - self.acquire_connection_inner(input_name, addr, false).await + self.acquire_connection_inner(input_name, addr, false, None).await + } + + /// Acquire a provider connection while optionally disabling provider grace allocations. + pub async fn acquire_connection_with_grace_override( + &self, + input_name: &str, + addr: &SocketAddr, + allow_grace: bool, + ) -> Option { + self.acquire_connection_inner(input_name, addr, false, Some(allow_grace)).await } // This method is used for redirects to cycle through provider @@ -211,4 +234,4 @@ impl ActiveProviderManager { pub async fn get_provider_connections_count(&self) -> usize { self.providers.active_connection_count().await } -} \ No newline at end of file +} diff --git a/backend/src/api/model/provider_config.rs b/backend/src/api/model/provider_config.rs index e69376aa4..2153e73aa 100644 --- a/backend/src/api/model/provider_config.rs +++ b/backend/src/api/model/provider_config.rs @@ -6,8 +6,10 @@ use std::ops::Deref; use std::sync::{Arc}; use tokio::sync::RwLock; use shared::model::InputType; +use shared::utils::sanitize_sensitive_info; use shared::write_if_some; use crate::api::model::ProviderAllocation; +use crate::utils::debug_if_enabled; pub type ProviderConnectionChangeCallback = Arc; @@ -103,6 +105,16 @@ impl ProviderConfig { where F: Fn(&str) -> Option<&'a ProviderConfigConnection>, { + let panel_api_enabled = cfg.panel_api.is_some(); + let effective_max_connections = if panel_api_enabled && cfg.max_connections == 0 { + debug_if_enabled!( + "panel_api: input '{}' has max_connections=0; defaulting effective max_connections to 1 for pool accounting", + cfg.name + ); + 1usize + } else { + cfg.max_connections as usize + }; Self { id: cfg.id, name: cfg.name.clone(), @@ -110,7 +122,7 @@ impl ProviderConfig { username: cfg.username.clone(), password: cfg.password.clone(), input_type: cfg.input_type, - max_connections: cfg.max_connections as usize, + max_connections: effective_max_connections, priority: cfg.priority, exp_date: cfg.exp_date, connection: RwLock::new(get_connection.and_then(|f| f(cfg.name.as_str())).map_or_else(Default::default, Clone::clone)), @@ -122,6 +134,16 @@ impl ProviderConfig { where F: Fn(&str) -> Option<&'a ProviderConfigConnection>, { + let panel_api_enabled = cfg.panel_api.is_some(); + let effective_max_connections = if panel_api_enabled && alias.max_connections == 0 { + debug_if_enabled!( + "panel_api: alias '{}' has max_connections=0; defaulting effective max_connections to 1 for pool accounting", + alias.name + ); + 1usize + } else { + alias.max_connections as usize + }; Self { id: alias.id, name: alias.name.clone(), @@ -129,7 +151,7 @@ impl ProviderConfig { username: alias.username.clone(), password: alias.password.clone(), input_type: cfg.input_type, - max_connections: alias.max_connections as usize, + max_connections: effective_max_connections, priority: alias.priority, exp_date: alias.exp_date, connection: RwLock::new(get_connection.and_then(|f| f(alias.name.as_str())).map_or_else(Default::default, Clone::clone)), @@ -137,6 +159,11 @@ impl ProviderConfig { } } + #[inline] + pub fn max_connections(&self) -> usize { + self.max_connections + } + pub fn get_user_info(&self) -> Option { InputUserInfo::new(self.input_type, self.username.as_deref(), self.password.as_deref(), &self.url) } @@ -222,6 +249,12 @@ impl ProviderConfig { guard.granted_grace = false; guard.grace_ts = 0; } + debug_if_enabled!( + "Provider {} granting grace allocation (current_connections={}, max_connections={})", + sanitize_sensitive_info(&self.name), + connections, + self.max_connections + ); guard.granted_grace = true; guard.grace_ts = now; modify_connections!(self, guard, +1); @@ -342,4 +375,4 @@ impl Deref for ProviderConfigWrapper { fn deref(&self) -> &Self::Target { &self.inner } -} \ No newline at end of file +} diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index 6a26733bf..db597571e 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -660,10 +660,12 @@ impl ProviderLineupManager { ProviderAllocation::Exhausted => {} ProviderAllocation::Available(ref cfg) | ProviderAllocation::GracePeriod(ref cfg) => { debug_if_enabled!( - "Using provider {} (pool user: {})", + "Using provider {} (pool user: {}, max_connections: {})", sanitize_sensitive_info(&cfg.name), cfg.get_user_info() .map_or_else(|| "?".to_string(), |ui| sanitize_sensitive_info(&ui.username).to_string()) + , + cfg.max_connections() ); } } @@ -681,11 +683,27 @@ impl ProviderLineupManager { // Returns the next available provider connection pub(crate) async fn acquire_connection(&self, input_name: &str) -> ProviderAllocation { + self.acquire_connection_with_grace_override(input_name, true).await + } + + /// Acquire a provider connection, optionally allowing provider-side grace allocations. + /// + /// When `allow_grace` is `false`, the lineup will not allocate providers in `GracePeriod`, + /// even if a global grace period is configured. + pub(crate) async fn acquire_connection_with_grace_override( + &self, + input_name: &str, + allow_grace: bool, + ) -> ProviderAllocation { let providers = self.providers.load(); + let with_grace = allow_grace && self.grace_period_millis.load(Ordering::Acquire) > 0; let allocation = match Self::get_provider_config_by_name(input_name, &providers) { None => ProviderAllocation::Exhausted, // No Name matched, we don't have this provider - Some((lineup, _config)) => lineup.acquire(self.grace_period_millis.load(Ordering::Acquire) > 0, - self.grace_period_timeout_secs.load(Ordering::Acquire)).await + Some((lineup, _config)) => { + lineup + .acquire(with_grace, self.grace_period_timeout_secs.load(Ordering::Acquire)) + .await + } }; Self::log_allocation(&allocation); allocation diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 99ead3f40..5787ef0c3 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -730,9 +730,11 @@ async fn try_create_new_account( pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &ConfigInput) -> bool { let Some(panel_cfg) = input.panel_api.as_ref() else { + debug_if_enabled!("panel_api: skipped (no panel_api config) for input {}", sanitize_sensitive_info(&input.name)); return false; }; if panel_cfg.url.trim().is_empty() { + debug_if_enabled!("panel_api: skipped (panel_api.url empty) for input {}", sanitize_sensitive_info(&input.name)); return false; } @@ -747,14 +749,27 @@ pub async fn try_provision_account_on_exhausted(app_state: &AppState, input: &Co return false; } + debug_if_enabled!( + "panel_api: exhausted -> provisioning for input {} (aliases={})", + sanitize_sensitive_info(&input.name), + input.aliases.as_ref().map_or(0, Vec::len) + ); + let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); let sources_path = PathBuf::from(&sources_file_path); if try_renew_expired_account(app_state, input, panel_cfg, is_batch, sources_path.as_path()).await { + debug_if_enabled!("panel_api: provisioning succeeded via client_renew for input {}", sanitize_sensitive_info(&input.name)); return true; } - try_create_new_account(app_state, input, panel_cfg, is_batch, sources_path.as_path()).await + let created = try_create_new_account(app_state, input, panel_cfg, is_batch, sources_path.as_path()).await; + debug_if_enabled!( + "panel_api: provisioning via client_new for input {} => {}", + sanitize_sensitive_info(&input.name), + if created { "success" } else { "failed" } + ); + created } pub(crate) async fn sync_panel_api_exp_dates_on_boot(app_state: &Arc) { diff --git a/backend/src/main.rs b/backend/src/main.rs index c186780aa..b512be81b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -10,19 +10,21 @@ mod modules; include_modules!(); use crate::auth::generate_password; -use crate::model::{AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig}; +use crate::model::{ + AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig, +}; use crate::processing::processor::playlist; +use crate::utils::init_logger; +use crate::utils::request::create_client; use crate::utils::{config_file_reader, resolve_env_var}; -use crate::utils::request::{create_client}; -use chrono::{DateTime, Utc}; -use clap::{Parser}; -use log::{error, info}; -use std::fs::File; -use std::sync::Arc; use arc_swap::access::Access; use arc_swap::ArcSwap; +use chrono::{DateTime, Utc}; +use clap::Parser; +use log::{error, info}; use shared::model::ConfigPaths; -use crate::utils::init_logger; +use std::fs::File; +use std::sync::Arc; #[derive(Parser)] #[command(name = "tuliprox")] @@ -55,7 +57,12 @@ struct Args { api_proxy: Option, /// Run in server mode - #[arg(short = 's', long, default_value_t = false, default_missing_value = "true")] + #[arg( + short = 's', + long, + default_value_t = false, + default_missing_value = "true" + )] server: bool, /// log level @@ -70,7 +77,6 @@ struct Args { healthcheck: bool, } - const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TIMESTAMP: &str = env!("VERGEN_BUILD_TIMESTAMP"); @@ -96,7 +102,10 @@ async fn main() { let mut config_paths = get_file_paths(&args); - init_logger(args.log_level.as_ref(), config_paths.config_file_path.as_str()); + init_logger( + args.log_level.as_ref(), + config_paths.config_file_path.as_str(), + ); if args.healthcheck { let healthy = healthcheck(config_paths.config_file_path.as_str()).await; @@ -104,14 +113,23 @@ async fn main() { } info!("Version: {VERSION}"); - if let Some(bts) = BUILD_TIMESTAMP.to_string().parse::>().ok().map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string()) { + if let Some(bts) = BUILD_TIMESTAMP + .to_string() + .parse::>() + .ok() + .map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string()) + { info!("Build time: {bts}"); } - let app_config = utils::read_initial_app_config(&mut config_paths, true, true, args.server).await.unwrap_or_else(|err| exit!("{}", err)); + let app_config = utils::read_initial_app_config(&mut config_paths, true, true, args.server) + .await + .unwrap_or_else(|err| exit!("{}", err)); print_info(&app_config); let sources = > as Access>::load(&app_config.sources); - let targets = sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err)); + let targets = sources + .validate_targets(args.target.as_ref()) + .unwrap_or_else(|err| exit!("{}", err)); if args.server { start_in_server_mode(Arc::new(app_config), Arc::new(targets)).await; @@ -123,14 +141,23 @@ async fn main() { fn print_info(app_config: &AppConfig) { let config = > as Access>::load(&app_config.config); let paths = > as Access>::load(&app_config.paths); - info!("Current time: {}", chrono::offset::Local::now().format("%Y-%m-%d %H:%M:%S")); + info!( + "Current time: {}", + chrono::offset::Local::now().format("%Y-%m-%d %H:%M:%S") + ); info!("Temp dir: {}", tempfile::env::temp_dir().display()); info!("Working dir: {:?}", &config.working_dir); info!("Config dir: {:?}", &paths.config_path); info!("Config file: {:?}", &paths.config_file_path); info!("Source file: {:?}", &paths.sources_file_path); info!("Api Proxy File: {:?}", &paths.api_proxy_file_path); - info!("Mapping file: {:?}", &paths.mapping_file_path.as_ref().map_or_else(|| "not used", |v| v.as_str())); + info!( + "Mapping file: {:?}", + &paths + .mapping_file_path + .as_ref() + .map_or_else(|| "not used", |v| v.as_str()) + ); if let Some(cache) = config.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()) { if cache.enabled { @@ -143,10 +170,24 @@ fn print_info(app_config: &AppConfig) { } fn get_file_paths(args: &Args) -> ConfigPaths { - let config_path: String = utils::resolve_directory_path(&resolve_env_var(&args.config_path.as_ref().map_or_else(utils::get_default_config_path, ToString::to_string))); - let config_file: String = resolve_env_var(&args.config_file.as_ref().map_or_else(|| utils::get_default_config_file_path(&config_path), ToString::to_string)); - let api_proxy_file = resolve_env_var(&args.api_proxy.as_ref().map_or_else(|| utils::get_default_api_proxy_config_path(config_path.as_str()), ToString::to_string)); - let sources_file: String = resolve_env_var(&args.source_file.as_ref().map_or_else(|| utils::get_default_sources_file_path(&config_path), ToString::to_string)); + let config_path: String = utils::resolve_directory_path(&resolve_env_var( + &args + .config_path + .as_ref() + .map_or_else(utils::get_default_config_path, ToString::to_string), + )); + let config_file: String = resolve_env_var(&args.config_file.as_ref().map_or_else( + || utils::get_default_config_file_path(&config_path), + ToString::to_string, + )); + let api_proxy_file = resolve_env_var(&args.api_proxy.as_ref().map_or_else( + || utils::get_default_api_proxy_config_path(config_path.as_str()), + ToString::to_string, + )); + let sources_file: String = resolve_env_var(&args.source_file.as_ref().map_or_else( + || utils::get_default_sources_file_path(&config_path), + ToString::to_string, + )); let mappings_file = args.mapping_file.as_ref().map(|p| resolve_env_var(p)); ConfigPaths { @@ -184,21 +225,21 @@ async fn healthcheck(config_file: &str) -> bool { .send() .await { - Ok(response) => matches!(response.json::().await, Ok(check) if check.status == "ok"), + Ok(response) => { + matches!(response.json::().await, Ok(check) if check.status == "ok") + } Err(_) => false, } - } Err(err) => { error!("Failed to parse config file for healthcheck {err:?}"); false } } - }, + } Err(err) => { error!("Failed to open config file for healthcheck {err:?}"); false } - } - + } } diff --git a/backend/src/modules.rs b/backend/src/modules.rs index 1838eab07..64bef0251 100644 --- a/backend/src/modules.rs +++ b/backend/src/modules.rs @@ -10,8 +10,7 @@ macro_rules! include_modules { pub mod model; pub mod processing; pub mod repository; - pub mod utils; pub mod tools; - } + pub mod utils; + }; } - diff --git a/frontend/src/app/components/accordion.rs b/frontend/src/app/components/accordion.rs index cb13ce856..4b6a851be 100644 --- a/frontend/src/app/components/accordion.rs +++ b/frontend/src/app/components/accordion.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use std::rc::Rc; +use yew::prelude::*; #[derive(Debug, Clone, PartialEq)] pub struct AccordionState { diff --git a/frontend/src/app/components/accordion_panel.rs b/frontend/src/app/components/accordion_panel.rs index fb7581ff3..31b0af5a2 100644 --- a/frontend/src/app/components/accordion_panel.rs +++ b/frontend/src/app/components/accordion_panel.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use crate::app::components::{AccordionState, AppIcon}; +use yew::prelude::*; #[derive(Properties, Clone, PartialEq, Debug)] pub struct AccordionPanelProps { @@ -16,7 +16,8 @@ pub struct AccordionPanelProps { #[function_component] pub fn AccordionPanel(props: &AccordionPanelProps) -> Html { - let context = use_context::().expect("AccordionPanel must be used inside Accordion"); + let context = + use_context::().expect("AccordionPanel must be used inside Accordion"); let expanded = (**context.active_panel).as_ref() == Some(&props.id); let toggle = { @@ -45,4 +46,4 @@ pub fn AccordionPanel(props: &AccordionPanelProps) -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/api_user/api_user_view.rs b/frontend/src/app/components/api_user/api_user_view.rs index 3a798a7a0..51bd855f0 100644 --- a/frontend/src/app/components/api_user/api_user_view.rs +++ b/frontend/src/app/components/api_user/api_user_view.rs @@ -1,11 +1,11 @@ -use yew::{function_component, html, Callback, Html}; +use crate::app::components::api_user::playlist::ApiUserPlaylist; use crate::app::components::loading_indicator::BusyIndicator; -use crate::app::components::{AppIcon, IconButton, ToastrView, WebsocketStatus}; use crate::app::components::theme::Theme; +use crate::app::components::{AppIcon, IconButton, ToastrView, WebsocketStatus}; use crate::hooks::use_service_context; use crate::provider::DialogProvider; use yew::use_state; -use crate::app::components::api_user::playlist::ApiUserPlaylist; +use yew::{function_component, html, Callback, Html}; #[function_component] pub fn ApiUserView() -> Html { @@ -15,7 +15,11 @@ pub fn ApiUserView() -> Html { let handle_theme_switch = { let set_theme = theme.clone(); Callback::from(move |_| { - let new_theme = if *set_theme == Theme::Dark { Theme::Bright } else { Theme::Dark }; + let new_theme = if *set_theme == Theme::Dark { + Theme::Bright + } else { + Theme::Dark + }; new_theme.switch_theme(); set_theme.set(new_theme); }) @@ -56,4 +60,4 @@ pub fn ApiUserView() -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/api_user/mod.rs b/frontend/src/app/components/api_user/mod.rs index 6553e0328..b5568ece0 100644 --- a/frontend/src/app/components/api_user/mod.rs +++ b/frontend/src/app/components/api_user/mod.rs @@ -2,4 +2,4 @@ mod api_user_view; mod playlist; mod target_playlist; -pub use api_user_view::*; \ No newline at end of file +pub use api_user_view::*; diff --git a/frontend/src/app/components/api_user/playlist.rs b/frontend/src/app/components/api_user/playlist.rs index 118550665..e566fe92f 100644 --- a/frontend/src/app/components/api_user/playlist.rs +++ b/frontend/src/app/components/api_user/playlist.rs @@ -41,16 +41,24 @@ impl fmt::Display for ApiUserPlaylistPage { } } -fn to_playlist_cluster(count: (usize, usize, usize), bouquet: Option<&Rc>>) -> Option { +fn to_playlist_cluster( + count: (usize, usize, usize), + bouquet: Option<&Rc>>, +) -> Option { if let Some(bouq) = bouquet { let selections = bouq.borrow(); let selected_vec = |map: &HashMap| { - let v: Vec = map.iter() + let v: Vec = map + .iter() .filter(|(_, &selected)| selected) .map(|(c, _)| c.clone()) .collect(); - if v.is_empty() { None } else { Some(v) } + if v.is_empty() { + None + } else { + Some(v) + } }; let live = selected_vec(&selections.live).filter(|v| v.len() != count.0); @@ -68,7 +76,6 @@ fn to_playlist_cluster(count: (usize, usize, usize), bouquet: Option<&Rc Html { let translate = use_translation(); @@ -77,13 +84,15 @@ pub fn ApiUserPlaylist() -> Html { let bouquets = use_state(|| None as Option>); let active_tab = use_state(|| ApiUserPlaylistPage::Xtream); let playlist_types = use_memo((), |_| { - [ApiUserPlaylistPage::Xtream, ApiUserPlaylistPage::M3u].iter().map(ToString::to_string).collect::>() + [ApiUserPlaylistPage::Xtream, ApiUserPlaylistPage::M3u] + .iter() + .map(ToString::to_string) + .collect::>() }); // Selection reference - let selections = use_mut_ref(|| { - HashMap::>>::new() - }); + let selections = + use_mut_ref(|| HashMap::>>::new()); let handle_tab_select = { let active_tab_clone = active_tab.clone(); @@ -105,8 +114,13 @@ pub fn ApiUserPlaylist() -> Html { use_effect_with((), move |_| { wasm_bindgen_futures::spawn_local(async move { - services.event.broadcast(EventMessage::Busy(BusyStatus::Show)); - let result = (services.user_api.get_playlist_bouquet().await, services.user_api.get_playlist_categories().await); + services + .event + .broadcast(EventMessage::Busy(BusyStatus::Show)); + let result = ( + services.user_api.get_playlist_bouquet().await, + services.user_api.get_playlist_categories().await, + ); match result { (Ok(bouquet), Ok(cats)) => { bouquets.set(bouquet.clone()); @@ -114,15 +128,21 @@ pub fn ApiUserPlaylist() -> Html { } (Err(e1), Err(e2)) => { log::error!("Failed to load bouquet: {e1:?}, categories: {e2:?}"); - services.toastr.error(translate.t("MESSAGES.DOWNLOAD.USER_BOUQUET.FAIL")); + services + .toastr + .error(translate.t("MESSAGES.DOWNLOAD.USER_BOUQUET.FAIL")); } (Err(e), _) | (_, Err(e)) => { log::error!("Failed to load user data: {e:?}"); - services.toastr.error(translate.t("MESSAGES.DOWNLOAD.USER_BOUQUET.FAIL")); + services + .toastr + .error(translate.t("MESSAGES.DOWNLOAD.USER_BOUQUET.FAIL")); } } - services.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); + services + .event + .broadcast(EventMessage::Busy(BusyStatus::Hide)); }); || {} }); @@ -139,33 +159,61 @@ pub fn ApiUserPlaylist() -> Html { let selections = selections.clone(); let services = services.clone(); let translate = translate.clone(); - let categories_xtream_count = categories.as_ref().and_then(|plc| plc.xtream.as_ref().map(|x| - (x.live.as_ref().map(|v| v.len()).unwrap_or(0), - x.vod.as_ref().map(|v| v.len()).unwrap_or(0), - x.series.as_ref().map(|v| v.len()).unwrap_or(0)) - )).unwrap_or((0, 0, 0)); - let categories_m3u_count = categories.as_ref().and_then(|plc| plc.m3u.as_ref().map(|x| - (x.live.as_ref().map(|v| v.len()).unwrap_or(0), - x.vod.as_ref().map(|v| v.len()).unwrap_or(0), - x.series.as_ref().map(|v| v.len()).unwrap_or(0)) - )).unwrap_or((0, 0, 0)); + let categories_xtream_count = categories + .as_ref() + .and_then(|plc| { + plc.xtream.as_ref().map(|x| { + ( + x.live.as_ref().map(|v| v.len()).unwrap_or(0), + x.vod.as_ref().map(|v| v.len()).unwrap_or(0), + x.series.as_ref().map(|v| v.len()).unwrap_or(0), + ) + }) + }) + .unwrap_or((0, 0, 0)); + let categories_m3u_count = categories + .as_ref() + .and_then(|plc| { + plc.m3u.as_ref().map(|x| { + ( + x.live.as_ref().map(|v| v.len()).unwrap_or(0), + x.vod.as_ref().map(|v| v.len()).unwrap_or(0), + x.series.as_ref().map(|v| v.len()).unwrap_or(0), + ) + }) + }) + .unwrap_or((0, 0, 0)); wasm_bindgen_futures::spawn_local(async move { - services.event.broadcast(EventMessage::Busy(BusyStatus::Show)); + services + .event + .broadcast(EventMessage::Busy(BusyStatus::Show)); let result = { let selects = selections.borrow(); PlaylistBouquetDto { - xtream: to_playlist_cluster(categories_xtream_count, selects.get(&ApiUserPlaylistPage::Xtream)), - m3u: to_playlist_cluster(categories_m3u_count, selects.get(&ApiUserPlaylistPage::M3u)), + xtream: to_playlist_cluster( + categories_xtream_count, + selects.get(&ApiUserPlaylistPage::Xtream), + ), + m3u: to_playlist_cluster( + categories_m3u_count, + selects.get(&ApiUserPlaylistPage::M3u), + ), } }; match services.user_api.save_playlist_bouquet(&result).await { - Ok(()) => services.toastr.success(translate.t("MESSAGES.SAVE.BOUQUET.SUCCESS")), - Err(_) => services.toastr.error(translate.t("MESSAGES.SAVE.BOUQUET.FAIL")), + Ok(()) => services + .toastr + .success(translate.t("MESSAGES.SAVE.BOUQUET.SUCCESS")), + Err(_) => services + .toastr + .error(translate.t("MESSAGES.SAVE.BOUQUET.FAIL")), } - services.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); + services + .event + .broadcast(EventMessage::Busy(BusyStatus::Hide)); }); }) }; @@ -173,14 +221,18 @@ pub fn ApiUserPlaylist() -> Html { let handle_m3u_change = { let selections = selections.clone(); Callback::from(move |selection: Rc>| { - selections.borrow_mut().insert(ApiUserPlaylistPage::M3u, selection); + selections + .borrow_mut() + .insert(ApiUserPlaylistPage::M3u, selection); }) }; let handle_xtream_change = { let selections = selections.clone(); Callback::from(move |selection: Rc>| { - selections.borrow_mut().insert(ApiUserPlaylistPage::Xtream, selection); + selections + .borrow_mut() + .insert(ApiUserPlaylistPage::Xtream, selection); }) }; diff --git a/frontend/src/app/components/api_user/target_playlist.rs b/frontend/src/app/components/api_user/target_playlist.rs index 76e1914b9..4f9256a57 100644 --- a/frontend/src/app/components/api_user/target_playlist.rs +++ b/frontend/src/app/components/api_user/target_playlist.rs @@ -1,13 +1,13 @@ -use std::cell::RefCell; use crate::app::components::{AppIcon, Card, CollapsePanel}; +use crate::html_if; use shared::model::{PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, XtreamCluster}; +use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use std::str::FromStr; use wasm_bindgen::JsCast; use yew::prelude::*; use yew_i18n::use_translation; -use crate::html_if; fn normalize(s: &str) -> String { let cleaned: String = s @@ -66,39 +66,42 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { let in_cats = props.categories.clone(); let in_bouquet = props.bouquet.clone(); let force_update = force_update.clone(); - use_effect_with((in_cats, in_bouquet), move |(maybe_categories, maybe_bouquet)| { - let mut selections = BouquetSelection::default(); - if let Some(categories) = maybe_categories.as_ref() { - if let Some(bouquet) = maybe_bouquet.as_ref() { - create_selection!(bouquet, categories, selections, live); - create_selection!(bouquet, categories, selections, vod); - create_selection!(bouquet, categories, selections, series); - } else { - if let Some(cats) = categories.live.as_ref() { - for c in cats { - selections.live.insert(c.clone(), true); - } - } - if let Some(cats) = categories.vod.as_ref() { - for c in cats { - selections.vod.insert(c.clone(), true); - } - } - if let Some(cats) = categories.series.as_ref() { - for c in cats { - selections.series.insert(c.clone(), true); + use_effect_with( + (in_cats, in_bouquet), + move |(maybe_categories, maybe_bouquet)| { + let mut selections = BouquetSelection::default(); + if let Some(categories) = maybe_categories.as_ref() { + if let Some(bouquet) = maybe_bouquet.as_ref() { + create_selection!(bouquet, categories, selections, live); + create_selection!(bouquet, categories, selections, vod); + create_selection!(bouquet, categories, selections, series); + } else { + if let Some(cats) = categories.live.as_ref() { + for c in cats { + selections.live.insert(c.clone(), true); + } + } + if let Some(cats) = categories.vod.as_ref() { + for c in cats { + selections.vod.insert(c.clone(), true); + } + } + if let Some(cats) = categories.series.as_ref() { + for c in cats { + selections.series.insert(c.clone(), true); + } } } + *bouquet_selection.borrow_mut() = selections; + let mut new_categories = categories.clone(); + sort_opt_vec(&mut new_categories.live); + sort_opt_vec(&mut new_categories.vod); + sort_opt_vec(&mut new_categories.series); + playlist_categories.set(new_categories); + force_update.set(*force_update + 1); } - *bouquet_selection.borrow_mut() = selections; - let mut new_categories = categories.clone(); - sort_opt_vec(&mut new_categories.live); - sort_opt_vec(&mut new_categories.vod); - sort_opt_vec(&mut new_categories.series); - playlist_categories.set(new_categories); - force_update.set(*force_update + 1); - } - }); + }, + ); } let handle_category_click = { @@ -115,15 +118,18 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { let mut selections = bouquet_selection.borrow_mut(); match cluster { XtreamCluster::Live => { - let selected = *selections.live.get(&category).unwrap_or(&false); + let selected = + *selections.live.get(&category).unwrap_or(&false); selections.live.insert(category, !selected); } XtreamCluster::Video => { - let selected = *selections.vod.get(&category).unwrap_or(&false); + let selected = + *selections.vod.get(&category).unwrap_or(&false); selections.vod.insert(category, !selected); } XtreamCluster::Series => { - let selected = *selections.series.get(&category).unwrap_or(&false); + let selected = + *selections.series.get(&category).unwrap_or(&false); selections.series.insert(category, !selected); } } @@ -137,32 +143,33 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { }) }; - let render_category_cluster = |cluster: XtreamCluster, cats: Option<&Vec>, selections: &HashMap| { - if let Some(c) = cats { - html_if!(!c.is_empty(), { - - "LABEL.LIVE", - XtreamCluster::Video => "LABEL.MOVIE", - XtreamCluster::Series => "LABEL.SERIES" - })}> -
- { for c.iter().map(|cat| { - let selected = *selections.get(cat).unwrap_or(&false); - html! { -
- { &cat } -
- }})} -
-
-
- }) - } else { - html! {} - } - }; + let render_category_cluster = + |cluster: XtreamCluster, cats: Option<&Vec>, selections: &HashMap| { + if let Some(c) = cats { + html_if!(!c.is_empty(), { + + "LABEL.LIVE", + XtreamCluster::Video => "LABEL.MOVIE", + XtreamCluster::Series => "LABEL.SERIES" + })}> +
+ { for c.iter().map(|cat| { + let selected = *selections.get(cat).unwrap_or(&false); + html! { +
+ { &cat } +
+ }})} +
+
+
+ }) + } else { + html! {} + } + }; let selections = &*bouquet_selection.borrow(); html! { diff --git a/frontend/src/app/components/authentication.rs b/frontend/src/app/components/authentication.rs index 63bd4ac4b..a63383232 100644 --- a/frontend/src/app/components/authentication.rs +++ b/frontend/src/app/components/authentication.rs @@ -20,15 +20,16 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { let services_ctx = services.clone(); let authenticated_state = authenticated.clone(); let _ = use_future(|| async move { - services_ctx.auth.auth_subscribe( - &mut |success| { + services_ctx + .auth + .auth_subscribe(&mut |success| { authenticated_state.set(success); if success { services_ctx.websocket.connect_ws_with_backoff(); } future::ready(()) - } - ).await + }) + .await }); } @@ -36,13 +37,16 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { let services_ctx = services.clone(); let authenticated_state = authenticated.clone(); let loading_state = loading.clone(); - use_async_with_options(async move { - let result = services_ctx.auth.refresh().await; - let success = result.is_ok(); - authenticated_state.set(success); - loading_state.set(false); - result - }, UseAsyncOptions::enable_auto()); + use_async_with_options( + async move { + let result = services_ctx.auth.refresh().await; + let success = result.is_ok(); + authenticated_state.set(success); + loading_state.set(false); + result + }, + UseAsyncOptions::enable_auto(), + ); } if *loading { @@ -54,4 +58,4 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { } else { html! {} } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/breadcrumbs.rs b/frontend/src/app/components/breadcrumbs.rs index 3665a573f..ff1a0521b 100644 --- a/frontend/src/app/components/breadcrumbs.rs +++ b/frontend/src/app/components/breadcrumbs.rs @@ -1,8 +1,8 @@ +use crate::app::components::AppIcon; use std::rc::Rc; use std::str::FromStr; use wasm_bindgen::JsCast; use yew::prelude::*; -use crate::app::components::AppIcon; #[derive(Properties, PartialEq, Clone)] pub struct BreadcrumbsProps { diff --git a/frontend/src/app/components/card.rs b/frontend/src/app/components/card.rs index ec4a85ab5..c3d7317bd 100644 --- a/frontend/src/app/components/card.rs +++ b/frontend/src/app/components/card.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use crate::app::CardContext; +use yew::prelude::*; #[derive(Properties, Clone, PartialEq, Debug)] pub struct CardProps { @@ -21,4 +21,4 @@ pub fn Card(props: &CardProps) -> Html { > } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/cell_value.rs b/frontend/src/app/components/cell_value.rs index f6a55f533..318f14fd6 100644 --- a/frontend/src/app/components/cell_value.rs +++ b/frontend/src/app/components/cell_value.rs @@ -6,7 +6,7 @@ pub enum CellValue<'a> { Status(ProxyUserStatus), Proxy(ProxyType), Text(&'a str), - Date(i64) + Date(i64), } impl<'a> PartialOrd for CellValue<'a> { @@ -28,9 +28,13 @@ impl<'a> Ord for CellValue<'a> { (CellValue::Empty, _) => std::cmp::Ordering::Less, (CellValue::Bool(_), CellValue::Empty) => std::cmp::Ordering::Greater, (CellValue::Bool(_), _) => std::cmp::Ordering::Less, - (CellValue::Status(_), CellValue::Empty | CellValue::Bool(_)) => std::cmp::Ordering::Greater, + (CellValue::Status(_), CellValue::Empty | CellValue::Bool(_)) => { + std::cmp::Ordering::Greater + } (CellValue::Status(_), _) => std::cmp::Ordering::Less, - (CellValue::Proxy(_), CellValue::Empty | CellValue::Bool(_) | CellValue::Status(_)) => std::cmp::Ordering::Greater, + (CellValue::Proxy(_), CellValue::Empty | CellValue::Bool(_) | CellValue::Status(_)) => { + std::cmp::Ordering::Greater + } (CellValue::Proxy(_), _) => std::cmp::Ordering::Less, (CellValue::Text(_), CellValue::Date(_)) => std::cmp::Ordering::Less, (CellValue::Text(_), _) => std::cmp::Ordering::Greater, @@ -45,4 +49,4 @@ impl<'a> PartialEq for CellValue<'a> { } } -impl<'a> Eq for CellValue<'a> {} \ No newline at end of file +impl<'a> Eq for CellValue<'a> {} diff --git a/frontend/src/app/components/chip.rs b/frontend/src/app/components/chip.rs index 66161af5f..2c16b0a47 100644 --- a/frontend/src/app/components/chip.rs +++ b/frontend/src/app/components/chip.rs @@ -1,5 +1,5 @@ -use yew::{classes, function_component, html, Callback, Html, Properties}; use crate::app::components::AppIcon; +use yew::{classes, function_component, html, Callback, Html, Properties}; pub fn convert_bool_to_chip_style(value: bool) -> Option { Option::from((if value { "active" } else { "inactive" }).to_string()) @@ -18,7 +18,6 @@ pub struct ChipProps { #[function_component] pub fn Chip(props: &ChipProps) -> Html { - let handle_remove = { if props.removable { let on_remove = props.on_remove.clone(); @@ -32,7 +31,7 @@ pub fn Chip(props: &ChipProps) -> Html { let remove = handle_remove.clone(); let label = props.label.clone(); let on_remove = Callback::from(move |_| remove.emit(label.clone())); - html ! { + html! { diff --git a/frontend/src/app/components/cluster_flags_input.rs b/frontend/src/app/components/cluster_flags_input.rs index 455370a11..4c8b9625e 100644 --- a/frontend/src/app/components/cluster_flags_input.rs +++ b/frontend/src/app/components/cluster_flags_input.rs @@ -1,4 +1,4 @@ -use shared::model::{ClusterFlags}; +use shared::model::ClusterFlags; use yew::prelude::*; use yew_i18n::use_translation; @@ -17,7 +17,7 @@ pub struct ClusterFlagsInputProps { #[prop_or_default] pub on_change: Callback<(String, Option)>, #[prop_or_default] - pub mode: ClusterFlagsInputMode, + pub mode: ClusterFlagsInputMode, } #[function_component] @@ -25,7 +25,7 @@ pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html { let translate = use_translation(); let flags = use_state(|| { - props.value.unwrap_or_else(|| match props.mode { + props.value.unwrap_or_else(|| match props.mode { ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(), ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(), }) @@ -33,25 +33,25 @@ pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html { { let set_flags = flags.clone(); use_effect_with((props.value, props.mode), move |(val, cmode)| { - set_flags.set((*val).unwrap_or_else(|| match cmode { - ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(), - ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(), - })); + set_flags.set((*val).unwrap_or_else(|| match cmode { + ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(), + ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(), + })); }); } let handle_change = { - let onchange = props.on_change.clone(); - let name = props.name.clone(); - Callback::from(move |new_flags: Option| { - let cluster_flags = if new_flags.is_none_or(|f| f.is_empty()) { - None - } else { - new_flags - }; - let name = name.clone(); - onchange.emit((name, cluster_flags)); - }) + let onchange = props.on_change.clone(); + let name = props.name.clone(); + Callback::from(move |new_flags: Option| { + let cluster_flags = if new_flags.is_none_or(|f| f.is_empty()) { + None + } else { + new_flags + }; + let name = name.clone(); + onchange.emit((name, cluster_flags)); + }) }; let handle_flag_click = { @@ -84,4 +84,4 @@ pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html { { translate.t("LABEL.SERIES") } } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/collapse_panel.rs b/frontend/src/app/components/collapse_panel.rs index 0358378f2..b09271283 100644 --- a/frontend/src/app/components/collapse_panel.rs +++ b/frontend/src/app/components/collapse_panel.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use crate::app::components::AppIcon; +use yew::prelude::*; #[derive(Properties, Clone, PartialEq, Debug)] pub struct CollapsePanelProps { @@ -37,4 +37,4 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/api_config_view.rs b/frontend/src/app/components/config/api_config_view.rs index 0805263ca..93b3bdfe2 100644 --- a/frontend/src/app/components/config/api_config_view.rs +++ b/frontend/src/app/components/config/api_config_view.rs @@ -1,11 +1,14 @@ +use crate::app::components::config::config_page::{ConfigForm, LABEL_API_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::AppIcon; use crate::app::context::ConfigContext; +use crate::{ + config_field, config_field_empty, edit_field_number_u16, edit_field_text, + generate_form_reducer, html_if, +}; +use shared::model::ConfigApiDto; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{ConfigApiDto}; -use crate::{config_field, config_field_empty, edit_field_number_u16, edit_field_text, generate_form_reducer, html_if}; -use crate::app::components::AppIcon; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_API_CONFIG}; const LABEL_HOST: &str = "LABEL.HOST"; const LABEL_PORT: &str = "LABEL.PORT"; @@ -28,8 +31,9 @@ pub fn ApiConfigView() -> Html { let config_ctx = use_context::().expect("Config context not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let form_state: UseReducerHandle = use_reducer(|| { - ApiConfigFormState { form: ConfigApiDto::default(), modified: false } + let form_state: UseReducerHandle = use_reducer(|| ApiConfigFormState { + form: ConfigApiDto::default(), + modified: false, }); { @@ -43,10 +47,7 @@ pub fn ApiConfigView() -> Html { { let form_state = form_state.clone(); - let api_config = config_ctx - .config - .as_ref() - .map(|c| c.config.api.clone()); + let api_config = config_ctx.config.as_ref().map(|c| c.config.api.clone()); let deps = (api_config, *config_view_ctx.edit_mode); use_effect_with(deps, move |(cfg, _mode)| { @@ -57,7 +58,6 @@ pub fn ApiConfigView() -> Html { } || () }); - } let render_empty = || { diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index 09d7f4bac..f16a59475 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,6 +1,10 @@ use shared::error::TuliproxError; use shared::info_err; -use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto}; +use shared::model::{ + ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfigDto, MainConfigDto, + MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, + SourcesConfigDto, VideoConfigDto, WebUiConfigDto, +}; use std::fmt; use std::str::FromStr; @@ -106,17 +110,20 @@ pub enum ConfigForm { impl ConfigForm { pub(crate) fn is_modified(&self) -> bool { - matches!(self, ConfigForm::Main(true, _) - | ConfigForm::Api(true, _) - | ConfigForm::Log(true, _) - | ConfigForm::Schedules(true, _) - | ConfigForm::Video(true, _) - | ConfigForm::Messaging(true, _) - | ConfigForm::WebUi(true, _) - | ConfigForm::ReverseProxy(true, _) - | ConfigForm::HdHomerun(true, _) - | ConfigForm::Proxy(true, _) - | ConfigForm::IpCheck(true, _) - | ConfigForm::Panel(true, _)) + matches!( + self, + ConfigForm::Main(true, _) + | ConfigForm::Api(true, _) + | ConfigForm::Log(true, _) + | ConfigForm::Schedules(true, _) + | ConfigForm::Video(true, _) + | ConfigForm::Messaging(true, _) + | ConfigForm::WebUi(true, _) + | ConfigForm::ReverseProxy(true, _) + | ConfigForm::HdHomerun(true, _) + | ConfigForm::Proxy(true, _) + | ConfigForm::IpCheck(true, _) + | ConfigForm::Panel(true, _) + ) } } diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 73aff9f90..e57da7a9c 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -1,5 +1,5 @@ -use shared::model::ConfigDto; use crate::app::components::config::config_page::ConfigForm; +use shared::model::ConfigDto; macro_rules! set_config_field { ($main_config:expr, $config:expr, $field:ident) => { @@ -19,19 +19,32 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { ConfigForm::Api(_, api_cfg) => config.api = api_cfg, ConfigForm::Log(_, mut log_cfg) => set_config_field!(config, log_cfg, log), ConfigForm::Schedules(_, schedules_cfg) => { - if schedules_cfg.schedules.is_none() || schedules_cfg.schedules.as_ref().is_some_and(|s| s.is_empty()) { + if schedules_cfg.schedules.is_none() + || schedules_cfg + .schedules + .as_ref() + .is_some_and(|s| s.is_empty()) + { config.schedules = None; } else { config.schedules = schedules_cfg.schedules.clone(); } - }, - ConfigForm::Video(_, mut video_cfg) => set_config_field!(config, video_cfg, video), - ConfigForm::Messaging(_, mut messaging_cfg) => set_config_field!(config, messaging_cfg, messaging), + } + ConfigForm::Video(_, mut video_cfg) => set_config_field!(config, video_cfg, video), + ConfigForm::Messaging(_, mut messaging_cfg) => { + set_config_field!(config, messaging_cfg, messaging) + } ConfigForm::WebUi(_, mut web_ui_cfg) => set_config_field!(config, web_ui_cfg, web_ui), - ConfigForm::ReverseProxy(_, mut reverse_proxy_cfg) => set_config_field!(config, reverse_proxy_cfg, reverse_proxy), - ConfigForm::HdHomerun(_, mut hdhr_cfg) => set_config_field!(config, hdhr_cfg, hdhomerun), + ConfigForm::ReverseProxy(_, mut reverse_proxy_cfg) => { + set_config_field!(config, reverse_proxy_cfg, reverse_proxy) + } + ConfigForm::HdHomerun(_, mut hdhr_cfg) => { + set_config_field!(config, hdhr_cfg, hdhomerun) + } ConfigForm::Proxy(_, mut proxy_cfg) => set_config_field!(config, proxy_cfg, proxy), - ConfigForm::IpCheck(_, mut ipcheck_cfg) => set_config_field!(config, ipcheck_cfg, ipcheck), + ConfigForm::IpCheck(_, mut ipcheck_cfg) => { + set_config_field!(config, ipcheck_cfg, ipcheck) + } ConfigForm::Panel(_, _) => {} } } diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index efd210a34..8ba5c5637 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -1,16 +1,25 @@ -use crate::app::components::config::config_page::{ConfigForm, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_PANEL_CONFIG, LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG}; +use crate::app::components::config::config_page::{ + ConfigForm, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, + LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_PANEL_CONFIG, + LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, + LABEL_WEB_UI_CONFIG, +}; +use crate::app::components::config::config_update::update_config; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::{ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, MessagingConfigView, PanelConfigView, ProxyConfigView, ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView}; +use crate::app::components::config::{ + ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, + MessagingConfigView, PanelConfigView, ProxyConfigView, ReverseProxyConfigView, + SchedulesConfigView, VideoConfigView, WebUiConfigView, +}; use crate::app::components::{Card, TabItem, TabSet, TextButton}; +use crate::app::ConfigContext; +use crate::hooks::use_service_context; use crate::html_if; +use shared::model::{ConfigDto, SourcesConfigDto}; use std::str::FromStr; use yew::platform::spawn_local; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{ConfigDto, SourcesConfigDto}; -use crate::app::components::config::config_update::update_config; -use crate::app::{ConfigContext}; -use crate::hooks::use_service_context; const LABEL_CONFIG: &str = "LABEL.CONFIG"; const LABEL_EDIT: &str = "LABEL.EDIT"; @@ -89,43 +98,131 @@ pub fn ConfigView() -> Html { let translate = translate.clone(); let edit_value = *edit_mode; - use_memo((form_state, edit_value, translate.clone()), move |(forms, editing, translate)| { - let forms: &ConfigFormState = forms; - let modified_pages = collect_modified!(forms, [ - main, api, log, schedules, video, messaging, web_ui, - reverse_proxy, hd_homerun, proxy, ipcheck, panel - ]).iter() + use_memo( + (form_state, edit_value, translate.clone()), + move |(forms, editing, translate)| { + let forms: &ConfigFormState = forms; + let modified_pages = collect_modified!( + forms, + [ + main, + api, + log, + schedules, + video, + messaging, + web_ui, + reverse_proxy, + hd_homerun, + proxy, + ipcheck, + panel + ] + ) + .iter() .map(config_form_to_config_page) .collect::>(); - let tab_configs = vec![ - (ConfigPage::Main, LABEL_MAIN_CONFIG, html! { }, "MainConfig"), - (ConfigPage::Api, LABEL_API_CONFIG, html! { }, "ApiConfig"), - (ConfigPage::Log, LABEL_LOG_CONFIG, html! { }, "Log"), - (ConfigPage::Schedules, LABEL_SCHEDULES_CONFIG, html! { }, "SchedulesConfig"), - (ConfigPage::Messaging, LABEL_MESSAGING_CONFIG, html! { }, "MessagingConfig"), - (ConfigPage::WebUi, LABEL_WEB_UI_CONFIG, html! { }, "WebUiConfig"), - (ConfigPage::ReverseProxy, LABEL_REVERSE_PROXY_CONFIG, html! { }, "ReverseProxyConfig"), - (ConfigPage::HdHomerun, LABEL_HDHOMERUN_CONFIG, html! { }, "HdHomerunConfig"), - (ConfigPage::Proxy, LABEL_PROXY_CONFIG, html! { }, "ProxyConfig"), - (ConfigPage::IpCheck, LABEL_IP_CHECK_CONFIG, html! { }, "IpCheckConfig"), - (ConfigPage::Panel, LABEL_PANEL_CONFIG, html! { }, "Settings"), - (ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { }, "VideoConfig"), - ]; + let tab_configs = vec![ + ( + ConfigPage::Main, + LABEL_MAIN_CONFIG, + html! { }, + "MainConfig", + ), + ( + ConfigPage::Api, + LABEL_API_CONFIG, + html! { }, + "ApiConfig", + ), + ( + ConfigPage::Log, + LABEL_LOG_CONFIG, + html! { }, + "Log", + ), + ( + ConfigPage::Schedules, + LABEL_SCHEDULES_CONFIG, + html! { }, + "SchedulesConfig", + ), + ( + ConfigPage::Messaging, + LABEL_MESSAGING_CONFIG, + html! { }, + "MessagingConfig", + ), + ( + ConfigPage::WebUi, + LABEL_WEB_UI_CONFIG, + html! { }, + "WebUiConfig", + ), + ( + ConfigPage::ReverseProxy, + LABEL_REVERSE_PROXY_CONFIG, + html! { }, + "ReverseProxyConfig", + ), + ( + ConfigPage::HdHomerun, + LABEL_HDHOMERUN_CONFIG, + html! { }, + "HdHomerunConfig", + ), + ( + ConfigPage::Proxy, + LABEL_PROXY_CONFIG, + html! { }, + "ProxyConfig", + ), + ( + ConfigPage::IpCheck, + LABEL_IP_CHECK_CONFIG, + html! { }, + "IpCheckConfig", + ), + ( + ConfigPage::Panel, + LABEL_PANEL_CONFIG, + html! { }, + "Settings", + ), + ( + ConfigPage::Video, + LABEL_VIDEO_CONFIG, + html! { }, + "VideoConfig", + ), + ]; - let editing = *editing; - tab_configs.into_iter().map(|(page, label, children, icon)| { - let is_modified = editing && modified_pages.contains(&page); - TabItem { - id: page.to_string(), - title: translate.t(label), - icon: icon.to_string(), - children, - active_class: if is_modified { Some("tp__tab__modified__active".to_string()) } else { None }, - inactive_class: if is_modified { Some("tp__tab__modified__inactive".to_string()) } else { None }, - } - }).collect::>() - }) + let editing = *editing; + tab_configs + .into_iter() + .map(|(page, label, children, icon)| { + let is_modified = editing && modified_pages.contains(&page); + TabItem { + id: page.to_string(), + title: translate.t(label), + icon: icon.to_string(), + children, + active_class: if is_modified { + Some("tp__tab__modified__active".to_string()) + } else { + None + }, + inactive_class: if is_modified { + Some("tp__tab__modified__inactive".to_string()) + } else { + None + }, + } + }) + .collect::>() + }, + ) }; let handle_config_edit = { @@ -144,10 +241,23 @@ pub fn ConfigView() -> Html { Callback::from(move |_| { let forms = &*get_form_state; - let modified_forms: Vec = collect_modified!(forms, [ - main, api, log, schedules, video, messaging, web_ui, - reverse_proxy, hd_homerun, proxy, ipcheck, panel - ]); + let modified_forms: Vec = collect_modified!( + forms, + [ + main, + api, + log, + schedules, + video, + messaging, + web_ui, + reverse_proxy, + hd_homerun, + proxy, + ipcheck, + panel + ] + ); if modified_forms.is_empty() { set_edit_mode.set(false); @@ -193,11 +303,15 @@ pub fn ConfigView() -> Html { if let Some(config_dto) = modified_main_dto { match services.config.save_config(config_dto).await { Ok(()) => { - services.toastr.success(translate.t("MESSAGES.SAVE.MAIN_CONFIG.SUCCESS")); + services + .toastr + .success(translate.t("MESSAGES.SAVE.MAIN_CONFIG.SUCCESS")); } Err(err) => { ok = false; - services.toastr.error(translate.t("MESSAGES.SAVE.MAIN_CONFIG.FAIL")); + services + .toastr + .error(translate.t("MESSAGES.SAVE.MAIN_CONFIG.FAIL")); services.toastr.error(err.to_string()); } } @@ -206,11 +320,15 @@ pub fn ConfigView() -> Html { if let Some(sources_dto) = modified_sources { match services.config.save_sources(sources_dto).await { Ok(()) => { - services.toastr.success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")); + services + .toastr + .success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")); } Err(err) => { ok = false; - services.toastr.error(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.FAIL")); + services + .toastr + .error(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.FAIL")); services.toastr.error(err.to_string()); } } @@ -247,7 +365,6 @@ pub fn ConfigView() -> Html { }) }; - let handle_update_geoip = { let services = services_ctx.clone(); let translate = translate.clone(); @@ -256,14 +373,17 @@ pub fn ConfigView() -> Html { let translate = translate.clone(); wasm_bindgen_futures::spawn_local(async move { match services.config.update_geoip().await { - Ok(_) => services.toastr.success(translate.t("MESSAGES.DOWNLOAD.GEOIP.SUCCESS")), - Err(_err) => services.toastr.error(translate.t("MESSAGES.DOWNLOAD.GEOIP.FAIL")), + Ok(_) => services + .toastr + .success(translate.t("MESSAGES.DOWNLOAD.GEOIP.SUCCESS")), + Err(_err) => services + .toastr + .error(translate.t("MESSAGES.DOWNLOAD.GEOIP.FAIL")), } }); }) }; - let context = ConfigViewContext { edit_mode: edit_mode.clone(), on_form_change: on_form_change.clone(), diff --git a/frontend/src/app/components/config/config_view_context.rs b/frontend/src/app/components/config/config_view_context.rs index 700b81b73..e23a7c162 100644 --- a/frontend/src/app/components/config/config_view_context.rs +++ b/frontend/src/app/components/config/config_view_context.rs @@ -1,8 +1,8 @@ -use yew::{Callback, UseStateHandle}; use crate::app::components::config::config_page::ConfigForm; +use yew::{Callback, UseStateHandle}; #[derive(Clone, PartialEq)] pub struct ConfigViewContext { pub edit_mode: UseStateHandle, pub on_form_change: Callback, -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/hdhomerun_config_view.rs b/frontend/src/app/components/config/hdhomerun_config_view.rs index 2033a79e7..8a4584b1d 100644 --- a/frontend/src/app/components/config/hdhomerun_config_view.rs +++ b/frontend/src/app/components/config/hdhomerun_config_view.rs @@ -1,20 +1,19 @@ -use log::error; -use yew::prelude::*; -use yew_i18n::use_translation; -use shared::model::{HdHomeRunDeviceConfigDto, HdHomeRunConfigDto}; +use crate::app::components::config::config_page::{ConfigForm, LABEL_HDHOMERUN_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::config::hdhomerun_device_view::HdHomerunDeviceView; use crate::app::components::{Card, NoContent, TextButton}; use crate::app::context::ConfigContext; use crate::{config_field_bool, edit_field_bool, generate_form_reducer, html_if}; -use crate::app::components::config::config_page::{ConfigForm, LABEL_HDHOMERUN_CONFIG}; -use crate::app::components::config::hdhomerun_device_view::{HdHomerunDeviceView}; -use crate::app::components::config::config_view_context::ConfigViewContext; +use log::error; +use shared::model::{HdHomeRunConfigDto, HdHomeRunDeviceConfigDto}; +use yew::prelude::*; +use yew_i18n::use_translation; const LABEL_ENABLED: &str = "LABEL.ENABLED"; const LABEL_DEVICE_AUTH: &str = "LABEL.DEVICE_AUTH"; -const LABEL_DEVICES: &str ="LABEL.DEVICES"; -const LABEL_SSDP_DISCOVERY: &str ="LABEL.SSDP_DISCOVERY"; -const LABEL_PROPRIETARY_DISCOVERY: &str ="LABEL.PROPRIETARY_DISCOVERY"; - +const LABEL_DEVICES: &str = "LABEL.DEVICES"; +const LABEL_SSDP_DISCOVERY: &str = "LABEL.SSDP_DISCOVERY"; +const LABEL_PROPRIETARY_DISCOVERY: &str = "LABEL.PROPRIETARY_DISCOVERY"; generate_form_reducer!( state: HdHomeRunConfigFormState { form: HdHomeRunConfigDto }, @@ -34,9 +33,11 @@ pub fn HdHomerunConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let form_state: UseReducerHandle = use_reducer(|| { - HdHomeRunConfigFormState { form: HdHomeRunConfigDto::default(), modified: false } - }); + let form_state: UseReducerHandle = + use_reducer(|| HdHomeRunConfigFormState { + form: HdHomeRunConfigDto::default(), + modified: false, + }); { let on_form_change = config_view_ctx.on_form_change.clone(); @@ -53,14 +54,19 @@ pub fn HdHomerunConfigView() -> Html { .as_ref() .and_then(|c| c.config.hdhomerun.clone()); - use_effect_with((hdhr_config, config_view_ctx.edit_mode.clone()), move |(hdhr_cfg, _mode)| { - if let Some(hdhr) = hdhr_cfg { - form_state.dispatch(HdHomeRunConfigFormAction::SetAll((*hdhr).clone())); - } else { - form_state.dispatch(HdHomeRunConfigFormAction::SetAll(HdHomeRunConfigDto::default())); - } - || () - }); + use_effect_with( + (hdhr_config, config_view_ctx.edit_mode.clone()), + move |(hdhr_cfg, _mode)| { + if let Some(hdhr) = hdhr_cfg { + form_state.dispatch(HdHomeRunConfigFormAction::SetAll((*hdhr).clone())); + } else { + form_state.dispatch(HdHomeRunConfigFormAction::SetAll( + HdHomeRunConfigDto::default(), + )); + } + || () + }, + ); } let handle_device_change = { @@ -79,10 +85,7 @@ pub fn HdHomerunConfigView() -> Html { Callback::from(move |_| { let mut devices = form_state.form.devices.clone(); let mut new_device = HdHomeRunDeviceConfigDto::default(); - let next_port = devices.iter() - .map(|d| d.port) - .max() - .unwrap_or(8901) + 1; + let next_port = devices.iter().map(|d| d.port).max().unwrap_or(8901) + 1; new_device.port = next_port; new_device.name = format!("hdhr_{next_port}"); if let Err(err) = new_device.prepare(devices.len() as u8, false) { @@ -96,10 +99,10 @@ pub fn HdHomerunConfigView() -> Html { let handle_remove_device = { let form_state = form_state.clone(); Callback::from(move |device_idx| { - let mut devices = form_state.form.devices.clone(); - devices.remove(device_idx); - form_state.dispatch(HdHomeRunConfigFormAction::Devices(devices)); - }) + let mut devices = form_state.form.devices.clone(); + devices.remove(device_idx); + form_state.dispatch(HdHomeRunConfigFormAction::Devices(devices)); + }) }; let render_empty = || { @@ -119,7 +122,7 @@ pub fn HdHomerunConfigView() -> Html { if devices.is_empty() { render_empty() } else { - html!{ for devices.iter().enumerate().map(|(idx, entry)| html! { + html! { for devices.iter().enumerate().map(|(idx, entry)| html! { })} @@ -161,4 +164,4 @@ pub fn HdHomerunConfigView() -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/hdhomerun_device_view.rs b/frontend/src/app/components/config/hdhomerun_device_view.rs index 6fd6945d2..d95db766c 100644 --- a/frontend/src/app/components/config/hdhomerun_device_view.rs +++ b/frontend/src/app/components/config/hdhomerun_device_view.rs @@ -1,8 +1,11 @@ +use crate::app::components::{Card, CollapsePanel, TextButton}; +use crate::{ + config_field, edit_field_number_u16, edit_field_number_u8, edit_field_text, + generate_form_reducer, html_if, +}; +use shared::model::HdHomeRunDeviceConfigDto; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{HdHomeRunDeviceConfigDto}; -use crate::app::components::{Card, CollapsePanel, TextButton}; -use crate::{config_field, edit_field_number_u16, edit_field_number_u8, edit_field_text, generate_form_reducer, html_if}; generate_form_reducer!( state: HdHomeRunDeviceConfigFormState { form: Box }, @@ -36,9 +39,11 @@ pub struct HdHomerunDeviceViewProps { pub fn HdHomerunDeviceView(props: &HdHomerunDeviceViewProps) -> Html { let translate = use_translation(); - let form_state: UseReducerHandle = use_reducer(|| { - HdHomeRunDeviceConfigFormState { form: Box::new(props.device.clone()), modified: false } - }); + let form_state: UseReducerHandle = + use_reducer(|| HdHomeRunDeviceConfigFormState { + form: Box::new(props.device.clone()), + modified: false, + }); { let on_form_change = props.on_form_change.clone(); @@ -106,4 +111,4 @@ pub fn HdHomerunDeviceView(props: &HdHomerunDeviceViewProps) -> Html { }; render_device(&form_state) -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/ipcheck_config_view.rs b/frontend/src/app/components/config/ipcheck_config_view.rs index 74f6635a5..9679f396e 100644 --- a/frontend/src/app/components/config/ipcheck_config_view.rs +++ b/frontend/src/app/components/config/ipcheck_config_view.rs @@ -1,15 +1,17 @@ +use crate::app::components::config::config_page::{ConfigForm, LABEL_IP_CHECK_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::context::ConfigContext; +use crate::{ + config_field_empty, config_field_optional, edit_field_text_option, generate_form_reducer, +}; +use shared::model::IpCheckConfigDto; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{IpCheckConfigDto}; -use crate::app::context::ConfigContext; -use crate::{config_field_empty, config_field_optional, edit_field_text_option, generate_form_reducer}; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_IP_CHECK_CONFIG}; -const LABEL_URL: &str = "LABEL.URL"; -const LABEL_URL_IPV4: &str = "LABEL.URL_IPV4"; -const LABEL_URL_IPV6: &str = "LABEL.URL_IPV6"; -const LABEL_PATTERN_IPV4: &str = "LABEL.PATTERN_IPV4"; -const LABEL_PATTERN_IPV6: &str = "LABEL.PATTERN_IPV6"; +const LABEL_URL: &str = "LABEL.URL"; +const LABEL_URL_IPV4: &str = "LABEL.URL_IPV4"; +const LABEL_URL_IPV6: &str = "LABEL.URL_IPV6"; +const LABEL_PATTERN_IPV4: &str = "LABEL.PATTERN_IPV4"; +const LABEL_PATTERN_IPV6: &str = "LABEL.PATTERN_IPV6"; generate_form_reducer!( state: IpCheckConfigFormState { form: IpCheckConfigDto }, @@ -29,9 +31,11 @@ pub fn IpCheckConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let form_state: UseReducerHandle = use_reducer(|| { - IpCheckConfigFormState { form: IpCheckConfigDto::default(), modified: false } - }); + let form_state: UseReducerHandle = + use_reducer(|| IpCheckConfigFormState { + form: IpCheckConfigDto::default(), + modified: false, + }); { let on_form_change = config_view_ctx.on_form_change.clone(); @@ -48,14 +52,18 @@ pub fn IpCheckConfigView() -> Html { .as_ref() .and_then(|c| c.config.ipcheck.clone()); - use_effect_with((ipcheck_config, config_view_ctx.edit_mode.clone()), move |(ipcheck_cfg, _mode)| { - if let Some(ipcheck) = ipcheck_cfg { - form_state.dispatch(IpCheckConfigFormAction::SetAll((*ipcheck).clone())); - } else { - form_state.dispatch(IpCheckConfigFormAction::SetAll(IpCheckConfigDto::default())); - } - || () - }); + use_effect_with( + (ipcheck_config, config_view_ctx.edit_mode.clone()), + move |(ipcheck_cfg, _mode)| { + if let Some(ipcheck) = ipcheck_cfg { + form_state.dispatch(IpCheckConfigFormAction::SetAll((*ipcheck).clone())); + } else { + form_state + .dispatch(IpCheckConfigFormAction::SetAll(IpCheckConfigDto::default())); + } + || () + }, + ); } let render_empty = || { @@ -116,4 +124,4 @@ pub fn IpCheckConfigView() -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/log_config_view.rs b/frontend/src/app/components/config/log_config_view.rs index fb2e55026..6c73438f7 100644 --- a/frontend/src/app/components/config/log_config_view.rs +++ b/frontend/src/app/components/config/log_config_view.rs @@ -1,16 +1,16 @@ +use crate::app::components::config::config_page::{ConfigForm, LABEL_LOG_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::{Chip, RadioButtonGroup}; +use crate::app::context::ConfigContext; +use crate::{config_field_bool, config_field_child, edit_field_bool, generate_form_reducer}; +use shared::model::LogConfigDto; use std::rc::Rc; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{LogConfigDto}; -use crate::app::context::ConfigContext; -use crate::{config_field_bool, config_field_child, edit_field_bool, generate_form_reducer}; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_LOG_CONFIG}; -use crate::app::components::{Chip, RadioButtonGroup}; -const LABEL_LOG_LEVEL: &str = "LABEL.LOG_LEVEL"; -const LABEL_LOG_ACTIVE_USER: &str = "LABEL.LOG_ACTIVE_USER"; -const LABEL_LOG_SANITIZE_SENSITIVE_INFO: &str = "LABEL.SANITIZE_SENSITIVE_INFO"; +const LABEL_LOG_LEVEL: &str = "LABEL.LOG_LEVEL"; +const LABEL_LOG_ACTIVE_USER: &str = "LABEL.LOG_ACTIVE_USER"; +const LABEL_LOG_SANITIZE_SENSITIVE_INFO: &str = "LABEL.SANITIZE_SENSITIVE_INFO"; const LOG_LEVELS: [&str; 5] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; @@ -31,11 +31,15 @@ pub fn LogConfigView() -> Html { let config_view_ctx = use_context::().expect("ConfigViewContext not found"); let log_level_options = use_memo((), |_| { - LOG_LEVELS.iter().map(ToString::to_string).collect::>() + LOG_LEVELS + .iter() + .map(ToString::to_string) + .collect::>() }); - let form_state: UseReducerHandle = use_reducer(|| { - LogConfigFormState { form: LogConfigDto::default(), modified: false } + let form_state: UseReducerHandle = use_reducer(|| LogConfigFormState { + form: LogConfigDto::default(), + modified: false, }); { @@ -53,14 +57,17 @@ pub fn LogConfigView() -> Html { .as_ref() .and_then(|c| c.config.log.clone()); // clone() Option - use_effect_with((log_config, config_view_ctx.edit_mode.clone()), move |(log_cfg, _mode)| { - if let Some(log) = log_cfg { - form_state.dispatch(LogConfigFormAction::SetAll((*log).clone())); - } else { - form_state.dispatch(LogConfigFormAction::SetAll(LogConfigDto::default())); - } - || () - }); + use_effect_with( + (log_config, config_view_ctx.edit_mode.clone()), + move |(log_cfg, _mode)| { + if let Some(log) = log_cfg { + form_state.dispatch(LogConfigFormAction::SetAll((*log).clone())); + } else { + form_state.dispatch(LogConfigFormAction::SetAll(LogConfigDto::default())); + } + || () + }, + ); } let render_view_mode = || { @@ -83,7 +90,13 @@ pub fn LogConfigView() -> Html { let render_edit_mode = || { let forms = form_state.clone(); - let log_level_selection = Rc::new(forms.form.log_level.as_ref().map_or_else(Vec::new, |l| vec![l.to_uppercase()])); + let log_level_selection = Rc::new( + forms + .form + .log_level + .as_ref() + .map_or_else(Vec::new, |l| vec![l.to_uppercase()]), + ); html! { <> { edit_field_bool!(form_state, translate.t(LABEL_LOG_ACTIVE_USER), log_active_user, LogConfigFormAction::LogActiveUser) } @@ -117,4 +130,4 @@ pub fn LogConfigView() -> Html { } -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index 5134803c5..1fbea9fa1 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -312,7 +312,6 @@ macro_rules! edit_field_number_u16 { }}; } - #[macro_export] macro_rules! edit_field_number_i16 { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ diff --git a/frontend/src/app/components/config/main_config_view.rs b/frontend/src/app/components/config/main_config_view.rs index 6c633cd43..5c2db1e3e 100644 --- a/frontend/src/app/components/config/main_config_view.rs +++ b/frontend/src/app/components/config/main_config_view.rs @@ -1,11 +1,13 @@ +use crate::app::components::config::config_page::{ConfigForm, LABEL_MAIN_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::context::ConfigContext; +use crate::{ + config_field, config_field_bool, config_field_optional, edit_field_bool, edit_field_number, + edit_field_number_option, edit_field_text, edit_field_text_option, generate_form_reducer, +}; +use shared::model::MainConfigDto; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::MainConfigDto; -use crate::app::context::ConfigContext; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_MAIN_CONFIG}; -use crate::{config_field_optional, config_field_bool, config_field, edit_field_text_option, edit_field_bool, - generate_form_reducer, edit_field_number, edit_field_number_option, edit_field_text}; const LABEL_UPDATE_ON_BOOT: &str = "LABEL.UPDATE_ON_BOOT"; const LABEL_CONFIG_HOT_RELOAD: &str = "LABEL.CONFIG_HOT_RELOAD"; @@ -45,8 +47,9 @@ pub fn MainConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let form_state: UseReducerHandle = use_reducer(|| { - MainConfigFormState { form: MainConfigDto::default(), modified: false } + let form_state: UseReducerHandle = use_reducer(|| MainConfigFormState { + form: MainConfigDto::default(), + modified: false, }); { @@ -60,15 +63,18 @@ pub fn MainConfigView() -> Html { { let form_state = form_state.clone(); let config = config_ctx.config.as_ref().map(|c| c.config.clone()); - use_effect_with((config, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { - if let Some(main) = cfg { - let main_config = MainConfigDto::from(main); - form_state.dispatch(MainConfigFormAction::SetAll(main_config.clone())); - } else { - form_state.dispatch(MainConfigFormAction::SetAll(MainConfigDto::default())); - } - || () - }); + use_effect_with( + (config, config_view_ctx.edit_mode.clone()), + move |(cfg, _mode)| { + if let Some(main) = cfg { + let main_config = MainConfigDto::from(main); + form_state.dispatch(MainConfigFormAction::SetAll(main_config.clone())); + } else { + form_state.dispatch(MainConfigFormAction::SetAll(MainConfigDto::default())); + } + || () + }, + ); } let render_view_mode = || { @@ -90,21 +96,23 @@ pub fn MainConfigView() -> Html { } }; - let render_edit_mode = || html! { - <> - { edit_field_bool!(form_state, translate.t(LABEL_UPDATE_ON_BOOT), update_on_boot, MainConfigFormAction::UpdateOnBoot) } - { edit_field_bool!(form_state, translate.t(LABEL_CONFIG_HOT_RELOAD), config_hot_reload, MainConfigFormAction::ConfigHotReload) } - { edit_field_bool!(form_state, translate.t(LABEL_USER_ACCESS_CONTROL), user_access_control, MainConfigFormAction::UserAccessControl) } - { edit_field_bool!(form_state, translate.t(LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES), accept_insecure_ssl_certificates, MainConfigFormAction::AcceptInsecureSslCertificates) } - { edit_field_bool!(form_state, translate.t(LABEL_PROCESS_PARALLEL), process_parallel, MainConfigFormAction::ProcessParallel) } - { edit_field_text!(form_state, translate.t(LABEL_WORKING_DIR), working_dir, MainConfigFormAction::WorkingDir) } - { edit_field_text_option!(form_state, translate.t(LABEL_MAPPING_PATH), mapping_path, MainConfigFormAction::MappingPath) } - { edit_field_text_option!(form_state, translate.t(LABEL_BACKUP_DIR), backup_dir, MainConfigFormAction::BackupDir) } - { edit_field_text_option!(form_state, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir, MainConfigFormAction::UserConfigDir) } - { edit_field_number_option!(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_text_option!(form_state, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_PATH), custom_stream_response_path, MainConfigFormAction::CustomStreamResponsePath) } - + let render_edit_mode = || { + html! { + <> + { edit_field_bool!(form_state, translate.t(LABEL_UPDATE_ON_BOOT), update_on_boot, MainConfigFormAction::UpdateOnBoot) } + { edit_field_bool!(form_state, translate.t(LABEL_CONFIG_HOT_RELOAD), config_hot_reload, MainConfigFormAction::ConfigHotReload) } + { edit_field_bool!(form_state, translate.t(LABEL_USER_ACCESS_CONTROL), user_access_control, MainConfigFormAction::UserAccessControl) } + { edit_field_bool!(form_state, translate.t(LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES), accept_insecure_ssl_certificates, MainConfigFormAction::AcceptInsecureSslCertificates) } + { edit_field_bool!(form_state, translate.t(LABEL_PROCESS_PARALLEL), process_parallel, MainConfigFormAction::ProcessParallel) } + { edit_field_text!(form_state, translate.t(LABEL_WORKING_DIR), working_dir, MainConfigFormAction::WorkingDir) } + { edit_field_text_option!(form_state, translate.t(LABEL_MAPPING_PATH), mapping_path, MainConfigFormAction::MappingPath) } + { edit_field_text_option!(form_state, translate.t(LABEL_BACKUP_DIR), backup_dir, MainConfigFormAction::BackupDir) } + { edit_field_text_option!(form_state, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir, MainConfigFormAction::UserConfigDir) } + { edit_field_number_option!(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_text_option!(form_state, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_PATH), custom_stream_response_path, MainConfigFormAction::CustomStreamResponsePath) } + + } }; html! { diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index 6216cbb92..4f45758f3 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -1,13 +1,20 @@ use crate::app::components::config::config_page::{ConfigForm, LABEL_MESSAGING_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::{Card, Chip, RadioButtonGroup}; -use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; -use shared::model::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; +use crate::app::ConfigContext; +use crate::{ + config_field, config_field_bool, config_field_bool_empty, config_field_child, + config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, + edit_field_text, edit_field_text_option, generate_form_reducer, +}; +use shared::model::{ + MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, + TelegramMessagingConfigDto, +}; use std::rc::Rc; use std::str::FromStr; use yew::prelude::*; use yew_i18n::use_translation; -use crate::app::ConfigContext; const LABEL_NOTIFY_ON: &str = "LABEL.NOTIFY_ON"; const LABEL_TELEGRAM: &str = "LABEL.TELEGRAM"; @@ -94,9 +101,13 @@ pub fn MessagingConfigView() -> Html { ] }); - let notify_on_options_text = use_memo((*notify_on_options).clone(), |options: &Vec| { - options.iter().map(ToString::to_string).collect::>() - }); + let notify_on_options_text = + use_memo((*notify_on_options).clone(), |options: &Vec| { + options + .iter() + .map(ToString::to_string) + .collect::>() + }); { let on_form_change = config_view_ctx.on_form_change.clone(); @@ -126,41 +137,65 @@ pub fn MessagingConfigView() -> Html { }); } - { let msg_state = messaging_state.clone(); let t_state = telegram_state.clone(); let p_state = pushover_state.clone(); let r_state = rest_state.clone(); - let msg_config : MessagingConfigDto = config_ctx + let msg_config: MessagingConfigDto = config_ctx .config .as_ref() .and_then(|c| c.config.messaging.as_ref()) .map_or_else(MessagingConfigDto::default, |m| m.clone()); - let telegram_cfg = msg_config.telegram.as_ref().map_or_else(TelegramMessagingConfigDto::default, |t| t.clone()); - use_effect_with((telegram_cfg, config_view_ctx.edit_mode.clone()), move |(telegram_cfg, _mode)| { - t_state.dispatch(TelegramMessagingConfigFormAction::SetAll(telegram_cfg.clone())); - || () - }); + let telegram_cfg = msg_config + .telegram + .as_ref() + .map_or_else(TelegramMessagingConfigDto::default, |t| t.clone()); + use_effect_with( + (telegram_cfg, config_view_ctx.edit_mode.clone()), + move |(telegram_cfg, _mode)| { + t_state.dispatch(TelegramMessagingConfigFormAction::SetAll( + telegram_cfg.clone(), + )); + || () + }, + ); - let rest_cfg = msg_config.rest.as_ref().map_or_else(RestMessagingConfigDto::default, |t| t.clone()); - use_effect_with((rest_cfg, config_view_ctx.edit_mode.clone()), move |(rest_cfg, _mode)| { - r_state.dispatch(RestMessagingConfigFormAction::SetAll(rest_cfg.clone())); - || () - }); + let rest_cfg = msg_config + .rest + .as_ref() + .map_or_else(RestMessagingConfigDto::default, |t| t.clone()); + use_effect_with( + (rest_cfg, config_view_ctx.edit_mode.clone()), + move |(rest_cfg, _mode)| { + r_state.dispatch(RestMessagingConfigFormAction::SetAll(rest_cfg.clone())); + || () + }, + ); - let pushover_cfg = msg_config.pushover.as_ref().map_or_else(PushoverMessagingConfigDto::default, |t| t.clone()); - use_effect_with((pushover_cfg, config_view_ctx.edit_mode.clone()), move |(pushover_cfg, _mode)| { - p_state.dispatch(PushoverMessagingConfigFormAction::SetAll(pushover_cfg.clone())); - || () - }); + let pushover_cfg = msg_config + .pushover + .as_ref() + .map_or_else(PushoverMessagingConfigDto::default, |t| t.clone()); + use_effect_with( + (pushover_cfg, config_view_ctx.edit_mode.clone()), + move |(pushover_cfg, _mode)| { + p_state.dispatch(PushoverMessagingConfigFormAction::SetAll( + pushover_cfg.clone(), + )); + || () + }, + ); - use_effect_with((msg_config, config_view_ctx.edit_mode.clone()), move |(msg_config, _mode)| { - msg_state.dispatch(MessagingConfigFormAction::SetAll(msg_config.clone())); - || () - }); + use_effect_with( + (msg_config, config_view_ctx.edit_mode.clone()), + move |(msg_config, _mode)| { + msg_state.dispatch(MessagingConfigFormAction::SetAll(msg_config.clone())); + || () + }, + ); } let render_telegram = |telegram: Option<&TelegramMessagingConfigDto>| match telegram { diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 2a3cf3791..d072d5bd5 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -1,34 +1,34 @@ mod macros; -mod config_view; -mod main_config_view; mod api_config_view; -mod schedules_config_view; -mod messaging_config_view; -mod webui_config_view; -mod reverse_proxy_config_view; -mod hdhomerun_config_view; -mod proxy_config_view; -mod ipcheck_config_view; -mod video_config_view; -mod config_view_context; mod config_page; -mod hdhomerun_device_view; mod config_update; +mod config_view; +mod config_view_context; +mod hdhomerun_config_view; +mod hdhomerun_device_view; +mod ipcheck_config_view; mod log_config_view; +mod main_config_view; +mod messaging_config_view; mod panel_config_view; +mod proxy_config_view; +mod reverse_proxy_config_view; +mod schedules_config_view; +mod video_config_view; +mod webui_config_view; -pub use config_view::*; -pub use main_config_view::*; pub use api_config_view::*; -pub use schedules_config_view::*; -pub use messaging_config_view::*; -pub use webui_config_view::*; -pub use reverse_proxy_config_view::*; +pub use config_view::*; pub use hdhomerun_config_view::*; -pub use proxy_config_view::*; pub use ipcheck_config_view::*; -pub use video_config_view::*; pub use log_config_view::*; -pub use panel_config_view::*; pub use macros::*; +pub use main_config_view::*; +pub use messaging_config_view::*; +pub use panel_config_view::*; +pub use proxy_config_view::*; +pub use reverse_proxy_config_view::*; +pub use schedules_config_view::*; +pub use video_config_view::*; +pub use webui_config_view::*; diff --git a/frontend/src/app/components/config/panel_config_view.rs b/frontend/src/app/components/config/panel_config_view.rs index 9af75efd7..085f844cd 100644 --- a/frontend/src/app/components/config/panel_config_view.rs +++ b/frontend/src/app/components/config/panel_config_view.rs @@ -1,7 +1,7 @@ -use crate::app::components::input::Input; -use crate::app::components::{Card, Chip, IconButton, ToggleSwitch}; use crate::app::components::config::config_page::{ConfigForm, LABEL_PANEL_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::input::Input; +use crate::app::components::{Card, Chip, IconButton, ToggleSwitch}; use crate::app::context::ConfigContext; use crate::html_if; use shared::model::{ConfigInputDto, PanelApiConfigDto, PanelApiQueryParamDto, SourcesConfigDto}; @@ -46,17 +46,57 @@ struct PanelConfigFormState { #[derive(Clone)] enum PanelConfigFormAction { SetAll(SourcesConfigDto), - SetEnabled { source_idx: usize, input_idx: usize, enabled: bool }, - SetPanelUrl { source_idx: usize, input_idx: usize, url: String }, - SetApiKey { source_idx: usize, input_idx: usize, api_key: String }, - AddParam { source_idx: usize, input_idx: usize, section: PanelSection }, - RemoveParam { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize }, - SetParamKey { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize, key: String }, - SetParamValue { source_idx: usize, input_idx: usize, section: PanelSection, param_idx: usize, value: String }, - EnsureRequired { source_idx: usize, input_idx: usize, section: PanelSection }, + SetEnabled { + source_idx: usize, + input_idx: usize, + enabled: bool, + }, + SetPanelUrl { + source_idx: usize, + input_idx: usize, + url: String, + }, + SetApiKey { + source_idx: usize, + input_idx: usize, + api_key: String, + }, + AddParam { + source_idx: usize, + input_idx: usize, + section: PanelSection, + }, + RemoveParam { + source_idx: usize, + input_idx: usize, + section: PanelSection, + param_idx: usize, + }, + SetParamKey { + source_idx: usize, + input_idx: usize, + section: PanelSection, + param_idx: usize, + key: String, + }, + SetParamValue { + source_idx: usize, + input_idx: usize, + section: PanelSection, + param_idx: usize, + value: String, + }, + EnsureRequired { + source_idx: usize, + input_idx: usize, + section: PanelSection, + }, } -fn params_mut(panel: &mut PanelApiConfigDto, section: PanelSection) -> &mut Vec { +fn params_mut( + panel: &mut PanelApiConfigDto, + section: PanelSection, +) -> &mut Vec { match section { PanelSection::Info => &mut panel.query_parameter.client_info, PanelSection::New => &mut panel.query_parameter.client_new, @@ -65,7 +105,9 @@ fn params_mut(panel: &mut PanelApiConfigDto, section: PanelSection) -> &mut Vec< } fn has_param(params: &[PanelApiQueryParamDto], key: &str) -> bool { - params.iter().any(|p| p.key.trim().eq_ignore_ascii_case(key)) + params + .iter() + .any(|p| p.key.trim().eq_ignore_ascii_case(key)) } fn get_param_value(params: &[PanelApiQueryParamDto], key: &str) -> Option { @@ -169,7 +211,12 @@ fn ensure_required_params(params: &mut Vec, section: Pane } } -fn with_input_mut(form: &mut SourcesConfigDto, source_idx: usize, input_idx: usize, f: impl FnOnce(&mut ConfigInputDto)) { +fn with_input_mut( + form: &mut SourcesConfigDto, + source_idx: usize, + input_idx: usize, + f: impl FnOnce(&mut ConfigInputDto), +) { if let Some(source) = form.sources.get_mut(source_idx) { if let Some(input) = source.inputs.get_mut(input_idx) { f(input); @@ -182,8 +229,16 @@ impl Reducible for PanelConfigFormState { fn reduce(self: Rc, action: Self::Action) -> Rc { match action { - PanelConfigFormAction::SetAll(form) => Self { form, modified: false }.into(), - PanelConfigFormAction::SetEnabled { source_idx, input_idx, enabled } => { + PanelConfigFormAction::SetAll(form) => Self { + form, + modified: false, + } + .into(), + PanelConfigFormAction::SetEnabled { + source_idx, + input_idx, + enabled, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { if enabled { @@ -194,33 +249,79 @@ impl Reducible for PanelConfigFormState { input.panel_api = None; } }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::SetPanelUrl { source_idx, input_idx, url } => { + PanelConfigFormAction::SetPanelUrl { + source_idx, + input_idx, + url, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { - let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + let panel = input + .panel_api + .get_or_insert_with(PanelApiConfigDto::default); panel.url = url; }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::SetApiKey { source_idx, input_idx, api_key } => { + PanelConfigFormAction::SetApiKey { + source_idx, + input_idx, + api_key, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { - let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); - panel.api_key = if api_key.trim().is_empty() { None } else { Some(api_key) }; + let panel = input + .panel_api + .get_or_insert_with(PanelApiConfigDto::default); + panel.api_key = if api_key.trim().is_empty() { + None + } else { + Some(api_key) + }; }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::AddParam { source_idx, input_idx, section } => { + PanelConfigFormAction::AddParam { + source_idx, + input_idx, + section, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { - let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); - params_mut(panel, section).push(PanelApiQueryParamDto { key: String::new(), value: String::new() }); + let panel = input + .panel_api + .get_or_insert_with(PanelApiConfigDto::default); + params_mut(panel, section).push(PanelApiQueryParamDto { + key: String::new(), + value: String::new(), + }); }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::RemoveParam { source_idx, input_idx, section, param_idx } => { + PanelConfigFormAction::RemoveParam { + source_idx, + input_idx, + section, + param_idx, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { if let Some(panel) = input.panel_api.as_mut() { @@ -230,9 +331,19 @@ impl Reducible for PanelConfigFormState { } } }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::SetParamKey { source_idx, input_idx, section, param_idx, key } => { + PanelConfigFormAction::SetParamKey { + source_idx, + input_idx, + section, + param_idx, + key, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { if let Some(panel) = input.panel_api.as_mut() { @@ -242,9 +353,19 @@ impl Reducible for PanelConfigFormState { } } }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::SetParamValue { source_idx, input_idx, section, param_idx, value } => { + PanelConfigFormAction::SetParamValue { + source_idx, + input_idx, + section, + param_idx, + value, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { if let Some(panel) = input.panel_api.as_mut() { @@ -254,15 +375,29 @@ impl Reducible for PanelConfigFormState { } } }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } - PanelConfigFormAction::EnsureRequired { source_idx, input_idx, section } => { + PanelConfigFormAction::EnsureRequired { + source_idx, + input_idx, + section, + } => { let mut form = self.form.clone(); with_input_mut(&mut form, source_idx, input_idx, |input| { - let panel = input.panel_api.get_or_insert_with(PanelApiConfigDto::default); + let panel = input + .panel_api + .get_or_insert_with(PanelApiConfigDto::default); ensure_required_params(params_mut(panel, section), section); }); - Self { form, modified: true }.into() + Self { + form, + modified: true, + } + .into() } } } @@ -281,7 +416,11 @@ fn render_param_editor( let form_state = form_state.clone(); Callback::from(move |(name, _): (String, web_sys::MouseEvent)| { if name == "required" { - form_state.dispatch(PanelConfigFormAction::EnsureRequired { source_idx, input_idx, section }); + form_state.dispatch(PanelConfigFormAction::EnsureRequired { + source_idx, + input_idx, + section, + }); } }) }; @@ -290,7 +429,11 @@ fn render_param_editor( let form_state = form_state.clone(); Callback::from(move |(name, _): (String, web_sys::MouseEvent)| { if name == "add" { - form_state.dispatch(PanelConfigFormAction::AddParam { source_idx, input_idx, section }); + form_state.dispatch(PanelConfigFormAction::AddParam { + source_idx, + input_idx, + section, + }); } }) }; @@ -387,14 +530,17 @@ pub fn PanelConfigView() -> Html { { let form_state = form_state.clone(); let sources_cfg = config_ctx.config.as_ref().map(|c| c.sources.clone()); - use_effect_with((sources_cfg, config_view_ctx.edit_mode.clone()), move |(sources_cfg, _mode)| { - if let Some(src) = sources_cfg { - form_state.dispatch(PanelConfigFormAction::SetAll((*src).clone())); - } else { - form_state.dispatch(PanelConfigFormAction::SetAll(SourcesConfigDto::default())); - } - || () - }); + use_effect_with( + (sources_cfg, config_view_ctx.edit_mode.clone()), + move |(sources_cfg, _mode)| { + if let Some(src) = sources_cfg { + form_state.dispatch(PanelConfigFormAction::SetAll((*src).clone())); + } else { + form_state.dispatch(PanelConfigFormAction::SetAll(SourcesConfigDto::default())); + } + || () + }, + ); } let render_input_card = |source_idx: usize, input_idx: usize, input: &ConfigInputDto| -> Html { @@ -414,30 +560,48 @@ pub fn PanelConfigView() -> Html { let on_toggle = { let form_state = form_state.clone(); Callback::from(move |enabled: bool| { - form_state.dispatch(PanelConfigFormAction::SetEnabled { source_idx, input_idx, enabled }); + form_state.dispatch(PanelConfigFormAction::SetEnabled { + source_idx, + input_idx, + enabled, + }); }) }; let on_url = { let form_state = form_state.clone(); Callback::from(move |value: String| { - form_state.dispatch(PanelConfigFormAction::SetPanelUrl { source_idx, input_idx, url: value }); + form_state.dispatch(PanelConfigFormAction::SetPanelUrl { + source_idx, + input_idx, + url: value, + }); }) }; let on_api_key = { let form_state = form_state.clone(); Callback::from(move |value: String| { - form_state.dispatch(PanelConfigFormAction::SetApiKey { source_idx, input_idx, api_key: value }); + form_state.dispatch(PanelConfigFormAction::SetApiKey { + source_idx, + input_idx, + api_key: value, + }); }) }; let panel = input.panel_api.as_ref(); let url_val = panel.map(|p| p.url.clone()).unwrap_or_default(); let api_key_val = panel.and_then(|p| p.api_key.clone()).unwrap_or_default(); - let client_info = panel.map(|p| p.query_parameter.client_info.as_slice()).unwrap_or(&[]); - let client_new = panel.map(|p| p.query_parameter.client_new.as_slice()).unwrap_or(&[]); - let client_renew = panel.map(|p| p.query_parameter.client_renew.as_slice()).unwrap_or(&[]); + let client_info = panel + .map(|p| p.query_parameter.client_info.as_slice()) + .unwrap_or(&[]); + let client_new = panel + .map(|p| p.query_parameter.client_new.as_slice()) + .unwrap_or(&[]); + let client_renew = panel + .map(|p| p.query_parameter.client_renew.as_slice()) + .unwrap_or(&[]); html! { @@ -524,7 +688,12 @@ pub fn PanelConfigView() -> Html { .sources .iter() .enumerate() - .flat_map(|(sidx, s)| s.inputs.iter().enumerate().map(move |(iidx, inp)| (sidx, iidx, inp))) + .flat_map(|(sidx, s)| { + s.inputs + .iter() + .enumerate() + .map(move |(iidx, inp)| (sidx, iidx, inp)) + }) .collect::>(); html! { diff --git a/frontend/src/app/components/config/proxy_config_view.rs b/frontend/src/app/components/config/proxy_config_view.rs index 8396e9f76..7824bdb6f 100644 --- a/frontend/src/app/components/config/proxy_config_view.rs +++ b/frontend/src/app/components/config/proxy_config_view.rs @@ -1,13 +1,13 @@ +use crate::app::components::config::config_page::{ConfigForm, LABEL_PROXY_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::context::ConfigContext; +use crate::{ + config_field, config_field_optional, config_field_optional_hide, edit_field_text, + edit_field_text_option, generate_form_reducer, +}; +use shared::model::ProxyConfigDto; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::ProxyConfigDto; -use crate::app::context::ConfigContext; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_PROXY_CONFIG}; -use crate::{ - config_field, config_field_optional, config_field_optional_hide, - edit_field_text, edit_field_text_option, generate_form_reducer, -}; const LABEL_URL: &str = "LABEL.URL"; const LABEL_USERNAME: &str = "LABEL.USERNAME"; @@ -29,8 +29,9 @@ pub fn ProxyConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let form_state: UseReducerHandle = use_reducer(|| { - ProxyConfigFormState { form: ProxyConfigDto::default(), modified: false } + let form_state: UseReducerHandle = use_reducer(|| ProxyConfigFormState { + form: ProxyConfigDto::default(), + modified: false, }); { @@ -43,15 +44,21 @@ pub fn ProxyConfigView() -> Html { { let form_state = form_state.clone(); - let proxy_config = config_ctx.config.as_ref().and_then(|c| c.config.proxy.clone()); - use_effect_with((proxy_config, config_view_ctx.edit_mode.clone()), move |(proxy_cfg, _mode)| { - if let Some(proxy) = proxy_cfg { - form_state.dispatch(ProxyConfigFormAction::SetAll((*proxy).clone())); - } else { - form_state.dispatch(ProxyConfigFormAction::SetAll(ProxyConfigDto::default())); - } - || () - }); + let proxy_config = config_ctx + .config + .as_ref() + .and_then(|c| c.config.proxy.clone()); + use_effect_with( + (proxy_config, config_view_ctx.edit_mode.clone()), + move |(proxy_cfg, _mode)| { + if let Some(proxy) = proxy_cfg { + form_state.dispatch(ProxyConfigFormAction::SetAll((*proxy).clone())); + } else { + form_state.dispatch(ProxyConfigFormAction::SetAll(ProxyConfigDto::default())); + } + || () + }, + ); } let render_view_mode = || { @@ -64,12 +71,14 @@ pub fn ProxyConfigView() -> Html { } }; - let render_edit_mode = || html! { -
- { edit_field_text!(form_state, translate.t(LABEL_URL), url, ProxyConfigFormAction::Url) } - { edit_field_text_option!(form_state, translate.t(LABEL_USERNAME), username, ProxyConfigFormAction::Username) } - { edit_field_text_option!(form_state, translate.t(LABEL_PASSWORD), password, ProxyConfigFormAction::Password) } -
+ let render_edit_mode = || { + html! { +
+ { edit_field_text!(form_state, translate.t(LABEL_URL), url, ProxyConfigFormAction::Url) } + { edit_field_text_option!(form_state, translate.t(LABEL_USERNAME), username, ProxyConfigFormAction::Username) } + { edit_field_text_option!(form_state, translate.t(LABEL_PASSWORD), password, ProxyConfigFormAction::Password) } +
+ } }; html! { diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 62b51a426..d2af21d7d 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -1,16 +1,21 @@ #![allow(clippy::large_enum_variant)] +use crate::app::components::config::config_page::{ConfigForm, LABEL_REVERSE_PROXY_CONFIG}; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::Card; +use crate::app::context::ConfigContext; +use crate::{ + config_field, config_field_bool, config_field_custom, config_field_hide, config_field_optional, + edit_field_bool, edit_field_list, edit_field_number, edit_field_number_f64, + edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer, +}; +use shared::model::{ + CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, + ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto, +}; +use shared::utils::{default_secret, format_float_localized}; use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto}; -use shared::utils::{default_secret, format_float_localized}; -use crate::app::context::ConfigContext; -use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm, LABEL_REVERSE_PROXY_CONFIG}; -use crate::app::components::{Card}; -use crate::{config_field, config_field_bool, config_field_custom, config_field_hide, config_field_optional, - edit_field_bool, edit_field_list, edit_field_number, edit_field_number_f64, - edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; const LABEL_CACHE: &str = "LABEL.CACHE"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; @@ -123,28 +128,45 @@ pub fn ReverseProxyConfigView() -> Html { let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); - let reverse_proxy_state: UseReducerHandle = use_reducer(|| { - ReverseProxyConfigFormState { form: ReverseProxyConfigDto { rewrite_secret: default_secret(), ..Default::default() }, modified: false } - }); - let disabled_header_state: UseReducerHandle = use_reducer(|| { - ReverseProxyDisabledHeaderConfigFormState { form: ReverseProxyDisabledHeaderConfigDto::default(), modified: false } - }); - let cache_state: UseReducerHandle = use_reducer(|| { - CacheConfigFormState { form: CacheConfigDto::default(), modified: false } - }); - let rate_limit_state: UseReducerHandle = use_reducer(|| { - RateLimitConfigFormState { form: RateLimitConfigDto::default(), modified: false } - }); - let resource_retry_state: UseReducerHandle = use_reducer(|| { - ResourceRetryConfigFormState { form: ResourceRetryConfigDto::default(), modified: false } - }); - let stream_state: UseReducerHandle = use_reducer(|| { - StreamConfigFormState { form: StreamConfigDto::default(), modified: false } - }); + let reverse_proxy_state: UseReducerHandle = + use_reducer(|| ReverseProxyConfigFormState { + form: ReverseProxyConfigDto { + rewrite_secret: default_secret(), + ..Default::default() + }, + modified: false, + }); + let disabled_header_state: UseReducerHandle = + use_reducer(|| ReverseProxyDisabledHeaderConfigFormState { + form: ReverseProxyDisabledHeaderConfigDto::default(), + modified: false, + }); + let cache_state: UseReducerHandle = + use_reducer(|| CacheConfigFormState { + form: CacheConfigDto::default(), + modified: false, + }); + let rate_limit_state: UseReducerHandle = + use_reducer(|| RateLimitConfigFormState { + form: RateLimitConfigDto::default(), + modified: false, + }); + let resource_retry_state: UseReducerHandle = + use_reducer(|| ResourceRetryConfigFormState { + form: ResourceRetryConfigDto::default(), + modified: false, + }); + let stream_state: UseReducerHandle = + use_reducer(|| StreamConfigFormState { + form: StreamConfigDto::default(), + modified: false, + }); - let geoip_state: UseReducerHandle = use_reducer(|| { - GeoIpConfigFormState { form: GeoIpConfigDto::default(), modified: false } - }); + let geoip_state: UseReducerHandle = + use_reducer(|| GeoIpConfigFormState { + form: GeoIpConfigDto::default(), + modified: false, + }); { let on_form_change = config_view_ctx.on_form_change.clone(); @@ -200,27 +222,73 @@ pub fn ReverseProxyConfigView() -> Html { let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); - let reverse_proxy_cfg = config_ctx.config.as_ref().and_then(|c| c.config.reverse_proxy.clone()); - use_effect_with((reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { - if let Some(rp) = cfg { - reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll((*rp).clone())); - disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(rp.disabled_header.as_ref().map_or_else(ReverseProxyDisabledHeaderConfigDto::default, |d| d.clone()))); - cache_state.dispatch(CacheConfigFormAction::SetAll(rp.cache.as_ref().map_or_else(CacheConfigDto::default, |c| c.clone()))); - rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rp.rate_limit.as_ref().map_or_else(RateLimitConfigDto::default, |rl| rl.clone()))); - resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(rp.resource_retry.as_ref().map_or_else(ResourceRetryConfigDto::default, |rr| rr.clone()))); - stream_state.dispatch(StreamConfigFormAction::SetAll(rp.stream.as_ref().map_or_else(StreamConfigDto::default, |s| s.clone()))); - geoip_state.dispatch(GeoIpConfigFormAction::SetAll(rp.geoip.as_ref().map_or_else(GeoIpConfigDto::default, |s| s.clone()))); - } else { - reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll(ReverseProxyConfigDto::default())); - disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(ReverseProxyDisabledHeaderConfigDto::default())); - cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); - rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(RateLimitConfigDto::default())); - resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(ResourceRetryConfigDto::default())); - stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); - geoip_state.dispatch(GeoIpConfigFormAction::SetAll(GeoIpConfigDto::default())); - } - || () - }); + let reverse_proxy_cfg = config_ctx + .config + .as_ref() + .and_then(|c| c.config.reverse_proxy.clone()); + use_effect_with( + (reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), + move |(cfg, _mode)| { + if let Some(rp) = cfg { + reverse_proxy_state + .dispatch(ReverseProxyConfigFormAction::SetAll((*rp).clone())); + disabled_header_state.dispatch( + ReverseProxyDisabledHeaderConfigFormAction::SetAll( + rp.disabled_header + .as_ref() + .map_or_else(ReverseProxyDisabledHeaderConfigDto::default, |d| { + d.clone() + }), + ), + ); + cache_state.dispatch(CacheConfigFormAction::SetAll( + rp.cache + .as_ref() + .map_or_else(CacheConfigDto::default, |c| c.clone()), + )); + rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll( + rp.rate_limit + .as_ref() + .map_or_else(RateLimitConfigDto::default, |rl| rl.clone()), + )); + resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll( + rp.resource_retry + .as_ref() + .map_or_else(ResourceRetryConfigDto::default, |rr| rr.clone()), + )); + stream_state.dispatch(StreamConfigFormAction::SetAll( + rp.stream + .as_ref() + .map_or_else(StreamConfigDto::default, |s| s.clone()), + )); + geoip_state.dispatch(GeoIpConfigFormAction::SetAll( + rp.geoip + .as_ref() + .map_or_else(GeoIpConfigDto::default, |s| s.clone()), + )); + } else { + reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll( + ReverseProxyConfigDto::default(), + )); + disabled_header_state.dispatch( + ReverseProxyDisabledHeaderConfigFormAction::SetAll( + ReverseProxyDisabledHeaderConfigDto::default(), + ), + ); + cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); + rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll( + RateLimitConfigDto::default(), + )); + resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll( + ResourceRetryConfigDto::default(), + )); + stream_state + .dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); + geoip_state.dispatch(GeoIpConfigFormAction::SetAll(GeoIpConfigDto::default())); + } + || () + }, + ); } let render_cache = || { @@ -344,45 +412,52 @@ pub fn ReverseProxyConfigView() -> Html { } }; - let render_geoip_edit = || html ! { - -

{translate.t(LABEL_GEOIP)}

- { edit_field_bool!(geoip_state, translate.t(LABEL_ENABLED), enabled, GeoIpConfigFormAction::Enabled) } - { edit_field_text!(geoip_state, translate.t(LABEL_URL), url, GeoIpConfigFormAction::Url) } -
+ let render_geoip_edit = || { + html! { + +

{translate.t(LABEL_GEOIP)}

+ { edit_field_bool!(geoip_state, translate.t(LABEL_ENABLED), enabled, GeoIpConfigFormAction::Enabled) } + { edit_field_text!(geoip_state, translate.t(LABEL_URL), url, GeoIpConfigFormAction::Url) } +
+ } }; - let render_cache_edit = || html! { - -

{translate.t(LABEL_CACHE)}

- { edit_field_bool!(cache_state, translate.t(LABEL_ENABLED), enabled, CacheConfigFormAction::Enabled) } - { edit_field_text_option!(cache_state, translate.t(LABEL_SIZE), size, CacheConfigFormAction::Size) } - { edit_field_text_option!(cache_state, translate.t(LABEL_DIRECTORY), dir, CacheConfigFormAction::Dir) } -
+ let render_cache_edit = || { + html! { + +

{translate.t(LABEL_CACHE)}

+ { edit_field_bool!(cache_state, translate.t(LABEL_ENABLED), enabled, CacheConfigFormAction::Enabled) } + { edit_field_text_option!(cache_state, translate.t(LABEL_SIZE), size, CacheConfigFormAction::Size) } + { edit_field_text_option!(cache_state, translate.t(LABEL_DIRECTORY), dir, CacheConfigFormAction::Dir) } +
+ } }; - let render_rate_limit_edit = || html! { - -

{translate.t(LABEL_RATE_LIMIT)}

- { edit_field_bool!(rate_limit_state, translate.t(LABEL_ENABLED), enabled, RateLimitConfigFormAction::Enabled) } - { edit_field_number_u64!(rate_limit_state, translate.t(LABEL_PERIOD_MILLIS), period_millis, RateLimitConfigFormAction::PeriodMillis) } - { edit_field_number!(rate_limit_state, translate.t(LABEL_BURST_SIZE), burst_size, RateLimitConfigFormAction::BurstSize) } -
+ let render_rate_limit_edit = || { + html! { + +

{translate.t(LABEL_RATE_LIMIT)}

+ { edit_field_bool!(rate_limit_state, translate.t(LABEL_ENABLED), enabled, RateLimitConfigFormAction::Enabled) } + { edit_field_number_u64!(rate_limit_state, translate.t(LABEL_PERIOD_MILLIS), period_millis, RateLimitConfigFormAction::PeriodMillis) } + { edit_field_number!(rate_limit_state, translate.t(LABEL_BURST_SIZE), burst_size, RateLimitConfigFormAction::BurstSize) } +
+ } }; - let render_stream_edit = || html! { - -

{translate.t(LABEL_STREAM)}

- { edit_field_bool!(stream_state, translate.t(LABEL_RETRY), retry, StreamConfigFormAction::Retry) } - { edit_field_text_option!(stream_state, translate.t(LABEL_THROTTLE), throttle, StreamConfigFormAction::Throttle) } - { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_MILLIS), grace_period_millis, StreamConfigFormAction::GracePeriodMillis) } - { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_TIMEOUT_SECS), grace_period_timeout_secs, StreamConfigFormAction::GracePeriodTimeoutSecs) } - { edit_field_number_u64!(stream_state, translate.t(LABEL_THROTTLE_KBPS), throttle_kbps, StreamConfigFormAction::ThrottleKbps) } - { edit_field_number_u64!(stream_state, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb, StreamConfigFormAction::SharedBurstBufferMb) } -
+ let render_stream_edit = || { + html! { + +

{translate.t(LABEL_STREAM)}

+ { edit_field_bool!(stream_state, translate.t(LABEL_RETRY), retry, StreamConfigFormAction::Retry) } + { edit_field_text_option!(stream_state, translate.t(LABEL_THROTTLE), throttle, StreamConfigFormAction::Throttle) } + { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_MILLIS), grace_period_millis, StreamConfigFormAction::GracePeriodMillis) } + { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_TIMEOUT_SECS), grace_period_timeout_secs, StreamConfigFormAction::GracePeriodTimeoutSecs) } + { edit_field_number_u64!(stream_state, translate.t(LABEL_THROTTLE_KBPS), throttle_kbps, StreamConfigFormAction::ThrottleKbps) } + { edit_field_number_u64!(stream_state, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb, StreamConfigFormAction::SharedBurstBufferMb) } +
+ } }; - let render_view_mode = || { html! {
@@ -397,16 +472,18 @@ pub fn ReverseProxyConfigView() -> Html { } }; - let render_edit_mode = || html! { -
- { render_settings_edit() } - { render_disabled_header_edit() } - { render_geoip_edit() } - { render_cache_edit() } - { render_resource_retry_edit() } - { render_rate_limit_edit() } - { render_stream_edit() } -
+ let render_edit_mode = || { + html! { +
+ { render_settings_edit() } + { render_disabled_header_edit() } + { render_geoip_edit() } + { render_cache_edit() } + { render_resource_retry_edit() } + { render_rate_limit_edit() } + { render_stream_edit() } +
+ } }; html! { diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 1a2d54e7e..10b75f748 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -1,17 +1,19 @@ -use std::str::FromStr; -use cron::{Schedule}; -use crate::app::components::{Card, Chip, DropDownOption, DropDownSelection, IconButton, NoContent}; -use crate::app::ConfigContext; -use yew::prelude::*; -use yew_i18n::use_translation; -use shared::model::{ScheduleConfigDto, SchedulesConfigDto}; use crate::app::components::config::config_page::{ConfigForm, LABEL_SCHEDULES_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::HasFormData; -use crate::{config_field_child, generate_form_reducer, html_if}; use crate::app::components::input::Input; use crate::app::components::select::Select; +use crate::app::components::{ + Card, Chip, DropDownOption, DropDownSelection, IconButton, NoContent, +}; +use crate::app::ConfigContext; use crate::hooks::use_service_context; +use crate::{config_field_child, generate_form_reducer, html_if}; +use cron::Schedule; +use shared::model::{ScheduleConfigDto, SchedulesConfigDto}; +use std::str::FromStr; +use yew::prelude::*; +use yew_i18n::use_translation; const LABEL_SCHEDULE: &str = "LABEL.SCHEDULE"; const LABEL_TARGETS: &str = "LABEL.TARGETS"; @@ -34,30 +36,37 @@ pub fn SchedulesConfigView() -> Html { let selected_targets = use_state(|| None::>); let selected_schedule = use_state(|| None); - let all_targets = use_memo(config_ctx.config.clone(), - move |config| config - .as_ref() - .map(|c| &c.sources.sources) - .into_iter() - .flat_map(|sources| sources.iter()) - .flat_map(|s| s.targets.iter()) - .map(|t| t.name.clone()).collect::>(), - ); - - let target_options = use_memo((selected_targets.clone(), all_targets.clone()), - move |(selected, all_targets)| all_targets - .iter() - .map(|t| DropDownOption { - id: t.clone(), - label: html! { t.clone() }, - selected: (*selected).as_ref().is_some_and(|s| s.contains(t)), - }).collect::>(), - ); - - let form_state: UseReducerHandle = use_reducer(|| { - SchedulesConfigFormState { form: SchedulesConfigDto::default(), modified: false } + let all_targets = use_memo(config_ctx.config.clone(), move |config| { + config + .as_ref() + .map(|c| &c.sources.sources) + .into_iter() + .flat_map(|sources| sources.iter()) + .flat_map(|s| s.targets.iter()) + .map(|t| t.name.clone()) + .collect::>() }); + let target_options = use_memo( + (selected_targets.clone(), all_targets.clone()), + move |(selected, all_targets)| { + all_targets + .iter() + .map(|t| DropDownOption { + id: t.clone(), + label: html! { t.clone() }, + selected: (*selected).as_ref().is_some_and(|s| s.contains(t)), + }) + .collect::>() + }, + ); + + let form_state: UseReducerHandle = + use_reducer(|| SchedulesConfigFormState { + form: SchedulesConfigDto::default(), + modified: false, + }); + { let on_form_change = config_view_ctx.on_form_change.clone(); let deps = (form_state.clone(), form_state.modified); @@ -69,38 +78,44 @@ pub fn SchedulesConfigView() -> Html { { let form_state = form_state.clone(); let config = config_ctx.config.as_ref().map(|c| c.config.clone()); - use_effect_with((config, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { - if let Some(conf) = cfg { - let schedules_config = SchedulesConfigDto::from(conf); - form_state.dispatch(SchedulesConfigFormAction::SetAll(schedules_config.clone())); - } else { - form_state.dispatch(SchedulesConfigFormAction::SetAll(SchedulesConfigDto::default())); - } - || () - }); + use_effect_with( + (config, config_view_ctx.edit_mode.clone()), + move |(cfg, _mode)| { + if let Some(conf) = cfg { + let schedules_config = SchedulesConfigDto::from(conf); + form_state + .dispatch(SchedulesConfigFormAction::SetAll(schedules_config.clone())); + } else { + form_state.dispatch(SchedulesConfigFormAction::SetAll( + SchedulesConfigDto::default(), + )); + } + || () + }, + ); } let handle_schedule_selection = { let set_selected_schedule = selected_schedule.clone(); - Callback::from(move |text:String| { + Callback::from(move |text: String| { let trimmed = text.trim().to_string(); - if trimmed.is_empty() { - set_selected_schedule.set(None); - } else { - set_selected_schedule.set(Some(trimmed)); - } + if trimmed.is_empty() { + set_selected_schedule.set(None); + } else { + set_selected_schedule.set(Some(trimmed)); + } }) }; let handle_target_selection = { let set_selected_targets = selected_targets.clone(); - Callback::from(move |(_name, selections):(String, DropDownSelection)| { - match selections { + Callback::from( + move |(_name, selections): (String, DropDownSelection)| match selections { DropDownSelection::Empty => set_selected_targets.set(None), DropDownSelection::Single(options) => set_selected_targets.set(Some(vec![options])), DropDownSelection::Multi(options) => set_selected_targets.set(Some(options)), - } - }) + }, + ) }; let handle_add_schedule = { @@ -118,16 +133,26 @@ pub fn SchedulesConfigView() -> Html { schedule: schedule.clone(), targets, }; - let mut new_schedules = form_state.data().schedules.as_ref().cloned().unwrap_or_default(); + let mut new_schedules = form_state + .data() + .schedules + .as_ref() + .cloned() + .unwrap_or_default(); - let exists = new_schedules.iter().any(|s| - s.schedule == dto.schedule && s.targets.as_deref() == dto.targets.as_deref() - ); + let exists = new_schedules.iter().any(|s| { + s.schedule == dto.schedule + && s.targets.as_deref() == dto.targets.as_deref() + }); if exists { - services.toastr.warning(translate.t("MESSAGES.SCHEDULE_EXISTS")); + services + .toastr + .warning(translate.t("MESSAGES.SCHEDULE_EXISTS")); } else { new_schedules.push(dto); - form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); + form_state.dispatch(SchedulesConfigFormAction::Schedules(Some( + new_schedules, + ))); // clear editor set_selected_schedule.set(None); set_selected_targets.set(None); @@ -138,7 +163,9 @@ pub fn SchedulesConfigView() -> Html { } } } else { - services.toastr.warning(translate.t("MESSAGES.SCHEDULE_NOT_SET")); + services + .toastr + .warning(translate.t("MESSAGES.SCHEDULE_NOT_SET")); } }) }; @@ -147,7 +174,11 @@ pub fn SchedulesConfigView() -> Html { let form_state = form_state.clone(); Callback::from(move |target: String| { if let Some(schedules) = form_state.data().schedules.as_ref() { - let new_schedules: Vec = schedules.iter().filter(|&s| s.schedule != target).cloned().collect(); + let new_schedules: Vec = schedules + .iter() + .filter(|&s| s.schedule != target) + .cloned() + .collect(); if new_schedules.is_empty() { form_state.dispatch(SchedulesConfigFormAction::Schedules(None)); } else { @@ -157,76 +188,74 @@ pub fn SchedulesConfigView() -> Html { }) }; - let render_view_mode = |deletable: bool| { - match form_state.data().schedules.as_ref() { - Some(schedules) => html! { - -
- - + let render_view_mode = |deletable: bool| match form_state.data().schedules.as_ref() { + Some(schedules) => html! { + +
+
+ + + {html_if!(deletable, {})} + + + + + + { for schedules.iter().map(|entry| { + let handle_remove_clone = handle_remove.clone(); + let schedule = entry.schedule.clone(); + html! { - {html_if!(deletable, {})} - - - - - - { for schedules.iter().map(|entry| { - let handle_remove_clone = handle_remove.clone(); - let schedule = entry.schedule.clone(); - html! { - - { html_if!(deletable, { - - })} - + { html_if!(deletable, { - - }}) } - -
{ translate.t(LABEL_SCHEDULE) }{ translate.t(LABEL_TARGETS) }
{ translate.t(LABEL_SCHEDULE) }{ translate.t(LABEL_TARGETS) }
- - { entry.schedule.clone() } -
- { - match entry.targets.as_ref() { - Some(targets) if !targets.is_empty() => html! { - { for targets.iter().map(|t| { - html! { - }})} - }, - _ => html! { - - }, - } - } -
+
-
-
- }, - None => html! { } - } + })} + { entry.schedule.clone() } + +
+ { + match entry.targets.as_ref() { + Some(targets) if !targets.is_empty() => html! { + { for targets.iter().map(|t| { + html! { + }})} + }, + _ => html! { + + }, + } + } +
+ + + }}) } + + +
+
+ }, + None => html! { }, }; let render_edit_mode = || { - html! { -
- {config_field_child!(translate.t(LABEL_SCHEDULE), { - html!{ } - })} - {config_field_child!(translate.t(LABEL_TARGETS), { - html!{ } + })} + {config_field_child!(translate.t(LABEL_TARGETS), { + html!{