From 0119abe8bb6f669d08f18705bbd81166a30c8a03 Mon Sep 17 00:00:00 2001 From: euzu Date: Mon, 24 Nov 2025 14:29:05 +0100 Subject: [PATCH] Added copy to clipboard menu for stream table --- backend/src/api/endpoints/hls_api.rs | 6 +- backend/src/api/endpoints/m3u_api.rs | 4 +- backend/src/api/endpoints/xtream_api.rs | 6 +- backend/src/utils/network/xtream.rs | 6 +- .../app/components/dashboard/streams_table.rs | 72 +++++++++++++++++-- .../components/playlist/playlist_explorer.rs | 5 +- frontend/src/services/playlist_service.rs | 8 +-- shared/src/model/stream_info.rs | 11 +-- 8 files changed, 92 insertions(+), 26 deletions(-) diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 6aac833fe..cf757c056 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -166,10 +166,11 @@ pub(in crate::api) async fn handle_hls_stream_request( async fn get_stream_channel(app_state: &Arc, target: &Arc, virtual_id: u32) -> Option { if target.has_output(TargetType::Xtream) { if let Ok((pli, _)) = xtream_repository::xtream_get_item_for_stream_id(virtual_id, app_state, target, None).await { - return Some(pli.to_stream_channel()); + return Some(pli.to_stream_channel(target.id)); } } - m3u_get_item_for_stream_id(virtual_id, app_state, target).await.ok().map(|pli| pli.to_stream_channel()) + let target_id = target.id; + m3u_get_item_for_stream_id(virtual_id, app_state, target).await.ok().map(|pli| pli.to_stream_channel(target_id)) } async fn resolve_stream_channel( @@ -181,6 +182,7 @@ async fn resolve_stream_channel( let mut channel = match get_stream_channel(app_state, target, virtual_id).await { Some(channel) => channel, None => StreamChannel { + target_id: target.id, virtual_id, provider_id: 0, item_type: PlaylistItemType::LiveHls, diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 100c764ea..cd24f4353 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -154,7 +154,7 @@ async fn m3u_api_stream( fingerprint, app_state, session, - pli.to_stream_channel(), + pli.to_stream_channel(target.id), req_headers, &input, &user, @@ -224,7 +224,7 @@ async fn m3u_api_stream( fingerprint, app_state, &session_key, - pli.to_stream_channel(), + pli.to_stream_channel(target.id), session_url, req_headers, &input, diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index f45cefe4d..34601d116 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -291,7 +291,7 @@ async fn xtream_player_api_stream( .into_response(); } - let stream_channel = create_stream_channel_with_type(&pli, item_type); + let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); if session.virtual_id == virtual_id && is_seek_request(cluster, req_headers).await { // partial request means we are in reverse proxy mode, seek happened @@ -380,7 +380,7 @@ async fn xtream_player_api_stream( .into_response(); } - let stream_channel = create_stream_channel_with_type(&pli, item_type); + let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); stream_response( fingerprint, @@ -516,7 +516,7 @@ async fn xtream_player_api_stream_with_token( fingerprint, app_state, session_key.as_str(), - pli.to_stream_channel(), + pli.to_stream_channel(target.id), &stream_url, req_headers, &input, diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 75ea86b1e..6cec22362 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -261,8 +261,8 @@ fn check_alias_user_state(cfg: &Arc, client: &Arc, inpu tokio::spawn(async move { if let Some(aliases) = input.aliases.as_ref() { for alias in aliases { - // Random wait time 5–20 seconds to avoid provider block - let delay = u64::from(fastrand::u32(5..=20)); + // Random wait time 60–180 seconds to avoid provider block + let delay = u64::from(fastrand::u32(60..=180)); tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await; if let (Some(username), Some(password)) = @@ -315,4 +315,4 @@ pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredenti "stream_id": {stream_id} }} }}"#) -} \ No newline at end of file +} diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index d5c43174f..acf6631a5 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -14,7 +14,9 @@ use std::rc::Rc; use std::str::FromStr; use wasm_bindgen::JsCast; use web_sys::Element; +use yew::platform::spawn_local; use yew::prelude::*; +use yew_hooks::use_clipboard; use yew_i18n::use_translation; const LIVE: &str = "Live"; @@ -24,6 +26,11 @@ const CATCHUP: &str = "Archive"; const HLS: &str = "HLS"; const DASH: &str = "DASH"; +const KICK: &str = "kick"; +const COPY_LINK_TULIPROX_VIRTUAL_ID: &str = "copy_link_tuliprox_virtual_id"; +const COPY_LINK_TULIPROX_WEBPLAYER_URL: &str = "copy_link_tuliprox_webplayer_url"; +const COPY_LINK_PROVIDER_URL: &str = "copy_link_provider_url"; + const HEADERS: [&str; 12] = [ "EMPTY", "USERNAME", @@ -70,7 +77,8 @@ pub struct StreamsTableProps { #[function_component] pub fn StreamsTable(props: &StreamsTableProps) -> Html { let translate = use_translation(); - let services = use_service_context(); + let service_ctx = use_service_context(); + let clipboard = use_clipboard(); let config_ctx = use_context::().expect("Config context not found"); let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); @@ -214,22 +222,59 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { }) }; + let copy_to_clipboard: Callback = { + let clipboard = clipboard.clone(); + let services = service_ctx.clone(); + let translate = translate.clone(); + Callback::from(move |text: String| { + if *clipboard.is_supported { + clipboard.write_text(text); + } else { + services.toastr.error(translate.t("MESSAGES.CLIPBOARD_NOT_SUPPORTED")); + } + }) + }; let handle_menu_click = { let popup_is_open_state = popup_is_open.clone(); let translate = translate.clone(); - let services_ctx = services.clone(); + let services = service_ctx.clone(); let selected_dto = selected_dto.clone(); + let copy_to_clipboard = copy_to_clipboard.clone(); 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_ctx.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr))) { - services_ctx.toastr.error(translate.t("MESSAGES.FAILED_TO_KICK_USER_STREAM")); + if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr))) { + services.toastr.error(translate.t("MESSAGES.FAILED_TO_KICK_USER_STREAM")); } } } + StreamsTableAction::CopyLinkTuliproxVirtualId => { + if let Some(dto) = &*selected_dto { + copy_to_clipboard.emit(dto.channel.virtual_id.to_string()); + } + } + StreamsTableAction::CopyLinkProviderUrl => { + if let Some(dto) = &*selected_dto { + copy_to_clipboard.emit(dto.channel.url.clone()); + } + } + StreamsTableAction::CopyLinkTuliproxWebPlayerUrl => { + if let Some(dto) = &*selected_dto { + let target_id = dto.channel.target_id; + let virtual_id = dto.channel.virtual_id; + let cluster = dto.channel.cluster; + let services = services.clone(); + let copy_to_clipboard = copy_to_clipboard.clone(); + spawn_local(async move { + if let Some(url) = services.playlist.get_playlist_webplayer_url(target_id, virtual_id, cluster).await { + copy_to_clipboard.emit(url); + } + }); + } + } } } popup_is_open_state.set(false); @@ -245,6 +290,9 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { definition={definition.clone()} /> + + + } @@ -259,12 +307,18 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { #[derive(Debug, Clone, Eq, PartialEq)] enum StreamsTableAction { Kick, + CopyLinkTuliproxVirtualId, + CopyLinkTuliproxWebPlayerUrl, + CopyLinkProviderUrl, } impl Display for StreamsTableAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", match self { - Self::Kick => "kick", + Self::Kick => KICK, + Self::CopyLinkTuliproxVirtualId => COPY_LINK_TULIPROX_VIRTUAL_ID, + Self::CopyLinkTuliproxWebPlayerUrl => COPY_LINK_TULIPROX_WEBPLAYER_URL, + Self::CopyLinkProviderUrl => COPY_LINK_PROVIDER_URL, }) } } @@ -273,8 +327,14 @@ impl FromStr for StreamsTableAction { type Err = TuliproxError; fn from_str(s: &str) -> Result { - if s.eq("kick") { + if s.eq(KICK) { Ok(Self::Kick) + } else if s.eq(COPY_LINK_TULIPROX_VIRTUAL_ID) { + Ok(Self::CopyLinkTuliproxVirtualId) + } else if s.eq(COPY_LINK_TULIPROX_WEBPLAYER_URL) { + Ok(Self::CopyLinkTuliproxWebPlayerUrl) + } else if s.eq(COPY_LINK_PROVIDER_URL) { + Ok(Self::CopyLinkProviderUrl) } else { create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown Stream Action: {}", s) } diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index e1c669512..f37636838 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -144,11 +144,12 @@ pub fn PlaylistExplorer() -> Html { if let Some(dto) = &*selected_channel { let copy_to_clipboard = copy_to_clipboard.clone(); let services = services.clone(); - let dto = dto.clone(); + let virtual_id = dto.virtual_id; + let cluster = dto.xtream_cluster.unwrap_or_default(); let translate_clone = translate_clone.clone(); let target_id = *target_id; spawn_local(async move { - if let Some(url) = services.playlist.get_playlist_webplayer_url(target_id, &dto).await { + if let Some(url) = services.playlist.get_playlist_webplayer_url(target_id, virtual_id, cluster).await { copy_to_clipboard.emit(url); services.toastr.success(translate_clone.t("MESSAGES.PLAYLIST.WEBPLAYER_URL_COPY_TO_CLIPBOARD")); } diff --git a/frontend/src/services/playlist_service.rs b/frontend/src/services/playlist_service.rs index a5c327b97..9a7d00e91 100644 --- a/frontend/src/services/playlist_service.rs +++ b/frontend/src/services/playlist_service.rs @@ -1,6 +1,6 @@ use crate::services::{get_base_href, request_post, ACCEPT_PREFER_BIN}; use log::error; -use shared::model::{CommonPlaylistItem, EpgTv, PlaylistCategoriesResponse, PlaylistEpgRequest, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest}; +use shared::model::{EpgTv, PlaylistCategoriesResponse, PlaylistEpgRequest, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest, XtreamCluster}; use std::rc::Rc; use shared::utils::{concat_path_leading_slash}; @@ -41,11 +41,11 @@ impl PlaylistService { }) } - pub async fn get_playlist_webplayer_url(&self, target_id: u16, dto: &Rc) -> Option { + pub async fn get_playlist_webplayer_url(&self, target_id: u16, virtual_id: u32, cluster: XtreamCluster) -> Option { let request = WebplayerUrlRequest { target_id, - virtual_id: dto.virtual_id, - cluster: dto.xtream_cluster.unwrap_or_default(), + virtual_id, + cluster, }; request_post::<&WebplayerUrlRequest, String>(&self.playlist_api_webplayer_url_path, &request, None, Some("text/plain".to_string())).await.unwrap_or_else(|err| { error!("{err}"); diff --git a/shared/src/model/stream_info.rs b/shared/src/model/stream_info.rs index 217239222..20d432713 100644 --- a/shared/src/model/stream_info.rs +++ b/shared/src/model/stream_info.rs @@ -5,6 +5,7 @@ use crate::utils::{current_time_secs, longest}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StreamChannel { + pub target_id: u16, pub virtual_id: u32, pub provider_id: u32, pub item_type: PlaylistItemType, @@ -15,15 +16,16 @@ pub struct StreamChannel { pub shared: bool, } -pub fn create_stream_channel_with_type(pli: &XtreamPlaylistItem, item_type: PlaylistItemType) -> StreamChannel { - let mut stream_channel = pli.to_stream_channel(); +pub fn create_stream_channel_with_type(target_id: u16, pli: &XtreamPlaylistItem, item_type: PlaylistItemType) -> StreamChannel { + let mut stream_channel = pli.to_stream_channel(target_id); stream_channel.item_type = item_type; stream_channel } impl XtreamPlaylistItem { - pub fn to_stream_channel(&self) -> StreamChannel { + pub fn to_stream_channel(&self, target_id: u16) -> StreamChannel { StreamChannel { + target_id, virtual_id: self.virtual_id, provider_id: self.provider_id, item_type: self.item_type, @@ -37,8 +39,9 @@ impl XtreamPlaylistItem { } impl M3uPlaylistItem { - pub fn to_stream_channel(&self) -> StreamChannel { + pub fn to_stream_channel(&self, target_id: u16) -> StreamChannel { StreamChannel { + target_id, virtual_id: self.virtual_id, provider_id: self.get_provider_id().unwrap_or_default(), item_type: self.item_type,