diff --git a/Cargo.lock b/Cargo.lock index c3d1576c1..6f63f80e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1168,7 +1168,7 @@ dependencies = [ "futures-signals", "gloo-render 0.2.0", "gloo-storage 0.3.0", - "gloo-timers 0.2.6", + "gloo-timers 0.3.0", "gloo-utils 0.2.0", "implicit-clone", "indexmap", @@ -1692,8 +1692,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" dependencies = [ - "futures-channel", - "futures-core", "js-sys", "wasm-bindgen", ] @@ -3949,6 +3947,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tuple" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f025b91216f15a2a32aa39669329a475733590a015835d1783549a56d09427" +dependencies = [ + "serde", + "serde_tuple_macros", +] + +[[package]] +name = "serde_tuple_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4076151d1a2b688e25aaf236997933c66e18b870d0369f8b248b8ab2be630d7e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4019,6 +4038,7 @@ dependencies = [ "serde", "serde-saphyr", "serde_json", + "serde_tuple", "url", "zeroize", ] @@ -4141,6 +4161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index dcaa43a80..cf3d3933e 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -28,7 +28,7 @@ use log::{debug, error, info, log_enabled, trace, warn}; use serde::Serialize; use shared::concat_string; use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, TargetType, UserConnectionPermission, VirtualId, XtreamCluster}; -use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_kbps, trim_slash, Internable}; +use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_kbps, trim_slash, Internable, CONTENT_TYPE_CBOR}; use shared::utils::{ extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT, }; @@ -43,8 +43,6 @@ use tokio::sync::Mutex; use tokio_util::io::ReaderStream; use url::Url; -const CONTENT_TYPE_BIN: &str = "application/cbor"; - #[macro_export] macro_rules! try_option_bad_request { ($option:expr, $msg_is_error:expr, $msg:expr) => { @@ -1505,7 +1503,7 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> pub fn bin_response(data: &T) -> impl IntoResponse + Send { match bin_serialize(data) { Ok(body) => ( - [(header::CONTENT_TYPE, CONTENT_TYPE_BIN)], + [(header::CONTENT_TYPE, CONTENT_TYPE_CBOR)], body, ).into_response(), Err(_) => internal_server_error!(), @@ -1517,7 +1515,7 @@ pub fn json_response(data: &T) -> impl IntoResponse + Send { } pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> impl IntoResponse + Send { - if accept.is_some_and(|a| a.contains(CONTENT_TYPE_BIN)) { + if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { return bin_response(data).into_response(); } json_response(data).into_response() @@ -1527,7 +1525,7 @@ pub fn stream_json_or_bin_response

