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); + } +}