From a5f2b248b2d4294bccbf48e299cff0027a5492b3 Mon Sep 17 00:00:00 2001 From: euzu Date: Mon, 29 Sep 2025 15:39:27 +0200 Subject: [PATCH] Epg View optimization --- Cargo.lock | 18 ++++ backend/Cargo.toml | 1 + backend/src/api/api_utils.rs | 23 +++++ .../src/api/endpoints/api_playlist_utils.rs | 12 +-- .../api/endpoints/extract_accept_header.rs | 23 +++++ backend/src/api/endpoints/mod.rs | 5 +- backend/src/api/endpoints/v1_api_config.rs | 28 ++++--- backend/src/api/endpoints/v1_api_playlist.rs | 16 ++-- backend/src/api/endpoints/xmltv_api.rs | 11 +-- backend/src/model/config/api_proxy.rs | 2 +- backend/src/model/config/app.rs | 7 +- backend/src/model/xmltv.rs | 22 ++++- frontend/Cargo.toml | 1 + frontend/scss/_theme.scss | 19 ++++- .../app/components/playlist/_epg_view.scss | 21 +++-- .../src/app/components/playlist/epg_view.rs | 84 ++++++++++++------- .../components/playlist/source_selector.rs | 19 +++-- frontend/src/services/playlist_service.rs | 6 +- frontend/src/services/requests.rs | 67 ++++++++++----- shared/src/model/epg.rs | 2 + shared/src/model/playlist_request.rs | 2 +- 21 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 backend/src/api/endpoints/extract_accept_header.rs diff --git a/Cargo.lock b/Cargo.lock index 93b173e9d..2e6baf718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,7 @@ dependencies = [ "regex", "reqwasm", "serde", + "serde_cbor", "serde_json", "shared", "thiserror 2.0.14", @@ -1691,6 +1692,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "hashbrown" version = "0.14.5" @@ -3465,6 +3472,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.219" @@ -4148,6 +4165,7 @@ dependencies = [ "rustelebot", "ruzstd", "serde", + "serde_cbor", "serde_json", "serde_yaml", "shared", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ed263676f..3e3b5d428 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -11,6 +11,7 @@ shared = { version = "3", path = "../shared" } serde = { version = "1.0", features = ["derive", "rc"] } serde_yaml = "0.9" serde_json = { version = "1", features = ["arbitrary_precision"] } +serde_cbor = "0" quick-xml = "0.38" regex = "1.11" clap = { version = "4", features = ["derive"] } diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 51afc1c9e..4d03cde77 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -39,6 +39,7 @@ use std::io::BufWriter; use std::path::Path; use std::sync::Arc; use std::time::Duration; +use serde::Serialize; use tokio::sync::Mutex; use url::Url; @@ -1270,3 +1271,25 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> } false } + +pub fn cbor_response(data: &T) -> impl IntoResponse + Send { + match serde_cbor::to_vec(data) { + Ok(body) => ( + [(axum::http::header::CONTENT_TYPE, "application/cbor")], + body, + ) + .into_response(), + Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +pub fn json_response(data: &T) -> impl IntoResponse + Send { + (axum::http::StatusCode::OK, axum::Json(data)).into_response() +} + +pub fn json_or_cbor_response(accept: Option<&String>, data: &T) -> impl IntoResponse + Send { + if accept.as_deref().is_some_and(|a| a.contains("application/cbor")) { + return cbor_response(data).into_response(); + } + json_response(data).into_response() +} \ No newline at end of file diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index a6cb51c5d..e5abffcbc 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -9,7 +9,7 @@ use serde_json::{json}; use shared::model::{CommonPlaylistItem, InputType, M3uPlaylistItem, PlaylistCategoriesResponse, PlaylistGroup, PlaylistItemType, PlaylistResponseGroup, TargetType, XtreamCluster}; use std::sync::Arc; - +use crate::api::api_utils::{json_or_cbor_response}; fn group_playlist_items( cluster: XtreamCluster, @@ -126,7 +126,7 @@ async fn grouped_channels( )) } -pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig) -> impl axum::response::IntoResponse + Send { +pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig, accept: Option<&String>) -> impl axum::response::IntoResponse + Send { if let Some(target) = cfg_target { if target.has_output(&TargetType::Xtream) { let live_channels = grouped_channels(cfg, target, XtreamCluster::Live).await; @@ -139,7 +139,7 @@ pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Optio series: series_channels, }; - return (axum::http::StatusCode::OK, axum::Json(response)).into_response(); + return json_or_cbor_response(accept, &response).into_response(); } else if target.has_output(&TargetType::M3u) { let all_channels = m3u_repository::iter_raw_m3u_playlist(cfg, target).await; let (live_channels, vod_channels, series_channels) = group_playlist_items_by_cluster(all_channels); @@ -149,13 +149,13 @@ pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Optio series: Some(group_playlist_items::(XtreamCluster::Series, series_channels.into_iter(), |item| item.group.clone())), }; - return (axum::http::StatusCode::OK, axum::Json(response)).into_response(); + return json_or_cbor_response(accept, &response).into_response(); } } (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response() } -pub(in crate::api::endpoints) async fn get_playlist(client: Arc, cfg_input: Option<&ConfigInput>, cfg: &Config) -> impl IntoResponse + Send { +pub(in crate::api::endpoints) async fn get_playlist(client: Arc, cfg_input: Option<&ConfigInput>, cfg: &Config, accept: Option<&String>) -> impl IntoResponse + Send { match cfg_input { Some(input) => { let (result, errors) = @@ -173,7 +173,7 @@ pub(in crate::api::endpoints) async fn get_playlist(client: Arc vod: Some(vod), series: Some(series), }; - (axum::http::StatusCode::OK, axum::Json(response)).into_response() + json_or_cbor_response(accept, &response).into_response() } } None => (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response(), diff --git a/backend/src/api/endpoints/extract_accept_header.rs b/backend/src/api/endpoints/extract_accept_header.rs new file mode 100644 index 000000000..4b3c6ce32 --- /dev/null +++ b/backend/src/api/endpoints/extract_accept_header.rs @@ -0,0 +1,23 @@ +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::StatusCode; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ExtractAcceptHeader(pub Option); + + +impl FromRequestParts for ExtractAcceptHeader +where + B: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &B) -> Result { + if let Some(accept_type) = parts.headers.get(axum::http::header::ACCEPT) { + if let Ok(val) = accept_type.to_str() { + return Ok(ExtractAcceptHeader(Some(val.to_string()))); + } + } + Ok(ExtractAcceptHeader(None)) + } +} diff --git a/backend/src/api/endpoints/mod.rs b/backend/src/api/endpoints/mod.rs index d1fde0a58..ef3640e38 100644 --- a/backend/src/api/endpoints/mod.rs +++ b/backend/src/api/endpoints/mod.rs @@ -8,7 +8,8 @@ pub(in crate::api) mod hls_api; mod user_api; pub(in crate::api) mod hdhomerun_api; mod api_playlist_utils; -pub (in crate::api) mod websocket_api; +pub(in crate::api) mod websocket_api; mod v1_api_playlist; mod v1_api_user; -mod v1_api_config; \ No newline at end of file +mod v1_api_config; +mod extract_accept_header; \ No newline at end of file diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 5489a06ce..12f493c84 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -1,5 +1,5 @@ use crate::api::model::AppState; -use crate::model::{InputSource}; +use crate::model::{ApiProxyConfig, InputSource}; use axum::response::IntoResponse; use axum::Router; use serde_json::json; @@ -62,18 +62,20 @@ async fn save_config_api_proxy_config( } } - // TODO if hot reload is on, loaded twice - if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() { - let mut api_proxy = (*old_api_proxy).clone(); - api_proxy.server = req_api_proxy.iter().map(Into::into).collect(); - let new_api_proxy = Arc::new(api_proxy); - app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); - let config = app_state.app_config.config.load(); - let backup_dir = config.get_backup_dir(); - let paths = app_state.app_config.paths.load(); - if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) { - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); - } + // TODO if hot reload is on, it is loaded twice, avoid this + let mut api_proxy = if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() { + (*old_api_proxy).clone() + } else { + ApiProxyConfig::default() + }; + api_proxy.server = req_api_proxy.iter().map(Into::into).collect(); + let new_api_proxy = Arc::new(api_proxy); + app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); + let config = app_state.app_config.config.load(); + let backup_dir = config.get_backup_dir(); + let paths = app_state.app_config.paths.load(); + if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } axum::http::StatusCode::OK.into_response() } diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index 5a52dfee7..2ac88d05d 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -11,6 +11,8 @@ use crate::auth::create_access_token; use crate::model::{parse_xmltv_for_web_ui, ConfigInput, ConfigInputOptions}; use crate::processing::processor::playlist; use url::Url; +use crate::api::api_utils::{json_or_cbor_response}; +use crate::api::endpoints::extract_accept_header::ExtractAcceptHeader; fn create_config_input_for_m3u(url: &str) -> ConfigInput { ConfigInput { @@ -61,7 +63,7 @@ async fn playlist_update( let app_config = Arc::clone(&app_state.app_config); let event_manager = Arc::clone(&app_state.event_manager); tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager))); - axum::http::StatusCode::OK.into_response() + axum::http::StatusCode::ACCEPTED.into_response() } Err(err) => { error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str())); @@ -72,6 +74,7 @@ async fn playlist_update( async fn playlist_content( + ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_req): axum::extract::Json, ) -> impl IntoResponse + Send { @@ -79,14 +82,14 @@ async fn playlist_content( match playlist_req.rtype { PlaylistRequestType::Input => { if let Some(source_id) = playlist_req.source_id { - get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config, accept.as_ref()).await.into_response() } else { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response() } } PlaylistRequestType::Target => { if let Some(source_id) = playlist_req.source_id { - get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response() + get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config, accept.as_ref()).await.into_response() } else { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response() } @@ -96,7 +99,7 @@ async fn playlist_content( match Url::parse(url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = create_config_input_for_xtream(username, password, url); - get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config, accept.as_ref()).await.into_response() } _ => { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response() @@ -111,7 +114,7 @@ async fn playlist_content( match Url::parse(url) { Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => { let input = create_config_input_for_m3u(url); - get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response() + get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config, accept.as_ref()).await.into_response() } _ => { (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response() @@ -137,6 +140,7 @@ async fn playlist_webplayer( } async fn playlist_epg( + ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(playlist_epg_req): axum::extract::Json, ) -> impl IntoResponse + Send { @@ -144,7 +148,7 @@ async fn playlist_epg( let config = &app_state.app_config.config.load(); if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) { if let Ok(epg) = parse_xmltv_for_web_ui(&epg_path) { - return (axum::http::StatusCode::OK, axum::Json(epg)).into_response(); + return json_or_cbor_response(accept.as_ref(), &epg).into_response(); } } } diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 8a3f1ca15..b23b9951e 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -28,7 +28,6 @@ pub fn get_empty_epg_response() -> impl axum::response::IntoResponse + Send { } fn time_correct(date_time: &str, correction: &TimeDelta) -> String { - // Split the dateTime string into date and time parts let date_time_split: Vec<&str> = date_time.split(' ').collect(); if date_time_split.len() != 2 { return date_time.to_string(); @@ -87,7 +86,7 @@ pub (in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigT None } -// `-2:30`(-2h30m), `1:45` (1h45m), `+0:15` (15m), `2` (2h), `:30` (30m), `:3` (3m), `2:` (3h) +// `-2:30`(-2h30m), `1:45` (1h45m), `+0:15` (15m), `2` (2h), `:30` (30m), `:3` (3m), `2:` (2h) fn parse_timeshift(time_shift: Option<&String>) -> Option { time_shift.and_then(|offset| { let sign_factor = if offset.starts_with('-') { -1 } else { 1 }; @@ -187,7 +186,7 @@ fn serve_epg_with_timeshift( Ok(compressed_data) => try_unwrap_body!(axum::response::Response::builder() .header( axum::http::header::CONTENT_TYPE, - mime::APPLICATION_OCTET_STREAM.to_string() + mime::TEXT_XML.to_string() ) .header(axum::http::header::CONTENT_ENCODING, "gzip") // Set Content-Encoding header .body(axum::body::Body::from(compressed_data))), @@ -273,10 +272,4 @@ mod tests { assert_eq!(parse_timeshift(Some(&String::new())), None); assert_eq!(parse_timeshift(None), None); } - - #[test] - fn test_deserialize_() { - let reader = BufReader::new(File::open(Path::new("/home/euzuner/Schreibtisch/pl.json")).unwrap()); - let cat: PlaylistCategoriesResponse = serde_json::from_reader(reader).unwrap(); - } } diff --git a/backend/src/model/config/api_proxy.rs b/backend/src/model/config/api_proxy.rs index 89e0849e5..d28865d63 100644 --- a/backend/src/model/config/api_proxy.rs +++ b/backend/src/model/config/api_proxy.rs @@ -68,7 +68,7 @@ impl ApiProxyServerInfo { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ApiProxyConfig { pub server: Vec, pub user: Vec, diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index f030b87de..f411fd08c 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -162,12 +162,7 @@ impl AppConfig { pub fn get_inputs_for_target(&self, target_name: &str) -> Option>> { let sources = > as Access>::load(&self.sources); - for source in &sources.sources { - if let Some(cfg) = source.get_inputs_for_target(target_name) { - return Some(cfg); - } - } - None + sources.sources.iter().find_map(|s| s.get_inputs_for_target(target_name)) } pub fn get_target_for_username(&self, username: &str) -> Option<(ProxyUserCredentials, Arc)> { diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index a6331a2f7..b56c02b17 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -67,7 +67,9 @@ impl XmlTag { for (k, v) in attribs { elem.push_attribute((k.as_str(), v.as_str())); } } writer.write_event(Event::Start(elem))?; - self.value.as_ref().map(|text| writer.write_event(Event::Text(BytesText::new(text.as_str())))); + if let Some(text) = self.value.as_ref() { + writer.write_event(Event::Text(BytesText::new(text.as_str())))?; + } if let Some(children) = &self.children { for child in children { child.write_to(writer)?; @@ -233,6 +235,24 @@ pub fn parse_xmltv_for_web_ui(path: &Path) -> Result { } } } + EPG_TAG_ICON => { + if let Some(channel) = &mut current_channel { + if channel.icon.is_none() { + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"src" => { + if let Some(icon) = get_attr_value(&attr) { + if !icon.is_empty() { + channel.icon = Some(icon); + } + } + } + _ => {} + } + } + } + } + } _ => {} } } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index c8a7fdd67..38be0d09c 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -12,6 +12,7 @@ yew-hooks = "0.3" yew-i18n = "0.1" serde_json = "1" serde = {version = "1.0", features = ["derive", "rc"] } +serde_cbor = "0" wasm-bindgen = {version = "0.2", features = ["serde-serialize"]} anyhow = "1" thiserror = "2" diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 39cb1652e..04c622171 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -54,6 +54,10 @@ --margin-mini: 0.3rem; --margin-small: 0.5rem; --margin-default: 0.75rem; + + --epg-row-height: 3rem; + --epg-header-height: 1.5rem; + --epg-channels-width: 160px; } } @@ -296,12 +300,12 @@ --toolbar-bg-color: #161b25; - --epg-program-background-color: #57317a; //#31597a; + --epg-program-background-color: #1a222b; // #57317a; //#31597a; --epg-program-color: #ffffff; - --epg-program-active-background-color: #65a181; + --epg-program-active-background-color: #263140; --epg-program-active-color: #ffffff; - --epg-program-time-color: #acbcdd; - --epg-program-active-time-color: #000000; + --epg-program-time-color: #707f99; + --epg-program-active-time-color: #778295; } body[data-theme="bright"] { @@ -545,4 +549,11 @@ body[data-theme="bright"] { --code-null-literal-color: #ef4444; --toolbar-bg-color: #ffffff; + + --epg-program-background-color: #e5e7eb; + --epg-program-color: #111827; + --epg-program-active-background-color: #3b82f6; + --epg-program-active-color: #ffffff; + --epg-program-time-color: #6b7280; + --epg-program-active-time-color: #ffffff; } \ No newline at end of file diff --git a/frontend/scss/app/components/playlist/_epg_view.scss b/frontend/scss/app/components/playlist/_epg_view.scss index 8fc54294a..7687227bd 100644 --- a/frontend/scss/app/components/playlist/_epg_view.scss +++ b/frontend/scss/app/components/playlist/_epg_view.scss @@ -1,3 +1,4 @@ +$epg-border-size: 4px; .tp__epg { display: flex; flex-flow: column; @@ -17,6 +18,7 @@ overflow-x: auto; overflow-y: auto; position: relative; + min-height: 0; } &__channels-header, @@ -36,7 +38,7 @@ flex-flow: column; box-sizing: border-box; border-left: 1px solid var(--text-color); - padding-left: 4px; + padding-left: $epg-border-size; background-color: var(--background-color); &-time, &-date { @@ -51,12 +53,11 @@ &__channels { display: flex; flex-flow: column; - //gap: var(--gap-default); position: sticky; left: 0; z-index: 12; background-color: var(--background-color); - border-right: 4px solid var(--background-color); + border-right: $epg-border-size solid var(--background-color); height: fit-content; } @@ -70,7 +71,6 @@ &__programs { display: flex; flex-flow: column; - //gap: var(--gap-default); min-width: 100%; height: fit-content; position: relative; @@ -79,11 +79,19 @@ &__channel { display: flex; flex-flow: row; + gap: var(--gap-small); align-items: center; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; - border-bottom: 1px solid var(--border-color); + background-color: var(--card-background-color); + border-bottom: $epg-border-size solid var(--background-color); + border-top: $epg-border-size solid var(--background-color); + &-icon { + img { + width: 2.5rem; + } + } } &__channel-programs { @@ -145,8 +153,9 @@ top: 0; bottom: 0; height: 100%; - width: 2px; /* line thickness */ + width: 2px; background-color: red; z-index: 11; /* above programs */ + pointer-events: none; } } \ No newline at end of file diff --git a/frontend/src/app/components/playlist/epg_view.rs b/frontend/src/app/components/playlist/epg_view.rs index 4db0a469c..16932a3e9 100644 --- a/frontend/src/app/components/playlist/epg_view.rs +++ b/frontend/src/app/components/playlist/epg_view.rs @@ -12,7 +12,7 @@ use crate::model::{BusyStatus, EventMessage, ExplorerSourceType}; const TIME_BLOCK_WIDTH: f64 = 210.0; const TIME_BLOCK_MINS: i64 = 30; -const PIXEL_PER_MIN:f64 = TIME_BLOCK_WIDTH as f64 / TIME_BLOCK_MINS as f64; +const PIXEL_PER_MIN:f64 = TIME_BLOCK_WIDTH / TIME_BLOCK_MINS as f64; fn get_pos(secs: i64, start_mins: i64) -> i64 { let mins = secs / 60; @@ -56,26 +56,28 @@ pub fn EpgView() -> Html { // TODO Active elements color change after time update let epg_tv_clone = epg_tv.clone(); - let calculate_position = Rc::new(move |epg_tv: &UseStateHandle>| { + let calculate_position = Rc::new(move |epg_tv: &UseStateHandle>, recenter: bool| { if let Some(tv) = &**epg_tv { if let (Some(div), Some(now_line)) = (container_ref.cast::(), now_line_ref.cast::()) { let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60); let start_window = (start_window_secs / 60).max(0); let now = Utc::now().timestamp(); let now_line_pos = get_pos(now, start_window); - let container_width = div.client_width(); - let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0); - div.set_scroll_left(scroll_pos); now_line.style().set_property("left", &format!("{now_line_pos}px")).unwrap(); + if recenter { + let container_width = div.client_width(); + let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0); + div.set_scroll_left(scroll_pos); + } } } }); let calculate_pos = calculate_position.clone(); let interval = Interval::new(60_000, move || { - calculate_pos(&epg_tv_clone); + calculate_pos(&epg_tv_clone, false); }); - calculate_position(epg_tv); + calculate_position(epg_tv, true); || drop(interval) }); } @@ -111,6 +113,15 @@ pub fn EpgView() -> Html { { for tv.channels.iter().map(|ch| { html! {
+
+ { + if let Some(icon) = &ch.icon { + html! { {ch.title.clone()} } + } else { + html! {} + } + } +
{ &ch.title }
@@ -122,16 +133,23 @@ pub fn EpgView() -> Html {
{ for (0..num_blocks).map(|i| { - let block_start = start_window + i as i64 * TIME_BLOCK_MINS; - let start_time_utc = Utc.timestamp_opt(block_start * 60, 0).unwrap(); - let start_time_local = start_time_utc.with_timezone(&Local); - let hour_min = start_time_local.format("%H:%M").to_string(); - let day_month = format!("{:02}.{:02}", start_time_local.day(), start_time_local.month()); - html! { -
-
{ hour_min }
-
{ day_month }
-
+ let block_start = start_window + i * TIME_BLOCK_MINS; + let block_secs = block_start.saturating_mul(60); + if let Some(start_time_utc) = Utc.timestamp_opt(block_secs, 0).single() { + let start_time_local = start_time_utc.with_timezone(&Local); + let hour_min = start_time_local.format("%H:%M").to_string(); + let day_month = format!("{:02}.{:02}", start_time_local.day(), start_time_local.month()); + html! { +
+
{ hour_min }
+
{ day_month }
+
+ } + } else { + html!{ +
+
+ } } }) }
@@ -144,21 +162,25 @@ pub fn EpgView() -> Html { let left = get_pos(p.start, start_window); let right = get_pos(p.stop, start_window); let width = right - left; + if let (Some(pstart_time), Some(pend_time)) = ( + Utc.timestamp_opt(p.start, 0).single(), + Utc.timestamp_opt(p.stop, 0).single()) { + let pstart_time_local = pstart_time.with_timezone(&Local); + let pend_time_local = pend_time.with_timezone(&Local); + let pstart = pstart_time_local.format("%H:%M").to_string(); + let pend = pend_time_local.format("%H:%M").to_string(); + let program_style = format!("left:{left}px; width:{width}px; min-width:{width}px; max-width:{width}px"); - let pstart_time = Utc.timestamp_opt(p.start, 0).unwrap(); - let pend_time = Utc.timestamp_opt(p.stop, 0).unwrap(); - let pstart_time_local = pstart_time.with_timezone(&Local); - let pend_time_local = pend_time.with_timezone(&Local); - let pstart = pstart_time_local.format("%H:%M").to_string(); - let pend = pend_time_local.format("%H:%M").to_string(); - let program_style = format!("left:{left}px; width:{width}px; min-width:{width}px; max-width:{width}px"); - - html! { -
-
{ &pstart } {"-"} { &pend }
-
{ &p.title }
-
- }})} + html! { +
+
{ &pstart } {"-"} { &pend }
+
{ &p.title }
+
+ } + } else { + html!{} + } + })}
} }) diff --git a/frontend/src/app/components/playlist/source_selector.rs b/frontend/src/app/components/playlist/source_selector.rs index e9ef12154..3e6e45080 100644 --- a/frontend/src/app/components/playlist/source_selector.rs +++ b/frontend/src/app/components/playlist/source_selector.rs @@ -107,18 +107,25 @@ pub fn PlaylistSourceSelector(props: &PlaylistSourceSelectorProps) -> Html { let url_ref = url_ref.clone(); Callback::from(move |_| { let is_xtream = matches!(*set_custom_provider, InputType::Xtream); - let url_input: HtmlInputElement = url_ref.cast::().unwrap(); - let url = url_input.value(); + let url = match url_ref.cast::() { + Some(input) => input.value(), + None => { + services.toastr.error(translate.t("MESSAGES.PLAYLIST_UPDATE.URL_MANDATORY")); + return; + } + }; + let mut valid = true; if url.is_empty() { services.toastr.error(translate.t("MESSAGES.PLAYLIST_UPDATE.URL_MANDATORY")); valid = false; } let (username, password) = if is_xtream { - let username_input: HtmlInputElement = u_ref.cast::().unwrap(); - let password_input: HtmlInputElement = p_ref.cast::().unwrap(); - let username = username_input.value().trim().to_owned(); - let password = password_input.value(); + let (username, password) = match (u_ref.cast::(), p_ref.cast::()) { + (Some(u), Some(p)) => (u.value().trim().to_owned(), p.value()), + _ => (String::new(), String::new()) + }; + if username.is_empty() || password.is_empty() { services.toastr.error(translate.t("MESSAGES.PLAYLIST_UPDATE.USERNAME_PASSWORD_MANDATORY")); valid = false; diff --git a/frontend/src/services/playlist_service.rs b/frontend/src/services/playlist_service.rs index 3f6f6d226..9e5b92f42 100644 --- a/frontend/src/services/playlist_service.rs +++ b/frontend/src/services/playlist_service.rs @@ -1,4 +1,4 @@ -use crate::services::{get_base_href, request_post}; +use crate::services::{get_base_href, request_post, ACCEPT_PREFER_CBOR}; use log::error; use shared::model::{CommonPlaylistItem, EpgTv, PlaylistCategoriesResponse, PlaylistEpgRequest, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest}; use std::rc::Rc; @@ -34,7 +34,7 @@ impl PlaylistService { } pub async fn get_playlist_categories(&self, playlist_request: &PlaylistRequest) -> Option> { - request_post::<&PlaylistRequest, PlaylistCategoriesResponse>(&self.playlist_api_path, playlist_request, None, None).await.map_or_else(|err| { + request_post::<&PlaylistRequest, PlaylistCategoriesResponse>(&self.playlist_api_path, playlist_request, None, Some(ACCEPT_PREFER_CBOR.to_string())).await.map_or_else(|err| { error!("{err}"); None }, |response| Some(Rc::new(response.into()))) @@ -59,7 +59,7 @@ impl PlaylistService { let request = PlaylistEpgRequest { target_id, }; - match request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, None).await { + match request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, Some(ACCEPT_PREFER_CBOR.to_string())).await { Ok(content) => Some(content), Err(err) => { error!("{err}"); diff --git a/frontend/src/services/requests.rs b/frontend/src/services/requests.rs index 4c82ae74a..6db318c10 100644 --- a/frontend/src/services/requests.rs +++ b/frontend/src/services/requests.rs @@ -1,21 +1,23 @@ use crate::error::{Error, ErrorInfo, ErrorSetInfo}; use gloo_storage::{LocalStorage, Storage}; -use log::error; +use log::{error}; use reqwasm::http::Request; use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; use web_sys::window; +const CONTENT_TYPE_CBOR: &str = "application/cbor"; +const CONTENT_TYPE_JSON: &str = "application/json"; +pub const ACCEPT_PREFER_CBOR: &str = "application/cbor, application/json;q=0.9"; + enum RequestMethod { Get, Post, Put, - // PATCH, + // Patch, Delete, } -//const API_ROOT: &str = "/api/v1"; - const TOKEN_KEY: &str = "tuliprox.token"; pub fn get_token() -> Option { LocalStorage::get(TOKEN_KEY).ok() @@ -30,39 +32,62 @@ pub fn set_token(token: Option<&str>) { } /// build all kinds of http request: post/get/delete etc. -async fn request(method: RequestMethod, url: &str, body: B, content_type: Option, response_type: Option) -> Result +async fn request(method: RequestMethod, url: &str, body: B, content_type: Option, + response_type: Option) -> Result where T: DeserializeOwned + 'static + std::fmt::Debug, B: Serialize + std::fmt::Debug, { - let c_type = content_type.as_ref().map_or("application/json", |c| c.as_str()); - let r_type = response_type.as_ref().map_or("application/json", |c| c.as_str()); + let c_type = content_type.as_ref().map_or(CONTENT_TYPE_JSON, |c| c.as_str()); + let r_type = response_type.as_ref().map_or(CONTENT_TYPE_JSON, |c| c.as_str()); let mut request = match method { RequestMethod::Get => Request::get(url), - RequestMethod::Post => Request::post(url).body(serde_json::to_string(&body).unwrap()), - RequestMethod::Put => Request::put(url).body(serde_json::to_string(&body).unwrap()), + RequestMethod::Post => Request::post(url).body(serde_json::to_string(&body).unwrap()).header("Content-Type", c_type), + RequestMethod::Put => Request::put(url).body(serde_json::to_string(&body).unwrap()).header("Content-Type", c_type), // RequestMethod::PATCH => Request::patch(&url).body(serde_json::to_string(&body).unwrap()), RequestMethod::Delete => Request::delete(url), - }.header("Content-Type", c_type); + }; if let Some(token) = get_token() { request = request.header("Authorization", format!("Bearer {token}").as_str()); } + + if r_type.contains(CONTENT_TYPE_CBOR) { + request = request.header("Accept", CONTENT_TYPE_CBOR); + } match request.send().await { Ok(response) => { match response.status() { 200 => { - if r_type.eq("application/json") { + let content_type = response + .headers() + .get("content-type") + .unwrap_or(r_type.to_string()); + let is_json = content_type.contains(CONTENT_TYPE_JSON); + let is_cbor = !is_json && content_type.contains(CONTENT_TYPE_CBOR); + if is_json || is_cbor { if std::any::TypeId::of::() == std::any::TypeId::of::<()>() { // `T = ()` valid - let _ = response.text().await; - serde_json::from_str("null").map_err(|_| Error::DeserializeError) - } else { - let data: Result = response.json::().await; - if let Ok(data) = data { - Ok(data) - } else { - Err(Error::DeserializeError) + let _ = response.binary().await; + return serde_json::from_str("null").map_err(|_| Error::DeserializeError); + } + } + + if is_cbor { + match response.binary().await { + Ok(bytes) => { + match serde_cbor::from_slice::(&bytes) { + Ok(data) => Ok(data), + Err(_) => Err(Error::DeserializeError), + } } + Err(_) => Err(Error::DeserializeError), + } + } else if is_json { + let data: Result = response.json::().await; + if let Ok(data) = data { + Ok(data) + } else { + Err(Error::DeserializeError) } } else { match response.text().await { @@ -76,14 +101,14 @@ where } } } - 400 => { + 400 => { let data: Result = response.json::().await; if let Ok(data) = data { Err(Error::BadRequest(data.error)) } else { Err(Error::BadRequest("400".to_string())) } - }, + } 401 => Err(Error::Unauthorized), 403 => Err(Error::Forbidden), 404 => Err(Error::NotFound), diff --git a/shared/src/model/epg.rs b/shared/src/model/epg.rs index d4a3bfe4b..14e439fca 100644 --- a/shared/src/model/epg.rs +++ b/shared/src/model/epg.rs @@ -31,6 +31,7 @@ impl PartialEq for EpgTv { pub struct EpgChannel { pub id: String, pub title: String, + pub icon: Option, pub programmes: Vec, } @@ -39,6 +40,7 @@ impl EpgChannel { Self { id, title: String::new(), + icon: None, programmes: Vec::new(), } } diff --git a/shared/src/model/playlist_request.rs b/shared/src/model/playlist_request.rs index 279546451..0acc144f1 100644 --- a/shared/src/model/playlist_request.rs +++ b/shared/src/model/playlist_request.rs @@ -183,7 +183,7 @@ impl UiPlaylistCategories { } } -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] pub struct PlaylistEpgRequest { pub target_id: u16, }