diff --git a/CHANGELOG.md b/CHANGELOG.md index d028d849e..cf637d4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,12 +138,30 @@ - **Web UI Landing Page**: Added `landing_page` setting to `web_ui` config to choose the initial view after login. Supported values: `dashboard`, `stats`, `streams`, `stream_history`, `downloads`, `users`, `config`, `source_editor`, `playlist_update`, `playlist_settings`, `playlist_explorer`, `playlist_epg`, `rbac`. +- **Stream Display Visibility Controls**: Added optional `web_ui.stream_info` config to hide selected fields in the + active stream display. + - Supported flags: + - `hide_group` + - `hide_ip` + - `hide_country` + - `hide_shared` + - `hide_duration` + - `hide_bandwidth` + - `hide_transferred` + - `hide_player` + - `hide_user_comment` + - `hide_epg` + - If all flags are `false`, the config is treated as absent and the default view remains unchanged. + - Hidden `epg` also suppresses the per-stream EPG fetch/display work in the dashboard. - **Runtime Config Report**: Added opt-in startup dump of the complete effective runtime configuration. - `log.runtime_config_report_enabled` (default `false`) — enables the report. - `log.runtime_config_report_format` — `yaml` (default) or `json`. - Sensitive values (passwords, secrets, tokens, API keys) are automatically redacted. - Includes prepared `config.yml`, `source.yml`, loaded mappings/templates/api-proxy sections, and resolved paths. - **CVD-Friendly Theme**: Web UI now includes a CVD (color vision deficiency) friendly theme option. +- **Stream View**: + - Displays the user comment in the stream view. + - Displays EPG information in the stream view. ## 🐛 Fixes @@ -174,6 +192,17 @@ served. - **config.yml (`web_ui`)**: - Added `landing_page` (optional, default `dashboard`): initial view after login. + - Added optional `stream_info` block to hide specific fields in the active stream display: + - `hide_group` + - `hide_ip` + - `hide_country` + - `hide_shared` + - `hide_duration` + - `hide_bandwidth` + - `hide_transferred` + - `hide_player` + - `hide_user_comment` + - `hide_epg` - **config.yml (`log`)**: - Added `runtime_config_report_enabled` (bool, default `false`): enables full runtime config dump at startup. - Added `runtime_config_report_format` (`yaml` | `json`, default `yaml`): output format for the runtime config report. diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 02414ff4e..9a5b8e938 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -873,11 +873,11 @@ pub(in crate::api) async fn resolve_admission_with_strategies( /// /// Rules: /// - Only `grace_context.strategies[(strategy_index + 1)..]` are evaluated -/// - `NoMatch` → continue to next strategy -/// - `Evict` → kick target, retry admission -/// - `Grace` → technically possible under current config (only one grace allowed), but handled -/// - `Deny` → final exhausted -/// - Empty remaining slice → final exhausted +/// - `NoMatch` -> continue to next strategy +/// - `Evict` -> kick target, retry admission +/// - `Grace` -> technically possible under current config (only one grace allowed), but handled +/// - `Deny` -> final exhausted +/// - Empty remaining slice -> final exhausted #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub(in crate::api) async fn evaluate_remaining_strategies_after_grace( app_state: &Arc, @@ -1183,7 +1183,7 @@ async fn activate_session_before_stream_open( .await; } else if matches!(grace_mode, Some(crate::api::model::GraceMode::Instant)) { // Instant: session is provisionally active immediately. Counts against admission limits - // until the grace window resolves (success → Active, failure → Expired). + // until the grace window resolves (success -> Active, failure -> Expired). app_state .active_users .mark_grace_active(&user.username, session_token) @@ -3734,6 +3734,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, } } @@ -3752,6 +3753,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, } } @@ -4793,7 +4795,7 @@ mod tests { async fn evaluate_remaining_strategies_evicts_after_used_grace() { // Strategies: [GraceHoldStream, EvictUserOldest] // Grace was used at index 0, so only EvictUserOldest (index 1) is evaluated. - // Eviction frees the slot → Allowed. + // Eviction frees the slot -> Allowed. let strategies = vec![ AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest, @@ -4893,8 +4895,8 @@ mod tests { async fn evaluate_remaining_strategies_skips_no_match_and_uses_later_eviction() { // Strategies: [GraceHoldStream, EvictUserSameIpOldest, EvictUserOldest] // Grace was at index 0, remaining are EvictUserSameIpOldest (index 1) and EvictUserOldest (index 2). - // The existing counted session is at a DIFFERENT IP, so EvictUserSameIpOldest → NoMatch. - // EvictUserOldest succeeds → Allowed. + // The existing counted session is at a DIFFERENT IP, so EvictUserSameIpOldest -> NoMatch. + // EvictUserOldest succeeds -> Allowed. let strategies = vec![ AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserSameIpOldest, @@ -4994,7 +4996,7 @@ mod tests { #[tokio::test] async fn evaluate_remaining_strategies_empty_slice_denies() { // Strategies: [GraceHoldStream] - // Grace was at index 0, remaining slice is empty → exhausted. + // Grace was at index 0, remaining slice is empty -> exhausted. let strategies = vec![AdmissionStrategy::GraceHoldStream]; let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: None }; @@ -5042,7 +5044,7 @@ mod tests { #[tokio::test] async fn evaluate_remaining_strategies_preserves_soft_kind_on_exhausted() { // Strategies: [GraceHoldStream] - // Grace was at index 0, remaining slice is empty → exhausted. + // Grace was at index 0, remaining slice is empty -> exhausted. // grace_context.kind is Soft — must be preserved in the exhausted result. let strategies = vec![AdmissionStrategy::GraceHoldStream]; let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: Some(crate::api::model::ConnectionKind::Soft) }; @@ -5195,7 +5197,7 @@ mod tests { #[tokio::test] async fn evaluate_remaining_strategies_empty_slice_uses_original_kind_not_context_kind() { // grace_context.kind = Normal, original_kind = Soft - // remaining slice is empty → exhausted result must use original_kind. + // remaining slice is empty -> exhausted result must use original_kind. // This proves the empty-slice branch uses original_kind, not grace_context.kind. let strategies = vec![AdmissionStrategy::GraceHoldStream]; let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: Some(crate::api::model::ConnectionKind::Normal) }; diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 5110a9f95..c2671ba62 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -188,6 +188,7 @@ mod tests { category_id: 0, rating: 0.0, input_name: "test".intern(), + epg_channel_id: None, } } diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index b349c340f..aaaccf34f 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -362,6 +362,7 @@ async fn resolve_stream_channel( shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }, }; diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index 0a6ccc695..dbb068220 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -4,11 +4,11 @@ use crate::{api::{ endpoints::{ api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}, extract_accept_header::ExtractAcceptHeader, - xmltv_api::{rewrite_epg_channel_resource_url, serve_epg_web_ui}, + xmltv_api::{rewrite_epg_channel_resource_url, serve_epg_web_ui, stream_epg_api}, xtream_api::xtream_get_stream_info_response, }, model::AppState, -}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id, utils::{epg::get_input_raw_epg_file_path, file_exists_async}}; +}, auth::{create_access_token, permission_layer}, model::{parse_xmltv_for_web_ui_from_file, parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id, utils::{epg::get_input_raw_epg_file_path, file_exists_async}}; use axum::{response::IntoResponse, Router}; use log::{debug, error}; use serde_json::json; @@ -546,6 +546,7 @@ pub fn v1_api_playlist_register_protected(router: Router>) -> axum .route("/playlist/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/playlist/update", axum::routing::post(playlist_update)) .route("/playlist/epg", axum::routing::post(playlist_epg)) + .route("/playlist/epg/stream", axum::routing::post(stream_epg_api)) .route("/playlist/live", axum::routing::post(playlist_content_live)) .route("/playlist/vod", axum::routing::post(playlist_content_vod)) .route("/playlist/series", axum::routing::post(playlist_content_series)) @@ -578,6 +579,7 @@ pub fn v1_api_playlist_register_with_permissions( let epg_routes = Router::new() .route("/epg", axum::routing::post(playlist_epg)) + .route("/epg/stream", axum::routing::post(stream_epg_api)) .layer(permission_layer!(app_state, Permission::EpgRead)); router.nest("/playlist", @@ -1382,4 +1384,5 @@ mod tests { assert!(body_text.contains("Second Show"), "{body_text}"); assert!(!body_text.contains("Secondary Show"), "{body_text}"); } + } diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 3c42f9019..8709e43af 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -1,15 +1,16 @@ use crate::{ api::{ api_utils::{ - empty_json_response_as_array, get_user_target, get_user_target_by_credentials, internal_server_error, - resource_response, stream_json_or_bin_response_stream, try_option_forbidden, try_unwrap_body, + create_api_proxy_user, empty_json_response_as_array, get_user_target, get_user_target_by_credentials, + internal_server_error, resource_response, stream_json_or_bin_response_stream, + try_option_forbidden, try_unwrap_body, }, model::{AppState, UserApiRequestQueryOrBody, UserApiRequest}, }, model::{Config, ConfigTarget, ProxyUserCredentials, TargetOutput, EPG_ATTRIB_ID, EPG_TAG_CHANNEL}, repository::{ get_target_storage_path, m3u_get_epg_file_path_for_target, storage_const, xtream_get_epg_file_path_for_target, - xtream_get_storage_path, BPlusTreeQuery, LockedReceiverStream, XML_PREAMBLE, + xtream_get_storage_path, BPlusTreeQuery, epg_query_channels, LockedReceiverStream, XML_PREAMBLE, }, utils, utils::{ @@ -23,10 +24,14 @@ use log::{debug, error, trace}; use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; use shared::{ concat_string, - model::{EpgChannel, EpgProgramme, ShortEpgDto, ShortEpgResultDto}, + model::{ + EpgChannel, EpgProgramme, EpgProgrammeDto, ShortEpgDto, ShortEpgResultDto, StreamEpgEntry, StreamEpgItemRequest, + StreamEpgRequest, StreamEpgResponse, + }, utils::{concat_path, concat_path_leading_slash, obfuscate_text, Internable}, }; use std::{ + collections::HashSet, path::{Path, PathBuf}, sync::Arc, }; @@ -366,29 +371,44 @@ fn get_applied_epg_timeshift( programme: &EpgProgramme, epg_processing_options: &EpgProcessingOptions, ) -> (String, String, i64, i64) { + let (start_ts, stop_ts) = get_applied_epg_timestamps(programme, epg_processing_options); + let (start_str, stop_str) = format_epg_timeshift_strings(programme, epg_processing_options, start_ts, stop_ts); + (start_str, stop_str, start_ts, stop_ts) +} + +fn get_applied_epg_timestamps( + programme: &EpgProgramme, + epg_processing_options: &EpgProcessingOptions, +) -> (i64, i64) { match &epg_processing_options.time_shift { - EpgTimeShift::None => { - (format_xmltv_time(programme.start), format_xmltv_time(programme.stop), programme.start, programme.stop) - } + EpgTimeShift::None | EpgTimeShift::TimeZone(_) => (programme.start, programme.stop), EpgTimeShift::Fixed(m) => { let off = i64::from(*m) * 60; - let s = programme.start + off; - let e = programme.stop + off; - (format_xmltv_time(s), format_xmltv_time(e), s, e) + (programme.start + off, programme.stop + off) } + } +} + +fn format_epg_timeshift_strings( + programme: &EpgProgramme, + epg_processing_options: &EpgProcessingOptions, + start_ts: i64, + stop_ts: i64, +) -> (String, String) { + match &epg_processing_options.time_shift { EpgTimeShift::TimeZone(tz) => { - let s_dt = chrono::Utc.timestamp_opt(programme.start, 0).unwrap().with_timezone(tz); - let e_dt = chrono::Utc.timestamp_opt(programme.stop, 0).unwrap().with_timezone(tz); - // We use the original timestamps (programme.start/stop) here because TimeZone adjustment - // is only for the visual string representation. The absolute event time (UTC) remains unchanged. - // Unlike 'Fixed' offset which artificially shifts the event time. - ( - s_dt.format("%Y-%m-%d %H:%M:%S").to_string(), - e_dt.format("%Y-%m-%d %H:%M:%S").to_string(), - programme.start, - programme.stop, - ) + match ( + chrono::Utc.timestamp_opt(programme.start, 0).single(), + chrono::Utc.timestamp_opt(programme.stop, 0).single(), + ) { + (Some(s_dt), Some(e_dt)) => ( + s_dt.with_timezone(tz).format("%Y-%m-%d %H:%M:%S").to_string(), + e_dt.with_timezone(tz).format("%Y-%m-%d %H:%M:%S").to_string(), + ), + _ => (format_xmltv_time(start_ts), format_xmltv_time(stop_ts)), + } } + EpgTimeShift::None | EpgTimeShift::Fixed(_) => (format_xmltv_time(start_ts), format_xmltv_time(stop_ts)), } } @@ -466,6 +486,176 @@ pub async fn serve_short_epg( } } +/// Serves per-stream EPG data for the UI "now playing" / "up next" display. +/// Queries epg.db by `epg_channel_id`, filters to 8h window, applies user timeshift. +/// Returns empty entries for all error/missing cases (no errors surfaced to client). +async fn serve_stream_epg( + app_state: &Arc, + user: &ProxyUserCredentials, + target: &Arc, + epg_path: &Path, + items: Vec, +) -> StreamEpgResponse { + let epg_processing_options = get_epg_processing_options(app_state, user, target); + let now = chrono::Utc::now().timestamp(); + let window_end = now + 8 * 3600; + + // Deduplicate by first occurrence, preserving order + let unique_ids: Vec<&str> = { + let mut seen = HashSet::new(); + items + .iter() + .filter_map(|item| { + let epg_channel_id = item.epg_channel_id.as_str(); + seen.insert(epg_channel_id).then_some(epg_channel_id) + }) + .collect() + }; + let query_ids = unique_ids + .iter() + .map(|id| (*id).intern()) + .collect::>>(); + + let channels = match epg_query_channels( + &app_state.app_config.file_locks, + epg_path, + query_ids, + ) + .await + { + Ok(results) => results, + Err(err) => { + error!("{err}"); + unique_ids.into_iter().map(|id| (id.intern(), None)).collect() + } + }; + + let mut entries = Vec::with_capacity(channels.len()); + + for (epg_channel_id, channel) in channels { + let programmes = match channel { + Some(ch) => stream_epg_programmes_for_channel(&ch.programmes, &epg_processing_options, now, window_end), + None => Vec::new(), + }; + + entries.push(StreamEpgEntry { + epg_channel_id: epg_channel_id.to_string(), + target_id: Some(target.id), + programmes, + }); + } + + StreamEpgResponse { entries } +} + +fn stream_epg_programmes_for_channel( + programmes: &[EpgProgramme], + epg_processing_options: &EpgProcessingOptions, + now: i64, + window_end: i64, +) -> Vec { + programmes + .iter() + .filter_map(|programme| { + let (start_ts, stop_ts) = get_applied_epg_timestamps(programme, epg_processing_options); + (stop_ts > now && start_ts <= window_end).then(|| { + let (start_str, stop_str) = + format_epg_timeshift_strings(programme, epg_processing_options, start_ts, stop_ts); + EpgProgrammeDto { + start: start_str, + stop: stop_str, + start_timestamp: start_ts, + stop_timestamp: stop_ts, + title: programme.title.as_ref().map_or_else(String::new, ToString::to_string), + } + }) + }) + .collect() +} + +fn group_stream_epg_items( + items: Vec, +) -> Result)>, &'static str> { + if items.is_empty() { + return Err("items must not be empty"); + } + + let mut groups = Vec::<(u16, Vec)>::new(); + for item in items { + let Some(target_id) = item.target_id else { + return Err("all items must include target_id"); + }; + + if let Some((_, group_items)) = groups.iter_mut().find(|(group_target_id, _)| *group_target_id == target_id) { + group_items.push(item); + } else { + groups.push((target_id, vec![item])); + } + } + + Ok(groups) +} + +fn empty_stream_epg_entries(target_id: u16, items: &[StreamEpgItemRequest]) -> Vec { + let mut seen = HashSet::new(); + items + .iter() + .filter_map(|item| { + let epg_channel_id = item.epg_channel_id.as_str(); + seen.insert(epg_channel_id).then_some(epg_channel_id) + }) + .map(|epg_channel_id| StreamEpgEntry { + epg_channel_id: epg_channel_id.to_string(), + target_id: Some(target_id), + programmes: Vec::new(), + }) + .collect() +} + +fn stream_epg_bad_request(message: &str) -> axum::response::Response { + (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": message }))).into_response() +} + +/// Handles stream EPG API requests for per-stream programme display. +pub(crate) async fn stream_epg_api( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(stream_epg_req): axum::extract::Json, +) -> impl IntoResponse + Send { + let grouped_items = match group_stream_epg_items(stream_epg_req.items) { + Ok(groups) => groups, + Err(message) => return stream_epg_bad_request(message), + }; + + let config = app_state.app_config.config.load_full(); + let user = create_api_proxy_user(&app_state); + let mut entries = Vec::new(); + + for (target_id, items) in grouped_items { + let Some(target) = app_state.app_config.get_target_by_id(target_id) else { + return stream_epg_bad_request(&format!("unknown target_id: {target_id}")); + }; + + let Some(epg_path) = get_epg_path_for_target(config.as_ref(), &target) else { + entries.extend(empty_stream_epg_entries(target_id, &items)); + continue; + }; + + let result = serve_stream_epg(&app_state, &user, &target, &epg_path, items).await; + entries.extend(result.entries); + } + + let result = StreamEpgResponse { entries }; + + match serde_json::to_string(&result) { + Ok(json) => ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string())], + json, + ).into_response(), + Err(_) => internal_server_error!(), + } +} + /// Handles XMLTV EPG API requests, serving the appropriate EPG file with optional time-shifting based on user configuration. /// /// Returns a 403 Forbidden response if the user or target is invalid or if the user lacks permission. If no EPG file is configured for the target, returns an empty EPG response. Otherwise, serves the EPG file, applying a time shift if specified by the user. @@ -563,12 +753,57 @@ pub fn xmltv_api_register() -> axum::Router> { #[cfg(test)] mod tests { - use super::rewrite_epg_channel_resource_url; + use super::{group_stream_epg_items, rewrite_epg_channel_resource_url, stream_epg_programmes_for_channel}; + use crate::utils::{EpgProcessingOptions, EpgTimeShift}; use shared::{ - model::EpgChannel, + model::{EpgChannel, EpgProgramme, StreamEpgItemRequest}, utils::{concat_path, obfuscate_text, Internable}, }; + #[test] + fn test_group_stream_epg_items_rejects_empty_requests() { + let err = group_stream_epg_items(Vec::new()).unwrap_err(); + assert_eq!(err, "items must not be empty"); + } + + #[test] + fn test_group_stream_epg_items_rejects_missing_target() { + let err = group_stream_epg_items(vec![StreamEpgItemRequest { + epg_channel_id: "epg-1".to_string(), + target_id: None, + }]) + .unwrap_err(); + assert_eq!(err, "all items must include target_id"); + } + + #[test] + fn test_group_stream_epg_items_groups_by_target_in_input_order() { + let groups = group_stream_epg_items(vec![ + StreamEpgItemRequest { + epg_channel_id: "epg-1".to_string(), + target_id: Some(2), + }, + StreamEpgItemRequest { + epg_channel_id: "epg-2".to_string(), + target_id: Some(5), + }, + StreamEpgItemRequest { + epg_channel_id: "epg-3".to_string(), + target_id: Some(2), + }, + ]) + .unwrap(); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].0, 2); + assert_eq!( + groups[0].1.iter().map(|item| item.epg_channel_id.as_str()).collect::>(), + vec!["epg-1", "epg-3"] + ); + assert_eq!(groups[1].0, 5); + assert_eq!(groups[1].1.iter().map(|item| item.epg_channel_id.as_str()).collect::>(), vec!["epg-2"]); + } + fn sample_channel(icon: Option<&str>) -> EpgChannel { EpgChannel { id: "channel-1".intern(), @@ -602,4 +837,36 @@ mod tests { assert_eq!(rewritten.icon.as_deref(), Some("/api/v1/library/thumbnail/test")); } + + #[test] + fn stream_epg_programmes_for_channel_filters_using_shifted_fixed_times() { + let now = 10_000; + let window_end = now + 8 * 3600; + let epg_processing_options = EpgProcessingOptions { + rewrite_urls: false, + time_shift: EpgTimeShift::Fixed(120), + encrypt_secret: [0; 16], + }; + let programmes = vec![ + EpgProgramme::new_all(now - 7_300, now - 100, "channel-1".intern(), Some("Shifted Into Window".intern()), None), + EpgProgramme::new_all(now + 60, now + 600, "channel-1".intern(), Some("Already In Window".intern()), None), + EpgProgramme::new_all( + window_end + 60, + window_end + 600, + "channel-1".intern(), + Some("Still Outside Window".intern()), + None, + ), + ]; + + let filtered = stream_epg_programmes_for_channel(&programmes, &epg_processing_options, now, window_end); + + assert_eq!( + filtered.iter().map(|programme| programme.title.as_str()).collect::>(), + vec!["Shifted Into Window", "Already In Window"] + ); + assert_eq!(filtered[0].start_timestamp, now - 100); + assert_eq!(filtered[0].stop_timestamp, now + 7_100); + } + } diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 5ff2cadbb..b6bb5c548 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -21,7 +21,7 @@ use crate::{ UserApiRequestQueryOrBody, XtreamAuthorizationResponse, }, }, - auth::Fingerprint, + auth::{verify_access_token, Fingerprint}, model::{ xtream_mapping_option_from_target_options, Config, ConfigInput, ConfigInputFlags, ConfigTarget, InputSource, ProxyUserCredentials, @@ -145,7 +145,7 @@ impl<'a> ApiStreamRequest<'a> { stream_id: &'a str, action_path: &'a str, ) -> Self { - Self { context, access_token: false, username: "", password, stream_id, action_path } + Self { context, access_token: true, username: "", password, stream_id, action_path } } } @@ -593,6 +593,12 @@ async fn xtream_player_api_stream_with_token( target_id: u16, stream_req: ApiStreamRequest<'_>, ) -> impl IntoResponse + Send { + if stream_req.access_token + && !verify_access_token(stream_req.password, &app_state.app_config.access_token_secret) + { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + if let Some(target) = app_state.app_config.get_target_by_id(target_id) { let target_name = &target.name; if !target.has_output(TargetType::Xtream) { diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index d8e3fd498..f91ca4e57 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -106,7 +106,7 @@ pub enum PlaybackLifecycle { PendingProvider { data: PendingProviderState }, Active, /// Provisional counted state for `GraceMode::Instant`. Counts against limits immediately - /// while the grace window resolves (success → Active, failure → Expired). + /// while the grace window resolves (success -> Active, failure -> Expired). GraceActive, Preserved, Expired, @@ -2093,8 +2093,8 @@ impl ActiveUserManager { /// /// This corresponds to `Prepared -> GraceActive` in the playback state machine. /// The session remains in `GraceActive` until either: - /// - `activate_grace_active` confirms it (grace window succeeded → `GraceActive -> Active`) - /// - `expire_grace_active` expires it (grace window failed → `GraceActive -> Expired`) + /// - `activate_grace_active` confirms it (grace window succeeded -> `GraceActive -> Active`) + /// - `expire_grace_active` expires it (grace window failed -> `GraceActive -> Expired`) pub async fn mark_grace_active(&self, username: &str, token: &str) { let mut user_connections = self.connections.write().await; let Some(connection_data) = user_connections.by_key.get_mut(username) else { @@ -2500,13 +2500,55 @@ impl ActiveUserManager { pub async fn socket_expiry_deadline(&self, addr: &SocketAddr) -> Option { let ttl_secs = self.active_socket_ttl_secs(); - self.connections.read().await.key_by_addr.get(addr).and_then(|registration| { + { + let connections = self.connections.read().await; + let registration = connections.key_by_addr.get(addr)?; if registration.username.is_empty() { - None - } else { - Some(registration.ts.saturating_add(ttl_secs)) + return None; } - }) + + let has_non_stable_stream = connections + .by_key + .get(®istration.username) + .is_some_and(|connection_data| { + connection_data + .streams + .iter() + .any(|stream| stream.addr == *addr && !is_stable_session_stream(stream)) + }); + + if !has_non_stable_stream { + return Some(registration.ts.saturating_add(ttl_secs)); + } + } + + let mut connections = self.connections.write().await; + let (username_is_empty, has_non_stable_stream) = { + let registration = connections.key_by_addr.get(addr)?; + let username = registration.username.as_str(); + let username_is_empty = username.is_empty(); + let has_non_stable_stream = !username_is_empty + && connections + .by_key + .get(username) + .is_some_and(|connection_data| { + connection_data + .streams + .iter() + .any(|stream| stream.addr == *addr && !is_stable_session_stream(stream)) + }); + (username_is_empty, has_non_stable_stream) + }; + if username_is_empty { + return None; + } + + let registration = connections.key_by_addr.get_mut(addr)?; + if has_non_stable_stream { + registration.ts = current_time_secs(); + } + + Some(registration.ts.saturating_add(ttl_secs)) } pub async fn touch_http_activity(&self, username: &str, token: &str, addr: &SocketAddr) { @@ -3155,6 +3197,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, } } @@ -3173,6 +3216,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, } } @@ -5654,9 +5698,41 @@ mod tests { let fresh_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap(); let stale_fp = Fingerprint::new("fp-stale".to_string(), "127.0.0.1".to_string(), stale_addr); let fresh_fp = Fingerprint::new("fp-fresh".to_string(), "127.0.0.1".to_string(), fresh_addr); + let mut stale_user = ProxyUserCredentials::default(); + stale_user.username = "user1".to_string(); + stale_user.max_connections = 1; + let mut fresh_user = ProxyUserCredentials::default(); + fresh_user.username = "user2".to_string(); + fresh_user.max_connections = 1; manager.add_connection(&stale_addr).await; manager.add_connection(&fresh_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &stale_user, + session_token: "tok-stale-deadline", + virtual_id: 9201, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &stale_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &fresh_user, + session_token: "tok-fresh-deadline", + virtual_id: 9202, + provider: "provider-b", + stream_url: "http://localhost/live.m3u8", + addr: &fresh_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; manager .update_connection(ActiveUserConnectionParams { uid: 201, @@ -5669,9 +5745,9 @@ mod tests { soft_priority: 0, fingerprint: &stale_fp, provider: "provider-a".intern(), - stream_channel: &test_channel(9201), + stream_channel: &test_adaptive_channel(9201), user_agent: Cow::Borrowed("ua"), - session_token: None, + session_token: Some("tok-stale-deadline"), }) .await .expect("stale stream should register"); @@ -5687,9 +5763,9 @@ mod tests { soft_priority: 0, fingerprint: &fresh_fp, provider: "provider-b".intern(), - stream_channel: &test_channel(9202), + stream_channel: &test_adaptive_channel(9202), user_agent: Cow::Borrowed("ua"), - session_token: None, + session_token: Some("tok-fresh-deadline"), }) .await .expect("fresh stream should register"); @@ -5836,6 +5912,83 @@ mod tests { assert_eq!(stream.addr, addr2, "touch_http_activity should update the stream addr"); } + #[tokio::test] + async fn socket_expiry_deadline_refreshes_active_vod_streams() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55040".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-vod".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-vod-expiry".to_string(); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-vod-expiry", + virtual_id: 8888, + provider: "provider-a", + stream_url: "http://localhost/movie.mkv", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let mut channel = test_channel(8888); + channel.item_type = PlaylistItemType::Video; + channel.cluster = XtreamCluster::Video; + channel.url = "http://localhost/movie.mkv".intern(); + + manager + .update_connection(ActiveUserConnectionParams { + uid: 602, + meter_uid: 702, + username: "user-vod-expiry", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-vod-expiry"), + }) + .await + .expect("vod stream should be created"); + + let previous_registration_ts = { + let mut connections = manager.connections.write().await; + let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); + registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); + registration.ts + }; + + let deadline = manager + .socket_expiry_deadline(&addr) + .await + .expect("continuous VOD streams should stay scheduled for expiry tracking"); + + let refreshed_registration_ts = { + let connections = manager.connections.read().await; + connections.key_by_addr.get(&addr).expect("registration should still exist").ts + }; + + assert!(refreshed_registration_ts > previous_registration_ts); + assert_eq!( + deadline, + refreshed_registration_ts.saturating_add(manager.active_socket_ttl_secs()), + "continuous VOD streams should refresh their socket expiry deadline" + ); + } + #[tokio::test] async fn update_session_addr_prunes_previous_registration_for_socket_bound_session() { let config = Config::default(); @@ -6898,6 +7051,7 @@ mod tests { cluster: XtreamCluster::Live, group: "g".intern(), title: "t".intern(), url: "http://localhost/stream.ts".intern(), shared: false, shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }, "ua".to_string(), None, Some("tok-orphan"), ); diff --git a/backend/src/api/model/connection_manager.rs b/backend/src/api/model/connection_manager.rs index 85e1b89a8..dead2df6f 100644 --- a/backend/src/api/model/connection_manager.rs +++ b/backend/src/api/model/connection_manager.rs @@ -1213,6 +1213,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }; StreamInfo::new(0, 0, "test", &addr, "127.0.0.1", provider.intern(), channel, String::new(), None, None) } diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index 5e59469ae..6d674b7de 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -1484,6 +1484,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, } } @@ -2161,7 +2162,7 @@ mod tests { /// - `grace_kind = Some(Soft)` (passed directly from `create_active_client_stream`) /// - `grace_resolution_context` pointing to [`GraceHoldStream`] (remaining slice is empty) /// - user-grace failure (deadline expires, user still at connection limit) - /// - remaining strategies exhausted → `expire_pending_provider` is called + /// - remaining strategies exhausted -> `expire_pending_provider` is called #[tokio::test(start_paused = true)] #[allow(clippy::too_many_lines)] async fn test_user_grace_failure_preserves_soft_kind_on_exhausted() { @@ -2267,7 +2268,7 @@ mod tests { // tok-second -> GracePeriod, PendingProvider (NOT counted, grace session) // // At grace deadline: user_connections = 2 (tok-first + tok-preload), max = 1. - // 2 > 1 → user_ok = false → user-grace failure path entered. + // 2 > 1 -> user_ok = false -> user-grace failure path entered. let preload_addr: std::net::SocketAddr = "127.0.0.1:55203".parse().unwrap_or_else(|_| unreachable!()); let preload_fingerprint = create_test_fingerprint(preload_addr); diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 49f02f733..5704315ed 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -1125,6 +1125,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }), connect_failure_stage: Some(FailureStage::ProviderOpen), }); diff --git a/backend/src/auth/access_token.rs b/backend/src/auth/access_token.rs index af241a7a4..15905bbc5 100644 --- a/backend/src/auth/access_token.rs +++ b/backend/src/auth/access_token.rs @@ -16,46 +16,63 @@ pub fn create_access_token(secret: &[u8; 32], ttl_secs: u16) -> String { let timestamp = Utc::now().timestamp(); let timestamp_bytes = timestamp.to_le_bytes(); let ttl_secs_bytes = ttl_secs.to_le_bytes(); - let hash = blake3::keyed_hash(secret, ×tamp_bytes); + let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_secs_bytes.len()); + payload.extend_from_slice(×tamp_bytes); + payload.extend_from_slice(&ttl_secs_bytes); + let hash = blake3::keyed_hash(secret, &payload); let signature = hex_encode(hash.as_bytes()); format!("{}{}{signature}", hex_encode(×tamp_bytes), hex_encode(&ttl_secs_bytes)) } pub fn verify_access_token(token_str: &str, secret: &[u8; 32]) -> bool { - if token_str.len() < 52 { + const TOKEN_LEN: usize = 84; + const TIMESTAMP_END: usize = 16; + const TTL_END: usize = 20; + + if token_str.len() != TOKEN_LEN { + return false; + } + if !token_str.is_ascii() || !token_str.bytes().all(|byte| byte.is_ascii_hexdigit()) { return false; } - let timestamp_bytes = hex_decode(&token_str[0..16]).unwrap_or_default(); + let timestamp_bytes = hex_decode(&token_str[..TIMESTAMP_END]).unwrap_or_default(); if timestamp_bytes.len() != 8 { return false; } - let timestamp = i64::from_le_bytes(timestamp_bytes.try_into().unwrap_or([0; 8])); + let timestamp = i64::from_le_bytes(timestamp_bytes.as_slice().try_into().unwrap_or([0; 8])); if timestamp == 0 { return false; } - let ttl_bytes = hex_decode(&token_str[16..20]).unwrap_or_default(); + let ttl_bytes = hex_decode(&token_str[TIMESTAMP_END..TTL_END]).unwrap_or_default(); if ttl_bytes.len() != 2 { return false; } - let ttl_secs = u16::from_le_bytes(ttl_bytes.try_into().unwrap_or([0; 2])); - let signature = hex_decode(&token_str[20..]).unwrap_or_default(); + let ttl_secs = u16::from_le_bytes(ttl_bytes.as_slice().try_into().unwrap_or([0; 2])); + let signature = hex_decode(&token_str[TTL_END..]).unwrap_or_default(); + + let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_bytes.len()); + payload.extend_from_slice(×tamp_bytes); + payload.extend_from_slice(&ttl_bytes); + let expected = blake3::keyed_hash(secret, &payload); + if !constant_time_eq(expected.as_bytes(), &signature) { + return false; + } let current_timestamp = Utc::now().timestamp(); if current_timestamp - timestamp > i64::from(ttl_secs) { return false; } - - let expected = blake3::keyed_hash(secret, ×tamp.to_le_bytes()); - constant_time_eq(expected.as_bytes(), &signature) + true } #[cfg(test)] mod tests { use crate::auth::access_token::{create_access_token, verify_access_token}; + use std::panic::catch_unwind; use std::thread; #[test] @@ -66,4 +83,26 @@ mod tests { thread::sleep(std::time::Duration::from_secs(2)); assert!(!verify_access_token(token.as_str(), secret)); } -} \ No newline at end of file + + #[test] + fn test_ttl_tampering_invalidates_token() { + let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; + let token = create_access_token(secret, 1); + + let mut tampered = token.clone(); + tampered.replace_range(16..20, "ffff"); + + assert!(!verify_access_token(tampered.as_str(), secret)); + } + + #[test] + fn test_verify_access_token_rejects_non_ascii_without_panicking() { + let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; + let invalid = "é".repeat(84); + + let result = catch_unwind(|| verify_access_token(&invalid, secret)); + + assert!(result.is_ok(), "verification should not panic on non-ascii input"); + assert!(!result.unwrap_or(true)); + } +} diff --git a/backend/src/model/config/api_user.rs b/backend/src/model/config/api_user.rs index bc8ef55bb..f90b84ddd 100644 --- a/backend/src/model/config/api_user.rs +++ b/backend/src/model/config/api_user.rs @@ -48,7 +48,7 @@ impl From<&ProxyUserCredentialsDto> for ProxyUserCredentials { exp_date: dto.exp_date, max_connections: dto.max_connections, status: dto.status, - output_clusters: dto.output_clusters, + output_clusters: dto.output_clusters.unwrap_or_else(ClusterFlags::all), ui_enabled: dto.ui_enabled, comment: dto.comment.clone(), priority: dto.priority, @@ -73,7 +73,7 @@ impl From<&ProxyUserCredentials> for ProxyUserCredentialsDto { exp_date: instance.exp_date, max_connections: instance.max_connections, status: instance.status, - output_clusters: instance.output_clusters, + output_clusters: if instance.output_clusters.is_all() { None } else { Some(instance.output_clusters) }, ui_enabled: instance.ui_enabled, comment: instance.comment.clone(), priority: instance.priority, diff --git a/backend/src/model/config/web_ui.rs b/backend/src/model/config/web_ui.rs index c1b88e63f..4dc157dc3 100644 --- a/backend/src/model/config/web_ui.rs +++ b/backend/src/model/config/web_ui.rs @@ -1,8 +1,65 @@ -use shared::model::view_type::ViewType; -use shared::error::TuliproxError; -use shared::model::{ContentSecurityPolicyConfigDto, WebUiConfigDto}; -use shared::utils::default_kick_secs; use crate::model::{macros, WebAuthConfig}; +use shared::error::TuliproxError; +use shared::model::view_type::ViewType; +use shared::model::{ContentSecurityPolicyConfigDto, StreamInfoConfigDto, StreamInfoFields, StreamInfoFieldsSet, WebUiConfigDto}; +use shared::utils::default_kick_secs; + +#[derive(Debug, Clone)] +pub struct StreamInfoConfig { + pub flags: StreamInfoFieldsSet, +} + +impl StreamInfoConfig { + pub fn is_none(&self) -> bool { + self.flags.is_empty() + } + + #[inline] + pub fn hide_group(&self) -> bool { + self.flags.contains(StreamInfoFields::HideGroup) + } + + #[inline] + pub fn hide_ip(&self) -> bool { + self.flags.contains(StreamInfoFields::HideIp) + } + + #[inline] + pub fn hide_country(&self) -> bool { + self.flags.contains(StreamInfoFields::HideCountry) + } + + #[inline] + pub fn hide_shared(&self) -> bool { + self.flags.contains(StreamInfoFields::HideShared) + } + + #[inline] + pub fn hide_duration(&self) -> bool { + self.flags.contains(StreamInfoFields::HideDuration) + } + + #[inline] + pub fn hide_bandwidth(&self) -> bool { + self.flags.contains(StreamInfoFields::HideBandwidth) + } + + #[inline] + pub fn hide_transferred(&self) -> bool { self.flags.contains(StreamInfoFields::HideTransferred) } + + #[inline] + pub fn hide_player(&self) -> bool { + self.flags.contains(StreamInfoFields::HidePlayer) + } + + #[inline] + pub fn hide_user_comment(&self) -> bool { self.flags.contains(StreamInfoFields::HideUserComment) } + + #[inline] + pub fn hide_epg(&self) -> bool { + self.flags.contains(StreamInfoFields::HideEpg) + } +} #[derive(Debug, Clone)] pub struct ContentSecurityPolicyConfig { @@ -21,6 +78,7 @@ pub struct WebUiConfig { pub kick_secs: u64, pub combine_views_stats_streams: bool, pub landing_page: ViewType, + pub stream_info: Option, } impl WebUiConfig { @@ -59,6 +117,32 @@ impl From<&ContentSecurityPolicyConfig> for ContentSecurityPolicyConfigDto { } } +macros::from_impl!(StreamInfoConfig); + +impl From<&StreamInfoConfigDto> for StreamInfoConfig { + fn from(dto: &StreamInfoConfigDto) -> Self { + let flags = dto.get_flags(); + Self { flags } + } +} + +impl From<&StreamInfoConfig> for StreamInfoConfigDto { + fn from(cfg: &StreamInfoConfig) -> Self { + Self { + hide_group: cfg.hide_group(), + hide_ip: cfg.hide_ip(), + hide_country: cfg.hide_country(), + hide_shared: cfg.hide_shared(), + hide_duration: cfg.hide_duration(), + hide_bandwidth: cfg.hide_bandwidth(), + hide_transferred: cfg.hide_transferred(), + hide_player: cfg.hide_player(), + hide_user_comment: cfg.hide_user_comment(), + hide_epg: cfg.hide_epg(), + } + } +} + macros::from_impl!(WebUiConfig); impl From<&WebUiConfigDto> for WebUiConfig { fn from(dto: &WebUiConfigDto) -> Self { @@ -80,11 +164,19 @@ impl From<&WebUiConfigDto> for WebUiConfig { kick_secs: dto.kick_secs, combine_views_stats_streams: dto.combine_views_stats_streams, landing_page: dto.landing_page, + stream_info: dto.stream_info.as_ref().map(Into::into), } } } impl From<&WebUiConfig> for WebUiConfigDto { fn from(instance: &WebUiConfig) -> Self { + let stream_info = instance.stream_info.as_ref().and_then(|cfg| { + if cfg.is_none() { + None + } else { + Some(cfg.into()) + } + }); Self { enabled: instance.enabled, user_ui_enabled: instance.user_ui_enabled, @@ -95,6 +187,7 @@ impl From<&WebUiConfig> for WebUiConfigDto { kick_secs: instance.kick_secs, combine_views_stats_streams: instance.combine_views_stats_streams, landing_page: instance.landing_page, + stream_info, } } } diff --git a/backend/src/repository/bplustree_migration.rs b/backend/src/repository/bplustree_migration.rs index 2ea749f12..7b62e9106 100644 --- a/backend/src/repository/bplustree_migration.rs +++ b/backend/src/repository/bplustree_migration.rs @@ -704,8 +704,8 @@ pub struct AllStartupMigrationStats { } /// Runs all startup migrations in sequence: -/// 1. B+Tree storage-format migration (V1 → current binary format) -/// 2. User DB schema migration (V1/V2/V3/V4 → V5 `MessagePack` layout) +/// 1. B+Tree storage-format migration (V1 -> current binary format) +/// 2. User DB schema migration (V1/V2/V3/V4 -> V5 `MessagePack` layout) /// /// `config_dir` is the directory that contains `api_user.db` and the merge-guard /// marker. `storage_dir` is used for the B+Tree migration marker. diff --git a/backend/src/repository/epg_repository.rs b/backend/src/repository/epg_repository.rs index 891afb7ae..3e8d3e745 100644 --- a/backend/src/repository/epg_repository.rs +++ b/backend/src/repository/epg_repository.rs @@ -1,13 +1,14 @@ use crate::model::{Config, ConfigTarget, TargetOutput}; use crate::model::{Epg}; use crate::repository::{m3u_get_epg_file_path_for_target, BPlusTree}; -use crate::repository::{xtream_get_epg_file_path_for_target, xtream_get_storage_path}; -use crate::utils::{debug_if_enabled}; +use crate::repository::{xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTreeQuery}; +use crate::utils::{debug_if_enabled, FileLockManager}; use shared::error::{ TuliproxError}; use shared::model::{EpgChannel, PlaylistGroup}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use tokio::task; pub const XML_PREAMBLE: &str = r#" @@ -121,3 +122,76 @@ pub async fn epg_write_for_target(cfg: &Config, target: &ConfigTarget, target_pa } Ok(()) } + +/// Queries EPG channels by their ids. Encapsulates lock acquisition, `BPlusTree` open, and batch lookup. +/// Returns channels in the same order as `query_ids`, with None for ids not found in the DB. +pub async fn epg_query_channels( + file_locks: &FileLockManager, + epg_path: &Path, + query_ids: Vec>, +) -> Result, Option)>, TuliproxError> { + let file_lock = file_locks.read_lock(epg_path).await; + let epg_path = epg_path.to_path_buf(); + + task::spawn_blocking(move || { + let _guard = file_lock; + let mut query = BPlusTreeQuery::, EpgChannel>::try_new(&epg_path) + .map_err(|e| TuliproxError::RepositoryEpg(format!("failed to open epg db {}: {e}", epg_path.display())))?; + + let mut results = Vec::with_capacity(query_ids.len()); + for channel_id in &query_ids { + let channel = query.query(channel_id) + .map_err(|e| TuliproxError::RepositoryEpg(format!("failed to query epg db {}: {e}", epg_path.display())))?; + results.push((Arc::clone(channel_id), channel)); + } + Ok(results) + }) + .await + .map_err(|e| TuliproxError::RepositoryEpg(format!("epg query task panicked: {e}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::BPlusTree; + use crate::utils::FileLockManager; + use shared::model::EpgChannel; + use shared::utils::Internable; + use tempfile::TempDir; + + #[tokio::test] + async fn epg_query_channels_returns_found_channels_in_order() { + let tmp = TempDir::new().expect("temp dir created"); + let path = tmp.path().join("epg.db"); + + // Write two channels directly via BPlusTree + let mut tree = BPlusTree::, EpgChannel>::new(); + tree.insert("ch1".intern(), EpgChannel { + id: "ch1".intern(), + title: Some("Channel 1".intern()), + icon: None, + programmes: Vec::new(), + }); + tree.insert("ch2".intern(), EpgChannel { + id: "ch2".intern(), + title: Some("Channel 2".intern()), + icon: None, + programmes: Vec::new(), + }); + tree.store(&path).expect("store epg"); + + let file_locks = FileLockManager::new(); + let query_ids = vec!["ch1".intern(), "ch2".intern(), "ch3".intern()]; + + let results = epg_query_channels(&file_locks, &path, query_ids).await + .expect("epg_query_channels succeeds"); + + assert_eq!(results.len(), 3); + assert_eq!(results[0].0.as_ref(), "ch1"); + assert!(results[0].1.is_some()); + assert_eq!(results[1].0.as_ref(), "ch2"); + assert!(results[1].1.is_some()); + assert_eq!(results[2].0.as_ref(), "ch3"); + assert!(results[2].1.is_none()); + } +} diff --git a/backend/src/repository/m3u_repository.rs b/backend/src/repository/m3u_repository.rs index c3611bd42..e58df773d 100644 --- a/backend/src/repository/m3u_repository.rs +++ b/backend/src/repository/m3u_repository.rs @@ -56,12 +56,10 @@ pub fn m3u_get_file_path_for_db(target_path: &Path) -> PathBuf { } pub fn m3u_get_epg_file_path_for_target(target_path: &Path) -> PathBuf { - let path = target_path.join(storage_const::PATH_M3U).join(concat_string!( - storage_const::FILE_M3U, - ".", + target_path.join(storage_const::PATH_M3U).join(concat_string!( + "epg.", storage_const::FILE_SUFFIX_DB - )); - utils::add_prefix_to_filename(&path, "epg_", Some(storage_const::FILE_SUFFIX_DB)) + )) } pub fn m3u_get_storage_path(cfg: &Config, target_name: &str) -> Option { diff --git a/backend/src/repository/stream_history/storage.rs b/backend/src/repository/stream_history/storage.rs index 2025e0723..1a1b4db7b 100644 --- a/backend/src/repository/stream_history/storage.rs +++ b/backend/src/repository/stream_history/storage.rs @@ -394,6 +394,7 @@ mod tests { audio_codec: "AAC".to_string(), audio_channels: "STEREO".to_string(), }), + epg_channel_id: None, }, String::from("VLC/3.0"), Some(String::from("DE")), diff --git a/backend/src/utils/epg_parser.rs b/backend/src/utils/epg_parser.rs index 7e8b45276..e40f3ea69 100644 --- a/backend/src/utils/epg_parser.rs +++ b/backend/src/utils/epg_parser.rs @@ -90,7 +90,9 @@ pub fn parse_xmltv_time(t: &str) -> Option { } pub fn format_xmltv_time_utc(ts: i64, time_shift: &EpgTimeShift) -> String { - let dt = Utc.timestamp_opt(ts, 0).unwrap(); + let Some(dt) = Utc.timestamp_opt(ts, 0).single() else { + return String::new(); + }; match time_shift { EpgTimeShift::None => dt.format("%Y%m%d%H%M%S %z").to_string(), EpgTimeShift::Fixed(minutes) => { @@ -116,9 +118,9 @@ pub fn apply_timeshift(date_str: &str, shift: &EpgTimeShift) -> String { // List of supported formats let formats = [ + "%Y-%m-%d %H:%M", // 2026-02-08 11:30 "%Y-%m-%d:%H-%M", // 2026-02-08:11-30 "%Y-%m-%d:%H:%M", // 2026-02-08:11:30 - "%Y-%m-%d %H:%M", // 2026-02-08 11:30 "%Y-%m-%d-%H-%M", // 2026-02-08-11-30 ]; diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 26f0fe484..35bd82598 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -129,6 +129,17 @@ web_ui: kick_secs: 90 combine_views_stats_streams: false landing_page: dashboard + stream_info: + hide_group: false + hide_ip: false + hide_country: false + hide_shared: false + hide_duration: false + hide_bandwidth: false + hide_transferred: false + hide_player: false + hide_user_comment: false + hide_epg: false content_security_policy: enabled: true custom-attributes: @@ -145,17 +156,46 @@ web_ui: ### 2.1 Web UI Parameters -| Parameter | Type | Default | Technical Impact & Background | -|:------------------------------|:-------|:------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `enabled` | Bool | `true` | Completely toggles the Web Dashboard and its REST API endpoints on or off. | -| `user_ui_enabled` | Bool | `true` | Allows standard proxy users (not just admins) to log into the Web UI to manage their own favorites/bouquets. | -| `path` | String | `""` | Base path for the UI (e.g., `admin`). Critical for reverse proxy subfolder setups so assets load from `example.com/admin/assets/`. | -| `player_server` | String | `default` | Determines which virtual server block from `api-proxy.yml` is used to construct the streaming URLs when playing a channel directly within the Web UI player. | -| `kick_secs` | Int | `90` | **Background:** When you kick a user via the Dashboard, they are not only disconnected but hard-blocked at the IP/User level for X seconds. This prevents their IPTV player's auto-reconnect logic from instantly stealing the provider slot back. | -| `combine_views_stats_streams` | Bool | `false` | Combines the "Server Stats" and "Active Streams" views into a single unified window in the UI. | -| `landing_page` | String | `dashboard` | Set the initial landing page for the webui. Possible values are `dashboard`, `stats`, `streams`, `stream_history`, `downloads`, `users`, `config`, `source_editor`, `playlist_update`, `playlist_settings`, `playlist_explorer`, `playlist_epg`, `rbac` | +| Parameter | Type | Default | Technical Impact & Background | +|:------------------------------|:-------|:------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | Bool | `true` | Completely toggles the Web Dashboard and its REST API endpoints on or off. | +| `user_ui_enabled` | Bool | `true` | Allows standard proxy users (not just admins) to log into the Web UI to manage their own favorites/bouquets. | +| `path` | String | `""` | Base path for the UI (e.g., `admin`). Critical for reverse proxy subfolder setups so assets load from `example.com/admin/assets/`. | +| `player_server` | String | `default` | Determines which virtual server block from `api-proxy.yml` is used to construct the streaming URLs when playing a channel directly within the Web UI player. | +| `kick_secs` | Int | `90` | **Background:** When you kick a user via the Dashboard, they are not only disconnected but hard-blocked at the IP/User level for X seconds. This prevents their IPTV player's auto-reconnect logic from instantly stealing the provider slot back. | +| `combine_views_stats_streams` | Bool | `false` | Combines the "Server Stats" and "Active Streams" views into a single unified window in the UI. | +| `landing_page` | String | `dashboard` | Set the initial landing page for the webui. Possible values are `dashboard`, `stats`, `streams`, `stream_history`, `downloads`, `users`, `config`, `source_editor`, `playlist_update`, `playlist_settings`, `playlist_explorer`, `playlist_epg`, `rbac` | +| `stream_info` | Object | `null` | Optional visibility controls for fields shown in the active stream display. If omitted, or if all nested `hide_*` flags are `false`, the default dashboard view is unchanged. | -### 2.2 Content Security Policy (`content_security_policy`) +### 2.2 Stream Display Visibility (`stream_info`) + +Use this optional block to hide selected fields in the active stream display shown in the dashboard. + +```yaml +web_ui: + stream_info: + hide_group: true + hide_ip: true + hide_epg: true +``` + +| Parameter | Type | Default | Technical Impact & Background | +|:----------------------|:-----|:--------|:---------------------------------------------------------------------------------------------------------------| +| `hide_group` | Bool | `false` | Hides the stream group/category label. | +| `hide_ip` | Bool | `false` | Hides the client IP shown for an active stream. | +| `hide_country` | Bool | `false` | Hides the detected client country flag/code. | +| `hide_shared` | Bool | `false` | Hides the shared-stream indicator. | +| `hide_duration` | Bool | `false` | Hides the running playback duration. | +| `hide_bandwidth` | Bool | `false` | Hides the live bandwidth badge when metrics are available. | +| `hide_transferred` | Bool | `false` | Hides the transferred-bytes badge when metrics are available. | +| `hide_player` | Bool | `false` | Hides the player / user-agent field. | +| `hide_user_comment` | Bool | `false` | Hides the user comment next to the active stream title. | +| `hide_epg` | Bool | `false` | Hides per-stream EPG information and skips the associated dashboard EPG fetch/update work for that display. | + +* If every `hide_*` flag is `false`, Tuliprox treats the whole `stream_info` block as empty/default. +* This setting is global for the Web UI. It is not currently per-user. + +### 2.3 Content Security Policy (`content_security_policy`) This block enhances security by restricting which resources the browser is allowed to load. @@ -166,7 +206,7 @@ This block enhances security by restricting which resources the browser is allow * **Customization:** Use `custom-attributes` to add specific rules (e.g., allowing external channel logos via `img-src`). -### 2.3 Authentication & RBAC (`auth`) +### 2.4 Authentication & RBAC (`auth`) Tuliprox features a robust Role-Based Access Control (RBAC) system. diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index ee9489473..03906b64e 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -739,7 +739,19 @@ "PATH": "Optional path prefix (e.g. /ui) for reverse proxy integration.", "PLAYER_SERVER": "Optional server override used specifically for the built-in web player.", "USER_UI_ENABLED": "Enables or disables the simplified bouquet editor for standard users.", - "COMBINE_VIEWS_STATS_STREAMS": "If set, the Stats and Streams views are combined into a single view." + "COMBINE_VIEWS_STATS_STREAMS": "If set, the Stats and Streams views are combined into a single view.", + "LANDING_PAGE": "The default view shown when loading the Web UI.", + "STREAM_INFO": "Controls which stream information fields are visible in the stream display.", + "HIDE_GROUP": "If enabled, the stream group is not shown.", + "HIDE_IP": "If enabled, client IP addresses are not displayed.", + "HIDE_COUNTRY": "If enabled, client country information is not shown.", + "HIDE_SHARED": "If enabled, the shared stream indicator is hidden.", + "HIDE_DURATION": "If enabled, stream duration is not displayed.", + "HIDE_BANDWIDTH": "If enabled, per-stream bandwidth metrics are hidden.", + "HIDE_TRANSFERRED": "If enabled, total bytes transferred is not shown.", + "HIDE_PLAYER": "If enabled, the player/user-agent field is hidden.", + "HIDE_USER_COMMENT": "If enabled, user comments on streams are not displayed.", + "HIDE_EPG": "If enabled, electronic program guide information is suppressed for streams." }, "XTREAM_TARGET_OUTPUT": { "SKIP_LIVE_DIRECT_SOURCE": "If enabled, live streams are not linked directly but proxied/resolved.", @@ -1400,7 +1412,18 @@ "VIEW_TYPE_RBAC": "WebUI Users", "ADMISSION_STRATEGIES": "Admission Strategies", "RUNTIME_CONFIG_REPORT": "Runtime Config Report", - "RUNTIME_CONFIG_REPORT_FORMAT": "Runtime Config Report Format" + "RUNTIME_CONFIG_REPORT_FORMAT": "Runtime Config Report Format", + "STREAM_INFO": "Stream Display Info", + "HIDE_GROUP": "Hide Group", + "HIDE_IP": "Hide Client IP", + "HIDE_COUNTRY": "Hide Country", + "HIDE_SHARED": "Hide Shared", + "HIDE_DURATION": "Hide Duration", + "HIDE_BANDWIDTH": "Hide Bandwidth", + "HIDE_TRANSFERRED": "Hide Transferred", + "HIDE_PLAYER": "Hide Player", + "HIDE_USER_COMMENT": "Hide User Comment", + "HIDE_EPG": "Hide EPG" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.", diff --git a/frontend/scss/app/components/_country.scss b/frontend/scss/app/components/_country.scss index 88ff70651..d4263e588 100644 --- a/frontend/scss/app/components/_country.scss +++ b/frontend/scss/app/components/_country.scss @@ -7,14 +7,12 @@ &__name { min-width: 0; - overflow-wrap: anywhere; - font-weight: 700; } &__flag { display: inline-flex; - width: 1rem; - height: 0.75rem; + width: 1.5rem; + height: 1rem; overflow: hidden; border-radius: 2px; diff --git a/frontend/scss/app/components/dashboard/_streams_view.scss b/frontend/scss/app/components/dashboard/_streams_view.scss index 40689cab7..c62d977df 100644 --- a/frontend/scss/app/components/dashboard/_streams_view.scss +++ b/frontend/scss/app/components/dashboard/_streams_view.scss @@ -191,6 +191,10 @@ color: var(--stream-tech-chip-video-codec-color); } + &__user-comment { + color: var(--modest-text-color); + } + &__channel_type { font-weight: bold; color: var(--stream-tech-chip-audio-codec-color); @@ -281,11 +285,30 @@ line-height: 1.3; } - &__duration { letter-spacing: 0.12em; } + &__epg-now { + color: var(--theme-brand-positive-strong); + } + + &__epg-time { + font-size: 0.9rem; + } + + &__epg-title { + font-weight: 700; + } + + &__epg-separator { + font-size: 1.2rem; + } + + &__epg-next { + color: var(--modest-text-color); + } + &__row { display: flex; flex-flow: row wrap; diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 8325d394b..320416c23 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -1,8 +1,8 @@ use crate::app::components::config::config_page::ConfigForm; use shared::{ model::{ - ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, - LibraryPlaylistConfigDto, ThumbnailConfigDto, WebAuthConfigDto, WebUiConfigDto, + ConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, LibraryPlaylistConfigDto, + ThumbnailConfigDto, WebUiConfigDto, }, utils::is_default_supported_library_extensions, }; @@ -52,32 +52,63 @@ fn update_library_field(config: &mut ConfigDto, mut library_cfg: LibraryConfigDt } } -fn is_webui_toggle_only_update(cfg: &WebUiConfigDto) -> bool { - cfg.path.as_deref().is_none_or(|path| { - let trimmed = path.trim(); - trimmed.is_empty() || trimmed.chars().all(|c| c == '/') - }) && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) - && cfg.kick_secs == WebUiConfigDto::default().kick_secs - && cfg.auth.as_ref().is_none_or(WebAuthConfigDto::is_empty) - && cfg.content_security_policy.as_ref().is_none_or(ContentSecurityPolicyConfigDto::is_empty) -} - -fn update_webui_field(config: &mut ConfigDto, mut web_ui_cfg: WebUiConfigDto) { - if let Some(existing) = config.web_ui.as_mut() { - if is_webui_toggle_only_update(&web_ui_cfg) { - // Toggle-only form updates must not drop existing nested WebUI payload. - existing.enabled = web_ui_cfg.enabled; - existing.user_ui_enabled = web_ui_cfg.user_ui_enabled; - existing.combine_views_stats_streams = web_ui_cfg.combine_views_stats_streams; +fn update_webui_field(config: &mut ConfigDto, web_ui_cfg: WebUiConfigDto, modified: bool) { + // If there's no existing web_ui config: clean and store the form (empty form -> None). + if config.web_ui.is_none() { + if web_ui_cfg.is_empty() { return; } + let mut cfg = web_ui_cfg; + let kick_secs = cfg.kick_secs; + cfg.clean(); + cfg.kick_secs = kick_secs; + config.web_ui = Some(cfg); + return; } - if web_ui_cfg.is_empty() { + if !modified && web_ui_cfg.is_empty() { + return; + } + + if !modified { + let existing = config.web_ui.as_mut().unwrap(); + existing.enabled = web_ui_cfg.enabled; + existing.user_ui_enabled = web_ui_cfg.user_ui_enabled; + existing.combine_views_stats_streams = web_ui_cfg.combine_views_stats_streams; + existing.landing_page = web_ui_cfg.landing_page; + + if let Some(auth) = web_ui_cfg.auth.filter(|auth| !auth.is_empty()) { + existing.auth = Some(auth); + } + if let Some(csp) = web_ui_cfg.content_security_policy.filter(|csp| !csp.is_empty()) { + existing.content_security_policy = Some(csp); + } + if web_ui_cfg.path.as_deref().is_some_and(|path| { + let trimmed = path.trim(); + !trimmed.is_empty() && !trimmed.chars().all(|c| c == '/') + }) { + existing.path = web_ui_cfg.path; + } + if web_ui_cfg.player_server.as_deref().is_some_and(|player_server| !player_server.trim().is_empty()) { + existing.player_server = web_ui_cfg.player_server; + } + if let Some(stream_info) = web_ui_cfg.stream_info.filter(|stream_info| !stream_info.is_empty()) { + existing.stream_info = Some(stream_info); + } + let kick_secs = existing.kick_secs; + existing.clean(); + existing.kick_secs = kick_secs; + return; + } + + if modified && web_ui_cfg.is_empty() { config.web_ui = None; } else { - web_ui_cfg.clean(); - config.web_ui = Some(web_ui_cfg); + let mut cfg = web_ui_cfg; + let kick_secs = cfg.kick_secs; + cfg.clean(); + cfg.kick_secs = kick_secs; + config.web_ui = Some(cfg); } } @@ -99,7 +130,7 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { set_config_field!(config, metadata_update_cfg, metadata_update) } ConfigForm::Messaging(_, mut messaging_cfg) => set_config_field!(config, messaging_cfg, messaging), - ConfigForm::WebUi(_, web_ui_cfg) => update_webui_field(config, web_ui_cfg), + ConfigForm::WebUi(modified, web_ui_cfg) => update_webui_field(config, web_ui_cfg, modified), ConfigForm::ReverseProxy(_, mut reverse_proxy_cfg) => { set_config_field!(config, reverse_proxy_cfg, reverse_proxy) } @@ -118,8 +149,9 @@ mod tests { use super::update_config; use crate::app::components::config::config_page::ConfigForm; use shared::model::{ - ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, LibraryConfigDto, - LibraryScanDirectoryDto, MetadataUpdateConfigDto, ProxyConfigDto, WebAuthConfigDto, WebUiConfigDto, + view_type::ViewType, ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, + LibraryConfigDto, LibraryScanDirectoryDto, MetadataUpdateConfigDto, ProxyConfigDto, StreamInfoConfigDto, + WebAuthConfigDto, WebUiConfigDto, }; #[test] @@ -241,7 +273,7 @@ mod tests { update_config( &mut config, vec![ConfigForm::WebUi( - true, + false, // <-- all nested fields are default/empty, preserve existing config WebUiConfigDto { enabled: false, user_ui_enabled: false, @@ -287,4 +319,201 @@ mod tests { assert!(stored.ffprobe.enabled); assert_eq!(stored.ffprobe.timeout, Some(60)); } + + #[test] + fn update_config_webui_explicit_stream_info_clear() { + // User explicitly clears all hide_* flags via the form (stream_info=None). + // modified=true signals explicit edit; existing stream_info must become None. + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + stream_info: Some(StreamInfoConfigDto { hide_ip: true, ..Default::default() }), + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + true, // modified=true: explicit edit + WebUiConfigDto { + enabled: false, + stream_info: None, // user cleared all flags + ..Default::default() + }, + )], + ); + + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert!(web_ui.stream_info.is_none(), "stream_info should be explicitly cleared to None"); + } + + #[test] + fn update_config_webui_explicit_landing_page_reset_to_default() { + // User explicitly resets landing_page to default while also changing scalars. + // The reset landing_page value must be applied literally, not preserved. + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, // existing is non-default + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + true, // modified=true: explicit edit + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Dashboard, // reset to default + ..Default::default() + }, + )], + ); + + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Dashboard, "landing_page reset to default must be applied"); + } + + #[test] + fn update_config_webui_toggle_only_preserves_stream_info() { + // Toggle-only sparse update (modified=false): preserve existing stream_info. + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + stream_info: Some(StreamInfoConfigDto { hide_ip: true, hide_country: true, ..Default::default() }), + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + false, // modified=false: toggle-only, preserve nested config + WebUiConfigDto { + enabled: false, + stream_info: None, // form has None but this is "untouched", not "cleared" + ..Default::default() + }, + )], + ); + + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + let stream_info = web_ui.stream_info.expect("stream_info should be preserved on toggle-only"); + assert!(stream_info.hide_ip); + assert!(stream_info.hide_country); + } + + #[test] + fn update_config_webui_toggle_only_preserves_landing_page() { + // Toggle-only sparse update (modified=false): landing_page in form matches existing + // (form syncs landing_page from existing on open). Landing_page should be preserved. + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, // existing is non-default + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + false, // modified=false: toggle-only, landing_page unchanged in form + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Streams, // form syncs from existing on open + ..Default::default() + }, + )], + ); + + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Streams, "landing_page should be preserved on toggle-only"); + } + + #[test] + fn update_config_webui_toggle_only_with_nested_field_change_does_selective_update() { + // User changed auth issuer (nested field change), landing_page stays at synced value. + // modified=true means this is a real form edit and nested values must be applied literally. + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, + auth: Some(WebAuthConfigDto { issuer: "test".to_string(), ..Default::default() }), + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + true, // explicit edit: nested values must be applied + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Streams, // form syncs from existing + auth: Some(WebAuthConfigDto { issuer: "other".to_string(), ..Default::default() }), + ..Default::default() + }, + )], + ); + + // Selective update: scalars and landing_page applied, auth preserved (nested change). + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Streams); + assert_eq!(web_ui.auth.as_ref().map(|a| a.issuer.as_str()), Some("other")); + } + + #[test] + fn update_config_webui_explicit_clear_auth_path_player_server_and_reset_kick_secs() { + let mut config = ConfigDto { + web_ui: Some(WebUiConfigDto { + enabled: true, + path: Some("/dashboard".to_string()), + player_server: Some("http://player.local".to_string()), + kick_secs: 321, + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "issuer".to_string(), + secret: "top-secret".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..ConfigDto::default() + }; + + update_config( + &mut config, + vec![ConfigForm::WebUi( + true, + WebUiConfigDto { + enabled: false, + path: None, + player_server: None, + kick_secs: WebUiConfigDto::default().kick_secs, + auth: Some(WebAuthConfigDto::default()), + ..Default::default() + }, + )], + ); + + let web_ui = config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert!(web_ui.path.is_none()); + assert!(web_ui.player_server.is_none()); + assert_eq!(web_ui.kick_secs, WebUiConfigDto::default().kick_secs); + assert!(web_ui.auth.is_none()); + } } diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index 0873d6585..7848ecc1b 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -15,7 +15,9 @@ use crate::{ edit_field_text_option, generate_form_reducer, html_if, i18n::use_translation, }; -use shared::model::{view_type::ViewType, ContentSecurityPolicyConfigDto, WebAuthConfigDto, WebUiConfigDto}; +use shared::model::{ + view_type::ViewType, ContentSecurityPolicyConfigDto, StreamInfoConfigDto, WebAuthConfigDto, WebUiConfigDto, +}; use yew::prelude::*; // Labels @@ -34,6 +36,17 @@ const LABEL_CONTENT_SECURITY_POLICY_CUSTOM_ATTRIBUTES: &str = "LABEL.CUSTOM_ATTR const LABEL_PATH: &str = "LABEL.PATH"; const LABEL_COMBINE_VIEWS_STATS_STREAMS: &str = "LABEL.COMBINE_VIEWS_STATS_STREAMS"; const LABEL_LANDING_PAGE: &str = "LABEL.LANDING_PAGE"; +const LABEL_STREAM_INFO: &str = "LABEL.STREAM_INFO"; +const LABEL_HIDE_GROUP: &str = "LABEL.HIDE_GROUP"; +const LABEL_HIDE_IP: &str = "LABEL.HIDE_IP"; +const LABEL_HIDE_COUNTRY: &str = "LABEL.HIDE_COUNTRY"; +const LABEL_HIDE_SHARED: &str = "LABEL.HIDE_SHARED"; +const LABEL_HIDE_DURATION: &str = "LABEL.HIDE_DURATION"; +const LABEL_HIDE_BANDWIDTH: &str = "LABEL.HIDE_BANDWIDTH"; +const LABEL_HIDE_TRANSFERRED: &str = "LABEL.HIDE_TRANSFERRED"; +const LABEL_HIDE_PLAYER: &str = "LABEL.HIDE_PLAYER"; +const LABEL_HIDE_USER_COMMENT: &str = "LABEL.HIDE_USER_COMMENT"; +const LABEL_HIDE_EPG: &str = "LABEL.HIDE_EPG"; // Reducers for form states generate_form_reducer!( @@ -72,6 +85,23 @@ generate_form_reducer!( } ); +generate_form_reducer!( + state: StreamInfoConfigFormState { form: StreamInfoConfigDto }, + action_name: StreamInfoConfigFormAction, + fields { + HideGroup => hide_group: bool, + HideIp => hide_ip: bool, + HideCountry => hide_country: bool, + HideShared => hide_shared: bool, + HideDuration => hide_duration: bool, + HideBandwidth => hide_bandwidth: bool, + HideTransferred => hide_transferred: bool, + HidePlayer => hide_player: bool, + HideUserComment => hide_user_comment: bool, + HideEpg => hide_epg: bool, + } +); + #[component] pub fn WebUiConfigView() -> Html { let translate = use_translation(); @@ -85,6 +115,8 @@ pub fn WebUiConfigView() -> Html { use_reducer(|| WebUiAuthConfigFormState { form: WebAuthConfigDto::default(), modified: false }); let csp_state: UseReducerHandle = use_reducer(|| CspConfigFormState { form: ContentSecurityPolicyConfigDto::default(), modified: false }); + let stream_info_state: UseReducerHandle = + use_reducer(|| StreamInfoConfigFormState { form: StreamInfoConfigDto::default(), modified: false }); let view_types = use_memo(webui_state.data().landing_page, |landing_page| { enum_iterator::all::() @@ -104,19 +136,31 @@ pub fn WebUiConfigView() -> Html { webui_state.form.clone(), auth_state.form.clone(), csp_state.form.clone(), + stream_info_state.form.clone(), webui_state.modified, auth_state.modified, csp_state.modified, + stream_info_state.modified, ); use_emit_mapped( deps, config_view_ctx.on_form_change.clone(), - |(webui_form, auth_form, csp_form, webui_modified, auth_modified, csp_modified)| { + |( + webui_form, + auth_form, + csp_form, + stream_info_form, + webui_modified, + auth_modified, + csp_modified, + stream_info_modified, + )| { let mut form = webui_form; form.auth = Some(auth_form); form.content_security_policy = Some(csp_form); + form.stream_info = if stream_info_form.is_empty() { None } else { Some(stream_info_form) }; - let modified = webui_modified || auth_modified || csp_modified; + let modified = webui_modified || auth_modified || csp_modified || stream_info_modified; ConfigForm::WebUi(modified, form) }, ); @@ -127,6 +171,7 @@ pub fn WebUiConfigView() -> Html { let webui_state = webui_state.clone(); let auth_state = auth_state.clone(); let csp_state = csp_state.clone(); + let stream_info_state = stream_info_state.clone(); let webui_cfg = config_ctx.config.as_ref().and_then(|c| c.config.web_ui.clone()); use_effect_with((webui_cfg, *config_view_ctx.edit_mode), move |(cfg, _mode)| { @@ -142,10 +187,16 @@ pub fn WebUiConfigView() -> Html { } else { csp_state.dispatch(CspConfigFormAction::SetAll(ContentSecurityPolicyConfigDto::default())); } + if let Some(stream_info) = &webui.stream_info { + stream_info_state.dispatch(StreamInfoConfigFormAction::SetAll(stream_info.clone())); + } else { + stream_info_state.dispatch(StreamInfoConfigFormAction::SetAll(StreamInfoConfigDto::default())); + } } else { webui_state.dispatch(WebUiConfigFormAction::SetAll(WebUiConfigDto::default())); auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(WebAuthConfigDto::default())); csp_state.dispatch(CspConfigFormAction::SetAll(ContentSecurityPolicyConfigDto::default())); + stream_info_state.dispatch(StreamInfoConfigFormAction::SetAll(StreamInfoConfigDto::default())); } }); } @@ -189,7 +240,20 @@ pub fn WebUiConfigView() -> Html { { config_field!(auth_state.form, translate.t(LABEL_TOKEN_TTL_MINS), token_ttl_mins) } { config_field_optional!(auth_state.form, translate.t(LABEL_USERFILE), userfile) } { config_field_optional!(auth_state.form, translate.t(LABEL_GROUPFILE), groupfile) } - + + +

