From a8b27553797009727401e8faa72b80b6db5ce325 Mon Sep 17 00:00:00 2001 From: euzu Date: Tue, 25 Nov 2025 16:30:04 +0100 Subject: [PATCH] User category selection --- backend/src/api/endpoints/hls_api.rs | 2 +- backend/src/api/endpoints/m3u_api.rs | 2 +- backend/src/api/endpoints/xtream_api.rs | 6 +- backend/src/repository/user_repository.rs | 5 +- frontend/public/assets/i18n/en.json | 6 +- frontend/public/assets/icons.json | 8 +- frontend/scss/_size.scss | 2 + .../components/api_user/_api_user_view.scss | 143 ++++++++- .../components/api_user/api_user_playlist.rs | 275 ------------------ .../app/components/api_user/api_user_view.rs | 2 +- frontend/src/app/components/api_user/mod.rs | 3 +- .../src/app/components/api_user/playlist.rs | 208 +++++++++++++ .../components/api_user/target_playlist.rs | 177 +++++++++++ .../source_editor/output_xtream_form.rs | 2 +- frontend/src/hooks/use_service_context.rs | 5 +- frontend/src/services/mod.rs | 2 + frontend/src/services/user_api_service.rs | 52 ++++ shared/src/model/playlist.rs | 13 + shared/src/model/playlist_categories.rs | 43 +-- 19 files changed, 645 insertions(+), 311 deletions(-) delete mode 100644 frontend/src/app/components/api_user/api_user_playlist.rs create mode 100644 frontend/src/app/components/api_user/playlist.rs create mode 100644 frontend/src/app/components/api_user/target_playlist.rs create mode 100644 frontend/src/services/user_api_service.rs diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index cf757c056..4cb9c9f77 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -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 ) ); diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index cd24f4353..26e203103 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -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); diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 34601d116..464123c68 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -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 ) ); diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index d1c58b1c7..d95747067 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -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) => { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 1c32f8fd6..8412e0268 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -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": { diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index eef6df83d..4f0bd687f 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -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": [ diff --git a/frontend/scss/_size.scss b/frontend/scss/_size.scss index aa087a6b0..bf8c9a1e5 100644 --- a/frontend/scss/_size.scss +++ b/frontend/scss/_size.scss @@ -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; diff --git a/frontend/scss/app/components/api_user/_api_user_view.scss b/frontend/scss/app/components/api_user/_api_user_view.scss index f62e230b6..2f09b9a1c 100644 --- a/frontend/scss/app/components/api_user/_api_user_view.scss +++ b/frontend/scss/app/components/api_user/_api_user_view.scss @@ -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); + } + } + } + } \ No newline at end of file diff --git a/frontend/src/app/components/api_user/api_user_playlist.rs b/frontend/src/app/components/api_user/api_user_playlist.rs deleted file mode 100644 index b4b39a128..000000000 --- a/frontend/src/app/components/api_user/api_user_playlist.rs +++ /dev/null @@ -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 { - 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, - pub vod: HashMap, - pub series: HashMap, -} - -// ----- Helper ----- -fn is_empty(value: &Option) -> 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); - let bouquets = use_state(|| None as Option); - 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::>() - }); - - let handle_tab_select = { - let active_tab_clone = active_tab.clone(); - Callback::from(move |page_selection: Rc>| { - 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::::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! { -
- { if *loading { html!{
{"Loading..."}
} } else { html!{} }} - -
- - -
- -
-
- -
- -
- - {"xtream"} - // - - - {"xtream"} - // - -
-
-
- } -} - -/// Convert user target categories → selection maps -fn target_to_selection(t: &TargetBouquetDto) -> BouquetSelection { - fn map(list: &Option>) -> HashMap { - 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, - target: &TargetBouquetDto, -) -> TargetBouquetDto { - let s = sel.unwrap_or(BouquetSelection::default()); - - fn to_vec(map: &HashMap) -> Option> { - let v: Vec = 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), - } -} diff --git a/frontend/src/app/components/api_user/api_user_view.rs b/frontend/src/app/components/api_user/api_user_view.rs index 806d8a2c8..701370739 100644 --- a/frontend/src/app/components/api_user/api_user_view.rs +++ b/frontend/src/app/components/api_user/api_user_view.rs @@ -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 { diff --git a/frontend/src/app/components/api_user/mod.rs b/frontend/src/app/components/api_user/mod.rs index 3054b2976..6553e0328 100644 --- a/frontend/src/app/components/api_user/mod.rs +++ b/frontend/src/app/components/api_user/mod.rs @@ -1,4 +1,5 @@ mod api_user_view; -mod api_user_playlist; +mod playlist; +mod target_playlist; pub use api_user_view::*; \ No newline at end of file diff --git a/frontend/src/app/components/api_user/playlist.rs b/frontend/src/app/components/api_user/playlist.rs new file mode 100644 index 000000000..0241ef422 --- /dev/null +++ b/frontend/src/app/components/api_user/playlist.rs @@ -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 { + 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>>) -> Option { + if let Some(bouq) = bouquet { + let selections = bouq.borrow(); + + let selected_vec = |map: &HashMap| { + let v: Vec = 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>); + let bouquets = use_state(|| None as Option>); + let active_tab = use_state(|| ApiUserPlaylistPage::Xtream); + let playlist_types = use_memo((), |_| { + [ApiUserPlaylistPage::Xtream, ApiUserPlaylistPage::M3u].iter().map(ToString::to_string).collect::>() + }); + + // Selection reference + let selections = use_mut_ref(|| { + HashMap::>>::new() + }); + + let handle_tab_select = { + let active_tab_clone = active_tab.clone(); + Callback::from(move |page_selection: Rc>| { + 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>| { + selections.borrow_mut().insert(ApiUserPlaylistPage::M3u, selection); + }) + }; + + let handle_xtream_change = { + let selections = selections.clone(); + Callback::from(move |selection: Rc>| { + selections.borrow_mut().insert(ApiUserPlaylistPage::Xtream, selection); + }) + }; + + html! { +
+
+

