Merge branch 'release/v3.2.0'

This commit is contained in:
euzu
2025-11-14 15:20:08 +01:00
110 changed files with 3060 additions and 1209 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -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
View File
@@ -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"] }
+81 -52
View File
@@ -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}")
}
}
+3 -3
View File
@@ -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>> {
+19 -24
View File
@@ -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) {
+8 -12
View File
@@ -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();
}
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
+14 -18
View File
@@ -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)
};
+5 -6
View File
@@ -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
}
}
+39 -22
View File
@@ -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 {
+1 -1
View File
@@ -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
+21 -7
View File
@@ -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)));
}
}
}
+17 -25
View File
@@ -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()
}
}
-2
View File
@@ -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) {
+2 -2
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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 {
+5 -5
View File
@@ -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);
}
}
+2
View File
@@ -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(),
+75 -1
View File
@@ -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,
+4 -1
View File
@@ -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>,
}
}
+3
View File
@@ -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(),
+2 -2
View File
@@ -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()
},
};
+1 -1
View File
@@ -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| {
+2 -2
View File
@@ -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)
}
}
+15 -15
View File
@@ -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;
}
}
+9 -7
View File
@@ -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()))?;
+13 -13
View File
@@ -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 {
+38 -38
View File
@@ -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];
+8 -8
View File
@@ -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}");
+15 -32
View File
@@ -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 }
}
}
+49 -1
View File
@@ -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, "");
}
}
+2 -2
View File
@@ -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")
}
}
+4 -4
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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"
+6 -1
View File
@@ -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"
}
}
}
-1
View File
@@ -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"
}
]
+4 -1
View File
@@ -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>
+11 -13
View File
@@ -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();
}
})
};
+42 -19
View File
@@ -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>
}
}
}
+10 -10
View File
@@ -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>
}
};
@@ -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>>,
}
-3
View File
@@ -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
+2 -1
View File
@@ -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),
+11 -24
View File
@@ -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());
+16 -6
View File
@@ -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) => {
+210 -129
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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 }
+5 -4
View File
@@ -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") {
+1 -1
View File
@@ -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
View File
@@ -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