feat: added support for panel api

This commit is contained in:
knylbyte
2025-12-16 23:03:24 +01:00
parent b64a6b65dd
commit 7b2dd5be14
9 changed files with 799 additions and 27 deletions
+47
View File
@@ -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`.
+11 -1
View File
@@ -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 {
+2 -1
View File
@@ -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;
mod sys_usage;
@@ -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 {
+676
View File
@@ -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<i64>,
}
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<i64> {
match value {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.trim().parse::<i64>().ok(),
_ => None,
}
}
fn first_json_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
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<String, Value>) -> 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<String> {
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<String> = 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<Vec<(String, String)>, 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<Url, TuliproxError> {
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<Value, TuliproxError> {
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(), &params)?;
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<String>), 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(), &params)?;
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(), &params)?;
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<i64> {
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::<i64>().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<AccountCredentials> {
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<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");
};
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<i64>,
) -> Result<(), TuliproxError> {
let raw = tokio::fs::read_to_string(csv_path).await.unwrap_or_default();
let mut lines: Vec<String> = 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<String> = header.split(';').map(|s| s.trim().to_lowercase()).collect();
if header_line_idx.is_none() {
lines.insert(0, format!("#{header}"));
}
let mut row: Vec<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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(())
}
+5 -2
View File
@@ -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<StagedInput>,
pub exp_date: Option<i64>,
pub t_batch_url: Option<String>,
pub panel_api: Option<PanelApiConfigDto>,
}
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<i64>) -> bool {
}
None => false,
}
}
}
+4
View File
@@ -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<StagedInputDto>,
#[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")]
pub exp_date: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub panel_api: Option<PanelApiConfigDto>,
}
impl Default for ConfigInputDto {
@@ -322,6 +325,7 @@ impl Default for ConfigInputDto {
method: InputFetchMethod::default(),
staged: None,
exp_date: None,
panel_api: None,
}
}
}
+3 -1
View File
@@ -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;
pub use crate::apply_batch_aliases;
+28
View File
@@ -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<PanelApiQueryParamDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub new_client: Vec<PanelApiQueryParamDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub renew_client: Vec<PanelApiQueryParamDto>,
}
#[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<String>,
#[serde(default)]
pub query_parameter: PanelApiQueryParametersDto,
}