{translate.t("TITLE.USER_BOUQUET_EDITOR") }

+
+ + +
+
+ +
+
+ +
+ +
+ + + + + + +
+
+
+ } +} diff --git a/frontend/src/app/components/api_user/target_playlist.rs b/frontend/src/app/components/api_user/target_playlist.rs new file mode 100644 index 000000000..76e1914b9 --- /dev/null +++ b/frontend/src/app/components/api_user/target_playlist.rs @@ -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>) { + 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, + pub vod: HashMap, + pub series: HashMap, +} + +#[derive(Properties, PartialEq)] +pub struct UserTargetPlaylistProps { + pub categories: Option, + pub bouquet: Option, + pub on_change: Callback>>, +} + +#[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::() { + 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>, selections: &HashMap| { + if let Some(c) = cats { + html_if!(!c.is_empty(), { + + "LABEL.LIVE", + XtreamCluster::Video => "LABEL.MOVIE", + XtreamCluster::Series => "LABEL.SERIES" + })}> +
+ { for c.iter().map(|cat| { + let selected = *selections.get(cat).unwrap_or(&false); + html! { +
+ { &cat } +
+ }})} +
+
+
+ }) + } else { + html! {} + } + }; + + let selections = &*bouquet_selection.borrow(); + html! { +
+
+ { 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) } +
+
+ } +} diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index 78172360c..fa81d3fb2 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -149,7 +149,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { {render_output()} - {"TODO"} + {"TODO..."} diff --git a/frontend/src/hooks/use_service_context.rs b/frontend/src/hooks/use_service_context.rs index d0463f154..d8db70a12 100644 --- a/frontend/src/hooks/use_service_context.rs +++ b/frontend/src/hooks/use_service_context.rs @@ -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, pub config: Rc, pub user: Rc, + pub user_api: Rc, pub status: Rc, pub streams: Rc, pub event: Rc, @@ -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 } diff --git a/frontend/src/services/mod.rs b/frontend/src/services/mod.rs index 25095df33..2e41e36bc 100644 --- a/frontend/src/services/mod.rs +++ b/frontend/src/services/mod.rs @@ -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::*; \ No newline at end of file diff --git a/frontend/src/services/user_api_service.rs b/frontend/src/services/user_api_service.rs new file mode 100644 index 000000000..e9abaf54c --- /dev/null +++ b/frontend/src/services/user_api_service.rs @@ -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>, Error> { + match request_get::>(&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>, Error> { + match request_get::>(&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) + } + } + } +} diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 032cd5ee0..2c4545e71 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -37,6 +37,19 @@ impl XtreamCluster { } } +impl FromStr for XtreamCluster { + type Err = String; + + fn from_str(s: &str) -> Result { + 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()) diff --git a/shared/src/model/playlist_categories.rs b/shared/src/model/playlist_categories.rs index f175834cd..957999e16 100644 --- a/shared/src/model/playlist_categories.rs +++ b/shared/src/model/playlist_categories.rs @@ -9,23 +9,8 @@ // pub series: Option>, // } -#[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>, - #[serde(skip_serializing_if = "Option::is_none")] - pub vod: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub series: Option>, -} - -#[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>, #[serde(skip_serializing_if = "Option::is_none")] @@ -34,10 +19,28 @@ pub struct TargetBouquetDto { pub series: Option>, } -#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub m3u: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +pub struct PlaylistClusterBouquetDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub live: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub vod: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub series: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] pub struct PlaylistBouquetDto { #[serde(skip_serializing_if = "Option::is_none")] - pub xtream: Option, + pub xtream: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub m3u: Option, + pub m3u: Option, } \ No newline at end of file