{translate.t(LABEL_STREAM_INFO)}

+ { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_GROUP), hide_group) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_IP), hide_ip) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_COUNTRY), hide_country) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_SHARED), hide_shared) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_DURATION), hide_duration) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_BANDWIDTH), hide_bandwidth) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_TRANSFERRED), hide_transferred) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_PLAYER), hide_player) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_USER_COMMENT), hide_user_comment) } + { config_field_bool!(stream_info_state.form, translate.t(LABEL_HIDE_EPG), hide_epg) } +
} }; @@ -235,6 +299,19 @@ pub fn WebUiConfigView() -> Html { { edit_field_text_option!(auth_state, translate.t(LABEL_USERFILE), userfile, WebUiAuthConfigFormAction::Userfile) } { edit_field_text_option!(auth_state, translate.t(LABEL_GROUPFILE), groupfile, WebUiAuthConfigFormAction::Groupfile) } + +

{translate.t(LABEL_STREAM_INFO)}

+ { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_GROUP), hide_group, StreamInfoConfigFormAction::HideGroup) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_IP), hide_ip, StreamInfoConfigFormAction::HideIp) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_COUNTRY), hide_country, StreamInfoConfigFormAction::HideCountry) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_SHARED), hide_shared, StreamInfoConfigFormAction::HideShared) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_DURATION), hide_duration, StreamInfoConfigFormAction::HideDuration) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_BANDWIDTH), hide_bandwidth, StreamInfoConfigFormAction::HideBandwidth) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_TRANSFERRED), hide_transferred, StreamInfoConfigFormAction::HideTransferred) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_PLAYER), hide_player, StreamInfoConfigFormAction::HidePlayer) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_USER_COMMENT), hide_user_comment, StreamInfoConfigFormAction::HideUserComment) } + { edit_field_bool!(stream_info_state, translate.t(LABEL_HIDE_EPG), hide_epg, StreamInfoConfigFormAction::HideEpg) } +
} }; diff --git a/frontend/src/app/components/dashboard/stream_display/helpers.rs b/frontend/src/app/components/dashboard/stream_display/helpers.rs index 387442426..91eea518e 100644 --- a/frontend/src/app/components/dashboard/stream_display/helpers.rs +++ b/frontend/src/app/components/dashboard/stream_display/helpers.rs @@ -5,10 +5,13 @@ use crate::{ }; use gloo_utils::window; use shared::{ - model::{PlaylistItemType, StreamChannel, StreamInfo, StreamTechnicalInfo}, + model::{PlaylistItemType, StreamChannel, StreamInfo, StreamInfoConfigDto, StreamTechnicalInfo}, utils::{current_time_secs, default_hls_session_ttl_secs}, }; -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, +}; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{console, Element}; use yew::UseStateHandle; @@ -32,6 +35,15 @@ pub fn is_stream_metrics_enabled(config_ctx: &ConfigContext) -> bool { .is_some_and(|stream| stream.metrics_enabled) } +pub fn get_stream_info_config(config_ctx: &ConfigContext) -> Option> { + config_ctx + .config + .as_ref() + .and_then(|cfg| cfg.config.web_ui.as_ref()) + .and_then(|web_ui| web_ui.stream_info.clone()) + .map(Rc::new) +} + pub fn get_adaptive_session_ttl_secs(config_ctx: &ConfigContext) -> u64 { config_ctx .config @@ -225,6 +237,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }, provider: "provider".intern(), addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), diff --git a/frontend/src/app/components/dashboard/stream_display/item.rs b/frontend/src/app/components/dashboard/stream_display/item.rs index 1cae345ca..abbd84488 100644 --- a/frontend/src/app/components/dashboard/stream_display/item.rs +++ b/frontend/src/app/components/dashboard/stream_display/item.rs @@ -5,23 +5,85 @@ use super::{ use crate::{ app::components::{country::display_country_code, AppIcon, Chip, Country, RevealContent, ToggleSwitch}, i18n::use_translation, + services::PlaylistService, utils::format_duration, }; use shared::{ - model::StreamInfo, + model::{ + EpgProgrammeDto, StreamEpgItemRequest, StreamEpgResponse, StreamInfo, StreamInfoConfigDto, StreamInfoFields, + StreamInfoFieldsSet, + }, utils::{current_time_secs, strip_port}, }; use std::rc::Rc; +use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use yew::prelude::*; +fn only_time(ts: &str) -> String { + match ts.split_once(' ') { + Some((_date, time)) => time.to_string(), + None => ts.to_string(), + } +} + #[derive(Properties, PartialEq, Clone)] pub struct StreamDisplayItemProps { pub stream: Rc, + pub user_comment: Option, pub metrics_enabled: bool, + pub stream_info: Option>, pub on_popup_click: Callback<(Rc, MouseEvent)>, } +/// Holds the fetched EPG data and its fetch timestamp for staleness checking. +#[derive(Debug, Clone)] +struct EpgData { + channel_id: std::sync::Arc, + response: StreamEpgResponse, + fetched_at_secs: u64, +} + +impl EpgData { + fn matches_channel(&self, channel_id: &std::sync::Arc) -> bool { + self.channel_id.as_ref() == channel_id.as_ref() + } + + fn is_stale(&self, now_secs: u64) -> bool { + let age = now_secs.saturating_sub(self.fetched_at_secs); + age >= 7 * 3600 + } +} + +fn epg_interval_effect_deps( + epg_channel_id: &Option>, + epg_data: Option<&Rc>, + hide_epg: bool, +) -> (Option>, Option, bool) { + (epg_channel_id.clone(), epg_data.map(|data| data.fetched_at_secs), hide_epg) +} + +/// Computes current and next programme from a list of programmes. +fn compute_current_next( + programmes: &[EpgProgrammeDto], + now_secs: i64, +) -> (Option, Option) { + let current = programmes.iter().find(|p| now_secs >= p.start_timestamp && now_secs < p.stop_timestamp).cloned(); + + let next = current.as_ref().and_then(|cur| { + programmes.iter().filter(|p| p.start_timestamp >= cur.stop_timestamp).min_by_key(|p| p.start_timestamp).cloned() + }); + + (current, next) +} + +fn format_user_comment(user_comment: Option) -> Option { + user_comment.and_then(|comment| { + let trimmed = comment.trim(); + (!trimmed.is_empty()).then(|| format!("({trimmed})")) + }) +} + #[component] pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html { let translate = use_translation(); @@ -30,6 +92,171 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html { let chips = build_technical_chips(stream.channel.item_type, stream.channel.technical.as_ref()); let client_ip = strip_port(&stream.client_ip).to_string(); + let epg_channel_id = stream.channel.epg_channel_id.clone(); + let epg_target_id = stream.channel.target_id; + + // EPG state: cached response + fetched timestamp + let epg_data: UseStateHandle>> = use_state(|| None); + // Computed current/next for display + let current_next: UseStateHandle)>> = use_state(|| None); + // Refetch trigger: set to true when cache is stale, causes fetch effect to re-run + let needs_refetch: UseStateHandle = use_state(|| false); + + // Fetch EPG when epg_channel_id is present or when needs_refetch is triggered + let hide_properties = props.stream_info.as_ref().map_or_else(StreamInfoFieldsSet::new, |cfg| cfg.get_flags()); + // When EPG becomes hidden, clear stale UI state + { + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + use_effect_with(hide_properties, move |hide_props| { + if hide_props.contains(StreamInfoFields::HideEpg) { + epg_data.set(None); + current_next.set(None); + } + || () + }); + } + { + let epg_channel_id = epg_channel_id.clone(); + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + let needs_refetch = needs_refetch.clone(); + use_effect_with( + (epg_channel_id.clone(), *needs_refetch, hide_properties), + move |(epg_channel_id, force_refetch, _hide_props)| { + let Some(epg_channel_id) = epg_channel_id.clone() else { + return; + }; + if hide_properties.contains(StreamInfoFields::HideEpg) { + return; + } + let force_refetch = *force_refetch; + + let epg_channel_id = epg_channel_id.clone(); + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + let needs_refetch = needs_refetch.clone(); + + spawn_local(async move { + let now_secs = current_time_secs(); + let now_i64 = now_secs as i64; + let reset_state = || { + current_next.set(None); + epg_data.set(None); + if force_refetch { + needs_refetch.set(false); + } + }; + + // Check if cache is valid before fetching + if let Some(ref data) = *epg_data { + if !data.matches_channel(&epg_channel_id) { + current_next.set(None); + epg_data.set(None); + } else if !force_refetch && !data.is_stale(now_secs) { + return; + } + } + + // Fetch fresh data + let service = PlaylistService::new(); + if let Some(response) = service + .get_stream_epg(vec![StreamEpgItemRequest { + epg_channel_id: epg_channel_id.to_string(), + target_id: Some(epg_target_id), + }]) + .await + { + if let Some(entry) = response.entries.first() { + let (current, next) = compute_current_next(&entry.programmes, now_i64); + let new_data = Rc::new(EpgData { + channel_id: epg_channel_id.clone(), + response, + fetched_at_secs: now_secs, + }); + current_next.set(current.map(|cur| (cur, next))); + epg_data.set(Some(new_data)); + } else { + reset_state(); + return; + } + } else { + reset_state(); + return; + } + + if force_refetch { + needs_refetch.set(false); + } + }); + }, + ); + } + + // Local tick: recompute current/next and detect staleness every 30 seconds + { + let interval_deps = epg_interval_effect_deps( + &epg_channel_id, + (*epg_data).as_ref(), + hide_properties.contains(StreamInfoFields::HideEpg), + ); + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + let needs_refetch = needs_refetch.clone(); + use_effect_with(interval_deps, move |(epg_channel_id, _, hide_epg)| { + if *hide_epg { + return Box::new(|| ()) as Box; + } + epg_channel_id.as_ref().map_or_else( + || Box::new(|| ()) as Box, + |channel_id| { + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + let needs_refetch = needs_refetch.clone(); + let channel_id = channel_id.clone(); + + let interval = gloo_timers::callback::Interval::new(30_000, move || { + let epg_data = epg_data.clone(); + let current_next = current_next.clone(); + let needs_refetch = needs_refetch.clone(); + + let now_secs = current_time_secs(); + + if let Some(data) = (*epg_data).as_ref() { + if !data.matches_channel(&channel_id) { + current_next.set(None); + epg_data.set(None); + needs_refetch.set(true); + return; + } + } + + // Check if cache is stale and needs refetch + if let Some(data) = (*epg_data).as_ref() { + if data.is_stale(now_secs) { + needs_refetch.set(true); + return; + } + } + + // Recompute current/next from cached data + if let Some(data) = (*epg_data).as_ref() { + if let Some(entry) = data.response.entries.first() { + let now_i64 = now_secs as i64; + let (current, next) = compute_current_next(&entry.programmes, now_i64); + // Only show current if one is active; clears stale state otherwise + current_next.set(current.map(|cur| (cur, next))); + } else { + current_next.set(None); + } + } + }); + Box::new(move || drop(interval)) as Box + }, + ) + }); + } + let handle_popup_click = { let stream = stream.clone(); let on_popup_click = props.on_popup_click.clone(); @@ -50,27 +277,55 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html {
{stream.channel.title.to_string()}
- {stream.username.clone()} - {" • "} {render_cluster(&stream.channel)} {" • "} {stream.provider.clone()} + {" • "} + {stream.username.clone()} + if !hide_properties.contains(StreamInfoFields::HideUserComment) { + {format_user_comment(props.user_comment.clone())} + }
+ if !hide_properties.contains(StreamInfoFields::HideEpg) { + if let Some((current, next_opt)) = current_next.as_ref() { + if !current.title.is_empty() { +
+ + {only_time(¤t.start)} + {" "} + {¤t.title} + + if let Some(next) = next_opt.as_ref() { + + {" ↦ "} + {only_time(&next.start)} + {" "} + {&next.title} + + } +
+ } + } + }
+ if !hide_properties.contains(StreamInfoFields::HideGroup) {
{translate.t("LABEL.GROUP")} {stream.channel.group.to_string()}
+ } + if !hide_properties.contains(StreamInfoFields::HideIp) {
{translate.t("LABEL.CLIENT_IP")} {client_ip.clone()}
- if display_country_code(stream.country_code.as_deref()).is_some() { + } + if !hide_properties.contains(StreamInfoFields::HideCountry) && display_country_code(stream.country_code.as_deref()).is_some() {
{translate.t("LABEL.COUNTRY")} @@ -78,23 +333,29 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html {
} + if !hide_properties.contains(StreamInfoFields::HideShared) {
{translate.t("LABEL.SHARED")}
+ } + if !hide_properties.contains(StreamInfoFields::HideDuration) {
{translate.t("LABEL.DURATION")} {format_duration(current_time_secs().saturating_sub(if stream.started_at == 0 { stream.ts } else { stream.started_at }))}
- if props.metrics_enabled && stream.meter_uid != 0 { + } + if props.metrics_enabled && stream.meter_uid != 0 && !hide_properties.contains(StreamInfoFields::HideBandwidth) {
{translate.t("LABEL.BANDWIDTH")}
+ } + if props.metrics_enabled && stream.meter_uid != 0 && !hide_properties.contains(StreamInfoFields::HideTransferred) {
{translate.t("LABEL.TRANSFERRED")} @@ -102,12 +363,14 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html {
} + if !hide_properties.contains(StreamInfoFields::HidePlayer) {
{translate.t("LABEL.PLAYER")} {&stream.user_agent}
+ }
@@ -123,3 +386,132 @@ pub fn StreamDisplayItem(props: &StreamDisplayItemProps) -> Html { } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_programme(title: &str, start: i64, stop: i64) -> EpgProgrammeDto { + EpgProgrammeDto { + start_timestamp: start, + stop_timestamp: stop, + start: format!("{:02}:{:02}", start / 3600, (start % 3600) / 60), + stop: format!("{:02}:{:02}", stop / 3600, (stop % 3600) / 60), + title: title.to_string(), + } + } + + #[test] + fn test_compute_current_next_finds_current_and_next() { + let programmes = vec![ + make_programme("Show A", 100, 200), + make_programme("Show B", 200, 300), + make_programme("Show C", 300, 400), + ]; + + let (current, next) = compute_current_next(&programmes, 150); + assert_eq!(current.as_ref().map(|p| p.title.as_str()), Some("Show A")); + assert_eq!(next.as_ref().map(|p| p.title.as_str()), Some("Show B")); + } + + #[test] + fn test_compute_current_next_at_boundary_becomes_next() { + let programmes = vec![make_programme("Show A", 100, 200), make_programme("Show B", 200, 300)]; + + let (current, next) = compute_current_next(&programmes, 200); + assert_eq!(current.as_ref().map(|p| p.title.as_str()), Some("Show B")); + assert!(next.is_none()); + } + + #[test] + fn test_compute_current_next_empty_programmes() { + let (current, next) = compute_current_next(&[], 100); + assert!(current.is_none()); + assert!(next.is_none()); + } + + #[test] + fn test_compute_current_next_no_programme_running() { + let programmes = vec![make_programme("Show A", 100, 200)]; + let (current, next) = compute_current_next(&programmes, 50); + assert!(current.is_none()); + assert!(next.is_none()); + } + + #[test] + fn test_compute_current_next_last_programme_no_next() { + let programmes = vec![make_programme("Show A", 100, 200), make_programme("Show B", 200, 300)]; + + let (current, next) = compute_current_next(&programmes, 250); + assert_eq!(current.as_ref().map(|p| p.title.as_str()), Some("Show B")); + assert!(next.is_none()); + } + + #[test] + fn test_compute_current_next_picks_earliest_future_programme_when_unsorted() { + let programmes = vec![ + make_programme("Late Show", 400, 500), + make_programme("Current Show", 100, 200), + make_programme("Next Show", 200, 300), + ]; + + let (current, next) = compute_current_next(&programmes, 150); + assert_eq!(current.as_ref().map(|p| p.title.as_str()), Some("Current Show")); + assert_eq!(next.as_ref().map(|p| p.title.as_str()), Some("Next Show")); + } + + #[test] + fn test_epg_data_is_stale() { + let data = EpgData { + channel_id: std::sync::Arc::::from("channel-1"), + response: StreamEpgResponse { entries: vec![] }, + fetched_at_secs: 100, + }; + assert!(!data.is_stale(100)); + assert!(!data.is_stale(3600)); + assert!(data.is_stale(25300)); + } + + #[test] + fn test_epg_data_matches_channel() { + let data = EpgData { + channel_id: std::sync::Arc::::from("channel-1"), + response: StreamEpgResponse { entries: vec![] }, + fetched_at_secs: 100, + }; + + assert!(data.matches_channel(&std::sync::Arc::::from("channel-1"))); + assert!(!data.matches_channel(&std::sync::Arc::::from("channel-2"))); + } + + #[test] + fn test_format_user_comment_trims_and_skips_empty_values() { + assert_eq!(format_user_comment(None), None); + assert_eq!(format_user_comment(Some(" ".to_string())), None); + assert_eq!(format_user_comment(Some(" comment ".to_string())), Some("(comment)".to_string())); + } + + #[test] + fn test_epg_interval_effect_deps_change_when_epg_data_changes() { + let channel_id = Some(std::sync::Arc::::from("channel-1")); + let first = Rc::new(EpgData { + channel_id: std::sync::Arc::::from("channel-1"), + response: StreamEpgResponse { entries: vec![] }, + fetched_at_secs: 100, + }); + let second = Rc::new(EpgData { + channel_id: std::sync::Arc::::from("channel-1"), + response: StreamEpgResponse { entries: vec![] }, + fetched_at_secs: 200, + }); + + let first_deps = epg_interval_effect_deps(&channel_id, Some(&first), false); + let second_deps = epg_interval_effect_deps(&channel_id, Some(&second), false); + + assert_ne!(first_deps, second_deps); + assert_eq!(first_deps.1, Some(100)); + assert_eq!(second_deps.1, Some(200)); + assert!(!first_deps.2); + assert!(!second_deps.2); + } +} diff --git a/frontend/src/app/components/dashboard/stream_display/mod.rs b/frontend/src/app/components/dashboard/stream_display/mod.rs index e43554aaa..60ae4b08c 100644 --- a/frontend/src/app/components/dashboard/stream_display/mod.rs +++ b/frontend/src/app/components/dashboard/stream_display/mod.rs @@ -21,9 +21,12 @@ use crate::{ services::DialogService, }; use gloo_timers::callback::Interval; +pub use helpers::get_stream_info_config; use shared::{ error::TuliproxError, - model::{PlaylistRequest, PlaylistUrlResolveRequest, ProtocolMessage, StreamInfo, UserCommand}, + model::{ + PlaylistRequest, PlaylistUrlResolveRequest, ProtocolMessage, StreamInfo, StreamInfoConfigDto, UserCommand, + }, utils::default_kick_secs, }; use std::{collections::HashMap, fmt::Display, rc::Rc, str::FromStr}; @@ -38,6 +41,37 @@ const COPY_LINK_PROVIDER_URL: &str = "copy_link_provider_url"; #[derive(Properties, PartialEq, Clone)] pub struct StreamDisplayProps { pub streams: Option>>, + pub stream_info_config: Option>, +} + +fn build_user_comments(credentials: I) -> HashMap> +where + I: IntoIterator)>, +{ + let mut comments = HashMap::>::new(); + + for (username, comment) in credentials { + let normalized = comment.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + match comments.get(&username) { + None => { + comments.insert(username, normalized); + } + Some(None) if normalized.is_some() => { + comments.insert(username, normalized); + } + Some(Some(_)) | Some(None) => {} + } + } + + comments } #[component] @@ -54,6 +88,16 @@ pub fn StreamDisplay(props: &StreamDisplayProps) -> Html { let cleanup_now_secs = use_state(shared::utils::current_time_secs); let adaptive_session_ttl_secs = get_adaptive_session_ttl_secs(&config_ctx); let metrics_enabled = is_stream_metrics_enabled(&config_ctx); + let user_comments = use_memo(config_ctx.api_proxy.clone(), |api_proxy| { + api_proxy.as_ref().map_or_else(HashMap::new, |api_proxy| { + build_user_comments(api_proxy.user.iter().flat_map(|target_user| { + target_user + .credentials + .iter() + .map(|credential| (credential.username.clone(), credential.comment.clone())) + })) + }) + }); use_effect_with((), move |_| { let interval = Interval::new(1000, update_timestamps); @@ -272,11 +316,14 @@ pub fn StreamDisplay(props: &StreamDisplayProps) -> Html {
{ for streams.iter().cloned().map(|stream| { let key = format!("{}-{}", stream.addr, stream.uid); + let user_comment = user_comments.get(stream.username.as_str()).cloned().flatten(); html! { } @@ -300,6 +347,33 @@ pub fn StreamDisplay(props: &StreamDisplayProps) -> Html { } } +#[cfg(test)] +mod tests { + use super::build_user_comments; + use std::collections::HashMap; + + #[test] + fn test_build_user_comments_prefers_first_non_none_comment() { + let comments = build_user_comments([ + ("alice".to_string(), None), + ("alice".to_string(), Some("first".to_string())), + ("alice".to_string(), Some("second".to_string())), + ("bob".to_string(), Some("kept".to_string())), + ]); + + assert_eq!(comments.get("alice"), Some(&Some("first".to_string()))); + assert_eq!(comments.get("bob"), Some(&Some("kept".to_string()))); + } + + #[test] + fn test_build_user_comments_keeps_existing_some_comment() { + let comments = + build_user_comments([("alice".to_string(), Some("first".to_string())), ("alice".to_string(), None)]); + + assert_eq!(comments, HashMap::from([("alice".to_string(), Some("first".to_string()))])); + } +} + #[derive(Debug, Clone, Eq, PartialEq)] enum StreamDisplayAction { Kick, diff --git a/frontend/src/app/components/dashboard/streams_view.rs b/frontend/src/app/components/dashboard/streams_view.rs index c0bbded83..6bd920799 100644 --- a/frontend/src/app/components/dashboard/streams_view.rs +++ b/frontend/src/app/components/dashboard/streams_view.rs @@ -1,7 +1,7 @@ use crate::{ app::{ - components::{Card, StatusCard, StreamDisplay}, - StatusContext, + components::{dashboard::stream_display::get_stream_info_config, Card, StatusCard, StreamDisplay}, + ConfigContext, StatusContext, }, hooks::use_service_context, html_if, @@ -22,7 +22,9 @@ pub fn StreamsView(props: &StreamsViewProps) -> Html { let translate = use_translation(); let service_ctx = use_service_context(); let status_ctx = use_context::().expect("Status context not found"); + let config_ctx = use_context::().expect("Config context not found"); let provider_connections = use_state(|| 0); + let stream_info_config = get_stream_info_config(&config_ctx); let memo_streams = { let status = status_ctx.status.clone(); @@ -82,7 +84,7 @@ pub fn StreamsView(props: &StreamsViewProps) -> Html {
- +
} diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index 3569888bb..836623e47 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -4,10 +4,9 @@ use crate::app::{ }; use shared::{ model::{ - ApiProxyConfigDto, AppConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, LibraryConfigDto, - LibraryMetadataConfigDto, LibraryPlaylistConfigDto, LogConfigDto, ReverseProxyConfigDto, - ReverseProxyDisabledHeaderConfigDto, StreamConfigDto, TargetOutputDto, TargetUserDto, ThumbnailConfigDto, - WebAuthConfigDto, WebUiConfigDto, + ApiProxyConfigDto, AppConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, + LibraryPlaylistConfigDto, LogConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, + StreamConfigDto, TargetOutputDto, TargetUserDto, ThumbnailConfigDto, WebUiConfigDto, }, utils::{default_secret, is_default_supported_library_extensions}, }; @@ -183,7 +182,7 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec config.messaging = Some(messaging_cfg), - ConfigForm::WebUi(_, web_ui_cfg) => apply_setup_webui_form(config, web_ui_cfg), + ConfigForm::WebUi(modified, web_ui_cfg) => apply_setup_webui_form(config, web_ui_cfg, modified), ConfigForm::ReverseProxy(_, reverse_proxy_cfg) => config.reverse_proxy = Some(reverse_proxy_cfg), ConfigForm::HdHomerun(_, hdhr_cfg) => apply_setup_hdhomerun_form(config, hdhr_cfg), ConfigForm::Proxy(_, proxy_cfg) => config.proxy = Some(proxy_cfg), @@ -221,26 +220,29 @@ fn apply_setup_library_form(config: &mut shared::model::ConfigDto, library_cfg: config.library = Some(library_cfg); } -fn is_setup_webui_toggle_only_update(cfg: &WebUiConfigDto) -> bool { - cfg.path.as_deref().is_none_or(|path| { - let trimmed = path.trim(); - trimmed.is_empty() || trimmed.chars().all(|c| c == '/') - }) && cfg.player_server.as_deref().is_none_or(|player_server| player_server.trim().is_empty()) - && cfg.kick_secs == WebUiConfigDto::default().kick_secs - && cfg.auth.as_ref().is_none_or(WebAuthConfigDto::is_empty) - && cfg.content_security_policy.as_ref().is_none_or(ContentSecurityPolicyConfigDto::is_empty) -} - -fn apply_setup_webui_form(config: &mut shared::model::ConfigDto, web_ui_cfg: WebUiConfigDto) { - if let Some(existing) = config.web_ui.as_mut() { - if is_setup_webui_toggle_only_update(&web_ui_cfg) { - existing.enabled = web_ui_cfg.enabled; - existing.user_ui_enabled = web_ui_cfg.user_ui_enabled; - existing.combine_views_stats_streams = web_ui_cfg.combine_views_stats_streams; +fn apply_setup_webui_form(config: &mut shared::model::ConfigDto, web_ui_cfg: WebUiConfigDto, modified: bool) { + // If there's no existing web_ui config: clean and store the form (empty form -> None). + if config.web_ui.is_none() { + if web_ui_cfg.is_empty() { return; } + let mut cfg = web_ui_cfg; + cfg.clean(); + config.web_ui = Some(cfg); + return; + } + + if !modified && web_ui_cfg.is_empty() { + return; + } + + if modified && web_ui_cfg.is_empty() { + config.web_ui = None; + } else { + let mut cfg = web_ui_cfg; + cfg.clean(); + config.web_ui = Some(cfg); } - config.web_ui = Some(web_ui_cfg); } fn apply_setup_main_defaults(app_config: &mut AppConfigDto) { @@ -367,9 +369,9 @@ mod tests { }; use crate::app::{components::map_sources_to_playlist_rows, ConfigContext}; use shared::model::{ - AppConfigDto, ConfigInputDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, - LibraryConfigDto, LibraryScanDirectoryDto, MetadataUpdateConfigDto, ReverseProxyConfigDto, SourcesConfigDto, - WebAuthConfigDto, WebUiConfigDto, + view_type::ViewType, AppConfigDto, ConfigInputDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, + HdHomeRunDeviceConfigDto, LibraryConfigDto, LibraryScanDirectoryDto, MetadataUpdateConfigDto, + ReverseProxyConfigDto, SourcesConfigDto, StreamInfoConfigDto, WebAuthConfigDto, WebUiConfigDto, }; use std::rc::Rc; @@ -622,8 +624,19 @@ mod tests { WebUiConfigDto { enabled: false, user_ui_enabled: false, - auth: Some(WebAuthConfigDto::default()), - content_security_policy: Some(ContentSecurityPolicyConfigDto::default()), + path: Some("/dashboard".to_string()), + player_server: Some("http://player.local".to_string()), + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "tuliprox".to_string(), + secret: "top-secret".to_string(), + groupfile: Some("groups.txt".to_string()), + ..Default::default() + }), + content_security_policy: Some(ContentSecurityPolicyConfigDto { + enabled: true, + custom_attributes: Some(vec!["default-src 'self'".to_string()]), + }), ..Default::default() }, )); @@ -638,6 +651,184 @@ mod tests { assert_eq!(web_ui.auth.as_ref().and_then(|auth| auth.groupfile.as_deref()), Some("groups.txt")); } + #[test] + fn setup_webui_explicit_stream_info_clear() { + // User explicitly clears all hide_* flags via the form (stream_info=None). + // modified=true signals explicit edit; existing stream_info must become None. + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + stream_info: Some(StreamInfoConfigDto { hide_ip: true, ..Default::default() }), + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, // modified=true: explicit edit + WebUiConfigDto { + enabled: false, + stream_info: None, // user cleared all flags + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert!(web_ui.stream_info.is_none(), "stream_info should be explicitly cleared to None"); + } + + #[test] + fn setup_webui_explicit_landing_page_reset_to_default() { + // User explicitly resets landing_page to default while also changing scalars. + // The reset landing_page value must be applied literally. + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, // existing is non-default + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, // modified=true: explicit edit + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Dashboard, // reset to default + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Dashboard, "landing_page reset to default must be applied"); + } + + #[test] + fn setup_webui_toggle_only_preserves_stream_info() { + // Toggle-only edit with a real synced form snapshot: user changes scalars, untouched + // stream_info is emitted literally from the existing config and must be preserved. + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + stream_info: Some(StreamInfoConfigDto { hide_ip: true, ..Default::default() }), + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, + WebUiConfigDto { + enabled: false, + stream_info: Some(StreamInfoConfigDto { hide_ip: true, ..Default::default() }), + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + let stream_info = web_ui.stream_info.expect("stream_info should be preserved on toggle-only"); + assert!(stream_info.hide_ip); + } + + #[test] + fn setup_webui_toggle_only_preserves_landing_page() { + // Toggle-only sparse update (modified=true): landing_page in form matches existing + // (form syncs landing_page from existing on open). Landing_page should be preserved. + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, // existing is non-default + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, // modified=true: form IS applied + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Streams, // form syncs from existing on open + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Streams, "landing_page should be preserved on toggle-only"); + } + + #[test] + fn setup_webui_toggle_only_with_nested_field_change_does_selective_update() { + // User changed auth issuer (nested field change), landing_page stays at synced value. + // modified=true means this is a real form edit and nested values must be applied literally. + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + landing_page: ViewType::Streams, + auth: Some(WebAuthConfigDto { issuer: "test".to_string(), ..Default::default() }), + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, // explicit edit: nested values must be applied + WebUiConfigDto { + enabled: false, + landing_page: ViewType::Streams, // form syncs from existing + auth: Some(WebAuthConfigDto { issuer: "other".to_string(), ..Default::default() }), + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert_eq!(web_ui.landing_page, ViewType::Streams); + assert_eq!(web_ui.auth.as_ref().map(|a| a.issuer.as_str()), Some("other")); + } + + #[test] + fn setup_webui_explicit_clear_auth_path_player_server_and_reset_kick_secs() { + let mut app_config = AppConfigDto::default(); + app_config.config.web_ui = Some(WebUiConfigDto { + enabled: true, + path: Some("/dashboard".to_string()), + player_server: Some("http://player.local".to_string()), + kick_secs: 321, + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "issuer".to_string(), + secret: "top-secret".to_string(), + ..Default::default() + }), + ..Default::default() + }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::WebUi( + true, + WebUiConfigDto { + enabled: false, + path: None, + player_server: None, + kick_secs: WebUiConfigDto::default().kick_secs, + auth: Some(WebAuthConfigDto::default()), + ..Default::default() + }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let web_ui = app_cfg.config.web_ui.expect("webui config should be present"); + assert!(!web_ui.enabled); + assert!(web_ui.path.is_none()); + assert!(web_ui.player_server.is_none()); + assert_eq!(web_ui.kick_secs, WebUiConfigDto::default().kick_secs); + assert!(web_ui.auth.is_none()); + } + #[test] fn setup_metadata_update_empty_form_clears_existing_payload() { let mut app_config = AppConfigDto::default(); diff --git a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs index 89d306637..132297fcb 100644 --- a/frontend/src/app/components/userlist/proxy_user_credentials_form.rs +++ b/frontend/src/app/components/userlist/proxy_user_credentials_form.rs @@ -119,7 +119,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { user.max_connections = DEFAULT_MAX_CONNECTIONS; user.proxy = ProxyType::Reverse(None); user.status = Some(ProxyUserStatus::Active); - user.output_clusters = ClusterFlags::all(); + user.output_clusters = None; user.ui_enabled = true; let now = Utc::now(); user.created_at = Some(now.timestamp()); @@ -212,8 +212,8 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html { )| { instance_output_clusters.dispatch(UserFormAction::OutputClusters(flags)); diff --git a/frontend/src/hooks/use_server_status.rs b/frontend/src/hooks/use_server_status.rs index 88f40ca64..e418b36b4 100644 --- a/frontend/src/hooks/use_server_status.rs +++ b/frontend/src/hooks/use_server_status.rs @@ -89,6 +89,7 @@ fn download_task_to_stream_with_ts(download: &FileDownloadDto, ts: u64) -> Strea shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }, provider: BACKGROUND_TRANSFER_PROVIDER.intern(), addr: download_stream_addr(uid), @@ -346,6 +347,7 @@ mod tests { shared_joined_existing: None, shared_stream_id: None, technical: None, + epg_channel_id: None, }, provider: "provider".intern(), addr: addr.parse::().unwrap_or_else(|_| unreachable!()), diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index 32a397e9a..814ec20fb 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -80,6 +80,8 @@ impl ConfigService { pub fn new(config: &WebConfig, auth: Rc, event_service: Rc) -> Self { let base_href = get_base_href(); let config_path = concat_path_leading_slash(&base_href, "api/v1/config"); + let api = |endpoint: &str| concat_path_leading_slash(&base_href, &format!("api/v1/{endpoint}")); + let api_config = |endpoint: &str| concat_path_leading_slash(&config_path, endpoint); Self { ui_config: Rc::new(config.clone()), auth, @@ -90,14 +92,14 @@ impl ConfigService { api_proxy_config_channel: Mutable::new(None), is_fetching: AtomicBool::new(false), config_path: config_path.clone(), - api_proxy_config_path: concat_path(&config_path, "apiproxy"), - sources_path: concat_path(&config_path, "sources"), - ip_check_path: concat_path_leading_slash(&base_href, "api/v1/ipinfo"), - batch_input_content_path: concat_path_leading_slash(&base_href, "api/v1/config/batchContent"), - xtream_login_info_path: concat_path_leading_slash(&base_href, "api/v1/config/xtream/login-info"), - geoip_path: concat_path_leading_slash(&base_href, "api/v1/geoip/update"), - library_path: concat_path_leading_slash(&base_href, "api/v1/library"), - setup_complete_path: concat_path_leading_slash(&base_href, "api/v1/setup/complete"), + api_proxy_config_path: api_config("apiproxy"), + sources_path: api_config("sources"), + batch_input_content_path: api_config("batchContent"), + xtream_login_info_path: api_config("xtream/login-info"), + ip_check_path: api("ipinfo"), + geoip_path: api("geoip/update"), + library_path: api("library"), + setup_complete_path: api("setup/complete"), event_service, } } diff --git a/frontend/src/services/downloads_service.rs b/frontend/src/services/downloads_service.rs index b91b9d849..f07070ee1 100644 --- a/frontend/src/services/downloads_service.rs +++ b/frontend/src/services/downloads_service.rs @@ -43,10 +43,8 @@ pub struct DownloadsService { impl DownloadsService { pub fn new() -> Self { let base_href = get_base_href(); - Self { - downloads_api_path: concat_path_leading_slash(&base_href, "api/v1/file/download"), - downloads_info_api_path: concat_path_leading_slash(&base_href, "api/v1/file/download/info"), - } + let api = |endpoint: &str| concat_path_leading_slash(&base_href, &format!("api/v1/file/{endpoint}")); + Self { downloads_api_path: api("download"), downloads_info_api_path: api("download/info") } } pub async fn queue_download( diff --git a/frontend/src/services/playlist_service.rs b/frontend/src/services/playlist_service.rs index d0f54e880..80e94861b 100644 --- a/frontend/src/services/playlist_service.rs +++ b/frontend/src/services/playlist_service.rs @@ -5,7 +5,8 @@ use log::error; use shared::{ model::{ EpgChannel, EpgTv, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, SeriesStreamProperties, - UiPlaylistCategories, UiPlaylistGroup, UiPlaylistItem, XtreamCluster, XtreamSeriesInfoDoc, + StreamEpgItemRequest, StreamEpgRequest, StreamEpgResponse, UiPlaylistCategories, UiPlaylistGroup, + UiPlaylistItem, XtreamCluster, XtreamSeriesInfoDoc, }, utils::concat_path_leading_slash, }; @@ -20,6 +21,7 @@ pub struct PlaylistService { playlist_api_epg_path: String, playlist_api_series_info_path: String, playlist_api_episode_info_path: String, + stream_epg_path: String, } impl Default for PlaylistService { fn default() -> Self { Self::new() } @@ -28,15 +30,18 @@ impl Default for PlaylistService { impl PlaylistService { pub fn new() -> Self { let base_href = get_base_href(); + let api = |endpoint: &str| concat_path_leading_slash(&base_href, &format!("api/v1/playlist/{endpoint}")); + Self { - target_update_api_path: concat_path_leading_slash(&base_href, "api/v1/playlist/update"), - playlist_api_live_path: concat_path_leading_slash(&base_href, "api/v1/playlist/live"), - playlist_api_vod_path: concat_path_leading_slash(&base_href, "api/v1/playlist/vod"), - playlist_api_series_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series"), - playlist_api_resolve_url_path: concat_path_leading_slash(&base_href, "api/v1/playlist/resolve_url"), - playlist_api_epg_path: concat_path_leading_slash(&base_href, "api/v1/playlist/epg"), - playlist_api_series_info_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series_info"), - playlist_api_episode_info_path: concat_path_leading_slash(&base_href, "api/v1/playlist/series/episode"), + target_update_api_path: api("update"), + playlist_api_live_path: api("live"), + playlist_api_vod_path: api("vod"), + playlist_api_series_path: api("series"), + playlist_api_resolve_url_path: api("resolve_url"), + playlist_api_epg_path: api("epg"), + playlist_api_series_info_path: api("series_info"), + playlist_api_episode_info_path: api("series/episode"), + stream_epg_path: api("epg/stream"), } } pub async fn update_targets(&self, targets: &[&str]) -> bool { @@ -135,6 +140,17 @@ impl PlaylistService { } } + /// Fetches per-stream EPG data for the UI "now playing" / "up next" display. + /// Accepts a batch of epg_channel_ids and returns programme data for each, + /// filtered to an 8h window with user timeshift applied server-side. + pub async fn get_stream_epg(&self, items: Vec) -> Option { + let request = StreamEpgRequest { items }; + request_post(&self.stream_epg_path, &request, None, Some(Encoding::Cbor)).await.unwrap_or_else(|err| { + error!("{err}"); + None + }) + } + pub async fn get_series_info( &self, pli: &Rc, diff --git a/frontend/src/services/stream_history_service.rs b/frontend/src/services/stream_history_service.rs index 65348b6db..dbff99ada 100644 --- a/frontend/src/services/stream_history_service.rs +++ b/frontend/src/services/stream_history_service.rs @@ -54,10 +54,8 @@ impl Default for StreamHistoryService { impl StreamHistoryService { pub fn new() -> Self { let base_href = get_base_href(); - Self { - path: concat_path_leading_slash(&base_href, "api/v1/stream-history"), - qos_path: concat_path_leading_slash(&base_href, "api/v1/qos-snapshots"), - } + let api = |endpoint: &str| concat_path_leading_slash(&base_href, &format!("api/v1/{endpoint}")); + Self { path: api("stream-history"), qos_path: api("qos-snapshots") } } pub async fn get_history( diff --git a/frontend/src/services/user_api_service.rs b/frontend/src/services/user_api_service.rs index 1a755c1e3..7a68bf6ba 100644 --- a/frontend/src/services/user_api_service.rs +++ b/frontend/src/services/user_api_service.rs @@ -9,6 +9,10 @@ use shared::{ }; use std::rc::Rc; +fn user_playlist_api_path(base_href: &str, endpoint: &str) -> String { + concat_path_leading_slash(base_href, &format!("api/v1/user/playlist/{endpoint}")) +} + #[derive(Debug, Default)] pub struct UserApiService { user_playlist_categories_path: String, @@ -19,8 +23,8 @@ 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"), + user_playlist_categories_path: user_playlist_api_path(&base_href, "categories"), + user_playlist_bouquet_path: user_playlist_api_path(&base_href, "bouquet"), } } @@ -43,3 +47,14 @@ impl UserApiService { .map(|_| ()) } } + +#[cfg(test)] +mod tests { + use super::user_playlist_api_path; + + #[test] + fn user_playlist_api_path_builds_expected_bouquet_route() { + assert_eq!(user_playlist_api_path("", "bouquet"), "/api/v1/user/playlist/bouquet"); + assert_eq!(user_playlist_api_path("/tp", "bouquet"), "/tp/api/v1/user/playlist/bouquet"); + } +} diff --git a/shared/src/model/cluster_flags.rs b/shared/src/model/cluster_flags.rs index 70587297a..edb85fb31 100644 --- a/shared/src/model/cluster_flags.rs +++ b/shared/src/model/cluster_flags.rs @@ -77,6 +77,9 @@ impl TryFrom<&str> for ClusterFlags { fn try_from(value: &str) -> Result { let input = value.trim().trim_matches(|c| ['[', ']', '(', ')'].contains(&c)); + if input.is_empty() { + return Ok(Self::empty()); + } let items = input.split(',').map(str::trim); ClusterFlags::from_items(items) } diff --git a/shared/src/model/config/api_user.rs b/shared/src/model/config/api_user.rs index ef7f7cf9e..c273d003a 100644 --- a/shared/src/model/config/api_user.rs +++ b/shared/src/model/config/api_user.rs @@ -2,7 +2,7 @@ use crate::{ error::TuliproxError, model::{ClusterFlags, ProxyType, ProxyUserStatus, XtreamCluster}, utils::{ - default_as_true, default_user_priority, deserialize_timestamp, is_blank_optional_string, + default_as_true, default_user_priority, deserialize_timestamp, is_blank_optional_string, is_cluster_optional, is_default_user_priority, is_true, }, }; @@ -14,8 +14,6 @@ pub enum UserConnectionPermission { GracePeriod, } -fn default_output_clusters() -> ClusterFlags { ClusterFlags::all() } - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ProxyUserCredentialsDto { @@ -39,8 +37,8 @@ pub struct ProxyUserCredentialsDto { pub max_connections: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, - #[serde(default = "default_output_clusters")] - pub output_clusters: ClusterFlags, + #[serde(default, skip_serializing_if = "is_cluster_optional")] + pub output_clusters: Option, #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub ui_enabled: bool, #[serde(default, skip_serializing_if = "is_blank_optional_string")] @@ -99,11 +97,11 @@ impl ProxyUserCredentialsDto { } pub fn allows_cluster(&self, cluster: XtreamCluster) -> bool { - match cluster { - XtreamCluster::Live => self.output_clusters.contains(ClusterFlags::Live), - XtreamCluster::Video => self.output_clusters.contains(ClusterFlags::Vod), - XtreamCluster::Series => self.output_clusters.contains(ClusterFlags::Series), - } + self.output_clusters.as_ref().is_none_or(|output_clusters| match cluster { + XtreamCluster::Live => output_clusters.contains(ClusterFlags::Live), + XtreamCluster::Video => output_clusters.contains(ClusterFlags::Vod), + XtreamCluster::Series => output_clusters.contains(ClusterFlags::Series), + }) } } @@ -121,7 +119,7 @@ impl Default for ProxyUserCredentialsDto { exp_date: None, max_connections: 0, status: None, - output_clusters: default_output_clusters(), + output_clusters: None, ui_enabled: default_as_true(), comment: None, priority: default_user_priority(), @@ -136,7 +134,7 @@ mod tests { use super::*; #[test] - fn proxy_user_credentials_defaults_output_clusters_to_all_when_missing() { + fn proxy_user_credentials_output_clusters_none_preserved() { let value = serde_json::json!({ "username": "alice", "password": "secret" @@ -144,7 +142,7 @@ mod tests { let user: ProxyUserCredentialsDto = serde_json::from_value(value).expect("user should deserialize"); - assert_eq!(user.output_clusters, ClusterFlags::all()); + assert_eq!(user.output_clusters, None); } #[test] @@ -152,14 +150,30 @@ mod tests { let user = ProxyUserCredentialsDto { username: "alice".to_string(), password: "secret".to_string(), - output_clusters: ClusterFlags::Live | ClusterFlags::Series, - ..Default::default() + output_clusters: Some(ClusterFlags::Live | ClusterFlags::Series), + ..ProxyUserCredentialsDto::default() }; let serialized = serde_json::to_value(&user).expect("user should serialize"); let deserialized: ProxyUserCredentialsDto = serde_json::from_value(serialized).expect("user should deserialize"); - assert_eq!(deserialized.output_clusters, ClusterFlags::Live | ClusterFlags::Series); + assert_eq!(deserialized.output_clusters, Some(ClusterFlags::Live | ClusterFlags::Series)); + } + + #[test] + fn proxy_user_credentials_roundtrip_preserves_empty_output_clusters() { + let user = ProxyUserCredentialsDto { + username: "alice".to_string(), + password: "secret".to_string(), + output_clusters: Some(ClusterFlags::empty()), + ..ProxyUserCredentialsDto::default() + }; + + let serialized = serde_json::to_value(&user).expect("user should serialize"); + let deserialized: ProxyUserCredentialsDto = + serde_json::from_value(serialized).expect("user should deserialize"); + + assert_eq!(deserialized.output_clusters, Some(ClusterFlags::empty())); } } diff --git a/shared/src/model/config/web_ui.rs b/shared/src/model/config/web_ui.rs index 72d73c928..4730b4394 100644 --- a/shared/src/model/config/web_ui.rs +++ b/shared/src/model/config/web_ui.rs @@ -1,4 +1,5 @@ use crate::{ + apply_flags, create_bitset, error::TuliproxError, model::{view_type::ViewType, WebAuthConfigDto}, utils::{ @@ -29,6 +30,78 @@ const RESERVED_PATHS: &[&str] = &[ fn default_web_ui_path() -> Option { Some("/".to_string()) } +create_bitset!( + u16, + StreamInfoFields, + HideGroup, + HideIp, + HideCountry, + HideShared, + HideDuration, + HideBandwidth, + HideTransferred, + HidePlayer, + HideUserComment, + HideEpg +); + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +pub struct StreamInfoConfigDto { + #[serde(default, skip_serializing_if = "is_false")] + pub hide_group: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_ip: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_country: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_shared: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_duration: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_bandwidth: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_transferred: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_player: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_user_comment: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub hide_epg: bool, +} + +impl StreamInfoConfigDto { + pub fn is_empty(&self) -> bool { + !self.hide_group + && !self.hide_ip + && !self.hide_country + && !self.hide_shared + && !self.hide_duration + && !self.hide_bandwidth + && !self.hide_transferred + && !self.hide_player + && !self.hide_user_comment + && !self.hide_epg + } + + pub fn get_flags(&self) -> StreamInfoFieldsSet { + let mut flags = StreamInfoFieldsSet::new(); + apply_flags!( + self, flags, StreamInfoFields; + (hide_group, HideGroup), + (hide_ip, HideIp), + (hide_country, HideCountry), + (hide_shared, HideShared), + (hide_duration, HideDuration), + (hide_bandwidth, HideBandwidth), + (hide_transferred, HideTransferred), + (hide_player, HidePlayer), + (hide_user_comment, HideUserComment), + (hide_epg, HideEpg) + ); + flags + } +} + fn is_blank_or_default_web_ui_path(path: &Option) -> bool { path.as_ref().is_none_or(|value| { let trimmed = value.trim(); @@ -96,6 +169,8 @@ pub struct WebUiConfigDto { pub combine_views_stats_streams: bool, #[serde(default, skip_serializing_if = "ViewType::is_default")] pub landing_page: ViewType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_info: Option, } impl Default for WebUiConfigDto { @@ -110,6 +185,7 @@ impl Default for WebUiConfigDto { kick_secs: default_kick_secs(), combine_views_stats_streams: false, landing_page: ViewType::default(), + stream_info: None, } } } @@ -127,6 +203,7 @@ impl WebUiConfigDto { && (self.content_security_policy.is_none() || self.content_security_policy.as_ref().is_some_and(|c| c.is_empty())) && (self.auth.is_none() || self.auth.as_ref().is_some_and(|c| c.is_empty())) + && (self.stream_info.is_none() || self.stream_info.as_ref().is_some_and(|s| s.is_empty())) } pub fn clean(&mut self) { @@ -136,6 +213,9 @@ impl WebUiConfigDto { if self.auth.as_ref().is_some_and(|c| c.is_empty()) { self.auth = None; } + if self.stream_info.as_ref().is_some_and(|s| s.is_empty()) { + self.stream_info = None; + } if is_blank_or_default_web_ui_path(&self.path) { self.path = None; @@ -176,3 +256,50 @@ impl WebUiConfigDto { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_info_config_dto_default_has_all_flags_false() { + let dto = StreamInfoConfigDto::default(); + assert!(!dto.hide_group); + assert!(!dto.hide_ip); + assert!(!dto.hide_country); + assert!(!dto.hide_shared); + assert!(!dto.hide_duration); + assert!(!dto.hide_bandwidth); + assert!(!dto.hide_transferred); + assert!(!dto.hide_player); + assert!(!dto.hide_user_comment); + assert!(!dto.hide_epg); + } + + #[test] + fn stream_info_config_dto_is_empty_when_all_flags_false() { + let dto = StreamInfoConfigDto::default(); + assert!(dto.is_empty()); + } + + #[test] + fn stream_info_config_dto_is_not_empty_when_any_flag_true() { + let dto = StreamInfoConfigDto { hide_ip: true, ..StreamInfoConfigDto::default() }; + assert!(!dto.is_empty()); + } + + #[test] + fn web_ui_config_dto_clean_normalizes_all_false_stream_info_to_none() { + let dto = WebUiConfigDto { stream_info: Some(StreamInfoConfigDto::default()), ..WebUiConfigDto::default() }; + let mut dto = dto; + dto.clean(); + assert!(dto.stream_info.is_none()); + } + + #[test] + fn web_ui_config_dto_is_empty_treats_none_stream_info_as_absent() { + let dto = WebUiConfigDto::default(); + assert!(dto.stream_info.is_none()); + assert!(dto.is_empty()); + } +} diff --git a/shared/src/model/epg.rs b/shared/src/model/epg.rs index 3f442672a..a1d12959f 100644 --- a/shared/src/model/epg.rs +++ b/shared/src/model/epg.rs @@ -94,3 +94,46 @@ impl EpgProgramme { Self { start, stop, channel, title, desc } } } + +/// Request DTO for per-stream EPG lookup. +/// The Vec allows future batch expansion (multiple epg_channel_ids) without a redesign. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StreamEpgRequest { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamEpgItemRequest { + pub epg_channel_id: String, + #[serde(default)] + pub target_id: Option, +} + +/// Response DTO for per-stream EPG lookup. +/// Contains entries per unique epg_channel_id, programmes already filtered to 8h window. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StreamEpgResponse { + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamEpgEntry { + pub epg_channel_id: String, + #[serde(default)] + pub target_id: Option, + pub programmes: Vec, +} + +/// Programme DTO with timeshift-adjusted display strings and raw timestamps for local current/next computation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpgProgrammeDto { + /// Raw UTC start timestamp — use for local current/next computation. + pub start_timestamp: i64, + /// Raw UTC stop timestamp. + pub stop_timestamp: i64, + /// Timeshift-adjusted display string, e.g. "20260421090000 +0200". + pub start: String, + /// Timeshift-adjusted display string. + pub stop: String, + pub title: String, +} diff --git a/shared/src/model/stream_info.rs b/shared/src/model/stream_info.rs index bf0faefb7..ba4f5ba9d 100644 --- a/shared/src/model/stream_info.rs +++ b/shared/src/model/stream_info.rs @@ -1,6 +1,8 @@ use crate::{ model::{M3uPlaylistItem, PlaylistEntry, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}, - utils::{arc_str_serde, current_time_secs, extract_extension_from_url, is_blank_optional_string}, + utils::{ + arc_str_option_serde, arc_str_serde, current_time_secs, extract_extension_from_url, is_blank_optional_string, + }, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -55,6 +57,10 @@ pub struct StreamChannel { pub shared_stream_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub technical: Option, + // EPG channel identifier for per-stream programme lookup. + // None when no EPG is configured for the underlying input/target/item. + #[serde(default, skip_serializing_if = "Option::is_none", with = "arc_str_option_serde")] + pub epg_channel_id: Option>, } pub fn create_stream_channel_with_type( @@ -85,6 +91,7 @@ impl XtreamPlaylistItem { shared_joined_existing: None, shared_stream_id: None, technical: stream_technical_from_properties(self.additional_properties.as_ref(), self.url.as_ref()), + epg_channel_id: self.epg_channel_id.clone(), } } } @@ -106,6 +113,7 @@ impl M3uPlaylistItem { shared_joined_existing: None, shared_stream_id: None, technical: stream_technical_from_properties(self.additional_properties.as_ref(), self.url.as_ref()), + epg_channel_id: self.epg_channel_id.clone(), } } } diff --git a/shared/src/model/ui_playlist_item.rs b/shared/src/model/ui_playlist_item.rs index 4115cd8d9..e4855f724 100644 --- a/shared/src/model/ui_playlist_item.rs +++ b/shared/src/model/ui_playlist_item.rs @@ -3,7 +3,7 @@ use crate::{ CommonPlaylistItem, M3uPlaylistItem, PlaylistItem, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem, }, - utils::{arc_str_serde, Internable}, + utils::{arc_str_option_serde, arc_str_serde, Internable}, }; use serde_tuple::{Deserialize_tuple, Serialize_tuple}; use std::sync::Arc; @@ -35,6 +35,9 @@ pub struct UiPlaylistItem { pub rating: f64, #[serde(rename = "i", with = "arc_str_serde")] pub input_name: Arc, + // EPG channel identifier for per-stream programme lookup. None when no EPG is configured. + #[serde(rename = "e", default, skip_serializing_if = "Option::is_none", with = "arc_str_option_serde")] + pub epg_channel_id: Option>, } /// Helper to pick the best logo: prefer `logo` if non-empty, else `logo_small` @@ -95,6 +98,7 @@ impl From<&CommonPlaylistItem> for UiPlaylistItem { category_id: item.category_id.unwrap_or(0), rating: get_rating(item.additional_properties.as_ref()), input_name: Arc::clone(&item.input_name), + epg_channel_id: item.epg_channel_id.clone(), } } } @@ -114,6 +118,7 @@ impl From for UiPlaylistItem { category_id: item.category_id, rating: get_rating(item.additional_properties.as_ref()), input_name: Arc::clone(&item.input_name), + epg_channel_id: item.epg_channel_id, } } } @@ -133,6 +138,7 @@ impl From for UiPlaylistItem { category_id: 0, rating: 0.0, input_name: Arc::clone(&item.input_name), + epg_channel_id: item.epg_channel_id, } } } @@ -153,6 +159,7 @@ impl From<&PlaylistItem> for UiPlaylistItem { category_id: header.category_id, rating: get_rating(header.additional_properties.as_ref()), input_name: Arc::clone(&header.input_name), + epg_channel_id: header.epg_channel_id.clone(), } } } diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index 4ec0659a5..941e06ad0 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -1,6 +1,6 @@ use crate::{ model::{ - ConfigTargetOptions, LibraryMetadataFormat, MetadataUpdateConfigDto, ProcessingOrder, + ClusterFlags, ConfigTargetOptions, LibraryMetadataFormat, MetadataUpdateConfigDto, ProcessingOrder, RuntimeConfigReportFormat, VideoConfigDto, }, utils::{ @@ -34,6 +34,8 @@ pub const fn is_default_runtime_config_report_format(value: &RuntimeConfigReport matches!(value, RuntimeConfigReportFormat::Yaml) } +pub fn is_cluster_optional(cf: &Option) -> bool { cf.is_none_or(|c| c.is_all()) } + // Default delay values for resolving VOD or Series requests, // used to prevent frequent requests that could trigger a provider ban. pub const fn default_resolve_delay_secs() -> u16 { 2 }