mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-24 00:32:30 +02:00
User category selection
This commit is contained in:
@@ -227,7 +227,7 @@ async fn hls_api_stream(
|
||||
app_state.app_config.get_input_by_id(params.input_id),
|
||||
true,
|
||||
format!(
|
||||
"Cant find input for target {target_name}, stream_id {virtual_id}, hls"
|
||||
"Cant find input {} for target {target_name}, stream_id {virtual_id}, hls", params.input_id
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn m3u_api_stream(
|
||||
.app_config
|
||||
.get_input_by_name(pli.input_name.as_str()),
|
||||
true,
|
||||
format!("Cant find input for target {target_name}, stream_id {virtual_id}")
|
||||
format!("Cant find input {} for target {target_name}, stream_id {virtual_id}", pli.input_name)
|
||||
);
|
||||
let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live);
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ async fn xtream_player_api_stream(
|
||||
let input = try_option_bad_request!(
|
||||
app_state.app_config.get_input_by_name(pli.input_name.as_str()),
|
||||
true,
|
||||
format!( "Cant find input for target {target_name}, context {}, stream_id {virtual_id}", stream_req.context)
|
||||
format!( "Cant find input {} for target {target_name}, context {}, stream_id {virtual_id}", pli.input_name, stream_req.context)
|
||||
);
|
||||
|
||||
let (cluster, item_type) = if stream_req.context == ApiStreamContext::Timeshift {
|
||||
@@ -432,8 +432,8 @@ async fn xtream_player_api_stream_with_token(
|
||||
.get_input_by_name(pli.input_name.as_str()),
|
||||
true,
|
||||
format!(
|
||||
"Cant find input for target {target_name}, context {}, stream_id {}",
|
||||
stream_req.context, pli.virtual_id
|
||||
"Cant find input {} for target {target_name}, context {}, stream_id {}",
|
||||
pli.input_name, stream_req.context, pli.virtual_id
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::utils;
|
||||
use crate::utils::json_write_documents_to_file;
|
||||
use chrono::Local;
|
||||
use log::error;
|
||||
use shared::model::{PlaylistBouquetDto, ProxyType, ProxyUserStatus, TargetBouquetDto, TargetType, XtreamCluster};
|
||||
use shared::model::{PlaylistBouquetDto, PlaylistClusterBouquetDto, ProxyType, ProxyUserStatus, TargetType, XtreamCluster};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -294,7 +294,7 @@ async fn save_m3u_user_bouquet_for_target(storage_path: &Path, target: TargetTyp
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_user_bouquet_for_target(config: &Config, target_name: &str, storage_path: &Path, target: TargetType, bouquet: &TargetBouquetDto) -> Result<(), Error> {
|
||||
async fn save_user_bouquet_for_target(config: &Config, target_name: &str, storage_path: &Path, target: TargetType, bouquet: &PlaylistClusterBouquetDto) -> Result<(), Error> {
|
||||
if target == TargetType::Xtream {
|
||||
save_xtream_user_bouquet_for_target(config, target_name, storage_path, XtreamCluster::Live, bouquet.live.as_ref()).await?;
|
||||
save_xtream_user_bouquet_for_target(config, target_name, storage_path, XtreamCluster::Video, bouquet.vod.as_ref()).await?;
|
||||
@@ -391,7 +391,6 @@ pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_i
|
||||
XtreamCluster::Series => user_get_series_bouquet(config, username, target).await,
|
||||
};
|
||||
|
||||
|
||||
match bouquet {
|
||||
None => None,
|
||||
Some(bouquet_categories) => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"LIVE_SHORT": "L",
|
||||
"VOD_SHORT": "V",
|
||||
"SERIES_SHORT": "S",
|
||||
"MOVIE": "Movie",
|
||||
"SAVE": "Save",
|
||||
"SUBMIT": "Submit",
|
||||
"OK": "Ok",
|
||||
@@ -327,7 +328,7 @@
|
||||
"COPY_CREDENTIALS": "Copy Credentials"
|
||||
},
|
||||
"TITLE": {
|
||||
"USER_BOUQUET_EDITOR": "User group editor"
|
||||
"USER_BOUQUET_EDITOR": "Playlist Category Selection"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"NO_CONTENT": "No content",
|
||||
@@ -354,6 +355,9 @@
|
||||
"GEOIP": {
|
||||
"SUCCESS": "Successfully downloaded Geo-IP db",
|
||||
"FAIL": "Failed to download Geo-IP db!"
|
||||
},
|
||||
"USER_BOUQUET": {
|
||||
"FAIL": "Failed to download user bouquets!"
|
||||
}
|
||||
},
|
||||
"LOGIN": {
|
||||
|
||||
@@ -201,7 +201,13 @@
|
||||
"keys": [
|
||||
"Checked"
|
||||
],
|
||||
"path": "M 4.2222224,2 C 3,2 2,3.000021 2,4.2222362 V 19.777764 C 2,20.999979 3,22 4.2222224,22 H 19.777778 C 20.999989,22 22,20.999979 22,19.777764 V 4.2222362 C 22,3.000021 20.999989,2 19.777778,2 Z m 0,2.2222362 H 19.777778 V 19.777764 H 4.2222224 Z M 17.288622,7.1063517 10.441833,13.953134 6.7113668,10.233533 5.2465223,11.698352 10.441833,16.89369 18.753467,8.5820472 Z"
|
||||
"path": "M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
},
|
||||
{
|
||||
"keys": [
|
||||
"Unchecked"
|
||||
],
|
||||
"path": "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
},
|
||||
{
|
||||
"keys": [
|
||||
|
||||
@@ -17,6 +17,8 @@ $form-grid-cell-width-small-screen: 200px;
|
||||
$form-field-min-width-small-screen: 160px;
|
||||
$form-max-grid-cells-small-screen: 2;
|
||||
|
||||
$playlist-categories-grid-cell-width: 400px;
|
||||
|
||||
@mixin fixed-width($width) {
|
||||
width: $width;
|
||||
min-width: $width;
|
||||
|
||||
@@ -1,3 +1,142 @@
|
||||
div {
|
||||
color: var(--text-color);
|
||||
@use "../../../size" as size;
|
||||
|
||||
.tp__api-user-playlist {
|
||||
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: var(--gap-default);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: var(--padding-small);
|
||||
width: 100%;
|
||||
|
||||
&__loading {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-flow: row wrap;
|
||||
gap: var(--gap-default);
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: var(--gap-default);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: var(--gap-default);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&-panels {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
gap: var(--gap-default);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tp__api-user-target-playlist {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
gap: var(--gap-default);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
gap: var(--gap-default);
|
||||
padding: var(--padding-default) 0;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
|
||||
.tp__collapse-panel__header {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
> :nth-child(1) {
|
||||
border-left: 2px solid var(--output-m3u-color);
|
||||
}
|
||||
|
||||
> :nth-child(2) {
|
||||
border-left: 2px solid var(--output-xtream-color);
|
||||
}
|
||||
|
||||
> :nth-child(3) {
|
||||
border-left: 2px solid var(--output-hdhomerun-color);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&__categories {
|
||||
//display: flex;
|
||||
//flex-flow: row wrap;
|
||||
//width: 100%;
|
||||
//gap: var(--gap-default);
|
||||
padding: var(--padding-default) 0;
|
||||
//box-sizing: border-box;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(size.$playlist-categories-grid-cell-width, 1fr));
|
||||
gap: var(--gap-large);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
&-category.selected {
|
||||
background-color: var(--text-button-active-background-color);
|
||||
color: var(--text-button-active-color);
|
||||
fill: var(--text-button-active-color);
|
||||
}
|
||||
|
||||
&-category {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
background-color: var(--text-button-background-color);
|
||||
color: var(--text-button-color);
|
||||
fill: var(--text-button-color);
|
||||
border: 1px solid var(--text-button-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
max-height: 3rem;
|
||||
min-height: 2.5rem;
|
||||
gap: var(--gap-default);
|
||||
padding: 0 var(--padding-default);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
svg, img {
|
||||
pointer-events: none;
|
||||
height: 1.2rem;
|
||||
width: 1.2rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--text-button-hover-background-color);
|
||||
color: var(--text-button-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
use yew::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
use yew_i18n::use_translation;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::info_err;
|
||||
use shared::model::{PlaylistBouquetDto, TargetBouquetDto};
|
||||
use crate::app::components::{Panel, RadioButtonGroup};
|
||||
use crate::hooks::use_service_context;
|
||||
use crate::html_if;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
enum ApiUserPlaylistPage {
|
||||
Xtream,
|
||||
M3u
|
||||
}
|
||||
|
||||
impl FromStr for ApiUserPlaylistPage {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"xtream" => Ok(ApiUserPlaylistPage::Xtream),
|
||||
"m3u" => Ok(ApiUserPlaylistPage::M3u),
|
||||
_ => Err(info_err!(format!("Unknown api user playlist type: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiUserPlaylistPage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
ApiUserPlaylistPage::Xtream => "xtream",
|
||||
ApiUserPlaylistPage::M3u => "m3u",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub struct BouquetSelection {
|
||||
pub live: HashMap<String, bool>,
|
||||
pub vod: HashMap<String, bool>,
|
||||
pub series: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
// ----- Helper -----
|
||||
fn is_empty<T: Serialize>(value: &Option<T>) -> bool {
|
||||
match value {
|
||||
None => true,
|
||||
Some(v) => {
|
||||
let json = serde_json::to_value(v).unwrap_or_default();
|
||||
json.is_null() || json.as_array().map(|a| a.is_empty()).unwrap_or(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Component -----
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct UserPlaylistProps {
|
||||
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn UserPlaylist(props: &UserPlaylistProps) -> Html {
|
||||
let translate = use_translation();
|
||||
let service_ctx = use_service_context();
|
||||
let loading = use_state(|| false);
|
||||
let categories = use_state(|| None as Option<PlaylistBouquetDto>);
|
||||
let bouquets = use_state(|| None as Option<PlaylistBouquetDto>);
|
||||
let active_tab = use_state(|| ApiUserPlaylistPage::Xtream);
|
||||
let playlist_types = use_memo((), |st| {
|
||||
let st = st.as_ref().map(|v| v.as_slice()).unwrap_or(&[ApiUserPlaylistPage::Xtream, ApiUserPlaylistPage::M3u]);
|
||||
st.iter().map(ToString::to_string).collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let handle_tab_select = {
|
||||
let active_tab_clone = active_tab.clone();
|
||||
Callback::from(move |page_selection: Rc<Vec<String>>| {
|
||||
if let Some(page_selection_str) = page_selection.first() {
|
||||
if let Ok(page) = ApiUserPlaylistPage::from_str(page_selection_str) {
|
||||
active_tab_clone.set(page)
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Selection reference
|
||||
let selections = use_mut_ref(|| {
|
||||
HashMap::<String, BouquetSelection>::new()
|
||||
});
|
||||
|
||||
// ----- Load data on mount -----
|
||||
{
|
||||
let loading = loading.clone();
|
||||
let categories = categories.clone();
|
||||
let bouquets = bouquets.clone();
|
||||
let services = service_ctx.clone();
|
||||
|
||||
use_effect_with((), move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loading.set(true);
|
||||
|
||||
let bq = services.user_config.get_playlist_bouquet().await;
|
||||
let ct = services.user_config.get_playlist_categories().await;
|
||||
|
||||
let mut bouquet = if is_empty(&bq) { None } else { bq };
|
||||
let mut cats = if is_empty(&ct) { None } else { ct };
|
||||
|
||||
bouquets.set(bouquet.clone());
|
||||
categories.set(cats.clone());
|
||||
|
||||
// Convert loaded user bouquet → rust maps
|
||||
if let Some(b) = bouquet {
|
||||
if let Some(xt) = &b.xtream {
|
||||
selections.borrow_mut().insert(
|
||||
"xtream".into(),
|
||||
target_to_selection(xt)
|
||||
);
|
||||
}
|
||||
if let Some(m3u) = &b.m3u {
|
||||
selections.borrow_mut().insert(
|
||||
"m3u".into(),
|
||||
target_to_selection(m3u)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loading.set(false);
|
||||
});
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Save handler -----
|
||||
let on_save = {
|
||||
let selections = selections.clone();
|
||||
let categories = categories.clone();
|
||||
let services = service_ctx.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
let selections = selections.clone();
|
||||
let categories = categories.clone();
|
||||
let services = services.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loading.set(true);
|
||||
|
||||
// Convert selection maps back into service payload
|
||||
let result = PlaylistBouquetDto {
|
||||
xtream: categories.as_ref().and_then(|cats| {
|
||||
cats.xtream.as_ref().map(|target| {
|
||||
selection_to_target(
|
||||
selections.borrow().get("xtream").cloned(),
|
||||
target,
|
||||
)
|
||||
})
|
||||
}),
|
||||
m3u: categories.as_ref().and_then(|cats| {
|
||||
cats.m3u.as_ref().map(|target| {
|
||||
selection_to_target(
|
||||
selections.borrow().get("m3u").cloned(),
|
||||
target,
|
||||
)
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
// Save bouquet
|
||||
// let r = services
|
||||
// .user_config
|
||||
// .save_playlist_bouquet(result)
|
||||
// .await;
|
||||
|
||||
// match r {
|
||||
// Ok(_) => services.toastr.success("Playlist saved successfully".into()),
|
||||
// Err(_) => services.toastr.error("Failed to save playlist".into()),
|
||||
// }
|
||||
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// ----- Tab switch -----
|
||||
let on_tab_click = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: String| active_tab.set(tab))
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tp__api-user-playlist">
|
||||
{ if *loading { html!{ <div class="loading">{"Loading..."}</div> } } else { html!{} }}
|
||||
|
||||
<div class="tp__api-user-playlist__toolbar">
|
||||
<label>{"User Bouquet Editor"}</label>
|
||||
<button onclick={on_save}>{"Save"}</button>
|
||||
</div>
|
||||
|
||||
<div class="tp__api-user-playlist__content">
|
||||
<div class="user-playlist__content-toolbar">
|
||||
<RadioButtonGroup options={playlist_types.clone()}
|
||||
selected={Rc::new(vec![(*active_tab).to_string()])}
|
||||
on_select={handle_tab_select} />
|
||||
</div>
|
||||
|
||||
<div class="tp__api-user-playlist__content-panels">
|
||||
<Panel value={ApiUserPlaylistPage::Xtream.to_string()} active={active_tab.to_string()}>
|
||||
{"xtream"}
|
||||
// <UserTargetPlaylist
|
||||
// visible={*active_tab == "xtream"}
|
||||
// categories={categories.as_ref().and_then(|c| c.xtream.clone())}
|
||||
// bouquet={bouquets.as_ref().and_then(|c| c.xtream.clone())}
|
||||
// on_change={Callback::from(move |sel: BouquetSelection| {
|
||||
// selections.borrow_mut().insert("xtream".into(), sel);
|
||||
// })}
|
||||
// />
|
||||
</Panel>
|
||||
<Panel value={ApiUserPlaylistPage::M3u.to_string()} active={active_tab.to_string()}>
|
||||
{"xtream"}
|
||||
// <UserTargetPlaylist
|
||||
// visible={*active_tab == "m3u"}
|
||||
// categories={categories.as_ref().and_then(|c| c.m3u.clone())}
|
||||
// bouquet={bouquets.as_ref().and_then(|c| c.m3u.clone())}
|
||||
// on_change={Callback::from(move |sel: BouquetSelection| {
|
||||
// selections.borrow_mut().insert("m3u".into(), sel);
|
||||
// })}
|
||||
// />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert user target categories → selection maps
|
||||
fn target_to_selection(t: &TargetBouquetDto) -> BouquetSelection {
|
||||
fn map(list: &Option<Vec<String>>) -> HashMap<String, bool> {
|
||||
match list {
|
||||
Some(vec) => vec.iter().map(|v| (v.clone(), true)).collect(),
|
||||
None => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
BouquetSelection {
|
||||
live: map(&t.live),
|
||||
vod: map(&t.vod),
|
||||
series: map(&t.series),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert selection back into a service payload
|
||||
fn selection_to_target(
|
||||
sel: Option<BouquetSelection>,
|
||||
target: &TargetBouquetDto,
|
||||
) -> TargetBouquetDto {
|
||||
let s = sel.unwrap_or(BouquetSelection::default());
|
||||
|
||||
fn to_vec(map: &HashMap<String, bool>) -> Option<Vec<String>> {
|
||||
let v: Vec<String> = map.iter().filter(|(_, v)| **v).map(|(k, _)| k.clone()).collect();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
}
|
||||
|
||||
TargetBouquetDto {
|
||||
live: to_vec(&s.live),
|
||||
vod: to_vec(&s.vod),
|
||||
series: to_vec(&s.series),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use crate::app::components::theme::Theme;
|
||||
use crate::hooks::use_service_context;
|
||||
use crate::provider::DialogProvider;
|
||||
use yew::use_state;
|
||||
|
||||
use crate::app::components::api_user::playlist::ApiUserPlaylist;
|
||||
|
||||
#[function_component]
|
||||
pub fn ApiUserView() -> Html {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod api_user_view;
|
||||
mod api_user_playlist;
|
||||
mod playlist;
|
||||
mod target_playlist;
|
||||
|
||||
pub use api_user_view::*;
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::cell::RefCell;
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
use yew_i18n::use_translation;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::info_err;
|
||||
use shared::model::{PlaylistBouquetDto, PlaylistCategoriesDto, PlaylistClusterBouquetDto};
|
||||
use crate::app::components::{Panel, RadioButtonGroup, TextButton};
|
||||
use crate::app::components::api_user::target_playlist::{BouquetSelection, UserTargetPlaylist};
|
||||
use crate::hooks::use_service_context;
|
||||
use crate::model::{BusyStatus, EventMessage};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
enum ApiUserPlaylistPage {
|
||||
Xtream,
|
||||
M3u
|
||||
}
|
||||
|
||||
impl FromStr for ApiUserPlaylistPage {
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, TuliproxError> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"xtream" => Ok(ApiUserPlaylistPage::Xtream),
|
||||
"m3u" => Ok(ApiUserPlaylistPage::M3u),
|
||||
_ => Err(info_err!(format!("Unknown api user playlist type: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiUserPlaylistPage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
ApiUserPlaylistPage::Xtream => "xtream",
|
||||
ApiUserPlaylistPage::M3u => "m3u",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
fn to_playlist_cluster(bouquet: Option<&Rc<RefCell<BouquetSelection>>>) -> Option<PlaylistClusterBouquetDto> {
|
||||
if let Some(bouq) = bouquet {
|
||||
let selections = bouq.borrow();
|
||||
|
||||
let selected_vec = |map: &HashMap<String, bool>| {
|
||||
let v: Vec<String> = map.iter()
|
||||
.filter(|(_, &selected)| selected)
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
};
|
||||
|
||||
let live = selected_vec(&selections.live);
|
||||
let vod = selected_vec(&selections.vod);
|
||||
let series = selected_vec(&selections.series);
|
||||
|
||||
// if all three are None, return None
|
||||
if live.is_none() && vod.is_none() && series.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(PlaylistClusterBouquetDto { live, vod, series })
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[function_component]
|
||||
pub fn ApiUserPlaylist() -> Html {
|
||||
let translate = use_translation();
|
||||
let service_ctx = use_service_context();
|
||||
let categories = use_state(|| None as Option<Rc<PlaylistCategoriesDto>>);
|
||||
let bouquets = use_state(|| None as Option<Rc<PlaylistBouquetDto>>);
|
||||
let active_tab = use_state(|| ApiUserPlaylistPage::Xtream);
|
||||
let playlist_types = use_memo((), |_| {
|
||||
[ApiUserPlaylistPage::Xtream, ApiUserPlaylistPage::M3u].iter().map(ToString::to_string).collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
// Selection reference
|
||||
let selections = use_mut_ref(|| {
|
||||
HashMap::<ApiUserPlaylistPage, Rc<RefCell<BouquetSelection>>>::new()
|
||||
});
|
||||
|
||||
let handle_tab_select = {
|
||||
let active_tab_clone = active_tab.clone();
|
||||
Callback::from(move |page_selection: Rc<Vec<String>>| {
|
||||
if let Some(page_selection_str) = page_selection.first() {
|
||||
if let Ok(page) = ApiUserPlaylistPage::from_str(page_selection_str) {
|
||||
active_tab_clone.set(page)
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
// ----- Load data on mount -----
|
||||
let categories = categories.clone();
|
||||
let bouquets = bouquets.clone();
|
||||
let services = service_ctx.clone();
|
||||
let translate = translate.clone();
|
||||
|
||||
use_effect_with((), move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
|
||||
let result = (services.user_api.get_playlist_bouquet().await, services.user_api.get_playlist_categories().await);
|
||||
match result {
|
||||
(Ok(bouquet), Ok(cats)) => {
|
||||
bouquets.set(bouquet.clone());
|
||||
categories.set(cats.clone());
|
||||
}
|
||||
_ => {
|
||||
services.toastr.error(translate.t("MESSAGES.DOWNLOAD.USER_BOUQUET.FAIL"));
|
||||
}
|
||||
}
|
||||
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
|
||||
});
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Save handler -----
|
||||
let on_save = {
|
||||
let selections = selections.clone();
|
||||
let services = service_ctx.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
let selections = selections.clone();
|
||||
let services = services.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
|
||||
let result = {
|
||||
let selects = selections.borrow();
|
||||
PlaylistBouquetDto {
|
||||
xtream: to_playlist_cluster(selects.get(&ApiUserPlaylistPage::Xtream)),
|
||||
m3u: to_playlist_cluster(selects.get(&ApiUserPlaylistPage::M3u)),
|
||||
}
|
||||
};
|
||||
|
||||
match services.user_api.save_playlist_bouquet(&result).await {
|
||||
Ok(()) => services.toastr.success("Bouquet saved successfully"),
|
||||
Err(_) => services.toastr.error("Failed to save playlist"),
|
||||
}
|
||||
|
||||
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let handle_m3u_change = {
|
||||
let selections = selections.clone();
|
||||
Callback::from(move |selection: Rc<RefCell<BouquetSelection>>| {
|
||||
selections.borrow_mut().insert(ApiUserPlaylistPage::M3u, selection);
|
||||
})
|
||||
};
|
||||
|
||||
let handle_xtream_change = {
|
||||
let selections = selections.clone();
|
||||
Callback::from(move |selection: Rc<RefCell<BouquetSelection>>| {
|
||||
selections.borrow_mut().insert(ApiUserPlaylistPage::Xtream, selection);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tp__api-user-playlist">
|
||||
<div class="tp__api-user-playlist__header tp__list-list__header">
|
||||
<h1>{translate.t("TITLE.USER_BOUQUET_EDITOR") }</h1>
|
||||
<div class="tp__userlist-list__header-toolbar">
|
||||
<TextButton class="primary" name="save"
|
||||
icon="Save"
|
||||
title={ translate.t("LABEL.SAVE")}
|
||||
onclick={on_save}></TextButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp__api-user-playlist__content">
|
||||
<div class="user-playlist__content-toolbar">
|
||||
<RadioButtonGroup options={playlist_types.clone()}
|
||||
selected={Rc::new(vec![(*active_tab).to_string()])}
|
||||
on_select={handle_tab_select} />
|
||||
</div>
|
||||
|
||||
<div class="tp__api-user-playlist__content-panels">
|
||||
<Panel value={ApiUserPlaylistPage::Xtream.to_string()} active={active_tab.to_string()}>
|
||||
<UserTargetPlaylist
|
||||
categories={categories.as_ref().and_then(|c| c.xtream.clone())}
|
||||
bouquet={bouquets.as_ref().and_then(|b| b.xtream.as_ref().cloned())}
|
||||
on_change={handle_xtream_change.clone()}
|
||||
></UserTargetPlaylist>
|
||||
</Panel>
|
||||
<Panel value={ApiUserPlaylistPage::M3u.to_string()} active={active_tab.to_string()}>
|
||||
<UserTargetPlaylist
|
||||
categories={categories.as_ref().and_then(|c| c.m3u.clone())}
|
||||
bouquet={bouquets.as_ref().and_then(|b| b.m3u.as_ref().cloned())}
|
||||
on_change={handle_m3u_change.clone()}
|
||||
></UserTargetPlaylist>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::cell::RefCell;
|
||||
use crate::app::components::{AppIcon, Card, CollapsePanel};
|
||||
use shared::model::{PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, XtreamCluster};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
use wasm_bindgen::JsCast;
|
||||
use yew::prelude::*;
|
||||
use yew_i18n::use_translation;
|
||||
use crate::html_if;
|
||||
|
||||
fn normalize(s: &str) -> String {
|
||||
let cleaned: String = s
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||
.collect();
|
||||
cleaned.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn sort_opt_vec(v: &mut Option<Vec<String>>) {
|
||||
if let Some(ref mut inner) = v {
|
||||
inner.sort_by_key(|a| normalize(a));
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! create_selection {
|
||||
($bouquet:expr, $categories:expr, $selections:expr, $field: ident) => {
|
||||
if let Some(selects) = $bouquet.$field.as_ref() {
|
||||
for b in selects {
|
||||
$selections.$field.insert(b.clone(), true);
|
||||
}
|
||||
} else {
|
||||
if let Some(cats) = $categories.$field.as_ref() {
|
||||
for c in cats {
|
||||
$selections.$field.insert(c.clone(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
pub struct BouquetSelection {
|
||||
pub live: HashMap<String, bool>,
|
||||
pub vod: HashMap<String, bool>,
|
||||
pub series: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct UserTargetPlaylistProps {
|
||||
pub categories: Option<PlaylistClusterCategoriesDto>,
|
||||
pub bouquet: Option<PlaylistClusterBouquetDto>,
|
||||
pub on_change: Callback<Rc<RefCell<BouquetSelection>>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html {
|
||||
let translate = use_translation();
|
||||
let bouquet_selection = use_mut_ref(BouquetSelection::default);
|
||||
let playlist_categories = use_state(PlaylistClusterCategoriesDto::default);
|
||||
let force_update = use_state(|| 0);
|
||||
|
||||
{
|
||||
let bouquet_selection = bouquet_selection.clone();
|
||||
let playlist_categories = playlist_categories.clone();
|
||||
let in_cats = props.categories.clone();
|
||||
let in_bouquet = props.bouquet.clone();
|
||||
let force_update = force_update.clone();
|
||||
use_effect_with((in_cats, in_bouquet), move |(maybe_categories, maybe_bouquet)| {
|
||||
let mut selections = BouquetSelection::default();
|
||||
if let Some(categories) = maybe_categories.as_ref() {
|
||||
if let Some(bouquet) = maybe_bouquet.as_ref() {
|
||||
create_selection!(bouquet, categories, selections, live);
|
||||
create_selection!(bouquet, categories, selections, vod);
|
||||
create_selection!(bouquet, categories, selections, series);
|
||||
} else {
|
||||
if let Some(cats) = categories.live.as_ref() {
|
||||
for c in cats {
|
||||
selections.live.insert(c.clone(), true);
|
||||
}
|
||||
}
|
||||
if let Some(cats) = categories.vod.as_ref() {
|
||||
for c in cats {
|
||||
selections.vod.insert(c.clone(), true);
|
||||
}
|
||||
}
|
||||
if let Some(cats) = categories.series.as_ref() {
|
||||
for c in cats {
|
||||
selections.series.insert(c.clone(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
*bouquet_selection.borrow_mut() = selections;
|
||||
let mut new_categories = categories.clone();
|
||||
sort_opt_vec(&mut new_categories.live);
|
||||
sort_opt_vec(&mut new_categories.vod);
|
||||
sort_opt_vec(&mut new_categories.series);
|
||||
playlist_categories.set(new_categories);
|
||||
force_update.set(*force_update + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let handle_category_click = {
|
||||
let on_change = props.on_change.clone();
|
||||
let bouquet_selection = bouquet_selection.clone();
|
||||
let force_update = force_update.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(element) = target.dyn_into::<web_sys::Element>() {
|
||||
if let Some(cluster) = element.get_attribute("data-cluster") {
|
||||
if let Ok(cluster) = XtreamCluster::from_str(cluster.as_str()) {
|
||||
if let Some(category) = element.get_attribute("data-category") {
|
||||
let mut selections = bouquet_selection.borrow_mut();
|
||||
match cluster {
|
||||
XtreamCluster::Live => {
|
||||
let selected = *selections.live.get(&category).unwrap_or(&false);
|
||||
selections.live.insert(category, !selected);
|
||||
}
|
||||
XtreamCluster::Video => {
|
||||
let selected = *selections.vod.get(&category).unwrap_or(&false);
|
||||
selections.vod.insert(category, !selected);
|
||||
}
|
||||
XtreamCluster::Series => {
|
||||
let selected = *selections.series.get(&category).unwrap_or(&false);
|
||||
selections.series.insert(category, !selected);
|
||||
}
|
||||
}
|
||||
on_change.emit(bouquet_selection.clone());
|
||||
force_update.set(*force_update + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let render_category_cluster = |cluster: XtreamCluster, cats: Option<&Vec<String>>, selections: &HashMap<String, bool>| {
|
||||
if let Some(c) = cats {
|
||||
html_if!(!c.is_empty(), {
|
||||
<Card>
|
||||
<CollapsePanel title={translate.t( match cluster {
|
||||
XtreamCluster::Live => "LABEL.LIVE",
|
||||
XtreamCluster::Video => "LABEL.MOVIE",
|
||||
XtreamCluster::Series => "LABEL.SERIES"
|
||||
})}>
|
||||
<div class="tp__api-user-target-playlist__categories">
|
||||
{ for c.iter().map(|cat| {
|
||||
let selected = *selections.get(cat).unwrap_or(&false);
|
||||
html! {
|
||||
<div key={cat.clone()} data-cluster={cluster.to_string()} data-category={cat.clone()} class={classes!("tp__api-user-target-playlist__categories-category", if selected {"selected"} else {""})}
|
||||
onclick={handle_category_click.clone()}>
|
||||
<AppIcon name={if selected {"Checked"} else {"Unchecked"}}/> { &cat }
|
||||
</div>
|
||||
}})}
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Card>
|
||||
})
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
};
|
||||
|
||||
let selections = &*bouquet_selection.borrow();
|
||||
html! {
|
||||
<div class={"tp__api-user-target-playlist"}>
|
||||
<div class="tp__api-user-target-playlist__body">
|
||||
{ render_category_cluster(XtreamCluster::Live, playlist_categories.live.as_ref(), &selections.live) }
|
||||
{ render_category_cluster(XtreamCluster::Video, playlist_categories.vod.as_ref(), &selections.vod) }
|
||||
{ render_category_cluster(XtreamCluster::Series, playlist_categories.series.as_ref(), &selections.series) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
{render_output()}
|
||||
</Panel>
|
||||
<Panel value={OutputFormPage::Trakt.to_string()} active={view_visible.to_string()}>
|
||||
{"TODO"}
|
||||
{"TODO..."}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use std::rc::Rc;
|
||||
use yew::prelude::*;
|
||||
use crate::model::WebConfig;
|
||||
use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, StreamsService, ToastrService, UserService, WebSocketService};
|
||||
use crate::services::{AuthService, ConfigService, EventService, PlaylistService, StatusService, StreamsService, ToastrService, UserApiService, UserService, WebSocketService};
|
||||
|
||||
pub struct Services {
|
||||
pub auth: Rc<AuthService>,
|
||||
pub config: Rc<ConfigService>,
|
||||
pub user: Rc<UserService>,
|
||||
pub user_api: Rc<UserApiService>,
|
||||
pub status: Rc<StatusService>,
|
||||
pub streams: Rc<StreamsService>,
|
||||
pub event: Rc<EventService>,
|
||||
@@ -25,6 +26,7 @@ impl Services {
|
||||
let playlist = Rc::new(PlaylistService::new());
|
||||
let toastr = Rc::new(ToastrService::new());
|
||||
let user = Rc::new(UserService::new(Rc::clone(&event)));
|
||||
let user_api = Rc::new(UserApiService::new());
|
||||
let websocket = Rc::new(WebSocketService::new(Rc::clone(&status), Rc::clone(&event)));
|
||||
Self {
|
||||
auth,
|
||||
@@ -34,6 +36,7 @@ impl Services {
|
||||
event,
|
||||
playlist,
|
||||
user,
|
||||
user_api,
|
||||
toastr,
|
||||
websocket
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod websocket_service;
|
||||
mod toastr_service;
|
||||
mod event_service;
|
||||
mod user_service;
|
||||
mod user_api_service;
|
||||
mod streams_service;
|
||||
|
||||
pub use self::auth_service::*;
|
||||
@@ -20,4 +21,5 @@ pub use self::websocket_service::*;
|
||||
pub use self::toastr_service::*;
|
||||
pub use self::event_service::*;
|
||||
pub use self::user_service::*;
|
||||
pub use self::user_api_service::*;
|
||||
pub use self::streams_service::*;
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::rc::Rc;
|
||||
use log::error;
|
||||
use shared::model::{PlaylistBouquetDto, PlaylistCategoriesDto};
|
||||
use shared::utils::{concat_path_leading_slash};
|
||||
use crate::error::Error;
|
||||
use crate::services::{get_base_href, request_get, request_post};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct UserApiService {
|
||||
user_playlist_categories_path: String,
|
||||
user_playlist_bouquet_path: String,
|
||||
}
|
||||
|
||||
impl UserApiService {
|
||||
pub fn new() -> Self {
|
||||
let base_href = get_base_href();
|
||||
Self {
|
||||
user_playlist_categories_path: concat_path_leading_slash(&base_href, "api/v1/user/playlist/categories"),
|
||||
user_playlist_bouquet_path: concat_path_leading_slash(&base_href, "api/v1/user/playlist/bouquet"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_playlist_categories(&self) -> Result<Option<Rc<PlaylistCategoriesDto>>, Error> {
|
||||
match request_get::<Rc<PlaylistCategoriesDto>>(&self.user_playlist_categories_path, None, None).await {
|
||||
Ok(categories) => { Ok(categories) },
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_playlist_bouquet(&self) -> Result<Option<Rc<PlaylistBouquetDto>>, Error> {
|
||||
match request_get::<Rc<PlaylistBouquetDto>>(&self.user_playlist_bouquet_path, None, None).await {
|
||||
Ok(bouquet) => { Ok(bouquet) },
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_playlist_bouquet(&self, bouquet: &PlaylistBouquetDto) -> Result<(), Error> {
|
||||
match request_post::<&PlaylistBouquetDto, ()>(&self.user_playlist_bouquet_path, bouquet, None, None).await{
|
||||
Ok(_) => { Ok(()) },
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,19 @@ impl XtreamCluster {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for XtreamCluster {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"live" | "Live" => Ok(XtreamCluster::Live),
|
||||
"video" | "Video" | "vod" | "movie" | "Movie" => Ok(XtreamCluster::Video),
|
||||
"series" | "Series" => Ok(XtreamCluster::Series),
|
||||
_ => Err(format!("Invalid XtreamCluster: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for XtreamCluster {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
|
||||
@@ -9,23 +9,8 @@
|
||||
// pub series: Option<Vec<String>>,
|
||||
// }
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct PlaylistCategoryDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct PlaylistCategoriesDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub live: Option<Vec<PlaylistCategoryDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vod: Option<Vec<PlaylistCategoryDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series: Option<Vec<PlaylistCategoryDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct TargetBouquetDto {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct PlaylistClusterCategoriesDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub live: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -34,10 +19,28 @@ pub struct TargetBouquetDto {
|
||||
pub series: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct PlaylistCategoriesDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub xtream: Option<PlaylistClusterCategoriesDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub m3u: Option<PlaylistClusterCategoriesDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct PlaylistClusterBouquetDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub live: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vod: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct PlaylistBouquetDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub xtream: Option<TargetBouquetDto>,
|
||||
pub xtream: Option<PlaylistClusterBouquetDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub m3u: Option<TargetBouquetDto>,
|
||||
pub m3u: Option<PlaylistClusterBouquetDto>,
|
||||
}
|
||||
Reference in New Issue
Block a user