Merge pull request #463 from euzu/feature/shared_stream_custom_response

Feature/shared stream custom response
This commit is contained in:
euzu
2025-12-03 13:03:56 +01:00
committed by GitHub
34 changed files with 1621 additions and 186 deletions
+3 -1
View File
@@ -29,4 +29,6 @@ docker/tuliprox
docker/resources
# Ignore VSCode settings and files
/.vscode
/.vscode
.claude
Generated
+3 -3
View File
@@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "frontend"
version = "3.2.13"
version = "3.2.14"
dependencies = [
"anyhow",
"base64",
@@ -3793,7 +3793,7 @@ dependencies = [
[[package]]
name = "shared"
version = "3.2.13"
version = "3.2.14"
dependencies = [
"base64",
"bitflags 2.10.0",
@@ -4356,7 +4356,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tuliprox"
version = "3.2.13"
version = "3.2.14"
dependencies = [
"arc-swap",
"async-compression",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuliprox"
version = "3.2.13"
version = "3.2.14"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+42 -28
View File
@@ -1,5 +1,5 @@
use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext};
use crate::api::model::UserSession;
use crate::api::model::{UserSession};
use crate::api::model::{
create_channel_unavailable_stream, create_custom_video_stream_response,
create_provider_connections_exhausted_stream, create_provider_stream,
@@ -17,12 +17,12 @@ use crate::utils::request;
use crate::utils::{debug_if_enabled, trace_if_enabled};
use crate::BUILD_TIMESTAMP;
use arc_swap::ArcSwapOption;
use axum::http::HeaderMap;
use axum::http::{HeaderMap};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use log::{debug, error, log_enabled, trace, warn};
use log::{debug, error, log_enabled, trace, warn};
use reqwest::header::RETRY_AFTER;
use serde::Serialize;
use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster};
@@ -373,16 +373,13 @@ async fn resolve_streaming_strategy(
)
};
let available = match allocation {
ProviderAllocation::Exhausted
| ProviderAllocation::Available(_) => true,
ProviderAllocation::GracePeriod(_) => false,
};
if available {
ProviderStreamState::Available(Some(provider), url)
} else {
ProviderStreamState::GracePeriod(Some(provider), url)
match allocation {
ProviderAllocation::Exhausted => {
let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]);
ProviderStreamState::Custom(stream)
},
ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(provider), url),
ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(provider), url),
}
}
}
@@ -418,7 +415,7 @@ fn get_grace_period_millis(
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn create_stream_response_details(
app_state: &Arc<AppState>,
stream_options: &StreamOptions,
@@ -450,6 +447,22 @@ async fn create_stream_response_details(
.as_ref()
.and_then(|guard| guard.allocation.get_provider_name());
if let ProviderStreamState::GracePeriod(ref provider_grace_check, ref stream_url) = streaming_strategy.provider_stream_state {
tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await;
if let Some(provider_name) = provider_grace_check.as_ref() {
if app_state.active_provider.is_over_limit(provider_name).await {
app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted).await;
debug!("Provider connections exhausted after grace period for provider: {provider_name}");
let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]);
streaming_strategy.provider_stream_state = ProviderStreamState::Custom(stream);
let provider_handle = streaming_strategy.provider_handle.take();
app_state.connection_manager.release_provider_handle(provider_handle).await;
}
} else {
streaming_strategy.provider_stream_state = ProviderStreamState::Available(provider_grace_check.clone(), stream_url.clone());
}
}
match streaming_strategy.provider_stream_state {
// custom stream means we display our own stream like connection exhausted, channel-unavailable...
ProviderStreamState::Custom(provider_stream) => {
@@ -837,8 +850,7 @@ pub async fn stream_response(
share_stream,
connection_permission,
None,
)
.await;
).await;
if stream_details.has_stream() {
// let content_length = get_stream_content_length(provider_response.as_ref());
@@ -853,15 +865,20 @@ pub async fn stream_response(
} else {
None
};
stream_channel.shared = share_stream;
let mut is_stream_shared = share_stream;
if let Some((_header, _status_code, _url, Some(_custom_video))) = stream_details.stream_info.as_ref() {
if stream_details.stream.is_some() {
is_stream_shared = false;
}
}
stream_channel.shared = is_stream_shared;
let stream =
ActiveClientStream::new(stream_details, app_state, user, connection_permission, fingerprint, stream_channel, Some(session_token), req_headers)
.await;
let stream_resp = if share_stream {
debug_if_enabled!(
"Streaming shared stream request from {}",
sanitize_sensitive_info(stream_url)
);
let stream_resp = if is_stream_shared {
debug_if_enabled!("Streaming shared stream request from {}",sanitize_sensitive_info(stream_url));
// Shared Stream response
let shared_headers = provider_response
.as_ref()
@@ -898,9 +915,7 @@ pub async fn stream_response(
);
if log_enabled!(log::Level::Debug) {
if session_url.eq(&stream_url) {
debug!(
"Streaming stream request from {}",
sanitize_sensitive_info(stream_url)
debug!("Streaming stream request from {}", sanitize_sensitive_info(stream_url)
);
} else {
debug!(
@@ -977,9 +992,7 @@ async fn try_shared_stream_response_if_any(
if let Some((stream, provider)) =
SharedStreamManager::subscribe_shared_stream(app_state, stream_url, &fingerprint.addr).await
{
debug_if_enabled!(
"Using shared stream {}",
sanitize_sensitive_info(stream_url)
debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url)
);
if let Some(headers) = app_state
.shared_stream_manager
@@ -991,6 +1004,7 @@ async fn try_shared_stream_response_if_any(
axum::http::StatusCode::OK,
)));
let mut stream_details = StreamDetails::from_stream(stream);
stream_details.provider_name = provider;
stream_channel.shared = true;
let stream =
@@ -210,7 +210,7 @@ impl SharedStreamState {
result = broadcast_rx.recv() => {
match result {
Ok(data) => {
// Wenn der Client pausiert, einfach skippen oder warten
// If the client press pause, skip
if client_tx_clone.is_closed() {
continue;
}
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "frontend"
version = "3.2.13"
version = "3.2.14"
edition = "2021"
[dependencies]
shared = { version = "3.2.13", path = "../shared" }
shared = { version = "3.2.14", path = "../shared" }
chrono = "0"
yew = "0.21"
yew-router = "0.18"
+30 -14
View File
@@ -274,8 +274,13 @@
"WATCH": "Watch",
"INPUT_TYPE": "Input Type",
"EPG": "Epg",
"EPG_SOURCE_URL": "EPG Source URL",
"EPG_SOURCES": "EPG Sources",
"ADD_EPG_SOURCE": "Add EPG Source",
"PERSIST": "Persist",
"ALIASES": "Aliases",
"ALIAS_NAME": "Alias Name",
"ADD_ALIAS": "Add Alias",
"MAX_CON": "Max Con.",
"MAX_CONNECTIONS": "Max Connections",
"METHOD": "Method",
@@ -320,14 +325,25 @@
"SKIP_SERIES": "Skip Series",
"ADD_WATCH": "Add Watch",
"ADD_MAPPING": "Add Mapping",
"SKIP_LIVE_DIRECT_SOURCE": "Skip Live Direct Source",
"SKIP_VOD_DIRECT_SOURCE": "Skip Vod Direct Source",
"SKIP_SERIES_DIRECT_SOURCE": "Skip Series Direct Source",
"RESOLVE_VOD": "Resolve Vod",
"RESOLVE_VOD_DELAY_SEC": "Resolve Vod Delay (sec)",
"RESOLVE_SERIES": "Resolve Series",
"RESOLVE_SERIES_DELAY_SEC": "Resolve Series Delay (sec)",
"COPY_CREDENTIALS": "Copy Credentials"
"RESOLVE_DELAY_SEC": "Resolve Delay (sec)",
"COPY_CREDENTIALS": "Copy Credentials",
"EXPORT_STYLE": "Export Style",
"STRM_PROPS": "Properties",
"ADD_PROPERTY": "Add Property",
"TRAKT_API_KEY": "API-Key",
"TRAKT_API_VERSION": "API-Version",
"TRAKT_API_URL": "API-URL",
"TRAKT_LISTS": "Lists",
"TRAKT_USER": "Trakt User",
"TRAKT_LIST_SLUG": "List Slug",
"TRAKT_CATEGORY_NAME": "Category name",
"TRAKT_CONTENT_TYPE": "Content Type",
"TRAKT_FUZZY_MATCH_THRESHOLD": "Fuzzy matchthreshold",
"ADD_TRAKT_LIST": "Add a Trakt list",
"EPG_LOGO_OVERRIDE": "Logo Override",
"API_CONFIGURATION" : "API Configuration",
"HEADER_NAME": "Header Name",
"HEADER_VALUE": "Header Value"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "Playlist Category Selection"
@@ -337,7 +353,7 @@
"INVALID_REGEXP": "Invalid regexp",
"INVALID_FILETYPE": "Invalid filetype",
"TARGET_NOT_EXISTS": "Target does not exist",
"CONFIRM_DELETE": "You really want to delete?",
"CONFIRM_DELETE": "You really want to delete it?",
"CONFIG_CHANGED": "Configuration on server changed.",
"USER_DELETED": "User successfully deleted",
"SCHEDULE_NOT_SET": "Please enter a schedule before adding",
@@ -369,11 +385,11 @@
"SAVE": {
"MAIN_CONFIG": {
"SUCCESS": "Main config saved!",
"FAIL": "Failed to save main config!"
"FAIL": "Failed to save the main config!"
},
"BOUQUET": {
"SUCCESS": "Successfully saved bouquet",
"FAIL": "Failed to save bouquet!"
"FAIL": "Failed to save the bouquet!"
},
"USER": {
"SUCCESS": "Successfully saved user",
@@ -384,7 +400,7 @@
"TARGET_NOT_FOUND": "Target not found!",
"MAX_CONNECTIONS_INVALID": "MaxConnections invalid",
"NOTHING_TO_SAVE": "Nothing changed",
"TARGET_NOT_SELECTED": "Playlist not selected"
"TARGET_NOT_SELECTED": "Playlist isn't selected"
}
},
"PLAYLIST_UPDATE": {
@@ -405,7 +421,7 @@
"INFO": {
"RESTART_TO_APPLY_CHANGES": "You need to restart to apply changes.",
"SCHEDULE_EXAMPLE": "Schedule example",
"PROCESS_PARALLEL": "Process Parallel: If you have multiple providers you can enable it. Accessing your provider in parallel can cause a ban.",
"PROCESS_PARALLEL": "Process Parallel: If you have multiple providers, you can enable it. Accessing your provider in parallel can cause a ban.",
"WEB_ROOT": "Web-Root: Contains the web-ui files. They are normally placed in the same directory under ./web"
},
"HINT": {
@@ -423,7 +439,7 @@
"RESOURCE_REWRITE_DISABLE": "If activated, the URL rewrite of resources in reverse proxy mode is disabled",
"DISABLED_HEADER": "Remove headers before sending requests to the upstream provider",
"REFERER_HEADER": "If activated, the referer header is not sent to the provider",
"X_HEADER": "If activated, headers starting with X- are removed before proxying the request",
"X_HEADER": "If activated, headers starting with 'X-' are removed before proxying the request",
"CUSTOM_HEADERS": "Headers listed here are removed before sending the upstream request",
"THROTTLE": "Supported units are KB/s,MB/s,KiB/s,MiB/s,kbps,mbps,Mibps"
},
+5 -1
View File
@@ -13,7 +13,7 @@
},
{
"keys": [
"Delete"
"Delete", "Remove"
],
"path": "M 4.1422511,6.2868364 H 2.7137817 a 0.7142347,0.7142347 0 0 1 0,-1.4284691 H 8.4276589 V 2.7142341 A 0.7142347,0.7142347 0 0 1 9.1418939,2 h 5.7138771 a 0.7142347,0.7142347 0 0 1 0.714235,0.7142341 v 2.1441332 h 5.713877 a 0.7142347,0.7142347 0 1 1 0,1.4284691 H 19.855414 V 21.285765 A 0.7142347,0.7142347 0 0 1 19.141179,22 H 4.8564857 A 0.7142347,0.7142347 0 0 1 4.1422511,21.285765 Z M 14.141537,4.8583673 v -1.42847 H 9.8561289 v 1.42847 z M 5.5707207,20.571531 H 18.426945 V 6.2868364 H 5.5707207 Z M 9.8561289,17.714592 A 0.7142347,0.7142347 0 0 1 9.1418939,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.4284691,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.7142341,0.714235 z m 4.2854081,0 A 0.7142347,0.7142347 0 0 1 13.427302,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.428469,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.714234,0.714235 z"
},
@@ -525,5 +525,9 @@
{
"keys": ["Accept"],
"path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z"
},
{
"keys": [ "Add" ],
"path": "M13 7h-2v4H7v2h4v4h2v-4h4v-2h-4V7zm-1-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
}
]
+1
View File
@@ -239,6 +239,7 @@
--menu-button-hover-color: #ffffff;
--menu-button-hover-background-color: rgba(32, 39, 51, 0.8);
--menu-button-warn-color: #b96665;
--menu-button-warn-hover-color: #dd7573;
--menu-button-warn-hover-background-color: #4c3434;
+1
View File
@@ -80,3 +80,4 @@
@forward "components/source_editor/source_editor_block";
@forward "components/source_editor/input_form";
@forward "components/cluster_flags";
@forward "components/titled_card";
+2 -3
View File
@@ -21,11 +21,10 @@
font-weight: bold;
cursor: pointer;
color: var(--modest-text-color);
.svg-icon {
fill: var(--menu-button-warn-hover-color);
}
fill: var(--menu-button-warn-color);
&:hover {
color: var(--menu-button-warn-hover-color);
fill: var(--menu-button-warn-hover-color);
}
}
}
@@ -0,0 +1,17 @@
.tp__titled-card {
position: relative;
padding: 1em 0.5em 0.5em 0.5em;
border: 1px solid var(--card-border-color);
border-radius: var(--border-radius);
}
.tp__titled-card__title {
position: absolute;
top: -0.7em;
left: 15px;
padding: 0 4px;
z-index: 2;
font-size: 14px;
line-height: 1.2;
background: linear-gradient(to bottom, #00000000 50%, var(--card-background-color) 50%);
}
@@ -8,6 +8,8 @@ $toggle-switch-toggle-end-pos: calc($toggle-switch-width - ($toggle-switch-toggl
position: relative;
display: inline-block;
width: $toggle-switch-width;
min-width: $toggle-switch-width;
max-width: $toggle-switch-width;
height: $toggle-switch-height;
cursor: pointer;
border: 2px solid transparent;
@@ -59,11 +59,36 @@
gap: var(--gap-large);
background-color: var(--sub-card-background-color);
overflow: hidden;
h1 {
font-size: 1.2rem;
font-weight: bold;
}
}
&__cols-2,
&__cols-3 {
display: grid;
gap: var(--gap-default);
.tp__form-field {
min-width: 0;
}
}
&__cols-2 {
grid-template-columns: 1fr 1fr;
@media (max-width: size.$mobile-breakpoint) {
grid-template-columns: 1fr;
}
}
&__cols-3 {
grid-template-columns: 1fr 1fr 1fr;
@media (max-width: size.$mobile-breakpoint) {
grid-template-columns: 1fr;
}
}
}
.tp__config-view-page {
@@ -111,6 +136,7 @@
gap: var(--gap-default);
box-sizing: border-box;
}
&__schedule {
display: flex;
flex-flow: column;
@@ -123,14 +149,16 @@
text-align: left;
}
th,td {
th, td {
width: 1%;
white-space: nowrap;
}
th:last-child,
td:last-child {
width: auto;
}
td {
vertical-align: middle;
}
@@ -141,28 +169,34 @@
}
}
&__footer {
margin-top: var(--margin-default);
padding-top: var(--margin-default);
border-top : 1px solid var(--card-border-color);
border-top: 1px solid var(--card-border-color);
svg {
width: 320px;
height: 90px;
}
fill: var(--label-color);
}
&__editor {
display: flex;
flex-flow: row wrap;
align-items: flex-end;
gap: var(--gap-default);
box-sizing: border-box;
.tp__icon-button {
svg {
width: 2.3rem;
}
}
}
&__delete-btn {
fill: var(--menu-button-warn-hover-color);
}
@@ -174,6 +208,7 @@
border-color: var(--warn-color);
}
}
&__inactive {
button {
border-color: var(--warn-color);
@@ -205,8 +240,9 @@
&__info {
display: flex;
flex-flow: row;
color:var(--warn-color);
color: var(--warn-color);
fill: currentColor;
span {
white-space: wrap;
}
@@ -1,4 +1,4 @@
$form-width: 300px;
$form-width: 380px;
.tp__source-editor-form-wrapper {
position: absolute;
@@ -38,4 +38,17 @@ $form-width: 300px;
}
}
}
.tp__form-list__item {
display: flex;
flex-flow: row nowrap;
gap: var(--gap-small);
align-items: center;
.tp__icon-button {
fill: var(--menu-button-warn-color);
&:hover {
fill: var(--menu-button-warn-hover-color);
}
}
}
}
+2
View File
@@ -49,6 +49,7 @@ mod websocket_status;
mod floating_background;
mod source_editor;
mod cluster_flags_input;
mod title_card;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
@@ -92,6 +93,7 @@ pub(crate) use self::key_value_editor::*;
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::*;
@@ -0,0 +1,105 @@
use crate::app::components::{Card, TextButton};
use crate::{edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer};
use shared::model::ConfigInputAliasDto;
use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_ALIAS_NAME: &str = "LABEL.ALIAS_NAME";
const LABEL_URL: &str = "LABEL.URL";
const LABEL_USERNAME: &str = "LABEL.USERNAME";
const LABEL_PASSWORD: &str = "LABEL.PASSWORD";
const LABEL_PRIORITY: &str = "LABEL.PRIORITY";
const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS";
const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE";
generate_form_reducer!(
state: AliasFormState { form: ConfigInputAliasDto },
action_name: AliasFormAction,
fields {
Name => name: String,
Url => url: String,
Username => username: Option<String>,
Password => password: Option<String>,
Priority => priority: i16,
MaxConnections => max_connections: u16,
ExpDate => exp_date: Option<i64>,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct AliasItemFormProps {
pub on_submit: Callback<ConfigInputAliasDto>,
pub on_cancel: Callback<()>,
#[prop_or_default]
pub initial: Option<ConfigInputAliasDto>,
}
#[function_component]
pub fn AliasItemForm(props: &AliasItemFormProps) -> Html {
let translate = use_translation();
let form_state: UseReducerHandle<AliasFormState> = use_reducer(|| {
AliasFormState {
form: props.initial.clone().unwrap_or_else(|| ConfigInputAliasDto {
id: 0,
name: String::new(),
url: String::new(),
username: None,
password: None,
priority: 0,
max_connections: 1,
exp_date: None,
}),
modified: false,
}
});
let handle_submit = {
let form_state = form_state.clone();
let on_submit = props.on_submit.clone();
Callback::from(move |_| {
let data = form_state.form.clone();
if !data.name.trim().is_empty() && !data.url.trim().is_empty() {
on_submit.emit(data);
}
})
};
let handle_cancel = {
let on_cancel = props.on_cancel.clone();
Callback::from(move |_| {
on_cancel.emit(());
})
};
html! {
<Card class="tp__config-view__card tp__item-form">
{ edit_field_text!(form_state, translate.t(LABEL_ALIAS_NAME), name, AliasFormAction::Name) }
{ edit_field_text!(form_state, translate.t(LABEL_URL), url, AliasFormAction::Url) }
<div class="tp__config-view__cols-2">
{ edit_field_text_option!(form_state, translate.t(LABEL_USERNAME), username, AliasFormAction::Username) }
{ edit_field_text_option!(form_state, translate.t(LABEL_PASSWORD), password, AliasFormAction::Password, true) }
{ edit_field_number_i16!(form_state, translate.t(LABEL_PRIORITY), priority, AliasFormAction::Priority) }
{ edit_field_number_u16!(form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, AliasFormAction::MaxConnections) }
</div>
{ edit_field_date!(form_state, translate.t(LABEL_EXP_DATE), exp_date, AliasFormAction::ExpDate) }
<div class="tp__form-page__toolbar">
<TextButton
class="secondary"
name="cancel_alias"
icon="Cancel"
title={translate.t("LABEL.CANCEL")}
onclick={handle_cancel}
/>
<TextButton
class="primary"
name="submit_alias"
icon="Accept"
title={translate.t("LABEL.SUBMIT")}
onclick={handle_submit}
/>
</div>
</Card>
}
}
@@ -0,0 +1,86 @@
use crate::app::components::{Card, TextButton};
use crate::{edit_field_bool, edit_field_number_i16, edit_field_text, generate_form_reducer};
use shared::model::EpgSourceDto;
use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_EPG_SOURCE_URL: &str = "LABEL.EPG_SOURCE_URL";
const LABEL_EPG_PRIORITY: &str = "LABEL.PRIORITY";
const LABEL_EPG_LOGO_OVERRIDE: &str = "LABEL.EPG_LOGO_OVERRIDE";
generate_form_reducer!(
state: EpgSourceFormState { form: EpgSourceDto },
action_name: EpgSourceFormAction,
fields {
Url => url: String,
Priority => priority: i16,
LogoOverride => logo_override: bool,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct EpgSourceItemFormProps {
pub on_submit: Callback<EpgSourceDto>,
pub on_cancel: Callback<()>,
#[prop_or_default]
pub initial: Option<EpgSourceDto>,
}
#[function_component]
pub fn EpgSourceItemForm(props: &EpgSourceItemFormProps) -> Html {
let translate = use_translation();
let form_state: UseReducerHandle<EpgSourceFormState> = use_reducer(|| {
EpgSourceFormState {
form: props.initial.clone().unwrap_or_else(|| EpgSourceDto {
url: String::new(),
priority: 0,
logo_override: false,
}),
modified: false,
}
});
let handle_submit = {
let form_state = form_state.clone();
let on_submit = props.on_submit.clone();
Callback::from(move |_| {
let data = form_state.form.clone();
if !data.url.trim().is_empty() {
on_submit.emit(data);
}
})
};
let handle_cancel = {
let on_cancel = props.on_cancel.clone();
Callback::from(move |_| {
on_cancel.emit(());
})
};
html! {
<Card class="tp__config-view__card tp__item-form">
{ edit_field_text!(form_state, translate.t(LABEL_EPG_SOURCE_URL), url, EpgSourceFormAction::Url) }
{ edit_field_number_i16!(form_state, translate.t(LABEL_EPG_PRIORITY), priority, EpgSourceFormAction::Priority) }
{ edit_field_bool!(form_state, translate.t(LABEL_EPG_LOGO_OVERRIDE), logo_override, EpgSourceFormAction::LogoOverride) }
<div class="tp__form-page__toolbar">
<TextButton
class="secondary"
name="cancel_epg_source"
icon="Cancel"
title={translate.t("LABEL.CANCEL")}
onclick={handle_cancel}
/>
<TextButton
class="primary"
name="submit_epg_source"
icon="Accept"
title={translate.t("LABEL.SUBMIT")}
onclick={handle_submit}
/>
</div>
</Card>
}
}
@@ -1,30 +1,36 @@
use crate::app::components::config::HasFormData;
use crate::app::components::key_value_editor::KeyValueEditor;
use crate::app::components::select::Select;
use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton};
use crate::{config_field_child, edit_field_bool, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer};
use shared::model::{ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto};
use crate::app::components::{AliasItemForm, BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, EpgSourceItemForm, IconButton, Panel, RadioButtonGroup, SourceEditorContext, TextButton, TitledCard};
use crate::{config_field_child, edit_field_bool, edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer};
use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, EpgConfigDto, EpgSourceDto, InputFetchMethod, InputType, StagedInputDto};
use std::collections::HashMap;
use std::fmt::Display;
use std::rc::Rc;
use web_sys::MouseEvent;
use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_NAME: &str = "LABEL.NAME";
const LABEL_INPUT_TYPE: &str = "LABEL.INPUT_TYPE";
const LABEL_FETCH_METHOD: &str = "LABEL.METHOD";
// const LABEL_HEADERS: &str = "LABEL.HEADERS";
const LABEL_HEADERS: &str = "LABEL.HEADERS";
const LABEL_URL: &str = "LABEL.URL";
// const LABEL_EPG: &str = "LABEL.EPG";
const LABEL_EPG_SOURCES: &str = "LABEL.EPG_SOURCES";
const LABEL_USERNAME: &str = "LABEL.USERNAME";
const LABEL_PASSWORD: &str = "LABEL.PASSWORD";
const LABEL_PERSIST: &str = "LABEL.PERSIST";
const LABEL_ENABLED: &str = "LABEL.ENABLED";
// const LABEL_ALIASES: &str = "LABEL.ALIASES";
const LABEL_ALIASES: &str = "LABEL.ALIASES";
const LABEL_PRIORITY: &str = "LABEL.PRIORITY";
const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS";
// const LABEL_ADD_HEADER: &str = "LABEL.HEADERS";
const LABEL_XTREAM_SKIP_LIVE: &str = "LABEL.SKIP_LIVE";
const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.SKIP_VOD";
const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SKIP_SERIES";
const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE";
const LABEL_ADD_EPG_SOURCE: &str = "LABEL.ADD_EPG_SOURCE";
const LABEL_ADD_ALIAS: &str = "LABEL.ADD_ALIAS";
const LABEL_SKIP: &str = "LABEL.SKIP";
const LABEL_XTREAM_SKIP_LIVE: &str = "LABEL.LIVE";
const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.VOD";
const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SERIES";
const LABEL_XTREAM_LIVE_STREAM_USE_PREFIX: &str = "LABEL.LIVE_STREAM_USE_PREFIX";
const LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION: &str = "LABEL.LIVE_STREAM_WITHOUT_EXTENSION";
@@ -34,28 +40,20 @@ enum InputFormPage {
Main,
Options,
Staged,
Advanced,
}
impl Display for InputFormPage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
InputFormPage::Main => "Main".to_string(),
InputFormPage::Options => "Options".to_string(),
InputFormPage::Staged => "Staged".to_string(),
InputFormPage::Main => "Main",
InputFormPage::Options => "Options",
InputFormPage::Staged => "Staged",
InputFormPage::Advanced => "Advanced",
})
}
}
// generate_form_reducer!(
// state: EpgConfigDtoFormState { form: EpgConfigDto },
// action_name: EpgConfigDtoFormAction,
// fields {
// Enabled => enabled: bool,
// PeriodMillis => period_millis: u64,
// BurstSize => burst_size: u32,
// }
// );
generate_form_reducer!(
state: ConfigInputOptionsDtoFormState { form: ConfigInputOptionsDto },
action_name: ConfigInputOptionsFormAction,
@@ -85,17 +83,16 @@ generate_form_reducer!(
state: ConfigInputFormState { form: ConfigInputDto },
action_name: ConfigInputFormAction,
fields {
Name => name: String,
//InputType => input_type: InputType,
// Headers => headers: HashMap<String, String>,
Url => url: String,
Username => username: Option<String>,
Password => password: Option<String>,
Persist => persist: Option<String>,
Enabled => enabled: bool,
Priority => priority: i16,
MaxConnections => max_connections: u16,
Method => method: InputFetchMethod,
Name => name: String,
Url => url: String,
Username => username: Option<String>,
Password => password: Option<String>,
Persist => persist: Option<String>,
Enabled => enabled: bool,
Priority => priority: i16,
MaxConnections => max_connections: u16,
Method => method: InputFetchMethod,
ExpDate => exp_date: Option<i64>,
}
);
@@ -107,7 +104,6 @@ pub struct ConfigInputViewProps {
#[function_component]
pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let fetch_methods = use_memo((), |_| {
@@ -139,6 +135,15 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
modified: false,
});
// State for EPG sources, Aliases, and Headers
let epg_sources_state = use_state(Vec::<EpgSourceDto>::new);
let aliases_state = use_state(Vec::<ConfigInputAliasDto>::new);
let headers_state = use_state(HashMap::<String, String>::new);
// State for showing item forms
let show_epg_form_state = use_state(|| false);
let show_alias_form_state = use_state(|| false);
let staged_input_types = use_memo(staged_input_state.form.input_type, |input_type| {
let default_it = input_type;
[
@@ -159,42 +164,133 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
let input_form_state = input_form_state.clone();
let input_options_state = input_options_state.clone();
let staged_input_state = staged_input_state.clone();
let epg_sources_state = epg_sources_state.clone();
let aliases_state = aliases_state.clone();
let headers_state = headers_state.clone();
let config_input = props.input.clone();
use_effect_with(config_input, move |cfg| {
if let Some(input) = cfg {
input_form_state.dispatch(ConfigInputFormAction::SetAll(input.as_ref().clone()));
input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(
input
.options
.as_ref()
.map_or_else(ConfigInputOptionsDto::default, |d| d.clone()),
input.options.as_ref().map_or_else(ConfigInputOptionsDto::default, |d| d.clone()),
));
staged_input_state.dispatch(StagedInputFormAction::SetAll(
input
.staged
.as_ref()
.map_or_else(StagedInputDto::default, |c| c.clone()),
input.staged.as_ref().map_or_else(StagedInputDto::default, |c| c.clone()),
));
// Load headers
headers_state.set(input.headers.clone());
// Load EPG sources
epg_sources_state.set(input.epg.as_ref().and_then(|epg| epg.sources.clone()).unwrap_or_default());
// Load aliases
aliases_state.set(input.aliases.clone().unwrap_or_default());
} else {
input_form_state.dispatch(ConfigInputFormAction::SetAll(ConfigInputDto::default()));
input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(
ConfigInputOptionsDto::default(),
));
staged_input_state
.dispatch(StagedInputFormAction::SetAll(StagedInputDto::default()));
input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(ConfigInputOptionsDto::default()));
staged_input_state.dispatch(StagedInputFormAction::SetAll(StagedInputDto::default()));
headers_state.set(HashMap::new());
epg_sources_state.set(Vec::new());
aliases_state.set(Vec::new());
}
|| ()
});
}
let handle_add_epg_item = {
let epg_sources = epg_sources_state.clone();
let show_epg_form = show_epg_form_state.clone();
Callback::from(move |source: EpgSourceDto| {
let mut sources = (*epg_sources).clone();
sources.push(source);
epg_sources.set(sources);
show_epg_form.set(false);
})
};
let handle_close_add_epg_item = {
let show_epg_form = show_epg_form_state.clone();
Callback::from(move |_| {
show_epg_form.set(false);
})
};
let handle_show_add_epg_item = {
let show_epg_form = show_epg_form_state.clone();
Callback::from(move |_| {
show_epg_form.set(true);
})
};
let handle_add_alias_item = {
let aliases = aliases_state.clone();
let show_alias_form = show_alias_form_state.clone();
Callback::from(move |alias: ConfigInputAliasDto| {
let mut items = (*aliases).clone();
items.push(alias);
aliases.set(items);
show_alias_form.set(false);
})
};
let handle_close_add_alias_item = {
let show_alias_form = show_alias_form_state.clone();
Callback::from(move |()| {
show_alias_form.set(false);
})
};
let handle_show_add_alias_item = {
let show_alias_form = show_alias_form_state.clone();
Callback::from(move |_| {
show_alias_form.set(true);
})
};
let handle_remove_alias_list_item = {
let alias_list = aliases_state.clone();
Callback::from(move |(idx, e): (String, MouseEvent)| {
e.prevent_default();
if let Ok(index) = idx.parse::<usize>() {
let mut items = (*alias_list).clone();
if index < items.len() {
items.remove(index);
alias_list.set(items);
}
}
})
};
let handle_remove_epg_source = {
let epg_list = epg_sources_state.clone();
Callback::from(move |(idx, e): (String, MouseEvent)| {
e.prevent_default();
if let Ok(index) = idx.parse::<usize>() {
let mut items = (*epg_list).clone();
if index < items.len() {
items.remove(index);
epg_list.set(items);
}
}
})
};
let render_options = || {
html! {
<Card class="tp__config-view__card">
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) }
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) }
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), xtream_skip_series, ConfigInputOptionsFormAction::XtreamSkipSeries) }
<TitledCard title={translate.t(LABEL_SKIP)}>
<div class="tp__config-view__cols-3">
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) }
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) }
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), xtream_skip_series, ConfigInputOptionsFormAction::XtreamSkipSeries) }
</div>
</TitledCard>
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_USE_PREFIX), xtream_live_stream_use_prefix, ConfigInputOptionsFormAction::XtreamLiveStreamUsePrefix) }
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION), xtream_live_stream_without_extension, ConfigInputOptionsFormAction::XtreamLiveStreamWithoutExtension) }
</Card>
@@ -208,6 +304,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
html! {
<Card class="tp__config-view__card">
{ edit_field_text!(staged_input_state, translate.t(LABEL_URL), url, StagedInputFormAction::Url) }
<div class="tp__config-view__cols-2">
{ edit_field_text_option!(staged_input_state, translate.t(LABEL_USERNAME), username, StagedInputFormAction::Username) }
{ edit_field_text_option!(staged_input_state, translate.t(LABEL_PASSWORD), password, StagedInputFormAction::Password, true) }
{ config_field_child!(translate.t(LABEL_FETCH_METHOD), {
@@ -247,6 +344,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
options={staged_input_types.clone()}
/>
}})}
</div>
//{ edit_field_list!(staged_input_state, translate.t(LABEL_HEADERS), headers, StagedInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) }
</Card>
@@ -256,17 +354,28 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
let render_input = || {
let input_method_selection = Rc::new(vec![input_form_state.form.method.to_string()]);
let input_form_state_disp = input_form_state.clone();
let aliases = aliases_state.clone();
let show_alias_form = show_alias_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) }
if *show_alias_form {
<AliasItemForm
on_submit={handle_add_alias_item}
on_cancel={handle_close_add_alias_item}
/>
} else {
<div class="tp__config-view__cols-2">
{ edit_field_text!(input_form_state, translate.t(LABEL_NAME), name, ConfigInputFormAction::Name) }
{ edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) }
</div>
{ edit_field_text!(input_form_state, translate.t(LABEL_URL), url, ConfigInputFormAction::Url) }
<div class="tp__config-view__cols-2">
{ edit_field_text_option!(input_form_state, translate.t(LABEL_USERNAME), username, ConfigInputFormAction::Username) }
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PASSWORD), password, ConfigInputFormAction::Password, true) }
{ edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) }
{ edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) }
// pub input_type: InputType,
//{ edit_field_list!(input_form_state, translate.t(LABEL_HEADERS), headers, ConfigInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) }
{ edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) }
{ edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) }
{ edit_field_date!(input_form_state, translate.t(LABEL_EXP_DATE), exp_date, ConfigInputFormAction::ExpDate) }
{ config_field_child!(translate.t(LABEL_FETCH_METHOD), {
html! {
<RadioButtonGroup
@@ -280,10 +389,108 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
selected={input_method_selection}
/>
}})}
</div>
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) }
// Aliases Section
{ config_field_child!(translate.t(LABEL_ALIASES), {
let aliases_list = aliases.clone();
html! {
<div class="tp__form-list">
<div class="tp__form-list__items">
{
for (*aliases_list).iter().enumerate().map(|(idx, alias)| {
html! {
<div class="tp__form-list__item" key={format!("alias-{idx}")}>
<IconButton
name={idx.to_string()}
icon="Delete"
onclick={handle_remove_alias_list_item.clone()}/>
<div class="tp__form-list__item-content">
<span><strong>{&alias.name}</strong>{" - "}{&alias.url}</span>
</div>
</div>
}
})
}
</div>
<TextButton
class="primary"
name="add_alias"
icon="Add"
title={translate.t(LABEL_ADD_ALIAS)}
onclick={handle_show_add_alias_item}
/>
</div>
}
})}
}
</Card>
}
};
// pub epg: Option<EpgConfigDto>,
// pub aliases: Option<Vec<ConfigInputAliasDto>>,
let render_advanced = || {
let headers = headers_state.clone();
let epg_sources = epg_sources_state.clone();
let show_epg_form = show_epg_form_state.clone();
html! {
<Card class="tp__config-view__card">
if *show_epg_form {
<EpgSourceItemForm
on_submit={handle_add_epg_item}
on_cancel={handle_close_add_epg_item}
/>
} else {
// Headers Section
{ config_field_child!(translate.t(LABEL_HEADERS), {
let headers_set = headers.clone();
html! {
<KeyValueEditor
entries={(*headers).clone()}
readonly={false}
key_placeholder={translate.t("LABEL.HEADER_NAME")}
value_placeholder={translate.t("LABEL.HEADER_VALUE")}
on_change={Callback::from(move |new_headers: HashMap<String, String>| {
headers_set.set(new_headers);
})}
/>
}
})}
// EPG Sources Section
{ config_field_child!(translate.t(LABEL_EPG_SOURCES), {
let epg_sources_list = epg_sources.clone();
html! {
<div class="tp__form-list">
<div class="tp__form-list__items">
{
for (*epg_sources_list).iter().enumerate().map(|(idx, source)| {
html! {
<div class="tp__form-list__item" key={format!("epg-{idx}")}>
<IconButton
name={idx.to_string()}
icon="Delete"
onclick={handle_remove_epg_source.clone()} />
<div class="tp__form-list__item-content">
<span>{&source.url}</span>
</div>
</div>
}
})
}
</div>
<TextButton
class="primary"
name="add_epg_source"
icon="Add"
title={translate.t(LABEL_ADD_EPG_SOURCE)}
onclick={handle_show_add_epg_item}
/>
</div>
}
})}
}
</Card>
}
};
@@ -293,6 +500,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
let input_form_state = input_form_state.clone();
let input_options_state = input_options_state.clone();
let staged_input_state = staged_input_state.clone();
let headers_state = headers_state.clone();
let epg_sources_state = epg_sources_state.clone();
let aliases_state = aliases_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let mut input = input_form_state.data().clone();
@@ -311,15 +521,42 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
Some(staged_input.clone())
};
// Handle Headers
input.headers = (*headers_state).clone();
// Handle EPG: update sources but preserve other fields if present
let epg_sources = (*epg_sources_state).clone();
if let Some(mut epg_cfg) = input.epg.take() {
epg_cfg.sources = if epg_sources.is_empty() { None } else { Some(epg_sources) };
input.epg = if epg_cfg.sources.is_some() || epg_cfg.smart_match.is_some() {
Some(epg_cfg)
} else {
None
};
} else if !epg_sources.is_empty() {
input.epg = Some(EpgConfigDto {
sources: Some(epg_sources),
..EpgConfigDto::default()
});
}
// Handle Aliases
let aliases = (*aliases_state).clone();
input.aliases = if aliases.is_empty() {
None
} else {
Some(aliases)
};
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Input(Rc::new(input))));
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
let handle_cancel = {
let source_editor_ctx = source_editor_ctx.clone();
Callback::from(move |_| {
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
let source_editor_ctx = source_editor_ctx.clone();
Callback::from(move |_| {
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
let render_edit_mode = || {
@@ -330,7 +567,8 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
for [
InputFormPage::Main,
InputFormPage::Options,
InputFormPage::Staged
InputFormPage::Staged,
InputFormPage::Advanced
].iter().map(|page| {
let page_str = page.to_string();
let active = *view_visible == page_str;
@@ -360,6 +598,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
<Panel value={InputFormPage::Staged.to_string()} active={view_visible.to_string()}>
{render_staged()}
</Panel>
<Panel value={InputFormPage::Advanced.to_string()} active={view_visible.to_string()}>
{render_advanced()}
</Panel>
</div>
</div>
}
@@ -368,14 +609,14 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form__toolbar tp__form-page__toolbar">
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_input}></TextButton>
<TextButton class="secondary" name="cancel_input"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_input}></TextButton>
</div>
{ render_edit_mode() }
</div>
@@ -8,7 +8,13 @@ mod output_form;
mod editor_form;
mod block_view;
mod output_xtream_form;
mod output_m3u_form;
mod output_strm_form;
mod output_hdhomerun_form;
mod layout;
mod epg_source_item_form;
mod alias_item_form;
mod trakt_list_item_form;
pub(crate) use editor_view::*;
pub(crate) use editor_model::*;
@@ -19,4 +25,10 @@ pub(crate) use target_form::*;
pub(crate) use output_form::*;
pub(crate) use editor_form::*;
pub(crate) use block_view::*;
pub(crate) use output_xtream_form::*;
pub(crate) use output_xtream_form::*;
pub(crate) use output_m3u_form::*;
pub(crate) use output_strm_form::*;
pub(crate) use output_hdhomerun_form::*;
pub(crate) use epg_source_item_form::*;
pub(crate) use alias_item_form::*;
pub(crate) use trakt_list_item_form::*;
@@ -1,4 +1,4 @@
use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView};
use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView, M3uTargetOutputView, StrmTargetOutputView, HdHomeRunTargetOutputView};
use shared::model::TargetOutputDto;
use std::rc::Rc;
use yew::{function_component, html, use_context, Html, Properties};
@@ -21,7 +21,14 @@ pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html {
BlockType::InputXtream
| BlockType::InputM3u
| BlockType::Target => html! {},
BlockType::OutputM3u => html! {},
BlockType::OutputM3u => {
let output = props.output.as_ref()
.and_then(|to| if let TargetOutputDto::M3u(m3u) = &**to {
Some(Rc::new(m3u.clone()))
} else { None });
html! { <M3uTargetOutputView block_id={block_id} output={output} /> }
}
BlockType::OutputXtream => {
let output = props.output.as_ref()
.and_then(|to| if let TargetOutputDto::Xtream(xtream) = &**to {
@@ -30,8 +37,22 @@ pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html {
html! { <XtreamTargetOutputView block_id={block_id} output={output} /> }
}
BlockType::OutputHdHomeRun => html! {},
BlockType::OutputStrm => html! {},
BlockType::OutputHdHomeRun => {
let output = props.output.as_ref()
.and_then(|to| if let TargetOutputDto::HdHomeRun(hdhomerun) = &**to {
Some(Rc::new(hdhomerun.clone()))
} else { None });
html! { <HdHomeRunTargetOutputView block_id={block_id} output={output} /> }
}
BlockType::OutputStrm => {
let output = props.output.as_ref()
.and_then(|to| if let TargetOutputDto::Strm(strm) = &**to {
Some(Rc::new(strm.clone()))
} else { None });
html! { <StrmTargetOutputView block_id={block_id} output={output} /> }
}
}
}
EditMode::Inactive => html! {}
@@ -0,0 +1,138 @@
use crate::app::components::config::HasFormData;
use crate::app::components::select::Select;
use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, SourceEditorContext, TextButton};
use crate::{config_field_child, edit_field_text, generate_form_reducer};
use shared::model::{HdHomeRunTargetOutputDto, TargetOutputDto, TargetType};
use std::rc::Rc;
use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_DEVICE: &str = "LABEL.DEVICE";
const LABEL_USERNAME: &str = "LABEL.USERNAME";
const LABEL_USE_OUTPUT: &str = "LABEL.USE_OUTPUT";
generate_form_reducer!(
state: HdHomeRunTargetOutputFormState { form: HdHomeRunTargetOutputDto },
action_name: HdHomeRunTargetOutputFormAction,
fields {
Device => device: String,
Username => username: String,
UseOutput => use_output: Option<TargetType>,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct HdHomeRunTargetOutputViewProps {
pub(crate) block_id: BlockId,
pub(crate) output: Option<Rc<HdHomeRunTargetOutputDto>>,
}
#[function_component]
pub fn HdHomeRunTargetOutputView(props: &HdHomeRunTargetOutputViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let output_form_state: UseReducerHandle<HdHomeRunTargetOutputFormState> =
use_reducer(|| HdHomeRunTargetOutputFormState {
form: HdHomeRunTargetOutputDto::default(),
modified: false,
});
let target_types = use_memo(output_form_state.form.use_output, |use_output| {
let default_type = use_output.unwrap_or(TargetType::M3u);
[
TargetType::M3u,
TargetType::Xtream,
]
.iter()
.map(|t| DropDownOption {
id: t.to_string(),
label: html! { t.to_string() },
selected: *t == default_type,
}).collect::<Vec<DropDownOption>>()
});
{
let output_form_state = output_form_state.clone();
let config_output = props.output.clone();
use_effect_with(config_output, move |cfg| {
if let Some(output) = cfg {
output_form_state.dispatch(HdHomeRunTargetOutputFormAction::SetAll(output.as_ref().clone()));
} else {
output_form_state.dispatch(HdHomeRunTargetOutputFormAction::SetAll(HdHomeRunTargetOutputDto::default()));
}
|| ()
});
}
let render_output = || {
let output_form_state_1 = output_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_text!(output_form_state, translate.t(LABEL_DEVICE), device, HdHomeRunTargetOutputFormAction::Device) }
{ edit_field_text!(output_form_state, translate.t(LABEL_USERNAME), username, HdHomeRunTargetOutputFormAction::Username) }
{ config_field_child!(translate.t(LABEL_USE_OUTPUT), {
html! {
<Select
name={"use_output"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
match selections {
DropDownSelection::Empty => {
output_form_state_1.dispatch(HdHomeRunTargetOutputFormAction::UseOutput(Some(TargetType::M3u)));
}
DropDownSelection::Single(option) => {
output_form_state_1.dispatch(HdHomeRunTargetOutputFormAction::UseOutput(Some(option.parse::<TargetType>().unwrap_or(TargetType::M3u))));
}
DropDownSelection::Multi(options) => {
if let Some(first) = options.first() {
output_form_state_1.dispatch(HdHomeRunTargetOutputFormAction::UseOutput(Some(first.parse::<TargetType>().unwrap_or(TargetType::M3u))));
}
}
}
})}
options={target_types.clone()}
/>
}
})}
</Card>
}
};
let handle_apply = {
let source_editor_ctx = source_editor_ctx.clone();
let output_form_state = output_form_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let output = output_form_state.data().clone();
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(output)))));
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
let handle_cancel = {
let source_editor_ctx = source_editor_ctx.clone();
Callback::from(move |_| {
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form__toolbar tp__form-page__toolbar">
<TextButton class="secondary" name="cancel_hdhomerun_output"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_hdhomerun_output"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply}></TextButton>
</div>
<div class="tp__input-form__body">
{ render_output() }
</div>
</div>
}
}
@@ -0,0 +1,102 @@
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 shared::model::{M3uTargetOutputDto, TargetOutputDto};
use std::rc::Rc;
use yew::{function_component, html, use_context, use_effect_with, use_reducer, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_FILENAME: &str = "LABEL.FILENAME";
const LABEL_INCLUDE_TYPE_IN_URL: &str = "LABEL.INCLUDE_TYPE_IN_URL";
const LABEL_MASK_REDIRECT_URL: &str = "LABEL.MASK_REDIRECT_URL";
const LABEL_FILTER: &str = "LABEL.FILTER";
generate_form_reducer!(
state: M3uTargetOutputFormState { form: M3uTargetOutputDto },
action_name: M3uTargetOutputFormAction,
fields {
Filename => filename: Option<String>,
IncludeTypeInUrl => include_type_in_url: bool,
MaskRedirectUrl => mask_redirect_url: bool,
Filter => filter: Option<String>,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct M3uTargetOutputViewProps {
pub(crate) block_id: BlockId,
pub(crate) output: Option<Rc<M3uTargetOutputDto>>,
}
#[function_component]
pub fn M3uTargetOutputView(props: &M3uTargetOutputViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let output_form_state: UseReducerHandle<M3uTargetOutputFormState> =
use_reducer(|| M3uTargetOutputFormState {
form: M3uTargetOutputDto::default(),
modified: false,
});
{
let output_form_state = output_form_state.clone();
let config_output = props.output.clone();
use_effect_with(config_output, move |cfg| {
if let Some(output) = cfg {
output_form_state.dispatch(M3uTargetOutputFormAction::SetAll(output.as_ref().clone()));
} else {
output_form_state.dispatch(M3uTargetOutputFormAction::SetAll(M3uTargetOutputDto::default()));
}
|| ()
});
}
let render_output = || {
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) }
</Card>
}
};
let handle_apply = {
let source_editor_ctx = source_editor_ctx.clone();
let output_form_state = output_form_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let output = output_form_state.data().clone();
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::M3u(output)))));
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
let handle_cancel = {
let source_editor_ctx = source_editor_ctx.clone();
Callback::from(move |_| {
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form__toolbar tp__form-page__toolbar">
<TextButton class="secondary" name="cancel_m3u_output"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_m3u_output"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply}></TextButton>
</div>
<div class="tp__input-form__body">
{ render_output() }
</div>
</div>
}
}
@@ -0,0 +1,226 @@
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::{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;
use std::rc::Rc;
use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_DIRECTORY: &str = "LABEL.DIRECTORY";
const LABEL_USERNAME: &str = "LABEL.USERNAME";
const LABEL_EXPORT_STYLE: &str = "LABEL.EXPORT_STYLE";
const LABEL_FLAT: &str = "LABEL.FLAT";
const LABEL_UNDERSCORE_WHITESPACE: &str = "LABEL.UNDERSCORE_WHITESPACE";
const LABEL_CLEANUP: &str = "LABEL.CLEANUP";
const LABEL_STRM_PROPS: &str = "LABEL.STRM_PROPS";
const LABEL_FILTER: &str = "LABEL.FILTER";
const LABEL_ADD_QUALITY_TO_FILENAME: &str = "LABEL.ADD_QUALITY_TO_FILENAME";
const LABEL_ADD_PROPERTY: &str = "LABEL.ADD_PROPERTY";
#[derive(Copy, Clone, PartialEq, Eq)]
enum StrmFormPage {
Main,
Options,
}
impl Display for StrmFormPage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
StrmFormPage::Main => "Main",
StrmFormPage::Options => "Options",
})
}
}
generate_form_reducer!(
state: StrmTargetOutputFormState { form: StrmTargetOutputDto },
action_name: StrmTargetOutputFormAction,
fields {
Directory => directory: String,
Username => username: Option<String>,
Style => style: StrmExportStyle,
Flat => flat: bool,
UnderscoreWhitespace => underscore_whitespace: bool,
Cleanup => cleanup: bool,
StrmProps => strm_props: Option<Vec<String>>,
Filter => filter: Option<String>,
AddQualityToFilename => add_quality_to_filename: bool,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct StrmTargetOutputViewProps {
pub(crate) block_id: BlockId,
pub(crate) output: Option<Rc<StrmTargetOutputDto>>,
}
#[function_component]
pub fn StrmTargetOutputView(props: &StrmTargetOutputViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let output_form_state: UseReducerHandle<StrmTargetOutputFormState> =
use_reducer(|| StrmTargetOutputFormState {
form: StrmTargetOutputDto::default(),
modified: false,
});
let view_visible = use_state(|| StrmFormPage::Main.to_string());
let on_tab_click = {
let view_visible = view_visible.clone();
Callback::from(move |page: StrmFormPage| view_visible.set(page.to_string()))
};
let export_styles = use_memo(output_form_state.form.style, |style| {
let default_style = *style;
[
StrmExportStyle::Kodi,
StrmExportStyle::Plex,
StrmExportStyle::Emby,
StrmExportStyle::Jellyfin,
]
.iter()
.map(|s| DropDownOption {
id: s.to_string(),
label: html! { s.to_string() },
selected: *s == default_style,
}).collect::<Vec<DropDownOption>>()
});
{
let output_form_state = output_form_state.clone();
let config_output = props.output.clone();
use_effect_with(config_output, move |cfg| {
if let Some(output) = cfg {
output_form_state.dispatch(StrmTargetOutputFormAction::SetAll(output.as_ref().clone()));
} else {
output_form_state.dispatch(StrmTargetOutputFormAction::SetAll(StrmTargetOutputDto::default()));
}
|| ()
});
}
let render_main = || {
let output_form_state_1 = output_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_text!(output_form_state, translate.t(LABEL_DIRECTORY), directory, StrmTargetOutputFormAction::Directory) }
{ edit_field_text_option!(output_form_state, translate.t(LABEL_USERNAME), username, StrmTargetOutputFormAction::Username) }
{ config_field_child!(translate.t(LABEL_EXPORT_STYLE), {
html! {
<Select
name={"export_style"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
match selections {
DropDownSelection::Empty => {
output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(StrmExportStyle::Kodi));
}
DropDownSelection::Single(option) => {
output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(option.parse::<StrmExportStyle>().unwrap_or(StrmExportStyle::Kodi)));
}
DropDownSelection::Multi(options) => {
if let Some(first) = options.first() {
output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(first.parse::<StrmExportStyle>().unwrap_or(StrmExportStyle::Kodi)));
}
}
}
})}
options={export_styles.clone()}
/>
}
})}
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, StrmTargetOutputFormAction::Filter) }
</Card>
}
};
let render_options = || {
html! {
<Card class="tp__config-view__card">
{ edit_field_bool!(output_form_state, translate.t(LABEL_FLAT), flat, StrmTargetOutputFormAction::Flat) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_UNDERSCORE_WHITESPACE), underscore_whitespace, StrmTargetOutputFormAction::UnderscoreWhitespace) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_CLEANUP), cleanup, StrmTargetOutputFormAction::Cleanup) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_ADD_QUALITY_TO_FILENAME), add_quality_to_filename, StrmTargetOutputFormAction::AddQualityToFilename) }
{ edit_field_list_option!(output_form_state, translate.t(LABEL_STRM_PROPS), strm_props, StrmTargetOutputFormAction::StrmProps, translate.t(LABEL_ADD_PROPERTY)) }
</Card>
}
};
let render_edit_mode = || {
html! {
<div class="tp__input-form__body">
<div class="tp__tab-header">
{
for [
StrmFormPage::Main,
StrmFormPage::Options,
].iter().map(|page| {
let page_str = page.to_string();
let active = *view_visible == page_str;
let on_tab_click = {
let on_tab_click = on_tab_click.clone();
let page = *page;
Callback::from(move |_| on_tab_click.emit(page))
};
html! {
<button
class={classes!("tp__tab-button", if active { "active" } else { "" })}
onclick={on_tab_click}
>
{ page_str.clone() }
</button>
}
})
}
</div>
<div class="tp__input-form__body__pages">
<Panel value={StrmFormPage::Main.to_string()} active={view_visible.to_string()}>
{render_main()}
</Panel>
<Panel value={StrmFormPage::Options.to_string()} active={view_visible.to_string()}>
{render_options()}
</Panel>
</div>
</div>
}
};
let handle_apply = {
let source_editor_ctx = source_editor_ctx.clone();
let output_form_state = output_form_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let output = output_form_state.data().clone();
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::Strm(output)))));
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
let handle_cancel = {
let source_editor_ctx = source_editor_ctx.clone();
Callback::from(move |_| {
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
};
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form__toolbar tp__form-page__toolbar">
<TextButton class="secondary" name="cancel_strm_output"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_strm_output"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply}></TextButton>
</div>
{ render_edit_mode() }
</div>
}
}
@@ -1,21 +1,26 @@
use crate::app::components::config::HasFormData;
use crate::app::components::{BlockId, BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton};
use crate::{ edit_field_bool, edit_field_number_u16, edit_field_text_option, generate_form_reducer};
use shared::model::{ XtreamTargetOutputDto, TargetOutputDto};
use 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 shared::model::{TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto};
use std::fmt::Display;
use std::rc::Rc;
use web_sys::MouseEvent;
use yew::{classes, function_component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_SKIP_LIVE_DIRECT_SOURCE: &str = "LABEL.SKIP_LIVE_DIRECT_SOURCE";
const LABEL_SKIP_VOD_DIRECT_SOURCE: &str = "LABEL.SKIP_VOD_DIRECT_SOURCE";
const LABEL_SKIP_SERIES_DIRECT_SOURCE: &str = "LABEL.SKIP_SERIES_DIRECT_SOURCE";
const LABEL_RESOLVE_VOD: &str = "LABEL.RESOLVE_VOD";
const LABEL_RESOLVE_VOD_DELAY: &str = "LABEL.RESOLVE_VOD_DELAY_SEC";
const LABEL_RESOLVE_SERIES: &str = "LABEL.RESOLVE_SERIES";
const LABEL_RESOLVE_SERIES_DELAY: &str = "LABEL.RESOLVE_SERIES_DELAY_SEC";
const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE";
const LABEL_LIVE: &str = "LABEL.LIVE";
const LABEL_VOD: &str = "LABEL.VOD";
const LABEL_SERIES: &str = "LABEL.SERIES";
const LABEL_RESOLVE: &str = "LABEL.RESOLVE";
const LABEL_RESOLVE_DELAY_SEC: &str = "LABEL.RESOLVE_DELAY_SEC";
const LABEL_FILTER: &str = "LABEL.FILTER";
const LABEL_TRAKT_API_KEY: &str = "LABEL.TRAKT_API_KEY";
const LABEL_TRAKT_API_VERSION: &str = "LABEL.TRAKT_API_VERSION";
const LABEL_TRAKT_API_URL: &str = "LABEL.TRAKT_API_URL";
const LABEL_TRAKT_LISTS: &str = "LABEL.TRAKT_LISTS";
const LABEL_ADD_TRAKT_LIST: &str = "LABEL.ADD_TRAKT_LIST";
const LABEL_API_CONFIGURATION: &str = "LABEL.API_CONFIGURATION";
#[derive(Copy, Clone, PartialEq, Eq)]
enum OutputFormPage {
@@ -26,22 +31,20 @@ enum OutputFormPage {
impl Display for OutputFormPage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
OutputFormPage::Main => "Main".to_string(),
OutputFormPage::Trakt => "Trakt".to_string(),
OutputFormPage::Main => "Main",
OutputFormPage::Trakt => "Trakt",
})
}
}
//
// generate_form_reducer!(
// state: TraktConfigFormState { form: TraktConfigDto },
// action_name: TraktConfigFormAction,
// fields {
// IgnoreLogo => ignore_logo: bool,
// ShareLiveStreams => share_live_streams: bool,
// RemoveDuplicates => remove_duplicates: bool,
// ForceRedirect => force_redirect: Option<ClusterFlags>,
// }
// );
generate_form_reducer!(
state: TraktApiConfigFormState { form: TraktApiConfigDto },
action_name: TraktApiConfigFormAction,
fields {
Key => key: String,
Version => version: String,
Url => url: String,
}
);
generate_form_reducer!(
state: XtreamTargetOutputFormState { form: XtreamTargetOutputDto },
@@ -74,11 +77,18 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
form: XtreamTargetOutputDto::default(),
modified: false,
});
// let target_options_state: UseReducerHandle<TraktConfigFormState> =
// use_reducer(|| TraktConfigFormState {
// form: TraktConfigDto::default(),
// modified: false,
// });
let trakt_api_state: UseReducerHandle<TraktApiConfigFormState> =
use_reducer(|| TraktApiConfigFormState {
form: TraktApiConfigDto::default(),
modified: false,
});
// State for Trakt lists
let trakt_lists_state = use_state(Vec::<TraktListConfigDto>::new);
// State for showing trakt list form
let show_trakt_list_form_state = use_state(|| false);
let view_visible = use_state(|| OutputFormPage::Main.to_string());
@@ -89,33 +99,172 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
{
let output_form_state = output_form_state.clone();
let trakt_api_state = trakt_api_state.clone();
let trakt_lists_state = trakt_lists_state.clone();
let config_output = props.output.clone();
use_effect_with(config_output, move |cfg| {
if let Some(target) = cfg {
output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(target.as_ref().clone()));
// Load Trakt configuration
if let Some(trakt) = &target.trakt {
trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(trakt.api.clone()));
trakt_lists_state.set(trakt.lists.clone());
} else {
trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default()));
trakt_lists_state.set(Vec::new());
}
} else {
output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(XtreamTargetOutputDto::default()));
trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default()));
trakt_lists_state.set(Vec::new());
}
|| ()
});
}
let handle_add_trakt_list_item = {
let trakt_list = trakt_lists_state.clone();
let show_trakt_list_form = show_trakt_list_form_state.clone();
Callback::from(move |item: TraktListConfigDto| {
let mut items = (*trakt_list).clone();
items.push(item);
trakt_list.set(items);
show_trakt_list_form.set(false);
})
};
let handle_remove_trakt_list_item = {
let trakt_list = trakt_lists_state.clone();
Callback::from(move |(idx, _e): (String, MouseEvent)| {
if let Ok(index) = idx.parse::<usize>() {
let mut items = (*trakt_list).clone();
if index < items.len() {
items.remove(index);
trakt_list.set(items);
}
}
})
};
let handle_close_trakt_list_form = {
let show_trakt_list_form = show_trakt_list_form_state.clone();
Callback::from(move |()| {
show_trakt_list_form.set(false);
})
};
let handle_show_trakt_list_form = {
let show_trakt_list_form = show_trakt_list_form_state.clone();
Callback::from(move |_name| {
show_trakt_list_form.set(true);
})
};
let render_output = || {
html! {
<Card class="tp__config-view__card">
{ edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_LIVE_DIRECT_SOURCE), skip_live_direct_source, XtreamTargetOutputFormAction::SkipLiveDirectSource) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_VOD_DIRECT_SOURCE), skip_video_direct_source, XtreamTargetOutputFormAction::SkipVideoDirectSource) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_SERIES_DIRECT_SOURCE), skip_series_direct_source, XtreamTargetOutputFormAction::SkipSeriesDirectSource) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_RESOLVE_VOD), resolve_vod, XtreamTargetOutputFormAction::ResolveVod) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_RESOLVE_SERIES), resolve_series, XtreamTargetOutputFormAction::ResolveSeries) }
{ edit_field_number_u16!(output_form_state, translate.t(LABEL_RESOLVE_VOD_DELAY), resolve_vod_delay, XtreamTargetOutputFormAction::ResolveVodDelay) }
{ edit_field_number_u16!(output_form_state, translate.t(LABEL_RESOLVE_SERIES_DELAY), resolve_series_delay, XtreamTargetOutputFormAction::ResolveSeriesDelay) }
<TitledCard title={translate.t(LABEL_SKIP_DIRECT_SOURCE)}>
<div class="tp__config-view__cols-3">
{ edit_field_bool!(output_form_state, translate.t(LABEL_LIVE), skip_live_direct_source, XtreamTargetOutputFormAction::SkipLiveDirectSource) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_VOD), skip_video_direct_source, XtreamTargetOutputFormAction::SkipVideoDirectSource) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), skip_series_direct_source, XtreamTargetOutputFormAction::SkipSeriesDirectSource) }
</div>
</TitledCard>
<TitledCard title={translate.t(LABEL_RESOLVE)}>
<div class="tp__config-view__cols-2">
{ edit_field_bool!(output_form_state, translate.t(LABEL_VOD), resolve_vod, XtreamTargetOutputFormAction::ResolveVod) }
{ edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), resolve_series, XtreamTargetOutputFormAction::ResolveSeries) }
</div>
</TitledCard>
<TitledCard title={translate.t(LABEL_RESOLVE_DELAY_SEC)}>
<div class="tp__config-view__cols-2">
{ edit_field_number_u16!(output_form_state, translate.t(LABEL_VOD), resolve_vod_delay, XtreamTargetOutputFormAction::ResolveVodDelay) }
{ 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) }
</Card>
}
};
let render_trakt = || {
let trakt_lists = trakt_lists_state.clone();
let trakt_api = trakt_api_state.clone();
let show_trakt_list_form = show_trakt_list_form_state.clone();
html! {
<Card class="tp__config-view__card">
if *show_trakt_list_form {
<TraktListItemForm
on_submit={handle_add_trakt_list_item}
on_cancel={handle_close_trakt_list_form}
/>
} else {
// Trakt API Configuration
<div class="tp__form-section">
<h3>{translate.t(LABEL_API_CONFIGURATION)}</h3>
{ edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_KEY), key, TraktApiConfigFormAction::Key) }
{ edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) }
{ edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) }
</div>
// Trakt Lists
{ config_field_child!(translate.t(LABEL_TRAKT_LISTS), {
let trakt_lists_list = trakt_lists.clone();
html! {
<div class="tp__form-list">
<div class="tp__form-list__items">
{
for (*trakt_lists_list).iter().enumerate().map(|(idx, list)| {
let content_type_str = match list.content_type {
TraktContentType::Vod => "Vod",
TraktContentType::Series => "Series",
TraktContentType::Both => "Both",
};
html! {
<div class="tp__form-list__item" key={format!("trakt-{idx}")}>
<IconButton
name={idx.to_string()}
icon="Delete"
onclick={handle_remove_trakt_list_item.clone()}/>
<div class="tp__form-list__item-content">
<span>
<strong>{&list.user}</strong>
{" / "}
{&list.list_slug}
{" - "}
{&list.category_name}
{" ("}
{content_type_str}
{", "}
{list.fuzzy_match_threshold}
{"%)"}
</span>
</div>
</div>
}
})
}
</div>
<TextButton
class="primary"
name="add_trakt_list"
icon="Add"
title={translate.t(LABEL_ADD_TRAKT_LIST)}
onclick={handle_show_trakt_list_form}
/>
</div>
}
})}
}
</Card>
}
};
let render_edit_mode = || {
html! {
@@ -149,7 +298,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
{render_output()}
</Panel>
<Panel value={OutputFormPage::Trakt.to_string()} active={view_visible.to_string()}>
{"TODO..."}
{render_trakt()}
</Panel>
</div>
</div>
@@ -159,9 +308,23 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
let handle_apply_target = {
let source_editor_ctx = source_editor_ctx.clone();
let output_form_state = output_form_state.clone();
let trakt_api_state = trakt_api_state.clone();
let trakt_lists_state = trakt_lists_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let output = output_form_state.data().clone();
let mut output = output_form_state.data().clone();
// Handle Trakt configuration
let trakt_lists = (*trakt_lists_state).clone();
output.trakt = if trakt_lists.is_empty() {
None
} else {
Some(TraktConfigDto {
api: trakt_api_state.data().clone(),
lists: trakt_lists,
})
};
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(output)))));
source_editor_ctx.edit_mode.set(EditMode::Inactive);
})
@@ -176,14 +339,14 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form__toolbar tp__form-page__toolbar">
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_target}></TextButton>
<TextButton class="secondary" name="cancel_input"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_target}></TextButton>
</div>
{ render_edit_mode() }
</div>
@@ -30,10 +30,10 @@ enum TargetFormPage {
impl Display for TargetFormPage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
TargetFormPage::Main => "Main".to_string(),
TargetFormPage::Options => "Options".to_string(),
})
match *self {
TargetFormPage::Main => write!(f, "Main"),
TargetFormPage::Options => write!(f, "Options"),
}
}
}
@@ -139,8 +139,10 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
let target_options_state_1 = target_options_state.clone();
html! {
<Card class="tp__config-view__card">
<div class="tp__config-view__cols-2">
{ edit_field_bool!(target_options_state, translate.t(LABEL_IGNORE_LOGO), ignore_logo, ConfigTargetOptionsFormAction::IgnoreLogo) }
{ edit_field_bool!(target_options_state, translate.t(LABEL_SHARE_LIVE_STREAMS), share_live_streams, ConfigTargetOptionsFormAction::ShareLiveStreams) }
</div>
{ edit_field_bool!(target_options_state, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates, ConfigTargetOptionsFormAction::RemoveDuplicates) }
{ config_field_child!(translate.t(LABEL_FORCE_REDIRECT), {
html! {
@@ -160,8 +162,10 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
let target_form_state_1 = target_form_state.clone();
html! {
<Card class="tp__config-view__card">
<div class="tp__config-view__cols-2">
{ edit_field_bool!(target_form_state, translate.t(LABEL_ENABLED), enabled, ConfigTargetFormAction::Enabled) }
{ 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) }
@@ -260,14 +264,14 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
html! {
<div class="tp__source-editor-form tp__config-view-page">
<div class="tp__source-editor-form_toolbar tp__form-page__toolbar">
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_target}></TextButton>
<TextButton class="secondary" name="cancel_input"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_target}></TextButton>
</div>
{ render_edit_mode() }
</div>
@@ -0,0 +1,136 @@
use crate::app::components::select::Select;
use crate::app::components::{Card, DropDownOption, DropDownSelection, TextButton};
use crate::{config_field_child, edit_field_number_u8, edit_field_text, generate_form_reducer};
use shared::model::{TraktContentType, TraktListConfigDto};
use yew::{function_component, html, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle};
use yew_i18n::use_translation;
const LABEL_TRAKT_USER: &str = "LABEL.TRAKT_USER";
const LABEL_TRAKT_LIST_SLUG: &str = "LABEL.TRAKT_LIST_SLUG";
const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME";
const LABEL_TRAKT_CONTENT_TYPE: &str = "LABEL.TRAKT_CONTENT_TYPE";
const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD";
generate_form_reducer!(
state: TraktListFormState { form: TraktListConfigDto },
action_name: TraktListFormAction,
fields {
User => user: String,
ListSlug => list_slug: String,
CategoryName => category_name: String,
ContentType => content_type: TraktContentType,
FuzzyMatchThreshold => fuzzy_match_threshold: u8,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct TraktListItemFormProps {
pub on_submit: Callback<TraktListConfigDto>,
pub on_cancel: Callback<()>,
#[prop_or_default]
pub initial: Option<TraktListConfigDto>,
}
#[function_component]
pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
let translate = use_translation();
let form_state: UseReducerHandle<TraktListFormState> = use_reducer(|| {
TraktListFormState {
form: props.initial.clone().unwrap_or_else(|| TraktListConfigDto {
user: String::new(),
list_slug: String::new(),
category_name: String::new(),
content_type: TraktContentType::Both,
fuzzy_match_threshold: 80,
}),
modified: false,
}
});
let content_type_options = use_memo(form_state.form.content_type, |content_type| {
let default_ct = content_type;
vec![
DropDownOption {
id: "vod".to_string(),
label: html! { "Vod" },
selected: default_ct == &TraktContentType::Vod,
},
DropDownOption {
id: "series".to_string(),
label: html! { "Series" },
selected: default_ct == &TraktContentType::Series,
},
DropDownOption {
id: "both".to_string(),
label: html! { "Both" },
selected: default_ct == &TraktContentType::Both,
},
]
});
let handle_submit = {
let form_state = form_state.clone();
let on_submit = props.on_submit.clone();
Callback::from(move |_| {
let data = form_state.form.clone();
if !data.user.trim().is_empty()
&& !data.list_slug.trim().is_empty()
&& !data.category_name.trim().is_empty() {
on_submit.emit(data);
}
})
};
let handle_cancel = {
let on_cancel = props.on_cancel.clone();
Callback::from(move |_| {
on_cancel.emit(());
})
};
html! {
<Card class="tp__config-view__card tp__item-form">
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_USER), user, TraktListFormAction::User) }
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug, TraktListFormAction::ListSlug) }
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) }
{ config_field_child!(translate.t(LABEL_TRAKT_CONTENT_TYPE), {
let form_state_ct = form_state.clone();
html! {
<Select
name={"trakt_content_type"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
if let DropDownSelection::Single(option) = selections {
if let Ok(ct) = option.parse::<TraktContentType>() {
form_state_ct.dispatch(TraktListFormAction::ContentType(ct));
}
}
})}
options={content_type_options.clone()}
/>
}
})}
{ edit_field_number_u8!(form_state, translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), fuzzy_match_threshold, TraktListFormAction::FuzzyMatchThreshold) }
<div class="tp__form-page__toolbar">
<TextButton
class="secondary"
name="cancel_trakt_list"
icon="Cancel"
title={translate.t("LABEL.CANCEL")}
onclick={handle_cancel}
/>
<TextButton
class="primary"
name="submit_trakt_list"
icon="Accept"
title={translate.t("LABEL.SUBMIT")}
onclick={handle_submit}
/>
</div>
</Card>
}
}
+20
View File
@@ -0,0 +1,20 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct TitledCardProps {
pub title: AttrValue,
#[prop_or_default]
pub children: Children,
}
#[function_component(TitledCard)]
pub fn titled_card(props: &TitledCardProps) -> Html {
html! {
<div class="tp__titled-card">
<span class="tp__titled-card__title">{ props.title.clone() }</span>
<div class="tp__titled-card__content">
{ for props.children.iter() }
</div>
</div>
}
}
@@ -246,14 +246,14 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
</div>
<div class="tp__proxy-user-credentials-form__toolbar tp__form-page__toolbar">
<TextButton class="primary" name="save_user"
icon="Save"
title={ translate.t("LABEL.SAVE")}
onclick={handle_save_user}></TextButton>
<TextButton class="secondary" name="cancel_user"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
<TextButton class="primary" name="save_user"
icon="Save"
title={ translate.t("LABEL.SAVE")}
onclick={handle_save_user}></TextButton>
</div>
</div>
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "3.2.13"
version = "3.2.14"
edition = "2021"
[dependencies]
+1 -1
View File
@@ -24,7 +24,7 @@ impl EpgSourceDto {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct EpgConfigDto {
#[serde(default, skip_serializing_if = "Option::is_none")]
+25
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
const TRAKT_API_KEY: &str = "0183a05ad97098d87287fe46da4ae286f434f32e8e951caad4cc147c947d79a3";
const TRAKT_API_VERSION: &str = "2";
@@ -16,6 +18,29 @@ pub enum TraktContentType {
Both,
}
impl fmt::Display for TraktContentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", match self {
TraktContentType::Vod => "Vod",
TraktContentType::Series => "Series",
TraktContentType::Both => "Both",
})
}
}
impl FromStr for TraktContentType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"vod" => Ok(TraktContentType::Vod),
"series" => Ok(TraktContentType::Series),
"both" => Ok(TraktContentType::Both),
_ => Err(format!("Invalid TraktContentType: {}", s)),
}
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
+34
View File
@@ -1,4 +1,6 @@
use enum_iterator::Sequence;
use std::fmt::Display;
use std::str::FromStr;
#[derive( Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence,
PartialEq, Eq, Hash, Default)]
@@ -12,4 +14,36 @@ pub enum StrmExportStyle {
Emby,
#[serde(rename = "jellyfin")]
Jellyfin,
}
impl StrmExportStyle {
const KODI: &'static str = "Kodi";
const PLEX: &'static str = "Plex";
const EMBY: &'static str = "Emby";
const JELLYFIN: &'static str = "Jellyfin";
}
impl Display for StrmExportStyle {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
Self::Kodi => Self::KODI,
Self::Plex => Self::PLEX,
Self::Emby => Self::EMBY,
Self::Jellyfin => Self::JELLYFIN,
})
}
}
impl FromStr for StrmExportStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
Self::KODI => Ok(Self::Kodi),
Self::PLEX => Ok(Self::Plex),
Self::EMBY => Ok(Self::Emby),
Self::JELLYFIN => Ok(Self::Jellyfin),
_ => Err(format!("Unknown StrmExportStyle: {}", s))
}
}
}
+15
View File
@@ -1,4 +1,5 @@
use std::fmt::Display;
use std::str::FromStr;
use enum_iterator::Sequence;
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash)]
@@ -31,3 +32,17 @@ impl Display for TargetType {
}
}
impl FromStr for TargetType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
Self::M3U => Ok(Self::M3u),
Self::XTREAM => Ok(Self::Xtream),
Self::STRM => Ok(Self::Strm),
Self::HDHOMERUN => Ok(Self::HdHomeRun),
_ => Err(format!("Unknown TargetType: {}", s))
}
}
}