(accept: Option<&str>, data: Box + Send> = Box::new(channel_iterator.filter(item_filter).map(CommonPlaylistItem::from)); + let converted_iterator: Box + Send> = Box::new(channel_iterator.filter(item_filter).map(UiPlaylistItem::from)); return stream_json_or_bin_response(accept, converted_iterator).into_response(); } else if target.has_output(TargetType::M3u) { let Some((_guard, channel_iterator)) = iter_raw_m3u_target_playlist(cfg, target, Some(cluster)).await else { @@ -31,8 +31,7 @@ pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Optio |_pli: &M3uPlaylistItem| true }; - let converted_iterator: Box + Send> = Box::new(channel_iterator - .filter(item_filter).map(CommonPlaylistItem::from)); + let converted_iterator: Box + Send> = Box::new(channel_iterator.filter(item_filter).map(UiPlaylistItem::from)); return stream_json_or_bin_response(accept, converted_iterator).into_response(); } } @@ -46,13 +45,13 @@ pub(in crate::api::endpoints) async fn get_playlist_for_input(cfg_input: Option< let Some((_guard, channel_iterator)) = iter_raw_xtream_input_playlist(cfg, input, cluster).await else { return empty_json_list_response(); }; - let converted_iterator: Box + Send> = Box::new(channel_iterator.map(CommonPlaylistItem::from)); + let converted_iterator: Box + Send> = Box::new(channel_iterator.map(UiPlaylistItem::from)); return stream_json_or_bin_response(accept, converted_iterator).into_response(); } else if matches!(input.input_type, InputType::M3u | InputType::M3uBatch) { let Some((_guard, channels)) = iter_raw_m3u_input_playlist(cfg, input, Some(cluster)).await else { return empty_json_list_response(); }; - let converted_iterator: Box + Send> = Box::new(channels.map(CommonPlaylistItem::from)); + let converted_iterator: Box + Send> = Box::new(channels.map(UiPlaylistItem::from)); return stream_json_or_bin_response(accept, converted_iterator).into_response(); } } @@ -78,7 +77,7 @@ pub(in crate::api::endpoints) async fn get_playlist_for_custom_provider(client: let error_strings: Vec = errors.iter().map(ToString::to_string).collect(); (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": error_strings.join(", ")}))).into_response() } else { - let channels: Vec = result.iter().flat_map(|g| g.channels.iter()).map(CommonPlaylistItem::from).collect(); + let channels: Vec = result.iter().flat_map(|g| g.channels.iter()).map(UiPlaylistItem::from).collect(); interner_gc(); json_or_bin_response(accept, &channels).into_response() } diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index bd3800de6..0b97b1af2 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -164,6 +164,7 @@ async fn playlist_series_info( } } } + PlaylistRequest::Input(input_id) => { if let Some(input) = app_state.app_config.get_input_by_id(input_id) { if matches!(input.input_type, InputType::Xtream | InputType::XtreamBatch) { diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index cdbd58f1c..69189b079 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -31,7 +31,7 @@ async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader let reader = tokio_util::io::SyncIoBridge::new(streams); let parsed: Result, serde_json::Error> = match xtream_cluster { - XtreamCluster::Live => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(StreamProperties::Live).collect()), + XtreamCluster::Live => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Live).collect()), XtreamCluster::Video => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Video).collect()), XtreamCluster::Series => serde_json::from_reader::<_, Vec>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Series).collect()), }; @@ -244,7 +244,7 @@ where XtreamCluster::Live => { let mut on_stream = |stream: LiveStreamProperties| { source_ordinal += 1; - let stream_prop = StreamProperties::Live(stream); + let stream_prop = StreamProperties::Live(Box::new(stream)); process_stream_item(&input_name, &url, &username, &password, xtream_cluster, &group_map, &unknown_group_name, stream_prop, &mut on_item, live_stream_use_prefix, live_stream_without_extension, source_ordinal) diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index db12a0b98..e239d7f17 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -37,7 +37,7 @@ indexmap = "2.12.1" [dependencies.web-sys] version = "0.3" -features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration"] +features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlImageElement", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration"] [dev-dependencies] js-sys = "0.3" diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index 1ad480e64..ee49c4630 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -1,7 +1,7 @@ $movie-card-height: 24rem; $movie-card-width: 16rem; $live-card-width: 17.5rem; -$episode-card-height: 10rem; +$episode-card-height: 13rem; $episode-card-width: 18rem; $series-backdrop-height: 300px; @@ -12,27 +12,23 @@ $series-backdrop-height: 300px; min-height: $height; max-width: $width; min-width: $width; +} +@mixin channel-logo($height, $width, $border-radius) { + .tp__playlist-explorer__channel-logo { + box-sizing: border-box; + overflow: hidden; + height: $height; + width: $width; - .tp__playlist-explorer__channel { - &-logo { + img { + height: $height; + width: $width; box-sizing: border-box; - height: 100%; object-fit: cover; object-position: center; - } - - &-title { - display: flex; - flex-flow: row; - align-items: center; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 3rem; - background-color: var(--card-background-color); - padding-left: 2rem; + overflow: hidden; + border-radius: $border-radius; } } } @@ -200,11 +196,7 @@ $series-backdrop-height: 300px; min-height: 3rem; } - &-video { - max-height: 3rem; - min-height: 3rem; - } - + &-video, &-series { max-height: 3rem; min-height: 3rem; @@ -222,64 +214,19 @@ $series-backdrop-height: 300px; } } - &__channel { - display: flex; - justify-content: flex-start; - align-items: center; - fill: var(--menu-icon-color); - background-color: var(--text-button-background-color); - color: var(--text-button-color); - border: 1px solid var(--text-button-border-color); - border-radius: var(--border-radius); - cursor: pointer; - box-sizing: border-box; - gap: var(--gap-default); - padding-left: var(--padding-micro); - overflow: hidden; - text-overflow: ellipsis; - - &-live { - position: relative; - max-height: 4rem; - min-height: 4rem; - - .tp__playlist-explorer__channel { - - &-logo { - box-sizing: border-box; - height: 3rem; - width: 3rem; - border-radius: var(--border-radius); - overflow: hidden; - object-fit: cover; - object-position: center; - } - } - } - - &-video, - &-series { - @include card-base($movie-card-height, $movie-card-width); - .tp__icon-button { - position: absolute; - z-index: 1; - bottom: 0.75rem; - left: 0; - background-color: transparent; - } - } - - &-episode { - @include card-base($episode-card-height, $episode-card-width); - } - } - &__series-info { display: flex; flex-flow: column; gap: var(--gap-default); box-sizing: border-box; overflow: hidden; + align-items: center; + + &__rating { + color: var(--card-color) !important; + background-color: rgb(from var(--card-background-color) r g b / 1) !important; + font-weight: bold; + } &__nowrap { white-space: nowrap; @@ -288,8 +235,10 @@ $series-backdrop-height: 300px; &__body { display: flex; flex-flow: column; + flex: 1 1 auto; gap: var(--gap-default); box-sizing: border-box; + width: 100%; overflow: auto; .tp__playlist-explorer__group-list { @@ -301,15 +250,17 @@ $series-backdrop-height: 300px; flex-flow: column; gap: var(--gap-default); min-height: $series-backdrop-height; - background-size: cover; - background-position: right top; - + background-repeat: no-repeat; + background-size: 100%; + //background-position: right top; + border-radius: var(--border-radius); font-size: 1.2rem; box-sizing: border-box; &-content { display: flex; flex-flow: column; + padding: var(--padding-default); gap: var(--gap-default); max-width: 60%; background-size: cover; @@ -343,4 +294,77 @@ $series-backdrop-height: 300px; } } } + + &__channel { + display: flex; + justify-content: flex-start; + align-items: center; + fill: var(--menu-icon-color); + background-color: var(--text-button-background-color); + color: var(--text-button-color); + border: 1px solid var(--text-button-border-color); + border-radius: var(--border-radius); + cursor: pointer; + box-sizing: border-box; + gap: var(--gap-default); + padding-left: var(--padding-micro); + overflow: hidden; + text-overflow: ellipsis; + + + &-live { + display: flex; + flex-flow: row; + box-sizing: border-box; + position: relative; + max-height: 4rem; + min-height: 4rem; + align-items: center; + + @include channel-logo(3rem, 3rem, var(--border-radius)); + + } + + &-episode { + @include card-base($episode-card-height, $episode-card-width); + } + + &-series, + &-video { + @include card-base($movie-card-height, $movie-card-width); + } + + &-episode, + &-series, + &-video { + display: flex; + flex-flow: column; + box-sizing: border-box; + + @include channel-logo(100%, 100%, 0); + + &-rating { + position: absolute; + top: 0.5rem; + left: 0.5rem; + color: var(--card-color) !important; + background-color: rgb(from var(--card-background-color) r g b / 1) !important; + font-weight: bold; + } + + &-info { + display: flex; + flex-flow: row; + align-items: center; + height: 3rem; + width: 100%; + } + + &-title { + display: flex; + flex-flow: row; + align-items: center; + } + } + } } \ No newline at end of file diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 64211bf2a..a1abaae72 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -1,13 +1,14 @@ use crate::app::components::menu_item::MenuItem; use crate::app::components::popup_menu::PopupMenu; -use crate::app::components::{AppIcon, IconButton, NoContent, Panel, Search}; +use crate::app::components::{AppIcon, Chip, IconButton, NoContent, Panel, Search}; use crate::app::context::PlaylistExplorerContext; use crate::hooks::use_service_context; use crate::html_if; use crate::model::{BusyStatus, EventMessage}; use crate::services::DialogService; use shared::error::{info_err_res, TuliproxError}; -use shared::model::{CommonPlaylistItem, PlaylistRequest, SearchRequest, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, StreamProperties, UiPlaylistGroup, VirtualId, XtreamCluster}; +use shared::model::{PlaylistRequest, SearchRequest, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, UiPlaylistGroup, UiPlaylistItem, VirtualId, XtreamCluster}; +use shared::utils::format_float_localized; use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; @@ -67,7 +68,7 @@ impl FromStr for ExplorerAction { enum ExplorerLevel { Categories, Group(Rc), - SeriesInfo(Rc, Rc, Option>), + SeriesInfo(Rc, Rc, Option>), } #[function_component] @@ -104,13 +105,13 @@ pub fn PlaylistExplorer() -> Html { let set_selected_channel = selected_channel.clone(); let set_anchor_ref = popup_anchor_ref.clone(); let set_is_open = popup_is_open.clone(); - Callback::from(move |(dto, event): (Rc, MouseEvent)| { + Callback::from(move |(dto, event): (Rc, MouseEvent)| { event.prevent_default(); event.stop_propagation(); if let Some(target) = event.target_dyn_into::() { set_selected_channel.set(Some(ChannelSelection { virtual_id: dto.virtual_id, - cluster: dto.xtream_cluster.unwrap_or_default(), + cluster: dto.xtream_cluster, url: dto.url.to_string(), })); set_anchor_ref.set(Some(target)); @@ -139,38 +140,31 @@ pub fn PlaylistExplorer() -> Html { let services = service_ctx.clone(); let ctx = context.clone(); - move |group: Rc, dto: Rc| { - if let Some(StreamProperties::Series(series_props)) = dto.additional_properties.as_ref() { - if series_props.details.as_ref().is_none_or(|d| d.episodes.as_ref().is_none_or(|e| e.is_empty())) { - let set_current_item = set_current_item.clone(); - let services = services.clone(); - let ctx = ctx.clone(); - services.event.broadcast(EventMessage::Busy(BusyStatus::Show)); - spawn_local(async move { - let mut handled = false; - if let Some(playlist_request) = ctx.playlist_request.as_ref() { - if let Some(props) = services.playlist.get_series_info(&dto, playlist_request).await { - handled = true; - set_current_item.set(ExplorerLevel::SeriesInfo(group.clone(), dto.clone(), Some(Box::new(props)))); - } - } - if !handled { - set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); - } - services.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); - }); - } else { + move |group: Rc, dto: Rc| { + // UiPlaylistItem has no additional_properties - always load from server + let set_current_item = set_current_item.clone(); + let services = services.clone(); + let ctx = ctx.clone(); + services.event.broadcast(EventMessage::Busy(BusyStatus::Show)); + spawn_local(async move { + let mut handled = false; + if let Some(playlist_request) = ctx.playlist_request.as_ref() { + if let Some(props) = services.playlist.get_series_info(&dto, playlist_request).await { + handled = true; + set_current_item.set(ExplorerLevel::SeriesInfo(group.clone(), dto.clone(), Some(Box::new(props)))); + } + } + if !handled { set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); - }; - } else { - set_current_item.set(ExplorerLevel::SeriesInfo(group, dto, None)); - } + } + services.event.broadcast(EventMessage::Busy(BusyStatus::Hide)); + }); } }; let handle_series_onclick = { let set_current_item = current_item.clone(); - Callback::from(move |(dto, event): (Rc, MouseEvent)| { + Callback::from(move |(dto, event): (Rc, MouseEvent)| { event.prevent_default(); event.stop_propagation(); if let ExplorerLevel::Group(ref group) = *set_current_item { @@ -382,20 +376,118 @@ pub fn PlaylistExplorer() -> Html { } }; - let render_channel_logo = |chan: &Rc| { - let logo = if chan.logo.is_empty() { &chan.logo_small } else { &chan.logo }; - if logo.is_empty() { - html! {} + let render_channel_logo = |logo: &str| { + let logo = if logo.is_empty() { + "assets/missing-logo.svg".to_string() } else { - html! { () { - img.set_src("assets/missing-logo.svg"); - } + logo.to_string() + }; + html! { + + } + }; + + let render_live = |chan: &Rc| { + let popup_onclick = handle_popup_onclick.clone(); + let chan_clone = Rc::clone(chan); + html! { + + + {render_channel_logo(&*chan.logo)} + {chan.title.clone()} + + } + }; + + let render_movie = |chan: &Rc| { + let popup_onclick = handle_popup_onclick.clone(); + let chan_clone = Rc::clone(chan); + html! { + + {render_channel_logo(&*chan.logo)} + { + html_if!(chan.rating > 0.001, { + + }) + } + + + {chan.title.clone()} + + + } + }; + + let render_series = |chan: &Rc| { + let popup_onclick = handle_popup_onclick.clone(); + let chan_clone = Rc::clone(chan); + let chan_click = { + let chan_clone = chan.clone(); + let series_click = handle_series_onclick.clone(); + Callback::from(move |event: MouseEvent| series_click.emit((chan_clone.clone(), event))) + }; + html! { + + {render_channel_logo(&*chan.logo)} + { + html_if!(chan.rating > 0.001, { + + }) + } + + + {chan.title.clone()} + + + } + }; + + let render_episode = |chan: &&SeriesStreamDetailEpisodeProperties| { + let channel_select = ChannelSelection { + virtual_id: chan.id, + cluster: XtreamCluster::Series, + url: String::new(), // TODO provider url + }; + let popup_onclick = handle_episode_popup_onclick.clone(); + let rating = chan.rating.unwrap_or_default(); + html! { + + {render_channel_logo(&*chan.movie_image)} + { + html_if!(rating > 0.001, { + + }) + } + + + {chan.title.clone()} + + + } + }; + + let render_channel = |chan: &Rc| { + match chan.xtream_cluster { + XtreamCluster::Live => render_live(chan), + XtreamCluster::Video => render_movie(chan), + XtreamCluster::Series => render_series(chan), } }; @@ -404,38 +496,16 @@ pub fn PlaylistExplorer() -> Html {

{ - group.channels.iter().map(|chan| { - let chan_clone = chan.clone(); - let popup_onclick = handle_popup_onclick.clone(); - let chan_click = if group.xtream_cluster == XtreamCluster::Series { - let chan_clone = chan.clone(); - let series_click = handle_series_onclick.clone(); - Some(Callback::from(move |event: MouseEvent| series_click.emit((chan_clone.clone(), event)))) - } else { - None - }; - html! { - - - {render_channel_logo(chan)} - {chan.title.clone()} - - } - }).collect::() + group.channels.iter().map(render_channel).collect::() }
} }; - let render_series_info = |series_info: &Rc, props: Option<&Box>| { - let series_info_props = props.or(match series_info.additional_properties.as_ref() { - Some(StreamProperties::Series(props)) => Some(props), - _ => None - }); + let render_series_info = |series_info: &Rc, props: Option<&Box>| { + // UiPlaylistItem has no additional_properties - props are passed in or None + let series_info_props = props; let (mut backdrop, plot, cast, genre, release_date, rating, details) = match series_info_props { Some(series_props) => { let backdrop = series_props.backdrop_path.as_ref().and_then(|l| l.first()).map_or_else(|| if series_props.cover.is_empty() { series_info.logo.to_string() } else { series_props.cover.to_string() }, ToString::to_string); @@ -467,8 +537,17 @@ pub fn PlaylistExplorer() -> Html {
{series_info.title.clone()} - {"(⭐️ "} {rating} {")"} + { + html_if!(rating > 0.001, { + <> + + + + {"⬤"} + + })} {release_date} + {"⬤"} {genre} {plot} @@ -487,8 +566,6 @@ pub fn PlaylistExplorer() -> Html { let mut grouped_list: Vec<(u32, Vec<&SeriesStreamDetailEpisodeProperties>)> = grouped.into_iter().collect(); grouped_list.sort_by_key(|(season, _)| *season); - let popup_onclick = handle_episode_popup_onclick.clone(); - html! { for grouped_list.iter().map(|(season, season_episodes)| html! { @@ -498,41 +575,7 @@ pub fn PlaylistExplorer() -> Html {
{ - for season_episodes.iter().map(|e| { - let channel_select = ChannelSelection { - virtual_id: e.id, - cluster: XtreamCluster::Series, - url: String::new(), // TODO provider url - }; - let popup_onclick = popup_onclick.clone(); - let logo = if e.movie_image.is_empty() { series_info.logo.to_string() } else { e.movie_image.to_string() }; - html! { - - - { - if logo.is_empty() { - html! {} - } else { - html! { () { - img.set_src("assets/missing-logo.svg"); - } - } - })} - />} - } - } - - - {e.title.clone()} - - - }}) + for season_episodes.iter().map(render_episode) }
diff --git a/frontend/src/services/playlist_service.rs b/frontend/src/services/playlist_service.rs index e9a5debca..a12df22a9 100644 --- a/frontend/src/services/playlist_service.rs +++ b/frontend/src/services/playlist_service.rs @@ -1,12 +1,11 @@ -use crate::services::{get_base_href, request_post, ACCEPT_PREFER_BIN}; +use crate::services::{get_base_href, request_post}; use log::error; -use shared::model::{CommonPlaylistItem, EpgTv, PlaylistEpgRequest, PlaylistRequest, - SeriesStreamProperties, UiPlaylistCategories, UiPlaylistGroup, - WebplayerUrlRequest, XtreamCluster, XtreamSeriesInfoDoc}; +use shared::model::{EpgTv, PlaylistEpgRequest, PlaylistRequest, SeriesStreamProperties, UiPlaylistCategories, UiPlaylistGroup, UiPlaylistItem, WebplayerUrlRequest, XtreamCluster, XtreamSeriesInfoDoc}; + use std::rc::Rc; use futures::join; use indexmap::IndexMap; -use shared::utils::{concat_path_leading_slash}; +use shared::utils::{concat_path_leading_slash, ACCEPT_PREFER_CBOR}; pub struct PlaylistService { target_update_api_path: String, @@ -44,9 +43,9 @@ impl PlaylistService { pub async fn get_playlist_categories(&self, playlist_request: &PlaylistRequest) -> Option> { let (live_res, vod_res, series_res) = join!( - request_post::<&PlaylistRequest, Vec>(&self.playlist_api_live_path, playlist_request, None, Some(ACCEPT_PREFER_BIN.to_string())), - request_post::<&PlaylistRequest, Vec>(&self.playlist_api_vod_path, playlist_request, None, Some(ACCEPT_PREFER_BIN.to_string())), - request_post::<&PlaylistRequest, Vec>(&self.playlist_api_series_path, playlist_request, None, Some(ACCEPT_PREFER_BIN.to_string())), + request_post::<&PlaylistRequest, Vec>(&self.playlist_api_live_path, playlist_request, None, Some(ACCEPT_PREFER_CBOR.to_string())), + request_post::<&PlaylistRequest, Vec>(&self.playlist_api_vod_path, playlist_request, None, Some(ACCEPT_PREFER_CBOR.to_string())), + request_post::<&PlaylistRequest, Vec>(&self.playlist_api_series_path, playlist_request, None, Some(ACCEPT_PREFER_CBOR.to_string())), ); let live = live_res.map_or_else(|err| { error!("Failed to fetch live playlist: {err}"); None }, |r| r.map(|resp| to_ui_playlist_groups(resp, XtreamCluster::Live))); @@ -76,15 +75,15 @@ impl PlaylistService { } pub async fn get_playlist_epg(&self, request: PlaylistEpgRequest) -> Option { - request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, Some(ACCEPT_PREFER_BIN.to_string())).await.unwrap_or_else(|err| { + request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, Some(ACCEPT_PREFER_CBOR.to_string())).await.unwrap_or_else(|err| { error!("{err}"); None }) } - pub async fn get_series_info(&self, pli: &Rc, playlist_request: &PlaylistRequest) -> Option { + pub async fn get_series_info(&self, pli: &Rc, playlist_request: &PlaylistRequest) -> Option { let path = format!("{}/{}/{}", self.playlist_api_series_info_path, pli.virtual_id, pli.provider_id); - request_post::<&PlaylistRequest, XtreamSeriesInfoDoc>(&path, playlist_request, None, Some(ACCEPT_PREFER_BIN.to_string())).await.map_or_else(|err| { + request_post::<&PlaylistRequest, XtreamSeriesInfoDoc>(&path, playlist_request, None, Some(ACCEPT_PREFER_CBOR.to_string())).await.map_or_else(|err| { error!("{err}"); None }, |response| { @@ -93,12 +92,12 @@ impl PlaylistService { } } -fn to_ui_playlist_groups(list: Vec, xtream_cluster: XtreamCluster) -> Vec> { +fn to_ui_playlist_groups(list: Vec, xtream_cluster: XtreamCluster) -> Vec> { let mut groups = IndexMap::new(); list.into_iter().for_each(|item| { let group_id = item.group.clone(); let group = groups.entry(group_id).or_insert_with(|| UiPlaylistGroup { - id: item.category_id.unwrap_or(0), + id: item.category_id, title: item.group.clone(), channels: vec![], xtream_cluster, diff --git a/frontend/src/services/requests.rs b/frontend/src/services/requests.rs index 64a60b7e2..cba5f8841 100644 --- a/frontend/src/services/requests.rs +++ b/frontend/src/services/requests.rs @@ -4,13 +4,9 @@ use log::error; use reqwasm::http::Request; use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; -use shared::utils::bin_deserialize; +use shared::utils::{bin_deserialize, CONTENT_TYPE_CBOR, CONTENT_TYPE_JSON}; use web_sys::window; -const CONTENT_TYPE_BIN: &str = "application/cbor"; -const CONTENT_TYPE_JSON: &str = "application/json"; -pub const ACCEPT_PREFER_BIN: &str = "application/cbor, application/json;q=0.9"; - enum RequestMethod { Get, Post, @@ -68,8 +64,8 @@ where request = request.header("Authorization", format!("Bearer {token}").as_str()); } - if r_type.contains(CONTENT_TYPE_BIN) { - request = request.header("Accept", CONTENT_TYPE_BIN); + if r_type.contains(CONTENT_TYPE_CBOR) { + request = request.header("Accept", CONTENT_TYPE_CBOR); } match request.send().await { Ok(response) => { @@ -80,7 +76,7 @@ where .get("content-type") .unwrap_or(r_type.to_string()); let is_json = content_type.contains(CONTENT_TYPE_JSON); - let is_bin = !is_json && content_type.contains(CONTENT_TYPE_BIN); + let is_bin = !is_json && content_type.contains(CONTENT_TYPE_CBOR); if (is_json || is_bin) && std::any::TypeId::of::() == std::any::TypeId::of::<()>() { // `T = ()` valid let _ = response.binary().await; @@ -126,7 +122,7 @@ where 400 => { let ct = response.headers().get("content-type").unwrap_or_default(); let is_json = ct.contains(CONTENT_TYPE_JSON); - let is_bin = !is_json && ct.contains(CONTENT_TYPE_BIN); + let is_bin = !is_json && ct.contains(CONTENT_TYPE_CBOR); let data: Result = if is_bin { match response.binary().await { Ok(bytes) => bin_deserialize::(&bytes).map_err(|_| Error::DeserializeError), @@ -149,7 +145,7 @@ where 422 => { let ct = response.headers().get("content-type").unwrap_or_default(); let is_json = ct.contains(CONTENT_TYPE_JSON); - let is_bin = !is_json && ct.contains(CONTENT_TYPE_BIN); + let is_bin = !is_json && ct.contains(CONTENT_TYPE_CBOR); let data: Result = if is_bin { match response.binary().await { Ok(bytes) => bin_deserialize::(&bytes).map_err(|_| Error::DeserializeError), diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 7e749ac14..460e67851 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -28,6 +28,7 @@ lz4_flex = "0.12.0" deunicode = "1.6.2" serde-saphyr = "0.0.14" dashmap = "6.1.0" +serde_tuple = "0.5" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.83" diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index dd24c2f96..a45b252f5 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -31,7 +31,7 @@ mod playlist_info_document; mod regex_cache; mod uuidtype; mod short_epg; - +mod ui_playlist_item; pub use self::cluster_flags::*; pub use self::playlist::*; pub use self::messaging::*; @@ -61,4 +61,5 @@ pub use self::stream_properties::*; pub use self::xtream::*; pub use self::playlist_info_document::*; pub use self::regex_cache::*; -pub use self::uuidtype::*; \ No newline at end of file +pub use self::uuidtype::*; +pub use self::ui_playlist_item::*; \ No newline at end of file diff --git a/shared/src/model/playlist_request.rs b/shared/src/model/playlist_request.rs index de6dae616..40bb554b5 100644 --- a/shared/src/model/playlist_request.rs +++ b/shared/src/model/playlist_request.rs @@ -1,4 +1,4 @@ -use crate::model::{PlaylistItemType, SearchRequest, StreamProperties, XtreamCluster}; +use crate::model::{PlaylistItemType, SearchRequest, StreamProperties, UiPlaylistItem, XtreamCluster}; use crate::utils::{arc_str_serde, arc_str_option_serde}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -69,7 +69,7 @@ pub struct UiPlaylistGroup { pub id: u32, #[serde(with = "arc_str_serde")] pub title: Arc, - pub channels: Vec>, + pub channels: Vec>, pub xtream_cluster: XtreamCluster, } @@ -99,7 +99,7 @@ fn filter_channels( return Some(Rc::clone(group)); } - let filtered_channels: Vec> = group + let filtered_channels: Vec> = group .channels .iter() .filter(|c| { @@ -132,7 +132,7 @@ fn filter_channels_re(groups: Option<&Vec>>, regex: &Regex) return Some(Rc::clone(group)); } - let filtered_channels: Vec> = group + let filtered_channels: Vec> = group .channels .iter() .filter(|c| regex.is_match(&c.title) || regex.is_match(&c.name)) diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 2eb22141b..839287750 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -287,7 +287,7 @@ pub struct EpisodeStreamProperties { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum StreamProperties { - Live(LiveStreamProperties), + Live(Box), Video(Box), Series(Box), Episode(EpisodeStreamProperties), diff --git a/shared/src/model/ui_playlist_item.rs b/shared/src/model/ui_playlist_item.rs new file mode 100644 index 000000000..8c8f4e824 --- /dev/null +++ b/shared/src/model/ui_playlist_item.rs @@ -0,0 +1,173 @@ +use crate::model::{CommonPlaylistItem, M3uPlaylistItem, PlaylistItem, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}; +use crate::utils::{arc_str_serde, Internable}; +use serde_tuple::{Deserialize_tuple, Serialize_tuple}; +use std::sync::Arc; + +/// Lightweight playlist item for UI streaming. +#[derive(Debug, Clone, Serialize_tuple, Deserialize_tuple, PartialEq)] +pub struct UiPlaylistItem { + #[serde(rename = "v")] + pub virtual_id: u32, + #[serde(rename = "p", with = "arc_str_serde")] + pub provider_id: Arc, + #[serde(rename = "n", with = "arc_str_serde")] + pub name: Arc, + #[serde(rename = "t", with = "arc_str_serde")] + pub title: Arc, + #[serde(rename = "g", with = "arc_str_serde")] + pub group: Arc, + #[serde(rename = "l", with = "arc_str_serde")] + pub logo: Arc, + #[serde(rename = "u", with = "arc_str_serde")] + pub url: Arc, + #[serde(rename = "t")] + pub item_type: PlaylistItemType, + #[serde(rename = "x")] + pub xtream_cluster: XtreamCluster, + #[serde(rename = "c")] + pub category_id: u32, + #[serde(rename = "s")] + pub rating: f64, +} + +/// Helper to pick the best logo: prefer `logo` if non-empty, else `logo_small` +fn pick_logo(logo: &Arc, logo_small: &Arc, props: Option<&StreamProperties>) -> Arc { + if !logo.is_empty() { + return Arc::clone(logo); + } + if !logo_small.is_empty() { + return Arc::clone(logo_small); + } + + if let Some(props) = props { + match props { + StreamProperties::Video(v) => { + if !v.stream_icon.is_empty() { + return Arc::clone(&v.stream_icon); + } + if let Some(details) = v.details.as_ref() { + if let Some(movie_image) = details.movie_image.as_ref() { + if !movie_image.is_empty() { + return Arc::clone(movie_image); + } + } + if let Some(cover_big) = details.cover_big.as_ref() { + if !cover_big.is_empty() { + return Arc::clone(cover_big); + } + } + if let Some(backdrop) = details.backdrop_path.as_ref() { + if let Some(first) = backdrop.first() { + if !first.is_empty() { + return Arc::clone(first); + } + } + } + } + } + StreamProperties::Series(s) => { + if !s.cover.is_empty() { + return Arc::clone(&s.cover); + } + if let Some(backdrop) = s.backdrop_path.as_ref() { + if let Some(first) = backdrop.first() { + if !first.is_empty() { + return Arc::clone(first); + } + } + } + } + StreamProperties::Live(_) => {} + StreamProperties::Episode(_) => {} + } + } + "".intern() +} + + +/// Helper to get rating +#[inline] +fn get_rating(props: Option<&StreamProperties>) -> f64 { + if let Some(p) = props { + return match p { + StreamProperties::Video(v) => v.rating.unwrap_or_default(), + StreamProperties::Series(s) => s.rating, + StreamProperties::Live(_) + | StreamProperties::Episode(_) => 0.0, + }; + } + 0.0 +} + +impl From<&CommonPlaylistItem> for UiPlaylistItem { + fn from(item: &CommonPlaylistItem) -> Self { + Self { + virtual_id: item.virtual_id, + provider_id: Arc::clone(&item.provider_id), + name: Arc::clone(&item.name), + title: Arc::clone(&item.title), + group: Arc::clone(&item.group), + logo: pick_logo(&item.logo, &item.logo_small, item.additional_properties.as_ref()), + url: Arc::clone(&item.url), + item_type: item.item_type, + xtream_cluster: item.xtream_cluster.unwrap_or_default(), + category_id: item.category_id.unwrap_or(0), + rating: get_rating(item.additional_properties.as_ref()), + } + } +} + +impl From for UiPlaylistItem { + fn from(item: XtreamPlaylistItem) -> Self { + Self { + virtual_id: item.virtual_id, + provider_id: item.provider_id.to_string().into(), + name: Arc::clone(&item.name), + title: Arc::clone(&item.title), + group: Arc::clone(&item.group), + logo: pick_logo(&item.logo, &item.logo_small, item.additional_properties.as_ref()), + url: Arc::clone(&item.url), + item_type: item.item_type, + xtream_cluster: item.xtream_cluster, + category_id: item.category_id, + rating: get_rating(item.additional_properties.as_ref()), + } + } +} + +impl From for UiPlaylistItem { + fn from(item: M3uPlaylistItem) -> Self { + Self { + virtual_id: item.virtual_id, + provider_id: Arc::clone(&item.provider_id), + name: Arc::clone(&item.name), + title: Arc::clone(&item.title), + group: Arc::clone(&item.group), + logo: pick_logo(&item.logo, &item.logo_small, None), + url: Arc::clone(&item.url), + item_type: item.item_type, + xtream_cluster: XtreamCluster::try_from(item.item_type).unwrap_or_default(), + category_id: 0, + rating: 0.0, + } + } +} + +impl From<&PlaylistItem> for UiPlaylistItem { + fn from(item: &PlaylistItem) -> Self { + let header = &item.header; + Self { + virtual_id: header.virtual_id, + provider_id: Arc::clone(&header.id), + name: Arc::clone(&header.name), + title: Arc::clone(&header.title), + group: Arc::clone(&header.group), + logo: pick_logo(&header.logo, &header.logo_small, header.additional_properties.as_ref()), + url: Arc::clone(&header.url), + item_type: header.item_type, + xtream_cluster: header.xtream_cluster, + category_id: header.category_id, + rating: get_rating(header.additional_properties.as_ref()), + } + } +} \ No newline at end of file diff --git a/shared/src/utils/request.rs b/shared/src/utils/request.rs index 1b0b2bea0..7268d9d41 100644 --- a/shared/src/utils/request.rs +++ b/shared/src/utils/request.rs @@ -4,6 +4,10 @@ use url::Url; use crate::utils::{CONSTANTS, DASH_EXT, DASH_EXT_FRAGMENT, DASH_EXT_QUERY, HLS_EXT, HLS_EXT_FRAGMENT, HLS_EXT_QUERY}; +pub const CONTENT_TYPE_JSON: &str = "application/json"; +pub const CONTENT_TYPE_CBOR: &str = "application/cbor"; +pub const ACCEPT_PREFER_CBOR: &str = "application/cbor, application/json;q=0.9"; + pub fn set_sanitize_sensitive_info(value: bool) { CONSTANTS.sanitize.store(value, Ordering::Relaxed); } diff --git a/shared/src/utils/string_interner.rs b/shared/src/utils/string_interner.rs index ec4bbc5a8..8eb0d2324 100644 --- a/shared/src/utils/string_interner.rs +++ b/shared/src/utils/string_interner.rs @@ -233,7 +233,6 @@ pub mod arc_str_option_serde { } } - pub fn arc_str_default_on_null<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>,