From d116b7d51e256ae91bc55f750ecd592dbe3b53fd Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:59:54 +0200 Subject: [PATCH] Feature/bouquet editor (#854) New Features Added target-specific bouquet filtering with whitelist and blacklist modes for Live, VOD, and Series groups. Added an in-context bouquet editor with search, selection controls, status indicators, reset, and streaming previews. Targets are managed by name, with saved selections available through the Source Editor. Improved stream alias resolution and provider failover behavior. Bug Fixes Existing playlists are preserved when refreshes produce no usable items. Empty playlists are no longer published. Configuration changes now recover more safely from persistence failures. Documentation Updated feature and configuration documentation and added translations. --- CHANGELOG.md | 12 + Cargo.lock | 4 + README.md | 3 +- backend/app/src/api/api_utils/mod.rs | 16 +- .../app/src/api/endpoints/hls_api/manifest.rs | 4 +- backend/app/src/api/endpoints/hls_api/mod.rs | 4 +- .../app/src/api/endpoints/hls_api/segment.rs | 4 +- .../app/src/api/endpoints/hls_api/session.rs | 87 +- .../app/src/api/endpoints/hls_api/tests.rs | 100 ++ backend/app/src/api/endpoints/mod.rs | 1 + backend/app/src/api/endpoints/rbac_api.rs | 38 +- .../src/api/endpoints/target_bouquet_api.rs | 593 ++++++++++++ backend/app/src/api/endpoints/v1_api.rs | 5 + .../app/src/api/endpoints/v1_api_config.rs | 291 +++++- backend/core/Cargo.toml | 4 + backend/core/src/model/mod.rs | 5 +- backend/core/src/model/target_bouquet.rs | 131 +++ backend/core/src/utils/atomic_json_store.rs | 156 +++- backend/iptv/src/error.rs | 1 + backend/iptv/src/xtream/mod.rs | 24 +- .../src/processor/playlist/target.rs | 19 +- .../src/processor/playlist/tests.rs | 111 ++- .../src/processor/playlist/transform.rs | 61 +- backend/processing/src/processor/stalker.rs | 42 +- .../src/processor/stalker_refresh.rs | 102 ++- backend/repository/Cargo.toml | 2 + backend/repository/src/lib.rs | 4 + backend/repository/src/playlist_repository.rs | 102 ++- .../src/raw_group_catalog_repository.rs | 251 +++++ .../src/target_bouquet_repository.rs | 861 ++++++++++++++++++ backend/repository/src/xtream_repository.rs | 233 ++++- docs/src/configuration/source.md | 32 + docs/src/features.md | 2 + frontend/Cargo.toml | 2 +- frontend/public/assets/i18n/ar.json | 23 + frontend/public/assets/i18n/en.json | 23 + frontend/public/assets/i18n/ru.json | 23 + frontend/public/assets/icons.json | 4 +- frontend/scss/_theme.scss | 2 +- frontend/scss/app/_component.scss | 1 + .../app/components/_target_bouquet_view.scss | 115 +++ .../source_editor/_source_editor.scss | 10 + .../components/api_user/target_playlist.rs | 470 +--------- .../app/components/bouquet_editor/editor.rs | 617 +++++++++++++ .../src/app/components/bouquet_editor/mod.rs | 5 + .../bouquet_editor/target_bouquet_view.rs | 426 +++++++++ frontend/src/app/components/home.rs | 14 +- frontend/src/app/components/mod.rs | 1 + frontend/src/app/components/sidebar.rs | 4 + .../components/source_editor/editor_model.rs | 2 + .../components/source_editor/editor_view.rs | 30 +- .../components/source_editor/target_form.rs | 124 ++- frontend/src/services/mod.rs | 5 +- .../src/services/target_bouquet_service.rs | 259 ++++++ shared/src/error/tuliprox_error.rs | 1 + shared/src/model/mod.rs | 5 +- shared/src/model/playlist_categories.rs | 31 + shared/src/model/target_bouquet.rs | 181 ++++ 58 files changed, 5038 insertions(+), 645 deletions(-) create mode 100644 backend/app/src/api/endpoints/target_bouquet_api.rs create mode 100644 backend/core/src/model/target_bouquet.rs create mode 100644 backend/repository/src/raw_group_catalog_repository.rs create mode 100644 backend/repository/src/target_bouquet_repository.rs create mode 100644 frontend/scss/app/components/_target_bouquet_view.scss create mode 100644 frontend/src/app/components/bouquet_editor/editor.rs create mode 100644 frontend/src/app/components/bouquet_editor/mod.rs create mode 100644 frontend/src/app/components/bouquet_editor/target_bouquet_view.rs create mode 100644 frontend/src/services/target_bouquet_service.rs create mode 100644 shared/src/model/target_bouquet.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 459bf1e95..bab05c32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,12 @@ ## ๐ŸŒŸ New Features +- **Target-specific bouquet filters are now managed directly from the Source Editor.** Each target shows its current + bouquet status below the regular filter settings and opens a full-size editor for selecting Live, VOD, and Series + groups. Bouquet filters support both whitelist and blacklist mode, are stored by the target's unique name, and take + effect on the next playlist update. Leaving every cluster unselected means no bouquet restriction; an individual + cluster may intentionally have no selected groups while another cluster remains configured. + - **Target filters can run during processing or immediately before persistence.** The existing scalar `filter` syntax remains the `processing` stage. The staged map accepts optional `processing` and `persist` filters; `persist` sees the fully finalized state after EPG processing, mappings, merge, deduplication, sorting, numbering, and counters. @@ -928,6 +934,12 @@ ## ๐Ÿ› Fixes +- **Empty playlist updates no longer replace previously published input or target data.** A completely empty refresh is + treated as a failed update and keeps the last usable playlist and its virtual-ID mapping intact. This prevents + transient provider/download failures from making channels disappear or assigning different IDs when service + recovers. An intentionally empty target must therefore be disabled or filtered operationally instead of being + published as an empty refresh. + - **M3U alias failover now uses the allocated provider's opaque query credentials.** When capacity allocation moved a stream from the primary input to an alias, a playlist URL carrying credentials such as `token` or `api_key` could retain the primary provider's value because failover only understood base URLs and username/password credentials. diff --git a/Cargo.lock b/Cargo.lock index 144a310f4..91d60a1fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4486,6 +4486,7 @@ dependencies = [ "chrono-tz", "ctr", "env_logger", + "fastrand", "flate2", "futures", "http-body-util", @@ -4518,6 +4519,7 @@ dependencies = [ "tuliprox-media-server", "tuliprox-mpegts", "url", + "windows-sys 0.61.2", "zeroize", ] @@ -4784,6 +4786,7 @@ version = "3.3.102" dependencies = [ "arc-swap", "base64 0.23.1", + "blake3", "bytes", "chrono", "crc32fast", @@ -4798,6 +4801,7 @@ dependencies = [ "paste", "rmp-serde", "serde", + "serde-saphyr", "serde_json", "shared", "tempfile", diff --git a/README.md b/README.md index 60d2c241c..13bc97481 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,8 @@ Generate all four formats simultaneously from the same source โ€” one setup, eve - **Dashboard**: System status, active streams, CPU usage, provider connections in real-time via WebSocket - **Source Editor**: Dedicated forms for M3U, Xtream, Stalker, Plex, Emby, Jellyfin and local-library inputs, with - drag & drop, block selection and batch mode + drag & drop, block selection and batch mode; target forms also provide a full-size bouquet editor with whitelist and + blacklist group selection - **Playlist Explorer**: Tree and gallery view for channels with EPG timeline and search โ€” text or regex, optionally scoped to specific fields (group, title, name, url) - **Download & Recording Manager**: Provider-aware VOD downloads and live recordings with retries, fairness, and RBAC-controlled actions diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index 075691f4a..7b0eb5cc0 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -1084,7 +1084,7 @@ fn stream_url_has_account_signature(stream_url: &str, user_info: &crate::model:: false } -async fn select_provider_stream_url( +pub(crate) async fn select_provider_stream_url( stream_url: &str, input: &ConfigInput, provider_cfg: &Arc, @@ -1098,9 +1098,21 @@ async fn select_provider_stream_url( if provider_cfg.input_type.is_m3u() { match load_input_m3u_stream_url(app_config, &provider_cfg.name, stream_url).await { Ok(Some(provider_stream_url)) => { + debug_if_enabled!( + "M3U alias URL lookup: provider={} requested_url={} index_match=true resolved_url={}", + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()), + sanitize_sensitive_info(resolve_request_url_for_logging(input, &provider_stream_url).as_ref()) + ); return Some((provider_cfg.name.clone(), provider_stream_url.to_string())); } - Ok(None) => {} + Ok(None) => { + debug_if_enabled!( + "M3U alias URL lookup: provider={} requested_url={} index_match=false", + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()) + ); + } Err(err) => { debug_if_enabled!( "Failed to resolve M3U stream URL for provider {}: {}", diff --git a/backend/app/src/api/endpoints/hls_api/manifest.rs b/backend/app/src/api/endpoints/hls_api/manifest.rs index 3e94c4a48..28536c91c 100644 --- a/backend/app/src/api/endpoints/hls_api/manifest.rs +++ b/backend/app/src/api/endpoints/hls_api/manifest.rs @@ -665,7 +665,9 @@ pub(super) async fn try_reserve_hls_entry_origin_account_for_redirect( app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; return None; }; - let Some(stream_url) = get_stream_alternative_url(request_url, input, &provider_config) else { + let Some((_provider_name, stream_url)) = + select_provider_stream_url(request_url, input, &provider_config, false, &app_state.app_config).await + else { app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; return None; }; diff --git a/backend/app/src/api/endpoints/hls_api/mod.rs b/backend/app/src/api/endpoints/hls_api/mod.rs index 3440f3b5f..6bd7fad18 100644 --- a/backend/app/src/api/endpoints/hls_api/mod.rs +++ b/backend/app/src/api/endpoints/hls_api/mod.rs @@ -18,8 +18,8 @@ use crate::{ create_playback_session_fingerprint, create_session_fingerprint, force_provider_stream_response, get_headers_from_request, get_hls_session_ttl_secs, get_stream_alternative_url, is_hls_stream_share_enabled, is_seekable_media_request, local_stream_response, - record_connect_failed_attempt, resolve_playback_request_admission, try_option_bad_request, try_unwrap_body, - ConnectFailedAttempt, EvictionReentryGuard, HeaderFilter, + record_connect_failed_attempt, resolve_playback_request_admission, select_provider_stream_url, + try_option_bad_request, try_unwrap_body, ConnectFailedAttempt, EvictionReentryGuard, HeaderFilter, }, model::{ hls_cache::initial_strip::{ diff --git a/backend/app/src/api/endpoints/hls_api/segment.rs b/backend/app/src/api/endpoints/hls_api/segment.rs index cdc163eb6..de05e966b 100644 --- a/backend/app/src/api/endpoints/hls_api/segment.rs +++ b/backend/app/src/api/endpoints/hls_api/segment.rs @@ -2375,7 +2375,9 @@ pub(in crate::api) async fn handle_hls_stream_request( Some(ProviderAllocation::Exhausted) => (url, None, provider_handle, None), Some(ProviderAllocation::Available(cfg) | ProviderAllocation::GracePeriod(cfg)) => { let selected_provider_config = Arc::clone(cfg); - let Some(stream_url) = get_stream_alternative_url(&url, input, cfg) else { + let Some((_provider_name, stream_url)) = + select_provider_stream_url(&url, input, cfg, false, &app_state.app_config).await + else { app_state.connection_manager.release_provider_handle(provider_handle).await; return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; diff --git a/backend/app/src/api/endpoints/hls_api/session.rs b/backend/app/src/api/endpoints/hls_api/session.rs index 46717ecf5..853cb01ec 100644 --- a/backend/app/src/api/endpoints/hls_api/session.rs +++ b/backend/app/src/api/endpoints/hls_api/session.rs @@ -1,5 +1,7 @@ #![allow(clippy::wildcard_imports)] + use super::*; +use tuliprox_core::model::AppConfig; #[derive(Clone, Debug)] pub(super) enum HlsOriginEntryUrl { @@ -218,6 +220,35 @@ pub(super) fn build_hls_origin_fetch_url( } } +pub(super) async fn resolve_hls_origin_fetch_url( + app_config: &Arc, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + provider_config: Option<&Arc>, +) -> Option { + if [session_entry_url, raw_request_url] + .into_iter() + .any(|url| hls_url_failover_provider_for_origin_url(input, url).is_some()) + { + return build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, provider_config); + } + + if let Some(provider_config) = provider_config { + for candidate in [raw_request_url, session_entry_url] { + if let Some((_provider_name, url)) = + select_provider_stream_url(candidate, input, provider_config, false, app_config).await + { + if is_supported_hls_origin_url(input, &url) { + return Some(url); + } + } + } + } + + build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, provider_config) +} + pub(super) fn rewrite_hls_provider_scheme_origin_account( provider_scheme_url: &str, input: &ConfigInput, @@ -632,7 +663,8 @@ pub(super) async fn prepare_hls_origin_runtime( raw_request_url, session_entry_url, &binding, - )); + ) + .await); } } } @@ -801,7 +833,14 @@ pub(super) async fn prepare_hls_origin_runtime_with_new_account( return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + let Some(fetch_url) = resolve_hls_origin_fetch_url( + &app_state.app_config, + input, + raw_request_url, + session_entry_url, + Some(&provider_config), + ) + .await else { app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); @@ -847,7 +886,7 @@ pub(super) async fn prepare_hls_origin_runtime_with_new_account( }) } -pub(super) fn prepared_hls_origin_runtime_for_known_binding( +pub(super) async fn prepared_hls_origin_runtime_for_known_binding( app_state: &Arc, input: &ConfigInput, raw_request_url: &str, @@ -855,12 +894,15 @@ pub(super) fn prepared_hls_origin_runtime_for_known_binding( binding: &HlsOriginAccountBinding, ) -> PreparedHlsOriginRuntime { let runtime_provider_config = app_state.active_provider.find_provider_config(&binding.account_name); - let fetch_url = runtime_provider_config - .as_ref() - .and_then(|provider_config| { - build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(provider_config)) - }) - .unwrap_or_else(|| session_entry_url.to_string()); + let fetch_url = resolve_hls_origin_fetch_url( + &app_state.app_config, + input, + raw_request_url, + session_entry_url, + runtime_provider_config.as_ref(), + ) + .await + .unwrap_or_else(|| session_entry_url.to_string()); PreparedHlsOriginRuntime { runtime_provider_config, @@ -997,7 +1039,14 @@ pub(super) async fn prepare_hls_origin_policy_preempt_runtime( debug!("HLS origin policy preemption denied: reason=missing-provider-config"); return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + let Some(fetch_url) = resolve_hls_origin_fetch_url( + &app_state.app_config, + input, + raw_request_url, + session_entry_url, + Some(&provider_config), + ) + .await else { app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; @@ -1194,7 +1243,14 @@ pub(super) async fn prepare_hls_speculative_origin_runtime( debug!("HLS account overlap denied: reason=missing-provider-config"); return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + let Some(fetch_url) = resolve_hls_origin_fetch_url( + &app_state.app_config, + input, + raw_request_url, + session_entry_url, + Some(&provider_config), + ) + .await else { app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; @@ -1563,7 +1619,14 @@ pub(super) async fn rebind_hls_origin_account( return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + let Some(fetch_url) = resolve_hls_origin_fetch_url( + &app_state.app_config, + input, + raw_request_url, + session_entry_url, + Some(&provider_config), + ) + .await else { app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "invalid_origin_url").await; diff --git a/backend/app/src/api/endpoints/hls_api/tests.rs b/backend/app/src/api/endpoints/hls_api/tests.rs index ccb226964..293e0267a 100644 --- a/backend/app/src/api/endpoints/hls_api/tests.rs +++ b/backend/app/src/api/endpoints/hls_api/tests.rs @@ -7988,6 +7988,106 @@ async fn hls_entry_origin_reservation_sets_owner_reservation_before_redirect() { app_state.connection_manager.release_provider_handle(same_owner_handle).await; } +#[tokio::test] +async fn hls_entry_origin_reservation_uses_persisted_alias_manifest_url() { + use shared::model::PlaylistGroup; + use tuliprox_repository::{get_input_m3u_playlist_file_path, get_input_storage_path, persist_input_m3u_playlist}; + + let temp = tempfile::tempdir().expect("temp dir should be created"); + let input = ConfigInput { + id: 1, + name: Arc::from("primary-account"), + input_type: InputType::M3u, + url: "http://playlist.example/list.m3u?access_key=primary-playlist-key".to_string(), + enabled: true, + max_connections: 1, + aliases: Some(vec![crate::model::ConfigInputAlias { + id: 2, + name: Arc::from("alias-account"), + url: "http://playlist.example/list.m3u?access_key=alias-playlist-key".to_string(), + username: None, + password: None, + max_connections: 0, + priority: 0, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let mut config = (*app_state.app_config.config.load_full()).clone(); + config.storage_dir = temp.path().to_string_lossy().into_owned(); + app_state.app_config.config.store(Arc::new(config)); + + let alias_name = "alias-account".intern(); + let storage_path = get_input_storage_path(&alias_name, &app_state.app_config.config.load().storage_dir) + .await + .expect("alias storage should be created"); + let playlist_path = get_input_m3u_playlist_file_path(&storage_path, &alias_name); + let alias_playlist = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + id: "news24hd".intern(), + input_stream_id: "news24hd".intern(), + url: "http://stream.example:4000/news24hd/mono.m3u8?token=alias-stream-token".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..PlaylistItemHeader::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }]; + persist_input_m3u_playlist(&app_state.app_config, &playlist_path, &alias_playlist) + .await + .expect("alias playlist should persist"); + + let primary_handle = app_state + .active_provider + .acquire_exact_connection_with_grace_for_session( + &input.name, + &test_fingerprint().addr, + false, + 0, + ConnectionKind::Normal, + Some("primary-session"), + ) + .await + .expect("primary account should be allocated"); + + let reservation = super::try_reserve_hls_entry_origin_account_for_redirect( + &app_state, + &test_fingerprint(), + &{ + let mut creds = ProxyUserCredentials::default(); + creds.username = "hls-user".to_string(); + creds + }, + &input, + 12345, + "http://stream.example:4000/news24hd/mono.m3u8?token=primary-stream-token", + "alias-session-token", + "alias-session-owner", + super::hls_origin_account_reservation_ttl_secs_fallback(), + UserConnectionPermission::Allowed, + ConnectionKind::Normal, + false, + ) + .await + .expect("alias provider reservation should succeed"); + + assert_eq!( + reservation.selected_provider_config.as_ref().map(|provider| provider.name.as_ref()), + Some("alias-account") + ); + assert_eq!(reservation.request_url, "http://stream.example:4000/news24hd/mono.m3u8?token=alias-stream-token"); + + app_state.connection_manager.release_provider_handle(reservation.provider_handle).await; + app_state.connection_manager.release_provider_handle(Some(primary_handle)).await; +} + #[tokio::test] async fn hls_virtual_entry_reservation_uses_input_stream_id_for_shared_session_owner() { let input = single_hls_provider_input("origin-id-reservation-input"); diff --git a/backend/app/src/api/endpoints/mod.rs b/backend/app/src/api/endpoints/mod.rs index 112f38f74..e34fa6d0a 100644 --- a/backend/app/src/api/endpoints/mod.rs +++ b/backend/app/src/api/endpoints/mod.rs @@ -13,6 +13,7 @@ mod rbac_api; pub(in crate::api) mod recording_api; pub(in crate::api) mod recording_media_api; mod stream_history_api; +pub(in crate::api) mod target_bouquet_api; mod user_api; mod user_visibility; pub(in crate::api) mod v1_api; diff --git a/backend/app/src/api/endpoints/rbac_api.rs b/backend/app/src/api/endpoints/rbac_api.rs index 75a34a265..585c34957 100644 --- a/backend/app/src/api/endpoints/rbac_api.rs +++ b/backend/app/src/api/endpoints/rbac_api.rs @@ -12,7 +12,6 @@ use axum::{ Json, Router, }; use log::{info, warn}; -use rand::Rng; use serde::Deserialize; use serde_json::json; use shared::{ @@ -58,43 +57,8 @@ struct PermissionInfo { reserved: bool, } -fn create_temp_path(file_path: &FsPath) -> PathBuf { - let file_name = - file_path.file_name().and_then(|value| value.to_str()).filter(|value| !value.is_empty()).unwrap_or("rbac"); - let temp_name = format!(".{file_name}.tmp-{}-{}", std::process::id(), rand::rng().random::()); - match file_path.parent() { - Some(parent) => parent.join(temp_name), - None => PathBuf::from(temp_name), - } -} - -async fn replace_file_atomic(source: &FsPath, target: &FsPath) -> std::io::Result<()> { - match tokio::fs::rename(source, target).await { - Ok(()) => Ok(()), - Err(err) => { - #[cfg(windows)] - { - if target.exists() { - tokio::fs::remove_file(target).await?; - return tokio::fs::rename(source, target).await; - } - } - Err(err) - } - } -} - async fn write_text_file_atomic(path: &FsPath, content: &str) -> Result<(), std::io::Error> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let temp_path = create_temp_path(path); - tokio::fs::write(&temp_path, content).await?; - if let Err(err) = replace_file_atomic(&temp_path, path).await { - let _ = tokio::fs::remove_file(&temp_path).await; - return Err(err); - } - Ok(()) + utils::write_text_file_atomic(path, content).await } fn serialize_users_file(users: &[WebUiUser]) -> String { diff --git a/backend/app/src/api/endpoints/target_bouquet_api.rs b/backend/app/src/api/endpoints/target_bouquet_api.rs new file mode 100644 index 000000000..a6c5bfe3a --- /dev/null +++ b/backend/app/src/api/endpoints/target_bouquet_api.rs @@ -0,0 +1,593 @@ +use crate::api::{ + api_utils::stream_json_or_bin_response_stream, auth_middleware::permission_layer, + endpoints::extract_accept_header::ExtractAcceptHeader, model::AppState, +}; +use axum::{ + extract::{Query, State}, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, put}, + Json, Router, +}; +use shared::{ + error::TuliproxError, + model::{ + permission::Permission, TargetBouquetDto, TargetBouquetStatusDto, TargetBouquetStreamEventDto, XtreamCluster, + }, +}; +use std::sync::Arc; +use tuliprox_core::model::{ConfigInput, ConfigInputFlags, ConfigTarget, SourcesConfig}; + +pub fn target_bouquet_api_register_with_permissions(app_state: &Arc) -> Router> { + let read_routes = Router::new() + .route("/target-bouquets/status", get(get_target_bouquet_status)) + .route("/target-bouquets/groups", get(get_target_bouquet_groups)) + .layer(permission_layer!(app_state, Permission::PlaylistRead)); + + let write_routes = Router::new() + .route("/target-bouquets/selection", put(save_target_bouquet_handler)) + .route("/target-bouquets/selection", delete(delete_target_bouquet_handler)) + .layer(permission_layer!(app_state, Permission::PlaylistWrite)); + + Router::new().merge(read_routes).merge(write_routes) +} + +pub fn target_bouquet_api_register_unprotected() -> Router> { + Router::new() + .route("/target-bouquets/status", get(get_target_bouquet_status)) + .route("/target-bouquets/groups", get(get_target_bouquet_groups)) + .route("/target-bouquets/selection", put(save_target_bouquet_handler)) + .route("/target-bouquets/selection", delete(delete_target_bouquet_handler)) +} + +#[derive(serde::Deserialize)] +struct TargetBouquetQuery { + target: String, +} + +async fn send_stream_event( + tx: &tokio::sync::mpsc::Sender, + event: TargetBouquetStreamEventDto, +) -> bool { + tx.send(event).await.is_ok() +} + +fn resolve_target_with_inputs( + sources: &SourcesConfig, + target_name: &str, +) -> Option<(Arc, Vec>)> { + sources.sources.iter().find_map(|source| { + let target = source.targets.iter().find(|target| target.name == target_name)?; + let inputs = source + .inputs + .iter() + .filter_map(|input_name| sources.inputs.iter().find(|input| input.name == *input_name).cloned()) + .collect(); + Some((Arc::clone(target), inputs)) + }) +} + +async fn target_summary(state: &AppState, target_name: &str) -> Result, TuliproxError> { + let sources = state.app_config.sources.load(); + let target = + sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == target_name).cloned(); + drop(sources); + let Some(target) = target else { return Ok(None) }; + let bouquet = tuliprox_repository::load_target_bouquet(&state.app_config, &target.name).await?; + let active_bouquet = bouquet.as_ref().filter(|value| !value.is_unrestricted()); + Ok(Some(TargetBouquetStatusDto { + name: target.name.clone(), + mode: active_bouquet.map(|value| value.bouquet.mode), + group_count: active_bouquet.map_or(0, |value| { + [&value.bouquet.groups.live, &value.bouquet.groups.vod, &value.bouquet.groups.series] + .into_iter() + .flatten() + .map(Vec::len) + .sum() + }), + })) +} + +async fn stream_input_clusters( + tx: &tokio::sync::mpsc::Sender, + state: &AppState, + input: &ConfigInput, + storage_dir: &str, +) -> bool { + if !send_stream_event(tx, TargetBouquetStreamEventDto::InputStarted { input: input.name.to_string() }).await { + return false; + } + + let input_storage = tuliprox_repository::build_input_storage_path(&input.name, storage_dir); + let clusters = [ + (XtreamCluster::Live, !input.has_flag(ConfigInputFlags::SkipLive)), + (XtreamCluster::Video, !input.has_flag(ConfigInputFlags::SkipVod)), + (XtreamCluster::Series, !input.has_flag(ConfigInputFlags::SkipSeries)), + ]; + + let mut group_count = 0; + let mut missing_clusters = Vec::new(); + for (cluster, enabled) in clusters { + if !enabled { + continue; + } + match tuliprox_repository::load_raw_group_catalog( + &input_storage, + &input.name, + cluster, + &state.app_config.file_locks, + ) + .await + { + Ok(Some(cat)) => { + if cat.groups.is_empty() { + let event = TargetBouquetStreamEventDto::InputChunk { + input: input.name.to_string(), + cluster, + groups: Vec::new(), + is_last_for_cluster: true, + }; + if !send_stream_event(tx, event).await { + return false; + } + } else { + let chunk_size = 500; + let total_groups = cat.groups.len(); + let mut groups_iter = cat.groups.into_iter(); + let mut sent = 0; + while sent < total_groups { + let take_count = chunk_size.min(total_groups - sent); + let chunk: Vec = groups_iter.by_ref().take(take_count).collect(); + sent += chunk.len(); + group_count += chunk.len(); + let event = TargetBouquetStreamEventDto::InputChunk { + input: input.name.to_string(), + cluster, + groups: chunk, + is_last_for_cluster: sent == total_groups, + }; + if !send_stream_event(tx, event).await { + return false; + } + tokio::task::yield_now().await; + } + } + } + Ok(None) => { + missing_clusters.push(cluster); + } + Err(err) => { + let event = TargetBouquetStreamEventDto::InputWarning { + input: input.name.to_string(), + message: format!( + "Failed to load raw group catalog for input '{}' ({}): {err}", + input.name, + cluster.as_ref(), + ), + }; + if !send_stream_event(tx, event).await { + return false; + } + } + } + } + + if !missing_clusters.is_empty() { + let cluster_labels = missing_clusters.iter().map(std::convert::AsRef::as_ref).collect::>().join(", "); + let event = TargetBouquetStreamEventDto::InputWarning { + input: input.name.to_string(), + message: format!( + "Raw group catalog for input '{}' ({cluster_labels}) is not available. Please trigger a playlist update.", + input.name + ), + }; + if !send_stream_event(tx, event).await { + return false; + } + } + + send_stream_event( + tx, + TargetBouquetStreamEventDto::InputFinished { input: input.name.to_string(), groups: group_count }, + ) + .await +} + +async fn get_target_bouquet_status( + State(app_state): State>, + Query(query): Query, +) -> Response { + let mut response = match target_summary(&app_state, &query.target).await { + Ok(Some(target)) => Json(target).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), + ) + .into_response(), + Err(err) => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": err.to_string() }))).into_response() + } + }; + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +async fn get_target_bouquet_groups( + State(app_state): State>, + Query(query): Query, + ExtractAcceptHeader(accept): ExtractAcceptHeader, +) -> Response { + let sources = app_state.app_config.sources.load(); + let Some((target, inputs)) = resolve_target_with_inputs(&sources, &query.target) else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), + ) + .into_response(); + }; + drop(sources); + + let (selection, selection_warning) = + match tuliprox_repository::load_target_bouquet(&app_state.app_config, &target.name).await { + Ok(bouquet) => (bouquet.map(|value| value.bouquet), None), + Err(err) => ( + None, + Some(format!( + "Stored target bouquet for '{}' could not be loaded: {err}. Reset or save it to replace the invalid configuration.", + target.name + )), + ), + }; + let target_name = target.name.clone(); + + let (tx, rx) = tokio::sync::mpsc::channel(64); + let state = Arc::clone(&app_state); + tokio::spawn(async move { + if !send_stream_event(&tx, TargetBouquetStreamEventDto::Selection { bouquet: selection }).await { + return; + } + if let Some(message) = selection_warning { + let warning = TargetBouquetStreamEventDto::InputWarning { input: target_name, message }; + if !send_stream_event(&tx, warning).await { + return; + } + } + let storage_dir = state.app_config.config.load().storage_dir.clone(); + for input in inputs { + if !stream_input_clusters(&tx, &state, &input, &storage_dir).await { + return; + } + } + let _ = send_stream_event(&tx, TargetBouquetStreamEventDto::Complete).await; + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut response = stream_json_or_bin_response_stream(accept.as_deref(), stream); + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +async fn save_target_bouquet_handler( + State(app_state): State>, + Query(query): Query, + Json(bouquet): Json, +) -> Response { + let sources = app_state.app_config.sources.load(); + let Some(target) = + sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == query.target) + else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), + ) + .into_response(); + }; + + match tuliprox_repository::save_target_bouquet(&app_state.app_config, &target.name, bouquet).await { + Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))).into_response(), + Err(err) => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": err.to_string() }))).into_response() + } + } +} + +async fn delete_target_bouquet_handler( + State(app_state): State>, + Query(query): Query, +) -> Response { + let sources = app_state.app_config.sources.load(); + let Some(target) = + sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == query.target) + else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), + ) + .into_response(); + }; + + match tuliprox_repository::delete_target_bouquet(&app_state.app_config, &target.name).await { + Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))).into_response(), + Err(err) => { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": err.to_string() }))).into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::create_test_app_state; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use shared::model::{ + ConfigInputDto, ConfigSourceDto, ConfigTargetDto, PlaylistClusterBouquetDto, SourcesConfigDto, + TargetBouquetMode, + }; + use std::sync::Arc; + use tempfile::TempDir; + use tower::ServiceExt; + use tuliprox_core::model::{Config, SourcesConfig}; + + fn test_config_with_sources(temp_dir: &TempDir) -> (Config, SourcesConfig) { + let mut config = Config::default(); + config.storage_dir = temp_dir.path().to_string_lossy().to_string(); + + let sources_dto = SourcesConfigDto { + inputs: vec![ + ConfigInputDto { + name: "provider1".to_string().into(), + url: "http://localhost:8080/playlist.m3u".to_string(), + ..Default::default() + }, + ConfigInputDto { + name: "unconnected".to_string().into(), + url: "http://localhost:8080/unconnected.m3u".to_string(), + ..Default::default() + }, + ], + sources: vec![ConfigSourceDto { + inputs: vec!["provider1".to_string().into()], + targets: vec![ConfigTargetDto { name: "living_room".to_string(), ..Default::default() }], + }], + ..Default::default() + }; + let sources_config = SourcesConfig::try_from(&sources_dto).unwrap(); + (config, sources_config) + } + + fn setup_test_app_state(temp_dir: &TempDir) -> Arc { + let (config, sources) = test_config_with_sources(temp_dir); + let app_state = create_test_app_state(config); + app_state.app_config.sources.store(Arc::new(sources)); + app_state.app_config.paths.store(Arc::new(shared::model::ConfigPaths { + home_path: String::new(), + config_path: temp_dir.path().to_string_lossy().to_string(), + storage_path: temp_dir.path().to_string_lossy().to_string(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })); + app_state + } + + #[tokio::test] + async fn target_bouquets_crud_lifecycle() { + let temp_dir = TempDir::new().unwrap(); + let app_state = setup_test_app_state(&temp_dir); + + let router = target_bouquet_api_register_unprotected().with_state(Arc::clone(&app_state)); + + // An unconfigured target starts without a restricted selection. + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/groups?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert!(matches!(events.first(), Some(TargetBouquetStreamEventDto::Selection { bouquet: None }))); + + // Persist a restricted selection. + let bouquet_dto = TargetBouquetDto::new( + TargetBouquetMode::Blacklist, + PlaylistClusterBouquetDto { live: Some(vec!["News".to_string()]), vod: None, series: None }, + ); + let req = Request::builder() + .method("PUT") + .uri("/target-bouquets/selection?target=living_room") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&bouquet_dto).unwrap())) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // The lightweight target summary exposes the mode and selected group count for the target editor. + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/status?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let target: TargetBouquetStatusDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(target.mode, Some(TargetBouquetMode::Blacklist)); + assert_eq!(target.group_count, 1); + + // Subsequent reads expose the persisted selection. + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/groups?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert!(matches!( + events.first(), + Some(TargetBouquetStreamEventDto::Selection { bouquet: Some(bouquet) }) + if bouquet.mode == TargetBouquetMode::Blacklist + && bouquet.groups.live == Some(vec!["News".to_string()]) + )); + + // Reset the selection. + let req = Request::builder() + .method("DELETE") + .uri("/target-bouquets/selection?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Reset restores the unrestricted state. + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/groups?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert!(matches!(events.first(), Some(TargetBouquetStreamEventDto::Selection { bouquet: None }))); + } + + #[tokio::test] + async fn invalid_saved_bouquet_keeps_the_editor_available_for_recovery() { + let temp_dir = TempDir::new().unwrap(); + let app_state = setup_test_app_state(&temp_dir); + let bouquet_path = tuliprox_repository::target_bouquet_path(temp_dir.path(), "living_room"); + tokio::fs::create_dir_all(bouquet_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(bouquet_path, "version: 1\ntarget: living_room\ngroups:\n live:\n - News\n") + .await + .unwrap(); + + let router = target_bouquet_api_register_unprotected().with_state(app_state); + let request = Request::builder() + .method("GET") + .uri("/target-bouquets/groups?target=living_room") + .body(Body::empty()) + .unwrap(); + let response = router.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert!(matches!(events.first(), Some(TargetBouquetStreamEventDto::Selection { bouquet: None }))); + assert!(events.iter().any(|event| matches!( + event, + TargetBouquetStreamEventDto::InputWarning { input, message } + if input == "living_room" + && message.contains("could not be loaded") + && message.contains("Reset or save") + ))); + } + + #[tokio::test] + async fn target_bouquet_group_stream_contains_only_target_inputs() { + let temp_dir = TempDir::new().unwrap(); + let app_state = setup_test_app_state(&temp_dir); + + // Publish catalog for provider1 Live + let input_storage = + tuliprox_repository::build_input_storage_path("provider1", temp_dir.path().to_str().unwrap()); + tokio::fs::create_dir_all(&input_storage).await.unwrap(); + tuliprox_repository::publish_raw_group_catalog( + &input_storage, + "provider1", + XtreamCluster::Live, + vec!["Sports".to_string(), "News".to_string()], + &app_state.app_config.file_locks, + ) + .await + .unwrap(); + let unconnected_storage = + tuliprox_repository::build_input_storage_path("unconnected", temp_dir.path().to_str().unwrap()); + tokio::fs::create_dir_all(&unconnected_storage).await.unwrap(); + tuliprox_repository::publish_raw_group_catalog( + &unconnected_storage, + "unconnected", + XtreamCluster::Live, + vec!["Must not leak".to_string()], + &app_state.app_config.file_locks, + ) + .await + .unwrap(); + + let router = target_bouquet_api_register_unprotected().with_state(Arc::clone(&app_state)); + + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/status?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let target: TargetBouquetStatusDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(target.name, "living_room"); + assert_eq!(target.mode, None); + assert_eq!(target.group_count, 0); + + let req = Request::builder() + .method("GET") + .uri("/target-bouquets/groups?target=living_room") + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get(axum::http::header::CONTENT_TYPE).unwrap(), shared::utils::CONTENT_TYPE_JSON); + assert_eq!(resp.headers().get(axum::http::header::CACHE_CONTROL).unwrap(), "no-store"); + + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let events: Vec = serde_json::from_slice(&body).unwrap(); + assert!(!events.iter().any(|event| match event { + TargetBouquetStreamEventDto::InputStarted { input } + | TargetBouquetStreamEventDto::InputFinished { input, .. } + | TargetBouquetStreamEventDto::InputWarning { input, .. } + | TargetBouquetStreamEventDto::InputChunk { input, .. } => input == "unconnected", + _ => false, + })); + assert!(matches!(events.first(), Some(TargetBouquetStreamEventDto::Selection { bouquet: None }))); + let chunk = events + .iter() + .find(|event| matches!(event, TargetBouquetStreamEventDto::InputChunk { .. })) + .expect("live input chunk"); + match chunk { + TargetBouquetStreamEventDto::InputChunk { input, cluster, groups, is_last_for_cluster } => { + assert_eq!(input, "provider1"); + assert_eq!(*cluster, XtreamCluster::Live); + assert_eq!(groups, &vec!["News".to_string(), "Sports".to_string()]); + assert!(is_last_for_cluster); + } + _ => panic!("Expected InputChunk event"), + } + let warning = events + .iter() + .find(|event| matches!(event, TargetBouquetStreamEventDto::InputWarning { .. })) + .expect("grouped vod/series warning"); + match warning { + TargetBouquetStreamEventDto::InputWarning { input, message } => { + assert_eq!(input, "provider1"); + assert_eq!( + message, + "Raw group catalog for input 'provider1' (video, series) is not available. Please trigger a playlist update." + ); + } + _ => panic!("Expected InputWarning event"), + } + assert!(matches!(events.last(), Some(TargetBouquetStreamEventDto::Complete))); + } +} diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index cd04cc037..2b242879c 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -9,6 +9,9 @@ use crate::{ rbac_api::{rbac_api_register, rbac_api_register_unprotected}, recording_api::{recording_api_register, recording_availability_register, recording_enabled_layer}, recording_media_api::recording_media_api_register, + target_bouquet_api::{ + target_bouquet_api_register_unprotected, target_bouquet_api_register_with_permissions, + }, user_api::user_api_register, v1_api_config::{v1_api_config_register, v1_api_config_register_with_permissions}, v1_api_playlist::{ @@ -209,6 +212,7 @@ pub fn v1_api_register( .merge(v1_api_user_register_with_permissions(axum::routing::Router::new(), app_state)) .merge(v1_api_playlist_register_with_permissions(axum::routing::Router::new(), app_state)) .merge(library_api_register(axum::routing::Router::new(), Some(app_state))) + .merge(target_bouquet_api_register_with_permissions(app_state)) .merge(rbac_api_register(Arc::clone(app_state))) .merge(recording_availability_register(axum::routing::Router::new())) // `recording.enabled: false` turns the DVR off end to end: @@ -230,6 +234,7 @@ pub fn v1_api_register( .merge(v1_api_user_register(axum::routing::Router::new())) .merge(v1_api_playlist_register_protected(axum::routing::Router::new())) .merge(library_api_register(axum::routing::Router::new(), None)) + .merge(target_bouquet_api_register_unprotected()) .merge(rbac_api_register_unprotected(Arc::clone(app_state))) .merge(recording_availability_register(axum::routing::Router::new())) .merge( diff --git a/backend/app/src/api/endpoints/v1_api_config.rs b/backend/app/src/api/endpoints/v1_api_config.rs index 5dfcf55ab..bdc79219d 100644 --- a/backend/app/src/api/endpoints/v1_api_config.rs +++ b/backend/app/src/api/endpoints/v1_api_config.rs @@ -36,6 +36,135 @@ use std::{collections::HashMap, path::Path, sync::Arc}; fn file_revision_from_bytes(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() } +/// Target-bouquet file updates derived from a sources configuration change. +struct BouquetMutationBatch { + deletions: Vec, +} + +/// Collects bouquet deletions required by the new configuration. +/// Target IDs are runtime-local and therefore cannot establish persisted identity. +fn build_bouquet_mutation_batch(app_state: &Arc, sources: &SourcesConfigDto) -> BouquetMutationBatch { + let old_sources = app_state.app_config.sources.load(); + let mut old_target_names = std::collections::HashSet::new(); + for source in &old_sources.sources { + for target in &source.targets { + old_target_names.insert(target.name.clone()); + } + } + + let mut new_target_names = std::collections::HashSet::new(); + for source_dto in &sources.sources { + for target_dto in &source_dto.targets { + new_target_names.insert(target_dto.name.trim().to_string()); + } + } + + let deletions: Vec = old_target_names.difference(&new_target_names).cloned().collect(); + + BouquetMutationBatch { deletions } +} + +/// Applies bouquet mutations under their shared lock. +async fn apply_bouquet_mutation_batch( + app_state: &Arc, + batch: &BouquetMutationBatch, +) -> Result<(), TuliproxError> { + tuliprox_repository::apply_target_bouquet_mutations_locked(&app_state.app_config, &[], &batch.deletions).await +} + +async fn restore_sources_file(path: &Path, content: Option<&[u8]>) -> Result<(), std::io::Error> { + if let Some(content) = content { + return tuliprox_core::utils::write_file_atomic(path, content).await; + } + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +async fn read_optional_file(path: &Path) -> Result>, std::io::Error> { + match tokio::fs::read(path).await { + Ok(content) => Ok(Some(content)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +async fn snapshot_template_file( + app_state: &Arc, + should_persist: bool, +) -> Result>)>, std::io::Error> { + if !should_persist { + return Ok(None); + } + let path = { + let config = app_state.app_config.config.load(); + let paths = app_state.app_config.paths.load(); + tuliprox_core::utils::resolve_template_persist_file_path( + paths.template_file_path.as_deref().or(config.template_path.as_deref()), + &paths.config_path, + ) + }; + let content = read_optional_file(Path::new(&path)).await?; + Ok(Some((path, content))) +} + +async fn roll_back_sources_after_update_failure( + app_state: &Arc, + sources_file_path: &Path, + original_sources_file: Option<&[u8]>, + template_snapshot: Option<(&Path, Option<&[u8]>)>, + update_err: &TuliproxError, +) -> axum::response::Response { + error!("Failed to complete sources configuration update: {update_err}"); + if let Err(rollback_err) = restore_sources_file(sources_file_path, original_sources_file).await { + error!("Failed to roll back source.yml after target bouquet mutation failure: {rollback_err}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({ + "error": format!( + "Updating sources failed: {update_err}; rolling back source.yml also failed: {rollback_err}" + ) + })), + ) + .into_response(); + } + if let Some((template_file_path, original_template_file)) = template_snapshot { + if let Err(rollback_err) = restore_sources_file(template_file_path, original_template_file).await { + error!("Failed to roll back template config after target bouquet mutation failure: {rollback_err}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({ + "error": format!( + "Updating sources failed: {update_err}; rolling back the template config also failed: {rollback_err}" + ) + })), + ) + .into_response(); + } + } + if let Err(reload_err) = ConfigFile::load_sources(app_state).await { + error!("source.yml was rolled back, but reloading the previous runtime configuration failed: {reload_err}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({ + "error": format!( + "Updating sources failed: {update_err}; source.yml was restored but runtime reload failed: {reload_err}" + ) + })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({ + "error": format!("Updating sources failed and persisted configuration was rolled back: {update_err}") + })), + ) + .into_response() +} + async fn read_file_revision(path: &str) -> Result { match tokio::fs::read(path).await { Ok(bytes) => Ok(file_revision_from_bytes(&bytes)), @@ -249,11 +378,10 @@ async fn save_config_sources( headers: HeaderMap, axum::extract::Json(sources): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let sources_file_path = { - let paths = app_state.app_config.paths.load(); - paths.sources_file_path.clone() - }; - let _lock = app_state.app_config.file_locks.write_lock(Path::new(&sources_file_path)).await; + let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); + let _source_lock = app_state.app_config.file_locks.write_lock(Path::new(&sources_file_path)).await; + let _bouquet_mutation_lock = + app_state.app_config.file_locks.write_lock_str(tuliprox_repository::TARGET_BOUQUET_MUTATION_LOCK).await; let current_revision = match read_file_revision(&sources_file_path).await { Ok(revision) => revision, @@ -267,6 +395,13 @@ async fn save_config_sources( { return response; } + let original_sources_file = match read_optional_file(Path::new(&sources_file_path)).await { + Ok(content) => content, + Err(err) => { + error!("Failed to snapshot source.yml '{sources_file_path}' before save: {err}"); + return internal_server_error!(); + } + }; let templates_to_persist = match crate::config_loader::validate_source_config_for_persist(&app_state.app_config, &sources).await { @@ -278,6 +413,14 @@ async fn save_config_sources( } }; + let template_snapshot = match snapshot_template_file(&app_state, templates_to_persist.is_some()).await { + Ok(snapshot) => snapshot, + Err(err) => { + error!("Failed to snapshot template config before save: {err}"); + return internal_server_error!(); + } + }; + if let Some(template_definition) = templates_to_persist.as_ref() { if let Err(err) = crate::config_loader::persist_templates_config(&app_state.app_config, template_definition).await @@ -288,20 +431,48 @@ async fn save_config_sources( } } + // Derive lifecycle changes from the currently loaded configuration before it is replaced. + let bouquet_mutations = build_bouquet_mutation_batch(&app_state, &sources); + match crate::config_loader::replace_source_config_from_user_edit(&app_state.app_config, None, sources).await { Ok(_) => {} Err(err) => { error!("Failed to persist source.yml {err}"); + if let Some((template_file_path, original_template_file)) = template_snapshot.as_ref() { + if let Err(rollback_err) = + restore_sources_file(Path::new(template_file_path), original_template_file.as_deref()).await + { + error!("Failed to roll back template config after source.yml save failure: {rollback_err}"); + } + } return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } } - // Reload from disk so runtime always uses fully prepared sources/mappings/templates. - if let Err(err) = ConfigFile::load_sources(&app_state).await { - error!("Failed to reload prepared sources after save {err}"); - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) - .into_response(); + // Reload before deleting bouquets so a failed reload can restore the persisted + // configuration without having touched target-local state. + if let Err(reload_err) = ConfigFile::load_sources(&app_state).await { + return roll_back_sources_after_update_failure( + &app_state, + Path::new(&sources_file_path), + original_sources_file.as_deref(), + template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())), + &reload_err, + ) + .await; + } + + // The mutation lock prevents concurrent bouquet writes from observing a partial lifecycle update. + if let Err(mutation_err) = apply_bouquet_mutation_batch(&app_state, &bouquet_mutations).await { + return roll_back_sources_after_update_failure( + &app_state, + Path::new(&sources_file_path), + original_sources_file.as_deref(), + template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())), + &mutation_err, + ) + .await; } app_state.active_provider.update_config(&app_state.app_config).await; @@ -974,4 +1145,104 @@ mod tests { Some("http://request.example") ); } + + #[tokio::test] + async fn save_config_sources_uses_names_and_does_not_deadlock_on_mutation_lock() { + use crate::api::model::create_test_app_state; + use std::time::Duration; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().to_string_lossy().to_string(); + let sources_file = temp_dir.path().join("source.yml"); + let input_dto = shared::model::ConfigInputDto { + name: "input1".into(), + url: "http://example.com/playlist.m3u".to_string(), + ..Default::default() + }; + let initial_sources = SourcesConfigDto { + inputs: vec![input_dto.clone()], + sources: vec![shared::model::ConfigSourceDto { + inputs: vec!["input1".into()], + targets: vec![shared::model::ConfigTargetDto { + id: 1, + name: "target_1".to_string(), + output: vec![shared::model::TargetOutputDto::M3u(shared::model::M3uTargetOutputDto::default())], + ..Default::default() + }], + }], + ..Default::default() + }; + let yaml = serde_saphyr::to_string(&initial_sources).unwrap(); + tokio::fs::write(&sources_file, &yaml).await.unwrap(); + + let test_config = crate::model::Config { + backup_dir: Some(temp_dir.path().join("backup").to_string_lossy().to_string()), + ..crate::model::Config::default() + }; + let app_state = create_test_app_state(test_config); + app_state.app_config.paths.store(Arc::new(shared::model::ConfigPaths { + home_path: config_path.clone(), + config_path: config_path.clone(), + storage_path: config_path.clone(), + config_file_path: String::new(), + sources_file_path: sources_file.to_string_lossy().to_string(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })); + super::ConfigFile::load_sources(&app_state).await.unwrap(); + + tuliprox_repository::save_target_bouquet( + &app_state.app_config, + "target_1", + shared::model::TargetBouquetDto::new( + shared::model::TargetBouquetMode::Whitelist, + shared::model::PlaylistClusterBouquetDto { + live: Some(vec!["News".to_string()]), + vod: None, + series: None, + }, + ), + ) + .await + .unwrap(); + + let updated_sources = SourcesConfigDto { + inputs: vec![input_dto], + sources: vec![shared::model::ConfigSourceDto { + inputs: vec!["input1".into()], + targets: vec![shared::model::ConfigTargetDto { + id: 1, + name: "target_renamed".to_string(), + output: vec![shared::model::TargetOutputDto::M3u(shared::model::M3uTargetOutputDto::default())], + ..Default::default() + }], + }], + ..Default::default() + }; + + let rev = super::read_file_revision(sources_file.to_str().unwrap()).await.unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(HEADER_IF_MATCH, HeaderValue::from_str(&rev).unwrap()); + + let save_future = super::save_config_sources( + axum::extract::State(Arc::clone(&app_state)), + headers, + axum::Json(updated_sources), + ); + let response = tokio::time::timeout(Duration::from_secs(5), save_future) + .await + .expect("save_config_sources must not deadlock"); + + use axum::response::IntoResponse; + assert_eq!(response.into_response().status(), StatusCode::OK); + assert!(!tuliprox_repository::target_bouquet_exists(temp_dir.path(), "target_1").await); + assert!(!tuliprox_repository::target_bouquet_exists(temp_dir.path(), "target_renamed").await); + let runtime_sources = app_state.app_config.sources.load(); + assert_eq!(runtime_sources.sources[0].targets[0].name, "target_renamed"); + } } diff --git a/backend/core/Cargo.toml b/backend/core/Cargo.toml index c59af361f..dee0994a0 100644 --- a/backend/core/Cargo.toml +++ b/backend/core/Cargo.toml @@ -25,6 +25,7 @@ chrono.workspace = true chrono-tz = "0.10.4" ctr = "0.9.2" env_logger = "0.11.11" +fastrand.workspace = true flate2 = "1.1.9" futures.workspace = true indexmap.workspace = true @@ -57,5 +58,8 @@ sysinfo = { version = "0.38.4", default-features = false, features = ["system", [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] } + [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/backend/core/src/model/mod.rs b/backend/core/src/model/mod.rs index 3670f9849..e20820eaf 100644 --- a/backend/core/src/model/mod.rs +++ b/backend/core/src/model/mod.rs @@ -23,6 +23,7 @@ pub mod provider; pub mod proxy_redirect; pub mod stalker_record; pub mod stream_error; +pub mod target_bouquet; pub mod update_guard; pub mod update_task; pub mod user_api_request; @@ -31,7 +32,7 @@ pub mod xtream_response; pub use self::{ auth_rejection::*, batch_result_collector::*, config::*, custom_stream_flags::*, fingerprint::*, healthcheck::*, input_source::*, mapping::*, messaging::*, notification::*, playlist_filter::*, playlist_key::*, provider::*, - proxy_redirect::*, stalker_record::*, stream_error::*, stream_history::*, update_guard::*, update_task::*, - xmltv::*, xtream::*, + proxy_redirect::*, stalker_record::*, stream_error::*, stream_history::*, target_bouquet::*, update_guard::*, + update_task::*, xmltv::*, xtream::*, }; pub use shared::model::xtream_const::*; diff --git a/backend/core/src/model/target_bouquet.rs b/backend/core/src/model/target_bouquet.rs new file mode 100644 index 000000000..5bfec31dc --- /dev/null +++ b/backend/core/src/model/target_bouquet.rs @@ -0,0 +1,131 @@ +use shared::model::{TargetBouquetDto, TargetBouquetMode, XtreamCluster}; +use std::collections::HashSet; + +/// Target-local compiled bouquet filter evaluated per item before transformation. +#[derive(Debug, Clone, PartialEq)] +pub struct TargetBouquetFilter { + mode: TargetBouquetMode, + live: Option>, + vod: Option>, + series: Option>, +} + +impl TargetBouquetFilter { + pub fn from_dto(mut bouquet: TargetBouquetDto) -> Option { + bouquet.groups.canonicalize_for_target(); + if bouquet.is_unrestricted() { + return None; + } + let live = bouquet.groups.live.map(|v| v.into_iter().collect::>()); + let vod = bouquet.groups.vod.map(|v| v.into_iter().collect::>()); + let series = bouquet.groups.series.map(|v| v.into_iter().collect::>()); + Some(Self { mode: bouquet.mode, live, vod, series }) + } + + #[inline] + fn for_cluster(&self, cluster: XtreamCluster) -> Option<&HashSet> { + match cluster { + XtreamCluster::Live => self.live.as_ref(), + XtreamCluster::Video => self.vod.as_ref(), + XtreamCluster::Series => self.series.as_ref(), + } + } + + #[inline] + pub fn allows(&self, cluster: XtreamCluster, group: &str) -> bool { + let groups = self.for_cluster(cluster); + match self.mode { + TargetBouquetMode::Whitelist => groups.is_none_or(|groups| groups.contains(group)), + TargetBouquetMode::Blacklist => groups.is_none_or(|groups| !groups.contains(group)), + } + } + + pub fn cluster_counts(&self) -> (Option, Option, Option) { + ( + self.live.as_ref().map(HashSet::len), + self.vod.as_ref().map(HashSet::len), + self.series.as_ref().map(HashSet::len), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::PlaylistClusterBouquetDto; + + fn whitelist(groups: PlaylistClusterBouquetDto) -> TargetBouquetDto { + TargetBouquetDto::new(TargetBouquetMode::Whitelist, groups) + } + + #[test] + fn allows_all_when_cluster_is_none() { + let filter = TargetBouquetFilter::from_dto(whitelist(PlaylistClusterBouquetDto { + live: Some(vec!["News".to_string()]), + vod: None, + series: None, + })) + .expect("filter should be Some"); + + assert!(filter.allows(XtreamCluster::Live, "News")); + assert!(!filter.allows(XtreamCluster::Live, "Sports")); + assert!(filter.allows(XtreamCluster::Video, "Any Movie")); + assert!(filter.allows(XtreamCluster::Series, "Any Series")); + } + + #[test] + fn case_sensitive_exact_matching() { + let filter = TargetBouquetFilter::from_dto(whitelist(PlaylistClusterBouquetDto { + live: Some(vec!["news".to_string(), "".to_string()]), + vod: None, + series: None, + })) + .expect("filter should be Some"); + + assert!(filter.allows(XtreamCluster::Live, "news")); + assert!(!filter.allows(XtreamCluster::Live, "News")); + assert!(filter.allows(XtreamCluster::Live, "")); + } + + #[test] + fn returns_none_when_unrestricted() { + let filter = + TargetBouquetFilter::from_dto(whitelist(PlaylistClusterBouquetDto { live: None, vod: None, series: None })); + assert!(filter.is_none()); + + let filter_empty = TargetBouquetFilter::from_dto(whitelist(PlaylistClusterBouquetDto { + live: Some(vec![]), + vod: None, + series: None, + })); + assert!(filter_empty.is_none()); + } + + #[test] + fn empty_cluster_excludes_that_cluster_when_another_cluster_is_selected() { + let filter = TargetBouquetFilter::from_dto(whitelist(PlaylistClusterBouquetDto { + live: Some(vec!["News".to_string()]), + vod: Some(vec![]), + series: None, + })) + .expect("filter should be Some"); + + assert!(filter.allows(XtreamCluster::Live, "News")); + assert!(!filter.allows(XtreamCluster::Live, "Sports")); + assert!(!filter.allows(XtreamCluster::Video, "Any Movie")); + } + + #[test] + fn blacklist_excludes_selected_groups_and_allows_everything_else() { + let filter = TargetBouquetFilter::from_dto(TargetBouquetDto::new( + TargetBouquetMode::Blacklist, + PlaylistClusterBouquetDto { live: Some(vec!["Sports".to_string()]), vod: Some(Vec::new()), series: None }, + )) + .expect("filter should be Some"); + + assert!(!filter.allows(XtreamCluster::Live, "Sports")); + assert!(filter.allows(XtreamCluster::Live, "News")); + assert!(filter.allows(XtreamCluster::Video, "Any Movie")); + assert!(filter.allows(XtreamCluster::Series, "Any Series")); + } +} diff --git a/backend/core/src/utils/atomic_json_store.rs b/backend/core/src/utils/atomic_json_store.rs index d407eaf91..a0cb48b6b 100644 --- a/backend/core/src/utils/atomic_json_store.rs +++ b/backend/core/src/utils/atomic_json_store.rs @@ -1,14 +1,9 @@ -//! Atomic JSON write helper with a narrowly scoped injection seam. +//! Format-neutral atomic write helper with an injection seam for tests. //! -//! Production callers use [`write_json_atomic`] which delegates to -//! [`RealAtomicWriteOps`]. Tests inject a custom [`AtomicWriteOps`] to exercise -//! failures at each stage (temp-write, file sync, rename, parent dir sync) -//! without modifying the calling code. Callers can opt into the file/parent -//! sync stages by overriding [`AtomicWriteOps::sync_file`] and -//! [`AtomicWriteOps::sync_parent`] on the provided implementation. -//! -//! Production behavior is unchanged when [`write_json_atomic`] is used: the -//! default implementations of `sync_file` and `sync_parent` are no-ops. +//! Production callers use [`write_file_atomic`], [`write_text_file_atomic`], or +//! [`write_json_atomic`], which delegate to [`RealAtomicWriteOps`]. Tests can inject a custom +//! [`AtomicWriteOps`] to exercise failures at each stage (temp-write, file sync, rename, parent dir sync) +//! without modifying the calling code. use std::path::{Path, PathBuf}; use tokio::fs; @@ -40,8 +35,8 @@ impl From for std::io::Error { } /// Filesystem operations needed for an atomic write. Default impls of the -/// sync stages are no-ops so the production code path matches the previous -/// `fs::write` + `fs::rename` behavior exactly. +/// sync stages are no-ops so the production code path matches the standard +/// `fs::write` + `fs::rename` behavior. pub trait AtomicWriteOps: Send + Sync { fn write_temp( &self, @@ -64,13 +59,55 @@ pub trait AtomicWriteOps: Send + Sync { /// Production implementation that delegates to `tokio::fs`. pub struct RealAtomicWriteOps; +#[cfg(windows)] +fn replace_file_windows(source: &Path, target: &Path) -> std::io::Result<()> { + use std::{io, os::windows::ffi::OsStrExt}; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH}; + + let mut source_wide: Vec = source.as_os_str().encode_wide().collect(); + source_wide.push(0); + let mut target_wide: Vec = target.as_os_str().encode_wide().collect(); + target_wide.push(0); + + // SAFETY: both paths are NUL-terminated UTF-16 buffers that remain alive for + // the call. `MOVEFILE_REPLACE_EXISTING` leaves the target in place on error. + let ok = unsafe { + MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + impl AtomicWriteOps for RealAtomicWriteOps { async fn write_temp(&self, tmp: &Path, content: &[u8]) -> Result<(), AtomicWriteError> { fs::write(tmp, content).await.map_err(|e| AtomicWriteError::new(AtomicWriteStage::WriteTemp, e)) } async fn rename(&self, tmp: &Path, final_path: &Path) -> Result<(), AtomicWriteError> { - fs::rename(tmp, final_path).await.map_err(|e| AtomicWriteError::new(AtomicWriteStage::Rename, e)) + match fs::rename(tmp, final_path).await { + Ok(()) => Ok(()), + Err(err) => { + #[cfg(windows)] + { + if replace_file_windows(tmp, final_path).is_ok() { + return Ok(()); + } + } + Err(AtomicWriteError::new(AtomicWriteStage::Rename, err)) + } + } + } +} + +/// Generates a unique temporary file path in the same parent directory as `final_path`. +pub fn create_unique_temp_path(final_path: &Path) -> PathBuf { + let file_name = final_path.file_name().and_then(|s| s.to_str()).unwrap_or("file"); + let temp_name = format!(".{file_name}.tmp-{}-{}", std::process::id(), fastrand::u64(..)); + match final_path.parent() { + Some(parent) => parent.join(temp_name), + None => PathBuf::from(temp_name), } } @@ -88,6 +125,54 @@ pub fn tmp_path_for(final_path: &Path) -> PathBuf { } } +/// Atomic write with caller-supplied filesystem ops and unique temp path. +pub async fn write_file_atomic_with_ops( + final_path: &Path, + content: &[u8], + ops: &O, +) -> Result<(), AtomicWriteError> { + write_file_atomic_with_parent_mode(final_path, content, ops, true).await +} + +async fn write_file_atomic_with_parent_mode( + final_path: &Path, + content: &[u8], + ops: &O, + create_parent: bool, +) -> Result<(), AtomicWriteError> { + if create_parent { + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent).await.map_err(|e| AtomicWriteError::new(AtomicWriteStage::WriteTemp, e))?; + } + } + let tmp = create_unique_temp_path(final_path); + let result = async { + ops.write_temp(&tmp, content).await?; + ops.sync_file(&tmp).await?; + ops.rename(&tmp, final_path).await?; + if let Some(parent) = final_path.parent() { + ops.sync_parent(parent).await?; + } + Ok(()) + } + .await; + + if result.is_err() { + let _ = fs::remove_file(&tmp).await; + } + result +} + +/// Atomic write for bytes using production filesystem ops. +pub async fn write_file_atomic(final_path: &Path, content: &[u8]) -> std::io::Result<()> { + write_file_atomic_with_ops(final_path, content, &RealAtomicWriteOps).await.map_err(std::io::Error::from) +} + +/// Atomic write for UTF-8 text using production filesystem ops. +pub async fn write_text_file_atomic(final_path: &Path, content: &str) -> std::io::Result<()> { + write_file_atomic(final_path, content.as_bytes()).await +} + /// Atomic write with caller-supplied filesystem ops. Generic over the ops /// type so the call site remains static dispatch. pub async fn write_json_atomic_with_ops( @@ -95,14 +180,7 @@ pub async fn write_json_atomic_with_ops( content: &[u8], ops: &O, ) -> Result<(), AtomicWriteError> { - let tmp = tmp_path_for(final_path); - ops.write_temp(&tmp, content).await?; - ops.sync_file(&tmp).await?; - ops.rename(&tmp, final_path).await?; - if let Some(parent) = final_path.parent() { - ops.sync_parent(parent).await?; - } - Ok(()) + write_file_atomic_with_parent_mode(final_path, content, ops, false).await } /// Atomic write using the production filesystem ops. Production callers should @@ -111,9 +189,7 @@ pub async fn write_json_atomic(final_path: &Path, content: &[u8]) -> std::io::Re write_json_atomic_with_ops(final_path, content, &RealAtomicWriteOps).await.map_err(std::io::Error::from) } -/// Atomic write with a caller-supplied temp file path. Use this when -/// concurrent writers need disjoint temp names โ€” the caller is -/// responsible for producing a unique path per call. +/// Atomic write with a caller-supplied temp file path. pub async fn write_json_atomic_to_tmp(final_path: &Path, tmp: &Path, content: &[u8]) -> Result<(), AtomicWriteError> { write_json_atomic_with_ops_to_tmp(final_path, tmp, content, &RealAtomicWriteOps).await } @@ -214,6 +290,38 @@ mod tests { assert_eq!(read, content); } + #[tokio::test] + async fn write_json_atomic_does_not_create_missing_parent() { + let dir = TempDir::new().expect("tempdir"); + let parent = dir.path().join("missing"); + let final_path = parent.join("downloads_state.json"); + + let err = write_json_atomic(&final_path, br#"{"hello":"world"}"#).await.expect_err("missing parent"); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + assert!(!parent.exists()); + } + + #[tokio::test] + async fn write_file_atomic_round_trip_and_unique_temp() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("nested").join("test_file.yml"); + let content = b"version: 1\ntarget: family\n"; + write_file_atomic(&final_path, content).await.expect("write"); + let read = tokio::fs::read(&final_path).await.expect("read"); + assert_eq!(read, content); + } + + #[tokio::test] + async fn write_text_file_atomic_round_trip() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("test_text.txt"); + let text = "hello world text"; + write_text_file_atomic(&final_path, text).await.expect("write"); + let read = tokio::fs::read_to_string(&final_path).await.expect("read"); + assert_eq!(read, text); + } + #[tokio::test] async fn tmp_path_for_appends_json_tmp_to_json_extension() { let p = Path::new("/tmp/downloads_state.json"); diff --git a/backend/iptv/src/error.rs b/backend/iptv/src/error.rs index 6ee03e271..81e64b497 100644 --- a/backend/iptv/src/error.rs +++ b/backend/iptv/src/error.rs @@ -101,6 +101,7 @@ impl ProviderErrorKind { | ErrorKind::ConfigVideoDownload | ErrorKind::ConfigTarget | ErrorKind::ConfigWebUi + | ErrorKind::TargetBouquet | ErrorKind::UrlParse | ErrorKind::FilterParse | ErrorKind::RegexCompile => Self::Config, diff --git a/backend/iptv/src/xtream/mod.rs b/backend/iptv/src/xtream/mod.rs index f6f9efe60..7e630f70a 100644 --- a/backend/iptv/src/xtream/mod.rs +++ b/backend/iptv/src/xtream/mod.rs @@ -24,7 +24,7 @@ use tuliprox_core::{ use tuliprox_parser::{xtream, xtream::parse_xtream_series_info}; use tuliprox_repository::{ get_input_storage_path, get_target_id_mapping, get_target_storage_path, persist_input_vod_info, - persist_input_xtream_playlist_cluster_to_disk, persists_input_series_info, + persist_input_xtream_playlist_clusters_to_disk, persists_input_series_info, rewrite_provider_series_info_episode_virtual_id, write_playlist_batch_item_upsert, write_playlist_item_update, PlaylistStorageState, ProviderEpisodeKey, VirtualIdRecord, }; @@ -541,6 +541,7 @@ async fn download_xtream_from_source( let use_disk_based_processing = cfg.disk_based_processing && source_input_type.is_xtream(); let mut errors = vec![]; + let mut disk_cluster_readers = Vec::new(); for (xtream_cluster, category, stream) in &ACTIONS { if !clusters.contains(xtream_cluster) { continue; @@ -564,18 +565,7 @@ async fn download_xtream_from_source( ) { (Ok(category_content), Ok(stream_content)) => { if use_disk_based_processing { - if let Err(err) = persist_input_xtream_playlist_cluster_to_disk( - app_config, - input, - *xtream_cluster, - category_content, - stream_content, - ) - .await - { - error!("persist_input_xtream_playlist_cluster_to_disk failed: {err}"); - errors.push(err); - } + disk_cluster_readers.push((*xtream_cluster, category_content, stream_content)); } else { match xtream::parse_xtream(input, *xtream_cluster, category_content, stream_content).await { Ok(sub_playlist_parsed) => { @@ -600,6 +590,14 @@ async fn download_xtream_from_source( } } + if use_disk_based_processing && errors.is_empty() && !disk_cluster_readers.is_empty() { + if let Err(err) = persist_input_xtream_playlist_clusters_to_disk(app_config, input, disk_cluster_readers).await + { + error!("persist_input_xtream_playlist_clusters_to_disk failed: {err}"); + errors.push(err); + } + } + (playlist_groups, errors, use_disk_based_processing) } diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs index 6b6373174..ed1bbec10 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -258,6 +258,15 @@ pub(crate) async fn prepare_playlist_for_target( fpl: &mut FetchedPlaylist<'a>, duplicates: &mut HashSet, consume_source: bool, + bouquet_filter: Option<&tuliprox_core::model::TargetBouquetFilter>, ) -> Result<(FetchedPlaylist<'a>, PipelineOutcome), TuliproxError> { - let source = if consume_source { + let mut source = if consume_source { if fpl.is_memory() { MemoryPlaylistSource::new(fpl.source.take_groups()).into_source() } else { @@ -489,21 +491,58 @@ pub(crate) fn execute_pipe<'a>( fpl.clone_source()? }; - let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() }; - // In-memory items are frozen here at the target-processing boundary. Read-only disk sources - // capture the same identity when their persisted M3U/Xtream items are converted to PlaylistItem. - if new_fpl.is_memory() { - for item in new_fpl.items_mut() { + let is_memory = source.is_memory(); + let pre_transform_dedup_enabled = target.execution_plan.pre_transform_identity_dedup; + if !is_memory && pre_transform_dedup_enabled { + warn!( + "Target '{}' input '{}': pre_transform_identity_dedup has no effect for disk-based playlist sources", + target.name, fpl.input.name + ); + } + // Memory sources dedup here; disk sources cannot, because persisted B+Tree + // leaves are read-only at the pipeline boundary. Cross-input dedup for disk + // sources runs later in `deduplicate_playlist` during target finalization. + let deduplicate_memory = is_memory && pre_transform_dedup_enabled; + let mut items = Vec::new(); + let mut rejected = 0usize; + let mut duplicates_removed = 0usize; + let mut total_items = 0usize; + + for mut item in source.into_items() { + total_items += 1; + if is_memory { item.header.freeze_input_stream_id(); } - } - if target.execution_plan.pre_transform_identity_dedup { - new_fpl.deduplicate(duplicates); + if bouquet_filter.is_some_and(|filter| !filter.allows(item.header.xtream_cluster, &item.header.group)) { + rejected += 1; + continue; + } + if deduplicate_memory && !duplicates.insert(item.get_uuid()) { + duplicates_removed += 1; + continue; + } + items.push(item); + } + + if rejected > 0 { + debug!( + "Target '{}' bouquet prefilter for input '{}': rejected {}/{} items", + target.name, fpl.input.name, rejected, total_items + ); + } + if duplicates_removed > 0 { + debug!( + "Target '{}' input '{}': pre-transform dedup removed {} duplicate items", + target.name, fpl.input.name, duplicates_removed + ); } - let items = new_fpl.source.into_items().collect(); let (groups, outcome) = execute_pipeline_on_items(items, target, pipe); - new_fpl.source = MemoryPlaylistSource::new(groups).into_source(); + let new_fpl = FetchedPlaylist { + input: fpl.input, + source: MemoryPlaylistSource::new(groups).into_source(), + epg: fpl.epg.clone(), + }; Ok((new_fpl, outcome)) } diff --git a/backend/processing/src/processor/stalker.rs b/backend/processing/src/processor/stalker.rs index 1ba311f8d..d249c8e0c 100644 --- a/backend/processing/src/processor/stalker.rs +++ b/backend/processing/src/processor/stalker.rs @@ -44,6 +44,10 @@ pub enum StalkerCluster { Series, } +fn raw_group_catalog_storage_path(stalker_storage_path: &std::path::Path) -> &std::path::Path { + stalker_storage_path.parent().unwrap_or(stalker_storage_path) +} + const DEFAULT_STALKER_CLUSTERS: [StalkerCluster; 3] = [StalkerCluster::Live, StalkerCluster::Vod, StalkerCluster::Series]; @@ -168,6 +172,7 @@ pub async fn download_stalker_playlist( ); } }; + let catalog_storage_path = raw_group_catalog_storage_path(&storage_path); let outcome = if let Some(_refresh_permit) = try_acquire_stalker_refresh(&input.name).await { let handshake = match api_client.handshake().await { @@ -297,6 +302,22 @@ pub async fn download_stalker_playlist( Ok(items) => { counts[cluster as usize] = items.len(); let cluster_groups = groups_for_cluster(items, cluster, &input.name); + let group_titles = cluster_groups.iter().map(|g| g.title.to_string()).collect::>(); + let xc = xtream_cluster(cluster); + if let Err(publish_err) = tuliprox_repository::publish_raw_group_catalog( + catalog_storage_path, + &input.name, + xc, + group_titles, + &app_config.file_locks, + ) + .await + { + warn!( + "Failed to publish raw group catalog for stalker input '{}' cluster {xc:?}: {publish_err}", + input.name + ); + } groups.extend(cluster_groups); } Err(err) => errors.push(err), @@ -339,12 +360,7 @@ fn groups_for_cluster( cluster: StalkerCluster, input_name: &str, ) -> Vec { - use shared::model::XtreamCluster; - let xtream_cluster = match cluster { - StalkerCluster::Live => XtreamCluster::Live, - StalkerCluster::Vod => XtreamCluster::Video, - StalkerCluster::Series => XtreamCluster::Series, - }; + let xtream_cluster = xtream_cluster(cluster); let mut groups_map: indexmap::IndexMap = indexmap::IndexMap::new(); for item in items { let category_id = item.category_id; @@ -360,6 +376,14 @@ fn groups_for_cluster( groups_map.into_values().collect() } +const fn xtream_cluster(cluster: StalkerCluster) -> shared::model::XtreamCluster { + match cluster { + StalkerCluster::Live => shared::model::XtreamCluster::Live, + StalkerCluster::Vod => shared::model::XtreamCluster::Video, + StalkerCluster::Series => shared::model::XtreamCluster::Series, + } +} + /// Map a Stalker failure onto the workspace error type, keeping its classification. /// /// Flattening everything to `ProviderConnection` read as "the network had a bad moment", @@ -478,6 +502,12 @@ mod tests { fn runtime_cfg() -> StalkerInputConfig { StalkerInputConfig::default() } + #[test] + fn raw_group_catalog_is_published_at_the_input_storage_root() { + let stalker_path = std::path::Path::new("/storage/input_portal/stalker"); + assert_eq!(raw_group_catalog_storage_path(stalker_path), std::path::Path::new("/storage/input_portal")); + } + #[test] fn runtime_client_cache_key_changes_with_endpoint_preference() { let mut cfg = runtime_cfg(); diff --git a/backend/processing/src/processor/stalker_refresh.rs b/backend/processing/src/processor/stalker_refresh.rs index a6d43b3b8..76ec317fe 100644 --- a/backend/processing/src/processor/stalker_refresh.rs +++ b/backend/processing/src/processor/stalker_refresh.rs @@ -19,8 +19,8 @@ use tuliprox_iptv::stalker::{ }; use tuliprox_repository::{ stalker_generation_repository::{ - cleanup_obsolete_generations, clear_checkpoint, generation_data_path, load_checkpoint, publish_selection, - save_checkpoint, StalkerCheckpoint, StalkerGenerationData, StalkerRefreshPhase, + cleanup_obsolete_generations, clear_checkpoint, generation_data_path, load_active_manifest, load_checkpoint, + publish_selection, save_checkpoint, StalkerCheckpoint, StalkerGenerationData, StalkerRefreshPhase, }, stalker_repository::{ load_stalker_items_after, prepare_stalker_episode_series_at, promote_stalker_file, remove_stalker_file, @@ -183,6 +183,42 @@ async fn load_or_start_checkpoint( Ok(state) } +async fn finish_completed_refresh_checked( + app_config: &Arc, + storage_path: &Path, + identity_fingerprint: u64, + checkpoint: &StalkerCheckpoint, +) -> Result<(), TuliproxError> { + if checkpoint.processed == 0 { + let active = load_active_manifest(storage_path, identity_fingerprint).await?; + let mut retained_has_items = false; + if checkpoint.selection_mask & 0b0001 == 0 { + if let Some(files) = active.live.as_ref() { + retained_has_items = !load_stalker_items_after(app_config, &files.data, None, 1).await?.is_empty(); + } + } + if !retained_has_items && checkpoint.selection_mask & 0b0010 == 0 { + if let Some(files) = active.vod.as_ref() { + retained_has_items = !load_stalker_items_after(app_config, &files.data, None, 1).await?.is_empty(); + } + } + if !retained_has_items && checkpoint.selection_mask & 0b0100 == 0 { + if let Some(files) = active.series.as_ref() { + retained_has_items = !load_stalker_items_after(app_config, &files.roots, None, 1).await?.is_empty() + || !load_stalker_items_after(app_config, &files.episodes, None, 1).await?.is_empty(); + } + } + if !retained_has_items { + clear_checkpoint(storage_path).await?; + cleanup_obsolete_generations(storage_path, &active).await?; + return Err(TuliproxError::RepositoryPlaylist( + "Refusing to publish an empty Stalker playlist; existing data was retained".to_string(), + )); + } + } + finish_completed_refresh(storage_path, identity_fingerprint, checkpoint).await +} + async fn finish_completed_refresh( storage_path: &Path, identity_fingerprint: u64, @@ -532,7 +568,7 @@ pub async fn advance_stalker_refresh( checkpoint.retry_count = 0; } StalkerRefreshPhase::Complete => { - finish_completed_refresh(storage_path, identity_fingerprint, &checkpoint).await?; + finish_completed_refresh_checked(app_config, storage_path, identity_fingerprint, &checkpoint).await?; return Ok(StalkerRefreshOutcome::Complete); } StalkerRefreshPhase::Terminal => { @@ -547,6 +583,42 @@ pub async fn advance_stalker_refresh( #[cfg(test)] mod tests { use super::*; + use arc_swap::{ArcSwap, ArcSwapOption}; + use shared::model::ConfigPaths; + use tuliprox_core::{ + model::{ApiProxyConfig, Config, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig}, + utils::FileLockManager, + }; + + fn test_app_config(storage_dir: &Path) -> Arc { + Arc::new(AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config { + storage_dir: storage_dir.to_string_lossy().into_owned(), + ..Config::default() + })), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), + hdhomerun: Arc::new(ArcSwapOption::::default()), + api_proxy: Arc::new(ArcSwapOption::::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: storage_dir.to_string_lossy().into_owned(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + }) + } #[tokio::test] async fn terminal_checkpoint_is_cleared_after_restart() -> Result<(), Box> { @@ -578,6 +650,30 @@ mod tests { Ok(()) } + #[tokio::test] + async fn empty_complete_refresh_keeps_the_active_manifest() -> Result<(), Box> { + let temp = tempfile::tempdir()?; + let app_config = test_app_config(temp.path()); + let mut active = tuliprox_repository::stalker_generation_repository::StalkerActiveManifest::empty(17); + active.live = Some(tuliprox_repository::stalker_generation_repository::ClusterFiles { + generation: 11, + data: temp.path().join("old-live.db"), + }); + tuliprox_repository::stalker_generation_repository::save_active_manifest(temp.path(), &active).await?; + + let selection = StalkerClusterSelection { live: true, vod: false, series: false, epg: false }; + let mut checkpoint = StalkerCheckpoint::new(17, 23, selection.mask(), 123); + checkpoint.phase = StalkerRefreshPhase::Complete; + save_checkpoint(temp.path(), &checkpoint).await?; + + let result = finish_completed_refresh_checked(&app_config, temp.path(), 17, &checkpoint).await; + + assert!(result.is_err()); + assert_eq!(load_active_manifest(temp.path(), 17).await?, active); + assert!(load_checkpoint(temp.path(), 17).await?.is_none()); + Ok(()) + } + #[tokio::test] async fn complete_publication_is_idempotent_after_restart() -> Result<(), Box> { let temp = tempfile::tempdir()?; diff --git a/backend/repository/Cargo.toml b/backend/repository/Cargo.toml index b4159d61a..0c1246859 100644 --- a/backend/repository/Cargo.toml +++ b/backend/repository/Cargo.toml @@ -20,6 +20,7 @@ tuliprox-core = { workspace = true } tuliprox-parser = { workspace = true } arc-swap.workspace = true base64.workspace = true +blake3.workspace = true bytes.workspace = true chrono.workspace = true crc32fast = "1.5.1" @@ -34,6 +35,7 @@ paste.workspace = true rmp-serde = "1.3.1" serde.workspace = true serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } +serde-saphyr.workspace = true tempfile = "3.27.0" thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "parking_lot", "fs", "time", "macros", "sync"] } diff --git a/backend/repository/src/lib.rs b/backend/repository/src/lib.rs index 3b5a77f34..2b3827012 100644 --- a/backend/repository/src/lib.rs +++ b/backend/repository/src/lib.rs @@ -25,6 +25,7 @@ mod playlist_source; mod playlist_stream; mod provider_dns_repository; mod qos_snapshot_repository; +pub mod raw_group_catalog_repository; pub mod recording_rule_repository; pub mod stalker_generation_repository; pub mod stalker_repository; @@ -32,6 +33,7 @@ mod startup_migration; pub mod storage_const; mod stream_history; mod strm_repository; +pub mod target_bouquet_repository; pub mod token_revocations; mod user_repository; mod xtream_playlist_iterator; @@ -57,12 +59,14 @@ pub use playlist_source::*; pub use playlist_stream::*; pub use provider_dns_repository::*; pub use qos_snapshot_repository::*; +pub use raw_group_catalog_repository::*; pub use stalker_repository::*; pub use startup_migration::*; pub use storage::*; pub use storage_const::*; pub use stream_history::*; pub use strm_repository::*; +pub use target_bouquet_repository::*; pub use target_id_mapping::*; // The B+Tree storage engine is its own package. Aliased under its historical // module name so every `crate::bplustree::X` path keeps resolving. diff --git a/backend/repository/src/playlist_repository.rs b/backend/repository/src/playlist_repository.rs index 6b86915c6..ab6a43ee0 100644 --- a/backend/repository/src/playlist_repository.rs +++ b/backend/repository/src/playlist_repository.rs @@ -33,6 +33,8 @@ struct LocalEpisodeKey { virtual_id: u32, } +fn playlist_has_items(playlist: &[PlaylistGroup]) -> bool { playlist.iter().any(|group| !group.channels.is_empty()) } + pub struct ProviderEpisodeKey { pub provider_id: u32, pub virtual_id: u32, @@ -65,6 +67,12 @@ pub async fn persist_playlist( target: &ConfigTarget, playlist_state: Option<&Arc>, ) -> Result<(), Vec> { + if !playlist_has_items(playlist) { + return Err(vec![TuliproxError::RepositoryPlaylist(format!( + "Refusing to persist empty playlist for target '{}'; existing data was retained", + target.name + ))]); + } let mut errors = vec![]; let config = &app_config.config.load(); let target_path = match ensure_target_storage_path(config, &target.name).await { @@ -629,13 +637,71 @@ pub async fn load_m3u_target_storage( } } +async fn publish_all_cluster_catalogs( + app_config: &AppConfig, + storage_path: &Path, + input_name: &str, + persisted_playlist: Vec, +) -> (Vec, Option) { + let mut live_groups = Vec::new(); + let mut vod_groups = Vec::new(); + let mut series_groups = Vec::new(); + for group in &persisted_playlist { + match group.xtream_cluster { + XtreamCluster::Live => live_groups.push(group.title.to_string()), + XtreamCluster::Video => vod_groups.push(group.title.to_string()), + XtreamCluster::Series => series_groups.push(group.title.to_string()), + } + } + for (cluster, groups) in + [(XtreamCluster::Live, live_groups), (XtreamCluster::Video, vod_groups), (XtreamCluster::Series, series_groups)] + { + if let Err(publish_err) = + crate::publish_raw_group_catalog(storage_path, input_name, cluster, groups, &app_config.file_locks).await + { + warn!( + "Playlist data for input '{input_name}' was persisted, but publishing its raw group catalog for cluster {cluster:?} failed: {publish_err}" + ); + } + } + (persisted_playlist, None) +} + pub async fn persist_input_playlist( app_config: &Arc, input: &ConfigInput, mut playlist: Vec, ) -> (Vec, Option) { - if playlist.is_empty() { - warn!("Skipping empty playlist for input {}", input.name); + if !playlist_has_items(&playlist) { + let empty_error = TuliproxError::RepositoryPlaylist(format!( + "Refusing to persist empty playlist for input '{}'; existing data was retained", + input.name + )); + warn!("{empty_error}"); + return match load_input_playlist(app_config, input, None).await { + Ok(mut previous) => { + if previous.is_empty() { + (playlist, Some(empty_error)) + } else { + (previous.take_groups(), Some(empty_error)) + } + } + Err(load_err) => ( + playlist, + Some(TuliproxError::RepositoryPlaylist(format!( + "{empty_error}; failed to load the retained input playlist: {load_err}" + ))), + ), + }; + } + if input.get_download_input_type().persistence() == InputPersistence::Stalker { + // The Stalker processor (`processor::stalker::download_stalker_playlist`) + // is the single writer of the per-cluster B+Tree and raw group catalogs. + // Re-encoding the `PlaylistItem` runtime projection back into `StalkerPlaylistItem` + // would destroy the canonical `cmd`/`playback_descriptor`/capability + // flags the processor just persisted โ€” including the field the + // runtime 4xx-re-resolve hook relies on. The disk layout is + // already in sync; nothing to do here. return (playlist, None); } playlist.iter_mut().for_each(PlaylistGroup::on_load); @@ -653,7 +719,7 @@ pub async fn persist_input_playlist( } }; - match input.get_download_input_type().persistence() { + let (persisted_playlist, err) = match input.get_download_input_type().persistence() { InputPersistence::Xtream => persist_input_xtream_playlist(app_config, &storage_path, playlist).await, InputPersistence::M3u => { @@ -681,17 +747,14 @@ pub async fn persist_input_playlist( } (playlist, None) } - InputPersistence::Stalker => { - // The Stalker processor (`processor::stalker::download_stalker_playlist`) - // is the single writer of the per-cluster B+Tree. Re-encoding the - // `PlaylistItem` runtime projection back into `StalkerPlaylistItem` - // would destroy the canonical `cmd`/`playback_descriptor`/capability - // flags the processor just persisted โ€” including the field the - // runtime 4xx-re-resolve hook relies on. The disk layout is - // already in sync; nothing to do here. - (playlist, None) - } + InputPersistence::Stalker => unreachable!("handled above"), + }; + + if err.is_none() { + return publish_all_cluster_catalogs(app_config, &storage_path, &input.name, persisted_playlist).await; } + + (persisted_playlist, err) } pub async fn load_input_playlist( @@ -871,7 +934,7 @@ mod tests { use super::{ assign_local_series_info_episode_key, assign_media_server_series_info_episode, get_input_media_server_playlist_file_path, materialize_media_server_series_info_episodes, - normalize_target_playlist_epg_ids, rewrite_local_series_info_episode_virtual_id, + normalize_target_playlist_epg_ids, playlist_has_items, rewrite_local_series_info_episode_virtual_id, rewrite_series_episode_parent_virtual_ids, rewrite_series_info_episode_virtual_id, skipped_clusters, LocalEpisodeKey, ProviderEpisodeKey, }; @@ -886,6 +949,17 @@ mod tests { utils::Internable, }; use std::{collections::HashMap, sync::Arc}; + + #[test] + fn playlist_without_channels_is_empty_for_persistence() { + assert!(!playlist_has_items(&[])); + assert!(!playlist_has_items(&[PlaylistGroup { + id: 1, + title: "empty".intern(), + channels: Vec::new(), + xtream_cluster: XtreamCluster::Live, + }])); + } use tempfile::tempdir; #[test] diff --git a/backend/repository/src/raw_group_catalog_repository.rs b/backend/repository/src/raw_group_catalog_repository.rs new file mode 100644 index 000000000..a6ca926d9 --- /dev/null +++ b/backend/repository/src/raw_group_catalog_repository.rs @@ -0,0 +1,251 @@ +use log::debug; +use shared::{error::TuliproxError, model::XtreamCluster}; +use std::path::{Path, PathBuf}; +use tuliprox_core::utils::{write_file_atomic, FileLockManager}; + +pub const RAW_GROUP_CATALOG_VERSION: u8 = 1; + +/// Versioned entity for persisted raw group catalogs. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RawInputGroupCatalog { + pub version: u8, + pub input: String, + pub cluster: XtreamCluster, + pub groups: Vec, +} + +impl RawInputGroupCatalog { + pub fn new(input: impl Into, cluster: XtreamCluster, mut groups: Vec) -> Self { + groups.sort_unstable(); + groups.dedup(); + Self { version: RAW_GROUP_CATALOG_VERSION, input: input.into(), cluster, groups } + } +} + +pub fn raw_group_catalog_file_name(cluster: XtreamCluster) -> &'static str { + match cluster { + XtreamCluster::Live => "raw-groups-live.json", + XtreamCluster::Video => "raw-groups-vod.json", + XtreamCluster::Series => "raw-groups-series.json", + } +} + +pub fn raw_group_catalog_path(input_storage_dir: &Path, cluster: XtreamCluster) -> PathBuf { + input_storage_dir.join(raw_group_catalog_file_name(cluster)) +} + +/// Invalidates (deletes) the raw group catalog file for a specific cluster. +pub async fn invalidate_raw_group_catalog( + input_storage_dir: &Path, + cluster: XtreamCluster, + file_locks: &FileLockManager, +) -> Result<(), TuliproxError> { + let path = raw_group_catalog_path(input_storage_dir, cluster); + let _lock = file_locks.write_lock(&path).await; + match tokio::fs::remove_file(&path).await { + Ok(()) => { + debug!("Invalidated raw group catalog: {}", path.display()); + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => { + Err(TuliproxError::RepositoryStorage(format!("Failed to invalidate catalog {}: {err}", path.display()))) + } + } +} + +/// Atomically publishes a raw group catalog for a cluster under `input_storage_dir`. +pub async fn publish_raw_group_catalog( + input_storage_dir: &Path, + input_name: &str, + cluster: XtreamCluster, + groups: Vec, + file_locks: &FileLockManager, +) -> Result<(), TuliproxError> { + let catalog = RawInputGroupCatalog::new(input_name, cluster, groups); + let json_bytes = serde_json::to_vec_pretty(&catalog) + .map_err(|err| TuliproxError::RepositoryStorage(format!("Failed to serialize raw group catalog: {err}")))?; + + let path = raw_group_catalog_path(input_storage_dir, cluster); + let _lock = file_locks.write_lock(&path).await; + + write_file_atomic(&path, &json_bytes).await.map_err(|err| { + TuliproxError::RepositoryStorage(format!("Failed to publish raw group catalog to {}: {err}", path.display())) + })?; + + debug!( + "Published raw group catalog for input '{}' cluster '{:?}' ({} groups) to {}", + input_name, + cluster, + catalog.groups.len(), + path.display() + ); + Ok(()) +} + +/// Loads a raw group catalog from disk. Returns `Ok(None)` if the catalog does not exist. +/// Fails with an error if the catalog is corrupt, has an unsupported version, or has a mismatched input/cluster. +pub async fn load_raw_group_catalog( + input_storage_dir: &Path, + expected_input: &str, + cluster: XtreamCluster, + file_locks: &FileLockManager, +) -> Result, TuliproxError> { + let path = raw_group_catalog_path(input_storage_dir, cluster); + let _lock = file_locks.read_lock(&path).await; + + let path_clone = path.clone(); + let expected_input_owned = expected_input.to_string(); + + tokio::task::spawn_blocking(move || { + let file = match std::fs::File::open(&path_clone) { + Ok(f) => f, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(TuliproxError::RepositoryStorage(format!( + "Failed to open catalog {}: {err}", + path_clone.display() + ))); + } + }; + + let reader = std::io::BufReader::new(file); + let catalog: RawInputGroupCatalog = serde_json::from_reader(reader).map_err(|err| { + TuliproxError::RepositoryStorage(format!( + "Failed to parse raw group catalog {}: {err}", + path_clone.display() + )) + })?; + + if catalog.version != RAW_GROUP_CATALOG_VERSION { + return Err(TuliproxError::RepositoryStorage(format!( + "Raw group catalog version mismatch in {}: expected {}, found {}", + path_clone.display(), + RAW_GROUP_CATALOG_VERSION, + catalog.version + ))); + } + + if catalog.input != expected_input_owned { + return Err(TuliproxError::RepositoryStorage(format!( + "Raw group catalog input mismatch in {}: expected '{}', found '{}'", + path_clone.display(), + expected_input_owned, + catalog.input + ))); + } + + if catalog.cluster != cluster { + return Err(TuliproxError::RepositoryStorage(format!( + "Raw group catalog cluster mismatch in {}: expected '{:?}', found '{:?}'", + path_clone.display(), + cluster, + catalog.cluster + ))); + } + + Ok(Some(catalog)) + }) + .await + .map_err(|err| TuliproxError::Task(format!("Spawn blocking failed for catalog load: {err}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn filenames_match_spec() { + let dir = Path::new("/storage/input_1"); + assert_eq!( + raw_group_catalog_path(dir, XtreamCluster::Live), + Path::new("/storage/input_1/raw-groups-live.json") + ); + assert_eq!( + raw_group_catalog_path(dir, XtreamCluster::Video), + Path::new("/storage/input_1/raw-groups-vod.json") + ); + assert_eq!( + raw_group_catalog_path(dir, XtreamCluster::Series), + Path::new("/storage/input_1/raw-groups-series.json") + ); + } + + #[tokio::test] + async fn publish_and_load_round_trip() { + let temp_dir = TempDir::new().unwrap(); + let locks = FileLockManager::new(); + let groups = vec![ + "News".to_string(), + "Sports".to_string(), + "News".to_string(), + "".to_string(), + " Animation".to_string(), + ]; + + publish_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, groups, &locks).await.unwrap(); + + let loaded = load_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, &locks).await.unwrap(); + + assert!(loaded.is_some()); + let cat = loaded.unwrap(); + assert_eq!(cat.version, 1); + assert_eq!(cat.input, "provider_a"); + assert_eq!(cat.cluster, XtreamCluster::Live); + assert_eq!(cat.groups, vec!["", " Animation", "News", "Sports"]); + } + + #[tokio::test] + async fn empty_catalog_is_distinct_from_missing() { + let temp_dir = TempDir::new().unwrap(); + let locks = FileLockManager::new(); + + // Missing returns None + let missing = load_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, &locks).await.unwrap(); + assert!(missing.is_none()); + + // Publish empty catalog + publish_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, vec![], &locks).await.unwrap(); + + let loaded = load_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, &locks).await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().groups, Vec::::new()); + } + + #[tokio::test] + async fn invalidation_removes_catalog() { + let temp_dir = TempDir::new().unwrap(); + let locks = FileLockManager::new(); + + publish_raw_group_catalog( + temp_dir.path(), + "provider_a", + XtreamCluster::Video, + vec!["Movies".to_string()], + &locks, + ) + .await + .unwrap(); + + assert!(raw_group_catalog_path(temp_dir.path(), XtreamCluster::Video).exists()); + + invalidate_raw_group_catalog(temp_dir.path(), XtreamCluster::Video, &locks).await.unwrap(); + + assert!(!raw_group_catalog_path(temp_dir.path(), XtreamCluster::Video).exists()); + } + + #[tokio::test] + async fn input_mismatch_fails() { + let temp_dir = TempDir::new().unwrap(); + let locks = FileLockManager::new(); + + publish_raw_group_catalog(temp_dir.path(), "provider_a", XtreamCluster::Live, vec!["News".to_string()], &locks) + .await + .unwrap(); + + let res = load_raw_group_catalog(temp_dir.path(), "different_provider", XtreamCluster::Live, &locks).await; + assert!(res.is_err()); + } +} diff --git a/backend/repository/src/target_bouquet_repository.rs b/backend/repository/src/target_bouquet_repository.rs new file mode 100644 index 000000000..7556d956a --- /dev/null +++ b/backend/repository/src/target_bouquet_repository.rs @@ -0,0 +1,861 @@ +use log::debug; +use shared::{ + error::TuliproxError, + model::{TargetBouquetDto, TargetBouquetFileDto, TARGET_BOUQUET_VERSION}, +}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; +use tuliprox_core::{ + model::AppConfig, + utils::{write_file_atomic, write_text_file_atomic}, +}; + +pub const TARGET_BOUQUET_MUTATION_LOCK: &str = "target_bouquet:mutation"; +const MAX_PREFIX_BYTES: usize = 64; + +/// Returns the bouquet storage directory derived from `config_path`. +pub fn target_bouquet_dir(config_path: &Path) -> PathBuf { config_path.join("bouquets") } + +/// Sanitizes a target name into a safe filesystem prefix (max 64 UTF-8 bytes). +pub fn sanitize_target_prefix(target_name: &str) -> String { + let sanitized: String = + target_name.chars().map(|c| if c.is_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect(); + + let mut prefix = String::new(); + for ch in sanitized.chars() { + if prefix.len() + ch.len_utf8() > MAX_PREFIX_BYTES { + break; + } + prefix.push(ch); + } + if prefix.is_empty() { + prefix.push('_'); + } + prefix +} + +/// Derives the canonical bouquet filename for a given target name. +pub fn target_bouquet_file_name(target_name: &str) -> String { + let prefix = sanitize_target_prefix(target_name); + let hash = blake3::hash(target_name.as_bytes()); + let hex = hash.to_hex(); + format!("{}--{}.yml", prefix, &hex[..12]) +} + +/// Derives the full path to a target bouquet file. +pub fn target_bouquet_path(config_path: &Path, target_name: &str) -> PathBuf { + target_bouquet_dir(config_path).join(target_bouquet_file_name(target_name)) +} + +/// Checks if a bouquet file exists for the given target. +pub async fn target_bouquet_exists(config_path: &Path, target_name: &str) -> bool { + tokio::fs::try_exists(target_bouquet_path(config_path, target_name)).await.unwrap_or(false) +} + +/// Loads a target bouquet from disk. Returns `Ok(None)` if the file does not exist. +/// Fails closed with an error on corruption, target mismatch, or unsupported version. +pub async fn load_target_bouquet( + app_config: &AppConfig, + target_name: &str, +) -> Result, TuliproxError> { + let paths = app_config.paths.load(); + let config_path = Path::new(&paths.config_path); + let file_path = target_bouquet_path(config_path, target_name); + + let _lock = app_config.file_locks.read_lock(&file_path).await; + + let content = match tokio::fs::read_to_string(&file_path).await { + Ok(c) => c, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(TuliproxError::TargetBouquet(format!( + "Failed to read bouquet file {}: {err}", + file_path.display() + ))); + } + }; + + let mut dto: TargetBouquetFileDto = serde_saphyr::from_str(&content).map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to parse target bouquet YAML in {}: {err}", file_path.display())) + })?; + + if dto.version != TARGET_BOUQUET_VERSION { + return Err(TuliproxError::TargetBouquet(format!( + "Unsupported target bouquet version {} in {}", + dto.version, + file_path.display() + ))); + } + + if dto.target != target_name { + return Err(TuliproxError::TargetBouquet(format!( + "Target name mismatch in {}: file contains target '{}', expected '{target_name}'", + file_path.display(), + dto.target + ))); + } + + dto.canonicalize(); + + Ok(Some(dto)) +} + +/// Saves a target bouquet, including unrestricted selections so the chosen mode is retained. +pub async fn save_target_bouquet( + app_config: &AppConfig, + target_name: &str, + mut bouquet: TargetBouquetDto, +) -> Result<(), TuliproxError> { + bouquet.groups.canonicalize_for_target(); + + let dto = TargetBouquetFileDto::new(target_name, bouquet); + let yaml = serde_saphyr::to_string(&dto) + .map_err(|err| TuliproxError::TargetBouquet(format!("Failed to serialize target bouquet to YAML: {err}")))?; + + let paths = app_config.paths.load(); + let config_path = Path::new(&paths.config_path); + let file_path = target_bouquet_path(config_path, target_name); + + let _mutation_lock = app_config.file_locks.write_lock_str(TARGET_BOUQUET_MUTATION_LOCK).await; + let _file_lock = app_config.file_locks.write_lock(&file_path).await; + + write_text_file_atomic(&file_path, &yaml).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to write target bouquet {}: {err}", file_path.display())) + })?; + + debug!("Saved target bouquet for '{}' to {}", target_name, file_path.display()); + Ok(()) +} + +/// Deletes a target bouquet file idempotently. +pub async fn delete_target_bouquet(app_config: &AppConfig, target_name: &str) -> Result<(), TuliproxError> { + let _mutation_lock = app_config.file_locks.write_lock_str(TARGET_BOUQUET_MUTATION_LOCK).await; + delete_target_bouquet_locked(app_config, target_name).await +} + +async fn delete_target_bouquet_locked(app_config: &AppConfig, target_name: &str) -> Result<(), TuliproxError> { + let paths = app_config.paths.load(); + let config_path = Path::new(&paths.config_path); + let file_path = target_bouquet_path(config_path, target_name); + + let _file_lock = app_config.file_locks.write_lock(&file_path).await; + + match tokio::fs::remove_file(&file_path).await { + Ok(()) => { + debug!("Deleted target bouquet file {}", file_path.display()); + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(TuliproxError::TargetBouquet(format!( + "Failed to delete target bouquet file {}: {err}", + file_path.display() + ))), + } +} + +/// Renames a target bouquet when a target is renamed in Source Editor. +pub async fn rename_target_bouquet( + app_config: &AppConfig, + old_target_name: &str, + new_target_name: &str, +) -> Result<(), TuliproxError> { + let _mutation_lock = app_config.file_locks.write_lock_str(TARGET_BOUQUET_MUTATION_LOCK).await; + rename_target_bouquet_locked(app_config, old_target_name, new_target_name).await +} + +async fn rename_target_bouquet_locked( + app_config: &AppConfig, + old_target_name: &str, + new_target_name: &str, +) -> Result<(), TuliproxError> { + if old_target_name == new_target_name { + return Ok(()); + } + + let paths = app_config.paths.load(); + let config_path = Path::new(&paths.config_path); + let old_path = target_bouquet_path(config_path, old_target_name); + let new_path = target_bouquet_path(config_path, new_target_name); + + if old_path == new_path { + return Ok(()); + } + + // Acquire locks in lexicographical order to prevent deadlocks + let (_first_lock, _second_lock) = if old_path <= new_path { + (app_config.file_locks.write_lock(&old_path).await, app_config.file_locks.write_lock(&new_path).await) + } else { + (app_config.file_locks.write_lock(&new_path).await, app_config.file_locks.write_lock(&old_path).await) + }; + + match tokio::fs::try_exists(&old_path).await { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(err) => { + return Err(TuliproxError::TargetBouquet(format!( + "Failed to inspect source bouquet {}: {err}", + old_path.display() + ))); + } + } + + if tokio::fs::try_exists(&new_path).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to inspect destination bouquet {}: {err}", new_path.display())) + })? { + return Err(TuliproxError::TargetBouquet(format!( + "Cannot rename bouquet: destination file {} already exists", + new_path.display() + ))); + } + + rewrite_bouquet_with_target(&old_path, &new_path, new_target_name).await?; + tokio::fs::remove_file(&old_path).await.map_err(|err| { + TuliproxError::TargetBouquet(format!( + "Renamed bouquet was written to {}, but removing source file {} failed: {err}", + new_path.display(), + old_path.display() + )) + })?; + debug!( + "Renamed target bouquet from '{}' ({}) to '{}' ({})", + old_target_name, + old_path.display(), + new_target_name, + new_path.display() + ); + Ok(()) +} + +async fn rewrite_bouquet_with_target(src: &Path, dest: &Path, new_target: &str) -> Result<(), TuliproxError> { + let content = tokio::fs::read_to_string(src).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to read source bouquet file {}: {err}", src.display())) + })?; + + let mut dto: TargetBouquetFileDto = serde_saphyr::from_str(&content).map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to parse source bouquet file {}: {err}", src.display())) + })?; + + dto.target = new_target.to_string(); + dto.canonicalize(); + + let yaml = serde_saphyr::to_string(&dto) + .map_err(|err| TuliproxError::TargetBouquet(format!("Failed to serialize renamed bouquet: {err}")))?; + + write_text_file_atomic(dest, &yaml).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to write renamed bouquet to {}: {err}", dest.display())) + }) +} + +/// Pre-validates that target bouquet rename mutations will not collide with existing destinations. +pub async fn validate_target_bouquet_mutations( + config_path: &Path, + renames: &[(String, String)], + deletions: &[String], +) -> Result<(), TuliproxError> { + let mut seen_sources = HashSet::with_capacity(renames.len()); + let mut seen_destinations = HashSet::with_capacity(renames.len()); + for (old_name, new_name) in renames { + if !seen_sources.insert(old_name.as_str()) { + return Err(TuliproxError::TargetBouquet(format!("Duplicate rename source '{old_name}'"))); + } + if !seen_destinations.insert(new_name.as_str()) { + return Err(TuliproxError::TargetBouquet(format!("Duplicate rename destination '{new_name}'"))); + } + } + + let deleted_names: HashSet<&str> = deletions.iter().map(String::as_str).collect(); + let renamed_old_names: HashSet<&str> = renames.iter().map(|(old, _)| old.as_str()).collect(); + + for (old_name, new_name) in renames { + if old_name == new_name { + continue; + } + let old_path = target_bouquet_path(config_path, old_name); + let new_path = target_bouquet_path(config_path, new_name); + + if !tokio::fs::try_exists(&old_path).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to inspect source bouquet {}: {err}", old_path.display())) + })? { + return Err(TuliproxError::TargetBouquet(format!( + "Cannot rename target bouquet from '{old_name}' to '{new_name}': source file {} does not exist", + old_path.display() + ))); + } + + if tokio::fs::try_exists(&new_path).await.map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to inspect destination bouquet {}: {err}", new_path.display())) + })? && !deleted_names.contains(new_name.as_str()) + && !renamed_old_names.contains(new_name.as_str()) + { + return Err(TuliproxError::TargetBouquet(format!( + "Cannot rename target bouquet from '{old_name}' to '{new_name}': destination file {} already exists", + new_path.display() + ))); + } + } + Ok(()) +} + +/// Applies source-editor renames and deletions while acquiring the logical mutation lock. +pub async fn apply_target_bouquet_mutations( + app_config: &AppConfig, + renames: &[(String, String)], + deletions: &[String], +) -> Result<(), TuliproxError> { + let _mutation_lock = app_config.file_locks.write_lock_str(TARGET_BOUQUET_MUTATION_LOCK).await; + apply_target_bouquet_mutations_locked(app_config, renames, deletions).await +} + +type BouquetFileSnapshot = Vec<(PathBuf, Option>)>; + +async fn capture_bouquet_snapshot(paths: &[PathBuf]) -> Result { + let mut snapshot = Vec::with_capacity(paths.len()); + for path in paths { + let content = match tokio::fs::read(path).await { + Ok(content) => Some(content), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + return Err(TuliproxError::TargetBouquet(format!( + "Failed to snapshot target bouquet {}: {err}", + path.display() + ))); + } + }; + snapshot.push((path.clone(), content)); + } + Ok(snapshot) +} + +async fn restore_bouquet_snapshot(snapshot: &BouquetFileSnapshot) -> Result<(), TuliproxError> { + let mut failures = Vec::new(); + for (path, _) in snapshot { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + failures.push(format!("failed to clear {} during rollback: {err}", path.display())); + } + } + } + for (path, content) in snapshot { + if let Some(content) = content { + if let Err(err) = write_file_atomic(path, content).await { + failures.push(format!("failed to restore {} during rollback: {err}", path.display())); + } + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(TuliproxError::TargetBouquet(failures.join("; "))) + } +} + +async fn rollback_bouquet_mutation(snapshot: &BouquetFileSnapshot, mutation_error: TuliproxError) -> TuliproxError { + match restore_bouquet_snapshot(snapshot).await { + Ok(()) => mutation_error, + Err(rollback_error) => TuliproxError::TargetBouquet(format!( + "{mutation_error}; target bouquet rollback also failed: {rollback_error}" + )), + } +} + +/// Applies source-editor renames and deletions. +/// Caller MUST already hold `TARGET_BOUQUET_MUTATION_LOCK`. +/// All affected files are snapshotted and locked before mutation so chained renames, +/// swaps, deletions, and failures restore the exact pre-mutation state. +pub async fn apply_target_bouquet_mutations_locked( + app_config: &AppConfig, + renames: &[(String, String)], + deletions: &[String], +) -> Result<(), TuliproxError> { + let paths = app_config.paths.load(); + let config_path = Path::new(&paths.config_path); + validate_target_bouquet_mutations(config_path, renames, deletions).await?; + + let mut affected_paths = Vec::with_capacity(renames.len() * 2 + deletions.len()); + for target_name in deletions { + affected_paths.push(target_bouquet_path(config_path, target_name)); + } + for (old_name, new_name) in renames { + affected_paths.push(target_bouquet_path(config_path, old_name)); + affected_paths.push(target_bouquet_path(config_path, new_name)); + } + affected_paths.sort_unstable(); + affected_paths.dedup(); + + let mut file_locks = Vec::with_capacity(affected_paths.len()); + for path in &affected_paths { + file_locks.push(app_config.file_locks.write_lock(path).await); + } + + let snapshot = capture_bouquet_snapshot(&affected_paths).await?; + let mut writes = Vec::with_capacity(renames.len()); + for (old_name, new_name) in renames { + if old_name == new_name { + continue; + } + let old_path = target_bouquet_path(config_path, old_name); + let Some(content) = + snapshot.iter().find(|(path, _)| path == &old_path).and_then(|(_, content)| content.as_deref()) + else { + continue; + }; + let content = std::str::from_utf8(content).map_err(|err| { + TuliproxError::TargetBouquet(format!("Source bouquet file {} is not UTF-8: {err}", old_path.display())) + })?; + let mut dto: TargetBouquetFileDto = serde_saphyr::from_str(content).map_err(|err| { + TuliproxError::TargetBouquet(format!("Failed to parse source bouquet file {}: {err}", old_path.display())) + })?; + if dto.version != TARGET_BOUQUET_VERSION || dto.target != *old_name { + return Err(TuliproxError::TargetBouquet(format!( + "Source bouquet {} does not match target '{old_name}' or version {TARGET_BOUQUET_VERSION}", + old_path.display() + ))); + } + dto.target.clone_from(new_name); + dto.canonicalize(); + let yaml = serde_saphyr::to_string(&dto) + .map_err(|err| TuliproxError::TargetBouquet(format!("Failed to serialize renamed bouquet: {err}")))?; + writes.push((target_bouquet_path(config_path, new_name), yaml)); + } + + for path in &affected_paths { + if let Err(err) = tokio::fs::remove_file(path).await { + if err.kind() != std::io::ErrorKind::NotFound { + let mutation_error = TuliproxError::TargetBouquet(format!( + "Failed to clear target bouquet {} during mutation: {err}", + path.display() + )); + return Err(rollback_bouquet_mutation(&snapshot, mutation_error).await); + } + } + } + + for (new_path, yaml) in writes { + if let Err(err) = write_text_file_atomic(&new_path, &yaml).await { + let mutation_error = TuliproxError::TargetBouquet(format!( + "Failed to publish renamed target bouquet {}: {err}", + new_path.display() + )); + return Err(rollback_bouquet_mutation(&snapshot, mutation_error).await); + } + debug!("Finalized renamed target bouquet at {}", new_path.display()); + } + + drop(file_locks); + Ok(()) +} + +/// Lists all bouquet files in `config/bouquets/`. +pub async fn list_target_bouquet_files(config_path: &Path) -> Result, TuliproxError> { + let dir = target_bouquet_dir(config_path); + let mut entries = Vec::new(); + let mut dir_reader = match tokio::fs::read_dir(&dir).await { + Ok(reader) => reader, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(entries), + Err(err) => { + return Err(TuliproxError::TargetBouquet(format!( + "Failed to read bouquets directory {}: {err}", + dir.display() + ))); + } + }; + + while let Some(entry) = dir_reader + .next_entry() + .await + .map_err(|err| TuliproxError::TargetBouquet(format!("Failed to read bouquet directory entry: {err}")))? + { + let file_type = entry + .file_type() + .await + .map_err(|err| TuliproxError::TargetBouquet(format!("Failed to inspect bouquet directory entry: {err}")))?; + let path = entry.path(); + if file_type.is_file() && path.extension().is_some_and(|ext| ext == "yml" || ext == "yaml") { + entries.push(path); + } + } + Ok(entries) +} + +/// Audit report for bouquets on disk. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TargetBouquetAuditReport { + pub orphan_files: Vec, + pub corrupt_files: Vec<(PathBuf, String)>, + pub leftover_temp_files: Vec, +} + +/// Audits target bouquet files on disk against current active target names. +pub async fn audit_target_bouquets( + config_path: &Path, + active_target_names: &HashSet, +) -> Result { + let files = list_target_bouquet_files(config_path).await?; + let mut report = TargetBouquetAuditReport::default(); + + // Clean up and report any orphaned temporary staging files in the bouquets directory + let dir = target_bouquet_dir(config_path); + if let Ok(mut dir_reader) = tokio::fs::read_dir(&dir).await { + while let Ok(Some(entry)) = dir_reader.next_entry().await { + let path = entry.path(); + if entry.file_type().await.is_ok_and(|file_type| file_type.is_file()) { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') && name.contains(".tmp-") { + let _ = tokio::fs::remove_file(&path).await; + report.leftover_temp_files.push(path); + } + } + } + } + } + + for file in files { + let content = match tokio::fs::read_to_string(&file).await { + Ok(c) => c, + Err(err) => { + report.corrupt_files.push((file, format!("Failed to read: {err}"))); + continue; + } + }; + + let dto: TargetBouquetFileDto = match serde_saphyr::from_str(&content) { + Ok(d) => d, + Err(err) => { + report.corrupt_files.push((file, format!("YAML parse error: {err}"))); + continue; + } + }; + + if !active_target_names.contains(&dto.target) { + report.orphan_files.push(file); + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use arc_swap::{ArcSwap, ArcSwapOption}; + use shared::model::{ConfigPaths, PlaylistClusterBouquetDto, TargetBouquetMode}; + use std::sync::Arc; + use tempfile::TempDir; + use tuliprox_core::{ + model::{Config, MediaToolCapabilities, SourcesConfig}, + utils::FileLockManager, + }; + + fn test_app_config(config_path: &str) -> AppConfig { + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: config_path.to_string(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } + } + + fn whitelist(groups: PlaylistClusterBouquetDto) -> TargetBouquetDto { TargetBouquetDto::whitelist(groups) } + + #[test] + fn path_derivation_sanitizes_and_hashes() { + let config_path = Path::new("/app/config"); + let path = target_bouquet_path(config_path, "Family & Kids/Living Room"); + let name = path.file_name().unwrap().to_str().unwrap(); + + assert!(name.starts_with("Family___Kids_Living_Room--")); + assert!(name.ends_with(".yml")); + assert_eq!(path.parent().unwrap(), Path::new("/app/config/bouquets")); + } + + #[test] + fn different_names_with_same_sanitized_prefix_have_distinct_paths() { + let config_path = Path::new("/app/config"); + let path1 = target_bouquet_path(config_path, "Target A!"); + let path2 = target_bouquet_path(config_path, "Target A?"); + assert_ne!(path1, path2); + } + + #[tokio::test] + async fn save_load_round_trip() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + let bouquet = PlaylistClusterBouquetDto { + live: Some(vec!["Kids".to_string(), "News".to_string()]), + vod: Some(vec!["Action".to_string()]), + series: None, + }; + + save_target_bouquet(&app_config, "family", whitelist(bouquet.clone())).await.unwrap(); + + let loaded = load_target_bouquet(&app_config, "family").await.unwrap(); + assert!(loaded.is_some()); + let dto = loaded.unwrap(); + assert_eq!(dto.target, "family"); + assert_eq!(dto.bouquet.mode, TargetBouquetMode::Whitelist); + assert_eq!(dto.bouquet.groups.live, Some(vec!["Kids".to_string(), "News".to_string()])); + assert_eq!(dto.bouquet.groups.vod, Some(vec!["Action".to_string()])); + assert!(dto.bouquet.groups.series.is_none()); + } + + #[tokio::test] + async fn saving_unrestricted_retains_the_selected_mode() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None }; + + save_target_bouquet(&app_config, "family", whitelist(bouquet)).await.unwrap(); + assert!(target_bouquet_exists(temp_dir.path(), "family").await); + + // Save unrestricted (empty vectors) + let empty_bouquet = PlaylistClusterBouquetDto { live: Some(vec![]), vod: None, series: None }; + save_target_bouquet(&app_config, "family", TargetBouquetDto::new(TargetBouquetMode::Blacklist, empty_bouquet)) + .await + .unwrap(); + assert!(target_bouquet_exists(temp_dir.path(), "family").await); + let loaded = load_target_bouquet(&app_config, "family").await.unwrap().unwrap(); + assert_eq!(loaded.bouquet.mode, TargetBouquetMode::Blacklist); + assert!(loaded.is_unrestricted()); + } + + #[tokio::test] + async fn corrupt_yaml_fails_closed() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + let file_path = target_bouquet_path(temp_dir.path(), "family"); + tokio::fs::create_dir_all(file_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&file_path, "not: valid: yaml: [").await.unwrap(); + + let result = load_target_bouquet(&app_config, "family").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn embedded_target_mismatch_fails() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + let file_path = target_bouquet_path(temp_dir.path(), "family"); + tokio::fs::create_dir_all(file_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&file_path, "version: 1\ntarget: other_target\ngroups:\n live:\n - Kids\n").await.unwrap(); + + let result = load_target_bouquet(&app_config, "family").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn rename_moves_file_and_updates_embedded_target() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "old_name", whitelist(bouquet)).await.unwrap(); + assert!(target_bouquet_exists(temp_dir.path(), "old_name").await); + + rename_target_bouquet(&app_config, "old_name", "new_name").await.unwrap(); + assert!(!target_bouquet_exists(temp_dir.path(), "old_name").await); + assert!(target_bouquet_exists(temp_dir.path(), "new_name").await); + + let loaded = load_target_bouquet(&app_config, "new_name").await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().target, "new_name"); + } + + #[tokio::test] + async fn mutation_batch_holds_global_lock_without_self_deadlock() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "rename_me", whitelist(bouquet.clone())).await.unwrap(); + save_target_bouquet(&app_config, "delete_me", whitelist(bouquet)).await.unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + apply_target_bouquet_mutations( + &app_config, + &[("rename_me".to_string(), "renamed".to_string())], + &["delete_me".to_string()], + ), + ) + .await + .expect("mutation batch must not deadlock") + .unwrap(); + + assert!(target_bouquet_exists(temp_dir.path(), "renamed").await); + assert!(!target_bouquet_exists(temp_dir.path(), "delete_me").await); + } + + #[tokio::test] + async fn mutation_validation_detects_destination_collisions() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None }; + + save_target_bouquet(&app_config, "target_a", whitelist(bouquet.clone())).await.unwrap(); + save_target_bouquet(&app_config, "target_b", whitelist(bouquet)).await.unwrap(); + + // target_a -> target_b should collide because target_b exists and is not deleted + let result = validate_target_bouquet_mutations( + temp_dir.path(), + &[("target_a".to_string(), "target_b".to_string())], + &[], + ) + .await; + assert!(result.is_err()); + + // target_a -> target_b is allowed if target_b is in deletions + let result_with_deletion = validate_target_bouquet_mutations( + temp_dir.path(), + &[("target_a".to_string(), "target_b".to_string())], + &["target_b".to_string()], + ) + .await; + assert!(result_with_deletion.is_ok()); + + // Applying deletion before rename succeeds + apply_target_bouquet_mutations( + &app_config, + &[("target_a".to_string(), "target_b".to_string())], + &["target_b".to_string()], + ) + .await + .unwrap(); + + assert!(!target_bouquet_exists(temp_dir.path(), "target_a").await); + assert!(target_bouquet_exists(temp_dir.path(), "target_b").await); + } + + #[tokio::test] + async fn mutation_application_handles_chained_renames_and_swaps() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + // Test chained rename: 1 -> 2, 2 -> 3 + let bouquet1 = PlaylistClusterBouquetDto { live: Some(vec!["News".to_string()]), vod: None, series: None }; + let bouquet2 = PlaylistClusterBouquetDto { live: Some(vec!["Sports".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "target_1", whitelist(bouquet1)).await.unwrap(); + save_target_bouquet(&app_config, "target_2", whitelist(bouquet2)).await.unwrap(); + + let chained_renames = + vec![("target_1".to_string(), "target_2".to_string()), ("target_2".to_string(), "target_3".to_string())]; + validate_target_bouquet_mutations(temp_dir.path(), &chained_renames, &[]).await.unwrap(); + apply_target_bouquet_mutations(&app_config, &chained_renames, &[]).await.unwrap(); + + assert!(!target_bouquet_exists(temp_dir.path(), "target_1").await); + let b2 = load_target_bouquet(&app_config, "target_2").await.unwrap().unwrap(); + assert_eq!(b2.bouquet.groups.live, Some(vec!["News".to_string()])); + let b3 = load_target_bouquet(&app_config, "target_3").await.unwrap().unwrap(); + assert_eq!(b3.bouquet.groups.live, Some(vec!["Sports".to_string()])); + + // Test swap: 2 <-> 3 + let swap_renames = + vec![("target_2".to_string(), "target_3".to_string()), ("target_3".to_string(), "target_2".to_string())]; + validate_target_bouquet_mutations(temp_dir.path(), &swap_renames, &[]).await.unwrap(); + apply_target_bouquet_mutations(&app_config, &swap_renames, &[]).await.unwrap(); + + let swapped_b2 = load_target_bouquet(&app_config, "target_2").await.unwrap().unwrap(); + assert_eq!(swapped_b2.bouquet.groups.live, Some(vec!["Sports".to_string()])); + let swapped_b3 = load_target_bouquet(&app_config, "target_3").await.unwrap().unwrap(); + assert_eq!(swapped_b3.bouquet.groups.live, Some(vec!["News".to_string()])); + } + + #[tokio::test] + async fn mutation_validation_rejects_duplicate_destinations() { + let temp_dir = TempDir::new().unwrap(); + let renames = + vec![("target_a".to_string(), "target_c".to_string()), ("target_b".to_string(), "target_c".to_string())]; + let err = validate_target_bouquet_mutations(temp_dir.path(), &renames, &[]).await.unwrap_err(); + assert!(err.to_string().contains("Duplicate rename destination 'target_c'")); + } + + #[tokio::test] + async fn mutation_with_missing_source_preserves_existing_destination() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["News".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "target_b", whitelist(bouquet)).await.unwrap(); + + let renames = [("missing".to_string(), "target_b".to_string())]; + let result = apply_target_bouquet_mutations(&app_config, &renames, &[]).await; + + assert!(result.is_err()); + assert!(target_bouquet_exists(temp_dir.path(), "target_b").await); + assert_eq!( + load_target_bouquet(&app_config, "target_b").await.unwrap().unwrap().bouquet.groups.live, + Some(vec!["News".to_string()]) + ); + } + + #[tokio::test] + async fn mutation_application_rolls_back_on_corrupt_yaml() { + let temp_dir = TempDir::new().unwrap(); + let app_config = test_app_config(temp_dir.path().to_str().unwrap()); + + // Create target_ok with valid bouquet + let bouquet = PlaylistClusterBouquetDto { live: Some(vec!["News".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "target_ok", whitelist(bouquet)).await.unwrap(); + let deleted_bouquet = + PlaylistClusterBouquetDto { live: Some(vec!["Kids".to_string()]), vod: None, series: None }; + save_target_bouquet(&app_config, "delete_me", whitelist(deleted_bouquet)).await.unwrap(); + + // Create target_corrupt with corrupt content directly on disk + let corrupt_path = target_bouquet_path(temp_dir.path(), "target_corrupt"); + tokio::fs::create_dir_all(corrupt_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&corrupt_path, "not: [valid: {yaml").await.unwrap(); + + let renames = vec![ + ("target_ok".to_string(), "target_ok_renamed".to_string()), + ("target_corrupt".to_string(), "target_corrupt_renamed".to_string()), + ]; + + // Validation passes since destinations do not exist on disk + validate_target_bouquet_mutations(temp_dir.path(), &renames, &[]).await.unwrap(); + + // Application must fail due to corrupt YAML and roll back target_ok + let result = apply_target_bouquet_mutations(&app_config, &renames, &["delete_me".to_string()]).await; + assert!(result.is_err()); + + // target_ok must have been restored or remain intact + assert!(target_bouquet_exists(temp_dir.path(), "target_ok").await); + assert!(target_bouquet_exists(temp_dir.path(), "delete_me").await); + // target_corrupt must have been restored + assert!(tokio::fs::try_exists(&corrupt_path).await.unwrap_or(false)); + } + + #[tokio::test] + async fn audit_sweeps_leftover_temporary_staging_files() { + let temp_dir = TempDir::new().unwrap(); + let bouquet_dir = target_bouquet_dir(temp_dir.path()); + tokio::fs::create_dir_all(&bouquet_dir).await.unwrap(); + + let leftover_tmp = bouquet_dir.join(".some_target.yml.tmp-123-456"); + tokio::fs::write(&leftover_tmp, "stale temp content").await.unwrap(); + + let report = audit_target_bouquets(temp_dir.path(), &HashSet::new()).await.unwrap(); + assert_eq!(report.leftover_temp_files, vec![leftover_tmp.clone()]); + assert!(!tokio::fs::try_exists(&leftover_tmp).await.unwrap_or(false)); + } +} diff --git a/backend/repository/src/xtream_repository.rs b/backend/repository/src/xtream_repository.rs index 4705e6436..185f0dde2 100644 --- a/backend/repository/src/xtream_repository.rs +++ b/backend/repository/src/xtream_repository.rs @@ -32,7 +32,7 @@ use shared::{ utils::{arc_str_serde, get_u32_from_serde_value, Internable}, }; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, ffi::OsString, fs::File, io::{self, Error, ErrorKind}, @@ -41,11 +41,12 @@ use std::{ }; use tuliprox_core::{ model::{ - AppConfig, Config, ConfigInput, ConfigTarget, PlaylistXtreamCategory, ProxyUserCredentials, XtreamCategory, + AppConfig, Config, ConfigInput, ConfigInputFlags, ConfigTarget, PlaylistXtreamCategory, ProxyUserCredentials, + XtreamCategory, }, utils::{ file_exists_async, file_reader, json_write_documents_to_file, parent_or_dot, remove_file_if_exists, - request::DynReader, require_same_parent_directory, FileReadGuard, + request::DynReader, require_same_parent_directory, FileReadGuard, FileWriteGuard, }, }; use tuliprox_parser::xtream; @@ -1025,14 +1026,24 @@ fn preserve_details_with_injected_operation_failure( }) } +struct StagedXtreamClusterRefresh { + refresh_lease: XtreamRefreshLease, + publish_lock: Arc, + storage_path: PathBuf, + input_name: Arc, + cluster: XtreamCluster, + raw_groups: Vec, + item_count: usize, +} + #[allow(clippy::too_many_lines)] -pub async fn persist_input_xtream_playlist_cluster_to_disk( +async fn stage_input_xtream_playlist_cluster_to_disk( app_config: &Arc, input: &ConfigInput, cluster: XtreamCluster, categories: DynReader, streams: DynReader, -) -> Result<(), TuliproxError> { +) -> Result { let cfg = app_config.config.load(); let storage_path = ensure_input_storage_path(&cfg, &input.name).await?; drop(cfg); @@ -1088,9 +1099,15 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( tree.set_flush_policy(FlushPolicy::Batch); let mut buffer = Vec::with_capacity(BATCH_SIZE); + let mut seen_groups: HashSet = HashSet::new(); + let mut item_count = 0_usize; // This loop exits when all 'tx' clones are dropped (signaling end of stream) while let Some(item) = rx.blocking_recv() { + item_count = item_count.saturating_add(1); + if !seen_groups.contains(item.group.as_ref()) { + seen_groups.insert(item.group.to_string()); + } buffer.push(item); if buffer.len() >= BATCH_SIZE { let batch: Vec<(&u32, &XtreamPlaylistItem)> = buffer.iter().map(|i| (&i.provider_id, i)).collect(); @@ -1146,7 +1163,7 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( staging_path.display() )) })?; - Ok::<(), TuliproxError>(()) + Ok::<(Vec, usize), TuliproxError>((seen_groups.into_iter().collect(), item_count)) }); // 3. Robust Joining of both tasks @@ -1157,7 +1174,7 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( // Handle internal errors from the tasks let parsed_categories = parse_res?; - consumer_res?; + let (raw_groups, item_count) = consumer_res?; save_xtream_categories_to_file(refresh_lease.clone(), &parsed_categories).await?; @@ -1209,6 +1226,31 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( TuliproxError::RepositoryXtream(format!("Compaction task failed to join during {cluster} refresh: {error}")) })??; + Ok(StagedXtreamClusterRefresh { + refresh_lease, + publish_lock, + storage_path, + input_name: Arc::clone(&input.name), + cluster, + raw_groups, + item_count, + }) +} + +async fn publish_staged_xtream_cluster( + app_config: &AppConfig, + staged: StagedXtreamClusterRefresh, +) -> Result<(), TuliproxError> { + let StagedXtreamClusterRefresh { + refresh_lease, + publish_lock, + storage_path, + input_name, + cluster, + raw_groups, + item_count: _, + } = staged; + let database_publish_lease = refresh_lease.clone(); let database_publish_lock = Arc::clone(&publish_lock); tokio::task::spawn_blocking(move || { @@ -1247,6 +1289,14 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( )) })??; + if let Err(publish_err) = + crate::publish_raw_group_catalog(&storage_path, &input_name, cluster, raw_groups, &app_config.file_locks).await + { + log::warn!( + "Xtream data for input '{input_name}' cluster {cluster} was published, but its raw group catalog could not be published: {publish_err}" + ); + } + let cleanup_lease = refresh_lease.clone(); let cleanup_lock = Arc::clone(&publish_lock); tokio::task::spawn_blocking(move || { @@ -1273,6 +1323,92 @@ pub async fn persist_input_xtream_playlist_cluster_to_disk( Ok(()) } +fn input_cluster_enabled(input: &ConfigInput, cluster: XtreamCluster) -> bool { + match cluster { + XtreamCluster::Live => !input.has_flag(ConfigInputFlags::SkipLive), + XtreamCluster::Video => !input.has_flag(ConfigInputFlags::SkipVod), + XtreamCluster::Series => !input.has_flag(ConfigInputFlags::SkipSeries), + } +} + +async fn published_xtream_cluster_has_items( + app_config: &AppConfig, + storage_path: &Path, + cluster: XtreamCluster, +) -> Result { + let path = xtream_get_file_path(storage_path, cluster); + let lock = app_config.file_locks.read_lock(&path).await; + tokio::task::spawn_blocking(move || { + let _guard = lock; + match BPlusTreeQuery::::try_new(&path) { + Ok(mut query) => query.iter().next().transpose().map(|entry| entry.is_some()).map_err(|error| { + TuliproxError::RepositoryXtream(format!( + "Failed to inspect published Xtream cluster {cluster} at {}: {error}", + path.display() + )) + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(TuliproxError::RepositoryXtream(format!( + "Failed to open published Xtream cluster {cluster} at {}: {error}", + path.display() + ))), + } + }) + .await + .map_err(|error| TuliproxError::Task(format!("Failed to join Xtream cluster inspection: {error}")))? +} + +pub async fn persist_input_xtream_playlist_clusters_to_disk( + app_config: &Arc, + input: &ConfigInput, + cluster_readers: Vec<(XtreamCluster, DynReader, DynReader)>, +) -> Result<(), TuliproxError> { + let mut staged = Vec::with_capacity(cluster_readers.len()); + for (cluster, categories, streams) in cluster_readers { + staged + .push(stage_input_xtream_playlist_cluster_to_disk(app_config, input, cluster, categories, streams).await?); + } + + let staged_clusters: HashSet = staged.iter().map(|refresh| refresh.cluster).collect(); + let mut has_items = staged.iter().any(|refresh| refresh.item_count > 0); + if !has_items { + let cfg = app_config.config.load(); + let storage_path = ensure_input_storage_path(&cfg, &input.name).await?; + drop(cfg); + for cluster in XTREAM_CLUSTER { + if input_cluster_enabled(input, cluster) + && !staged_clusters.contains(&cluster) + && published_xtream_cluster_has_items(app_config, &storage_path, cluster).await? + { + has_items = true; + break; + } + } + } + + if !has_items { + return Err(TuliproxError::RepositoryPlaylist(format!( + "Refusing to publish empty disk-based Xtream playlist for input '{}'; existing data was retained", + input.name + ))); + } + + for refresh in staged { + publish_staged_xtream_cluster(app_config, refresh).await?; + } + Ok(()) +} + +pub async fn persist_input_xtream_playlist_cluster_to_disk( + app_config: &Arc, + input: &ConfigInput, + cluster: XtreamCluster, + categories: DynReader, + streams: DynReader, +) -> Result<(), TuliproxError> { + persist_input_xtream_playlist_clusters_to_disk(app_config, input, vec![(cluster, categories, streams)]).await +} + fn publish_staged_file_same_directory(staging: &Path, published: &Path) -> io::Result<()> { require_same_parent_directory(staging, published)?; let staging_path = tempfile::TempPath::try_from_path(staging).map_err(|error| { @@ -1992,6 +2128,89 @@ mod tests { Box::pin(reader) } + #[tokio::test] + async fn empty_disk_refresh_retains_published_playlist_and_catalog() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let app_config = test_app_config(directory.path()); + let input = ConfigInput { name: "empty-guard".intern(), input_type: InputType::Xtream, ..Default::default() }; + + persist_input_xtream_playlist_cluster_to_disk( + &app_config, + &input, + XtreamCluster::Live, + json_reader(r#"[{"category_id":"1","category_name":"News"}]"#), + json_reader(r#"[{"name":"Channel","stream_id":7,"category_id":"1","added":"0"}]"#), + ) + .await?; + + let storage = build_input_storage_path(&input.name, directory.path().to_string_lossy().as_ref()); + let database = super::xtream_get_file_path(&storage, XtreamCluster::Live); + let catalog = crate::raw_group_catalog_path(&storage, XtreamCluster::Live); + let database_before = tokio::fs::read(&database).await?; + let catalog_before = tokio::fs::read(&catalog).await?; + + let result = persist_input_xtream_playlist_cluster_to_disk( + &app_config, + &input, + XtreamCluster::Live, + json_reader("[]"), + json_reader("[]"), + ) + .await; + + assert!(result.is_err()); + assert_eq!(tokio::fs::read(database).await?, database_before); + assert_eq!(tokio::fs::read(catalog).await?, catalog_before); + Ok(()) + } + + #[tokio::test] + async fn empty_cluster_is_published_when_another_cluster_still_has_items() -> Result<(), Box> + { + let directory = tempfile::tempdir()?; + let app_config = test_app_config(directory.path()); + let input = ConfigInput { name: "empty-cluster".intern(), input_type: InputType::Xtream, ..Default::default() }; + + for (cluster, category, stream) in [ + ( + XtreamCluster::Live, + r#"[{"category_id":"1","category_name":"News"}]"#, + r#"[{"name":"Channel","stream_id":7,"category_id":"1","added":"0"}]"#, + ), + ( + XtreamCluster::Video, + r#"[{"category_id":"2","category_name":"Movies"}]"#, + r#"[{"name":"Movie","stream_id":8,"category_id":"2","added":"0"}]"#, + ), + ] { + persist_input_xtream_playlist_cluster_to_disk( + &app_config, + &input, + cluster, + json_reader(category), + json_reader(stream), + ) + .await?; + } + + persist_input_xtream_playlist_cluster_to_disk( + &app_config, + &input, + XtreamCluster::Live, + json_reader("[]"), + json_reader("[]"), + ) + .await?; + + let storage = build_input_storage_path(&input.name, directory.path().to_string_lossy().as_ref()); + let playlist = + super::load_input_xtream_playlist(&app_config, &storage, &[XtreamCluster::Live, XtreamCluster::Video]) + .await?; + assert!(playlist.iter().all(|group| group.xtream_cluster != XtreamCluster::Live)); + assert!(playlist.iter().any(|group| group.xtream_cluster == XtreamCluster::Video)); + Ok(()) + } + fn wait_for_child(mut child: Child, timeout: Duration) -> io::Result { let deadline = Instant::now() + timeout; loop { diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 1743cfeaf..c444a4b64 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1306,6 +1306,36 @@ Parentheses make clear which questions belong together. Text values always use q the literal group name `EMPTY`, whereas `EpgId = EMPTY` without quotes is the short form of `EpgId IS EMPTY` and checks for a missing EPG ID. In practice, start with one simple question and add further conditions only when needed. +#### Target bouquet filter in the Web UI + +The Source Editor provides an additional **Bouquets** row below a target's regular filter settings. The row shows +whether the target currently has no bouquet filter or summarizes the active mode and group count. Its action button +opens the bouquet editor as a full-size stacked view; use the back button to return to the unchanged target form. +The target shown in the title is informational because the editor always belongs to the target from which it was +opened. + +The bouquet is an additional, target-specific group filter and supports two modes: + +* **Whitelist** keeps only the selected groups. +* **Blacklist** removes the selected groups and keeps the others. + +Live, VOD, and Series selections are independent. One cluster may intentionally have an empty selection while groups +remain selected in another cluster. If no group is selected in any cluster, the bouquet is unrestricted and the target +keeps all groups; a completely empty selection never means "publish an empty playlist". Saving or resetting the +bouquet takes effect during the next playlist update. + +The available groups are read from the raw catalogs produced by playlist processing. A new installation or deleted +raw catalog therefore shows no available groups until a playlist update has completed. Missing catalogs and a missing +saved bouquet are normal states, not configuration errors. + +Bouquet files are associated with the unique target name rather than the transient numeric target ID used by the Web +UI. Renaming a target through the Source Editor removes the bouquet stored under the old name; no bouquet is created or +moved under the new target name. Deleting a target removes its bouquet together with the target. + +> **Update safety:** Tuliprox treats a completely empty input or target playlist as a failed refresh and retains the +> previously published data. This protects stable virtual IDs when a provider temporarily returns no data or a download +> fails. Consequently, an intentionally empty playlist is not persisted through a normal update. + --- ### 3.2.3 `rename` @@ -1921,3 +1951,5 @@ watch: > **Note:** `watch` is especially useful for monitoring premium groups, VOD collections, > or unstable provider segments where additions and removals should generate operational alerts. +> **Processing order:** watch evaluation runs only after `persist_playlist` succeeds. If persistence fails, watch +> evaluation is skipped and no watch events are emitted for that target on that update. diff --git a/docs/src/features.md b/docs/src/features.md index e37461e5b..0a721ec71 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -22,6 +22,7 @@ That makes it usable both for IPTV players and for media-server-oriented workflo ## Playlist processing - filter channels and groups +- manage target-specific group bouquets in whitelist or blacklist mode from the Source Editor - rename or normalize entries - apply mappings and templates - sort and regroup outputs @@ -51,6 +52,7 @@ That makes it usable both for IPTV players and for media-server-oriented workflo Tuliprox also includes: - scheduled playlist refreshes +- retain the last usable input and target playlists when an update unexpectedly produces no items - hot config reload support - provider failover and DNS-aware connection rotation - integrated download and recording manager with provider-aware fairness, retries, and RBAC diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 041c17140..a1bfa5e8c 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -43,7 +43,7 @@ strum_macros.workspace = true [dependencies.web-sys] version = "0.3.104" -features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlImageElement", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration", "Event", "BeforeUnloadEvent", "MutationObserver", "MutationObserverInit", "WebGlRenderingContext", "WebGlProgram", "WebGlShader", "WebGlBuffer", "WebGlUniformLocation", "HtmlCanvasElement", "HtmlSelectElement", "DomTokenList", "Selection"] +features = ["AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader", "Document", "Element", "Node", "Window", "HtmlCollection", "HtmlImageElement", "HtmlInputElement", "DomRect", "HtmlDialogElement", "CssStyleDeclaration", "Event", "BeforeUnloadEvent", "MutationObserver", "MutationObserverInit", "WebGlRenderingContext", "WebGlProgram", "WebGlShader", "WebGlBuffer", "WebGlUniformLocation", "HtmlCanvasElement", "HtmlSelectElement", "DomTokenList", "Selection"] [dev-dependencies] wasm-bindgen-test = "0.3.77" diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index cf488ab5d..6c55d507c 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -768,6 +768,7 @@ "USERNAME": "ุงู„ู…ุณุชุฎุฏู… ุงู„ุฐูŠ ุณูŠุชู… ุชุถู…ูŠู† ุจูŠุงู†ุงุชู‡ ููŠ ุนู†ุงูˆูŠู† URL ู„ู€ .strm." }, "TARGET_FORM": { + "BOUQUETS": "ูŠูุชุญ ู…ุญุฑุฑ ุจุงู‚ุงุช ู‡ุฐุง ุงู„ู‡ุฏู. ุชุญุชูุธ ุงู„ู‚ุงุฆู…ุฉ ุงู„ุจูŠุถุงุก ุจุงู„ู…ุฌู…ูˆุนุงุช ุงู„ู…ุญุฏุฏุฉ ูู‚ุทุŒ ุจูŠู†ู…ุง ุชุณุชุจุนุฏ ุงู„ู‚ุงุฆู…ุฉ ุงู„ุณูˆุฏุงุก ุงู„ู…ุฌู…ูˆุนุงุช ุงู„ู…ุญุฏุฏุฉ. ุชุณุฑูŠ ุงู„ุชุบูŠูŠุฑุงุช ู…ุน ุชุญุฏูŠุซ ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„ ุงู„ุชุงู„ูŠ.", "PERSIST_FILTER": "ู…ุฑุดุญ ู†ู‡ุงุฆูŠ ุงุฎุชูŠุงุฑูŠ ูŠูุทุจู‘ู‚ ุจุนุฏ ู…ุนุงู„ุฌุฉ EPG ูˆุฌู…ูŠุน ุงู„ุชุนูŠูŠู†ุงุชุŒ ู…ุจุงุดุฑุฉ ู‚ุจู„ ุงู„ุญูุธ.", "FILTER": "ุชุนุจูŠุฑ ุชุตููŠุฉ ุนุงู… ู…ุทุจู‚ ุนู„ู‰ ุงู„ู‡ุฏู ู‚ุจู„ ุฅู†ุดุงุก ู…ุฎุฑุฌุงุช ุตุฑูŠุญุฉ.", "FORCE_REDIRECT": "ูŠูุฌุจุฑ ุงู„ุจุซ ู…ุจุงุดุฑุฉ ุนู„ู‰ ุงู„ูˆูƒูŠู„ ุฃูˆ ุงู„ู…ุฒูˆุฏ ุจุฏู„ุงู‹ ู…ู† ุงู„ุญู„ ุจุดูƒู„ ูุฑุฏูŠ.", @@ -892,6 +893,8 @@ "WEB_ROOT": "Web-Root: ูŠุญุชูˆูŠ ุนู„ู‰ ู…ู„ูุงุช ูˆุงุฌู‡ุฉ ุงู„ูˆูŠุจ. ุนุงุฏุฉ ู…ุง ูŠุชู… ูˆุถุนู‡ุง ููŠ ู†ูุณ ุงู„ุฏู„ูŠู„ ุชุญุช ./web" }, "LABEL": { + "BACK": "ุฑุฌูˆุน", + "BOUQUETS": "ุงู„ุจุงู‚ุงุช", "ALL_TARGETS": "ุฌู…ูŠุน ุงู„ุฃู‡ุฏุงู", "ACCEPT_INSECURE_SSL_CERTIFICATES": "ู‚ุจูˆู„ ุดู‡ุงุฏุงุช SSL ุบูŠุฑ ุงู„ุขู…ู†ุฉ", "ACTIVE": "ู†ุดุท", @@ -1140,6 +1143,9 @@ "INCLUDE_TYPE_IN_URL": "ุชุถู…ูŠู† ุงู„ู†ูˆุน ููŠ URL", "INFINITE": "ู„ุง ู†ู‡ุงุฆูŠ", "INPUTS": "ุงู„ุฅุฏุฎุงู„ุงุช", + "SELECTION_MODE": "ูˆุถุน ุงู„ุชุญุฏูŠุฏ", + "WHITELIST_INCLUDE_SELECTED": "ุงู„ู‚ุงุฆู…ุฉ ุงู„ุจูŠุถุงุก (ุชุถู…ูŠู† ุงู„ู…ุญุฏุฏ)", + "BLACKLIST_EXCLUDE_SELECTED": "ุงู„ู‚ุงุฆู…ุฉ ุงู„ุณูˆุฏุงุก (ุงุณุชุจุนุงุฏ ุงู„ู…ุญุฏุฏ)", "INTERVAL_SECS": "ูุงุตู„ ุงู„ุซูˆุงู†ูŠ", "INPUT_TYPE": "ู†ูˆุน ุงู„ุฅุฏุฎุงู„", "IP_CHECK": "ูุญุต IP", @@ -1436,6 +1442,7 @@ "RESOURCE_REWRITE_DISABLE": "ุชุนุทูŠู„ ุฅุนุงุฏุฉ ูƒุชุงุจุฉ ุงู„ู…ูˆุฑุฏ", "RESOURCE_REWRITE_DISABLED": "ุฅุนุงุฏุฉ ูƒุชุงุจุฉ ุงู„ู…ูˆุฑุฏ ู…ุนุทู„ุฉ", "RESERVE_SLOTS_FOR_USERS": "ุญุฌุฒ ูุชุญุงุช ู„ู„ู…ุณุชุฎุฏู…ูŠู†", + "RESET": "ุฅุนุงุฏุฉ ุชุนูŠูŠู†", "REST": "REST", "RETRY": "ุฅุนุงุฏุฉ ุงู„ู…ุญุงูˆู„ุฉ", "REVERSE": "ุนูƒุณูŠ", @@ -1506,6 +1513,7 @@ "SOURCE": "ู…ุตุฏุฑ", "SOURCES": "ุงู„ู…ุตุงุฏุฑ", "SOURCE_EDITOR": "ุงู„ู…ุตุงุฏุฑ", + "TARGET_BOUQUETS": "ุจุงู‚ุงุช ุงู„ู‡ุฏู", "SOURCE_PICKER": "ู…ู†ุชู‚ูŠ ุงู„ู…ุตุฏุฑ", "SPECIFIC_TEMPLATES": "ู‚ูˆุงู„ุจ ู…ุญุฏุฏุฉ", "SSDP_DISCOVERY": "ุงูƒุชุดุงู SSDP", @@ -1952,6 +1960,21 @@ "URL_MANDATORY": "URL ู…ุทู„ูˆุจ!", "USERNAME_PASSWORD_MANDATORY": "ุงุณู… ุงู„ู…ุณุชุฎุฏู… ูˆูƒู„ู…ุฉ ุงู„ู…ุฑูˆุฑ ู…ุทู„ูˆุจุงู†!" }, + "RESET": { + "BOUQUET": { + "SUCCESS": "ุชู…ุช ุฅุนุงุฏุฉ ุชุนูŠูŠู† ุงู„ุจุงู‚ุฉ ุจู†ุฌุงุญ" + } + }, + "TARGET_BOUQUET": { + "CHANGES_APPLY_ON_UPDATE": "ูŠุชู… ุชุทุจูŠู‚ ุงู„ุชุบูŠูŠุฑุงุช ุนู†ุฏ ุชุญุฏูŠุซ ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„ ุงู„ุชุงู„ูŠ.", + "NO_FILTER": "ู„ุง ูŠูˆุฌุฏ ู…ุฑุดุญ ุจุงู‚ุงุช", + "NO_TARGETS": "ู„ู… ูŠุชู… ุชูƒูˆูŠู† ุฃูŠ ุฃู‡ุฏุงู.", + "SAVE_TARGET_FIRST": "ุงุญูุธ ุงู„ู‡ุฏู ู‚ุจู„ ุชูƒูˆูŠู† ุงู„ุจุงู‚ุงุช", + "STATUS_BLACKLIST": "ู‚ุงุฆู…ุฉ ุณูˆุฏุงุก ยท {count} ู…ุฌู…ูˆุนุงุช", + "STATUS_UNAVAILABLE": "ุญุงู„ุฉ ุงู„ุจุงู‚ุฉ ุบูŠุฑ ู…ุชุงุญุฉ", + "STATUS_WHITELIST": "ู‚ุงุฆู…ุฉ ุจูŠุถุงุก ยท {count} ู…ุฌู…ูˆุนุงุช", + "UNAVAILABLE_GROUPS": "ุงู„ู…ุฌู…ูˆุนุงุช ุงู„ู…ูƒูˆู‘ู†ุฉ ุบูŠุฑ ุงู„ู…ุชุงุญุฉ ุญุงู„ูŠู‹ุง:" + }, "SAVE": { "API_PROXY_CONFIG": { "FAIL": "ูุดู„ ุญูุธ ุชูƒูˆูŠู† ApiProxy!", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index ef57c3dbc..d46253d31 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -772,6 +772,7 @@ "USERNAME": "The user whose credentials will be embedded in the .strm URLs." }, "TARGET_FORM": { + "BOUQUETS": "Opens this target's bouquet editor. A whitelist keeps only selected groups; a blacklist excludes selected groups. Changes take effect with the next playlist update.", "PERSIST_FILTER": "Optional final filter applied after EPG processing and all mappings, immediately before persistence.", "FILTER": "Global filter expression applied to the target before creating explicit outputs.", "FORCE_REDIRECT": "Forces streams directly to the proxy or the provider instead of resolving individually.", @@ -965,10 +966,12 @@ "BACKOFF_MILLIS": "Backoff (ms)", "BACKOFF_MULTIPLIER": "Backoff multiplier", "BANDWIDTH": "Bandwidth", + "BACK": "Back", "BACKUP_DIR": "Backup dir", "BEST_MATCH_THRESHOLD": "Best Match Threshold", "BODY_TEMPLATE": "Body Template", "BOT_TOKEN": "Bot Token", + "BOUQUETS": "Bouquets", "BUFFER_ENABLED": "Buffer Enabled", "BUFFER_SIZE": "Buffer Size", "BUILD_TIME": "Build time", @@ -1147,6 +1150,9 @@ "INCLUDE_TYPE_IN_URL": "Include type in url", "INFINITE": "Infinite", "INPUTS": "Inputs", + "SELECTION_MODE": "Selection mode", + "WHITELIST_INCLUDE_SELECTED": "Whitelist (include selected)", + "BLACKLIST_EXCLUDE_SELECTED": "Blacklist (exclude selected)", "INTERVAL_SECS": "Interval secs", "INPUT_TYPE": "Input Type", "IP_CHECK": "IP Check", @@ -1521,6 +1527,7 @@ "RESOURCE_REWRITE_DISABLE": "Resource Rewrite disable", "RESOURCE_REWRITE_DISABLED": "Resource Rewrite Disabled", "RESERVE_SLOTS_FOR_USERS": "Reserve slots for users", + "RESET": "Reset", "REST": "Rest", "RETRY": "Retry", "REVERSE": "Reverse", @@ -1592,6 +1599,7 @@ "SOURCE": "Source", "SOURCES": "Sources", "SOURCE_EDITOR": "Sources", + "TARGET_BOUQUETS": "Target Bouquets", "SOURCE_PICKER": "Source Picker", "SPECIFIC_TEMPLATES": "Specific Templates", "SSDP_DISCOVERY": "SSDP Discovery", @@ -1987,6 +1995,21 @@ "URL_MANDATORY": "Url is mandatory!", "USERNAME_PASSWORD_MANDATORY": "Username and Password are mandatory!" }, + "RESET": { + "BOUQUET": { + "SUCCESS": "Bouquet reset successfully" + } + }, + "TARGET_BOUQUET": { + "CHANGES_APPLY_ON_UPDATE": "Changes are applied by the next playlist update.", + "NO_FILTER": "No bouquet filter", + "NO_TARGETS": "No targets configured.", + "SAVE_TARGET_FIRST": "Save the target before configuring bouquets", + "STATUS_BLACKLIST": "Blacklist ยท {count} groups", + "STATUS_UNAVAILABLE": "Bouquet status unavailable", + "STATUS_WHITELIST": "Whitelist ยท {count} groups", + "UNAVAILABLE_GROUPS": "Configured groups currently unavailable:" + }, "SAVE": { "API_PROXY_CONFIG": { "FAIL": "Failed to save the ApiProxy config!", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 9bbe1f7cb..15f20d8f4 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -731,6 +731,7 @@ "USERNAME": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ, ั‡ัŒะธ ัƒั‡ะตั‚ะฝั‹ะต ะดะฐะฝะฝั‹ะต ะฑัƒะดัƒั‚ ะฒัั‚ั€ะพะตะฝั‹ ะฒ URL-ะฐะดั€ะตัะฐ .strm." }, "TARGET_FORM": { + "BOUQUETS": "ะžั‚ะบั€ั‹ะฒะฐะตั‚ ั€ะตะดะฐะบั‚ะพั€ ะฑัƒะบะตั‚ะพะฒ ัั‚ะพะน ั†ะตะปะธ. ะ‘ะตะปั‹ะน ัะฟะธัะพะบ ะพัั‚ะฐะฒะปัะตั‚ ั‚ะพะปัŒะบะพ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹, ะฐ ั‡ั‘ั€ะฝั‹ะน ัะฟะธัะพะบ ะธัะบะปัŽั‡ะฐะตั‚ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹. ะ˜ะทะผะตะฝะตะฝะธั ะฟั€ะธะผะตะฝัั‚ัั ะฟั€ะธ ัะปะตะดัƒัŽั‰ะตะผ ะพะฑะฝะพะฒะปะตะฝะธะธ ะฟะปะตะนะปะธัั‚ะฐ.", "PERSIST_FILTER": "ะะตะพะฑัะทะฐั‚ะตะปัŒะฝั‹ะน ั„ะธะฝะฐะปัŒะฝั‹ะน ั„ะธะปัŒั‚ั€ ะฟะพัะปะต ะพะฑั€ะฐะฑะพั‚ะบะธ EPG ะธ ะฒัะตั… ัะพะฟะพัั‚ะฐะฒะปะตะฝะธะน, ะฝะตะฟะพัั€ะตะดัั‚ะฒะตะฝะฝะพ ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", "FILTER": "ะ“ะปะพะฑะฐะปัŒะฝะพะต ะฒั‹ั€ะฐะถะตะฝะธะต ั„ะธะปัŒั‚ั€ะฐ, ะฟั€ะธะผะตะฝัะตะผะพะต ะบ ั†ะตะปะธ ะฟะตั€ะตะด ัะพะทะดะฐะฝะธะตะผ ัะฒะฝั‹ั… ะฒั‹ะฒะพะดะพะฒ.", "FORCE_REDIRECT": "ะŸั€ะธะฝัƒะดะธั‚ะตะปัŒะฝะพ ะฝะฐะฟั€ะฐะฒะปัะตั‚ ะฟะพั‚ะพะบะธ ะฝะฐะฟั€ัะผัƒัŽ ะฝะฐ ะฟั€ะพะบัะธ ะธะปะธ ะฟั€ะพะฒะฐะนะดะตั€ะฐ, ะฒะผะตัั‚ะพ ั€ะฐะทั€ะตัˆะตะฝะธั ะฟะพ ะพั‚ะดะตะปัŒะฝะพัั‚ะธ.", @@ -855,6 +856,8 @@ "WEB_ROOT": "Web-Root: ะกะพะดะตั€ะถะธั‚ ั„ะฐะนะปั‹ ะฒะตะฑ-ะธะฝั‚ะตั€ั„ะตะนัะฐ. ะžะฝะธ ะพะฑั‹ั‡ะฝะพ ั€ะฐะทะผะตั‰ะฐัŽั‚ัั ะฒ ั‚ะพะน ะถะต ะดะธั€ะตะบั‚ะพั€ะธะธ ะฟะพะด ./web" }, "LABEL": { + "BACK": "ะะฐะทะฐะด", + "BOUQUETS": "ะ‘ัƒะบะตั‚ั‹", "ALL_TARGETS": "ะ’ัะต ั†ะตะปะธ", "ACCEPT_INSECURE_SSL_CERTIFICATES": "ะŸั€ะธะฝะธะผะฐั‚ัŒ ะฝะตะฑะตะทะพะฟะฐัะฝั‹ะต SSL-ัะตั€ั‚ะธั„ะธะบะฐั‚ั‹", "ACTIVE": "ะะบั‚ะธะฒะฝั‹ะน", @@ -1090,6 +1093,9 @@ "INCLUDE_TYPE_IN_URL": "ะ’ะบะปัŽั‡ะฐั‚ัŒ ั‚ะธะฟ ะฒ url", "INFINITE": "ะ‘ะตัะบะพะฝะตั‡ะฝะพ", "INPUTS": "ะ˜ัั‚ะพั‡ะฝะธะบะธ", + "SELECTION_MODE": "ะ ะตะถะธะผ ะฒั‹ะฑะพั€ะฐ", + "WHITELIST_INCLUDE_SELECTED": "ะ‘ะตะปั‹ะน ัะฟะธัะพะบ (ะฒะบะปัŽั‡ะธั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต)", + "BLACKLIST_EXCLUDE_SELECTED": "ะงั‘ั€ะฝั‹ะน ัะฟะธัะพะบ (ะธัะบะปัŽั‡ะธั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต)", "INTERVAL_SECS": "ะ˜ะฝั‚ะตั€ะฒะฐะป (ัะตะบ)", "INPUT_TYPE": "ะขะธะฟ ะธัั‚ะพั‡ะฝะธะบะฐ", "IP_CHECK": "ะŸั€ะพะฒะตั€ะบะฐ IP", @@ -1376,6 +1382,7 @@ "RESOURCE_REWRITE_DISABLE": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ะฟะตั€ะตะฟะธัั‹ะฒะฐะฝะธะต ั€ะตััƒั€ัะพะฒ", "RESOURCE_REWRITE_DISABLED": "ะŸะตั€ะตะฟะธัั‹ะฒะฐะฝะธะต ั€ะตััƒั€ัะพะฒ ะพั‚ะบะปัŽั‡ะตะฝะพ", "RESERVE_SLOTS_FOR_USERS": "ะ—ะฐั€ะตะทะตั€ะฒะธั€ะพะฒะฐั‚ัŒ ัะปะพั‚ั‹ ะดะปั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปะตะน", + "RESET": "ะกะฑั€ะพัะธั‚ัŒ", "REST": "Rest", "RETRY": "ะŸะพะฒั‚ะพั€", "REVERSE": "ะžะฑั€ะฐั‚ะฝั‹ะน", @@ -1419,6 +1426,7 @@ "SOURCE": "ะ˜ัั‚ะพั‡ะฝะธะบ", "SOURCES": "ะ˜ัั‚ะพั‡ะฝะธะบะธ", "SOURCE_EDITOR": "ะ˜ัั‚ะพั‡ะฝะธะบะธ", + "TARGET_BOUQUETS": "ะ‘ัƒะบะตั‚ั‹ ั†ะตะปะตะน", "SOURCE_PICKER": "ะ’ั‹ะฑะพั€ ะธัั‚ะพั‡ะฝะธะบะฐ", "SPECIFIC_TEMPLATES": "ะšะพะฝะบั€ะตั‚ะฝั‹ะต ัˆะฐะฑะปะพะฝั‹", "SSDP_DISCOVERY": "ะžะฑะฝะฐั€ัƒะถะตะฝะธะต SSDP", @@ -1853,6 +1861,21 @@ "URL_MANDATORY": "URL ะพะฑัะทะฐั‚ะตะปะตะฝ!", "USERNAME_PASSWORD_MANDATORY": "ะ˜ะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะธ ะฟะฐั€ะพะปัŒ ะพะฑัะทะฐั‚ะตะปัŒะฝั‹!" }, + "RESET": { + "BOUQUET": { + "SUCCESS": "ะ‘ัƒะบะตั‚ ัƒัะฟะตัˆะฝะพ ัะฑั€ะพัˆะตะฝ" + } + }, + "TARGET_BOUQUET": { + "CHANGES_APPLY_ON_UPDATE": "ะ˜ะทะผะตะฝะตะฝะธั ะฟั€ะธะผะตะฝัั‚ัั ะฟั€ะธ ัะปะตะดัƒัŽั‰ะตะผ ะพะฑะฝะพะฒะปะตะฝะธะธ ะฟะปะตะนะปะธัั‚ะฐ.", + "NO_FILTER": "ะคะธะปัŒั‚ั€ ะฑัƒะบะตั‚ะพะฒ ะฝะต ะทะฐะดะฐะฝ", + "NO_TARGETS": "ะฆะตะปะธ ะฝะต ะฝะฐัั‚ั€ะพะตะฝั‹.", + "SAVE_TARGET_FIRST": "ะกะพั…ั€ะฐะฝะธั‚ะต ั†ะตะปัŒ ะฟะตั€ะตะด ะฝะฐัั‚ั€ะพะนะบะพะน ะฑัƒะบะตั‚ะพะฒ", + "STATUS_BLACKLIST": "ะงั‘ั€ะฝั‹ะน ัะฟะธัะพะบ ยท {count} ะณั€ัƒะฟะฟ", + "STATUS_UNAVAILABLE": "ะกั‚ะฐั‚ัƒั ะฑัƒะบะตั‚ะฐ ะฝะตะดะพัั‚ัƒะฟะตะฝ", + "STATUS_WHITELIST": "ะ‘ะตะปั‹ะน ัะฟะธัะพะบ ยท {count} ะณั€ัƒะฟะฟ", + "UNAVAILABLE_GROUPS": "ะะฐัั‚ั€ะพะตะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹, ะบะพั‚ะพั€ั‹ะต ัะตะนั‡ะฐั ะฝะตะดะพัั‚ัƒะฟะฝั‹:" + }, "SAVE": { "API_PROXY_CONFIG": { "FAIL": "ะะต ัƒะดะฐะปะพััŒ ัะพั…ั€ะฐะฝะธั‚ัŒ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธัŽ ApiProxy!", diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 86a4ac176..e9b6dc775 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -13,7 +13,7 @@ }, { "keys": [ - "Delete", "Remove" + "Delete", "Remove", "Trash" ], "path": "M 4.1422511,6.2868364 H 2.7137817 a 0.7142347,0.7142347 0 0 1 0,-1.4284691 H 8.4276589 V 2.7142341 A 0.7142347,0.7142347 0 0 1 9.1418939,2 h 5.7138771 a 0.7142347,0.7142347 0 0 1 0.714235,0.7142341 v 2.1441332 h 5.713877 a 0.7142347,0.7142347 0 1 1 0,1.4284691 H 19.855414 V 21.285765 A 0.7142347,0.7142347 0 0 1 19.141179,22 H 4.8564857 A 0.7142347,0.7142347 0 0 1 4.1422511,21.285765 Z M 14.141537,4.8583673 v -1.42847 H 9.8561289 v 1.42847 z M 5.5707207,20.571531 H 18.426945 V 6.2868364 H 5.5707207 Z M 9.8561289,17.714592 A 0.7142347,0.7142347 0 0 1 9.1418939,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.4284691,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.7142341,0.714235 z m 4.2854081,0 A 0.7142347,0.7142347 0 0 1 13.427302,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.428469,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.714234,0.714235 z" }, @@ -535,7 +535,7 @@ "path": "M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02m0 17.96c-4.4 0-7.98-3.58-7.98-7.98S7.6 4.02 12 4.02 19.98 7.6 19.98 12 16.4 19.98 12 19.98M12.75 5l-4.5 8.5h3.14V19l4.36-8.5h-3z" }, { - "keys": ["Epg"], + "keys": ["Epg", "Bouquet"], "path": "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18zm-2-9H8v2h11zm0 4H8v2h11zM7 8H5v2h2zm0 4H5v2h2z" }, { diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 15142bbc8..eab8e62bd 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -24,7 +24,7 @@ body { --mobile-size: size.$mobile-breakpoint; --dashboard-mobile-size: size.$dashboard-mobile-breakpoint; - --sidebar-width-expanded: 180px; + --sidebar-width-expanded: 240px; --sidebar-width-collapsed: 36px; --max-view-width: size.$dashboard-mobile-breakpoint; --max-table-cell-width: 240px; diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index e5e703b1e..4c4b97068 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -101,3 +101,4 @@ @forward "components/rbac/user_management"; @forward "components/country"; @forward "components/recording"; +@forward "components/target_bouquet_view"; diff --git a/frontend/scss/app/components/_target_bouquet_view.scss b/frontend/scss/app/components/_target_bouquet_view.scss new file mode 100644 index 000000000..15d964338 --- /dev/null +++ b/frontend/scss/app/components/_target_bouquet_view.scss @@ -0,0 +1,115 @@ +.tp__target-bouquet-view { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: var(--padding-default); + box-sizing: border-box; + overflow-y: auto; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--gap-default); + flex-wrap: wrap; + gap: var(--gap-default); + } + + &__title { + display: flex; + align-items: center; + gap: var(--gap-small); + + h2 { + margin: 0; + font-size: var(--font-size-xl); + color: var(--text-color); + } + } + + &__actions { + display: flex; + gap: var(--gap-small); + align-items: center; + } + + &__warnings { + display: flex; + flex-direction: column; + gap: var(--gap-small); + margin-bottom: var(--gap-default); + } + + &__warning-item { + display: flex; + align-items: center; + gap: var(--gap-small); + padding: var(--padding-small) var(--padding-default); + background-color: var(--warn-background-color); + border: 1px solid var(--warn-color); + border-radius: var(--border-radius); + color: var(--text-color); + font-size: var(--font-size-s); + } + + &__warning-icon { + font-size: var(--font-size-default); + } + + &__editor-container { + display: flex; + flex-direction: column; + gap: var(--gap-small); + } + + &__notice { + margin: 0 0 var(--gap-small); + color: var(--text-color); + opacity: 0.8; + font-size: var(--font-size-s); + } + + &__mode { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--gap-small); + color: var(--text-color); + font-size: var(--font-size-s); + } + + &__origins { + margin-left: auto; + padding-left: var(--gap-small); + color: var(--text-color); + opacity: 0.65; + font-size: var(--font-size-xs); + } + + &__unavailable { + display: flex; + flex-wrap: wrap; + gap: var(--gap-small); + padding: var(--padding-small); + border: 1px solid var(--warn-color); + border-radius: var(--border-radius); + color: var(--text-color); + font-size: var(--font-size-s); + } + +} + +.tp__target-bouquet-input { + width: 100%; + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: left; + + &:disabled { + cursor: default; + opacity: 0.65; + } +} diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index bb4709a7e..52ed5370a 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -32,6 +32,16 @@ overflow: hidden; } + &__stack-layer { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + min-width: 0; + min-height: 0; + background-color: rgb(from var(--card-background-color) r g b / 255); + } + &__sidebar { display: flex; flex-flow: column; diff --git a/frontend/src/app/components/api_user/target_playlist.rs b/frontend/src/app/components/api_user/target_playlist.rs index ca38829f3..e7d43efb9 100644 --- a/frontend/src/app/components/api_user/target_playlist.rs +++ b/frontend/src/app/components/api_user/target_playlist.rs @@ -1,111 +1,9 @@ -use crate::{ - app::components::{AppIcon, Card, CollapsePanel, IconButton, RadioButtonGroup, Search}, - html_if, - i18n::use_translation, -}; -use shared::{ - error::TuliproxError, - model::{PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, SearchRequest, XtreamCluster}, -}; -use std::{ - cell::RefCell, - collections::HashMap, - fmt::{Display, Formatter}, - rc::Rc, - str::FromStr, -}; -use wasm_bindgen::JsCast; +use crate::app::components::bouquet_editor::BouquetEditor; +pub use crate::app::components::bouquet_editor::BouquetSelection; +use shared::model::{PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto}; +use std::{cell::RefCell, rc::Rc}; use yew::prelude::*; -fn normalize(s: &str) -> String { - let cleaned: String = s.chars().filter(|c| c.is_alphanumeric() || c.is_whitespace()).collect(); - cleaned.trim().to_lowercase() -} - -fn sort_opt_vec(v: &mut Option>) { - if let Some(ref mut inner) = v { - inner.sort_by_key(|a| normalize(a)); - } -} - -fn resolve_category_element(target: Option) -> Option { - let mut current = target.and_then(|target| target.dyn_into::().ok()); - while let Some(node) = current { - if let Ok(element) = node.clone().dyn_into::() { - if element.has_attribute("data-cluster") && element.has_attribute("data-category") { - return Some(element); - } - current = element.parent_element().map(Into::into); - } else { - current = node.parent_node(); - } - } - None -} - -macro_rules! create_selection { - ($bouquet:expr, $categories:expr, $selections:expr, $field: ident) => { - if let Some(selects) = $bouquet.$field.as_ref() { - for b in selects { - $selections.$field.insert(b.clone(), true); - } - } else { - if let Some(cats) = $categories.$field.as_ref() { - for c in cats { - $selections.$field.insert(c.clone(), true); - } - } - } - }; -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum FilterState { - All, - Selected, - Deselected, -} - -impl FilterState { - const ALL: &'static str = "All"; - const SELECTED: &'static str = "Selected"; - const DESELECTED: &'static str = "Deselected"; -} - -impl Display for FilterState { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self::All => Self::ALL, - Self::Selected => Self::SELECTED, - Self::Deselected => Self::DESELECTED, - } - ) - } -} - -impl FromStr for FilterState { - type Err = TuliproxError; - - fn from_str(s: &str) -> Result { - match s { - Self::ALL => Ok(Self::All), - Self::SELECTED => Ok(Self::Selected), - Self::DESELECTED => Ok(Self::Deselected), - _ => Err(TuliproxError::Config(format!("Unknown FilterState: {s}"))), - } - } -} - -#[derive(Clone, PartialEq, Default)] -pub struct BouquetSelection { - pub live: HashMap, - pub vod: HashMap, - pub series: HashMap, -} - #[derive(Properties, PartialEq)] pub struct UserTargetPlaylistProps { pub categories: Option, @@ -115,361 +13,11 @@ pub struct UserTargetPlaylistProps { #[component] pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { - let translate = use_translation(); - let bouquet_selection = use_mut_ref(BouquetSelection::default); - let playlist_categories = use_state(PlaylistClusterCategoriesDto::default); - let filter_state = use_state(HashMap::::new); - let collapse_state = use_state(HashMap::::new); - let force_update = use_state(|| 0); - let search_filter = use_state::(|| SearchRequest::Clear); - // Anchor for shift+click range selection: the last single-clicked category per cluster. - let anchor = use_state(HashMap::::new); - // Mousedown position of the last category press, used by the click handler to tell a - // real click apart from a click+drag (text selection) so dragging over the text to - // highlight it does not also toggle the category. - let mousedown_info = use_state::, _>(|| None); - - let handle_search = { - let search_filter = search_filter.clone(); - Callback::from(move |req: SearchRequest| search_filter.set(req)) - }; - - // Record the mousedown position so the click handler can tell a real click apart - // from a click+drag (text selection). - let handle_category_mousedown = { - let mousedown_info = mousedown_info.clone(); - Callback::from(move |e: MouseEvent| { - if e.shift_key() { - // Shift+click is reserved for range selection; block native text selection - // before the browser starts highlighting text. - e.prevent_default(); - } - if let Some(element) = resolve_category_element(e.target()) { - if let Some(cluster_attr) = element.get_attribute("data-cluster") { - if let Ok(cluster_enum) = XtreamCluster::from_str(cluster_attr.as_str()) { - if let Some(category) = element.get_attribute("data-category") { - mousedown_info.set(Some((cluster_enum, category, e.client_x(), e.client_y()))); - } - } - } - } - }) - }; - - { - let bouquet_selection = bouquet_selection.clone(); - let playlist_categories = playlist_categories.clone(); - let in_cats = props.categories.clone(); - let in_bouquet = props.bouquet.clone(); - let force_update = force_update.clone(); - use_effect_with((in_cats, in_bouquet), move |(maybe_categories, maybe_bouquet)| { - let mut selections = BouquetSelection::default(); - if let Some(categories) = maybe_categories.as_ref() { - if let Some(bouquet) = maybe_bouquet.as_ref() { - create_selection!(bouquet, categories, selections, live); - create_selection!(bouquet, categories, selections, vod); - create_selection!(bouquet, categories, selections, series); - } else { - if let Some(cats) = categories.live.as_ref() { - for c in cats { - selections.live.insert(c.clone(), true); - } - } - if let Some(cats) = categories.vod.as_ref() { - for c in cats { - selections.vod.insert(c.clone(), true); - } - } - if let Some(cats) = categories.series.as_ref() { - for c in cats { - selections.series.insert(c.clone(), true); - } - } - } - *bouquet_selection.borrow_mut() = selections; - let mut new_categories = categories.clone(); - sort_opt_vec(&mut new_categories.live); - sort_opt_vec(&mut new_categories.vod); - sort_opt_vec(&mut new_categories.series); - playlist_categories.set(new_categories); - force_update.set(*force_update + 1); - } - }); - } - - let handle_category_click = { - let on_change = props.on_change.clone(); - let bouquet_selection = bouquet_selection.clone(); - let force_update = force_update.clone(); - let anchor = anchor.clone(); - let playlist_categories = playlist_categories.clone(); - let mousedown_info = mousedown_info.clone(); - Callback::from(move |e: MouseEvent| { - if let Some(element) = resolve_category_element(e.target()) { - if let Some(cluster_attr) = element.get_attribute("data-cluster") { - if let Ok(cluster_enum) = XtreamCluster::from_str(cluster_attr.as_str()) { - if let Some(category) = element.get_attribute("data-category") { - // Distinguish a real click from a drag (text selection): - // only toggle/range-select if the mouse hasn't moved more than - // a few pixels between mousedown and mouseup and the press - // happened on the same category/cluster. Anything else is a - // text-selection drag and must not toggle the category. - let is_drag = - (*mousedown_info).as_ref().is_some_and(|(mousedown_cluster, mousedown_cat, x, y)| { - *mousedown_cluster != cluster_enum - || mousedown_cat != &category - || (e.client_x() - x).abs() > 2 - || (e.client_y() - y).abs() > 2 - }); - // mousedown_info is only valid for one click cycle. - mousedown_info.set(None); - - if is_drag { - return; - } - - e.prevent_default(); - e.stop_propagation(); - - let shift = e.shift_key(); - { - let mut selections = bouquet_selection.borrow_mut(); - let map = match cluster_enum { - XtreamCluster::Live => &mut selections.live, - XtreamCluster::Video => &mut selections.vod, - XtreamCluster::Series => &mut selections.series, - }; - if shift { - // Range-select: set every category between the anchor - // (last single-clicked) and the clicked one to the same - // state as the clicked one โ€” so shift+click extends the - // selection in either direction. - if let Some(anchor_cat) = anchor.get(&cluster_enum).cloned() { - let all_cats: Option<&Vec> = match cluster_enum { - XtreamCluster::Live => playlist_categories.live.as_ref(), - XtreamCluster::Video => playlist_categories.vod.as_ref(), - XtreamCluster::Series => playlist_categories.series.as_ref(), - }; - if let Some(cats) = all_cats { - if let (Some(start), Some(end)) = ( - cats.iter().position(|c| c == &anchor_cat), - cats.iter().position(|c| c == &category), - ) { - let (from, to) = if start < end { (start, end) } else { (end, start) }; - let target_state = !*map.get(&category).unwrap_or(&false); - for cat in &cats[from..=to] { - map.insert(cat.clone(), target_state); - } - } - } - } else { - // No anchor for this cluster: fall back to a plain toggle. - let selected = *map.get(&category).unwrap_or(&false); - map.insert(category.clone(), !selected); - } - // Clear any text selection that shift+click may have - // triggered (e.g. extending an existing selection) so - // the range-select is the only visible effect. - if let Some(window) = web_sys::window() { - if let Some(document) = window.document() { - if let Ok(Some(selection)) = document.get_selection() { - let _ = selection.remove_all_ranges(); - } - } - } - } else { - // Plain click: update anchor and toggle the clicked category. - let mut new_anchor = (*anchor).clone(); - new_anchor.insert(cluster_enum, category.clone()); - anchor.set(new_anchor); - let selected = *map.get(&category).unwrap_or(&false); - map.insert(category.clone(), !selected); - } - } - on_change.emit(bouquet_selection.clone()); - force_update.set(*force_update + 1); - } - } - } - } - }) - }; - - let handle_selection_change = { - let bouquet_selection = bouquet_selection.clone(); - let on_change = props.on_change.clone(); - let force_update = force_update.clone(); - - Callback::from(move |(cluster, cats, select): (XtreamCluster, Vec, bool)| { - { - let mut selections = bouquet_selection.borrow_mut(); - let map = match cluster { - XtreamCluster::Live => &mut selections.live, - XtreamCluster::Video => &mut selections.vod, - XtreamCluster::Series => &mut selections.series, - }; - for cat in cats { - map.insert(cat, select); - } - } - on_change.emit(bouquet_selection.clone()); - force_update.set(*force_update + 1); - }) - }; - - let render_cluster_stats = |selected_count: usize, visible_count: usize, total: usize| { - html! { - - {translate.t("LABEL.SELECTED")} - {selected_count} - {translate.t("LABEL.FILTERED")} - {visible_count} - {translate.t("LABEL.TOTAL")} - {total} - - } - }; - - let render_category_cluster = |cluster: XtreamCluster, - cats: Option<&Vec>, - selections: &HashMap| { - if let Some(c) = cats { - let cluster_clone = cluster; - let handler = handle_selection_change.clone(); - let current_filter = *filter_state.get(&cluster).unwrap_or(&FilterState::All); - - let is_visible = |cat: &str| { - let selected = *selections.get(cat).unwrap_or(&false); - let filter_ok = match current_filter { - FilterState::All => true, - FilterState::Selected => selected, - FilterState::Deselected => !selected, - }; - let search_ok = match &*search_filter { - SearchRequest::Clear => true, - SearchRequest::Text(pattern, _) => { - let lc = pattern.to_lowercase(); - cat.to_lowercase().contains(&lc) - } - SearchRequest::Regexp(pattern, _) => { - shared::model::REGEX_CACHE.get_or_compile(pattern).is_ok_and(|re| re.is_match(cat)) - } - }; - filter_ok && search_ok - }; - - // Only the items that are currently visible (FilterState + search) should be - // affected by Select All / Deselect All. - let visible_cats: Vec = c.iter().filter(|cat| is_visible(cat)).cloned().collect(); - - let total = c.len(); - let selected_count = c.iter().filter(|cat| *selections.get(*cat).unwrap_or(&false)).count(); - let visible_count = visible_cats.len(); - - let select_all = { - let cats = visible_cats.clone(); - let handler = handler.clone(); - Callback::from(move |(_, event): (String, MouseEvent)| { - event.stop_propagation(); - handler.emit((cluster_clone, cats.clone(), true)); - }) - }; - - let deselect_all = { - let cats = visible_cats; - let handler = handler.clone(); - Callback::from(move |(_, event): (String, MouseEvent)| { - event.stop_propagation(); - handler.emit((cluster_clone, cats.clone(), false)); - }) - }; - - let filter_state_handle = filter_state.clone(); - let filter_state_selections = - Rc::new(vec![filter_state.get(&cluster).copied().unwrap_or(FilterState::All).to_string()]); - let title_content = if *collapse_state.get(&cluster).unwrap_or(&true) { - html! { -
-
- {translate.t( match cluster { - XtreamCluster::Live => "LABEL.LIVE", - XtreamCluster::Video => "LABEL.MOVIE", - XtreamCluster::Series => "LABEL.SERIES" - })} -
- { render_cluster_stats(selected_count, visible_count, total) } -
- >| { - if let Some(first) = selections.first() { - let mut cluster_state = (*filter_state_handle).clone(); - cluster_state.insert(cluster, FilterState::from_str(first.as_str()).unwrap_or(FilterState::All)); - filter_state_handle.set(cluster_state); - } - })} - options={Rc::new([FilterState::All, FilterState::Selected, FilterState::Deselected].iter().map(std::string::ToString::to_string).collect::>())} - labels={Rc::new(vec![translate.t("LABEL.ALL"), translate.t("LABEL.SELECTED"), translate.t("LABEL.DESELECTED")])} - selected={filter_state_selections} - /> - - -
-
- } - } else { - html! { - <> -
- {translate.t( match cluster { - XtreamCluster::Live => "LABEL.LIVE", - XtreamCluster::Video => "LABEL.MOVIE", - XtreamCluster::Series => "LABEL.SERIES" - })} -
- { render_cluster_stats(selected_count, visible_count, total) } - - } - }; - - let collapse_state = collapse_state.clone(); - html_if!(!c.is_empty(), { - - -
- { for c.iter().filter(|cat| is_visible(cat)).map(|cat| { - let selected = *selections.get(cat).unwrap_or(&false); - html! { -
- { &cat } -
- }})} -
-
-
- }) - } else { - html! {} - } - }; - - let selections = &*bouquet_selection.borrow(); html! { -
-
- -
-
- { render_category_cluster(XtreamCluster::Live, playlist_categories.live.as_ref(), &selections.live) } - { render_category_cluster(XtreamCluster::Video, playlist_categories.vod.as_ref(), &selections.vod) } - { render_category_cluster(XtreamCluster::Series, playlist_categories.series.as_ref(), &selections.series) } -
-
+ } } diff --git a/frontend/src/app/components/bouquet_editor/editor.rs b/frontend/src/app/components/bouquet_editor/editor.rs new file mode 100644 index 000000000..5e6fc1d40 --- /dev/null +++ b/frontend/src/app/components/bouquet_editor/editor.rs @@ -0,0 +1,617 @@ +use crate::{ + app::components::{AppIcon, Card, CollapsePanel, IconButton, RadioButtonGroup, Search}, + html_if, + i18n::use_translation, +}; +use shared::{ + error::TuliproxError, + model::{ + ClusterFlags, PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, SearchRequest, TargetBouquetMode, + XtreamCluster, + }, +}; +use std::{ + cell::RefCell, + collections::HashMap, + fmt::{Display, Formatter}, + rc::Rc, + str::FromStr, +}; +use wasm_bindgen::JsCast; +use yew::prelude::*; + +fn normalize(s: &str) -> String { + let cleaned: String = s.chars().filter(|c| c.is_alphanumeric() || c.is_whitespace()).collect(); + cleaned.trim().to_lowercase() +} + +fn sort_opt_vec(v: &mut Option>) { + if let Some(ref mut inner) = v { + inner.sort_by_cached_key(|a| normalize(a)); + } +} + +fn resolve_category_element(target: Option) -> Option { + let mut current = target.and_then(|target| target.dyn_into::().ok()); + while let Some(node) = current { + if let Ok(element) = node.clone().dyn_into::() { + if element.has_attribute("data-cluster") && element.has_attribute("data-category") { + return Some(element); + } + current = element.parent_element().map(Into::into); + } else { + current = node.parent_node(); + } + } + None +} + +macro_rules! create_selection { + ($bouquet:expr, $categories:expr, $selections:expr, $select_unrestricted:expr, $field: ident) => { + if let Some(selects) = $bouquet.$field.as_ref() { + for b in selects { + $selections.$field.insert(b.clone(), true); + } + } else if $select_unrestricted { + if let Some(cats) = $categories.$field.as_ref() { + for c in cats { + $selections.$field.insert(c.clone(), true); + } + } + } + }; +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FilterState { + All, + Selected, + Deselected, +} + +impl FilterState { + const ALL: &'static str = "All"; + const SELECTED: &'static str = "Selected"; + const DESELECTED: &'static str = "Deselected"; +} + +impl Display for FilterState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::All => Self::ALL, + Self::Selected => Self::SELECTED, + Self::Deselected => Self::DESELECTED, + } + ) + } +} + +impl FromStr for FilterState { + type Err = TuliproxError; + + fn from_str(s: &str) -> Result { + match s { + Self::ALL => Ok(Self::All), + Self::SELECTED => Ok(Self::Selected), + Self::DESELECTED => Ok(Self::Deselected), + _ => Err(TuliproxError::Config(format!("Unknown FilterState: {s}"))), + } + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +pub struct BouquetSelection { + pub live: HashMap, + pub vod: HashMap, + pub series: HashMap, +} + +pub type BouquetOrigins = HashMap>>; + +impl BouquetSelection { + pub fn to_target_dto( + &self, + categories: &PlaylistClusterCategoriesDto, + mode: TargetBouquetMode, + ) -> PlaylistClusterBouquetDto { + let selection = |map, available| match mode { + TargetBouquetMode::Whitelist => selected_categories_or_none(map, available), + TargetBouquetMode::Blacklist => selected_categories_for_blacklist(map, available), + }; + PlaylistClusterBouquetDto { + live: selection(&self.live, categories.live.as_deref()), + vod: selection(&self.vod, categories.vod.as_deref()), + series: selection(&self.series, categories.series.as_deref()), + } + } +} + +fn selected_categories_for_blacklist(map: &HashMap, available: Option<&[String]>) -> Option> { + let mut selected: Vec = + map.iter().filter(|(_, is_selected)| **is_selected).map(|(category, _)| category.clone()).collect(); + selected.sort_unstable(); + selected.dedup(); + if selected.is_empty() && available.is_none() { + None + } else { + Some(selected) + } +} + +fn selected_categories_or_none(map: &HashMap, available: Option<&[String]>) -> Option> { + let mut selected: Vec = + map.iter().filter(|(_, is_selected)| **is_selected).map(|(category, _)| category.clone()).collect(); + selected.sort_unstable(); + selected.dedup(); + + if selected.is_empty() { + return available.map(|_| Vec::new()); + } + + let available_set: std::collections::HashSet<&str> = + available.unwrap_or_default().iter().map(String::as_str).collect(); + if !available_set.is_empty() + && selected.len() == available_set.len() + && selected.iter().all(|category| available_set.contains(category.as_str())) + { + None + } else { + Some(selected) + } +} + +#[derive(Properties, PartialEq)] +pub struct BouquetEditorProps { + pub categories: Option, + pub bouquet: Option, + pub on_change: Callback>>, + #[prop_or_default] + pub on_initialize: Callback>>, + #[prop_or(true)] + pub select_all_when_unrestricted: bool, + #[prop_or_default] + pub cluster_flags: Option, + #[prop_or_default] + pub origins: Option>, +} + +#[component] +pub fn BouquetEditor(props: &BouquetEditorProps) -> Html { + let translate = use_translation(); + let bouquet_selection = use_mut_ref(BouquetSelection::default); + let playlist_categories = use_state(PlaylistClusterCategoriesDto::default); + let filter_state = use_state(HashMap::::new); + let collapse_state = use_state(HashMap::::new); + let force_update = use_state(|| 0); + let search_filter = use_state::(|| SearchRequest::Clear); + // Anchor for shift+click range selection: the last single-clicked category per cluster. + let anchor = use_state(HashMap::::new); + // Mousedown position of the last category press, used by the click handler to tell a + // real click apart from a click+drag (text selection) so dragging over the text to + // highlight it does not also toggle the category. + let mousedown_info = use_state::, _>(|| None); + + let handle_search = { + let search_filter = search_filter.clone(); + Callback::from(move |req: SearchRequest| search_filter.set(req)) + }; + + let handle_category_mousedown = { + let mousedown_info = mousedown_info.clone(); + Callback::from(move |e: MouseEvent| { + if e.shift_key() { + e.prevent_default(); + } + if let Some(element) = resolve_category_element(e.target()) { + if let Some(cluster_attr) = element.get_attribute("data-cluster") { + if let Ok(cluster_enum) = XtreamCluster::from_str(cluster_attr.as_str()) { + if let Some(category) = element.get_attribute("data-category") { + mousedown_info.set(Some((cluster_enum, category, e.client_x(), e.client_y()))); + } + } + } + } + }) + }; + + { + let bouquet_selection = bouquet_selection.clone(); + let playlist_categories = playlist_categories.clone(); + let in_cats = props.categories.clone(); + let in_bouquet = props.bouquet.clone(); + let select_all_when_unrestricted = props.select_all_when_unrestricted; + let on_initialize = props.on_initialize.clone(); + let force_update = force_update.clone(); + use_effect_with( + (in_cats, in_bouquet, select_all_when_unrestricted), + move |(maybe_categories, maybe_bouquet, select_all_when_unrestricted)| { + let mut selections = BouquetSelection::default(); + if let Some(categories) = maybe_categories.as_ref() { + if let Some(bouquet) = maybe_bouquet.as_ref() { + create_selection!(bouquet, categories, selections, *select_all_when_unrestricted, live); + create_selection!(bouquet, categories, selections, *select_all_when_unrestricted, vod); + create_selection!(bouquet, categories, selections, *select_all_when_unrestricted, series); + } else if *select_all_when_unrestricted { + if let Some(cats) = categories.live.as_ref() { + for c in cats { + selections.live.insert(c.clone(), true); + } + } + if let Some(cats) = categories.vod.as_ref() { + for c in cats { + selections.vod.insert(c.clone(), true); + } + } + if let Some(cats) = categories.series.as_ref() { + for c in cats { + selections.series.insert(c.clone(), true); + } + } + } + *bouquet_selection.borrow_mut() = selections.clone(); + on_initialize.emit(Rc::new(RefCell::new(selections))); + let mut new_categories = categories.clone(); + sort_opt_vec(&mut new_categories.live); + sort_opt_vec(&mut new_categories.vod); + sort_opt_vec(&mut new_categories.series); + playlist_categories.set(new_categories); + force_update.set(*force_update + 1); + } + }, + ); + } + + let handle_category_click = { + let on_change = props.on_change.clone(); + let bouquet_selection = bouquet_selection.clone(); + let force_update = force_update.clone(); + let anchor = anchor.clone(); + let playlist_categories = playlist_categories.clone(); + let mousedown_info = mousedown_info.clone(); + Callback::from(move |e: MouseEvent| { + if let Some(element) = resolve_category_element(e.target()) { + if let Some(cluster_attr) = element.get_attribute("data-cluster") { + if let Ok(cluster_enum) = XtreamCluster::from_str(cluster_attr.as_str()) { + if let Some(category) = element.get_attribute("data-category") { + let is_drag = + (*mousedown_info).as_ref().is_some_and(|(mousedown_cluster, mousedown_cat, x, y)| { + *mousedown_cluster != cluster_enum + || mousedown_cat != &category + || (e.client_x() - x).abs() > 2 + || (e.client_y() - y).abs() > 2 + }); + mousedown_info.set(None); + + if is_drag { + return; + } + + e.prevent_default(); + e.stop_propagation(); + + let shift = e.shift_key(); + { + let mut selections = bouquet_selection.borrow_mut(); + let map = match cluster_enum { + XtreamCluster::Live => &mut selections.live, + XtreamCluster::Video => &mut selections.vod, + XtreamCluster::Series => &mut selections.series, + }; + if shift { + if let Some(anchor_cat) = anchor.get(&cluster_enum).cloned() { + let all_cats: Option<&Vec> = match cluster_enum { + XtreamCluster::Live => playlist_categories.live.as_ref(), + XtreamCluster::Video => playlist_categories.vod.as_ref(), + XtreamCluster::Series => playlist_categories.series.as_ref(), + }; + if let Some(cats) = all_cats { + if let (Some(start), Some(end)) = ( + cats.iter().position(|c| c == &anchor_cat), + cats.iter().position(|c| c == &category), + ) { + let (from, to) = if start < end { (start, end) } else { (end, start) }; + let target_state = !*map.get(&category).unwrap_or(&false); + for cat in &cats[from..=to] { + map.insert(cat.clone(), target_state); + } + } + } + } else { + let selected = *map.get(&category).unwrap_or(&false); + map.insert(category.clone(), !selected); + } + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Ok(Some(selection)) = document.get_selection() { + let _ = selection.remove_all_ranges(); + } + } + } + } else { + let mut new_anchor = (*anchor).clone(); + new_anchor.insert(cluster_enum, category.clone()); + anchor.set(new_anchor); + let selected = *map.get(&category).unwrap_or(&false); + map.insert(category.clone(), !selected); + } + } + on_change.emit(bouquet_selection.clone()); + force_update.set(*force_update + 1); + } + } + } + } + }) + }; + + let handle_selection_change = { + let bouquet_selection = bouquet_selection.clone(); + let on_change = props.on_change.clone(); + let force_update = force_update.clone(); + + Callback::from(move |(cluster, cats, select): (XtreamCluster, Vec, bool)| { + { + let mut selections = bouquet_selection.borrow_mut(); + let map = match cluster { + XtreamCluster::Live => &mut selections.live, + XtreamCluster::Video => &mut selections.vod, + XtreamCluster::Series => &mut selections.series, + }; + for cat in cats { + map.insert(cat, select); + } + } + on_change.emit(bouquet_selection.clone()); + force_update.set(*force_update + 1); + }) + }; + + let render_cluster_stats = |selected_count: usize, visible_count: usize, total: usize| { + html! { + + {translate.t("LABEL.SELECTED")} + {selected_count} + {translate.t("LABEL.FILTERED")} + {visible_count} + {translate.t("LABEL.TOTAL")} + {total} + + } + }; + + let render_category_cluster = |cluster: XtreamCluster, + cats: Option<&Vec>, + selections: &HashMap| { + if let Some(c) = cats { + let cluster_clone = cluster; + let handler = handle_selection_change.clone(); + let current_filter = *filter_state.get(&cluster).unwrap_or(&FilterState::All); + + let is_visible = |cat: &str| { + let selected = *selections.get(cat).unwrap_or(&false); + let filter_ok = match current_filter { + FilterState::All => true, + FilterState::Selected => selected, + FilterState::Deselected => !selected, + }; + let search_ok = match &*search_filter { + SearchRequest::Clear => true, + SearchRequest::Text(pattern, _) => { + let lc = pattern.to_lowercase(); + cat.to_lowercase().contains(&lc) + } + SearchRequest::Regexp(pattern, _) => { + shared::model::REGEX_CACHE.get_or_compile(pattern).is_ok_and(|re| re.is_match(cat)) + } + }; + filter_ok && search_ok + }; + + let visible_cats: Rc> = Rc::new(c.iter().filter(|cat| is_visible(cat)).cloned().collect()); + + let total = c.len(); + let selected_count = c.iter().filter(|cat| *selections.get(*cat).unwrap_or(&false)).count(); + let visible_count = visible_cats.len(); + + let select_all = { + let cats = Rc::clone(&visible_cats); + let handler = handler.clone(); + Callback::from(move |(_, event): (String, MouseEvent)| { + event.stop_propagation(); + handler.emit((cluster_clone, cats.as_ref().clone(), true)); + }) + }; + + let deselect_all = { + let cats = Rc::clone(&visible_cats); + let handler = handler.clone(); + Callback::from(move |(_, event): (String, MouseEvent)| { + event.stop_propagation(); + handler.emit((cluster_clone, cats.as_ref().clone(), false)); + }) + }; + + let filter_state_handle = filter_state.clone(); + let filter_state_selections = + Rc::new(vec![filter_state.get(&cluster).copied().unwrap_or(FilterState::All).to_string()]); + let title_content = if *collapse_state.get(&cluster).unwrap_or(&true) { + html! { +
+
+ {translate.t( match cluster { + XtreamCluster::Live => "LABEL.LIVE", + XtreamCluster::Video => "LABEL.MOVIE", + XtreamCluster::Series => "LABEL.SERIES" + })} +
+ { render_cluster_stats(selected_count, visible_count, total) } +
+ >| { + if let Some(first) = selections.first() { + let mut cluster_state = (*filter_state_handle).clone(); + cluster_state.insert(cluster, FilterState::from_str(first.as_str()).unwrap_or(FilterState::All)); + filter_state_handle.set(cluster_state); + } + })} + options={Rc::new([FilterState::All, FilterState::Selected, FilterState::Deselected].iter().map(std::string::ToString::to_string).collect::>())} + labels={Rc::new(vec![translate.t("LABEL.ALL"), translate.t("LABEL.SELECTED"), translate.t("LABEL.DESELECTED")])} + selected={filter_state_selections} + /> + + +
+
+ } + } else { + html! { + <> +
+ {translate.t( match cluster { + XtreamCluster::Live => "LABEL.LIVE", + XtreamCluster::Video => "LABEL.MOVIE", + XtreamCluster::Series => "LABEL.SERIES" + })} +
+ { render_cluster_stats(selected_count, visible_count, total) } + + } + }; + + let collapse_state = collapse_state.clone(); + html_if!(!c.is_empty(), { + + +
+ { for visible_cats.iter().map(|cat| { + let selected = *selections.get(cat).unwrap_or(&false); + let origins = props + .origins + .as_ref() + .and_then(|by_cluster| by_cluster.get(&cluster)) + .and_then(|by_name| by_name.get(cat)); + html! { +
+ { &cat } + { origins.map_or_else( + || html! {}, + |values| html! { + + { values.join(", ") } + + }, + ) } +
+ }})} +
+
+
+ }) + } else { + html! {} + } + }; + + let selections = &*bouquet_selection.borrow(); + let cluster_flags = props.cluster_flags.unwrap_or(ClusterFlags::all()); + + html! { +
+
+ +
+
+ { if cluster_flags.has_cluster(XtreamCluster::Live.into()) { + render_category_cluster(XtreamCluster::Live, playlist_categories.live.as_ref(), &selections.live) + } else { html! {} } } + { if cluster_flags.has_cluster(XtreamCluster::Video.into()) { + render_category_cluster(XtreamCluster::Video, playlist_categories.vod.as_ref(), &selections.vod) + } else { html! {} } } + { if cluster_flags.has_cluster(XtreamCluster::Series.into()) { + render_category_cluster(XtreamCluster::Series, playlist_categories.series.as_ref(), &selections.series) + } else { html! {} } } +
+
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_dto_normalizes_all_selected_to_unrestricted() { + let categories = PlaylistClusterCategoriesDto { + live: Some(vec!["News".to_string(), "Sports".to_string()]), + vod: None, + series: None, + }; + let selection = BouquetSelection { + live: HashMap::from([("News".to_string(), true), ("Sports".to_string(), true)]), + ..Default::default() + }; + + assert_eq!(selection.to_target_dto(&categories, TargetBouquetMode::Whitelist).live, None); + } + + #[test] + fn target_dto_preserves_selected_groups_missing_from_catalog() { + let categories = PlaylistClusterCategoriesDto { live: Some(vec!["News".to_string()]), vod: None, series: None }; + let selection = BouquetSelection { + live: HashMap::from([("News".to_string(), true), ("Temporarily Missing".to_string(), true)]), + ..Default::default() + }; + + assert_eq!( + selection.to_target_dto(&categories, TargetBouquetMode::Whitelist).live, + Some(vec!["News".to_string(), "Temporarily Missing".to_string()]) + ); + } + + #[test] + fn target_dto_preserves_an_empty_cluster_when_categories_are_available() { + let categories = PlaylistClusterCategoriesDto { + live: Some(vec!["News".to_string()]), + vod: Some(vec!["Movies".to_string()]), + series: None, + }; + let selection = BouquetSelection { + live: HashMap::from([("News".to_string(), true)]), + vod: HashMap::from([("Movies".to_string(), false)]), + ..Default::default() + }; + + let dto = selection.to_target_dto(&categories, TargetBouquetMode::Whitelist); + assert_eq!(dto.live, None); + assert_eq!(dto.vod, Some(Vec::new())); + assert_eq!(dto.series, None); + } + + #[test] + fn blacklist_keeps_all_selected_groups_as_explicit_exclusions() { + let categories = PlaylistClusterCategoriesDto { + live: Some(vec!["News".to_string(), "Sports".to_string()]), + vod: None, + series: None, + }; + let selection = BouquetSelection { + live: HashMap::from([("News".to_string(), true), ("Sports".to_string(), true)]), + ..Default::default() + }; + + assert_eq!( + selection.to_target_dto(&categories, TargetBouquetMode::Blacklist).live, + Some(vec!["News".to_string(), "Sports".to_string()]) + ); + } +} diff --git a/frontend/src/app/components/bouquet_editor/mod.rs b/frontend/src/app/components/bouquet_editor/mod.rs new file mode 100644 index 000000000..2db0e2a68 --- /dev/null +++ b/frontend/src/app/components/bouquet_editor/mod.rs @@ -0,0 +1,5 @@ +mod editor; +mod target_bouquet_view; + +pub use editor::*; +pub use target_bouquet_view::*; diff --git a/frontend/src/app/components/bouquet_editor/target_bouquet_view.rs b/frontend/src/app/components/bouquet_editor/target_bouquet_view.rs new file mode 100644 index 000000000..d52c18716 --- /dev/null +++ b/frontend/src/app/components/bouquet_editor/target_bouquet_view.rs @@ -0,0 +1,426 @@ +use crate::{ + app::components::{ + bouquet_editor::{BouquetEditor, BouquetOrigins, BouquetSelection}, + LoadingIndicator, RadioButtonGroup, TextButton, + }, + hooks::use_service_context, + i18n::use_translation, + services::TargetBouquetService, +}; +use shared::model::{ + permission::Permission, PlaylistClusterBouquetDto, PlaylistClusterCategoriesDto, TargetBouquetDto, + TargetBouquetMode, TargetBouquetStreamEventDto, XtreamCluster, +}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, +}; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +type ClusterGroups = HashMap>; +type ClusterOrigins = HashMap>>; + +fn finish_catalog(groups: ClusterGroups) -> PlaylistClusterCategoriesDto { + let finish = |cluster| { + groups.get(&cluster).map(|values| { + let mut values: Vec = values.iter().cloned().collect(); + values.sort_unstable(); + values + }) + }; + PlaylistClusterCategoriesDto { + live: finish(XtreamCluster::Live).filter(|values| !values.is_empty()), + vod: finish(XtreamCluster::Video).filter(|values| !values.is_empty()), + series: finish(XtreamCluster::Series).filter(|values| !values.is_empty()), + } +} + +fn finish_origins(origins: ClusterOrigins) -> BouquetOrigins { + origins + .into_iter() + .map(|(cluster, groups)| { + let groups = groups + .into_iter() + .map(|(name, inputs)| { + let mut inputs: Vec = inputs.into_iter().collect(); + inputs.sort_unstable(); + (name, inputs) + }) + .collect(); + (cluster, groups) + }) + .collect() +} + +fn unavailable_groups( + bouquet: Option<&PlaylistClusterBouquetDto>, + categories: &PlaylistClusterCategoriesDto, +) -> Vec<(XtreamCluster, String)> { + let mut unavailable = Vec::new(); + let mut collect = |cluster, selected: Option<&Vec>, available: Option<&Vec>| { + let available: HashSet<&str> = available.into_iter().flatten().map(String::as_str).collect(); + unavailable.extend( + selected + .into_iter() + .flatten() + .filter(|name| !available.contains(name.as_str())) + .cloned() + .map(|name| (cluster, name)), + ); + }; + if let Some(bouquet) = bouquet { + collect(XtreamCluster::Live, bouquet.live.as_ref(), categories.live.as_ref()); + collect(XtreamCluster::Video, bouquet.vod.as_ref(), categories.vod.as_ref()); + collect(XtreamCluster::Series, bouquet.series.as_ref(), categories.series.as_ref()); + } + unavailable.sort_unstable_by(|left, right| (left.0 as u8).cmp(&(right.0 as u8)).then_with(|| left.1.cmp(&right.1))); + unavailable +} + +#[derive(Properties, PartialEq)] +pub struct TargetBouquetViewProps { + pub target_name: String, + pub on_back: Callback<()>, +} + +#[component] +pub fn TargetBouquetView(props: &TargetBouquetViewProps) -> Html { + let translate = use_translation(); + let services = use_service_context(); + let can_write = services.auth.has_permission(Permission::PlaylistWrite); + + let is_loading_groups = use_state(|| false); + let categories = use_state(PlaylistClusterCategoriesDto::default); + let origins = use_state(BouquetOrigins::default); + let warnings = use_state(Vec::::new); + let active_bouquet = use_state(|| Option::::None); + let bouquet_mode = use_state(TargetBouquetMode::default); + let pending_selection = use_mut_ref(BouquetSelection::default); + let selection_has_changed = use_state(|| false); + let has_unsaved_changes = use_state(|| false); + let reload_nonce = use_state(|| 0_u64); + let request_generation = use_mut_ref(|| 0_u64); + + { + let target_name = props.target_name.clone(); + let reload = *reload_nonce; + let is_loading_groups = is_loading_groups.clone(); + let categories = categories.clone(); + let origins = origins.clone(); + let warnings = warnings.clone(); + let active_bouquet = active_bouquet.clone(); + let bouquet_mode = bouquet_mode.clone(); + let pending_selection = pending_selection.clone(); + let selection_has_changed = selection_has_changed.clone(); + let has_unsaved_changes = has_unsaved_changes.clone(); + let request_generation = request_generation.clone(); + let services = services.clone(); + + use_effect_with((target_name, reload), move |(target_name, _)| { + let generation = { + let mut current = request_generation.borrow_mut(); + *current = current.wrapping_add(1); + *current + }; + let abort_controller = web_sys::AbortController::new().ok(); + let abort_on_cleanup = abort_controller.clone(); + let cleanup = move || { + if let Some(controller) = abort_on_cleanup { + controller.abort(); + } + }; + let target_name = target_name.clone(); + is_loading_groups.set(true); + categories.set(PlaylistClusterCategoriesDto::default()); + origins.set(BouquetOrigins::default()); + warnings.set(Vec::new()); + active_bouquet.set(None); + bouquet_mode.set(TargetBouquetMode::default()); + has_unsaved_changes.set(false); + + spawn_local(async move { + let mut loaded_groups = ClusterGroups::new(); + let mut loaded_origins = ClusterOrigins::new(); + let mut loaded_warnings = Vec::new(); + let mut selection = None; + + let abort_signal = abort_controller.map(|controller| controller.signal()); + let result = + TargetBouquetService::fetch_target_bouquet_stream( + &target_name, + abort_signal, + |event| match event { + TargetBouquetStreamEventDto::Selection { bouquet } => selection = bouquet, + TargetBouquetStreamEventDto::InputChunk { input, cluster, groups, .. } => { + for group in groups { + loaded_groups.entry(cluster).or_default().insert(group.clone()); + loaded_origins + .entry(cluster) + .or_default() + .entry(group) + .or_default() + .insert(input.clone()); + } + } + TargetBouquetStreamEventDto::InputWarning { message, .. } => loaded_warnings.push(message), + _ => {} + }, + ) + .await; + + if *request_generation.borrow() != generation { + return; + } + match result { + Ok(()) => { + categories.set(finish_catalog(loaded_groups)); + origins.set(finish_origins(loaded_origins)); + warnings.set(loaded_warnings); + bouquet_mode + .set(selection.as_ref().map_or_else(TargetBouquetMode::default, |value| value.mode)); + active_bouquet.set(selection); + *pending_selection.borrow_mut() = BouquetSelection::default(); + selection_has_changed.set(false); + } + Err(err) => { + let message = format!("Failed to load target bouquet: {err}"); + warnings.set(vec![message.clone()]); + services.toastr.error(message); + } + } + is_loading_groups.set(false); + }); + + cleanup + }); + } + + let on_selection_change = { + let pending_selection = pending_selection.clone(); + let selection_has_changed = selection_has_changed.clone(); + let has_unsaved_changes = has_unsaved_changes.clone(); + Callback::from(move |sel: Rc>| { + *pending_selection.borrow_mut() = sel.borrow().clone(); + selection_has_changed.set(true); + has_unsaved_changes.set(true); + }) + }; + + let on_selection_initialize = { + let pending_selection = pending_selection.clone(); + Callback::from(move |selection: Rc>| { + *pending_selection.borrow_mut() = selection.borrow().clone(); + }) + }; + + let on_mode_change = { + let bouquet_mode = bouquet_mode.clone(); + let active_bouquet = active_bouquet.clone(); + let pending_selection = pending_selection.clone(); + let selection_has_changed = selection_has_changed.clone(); + let categories = categories.clone(); + let has_unsaved_changes = has_unsaved_changes.clone(); + Callback::from(move |selections: Rc>| { + let mode = match selections.first().map(String::as_str) { + Some("blacklist") => TargetBouquetMode::Blacklist, + _ => TargetBouquetMode::Whitelist, + }; + if mode != *bouquet_mode { + if *selection_has_changed { + let groups = pending_selection.borrow().to_target_dto(&categories, mode); + active_bouquet.set(Some(TargetBouquetDto::new(mode, groups))); + selection_has_changed.set(false); + } + bouquet_mode.set(mode); + has_unsaved_changes.set(true); + } + }) + }; + + let handle_save = { + let target_name = props.target_name.clone(); + let pending_selection = pending_selection.clone(); + let selection_has_changed = selection_has_changed.clone(); + let categories = categories.clone(); + let active_bouquet = active_bouquet.clone(); + let bouquet_mode = bouquet_mode.clone(); + let reload_nonce = reload_nonce.clone(); + let services = services.clone(); + let translate = translate.clone(); + + Callback::from(move |_: String| { + let groups = if *selection_has_changed { + pending_selection.borrow().to_target_dto(&categories, *bouquet_mode) + } else { + active_bouquet.as_ref().map_or_else(PlaylistClusterBouquetDto::default, |value| value.groups.clone()) + }; + let bouquet = TargetBouquetDto::new(*bouquet_mode, groups); + let services_state = services.clone(); + let translate_state = translate.clone(); + let reload_nonce_state = reload_nonce.clone(); + let target_name = target_name.clone(); + + spawn_local(async move { + match TargetBouquetService::save_target_bouquet(&target_name, &bouquet).await { + Ok(()) => { + services_state.toastr.success(translate_state.t("MESSAGES.SAVE.BOUQUET.SUCCESS")); + reload_nonce_state.set(reload_nonce_state.wrapping_add(1)); + } + Err(err) => services_state.toastr.error(format!("Failed to save target bouquet: {err}")), + } + }); + }) + }; + + let handle_reset = { + let target_name = props.target_name.clone(); + let reload_nonce = reload_nonce.clone(); + let services = services.clone(); + let translate = translate.clone(); + + Callback::from(move |_: String| { + let services_state = services.clone(); + let translate_state = translate.clone(); + let reload_nonce_state = reload_nonce.clone(); + let target_name = target_name.clone(); + + spawn_local(async move { + match TargetBouquetService::delete_target_bouquet(&target_name).await { + Ok(()) => { + services_state.toastr.success(translate_state.t("MESSAGES.RESET.BOUQUET.SUCCESS")); + reload_nonce_state.set(reload_nonce_state.wrapping_add(1)); + } + Err(err) => services_state.toastr.error(format!("Failed to reset bouquet: {err}")), + } + }); + }) + }; + + let handle_refresh = { + let reload_nonce = reload_nonce.clone(); + Callback::from(move |_: String| reload_nonce.set(reload_nonce.wrapping_add(1))) + }; + + let unavailable = unavailable_groups(active_bouquet.as_ref().map(|value| &value.groups), &categories); + let selected_mode = Rc::new(vec![match *bouquet_mode { + TargetBouquetMode::Whitelist => "whitelist".to_string(), + TargetBouquetMode::Blacklist => "blacklist".to_string(), + }]); + + html! { +
+
+
+ +

{ format!("{} ยท {}", translate.t("LABEL.BOUQUETS"), props.target_name) }

+
+
+ + + +
+
+ + { if warnings.is_empty() { + html! {} + } else { + html! { +
+ { for warnings.iter().map(|w| html! { +
+ { "โš ๏ธ" } + { w } +
+ }) } +
+ } + } } + + { if *is_loading_groups { + html! { } + } else { + html! { +
+

+ { translate.t("MESSAGES.TARGET_BOUQUET.CHANGES_APPLY_ON_UPDATE") } +

+
+ { translate.t("LABEL.SELECTION_MODE") } + +
+ { if unavailable.is_empty() { html! {} } else { html! { +
+ { translate.t("MESSAGES.TARGET_BOUQUET.UNAVAILABLE_GROUPS") } + { for unavailable.iter().map(|(cluster, name)| html! { + { format!("{cluster}: {name}") } + }) } +
+ } } } + +
+ } + } } +
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unavailable_selection_is_preserved_for_display() { + let bouquet = PlaylistClusterBouquetDto { + live: Some(vec!["News".to_string(), "Temporary".to_string()]), + vod: None, + series: None, + }; + let categories = PlaylistClusterCategoriesDto { live: Some(vec!["News".to_string()]), vod: None, series: None }; + + assert_eq!(unavailable_groups(Some(&bouquet), &categories), vec![(XtreamCluster::Live, "Temporary".into())]); + } +} diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index c6436c84d..f3a8f0637 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -602,13 +602,13 @@ pub fn Home() -> Html { })} - { html_if!(can_read_sources, { - - - - - - })} + { html_if!(can_read_sources, { + + + + + + })} { html_if!(can_write_playlist, { diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index dac934004..33f0d7bac 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -2,6 +2,7 @@ mod accordion; mod accordion_panel; mod api_user; mod authentication; +pub mod bouquet_editor; mod breadcrumbs; mod button_utils; mod card; diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index 317e9884a..7888cd7ad 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -240,7 +240,9 @@ pub fn Sidebar(props: &SidebarProps) -> Html { })} {html_if!(auth.has_permission(Permission::PlaylistRead), { + <> + })} {html_if!(auth.has_permission(Permission::EpgRead), { @@ -312,7 +314,9 @@ pub fn Sidebar(props: &SidebarProps) -> Html { })} {html_if!(auth.has_permission(Permission::PlaylistRead), { + <> + })} {html_if!(auth.has_permission(Permission::EpgRead), { diff --git a/frontend/src/app/components/source_editor/editor_model.rs b/frontend/src/app/components/source_editor/editor_model.rs index d3a314760..d13cbd30d 100644 --- a/frontend/src/app/components/source_editor/editor_model.rs +++ b/frontend/src/app/components/source_editor/editor_model.rs @@ -233,6 +233,8 @@ pub enum EditMode { #[derive(Clone)] pub struct SourceEditorContext { pub on_form_change: Callback<(BlockId, BlockInstance)>, + pub open_target_bouquet: Callback, + pub bouquet_revision: u64, pub edit_mode: UseStateHandle, pub allow_write: bool, } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index 6e0b391aa..75d31e49f 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -1,9 +1,10 @@ use crate::{ app::{ components::{ - can_connect, source_editor::layout::layout, Block, BlockId, BlockInstance, BlockType, BlockView, - Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, - TextButton, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH, + bouquet_editor::TargetBouquetView, can_connect, source_editor::layout::layout, Block, BlockId, + BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, + SourceEditorForm, SourceEditorSidebar, TextButton, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, + BLOCK_WIDTH, }, ConfigContext, PlaylistContext, }, @@ -2223,6 +2224,22 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { }; let edit_mode = use_state(|| EditMode::Inactive); + let target_bouquet = use_state(|| Option::::None); + let bouquet_revision = use_state(|| 0_u64); + + let open_target_bouquet = { + let target_bouquet = target_bouquet.clone(); + Callback::from(move |target_name: String| target_bouquet.set(Some(target_name))) + }; + + let close_target_bouquet = { + let target_bouquet = target_bouquet.clone(); + let bouquet_revision = bouquet_revision.clone(); + Callback::from(move |()| { + target_bouquet.set(None); + bouquet_revision.set(bouquet_revision.wrapping_add(1)); + }) + }; let handle_block_edit = { let edit_mode_set = edit_mode.clone(); @@ -2238,6 +2255,8 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let editor_context = SourceEditorContext { on_form_change: form_changed, + open_target_bouquet, + bouquet_revision: *bouquet_revision, edit_mode: edit_mode.clone(), allow_write: can_write_sources, }; @@ -2434,6 +2453,11 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { + if let Some(target_name) = target_bouquet.as_ref() { +
+ +
+ } > } diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index 80f255950..043a98a10 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -1,22 +1,27 @@ use crate::{ app::components::{ - build_options, config::HasFormData, select::Select, selection_parse_first, BlockId, BlockInstance, Card, - ClusterFlagsInput, ClusterFlagsInputMode, DropDownSelection, EditMode, FilterInput, IconButton, Panel, + build_options, config::HasFormData, select::Select, selection_parse_first, AppIcon, BlockId, BlockInstance, + Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton, ToggleSwitch, }, config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer, + hooks::use_service_context, i18n::use_translation, + services::TargetBouquetService, }; use shared::{ error::TuliproxError, - model::{ClusterFlags, ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, ProcessingOrder}, + model::{ + permission::Permission, ClusterFlags, ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, + ProcessingOrder, TargetBouquetMode, TargetBouquetStatusDto, + }, utils::Internable, }; use std::{fmt::Display, rc::Rc, str::FromStr, sync::Arc}; use yew::{ - component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, - UseReducerHandle, + component, html, platform::spawn_local, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, + Html, MouseEvent, Properties, UseReducerHandle, }; const LABEL_ENABLED: &str = "LABEL.ENABLED"; @@ -41,6 +46,14 @@ const LABEL_LOWERCASE_EPG_IDS: &str = "LABEL.LOWERCASE_EPG_IDS"; const LABEL_LOWERCASE_XMLTV_DISPLAY_NAMES: &str = "LABEL.LOWERCASE_XMLTV_DISPLAY_NAMES"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; +const LABEL_BOUQUETS: &str = "LABEL.BOUQUETS"; + +#[derive(Clone, PartialEq)] +enum BouquetStatus { + Loading, + Ready(Option), + Unavailable, +} #[derive(Copy, Clone, PartialEq, Eq)] enum TargetFormPage { @@ -191,6 +204,7 @@ pub struct ConfigTargetViewProps { #[component] pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { let translate = use_translation(); + let services = use_service_context(); let source_editor_ctx = use_context::().expect("SourceEditorContext not found"); let target_form_state: UseReducerHandle = @@ -199,6 +213,41 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { use_reducer(|| ConfigTargetOptionsFormState { form: ConfigTargetOptions::default(), modified: false }); let view_visible = use_state(|| TargetFormPage::Main); + let bouquet_status = use_state(|| BouquetStatus::Loading); + let bouquet_target_name = props.target.as_ref().map_or_else(String::new, |target| target.name.clone()); + let can_read_bouquets = services.auth.has_permission(Permission::PlaylistRead); + + { + let bouquet_status = bouquet_status.clone(); + let target_name = bouquet_target_name.clone(); + let reload = source_editor_ctx.bouquet_revision; + let request_generation = use_state(|| 0_u64); + use_effect_with((target_name, reload, can_read_bouquets), move |(target_name, _, can_read)| { + if !can_read || target_name.is_empty() { + bouquet_status.set(BouquetStatus::Ready(None)); + return; + } + bouquet_status.set(BouquetStatus::Loading); + let target_name = target_name.clone(); + let my_generation = { + let next = *request_generation + 1; + request_generation.set(next); + next + }; + let request_generation = request_generation.clone(); + spawn_local(async move { + let result = TargetBouquetService::fetch_target_status(&target_name).await; + if *request_generation != my_generation { + return; + } + match result { + Ok(target) => bouquet_status.set(BouquetStatus::Ready(Some(target))), + Err(crate::error::Error::NotFound) => bouquet_status.set(BouquetStatus::Ready(None)), + Err(_) => bouquet_status.set(BouquetStatus::Unavailable), + } + }); + }); + } let handle_menu_click = { let active_menu = view_visible.clone(); @@ -409,6 +458,55 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } }; + let render_bouquet_input = || { + if !can_read_bouquets { + return html! {}; + } + let status = match &*bouquet_status { + BouquetStatus::Loading => translate.t("LABEL.LOADING"), + BouquetStatus::Unavailable => translate.t("MESSAGES.TARGET_BOUQUET.STATUS_UNAVAILABLE"), + BouquetStatus::Ready(Some(summary)) => match summary.mode { + Some(TargetBouquetMode::Whitelist) => translate + .t("MESSAGES.TARGET_BOUQUET.STATUS_WHITELIST") + .replace("{count}", &summary.group_count.to_string()), + Some(TargetBouquetMode::Blacklist) => translate + .t("MESSAGES.TARGET_BOUQUET.STATUS_BLACKLIST") + .replace("{count}", &summary.group_count.to_string()), + None => translate.t("MESSAGES.TARGET_BOUQUET.NO_FILTER"), + }, + BouquetStatus::Ready(None) => translate.t("MESSAGES.TARGET_BOUQUET.SAVE_TARGET_FIRST"), + }; + let can_open = matches!(&*bouquet_status, BouquetStatus::Ready(Some(_))); + let on_open = { + let open_target_bouquet = source_editor_ctx.open_target_bouquet.clone(); + let target_name = bouquet_target_name.clone(); + Callback::from(move |event: MouseEvent| { + event.prevent_default(); + event.stop_propagation(); + if can_open { + open_target_bouquet.emit(target_name.clone()); + } + }) + }; + html! { + { config_field_child!(translate.t(LABEL_BOUQUETS), "TARGET_FORM.BOUQUETS", { + html! { + + } + }) } + } + }; + let render_target = || { let target_form_state_1 = target_form_state.clone(); let target_form_state_2 = target_form_state.clone(); @@ -430,6 +528,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { })} /> } })} + { render_bouquet_input() } { config_field_child!(translate.t(LABEL_PERSIST_FILTER), "TARGET_FORM.PERSIST_FILTER", { html! { | { @@ -474,6 +573,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { translate.t(LABEL_PERSIST_FILTER), target_form_state.form.filter.persist.clone().unwrap_or_default() ) } + { render_bouquet_input() } { config_field_custom!( translate.t(LABEL_PROCESSING_ORDER), target_form_state.form.processing_order.to_string() @@ -540,17 +640,17 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { }; html! { -
+
+ icon="Cancel" + title={ translate.t("LABEL.CANCEL")} + onclick={handle_cancel}> if props.allow_write { + icon="Accept" + title={ translate.t("LABEL.OK")} + onclick={handle_apply_target}> }
diff --git a/frontend/src/services/mod.rs b/frontend/src/services/mod.rs index 5b0beebd8..3d6f48df4 100644 --- a/frontend/src/services/mod.rs +++ b/frontend/src/services/mod.rs @@ -11,6 +11,7 @@ mod requests; mod status_service; mod stream_history_service; mod streams_service; +mod target_bouquet_service; mod toastr_service; mod user_api_service; mod user_service; @@ -19,6 +20,6 @@ mod websocket_service; pub use self::{ auth_service::*, config_service::*, dialog_service::*, downloads_service::*, event_service::*, flags_service::*, playlist_service::*, rbac_service::*, recording_service::*, requests::*, status_service::*, - stream_history_service::*, streams_service::*, toastr_service::*, user_api_service::*, user_service::*, - websocket_service::*, + stream_history_service::*, streams_service::*, target_bouquet_service::*, toastr_service::*, user_api_service::*, + user_service::*, websocket_service::*, }; diff --git a/frontend/src/services/target_bouquet_service.rs b/frontend/src/services/target_bouquet_service.rs new file mode 100644 index 000000000..0ca9f471b --- /dev/null +++ b/frontend/src/services/target_bouquet_service.rs @@ -0,0 +1,259 @@ +use crate::{ + error::Error, + services::{get_base_href, get_token, request_delete, request_get, request_put, Encoding}, +}; +use gloo_net::http::Request; +use js_sys::{encode_uri_component, Reflect, Uint8Array}; +use shared::{ + model::{TargetBouquetDto, TargetBouquetStatusDto, TargetBouquetStreamEventDto}, + utils::concat_path_leading_slash, +}; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +const MAX_STREAM_EVENT_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Default)] +struct JsonArrayEventDecoder { + buffer: Vec, + cursor: usize, + value_start: Option, + depth: usize, + in_string: bool, + escaped: bool, + started: bool, + finished: bool, +} + +impl JsonArrayEventDecoder { + fn push(&mut self, bytes: &[u8], on_event: &mut F) -> Result<(), Error> + where + F: FnMut(TargetBouquetStreamEventDto), + { + self.buffer.extend_from_slice(bytes); + self.decode_available(on_event)?; + if self.buffer.len() > MAX_STREAM_EVENT_BYTES { + return Err(Error::DeserializeError); + } + Ok(()) + } + + fn finish(&mut self, on_event: &mut F) -> Result<(), Error> + where + F: FnMut(TargetBouquetStreamEventDto), + { + self.decode_available(on_event)?; + if self.started + && self.finished + && self.value_start.is_none() + && self.buffer.iter().all(u8::is_ascii_whitespace) + { + Ok(()) + } else { + Err(Error::DeserializeError) + } + } + + fn decode_available(&mut self, on_event: &mut F) -> Result<(), Error> + where + F: FnMut(TargetBouquetStreamEventDto), + { + while self.cursor < self.buffer.len() { + let byte = self.buffer[self.cursor]; + if !self.started { + if byte.is_ascii_whitespace() { + self.cursor += 1; + continue; + } + if byte != b'[' { + return Err(Error::DeserializeError); + } + self.started = true; + self.discard_through(self.cursor + 1); + continue; + } + if self.finished { + if !byte.is_ascii_whitespace() { + return Err(Error::DeserializeError); + } + self.cursor += 1; + continue; + } + if self.value_start.is_none() { + if byte.is_ascii_whitespace() || byte == b',' { + self.cursor += 1; + continue; + } + if byte == b']' { + self.finished = true; + self.discard_through(self.cursor + 1); + continue; + } + if byte != b'{' { + return Err(Error::DeserializeError); + } + self.value_start = Some(self.cursor); + } + + if self.in_string { + if self.escaped { + self.escaped = false; + } else if byte == b'\\' { + self.escaped = true; + } else if byte == b'"' { + self.in_string = false; + } + } else { + match byte { + b'"' => self.in_string = true, + b'{' | b'[' => self.depth += 1, + b'}' | b']' => { + self.depth = self.depth.checked_sub(1).ok_or(Error::DeserializeError)?; + if self.depth == 0 { + let start = self.value_start.ok_or(Error::DeserializeError)?; + let end = self.cursor + 1; + if end - start > MAX_STREAM_EVENT_BYTES { + return Err(Error::DeserializeError); + } + let event = serde_json::from_slice::(&self.buffer[start..end]) + .map_err(|_| Error::DeserializeError)?; + on_event(event); + self.value_start = None; + self.discard_through(end); + continue; + } + } + _ => {} + } + } + self.cursor += 1; + } + Ok(()) + } + + fn discard_through(&mut self, end: usize) { + self.buffer.drain(..end); + self.cursor = 0; + } +} + +#[derive(Default)] +pub struct TargetBouquetService; + +impl TargetBouquetService { + pub fn new() -> Self { Self } + + fn collection_url_from_base(base_href: &str) -> String { + concat_path_leading_slash(base_href.trim_end_matches('/'), "api/v1/target-bouquets") + } + + fn collection_url() -> String { Self::collection_url_from_base(&get_base_href()) } + + fn target_url(target_name: &str, endpoint: &str) -> String { + let collection = Self::collection_url(); + let endpoint_url = concat_path_leading_slash(&collection, endpoint); + let encoded_name: String = encode_uri_component(target_name).into(); + format!("{endpoint_url}?target={encoded_name}") + } + + pub async fn fetch_target_status(target_name: &str) -> Result { + request_get::(&Self::target_url(target_name, "status"), None, Some(Encoding::Json)) + .await? + .ok_or(Error::NotFound) + } + + /// Loads the selected target's bounded event stream and dispatches decoded events in wire order. + pub async fn fetch_target_bouquet_stream( + target_name: &str, + abort_signal: Option, + mut on_event: F, + ) -> Result<(), Error> + where + F: FnMut(TargetBouquetStreamEventDto), + { + let url = Self::target_url(target_name, "groups"); + let mut request = Request::get(&url).header("Accept", "application/json").abort_signal(abort_signal.as_ref()); + if let Some(token) = get_token() { + request = request.header("Authorization", format!("Bearer {token}").as_str()); + } + let response = request.send().await.map_err(|_| Error::RequestError)?; + + if !response.ok() { + return Err(Error::HttpResponse(format!("HTTP {}", response.status()))); + } + + let content_type = response.headers().get("Content-Type").unwrap_or_default(); + if !content_type.starts_with("application/json") { + return Err(Error::DeserializeError); + } + let body = response.body().ok_or(Error::DeserializeError)?; + let reader = web_sys::ReadableStreamDefaultReader::new(&body).map_err(|_| Error::RequestError)?; + let mut decoder = JsonArrayEventDecoder::default(); + loop { + let result = JsFuture::from(reader.read()).await.map_err(|_| Error::RequestError)?; + let done = Reflect::get(&result, &JsValue::from_str("done")) + .map_err(|_| Error::RequestError)? + .as_bool() + .ok_or(Error::RequestError)?; + if done { + break; + } + let value = Reflect::get(&result, &JsValue::from_str("value")).map_err(|_| Error::RequestError)?; + let chunk = Uint8Array::new(&value); + let mut bytes = vec![0; chunk.length() as usize]; + chunk.copy_to(&mut bytes); + decoder.push(&bytes, &mut on_event)?; + } + decoder.finish(&mut on_event) + } + + pub async fn save_target_bouquet(target_name: &str, bouquet: &TargetBouquetDto) -> Result<(), Error> { + let url = Self::target_url(target_name, "selection"); + let _ = request_put::<_, serde_json::Value>(&url, bouquet, Some(Encoding::Json), Some(Encoding::Json)).await?; + Ok(()) + } + + pub async fn delete_target_bouquet(target_name: &str) -> Result<(), Error> { + let url = Self::target_url(target_name, "selection"); + let _ = request_delete::(&url, None, None).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collection_url_never_becomes_protocol_relative() { + assert_eq!(TargetBouquetService::collection_url_from_base("/"), "/api/v1/target-bouquets"); + assert_eq!(TargetBouquetService::collection_url_from_base("/tuliprox/"), "/tuliprox/api/v1/target-bouquets"); + } + + #[test] + fn json_array_decoder_handles_split_nested_and_escaped_events() { + let json = br#" [ {"type":"selection","groups":{"live":["News, \"HD\""],"vod":null,"series":null}}, + {"type":"input_chunk","input":"provider","cluster":"Live","groups":["Kids"],"is_last_for_cluster":true}, + {"type":"complete"} ] "#; + let mut decoder = JsonArrayEventDecoder::default(); + let mut events = Vec::new(); + for chunk in json.chunks(7) { + decoder.push(chunk, &mut |event| events.push(event)).unwrap(); + } + decoder.finish(&mut |event| events.push(event)).unwrap(); + + assert_eq!(events.len(), 3); + assert!(matches!(events[0], TargetBouquetStreamEventDto::Selection { .. })); + assert!(matches!(events[1], TargetBouquetStreamEventDto::InputChunk { .. })); + assert_eq!(events[2], TargetBouquetStreamEventDto::Complete); + } + + #[test] + fn json_array_decoder_rejects_oversized_incomplete_event() { + let mut decoder = JsonArrayEventDecoder::default(); + let mut bytes = vec![b'a'; MAX_STREAM_EVENT_BYTES + 2]; + bytes[0] = b'['; + bytes[1] = b'{'; + assert_eq!(decoder.push(&bytes, &mut |_| {}), Err(Error::DeserializeError)); + } +} diff --git a/shared/src/error/tuliprox_error.rs b/shared/src/error/tuliprox_error.rs index 641eaaf87..082eff995 100644 --- a/shared/src/error/tuliprox_error.rs +++ b/shared/src/error/tuliprox_error.rs @@ -108,6 +108,7 @@ error_kinds! { ConfigStreamHistory => "config stream history error", ConfigVideoDownload => "config video download error", ConfigTarget => "config target error", + TargetBouquet => "target bouquet error", ConfigWebUi => "config web UI error", RepositoryEpg => "repository EPG error", RepositoryXtream => "repository XTream error", diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index 4b63ea9c9..7cdc79b43 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -57,6 +57,7 @@ mod stream_probe_failure; mod stream_properties; mod strm_export_style; pub mod system_info; +mod target_bouquet; mod target_type; mod transfer; mod ui_playlist_item; @@ -79,8 +80,8 @@ pub use self::{ regex_cache::*, scheduled_task_failure::*, search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*, status_check::*, stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*, stream_probe_failure::*, stream_properties::*, strm_export_style::*, - system_info::*, target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_lifecycle::*, uuidtype::*, - watch_health::*, web_socket::*, xtream::*, + system_info::*, target_bouquet::*, target_type::*, transfer::*, ui_playlist_item::*, user_command::*, + user_lifecycle::*, uuidtype::*, watch_health::*, web_socket::*, xtream::*, }; pub use ids::*; pub use prepare::*; diff --git a/shared/src/model/playlist_categories.rs b/shared/src/model/playlist_categories.rs index 68ee31cf7..8250331dc 100644 --- a/shared/src/model/playlist_categories.rs +++ b/shared/src/model/playlist_categories.rs @@ -26,6 +26,37 @@ pub struct PlaylistClusterBouquetDto { pub series: Option>, } +impl PlaylistClusterBouquetDto { + pub fn canonicalize_for_target(&mut self) { + Self::canonicalize_cluster_list(&mut self.live); + Self::canonicalize_cluster_list(&mut self.vod); + Self::canonicalize_cluster_list(&mut self.series); + + // An empty cluster is meaningful when another cluster has a selection: + // it excludes that entire cluster. Only an entirely empty target means + // unrestricted and is normalized to a single canonical representation. + if self.is_target_unrestricted() { + self.live = None; + self.vod = None; + self.series = None; + } + } + + #[inline] + pub fn is_target_unrestricted(&self) -> bool { + self.live.as_ref().is_none_or(Vec::is_empty) + && self.vod.as_ref().is_none_or(Vec::is_empty) + && self.series.as_ref().is_none_or(Vec::is_empty) + } + + fn canonicalize_cluster_list(list: &mut Option>) { + if let Some(items) = list { + items.sort_unstable(); + items.dedup(); + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] pub struct PlaylistBouquetDto { #[serde(default)] diff --git a/shared/src/model/target_bouquet.rs b/shared/src/model/target_bouquet.rs new file mode 100644 index 000000000..5746e1f10 --- /dev/null +++ b/shared/src/model/target_bouquet.rs @@ -0,0 +1,181 @@ +use crate::model::{PlaylistClusterBouquetDto, XtreamCluster}; + +pub const TARGET_BOUQUET_VERSION: u8 = 2; + +#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TargetBouquetMode { + #[default] + Whitelist, + Blacklist, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)] +#[serde(deny_unknown_fields)] +pub struct TargetBouquetDto { + pub mode: TargetBouquetMode, + pub groups: PlaylistClusterBouquetDto, +} + +impl TargetBouquetDto { + pub fn new(mode: TargetBouquetMode, mut groups: PlaylistClusterBouquetDto) -> Self { + groups.canonicalize_for_target(); + Self { mode, groups } + } + + pub fn whitelist(groups: PlaylistClusterBouquetDto) -> Self { Self::new(TargetBouquetMode::Whitelist, groups) } + + #[inline] + pub fn is_unrestricted(&self) -> bool { self.groups.is_target_unrestricted() } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TargetBouquetFileDto { + pub version: u8, + pub target: String, + #[serde(flatten)] + pub bouquet: TargetBouquetDto, +} + +impl TargetBouquetFileDto { + pub fn new(target: impl Into, bouquet: TargetBouquetDto) -> Self { + let mut file = Self { version: TARGET_BOUQUET_VERSION, target: target.into(), bouquet }; + file.canonicalize(); + file + } + + #[inline] + pub fn is_unrestricted(&self) -> bool { self.bouquet.is_unrestricted() } + + pub fn canonicalize(&mut self) { self.bouquet.groups.canonicalize_for_target(); } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TargetBouquetStreamEventDto { + Selection { bouquet: Option }, + InputStarted { input: String }, + InputChunk { input: String, cluster: XtreamCluster, groups: Vec, is_last_for_cluster: bool }, + InputFinished { input: String, groups: usize }, + InputWarning { input: String, message: String }, + Complete, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct TargetBouquetStatusDto { + pub name: String, + pub mode: Option, + pub group_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_yaml_and_json() { + let file = TargetBouquetFileDto::new( + "family", + TargetBouquetDto::new( + TargetBouquetMode::Blacklist, + PlaylistClusterBouquetDto { + live: Some(vec!["News".to_string(), "Kids".to_string()]), + vod: Some(vec!["Movies".to_string()]), + series: None, + }, + ), + ); + + let json = serde_json::to_string(&file).expect("serialize json"); + let from_json: TargetBouquetFileDto = serde_json::from_str(&json).expect("deserialize json"); + assert_eq!(file, from_json); + + let yaml = serde_saphyr::to_string(&file).expect("serialize yaml"); + let from_yaml: TargetBouquetFileDto = serde_saphyr::from_str(&yaml).expect("deserialize yaml"); + assert_eq!(file, from_yaml); + } + + #[test] + fn rejects_unknown_fields() { + let yaml = "version: 2\ntarget: family\nmode: whitelist\nextra_field: oops\ngroups:\n live:\n - Kids\n"; + let result: Result = serde_saphyr::from_str(yaml); + assert!(result.is_err()); + } + + #[test] + fn rejects_legacy_files_without_an_explicit_mode() { + let yaml = "version: 1\ntarget: family\ngroups:\n live:\n - Kids\n"; + let result: Result = serde_saphyr::from_str(yaml); + assert!(result.is_err()); + } + + #[test] + fn canonicalization_sorts_and_deduplicates_without_trimming_or_case_folding() { + let mut dto = PlaylistClusterBouquetDto { + live: Some(vec![ + " Zebra".to_string(), + "news".to_string(), + "News".to_string(), + " Zebra".to_string(), + "".to_string(), + ]), + vod: Some(vec![]), + series: None, + }; + + dto.canonicalize_for_target(); + let live = dto.live.as_ref().expect("live should be Some"); + assert_eq!(live, &vec!["", " Zebra", "News", "news"]); + assert_eq!(dto.vod, Some(Vec::new())); + assert!(dto.series.is_none()); + assert!(!dto.is_target_unrestricted()); + } + + #[test] + fn uncategorized_empty_string_is_restricted() { + let mut file = TargetBouquetFileDto { + version: TARGET_BOUQUET_VERSION, + target: "test".to_string(), + bouquet: TargetBouquetDto::whitelist(PlaylistClusterBouquetDto { + live: Some(vec![String::new()]), + vod: None, + series: None, + }), + }; + file.canonicalize(); + assert!(!file.is_unrestricted()); + assert_eq!(file.bouquet.groups.live, Some(vec![String::new()])); + } + + #[test] + fn all_empty_is_unrestricted() { + let mut file = TargetBouquetFileDto { + version: TARGET_BOUQUET_VERSION, + target: "test".to_string(), + bouquet: TargetBouquetDto::new( + TargetBouquetMode::Blacklist, + PlaylistClusterBouquetDto { live: Some(vec![]), vod: Some(vec![]), series: None }, + ), + }; + file.canonicalize(); + assert!(file.is_unrestricted()); + assert_eq!(file.bouquet.mode, TargetBouquetMode::Blacklist); + assert_eq!(file.bouquet.groups.live, None); + assert_eq!(file.bouquet.groups.vod, None); + assert_eq!(file.bouquet.groups.series, None); + } + + #[test] + fn stream_event_serde_round_trip() { + let event = TargetBouquetStreamEventDto::InputChunk { + input: "provider_1".to_string(), + cluster: XtreamCluster::Live, + groups: vec!["News HD".to_string()], + is_last_for_cluster: true, + }; + let json = serde_json::to_string(&event).expect("serialize"); + let parsed: TargetBouquetStreamEventDto = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(event, parsed); + } +}