Merge pull request #477 from euzu/feature/kick_x_time

Feature/kick x time
This commit is contained in:
euzu
2025-12-12 21:29:36 +01:00
committed by GitHub
49 changed files with 613 additions and 118 deletions
+2
View File
@@ -35,6 +35,8 @@
- Added extended debug logging for client requests and ID chain (request/action/virtual) to trace stream resolution.
- Fixed xtream series/catchup lookups using the series-info virtual_id so episode requests now keep their own virtual_id/session.
- Made cache storage more robust. Incomplete downloads will be deleted from cache.
- `kick_secs` added to config.yaml `web_ui` config. Default 30 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration.
This setting is also used for sleep-timed streams.
# 3.2.0 (2025-11-14)
- Added `name` attribute to Staged Input.
Generated
+3 -3
View File
@@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "frontend"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"anyhow",
"base64",
@@ -3793,7 +3793,7 @@ dependencies = [
[[package]]
name = "shared"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"base64",
"bitflags 2.10.0",
@@ -4356,7 +4356,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tuliprox"
version = "3.2.18"
version = "3.2.19"
dependencies = [
"arc-swap",
"async-compression",
+7 -5
View File
@@ -412,11 +412,13 @@ log:
```
### 1.10 `web_ui`
- enabled: default is true, if set to false the web_ui is disabled
- user_ui_enabled, true or false, for user bouquet editor
- content_security_policy: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`.
- path is for web_ui path like `/ui` for reverse proxy integration if necessary.
- auth for authentication settings
- `enabled`: default is true, if set to false the web_ui is disabled
- `user_ui_enabled`: true or false, for user group editor
- `content_security_policy`: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`.
- `path` is for web_ui path like `/ui` for reverse proxy integration if necessary.
- `player_server` optional, if set the server setting is used for the web-ui-player.
- `kick_secs` default 30 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration. This setting is also used for sleep-timed streams.
- `auth` for authentication settings
- `enabled` can be deactivated if `enabled` is set to `false`. If not set default is `true`.
- `issuer`
- `secret` is used for jwt token generation.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuliprox"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+1 -1
View File
@@ -973,7 +973,7 @@ fn get_stream_throttle(app_state: &AppState) -> u64 {
#[allow(clippy::too_many_arguments)]
async fn try_shared_stream_response_if_any(
app_state: &AppState,
app_state: &Arc<AppState>,
stream_url: &str,
fingerprint: &Fingerprint,
user: &ProxyUserCredentials,
+18 -19
View File
@@ -24,6 +24,15 @@ use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionP
use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, CUSTOM_VIDEO_PREFIX, HLS_EXT};
use std::sync::Arc;
const PLAYLIST_TEMPLATE: &str = r"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
{url}
#EXT-X-ENDLIST
";
#[derive(Debug, Deserialize)]
struct HlsApiPathParams {
username: String,
@@ -52,6 +61,10 @@ pub(in crate::api) async fn handle_hls_stream_request(
req_headers: &HeaderMap,
connection_permission: UserConnectionPermission,
) -> impl IntoResponse + Send {
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let url = replace_url_extension(hls_url, HLS_EXT);
let server_info = app_state.app_config.get_user_server_info(user);
@@ -84,9 +97,8 @@ pub(in crate::api) async fn handle_hls_stream_request(
Some(provider_cfg) => {
let stream_url = get_stream_alternative_url(&url, input, &provider_cfg);
debug_if_enabled!(
"API endpoint [HLS] create_session_fingerprint user={} virtual_id={} provider={} stream_url={}",
"API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}",
sanitize_sensitive_info(&user.username),
virtual_id,
provider_cfg.name,
sanitize_sensitive_info(&stream_url)
);
@@ -151,15 +163,8 @@ pub(in crate::api) async fn handle_hls_stream_request(
user.password,
CustomVideoStreamType::ChannelUnavailable);
let playlist = format!(r"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
{url}
#EXT-X-ENDLIST
");
hls_response(playlist.clone()).into_response()
let playlist = PLAYLIST_TEMPLATE.replace("{url}", &url);
hls_response(playlist).into_response()
} else {
axum::http::StatusCode::NOT_FOUND.into_response()
}
@@ -230,16 +235,10 @@ async fn hls_api_stream(
let input = try_option_bad_request!(
app_state.app_config.get_input_by_id(params.input_id),
true,
format!(
"Cant find input {} for target {target_name}, stream_id {virtual_id}, hls", params.input_id
)
format!("Cant find input {} for target {target_name}, stream_id {virtual_id}, hls", params.input_id)
);
debug_if_enabled!(
"ID chain for hls endpoint: request_stream_id={} -> virtual_id={}",
params.stream_id,
virtual_id
);
debug_if_enabled!("ID chain for hls endpoint: request_stream_id={} -> virtual_id={virtual_id}", params.stream_id);
let user_session_token = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id);
let mut user_session = app_state
.active_users
+7 -6
View File
@@ -118,6 +118,11 @@ async fn m3u_api_stream(
format!("Failed to read m3u item for stream id {req_virtual_id}")
);
let virtual_id = pli.virtual_id;
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let input = try_option_bad_request!(
app_state
.app_config
@@ -128,12 +133,8 @@ async fn m3u_api_stream(
let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live);
debug_if_enabled!(
"ID chain for m3u endpoint: request_stream_id={} -> action_stream_id={} -> req_virtual_id={} -> virtual_id={}",
stream_req.stream_id,
action_stream_id,
req_virtual_id,
virtual_id
);
"ID chain for m3u endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}",
stream_req.stream_id);
let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id);
let user_session = app_state
.active_users
+3 -3
View File
@@ -136,7 +136,7 @@ async fn handle_protocol_message(
Ok(ProtocolMessage::UserAction(cmd)) => {
if let Some(token) = mem.token.as_ref() {
if !auth_required || verify_auth_admin_token(token, secret_key) {
Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd)))
Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd).await))
} else {
Some(ProtocolMessage::UserActionResponse(false))
}
@@ -314,8 +314,8 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc<AppState>, auth_req
}
}
fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool {
async fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool {
match cmd {
UserCommand::Kick(addr) => app_state.connection_manager.kick_connection(&addr),
UserCommand::Kick(addr, virtual_id, secs) => app_state.connection_manager.kick_connection(&addr, virtual_id, secs).await,
}
}
+7 -8
View File
@@ -229,7 +229,6 @@ async fn xtream_player_api_stream(
// debug!("{}", sanitize_sensitive_info(&message));
// }
let (user, target) = try_option_bad_request!(
get_user_target_by_credentials( stream_req.username, stream_req.password, api_req, app_state),
false,
@@ -256,6 +255,10 @@ async fn xtream_player_api_stream(
format!("Failed to read xtream item for stream id {req_virtual_id}")
);
let virtual_id = pli.virtual_id;
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let input = try_option_bad_request!(
app_state.app_config.get_input_by_name(pli.input_name.as_str()),
true,
@@ -267,14 +270,10 @@ async fn xtream_player_api_stream(
} else {
(pli.xtream_cluster, pli.item_type)
};
debug_if_enabled!(
"ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={} -> req_virtual_id={} -> virtual_id={}",
stream_req.stream_id,
action_stream_id,
req_virtual_id,
virtual_id
);
"ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}",
stream_req.stream_id);
let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id);
let user_session = app_state
.active_users
+19 -2
View File
@@ -6,7 +6,7 @@ use crate::utils::GeoIp;
use arc_swap::ArcSwapOption;
use jsonwebtoken::get_current_timestamp;
use log::{debug, info};
use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission};
use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission, VirtualId};
use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info, strip_port};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
@@ -75,6 +75,7 @@ impl UserConnectionData {
#[derive(Debug, Default)]
struct UserConnections {
kicked: HashMap<String, (u64, VirtualId)>,
by_key: HashMap<String, UserConnectionData>,
key_by_addr: HashMap<SocketAddr, String>,
}
@@ -124,7 +125,6 @@ impl ActiveUserManager {
self.last_logged_user_count.store(last_user_count, Ordering::Relaxed);
self.last_logged_user_connection_count.store(last_connection_count, Ordering::Relaxed);
info!("Active Users: {user_count}, Active User Connections: {user_connection_count}");
}
}
}
@@ -496,6 +496,22 @@ impl ActiveUserManager {
}
}
pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool {
let connections = self.connections.read().await;
let now = current_time_secs();
matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now)
}
pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) {
let mut connections = self.connections.write().await;
let now = current_time_secs();
connections.kicked.retain(|_, (expires_at, _)| *expires_at > now);
if let Some(username) = connections.key_by_addr.get(addr).cloned() {
let expires_at = now + blocked_secs.clamp(1,86_400); // max 1 day
connections.kicked.insert(username, (expires_at, virtual_id));
}
}
fn gc(&self) {
if let Some(gc_ts) = &self.gc_ts {
let ts = gc_ts.load(Ordering::Acquire);
@@ -503,6 +519,7 @@ impl ActiveUserManager {
if now - ts > USER_GC_TTL {
if let Ok(mut user_connections) = self.connections.try_write() {
user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now);
user_connections.by_key.retain(|_k, v| now - v.ts < USER_CON_TTL && v.connections > 0);
for connection_data in user_connections.by_key.values_mut() {
connection_data.sessions.retain(|s| now - s.ts < USER_CON_TTL);
+6 -3
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, ProviderHandle, SharedStreamManager};
use std::sync::Arc;
use log::{debug, warn};
use shared::model::{ActiveUserConnectionChange, StreamChannel};
use shared::model::{ActiveUserConnectionChange, StreamChannel, VirtualId};
use crate::auth::Fingerprint;
pub struct ConnectionManager {
@@ -36,7 +36,10 @@ impl ConnectionManager {
self.close_socket_signal_tx.subscribe()
}
pub fn kick_connection(&self, addr: &SocketAddr) -> bool {
pub async fn kick_connection(&self, addr: &SocketAddr, virtual_id: VirtualId, block_secs: u64) -> bool {
if block_secs > 0 {
self.user_manager.block_user_for_stream(addr, virtual_id, block_secs).await;
}
if let Err(e) = self.close_socket_signal_tx.send(*addr) {
debug!("No active receivers for close signal ({addr}): {e:?}");
return false;
@@ -74,7 +77,7 @@ impl ConnectionManager {
self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info)));
} else {
warn!("Failed to register connection for user {username} at {}; disconnecting client", fingerprint.addr);
let _ = self.kick_connection(&fingerprint.addr);
let _ = self.kick_connection(&fingerprint.addr,0, 0).await;
}
}
@@ -37,7 +37,7 @@ impl ActiveClientStream {
#[allow(clippy::too_many_arguments)]
pub(crate) async fn new(mut stream_details: StreamDetails,
app_state: &AppState,
app_state: &Arc<AppState>,
user: &ProxyUserCredentials,
connection_permission: UserConnectionPermission,
fingerprint: &Fingerprint,
@@ -53,6 +53,7 @@ impl ActiveClientStream {
let user_agent = req_headers.get(USER_AGENT).map(|h| String::from_utf8_lossy(h.as_bytes())).unwrap_or_default();
let virtual_id = stream_channel.virtual_id;
app_state.connection_manager.update_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent, session_token).await;
if let Some((_,_,_m_, Some(cvt))) = stream_details.stream_info.as_ref() {
app_state.connection_manager.update_stream_detail(&fingerprint.addr, *cvt).await;
@@ -76,12 +77,13 @@ impl ActiveClientStream {
futures::stream::empty::<Result<Bytes, StreamError>>().boxed()
}
Some(stream) => {
match app_state.app_config.config.load().sleep_timer_mins {
let config = app_state.app_config.config.load();
match config.sleep_timer_mins {
None => stream,
Some(mins) => {
let secs = u32::try_from((u64::from(mins) * 60).min(u64::from(u32::MAX))).unwrap_or(0);
if secs > 0 {
TimedClientStream::new(stream, secs).boxed()
TimedClientStream::new(app_state, stream, secs, fingerprint.addr, virtual_id).boxed()
} else {
stream
}
@@ -1,7 +1,6 @@
use crate::api::api_utils::{get_headers_from_request, StreamOptions};
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::{ReverseProxyDisabledHeaderConfig};
@@ -33,7 +32,6 @@ pub struct ProviderStreamFactoryOptions {
addr: SocketAddr,
// item_type: PlaylistItemType,
reconnect_enabled: bool,
force_reconnect_secs: u32,
buffer_enabled: bool,
buffer_size: usize,
share_stream: bool,
@@ -77,7 +75,6 @@ impl ProviderStreamFactoryOptions {
// 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,
buffer_enabled: stream_options.buffer_enabled,
buffer_size,
@@ -161,10 +158,6 @@ impl ProviderStreamFactoryOptions {
self.reconnect_flag.is_active()
}
#[inline]
pub fn get_reconnect_force_secs(&self) -> u32 {
self.force_reconnect_secs
}
}
fn get_request_range_start_bytes(req_headers: &HashMap<String, Vec<u8>>) -> Option<usize> {
@@ -302,16 +295,7 @@ async fn provider_stream_request(
StreamError::reqwest(&err)
})
.boxed();
let boxed_provider_stream = if stream_options.get_reconnect_force_secs() > 0 {
TimedClientStream::new(
provider_stream,
stream_options.get_reconnect_force_secs(),
)
.boxed()
} else {
provider_stream
};
return Ok(Some((boxed_provider_stream, response_info)));
return Ok(Some((provider_stream, response_info)));
}
if status.is_client_error() {
@@ -1,20 +1,27 @@
use std::net::SocketAddr;
use crate::api::model::stream_error::StreamError;
use bytes::Bytes;
use futures::Stream;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Poll;
use std::time::{Duration, Instant};
use crate::api::model::BoxedProviderStream;
use shared::model::VirtualId;
use shared::utils::default_kick_secs;
use crate::api::model::{AppState, BoxedProviderStream};
pub struct TimedClientStream {
inner: BoxedProviderStream,
deadline: Instant,
app_state: Arc<AppState>,
addr: SocketAddr,
virtual_id: VirtualId,
}
impl TimedClientStream {
pub(crate) fn new(inner: BoxedProviderStream, duration: u32) -> Self {
pub(crate) fn new(app_state: &Arc<AppState>, inner: BoxedProviderStream, duration: u32, addr: SocketAddr, virtual_id: VirtualId) -> Self {
let deadline = Instant::now() + Duration::from_secs(u64::from(duration));
Self { inner, deadline }
Self { inner, deadline, app_state: Arc::clone(app_state), addr, virtual_id }
}
}
impl Stream for TimedClientStream {
@@ -22,6 +29,13 @@ impl Stream for TimedClientStream {
fn poll_next(mut self: Pin<&mut Self>,cx: &mut std::task::Context<'_>,) -> Poll<Option<Self::Item>> {
if Instant::now() >= self.deadline {
let kick_secs = self.app_state.app_config.config.load().web_ui.as_ref().map_or_else(default_kick_secs, |wc| wc.kick_secs);
let user_manager = Arc::clone(&self.app_state.active_users);
let addr = self.addr;
let virtual_id = self.virtual_id;
tokio::spawn(async move {
user_manager.block_user_for_stream(&addr, virtual_id, kick_secs).await;
});
return Poll::Ready(None);
}
Pin::as_mut(&mut self.inner).poll_next(cx)
+7
View File
@@ -1,5 +1,6 @@
use shared::error::TuliproxError;
use shared::model::{ContentSecurityPolicyConfigDto, WebUiConfigDto};
use shared::utils::default_kick_secs;
use crate::model::{macros, WebAuthConfig};
#[derive(Debug, Clone)]
@@ -16,6 +17,7 @@ pub struct WebUiConfig {
pub path: Option<String>,
pub auth: Option<WebAuthConfig>,
pub player_server: Option<String>,
pub kick_secs: u64,
}
impl WebUiConfig {
@@ -27,6 +29,9 @@ impl WebUiConfig {
self.auth = None;
}
}
if self.kick_secs == 0 {
self.kick_secs = default_kick_secs();
}
Ok(())
}
}
@@ -61,6 +66,7 @@ impl From<&WebUiConfigDto> for WebUiConfig {
path: dto.path.clone(),
auth: dto.auth.as_ref().map(Into::into),
player_server: dto.player_server.clone(),
kick_secs: dto.kick_secs,
}
}
}
@@ -73,6 +79,7 @@ impl From<&WebUiConfig> for WebUiConfigDto {
path: instance.path.clone(),
auth: instance.auth.as_ref().map(Into::into),
player_server: instance.player_server.clone(),
kick_secs: instance.kick_secs,
}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "frontend"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
[dependencies]
shared = { version = "3.2.18", path = "../shared" }
shared = { version = "3.2.19", path = "../shared" }
chrono = "0"
yew = "0.21"
yew-router = "0.18"
+3 -1
View File
@@ -127,6 +127,7 @@
"SECRET": "Secret",
"USERFILE": "Userfile",
"PLAYER_SERVER": "Player Server",
"KICK_DURATION": "Kick duration (secs)",
"VERSION": "Version",
"SERVER_TIME": "Server time",
"BUILD_TIME": "Build time",
@@ -343,7 +344,8 @@
"EPG_LOGO_OVERRIDE": "Logo Override",
"API_CONFIGURATION" : "API Configuration",
"HEADER_NAME": "Header Name",
"HEADER_VALUE": "Header Value"
"HEADER_VALUE": "Header Value",
"TEMPLATES": "Templates"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "Playlist Category Selection"
+1 -1
View File
@@ -7,7 +7,7 @@
"authUrl": "/auth/"
},
"discord": "https://discord.gg/gkzCmWw9Tf",
"documentation": "https://github.com/euzu/tuliprox/blob/master/README.md",
"documentation": "https://euzu.github.io/tuliprox-docs/",
"wsUrl" : "/ws",
"protocolVersion": 1
}
+2
View File
@@ -81,3 +81,5 @@
@forward "components/source_editor/input_form";
@forward "components/cluster_flags";
@forward "components/titled_card";
@forward "components/filter_input";
@forward "components/filter_editor";
@@ -13,6 +13,7 @@
cursor: pointer;
box-sizing: border-box;
width: 100%;
padding: var(--padding-small);
&:hover {
color: var(--menu-button-hover-color);
@@ -23,6 +24,7 @@
flex: 1 1 auto;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
span {
pointer-events: none;
@@ -61,6 +61,7 @@
.bracket-3 { color: var(--code-bracket-3-color); }
}
.tp__filter__inline {
overflow: hidden;
.tp__filter__code {
padding: 0;
margin-block: 0;
@@ -0,0 +1,86 @@
.tp__filter-editor {
display: flex;
flex-flow: column;
overflow: hidden;
background-color: transparent;
box-sizing: border-box;
gap: var(--gap-default);
&__templates {
display: flex;
flex-flow: column;
background-color: transparent;
box-sizing: border-box;
padding: var(--padding-default);
gap: var(--gap-default);
max-height: 400px;
overflow: auto;
&-container {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}
&-content {
display: grid;
grid-template-columns: minmax(max-content, 100px) 1fr;
background-color: transparent;
box-sizing: border-box;
gap: var(--gap-default);
}
&-template {
display: flex;
flex-flow: row nowrap;
overflow: hidden;
box-sizing: border-box;
gap: var(--gap-default);
&-name {
font-weight: bold;
color: var(--code-reserved-color);
&:after {
content: ':';
}
}
&-value {
color: var(--modest-text-color);
}
}
}
&__editor {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--code-editor-background-color);
color: var(--code-editor-color);
&-input {
height: 20rem;
width: 100%;
max-width: 100%;
}
}
&__preview {
display: flex;
flex-flow: column;
border-radius: var(--border-radius);
min-height: 3rem;
max-height: 12rem;
}
}
.tp__filter-editor-valid {
.tp__filter-editor__preview {
border: 1px solid var(--ok-color);
}
}
.tp__filter-editor-invalid {
.tp__filter-editor__preview {
border: 1px solid var(--attention-color);
}
}
@@ -0,0 +1,28 @@
.tp__filter-input {
display: flex;
cursor: pointer;
fill: var(--modest-text-color);
.tp__input-wrapper {
height: 2.5rem;
padding: 0 var(--padding-mini);
display: flex;
align-items: center;
justify-content: space-between;
}
&:hover {
fill: var(--text-color);
filter: brightness(1.2);
}
&__preview {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-height: 2rem;
pointer-events: none;
}
}
+2 -1
View File
@@ -3,7 +3,7 @@
flex-flow: column;
overflow: hidden;
box-sizing: border-box;
gap: 6px;
gap: var(--gap-small);
.tp__input-wrapper {
display: flex;
flex-flow: row nowrap;
@@ -43,6 +43,7 @@
}
}
textarea,
input {
background-clip: content-box;
box-sizing: border-box;
@@ -8,7 +8,6 @@
&:hover {
fill: var(--text-color);
cursor: pointer;
filter: brightness(1.2);
}
@@ -2,6 +2,13 @@ use shared::model::{ClusterFlags};
use yew::prelude::*;
use yew_i18n::use_translation;
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum ClusterFlagsInputMode {
#[default]
NoneIsAll,
NoneIsNone,
}
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct ClusterFlagsInputProps {
pub name: String,
@@ -9,17 +16,27 @@ pub struct ClusterFlagsInputProps {
pub value: Option<ClusterFlags>,
#[prop_or_default]
pub on_change: Callback<(String, Option<ClusterFlags>)>,
#[prop_or_default]
pub mode: ClusterFlagsInputMode,
}
#[function_component]
pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html {
let translate = use_translation();
let flags = use_state(|| props.value.unwrap_or_else(ClusterFlags::all));
let flags = use_state(|| {
props.value.unwrap_or_else(|| match props.mode {
ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(),
ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(),
})
});
{
let set_flags = flags.clone();
use_effect_with(props.value, move |val| {
set_flags.set((*val).unwrap_or_else(ClusterFlags::all));
use_effect_with((props.value, props.mode), move |(val, cmode)| {
set_flags.set((*val).unwrap_or_else(|| match cmode {
ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(),
ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(),
}));
});
}
@@ -2,7 +2,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, 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 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_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer, html_if};
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{WebUiConfigDto, ContentSecurityPolicyConfigDto, WebAuthConfigDto};
@@ -15,6 +15,7 @@ const LABEL_SECRET: &str = "LABEL.SECRET";
const LABEL_TOKEN_TTL_MINS: &str = "LABEL.TOKEN_TTL_MINS";
const LABEL_USERFILE: &str = "LABEL.USERFILE";
const LABEL_PLAYER_SERVER: &str = "LABEL.PLAYER_SERVER";
const LABEL_KICK_DURATION: &str = "LABEL.KICK_DURATION";
const LABEL_USER_UI_ENABLED: &str = "LABEL.USER_UI_ENABLED";
const LABEL_CONTENT_SECURITY_POLICY: &str = "LABEL.CONTENT_SECURITY_POLICY";
const LABEL_CONTENT_SECURITY_POLICY_CUSTOM_ATTRIBUTES: &str = "LABEL.CUSTOM_ATTRIBUTES";
@@ -29,6 +30,7 @@ generate_form_reducer!(
UserUiEnabled => user_ui_enabled: bool,
Path => path: Option<String>,
PlayerServer => player_server: Option<String>,
KickSecs => kick_secs: u64,
}
);
@@ -126,6 +128,7 @@ pub fn WebUiConfigView() -> Html {
{ config_field_bool!(webui_state.form, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled) }
{ config_field_optional!(webui_state.form, translate.t(LABEL_PATH), path) }
{ config_field_optional!(webui_state.form, translate.t(LABEL_PLAYER_SERVER), player_server) }
{ config_field!(webui_state.form, translate.t(LABEL_KICK_DURATION), kick_secs) }
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_CONTENT_SECURITY_POLICY)}</h1>
{ config_field_bool!(csp_state.form, translate.t(LABEL_ENABLED), enabled) }
@@ -163,6 +166,7 @@ pub fn WebUiConfigView() -> Html {
{ edit_field_bool!(webui_state, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled, WebUiConfigFormAction::UserUiEnabled) }
{ edit_field_text_option!(webui_state, translate.t(LABEL_PATH), path, WebUiConfigFormAction::Path) }
{ edit_field_text_option!(webui_state, translate.t(LABEL_PLAYER_SERVER), player_server, WebUiConfigFormAction::PlayerServer) }
{ edit_field_number_u64!(webui_state, translate.t(LABEL_KICK_DURATION), kick_secs, WebUiConfigFormAction::KickSecs) }
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_CONTENT_SECURITY_POLICY)}</h1>
{ edit_field_bool!(csp_state, translate.t(LABEL_ENABLED), enabled, CspConfigFormAction::Enabled) }
@@ -10,6 +10,8 @@ pub struct ConfirmDialogProps {
pub ok_caption: String,
pub cancel_caption: String,
pub on_confirm: Callback<DialogResult>,
#[prop_or(true)]
pub close_on_backdrop_click: bool,
}
#[function_component]
@@ -45,7 +47,7 @@ pub fn ConfirmDialog(props: &ConfirmDialogProps) -> Html {
open={*is_open}
class="tp__confirm-dialog"
modal=true
close_on_backdrop_click=true
close_on_backdrop_click={props.close_on_backdrop_click}
on_close={Some(on_close)}
>
<h2>{ &props.title }</h2>
@@ -9,6 +9,8 @@ pub struct ContentDialogProps {
pub content: Html,
pub actions: DialogActions,
pub on_confirm: Callback<DialogResult>,
#[prop_or(true)]
pub close_on_backdrop_click: bool,
}
#[function_component]
@@ -68,7 +70,7 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html {
open={*is_open}
class="tp__content-dialog"
modal=true
close_on_backdrop_click=true
close_on_backdrop_click={props.close_on_backdrop_click}
on_close={on_close}
>
{ props.content.clone() }
+2 -2
View File
@@ -26,12 +26,12 @@ pub fn CustomDialog(props: &CustomDialogProps) -> Html {
|| ()
});
}
// Handle backdrop click
let on_backdrop_click = {
let on_close = props.on_close.clone();
let close_on_backdrop = props.close_on_backdrop_click;
Callback::from(move |_e: MouseEvent| {
if close_on_backdrop {
if let Some(on_close) = &on_close {
@@ -8,7 +8,7 @@ use gloo_timers::callback::Interval;
use gloo_utils::window;
use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
use shared::model::{PlaylistItemType, ProtocolMessage, SortOrder, StreamChannel, StreamInfo, UserCommand};
use shared::utils::{current_time_secs, strip_port};
use shared::utils::{current_time_secs, default_kick_secs, strip_port};
use std::fmt::Display;
use std::rc::Rc;
use std::str::FromStr;
@@ -84,7 +84,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
let popup_is_open = use_state(|| false);
let selected_dto = use_state(|| None::<Rc<StreamInfo>>);
let headers = use_memo(config_ctx, |cfg| {
let headers = use_memo(config_ctx.clone(), |cfg| {
let include_country = if let Some(app_cfg) = &cfg.config {
app_cfg.config.is_geoip_enabled()
} else {
@@ -241,12 +241,15 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
let services = service_ctx.clone();
let selected_dto = selected_dto.clone();
let copy_to_clipboard = copy_to_clipboard.clone();
let kick_secs = config_ctx.config.as_ref().and_then(|app_cfg| app_cfg.config.web_ui.as_ref())
.map(|web_ui| web_ui.kick_secs)
.unwrap_or_else(default_kick_secs);
Callback::from(move |(name, _): (String, _)| {
if let Ok(action) = StreamsTableAction::from_str(&name) {
match action {
StreamsTableAction::Kick => {
if let Some(dto) = (*selected_dto).as_ref() {
if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr))) {
if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr, dto.channel.virtual_id, kick_secs))) {
services.toastr.error(translate.t("MESSAGES.FAILED_TO_KICK_USER_STREAM"));
}
}
@@ -0,0 +1,118 @@
use web_sys::InputEvent;
use crate::app::ConfigContext;
use shared::model::PatternTemplate;
use yew::{classes, function_component, html, use_context, use_effect_with, use_state, Callback, Html, Properties, TargetCast};
use yew_i18n::use_translation;
use shared::foundation::filter::{get_filter};
use crate::app::components::{CollapsePanel, FilterView};
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct FilterEditorProps {
#[prop_or_default]
pub filter: Option<String>,
#[prop_or_default]
pub on_filter_change: Callback<Option<String>>,
pub on_templates_change: Callback<Option<Vec<PatternTemplate>>>,
}
#[function_component]
pub fn FilterEditor(props: &FilterEditorProps) -> Html {
let config_ctx = use_context::<ConfigContext>().expect("Config context not found");
let translate = use_translation();
let templates_state = use_state(|| None);
let filter_state = use_state(|| None);
let parsed_filter_state = use_state(|| None);
let valid_filter_state = use_state(|| true);
{
let templates = templates_state.clone();
let cfg_templates = config_ctx.config.as_ref().and_then(|c| c.sources.templates.clone());
use_effect_with(cfg_templates, move |templ| {
templates.set(templ.clone());
});
}
{
let filter = filter_state.clone();
use_effect_with(props.filter.clone(), move |flt| {
filter.set(flt.clone());
});
}
{
let filter = filter_state.clone();
let parsed_filter = parsed_filter_state.clone();
let templates = templates_state.clone();
let valid_filter = valid_filter_state.clone();
use_effect_with(filter.clone(), move |flt| {
let parsed = if let Some(new_fltr) = flt.as_ref() {
match get_filter(new_fltr, (*templates).as_ref()) {
Ok(fltr) => {
valid_filter.set(true);
Some(fltr)
}
Err(_) => {
valid_filter.set(false);
None
}
}
} else {
valid_filter.set(true);
None
};
parsed_filter.set(parsed);
});
}
let handle_filter_input = {
let filter = filter_state.clone();
let on_filter_change = props.on_filter_change.clone();
Callback::from(move |event: InputEvent| {
if let Some(input) = event.target_dyn_into::<web_sys::HtmlTextAreaElement>() {
let value = input.value();
if value.is_empty() {
filter.set(None);
on_filter_change.emit(None);
} else {
filter.set(Some(value.clone()));
on_filter_change.emit(Some(value));
}
}
})
};
html! {
<div class={classes!("tp__filter-editor", if *valid_filter_state {"tp__filter-editor-valid"} else {"tp__filter-editor-invalid"})}>
<CollapsePanel class="tp__filter-editor__templates-container" expanded={false} title={translate.t("LABEL.TEMPLATES")}>
<div class="tp__filter-editor__templates">
<div class="tp__filter-editor__templates-content">
{ if let Some(templ_vec) = &*templates_state {
html! {
for templ_vec.iter().map(|templ| html! {
<>
<div class="tp__filter-editor__templates-template-name">
{ &templ.name }
</div>
<div class="tp__filter-editor__templates-template-value">
{ templ.value.to_string() }
</div>
</>
})
}
} else {
html! {}
}
}
</div>
</div>
</CollapsePanel>
<div class="tp__filter-editor__editor">
<textarea class="tp__filter-editor__editor-input" value={(*filter_state).clone()} oninput={handle_filter_input}/>
</div>
<div class="tp__filter-editor__preview">
<FilterView inline={false} pretty={true} filter={(*parsed_filter_state).clone()} />
</div>
</div>
}
}
@@ -0,0 +1,130 @@
use crate::app::components::{AppIcon, FilterEditor, FilterView};
use crate::app::ConfigContext;
use crate::model::{DialogAction, DialogActions, DialogResult};
use crate::services::DialogService;
use shared::foundation::filter::get_filter;
use shared::model::PatternTemplate;
use yew::platform::spawn_local;
use yew::prelude::*;
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct FilterInputProps {
#[prop_or_default]
pub icon: String,
#[prop_or_default]
pub filter: Option<String>,
#[prop_or_default]
pub on_change: Callback<Option<String>>,
}
#[function_component]
pub fn FilterInput(props: &FilterInputProps) -> Html {
let config_ctx = use_context::<ConfigContext>().expect("Config context not found");
let dialog = use_context::<DialogService>().expect("Dialog service not found");
let dialog_actions = use_memo((), |()| {
Some(DialogActions {
left: Some(vec![DialogAction::new("close", "LABEL.CLOSE", DialogResult::Cancel, Some("Close".to_owned()), None)]),
right: vec![DialogAction::new("submit", "LABEL.OK", DialogResult::Ok, Some("Accept".to_owned()), Some("primary".to_string()))],
})
});
let filter_state = use_state(|| None);
let parsed_filter_state = use_state(|| None);
let templates_state = use_state(|| None);
{
let templates = templates_state.clone();
let cfg_templates = config_ctx.config.as_ref().and_then(|c| c.sources.templates.clone());
use_effect_with(cfg_templates, move |templ| {
templates.set(templ.clone());
});
}
{
let filter = filter_state.clone();
use_effect_with(props.filter.clone(), move |flt| {
filter.set(flt.clone());
});
}
{
let parsed_filter = parsed_filter_state.clone();
let templates = templates_state.clone();
use_effect_with((*filter_state).clone(), move |flt| {
let parsed = if let Some(new_fltr) = flt.as_ref() {
get_filter(new_fltr, (*templates).as_ref()).ok()
} else {
None
};
parsed_filter.set(parsed);
});
}
let handle_filter_edit = {
let filter = filter_state.clone();
Callback::from(move |flt: Option<String>| {
filter.set(flt);
})
};
let handle_templates_edit = {
let templates = templates_state.clone();
Callback::from(move |templ: Option<Vec<PatternTemplate>>| {
templates.set(templ);
})
};
let handle_click = {
let dialog = dialog.clone();
let filter_state = filter_state.clone();
let templates_state = templates_state.clone();
let on_change = props.on_change.clone();
let handle_filter_edit = handle_filter_edit.clone();
let handle_templates_edit = handle_templates_edit.clone();
let dialog_actions = dialog_actions.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
let original_filter = (*filter_state).clone();
let original_templates = (*templates_state).clone();
let current_filter = (*filter_state).clone();
let handle_filter_edit = handle_filter_edit.clone();
let handle_templates_edit = handle_templates_edit.clone();
let actions = dialog_actions.clone();
let dlg = dialog.clone();
let filter_state = filter_state.clone();
let templates_state = templates_state.clone();
let on_change = on_change.clone();
spawn_local(async move {
let filter_view = html! {<FilterEditor filter={current_filter}
on_filter_change={handle_filter_edit}
on_templates_change={handle_templates_edit} />};
let result = dlg.content(filter_view, (*actions).clone(), false).await;
match result {
DialogResult::Ok => on_change.emit((*filter_state).clone()),
_ => {
filter_state.set(original_filter);
templates_state.set(original_templates);
}
}
});
})
};
html! {
<div class={"tp__filter-input tp__input"} onclick={handle_click} tabindex="0">
<div class={"tp__input-wrapper"}>
<span class="tp__filter-input__preview">
{
match (*parsed_filter_state).as_ref() {
None => html! {},
Some(preview) => html! {
<FilterView inline={true} filter={preview.clone()} />
}
}
}
</span>
<AppIcon name={if props.icon.is_empty() { "Edit".to_owned() } else { props.icon.clone()} } />
</div>
</div>
}
}
@@ -0,0 +1,5 @@
mod filter_editor;
mod filter_input;
pub use filter_editor::*;
pub use filter_input::*;
+4 -3
View File
@@ -50,9 +50,12 @@ mod floating_background;
mod source_editor;
mod cluster_flags_input;
mod title_card;
mod filter;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
//pub use self::number_input::*;
//pub use self::date_input::*;
pub(crate) use self::cluster_flags_input::*;
pub(crate) use self::toggle_switch::*;
@@ -94,6 +97,4 @@ pub(crate) use self::loading_screen::*;
pub(crate) use self::websocket_status::*;
pub(crate) use self::source_editor::*;
pub(crate) use self::title_card::*;
//pub use self::number_input::*;
//pub use self::date_input::*;
pub(crate) use self::filter::*;
@@ -29,7 +29,7 @@ pub fn RevealContent(props: &RevealContentProps) -> Html {
let actions = actions.clone();
let dlg = dialog.clone();
spawn_local(async move {
let _result = dlg.content(content, actions).await;
let _result = dlg.content(content, actions, true).await;
});
})
};
@@ -1,6 +1,6 @@
use crate::app::components::config::HasFormData;
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, SourceEditorContext, TextButton};
use crate::{edit_field_bool, edit_field_text_option, generate_form_reducer};
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, FilterInput, SourceEditorContext, TextButton};
use crate::{config_field_child, edit_field_bool, edit_field_text_option, generate_form_reducer};
use shared::model::{M3uTargetOutputDto, TargetOutputDto};
use std::rc::Rc;
use yew::{function_component, html, use_context, use_effect_with, use_reducer, Callback, Html, Properties, UseReducerHandle};
@@ -54,12 +54,19 @@ pub fn M3uTargetOutputView(props: &M3uTargetOutputViewProps) -> Html {
}
let render_output = || {
let output_form_state_1 = output_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILENAME), filename, M3uTargetOutputFormAction::Filename) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_INCLUDE_TYPE_IN_URL), include_type_in_url, M3uTargetOutputFormAction::IncludeTypeInUrl) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_MASK_REDIRECT_URL), mask_redirect_url, M3uTargetOutputFormAction::MaskRedirectUrl) }
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, M3uTargetOutputFormAction::Filter) }
{ config_field_child!(translate.t(LABEL_FILTER), {
html! {
<FilterInput filter={output_form_state_1.form.filter.clone()} on_change={Callback::from(move |new_filter| {
output_form_state_1.dispatch(M3uTargetOutputFormAction::Filter(new_filter));
})} />
}
})}
</Card>
}
};
@@ -1,6 +1,6 @@
use crate::app::components::config::HasFormData;
use crate::app::components::select::Select;
use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton};
use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, FilterInput, Panel, SourceEditorContext, TextButton};
use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, edit_field_text_option, generate_form_reducer};
use shared::model::{StrmExportStyle, StrmTargetOutputDto, TargetOutputDto};
use std::fmt::Display;
@@ -106,6 +106,7 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html {
let render_main = || {
let output_form_state_1 = output_form_state.clone();
let output_form_state_2 = output_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_text!(output_form_state, translate.t(LABEL_DIRECTORY), directory, StrmTargetOutputFormAction::Directory) }
@@ -134,7 +135,13 @@ pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html {
/>
}
})}
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, StrmTargetOutputFormAction::Filter) }
{ config_field_child!(translate.t(LABEL_FILTER), {
html! {
<FilterInput filter={output_form_state_2.form.filter.clone()} on_change={Callback::from(move |new_filter| {
output_form_state_2.dispatch(StrmTargetOutputFormAction::Filter(new_filter));
})} />
}
})}
</Card>
}
};
@@ -1,6 +1,6 @@
use crate::app::components::config::HasFormData;
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, IconButton, Panel, SourceEditorContext, TextButton, TraktListItemForm, TitledCard};
use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer};
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, IconButton, Panel, SourceEditorContext, TextButton, TraktListItemForm, TitledCard, FilterInput};
use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_field_text, generate_form_reducer};
use shared::model::{TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto};
use std::fmt::Display;
use std::rc::Rc;
@@ -165,6 +165,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
};
let render_output = || {
let output_form_state_1 = output_form_state.clone();
html! {
<Card class="tp__config-view__card">
<TitledCard title={translate.t(LABEL_SKIP_DIRECT_SOURCE)}>
@@ -186,7 +187,13 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
{ edit_field_number_u16!(output_form_state, translate.t(LABEL_SERIES), resolve_series_delay, XtreamTargetOutputFormAction::ResolveSeriesDelay) }
</div>
</TitledCard>
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, XtreamTargetOutputFormAction::Filter) }
{ config_field_child!(translate.t(LABEL_FILTER), {
html! {
<FilterInput filter={output_form_state_1.form.filter.clone()} on_change={Callback::from(move |new_filter| {
output_form_state_1.dispatch(XtreamTargetOutputFormAction::Filter(new_filter));
})} />
}
})}
</Card>
}
};
@@ -1,6 +1,6 @@
use crate::app::components::config::HasFormData;
use crate::app::components::select::Select;
use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton};
use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, EditMode, FilterInput, 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;
@@ -149,6 +149,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
<ClusterFlagsInput
name="force_redirect"
value={target_options_state.form.force_redirect}
mode={ClusterFlagsInputMode::NoneIsNone}
on_change={Callback::from(move |(_name, flags):(String, Option<ClusterFlags>)| {
target_options_state_1.dispatch(ConfigTargetOptionsFormAction::ForceRedirect(flags));
})}
@@ -160,6 +161,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
let render_target = || {
let target_form_state_1 = target_form_state.clone();
let target_form_state_2 = target_form_state.clone();
html! {
<Card class="tp__config-view__card">
<div class="tp__config-view__cols-2">
@@ -167,7 +169,13 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
{ edit_field_bool!(target_form_state, translate.t(LABEL_USE_MEMORY_CACHE), use_memory_cache, ConfigTargetFormAction::UseMemoryCache) }
</div>
{ edit_field_text!(target_form_state, translate.t(LABEL_NAME), name, ConfigTargetFormAction::Name) }
{ edit_field_text!(target_form_state, translate.t(LABEL_FILTER), filter, ConfigTargetFormAction::Filter) }
{ config_field_child!(translate.t(LABEL_FILTER), {
html! {
<FilterInput filter={target_form_state_2.form.filter.clone()} on_change={Callback::from(move |new_filter: Option<String>| {
target_form_state_2.dispatch(ConfigTargetFormAction::Filter(new_filter.unwrap_or_default()));
})} />
}
})}
{ config_field_child!(translate.t(LABEL_PROCESSING_ORDER), {
html! {
+4 -1
View File
@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
pub fn default_documentation_url() -> String { String::from("https://euzu.github.io/tuliprox-docs/") }
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug, Default)]
pub struct ApiConfig {
#[serde(alias = "apiUrl")]
@@ -24,7 +27,7 @@ pub struct WebConfig {
pub discord: String,
#[serde(default)]
pub github: String,
#[serde(default)]
#[serde(default = "default_documentation_url")]
pub documentation: String,
#[serde(alias = "wsUrl")]
pub ws_url: String,
+1
View File
@@ -69,6 +69,7 @@ pub fn DialogProvider(props: &ConfirmProviderProps) -> Html {
<ContentDialog
content={content.content.clone()}
actions={content.actions.clone()}
close_on_backdrop_click={content.close_on_backdrop_click}
on_confirm={on_confirm.clone()}
/>
}
+3 -1
View File
@@ -60,6 +60,7 @@ pub struct ContentRequest {
pub content: Html,
pub actions: DialogActions,
pub resolve: Rc<RefCell<Option<DialogResultCallback>>>,
pub close_on_backdrop_click: bool,
}
#[derive(Clone)]
@@ -98,7 +99,7 @@ impl DialogService {
future
}
pub fn content(&self, content: Html, actions: Option<DialogActions>) -> DialogFuture {
pub fn content(&self, content: Html, actions: Option<DialogActions>, close_on_backdrop_click: bool) -> DialogFuture {
let (future, resolver) = DialogFuture::new();
let request = ContentRequest {
content,
@@ -107,6 +108,7 @@ impl DialogService {
right: vec![DialogAction::new_focused("close", "LABEL.CLOSE", DialogResult::Cancel, Some("Close".to_owned()), None)],
}),
resolve: Rc::new(RefCell::new(Some(Box::new(resolver)))),
close_on_backdrop_click
};
if let Some(cb) = &*self.inner.borrow() {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "3.2.18"
version = "3.2.19"
edition = "2021"
[dependencies]
@@ -1,3 +1,4 @@
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(untagged)]
@@ -6,6 +7,23 @@ pub enum TemplateValue {
Multi(Vec<String>),
}
impl Display for TemplateValue {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
TemplateValue::Single(value) => write!(f, "{value}")?,
TemplateValue::Multi(values) => {
for (i, value) in values.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{value}")?;
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct PatternTemplate {
pub name: String,
+6 -1
View File
@@ -1,6 +1,6 @@
use crate::error::{TuliproxError, TuliproxErrorKind};
use crate::model::WebAuthConfigDto;
use crate::utils::{default_as_true, is_blank_optional_string};
use crate::utils::{default_as_true, is_blank_optional_string, default_kick_secs};
const RESERVED_PATHS: &[&str] = &[
"cvs",
@@ -82,6 +82,8 @@ pub struct WebUiConfigDto {
pub auth: Option<WebAuthConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_server: Option<String>,
#[serde(default = "default_kick_secs")]
pub kick_secs: u64,
}
impl Default for WebUiConfigDto {
@@ -93,6 +95,7 @@ impl Default for WebUiConfigDto {
path: None,
auth: None,
player_server: None,
kick_secs: default_kick_secs(),
}
}
}
@@ -104,6 +107,7 @@ impl WebUiConfigDto {
&& self.user_ui_enabled == empty.user_ui_enabled
&& is_blank_optional_string(&self.path)
&& is_blank_optional_string(&self.player_server)
&& self.kick_secs == default_kick_secs()
&& (self.content_security_policy.is_none()
|| self
.content_security_policy
@@ -130,6 +134,7 @@ impl WebUiConfigDto {
if is_blank_optional_string(&self.player_server) {
self.player_server = None;
}
self.kick_secs = default_kick_secs();
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
+8 -7
View File
@@ -10,6 +10,7 @@ use std::str::FromStr;
// https://siptv.eu/howto/playlist.html
pub type UUIDType = [u8; 32];
pub type VirtualId = u32;
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, Default)]
#[repr(u8)]
@@ -138,7 +139,7 @@ pub trait FieldSetAccessor {
}
pub trait PlaylistEntry: Send + Sync {
fn get_virtual_id(&self) -> u32;
fn get_virtual_id(&self) -> VirtualId;
fn get_provider_id(&self) -> Option<u32>;
fn get_category_id(&self) -> Option<u32>;
fn get_provider_url(&self) -> String;
@@ -151,7 +152,7 @@ pub struct PlaylistItemHeader {
#[serde(skip)]
pub uuid: UUIDType, // calculated
pub id: String, // provider id
pub virtual_id: u32, // virtual id
pub virtual_id: VirtualId, // virtual id
pub name: String,
pub chno: String,
pub logo: String,
@@ -290,7 +291,7 @@ generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name,
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct M3uPlaylistItem {
pub virtual_id: u32,
pub virtual_id: VirtualId,
pub provider_id: String,
pub name: String,
pub chno: String,
@@ -367,7 +368,7 @@ impl M3uPlaylistItem {
impl PlaylistEntry for M3uPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
@@ -421,7 +422,7 @@ impl From<M3uPlaylistItem> for CommonPlaylistItem {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XtreamPlaylistItem {
pub virtual_id: u32,
pub virtual_id: VirtualId,
pub provider_id: u32,
pub name: String,
pub logo: String,
@@ -477,7 +478,7 @@ impl XtreamPlaylistItem {
impl PlaylistEntry for XtreamPlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.virtual_id
}
#[inline]
@@ -718,7 +719,7 @@ impl PlaylistItem {
impl PlaylistEntry for PlaylistItem {
#[inline]
fn get_virtual_id(&self) -> u32 {
fn get_virtual_id(&self) -> VirtualId {
self.header.virtual_id
}
+2 -1
View File
@@ -1,7 +1,8 @@
use std::net::SocketAddr;
use serde::{Deserialize, Serialize};
use crate::model::VirtualId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UserCommand {
Kick(SocketAddr)
Kick(SocketAddr, VirtualId, u64),
}
+3 -1
View File
@@ -21,4 +21,6 @@ pub fn default_secret() -> String {
}
out.iter().map(|b| format!("{:02X}", b)).collect()
}
}
pub const fn default_kick_secs() -> u64 { 30 }