mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-27 17:22:39 +01:00
Merge branch 'release/v3.2.0'
This commit is contained in:
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Install trunk
|
||||
run: |
|
||||
if ! command -v trunk &> /dev/null; then
|
||||
cargo install trunk
|
||||
RUSTFLAGS="" cargo install trunk --locked
|
||||
fi
|
||||
|
||||
- name: Install wasm-bindgen-cli
|
||||
|
||||
+16
-1
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
# 3.1.8 (2025-11-18)
|
||||
# 3.2.0 (2025-11-14)
|
||||
- Added `name` attribute to Staged Input.
|
||||
- Real-time active provider connection monitoring (dashboard + websocket)
|
||||
- Source editor: block selection, batch-mode UI and automatic layout
|
||||
- Fixed SSL certificate field binding in configuration view
|
||||
- More robust connection-state and provider-handle management
|
||||
- Streamlined event notifications and provider-count reporting
|
||||
- Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied resources.
|
||||
- Multi Strm outputs with same type is now allowed.
|
||||
- Added new mapper function `pad(text | number, number, char, optional position: "<" | ">" | "^")`
|
||||
- Added new mapper function `format` for simple in-text replacement like `format("Hello {}! Hello {}!", "Bob", "World")`
|
||||
- Added `reverse_proxy.stream.shared_burst_buffer_mb` to control shared-stream burst buffer size (default 12 MB).
|
||||
- Added `movie` as alias for `vod` for type filter. You can now use `Type = movie` as an alternative to `Type = vod`.
|
||||
- Fixed file locks to avoid race conditions on file operations
|
||||
|
||||
# 3.1.8 (2025-11-06)
|
||||
- Fixed HLS streaming issues caused by session eviction and incorrect headers.
|
||||
- Catchup stream fix cycling through multiple providers on play.
|
||||
- Custom streams fix and update webui stream info
|
||||
|
||||
Generated
+196
-218
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,7 @@ Attributes:
|
||||
- `throttle` Allowed units are `KB/s`,`MB/s`,`KiB/s`,`MiB/s`,`kbps`,`mbps`,`Mibps`. Default unit is `kbps`
|
||||
- `grace_period_millis` default set to 300 milliseconds.
|
||||
- `grace_period_timeout_secs` default set to 2 seconds.
|
||||
- `shared_burst_buffer_mb` optional (default `12`). Minimum burst buffer size (in MB) used for shared streams.
|
||||
|
||||
##### 1.6.1.1 `retry`
|
||||
If set to `true` on connection loss to provider, the stream will be reconnected.
|
||||
@@ -317,6 +318,22 @@ reverse_proxy:
|
||||
- my-custom-header
|
||||
```
|
||||
|
||||
#### 1.6.6 `resource_retry`
|
||||
Controls how aggressively tuliprox retries upstream resource (logo, EPG, stream) downloads whenever it proxies requests for clients.
|
||||
It has three attributes:
|
||||
|
||||
- `max_attempts`: How many times a failing request should be retried. Defaults to `3`, minimum `1`.
|
||||
- `backoff_millis`: The wait time between attempts (unless the upstream responds with a `Retry-After` header). Defaults to `250`.
|
||||
- `backoff_multiplier`: Multiplies the base backoff after each failed attempt. Values `<= 1.0` result in constant (linear) delay; values `> 1.0` produce exponential backoff.
|
||||
|
||||
```yaml
|
||||
reverse_proxy:
|
||||
resource_retry:
|
||||
max_attempts: 5
|
||||
backoff_millis: 500
|
||||
backoff_multiplier: 1.5
|
||||
```
|
||||
|
||||
### 1.6.7 `geoip`
|
||||
`geoip` is for resolving IP addresses to country names.
|
||||
|
||||
@@ -1252,8 +1269,12 @@ It is whitespace-tolerant and uses familiar programming concepts with a custom s
|
||||
- first(a)
|
||||
- template(a)
|
||||
- replace(text, match, replacement)
|
||||
- pad(text | number, number, char, optional position: "<" | ">" | "^")
|
||||
- format(fmt_text, ...args)
|
||||
Field names are: `name`, `title"`, `caption"`, `group"`, `id"`, `chno"`, `logo"`, `logo_small"`, `parent_code"`, `audio_track"`, `time_shift" | "url"`, `epg_channel_id"`, `epg_id`.
|
||||
When you use Regular expressions it could be that your match contains multiple results. The builtin function `first` returns the first match.
|
||||
Format is very simple and only supports in text replacement like `format("Hello {}! Hello {}!", "Bob", "World")`
|
||||
When you use Regular expressions it could be that your match contains multiple results.
|
||||
The builtin function `first` returns the first match.
|
||||
Example `print(uppercase("hello"))`. output is only visible in `trace` log level you can enable it like `log_level: debug,tuliprox::foundation::mapper=trace` in config
|
||||
- Assignment assigns an expression result. variable or field.
|
||||
```dsl
|
||||
@@ -2217,4 +2238,4 @@ Regex extraction: Pulls the 4-digit year from the caption.
|
||||
Mapping:
|
||||
If the year is ≤ 2019, it maps to " < 2020".
|
||||
Otherwise, the group is named by the actual year (e.g., "2021").
|
||||
Assignment: Constructs a new group label like "FR | MOVIES 2021" and assigns it to @Group
|
||||
Assignment: Constructs a new group label like "FR | MOVIES 2021" and assigns it to @Group
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuliprox"
|
||||
version = "3.1.8"
|
||||
version = "3.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@@ -22,7 +22,7 @@ axum = { version = "0.8" , features = ["macros", "default", "ws"]}
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "auth", "fs", "compression-full", "trace"] }
|
||||
tower_governor = { version = "0.8", features = ["axum"] }
|
||||
jsonwebtoken = { version = "10.1", features = ["rust_crypto"] }
|
||||
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
||||
rust-argon2 = "3"
|
||||
futures = "0.3"
|
||||
path-clean = "1.0"
|
||||
@@ -60,7 +60,7 @@ indexmap = "2"
|
||||
notify = "8.2"
|
||||
arc-swap = "1.7"
|
||||
dashmap = "6.1"
|
||||
hyper = "1.7"
|
||||
hyper = "1.8"
|
||||
hyper-util = "0.1"
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
async-compression = { version = "0.4", features = ["tokio"] }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext};
|
||||
use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy};
|
||||
use crate::api::model::UserSession;
|
||||
use crate::api::model::{
|
||||
create_channel_unavailable_stream, create_custom_video_stream_response,
|
||||
@@ -9,7 +8,8 @@ use crate::api::model::{
|
||||
ProviderStreamFactoryOptions, SharedStreamManager,
|
||||
StreamError, ThrottledStream, UserApiRequest,
|
||||
};
|
||||
use crate::model::ConfigInput;
|
||||
use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy};
|
||||
use crate::model::{ConfigInput, ResourceRetryConfig};
|
||||
use crate::model::{ConfigTarget, ProxyUserCredentials};
|
||||
use crate::tools::lru_cache::LRUResourceCache;
|
||||
use crate::utils::create_new_file_for_write;
|
||||
@@ -24,6 +24,7 @@ use futures::{StreamExt, TryStreamExt};
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use log::{debug, error, log_enabled, trace};
|
||||
use reqwest::header::RETRY_AFTER;
|
||||
use serde::Serialize;
|
||||
use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster};
|
||||
use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_byte_size, trim_slash};
|
||||
use shared::utils::{
|
||||
@@ -35,7 +36,6 @@ use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use url::Url;
|
||||
|
||||
@@ -381,11 +381,11 @@ async fn resolve_streaming_strategy(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Input {} is exhausted. No connections allowed.", input.name);
|
||||
let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]);
|
||||
ProviderStreamState::Custom(stream)
|
||||
};
|
||||
} else {
|
||||
debug!("Input {} is exhausted. No connections allowed.", input.name);
|
||||
let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]);
|
||||
ProviderStreamState::Custom(stream)
|
||||
};
|
||||
|
||||
|
||||
StreamingStrategy {
|
||||
@@ -402,10 +402,10 @@ fn get_grace_period_millis(
|
||||
) -> u64 {
|
||||
if config_grace_period_millis > 0
|
||||
&& (
|
||||
matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period
|
||||
matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period
|
||||
|| connection_permission == UserConnectionPermission::GracePeriod
|
||||
// user grace period
|
||||
)
|
||||
// user grace period
|
||||
)
|
||||
{
|
||||
config_grace_period_millis
|
||||
} else {
|
||||
@@ -415,7 +415,7 @@ fn get_grace_period_millis(
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_stream_response_details(
|
||||
app_state: &AppState,
|
||||
app_state: &Arc<AppState>,
|
||||
stream_options: &StreamOptions,
|
||||
stream_url: &str,
|
||||
fingerprint: &Fingerprint,
|
||||
@@ -470,6 +470,7 @@ async fn create_stream_response_details(
|
||||
.as_ref()
|
||||
.and_then(|r| r.disabled_header.clone());
|
||||
let provider_stream_factory_options = ProviderStreamFactoryOptions::new(
|
||||
fingerprint.addr,
|
||||
item_type,
|
||||
share_stream,
|
||||
stream_options,
|
||||
@@ -480,11 +481,11 @@ async fn create_stream_response_details(
|
||||
);
|
||||
let reconnect_flag = provider_stream_factory_options.get_reconnect_flag_clone();
|
||||
let provider_stream = match create_provider_stream(
|
||||
Arc::clone(&app_state.app_config),
|
||||
Arc::clone(&app_state.http_client.load()),
|
||||
app_state,
|
||||
&app_state.http_client.load(),
|
||||
provider_stream_factory_options,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
{
|
||||
None => (None, None),
|
||||
Some((stream, info)) => (Some(stream), info),
|
||||
@@ -495,7 +496,7 @@ async fn create_stream_response_details(
|
||||
};
|
||||
|
||||
if log_enabled!(log::Level::Debug) {
|
||||
if let Some((headers, status_code, response_url)) = stream_info.as_ref() {
|
||||
if let Some((headers, status_code, response_url, _custom_video_type)) = stream_info.as_ref() {
|
||||
debug!(
|
||||
"Responding stream request {} with status {}, headers {:?}",
|
||||
sanitize_sensitive_info(
|
||||
@@ -508,12 +509,14 @@ async fn create_stream_response_details(
|
||||
}
|
||||
|
||||
// if we have no stream, we should release the provider
|
||||
if stream.is_none() {
|
||||
if let Some(guard) = streaming_strategy.provider_handle.take() {
|
||||
drop(guard);
|
||||
}
|
||||
let provider_handle = if stream.is_none() {
|
||||
let provider_handle = streaming_strategy.provider_handle.take();
|
||||
app_state.connection_manager.release_provider_handle(provider_handle).await;
|
||||
error!("Cant open stream {}", sanitize_sensitive_info(&request_url));
|
||||
}
|
||||
None
|
||||
} else {
|
||||
streaming_strategy.provider_handle.take()
|
||||
};
|
||||
|
||||
StreamDetails {
|
||||
stream,
|
||||
@@ -521,7 +524,7 @@ async fn create_stream_response_details(
|
||||
provider_name: guard_provider_name.clone(),
|
||||
grace_period_millis,
|
||||
reconnect_flag,
|
||||
provider_handle: streaming_strategy.provider_handle.take(),
|
||||
provider_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +705,7 @@ fn prepare_body_stream(
|
||||
/// # Panics
|
||||
pub async fn force_provider_stream_response(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &AppState,
|
||||
app_state: &Arc<AppState>,
|
||||
user_session: &UserSession,
|
||||
mut stream_channel: StreamChannel,
|
||||
req_headers: &HeaderMap,
|
||||
@@ -714,7 +717,7 @@ pub async fn force_provider_stream_response(
|
||||
let connection_permission = UserConnectionPermission::Allowed;
|
||||
let item_type = stream_channel.item_type;
|
||||
|
||||
let mut stream_details = create_stream_response_details(
|
||||
let stream_details = create_stream_response_details(
|
||||
app_state,
|
||||
&stream_options,
|
||||
&user_session.stream_url,
|
||||
@@ -726,13 +729,13 @@ pub async fn force_provider_stream_response(
|
||||
connection_permission,
|
||||
Some(&user_session.provider),
|
||||
)
|
||||
.await;
|
||||
.await;
|
||||
|
||||
if stream_details.has_stream() {
|
||||
let provider_response = stream_details
|
||||
.stream_info
|
||||
.as_ref()
|
||||
.map(|(h, sc, url)| (h.clone(), *sc, url.clone()));
|
||||
.map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt));
|
||||
app_state
|
||||
.active_users
|
||||
.update_session_addr(&user.username, &user_session.token, &fingerprint.addr)
|
||||
@@ -743,7 +746,7 @@ pub async fn force_provider_stream_response(
|
||||
.await;
|
||||
|
||||
let (status_code, header_map) =
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s)));
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s)));
|
||||
let mut response = axum::response::Response::builder().status(status_code);
|
||||
for (key, value) in &header_map {
|
||||
response = response.header(key, value);
|
||||
@@ -756,13 +759,14 @@ pub async fn force_provider_stream_response(
|
||||
);
|
||||
return try_unwrap_body!(response.body(body_stream));
|
||||
}
|
||||
drop(stream_details.provider_handle.take());
|
||||
|
||||
app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await;
|
||||
if let (Some(stream), _stream_info) = create_channel_unavailable_stream(
|
||||
&app_state.app_config,
|
||||
&[],
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
) {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::ChannelUnavailable).await;
|
||||
app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable).await;
|
||||
debug!("Streaming custom stream");
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
@@ -776,7 +780,7 @@ pub async fn force_provider_stream_response(
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub async fn stream_response(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &AppState,
|
||||
app_state: &Arc<AppState>,
|
||||
session_token: &str,
|
||||
mut stream_channel: StreamChannel,
|
||||
stream_url: &str,
|
||||
@@ -792,10 +796,11 @@ pub async fn stream_response(
|
||||
|
||||
if connection_permission == UserConnectionPermission::Exhausted {
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state,
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let virtual_id = stream_channel.virtual_id;
|
||||
@@ -823,14 +828,14 @@ pub async fn stream_response(
|
||||
connection_permission,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
.await;
|
||||
|
||||
if stream_details.has_stream() {
|
||||
// let content_length = get_stream_content_length(provider_response.as_ref());
|
||||
let provider_response = stream_details
|
||||
.stream_info
|
||||
.as_ref()
|
||||
.map(|(h, sc, response_url)| (h.clone(), *sc, response_url.clone()));
|
||||
.map(|(h, sc, response_url, cvt)| (h.clone(), *sc, response_url.clone(), *cvt));
|
||||
let provider_name = stream_details.provider_name.clone();
|
||||
|
||||
let provider_handle = if share_stream {
|
||||
@@ -850,7 +855,7 @@ pub async fn stream_response(
|
||||
// Shared Stream response
|
||||
let shared_headers = provider_response
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |(h, _, _)| h.clone());
|
||||
.map_or_else(Vec::new, |(h, _, _, _)| h.clone());
|
||||
|
||||
if let Some((broadcast_stream, _shared_provider)) = SharedStreamManager::register_shared_stream(
|
||||
app_state,
|
||||
@@ -861,10 +866,10 @@ pub async fn stream_response(
|
||||
stream_options.buffer_size,
|
||||
provider_handle,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
{
|
||||
let (status_code, header_map) =
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s)));
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s)));
|
||||
let mut response = axum::response::Response::builder().status(status_code);
|
||||
for (key, value) in &header_map {
|
||||
response = response.header(key, value);
|
||||
@@ -876,7 +881,7 @@ pub async fn stream_response(
|
||||
} else {
|
||||
let session_url = provider_response
|
||||
.as_ref()
|
||||
.and_then(|(_, _, u)| u.as_ref())
|
||||
.and_then(|(_, _, u, _)| u.as_ref())
|
||||
.map_or_else(
|
||||
|| Cow::Borrowed(stream_url),
|
||||
|url| Cow::Owned(url.to_string()),
|
||||
@@ -896,7 +901,7 @@ pub async fn stream_response(
|
||||
}
|
||||
}
|
||||
let (status_code, header_map) =
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s)));
|
||||
get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s)));
|
||||
let mut response = axum::response::Response::builder().status(status_code);
|
||||
for (key, value) in &header_map {
|
||||
response = response.header(key, value);
|
||||
@@ -932,7 +937,7 @@ pub async fn stream_response(
|
||||
|
||||
return stream_resp.into_response();
|
||||
}
|
||||
drop(stream_details.provider_handle.take());
|
||||
app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await;
|
||||
axum::http::StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
|
||||
@@ -993,9 +998,9 @@ async fn try_shared_stream_response_if_any(
|
||||
pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool {
|
||||
(item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */)
|
||||
&& target
|
||||
.options
|
||||
.as_ref()
|
||||
.is_some_and(|opt| opt.share_live_streams)
|
||||
.options
|
||||
.as_ref()
|
||||
.is_some_and(|opt| opt.share_live_streams)
|
||||
}
|
||||
|
||||
pub type HeaderFilter = Option<Box<dyn Fn(&str) -> bool + Send>>;
|
||||
@@ -1090,10 +1095,11 @@ async fn fetch_resource_with_retry(
|
||||
req_headers: &HashMap<String, Vec<u8>>,
|
||||
input: Option<&ConfigInput>,
|
||||
) -> Option<axum::response::Response> {
|
||||
// TODO: add max_attempts to config
|
||||
let max_attempts: u32 = 3; //&app_state.app_config.config.load().max_attempts;
|
||||
let backoff_ms: u64 = 250;
|
||||
let config = app_state.app_config.config.load();
|
||||
let (max_attempts, backoff_ms, backoff_multiplier) = config
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
.map_or_else(ResourceRetryConfig::get_default_retry_values, |rp| rp.resource_retry.get_retry_values());
|
||||
let disabled_headers = config
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
@@ -1125,13 +1131,19 @@ async fn fetch_resource_with_retry(
|
||||
| reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
if attempt < max_attempts - 1 && should_retry {
|
||||
if attempt < max_attempts - 1 && should_retry {
|
||||
let wait_dur = response
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map_or(Duration::from_millis(backoff_ms), Duration::from_secs);
|
||||
.map_or_else(
|
||||
|| {
|
||||
let delay = calculate_retry_backoff(backoff_ms, backoff_multiplier, attempt);
|
||||
Duration::from_millis(delay)
|
||||
},
|
||||
Duration::from_secs,
|
||||
);
|
||||
tokio::time::sleep(wait_dur).await;
|
||||
continue;
|
||||
}
|
||||
@@ -1154,8 +1166,9 @@ async fn fetch_resource_with_retry(
|
||||
}
|
||||
Err(err) => {
|
||||
if attempt < max_attempts - 1 {
|
||||
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
|
||||
continue;
|
||||
let delay = calculate_retry_backoff(backoff_ms, backoff_multiplier, attempt);
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
continue;
|
||||
}
|
||||
error!("Received failure from server {}: {err}", sanitize_sensitive_info(resource_url));
|
||||
}
|
||||
@@ -1165,6 +1178,22 @@ async fn fetch_resource_with_retry(
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
|
||||
fn calculate_retry_backoff(base_delay_ms: u64, multiplier: f64, attempt: u32) -> u64 {
|
||||
let base = base_delay_ms.max(1);
|
||||
if multiplier <= 1.0 {
|
||||
return base;
|
||||
}
|
||||
let delay = (base as f64) * multiplier.powi(i32::try_from(attempt).unwrap_or(i32::MAX));
|
||||
if !delay.is_finite() || delay < 1.0 {
|
||||
base
|
||||
} else if delay >= u64::MAX as f64 {
|
||||
u64::MAX
|
||||
} else {
|
||||
delay as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
pub async fn resource_response(
|
||||
app_state: &AppState,
|
||||
@@ -1301,4 +1330,4 @@ pub fn json_or_bin_response<T: Serialize>(accept: Option<&String>, data: &T) ->
|
||||
|
||||
pub fn create_session_fingerprint(fingerprint: &str, username: &str, virtual_id: u32) -> String {
|
||||
format!("{fingerprint}|{username}|{virtual_id}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ impl ConfigFile {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_api_proxy(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
||||
match utils::read_api_proxy_config(&app_state.app_config, true) {
|
||||
async fn load_api_proxy(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
||||
match utils::read_api_proxy_config(&app_state.app_config, true).await {
|
||||
Ok(Some(api_proxy)) => {
|
||||
app_state.app_config.set_api_proxy(api_proxy)?;
|
||||
let paths = <Arc<ArcSwap<ConfigPaths>> as Access<ConfigPaths>>::load(&app_state.app_config.paths);
|
||||
@@ -101,7 +101,7 @@ impl ConfigFile {
|
||||
match self {
|
||||
ConfigFile::ApiProxy => {
|
||||
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy));
|
||||
ConfigFile::load_api_proxy(app_state)
|
||||
ConfigFile::load_api_proxy(app_state).await
|
||||
}
|
||||
ConfigFile::Mapping => {
|
||||
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping));
|
||||
|
||||
@@ -28,11 +28,11 @@ async fn cvs_api(
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, custom_video_type).await;
|
||||
create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
&app_state,
|
||||
&fingerprint.addr,
|
||||
custom_video_type
|
||||
).into_response()
|
||||
).await.into_response()
|
||||
}
|
||||
|
||||
pub fn cvs_api_register() -> axum::Router<Arc<AppState>> {
|
||||
|
||||
@@ -3,15 +3,17 @@ use crate::api::api_utils::{
|
||||
force_provider_stream_response, get_stream_alternative_url, is_seek_request,
|
||||
};
|
||||
use crate::api::api_utils::{get_headers_from_request, try_option_bad_request, HeaderFilter};
|
||||
use crate::api::model::{ProviderAllocation, UserSession};
|
||||
use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType};
|
||||
use crate::api::model::AppState;
|
||||
use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType};
|
||||
use crate::api::model::{ProviderAllocation, UserSession};
|
||||
use crate::auth::Fingerprint;
|
||||
use crate::model::{ConfigTarget, ProxyUserCredentials};
|
||||
use crate::model::{ConfigInput, InputSource};
|
||||
use crate::model::{ConfigTarget, ProxyUserCredentials};
|
||||
use crate::processing::parser::hls::{
|
||||
get_hls_session_token_and_url_from_token, rewrite_hls, RewriteHlsProps,
|
||||
};
|
||||
use crate::repository::m3u_repository::m3u_get_item_for_stream_id;
|
||||
use crate::repository::xtream_repository;
|
||||
use crate::utils::request;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::IntoResponse;
|
||||
@@ -20,8 +22,6 @@ use serde::Deserialize;
|
||||
use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster};
|
||||
use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, CUSTOM_VIDEO_PREFIX, HLS_EXT};
|
||||
use std::sync::Arc;
|
||||
use crate::repository::m3u_repository::m3u_get_item_for_stream_id;
|
||||
use crate::repository::xtream_repository;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HlsApiPathParams {
|
||||
@@ -63,7 +63,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
match handle {
|
||||
Some(provider_handle) => {
|
||||
match provider_handle.allocation {
|
||||
ProviderAllocation::Exhausted => (url, None),
|
||||
ProviderAllocation::Exhausted => (url, None),
|
||||
ProviderAllocation::Available(cfg)
|
||||
| ProviderAllocation::GracePeriod(cfg) => {
|
||||
let stream_url = get_stream_alternative_url(&url, input, &cfg);
|
||||
@@ -169,7 +169,7 @@ async fn get_stream_channel(app_state: &Arc<AppState>, target: &Arc<ConfigTarget
|
||||
return Some(pli.to_stream_channel());
|
||||
}
|
||||
}
|
||||
m3u_get_item_for_stream_id(virtual_id, app_state, target).await.ok().map(|pli| pli.to_stream_channel())
|
||||
m3u_get_item_for_stream_id(virtual_id, app_state, target).await.ok().map(|pli| pli.to_stream_channel())
|
||||
}
|
||||
|
||||
async fn resolve_stream_channel(
|
||||
@@ -211,11 +211,11 @@ async fn hls_api_stream(
|
||||
format!("Could not find any user for hls stream {}", params.username)
|
||||
);
|
||||
if user.permission_denied(&app_state) {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, CustomVideoStreamType::UserAccountExpired).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
&app_state,
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::UserAccountExpired,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -236,12 +236,10 @@ async fn hls_api_stream(
|
||||
|
||||
if let Some(session) = &mut user_session {
|
||||
if session.permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
&app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
).await.into_response();
|
||||
}
|
||||
|
||||
if app_state
|
||||
@@ -249,12 +247,11 @@ async fn hls_api_stream(
|
||||
.is_over_limit(&session.provider)
|
||||
.await
|
||||
{
|
||||
app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
&app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let hls_url = match get_hls_session_token_and_url_from_token(
|
||||
@@ -279,8 +276,8 @@ async fn hls_api_stream(
|
||||
&input,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
@@ -288,12 +285,10 @@ async fn hls_api_stream(
|
||||
|
||||
let connection_permission = user.connection_permission(&app_state).await;
|
||||
if connection_permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
&app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
).await.into_response();
|
||||
}
|
||||
|
||||
if is_hls_url(&session.stream_url) {
|
||||
|
||||
@@ -93,11 +93,10 @@ async fn m3u_api_stream(
|
||||
)
|
||||
);
|
||||
if user.permission_denied(app_state) {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserAccountExpired).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserAccountExpired,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -131,11 +130,10 @@ async fn m3u_api_stream(
|
||||
|
||||
let session_url = if let Some(session) = &user_session {
|
||||
if session.permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -144,11 +142,10 @@ async fn m3u_api_stream(
|
||||
.is_over_limit(&session.provider)
|
||||
.await
|
||||
{
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
if session.virtual_id == virtual_id && is_seek_request(cluster, req_headers).await {
|
||||
@@ -172,11 +169,10 @@ async fn m3u_api_stream(
|
||||
|
||||
let connection_permission = user.connection_permission(app_state).await;
|
||||
if connection_permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,10 +79,11 @@ async fn geoip_update(axum::extract::State(app_state): axum::extract::State<Arc<
|
||||
if let Some(geoip) = config.reverse_proxy.as_ref().and_then(|r| r.geoip.as_ref()) {
|
||||
if geoip.enabled {
|
||||
let geoip_db_path = &*get_geoip_path(&config.working_dir);
|
||||
let _file_lock = app_state.app_config.file_locks.write_lock(geoip_db_path);
|
||||
let _file_lock = app_state.app_config.file_locks.write_lock(geoip_db_path).await;
|
||||
|
||||
let url = if geoip.url.trim().is_empty() { default_geoip_url() } else { geoip.url.clone() };
|
||||
let input_source = InputSource {
|
||||
name: String::from("GeoIP"),
|
||||
url,
|
||||
username: None,
|
||||
password: None,
|
||||
|
||||
@@ -96,7 +96,7 @@ async fn config(
|
||||
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
|
||||
error!("Failed to prepare sources batch: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
|
||||
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await {
|
||||
error!("Failed to prepare users: {err}");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
} else {
|
||||
|
||||
@@ -113,7 +113,7 @@ async fn save_config_api_proxy_user(
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user).await {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(json!({"error": err.to_string()}))
|
||||
@@ -160,7 +160,7 @@ async fn delete_config_api_proxy_user(
|
||||
if modified {
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
if new_api_proxy.use_user_db {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
|
||||
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user).await {
|
||||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::api::api_utils::try_unwrap_body;
|
||||
use crate::api::model::AppState;
|
||||
use crate::auth::{create_jwt_admin, create_jwt_user, is_admin, verify_password, verify_token, AuthBearer};
|
||||
use axum::response::IntoResponse;
|
||||
use log::error;
|
||||
use log::{error};
|
||||
use serde_json::json;
|
||||
use shared::model::{TokenResponse, UserCredential, TOKEN_NO_AUTH};
|
||||
use shared::utils::{concat_path_leading_slash, CONSTANTS};
|
||||
|
||||
@@ -144,6 +144,16 @@ async fn handle_protocol_message(
|
||||
Some(ProtocolMessage::UserActionResponse(false))
|
||||
}
|
||||
},
|
||||
Ok(ProtocolMessage::ActiveProviderCountRequest(auth_token)) => {
|
||||
if !auth_required || verify_auth_admin_token(&auth_token, secret_key) {
|
||||
mem.role = UserRole::Admin;
|
||||
mem.token = Some(auth_token);
|
||||
let connections = app_state.active_provider.get_provider_connections_count().await;
|
||||
Some(ProtocolMessage::ActiveProviderCountResponse(connections))
|
||||
} else {
|
||||
Some(ProtocolMessage::Unauthorized)
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
trace!("Unexpected protocol message after handshake");
|
||||
None
|
||||
|
||||
@@ -236,8 +236,7 @@ async fn xtream_player_api_stream(
|
||||
format!("Could not find any user for xc stream {}", stream_req.username)
|
||||
);
|
||||
if user.permission_denied(app_state) {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserAccountExpired).await;
|
||||
return create_custom_video_stream_response(&app_state.app_config, CustomVideoStreamType::UserAccountExpired,).into_response();
|
||||
return create_custom_video_stream_response(app_state, &fingerprint.addr, CustomVideoStreamType::UserAccountExpired).await.into_response();
|
||||
}
|
||||
|
||||
let target_name = &target.name;
|
||||
@@ -273,11 +272,10 @@ async fn xtream_player_api_stream(
|
||||
|
||||
let session_url = if let Some(session) = &user_session {
|
||||
if session.permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -286,11 +284,10 @@ async fn xtream_player_api_stream(
|
||||
.is_over_limit(&session.provider)
|
||||
.await
|
||||
{
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -318,11 +315,10 @@ async fn xtream_player_api_stream(
|
||||
|
||||
let connection_permission = user.connection_permission(app_state).await;
|
||||
if connection_permission == UserConnectionPermission::Exhausted {
|
||||
app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.app_config,
|
||||
app_state, &fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
).await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -563,7 +559,7 @@ fn get_doc_resource_field_value<'a>(
|
||||
None
|
||||
}
|
||||
|
||||
fn xtream_get_info_resource_url<'a>(
|
||||
async fn xtream_get_info_resource_url<'a>(
|
||||
config: &'a AppConfig,
|
||||
pli: &'a XtreamPlaylistItem,
|
||||
target: &'a ConfigTarget,
|
||||
@@ -574,12 +570,12 @@ fn xtream_get_info_resource_url<'a>(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
),
|
||||
).await,
|
||||
XtreamCluster::Series => xtream_repository::xtream_load_series_info(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
),
|
||||
).await,
|
||||
XtreamCluster::Live => None,
|
||||
};
|
||||
if let Some(content) = info_content {
|
||||
@@ -653,7 +649,7 @@ fn get_season_info_doc(doc: &Vec<Value>, season_id: u32) -> Option<&Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn xtream_get_season_resource_url<'a>(
|
||||
async fn xtream_get_season_resource_url<'a>(
|
||||
config: &'a AppConfig,
|
||||
pli: &'a XtreamPlaylistItem,
|
||||
target: &'a ConfigTarget,
|
||||
@@ -664,7 +660,7 @@ fn xtream_get_season_resource_url<'a>(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
),
|
||||
).await,
|
||||
XtreamCluster::Video | XtreamCluster::Live => None,
|
||||
};
|
||||
if let Some(content) = info_content {
|
||||
@@ -737,14 +733,14 @@ async fn xtream_player_api_resource(
|
||||
&pli,
|
||||
&target,
|
||||
resource
|
||||
))
|
||||
).await)
|
||||
} else if resource.starts_with(crate::model::XC_SEASON_RESOURCE_PREFIX) {
|
||||
try_result_bad_request!(xtream_get_season_resource_url(
|
||||
&app_state.app_config,
|
||||
&pli,
|
||||
&target,
|
||||
resource
|
||||
))
|
||||
).await)
|
||||
} else {
|
||||
pli.get_field(resource)
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn healthcheck() -> impl axum::response::IntoResponse {
|
||||
axum::Json(create_healthcheck())
|
||||
}
|
||||
|
||||
fn create_shared_data(
|
||||
async fn create_shared_data(
|
||||
app_config: &Arc<AppConfig>,
|
||||
forced_targets: &Arc<ProcessTargets>,
|
||||
) -> AppState {
|
||||
@@ -68,7 +68,7 @@ fn create_shared_data(
|
||||
let use_geoip = config.is_geoip_enabled();
|
||||
let geoip = if use_geoip {
|
||||
let path = get_geoip_path(&config.working_dir);
|
||||
let _file_lock = app_config.file_locks.read_lock(&path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&path).await;
|
||||
match GeoIp::load(&path) {
|
||||
Ok(db) => {
|
||||
info!("GeoIp db loaded");
|
||||
@@ -84,9 +84,8 @@ fn create_shared_data(
|
||||
};
|
||||
|
||||
let cache = create_cache(&config);
|
||||
let (provider_change_tx, provider_change_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let event_manager = Arc::new(EventManager::new(provider_change_rx, ));
|
||||
let active_provider = Arc::new(ActiveProviderManager::new(app_config, provider_change_tx));
|
||||
let event_manager = Arc::new(EventManager::new());
|
||||
let active_provider = Arc::new(ActiveProviderManager::new(app_config, &event_manager));
|
||||
let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider)));
|
||||
let active_users = Arc::new(ActiveUserManager::new(&config,&geoip, &event_manager));
|
||||
let connection_manager = Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager));
|
||||
@@ -250,7 +249,7 @@ pub async fn start_server(
|
||||
if web_ui_enabled {
|
||||
infos.push(format!("Web root: {}", web_dir_path.display()));
|
||||
}
|
||||
let app_shared_data = create_shared_data(&app_config, &targets);
|
||||
let app_shared_data = create_shared_data(&app_config, &targets).await;
|
||||
let app_state = Arc::new(app_shared_data);
|
||||
let shared_data = Arc::clone(&app_state);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::model::provider_lineup_manager::{ProviderAllocation, ProviderLineupManager};
|
||||
use crate::api::model::{ProviderConfig, ProviderConnectionChangeSender};
|
||||
use crate::api::model::{EventManager, ProviderConfig};
|
||||
use crate::model::{AppConfig, ConfigInput};
|
||||
use log::{debug, error};
|
||||
use shared::utils::{default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info};
|
||||
@@ -46,11 +46,11 @@ pub struct ActiveProviderManager {
|
||||
}
|
||||
|
||||
impl ActiveProviderManager {
|
||||
pub fn new(cfg: &AppConfig, connection_change_sender: ProviderConnectionChangeSender) -> Self {
|
||||
pub fn new(cfg: &AppConfig, event_manager: &Arc<EventManager>) -> Self {
|
||||
let (grace_period_millis, grace_period_timeout_secs) = Self::get_grace_options(cfg);
|
||||
let inputs = Self::get_config_inputs(cfg);
|
||||
Self {
|
||||
providers: ProviderLineupManager::new(inputs, grace_period_millis, grace_period_timeout_secs, connection_change_sender),
|
||||
providers: ProviderLineupManager::new(inputs, grace_period_millis, grace_period_timeout_secs, event_manager),
|
||||
connections: RwLock::new(Connections::default()),
|
||||
}
|
||||
}
|
||||
@@ -179,4 +179,8 @@ impl ActiveProviderManager {
|
||||
shared_allocation.connections.insert(*addr);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_provider_connections_count(&self) -> usize {
|
||||
self.providers.active_connection_count().await
|
||||
}
|
||||
}
|
||||
@@ -220,11 +220,17 @@ impl ActiveUserManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_stream_detail(&self, username: &str, fingerprint: &Fingerprint, video_type: CustomVideoStreamType) -> Option<StreamInfo> {
|
||||
pub async fn update_stream_detail(&self, addr: &SocketAddr, video_type: CustomVideoStreamType) -> Option<StreamInfo> {
|
||||
let mut user_connections = self.connections.write().await;
|
||||
if let Some(connection_data) = user_connections.by_key.get_mut(username) {
|
||||
let username = {
|
||||
match user_connections.key_by_addr.get(addr) {
|
||||
Some(username) => username.clone(),
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
if let Some(connection_data) = user_connections.by_key.get_mut(&username) {
|
||||
for stream in &mut connection_data.streams {
|
||||
if stream.addr == fingerprint.addr {
|
||||
if &stream.addr == addr {
|
||||
stream.provider = String::from("tuliprox");
|
||||
stream.channel.title = video_type.to_string();
|
||||
stream.channel.group = String::new();
|
||||
@@ -236,33 +242,44 @@ impl ActiveUserManager {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn add_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint,
|
||||
provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) -> StreamInfo {
|
||||
pub async fn add_connection(&self, addr: &SocketAddr) {
|
||||
let mut user_connections = self.connections.write().await;
|
||||
if !user_connections.key_by_addr.contains_key(addr) {
|
||||
user_connections.key_by_addr.insert(*addr, String::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint,
|
||||
provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) -> Option<StreamInfo> {
|
||||
let stream_info = {
|
||||
let mut user_connections = self.connections.write().await;
|
||||
|
||||
let already_counted = user_connections.key_by_addr.get(&fingerprint.addr).is_some_and(|u| u == username);
|
||||
// needs to be registered through socket connection to avoid race time conditions through short disconnect
|
||||
if !user_connections
|
||||
.key_by_addr
|
||||
.contains_key(&fingerprint.addr) {
|
||||
return None;
|
||||
}
|
||||
|
||||
user_connections
|
||||
.key_by_addr.insert(fingerprint.addr, username.to_string());
|
||||
|
||||
let connection_data = user_connections.by_key
|
||||
.entry(username.to_string())
|
||||
.or_insert_with(|| UserConnectionData::new(0, max_connections));
|
||||
connection_data.max_connections = max_connections;
|
||||
|
||||
let existing_stream_info = if already_counted {
|
||||
if let Some(existing) = connection_data
|
||||
.streams
|
||||
.iter_mut()
|
||||
.find(|s| s.addr == fingerprint.addr)
|
||||
{
|
||||
existing.channel = stream_channel.clone();
|
||||
existing.provider = provider.to_string();
|
||||
Some(existing.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let existing_stream_info = connection_data
|
||||
.streams
|
||||
.iter_mut()
|
||||
.find(|s| s.addr == fingerprint.addr)
|
||||
.map(|stream_info| {
|
||||
stream_info.channel = stream_channel.clone();
|
||||
stream_info.provider = provider.to_string();
|
||||
stream_info.clone()
|
||||
});
|
||||
|
||||
if let Some(stream_info) = existing_stream_info { stream_info } else {
|
||||
let country = {
|
||||
let geoip = self.geo_ip.load();
|
||||
@@ -295,7 +312,7 @@ impl ActiveUserManager {
|
||||
|
||||
self.log_active_user().await;
|
||||
|
||||
stream_info
|
||||
Some(stream_info)
|
||||
}
|
||||
|
||||
fn is_log_user_enabled(&self) -> bool {
|
||||
|
||||
@@ -282,7 +282,7 @@ impl AppState {
|
||||
if changes.geoip {
|
||||
let new_geoip = if use_geoip {
|
||||
let path = get_geoip_path(&working_dir);
|
||||
let _file_lock = self.app_config.file_locks.read_lock(&path);
|
||||
let _file_lock = self.app_config.file_locks.read_lock(&path).await;
|
||||
GeoIp::load(&path).ok().map(Arc::new)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::borrow::Cow;
|
||||
use std::net::SocketAddr;
|
||||
use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, SharedStreamManager};
|
||||
use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, ProviderHandle, SharedStreamManager};
|
||||
use std::sync::Arc;
|
||||
use log::debug;
|
||||
use log::{debug};
|
||||
use shared::model::{ActiveUserConnectionChange, StreamChannel};
|
||||
use crate::auth::Fingerprint;
|
||||
|
||||
@@ -56,21 +56,35 @@ impl ConnectionManager {
|
||||
self.shared_stream_manager.release_connection(addr, false).await;
|
||||
}
|
||||
|
||||
pub async fn release_provider_handle(&self, provider_handle: Option<ProviderHandle>) {
|
||||
if let Some(handle) = provider_handle {
|
||||
self.release_provider_connection(&handle.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn add_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint,
|
||||
pub async fn add_connection(&self, addr: &SocketAddr) {
|
||||
self.user_manager.add_connection(addr).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint,
|
||||
provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) {
|
||||
let stream_info = self.user_manager.add_connection(username, max_connections, fingerprint, provider, stream_channel, user_agent).await;
|
||||
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connected(stream_info)));
|
||||
if let Some(stream_info) = self.user_manager.update_connection(username, max_connections, fingerprint, provider, stream_channel, user_agent).await {
|
||||
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
|
||||
} else {
|
||||
// TODO what do we do here ?
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn send_active_user_stats(&self, user_count: usize, user_connection_count: usize) {
|
||||
// self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(user_count, user_connection_count)));
|
||||
// }
|
||||
|
||||
pub async fn update_stream_detail(&self, username: &str, fingerprint: &Fingerprint, video_type: CustomVideoStreamType) {
|
||||
if let Some(stream_info) = self.user_manager.update_stream_detail(username, fingerprint, video_type).await {
|
||||
pub async fn update_stream_detail(&self, addr: &SocketAddr, video_type: CustomVideoStreamType) {
|
||||
if let Some(stream_info) = self.user_manager.update_stream_detail(addr, video_type).await {
|
||||
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use log::{info, trace};
|
||||
use log::{trace};
|
||||
use shared::model::{ActiveUserConnectionChange, ConfigType, PlaylistUpdateState};
|
||||
use crate::api::model::{ProviderConnectionChangeReceiver};
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -20,29 +19,9 @@ pub struct EventManager {
|
||||
}
|
||||
|
||||
impl EventManager {
|
||||
pub fn new(mut provider_change_rx: ProviderConnectionChangeReceiver,
|
||||
) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let (channel_tx, _channel_rx) = tokio::sync::broadcast::channel(10);
|
||||
|
||||
let channel_tx_clone = channel_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
||||
Some((provider, connection_count)) = provider_change_rx.recv() => {
|
||||
if let Err(e) = channel_tx_clone.send(EventMessage::ActiveProvider(provider, connection_count)) {
|
||||
trace!("Failed to send active provider change event: {e}");
|
||||
}
|
||||
}
|
||||
else => {
|
||||
// Both channels are closed, exit gracefully
|
||||
info!("All input channels closed, terminating event manager task");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
channel_tx,
|
||||
//channel_rx,
|
||||
@@ -53,11 +32,24 @@ impl EventManager {
|
||||
self.channel_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn send_event(&self, event: EventMessage) {
|
||||
pub fn send_event(&self, event: EventMessage) -> bool {
|
||||
if let Err(err) = self.channel_tx.send(event) {
|
||||
trace!("Failed to send event: {err}");
|
||||
trace!("Failed to send event: {err}");
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_provider_event(&self, provider: &str, connection_count: usize) {
|
||||
if !self.send_event(EventMessage::ActiveProvider(String::from(provider), connection_count)) {
|
||||
trace!("Failed to send connection change: {provider}: {connection_count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ use shared::model::InputType;
|
||||
use shared::write_if_some;
|
||||
use crate::api::model::ProviderAllocation;
|
||||
|
||||
pub type ProviderConnectionChangeSender = tokio::sync::mpsc::UnboundedSender<(String, usize)>;
|
||||
pub type ProviderConnectionChangeReceiver = tokio::sync::mpsc::UnboundedReceiver<(String, usize)>;
|
||||
|
||||
pub type ProviderConnectionChangeCallback = Arc<dyn Fn(&str, usize) + Send + Sync>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::api::model::provider_config::ProviderConfigWrapper;
|
||||
use crate::api::model::{ProviderConfig, ProviderConfigConnection, ProviderConnectionChangeCallback, ProviderConnectionChangeSender};
|
||||
use crate::api::model::{EventManager, ProviderConfig, ProviderConfigConnection, ProviderConnectionChangeCallback};
|
||||
use crate::utils::debug_if_enabled;
|
||||
use crate::model::ConfigInput;
|
||||
use arc_swap::ArcSwap;
|
||||
use log::{debug, error, log_enabled};
|
||||
use log::{debug, log_enabled};
|
||||
use shared::utils::{display_vec, sanitize_sensitive_info};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
@@ -516,35 +516,30 @@ pub(in crate::api::model) struct ProviderLineupManager {
|
||||
grace_period_timeout_secs: AtomicU64,
|
||||
inputs: Arc<ArcSwap<Vec<Arc<ConfigInput>>>>,
|
||||
providers: Arc<ArcSwap<Vec<ProviderLineup>>>,
|
||||
connection_change_tx: ProviderConnectionChangeSender,
|
||||
event_manager: Arc<EventManager>,
|
||||
}
|
||||
|
||||
impl ProviderLineupManager {
|
||||
pub fn new(inputs: Vec<Arc<ConfigInput>>, grace_period_millis: u64, grace_period_timeout_secs: u64, connection_change_tx: ProviderConnectionChangeSender) -> Self {
|
||||
let lineups = inputs.iter().map(|i| Self::create_lineup(i, None, connection_change_tx.clone())).collect();
|
||||
pub fn new(inputs: Vec<Arc<ConfigInput>>, grace_period_millis: u64, grace_period_timeout_secs: u64, event_manager: &Arc<EventManager>) -> Self {
|
||||
let lineups = inputs.iter().map(|i| Self::create_lineup(i, None, event_manager)).collect();
|
||||
Self {
|
||||
grace_period_millis: AtomicU64::new(grace_period_millis),
|
||||
grace_period_timeout_secs: AtomicU64::new(grace_period_timeout_secs),
|
||||
inputs: Arc::new(ArcSwap::from_pointee(inputs)),
|
||||
providers: Arc::new(ArcSwap::from_pointee(lineups)),
|
||||
connection_change_tx,
|
||||
event_manager: Arc::clone(event_manager),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_lineup(cfg_input: &ConfigInput, provider_connections: Option<&HashMap<&str, ProviderConfigConnection>>, connection_change_sender: ProviderConnectionChangeSender) -> ProviderLineup {
|
||||
fn create_lineup(cfg_input: &ConfigInput, provider_connections: Option<&HashMap<&str, ProviderConfigConnection>>, event_manager: &Arc<EventManager>) -> ProviderLineup {
|
||||
let get_connections = provider_connections.map(|c| |name: &str| c.get(name));
|
||||
|
||||
let cfg_name = cfg_input.name.clone();
|
||||
let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |_name: &str, connections: usize| {
|
||||
let connection_change_sender = connection_change_sender.clone();
|
||||
let provider_cfg_name = cfg_name.clone();
|
||||
if let Err(err) = connection_change_sender.send((provider_cfg_name.clone(), connections)) {
|
||||
error!("Failed to send connection change: {provider_cfg_name}: {connections}, {err}");
|
||||
}
|
||||
//let cfg_name = cfg_input.name.clone();
|
||||
let event_manager = Arc::clone(event_manager);
|
||||
let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |name: &str, connections: usize| {
|
||||
event_manager.send_provider_event(name, connections);
|
||||
});
|
||||
|
||||
let on_connection_change = Arc::new(on_connection_change);
|
||||
|
||||
if cfg_input.aliases.as_ref().is_some_and(|a| !a.is_empty()) {
|
||||
ProviderLineup::Multi(MultiProviderLineup::new(cfg_input, get_connections, &on_connection_change))
|
||||
} else {
|
||||
@@ -645,7 +640,7 @@ impl ProviderLineupManager {
|
||||
let mut new_lineups: Vec<ProviderLineup> = Vec::with_capacity(new_inputs.len());
|
||||
let connections = Some(provider_connections);
|
||||
for input in &new_inputs {
|
||||
new_lineups.push(Self::create_lineup(input, connections.as_ref(), self.connection_change_tx.clone()));
|
||||
new_lineups.push(Self::create_lineup(input, connections.as_ref(), &self.event_manager));
|
||||
}
|
||||
|
||||
debug_if_enabled!("inputs {}", sanitize_sensitive_info(&display_vec(&new_inputs)));
|
||||
@@ -737,6 +732,22 @@ impl ProviderLineupManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn active_connection_count(&self) -> usize {
|
||||
let mut count = 0;
|
||||
let providers = self.providers.load();
|
||||
for lineup in providers.iter() {
|
||||
match lineup {
|
||||
ProviderLineup::Single(provider_lineup) => {
|
||||
count += provider_lineup.provider.get_current_connections().await;
|
||||
}
|
||||
ProviderLineup::Multi(provider_lineup) => {
|
||||
count += provider_lineup.get_total_connections().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub async fn is_over_limit(&self, provider_name: &str) -> bool {
|
||||
let providers = self.providers.load();
|
||||
if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &providers) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use crate::api::model::{ProviderHandle, StreamError};
|
||||
use crate::api::model::{CustomVideoStreamType, ProviderHandle, StreamError};
|
||||
use axum::http::StatusCode;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::BoxStream;
|
||||
@@ -10,7 +10,7 @@ use crate::tools::atomic_once_flag::AtomicOnceFlag;
|
||||
|
||||
pub type BoxedProviderStream = BoxStream<'static, Result<Bytes, StreamError>>;
|
||||
pub type ProviderStreamHeader = Vec<(String, String)>;
|
||||
pub type ProviderStreamInfo = Option<(ProviderStreamHeader, StatusCode, Option<Url>)>;
|
||||
pub type ProviderStreamInfo = Option<(ProviderStreamHeader, StatusCode, Option<Url>, Option<CustomVideoStreamType>)>;
|
||||
|
||||
pub type ProviderStreamResponse = (Option<BoxedProviderStream>, ProviderStreamInfo);
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ impl ActiveClientStream {
|
||||
let provider_name = stream_details.provider_name.as_ref().map_or_else(String::new, ToString::to_string);
|
||||
|
||||
let user_agent = req_headers.get(USER_AGENT).map(|h| String::from_utf8_lossy(h.as_bytes())).unwrap_or_default();
|
||||
app_state.connection_manager.add_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent).await;
|
||||
|
||||
app_state.connection_manager.update_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent).await;
|
||||
if let Some((_,_,_m_, Some(cvt))) = stream_details.stream_info.as_ref() {
|
||||
app_state.connection_manager.update_stream_detail(&fingerprint.addr, *cvt).await;
|
||||
}
|
||||
let cfg = &app_state.app_config;
|
||||
let waker = Some(Arc::new(AtomicWaker::new()));
|
||||
let waker_clone = waker.clone();
|
||||
@@ -63,9 +67,8 @@ impl ActiveClientStream {
|
||||
|
||||
let stream = match stream_details.stream.take() {
|
||||
None => {
|
||||
if let Some(guard) = stream_details.provider_handle.as_ref() {
|
||||
app_state.active_provider.release_handle(guard).await;
|
||||
}
|
||||
let provider_handle = stream_details.provider_handle.take();
|
||||
app_state.connection_manager.release_provider_handle(provider_handle).await;
|
||||
futures::stream::empty::<Result<Bytes, StreamError>>().boxed()
|
||||
}
|
||||
Some(stream) => {
|
||||
@@ -128,7 +131,6 @@ impl ActiveClientStream {
|
||||
let connection_manager = Arc::clone(&connection_manager);
|
||||
let reconnect_flag = stream_details.reconnect_flag.clone();
|
||||
let fingerprint = fingerprint.clone();
|
||||
let username = user.username.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await;
|
||||
|
||||
@@ -137,7 +139,7 @@ impl ActiveClientStream {
|
||||
let active_connections = user_manager.user_connections(&username).await;
|
||||
if active_connections > max_connections {
|
||||
stream_strategy_flag_copy.store(USER_EXHAUSTED_STREAM, std::sync::atomic::Ordering::Release);
|
||||
connection_manager.update_stream_detail(&username, &fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted).await;
|
||||
info!("User connections exhausted for active clients: {username}");
|
||||
updated = true;
|
||||
}
|
||||
@@ -147,7 +149,7 @@ impl ActiveClientStream {
|
||||
if let Some(provider_name) = provider_grace_check {
|
||||
if provider_manager.is_over_limit(&provider_name).await {
|
||||
stream_strategy_flag_copy.store(PROVIDER_EXHAUSTED_STREAM, std::sync::atomic::Ordering::Release);
|
||||
connection_manager.update_stream_detail(&username, &fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await;
|
||||
connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted).await;
|
||||
info!("Provider connections exhausted for active clients: {provider_name}");
|
||||
updated = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::api_utils::{HeaderFilter};
|
||||
use crate::api::model::{CustomVideoStream, ThrottledStream};
|
||||
use crate::api::model::{AppState, CustomVideoStream, ThrottledStream};
|
||||
use crate::model::{AppConfig};
|
||||
use shared::model::PlaylistItemType;
|
||||
use log::{trace};
|
||||
@@ -10,6 +10,8 @@ use crate::api::model::TransportStreamBuffer;
|
||||
use crate::api::api_utils::try_unwrap_body;
|
||||
use std::str::FromStr;
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use serde::{Serialize, Deserialize, Serializer, Deserializer};
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
@@ -64,14 +66,14 @@ impl<'de> Deserialize<'de> for CustomVideoStreamType {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_video_stream(video_buffer: Option<&TransportStreamBuffer>, headers: &[(String, String)], log_message: &str) -> ProviderStreamResponse {
|
||||
fn create_video_stream(stream_type: CustomVideoStreamType, video_buffer: Option<&TransportStreamBuffer>, headers: &[(String, String)], log_message: &str) -> ProviderStreamResponse {
|
||||
if let Some(video) = video_buffer {
|
||||
trace!("{log_message}");
|
||||
let mut response_headers: Vec<(String, String)> = headers.iter()
|
||||
.filter(|(key, _)| !(key.eq("content-type") || key.eq("content-length") || key.contains("range")))
|
||||
.map(|(key, value)| (key.clone(), value.clone())).collect();
|
||||
response_headers.push(("content-type".to_string(), "video/mp2t".to_string()));
|
||||
(Some(Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000))), Some((response_headers, StatusCode::OK, None)))
|
||||
(Some(Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000))), Some((response_headers, StatusCode::OK, None, Some(stream_type))))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
@@ -80,34 +82,37 @@ fn create_video_stream(video_buffer: Option<&TransportStreamBuffer>, headers: &[
|
||||
pub fn create_channel_unavailable_stream(cfg: &AppConfig, headers: &[(String, String)], status: StatusCode) -> ProviderStreamResponse {
|
||||
let custom_stream_response = cfg.custom_stream_response.load();
|
||||
let video = custom_stream_response.as_ref().and_then(|c| c.channel_unavailable.as_ref());
|
||||
create_video_stream(video, headers, &format!("Streaming response channel unavailable for status {status}"))
|
||||
create_video_stream(CustomVideoStreamType::ChannelUnavailable, video, headers, &format!("Streaming response channel unavailable for status {status}"))
|
||||
}
|
||||
|
||||
pub fn create_user_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse {
|
||||
let custom_stream_response = cfg.custom_stream_response.load();
|
||||
let video = custom_stream_response.as_ref().and_then(|c| c.user_connections_exhausted.as_ref());
|
||||
create_video_stream(video, headers, "Streaming response user connections exhausted")
|
||||
create_video_stream(CustomVideoStreamType::UserConnectionsExhausted, video, headers, "Streaming response user connections exhausted")
|
||||
}
|
||||
|
||||
pub fn create_provider_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse {
|
||||
let custom_stream_response = cfg.custom_stream_response.load();
|
||||
let video = custom_stream_response.as_ref().and_then(|c| c.provider_connections_exhausted.as_ref());
|
||||
create_video_stream(video, headers, "Streaming response provider connections exhausted")
|
||||
create_video_stream(CustomVideoStreamType::ProviderConnectionsExhausted, video, headers, "Streaming response provider connections exhausted")
|
||||
}
|
||||
|
||||
pub fn create_user_account_expired_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse {
|
||||
let custom_stream_response = cfg.custom_stream_response.load();
|
||||
let video = custom_stream_response.as_ref().and_then(|c| c.user_account_expired.as_ref());
|
||||
create_video_stream(video, headers, "Streaming response user account expired")
|
||||
create_video_stream(CustomVideoStreamType::UserAccountExpired, video, headers, "Streaming response user account expired")
|
||||
}
|
||||
|
||||
pub fn create_custom_video_stream_response(config: &AppConfig, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send {
|
||||
if let (Some(stream), Some((headers, status_code, _))) = match video_response {
|
||||
pub async fn create_custom_video_stream_response(app_state: &Arc<AppState>, addr: &SocketAddr, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send {
|
||||
let config = &app_state.app_config;
|
||||
if let (Some(stream), Some((headers, status_code, _, _))) = match video_response {
|
||||
CustomVideoStreamType::ChannelUnavailable => create_channel_unavailable_stream(config, &[], StatusCode::BAD_REQUEST),
|
||||
CustomVideoStreamType::UserConnectionsExhausted => create_user_connections_exhausted_stream(config, &[]),
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted => create_provider_connections_exhausted_stream(config, &[]),
|
||||
CustomVideoStreamType::UserAccountExpired => create_user_account_expired_stream(config, &[]),
|
||||
} {
|
||||
app_state.connection_manager.update_stream_detail(addr, video_response).await;
|
||||
app_state.connection_manager.release_provider_connection(addr).await;
|
||||
let mut builder = axum::response::Response::builder()
|
||||
.status(status_code);
|
||||
for (key, value) in headers {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::api::api_utils::{get_headers_from_request, StreamOptions};
|
||||
use crate::api::model::get_response_headers;
|
||||
use crate::api::model::{get_response_headers, AppState, CustomVideoStreamType};
|
||||
use crate::api::model::StreamError;
|
||||
use crate::api::model::TimedClientStream;
|
||||
use crate::api::model::{create_channel_unavailable_stream, get_header_filter_for_item_type};
|
||||
use crate::api::model::{BoxedProviderStream, ProviderStreamFactoryResponse};
|
||||
use crate::model::{AppConfig, ReverseProxyDisabledHeaderConfig};
|
||||
use crate::model::{ReverseProxyDisabledHeaderConfig};
|
||||
use crate::tools::atomic_once_flag::AtomicOnceFlag;
|
||||
use crate::utils::debug_if_enabled;
|
||||
use crate::utils::request::{classify_content_type, get_request_headers, MimeCategory};
|
||||
@@ -16,6 +16,7 @@ use reqwest::StatusCode;
|
||||
use shared::model::{PlaylistItemType, DEFAULT_USER_AGENT};
|
||||
use shared::utils::{filter_request_header, sanitize_sensitive_info};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -29,6 +30,7 @@ const ERR_MAX_RETRY_COUNT: u32 = 5;
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderStreamFactoryOptions {
|
||||
addr: SocketAddr,
|
||||
// item_type: PlaylistItemType,
|
||||
reconnect_enabled: bool,
|
||||
force_reconnect_secs: u32,
|
||||
@@ -43,7 +45,9 @@ pub struct ProviderStreamFactoryOptions {
|
||||
}
|
||||
|
||||
impl ProviderStreamFactoryOptions {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
addr: SocketAddr,
|
||||
item_type: PlaylistItemType,
|
||||
share_stream: bool,
|
||||
stream_options: &StreamOptions,
|
||||
@@ -71,6 +75,7 @@ impl ProviderStreamFactoryOptions {
|
||||
|
||||
Self {
|
||||
// item_type,
|
||||
addr,
|
||||
reconnect_enabled: stream_options.stream_retry,
|
||||
force_reconnect_secs: stream_options.stream_force_retry_secs,
|
||||
pipe_stream: stream_options.pipe_provider_stream,
|
||||
@@ -257,11 +262,11 @@ fn prepare_client(
|
||||
}
|
||||
|
||||
async fn provider_stream_request(
|
||||
cfg: &AppConfig,
|
||||
request_client: Arc<reqwest::Client>,
|
||||
app_state: &Arc<AppState>,
|
||||
request_client: &Arc<reqwest::Client>,
|
||||
stream_options: &ProviderStreamFactoryOptions,
|
||||
) -> Result<Option<ProviderStreamFactoryResponse>, StatusCode> {
|
||||
let (client, _partial_content) = prepare_client(&request_client, stream_options);
|
||||
let (client, _partial_content) = prepare_client(request_client, stream_options);
|
||||
match client.send().await {
|
||||
Ok(mut response) => {
|
||||
let status = response.status();
|
||||
@@ -286,6 +291,7 @@ async fn provider_stream_request(
|
||||
response_headers,
|
||||
response.status(),
|
||||
Some(response.url().clone()),
|
||||
None,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -316,17 +322,7 @@ async fn provider_stream_request(
|
||||
| StatusCode::UNAUTHORIZED
|
||||
| StatusCode::METHOD_NOT_ALLOWED
|
||||
| StatusCode::BAD_REQUEST => {
|
||||
if let (Some(boxed_provider_stream), response_info) =
|
||||
create_channel_unavailable_stream(
|
||||
cfg,
|
||||
&get_response_headers(stream_options.get_headers()),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
)
|
||||
{
|
||||
Ok(Some((boxed_provider_stream, response_info)))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
handle_channel_unavailable_stream(app_state, stream_options).await
|
||||
}
|
||||
_ => Err(status),
|
||||
};
|
||||
@@ -338,17 +334,7 @@ async fn provider_stream_request(
|
||||
| StatusCode::BAD_GATEWAY
|
||||
| StatusCode::SERVICE_UNAVAILABLE
|
||||
| StatusCode::GATEWAY_TIMEOUT => {
|
||||
if let (Some(boxed_provider_stream), response_info) =
|
||||
create_channel_unavailable_stream(
|
||||
cfg,
|
||||
&get_response_headers(stream_options.get_headers()),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
)
|
||||
{
|
||||
Ok(Some((boxed_provider_stream, response_info)))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
handle_channel_unavailable_stream(app_state, stream_options).await
|
||||
}
|
||||
_ => Err(status),
|
||||
};
|
||||
@@ -356,22 +342,33 @@ async fn provider_stream_request(
|
||||
Err(status)
|
||||
}
|
||||
Err(_err) => {
|
||||
if let (Some(boxed_provider_stream), response_info) = create_channel_unavailable_stream(
|
||||
cfg,
|
||||
&get_response_headers(stream_options.get_headers()),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
) {
|
||||
Ok(Some((boxed_provider_stream, response_info)))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
handle_channel_unavailable_stream(app_state, stream_options).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_channel_unavailable_stream(app_state: &Arc<AppState>,
|
||||
stream_options: &ProviderStreamFactoryOptions
|
||||
) -> Result<Option<ProviderStreamFactoryResponse>, StatusCode> {
|
||||
app_state.connection_manager.update_stream_detail(&stream_options.addr, CustomVideoStreamType::ChannelUnavailable).await;
|
||||
app_state.connection_manager.release_provider_connection(&stream_options.addr).await;
|
||||
|
||||
if let (Some(boxed_provider_stream), response_info) =
|
||||
create_channel_unavailable_stream(
|
||||
&app_state.app_config,
|
||||
&get_response_headers(stream_options.get_headers()),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
)
|
||||
{
|
||||
Ok(Some((boxed_provider_stream, response_info)))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_provider_stream(
|
||||
cfg: &AppConfig,
|
||||
client: Arc<reqwest::Client>,
|
||||
app_state: &Arc<AppState>,
|
||||
client: &Arc<reqwest::Client>,
|
||||
stream_options: &ProviderStreamFactoryOptions,
|
||||
) -> Result<Option<ProviderStreamFactoryResponse>, StatusCode> {
|
||||
let url = stream_options.get_url();
|
||||
@@ -380,7 +377,7 @@ async fn get_provider_stream(
|
||||
let mut connect_err: u32 = 1;
|
||||
|
||||
while stream_options.should_continue() {
|
||||
match provider_stream_request(cfg, Arc::clone(&client), stream_options).await {
|
||||
match provider_stream_request(app_state, client, stream_options).await {
|
||||
Ok(Some(stream_response)) => {
|
||||
return Ok(Some(stream_response));
|
||||
}
|
||||
@@ -443,8 +440,8 @@ async fn get_provider_stream(
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn create_provider_stream(
|
||||
cfg: Arc<AppConfig>,
|
||||
client: Arc<reqwest::Client>,
|
||||
app_state: &Arc<AppState>,
|
||||
client: &Arc<reqwest::Client>,
|
||||
stream_options: ProviderStreamFactoryOptions,
|
||||
) -> Option<ProviderStreamFactoryResponse> {
|
||||
let client_stream_factory = |stream, reconnect_flag, range_cnt| {
|
||||
@@ -471,9 +468,9 @@ pub async fn create_provider_stream(
|
||||
.boxed()
|
||||
};
|
||||
|
||||
match get_provider_stream(&cfg, Arc::clone(&client), &stream_options).await {
|
||||
match get_provider_stream(app_state, client, &stream_options).await {
|
||||
Ok(Some((init_stream, info))) => {
|
||||
let is_media_stream_or_not_piped = if let Some((headers, _, _)) = &info {
|
||||
let is_media_stream_or_not_piped = if let Some((headers, _, _, _custom_video_type)) = &info {
|
||||
// if it is piped or no video stream, then we don't reconnect
|
||||
!stream_options.pipe_stream && classify_content_type(headers) == MimeCategory::Video
|
||||
} else {
|
||||
@@ -485,22 +482,24 @@ pub async fn create_provider_stream(
|
||||
let continue_client_signal = Arc::clone(&continue_signal);
|
||||
let continue_streaming_signal = continue_client_signal.clone();
|
||||
let stream_options_provider = stream_options.clone();
|
||||
let config = Arc::clone(&cfg);
|
||||
let app_state_clone = Arc::clone(app_state);
|
||||
let client = Arc::clone(client);
|
||||
let unfold: BoxedProviderStream = stream::unfold((), move |()| {
|
||||
let client = Arc::clone(&client);
|
||||
let stream_opts = stream_options_provider.clone();
|
||||
let continue_streaming = continue_streaming_signal.clone();
|
||||
let config_clone = Arc::clone(&config);
|
||||
let app_state_clone = Arc::clone(&app_state_clone);
|
||||
async move {
|
||||
if continue_streaming.is_active() {
|
||||
match get_provider_stream(&config_clone, client, &stream_opts).await {
|
||||
match get_provider_stream(&app_state_clone, &client, &stream_opts).await {
|
||||
Ok(Some((stream, _info))) => Some((stream, ())),
|
||||
Ok(None) => None,
|
||||
Err(status) => {
|
||||
app_state_clone.connection_manager.release_provider_connection(&stream_opts.addr).await;
|
||||
continue_streaming.notify();
|
||||
if let (Some(boxed_provider_stream), _response_info) =
|
||||
create_channel_unavailable_stream(
|
||||
&config_clone,
|
||||
&app_state_clone.app_config,
|
||||
&get_response_headers(stream_opts.get_headers()),
|
||||
status,
|
||||
)
|
||||
@@ -540,8 +539,9 @@ pub async fn create_provider_stream(
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(status) => {
|
||||
app_state.connection_manager.release_provider_connection(&stream_options.addr).await;
|
||||
if let (Some(boxed_provider_stream), response_info) = create_channel_unavailable_stream(
|
||||
&cfg,
|
||||
&app_state.app_config,
|
||||
&get_response_headers(stream_options.get_headers()),
|
||||
status,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::api::model::{ActiveProviderManager, ProviderHandle, StreamError};
|
||||
use crate::model::Config;
|
||||
use crate::utils::debug_if_enabled;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::BoxStream;
|
||||
@@ -22,8 +23,7 @@ use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// TODO make this configurable
|
||||
const MIN_SHARED_BUFFER_SIZE: usize = 1024 * 1024 * 12; // 12 MB
|
||||
const DEFAULT_SHARED_BUFFER_SIZE_BYTES: usize = 1024 * 1024 * 12; // 12 MB
|
||||
|
||||
const YIELD_COUNTER: usize = 200;
|
||||
|
||||
@@ -49,6 +49,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_min_burst_buffer_bytes(config: &Config) -> usize {
|
||||
config
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
.and_then(|rp| rp.stream.as_ref())
|
||||
.and_then(|stream| usize::try_from(stream.shared_burst_buffer_mb.saturating_mul(1024 * 1024)).ok())
|
||||
.unwrap_or(DEFAULT_SHARED_BUFFER_SIZE_BYTES)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
fn convert_stream(stream: BoxStream<Bytes>) -> BoxStream<Result<Bytes, StreamError>> {
|
||||
ReceiverStreamWrapper { stream }.boxed()
|
||||
}
|
||||
@@ -129,10 +139,10 @@ pub struct SharedStreamState {
|
||||
|
||||
impl SharedStreamState {
|
||||
fn new(headers: Vec<(String, String)>, buf_size: usize,
|
||||
provider_guard: Option<ProviderHandle>) -> Self {
|
||||
provider_guard: Option<ProviderHandle>, min_burst_buffer_size: usize) -> Self {
|
||||
let (broadcaster, _) = tokio::sync::broadcast::channel(buf_size);
|
||||
// TODO channel size versus byte size, channels are chunk sized, burst_buffer byte sized
|
||||
let burst_buffer_size_in_bytes = MIN_SHARED_BUFFER_SIZE.max(buf_size * 1024 * 12);
|
||||
let burst_buffer_size_in_bytes = min_burst_buffer_size.max(buf_size * 1024 * 12);
|
||||
Self {
|
||||
headers,
|
||||
buf_size,
|
||||
@@ -393,7 +403,9 @@ impl SharedStreamManager {
|
||||
E: std::fmt::Debug + Send,
|
||||
{
|
||||
let buf_size = CHANNEL_SIZE.max(buffer_size);
|
||||
let shared_state = Arc::new(SharedStreamState::new(headers, buf_size, provider_handle));
|
||||
let config = app_state.app_config.config.load();
|
||||
let min_buffer_bytes = resolve_min_burst_buffer_bytes(&config);
|
||||
let shared_state = Arc::new(SharedStreamState::new(headers, buf_size, provider_handle, min_buffer_bytes));
|
||||
app_state.shared_stream_manager.register(addr, stream_url, Arc::clone(&shared_state)).await;
|
||||
app_state.active_provider.make_shared_connection(addr, stream_url).await;
|
||||
let subscribed_stream = Self::subscribe_shared_stream(app_state, stream_url, addr).await;
|
||||
|
||||
@@ -136,6 +136,7 @@ where
|
||||
let mut addr_close_rx = connection_manager_clone.get_close_connection_channel();
|
||||
|
||||
trace!("Connection opened: {addr}");
|
||||
connection_manager.add_connection(&addr).await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
||||
+7
-7
@@ -106,15 +106,15 @@ fn main() {
|
||||
if let Some(bts) = BUILD_TIMESTAMP.to_string().parse::<DateTime<Utc>>().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).unwrap_or_else(|err| exit!("{}", err));
|
||||
print_info(&app_config);
|
||||
|
||||
let sources = <Arc<ArcSwap<SourcesConfig>> as Access<SourcesConfig>>::load(&app_config.sources);
|
||||
let targets = sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err));
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let () = rt.block_on(async {
|
||||
|
||||
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 = <Arc<ArcSwap<SourcesConfig>> as Access<SourcesConfig>>::load(&app_config.sources);
|
||||
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;
|
||||
} else {
|
||||
|
||||
@@ -100,14 +100,14 @@ impl ApiProxyConfig {
|
||||
// we have the option to store user in the config file or in the user_db
|
||||
// When we switch from one to other we need to migrate the existing data.
|
||||
/// # Panics
|
||||
pub fn migrate_api_user(&mut self, cfg: &AppConfig, errors: &mut Vec<String>) {
|
||||
pub async fn migrate_api_user(&mut self, cfg: &AppConfig, errors: &mut Vec<String>) {
|
||||
let paths = <Arc<ArcSwap<ConfigPaths>> as Access<ConfigPaths>>::load(&cfg.paths);
|
||||
let api_proxy_file = paths.api_proxy_file_path.as_str();
|
||||
if self.use_user_db {
|
||||
// we have user defined in config file.
|
||||
// we migrate them to the db and delete them from the config file
|
||||
if !&self.user.is_empty() {
|
||||
if let Err(err) = merge_api_user(cfg, &self.user) {
|
||||
if let Err(err) = merge_api_user(cfg, &self.user).await {
|
||||
errors.push(err.to_string());
|
||||
} else {
|
||||
let config = <Arc<ArcSwap<Config>> as Access<Config>>::load(&cfg.config);
|
||||
@@ -118,7 +118,7 @@ impl ApiProxyConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
match load_api_user(cfg) {
|
||||
match load_api_user(cfg).await {
|
||||
Ok(users) => {
|
||||
self.user = users;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ impl ApiProxyConfig {
|
||||
if user_db_path.exists() {
|
||||
// we cant have user defined in db file.
|
||||
// we need to load them and save them into the config file
|
||||
if let Ok(stored_users) = load_api_user(cfg) {
|
||||
if let Ok(stored_users) = load_api_user(cfg).await {
|
||||
for stored_user in stored_users {
|
||||
if let Some(target_user) = self.user.iter_mut().find(|t| t.target == stored_user.target) {
|
||||
for stored_credential in &stored_user.credentials {
|
||||
@@ -151,7 +151,7 @@ impl ApiProxyConfig {
|
||||
if let Err(err) = save_api_proxy(api_proxy_file, backup_dir.as_ref(), &ApiProxyConfigDto::from(&*self)) {
|
||||
errors.push(format!("Error saving api proxy file: {err}"));
|
||||
} else {
|
||||
backup_api_user_db_file(cfg, &user_db_path);
|
||||
backup_api_user_db_file(cfg, &user_db_path).await;
|
||||
let _ = fs::remove_file(&user_db_path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ impl InputUserInfo {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StagedInput {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
@@ -80,6 +81,7 @@ macros::from_impl!(StagedInput);
|
||||
impl From<&StagedInputDto> for StagedInput {
|
||||
fn from(dto: &StagedInputDto) -> Self {
|
||||
Self {
|
||||
name: dto.name.clone(),
|
||||
input_type: dto.input_type,
|
||||
url: dto.url.clone(),
|
||||
username: dto.username.clone(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use shared::model::{ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto};
|
||||
use crate::model::config::cache::CacheConfig;
|
||||
use crate::model::{macros, GeoIpConfig, RateLimitConfig, StreamConfig};
|
||||
use shared::model::{ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto};
|
||||
use shared::utils::{default_resource_retry_attempts, default_resource_retry_backoff_ms, default_resource_retry_backoff_multiplier};
|
||||
use std::cmp::max;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReverseProxyDisabledHeaderConfig {
|
||||
@@ -24,9 +26,76 @@ impl ReverseProxyDisabledHeaderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceRetryConfig {
|
||||
pub max_attempts: u32,
|
||||
pub backoff_millis: u64,
|
||||
pub backoff_multiplier: f64,
|
||||
}
|
||||
|
||||
impl Default for ResourceRetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: default_resource_retry_attempts(),
|
||||
backoff_millis: default_resource_retry_backoff_ms(),
|
||||
backoff_multiplier: default_resource_retry_backoff_multiplier(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceRetryConfig {
|
||||
pub fn get_retry_values(&self) -> (u32, u64, f64) {
|
||||
(
|
||||
max(1, self.max_attempts),
|
||||
self.backoff_millis.max(1),
|
||||
if self.backoff_multiplier.is_finite() {
|
||||
self.backoff_multiplier.max(1.0)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_default_retry_values() -> (u32, u64, f64) {
|
||||
(
|
||||
default_resource_retry_attempts(),
|
||||
default_resource_retry_backoff_ms(),
|
||||
default_resource_retry_backoff_multiplier(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
macros::from_impl!(ResourceRetryConfig);
|
||||
|
||||
impl From<&ResourceRetryConfigDto> for ResourceRetryConfig {
|
||||
fn from(dto: &ResourceRetryConfigDto) -> Self {
|
||||
let multiplier = if dto.backoff_multiplier.is_finite() {
|
||||
dto.backoff_multiplier.max(1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
Self {
|
||||
max_attempts: dto.max_attempts,
|
||||
backoff_millis: dto.backoff_millis,
|
||||
backoff_multiplier: multiplier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ResourceRetryConfig> for ResourceRetryConfigDto {
|
||||
fn from(cfg: &ResourceRetryConfig) -> Self {
|
||||
Self {
|
||||
max_attempts: cfg.max_attempts,
|
||||
backoff_millis: cfg.backoff_millis,
|
||||
backoff_multiplier: cfg.backoff_multiplier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReverseProxyConfig {
|
||||
pub resource_rewrite_disabled: bool,
|
||||
pub resource_retry: ResourceRetryConfig,
|
||||
pub disabled_header: Option<ReverseProxyDisabledHeaderConfig>,
|
||||
pub stream: Option<StreamConfig>,
|
||||
pub cache: Option<CacheConfig>,
|
||||
@@ -40,6 +109,10 @@ impl From<&ReverseProxyConfigDto> for ReverseProxyConfig {
|
||||
fn from(dto: &ReverseProxyConfigDto) -> Self {
|
||||
Self {
|
||||
resource_rewrite_disabled: dto.resource_rewrite_disabled,
|
||||
resource_retry: dto
|
||||
.resource_retry
|
||||
.as_ref()
|
||||
.map_or_else(ResourceRetryConfig::default, Into::into),
|
||||
disabled_header: dto.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfig {
|
||||
referer_header: d.referer_header,
|
||||
x_header: d.x_header,
|
||||
@@ -57,6 +130,7 @@ impl From<&ReverseProxyConfig> for ReverseProxyConfigDto {
|
||||
fn from(instance: &ReverseProxyConfig) -> Self {
|
||||
Self {
|
||||
resource_rewrite_disabled: instance.resource_rewrite_disabled,
|
||||
resource_retry: Some(ResourceRetryConfigDto::from(&instance.resource_retry)),
|
||||
disabled_header: instance.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfigDto {
|
||||
referer_header: d.referer_header,
|
||||
x_header: d.x_header,
|
||||
|
||||
@@ -37,6 +37,7 @@ pub struct StreamConfig {
|
||||
pub forced_retry_interval_secs: u32,
|
||||
pub throttle_str: Option<String>,
|
||||
pub throttle_kbps: u64,
|
||||
pub shared_burst_buffer_mb: u64,
|
||||
}
|
||||
|
||||
macros::from_impl!(StreamConfig);
|
||||
@@ -50,6 +51,7 @@ impl From<&StreamConfigDto> for StreamConfig {
|
||||
forced_retry_interval_secs: dto.forced_retry_interval_secs,
|
||||
throttle_str: dto.throttle.clone(),
|
||||
throttle_kbps: dto.throttle.as_ref().map_or(0u64, |throttle| parse_to_kbps(throttle).unwrap_or(0u64)),
|
||||
shared_burst_buffer_mb: dto.shared_burst_buffer_mb,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +66,7 @@ impl From<&StreamConfig> for StreamConfigDto {
|
||||
forced_retry_interval_secs: instance.forced_retry_interval_secs,
|
||||
throttle: instance.throttle_str.clone(),
|
||||
throttle_kbps: instance.throttle_kbps,
|
||||
shared_burst_buffer_mb: instance.shared_burst_buffer_mb,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,4 +77,4 @@ pub struct CustomStreamResponse {
|
||||
pub user_connections_exhausted: Option<TransportStreamBuffer>, // user has no more connections
|
||||
pub provider_connections_exhausted: Option<TransportStreamBuffer>, // provider limit reached, has no more connections
|
||||
pub user_account_expired: Option<TransportStreamBuffer>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::model::{ConfigInput, StagedInput};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InputSource {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
@@ -14,6 +15,7 @@ pub struct InputSource {
|
||||
impl InputSource {
|
||||
pub fn with_url(&self, url: String) -> Self {
|
||||
Self {
|
||||
name: self.name.clone(),
|
||||
url,
|
||||
username: self.username.clone(),
|
||||
password: self.password.clone(),
|
||||
@@ -28,6 +30,7 @@ macro_rules! impl_input_source_from {
|
||||
impl From<&$input_type> for InputSource {
|
||||
fn from(input: &$input_type) -> Self {
|
||||
Self {
|
||||
name: input.name.clone(),
|
||||
url: input.url.clone(),
|
||||
username: input.username.clone(),
|
||||
password: input.password.clone(),
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn parse_xtream(input: &ConfigInput,
|
||||
streams: &Value) -> Result<Option<Vec<PlaylistGroup>>, TuliproxError> {
|
||||
match map_to_xtream_category(categories) {
|
||||
Ok(xtream_categories) => {
|
||||
let input_name = Arc::new(input.name.clone());
|
||||
let input_name = input.name.clone();
|
||||
let url = input.url.as_str();
|
||||
let username = input.username.as_ref().map_or("", |v| v);
|
||||
let password = input.password.as_ref().map_or("", |v| v);
|
||||
@@ -173,7 +173,7 @@ pub fn parse_xtream(input: &ConfigInput,
|
||||
xtream_cluster,
|
||||
additional_properties: stream.get_additional_properties(),
|
||||
category_id: 0,
|
||||
input_name: input_name.to_string(),
|
||||
input_name: input_name.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ where
|
||||
};
|
||||
|
||||
{
|
||||
let file_lock = cfg.file_locks.read_lock(&file_path);
|
||||
let file_lock = cfg.file_locks.read_lock(&file_path).await;
|
||||
if let Ok(info_records) = BPlusTree::<u32, V>::load(&file_path) {
|
||||
info_records.iter().for_each(|(provider_id, record)| {
|
||||
processed_info_ids.insert(*provider_id, extract_ts(record));
|
||||
|
||||
@@ -142,7 +142,7 @@ async fn process_series_info(
|
||||
return result;
|
||||
};
|
||||
|
||||
let _file_lock = app_config.file_locks.read_lock(&info_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&info_path).await;
|
||||
|
||||
// Contains the Series Info with episode listing
|
||||
let Ok(mut info_reader) = IndexedDocumentReader::<u32, String>::new(&info_path, &idx_path) else { return result; };
|
||||
|
||||
@@ -148,7 +148,7 @@ pub async fn playlist_resolve_vod(app_config: &AppConfig, client: Arc<reqwest::C
|
||||
|
||||
for pli in vod_info_iter {
|
||||
if let Some(provider_id) = pli.header.get_provider_id() {
|
||||
if let Some(content) = xtream_get_input_info(app_config, fpl.input, provider_id, XtreamCluster::Video) {
|
||||
if let Some(content) = xtream_get_input_info(app_config, fpl.input, provider_id, XtreamCluster::Video).await {
|
||||
// Add the "info" section to the playlist item additional properties.
|
||||
pli.header.additional_properties = from_str::<Map<String, Value>>(&content).ok().and_then(|info_doc| {
|
||||
info_doc.get("info").cloned().map(|info_content| {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub async fn m3u_write_playlist(cfg: &AppConfig, target: &ConfigTarget, target_o
|
||||
|
||||
persist_m3u_playlist_as_text(&cfg.config.load(), target, target_output, &m3u_playlist);
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&m3u_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&m3u_path).await;
|
||||
match IndexedDocumentWriter::new(m3u_path.clone(), idx_path) {
|
||||
Ok(mut writer) => {
|
||||
for m3u in m3u_playlist {
|
||||
@@ -105,7 +105,7 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta
|
||||
let cfg: &AppConfig = &app_state.app_config;
|
||||
let target_path = get_target_storage_path(&cfg.config.load(), target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?;
|
||||
let (m3u_path, idx_path) = m3u_get_file_paths(&target_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&m3u_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&m3u_path).await;
|
||||
IndexedDocumentDirectAccess::read_indexed_item::<u32, M3uPlaylistItem>(&m3u_path, &idx_path, &stream_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,12 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr
|
||||
for output in &target.output {
|
||||
match output {
|
||||
TargetOutput::Xtream(_) => {
|
||||
if let Ok(storage) = load_xtream_target_storage(app_config, target) {
|
||||
if let Ok(storage) = load_xtream_target_storage(app_config, target).await {
|
||||
playlist_storage.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
}
|
||||
},
|
||||
TargetOutput::M3u(_) => {
|
||||
if let Ok(storage) = load_m3u_target_storage(app_config, target) {
|
||||
if let Ok(storage) = load_m3u_target_storage(app_config, target).await {
|
||||
playlist_storage.cache_playlist(&target.name, PlaylistStorage::M3uPlaylist(Box::new(storage))).await;
|
||||
}
|
||||
},
|
||||
@@ -123,9 +123,9 @@ pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (Targ
|
||||
}
|
||||
|
||||
|
||||
fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, target: &ConfigTarget) -> Result<BPlusTree<u32, VirtualIdRecord>, TuliproxError> {
|
||||
async fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, target: &ConfigTarget) -> Result<BPlusTree<u32, VirtualIdRecord>, TuliproxError> {
|
||||
let target_id_mapping_file = get_target_id_mapping_file(target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
|
||||
BPlusTree::<u32, VirtualIdRecord>::load(&target_id_mapping_file).map_err(|err|
|
||||
create_tuliprox_error!(
|
||||
@@ -134,9 +134,9 @@ fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, ta
|
||||
))
|
||||
}
|
||||
|
||||
fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, cluster: XtreamCluster) -> BPlusTree<u32, XtreamPlaylistItem> {
|
||||
async fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, cluster: XtreamCluster) -> BPlusTree<u32, XtreamPlaylistItem> {
|
||||
let (main_path, index_path) = xtream_get_file_paths(storage_path, cluster);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path).await;
|
||||
let mut tree = BPlusTree::<u32, XtreamPlaylistItem>::new();
|
||||
if let Ok(reader) = IndexedDocumentIterator::<u32, XtreamPlaylistItem>::new(&main_path, &index_path) {
|
||||
for (doc, _has_next) in reader {
|
||||
@@ -146,7 +146,7 @@ fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, clu
|
||||
tree
|
||||
}
|
||||
|
||||
fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result<PlaylistXtreamStorage, TuliproxError> {
|
||||
async fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result<PlaylistXtreamStorage, TuliproxError> {
|
||||
let config = app_config.config.load();
|
||||
let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(||
|
||||
create_tuliprox_error!(
|
||||
@@ -159,10 +159,10 @@ fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) ->
|
||||
TuliproxErrorKind::Info,
|
||||
"Could not find path for target {} xtream output", &target.name))?;
|
||||
|
||||
let target_id_mapping = load_target_id_mapping_as_tree(app_config, &target_path, target)?;
|
||||
let live_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live);
|
||||
let vod_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video);
|
||||
let series_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series);
|
||||
let target_id_mapping = load_target_id_mapping_as_tree(app_config, &target_path, target).await?;
|
||||
let live_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live).await;
|
||||
let vod_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video).await;
|
||||
let series_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series).await;
|
||||
|
||||
Ok(PlaylistXtreamStorage {
|
||||
id_mapping: target_id_mapping,
|
||||
@@ -172,7 +172,7 @@ fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result<PlaylistM3uStorage, TuliproxError> {
|
||||
async fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result<PlaylistM3uStorage, TuliproxError> {
|
||||
let config = app_config.config.load();
|
||||
let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(||
|
||||
create_tuliprox_error!(
|
||||
@@ -181,7 +181,7 @@ fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Res
|
||||
))?;
|
||||
|
||||
let (main_path, index_path) = m3u_get_file_paths(&target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path).await;
|
||||
let mut tree = BPlusTree::<u32, M3uPlaylistItem>::new();
|
||||
if let Ok(reader) = IndexedDocumentIterator::<u32, M3uPlaylistItem>::new(&main_path, &index_path) {
|
||||
for (doc, _has_next) in reader {
|
||||
@@ -207,12 +207,12 @@ pub async fn load_target_into_memory_cache(app_state: &AppState, target: &Arc<Co
|
||||
for output in &target.output {
|
||||
match output {
|
||||
TargetOutput::Xtream(_) => {
|
||||
if let Ok(storage) = load_xtream_target_storage(&app_state.app_config, target) {
|
||||
if let Ok(storage) = load_xtream_target_storage(&app_state.app_config, target).await {
|
||||
app_state.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
}
|
||||
}
|
||||
TargetOutput::M3u(_) => {
|
||||
if let Ok(storage) = load_m3u_target_storage(&app_state.app_config, target) {
|
||||
if let Ok(storage) = load_m3u_target_storage(&app_state.app_config, target).await {
|
||||
app_state.cache_playlist(&target.name, PlaylistStorage::M3uPlaylist(Box::new(storage))).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::repository::bplustree::BPlusTree;
|
||||
use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path};
|
||||
use crate::repository::storage_const;
|
||||
use crate::repository::xtream_repository::{xtream_get_record_file_path, InputVodInfoRecord};
|
||||
use shared::utils::{extract_extension_from_url, hash_bytes, truncate_string, ExportStyleConfig, CONSTANTS};
|
||||
use crate::utils::{truncate_filename, FileReadGuard};
|
||||
use shared::utils::{extract_extension_from_url, hash_bytes, hash_string_as_hex, truncate_string, ExportStyleConfig, CONSTANTS};
|
||||
use crate::utils::{normalize_string_path, truncate_filename, FileReadGuard};
|
||||
use chrono::Datelike;
|
||||
use filetime::{set_file_times, FileTime};
|
||||
use log::{error, trace};
|
||||
@@ -242,8 +242,8 @@ async fn get_tmdb_value(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strm_get_file_paths(target_path: &Path) -> PathBuf {
|
||||
target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_STRM, storage_const::FILE_SUFFIX_DB)))
|
||||
pub fn strm_get_file_paths(file_prefix: &str, target_path: &Path) -> PathBuf {
|
||||
target_path.join(PathBuf::from(format!("{file_prefix}_{}.{}", storage_const::FILE_STRM, storage_const::FILE_SUFFIX_DB)))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -837,12 +837,14 @@ pub async fn write_strm_playlist(
|
||||
};
|
||||
|
||||
let user_and_server_info = get_credentials_and_server_info(app_config, target_output.username.as_ref());
|
||||
let normalized_dir = normalize_string_path(&target_output.directory);
|
||||
let strm_file_prefix = hash_string_as_hex(&normalized_dir);
|
||||
let strm_index_path =
|
||||
strm_get_file_paths(&ensure_target_storage_path(&config, target.name.as_str())?);
|
||||
strm_get_file_paths(&strm_file_prefix, &ensure_target_storage_path(&config, target.name.as_str())?);
|
||||
let existing_strm = {
|
||||
let _file_lock = app_config
|
||||
.file_locks
|
||||
.read_lock(&strm_index_path);
|
||||
.read_lock(&strm_index_path).await;
|
||||
read_strm_file_index(&strm_index_path)
|
||||
.await
|
||||
.unwrap_or_else(|_| HashSet::with_capacity(4096))
|
||||
@@ -926,7 +928,7 @@ async fn write_strm_index_file(
|
||||
) -> Result<(), String> {
|
||||
let _file_lock = cfg
|
||||
.file_locks
|
||||
.write_lock(index_file_path);
|
||||
.write_lock(index_file_path).await;
|
||||
let file = File::create(index_file_path)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to create strm index file: {} {err}", index_file_path.display()))?;
|
||||
|
||||
@@ -120,23 +120,23 @@ fn add_target_user_to_user_tree(target_users: &[TargetUser], user_tree: &mut BPl
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result<u64, Error> {
|
||||
pub async fn merge_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result<u64, Error> {
|
||||
let path = get_api_user_db_path(cfg);
|
||||
let lock = cfg.file_locks.read_lock(&path);
|
||||
let lock = cfg.file_locks.read_lock(&path).await;
|
||||
let mut user_tree: BPlusTree<String, StoredProxyUserCredentials> = BPlusTree::load(&path).unwrap_or_else(|_| BPlusTree::new());
|
||||
drop(lock);
|
||||
add_target_user_to_user_tree(target_users, &mut user_tree);
|
||||
let _lock = cfg.file_locks.write_lock(&path);
|
||||
let _lock = cfg.file_locks.write_lock(&path).await;
|
||||
user_tree.store(&path)
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Will panic if `backup_dir` is not given
|
||||
pub fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) {
|
||||
pub async fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) {
|
||||
if let Some(backup_dir) = cfg.config.load().backup_dir.as_ref() {
|
||||
let backup_path = PathBuf::from(backup_dir).join(format!("{}_{}", storage_const::API_USER_DB_FILE, Local::now().format("%Y%m%d_%H%M%S")));
|
||||
let _lock = cfg.file_locks.read_lock(path);
|
||||
let _lock = cfg.file_locks.read_lock(path).await;
|
||||
match std::fs::copy(path, &backup_path) {
|
||||
Ok(_) => {}
|
||||
Err(err) => { error!("Could not backup file {}:{}", &backup_path.to_str().unwrap_or("?"), err) }
|
||||
@@ -144,19 +144,19 @@ pub fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result<u64, Error> {
|
||||
pub async fn store_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result<u64, Error> {
|
||||
let mut user_tree = BPlusTree::<String, StoredProxyUserCredentials>::new();
|
||||
add_target_user_to_user_tree(target_users, &mut user_tree);
|
||||
let path = get_api_user_db_path(cfg);
|
||||
backup_api_user_db_file(cfg, &path);
|
||||
let _lock = cfg.file_locks.write_lock(&path);
|
||||
backup_api_user_db_file(cfg, &path).await;
|
||||
let _lock = cfg.file_locks.write_lock(&path).await;
|
||||
user_tree.store(&path)
|
||||
}
|
||||
|
||||
// TODO remove me if we get stable on user_db
|
||||
pub fn load_api_user_deprecated(cfg: &AppConfig) -> Result<Vec<TargetUser>, Error> {
|
||||
pub async fn load_api_user_deprecated(cfg: &AppConfig) -> Result<Vec<TargetUser>, Error> {
|
||||
let path = get_api_user_db_path(cfg);
|
||||
let lock = cfg.file_locks.read_lock(&path);
|
||||
let lock = cfg.file_locks.read_lock(&path).await;
|
||||
let user_tree = BPlusTree::<String, StoredProxyUserCredentialsDeprecated>::load(&path)?;
|
||||
drop(lock);
|
||||
let mut target_users: HashMap<String, TargetUser> = HashMap::new();
|
||||
@@ -180,10 +180,10 @@ pub fn load_api_user_deprecated(cfg: &AppConfig) -> Result<Vec<TargetUser>, Erro
|
||||
}
|
||||
|
||||
|
||||
pub fn load_api_user(cfg: &AppConfig) -> Result<Vec<TargetUser>, Error> {
|
||||
pub async fn load_api_user(cfg: &AppConfig) -> Result<Vec<TargetUser>, Error> {
|
||||
let path = get_api_user_db_path(cfg);
|
||||
let lock = cfg.file_locks.read_lock(&path);
|
||||
let Ok(user_tree) = BPlusTree::<String, StoredProxyUserCredentials>::load(&path) else { return load_api_user_deprecated(cfg) };
|
||||
let lock = cfg.file_locks.read_lock(&path).await;
|
||||
let Ok(user_tree) = BPlusTree::<String, StoredProxyUserCredentials>::load(&path) else { return load_api_user_deprecated(cfg).await };
|
||||
drop(lock);
|
||||
let mut target_users: HashMap<String, TargetUser> = HashMap::new();
|
||||
for (_uname, stored_user) in &user_tree {
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn xtream_get_record_file_path(storage_path: &Path, item_type: PlaylistItemT
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn write_playlists_to_file(
|
||||
async fn write_playlists_to_file(
|
||||
cfg: &AppConfig,
|
||||
storage_path: &Path,
|
||||
collections: Vec<(XtreamCluster, &[&mut PlaylistItem])>,
|
||||
@@ -109,7 +109,7 @@ fn write_playlists_to_file(
|
||||
for (cluster, playlist) in collections {
|
||||
let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, cluster);
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&xtream_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&xtream_path).await;
|
||||
match IndexedDocumentWriter::new(xtream_path.clone(), idx_path) {
|
||||
Ok(mut writer) => {
|
||||
for item in playlist {
|
||||
@@ -183,7 +183,7 @@ pub fn xtream_get_file_paths(storage_path: &Path, cluster: XtreamCluster) -> (Pa
|
||||
// xtream_get_file_paths_for_name(storage_path, storage_const::FILE_SERIES)
|
||||
// }
|
||||
|
||||
fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Result<()> {
|
||||
async fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Result<()> {
|
||||
// Garbage collect series
|
||||
let storage_path = {
|
||||
let cfg = config.config.load();
|
||||
@@ -194,7 +194,7 @@ fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Res
|
||||
XtreamCluster::Series
|
||||
));
|
||||
{
|
||||
let _file_lock = config.file_locks.write_lock(&info_path);
|
||||
let _file_lock = config.file_locks.write_lock(&info_path).await;
|
||||
IndexedDocumentGarbageCollector::<u32>::new(info_path.clone(), idx_path)?.garbage_collect()?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -273,9 +273,9 @@ pub async fn xtream_write_playlist(
|
||||
(XtreamCluster::Video, &vod_col),
|
||||
(XtreamCluster::Series, &series_col),
|
||||
],
|
||||
) {
|
||||
).await {
|
||||
Ok(()) => {
|
||||
if let Err(err) = xtream_garbage_collect(cfg, &target.name) {
|
||||
if let Err(err) = xtream_garbage_collect(cfg, &target.name).await {
|
||||
if err.kind() != ErrorKind::NotFound {
|
||||
errors.push(format!("Garbage collection failed:{err}"));
|
||||
}
|
||||
@@ -311,7 +311,7 @@ pub fn xtream_get_collection_path(
|
||||
Err(str_to_io_error(&format!("Cant find collection: {target_name}/{collection_name}")))
|
||||
}
|
||||
|
||||
fn xtream_read_item_for_stream_id(
|
||||
async fn xtream_read_item_for_stream_id(
|
||||
cfg: &AppConfig,
|
||||
stream_id: u32,
|
||||
storage_path: &Path,
|
||||
@@ -319,19 +319,19 @@ fn xtream_read_item_for_stream_id(
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, cluster);
|
||||
{
|
||||
let _file_lock = cfg.file_locks.read_lock(&xtream_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&xtream_path).await;
|
||||
IndexedDocumentDirectAccess::read_indexed_item::<u32, XtreamPlaylistItem>(&xtream_path, &idx_path, &stream_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn xtream_read_series_item_for_stream_id(
|
||||
async fn xtream_read_series_item_for_stream_id(
|
||||
cfg: &AppConfig,
|
||||
stream_id: u32,
|
||||
storage_path: &Path,
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, XtreamCluster::Series);
|
||||
{
|
||||
let _file_lock = cfg.file_locks.read_lock(&xtream_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&xtream_path).await;
|
||||
IndexedDocumentDirectAccess::read_indexed_item::<u32, XtreamPlaylistItem>(&xtream_path, &idx_path, &stream_id)
|
||||
}
|
||||
}
|
||||
@@ -431,33 +431,33 @@ pub async fn xtream_get_item_for_stream_id(
|
||||
let storage_path = xtream_get_storage_path(&config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {} xtream output", &target.name)))?;
|
||||
{
|
||||
let target_id_mapping_file = get_target_id_mapping_file(&target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
|
||||
let mut target_id_mapping = BPlusTreeQuery::<u32, VirtualIdRecord>::try_new(&target_id_mapping_file).map_err(|err| str_to_io_error(&format!("Could not load id mapping for target {} err:{err}", target.name)))?;
|
||||
let mapping = target_id_mapping.query(&virtual_id).ok_or_else(|| str_to_io_error(&format!("Could not find mapping for target {} and id {}", target.name, virtual_id)))?;
|
||||
let result = match mapping.item_type {
|
||||
PlaylistItemType::SeriesInfo => {
|
||||
xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path)
|
||||
xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path).await
|
||||
}
|
||||
PlaylistItemType::Series => {
|
||||
if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path) {
|
||||
if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path).await {
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = PlaylistItemType::Series;
|
||||
Ok(item)
|
||||
} else {
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series)
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await
|
||||
}
|
||||
}
|
||||
PlaylistItemType::Catchup => {
|
||||
let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?;
|
||||
let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster)?;
|
||||
let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster).await?;
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = PlaylistItemType::Catchup;
|
||||
Ok(item)
|
||||
}
|
||||
_ => {
|
||||
let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?;
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster)
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -475,7 +475,7 @@ pub async fn xtream_load_rewrite_playlist(
|
||||
XtreamPlaylistJsonIterator::new(cluster, config, target, category_id, user).await
|
||||
}
|
||||
|
||||
pub fn xtream_write_series_info(
|
||||
pub async fn xtream_write_series_info(
|
||||
app_config: &AppConfig,
|
||||
target_name: &str,
|
||||
series_info_id: u32,
|
||||
@@ -490,14 +490,14 @@ pub fn xtream_write_series_info(
|
||||
));
|
||||
|
||||
{
|
||||
let _file_lock = app_config.file_locks.write_lock(&info_path);
|
||||
let _file_lock = app_config.file_locks.write_lock(&info_path).await;
|
||||
let mut writer = IndexedDocumentWriter::new_append(info_path.clone(), idx_path)?;
|
||||
writer.write_doc(series_info_id, content).map_err(|_| str_to_io_error(&format!("failed to write xtream series info for target {target_name}")))?;
|
||||
writer.store()?;
|
||||
}
|
||||
{
|
||||
let target_id_mapping_file = get_target_id_mapping_file(&target_path);
|
||||
let _file_lock = app_config.file_locks.write_lock(&target_id_mapping_file);
|
||||
let _file_lock = app_config.file_locks.write_lock(&target_id_mapping_file).await;
|
||||
if let Ok(mut target_id_mapping) = BPlusTreeUpdate::<u32, VirtualIdRecord>::try_new(&target_id_mapping_file) {
|
||||
if let Some(record) = target_id_mapping.query(&series_info_id) {
|
||||
let new_record = record.copy_update_timestamp();
|
||||
@@ -527,20 +527,20 @@ pub async fn xtream_write_vod_info(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xtream_get_series_info_mapping(
|
||||
async fn xtream_get_series_info_mapping(
|
||||
config: &AppConfig,
|
||||
target_name: &str,
|
||||
series_id: u32,
|
||||
) -> Option<VirtualIdRecord> {
|
||||
xtream_get_info_mapping(config, target_name, series_id).filter(|id_record| !id_record.is_expired())
|
||||
xtream_get_info_mapping(config, target_name, series_id).await.filter(|id_record| !id_record.is_expired())
|
||||
}
|
||||
|
||||
fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u32) -> Option<VirtualIdRecord> {
|
||||
async fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u32) -> Option<VirtualIdRecord> {
|
||||
let config = app_config.config.load();
|
||||
let target_path = get_target_storage_path(&config, target_name)?;
|
||||
|
||||
let target_id_mapping_file = get_target_id_mapping_file(&target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
BPlusTreeQuery::<u32, VirtualIdRecord>::try_new(&target_id_mapping_file).map_err(|err| {
|
||||
error!("Could not load id mapping for target {target_name}: {err}");
|
||||
str_to_io_error(&format!("ID mapping load error for target {target_name}"))
|
||||
@@ -548,12 +548,12 @@ fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u
|
||||
}
|
||||
|
||||
// Reads the series info entry if exists
|
||||
pub fn xtream_load_series_info(
|
||||
pub async fn xtream_load_series_info(
|
||||
app_config: &AppConfig,
|
||||
target_name: &str,
|
||||
series_id: u32,
|
||||
) -> Option<String> {
|
||||
xtream_get_series_info_mapping(app_config, target_name, series_id)?;
|
||||
xtream_get_series_info_mapping(app_config, target_name, series_id).await?;
|
||||
let config = app_config.config.load();
|
||||
let storage_path = xtream_get_storage_path(&config, target_name)?;
|
||||
|
||||
@@ -561,7 +561,7 @@ pub fn xtream_load_series_info(
|
||||
|
||||
if info_path.exists() && idx_path.exists() {
|
||||
{
|
||||
let _file_lock = app_config.file_locks.read_lock(&info_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&info_path).await;
|
||||
return match IndexedDocumentDirectAccess::read_indexed_item::<u32, String>(&info_path, &idx_path, &series_id) {
|
||||
Ok(content) => Some(content),
|
||||
Err(err) => {
|
||||
@@ -573,24 +573,24 @@ pub fn xtream_load_series_info(
|
||||
}
|
||||
None
|
||||
}
|
||||
fn xtream_get_vod_info_mapping(
|
||||
async fn xtream_get_vod_info_mapping(
|
||||
config: &AppConfig,
|
||||
target_name: &str,
|
||||
vod_id: u32,
|
||||
) -> Option<VirtualIdRecord> {
|
||||
xtream_get_info_mapping(config, target_name, vod_id)
|
||||
xtream_get_info_mapping(config, target_name, vod_id).await
|
||||
//.filter(|id_record| !id_record.is_expired())
|
||||
}
|
||||
|
||||
// Reads the vod info entry if exists
|
||||
pub fn xtream_load_vod_info(
|
||||
pub async fn xtream_load_vod_info(
|
||||
config: &AppConfig,
|
||||
target_name: &str,
|
||||
vod_id: u32,
|
||||
) -> Option<String> {
|
||||
|
||||
// Check if the entry exists; if not, we don't need to look further.
|
||||
xtream_get_vod_info_mapping(config, target_name, vod_id).as_ref()?;
|
||||
xtream_get_vod_info_mapping(config, target_name, vod_id).await.as_ref()?;
|
||||
// Entry exists, read db entry
|
||||
let target_storage_path = xtream_get_storage_path(&config.config.load(), target_name)?;
|
||||
|
||||
@@ -598,7 +598,7 @@ pub fn xtream_load_vod_info(
|
||||
|
||||
if info_path.exists() && idx_path.exists() {
|
||||
{
|
||||
let _file_lock = config.file_locks.read_lock(&info_path);
|
||||
let _file_lock = config.file_locks.read_lock(&info_path).await;
|
||||
return IndexedDocumentDirectAccess::read_indexed_item::<u32, String>(&info_path, &idx_path, &vod_id).ok();
|
||||
}
|
||||
}
|
||||
@@ -809,11 +809,11 @@ pub async fn write_and_get_xtream_series_info<P>(
|
||||
let mut doc = serde_json::from_str::<Map<String, Value>>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?;
|
||||
let virtual_id = pli_series_info.get_virtual_id();
|
||||
let app_config = &app_state.app_config;
|
||||
xtream_write_series_info(app_config, target.name.as_str(), virtual_id, content).ok();
|
||||
xtream_write_series_info(app_config, target.name.as_str(), virtual_id, content).await.ok();
|
||||
rewrite_xtream_series_info(app_state, target, xtream_output, pli_series_info, user, &mut doc).await
|
||||
}
|
||||
|
||||
pub fn xtream_get_input_info(
|
||||
pub async fn xtream_get_input_info(
|
||||
cfg: &AppConfig,
|
||||
input: &ConfigInput,
|
||||
provider_id: u32,
|
||||
@@ -821,7 +821,7 @@ pub fn xtream_get_input_info(
|
||||
) -> Option<String> {
|
||||
if let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &cfg.config.load().working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster))
|
||||
{
|
||||
let _file_lock = cfg.file_locks.read_lock(&info_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&info_path).await;
|
||||
if let Ok(content) = IndexedDocumentDirectAccess::read_indexed_item::<u32, String>(&info_path, &idx_path, &provider_id) {
|
||||
return Some(content);
|
||||
}
|
||||
@@ -839,7 +839,7 @@ pub async fn xtream_update_input_info_file(
|
||||
match get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) {
|
||||
Ok(Some((info_path, idx_path))) => {
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&info_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&info_path).await;
|
||||
let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read {cluster} info {err}")))?);
|
||||
match IndexedDocumentWriter::<u32>::new_append(info_path.clone(), idx_path) {
|
||||
Ok(mut writer) => {
|
||||
@@ -885,7 +885,7 @@ pub async fn xtream_update_input_vod_record_from_wal_file(
|
||||
.and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?;
|
||||
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path).await;
|
||||
let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read vod wal info {err}")))?);
|
||||
let mut provider_id_bytes = [0u8; 4];
|
||||
let mut tmdb_id_bytes = [0u8; 4];
|
||||
@@ -953,7 +953,7 @@ pub async fn xtream_update_input_series_record_from_wal_file(
|
||||
.map_err(|err| notify_err!(format!("Error accessing storage path: {err}")))
|
||||
.and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?;
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path).await;
|
||||
let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read series wal info {err}")))?);
|
||||
let mut provider_id_bytes = [0u8; 4];
|
||||
let mut ts_bytes = [0u8; 8];
|
||||
@@ -988,7 +988,7 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file(
|
||||
.map_err(|err| notify_err!(format!("Error accessing storage path: {err}")))
|
||||
.and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?;
|
||||
{
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path);
|
||||
let _file_lock = cfg.file_locks.write_lock(&record_path).await;
|
||||
let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read series episode wal info {err}")))?);
|
||||
let mut provider_id_bytes = [0u8; 4];
|
||||
let mut len_bytes = [0u8; 4];
|
||||
|
||||
@@ -43,13 +43,13 @@ pub fn config_file_reader(file: File, resolve_env: bool) -> impl Read
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_api_proxy_config(config: &AppConfig, resolve_env: bool) -> Result<Option<ApiProxyConfig>, TuliproxError> {
|
||||
pub async fn read_api_proxy_config(config: &AppConfig, resolve_env: bool) -> Result<Option<ApiProxyConfig>, TuliproxError> {
|
||||
let paths = <Arc<ArcSwap<ConfigPaths>> as Access<ConfigPaths>>::load(&config.paths);
|
||||
let api_proxy_file_path = paths.api_proxy_file_path.as_str();
|
||||
if let Some(api_proxy_dto) = read_api_proxy_file(api_proxy_file_path, resolve_env)? {
|
||||
let mut errors = vec![];
|
||||
let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto);
|
||||
api_proxy.migrate_api_user(config, &mut errors);
|
||||
api_proxy.migrate_api_user(config, &mut errors).await;
|
||||
if !errors.is_empty() {
|
||||
for error in errors {
|
||||
error!("{error}");
|
||||
@@ -181,7 +181,7 @@ pub fn get_batch_aliases(input_type: InputType, url: &str) -> Result<Option<(Pat
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) -> Result<(), TuliproxError> {
|
||||
pub async fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) -> Result<(), TuliproxError> {
|
||||
let use_user_db = app_config_dto
|
||||
.api_proxy
|
||||
.as_ref()
|
||||
@@ -190,7 +190,7 @@ pub fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig)
|
||||
if use_user_db {
|
||||
let user_db_path = get_api_user_db_path(app_config);
|
||||
if user_db_path.exists() {
|
||||
match load_api_user(app_config) {
|
||||
match load_api_user(app_config).await {
|
||||
Ok(stored_users) => if let Some(api_proxy) = app_config_dto.api_proxy.as_mut() {
|
||||
api_proxy.user.extend(stored_users.iter().map(TargetUserDto::from));
|
||||
},
|
||||
@@ -203,7 +203,7 @@ pub fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_initial_app_config(paths: &mut ConfigPaths,
|
||||
pub async fn read_initial_app_config(paths: &mut ConfigPaths,
|
||||
resolve_env: bool,
|
||||
include_computed: bool,
|
||||
server_mode: bool) -> Result<AppConfig, TuliproxError> {
|
||||
@@ -250,7 +250,7 @@ pub fn read_initial_app_config(paths: &mut ConfigPaths,
|
||||
}
|
||||
|
||||
if server_mode {
|
||||
match read_api_proxy_config(&app_config, resolve_env) {
|
||||
match read_api_proxy_config(&app_config, resolve_env).await {
|
||||
Ok(Some(api_proxy)) => app_config.set_api_proxy(api_proxy)?,
|
||||
Ok(None) => info!("Api-Proxy file: not used"),
|
||||
Err(err) => exit!("{err}"),
|
||||
@@ -279,13 +279,13 @@ pub fn read_api_proxy_file(api_proxy_file: &str, resolve_env: bool) -> Result<Op
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option<ApiProxyConfig> {
|
||||
pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option<ApiProxyConfig> {
|
||||
let paths = <Arc<ArcSwap<ConfigPaths>> as Access<ConfigPaths>>::load(&config.paths);
|
||||
match read_api_proxy_file(paths.api_proxy_file_path.as_str(), resolve_env) {
|
||||
Ok(Some(api_proxy_dto)) => {
|
||||
let mut errors = vec![];
|
||||
let mut api_proxy: ApiProxyConfig = api_proxy_dto.into();
|
||||
api_proxy.migrate_api_user(config, &mut errors);
|
||||
api_proxy.migrate_api_user(config, &mut errors).await;
|
||||
if !errors.is_empty() {
|
||||
for error in errors {
|
||||
error!("{error}");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::{fmt, io};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard};
|
||||
use shared::error::str_to_io_error;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -20,26 +21,22 @@ impl FileLockManager {
|
||||
// Acquires a read lock for the specified file and returns a FileReadGuard.
|
||||
pub async fn read_lock(&self, path: &Path) -> FileReadGuard {
|
||||
let file_lock = self.get_or_create_lock(path).await;
|
||||
let guard = file_lock.read().await;
|
||||
// Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard`
|
||||
FileReadGuard::new(Arc::clone(&file_lock), guard)
|
||||
let guard = Arc::clone(&file_lock).read_owned().await;
|
||||
FileReadGuard::new(guard)
|
||||
}
|
||||
|
||||
// Acquires a write lock for the specified file and returns a FileWriteGuard.
|
||||
pub async fn write_lock(&self, path: &Path) -> FileWriteGuard {
|
||||
let file_lock = self.get_or_create_lock(path).await;
|
||||
let guard = file_lock.write().await;
|
||||
// Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard`
|
||||
FileWriteGuard::new(Arc::clone(&file_lock), guard)
|
||||
let guard = Arc::clone(&file_lock).write_owned().await;
|
||||
FileWriteGuard::new(guard)
|
||||
}
|
||||
|
||||
// Tries to acquire a write lock for the specified file and returns a FileWriteGuard.
|
||||
pub async fn try_write_lock(&self, path: &Path) -> io::Result<FileWriteGuard> {
|
||||
let file_lock = self.get_or_create_lock(path).await;
|
||||
let guard = file_lock.try_write();
|
||||
match guard {
|
||||
// Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard`
|
||||
Ok(lock_guard) => Ok(FileWriteGuard::new(Arc::clone(&file_lock), lock_guard)),
|
||||
match Arc::clone(&file_lock).try_write_owned() {
|
||||
Ok(lock_guard) => Ok(FileWriteGuard::new(lock_guard)),
|
||||
Err(_) => Err(str_to_io_error("Failed to acquire write lock"))
|
||||
}
|
||||
}
|
||||
@@ -75,39 +72,25 @@ impl fmt::Debug for FileLockManager {
|
||||
}
|
||||
|
||||
// Define FileReadGuard to hold both the lock reference and the actual read guard.
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct FileReadGuard {
|
||||
lock: Arc<RwLock<()>>,
|
||||
guard: Arc<RwLockReadGuard<'static, ()>>,
|
||||
_guard: OwnedRwLockReadGuard<()>,
|
||||
}
|
||||
|
||||
impl FileReadGuard {
|
||||
pub fn new(lock: Arc<RwLock<()>>, guard: RwLockReadGuard<'_, ()>) -> Self {
|
||||
// Convert the lifetime of `guard` to 'static by transmuting.
|
||||
let static_guard: RwLockReadGuard<'static, ()> = unsafe { std::mem::transmute(guard) };
|
||||
Self {
|
||||
lock,
|
||||
guard: Arc::new(static_guard),
|
||||
}
|
||||
fn new(guard: OwnedRwLockReadGuard<()>) -> Self {
|
||||
Self { _guard: guard }
|
||||
}
|
||||
}
|
||||
|
||||
// Define FileWriteGuard to hold both the lock reference and the actual write guard.
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct FileWriteGuard {
|
||||
lock: Arc<RwLock<()>>,
|
||||
guard: Arc<RwLockWriteGuard<'static, ()>>,
|
||||
_guard: OwnedRwLockWriteGuard<()>,
|
||||
}
|
||||
|
||||
impl FileWriteGuard {
|
||||
pub fn new(lock: Arc<RwLock<()>>, guard: RwLockWriteGuard<'_, ()>) -> Self {
|
||||
// Convert the lifetime of `guard` to 'static by transmuting.
|
||||
let static_guard: RwLockWriteGuard<'static, ()> = unsafe { std::mem::transmute(guard) };
|
||||
Self {
|
||||
lock,
|
||||
guard: Arc::new(static_guard),
|
||||
}
|
||||
fn new(guard: OwnedRwLockWriteGuard<()>) -> Self {
|
||||
Self { _guard: guard }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,4 +361,52 @@ pub fn truncate_filename(path: &Path, max_len: usize) -> PathBuf {
|
||||
};
|
||||
|
||||
path.with_file_name(truncated_name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_string_path(path: &str) -> String {
|
||||
std::path::PathBuf::from(path)
|
||||
.components()
|
||||
.collect::<std::path::PathBuf>()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_string_path;
|
||||
|
||||
#[test]
|
||||
fn test_simple_relative_path() {
|
||||
let input = "foo/bar/baz";
|
||||
let normalized = normalize_string_path(input);
|
||||
assert_eq!(normalized, "foo/bar/baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redundant_slashes() {
|
||||
let input = "foo//bar///baz";
|
||||
let normalized = normalize_string_path(input);
|
||||
assert_eq!(normalized, "foo/bar/baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dot_components() {
|
||||
let input = "./foo/./bar/./baz";
|
||||
let normalized = normalize_string_path(input);
|
||||
assert_eq!(normalized, "./foo/bar/baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_components() {
|
||||
let input = "foo/bar/../baz";
|
||||
let normalized = normalize_string_path(input);
|
||||
assert_eq!(normalized, "foo/bar/../baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_path() {
|
||||
let input = "";
|
||||
let normalized = normalize_string_path(input);
|
||||
assert_eq!(normalized, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub async fn get_input_epg_content_as_file(client: Arc<reqwest::Client>, input:
|
||||
match download_epg_content_as_file(client, input, url_str, working_dir, persist_filepath).await {
|
||||
Ok(content) => Ok(content),
|
||||
Err(e) => {
|
||||
error!("cant download input epg url: {} => {}", sanitize_sensitive_info(url_str), sanitize_sensitive_info(e.to_string().as_str()));
|
||||
error!("cant download input {} epg url: {} => {}", input.name, sanitize_sensitive_info(url_str), sanitize_sensitive_info(e.to_string().as_str()));
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download")
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ pub async fn get_input_text_content(client: Arc<reqwest::Client>, input: &InputS
|
||||
match download_text_content(client, None, input, None, persist_filepath).await {
|
||||
Ok((content, _response_url)) => Ok(content),
|
||||
Err(e) => {
|
||||
error!("cant download input url: {} => {}", sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str()));
|
||||
error!("Failed to download input '{}': {}", &input.name, sanitize_sensitive_info(e.to_string().as_str()));
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ where
|
||||
let app_config = &app_state.app_config;
|
||||
|
||||
if cluster == XtreamCluster::Series {
|
||||
if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()) {
|
||||
if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()).await {
|
||||
// Deliver existing target content
|
||||
return rewrite_xtream_series_info_content(app_state, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
@@ -78,20 +78,20 @@ where
|
||||
// Check if the content has been resolved
|
||||
if xtream_output.resolve_series {
|
||||
if let Some(provider_id) = pli.get_provider_id() {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series) {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series).await {
|
||||
return xtream_repository::write_and_get_xtream_series_info(app_state, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if cluster == XtreamCluster::Video {
|
||||
if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()) {
|
||||
if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()).await {
|
||||
// Deliver existing target content
|
||||
return rewrite_xtream_vod_info_content(app_config, target, xtream_output, pli, user, &content);
|
||||
}
|
||||
// Check if the content has been resolved
|
||||
if xtream_output.resolve_vod {
|
||||
if let Some(provider_id) = pli.get_provider_id() {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video) {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video).await {
|
||||
return xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ for PLATFORM in "${!ARCHITECTURES[@]}"; do
|
||||
# Use incremental compilation and enable cache-friendly flags
|
||||
env RUSTFLAGS="--remap-path-prefix $HOME=~ -C incremental=/tmp/rust-incremental-${ARCHITECTURE}" \
|
||||
CARGO_INCREMENTAL=1 \
|
||||
cross build -p tuliprox --release --target "$ARCHITECTURE"
|
||||
cross build -p tuliprox --release --target "$ARCHITECTURE" --locked
|
||||
|
||||
BINARY_PATH="${WORKING_DIR}/target/${ARCHITECTURE}/release/tuliprox"
|
||||
if [ ! -f "$BINARY_PATH" ]; then
|
||||
|
||||
@@ -64,3 +64,11 @@ video:
|
||||
directory: ${env:TULIPROX_HOME}/downloads
|
||||
organize_into_directories: false
|
||||
episode_pattern: .*(?P<episode>[Ss]\d{1,2}(.*?)[Ee]\d{1,2}).*
|
||||
|
||||
reverse_proxy:
|
||||
resource_retry:
|
||||
max_attempts: 3
|
||||
backoff_millis: 250
|
||||
backoff_multiplier: 1.0
|
||||
stream:
|
||||
shared_burst_buffer_mb: 12 # default 12 MB
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "frontend"
|
||||
version = "3.1.8"
|
||||
version = "3.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
shared = { version = "3.1.8", path = "../shared" }
|
||||
shared = { version = "3.2.0", path = "../shared" }
|
||||
chrono = "0"
|
||||
yew = "0.21"
|
||||
yew-router = "0.18"
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
"SANITIZE_SENSITIVE_INFO": "Sanitize sensitive info",
|
||||
"ACTIVE_USER": "Active User",
|
||||
"RESOURCE_REWRITE_DISABLE": "Resource Rewrite disable",
|
||||
"RESOURCE_RETRY": "Resource Retry",
|
||||
"DISABLED_HEADER": "Disabled header",
|
||||
"MAX_ATTEMPTS": "Max attempts",
|
||||
"BACKOFF_MILLIS": "Backoff (ms)",
|
||||
"BACKOFF_MULTIPLIER": "Backoff multiplier",
|
||||
"REFERER_HEADER": "Remove referer header",
|
||||
"X_HEADER": "Remove X-* headers",
|
||||
"CUSTOM_HEADERS": "Custom headers",
|
||||
@@ -50,6 +54,7 @@
|
||||
"GRACE_PERIOD_TIMEOUT": "Grace period timeout sec",
|
||||
"BUFFER_ENABLED": "Buffer Enabled",
|
||||
"BUFFER_SIZE": "Buffer Size",
|
||||
"SHARED_BURST_BUFFER_BYTES": "Shared burst buffer (MB)",
|
||||
"ENABLED": "Enabled",
|
||||
"SIZE": "Size",
|
||||
"CACHE_DIR": "Cache dir",
|
||||
@@ -693,4 +698,4 @@
|
||||
"ZM": "Zambia",
|
||||
"ZW": "Zimbabwe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,5 +520,4 @@
|
||||
"keys": ["Accept"],
|
||||
"path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z"
|
||||
}
|
||||
|
||||
]
|
||||
@@ -363,8 +363,11 @@
|
||||
|
||||
--source-editor-line-color: #ffffff;
|
||||
--source-editor-pending-line-color: yellow;
|
||||
}
|
||||
--brick-port-hover-color: yellow;
|
||||
|
||||
--selection-area-border-color: rgba(0, 120, 215, 0.9);
|
||||
--selection-area-background-color: rgba(0, 120, 215, 0.12);
|
||||
}
|
||||
|
||||
body[data-theme="bright"] {
|
||||
--primary-color: rgba(10, 120, 255, 0.95);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use "../../size" as size;
|
||||
|
||||
.tp__app-sidebar.expanded {
|
||||
min-width: var(--sidebar-width-expaned);
|
||||
max-width: var(--sidebar-width-expaned);
|
||||
@@ -76,3 +78,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: size.$mobile-breakpoint) {
|
||||
.tp__app-sidebar-menu {
|
||||
&--source_editor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use "../../../size";
|
||||
@use "../../../size" as size;
|
||||
|
||||
.tp__config-view {
|
||||
display: flex;
|
||||
@@ -75,6 +75,16 @@
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
|
||||
&__title {
|
||||
display: none;
|
||||
color: var(--modest-text-color);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--margin-default);
|
||||
@media (max-width: size.$mobile-breakpoint) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(size.$config-grid-cell-width, 1fr));
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
&__connections {
|
||||
}
|
||||
|
||||
&__selection-rect {
|
||||
position:absolute;
|
||||
display: none;
|
||||
border: 1px dashed var(--selection-area-border-color);
|
||||
background: var(--selection-area-background-color);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.graph-paper-advanced {
|
||||
@@ -94,6 +103,11 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.selection_mode * {
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ $port-size: 10px;
|
||||
.tp__source-editor__block {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
left: 0;
|
||||
top: 0;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
@@ -13,6 +15,17 @@ $port-size: 10px;
|
||||
min-width: $block-width;
|
||||
max-width: $block-width;
|
||||
width: $block-width;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-editing {
|
||||
.tp__source-editor__block-content {
|
||||
border-color: red !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-selected {
|
||||
filter: sepia(1);
|
||||
}
|
||||
|
||||
&-header {
|
||||
position: relative;
|
||||
@@ -41,7 +54,6 @@ $port-size: 10px;
|
||||
justify-content: center;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
}
|
||||
|
||||
&-content {
|
||||
@@ -71,6 +83,10 @@ $port-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&-batch {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -113,6 +129,10 @@ $port-size: 10px;
|
||||
cursor: crosshair;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--brick-port-hover-color);
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: -5px;
|
||||
background-color: var(--brick-port-background-color);
|
||||
@@ -180,8 +200,35 @@ $port-size: 10px;
|
||||
}
|
||||
|
||||
.tp__source-editor__block {
|
||||
|
||||
&-batch-banner {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
&-label {
|
||||
color: var(--modest-text-color);
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&-InputXtream,
|
||||
&-InputM3u {
|
||||
.tp__source-editor__block-batch-banner-label {
|
||||
border: 1px solid var(--brick-input-border-color);
|
||||
border-top-color: transparent;
|
||||
background-color: var(--brick-input-background-color);
|
||||
}
|
||||
|
||||
.tp__source-editor__block-content {
|
||||
border-color: var(--brick-input-border-color);
|
||||
background-color: var(--brick-input-background-color);
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn Authentication(props: &AuthenticationProps) -> Html {
|
||||
&mut |success| {
|
||||
authenticated_state.set(success);
|
||||
if success {
|
||||
services_ctx.websocket.connect_ws();
|
||||
services_ctx.websocket.connect_ws_with_backoff();
|
||||
}
|
||||
future::ready(())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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;
|
||||
use crate::app::components::config::config_page::{ConfigForm, LABEL_API_CONFIG};
|
||||
|
||||
const LABEL_HOST: &str = "LABEL.HOST";
|
||||
const LABEL_PORT: &str = "LABEL.PORT";
|
||||
@@ -96,6 +96,7 @@ pub fn ApiConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__api-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_API_CONFIG)}</div>
|
||||
{
|
||||
html_if!(*config_view_ctx.edit_mode, {
|
||||
<div class="tp__webui-config-view__info tp__config-view-page__info">
|
||||
|
||||
@@ -4,6 +4,18 @@ use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfi
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const LABEL_MAIN_CONFIG: &str = "LABEL.MAIN";
|
||||
pub const LABEL_API_CONFIG: &str = "LABEL.API";
|
||||
pub const LABEL_LOG_CONFIG: &str = "LABEL.LOG";
|
||||
pub const LABEL_SCHEDULES_CONFIG: &str = "LABEL.SCHEDULES";
|
||||
pub const LABEL_MESSAGING_CONFIG: &str = "LABEL.MESSAGING";
|
||||
pub const LABEL_WEB_UI_CONFIG: &str = "LABEL.WEB_UI";
|
||||
pub const LABEL_REVERSE_PROXY_CONFIG: &str = "LABEL.REVERSE_PROXY";
|
||||
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";
|
||||
|
||||
const MAIN_PAGE: &str = "main";
|
||||
const API_PAGE: &str = "api";
|
||||
const LOG_PAGE: &str = "log";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::app::components::config::config_page::{ConfigForm, ConfigPage};
|
||||
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_view_context::ConfigViewContext;
|
||||
use crate::app::components::config::{ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, MessagingConfigView, ProxyConfigView, ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView};
|
||||
use crate::app::components::{Card, TabItem, TabSet, TextButton};
|
||||
@@ -12,26 +12,12 @@ use crate::app::components::config::config_update::update_config;
|
||||
use crate::app::{ConfigContext};
|
||||
use crate::hooks::use_service_context;
|
||||
|
||||
// ==== Label Konstanten ====
|
||||
const LABEL_MAIN: &str = "LABEL.MAIN";
|
||||
const LABEL_API: &str = "LABEL.API";
|
||||
const LABEL_LOG: &str = "LABEL.LOG";
|
||||
const LABEL_SCHEDULES: &str = "LABEL.SCHEDULES";
|
||||
const LABEL_MESSAGING: &str = "LABEL.MESSAGING";
|
||||
const LABEL_WEB_UI: &str = "LABEL.WEB_UI";
|
||||
const LABEL_REVERSE_PROXY: &str = "LABEL.REVERSE_PROXY";
|
||||
const LABEL_HDHOMERUN_CONFIG: &str = "LABEL.HDHOMERUN_CONFIG";
|
||||
const LABEL_PROXY: &str = "LABEL.PROXY";
|
||||
const LABEL_IP_CHECK: &str = "LABEL.IP_CHECK";
|
||||
const LABEL_VIDEO: &str = "LABEL.VIDEO";
|
||||
const LABEL_CONFIG: &str = "LABEL.CONFIG";
|
||||
const LABEL_EDIT: &str = "LABEL.EDIT";
|
||||
const LABEL_VIEW: &str = "LABEL.VIEW";
|
||||
const LABEL_SAVE: &str = "LABEL.SAVE";
|
||||
const LABEL_UPDATE_GEOIP: &str = "LABEL.UPDATE_GEOIP_DB";
|
||||
|
||||
// ==========================
|
||||
|
||||
macro_rules! collect_modified {
|
||||
($forms:expr, [ $($field:ident),+ $(,)? ]) => {{
|
||||
let mut modified = Vec::new();
|
||||
@@ -111,17 +97,17 @@ pub fn ConfigView() -> Html {
|
||||
.collect::<Vec<ConfigPage>>();
|
||||
|
||||
let tab_configs = vec![
|
||||
(ConfigPage::Main, LABEL_MAIN, html! { <MainConfigView/> }, "MainConfig"),
|
||||
(ConfigPage::Api, LABEL_API, html! { <ApiConfigView/> }, "ApiConfig"),
|
||||
(ConfigPage::Log, LABEL_LOG, html! { <LogConfigView/> }, "Log"),
|
||||
(ConfigPage::Schedules, LABEL_SCHEDULES, html! { <SchedulesConfigView/> }, "SchedulesConfig"),
|
||||
(ConfigPage::Messaging, LABEL_MESSAGING, html! { <MessagingConfigView/> }, "MessagingConfig"),
|
||||
(ConfigPage::WebUi, LABEL_WEB_UI, html! { <WebUiConfigView/> }, "WebUiConfig"),
|
||||
(ConfigPage::ReverseProxy, LABEL_REVERSE_PROXY, html! { <ReverseProxyConfigView/> }, "ReverseProxyConfig"),
|
||||
(ConfigPage::Main, LABEL_MAIN_CONFIG, html! { <MainConfigView/> }, "MainConfig"),
|
||||
(ConfigPage::Api, LABEL_API_CONFIG, html! { <ApiConfigView/> }, "ApiConfig"),
|
||||
(ConfigPage::Log, LABEL_LOG_CONFIG, html! { <LogConfigView/> }, "Log"),
|
||||
(ConfigPage::Schedules, LABEL_SCHEDULES_CONFIG, html! { <SchedulesConfigView/> }, "SchedulesConfig"),
|
||||
(ConfigPage::Messaging, LABEL_MESSAGING_CONFIG, html! { <MessagingConfigView/> }, "MessagingConfig"),
|
||||
(ConfigPage::WebUi, LABEL_WEB_UI_CONFIG, html! { <WebUiConfigView/> }, "WebUiConfig"),
|
||||
(ConfigPage::ReverseProxy, LABEL_REVERSE_PROXY_CONFIG, html! { <ReverseProxyConfigView/> }, "ReverseProxyConfig"),
|
||||
(ConfigPage::HdHomerun, LABEL_HDHOMERUN_CONFIG, html! { <HdHomerunConfigView/> }, "HdHomerunConfig"),
|
||||
(ConfigPage::Proxy, LABEL_PROXY, html! { <ProxyConfigView/> }, "ProxyConfig"),
|
||||
(ConfigPage::IpCheck, LABEL_IP_CHECK, html! { <IpCheckConfigView/> }, "IpCheckConfig"),
|
||||
(ConfigPage::Video, LABEL_VIDEO, html! { <VideoConfigView/> }, "VideoConfig"),
|
||||
(ConfigPage::Proxy, LABEL_PROXY_CONFIG, html! { <ProxyConfigView/> }, "ProxyConfig"),
|
||||
(ConfigPage::IpCheck, LABEL_IP_CHECK_CONFIG, html! { <IpCheckConfigView/> }, "IpCheckConfig"),
|
||||
(ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { <VideoConfigView/> }, "VideoConfig"),
|
||||
];
|
||||
|
||||
let editing = *editing;
|
||||
|
||||
@@ -5,7 +5,7 @@ use shared::model::{HdHomeRunDeviceConfigDto, HdHomeRunConfigDto};
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -128,6 +128,7 @@ pub fn HdHomerunConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__hdhomerun-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_HDHOMERUN_CONFIG)}</div>
|
||||
<div class="hdhomerun-config-view__body tp__config-view-page__header">
|
||||
{if edit_mode {
|
||||
html! {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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};
|
||||
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";
|
||||
@@ -104,6 +104,7 @@ pub fn IpCheckConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__ipcheck-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_IP_CHECK_CONFIG)}</div>
|
||||
<div class="tp__ipcheck-config-view__body tp__config-view-page__body">
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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};
|
||||
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";
|
||||
@@ -105,6 +105,7 @@ pub fn LogConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__log-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_LOG_CONFIG)}</div>
|
||||
<div class="tp__log-config-view__body tp__config-view-page__body">
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
|
||||
@@ -247,6 +247,32 @@ macro_rules! edit_field_number_u8 {
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! edit_field_number_f64 {
|
||||
($instance:expr, $label:expr, $field:ident, $action:path) => {{
|
||||
let instance = $instance.clone();
|
||||
html! {
|
||||
<div class="tp__form-field tp__form-field__number">
|
||||
<$crate::app::components::number_input::NumberInput
|
||||
label={$label}
|
||||
name={stringify!($field)}
|
||||
float_value={Some(instance.form.$field)}
|
||||
on_change_float={Some({
|
||||
let instance = instance.clone();
|
||||
Callback::from(move |value: Option<f64>| {
|
||||
if let Some(value) = value {
|
||||
if value.is_finite() {
|
||||
instance.dispatch($action(value));
|
||||
}
|
||||
}
|
||||
})
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! edit_field_number_u16 {
|
||||
($instance:expr, $label:expr, $field:ident, $action:path) => {{
|
||||
|
||||
@@ -3,8 +3,9 @@ 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;
|
||||
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, edit_field_number_u8};
|
||||
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, edit_field_number_u8};
|
||||
|
||||
const LABEL_UPDATE_ON_BOOT: &str = "LABEL.UPDATE_ON_BOOT";
|
||||
const LABEL_CONFIG_HOT_RELOAD: &str = "LABEL.CONFIG_HOT_RELOAD";
|
||||
@@ -94,7 +95,7 @@ pub fn MainConfigView() -> 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), user_access_control, MainConfigFormAction::AcceptInsecureSslCertificates) }
|
||||
{ edit_field_bool!(form_state, translate.t(LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES), accept_insecure_ssl_certificates, MainConfigFormAction::AcceptInsecureSslCertificates) }
|
||||
{ edit_field_number_u8!(form_state, translate.t(LABEL_THREADS), threads, MainConfigFormAction::Threads) }
|
||||
{ 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) }
|
||||
@@ -108,6 +109,7 @@ pub fn MainConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__main-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_MAIN_CONFIG)}</div>
|
||||
<div class="tp__main-config-view__body tp__config-view-page__body">
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::app::components::config::config_page::ConfigForm;
|
||||
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};
|
||||
@@ -305,6 +305,7 @@ pub fn MessagingConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__messaging-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_MESSAGING_CONFIG)}</div>
|
||||
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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;
|
||||
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,
|
||||
@@ -74,6 +74,7 @@ pub fn ProxyConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__proxy-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_PROXY_CONFIG)}</div>
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
render_edit_mode()
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto};
|
||||
use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto};
|
||||
use shared::utils::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;
|
||||
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_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer};
|
||||
use crate::{config_field, config_field_bool, config_field_custom, 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";
|
||||
@@ -19,15 +20,20 @@ const LABEL_RETRY: &str = "LABEL.RETRY";
|
||||
const LABEL_THROTTLE: &str = "LABEL.THROTTLE";
|
||||
const LABEL_GRACE_PERIOD_MILLIS: &str = "LABEL.GRACE_PERIOD_MILLIS";
|
||||
const LABEL_GRACE_PERIOD_TIMEOUT_SECS: &str = "LABEL.GRACE_PERIOD_TIMEOUT_SECS";
|
||||
const LABEL_FORCED_RETRY_INTERVAL_SECS: &str = "LABEL.FORCED_RETRY_INTERVAL_SECS";
|
||||
//const LABEL_FORCED_RETRY_INTERVAL_SECS: &str = "LABEL.FORCED_RETRY_INTERVAL_SECS";
|
||||
const LABEL_THROTTLE_KBPS: &str = "LABEL.THROTTLE_KBPS";
|
||||
|
||||
const LABEL_RATE_LIMIT: &str = "LABEL.RATE_LIMIT";
|
||||
const LABEL_PERIOD_MILLIS: &str = "LABEL.PERIOD_MILLIS";
|
||||
const LABEL_BURST_SIZE: &str = "LABEL.BURST_SIZE";
|
||||
const LABEL_SHARED_BURST_BUFFER_MB: &str = "LABEL.SHARED_BURST_BUFFER_BYTES";
|
||||
|
||||
const LABEL_SETTINGS: &str = "LABEL.SETTINGS";
|
||||
const LABEL_RESOURCE_REWRITE_DISABLED: &str = "LABEL.RESOURCE_REWRITE_DISABLED";
|
||||
const LABEL_RESOURCE_RETRY: &str = "LABEL.RESOURCE_RETRY";
|
||||
const LABEL_MAX_ATTEMPTS: &str = "LABEL.MAX_ATTEMPTS";
|
||||
const LABEL_BACKOFF_MILLIS: &str = "LABEL.BACKOFF_MILLIS";
|
||||
const LABEL_BACKOFF_MULTIPLIER: &str = "LABEL.BACKOFF_MULTIPLIER";
|
||||
const LABEL_DISABLED_HEADER: &str = "LABEL.DISABLED_HEADER";
|
||||
const LABEL_REFERER_HEADER: &str = "LABEL.REFERER_HEADER";
|
||||
const LABEL_X_HEADER: &str = "LABEL.X_HEADER";
|
||||
@@ -56,6 +62,16 @@ generate_form_reducer!(
|
||||
}
|
||||
);
|
||||
|
||||
generate_form_reducer!(
|
||||
state: ResourceRetryConfigFormState { form: ResourceRetryConfigDto },
|
||||
action_name: ResourceRetryConfigFormAction,
|
||||
fields {
|
||||
MaxAttempts => max_attempts: u32,
|
||||
BackoffMillis => backoff_millis: u64,
|
||||
BackoffMultiplier => backoff_multiplier: f64,
|
||||
}
|
||||
);
|
||||
|
||||
generate_form_reducer!(
|
||||
state: StreamConfigFormState { form: StreamConfigDto },
|
||||
action_name: StreamConfigFormAction,
|
||||
@@ -64,8 +80,9 @@ generate_form_reducer!(
|
||||
Throttle => throttle: Option<String>,
|
||||
GracePeriodMillis => grace_period_millis: u64,
|
||||
GracePeriodTimeoutSecs => grace_period_timeout_secs: u64,
|
||||
ForcedRetryIntervalSecs => forced_retry_interval_secs: u32,
|
||||
//ForcedRetryIntervalSecs => forced_retry_interval_secs: u32,
|
||||
ThrottleKbps => throttle_kbps: u64,
|
||||
SharedBurstBufferMb => shared_burst_buffer_mb: u64,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -114,6 +131,9 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
let rate_limit_state: UseReducerHandle<RateLimitConfigFormState> = use_reducer(|| {
|
||||
RateLimitConfigFormState { form: RateLimitConfigDto::default(), modified: false }
|
||||
});
|
||||
let resource_retry_state: UseReducerHandle<ResourceRetryConfigFormState> = use_reducer(|| {
|
||||
ResourceRetryConfigFormState { form: ResourceRetryConfigDto::default(), modified: false }
|
||||
});
|
||||
let stream_state: UseReducerHandle<StreamConfigFormState> = use_reducer(|| {
|
||||
StreamConfigFormState { form: StreamConfigDto::default(), modified: false }
|
||||
});
|
||||
@@ -128,6 +148,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
let disabled_header_state = disabled_header_state.clone();
|
||||
let cache_state = cache_state.clone();
|
||||
let rate_limit_state = rate_limit_state.clone();
|
||||
let resource_retry_state = resource_retry_state.clone();
|
||||
let stream_state = stream_state.clone();
|
||||
let geoip_state = geoip_state.clone();
|
||||
|
||||
@@ -137,13 +158,15 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
disabled_header_state,
|
||||
cache_state,
|
||||
rate_limit_state,
|
||||
resource_retry_state,
|
||||
stream_state,
|
||||
geoip_state,
|
||||
),
|
||||
move |(rp, disabled_header, cache, rl, stream, geoip)| {
|
||||
move |(rp, disabled_header, cache, rl, resource_retry, stream, geoip)| {
|
||||
let mut form = rp.form.clone();
|
||||
form.cache = Some(cache.form.clone());
|
||||
form.rate_limit = Some(rl.form.clone());
|
||||
form.resource_retry = Some(resource_retry.form.clone());
|
||||
form.stream = Some(stream.form.clone());
|
||||
form.geoip = Some(geoip.form.clone());
|
||||
form.disabled_header = if disabled_header.form.is_empty() {
|
||||
@@ -156,6 +179,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
|| disabled_header.modified
|
||||
|| cache.modified
|
||||
|| rl.modified
|
||||
|| resource_retry.modified
|
||||
|| stream.modified
|
||||
|| geoip.modified;
|
||||
on_form_change.emit(ConfigForm::ReverseProxy(modified, form));
|
||||
@@ -168,6 +192,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
let disabled_header_state = disabled_header_state.clone();
|
||||
let cache_state = cache_state.clone();
|
||||
let rate_limit_state = rate_limit_state.clone();
|
||||
let resource_retry_state = resource_retry_state.clone();
|
||||
let stream_state = stream_state.clone();
|
||||
let geoip_state = geoip_state.clone();
|
||||
|
||||
@@ -178,6 +203,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
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 {
|
||||
@@ -185,6 +211,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
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()));
|
||||
}
|
||||
@@ -210,8 +237,9 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
{ config_field_optional!(stream_state.form, translate.t(LABEL_THROTTLE), throttle) }
|
||||
{ config_field!(stream_state.form, translate.t(LABEL_GRACE_PERIOD_MILLIS), grace_period_millis) }
|
||||
{ config_field!(stream_state.form, translate.t(LABEL_GRACE_PERIOD_TIMEOUT_SECS), grace_period_timeout_secs) }
|
||||
{ config_field!(stream_state.form, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs) }
|
||||
//{ config_field!(stream_state.form, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs) }
|
||||
{ config_field!(stream_state.form, translate.t(LABEL_THROTTLE_KBPS), throttle_kbps) }
|
||||
{ config_field!(stream_state.form, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb) }
|
||||
</Card>
|
||||
}
|
||||
};
|
||||
@@ -255,6 +283,33 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
}
|
||||
};
|
||||
|
||||
let render_resource_retry_view = || {
|
||||
html! {
|
||||
<Card class="tp__config-view__card">
|
||||
<h1>{translate.t(LABEL_RESOURCE_RETRY)}</h1>
|
||||
{ config_field!(resource_retry_state.form, translate.t(LABEL_MAX_ATTEMPTS), max_attempts) }
|
||||
{ config_field!(resource_retry_state.form, translate.t(LABEL_BACKOFF_MILLIS), backoff_millis) }
|
||||
{
|
||||
config_field_custom!(
|
||||
translate.t(LABEL_BACKOFF_MULTIPLIER),
|
||||
format_float_localized(resource_retry_state.form.backoff_multiplier, 4, true)
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
}
|
||||
};
|
||||
|
||||
let render_resource_retry_edit = || {
|
||||
html! {
|
||||
<Card class="tp__config-view__card">
|
||||
<h1>{translate.t(LABEL_RESOURCE_RETRY)}</h1>
|
||||
{ edit_field_number!(resource_retry_state, translate.t(LABEL_MAX_ATTEMPTS), max_attempts, ResourceRetryConfigFormAction::MaxAttempts) }
|
||||
{ edit_field_number_u64!(resource_retry_state, translate.t(LABEL_BACKOFF_MILLIS), backoff_millis, ResourceRetryConfigFormAction::BackoffMillis) }
|
||||
{ edit_field_number_f64!(resource_retry_state, translate.t(LABEL_BACKOFF_MULTIPLIER), backoff_multiplier, ResourceRetryConfigFormAction::BackoffMultiplier) }
|
||||
</Card>
|
||||
}
|
||||
};
|
||||
|
||||
let render_disabled_header_view = || {
|
||||
let custom_headers = if disabled_header_state.form.custom_header.is_empty() {
|
||||
"-".to_string()
|
||||
@@ -286,6 +341,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
html! {
|
||||
<div class="tp__reverse-proxy-config-view__body tp__config-view-page__body">
|
||||
{ render_settings_view() }
|
||||
{ render_resource_retry_view() }
|
||||
{ render_disabled_header_view() }
|
||||
{ render_cache() }
|
||||
{ render_rate_limit() }
|
||||
@@ -298,6 +354,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
let render_edit_mode = || html! {
|
||||
<div class="tp__reverse-proxy-config-view__body tp__config-view-page__body">
|
||||
{ render_settings_edit() }
|
||||
{ render_resource_retry_edit() }
|
||||
{ render_disabled_header_edit() }
|
||||
<Card class="tp__config-view__card">
|
||||
<h1>{translate.t(LABEL_CACHE)}</h1>
|
||||
@@ -317,8 +374,9 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
{ 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!(stream_state, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs, StreamConfigFormAction::ForcedRetryIntervalSecs) }
|
||||
//{ edit_field_number!(stream_state, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs, StreamConfigFormAction::ForcedRetryIntervalSecs) }
|
||||
{ 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) }
|
||||
</Card>
|
||||
<Card class="tp__config-view__card">
|
||||
<h1>{translate.t(LABEL_GEOIP)}</h1>
|
||||
@@ -330,6 +388,7 @@ pub fn ReverseProxyConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__reverse-proxy-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_REVERSE_PROXY_CONFIG)}</div>
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
render_edit_mode()
|
||||
|
||||
@@ -5,7 +5,7 @@ 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;
|
||||
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};
|
||||
@@ -231,6 +231,7 @@ pub fn SchedulesConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__schedules-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_SCHEDULES_CONFIG)}</div>
|
||||
<div class="tp__schedules-config-view__body">
|
||||
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { html!{} } }
|
||||
{ render_view_mode(*config_view_ctx.edit_mode) }
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{config_field_bool, config_field_child, config_field_optional, edit_f
|
||||
use shared::model::{VideoDownloadConfigDto, VideoConfigDto};
|
||||
use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use crate::app::components::config::config_page::ConfigForm;
|
||||
use crate::app::components::config::config_page::{ConfigForm, LABEL_VIDEO_CONFIG};
|
||||
use crate::app::components::config::config_view_context::ConfigViewContext;
|
||||
|
||||
const LABEL_DOWNLOAD: &str = "LABEL.DOWNLOAD";
|
||||
@@ -161,6 +161,7 @@ pub fn VideoConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__video-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_VIDEO_CONFIG)}</div>
|
||||
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::components::{AppIcon, Card, Chip};
|
||||
use crate::app::context::ConfigContext;
|
||||
use crate::app::components::config::config_view_context::ConfigViewContext;
|
||||
use crate::app::components::config::config_page::ConfigForm;
|
||||
use crate::app::components::config::config_page::{ConfigForm, LABEL_WEB_UI_CONFIG};
|
||||
use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer, html_if};
|
||||
use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
@@ -182,6 +182,7 @@ pub fn WebUiConfigView() -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__webui-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_WEB_UI_CONFIG)}</div>
|
||||
{
|
||||
html_if!(*config_view_ctx.edit_mode, {
|
||||
<div class="tp__webui-config-view__info tp__config-view-page__info">
|
||||
|
||||
@@ -3,11 +3,15 @@ use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use crate::app::components::{Card, StatusCard, StreamsTable};
|
||||
use crate::app::StatusContext;
|
||||
use crate::hooks::use_service_context;
|
||||
use crate::model::EventMessage;
|
||||
|
||||
#[function_component]
|
||||
pub fn StreamsView() -> Html {
|
||||
let translate = use_translation();
|
||||
let service_ctx = use_service_context();
|
||||
let status_ctx = use_context::<StatusContext>().expect("Status context not found");
|
||||
let provider_connections = use_state(|| 0);
|
||||
|
||||
let memo_streams = {
|
||||
let status = status_ctx.status.clone();
|
||||
@@ -16,6 +20,30 @@ pub fn StreamsView() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let services = service_ctx.clone();
|
||||
let provider_connections = provider_connections.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let services = services.clone();
|
||||
let subid = services.event.subscribe(move |msg| {
|
||||
if let EventMessage::ActiveProviderCount(count) = msg {
|
||||
provider_connections.set(count);
|
||||
}
|
||||
});
|
||||
move || services.event.unsubscribe(subid)
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let status_ctx = status_ctx.clone();
|
||||
let provider_connections = provider_connections.clone();
|
||||
use_effect_with(status_ctx.status, move |status| {
|
||||
let count = status.as_ref().map_or(0, |status|
|
||||
status.active_provider_connections.as_ref().map(|map| map.values().sum::<usize>()).unwrap_or(0));
|
||||
provider_connections.set(count);
|
||||
})
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="tp__streams">
|
||||
<div class="tp__streams__header">
|
||||
@@ -28,8 +56,7 @@ pub fn StreamsView() -> Html {
|
||||
<Card>
|
||||
<StatusCard
|
||||
title={translate.t("LABEL.ACTIVE_PROVIDER_CONNECTIONS")}
|
||||
data={status_ctx.status.as_ref().map_or_else(|| "n/a".to_string(), |status|
|
||||
status.active_provider_connections.as_ref().map(|map| map.values().sum::<usize>()).unwrap_or(0).to_string())}
|
||||
data={(*provider_connections).to_string()}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -38,32 +38,30 @@ pub fn Login() -> Html {
|
||||
let username = username_input.value();
|
||||
let password = password_input.value();
|
||||
let result = services_ctx.auth.authenticate(username, password).await;
|
||||
let success = result.is_ok();
|
||||
authorized_state.set(success);
|
||||
match &result {
|
||||
Ok(_token) => {
|
||||
authorized_state.set(true)
|
||||
}
|
||||
Err(_) => {authorized_state.set(false);}
|
||||
}
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
let do_login = {
|
||||
|
||||
let handle_login = {
|
||||
let authenticator = authenticate.clone();
|
||||
Callback::from(move |_| {
|
||||
Callback::from(move |_: String| {
|
||||
authenticator.run();
|
||||
})
|
||||
};
|
||||
|
||||
let handle_login = {
|
||||
let login = do_login.clone();
|
||||
Callback::from(move |_: String| {
|
||||
login.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let handle_key_down = {
|
||||
let login = do_login.clone();
|
||||
let authenticator = authenticate.clone();
|
||||
Callback::from(move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" {
|
||||
e.prevent_default();
|
||||
login.emit(());
|
||||
authenticator.run();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use web_sys::{HtmlInputElement, KeyboardEvent};
|
||||
use yew::prelude::*;
|
||||
use yew::TargetCast;
|
||||
use shared::utils::{format_float_localized, parse_localized_float};
|
||||
|
||||
#[derive(Properties, Clone, PartialEq)]
|
||||
pub struct NumberInputProps {
|
||||
@@ -11,8 +12,12 @@ pub struct NumberInputProps {
|
||||
#[prop_or_default]
|
||||
pub value: Option<i64>,
|
||||
#[prop_or_default]
|
||||
pub float_value: Option<f64>,
|
||||
#[prop_or_default]
|
||||
pub on_change: Callback<Option<i64>>,
|
||||
#[prop_or_default]
|
||||
pub on_change_float: Option<Callback<Option<f64>>>,
|
||||
#[prop_or_default]
|
||||
pub placeholder: String,
|
||||
}
|
||||
|
||||
@@ -22,25 +27,46 @@ pub fn NumberInput(props: &NumberInputProps) -> Html {
|
||||
|
||||
{
|
||||
let input_ref = input_ref.clone();
|
||||
let value = props.value;
|
||||
use_effect_with(value, move |val| {
|
||||
let deps = (props.value, props.float_value);
|
||||
use_effect_with(deps, move |(int_val, float_val)| {
|
||||
if let Some(input) = input_ref.cast::<HtmlInputElement>() {
|
||||
match val {
|
||||
Some(v) => input.set_value(&v.to_string()),
|
||||
None => input.set_value(""),
|
||||
}
|
||||
let new_value = float_val
|
||||
.map(|f| format_float_localized(f, 4, true))
|
||||
.or_else(|| int_val.map(|v| v.to_string()))
|
||||
.unwrap_or_default();
|
||||
input.set_value(&new_value);
|
||||
}
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
let prefers_float = props.on_change_float.is_some();
|
||||
let on_input = {
|
||||
let onchange = props.on_change.clone();
|
||||
let onchange_int = props.on_change.clone();
|
||||
let onchange_float = props.on_change_float.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let raw = input.value();
|
||||
let parsed = raw.parse::<i64>().ok();
|
||||
onchange.emit(parsed);
|
||||
let raw = input.value().trim().to_string();
|
||||
if raw.is_empty() {
|
||||
if prefers_float {
|
||||
if let Some(cb) = onchange_float.as_ref() {
|
||||
cb.emit(None);
|
||||
}
|
||||
} else {
|
||||
onchange_int.emit(None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if prefers_float {
|
||||
if let Some(cb) = onchange_float.as_ref() {
|
||||
let parsed = parse_localized_float(&raw);
|
||||
cb.emit(parsed);
|
||||
}
|
||||
} else {
|
||||
let parsed = raw.parse::<i64>().ok();
|
||||
onchange_int.emit(parsed);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -67,22 +93,19 @@ pub fn NumberInput(props: &NumberInputProps) -> Html {
|
||||
|
||||
html! {
|
||||
<div class="tp__input">
|
||||
{ if props.label.is_some() {
|
||||
html! {
|
||||
<label>{props.label.clone().unwrap_or_default()}</label>
|
||||
}
|
||||
} else { html!{} }
|
||||
}
|
||||
{ if let Some(label) = &props.label {
|
||||
html! { <label>{ label }</label> }
|
||||
} else { html!{} } }
|
||||
<div class="tp__input-wrapper">
|
||||
<input
|
||||
ref={input_ref.clone()}
|
||||
type="text" // type is text to avoid browser validation
|
||||
type="text"
|
||||
name={props.name.clone()}
|
||||
placeholder={props.placeholder.clone()}
|
||||
onkeydown={handle_keydown.clone()}
|
||||
oninput={on_input.clone()}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,18 +154,18 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
|
||||
let render_collapsed = || {
|
||||
html! {
|
||||
<div class="tp__app-sidebar__content">
|
||||
<IconButton class={if *active_menu == ViewType::Dashboard { "active" } else {""}} icon="DashboardOutline" name={ViewType::Dashboard.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::Stats { "active" } else {""}} icon="Stats" name={ViewType::Stats.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::Streams { "active" } else {""}} icon="Streams" name={ViewType::Streams.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::Dashboard, if *active_menu == ViewType::Dashboard { " active" } else {""})} icon="DashboardOutline" name={ViewType::Dashboard.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::Stats, if *active_menu == ViewType::Stats { " active" } else {""})} icon="Stats" name={ViewType::Stats.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::Streams, if *active_menu == ViewType::Streams { " active" } else {""})} icon="Streams" name={ViewType::Streams.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<span class="tp__app-sidebar__content-space"></span>
|
||||
<IconButton class={if *active_menu == ViewType::Config { "active" } else {""}} icon="Config" name={ViewType::Config.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::Users { "active" } else {""}} icon="UserOutline" name={ViewType::Users.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::SourceEditor { "active" } else {""}} icon="SourceEditor" name={ViewType::SourceEditor.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::Config, if *active_menu == ViewType::Config { " active" } else {""})} icon="Config" name={ViewType::Config.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::Users, if *active_menu == ViewType::Users { " active" } else {""})} icon="UserOutline" name={ViewType::Users.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::SourceEditor, if *active_menu == ViewType::SourceEditor { " active" } else {""})} icon="SourceEditor" name={ViewType::SourceEditor.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<span class="tp__app-sidebar__content-space"></span>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={if *active_menu == ViewType::PlaylistEpg { "active" } else {""}} icon="Epg" name={ViewType::PlaylistEpg.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::PlaylistUpdate, if *active_menu == ViewType::PlaylistUpdate { " active" } else {""})} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::PlaylistEditor, if *active_menu == ViewType::PlaylistEditor { " active" } else {""})} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::PlaylistExplorer, if *active_menu == ViewType::PlaylistExplorer { " active" } else {""})} icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
<IconButton class={format!("tp__app-sidebar-menu--{}{}", ViewType::PlaylistEpg, if *active_menu == ViewType::PlaylistEpg { " active" } else {""})} icon="Epg" name={ViewType::PlaylistEpg.to_string()} onclick={&handle_menu_click}></IconButton>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
+58
-23
@@ -1,18 +1,20 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::{classes, function_component, html, Callback, Html, Properties};
|
||||
use yew::{classes, function_component, html, Callback, Html, Properties, TargetCast};
|
||||
use yew_i18n::use_translation;
|
||||
use crate::html_if;
|
||||
use crate::app::components::{Block, BlockInstance, BlockType, PortStatus};
|
||||
use crate::app::components::{Block, BlockId, BlockInstance, BlockType, PortStatus};
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct BlockProps {
|
||||
pub(crate) block: Block,
|
||||
pub(crate) edited:bool,
|
||||
pub(crate) selected:bool,
|
||||
pub(crate) delete_mode: bool,
|
||||
pub(crate) delete_block: Callback<usize>,
|
||||
pub(crate) delete_block: Callback<BlockId>,
|
||||
pub(crate) port_status: PortStatus,
|
||||
pub(crate) on_edit: Callback<usize>,
|
||||
pub(crate) on_mouse_down: Callback<(usize, MouseEvent)>,
|
||||
pub(crate) on_connection_drop: Callback<usize>, // to_id
|
||||
pub(crate) on_connection_start: Callback<usize>, // from_id
|
||||
pub(crate) on_edit: Callback<BlockId>,
|
||||
pub(crate) on_mouse_down: Callback<(BlockId, MouseEvent)>,
|
||||
pub(crate) on_connection_drop: Callback<BlockId>, // to_id
|
||||
pub(crate) on_connection_start: Callback<BlockId>, // from_id
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
@@ -27,7 +29,7 @@ pub fn BlockView(props: &BlockProps) -> Html {
|
||||
|
||||
let block_id = block.id;
|
||||
let block_type = block.block_type;
|
||||
let style = format!("position:absolute; left:{}px; top:{}px;", block.position.0, block.position.1);
|
||||
let style = format!("transform: translate({}px, {}px)", block.position.0, block.position.1);
|
||||
let from_id = block_id;
|
||||
let to_id = block_id;
|
||||
|
||||
@@ -43,7 +45,17 @@ pub fn BlockView(props: &BlockProps) -> Html {
|
||||
|
||||
let handle_mouse_down = {
|
||||
let on_block_mouse_down = props.on_mouse_down.clone();
|
||||
Callback::from(move |e| on_block_mouse_down.emit((block_id, e)))
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if let Some(target) = e.target_dyn_into::<web_sys::Element>() {
|
||||
let tag = target.tag_name().to_lowercase();
|
||||
if &tag == "span" {
|
||||
return;
|
||||
}
|
||||
}
|
||||
e.stop_propagation();
|
||||
on_block_mouse_down.emit((block_id, e))
|
||||
})
|
||||
};
|
||||
|
||||
let handle_edit = {
|
||||
@@ -53,26 +65,38 @@ pub fn BlockView(props: &BlockProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let (title, show_type) = {
|
||||
let (dto_title, show_type) = match &block.instance {
|
||||
BlockInstance::Input(dto) => (dto.name.clone(), true),
|
||||
BlockInstance::Target(dto) => (dto.name.clone(), true),
|
||||
let (title, show_type, is_batch) = {
|
||||
let (dto_title, show_type, is_batch) = match &block.instance {
|
||||
BlockInstance::Input(dto) => {
|
||||
dto.aliases.as_ref().map_or(
|
||||
(dto.name.clone(), true, false),
|
||||
|a| {
|
||||
if a.is_empty() {
|
||||
(dto.name.clone(), true, false)
|
||||
} else {
|
||||
(if dto.name.is_empty() {a[0].name.clone()} else {dto.name.clone()}, true, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
BlockInstance::Target(dto) => (dto.name.clone(), true, false),
|
||||
BlockInstance::Output(_output) => {
|
||||
(translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false)
|
||||
(translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false, false)
|
||||
}
|
||||
};
|
||||
if dto_title.is_empty() {
|
||||
(translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false)
|
||||
(translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false, is_batch)
|
||||
} else {
|
||||
(dto_title, show_type)
|
||||
(dto_title, show_type, is_batch)
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={format!("tp__source-editor__block no-select tp__source-editor__block-{}", block_type)} style={style}>
|
||||
<div id={format!("block-{block_id}")} class={format!("tp__source-editor__block no-select tp__source-editor__block-{}{}{}", block_type, if props.edited {" tp__source-editor__block-editing"} else {""}, if props.selected {" tp__source-editor__block-selected"} else {""})}
|
||||
style={style}>
|
||||
<div class={"tp__source-editor__block-header"}>
|
||||
// Block handle (drag)
|
||||
<div class="tp__source-editor__block-handle" onmousedown={handle_mouse_down}/>
|
||||
<div class="tp__source-editor__block-handle" onmousedown={handle_mouse_down.clone()} />
|
||||
// Delete button for block
|
||||
{
|
||||
html_if!(delete_mode, {
|
||||
@@ -84,7 +108,7 @@ pub fn BlockView(props: &BlockProps) -> Html {
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div class={"tp__source-editor__block-content"} ondblclick={handle_edit}>
|
||||
<div class={if is_batch { "tp__source-editor__block-content tp__source-editor__block-batch" } else { "tp__source-editor__block-content" }} onmousedown={handle_mouse_down} ondblclick={handle_edit}>
|
||||
<div class={"tp__source-editor__block-content-body"}>
|
||||
<div class="tp__source-editor__block-label">
|
||||
{ title }
|
||||
@@ -98,24 +122,35 @@ pub fn BlockView(props: &BlockProps) -> Html {
|
||||
|
||||
{html_if!(is_target || is_output, {
|
||||
// Left port
|
||||
<div
|
||||
<span
|
||||
class={classes!("tp__source-editor__block-port", "tp__source-editor__block-port--left", port_style)}
|
||||
onmouseup={{
|
||||
let on_connection_drop = props.on_connection_drop.clone();
|
||||
Callback::from(move |_| on_connection_drop.emit(to_id))
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_connection_drop.emit(to_id)
|
||||
})
|
||||
}} />
|
||||
})}
|
||||
|
||||
{html_if!(is_target || is_input, {
|
||||
// Right port
|
||||
<div
|
||||
<span
|
||||
class="tp__source-editor__block-port tp__source-editor__block-port--right"
|
||||
onmousedown={{
|
||||
let on_connection_start = props.on_connection_start.clone();
|
||||
Callback::from(move |_| on_connection_start.emit(from_id))
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_connection_start.emit(from_id);
|
||||
})
|
||||
}} />
|
||||
})}
|
||||
</div>
|
||||
{html_if!(is_batch, {
|
||||
<div class="tp__source-editor__block-batch-banner">
|
||||
<div class="tp__source-editor__block-batch-banner-label">{"batch"}</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ use std::fmt;
|
||||
use std::rc::Rc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yew::{Callback, UseStateHandle};
|
||||
use shared::model::{ConfigInputDto, ConfigTargetDto, TargetOutputDto};
|
||||
use shared::model::{ConfigInputDto, ConfigTargetDto, InputType, TargetOutputDto};
|
||||
|
||||
pub const BLOCK_WIDTH: f32 = 100.0;
|
||||
pub const BLOCK_HEIGHT: f32 = 50.0;
|
||||
pub const BLOCK_HEADER_HEIGHT: f32 = 12.0;
|
||||
pub const BLOCK_PORT_HEIGHT: f32 = 10.0;
|
||||
|
||||
// ----------------- Data Models -----------------
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -25,6 +30,18 @@ impl BlockType {
|
||||
pub const OUTPUT_XTREAM: &'static str = "OutputXtream";
|
||||
pub const OUTPUT_HDHOMERUN: &'static str = "OutputHdHomeRun";
|
||||
pub const OUTPUT_STRM: &'static str = "OutputStrm";
|
||||
|
||||
pub fn is_input(&self) -> bool {
|
||||
matches!(self, Self::InputXtream | Self::InputM3u)
|
||||
}
|
||||
|
||||
pub fn is_target(&self) -> bool {
|
||||
matches!(self, Self::Target)
|
||||
}
|
||||
|
||||
// pub fn is_output(&self) -> bool {
|
||||
// matches!(self, Self::OutputXtream | Self::OutputM3u | Self::OutputHdHomeRun | Self::OutputStrm)
|
||||
// }
|
||||
}
|
||||
|
||||
// Convert from String to BlockType
|
||||
@@ -49,6 +66,17 @@ impl From<String> for BlockType {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InputType> for BlockType {
|
||||
fn from(s: InputType) -> Self {
|
||||
match s {
|
||||
InputType::M3uBatch
|
||||
| InputType::M3u => BlockType::InputM3u,
|
||||
InputType::XtreamBatch
|
||||
| InputType::Xtream => BlockType::InputXtream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Display trait using constants
|
||||
impl fmt::Display for BlockType {
|
||||
@@ -65,8 +93,7 @@ impl fmt::Display for BlockType {
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type BlockId = usize;
|
||||
pub(crate) type BlockId = u16;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Block {
|
||||
@@ -76,13 +103,50 @@ pub struct Block {
|
||||
pub instance: BlockInstance,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
/// Liefert die Bounding-Box des Blocks unter Berücksichtigung der Canvas-Verschiebung.
|
||||
pub fn bounds(&self, canvas_offset: (f32, f32)) -> (f32, f32, f32, f32) {
|
||||
let (ox, oy) = canvas_offset;
|
||||
let bx = self.position.0 + ox;
|
||||
let by = self.position.1 + oy;
|
||||
let b_left = bx;
|
||||
let b_top = by;
|
||||
let b_right = bx + BLOCK_WIDTH;
|
||||
let b_bottom = by + (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT);
|
||||
(b_left, b_top, b_right, b_bottom)
|
||||
}
|
||||
|
||||
/// Prüft, ob der Block von einem Auswahlrechteck getroffen wird.
|
||||
pub fn intersects_rect(
|
||||
&self,
|
||||
rect_start: (f32, f32),
|
||||
rect_end: (f32, f32),
|
||||
canvas_offset: (f32, f32),
|
||||
) -> bool {
|
||||
let (b_left, b_top, b_right, b_bottom) = self.bounds(canvas_offset);
|
||||
|
||||
// Rechteck normalisieren
|
||||
let (x1, y1) = rect_start;
|
||||
let (x2, y2) = rect_end;
|
||||
let r_left = x1.min(x2);
|
||||
let r_top = y1.min(y2);
|
||||
let r_right = x1.max(x2);
|
||||
let r_bottom = y1.max(y2);
|
||||
|
||||
b_right >= r_left &&
|
||||
b_left <= r_right &&
|
||||
b_bottom >= r_top &&
|
||||
b_top <= r_bottom
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Connection {
|
||||
pub from: BlockId,
|
||||
pub to: BlockId,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum PortStatus {
|
||||
Valid,
|
||||
@@ -90,8 +154,6 @@ pub enum PortStatus {
|
||||
Inactive
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockInstance {
|
||||
Input(Rc<ConfigInputDto>),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use crate::app::components::config::HasFormData;
|
||||
use crate::app::components::select::Select;
|
||||
use crate::app::components::{BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton};
|
||||
use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton};
|
||||
use crate::{config_field_child, edit_field_bool, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer};
|
||||
use shared::model::{ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto};
|
||||
use std::fmt::Display;
|
||||
@@ -101,7 +101,7 @@ generate_form_reducer!(
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ConfigInputViewProps {
|
||||
pub(crate) block_id: usize,
|
||||
pub(crate) block_id: BlockId,
|
||||
pub(crate) input: Option<Rc<ConfigInputDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::app::components::{Block, BlockId, Connection};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const CANVAS_OFFSET: f32 = 10.0;
|
||||
|
||||
const Y_GAP: f32 = 25.0;
|
||||
const X_GAP: f32 = 50.0;
|
||||
|
||||
const BLOCK_HEIGHT: f32 = 50.0;
|
||||
const BLOCK_WIDTH: f32 = 200.0;
|
||||
|
||||
struct TargetBlock {
|
||||
id: BlockId,
|
||||
outputs: Option<Vec<BlockId>>,
|
||||
height: f32,
|
||||
position: (f32, f32),
|
||||
}
|
||||
|
||||
impl TargetBlock {
|
||||
pub fn new(id: BlockId, outputs: Option<Vec<BlockId>>) -> Self {
|
||||
let height = Self::height(outputs.as_ref());
|
||||
TargetBlock { id, outputs, height, position: (0.0, 0.0) }
|
||||
}
|
||||
|
||||
fn height(outputs: Option<&Vec<BlockId>>) -> f32 {
|
||||
if let Some(outs) = outputs {
|
||||
let len = outs.len() as f32;
|
||||
((len * BLOCK_HEIGHT) + ((len - 1.0) * Y_GAP)).max(BLOCK_HEIGHT)
|
||||
} else {
|
||||
BLOCK_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_position(&mut self, x: f32, y: f32, blocks: &mut [Block]) {
|
||||
self.position = (x, y);
|
||||
let out_x = x + BLOCK_WIDTH + X_GAP;
|
||||
let mut out_y = y;
|
||||
if let Some(outputs) = self.outputs.as_ref() {
|
||||
for out in outputs {
|
||||
blocks[*out as usize -1].position = (out_x, out_y);
|
||||
out_y += BLOCK_HEIGHT + Y_GAP;
|
||||
}
|
||||
}
|
||||
blocks[self.id as usize - 1].position = (x, y + (self.height - BLOCK_HEIGHT) / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_target_blocks(blocks: &mut [Block], connections: &[Connection]) -> Vec<TargetBlock> {
|
||||
let mut out_edges: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
let mut in_edges: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
|
||||
for c in connections {
|
||||
out_edges.entry(c.from).or_default().push(c.to);
|
||||
in_edges.entry(c.to).or_default().push(c.from);
|
||||
}
|
||||
|
||||
blocks
|
||||
.iter()
|
||||
.filter(|b| b.block_type.is_target())
|
||||
.map(|b| TargetBlock::new(b.id, out_edges.get(&b.id).cloned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
/// calcuates Barycenter for a Block, based on connected Blocks in given Order-Array
|
||||
fn barycenter(id: BlockId, map: &HashMap<BlockId, Vec<BlockId>>, order: &[BlockId]) -> f32 {
|
||||
if let Some(connected) = map.get(&id) {
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0;
|
||||
for &c in connected {
|
||||
if let Some(pos) = order.iter().position(|&x| x == c) {
|
||||
sum += pos as f32;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == 0 { f32::INFINITY } else { sum / count as f32 }
|
||||
} else {
|
||||
f32::INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts crossings
|
||||
fn count_crossings(
|
||||
input_order: &[BlockId],
|
||||
target_order: &[BlockId],
|
||||
connections: &[Connection],
|
||||
) -> usize {
|
||||
let input_index: HashMap<BlockId, usize> =
|
||||
input_order.iter().enumerate().map(|(i, &id)| (id, i)).collect();
|
||||
let target_index: HashMap<BlockId, usize> =
|
||||
target_order.iter().enumerate().map(|(i, &id)| (id, i)).collect();
|
||||
|
||||
let mut count = 0;
|
||||
for (i, c1) in connections.iter().enumerate() {
|
||||
if !input_index.contains_key(&c1.from) || !target_index.contains_key(&c1.to) {
|
||||
continue;
|
||||
}
|
||||
for c2 in &connections[i + 1..] {
|
||||
if !input_index.contains_key(&c2.from) || !target_index.contains_key(&c2.to) {
|
||||
continue;
|
||||
}
|
||||
let i1 = input_index[&c1.from];
|
||||
let j1 = target_index[&c1.to];
|
||||
let i2 = input_index[&c2.from];
|
||||
let j2 = target_index[&c2.to];
|
||||
|
||||
if (i1 < i2 && j1 > j2) || (i1 > i2 && j1 < j2) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Barycentric Sort
|
||||
pub fn barycentric_sort(
|
||||
blocks: &[Block],
|
||||
connections: &[Connection],
|
||||
iterations: usize,
|
||||
) -> (Vec<BlockId>, Vec<BlockId>) {
|
||||
// Initiale Reihenfolge
|
||||
let mut input_order: Vec<BlockId> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.block_type.is_input())
|
||||
.map(|b| b.id)
|
||||
.collect();
|
||||
let mut target_order: Vec<BlockId> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.block_type.is_target())
|
||||
.map(|b| b.id)
|
||||
.collect();
|
||||
|
||||
let mut input_to_targets: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
let mut target_to_inputs: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
|
||||
for con in connections {
|
||||
if blocks[con.from as usize - 1].block_type.is_input()
|
||||
&& blocks[con.to as usize - 1].block_type.is_target()
|
||||
{
|
||||
input_to_targets.entry(con.from).or_default().push(con.to);
|
||||
target_to_inputs.entry(con.to).or_default().push(con.from);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative Barycenter-Sortierung
|
||||
for _ in 0..iterations {
|
||||
// sort inputs by middle value of targets
|
||||
input_order.sort_by(|&a, &b| {
|
||||
barycenter(a, &input_to_targets, &target_order)
|
||||
.partial_cmp(&barycenter(b, &input_to_targets, &target_order))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
// sort targets by middle value of inputs
|
||||
target_order.sort_by(|&a, &b| {
|
||||
barycenter(a, &target_to_inputs, &input_order)
|
||||
.partial_cmp(&barycenter(b, &target_to_inputs, &input_order))
|
||||
.unwrap()
|
||||
});
|
||||
}
|
||||
|
||||
// simple local cross optimisation for inputs
|
||||
let mut improved = true;
|
||||
for _ in 0..10 {
|
||||
if !improved { break; }
|
||||
improved = false;
|
||||
for i in 0..input_order.len().saturating_sub(1) {
|
||||
let mut swapped = input_order.clone();
|
||||
swapped.swap(i, i + 1);
|
||||
if count_crossings(&swapped, &target_order, connections) < count_crossings(&input_order, &target_order, connections) {
|
||||
input_order.swap(i, i + 1);
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// simple local cross optimisation for targets
|
||||
improved = true;
|
||||
for _ in 0..10 {
|
||||
if !improved { break; }
|
||||
improved = false;
|
||||
for i in 0..target_order.len().saturating_sub(1) {
|
||||
let mut swapped = target_order.clone();
|
||||
swapped.swap(i, i + 1);
|
||||
if count_crossings(&input_order, &swapped, connections) < count_crossings(&input_order, &target_order, connections) {
|
||||
target_order.swap(i, i + 1);
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(input_order, target_order)
|
||||
}
|
||||
|
||||
pub fn layout(blocks: &mut [Block], connections: &[Connection]) {
|
||||
|
||||
let (input_order, target_order) = barycentric_sort(blocks, connections, 5);
|
||||
|
||||
let mut target_blocks = build_target_blocks(blocks, connections);
|
||||
target_blocks.sort_by_key(|a| target_order.iter().position(|&id| id == a.id).unwrap());
|
||||
|
||||
let mut start_y = CANVAS_OFFSET;
|
||||
let start_x = CANVAS_OFFSET + BLOCK_WIDTH + X_GAP;
|
||||
for target_block in &mut target_blocks {
|
||||
target_block.set_position(start_x, start_y, blocks);
|
||||
start_y += target_block.height + Y_GAP;
|
||||
}
|
||||
|
||||
let target_map: HashMap<BlockId, &TargetBlock> = target_blocks
|
||||
.iter()
|
||||
.map(|t| (t.id, t))
|
||||
.collect();
|
||||
|
||||
let mut last_input_y = CANVAS_OFFSET;
|
||||
|
||||
for block_id in &input_order {
|
||||
let connected_targets: Vec<&TargetBlock> = connections
|
||||
.iter()
|
||||
.filter(|c| c.from == *block_id)
|
||||
.filter_map(|c| target_map.get(&c.to))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let desired_y = if connected_targets.is_empty() {
|
||||
last_input_y
|
||||
} else {
|
||||
// Center Y-Position of target
|
||||
let min_y = connected_targets
|
||||
.iter()
|
||||
.map(|t| t.position.1)
|
||||
.fold(f32::INFINITY, |a, b| a.min(b));
|
||||
|
||||
let max_y = connected_targets
|
||||
.iter()
|
||||
.map(|t| t.position.1 + t.height)
|
||||
.fold(f32::NEG_INFINITY, |a, b| a.max(b));
|
||||
|
||||
(min_y + max_y)/2.0 - BLOCK_HEIGHT/2.0
|
||||
};
|
||||
|
||||
let mut final_y = desired_y;
|
||||
// prevent overlap
|
||||
if final_y < last_input_y {
|
||||
final_y = last_input_y;
|
||||
}
|
||||
|
||||
let block = &mut blocks[*block_id as usize -1];
|
||||
block.position = (CANVAS_OFFSET, final_y);
|
||||
last_input_y = final_y + BLOCK_HEIGHT + Y_GAP*2.0;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ mod input_form;
|
||||
mod target_form;
|
||||
mod output_form;
|
||||
mod editor_form;
|
||||
mod block;
|
||||
mod block_view;
|
||||
mod output_xtream_form;
|
||||
mod layout;
|
||||
|
||||
pub(crate) use editor_view::*;
|
||||
pub(crate) use editor_model::*;
|
||||
@@ -17,5 +18,5 @@ pub(crate) use input_form::*;
|
||||
pub(crate) use target_form::*;
|
||||
pub(crate) use output_form::*;
|
||||
pub(crate) use editor_form::*;
|
||||
pub(crate) use block::*;
|
||||
pub(crate) use block_view::*;
|
||||
pub(crate) use output_xtream_form::*;
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::app::components::{BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView};
|
||||
use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView};
|
||||
use shared::model::TargetOutputDto;
|
||||
use std::rc::Rc;
|
||||
use yew::{function_component, html, use_context, Html, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ConfigOutputViewProps {
|
||||
pub(crate) block_id: usize,
|
||||
pub(crate) block_id: BlockId,
|
||||
pub(crate) output: Option<Rc<TargetOutputDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::app::components::config::HasFormData;
|
||||
use crate::app::components::{BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton};
|
||||
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton};
|
||||
use crate::{ edit_field_bool, edit_field_number_u16, edit_field_text_option, generate_form_reducer};
|
||||
use shared::model::{ XtreamTargetOutputDto, TargetOutputDto};
|
||||
use std::fmt::Display;
|
||||
@@ -60,7 +60,7 @@ generate_form_reducer!(
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct XtreamTargetOutputViewProps {
|
||||
pub(crate) block_id: usize,
|
||||
pub(crate) block_id: BlockId,
|
||||
pub(crate) output: Option<Rc<XtreamTargetOutputDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct SourceEditorSidebarProps {
|
||||
pub on_toggle_delete: Callback<(String, MouseEvent)>,
|
||||
#[prop_or_default]
|
||||
pub on_drag_start: Callback<DragEvent>,
|
||||
#[prop_or_default]
|
||||
pub on_layout: Callback<(String, MouseEvent)>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
@@ -49,6 +51,7 @@ pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html {
|
||||
// Sidebar
|
||||
<div class="tp__source-editor__sidebar">
|
||||
<div class="tp__source-editor__sidebar-actions">
|
||||
<IconButton name="layout" icon="Nodes" onclick={props.on_layout.clone()} />
|
||||
// Delete mode toggle button
|
||||
<IconButton class={if props.delete_mode {"tp__source-editor__sidebar-actions-active"} else {""} } name="toggle_delete" icon="Delete" onclick={props.on_toggle_delete.clone()} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::app::components::config::HasFormData;
|
||||
use crate::app::components::select::Select;
|
||||
use crate::app::components::{BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton};
|
||||
use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton};
|
||||
use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer};
|
||||
use shared::model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ProcessingOrder};
|
||||
use std::fmt::Display;
|
||||
@@ -69,7 +69,7 @@ generate_form_reducer!(
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ConfigTargetViewProps {
|
||||
pub(crate) block_id: usize,
|
||||
pub(crate) block_id: BlockId,
|
||||
pub(crate) target: Option<Rc<ConfigTargetDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,6 @@ pub fn use_server_status(
|
||||
};
|
||||
|
||||
match event {
|
||||
ActiveUserConnectionChange::Connected(stream_info) => {
|
||||
server_status.active_user_streams.push(stream_info);
|
||||
}
|
||||
ActiveUserConnectionChange::Updated(stream_info) => {
|
||||
if let Some(pos) = server_status
|
||||
.active_user_streams
|
||||
|
||||
@@ -9,7 +9,8 @@ pub enum EventMessage {
|
||||
ServerError(String),
|
||||
ServerStatus(Rc<StatusCheck>),
|
||||
ActiveUser(ActiveUserConnectionChange),
|
||||
ActiveProvider(String, usize),
|
||||
ActiveProvider(String, usize), // single provider
|
||||
ActiveProviderCount(usize), // all provider
|
||||
ConfigChange(ConfigType),
|
||||
Busy(BusyStatus),
|
||||
PlaylistUpdate(PlaylistUpdateState),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use std::cell::RefCell;
|
||||
use super::{get_base_href, request_post};
|
||||
use crate::services::requests::set_token;
|
||||
use super::{check_dummy_token, get_base_href, request_post, set_token};
|
||||
use futures_signals::signal::Mutable;
|
||||
use futures_signals::signal::SignalExt;
|
||||
use shared::model::{Claims, TokenResponse, UserCredential, ROLE_ADMIN, ROLE_USER, TOKEN_NO_AUTH};
|
||||
use std::future::Future;
|
||||
use shared::utils::{concat_path, concat_path_leading_slash};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use log::warn;
|
||||
use crate::error::{Error, Error::BadRequest, Error::NotFound};
|
||||
use log::{warn};
|
||||
use crate::error::{Error, Error::Unauthorized};
|
||||
|
||||
fn decode_jwt_payload(token: &str) -> Option<Claims> {
|
||||
let payload_enc = token.split('.').nth(1)?;
|
||||
@@ -64,21 +63,10 @@ impl AuthService {
|
||||
self.auth_channel.set(false);
|
||||
}
|
||||
|
||||
fn no_auth(&self, err: Error) -> Result<TokenResponse, Error> {
|
||||
if matches!(err, BadRequest(_)) {
|
||||
self.username.replace("admin".to_string());
|
||||
self.auth_channel.set(true);
|
||||
set_token(Some(TOKEN_NO_AUTH));
|
||||
self.roles.borrow_mut().push(ROLE_ADMIN.to_string());
|
||||
Ok(TokenResponse {
|
||||
token: TOKEN_NO_AUTH.to_string(),
|
||||
username: "admin".to_string(),
|
||||
})
|
||||
} else {
|
||||
self.auth_channel.set(false);
|
||||
set_token(None);
|
||||
Err(err)
|
||||
}
|
||||
fn unauthorized(&self) -> Result<TokenResponse, Error> {
|
||||
self.auth_channel.set(false);
|
||||
set_token(None);
|
||||
Err(Unauthorized)
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self, username: String, password: String) -> Result<TokenResponse, Error> {
|
||||
@@ -94,12 +82,12 @@ impl AuthService {
|
||||
self.handle_token(&token.token);
|
||||
Ok(token)
|
||||
}
|
||||
Ok(None) => self.no_auth(NotFound),
|
||||
Err(e) => self.no_auth(e),
|
||||
_ => self.unauthorized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<TokenResponse, Error> {
|
||||
check_dummy_token();
|
||||
match request_post::<(), TokenResponse>(&concat_path(&self.auth_path, "refresh"), (), None, None).await {
|
||||
Ok(Some(token)) => {
|
||||
self.username.replace(token.username.to_string());
|
||||
@@ -108,8 +96,7 @@ impl AuthService {
|
||||
self.handle_token(&token.token);
|
||||
Ok(token)
|
||||
}
|
||||
Ok(None) => self.no_auth(NotFound),
|
||||
Err(e) => self.no_auth(e),
|
||||
_ => self.unauthorized(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +107,7 @@ impl AuthService {
|
||||
if token == TOKEN_NO_AUTH {
|
||||
roles.push(ROLE_ADMIN.to_string());
|
||||
}
|
||||
|
||||
|
||||
if let Some(claims) = decode_jwt_payload(token) {
|
||||
for role in claims.roles.iter() {
|
||||
roles.push(role.clone());
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::error::{Error, ErrorInfo, ErrorSetInfo};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use log::{error};
|
||||
use log::error;
|
||||
use reqwasm::http::Request;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use web_sys::window;
|
||||
use shared::utils::bin_deserialize;
|
||||
use web_sys::window;
|
||||
|
||||
const CONTENT_TYPE_BIN: &str = "application/cbor";
|
||||
const CONTENT_TYPE_JSON: &str = "application/json";
|
||||
@@ -32,6 +32,16 @@ pub fn set_token(token: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
const DUMMY_TOKEN: &str = "eyJraWQiOiJkZWZhdWx0IiwiYWxnIjoiUlMyNTYifQ.eyJsb2dpbiI6ImR1bW15In0.WWzZP0hICmJeIgMLVYNOpayriEC08J_lYssk9z8GglHXfZ6oJUDv3svlJDA8sQG025VA_LR5UzyyiWeQDCdpWyrCI_nI2Xd-3ga3JwWtxHE9NWFalgq0Q9jjxoB4LYWCXsAkqoZqk6s7b3F5Fi_h5oYHfwM4h8hXEbrgnJ_Z1wpSc7HNh6SUnOllxcaJOxYlRrlUn3XulSSf2NhHe3XotvFguiIV1-RIns3cSIL29bvMUEFw84w7BfJn-joynZsWlfJBvzyOiuDqduXa0deH7b962unM2wPpbvTgliJhFFOUBHClRhBOmoo0cijuMZB4K7NjgjGmU5eVfHG6pVWs_b0ikS4V_P6RJcNS6Alcc_HB_YXv0yCD3pjcBbuRXAskivEhgXuecdRMGQgohAhXplLuu5SR0K6Bcrt7UFFnBi2qN6fbw1i4s8PDXqiTu4rIg9agCkVNfplRvj8Szl6egF0Vd1TN1WGEarkdINEUyfNQkAihFY5BKxfaPun1-a0VydRMZElu6VzrrUMxXt4T7zybuJZI63C3mKLEHZixdSC76c9AE-zGom5LZYE4mqwd4dW3QHtWFZgGZiL9C_VBIf63WzjTYhWVuO2U8O9bsKkSEl5L-Ww9j8ccDHp5nc7y6yUgSYd600TBRI7WblFovLsl2tjElvUqfJZhj6JmX_Q";
|
||||
|
||||
// The Authorization header is for the Backend Authenticator mandatory.
|
||||
// If we don't set a dummy token the backend api cant be called.
|
||||
pub fn check_dummy_token() {
|
||||
if get_token().is_none() {
|
||||
set_token(Some(DUMMY_TOKEN));
|
||||
}
|
||||
}
|
||||
|
||||
/// build all kinds of http request: post/get/delete etc.
|
||||
async fn request<B, T>(method: RequestMethod, url: &str, body: B, content_type: Option<String>,
|
||||
response_type: Option<String>) -> Result<Option<T>, Error>
|
||||
@@ -43,14 +53,14 @@ where
|
||||
let r_type = response_type.as_ref().map_or(CONTENT_TYPE_JSON, |c| c.as_str());
|
||||
let mut request = match method {
|
||||
RequestMethod::Get => Request::get(url),
|
||||
RequestMethod::Post => {
|
||||
RequestMethod::Post => {
|
||||
let json = serde_json::to_string(&body).map_err(|_| Error::RequestError)?;
|
||||
Request::post(url).body(json).header("Content-Type", c_type)
|
||||
},
|
||||
}
|
||||
RequestMethod::Put => {
|
||||
let json = serde_json::to_string(&body).map_err(|_| Error::RequestError)?;
|
||||
Request::put(url).body(json).header("Content-Type", c_type)
|
||||
},
|
||||
}
|
||||
// RequestMethod::PATCH => Request::patch(&url).body(serde_json::to_string(&body).unwrap()),
|
||||
RequestMethod::Delete => Request::delete(url),
|
||||
};
|
||||
@@ -85,7 +95,7 @@ where
|
||||
Err(err) => {
|
||||
error!("Failed to deserialize {err}");
|
||||
Err(Error::DeserializeError)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{WebSocket, MessageEvent, Event, ErrorEvent, CloseEvent};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
|
||||
use web_sys::js_sys::{Uint8Array, ArrayBuffer};
|
||||
use log::{debug, error, trace};
|
||||
use log::{error, trace, warn};
|
||||
use shared::model::{ProtocolMessage, PROTOCOL_VERSION};
|
||||
use shared::utils::{concat_path_leading_slash};
|
||||
use crate::model::EventMessage;
|
||||
use crate::services::{get_base_href, get_token, EventService, StatusService};
|
||||
use crate::utils::set_timeout;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
const WS_RECONNECT_MS:i32 = 2000;
|
||||
const WS_RECONNECT_BASE_MS: u32 = 300;
|
||||
const WS_RECONNECT_MAX_MS: u32 = 2000;
|
||||
const WS_RECONNECT_MAX_ATTEMPTS: u32 = 20;
|
||||
|
||||
fn reconnect_delay(attempt: u32) -> u32 {
|
||||
if attempt < 6 {
|
||||
let d = WS_RECONNECT_BASE_MS * (attempt+1);
|
||||
d.min(WS_RECONNECT_MAX_MS)
|
||||
} else {
|
||||
WS_RECONNECT_MAX_MS
|
||||
}
|
||||
}
|
||||
|
||||
type JsOnMessageCallback = Option<Closure<dyn FnMut(MessageEvent)>>;
|
||||
type JsOnCloseCallback = Option<Closure<dyn FnMut(CloseEvent)>>;
|
||||
type JsOnErrorCallback = Option<Closure<dyn FnMut(ErrorEvent)>>;
|
||||
type JsOnOpenCallback = Option<Closure<dyn FnMut(Event)>>;
|
||||
|
||||
pub struct WebSocketService {
|
||||
connected: Rc<AtomicBool>,
|
||||
attempt_counter: Rc<AtomicU16>,
|
||||
ws: Rc<RefCell<Option<WebSocket>>>,
|
||||
status_service: Rc<StatusService>,
|
||||
event_service: Rc<EventService>,
|
||||
ws_path: String,
|
||||
|
||||
// store closures so they live as long as we want and can be dropped on close
|
||||
ws_onmessage: Rc<RefCell<JsOnMessageCallback>>,
|
||||
ws_onopen: Rc<RefCell<JsOnOpenCallback>>,
|
||||
ws_onclose: Rc<RefCell<JsOnCloseCallback>>,
|
||||
ws_onerror: Rc<RefCell<JsOnErrorCallback>>,
|
||||
}
|
||||
|
||||
impl WebSocketService {
|
||||
@@ -27,24 +50,35 @@ impl WebSocketService {
|
||||
let base_href = get_base_href();
|
||||
Self {
|
||||
connected: Rc::new(AtomicBool::new(false)),
|
||||
attempt_counter: Rc::new(AtomicU16::new(0)),
|
||||
ws: Rc::new(RefCell::new(None)),
|
||||
status_service,
|
||||
event_service,
|
||||
ws_path: concat_path_leading_slash(&base_href, "ws"),
|
||||
ws_onmessage: Rc::new(RefCell::new(None)),
|
||||
ws_onopen: Rc::new(RefCell::new(None)),
|
||||
ws_onclose: Rc::new(RefCell::new(None)),
|
||||
ws_onerror: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to allow cloning the service into JS closures for reconnect
|
||||
fn clone_for_reconnect(&self) -> Self {
|
||||
Self {
|
||||
connected: self.connected.clone(),
|
||||
attempt_counter: self.attempt_counter.clone(),
|
||||
ws: self.ws.clone(),
|
||||
status_service: self.status_service.clone(),
|
||||
event_service: self.event_service.clone(),
|
||||
ws_path: self.ws_path.clone(),
|
||||
ws_onmessage: self.ws_onmessage.clone(),
|
||||
ws_onopen: self.ws_onopen.clone(),
|
||||
ws_onclose: self.ws_onclose.clone(),
|
||||
ws_onerror: self.ws_onerror.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_ws(&self) {
|
||||
pub fn connect_ws_with_backoff(&self) {
|
||||
if self.connected.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
@@ -54,119 +88,125 @@ impl WebSocketService {
|
||||
socket.set_binary_type(web_sys::BinaryType::Arraybuffer);
|
||||
let ws_clone = self.ws.clone();
|
||||
*ws_clone.borrow_mut() = Some(socket.clone());
|
||||
let event_service = self.event_service.clone();
|
||||
|
||||
let ws_onmessage_clone = Rc::clone(&ws_clone);
|
||||
// onmessage
|
||||
let onmessage_callback = Closure::<dyn FnMut(_)>::wrap(Box::new(move |event: MessageEvent| {
|
||||
trace!("WebSocket received message: {event:?}");
|
||||
if let Ok(buf) = event.data().dyn_into::<ArrayBuffer>() {
|
||||
let array = Uint8Array::new(&buf);
|
||||
let bytes = bytes::Bytes::from(array.to_vec());
|
||||
match ProtocolMessage::from_bytes(bytes) {
|
||||
Ok(message) => {
|
||||
match message {
|
||||
ProtocolMessage::Unauthorized => {
|
||||
event_service.broadcast(EventMessage::Unauthorized);
|
||||
},
|
||||
ProtocolMessage::Error(err) => {
|
||||
error!("{err}");
|
||||
},
|
||||
ProtocolMessage::ActiveUserResponse(event) => {
|
||||
event_service.broadcast(EventMessage::ActiveUser(event));
|
||||
},
|
||||
ProtocolMessage::ActiveProviderResponse(user_count, connections) => {
|
||||
event_service.broadcast(EventMessage::ActiveProvider(user_count, connections));
|
||||
},
|
||||
ProtocolMessage::StatusResponse(status) => {
|
||||
let data = Rc::new(status);
|
||||
event_service.broadcast(EventMessage::ServerStatus(data));
|
||||
}
|
||||
ProtocolMessage::ConfigChangeResponse(config_type) => {
|
||||
if !event_service.is_config_change_message_blocked() {
|
||||
event_service.broadcast(EventMessage::ConfigChange(config_type));
|
||||
}
|
||||
}
|
||||
ProtocolMessage::ServerError(error) => {
|
||||
event_service.broadcast(EventMessage::ServerError(error));
|
||||
}
|
||||
ProtocolMessage::PlaylistUpdateResponse(update_state) => {
|
||||
event_service.broadcast(EventMessage::PlaylistUpdate(update_state));
|
||||
}
|
||||
ProtocolMessage::PlaylistUpdateProgressResponse(target, msg) => {
|
||||
event_service.broadcast(EventMessage::PlaylistUpdateProgress(target, msg));
|
||||
}
|
||||
ProtocolMessage::Version(_) => {
|
||||
if let Some(token) = get_token() {
|
||||
Self::try_send_message(ws_onmessage_clone.borrow().as_ref(), ProtocolMessage::Auth(token));
|
||||
}
|
||||
}
|
||||
ProtocolMessage::UserActionResponse(_success) => {
|
||||
// Success is already handled in the UI component that initiated the action
|
||||
}
|
||||
ProtocolMessage::Auth(_)
|
||||
| ProtocolMessage::Authorized
|
||||
| ProtocolMessage::StatusRequest(_)
|
||||
| ProtocolMessage::UserAction(_) => {}
|
||||
}
|
||||
}
|
||||
Err(err) => error!("Failed to decode websocket message: {err}")
|
||||
{
|
||||
let ws_onmessage_ref = self.ws_onmessage.clone();
|
||||
let ws_onmessage_clone = ws_clone.clone();
|
||||
let event_service = self.event_service.clone();
|
||||
let attempt_counter = self.attempt_counter.clone();
|
||||
let onmessage_callback = Closure::<dyn FnMut(MessageEvent)>::wrap(Box::new(move |event: MessageEvent| {
|
||||
trace!("WebSocket received message: {event:?}");
|
||||
if let Some(response) = handle_socket_protocol_msg(event, &event_service, &attempt_counter) {
|
||||
Self::try_send_message(ws_onmessage_clone.borrow().as_ref(), response);
|
||||
}
|
||||
}
|
||||
}));
|
||||
socket.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget(); // Important: leak the closure to keep it alive
|
||||
}));
|
||||
socket.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
// store closure so we can drop it later (no forget)
|
||||
ws_onmessage_ref.borrow_mut().replace(onmessage_callback);
|
||||
}
|
||||
|
||||
let ws_open_clone = Rc::clone(&ws_clone);
|
||||
let connected_clone = self.connected.clone();
|
||||
let event_service_clone = Rc::clone(&self.event_service);
|
||||
// onopen
|
||||
let onopen_callback = Closure::<dyn FnMut(_)>::wrap(Box::new(move |_event: Event| {
|
||||
trace!("WebSocket connection opened.");
|
||||
connected_clone.store(true, Ordering::SeqCst);
|
||||
Self::try_send_message(ws_open_clone.borrow().as_ref(), ProtocolMessage::Version(PROTOCOL_VERSION));
|
||||
event_service_clone.broadcast(EventMessage::WebSocketStatus(true));
|
||||
}));
|
||||
socket.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
onopen_callback.forget();
|
||||
{
|
||||
let ws_onopen_ref = self.ws_onopen.clone();
|
||||
let ws_open_clone = ws_clone.clone();
|
||||
let connected_clone = self.connected.clone();
|
||||
let onopen_callback = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_event: Event| {
|
||||
// on open is called on a connect attempt, it does not mean it is connected!
|
||||
trace!("WebSocket connection opened.");
|
||||
if Self::try_send_message(ws_open_clone.borrow().as_ref(), ProtocolMessage::Version(PROTOCOL_VERSION)) {
|
||||
connected_clone.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
socket.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
ws_onopen_ref.borrow_mut().replace(onopen_callback);
|
||||
}
|
||||
|
||||
let ws_close_rc = self.ws.clone();
|
||||
let connected_clone = self.connected.clone();
|
||||
// prepare a reconnect-holder (shared clone)
|
||||
let ws_service_reconnect = Rc::new(self.clone_for_reconnect());
|
||||
let event_service_clone = Rc::clone(&self.event_service);
|
||||
let onclose_callback = Closure::<dyn FnMut(_)>::wrap(Box::new(move |e: CloseEvent| {
|
||||
debug!("Websocket closed, Code: {}, Reason: {}, WasClean: {}", e.code(), e.reason(), e.was_clean());
|
||||
*ws_close_rc.borrow_mut() = None;
|
||||
connected_clone.store(false, Ordering::SeqCst);
|
||||
event_service_clone.broadcast(EventMessage::WebSocketStatus(false));
|
||||
|
||||
// schedule reconnect after 3 seconds
|
||||
let ws_service_inner = ws_service_reconnect.clone();
|
||||
set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS);
|
||||
}));
|
||||
socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
onclose_callback.forget();
|
||||
// onclose
|
||||
{
|
||||
let ws_onclose_ref = self.ws_onclose.clone();
|
||||
let ws_onmessage_ref = self.ws_onmessage.clone();
|
||||
let ws_onopen_ref = self.ws_onopen.clone();
|
||||
let ws_onerror_ref = self.ws_onerror.clone();
|
||||
let ws_close_rc = self.ws.clone();
|
||||
let connected_clone = self.connected.clone();
|
||||
let event_service_clone = Rc::clone(&self.event_service);
|
||||
let ws_service_reconnect_clone = Rc::clone(&ws_service_reconnect);
|
||||
|
||||
let onclose_callback = Closure::<dyn FnMut(CloseEvent)>::wrap(Box::new(move |e: CloseEvent| {
|
||||
trace!(
|
||||
"WebSocket closed (Code {}, Reason: {}, Clean: {})",
|
||||
e.code(), e.reason(), e.was_clean()
|
||||
);
|
||||
|
||||
// clear JS handlers on the underlying socket if present
|
||||
if let Some(s) = ws_close_rc.borrow().as_ref() {
|
||||
s.set_onmessage(None);
|
||||
s.set_onopen(None);
|
||||
s.set_onerror(None);
|
||||
s.set_onclose(None);
|
||||
}
|
||||
|
||||
// drop stored closures so they are freed
|
||||
ws_onmessage_ref.borrow_mut().take();
|
||||
ws_onopen_ref.borrow_mut().take();
|
||||
ws_onerror_ref.borrow_mut().take();
|
||||
// Note: we keep ws_onclose alive (this closure) until function returns;
|
||||
// it will be dropped when the service or field is taken elsewhere if desired.
|
||||
|
||||
*ws_close_rc.borrow_mut() = None;
|
||||
connected_clone.store(false, Ordering::SeqCst);
|
||||
event_service_clone.broadcast(EventMessage::WebSocketStatus(false));
|
||||
|
||||
// schedule reconnect
|
||||
ws_service_reconnect_clone.schedule_reconnect();
|
||||
}));
|
||||
socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
ws_onclose_ref.borrow_mut().replace(onclose_callback);
|
||||
}
|
||||
|
||||
let connected_clone = self.connected.clone();
|
||||
let ws_service_reconnect = Rc::new(self.clone_for_reconnect());
|
||||
let event_service_clone = Rc::clone(&self.event_service);
|
||||
// onerror
|
||||
let onerror_callback = Closure::<dyn FnMut(_)>::wrap(Box::new(move |e: ErrorEvent| {
|
||||
error!("WebSocket error");
|
||||
connected_clone.store(false, Ordering::SeqCst);
|
||||
event_service_clone.broadcast(EventMessage::WebSocketStatus(false));
|
||||
web_sys::console::error_1(&e);
|
||||
{
|
||||
let ws_onerror_ref = self.ws_onerror.clone();
|
||||
let connected_clone = self.connected.clone();
|
||||
let event_service_clone = Rc::clone(&self.event_service);
|
||||
//let ws_service_reconnect_clone = Rc::clone(&ws_service_reconnect);
|
||||
|
||||
// schedule reconnect after 3 seconds
|
||||
let ws_service_inner = ws_service_reconnect.clone();
|
||||
set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS);
|
||||
}));
|
||||
socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget();
|
||||
let onerror_callback = Closure::<dyn FnMut(ErrorEvent)>::wrap(Box::new(move |e: ErrorEvent| {
|
||||
error!("WebSocket error: {:?}", e);
|
||||
connected_clone.store(false, Ordering::SeqCst);
|
||||
event_service_clone.broadcast(EventMessage::WebSocketStatus(false));
|
||||
// ws_service_reconnect_clone.schedule_reconnect();
|
||||
}));
|
||||
socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
ws_onerror_ref.borrow_mut().replace(onerror_callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_reconnect(&self) {
|
||||
// increment attempts atomically and get the previous value
|
||||
let attempt = self.attempt_counter.fetch_add(1, Ordering::SeqCst) as u32;
|
||||
|
||||
if attempt >= WS_RECONNECT_MAX_ATTEMPTS {
|
||||
warn!("WebSocket reconnect attempts exceeded ({attempt}). Giving up.");
|
||||
return;
|
||||
}
|
||||
let delay = reconnect_delay(attempt);
|
||||
|
||||
warn!("WebSocket reconnect attempt #{attempt} scheduled in {} ms", delay);
|
||||
|
||||
// clone_for_reconnect returns a Service with shared internals (Atomic, etc.)
|
||||
let ws_clone = Rc::new(self.clone_for_reconnect());
|
||||
set_timeout(move || {
|
||||
ws_clone.connect_ws_with_backoff();
|
||||
}, delay as i32);
|
||||
}
|
||||
|
||||
fn try_send_message(ws_opt: Option<&WebSocket>, msg: ProtocolMessage) -> bool {
|
||||
if let Some(ws) = ws_opt {
|
||||
match msg.to_bytes() {
|
||||
@@ -205,30 +245,71 @@ impl WebSocketService {
|
||||
Err(err) => {error!("Failed to get server status: {err:?}");}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO
|
||||
// on no wesocket connection
|
||||
|
||||
// let fetch_status = {
|
||||
// let status = status_signal.clone();
|
||||
// let services_ctx = services_ctx.clone();
|
||||
// move || {
|
||||
// let status = status.clone();
|
||||
// let services_ctx = services_ctx.clone();
|
||||
// spawn_local(async move {
|
||||
// status.set(services_ctx.status.get_server_status().await.ok());
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// fetch_status();
|
||||
// // all 5 seconds
|
||||
// let interval = Interval::new(5000, move || {
|
||||
// fetch_status();
|
||||
// });
|
||||
//
|
||||
// // Cleanup function
|
||||
// || drop(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_socket_protocol_msg(event: MessageEvent, event_service: &Rc<EventService>, attempt_counter: &Rc<AtomicU16>) -> Option<ProtocolMessage>{
|
||||
if let Ok(buf) = event.data().dyn_into::<ArrayBuffer>() {
|
||||
let array = Uint8Array::new(&buf);
|
||||
let bytes = bytes::Bytes::from(array.to_vec());
|
||||
match ProtocolMessage::from_bytes(bytes) {
|
||||
Ok(message) => {
|
||||
match message {
|
||||
ProtocolMessage::Unauthorized => {
|
||||
event_service.broadcast(EventMessage::Unauthorized);
|
||||
},
|
||||
ProtocolMessage::Error(err) => {
|
||||
error!("{err}");
|
||||
},
|
||||
ProtocolMessage::ActiveUserResponse(event) => {
|
||||
event_service.broadcast(EventMessage::ActiveUser(event));
|
||||
},
|
||||
ProtocolMessage::ActiveProviderResponse(provider_name, connections) => {
|
||||
event_service.broadcast(EventMessage::ActiveProvider(provider_name, connections));
|
||||
if let Some(token) = get_token() {
|
||||
return Some(ProtocolMessage::ActiveProviderCountRequest(token));
|
||||
}
|
||||
},
|
||||
ProtocolMessage::ActiveProviderCountResponse(connections) => {
|
||||
event_service.broadcast(EventMessage::ActiveProviderCount(connections));
|
||||
}
|
||||
ProtocolMessage::StatusResponse(status) => {
|
||||
let data = Rc::new(status);
|
||||
event_service.broadcast(EventMessage::ServerStatus(data));
|
||||
}
|
||||
ProtocolMessage::ConfigChangeResponse(config_type) => {
|
||||
if !event_service.is_config_change_message_blocked() {
|
||||
event_service.broadcast(EventMessage::ConfigChange(config_type));
|
||||
}
|
||||
}
|
||||
ProtocolMessage::ServerError(error) => {
|
||||
event_service.broadcast(EventMessage::ServerError(error));
|
||||
}
|
||||
ProtocolMessage::PlaylistUpdateResponse(update_state) => {
|
||||
event_service.broadcast(EventMessage::PlaylistUpdate(update_state));
|
||||
}
|
||||
ProtocolMessage::PlaylistUpdateProgressResponse(target, msg) => {
|
||||
event_service.broadcast(EventMessage::PlaylistUpdateProgress(target, msg));
|
||||
}
|
||||
ProtocolMessage::Version(_) => {
|
||||
attempt_counter.store(0, Ordering::SeqCst);
|
||||
event_service.broadcast(EventMessage::WebSocketStatus(true));
|
||||
if let Some(token) = get_token() {
|
||||
return Some(ProtocolMessage::Auth(token));
|
||||
}
|
||||
}
|
||||
ProtocolMessage::UserActionResponse(_success) => {
|
||||
// Success is already handled in the UI component that initiated the action
|
||||
}
|
||||
ProtocolMessage::Auth(_)
|
||||
| ProtocolMessage::Authorized
|
||||
| ProtocolMessage::ActiveProviderCountRequest(_)
|
||||
| ProtocolMessage::StatusRequest(_)
|
||||
| ProtocolMessage::UserAction(_) => {}
|
||||
}
|
||||
}
|
||||
Err(err) => error!("Failed to decode websocket message: {err}")
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "3.1.8"
|
||||
version = "3.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bitflags = "2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
regex = "1"
|
||||
enum-iterator = "2"
|
||||
log = "0"
|
||||
|
||||
@@ -4,7 +4,7 @@ and = { ^"and" }
|
||||
or = { ^"or" }
|
||||
not = { ^"not" }
|
||||
regexp = @{ "\"" ~ ( "\\\"" | (!"\"" ~ ANY) )* ~ "\"" }
|
||||
type_value = { ^"live" | ^"vod" | ^"series" }
|
||||
type_value = { ^"live" | ^"vod" | ^"movie" | ^"series" }
|
||||
type_comparison = { ^"type" ~ "=" ~ type_value }
|
||||
field_comparison_value = _{ regexp }
|
||||
field_comparison = { field ~ "~" ~ field_comparison_value }
|
||||
|
||||
@@ -93,7 +93,7 @@ and = { ^"and" }
|
||||
or = { ^"or" }
|
||||
not = { ^"not" }
|
||||
regexp = @{ "\"" ~ ( "\\\"" | (!"\"" ~ ANY) )* ~ "\"" }
|
||||
type_value = { ^"live" | ^"vod" | ^"series" }
|
||||
type_value = { ^"live" | ^"vod" | ^"movie" | ^"series" }
|
||||
type_comparison = { ^"type" ~ "=" ~ type_value }
|
||||
field_comparison_value = _{ regexp }
|
||||
field_comparison = { field ~ "~" ~ field_comparison_value }
|
||||
@@ -216,6 +216,7 @@ impl Filter {
|
||||
impl Filter {
|
||||
const LIVE: &'static str = "live";
|
||||
const VOD: &'static str = "vod";
|
||||
const MOVIE: &'static str = "movie";
|
||||
const SERIES: &'static str = "series";
|
||||
const UNSUPPORTED: &'static str = "unsupported";
|
||||
}
|
||||
@@ -229,7 +230,7 @@ impl std::fmt::Display for Filter {
|
||||
Self::TypeComparison(field, item_type) => {
|
||||
write!(f, "{} = {}", field, match item_type {
|
||||
PlaylistItemType::Live => Self::LIVE,
|
||||
PlaylistItemType::Video => Self::VOD,
|
||||
PlaylistItemType::Video => Self::MOVIE,
|
||||
PlaylistItemType::Series | PlaylistItemType::SeriesInfo => Self::SERIES, // yes series-info is handled as series in filter
|
||||
_ => Self::UNSUPPORTED
|
||||
})
|
||||
@@ -304,9 +305,9 @@ fn get_parser_field_comparison(
|
||||
fn get_filter_item_type(text_item_type: &str) -> Option<PlaylistItemType> {
|
||||
if text_item_type.eq_ignore_ascii_case("live") {
|
||||
Some(PlaylistItemType::Live)
|
||||
} else if text_item_type.eq_ignore_ascii_case("vod")
|
||||
} else if text_item_type.eq_ignore_ascii_case(Filter::VOD)
|
||||
|| text_item_type.eq_ignore_ascii_case("video")
|
||||
|| text_item_type.eq_ignore_ascii_case("movie")
|
||||
|| text_item_type.eq_ignore_ascii_case(Filter::MOVIE)
|
||||
{
|
||||
Some(PlaylistItemType::Video)
|
||||
} else if text_item_type.eq_ignore_ascii_case("series") {
|
||||
|
||||
@@ -18,7 +18,7 @@ block_expr = { "{" ~ statements ~ "}" }
|
||||
condition = { function_call | var_access | field_access }
|
||||
assignment = { (field_access | identifier) ~ "=" ~ expression }
|
||||
expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr }
|
||||
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" }
|
||||
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format"}
|
||||
function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" }
|
||||
any_match = { "_" }
|
||||
match_case_key = { any_match | identifier }
|
||||
|
||||
+153
-14
@@ -1,21 +1,22 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use crate::foundation::filter::{ValueAccessor};
|
||||
use crate::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind};
|
||||
use crate::foundation::filter::ValueAccessor;
|
||||
use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value};
|
||||
use crate::model::{PatternTemplate, TemplateValue};
|
||||
use crate::utils::Capitalize;
|
||||
use log::{debug, trace};
|
||||
use pest::iterators::{Pair, Pairs};
|
||||
use pest::Parser;
|
||||
use pest_derive::Parser;
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Write;
|
||||
use std::ops::Deref;
|
||||
use std::str::FromStr;
|
||||
use pest::Parser;
|
||||
use crate::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result, info_err};
|
||||
use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value};
|
||||
use crate::model::{PatternTemplate, TemplateValue};
|
||||
use crate::utils::Capitalize;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[grammar_inline = r##"
|
||||
@@ -39,7 +40,7 @@ block_expr = { "{" ~ statements ~ "}" }
|
||||
condition = { function_call | var_access | field_access }
|
||||
assignment = { (field_access | identifier) ~ "=" ~ expression }
|
||||
expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr }
|
||||
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" }
|
||||
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" }
|
||||
function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" }
|
||||
any_match = { "_" }
|
||||
match_case_key = { any_match | identifier }
|
||||
@@ -118,6 +119,8 @@ pub enum BuiltInFunction {
|
||||
First,
|
||||
Template,
|
||||
Replace,
|
||||
Pad,
|
||||
Format,
|
||||
}
|
||||
|
||||
impl FromStr for BuiltInFunction {
|
||||
@@ -135,6 +138,8 @@ impl FromStr for BuiltInFunction {
|
||||
"first" => Ok(Self::First),
|
||||
"template" => Ok(Self::Template),
|
||||
"replace" => Ok(Self::Replace),
|
||||
"pad" => Ok(Self::Pad),
|
||||
"format" => Ok(Self::Format),
|
||||
_ => create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown function {s}"),
|
||||
}
|
||||
}
|
||||
@@ -153,6 +158,8 @@ impl Display for BuiltInFunction {
|
||||
Self::First => "first",
|
||||
Self::Template => "template",
|
||||
Self::Replace => "replace",
|
||||
Self::Pad => "pad",
|
||||
Self::Format => "format",
|
||||
}.to_owned();
|
||||
write!(f, "{str}")
|
||||
}
|
||||
@@ -620,7 +627,7 @@ impl<'a> MapperContext<'a> {
|
||||
}
|
||||
Some(hash_map)
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,13 +703,18 @@ impl<'a> MapperContext<'a> {
|
||||
if args.len() > 1 {
|
||||
return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts only one argument {:?}, {} given", name, args.len());
|
||||
}
|
||||
},
|
||||
}
|
||||
BuiltInFunction::Replace => {
|
||||
if args.len() != 3 {
|
||||
if args.len() != 3 {
|
||||
return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three arguments {:?}, {} given", name, args.len());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
BuiltInFunction::Pad => {
|
||||
if !(args.len() == 3 || args.len() == 4) {
|
||||
return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three or four arguments {:?}, {} given", name, args.len());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for expr_id in args {
|
||||
self.validate_expr(*expr_id, identifiers)?;
|
||||
@@ -931,8 +943,22 @@ fn concat_args(args: &Vec<EvalResult>) -> Vec<Cow<'_, str>> {
|
||||
|
||||
macro_rules! extract_evaluated_arg_value {
|
||||
($evaluated_args:expr, $index:expr) => {{
|
||||
let evaluated_arg = &$evaluated_args[$index];
|
||||
match evaluated_arg {
|
||||
if $index >= $evaluated_args.len() {
|
||||
None
|
||||
} else {
|
||||
let evaluated_arg = &$evaluated_args[$index];
|
||||
match evaluated_arg {
|
||||
Value(value) => Some(value),
|
||||
Named(values) => values.first().map(|(_key, val)| val),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! extract_arg_value {
|
||||
($evaluated_arg:expr) => {{
|
||||
match $evaluated_arg {
|
||||
Value(value) => Some(value),
|
||||
Named(values) => values.first().map(|(_key, val)| val),
|
||||
_ => None,
|
||||
@@ -1122,6 +1148,98 @@ impl Expression {
|
||||
evaluated_args[0].clone()
|
||||
}
|
||||
}
|
||||
BuiltInFunction::Pad => {
|
||||
let value = match &evaluated_args[0] {
|
||||
Number(value) => Some(value.to_string()),
|
||||
Value(value) => Some(value.clone()),
|
||||
Named(values) => values.first().map(|(_key, val)| val.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let width = match &evaluated_args[1] {
|
||||
Number(value) => {
|
||||
if value.is_nan() || value.is_infinite() {
|
||||
0
|
||||
} else {
|
||||
value.abs().min(usize::MAX as f64) as usize
|
||||
}
|
||||
}
|
||||
Value(value) => value.parse::<usize>().ok().unwrap_or(0),
|
||||
Named(values) => values.first().and_then(|(_key, val)| val.parse::<usize>().ok()).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let fill = match &evaluated_args[2] {
|
||||
Number(value) => Some(value.to_string()),
|
||||
Value(value) => Some(value.clone()),
|
||||
Named(values) => values.first().map(|(_key, val)| val.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let align = extract_evaluated_arg_value!(evaluated_args, 3); // "<", ">", "^"
|
||||
|
||||
if let Some(text) = value {
|
||||
let fill_char = fill.and_then(|s| s.chars().next()).unwrap_or(' ');
|
||||
|
||||
let padded = if width <= text.len() {
|
||||
text.clone()
|
||||
} else {
|
||||
let pad = width - text.len();
|
||||
if let Some(al) = align {
|
||||
match al.as_str() {
|
||||
"^" => {
|
||||
let left = pad / 2;
|
||||
let right = pad - left;
|
||||
format!(
|
||||
"{}{}{}",
|
||||
fill_char.to_string().repeat(left),
|
||||
text,
|
||||
fill_char.to_string().repeat(right)
|
||||
)
|
||||
}
|
||||
"<" => format!("{}{}", text, fill_char.to_string().repeat(pad)),
|
||||
_ => format!("{}{}", fill_char.to_string().repeat(pad), text),
|
||||
}
|
||||
} else {
|
||||
format!("{}{}", fill_char.to_string().repeat(pad), text)
|
||||
}
|
||||
};
|
||||
|
||||
Value(padded)
|
||||
} else {
|
||||
Undefined
|
||||
}
|
||||
}
|
||||
BuiltInFunction::Format => {
|
||||
let fmt_pattern = extract_evaluated_arg_value!(evaluated_args, 0);
|
||||
|
||||
if let Some(fmt_str) = fmt_pattern {
|
||||
let args: Vec<Option<&String>> = evaluated_args.iter()
|
||||
.skip(1)
|
||||
.map(|v| extract_arg_value!(v))
|
||||
.collect();
|
||||
|
||||
let mut formatted = String::new();
|
||||
let mut arg_iter = args.iter();
|
||||
let mut chars = fmt_str.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '{' && chars.peek() == Some(&'}') {
|
||||
chars.next();
|
||||
if let Some(Some(arg)) = arg_iter.next() {
|
||||
write!(formatted, "{}", arg).unwrap();
|
||||
} else {
|
||||
formatted.push_str("{}");
|
||||
}
|
||||
} else {
|
||||
formatted.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
Value(formatted)
|
||||
} else {
|
||||
Undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1381,6 +1499,27 @@ mod tests {
|
||||
}
|
||||
"#;
|
||||
let mapper = MapperScript::parse(script, None).expect("Parsing failed");
|
||||
println!("Program: {mapper:?}");
|
||||
println!("Program: {mapper:?}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mapper_format() {
|
||||
let dsl = r#"
|
||||
@Name = pad(1000, 10, 0);
|
||||
@Title = format("Hello {} how {}", "a", "b");
|
||||
"#;
|
||||
|
||||
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
|
||||
let mut channels: Vec<PlaylistItem> = vec![
|
||||
("D", "HD"),
|
||||
].into_iter().map(|(name, quality)| PlaylistItem { header: PlaylistItemHeader { title: format!("Chanel {name} [{quality}]"), ..Default::default() } }).collect::<Vec<PlaylistItem>>();
|
||||
|
||||
for pli in &mut channels {
|
||||
let mut accessor = ValueAccessor {
|
||||
pli,
|
||||
};
|
||||
mapper.eval(&mut accessor, None);
|
||||
println!("Result: {pli:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::model::StreamInfo;
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", content = "payload", rename_all = "camelCase")]
|
||||
pub enum ActiveUserConnectionChange {
|
||||
Connected(StreamInfo),
|
||||
Updated(StreamInfo),
|
||||
Disconnected(SocketAddr), // addr
|
||||
Connections(usize, usize) // user_count, connection_count
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user