mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-04 15:02:16 +02:00
Merge pull request #477 from euzu/feature/kick_x_time
Feature/kick x time
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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! {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user