mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-11 02:12:23 +02:00
Fix: Copy url in frontend does not resolve provider:// (#673)
* Fix: Copy url in frontend does not resolve provider:// * Fix: Filter editor on demand validation * Fix: redirect to login screen if auto logout happens
This commit is contained in:
@@ -7,14 +7,14 @@ use crate::{api::{
|
||||
xtream_api::xtream_get_stream_info_response,
|
||||
},
|
||||
model::AppState,
|
||||
}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id};
|
||||
}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id};
|
||||
use axum::{response::IntoResponse, Router};
|
||||
use log::{debug, error};
|
||||
use serde_json::json;
|
||||
use shared::{
|
||||
model::{
|
||||
permission::Permission,
|
||||
InputType, PlaylistEpgRequest, PlaylistRequest, ProxyType, TargetType, UiPlaylistItem, WebplayerUrlRequest,
|
||||
InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem,
|
||||
XtreamCluster,
|
||||
},
|
||||
utils::{sanitize_sensitive_info, Internable},
|
||||
@@ -60,6 +60,46 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_provider_url_with_input(input: &ConfigInput, url: &str) -> String {
|
||||
match input.resolve_url(url) {
|
||||
Ok(resolved) => resolved.into_owned(),
|
||||
Err(err) => {
|
||||
let sanitized_url = sanitize_sensitive_info(url);
|
||||
let err_text = err.to_string();
|
||||
let sanitized_err = sanitize_sensitive_info(&err_text);
|
||||
error!("resolve_provider_url_with_input failed for url '{sanitized_url}': {sanitized_err}");
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_provider_url_for_request(app_config: &AppConfig, playlist_request: &PlaylistRequest, url: &str) -> String {
|
||||
if !url.starts_with(shared::utils::PROVIDER_SCHEME_PREFIX) {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
match playlist_request {
|
||||
PlaylistRequest::Input(input_id) => app_config
|
||||
.get_input_by_id(*input_id)
|
||||
.map_or_else(|| url.to_string(), |input| resolve_provider_url_with_input(input.as_ref(), url)),
|
||||
PlaylistRequest::Target(target_id) => app_config
|
||||
.get_target_by_id(*target_id)
|
||||
.and_then(|target| app_config.get_inputs_for_target(&target.name))
|
||||
.and_then(|inputs| {
|
||||
let mut matches = inputs
|
||||
.into_iter()
|
||||
.filter(|input| input.get_resolve_provider(url).is_some());
|
||||
let first = matches.next()?;
|
||||
if matches.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(resolve_provider_url_with_input(first.as_ref(), url))
|
||||
})
|
||||
.unwrap_or_else(|| url.to_string()),
|
||||
PlaylistRequest::CustomXtream(_) | PlaylistRequest::CustomM3u(_) => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_update(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(targets): axum::extract::Json<Vec<String>>,
|
||||
@@ -213,9 +253,11 @@ async fn playlist_series_info(
|
||||
axum::http::StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn playlist_webplayer(
|
||||
fn playlist_webplayer(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
|
||||
target_id: u16,
|
||||
virtual_id: u32,
|
||||
cluster: XtreamCluster,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
let access_token = create_access_token(&app_state.app_config.access_token_secret, 30);
|
||||
let config = app_state.app_config.config.load();
|
||||
@@ -228,9 +270,9 @@ async fn playlist_webplayer(
|
||||
let base_url = server_info.get_base_url();
|
||||
format!(
|
||||
"{base_url}/token/{access_token}/{}/{}/{}",
|
||||
playlist_item.target_id,
|
||||
playlist_item.cluster.as_stream_type(),
|
||||
playlist_item.virtual_id
|
||||
target_id,
|
||||
cluster.as_stream_type(),
|
||||
virtual_id
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -282,9 +324,29 @@ async fn playlist_resource(
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_resolve_url(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(request): axum::extract::Json<PlaylistUrlResolveRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
match request {
|
||||
PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster } => {
|
||||
playlist_webplayer(
|
||||
axum::extract::State(app_state),
|
||||
target_id,
|
||||
virtual_id,
|
||||
cluster,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
PlaylistUrlResolveRequest::Provider { playlist_request, url } => {
|
||||
resolve_provider_url_for_request(&app_state.app_config, &playlist_request, &url).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn v1_api_playlist_register_protected(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
||||
router
|
||||
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
|
||||
.route("/playlist/resolve_url", axum::routing::post(playlist_resolve_url))
|
||||
.route("/playlist/update", axum::routing::post(playlist_update))
|
||||
.route("/playlist/epg", axum::routing::post(playlist_epg))
|
||||
.route("/playlist/live", axum::routing::post(playlist_content_live))
|
||||
@@ -309,9 +371,9 @@ pub fn v1_api_playlist_register_with_permissions(
|
||||
.route("/live", axum::routing::post(playlist_content_live))
|
||||
.route("/vod", axum::routing::post(playlist_content_vod))
|
||||
.route("/series", axum::routing::post(playlist_content_series))
|
||||
.route("/resolve_url", axum::routing::post(playlist_resolve_url))
|
||||
.route("/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info))
|
||||
.route("/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item))
|
||||
.route("/webplayer", axum::routing::post(playlist_webplayer))
|
||||
.layer(permission_layer!(app_state, Permission::PlaylistRead));
|
||||
|
||||
let write_routes = Router::new()
|
||||
@@ -329,6 +391,259 @@ pub fn v1_api_playlist_register_with_permissions(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_provider_url_for_request;
|
||||
use crate::model::{AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, ConfigTarget, SourcesConfig};
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::foundation::Filter;
|
||||
use shared::{
|
||||
model::{ConfigPaths, ConfigProviderDto, PlaylistRequest},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_app_config(input: Arc<ConfigInput>, source: ConfigSource) -> AppConfig {
|
||||
let sources = SourcesConfig {
|
||||
batch_files: vec![],
|
||||
provider: vec![],
|
||||
inputs: vec![input],
|
||||
sources: vec![source],
|
||||
templates: None,
|
||||
};
|
||||
|
||||
AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config::default())),
|
||||
sources: Arc::new(ArcSwap::from_pointee(sources)),
|
||||
hdhomerun: Arc::new(ArcSwapOption::empty()),
|
||||
api_proxy: Arc::new(ArcSwapOption::empty()),
|
||||
file_locks: Arc::new(crate::utils::FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::empty()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(crate::model::MediaToolCapabilities::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_url_for_input_request_rewrites_provider_scheme() {
|
||||
let provider = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "demo".intern(),
|
||||
urls: vec!["http://provider.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let input = Arc::new(ConfigInput {
|
||||
id: 7,
|
||||
name: "input".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider)]),
|
||||
..Default::default()
|
||||
});
|
||||
let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] };
|
||||
let app_config = test_app_config(input, source);
|
||||
let resolved = resolve_provider_url_for_request(
|
||||
&app_config,
|
||||
&PlaylistRequest::Input(7),
|
||||
"provider://demo/live/user/pass/1359.ts",
|
||||
);
|
||||
|
||||
assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_url_for_target_request_rewrites_provider_scheme() {
|
||||
let provider = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "demo".intern(),
|
||||
urls: vec!["http://provider.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let input = Arc::new(ConfigInput {
|
||||
id: 7,
|
||||
name: "input".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider)]),
|
||||
..Default::default()
|
||||
});
|
||||
let target = Arc::new(ConfigTarget {
|
||||
id: 11,
|
||||
enabled: true,
|
||||
name: "target".to_string(),
|
||||
options: None,
|
||||
sort: None,
|
||||
filter: Filter::default(),
|
||||
output: vec![],
|
||||
rename: None,
|
||||
mapping_ids: None,
|
||||
mapping: Arc::default(),
|
||||
favourites: None,
|
||||
processing_order: Default::default(),
|
||||
watch: None,
|
||||
use_memory_cache: false,
|
||||
});
|
||||
let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] };
|
||||
let app_config = test_app_config(input, source);
|
||||
let resolved = resolve_provider_url_for_request(
|
||||
&app_config,
|
||||
&PlaylistRequest::Target(11),
|
||||
"provider://demo/live/user/pass/1359.ts",
|
||||
);
|
||||
|
||||
assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_url_passthrough_for_unresolved_provider_input_request() {
|
||||
let provider = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "demo".intern(),
|
||||
urls: vec!["http://provider.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let input = Arc::new(ConfigInput {
|
||||
id: 7,
|
||||
name: "input".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider)]),
|
||||
..Default::default()
|
||||
});
|
||||
let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] };
|
||||
let app_config = test_app_config(input, source);
|
||||
let original = "provider://unknown/live/user/pass/1359.ts";
|
||||
let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Input(7), original);
|
||||
|
||||
assert_eq!(resolved, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_url_passthrough_for_unresolved_provider_target_request() {
|
||||
let provider = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "demo".intern(),
|
||||
urls: vec!["http://provider.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let input = Arc::new(ConfigInput {
|
||||
id: 7,
|
||||
name: "input".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider)]),
|
||||
..Default::default()
|
||||
});
|
||||
let target = Arc::new(ConfigTarget {
|
||||
id: 11,
|
||||
enabled: true,
|
||||
name: "target".to_string(),
|
||||
options: None,
|
||||
sort: None,
|
||||
filter: Filter::default(),
|
||||
output: vec![],
|
||||
rename: None,
|
||||
mapping_ids: None,
|
||||
mapping: Arc::default(),
|
||||
favourites: None,
|
||||
processing_order: Default::default(),
|
||||
watch: None,
|
||||
use_memory_cache: false,
|
||||
});
|
||||
let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] };
|
||||
let app_config = test_app_config(input, source);
|
||||
let original = "provider://unknown/live/user/pass/1359.ts";
|
||||
let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original);
|
||||
|
||||
assert_eq!(resolved, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_url_passthrough_for_ambiguous_target_request() {
|
||||
let provider_a = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "shared".intern(),
|
||||
urls: vec!["http://provider-a.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let provider_b = ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "shared".intern(),
|
||||
urls: vec!["http://provider-b.example".intern()],
|
||||
dns: None,
|
||||
});
|
||||
let input_a = Arc::new(ConfigInput {
|
||||
id: 7,
|
||||
name: "input-a".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider_a)]),
|
||||
..Default::default()
|
||||
});
|
||||
let input_b = Arc::new(ConfigInput {
|
||||
id: 8,
|
||||
name: "input-b".intern(),
|
||||
provider_configs: Some(vec![Arc::new(provider_b)]),
|
||||
..Default::default()
|
||||
});
|
||||
let target = Arc::new(ConfigTarget {
|
||||
id: 11,
|
||||
enabled: true,
|
||||
name: "target".to_string(),
|
||||
options: None,
|
||||
sort: None,
|
||||
filter: Filter::default(),
|
||||
output: vec![],
|
||||
rename: None,
|
||||
mapping_ids: None,
|
||||
mapping: Arc::default(),
|
||||
favourites: None,
|
||||
processing_order: Default::default(),
|
||||
watch: None,
|
||||
use_memory_cache: false,
|
||||
});
|
||||
let source = ConfigSource {
|
||||
inputs: vec![Arc::clone(&input_a.name), Arc::clone(&input_b.name)],
|
||||
targets: vec![target],
|
||||
};
|
||||
let sources = SourcesConfig {
|
||||
batch_files: vec![],
|
||||
provider: vec![],
|
||||
inputs: vec![input_a, input_b],
|
||||
sources: vec![source],
|
||||
templates: None,
|
||||
};
|
||||
|
||||
let app_config = AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config::default())),
|
||||
sources: Arc::new(ArcSwap::from_pointee(sources)),
|
||||
hdhomerun: Arc::new(ArcSwapOption::empty()),
|
||||
api_proxy: Arc::new(ArcSwapOption::empty()),
|
||||
file_locks: Arc::new(crate::utils::FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::empty()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(crate::model::MediaToolCapabilities::default()),
|
||||
};
|
||||
|
||||
let original = "provider://shared/live/user/pass/1359.ts";
|
||||
let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original);
|
||||
|
||||
assert_eq!(resolved, original);
|
||||
}
|
||||
}
|
||||
|
||||
async fn playlist_episode_item(
|
||||
axum::extract::Path(virtual_id): axum::extract::Path<String>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
|
||||
@@ -704,6 +704,8 @@ Tuliprox supports the following filter expression types:
|
||||
* Type Comparison: `Type = vod` or `Type = live` or `Type = series`
|
||||
* Regular expression comparison: `([fieldanme]) ~ "regexp"` <br>
|
||||
The `[fieldanme]` can be `Group`, `Title`, `Name`, `Caption`, `Url`, `Genre`, `Input` or `Type`.
|
||||
* Filters don't have operator precedence, so please use parentheses
|
||||
* You can apply Morgan’s Law `NOT (A) AND NOT (B)`is the same as `NOT( A OR B)`
|
||||
|
||||
> **Note:**
|
||||
>
|
||||
|
||||
@@ -33,6 +33,11 @@ body {
|
||||
margin: var(--margin-mini) 0;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use crate::{app::components::login::Login, hooks::use_service_context};
|
||||
use crate::{
|
||||
app::{components::login::Login, AppRoute},
|
||||
hooks::use_service_context,
|
||||
};
|
||||
use shared::model::permission::Permission;
|
||||
use std::future;
|
||||
use yew::{prelude::*, suspense::use_future};
|
||||
use yew_hooks::{use_async_with_options, UseAsyncOptions};
|
||||
use yew_router::prelude::use_navigator;
|
||||
|
||||
fn should_connect_websocket(success: bool, setup_mode: bool, can_read_system: bool) -> bool {
|
||||
success && !setup_mode && can_read_system
|
||||
@@ -18,6 +22,7 @@ pub fn Authentication(props: &AuthenticationProps) -> Html {
|
||||
let services = use_service_context();
|
||||
let loading = use_state(|| true);
|
||||
let authenticated = use_state(|| false);
|
||||
let navigator = use_navigator();
|
||||
|
||||
{
|
||||
let services_ctx = services.clone();
|
||||
@@ -56,6 +61,18 @@ pub fn Authentication(props: &AuthenticationProps) -> Html {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let navigator = navigator.clone();
|
||||
use_effect_with((*loading, *authenticated), move |(loading, authenticated)| {
|
||||
if !*loading && !*authenticated {
|
||||
if let Some(navigator) = navigator.clone() {
|
||||
navigator.replace(&AppRoute::Login);
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
if *loading {
|
||||
html! {}
|
||||
} else if *authenticated {
|
||||
|
||||
@@ -5,6 +5,24 @@ use crate::{
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
fn resolve_close_result(actions: &DialogActions) -> Option<DialogResult> {
|
||||
actions
|
||||
.left
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(actions.right.iter())
|
||||
.find(|action| !action.disabled && matches!(action.result, DialogResult::Cancel))
|
||||
.map(|action| action.result.clone())
|
||||
.or_else(|| actions.right.iter().find(|action| !action.disabled).map(|action| action.result.clone()))
|
||||
.or_else(|| {
|
||||
actions
|
||||
.left
|
||||
.as_ref()
|
||||
.and_then(|actions| actions.iter().find(|action| !action.disabled).map(|action| action.result.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ContentDialogProps {
|
||||
pub content: Html,
|
||||
@@ -40,6 +58,7 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html {
|
||||
html! {
|
||||
<TextButton
|
||||
autofocus={action.focus}
|
||||
disabled={action.disabled}
|
||||
class={action.style.as_ref().map_or_else(String::new, ToString::to_string)}
|
||||
name={action.name.clone()}
|
||||
icon={action.icon.as_ref().map_or_else(String::new, |i| i.clone())}
|
||||
@@ -57,21 +76,11 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html {
|
||||
// Find a cancel action to use for backdrop clicks
|
||||
let on_close = {
|
||||
let on_result = on_result.clone();
|
||||
let cancel_action = props
|
||||
.actions
|
||||
.right
|
||||
.iter()
|
||||
.find(|action| matches!(action.result, DialogResult::Cancel))
|
||||
.or_else(|| props.actions.right.first());
|
||||
|
||||
if let Some(action) = cancel_action {
|
||||
let result = action.result.clone();
|
||||
Some(Callback::from(move |_| {
|
||||
resolve_close_result(&props.actions).map(|result| {
|
||||
Callback::from(move |_| {
|
||||
on_result(result.clone());
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
@@ -94,3 +103,31 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html {
|
||||
</CustomDialog>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_close_result;
|
||||
use crate::model::{DialogAction, DialogActions, DialogResult};
|
||||
|
||||
#[test]
|
||||
fn resolve_close_result_prefers_cancel_from_left_actions() {
|
||||
let actions = DialogActions {
|
||||
left: Some(vec![DialogAction::new("close", "LABEL.CLOSE", DialogResult::Cancel, None, None)]),
|
||||
right: vec![DialogAction::new("ok", "LABEL.OK", DialogResult::Ok, None, None)],
|
||||
};
|
||||
|
||||
assert_eq!(resolve_close_result(&actions), Some(DialogResult::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_close_result_ignores_disabled_actions() {
|
||||
let actions = DialogActions {
|
||||
left: Some(vec![
|
||||
DialogAction::new("close", "LABEL.CLOSE", DialogResult::Cancel, None, None).with_disabled(true)
|
||||
]),
|
||||
right: vec![DialogAction::new("ok", "LABEL.OK", DialogResult::Ok, None, None)],
|
||||
};
|
||||
|
||||
assert_eq!(resolve_close_result(&actions), Some(DialogResult::Ok));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ use shared::{
|
||||
concat_string,
|
||||
error::{info_err_res, TuliproxError},
|
||||
model::{
|
||||
PlaylistItemType, ProtocolMessage, SortOrder, StreamChannel, StreamInfo, StreamMeterEntry, StreamTechnicalInfo,
|
||||
UserCommand,
|
||||
PlaylistItemType, PlaylistRequest, PlaylistUrlResolveRequest, ProtocolMessage, SortOrder, StreamChannel,
|
||||
StreamInfo, StreamMeterEntry, StreamTechnicalInfo, UserCommand,
|
||||
},
|
||||
utils::{current_time_secs, default_hls_session_ttl_secs, default_kick_secs, strip_port},
|
||||
};
|
||||
@@ -605,7 +605,16 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
|
||||
}
|
||||
StreamsTableAction::CopyLinkProviderUrl => {
|
||||
if let Some(dto) = &*selected_dto {
|
||||
copy_to_clipboard.emit(dto.channel.url.to_string());
|
||||
let url = dto.channel.url.to_string();
|
||||
let playlist_request = PlaylistRequest::Target(dto.channel.target_id);
|
||||
let copy_to_clipboard = copy_to_clipboard.clone();
|
||||
let services = services.clone();
|
||||
spawn_local(async move {
|
||||
let request =
|
||||
PlaylistUrlResolveRequest::Provider { playlist_request, url: url.to_string() };
|
||||
let resolved = services.playlist.resolve_url(request).await.unwrap_or(url);
|
||||
copy_to_clipboard.emit(resolved);
|
||||
});
|
||||
}
|
||||
}
|
||||
StreamsTableAction::CopyLinkTuliproxWebPlayerUrl => {
|
||||
@@ -617,9 +626,8 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
|
||||
let translate = translate.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
|
||||
{
|
||||
let request = PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster };
|
||||
if let Some(url) = services.playlist.resolve_url(request).await {
|
||||
copy_to_clipboard.emit(url);
|
||||
} else {
|
||||
services.toastr.error(translate.t("MESSAGES.FAILED_TO_RETRIEVE_WEBPLAYER_URL"));
|
||||
|
||||
@@ -5,7 +5,10 @@ use crate::{
|
||||
},
|
||||
i18n::use_translation,
|
||||
};
|
||||
use shared::{foundation::get_filter, model::PatternTemplate};
|
||||
use shared::{
|
||||
foundation::{get_filter, Filter},
|
||||
model::PatternTemplate,
|
||||
};
|
||||
use web_sys::InputEvent;
|
||||
use yew::{classes, component, html, use_context, use_effect_with, use_state, Callback, Html, Properties, TargetCast};
|
||||
|
||||
@@ -15,25 +18,38 @@ pub struct FilterEditorProps {
|
||||
pub filter: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub on_filter_change: Callback<Option<String>>,
|
||||
#[prop_or_default]
|
||||
pub on_valid_change: Callback<bool>,
|
||||
pub on_templates_change: Callback<Option<Vec<PatternTemplate>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_filter_preview(
|
||||
filter: Option<&str>,
|
||||
templates: Option<&[PatternTemplate]>,
|
||||
) -> (Option<Filter>, bool) {
|
||||
match filter {
|
||||
Some(filter) => match get_filter(filter, templates) {
|
||||
Ok(parsed) => (Some(parsed), true),
|
||||
Err(_) => (None, false),
|
||||
},
|
||||
None => (None, true),
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FilterEditor(props: &FilterEditorProps) -> Html {
|
||||
let config_ctx = use_context::<ConfigContext>().expect("Config context not found");
|
||||
let translate = use_translation();
|
||||
let cfg_templates = config_ctx.config.as_ref().and_then(|c| {
|
||||
c.templates.as_ref().map(|definition| definition.templates.clone()).or_else(|| c.sources.templates.clone())
|
||||
});
|
||||
|
||||
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_state = use_state(|| cfg_templates.clone());
|
||||
let filter_state = use_state(|| props.filter.clone());
|
||||
|
||||
{
|
||||
let templates = templates_state.clone();
|
||||
let on_templates_change = props.on_templates_change.clone();
|
||||
let cfg_templates = config_ctx.config.as_ref().and_then(|c| {
|
||||
c.templates.as_ref().map(|definition| definition.templates.clone()).or_else(|| c.sources.templates.clone())
|
||||
});
|
||||
use_effect_with(cfg_templates, move |templ| {
|
||||
templates.set(templ.clone());
|
||||
on_templates_change.emit(templ.clone());
|
||||
@@ -47,28 +63,13 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
|
||||
});
|
||||
}
|
||||
|
||||
let (parsed_filter, valid_filter) = parse_filter_preview((*filter_state).as_deref(), (*templates_state).as_deref());
|
||||
|
||||
{
|
||||
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_deref()) {
|
||||
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 on_valid_change = props.on_valid_change.clone();
|
||||
use_effect_with(valid_filter, move |valid| {
|
||||
on_valid_change.emit(*valid);
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!("tp__filter-editor", if *valid_filter_state {"tp__filter-editor-valid"} else {"tp__filter-editor-invalid"})}>
|
||||
<div class={classes!("tp__filter-editor", if valid_filter {"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">
|
||||
@@ -118,8 +119,34 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
|
||||
<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()} />
|
||||
<FilterView inline={false} pretty={true} filter={parsed_filter} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_filter_preview;
|
||||
|
||||
#[test]
|
||||
fn parse_filter_preview_accepts_empty_filter() {
|
||||
let (parsed, valid) = parse_filter_preview(None, None);
|
||||
assert!(parsed.is_none());
|
||||
assert!(valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filter_preview_accepts_valid_filter() {
|
||||
let (parsed, valid) = parse_filter_preview(Some("Group ~ \".*\""), None);
|
||||
assert!(parsed.is_some());
|
||||
assert!(valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filter_preview_rejects_invalid_filter() {
|
||||
let (parsed, valid) = parse_filter_preview(Some("("), None);
|
||||
assert!(parsed.is_none());
|
||||
assert!(!valid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use super::parse_filter_preview;
|
||||
use crate::{
|
||||
app::{
|
||||
components::{AppIcon, FilterEditor, FilterView},
|
||||
components::{AppIcon, ContentDialog, FilterEditor, FilterView},
|
||||
ConfigContext,
|
||||
},
|
||||
model::{DialogAction, DialogActions, DialogResult},
|
||||
services::DialogService,
|
||||
};
|
||||
use shared::{foundation::get_filter, model::PatternTemplate};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use yew::{platform::spawn_local, prelude::*};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, Clone, PartialEq, Debug)]
|
||||
pub struct FilterInputProps {
|
||||
@@ -23,29 +22,14 @@ pub struct FilterInputProps {
|
||||
#[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 dialog_open = use_state(|| false);
|
||||
let editor_filter_state = use_state(|| None);
|
||||
let editor_templates_state = use_state(|| None);
|
||||
let editor_valid_state = use_state(|| true);
|
||||
|
||||
{
|
||||
let templates = templates_state.clone();
|
||||
@@ -66,86 +50,115 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
|
||||
|
||||
{
|
||||
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_deref()).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
use_effect_with(((*filter_state).clone(), (*templates_state).clone()), move |(flt, templates)| {
|
||||
let parsed =
|
||||
if let Some(new_fltr) = flt.as_ref() { get_filter(new_fltr, templates.as_deref()).ok() } else { None };
|
||||
parsed_filter.set(parsed);
|
||||
});
|
||||
}
|
||||
|
||||
let handle_templates_edit = {
|
||||
let templates = templates_state.clone();
|
||||
let templates = editor_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_templates_edit = handle_templates_edit.clone();
|
||||
let dialog_actions = dialog_actions.clone();
|
||||
let dialog_open = dialog_open.clone();
|
||||
let editor_filter_state = editor_filter_state.clone();
|
||||
let editor_templates_state = editor_templates_state.clone();
|
||||
let editor_valid_state = editor_valid_state.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let original_filter = (*filter_state).clone();
|
||||
let original_templates = (*templates_state).clone();
|
||||
let current_filter = (*filter_state).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 {
|
||||
// we need this refcell because the state hook does not update
|
||||
// when we close the dialog
|
||||
let filter_ref = Rc::new(RefCell::new((*filter_state).clone()));
|
||||
let handle_filter_edit = {
|
||||
let filter_ref_set = filter_ref.clone();
|
||||
let filter = filter_state.clone();
|
||||
Callback::from(move |flt: Option<String>| {
|
||||
*filter_ref_set.borrow_mut() = flt.clone();
|
||||
filter.set(flt);
|
||||
})
|
||||
};
|
||||
|
||||
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_ref.borrow().clone()),
|
||||
_ => {
|
||||
filter_state.set(original_filter);
|
||||
templates_state.set(original_templates);
|
||||
}
|
||||
}
|
||||
});
|
||||
let current_templates = (*templates_state).clone();
|
||||
let (_, valid) = parse_filter_preview(current_filter.as_deref(), current_templates.as_deref());
|
||||
editor_filter_state.set(current_filter);
|
||||
editor_templates_state.set(current_templates);
|
||||
editor_valid_state.set(valid);
|
||||
dialog_open.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
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()} />
|
||||
}
|
||||
let handle_dialog_result = {
|
||||
let dialog_open = dialog_open.clone();
|
||||
let filter_state = filter_state.clone();
|
||||
let templates_state = templates_state.clone();
|
||||
let editor_filter_state = editor_filter_state.clone();
|
||||
let editor_templates_state = editor_templates_state.clone();
|
||||
let editor_valid_state = editor_valid_state.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
Callback::from(move |result: DialogResult| {
|
||||
if result == DialogResult::Ok && *editor_valid_state {
|
||||
let next_filter = (*editor_filter_state).clone();
|
||||
filter_state.set(next_filter.clone());
|
||||
templates_state.set((*editor_templates_state).clone());
|
||||
on_change.emit(next_filter);
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<AppIcon name={if props.icon.is_empty() { "Edit".to_owned() } else { props.icon.clone()} } />
|
||||
</div>
|
||||
</div>
|
||||
dialog_open.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let dialog_actions = 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()),
|
||||
)
|
||||
.with_disabled(!*editor_valid_state)],
|
||||
};
|
||||
|
||||
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>
|
||||
if *dialog_open {
|
||||
<ContentDialog
|
||||
content={html! {
|
||||
<FilterEditor
|
||||
filter={(*editor_filter_state).clone()}
|
||||
on_filter_change={{
|
||||
let editor_filter_state = editor_filter_state.clone();
|
||||
Callback::from(move |flt: Option<String>| editor_filter_state.set(flt))
|
||||
}}
|
||||
on_valid_change={{
|
||||
let editor_valid_state = editor_valid_state.clone();
|
||||
Callback::from(move |valid: bool| editor_valid_state.set(valid))
|
||||
}}
|
||||
on_templates_change={handle_templates_edit}
|
||||
/>
|
||||
}}
|
||||
actions={dialog_actions}
|
||||
close_on_backdrop_click={false}
|
||||
on_confirm={handle_dialog_result}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ use std::{cell::RefCell, rc::Rc};
|
||||
use wasm_bindgen::{prelude::Closure, JsCast};
|
||||
use web_sys::{window, HtmlElement};
|
||||
use yew::{
|
||||
classes, component, html, platform::spawn_local, use_effect_with, use_memo, use_node_ref, use_state, Callback,
|
||||
Html, UseStateHandle,
|
||||
classes, component, html, platform::spawn_local, use_effect_with, use_memo, use_node_ref, use_state, Callback, Html,
|
||||
};
|
||||
|
||||
const TIME_BLOCK_WIDTH: f64 = 210.0;
|
||||
@@ -67,18 +66,19 @@ pub fn EpgView() -> Html {
|
||||
{
|
||||
let container_ref = container_ref.clone();
|
||||
let now_line_ref = now_line_ref.clone();
|
||||
use_effect_with(epg.clone(), move |epg_tv| {
|
||||
let epg_window = (*epg).as_ref().map(|tv| (tv.start, tv.stop));
|
||||
use_effect_with(epg_window, move |epg_window| {
|
||||
// Updates the now-line position
|
||||
let epg_tv_clone = epg_tv.clone();
|
||||
let epg_window_clone = *epg_window;
|
||||
|
||||
let calculate_position = Rc::new(move |epg_tv: &UseStateHandle<Option<EpgTv>>, recenter: bool| {
|
||||
if let Some(tv) = &**epg_tv {
|
||||
let calculate_position = Rc::new(move |epg_window: &Option<(i64, i64)>, recenter: bool| {
|
||||
if let Some((start, stop)) = epg_window {
|
||||
if let (Some(div), Some(now_line)) =
|
||||
(container_ref.cast::<HtmlElement>(), now_line_ref.cast::<HtmlElement>())
|
||||
{
|
||||
let now = Utc::now().timestamp();
|
||||
if now >= tv.start && now <= tv.stop {
|
||||
let start_window_secs = (tv.start / (TIME_BLOCK_MINS * 60)) * (TIME_BLOCK_MINS * 60);
|
||||
if now >= *start && now <= *stop {
|
||||
let start_window_secs = (*start / (TIME_BLOCK_MINS * 60)) * (TIME_BLOCK_MINS * 60);
|
||||
let start_window = (start_window_secs / 60).max(0);
|
||||
let now_line_pos = get_pos(now, start_window);
|
||||
now_line.style().set_property("width", &format!("{now_line_pos}px")).unwrap();
|
||||
@@ -97,9 +97,9 @@ pub fn EpgView() -> Html {
|
||||
|
||||
let calculate_pos = calculate_position.clone();
|
||||
let interval = Interval::new(60_000, move || {
|
||||
calculate_pos(&epg_tv_clone, false);
|
||||
calculate_pos(&epg_window_clone, false);
|
||||
});
|
||||
calculate_position(epg_tv, true);
|
||||
calculate_position(epg_window, true);
|
||||
|| drop(interval)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::{
|
||||
use shared::{
|
||||
error::{info_err_res, TuliproxError},
|
||||
model::{
|
||||
PlaylistRequest, SearchRequest, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, UiPlaylistGroup,
|
||||
UiPlaylistItem, VirtualId, XtreamCluster,
|
||||
PlaylistRequest, PlaylistUrlResolveRequest, SearchRequest, SeriesStreamDetailEpisodeProperties,
|
||||
SeriesStreamProperties, UiPlaylistGroup, UiPlaylistItem, VirtualId, XtreamCluster,
|
||||
},
|
||||
utils::format_float_localized,
|
||||
};
|
||||
@@ -245,11 +245,9 @@ pub fn PlaylistExplorer() -> Html {
|
||||
let target_id = *target_id;
|
||||
let services_clone = services.clone();
|
||||
spawn_local(async move {
|
||||
if let Some(url) = services
|
||||
.playlist
|
||||
.get_playlist_webplayer_url(target_id, virtual_id, cluster)
|
||||
.await
|
||||
{
|
||||
let request =
|
||||
PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster };
|
||||
if let Some(url) = services.playlist.resolve_url(request).await {
|
||||
copy_to_clipboard.emit(url);
|
||||
services_clone.toastr.success(
|
||||
translate_clone
|
||||
@@ -273,7 +271,25 @@ pub fn PlaylistExplorer() -> Html {
|
||||
if let Some(dto) = &*selected_channel {
|
||||
let url = dto.url.clone();
|
||||
if !url.is_empty() {
|
||||
copy_to_clipboard.emit(url);
|
||||
if let Some(playlist_request) = playlist_ctx.playlist_request.as_ref() {
|
||||
let copy_to_clipboard = copy_to_clipboard.clone();
|
||||
let services = services.clone();
|
||||
let playlist_request = playlist_request.clone();
|
||||
spawn_local(async move {
|
||||
let request = PlaylistUrlResolveRequest::Provider {
|
||||
playlist_request,
|
||||
url: url.to_string(),
|
||||
};
|
||||
let resolved = services
|
||||
.playlist
|
||||
.resolve_url(request)
|
||||
.await
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
copy_to_clipboard.emit(resolved);
|
||||
});
|
||||
} else {
|
||||
copy_to_clipboard.emit(url);
|
||||
}
|
||||
} else {
|
||||
// Try to fetch episode
|
||||
if let Some(playlist_request) = playlist_ctx.playlist_request.as_ref() {
|
||||
@@ -285,7 +301,13 @@ pub fn PlaylistExplorer() -> Html {
|
||||
if let Some(pli) =
|
||||
services.playlist.get_episode(virtual_id, &playlist_request).await
|
||||
{
|
||||
copy_to_clipboard.emit(pli.url.to_string());
|
||||
let url = pli.url.to_string();
|
||||
let request = PlaylistUrlResolveRequest::Provider {
|
||||
playlist_request,
|
||||
url: url.to_string(),
|
||||
};
|
||||
let resolved = services.playlist.resolve_url(request).await.unwrap_or(url);
|
||||
copy_to_clipboard.emit(resolved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct DialogAction {
|
||||
pub style: Option<String>,
|
||||
pub result: DialogResult,
|
||||
pub focus: bool,
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
impl DialogAction {
|
||||
@@ -22,7 +23,7 @@ impl DialogAction {
|
||||
icon: Option<String>,
|
||||
style: Option<String>,
|
||||
) -> Self {
|
||||
Self { name: name.to_string(), label: label.to_string(), icon, style, result, focus: false }
|
||||
Self { name: name.to_string(), label: label.to_string(), icon, style, result, focus: false, disabled: false }
|
||||
}
|
||||
pub(crate) fn new_focused(
|
||||
name: &str,
|
||||
@@ -35,6 +36,11 @@ impl DialogAction {
|
||||
result.focus = true;
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn with_disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
|
||||
@@ -12,6 +12,14 @@ pub struct IconContextProps {
|
||||
pub fn IconContextProvider(props: &IconContextProps) -> Html {
|
||||
let icon_ctx = use_state(|| IconContext::new(&props.icons));
|
||||
|
||||
{
|
||||
let icon_ctx = icon_ctx.clone();
|
||||
use_effect_with(props.icons.clone(), move |icons| {
|
||||
icon_ctx.set(IconContext::new(icons));
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
html! {
|
||||
<ContextProvider<UseStateHandle<IconContext>> context={icon_ctx}>
|
||||
{ for props.children.iter() }
|
||||
|
||||
@@ -4,8 +4,8 @@ use indexmap::IndexMap;
|
||||
use log::error;
|
||||
use shared::{
|
||||
model::{
|
||||
EpgChannel, EpgTv, PlaylistEpgRequest, PlaylistRequest, SeriesStreamProperties, UiPlaylistCategories,
|
||||
UiPlaylistGroup, UiPlaylistItem, WebplayerUrlRequest, XtreamCluster, XtreamSeriesInfoDoc,
|
||||
EpgChannel, EpgTv, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, SeriesStreamProperties,
|
||||
UiPlaylistCategories, UiPlaylistGroup, UiPlaylistItem, XtreamCluster, XtreamSeriesInfoDoc,
|
||||
},
|
||||
utils::concat_path_leading_slash,
|
||||
};
|
||||
@@ -16,7 +16,7 @@ pub struct PlaylistService {
|
||||
playlist_api_live_path: String,
|
||||
playlist_api_vod_path: String,
|
||||
playlist_api_series_path: String,
|
||||
playlist_api_webplayer_url_path: String,
|
||||
playlist_api_resolve_url_path: String,
|
||||
playlist_api_epg_path: String,
|
||||
playlist_api_series_info_path: String,
|
||||
playlist_api_episode_info_path: String,
|
||||
@@ -33,7 +33,7 @@ impl PlaylistService {
|
||||
playlist_api_live_path: concat_path_leading_slash(&base_href, "api/v1/playlist/live"),
|
||||
playlist_api_vod_path: concat_path_leading_slash(&base_href, "api/v1/playlist/vod"),
|
||||
playlist_api_series_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series"),
|
||||
playlist_api_webplayer_url_path: concat_path_leading_slash(&base_href, "api/v1/playlist/webplayer"),
|
||||
playlist_api_resolve_url_path: concat_path_leading_slash(&base_href, "api/v1/playlist/resolve_url"),
|
||||
playlist_api_epg_path: concat_path_leading_slash(&base_href, "api/v1/playlist/epg"),
|
||||
playlist_api_series_info_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series_info"),
|
||||
playlist_api_episode_info_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series/episode"),
|
||||
@@ -98,15 +98,15 @@ impl PlaylistService {
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn get_playlist_webplayer_url(
|
||||
&self,
|
||||
target_id: u16,
|
||||
virtual_id: u32,
|
||||
cluster: XtreamCluster,
|
||||
) -> Option<String> {
|
||||
let request = WebplayerUrlRequest { target_id, virtual_id, cluster };
|
||||
request_post::<&WebplayerUrlRequest, String>(
|
||||
&self.playlist_api_webplayer_url_path,
|
||||
pub async fn resolve_url(&self, request: PlaylistUrlResolveRequest) -> Option<String> {
|
||||
if let PlaylistUrlResolveRequest::Provider { url, .. } = &request {
|
||||
if !url.starts_with(shared::utils::PROVIDER_SCHEME_PREFIX) {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
request_post::<&PlaylistUrlResolveRequest, String>(
|
||||
&self.playlist_api_resolve_url_path,
|
||||
&request,
|
||||
None,
|
||||
Some(Encoding::Text),
|
||||
|
||||
@@ -32,7 +32,6 @@ mod ui_playlist_item;
|
||||
mod user_command;
|
||||
mod uuidtype;
|
||||
mod web_socket;
|
||||
mod webplayer_url_request;
|
||||
mod xtream;
|
||||
pub mod xtream_const;
|
||||
|
||||
@@ -42,5 +41,5 @@ pub use self::{
|
||||
playlist_categories::*, playlist_info_document::*, playlist_request::*, processing_order::*, regex_cache::*,
|
||||
search_request::*, short_epg::*, stats::*, status_check::*, stream_info::*, stream_meter::*, stream_properties::*,
|
||||
strm_export_style::*, system_info::*, target_type::*, ui_playlist_item::*, user_command::*, uuidtype::*,
|
||||
web_socket::*, webplayer_url_request::*, xtream::*,
|
||||
web_socket::*, xtream::*,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,12 @@ pub enum PlaylistRequest {
|
||||
CustomM3u(PlaylistRequestM3u),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub enum PlaylistUrlResolveRequest {
|
||||
Webplayer { target_id: u16, virtual_id: u32, cluster: XtreamCluster },
|
||||
Provider { playlist_request: PlaylistRequest, url: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
pub struct CommonPlaylistItem {
|
||||
pub virtual_id: u32,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
use crate::model::XtreamCluster;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WebplayerUrlRequest {
|
||||
pub target_id: u16,
|
||||
pub virtual_id: u32,
|
||||
pub cluster: XtreamCluster,
|
||||
}
|
||||
Reference in New Issue
Block a user