User category selection

This commit is contained in:
euzu
2025-11-25 16:30:04 +01:00
parent 016d7589f3
commit a8b2755379
19 changed files with 645 additions and 311 deletions
+1 -1
View File
@@ -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
)
);
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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
)
);
+2 -3
View File
@@ -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) => {
+5 -1
View File
@@ -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": {
+7 -1
View File
@@ -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": [
+2
View File
@@ -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 {
+2 -1
View File
@@ -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>
+4 -1
View File
@@ -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
}
+2
View File
@@ -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::*;
+52
View File
@@ -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)
}
}
}
}
+13
View File
@@ -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())
+23 -20
View File
@@ -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>,
}