From 89ec7c0e9760feacdfa22e744b42e122232d1def Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:28:58 +0200 Subject: [PATCH] Fix/filter input and flussonic fix (#888) * filter input for user form * added opt-in bounded Flussonic HLS catch-up - Add flussonic_hls_catchup input option with native default - Generate bounded archive sources during M3U import - Preserve live URLs and query tokens - Expose native and bounded_archive in the WebUI select * added opt-in bounded Flussonic HLS catch-up - Add flussonic_hls_catchup input option with native default - Generate bounded archive sources during M3U import - Preserve live URLs and query tokens - Expose native and bounded_archive in the WebUI select --- Makefile | 2 +- backend/app/src/api/config_file.rs | 53 ++-- .../app/src/api/endpoints/hls_api/tests.rs | 246 ++++++++++++++++++ .../app/src/api/endpoints/v1_api_config.rs | 21 +- .../app/src/api/endpoints/v1_api_playlist.rs | 4 + backend/app/src/api/endpoints/v1_api_user.rs | 138 +++++++++- backend/app/src/api/setup_api.rs | 12 +- backend/config-loader/src/lib.rs | 195 ++++++++++++-- backend/core/src/model/config/api_proxy.rs | 67 ++++- backend/core/src/model/config/api_user.rs | 38 ++- backend/core/src/model/config/input.rs | 8 +- backend/parser/src/m3u.rs | 139 +++++++++- docs/src/configuration/source.md | 8 + frontend/public/assets/i18n/ar.json | 2 + frontend/public/assets/i18n/en.json | 2 + frontend/public/assets/i18n/ru.json | 2 + frontend/src/app/components/content_dialog.rs | 7 +- .../app/components/filter/filter_editor.rs | 74 +++++- .../src/app/components/filter/filter_input.rs | 75 +++++- .../components/source_editor/input_form.rs | 25 +- .../source_editor/input_form/common.rs | 82 +++++- frontend/src/app/components/userlist/edit.rs | 2 +- .../userlist/proxy_user_credentials_form.rs | 15 +- .../src/app/components/userlist/user_table.rs | 69 +++-- frontend/src/services/user_service.rs | 5 + shared/src/model/config/api_proxy.rs | 13 +- shared/src/model/config/api_user.rs | 26 +- shared/src/model/config/input.rs | 48 ++++ 28 files changed, 1239 insertions(+), 139 deletions(-) diff --git a/Makefile b/Makefile index cab90cb54..6d2c2832c 100644 --- a/Makefile +++ b/Makefile @@ -229,7 +229,7 @@ serve: ## Run tuliprox server with settings folder: make serve exit 1; \ fi @echo "==> Starting tuliprox server with TULIPROX_HOME=$(SETTINGS_FOLDER)" - TULIPROX_HOME="$(SETTINGS_FOLDER)" $(CARGO) run --release --manifest-path $(PROJECT_DIR)/Cargo.toml --package tuliprox --bin tuliprox -- -s + TULIPROX_HOME="$(SETTINGS_FOLDER)" $(CARGO) run --manifest-path $(PROJECT_DIR)/Cargo.toml --package tuliprox --bin tuliprox -- -s .PHONY: architecture-check architecture-check: ## Verify workspace dependency direction diff --git a/backend/app/src/api/config_file.rs b/backend/app/src/api/config_file.rs index e84b3feb8..981e03e5d 100644 --- a/backend/app/src/api/config_file.rs +++ b/backend/app/src/api/config_file.rs @@ -1,12 +1,9 @@ use crate::{ api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}, - config_loader::{ - prepare_sources_batch, read_sources_file, read_sources_file_from_path_with_templates, read_templates, - resolve_template_and_mapping_paths, - }, + config_loader::{prepare_sources_batch, read_sources_file_from_path_with_templates}, model::{CompiledMappings, Config, ProcessTargets, SourcesConfig}, utils, - utils::{read_mappings_file_unprepared, read_mappings_file_with_templates}, + utils::read_mappings_file_with_templates, }; use log::{debug, error, info, warn}; use shared::{ @@ -40,12 +37,13 @@ struct PreparedSourcesReload { sources: SourcesConfig, mapping: Option, sources_file: String, + templates: Option>, } /// What dependent reload (if any) was prepared alongside a config change. enum PreparedFollowUp { Unchanged, - Mapping(Option), + Mapping(Option, Option>), Sources(PreparedSourcesReload), } @@ -110,24 +108,7 @@ impl ConfigFile { paths: &ConfigPaths, config: &Config, ) -> Result>, TuliproxError> { - let sources_inline_templates = - read_sources_file(paths.sources_file_path.as_str(), false, false, None, None).await?.templates; - - // Use robust fallbacks for mapping and template paths - let (effective_template_path, effective_mapping_path) = - resolve_template_and_mapping_paths(paths, config.template_path.as_deref(), config.mapping_path.as_deref()); - - let mapping_inline_templates = read_mappings_file_unprepared(effective_mapping_path.as_ref(), false)? - .map(|(_, mapping)| mapping) - .and_then(|mapping| mapping.mappings.templates); - - let template_bundle = read_templates( - Some(effective_template_path.as_ref()), - true, - sources_inline_templates.as_deref(), - mapping_inline_templates.as_deref(), - )?; - Ok(template_bundle.prepared) + crate::config_loader::load_prepared_global_templates(paths, config).await } // ----------------------------------------------------------------- @@ -171,6 +152,18 @@ impl ConfigFile { } } + fn apply_user_filter_templates( + app_state: &Arc, + templates: Option>, + ) -> Result<(), TuliproxError> { + if let Some(current) = app_state.app_config.api_proxy.load_full() { + let mut config = (*current).clone(); + config.set_templates(templates.map(Arc::from)); + app_state.app_config.set_api_proxy(config)?; + } + Ok(()) + } + fn load_mapping_with_templates( app_state: &Arc, prepared_templates: Option<&[PatternTemplate]>, @@ -183,7 +176,8 @@ impl ConfigFile { async fn load_mapping(app_state: &Arc) -> Result<(), TuliproxError> { let prepared_templates = Self::load_prepared_global_templates(app_state).await?; - Self::load_mapping_with_templates(app_state, prepared_templates.as_deref()) + Self::load_mapping_with_templates(app_state, prepared_templates.as_deref())?; + Self::apply_user_filter_templates(app_state, prepared_templates) } // ----------------------------------------------------------------- @@ -209,7 +203,7 @@ impl ConfigFile { let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; let prepared_mapping = Self::prepare_mapping_reload(paths.mapping_file_path.as_deref(), prepared_templates.as_deref())?; - Ok(PreparedSourcesReload { sources, mapping: prepared_mapping, sources_file }) + Ok(PreparedSourcesReload { sources, mapping: prepared_mapping, sources_file, templates: prepared_templates }) } /// Apply a fully-prepared sources reload to app state (infallible under normal conditions). @@ -222,6 +216,7 @@ impl ConfigFile { update_app_state_sources(app_state, prepared.sources, Some(validated_forced)).await?; Self::apply_mapping_reload(app_state, prepared.mapping); + Self::apply_user_filter_templates(app_state, prepared.templates)?; info!("Loaded sources file {}", prepared.sources_file); Ok(()) } @@ -313,7 +308,7 @@ impl ConfigFile { effective_paths.mapping_file_path.as_deref(), prepared_templates.as_deref(), )?; - PreparedFollowUp::Mapping(prepared) + PreparedFollowUp::Mapping(prepared, prepared_templates) } else { PreparedFollowUp::Unchanged }; @@ -336,9 +331,9 @@ impl ConfigFile { let follow_up_result: Result<(), TuliproxError> = match follow_up { PreparedFollowUp::Unchanged => Ok(()), - PreparedFollowUp::Mapping(prepared) => { + PreparedFollowUp::Mapping(prepared, templates) => { Self::apply_mapping_reload(app_state, prepared); - Ok(()) + Self::apply_user_filter_templates(app_state, templates) } PreparedFollowUp::Sources(prepared) => Self::apply_sources_reload(app_state, prepared).await, }; diff --git a/backend/app/src/api/endpoints/hls_api/tests.rs b/backend/app/src/api/endpoints/hls_api/tests.rs index daa69db7d..a1529eb26 100644 --- a/backend/app/src/api/endpoints/hls_api/tests.rs +++ b/backend/app/src/api/endpoints/hls_api/tests.rs @@ -7078,6 +7078,252 @@ async fn hls_cache_archive_entry_uses_distinct_identity_and_preserves_origin() - Ok(()) } +struct BoundedFlussonicFixture { + _temp: tempfile::TempDir, + origin: TestSegmentOrigin, + app_state: Arc, + requests: Arc>>, + catchup_template: String, + live_uri: String, +} + +async fn bounded_flussonic_request( + app_state: Arc, + url: &str, +) -> Result, Box> { + let parsed = url::Url::parse(url)?; + let mut uri = parsed.path().to_owned(); + if let Some(query) = parsed.query() { + uri.push('?'); + uri.push_str(query); + } + let router = crate::api::endpoints::m3u_api::m3u_api_register().merge(hls_api_register()).with_state(app_state); + let mut request = Request::builder().uri(uri).body(Body::empty())?; + request.extensions_mut().insert(ConnectInfo(test_addr())); + Ok(router.oneshot(request).await?) +} + +async fn bounded_flussonic_origin( + missing: bool, + requests: &Arc>>, +) -> Result> { + let recorded = Arc::clone(requests); + let base = Arc::new(std::sync::OnceLock::::new()); + let origin_base = Arc::clone(&base); + let segment = Arc::<[u8]>::from( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) + .as_slice(), + ); + let origin = spawn_test_binary_origin(Arc::new(move |path| { + if let Ok(mut paths) = recorded.lock() { + paths.push(path.to_owned()); + } + let resource = path.split('?').next().unwrap_or(path); + if resource == "/input.m3u" { + let Some(host) = origin_base.get() else { + return TestBinaryOriginResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Arc::from(&b"origin"[..])); + }; + let text = format!( + "#EXTM3U\n#EXTINF:-1 catchup=\"fs\" catchup-days=\"7\",Channel\n{host}/channel/mono.m3u8?token=a%2Fb\n" + ); + return TestBinaryOriginResponse::new(StatusCode::OK, Arc::from(text.into_bytes())); + } + if resource.ends_with(".ts") + && (resource.starts_with("/channel/archive-") || resource.starts_with("/channel/live/")) + { + return TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&segment)); + } + let media_directory = if resource == "/channel/mono.m3u8" { + Some("live") + } else { + resource + .strip_prefix("/channel/") + .and_then(|file| file.strip_suffix(".m3u8")) + .filter(|file| file.starts_with("archive-")) + }; + if let Some(directory) = media_directory { + if missing && directory != "live" { + return TestBinaryOriginResponse::new(StatusCode::NOT_FOUND, Arc::from(&b"unavailable"[..])); + } + let mut manifest = String::from_utf8_lossy(®ression_origin_manifest(123, 6)).into_owned(); + for sequence in 123..129 { + manifest = + manifest.replace(&format!("{sequence}.ts"), &format!("{directory}/{sequence}.ts?token=a%2Fb")); + } + if directory != "live" { + manifest.push_str("#EXT-X-ENDLIST\n"); + } + return TestBinaryOriginResponse::new(StatusCode::OK, Arc::from(manifest.into_bytes())); + } + TestBinaryOriginResponse::new(StatusCode::NOT_FOUND, Arc::from(&b"unexpected origin path"[..])) + })) + .await; + base.set(origin.base_url.clone()).map_err(|_| "origin already configured")?; + Ok(origin) +} + +async fn bounded_flussonic_hls_fixture(missing: bool) -> Result> { + use shared::model::{ConfigInputOptionsDto, FlussonicHlsCatchup, ProxyType}; + use tuliprox_core::model::ConfigInputOptions; + use tuliprox_repository::{ensure_target_storage_path, m3u_write_playlist}; + + let temp = tempfile::tempdir()?; + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let origin = bounded_flussonic_origin(missing, &requests).await?; + let options: ConfigInputOptionsDto = serde_json::from_str(r#"{"flussonic_hls_catchup":"bounded_archive"}"#)?; + assert_eq!(options.flussonic_hls_catchup, FlussonicHlsCatchup::BoundedArchive); + let input = ConfigInput { + id: 1, + name: Arc::from("bounded-flussonic"), + input_type: InputType::M3u, + url: format!("{}/input.m3u", origin.base_url), + enabled: true, + options: Some(ConfigInputOptions::from(&options)), + ..ConfigInput::default() + }; + let mut target = test_m3u_hls_share_target(); + target.name = "default".to_owned(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + let mut config = (*app_state.app_config.config.load_full()).clone(); + config.storage_dir = temp.path().to_string_lossy().into_owned(); + config.custom_stream_response_enabled = false; + app_state.app_config.config.store(Arc::new(config)); + configure_default_test_server(&app_state); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_owned(); + user.password = "hls-pass".to_owned(); + user.proxy = ProxyType::Reverse(None); + let mut proxy = app_state.app_config.api_proxy.load_full().ok_or("api proxy")?.as_ref().clone(); + proxy.user = vec![TargetUser { target: target.name.clone(), credentials: vec![Arc::new(user)] }]; + app_state.app_config.api_proxy.store(Some(Arc::new(proxy))); + let (mut groups, errors) = crate::iptv::m3u::download_m3u_playlist( + &app_state.app_config, + &reqwest::Client::new(), + &app_state.app_config.config.load_full(), + &input, + ) + .await; + assert_eq!(errors.len(), 0, "import errors: {errors:?}"); + let item = groups.first_mut().and_then(|group| group.channels.first_mut()).ok_or("imported item")?; + item.header.virtual_id = VirtualId::new(12345); + assert_eq!(item.header.url.as_ref(), format!("{}/channel/mono.m3u8?token=a%2Fb", origin.base_url)); + let target_path = ensure_target_storage_path(&app_state.app_config.config.load(), &target.name).await?; + m3u_write_playlist( + &app_state.app_config, + &target, + target.get_m3u_output().ok_or("M3U output")?, + &target_path, + &groups, + false, + ) + .await?; + let response = bounded_flussonic_request( + Arc::clone(&app_state), + "http://proxy/get.php?username=hls-user&password=hls-pass&type=m3u_plus", + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let export = String::from_utf8(response_body(response).await.to_vec())?; + let catchup_template = export + .split("catchup-source=\"") + .nth(1) + .and_then(|tail| tail.split('"').next()) + .ok_or("exported catchup source")? + .to_owned(); + assert!(export.contains("catchup=\"default\"")); + assert!(!export.contains("a%2Fb")); + let live_uri = + export.lines().find(|line| !line.starts_with('#') && !line.is_empty()).ok_or("exported live URL")?.to_owned(); + Ok(BoundedFlussonicFixture { _temp: temp, origin, app_state, requests, catchup_template, live_uri }) +} + +async fn bounded_flussonic_hls_entry( + fixture: &BoundedFlussonicFixture, + start: i64, + duration: i64, +) -> Result, Box> { + let url = + fixture.catchup_template.replace("{utc}", &start.to_string()).replace("{duration}", &duration.to_string()); + bounded_flussonic_request(Arc::clone(&fixture.app_state), &url).await +} + +#[tokio::test] +async fn bounded_flussonic_hls_preserves_master_child_segments_and_duration_identity( +) -> Result<(), Box> { + let fixture = bounded_flussonic_hls_fixture(false).await?; + let mut identities = std::collections::HashSet::new(); + for (start, duration) in [(1_784_898_000, 3600), (1_784_898_000, 7200), (1_784_898_600, 3600)] { + let entry = bounded_flussonic_hls_entry(&fixture, start, duration).await?; + assert_eq!(entry.status(), StatusCode::OK); + let (_, uri) = single_variant_master_playlist(entry).await; + let proxy_id = ProxySessionId(proxy_session_id_from_variant_uri(&uri).to_owned()); + assert!(identities.insert(proxy_id.clone())); + let lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&uri).to_owned()); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_id, super::current_time_millis()) + .await + .ok_or("lease")?; + let archive = format!("archive-{start}-{duration}"); + let expected_origin = format!("{}/channel/{archive}.m3u8?token=a%2Fb", fixture.origin.base_url); + assert_eq!(lease.archive_origin_url.as_deref(), Some(expected_origin.as_str())); + assert!(lease.user_session_token.contains(&format!("|archive|{start}|{duration}|hls-cache|"))); + let media = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(media.status(), StatusCode::OK); + let body = String::from_utf8(response_body(media).await.to_vec())?; + let segment_uri = + body.lines().find(|line| line.starts_with("/hls/") && path_has_extension(line, "ts")).ok_or("segment")?; + let segment = get_response(Arc::clone(&fixture.app_state), segment_uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert_ne!(response_body(segment).await, bytes::Bytes::new()); + let reload = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(reload.status(), StatusCode::OK); + let reload_body = String::from_utf8(response_body(reload).await.to_vec())?; + assert_eq!(manifest_media_sequence(&body), manifest_media_sequence(&reload_body)); + let paths = fixture.requests.lock().map_err(|_| "request log")?; + assert!(paths.iter().any(|p| p == &format!("/channel/{archive}.m3u8?token=a%2Fb"))); + assert!(paths.iter().any(|p| p.starts_with(&format!("/channel/{archive}/")) && p.ends_with(".ts?token=a%2Fb"))); + assert!(!paths.iter().any(|p| p.contains("timeshift_abs") || p.contains("mono.m3u8"))); + } + Ok(()) +} + +#[tokio::test] +async fn bounded_flussonic_hls_404_is_unavailable_without_live_or_timeshift_fallback( +) -> Result<(), Box> { + let fixture = bounded_flussonic_hls_fixture(true).await?; + let entry = bounded_flussonic_hls_entry(&fixture, 1_784_898_000, 3600).await?; + assert_eq!(entry.status(), StatusCode::OK); + let (_, uri) = single_variant_master_playlist(entry).await; + let response = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); + { + let paths = fixture.requests.lock().map_err(|_| "request log")?; + assert!(paths.iter().any(|p| p == "/channel/archive-1784898000-3600.m3u8?token=a%2Fb")); + assert!(!paths.iter().any(|p| p.contains("timeshift_abs") || p.contains("mono.m3u8"))); + } + let live = bounded_flussonic_request(Arc::clone(&fixture.app_state), &fixture.live_uri).await?; + assert_eq!(live.status(), StatusCode::OK); + let (_, live_manifest) = single_variant_master_playlist(live).await; + let media = get_response(Arc::clone(&fixture.app_state), &live_manifest, None).await; + assert_eq!(media.status(), StatusCode::OK); + let body = String::from_utf8(response_body(media).await.to_vec())?; + let segment_uri = + body.lines().find(|line| line.starts_with("/hls/") && path_has_extension(line, "ts")).ok_or("live segment")?; + let segment = get_response(Arc::clone(&fixture.app_state), segment_uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert_ne!(response_body(segment).await, bytes::Bytes::new()); + let paths = fixture.requests.lock().map_err(|_| "request log")?; + assert!(paths.iter().any(|p| p == "/channel/mono.m3u8?token=a%2Fb")); + assert!(paths.iter().any(|p| p.starts_with("/channel/live/") && p.ends_with(".ts?token=a%2Fb"))); + assert!(!paths.iter().any(|p| p.contains("timeshift_abs"))); + Ok(()) +} + #[tokio::test] async fn shared_hls_request_flow_keeps_media_playlist_lease_bound_across_reloads() { let fixture = shared_request_flow_fixture().await; diff --git a/backend/app/src/api/endpoints/v1_api_config.rs b/backend/app/src/api/endpoints/v1_api_config.rs index 61b4fc029..b643dcf53 100644 --- a/backend/app/src/api/endpoints/v1_api_config.rs +++ b/backend/app/src/api/endpoints/v1_api_config.rs @@ -7,8 +7,8 @@ use crate::{ }, auth::{verify_token, AuthBearer}, config_loader::{ - persist_messaging_templates, plans_file_path, prepare_sources_batch, prepare_users, read_api_proxy_file, - read_plans_file, save_plans, + persist_messaging_templates, plans_file_path, prepare_sources_batch, prepare_users, + read_api_proxy_file_with_templates, read_plans_file, save_plans, }, iptv::xtream::{get_xtream_stream_url_base, xtream_login}, model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource, UserPlan}, @@ -528,7 +528,11 @@ async fn get_config_api_proxy_config_public( return internal_server_error!(); } }; - match read_api_proxy_file(api_proxy_file_path.as_str(), true) { + match read_api_proxy_file_with_templates( + api_proxy_file_path.as_str(), + true, + app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()), + ) { Ok(Some(mut api_proxy_dto)) => { filter_api_proxy_by_permissions(&mut api_proxy_dto, PermissionSet::new()); let response = axum::response::Json(api_proxy_dto).into_response(); @@ -590,7 +594,7 @@ async fn save_config_api_proxy_config( // and users referencing missing servers, which per-row validate() cannot see let stored_plans = updated_api_proxy.plans.clone(); let mut updated_api_proxy_dto = ApiProxyConfigDto::from(&updated_api_proxy); - if let Err(err) = updated_api_proxy_dto.prepare() { + if let Err(err) = updated_api_proxy_dto.prepare_with_templates(updated_api_proxy.templates.as_deref()) { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } @@ -600,7 +604,8 @@ async fn save_config_api_proxy_config( } // Persist succeeded — now update in‑memory state with the prepared config. // Plans live in plans.yml, so re-inject them (the DTO round-trip drops them). - let mut stored_api_proxy = ApiProxyConfig::from(&updated_api_proxy_dto); + let mut stored_api_proxy = + ApiProxyConfig::from_dto_with_templates(&updated_api_proxy_dto, updated_api_proxy.templates.clone()); stored_api_proxy.set_plans(stored_plans); app_state.app_config.api_proxy.store(Some(Arc::new(stored_api_proxy))); @@ -720,7 +725,11 @@ async fn get_config_api_proxy_config( return internal_server_error!(); } }; - match read_api_proxy_file(api_proxy_file_path.as_str(), true) { + match read_api_proxy_file_with_templates( + api_proxy_file_path.as_str(), + true, + app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()), + ) { Ok(Some(mut api_proxy_dto)) => { filter_api_proxy_by_permissions(&mut api_proxy_dto, permissions); let response = axum::response::Json(api_proxy_dto).into_response(); diff --git a/backend/app/src/api/endpoints/v1_api_playlist.rs b/backend/app/src/api/endpoints/v1_api_playlist.rs index 3b0a0c277..b1f95bfb8 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist.rs @@ -105,6 +105,7 @@ fn create_config_input_for_m3u(url: &str) -> ConfigInput { probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, + flussonic_hls_catchup: shared::model::FlussonicHlsCatchup::Native, }), ..Default::default() } @@ -127,6 +128,7 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, + flussonic_hls_catchup: shared::model::FlussonicHlsCatchup::Native, }), ..Default::default() } @@ -3428,6 +3430,7 @@ mod tests { probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, + flussonic_hls_catchup: shared::model::FlussonicHlsCatchup::Native, }), stalker: Some(crate::model::StalkerInputConfig { device: None, @@ -3575,6 +3578,7 @@ mod tests { probe_live_interval_hours: 120, resolve_filter: None, probe_filter: None, + flussonic_hls_catchup: shared::model::FlussonicHlsCatchup::Native, }), stalker: Some(crate::model::StalkerInputConfig { device: None, diff --git a/backend/app/src/api/endpoints/v1_api_user.rs b/backend/app/src/api/endpoints/v1_api_user.rs index 9521f8366..72f87f8d1 100644 --- a/backend/app/src/api/endpoints/v1_api_user.rs +++ b/backend/app/src/api/endpoints/v1_api_user.rs @@ -18,6 +18,25 @@ use shared::{ }; use std::{path::Path, sync::Arc}; +async fn validate_user_filter( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(filter): axum::extract::Json>, +) -> axum::response::Response { + let api_proxy = app_state.app_config.api_proxy.load(); + let templates = api_proxy.as_ref().and_then(|config| config.templates.as_deref()); + let valid = filter + .as_deref() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .is_none_or(|raw| shared::foundation::get_filter(raw, templates).is_ok()); + if valid { + axum::http::StatusCode::NO_CONTENT.into_response() + } else { + // Parser errors can contain expanded template values that the caller cannot read. + (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid user filter"}))).into_response() + } +} + #[allow(clippy::too_many_lines)] async fn save_config_api_proxy_user( method: axum::http::Method, @@ -31,7 +50,9 @@ async fn save_config_api_proxy_user( }; let _lock = app_state.app_config.file_locks.write_lock(Path::new(&api_proxy_file_path)).await; - if let Err(err) = credential.prepare() { + let runtime_api_proxy = app_state.app_config.api_proxy.load_full(); + let templates = runtime_api_proxy.as_ref().and_then(|config| config.templates.as_deref()); + if let Err(err) = credential.prepare_with_templates(templates) { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } if let Err(err) = credential.validate() { @@ -72,7 +93,7 @@ async fn save_config_api_proxy_user( } let new_user = { let mut user = ProxyUserCredentials::from(&credential); - user.resolve_plan(&api_proxy.plan_map()); + user.resolve_plan_with_templates(&api_proxy.plan_map(), api_proxy.templates.as_deref()); Arc::new(user) }; @@ -278,6 +299,7 @@ async fn terminate_user_session( pub fn v1_api_user_register(router: Router>) -> axum::Router> { router + .route("/user/filter/validate", axum::routing::post(validate_user_filter)) .route("/user/{target}", axum::routing::post(save_config_api_proxy_user)) .route("/user/{target}", axum::routing::put(save_config_api_proxy_user)) .route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user)) @@ -289,6 +311,7 @@ pub fn v1_api_user_register_with_permissions( app_state: &Arc, ) -> axum::Router> { let user_write_routes = Router::new() + .route("/filter/validate", axum::routing::post(validate_user_filter)) .route("/{target}", axum::routing::post(save_config_api_proxy_user).put(save_config_api_proxy_user)) .route("/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user)) .route("/{username}/session/{session_token}", axum::routing::delete(terminate_user_session)) @@ -296,3 +319,114 @@ pub fn v1_api_user_register_with_permissions( router.nest("/user", user_write_routes) } + +#[cfg(test)] +mod tests { + use super::v1_api_user_register_with_permissions; + use crate::{ + api::model::create_test_app_state, + auth::create_jwt_web_user, + model::{ApiProxyConfig, Config}, + }; + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + Router, + }; + use shared::model::{PatternTemplate, Permission, TemplateValue, UserId, WebAuthConfigDto, WebUiConfigDto}; + use std::sync::Arc; + use tower::ServiceExt; + + #[tokio::test] + async fn user_filter_validation_requires_user_write_and_keeps_templates_private( + ) -> Result<(), Box> { + let web_ui = WebUiConfigDto { + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "filter-test".to_owned(), + secret: "filter-test-secret".to_owned(), + ..Default::default() + }), + ..Default::default() + }; + let config = Config { web_ui: Some((&web_ui).into()), ..Default::default() }; + let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { + return Err("missing web auth".into()); + }; + let writer = + create_jwt_web_user(web_auth, "writer", Permission::UserWrite.into(), 0, UserId::from("web:writer"))?; + let reader = + create_jwt_web_user(web_auth, "reader", Permission::UserRead.into(), 0, UserId::from("web:reader"))?; + let app_state = create_test_app_state(config); + let mut groups = PatternTemplate { + name: "groups".to_owned(), + value: TemplateValue::Single("Sports".to_owned()), + placeholder: String::new(), + }; + groups.prepare(); + let mut secret = PatternTemplate { + name: "invalid_regex".to_owned(), + value: TemplateValue::Single("private-template-value[".to_owned()), + placeholder: String::new(), + }; + secret.prepare(); + app_state.app_config.api_proxy.store(Some(Arc::new(ApiProxyConfig { + templates: Some(Arc::from([groups, secret])), + ..Default::default() + }))); + let router = v1_api_user_register_with_permissions(Router::new(), &app_state).with_state(app_state); + for (filter, status) in [ + (Some(r#"Group = "!groups!""#), StatusCode::NO_CONTENT), + (None, StatusCode::NO_CONTENT), + (Some(" "), StatusCode::NO_CONTENT), + (Some("("), StatusCode::BAD_REQUEST), + (Some(r#"Group = "!missing!""#), StatusCode::BAD_REQUEST), + (Some(r#"Group ~ "!invalid_regex!""#), StatusCode::BAD_REQUEST), + ] { + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/filter/validate") + .header("Authorization", format!("Bearer {writer}")) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&filter)?))?, + ) + .await?; + assert_eq!(response.status(), status); + let body = to_bytes(response.into_body(), 1024).await?; + if status == StatusCode::NO_CONTENT { + assert!(body.is_empty()); + } else { + assert_eq!( + serde_json::from_slice::(&body)?, + serde_json::json!({"error": "Invalid user filter"}) + ); + } + } + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/user/filter/validate") + .header("Authorization", format!("Bearer {reader}")) + .header("Content-Type", "application/json") + .body(Body::from("null"))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/user/filter/validate") + .header("Content-Type", "application/json") + .body(Body::from("null"))?, + ) + .await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } +} diff --git a/backend/app/src/api/setup_api.rs b/backend/app/src/api/setup_api.rs index 2fb61ffef..a74f58cf5 100644 --- a/backend/app/src/api/setup_api.rs +++ b/backend/app/src/api/setup_api.rs @@ -2,7 +2,8 @@ use crate::{ api::{api_utils::serve_file, http_layers::create_cors_layer}, auth::generate_password_from_input, config_loader::{ - read_api_proxy_file, read_config_file_with_options, read_sources_file, sanitize_sources_for_persist, + read_api_proxy_file_with_templates, read_config_file_with_options, read_sources_file, + sanitize_sources_for_persist, }, model::validate_library_paths_from_dto, utils::{file_exists, get_default_web_root_path_for_home, read_templates_file, resolve_template_persist_file_path}, @@ -227,7 +228,12 @@ async fn build_initial_draft(paths: &ConfigPaths) -> AppConfigDto { } if file_exists(&paths.api_proxy_file_path) { - match read_api_proxy_file(paths.api_proxy_file_path.as_str(), true) { + let template_path = + resolve_template_persist_file_path(draft.config.template_path.as_deref(), &paths.config_path); + let result = prepare_setup_validation_templates(&draft, &template_path).and_then(|templates| { + read_api_proxy_file_with_templates(paths.api_proxy_file_path.as_str(), true, templates.as_deref()) + }); + match result { Ok(Some(api_proxy)) => draft.api_proxy = Some(api_proxy), Ok(None) => {} Err(err) => warn!("Setup mode: failed to load existing api-proxy.yml: {err}"), @@ -679,7 +685,7 @@ async fn setup_complete_inner( if api_proxy.server.is_empty() { api_proxy.server.push(create_default_api_proxy_server()); } - if let Err(err) = api_proxy.prepare() { + if let Err(err) = api_proxy.prepare_with_templates(prepared_templates.as_deref()) { return (StatusCode::BAD_REQUEST, axum::Json(json!({ "error": err.to_string() }))).into_response(); } diff --git a/backend/config-loader/src/lib.rs b/backend/config-loader/src/lib.rs index 6d814cd9a..8be258c25 100644 --- a/backend/config-loader/src/lib.rs +++ b/backend/config-loader/src/lib.rs @@ -87,9 +87,12 @@ pub async fn read_api_proxy_config( ) -> Result, TuliproxError> { let paths = config.paths.load(); let api_proxy_file_path = paths.api_proxy_file_path.as_str(); - if let Some(api_proxy_dto) = read_api_proxy_file(api_proxy_file_path, resolve_env)? { + let templates = read_user_filter_templates(config).await?; + if let Some(api_proxy_dto) = + read_api_proxy_file_with_templates(api_proxy_file_path, resolve_env, templates.as_deref())? + { let mut errors = vec![]; - let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto); + let mut api_proxy = ApiProxyConfig::from_dto_with_templates(&api_proxy_dto, templates.map(Arc::from)); apply_authoritative_plans(config, &mut api_proxy, resolve_env).await; migrate_api_user(&mut api_proxy, config, &mut errors).await; if !errors.is_empty() { @@ -385,7 +388,8 @@ pub async fn read_app_config_dto( mapping.mappings.templates = None; } - let api_proxy = match read_api_proxy_file(api_proxy_file, resolve_env) { + let api_proxy = match read_api_proxy_file_with_templates(api_proxy_file, resolve_env, prepared_templates.as_deref()) + { Ok(api_proxy) => api_proxy, Err(err) => { // Surface the fault instead of silently returning a config without api_proxy @@ -614,9 +618,47 @@ pub async fn read_initial_app_config( Ok(app_config) } +pub async fn read_user_filter_templates(config: &AppConfig) -> Result>, TuliproxError> { + let paths = config.paths.load(); + let cfg = config.config.load(); + load_prepared_global_templates(&paths, &cfg).await +} + +pub async fn load_prepared_global_templates( + paths: &ConfigPaths, + config: &Config, +) -> Result>, TuliproxError> { + let sources_inline_templates = + parse_sources_file_from_path(Path::new(&paths.sources_file_path), true).await?.templates; + + // Use robust fallbacks for mapping and template paths + let (effective_template_path, effective_mapping_path) = + resolve_template_and_mapping_paths(paths, config.template_path.as_deref(), config.mapping_path.as_deref()); + + let mapping_inline_templates = read_mappings_file_unprepared(effective_mapping_path.as_ref(), true)? + .map(|(_, mapping)| mapping) + .and_then(|mapping| mapping.mappings.templates); + + let template_bundle = read_templates( + Some(effective_template_path.as_ref()), + true, + sources_inline_templates.as_deref(), + mapping_inline_templates.as_deref(), + )?; + Ok(template_bundle.prepared) +} + pub fn read_api_proxy_file( api_proxy_file: &str, resolve_env: bool, +) -> Result, TuliproxError> { + read_api_proxy_file_with_templates(api_proxy_file, resolve_env, None) +} + +pub fn read_api_proxy_file_with_templates( + api_proxy_file: &str, + resolve_env: bool, + templates: Option<&[PatternTemplate]>, ) -> Result, TuliproxError> { open_file(&std::path::PathBuf::from(api_proxy_file)).map_or(Ok(None), |file| { let maybe_api_proxy: Result = @@ -625,7 +667,7 @@ pub fn read_api_proxy_file( Ok(mut api_proxy_dto) => { if resolve_env { // A recoverable error keeps the last good config alive during hot reload - if let Err(err) = api_proxy_dto.prepare() { + if let Err(err) = api_proxy_dto.prepare_with_templates(templates) { return Err(TuliproxError::Config(format!("can't read api-proxy-config file: {err}"))); } } @@ -636,27 +678,8 @@ pub fn read_api_proxy_file( }) } -pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option { - let paths = config.paths.load(); - match read_api_proxy_file(paths.api_proxy_file_path.as_str(), resolve_env) { - Ok(Some(api_proxy_dto)) => { - let mut errors = vec![]; - let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto); - apply_authoritative_plans(config, &mut api_proxy, resolve_env).await; - migrate_api_user(&mut api_proxy, config, &mut errors).await; - if !errors.is_empty() { - for error in errors { - error!("{error}"); - } - } - Some(api_proxy) - } - Ok(None) => None, - Err(err) => { - error!("Failed to read Api-Proxy file {err}"); - None - } - } +pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Result, TuliproxError> { + read_api_proxy_config(config, resolve_env).await } async fn write_config_file( @@ -1304,6 +1327,128 @@ mod tests { use tempfile::tempdir; use tuliprox_core::utils::resolve_env_var; + #[tokio::test] + async fn api_proxy_template_failure_propagates_and_preserves_active_config( + ) -> Result<(), Box> { + use super::*; + + let dir = tempdir()?; + let sources_path = dir.path().join("source.yml"); + let template_path = dir.path().join("template.yml"); + let proxy_path = dir.path().join("api-proxy.yml"); + fs::write(&sources_path, "inputs: []\nsources: []\n").await?; + fs::write(&template_path, "templates: [").await?; + fs::write(&proxy_path, serde_saphyr::to_string(&ApiProxyConfigDto::default())?).await?; + let paths = ConfigPaths { + home_path: String::new(), + config_path: dir.path().to_string_lossy().into_owned(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: sources_path.to_string_lossy().into_owned(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: Some(template_path.to_string_lossy().into_owned()), + template_files_used: None, + api_proxy_file_path: proxy_path.to_string_lossy().into_owned(), + custom_stream_response_path: None, + }; + assert!(read_api_proxy_file_with_templates(&paths.api_proxy_file_path, false, None)?.is_some()); + let active = Arc::new(ApiProxyConfig::default()); + let config = AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), + hdhomerun: Arc::new(ArcSwapAny::default()), + api_proxy: Arc::new(ArcSwapAny::from(Some(Arc::clone(&active)))), + paths: Arc::new(ArcSwap::from_pointee(paths)), + file_locks: Arc::new(FileLockManager::default()), + custom_stream_response: Arc::new(ArcSwapAny::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + }; + for resolve_env in [false, true] { + assert!(read_api_proxy(&config, resolve_env).await.is_err()); + assert!(read_api_proxy_config(&config, resolve_env).await.is_err()); + let Some(retained) = config.api_proxy.load_full() else { + return Err("active API-proxy configuration was cleared".into()); + }; + assert!(Arc::ptr_eq(&active, &retained)); + } + Ok(()) + } + + #[tokio::test] + async fn user_filter_templates_resolve_env_in_all_sources() -> Result<(), Box> { + let dir = tempdir()?; + let sources_path = dir.path().join("source.yml"); + let mapping_path = dir.path().join("mapping.yml"); + let template_path = dir.path().join("template.yml"); + tokio::fs::write( + &sources_path, + r#" +inputs: [] +sources: [] +templates: + - name: source_group + value: '${env:PATH}' +"#, + ) + .await?; + tokio::fs::write( + &mapping_path, + r#" +mappings: + templates: + - name: mapping_group + value: '${env:PATH}' + mapping: [] +"#, + ) + .await?; + tokio::fs::write( + &template_path, + r#" +templates: + - name: file_group + value: '${env:PATH}' + - name: combined + value: '!source_group!' +"#, + ) + .await?; + let paths = shared::model::ConfigPaths { + home_path: String::new(), + config_path: dir.path().to_string_lossy().into_owned(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: sources_path.to_string_lossy().into_owned(), + mapping_file_path: Some(mapping_path.to_string_lossy().into_owned()), + mapping_files_used: None, + template_file_path: Some(template_path.to_string_lossy().into_owned()), + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + }; + let Some(templates) = + super::load_prepared_global_templates(&paths, &tuliprox_core::model::Config::default()).await? + else { + return Err("missing templates".into()); + }; + let expected = std::env::var("PATH")?; + for name in ["source_group", "mapping_group", "file_group", "combined"] { + let Some(template) = templates.iter().find(|template| template.name == name) else { + return Err(format!("missing template {name}").into()); + }; + assert_eq!(template.value.to_string(), expected); + let raw = format!(r#"Group = "!{name}!""#); + let mut credential = + shared::model::ProxyUserCredentialsDto { filter: Some(raw.clone()), ..Default::default() }; + credential.prepare_with_templates(Some(&templates))?; + assert_eq!(credential.filter.as_deref(), Some(raw.as_str())); + } + Ok(()) + } + #[tokio::test] async fn curation_item_limits_survive_source_load_sanitize_save_reload() { let dir = tempdir().unwrap(); diff --git a/backend/core/src/model/config/api_proxy.rs b/backend/core/src/model/config/api_proxy.rs index b7451f00d..1acef1e3e 100644 --- a/backend/core/src/model/config/api_proxy.rs +++ b/backend/core/src/model/config/api_proxy.rs @@ -130,11 +130,19 @@ pub struct ApiProxyConfig { pub use_user_db: bool, /// HTTP status code for auth failures. 0 means default (403). pub auth_error_status: u16, + pub templates: Option>, } macros::from_impl!(ApiProxyConfig); impl From<&ApiProxyConfigDto> for ApiProxyConfig { - fn from(dto: &ApiProxyConfigDto) -> Self { + fn from(dto: &ApiProxyConfigDto) -> Self { Self::from_dto_with_templates(dto, None) } +} + +impl ApiProxyConfig { + pub fn from_dto_with_templates( + dto: &ApiProxyConfigDto, + templates: Option>, + ) -> Self { // Plans live in plans.yml now and are injected via `set_plans` after load. let plan_map: HashMap> = HashMap::new(); let user = dto @@ -147,7 +155,7 @@ impl From<&ApiProxyConfigDto> for ApiProxyConfig { .iter() .map(|credentials| { let mut user = ProxyUserCredentials::from(credentials); - user.resolve_plan(&plan_map); + user.resolve_plan_with_templates(&plan_map, templates.as_deref()); Arc::new(user) }) .collect(), @@ -156,6 +164,7 @@ impl From<&ApiProxyConfigDto> for ApiProxyConfig { Self { server: dto.server.iter().map(ApiProxyServerInfo::from).collect(), plans: Vec::new(), + templates, user, use_user_db: dto.use_user_db, auth_error_status: dto.auth_error_status, @@ -175,6 +184,13 @@ impl From<&ApiProxyConfig> for ApiProxyConfigDto { } impl ApiProxyConfig { + pub fn set_templates(&mut self, templates: Option>) { + self.templates = templates; + let mut users = std::mem::take(&mut self.user); + self.resolve_target_users(&mut users); + self.user = users; + } + pub fn plan_map(&self) -> HashMap> { self.plans.iter().map(|plan| (plan.name.clone(), Arc::clone(plan))).collect() } @@ -186,7 +202,7 @@ impl ApiProxyConfig { let plan_map = self.plan_map(); for target_user in &mut self.user { for credentials in &mut target_user.credentials { - Arc::make_mut(credentials).resolve_plan(&plan_map); + Arc::make_mut(credentials).resolve_plan_with_templates(&plan_map, self.templates.as_deref()); } } } @@ -196,7 +212,7 @@ impl ApiProxyConfig { let plan_map = self.plan_map(); for target_user in users { for credentials in &mut target_user.credentials { - Arc::make_mut(credentials).resolve_plan(&plan_map); + Arc::make_mut(credentials).resolve_plan_with_templates(&plan_map, self.templates.as_deref()); } } } @@ -235,3 +251,46 @@ impl ApiProxyConfig { result } } + +#[cfg(test)] +mod tests { + use super::ApiProxyConfig; + use shared::model::{ApiProxyConfigDto, PatternTemplate, ProxyUserCredentialsDto, TargetUserDto, TemplateValue}; + use std::sync::Arc; + + #[test] + fn user_templates_survive_dto_reload_and_recompile_on_change() { + let raw = r#"Group = "!groups!""#; + let dto = ApiProxyConfigDto { + user: vec![TargetUserDto { + target: "test".to_owned(), + credentials: vec![ProxyUserCredentialsDto { filter: Some(raw.to_owned()), ..Default::default() }], + }], + ..Default::default() + }; + let mut template = PatternTemplate { + name: "groups".to_owned(), + value: TemplateValue::Single("Sports".to_owned()), + placeholder: String::new(), + }; + template.prepare(); + let mut config = ApiProxyConfig::from_dto_with_templates(&dto, Some(Arc::from([template.clone()]))); + assert!(!config.user[0].credentials[0].t_has_invalid_filter); + template.value = TemplateValue::Single("News".to_owned()); + config.set_templates(Some(Arc::from([template]))); + assert_eq!( + config.user[0].credentials[0].t_filter.as_ref().map(ToString::to_string).as_deref(), + Some(r#"Group = "News""#) + ); + let saved = ApiProxyConfigDto::from(&config); + assert_eq!(saved.user[0].credentials[0].filter.as_deref(), Some(raw)); + let reloaded = ApiProxyConfig::from_dto_with_templates(&saved, config.templates.clone()); + let cloned = config.clone(); + assert!(config.templates.as_ref().zip(cloned.templates.as_ref()).is_some_and(|(a, b)| Arc::ptr_eq(a, b))); + assert!(config.templates.as_ref().zip(reloaded.templates.as_ref()).is_some_and(|(a, b)| Arc::ptr_eq(a, b))); + assert_eq!( + reloaded.user[0].credentials[0].t_filter.as_ref().map(ToString::to_string).as_deref(), + Some(r#"Group = "News""#) + ); + } +} diff --git a/backend/core/src/model/config/api_user.rs b/backend/core/src/model/config/api_user.rs index 60f59fcf6..8a5cd10b1 100644 --- a/backend/core/src/model/config/api_user.rs +++ b/backend/core/src/model/config/api_user.rs @@ -204,6 +204,14 @@ impl ProxyUserCredentials { /// Fill unset capability values from the referenced plan and compile the /// combined content filter. Idempotent; call after any load/conversion. pub fn resolve_plan(&mut self, plans: &HashMap>) { + self.resolve_plan_with_templates(plans, None); + } + + pub fn resolve_plan_with_templates( + &mut self, + plans: &HashMap>, + templates: Option<&[shared::model::PatternTemplate]>, + ) { let plan = self.plan.as_ref().and_then(|name| plans.get(name)); self.t_has_unresolved_plan = self.plan.is_some() && plan.is_none(); if self.t_has_unresolved_plan { @@ -227,7 +235,7 @@ impl ProxyUserCredentials { let plan_filter = plan.and_then(|p| p.t_filter.as_ref().map(Arc::clone)); self.t_has_invalid_filter = false; - let user_filter = self.filter.as_ref().and_then(|raw| match get_filter(raw, None) { + let user_filter = self.filter.as_ref().and_then(|raw| match get_filter(raw, templates) { Ok(filter) => Some(Arc::new(filter)), Err(err) => { error!( @@ -383,6 +391,34 @@ mod tests { use super::*; use shared::model::UserPlanDto; + #[test] + fn user_filter_recompiles_with_changed_templates_and_preserves_raw_dto() { + use shared::model::{PatternTemplate, PlaylistItem, PlaylistItemHeader, TemplateValue}; + let raw = r#"Group = "!groups!""#; + let dto = ProxyUserCredentialsDto { filter: Some(raw.to_owned()), ..Default::default() }; + let mut user = ProxyUserCredentials::from(&dto); + let mut template = PatternTemplate { + name: "groups".to_owned(), + value: TemplateValue::Single("Sports".to_owned()), + placeholder: String::new(), + }; + template.prepare(); + let sports = PlaylistItem { header: PlaylistItemHeader { group: "Sports".into(), ..Default::default() } }; + let news = PlaylistItem { header: PlaylistItemHeader { group: "News".into(), ..Default::default() } }; + user.resolve_plan_with_templates(&HashMap::new(), Some(std::slice::from_ref(&template))); + assert!(!user.t_has_invalid_filter); + assert!(user.allows_content(&sports)); + assert!(!user.allows_content(&news)); + template.value = TemplateValue::Single("News".to_owned()); + user.resolve_plan_with_templates(&HashMap::new(), Some(std::slice::from_ref(&template))); + assert!(!user.allows_content(&sports)); + assert!(user.allows_content(&news)); + let saved = ProxyUserCredentialsDto::from(&user); + assert_eq!(saved.filter.as_deref(), Some(raw)); + user.resolve_plan_with_templates(&HashMap::new(), None); + assert!(user.t_has_invalid_filter); + } + #[test] fn test_resolve_plan_inherits_proxy() { let plan_dto = UserPlanDto { diff --git a/backend/core/src/model/config/input.rs b/backend/core/src/model/config/input.rs index 19d2efbd7..9d7d306ab 100644 --- a/backend/core/src/model/config/input.rs +++ b/backend/core/src/model/config/input.rs @@ -10,9 +10,9 @@ use shared::{ foundation::Filter, model::{ ClusterFlags, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, - InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, MediaServerInputConfigDto, - MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputType, StalkerAuthMode, - StalkerDeviceProfileDto, StalkerEndpointPreference, StalkerInputConfigDto, StalkerMagPreset, + FlussonicHlsCatchup, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, + MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputType, + StalkerAuthMode, StalkerDeviceProfileDto, StalkerEndpointPreference, StalkerInputConfigDto, StalkerMagPreset, }, utils::{ get_credentials_from_url, get_credentials_from_url_str, is_non_blank_optional_string, @@ -59,6 +59,7 @@ create_bitset!( pub struct ConfigInputOptions { pub flags: ConfigInputFlagsSet, pub update_quality: ConfigInputUpdateQuality, + pub flussonic_hls_catchup: FlussonicHlsCatchup, pub resolve_delay: u16, pub probe_delay: u16, pub probe_live_interval_hours: u32, @@ -105,6 +106,7 @@ impl From<&ConfigInputOptionsDto> for ConfigInputOptions { Self { flags, update_quality: ConfigInputUpdateQuality::from(&dto.update_quality), + flussonic_hls_catchup: dto.flussonic_hls_catchup, resolve_delay: dto.resolve_delay, probe_delay: dto.probe_delay, probe_live_interval_hours: dto.probe_live_interval_hours, diff --git a/backend/parser/src/m3u.rs b/backend/parser/src/m3u.rs index b26203075..63de72e4f 100644 --- a/backend/parser/src/m3u.rs +++ b/backend/parser/src/m3u.rs @@ -3,8 +3,8 @@ use regex::Regex; use shared::{ defaults::default_supported_video_extensions, model::{ - CatchupAttribute, CatchupProperties, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, - PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, + CatchupAttribute, CatchupProperties, FlussonicHlsCatchup, LiveStreamProperties, PlaylistGroup, PlaylistItem, + PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, XtreamCluster, }, utils::{ @@ -523,6 +523,32 @@ enum ParseScope { Update, } +fn apply_flussonic_hls_catchup(header: &mut PlaylistItemHeader, mode: FlussonicHlsCatchup) { + if mode != FlussonicHlsCatchup::BoundedArchive || !header.item_type.is_live() { + return; + } + let Some(StreamProperties::Live(live)) = header.additional_properties.as_mut() else { return }; + let Some(catchup) = live.catchup.as_mut() else { return }; + if catchup.source.is_some() + || !catchup.mode.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("fs")) + || catchup.native_flussonic_player_mode() != Some("flussonic") + { + return; + } + let Ok(url) = url::Url::parse(&header.url) else { return }; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return; + } + let Some(channel_path) = url.path().strip_suffix("/mono.m3u8") else { return }; + if channel_path.rsplit('/').next().is_none_or(str::is_empty) { + return; + } + // Replace only the filename so signed queries and the original live URL remain byte-for-byte intact. + let suffix_start = header.url.find(['?', '#']).unwrap_or(header.url.len()); + let Some(prefix) = header.url[..suffix_start].strip_suffix("mono.m3u8") else { return }; + catchup.source = Some(format!("{prefix}archive-{{utc}}-{{duration}}.m3u8{}", &header.url[suffix_start..]).intern()); +} + pub async fn consume_m3u(cfg: &Config, input: &ConfigInput, lines: DynReader, visit: F) { let _ = consume_m3u_scoped(cfg, input, lines, visit, ParseScope::Direct).await; } @@ -621,6 +647,10 @@ async fn consume_m3u_scoped( header.group = get_title_group(&header.title); } } + apply_flussonic_hls_catchup( + header, + input.options.as_ref().map_or(FlussonicHlsCatchup::Native, |options| options.flussonic_hls_catchup), + ); visit(item); } } @@ -822,6 +852,111 @@ mod test { } } + #[tokio::test] + async fn bounded_flussonic_import_reuses_explicit_source_export_and_resolution( + ) -> Result<(), Box> { + use crate::m3u_format::{build_m3u_catchup_rewrite, resolve_m3u_catchup_url}; + use shared::model::{ConfigInputOptionsDto, FlussonicHlsCatchup}; + use tuliprox_core::model::ConfigInputOptions; + + let dto: ConfigInputOptionsDto = serde_json::from_str(r#"{"flussonic_hls_catchup":"bounded_archive"}"#)?; + let input = ConfigInput { options: Some(ConfigInputOptions::from(&dto)), ..test_input() }; + assert_eq!(input.options.as_ref().map(|o| o.flussonic_hls_catchup), Some(FlussonicHlsCatchup::BoundedArchive)); + let live_url = "https://HOST:8443/prefix/CHANNEL/mono.m3u8?token=a%2Fb+%3D&auth=secret"; + let text = format!("#EXTM3U\n#EXTINF:-1 catchup=\"fs\",Channel\n{live_url}\n"); + let direct = parse_m3u(&Config::default(), &input, make_reader(&text)).await; + let update = super::parse_m3u_for_update(&Config::default(), &input, make_reader(&text)).await?; + for groups in [direct, update] { + let header = &groups.first().ok_or("group")?.channels.first().ok_or("channel")?.header; + assert_eq!(header.url.as_ref(), live_url); + let Some(StreamProperties::Live(live)) = header.additional_properties.as_ref() else { + return Err("live properties".into()); + }; + let catchup = live.catchup.as_ref().ok_or("catchup")?; + assert_eq!(catchup.mode.as_deref(), Some("fs")); + assert_eq!( + catchup.source.as_deref(), + Some("https://HOST:8443/prefix/CHANNEL/archive-{utc}-{duration}.m3u8?token=a%2Fb+%3D&auth=secret") + ); + let rewrite = build_m3u_catchup_rewrite(&[7; 16], "http://proxy", "alice", 7, 42, live_url, catchup)? + .ok_or("rewrite")?; + assert_eq!(rewrite.mode.as_ref(), "default"); + assert!(rewrite.source.contains("v0={utc}&v1={duration}")); + assert!(!rewrite.source.contains("secret")); + let resolved = + resolve_m3u_catchup_url(live_url, catchup, Some("v0=1717200000&v1=3600"))?.ok_or("resolution")?; + assert_eq!( + resolved.url, + "https://HOST:8443/prefix/CHANNEL/archive-1717200000-3600.m3u8?token=a%2Fb+%3D&auth=secret" + ); + assert_eq!(resolved.discriminator, "archive|1717200000|3600"); + let longer = + resolve_m3u_catchup_url(live_url, catchup, Some("v0=1717200000&v1=7200"))?.ok_or("resolution")?; + assert_ne!(longer.discriminator, resolved.discriminator); + assert!(resolve_m3u_catchup_url(live_url, catchup, Some("v0=1717200000&v1=0")).is_err()); + } + Ok(()) + } + + #[tokio::test] + async fn bounded_flussonic_import_preserves_non_matching_entries() -> Result<(), Box> { + use shared::model::{ConfigInputOptionsDto, FlussonicHlsCatchup}; + use tuliprox_core::model::ConfigInputOptions; + + let bounded = ConfigInputOptionsDto { + flussonic_hls_catchup: FlussonicHlsCatchup::BoundedArchive, + ..ConfigInputOptionsDto::default() + }; + let input = ConfigInput { options: Some(ConfigInputOptions::from(&bounded)), ..test_input() }; + for (attributes, url, source) in [ + ( + "catchup=\"fs\" catchup-source=\"https://other/archive-{utc}-{duration}.m3u8\"", + "https://host/channel/mono.m3u8", + Some("https://other/archive-{utc}-{duration}.m3u8"), + ), + ("catchup=\"fs\" catchup-type=\"flussonic-ts\"", "https://host/channel/mono.m3u8", None), + ("catchup=\"fs\" catchup-type=\"shift\"", "https://host/channel/mono.m3u8", None), + ("catchup=\"append\"", "https://host/channel/mono.m3u8", None), + ("catchup=\"fs\"", "https://host/channel/index.m3u8", None), + ("catchup=\"fs\"", "https://host/channel/mpegts", None), + ("catchup=\"fs\"", "https://host/channel/index.ts", None), + ("catchup=\"fs\"", "https://host/mono.m3u8", None), + ("catchup=\"fs\"", "https://host//mono.m3u8", None), + ("catchup=\"fs\"", "not-a-url/mono.m3u8", None), + ] { + let text = format!("#EXTM3U\n#EXTINF:-1 {attributes},Channel\n{url}\n"); + let groups = parse_m3u(&Config::default(), &input, make_reader(&text)).await; + let header = &groups.first().ok_or("group")?.channels.first().ok_or("channel")?.header; + assert_eq!(header.url.as_ref(), url); + let Some(StreamProperties::Live(live)) = header.additional_properties.as_ref() else { + return Err("live properties".into()); + }; + assert_eq!(live.catchup.as_ref().and_then(|c| c.source.as_deref()), source, "{attributes} {url}"); + } + let mut vod = process_header( + &"input".intern(), + &["m3u8".to_owned()], + "#EXTINF:-1 catchup=\"fs\",Movie", + "https://host/channel/mono.m3u8".to_owned(), + ); + assert!(!vod.item_type.is_live()); + let original = vod.clone(); + super::apply_flussonic_hls_catchup(&mut vod, FlussonicHlsCatchup::BoundedArchive); + assert_eq!(vod.url, original.url); + assert_eq!(vod.additional_properties, original.additional_properties); + let text = "#EXTM3U\n#EXTINF:-1 catchup=\"fs\",Channel\nhttps://host/channel/mono.m3u8?token=secret\n"; + for options in [None, Some(ConfigInputOptions::from(&ConfigInputOptionsDto::default()))] { + let native = ConfigInput { options, ..test_input() }; + let groups = parse_m3u(&Config::default(), &native, make_reader(text)).await; + let header = &groups.first().ok_or("group")?.channels.first().ok_or("channel")?.header; + let Some(StreamProperties::Live(live)) = header.additional_properties.as_ref() else { + return Err("live properties".into()); + }; + assert!(live.catchup.as_ref().is_some_and(|c| c.source.is_none())); + } + Ok(()) + } + #[tokio::test] async fn m3u_update_quality_parse_failure_does_not_yield_a_partial_candidate() { for text in [ diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index d7c4680a7..9731f457d 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -246,6 +246,7 @@ specific provider. | `xtream_live_stream_without_extension` | Bool | `false` | Strips `.ts` from generated stream URLs. | | `xtream_live_stream_use_prefix` | Bool | `true` | Injects the `/live/` prefix into URLs. | | `disable_hls_streaming` | Bool | `false` | Rewrites live `.m3u8` requests to `.ts` and bypasses Tuliprox HLS handling. | +| `flussonic_hls_catchup` | String | `native`| M3U Flussonic HLS catch-up mode: `native` or `bounded_archive`. See below. | | `user_agent_stream_index` | Bool | `false` | Appends a process-local stream index to upstream `User-Agent` requests (e.g. `VLC/3.0 42`), keeping it stable for the session. | | `resolve_tmdb` | Bool | `false` | Enables TMDB queries for this specific input based on parsed titles to fill missing posters and release years. | | `probe_stream` | Bool | `false` | Uses FFprobe to read A/V details (HDR, 4K). Respects `max_connections`. | @@ -258,6 +259,13 @@ specific provider. | `resolve_filter` | String | - | Filter expression to selectively resolve only entries matching the condition. Uses the same Filter syntax. | | `probe_filter` | String | - | Filter expression to selectively probe only entries matching the condition. Uses the same Filter syntax. | +For M3U inputs, `flussonic_hls_catchup: bounded_archive` generates a `catchup-source` for live entries with +`catchup="fs"`, no existing source, and a `/CHANNEL/mono.m3u8` URL. The generated URL uses +`/CHANNEL/archive-{utc}-{duration}.m3u8` and preserves the scheme, host, and query string, including tokens. +The live URL, explicit sources, other URL forms, and native TS entries remain unchanged. Archive requests use +the existing explicit-source range validation and HLS sessions. Missing archives are unavailable without a +fallback to live playback or moving timeshift playlists. Omit the option or set `native` to retain native behavior. + > **Note:** For `resolve_vod` and `resolve_series`, data is cached per input and only new or changed entries are > updated. diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 69071fd46..400c17fa0 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -367,6 +367,7 @@ "XTREAM_LIVE_STREAM_USE_PREFIX": "إذا كان صحيحًا، يُضيف بادئة /live/ إلى روابط بث Xtream (الافتراضي صحيح).", "XTREAM_LIVE_STREAM_WITHOUT_EXTENSION": "إذا كان صحيحًا، يزيل امتداد .ts من روابط بث Xtream (الافتراضي خطأ).", "DISABLE_HLS_STREAMING": "يستبدل طلبات البث المباشر من Xtream بصيغة .m3u8 بصيغة .ts. فعّله فقط عندما يدعم المزوّد نقاط نهاية MPEG-TS؛ هذا الخيار لا يقوم بتحويل ترميز HLS.", + "FLUSSONIC_HLS_CATCHUP": "يحافظ native على سلوك المشاهدة المؤجلة الأصلي للمزوّد (الافتراضي). ينشئ bounded_archive مصدر أرشيف بحدود ثابتة لقنوات M3U المباشرة التي تحتوي على catchup=\"fs\" ورابط /CHANNEL/mono.m3u8 عندما لا يوجد catchup-source. يبقى رابط البث المباشر والرمز دون تغيير.", "USER_AGENT_STREAM_INDEX": "يضيف فهرسًا رقميًا محليًا للعملية إلى وكيل المستخدم المضبوط. يبقى الفهرس ثابتًا لجلسة تشغيل واحدة عبر القوائم والمقاطع والتنقل وإعادة الاتصال وتبديل المزوّد.", "XTREAM_SKIP_LIVE": "إذا كان صحيحًا، يتم تخطي قسم البث المباشر لمزود Xtream أثناء التحديث.", "XTREAM_SKIP_SERIES": "إذا كان صحيحًا، يتم تخطي قسم المسلسلات لمزود Xtream أثناء التحديث.", @@ -1174,6 +1175,7 @@ "LIVE_STREAM_USE_PREFIX": "استخدام بادئة", "LIVE_STREAM_WITHOUT_EXTENSION": "بدون امتداد", "DISABLE_HLS_STREAMING": "إجبار MPEG-TS", + "FLUSSONIC_HLS_CATCHUP": "المشاهدة المؤجلة عبر Flussonic HLS", "USER_AGENT_STREAM_INDEX": "إضافة فهرس البث إلى وكيل المستخدم", "LOG": "سجل", "LOGS": "السجلات", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 4374f8c4f..849bef791 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -367,6 +367,7 @@ "XTREAM_LIVE_STREAM_USE_PREFIX": "If true, adds /live/ prefix to Xtream stream links (default true).", "XTREAM_LIVE_STREAM_WITHOUT_EXTENSION": "If true, removes .ts extension from Xtream stream links (default false).", "DISABLE_HLS_STREAMING": "Replaces live Xtream .m3u8 requests with .ts. Enable only when the provider supports MPEG-TS endpoints; this does not transcode HLS.", + "FLUSSONIC_HLS_CATCHUP": "native keeps the provider’s native catch-up behavior (default). bounded_archive generates a fixed archive source for live M3U entries with catchup=\"fs\" and a /CHANNEL/mono.m3u8 URL when no catchup-source exists. The live URL and token remain unchanged.", "USER_AGENT_STREAM_INDEX": "Appends a process-local numeric index to the configured User-Agent. The index stays stable for one playback session across manifests, segments, seeks, reconnects, and provider failover.", "XTREAM_SKIP_LIVE": "If true, the live section of the Xtream provider is skipped during update.", "XTREAM_SKIP_SERIES": "If true, the series section of the Xtream provider is skipped during update.", @@ -1181,6 +1182,7 @@ "LIVE_STREAM_USE_PREFIX": "Use prefix", "LIVE_STREAM_WITHOUT_EXTENSION": "Without ext.", "DISABLE_HLS_STREAMING": "Force MPEG-TS", + "FLUSSONIC_HLS_CATCHUP": "Flussonic HLS catch-up", "USER_AGENT_STREAM_INDEX": "Append stream index to User-Agent", "LOG": "Log", "LOGS": "Logs", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 5969d0af4..35a3ecdfb 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -344,6 +344,7 @@ "XTREAM_LIVE_STREAM_USE_PREFIX": "Если true, добавляет префикс /live/ к ссылкам на прямые трансляции Xtream (по умолчанию true).", "XTREAM_LIVE_STREAM_WITHOUT_EXTENSION": "Если true, удаляет расширение .ts из ссылок на прямые трансляции Xtream (по умолчанию false).", "DISABLE_HLS_STREAMING": "Заменяет запросы прямых трансляций Xtream в формате .m3u8 на .ts. Включайте, только если провайдер поддерживает конечные точки MPEG-TS; перекодирование HLS не выполняется.", + "FLUSSONIC_HLS_CATCHUP": "native сохраняет стандартное поведение архива провайдера (по умолчанию). bounded_archive создаёт источник архива с фиксированными границами для прямых трансляций M3U с catchup=\"fs\" и URL /CHANNEL/mono.m3u8, если catchup-source отсутствует. URL прямой трансляции и токен сохраняются.", "USER_AGENT_STREAM_INDEX": "Добавляет локальный числовой индекс процесса к настроенному User-Agent. Индекс остается постоянным для одного сеанса воспроизведения в манифестах, сегментах, перемотках, переподключениях и при переключении провайдера.", "XTREAM_SKIP_LIVE": "Если true, раздел прямых трансляций провайдера Xtream пропускается при обновлении.", "XTREAM_SKIP_SERIES": "Если true, раздел сериалов провайдера Xtream пропускается при обновлении.", @@ -1123,6 +1124,7 @@ "LIVE_STREAM_USE_PREFIX": "Использовать префикс", "LIVE_STREAM_WITHOUT_EXTENSION": "Без расширения", "DISABLE_HLS_STREAMING": "Принудительный MPEG-TS", + "FLUSSONIC_HLS_CATCHUP": "Архив Flussonic HLS", "USER_AGENT_STREAM_INDEX": "Добавлять индекс потока в User-Agent", "LOG": "Лог", "LOGS": "Логи", diff --git a/frontend/src/app/components/content_dialog.rs b/frontend/src/app/components/content_dialog.rs index a30288855..e1942d9b8 100644 --- a/frontend/src/app/components/content_dialog.rs +++ b/frontend/src/app/components/content_dialog.rs @@ -30,6 +30,8 @@ pub struct ContentDialogProps { pub on_confirm: Callback, #[prop_or(true)] pub close_on_backdrop_click: bool, + #[prop_or(true)] + pub close_on_confirm: bool, } #[component] @@ -40,8 +42,11 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html { let on_result = { let on_confirm = props.on_confirm.clone(); let is_open = is_open.clone(); + let close_on_confirm = props.close_on_confirm; move |result: DialogResult| { - is_open.set(false); + if close_on_confirm || result != DialogResult::Ok { + is_open.set(false); + } on_confirm.emit(result); } }; diff --git a/frontend/src/app/components/filter/filter_editor.rs b/frontend/src/app/components/filter/filter_editor.rs index a9f47e957..4b0b23511 100644 --- a/frontend/src/app/components/filter/filter_editor.rs +++ b/frontend/src/app/components/filter/filter_editor.rs @@ -9,10 +9,11 @@ use shared::{ foundation::{get_filter, Filter}, model::PatternTemplate, }; +use std::cell::RefCell; use web_sys::InputEvent; use yew::{ - classes, component, html, use_context, use_effect_with, use_mut_ref, use_state, Callback, Html, Properties, - TargetCast, + classes, component, html, use_context, use_effect_with, use_memo, use_mut_ref, use_state, Callback, Html, + Properties, TargetCast, }; use yew_hooks::use_debounce; @@ -25,6 +26,10 @@ pub struct FilterEditorProps { #[prop_or_default] pub on_valid_change: Callback, pub on_templates_change: Callback>>, + #[prop_or_default] + pub validate_on_server: bool, + #[prop_or_default] + pub disabled: bool, } const FILTER_INPUT_DEBOUNCE_MS: u32 = 300; @@ -42,6 +47,16 @@ pub(crate) fn parse_filter_preview( } } +fn emit_filter_input( + value: String, + pending_value: &RefCell>, + on_filter_change: &Callback>, +) { + let next = if value.trim().is_empty() { None } else { Some(value) }; + pending_value.replace(next.clone()); + on_filter_change.emit(next); +} + #[component] pub fn FilterEditor(props: &FilterEditorProps) -> Html { let config_ctx = use_context::().expect("Config context not found"); @@ -54,7 +69,7 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { let filter_state = use_state(|| props.filter.clone()); // Raw textarea value, updated immediately on every keystroke so typing stays responsive. let input_value = use_state(|| props.filter.clone().unwrap_or_default()); - // Latest pending value awaiting the debounced parse/emit. + // Latest value awaiting the debounced preview. let pending_value = use_mut_ref(|| props.filter.clone()); { @@ -71,17 +86,23 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { let input_value = input_value.clone(); let pending_value = pending_value.clone(); use_effect_with(props.filter.clone(), move |flt| { + if *pending_value.borrow() == *flt { + return; + } filter.set(flt.clone()); input_value.set(flt.clone().unwrap_or_default()); *pending_value.borrow_mut() = flt.clone(); }); } - let (parsed_filter, valid_filter) = parse_filter_preview((*filter_state).as_deref(), (*templates_state).as_deref()); + let preview = use_memo(((*filter_state).clone(), (*templates_state).clone()), |(filter, templates)| { + parse_filter_preview(filter.as_deref(), templates.as_deref()) + }); + let (parsed_filter, valid_filter) = &*preview; { let on_valid_change = props.on_valid_change.clone(); - use_effect_with(valid_filter, move |valid| { + use_effect_with(*valid_filter, move |valid| { on_valid_change.emit(*valid); || () }); @@ -89,13 +110,11 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { let debounce = { let filter = filter_state.clone(); - let on_filter_change = props.on_filter_change.clone(); let pending_value = pending_value.clone(); use_debounce( move || { let next = pending_value.borrow().clone(); - filter.set(next.clone()); - on_filter_change.emit(next); + filter.set(next); }, FILTER_INPUT_DEBOUNCE_MS, ) @@ -105,18 +124,19 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { let input_value = input_value.clone(); let pending_value = pending_value.clone(); let debounce = debounce.clone(); + let on_filter_change = props.on_filter_change.clone(); Callback::from(move |event: InputEvent| { if let Some(input) = event.target_dyn_into::() { let value = input.value(); input_value.set(value.clone()); - *pending_value.borrow_mut() = if value.is_empty() { None } else { Some(value) }; + emit_filter_input(value, &pending_value, &on_filter_change); debounce.run(); } }) }; html! { -
+
@@ -141,10 +161,14 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
-