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
This commit is contained in:
euzu
2026-09-30 17:28:58 +02:00
committed by GitHub
parent 02fba44fd4
commit 89ec7c0e97
28 changed files with 1239 additions and 139 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ serve: ## Run tuliprox server with settings folder: make serve <settings_folder>
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
+24 -29
View File
@@ -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<PreparedMappingsReload>,
sources_file: String,
templates: Option<Vec<PatternTemplate>>,
}
/// What dependent reload (if any) was prepared alongside a config change.
enum PreparedFollowUp {
Unchanged,
Mapping(Option<PreparedMappingsReload>),
Mapping(Option<PreparedMappingsReload>, Option<Vec<PatternTemplate>>),
Sources(PreparedSourcesReload),
}
@@ -110,24 +108,7 @@ impl ConfigFile {
paths: &ConfigPaths,
config: &Config,
) -> Result<Option<Vec<PatternTemplate>>, 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<AppState>,
templates: Option<Vec<PatternTemplate>>,
) -> 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<AppState>,
prepared_templates: Option<&[PatternTemplate]>,
@@ -183,7 +176,8 @@ impl ConfigFile {
async fn load_mapping(app_state: &Arc<AppState>) -> 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,
};
@@ -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<AppState>,
requests: Arc<std::sync::Mutex<Vec<String>>>,
catchup_template: String,
live_uri: String,
}
async fn bounded_flussonic_request(
app_state: Arc<AppState>,
url: &str,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
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<std::sync::Mutex<Vec<String>>>,
) -> Result<TestSegmentOrigin, Box<dyn std::error::Error>> {
let recorded = Arc::clone(requests);
let base = Arc::new(std::sync::OnceLock::<String>::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(&regression_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<BoundedFlussonicFixture, Box<dyn std::error::Error>> {
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<Response<Body>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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;
+15 -6
View File
@@ -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();
@@ -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,
+136 -2
View File
@@ -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<Arc<AppState>>,
axum::extract::Json(filter): axum::extract::Json<Option<String>>,
) -> 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<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
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<AppState>,
) -> axum::Router<Arc<AppState>> {
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<dyn std::error::Error>> {
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::<serde_json::Value>(&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(())
}
}
+9 -3
View File
@@ -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();
}
+170 -25
View File
@@ -87,9 +87,12 @@ pub async fn read_api_proxy_config(
) -> Result<Option<ApiProxyConfig>, 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<Option<Vec<PatternTemplate>>, 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<Option<Vec<PatternTemplate>>, 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<Option<ApiProxyConfigDto>, 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<Option<ApiProxyConfigDto>, TuliproxError> {
open_file(&std::path::PathBuf::from(api_proxy_file)).map_or(Ok(None), |file| {
let maybe_api_proxy: Result<ApiProxyConfigDto, _> =
@@ -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<ApiProxyConfig> {
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<Option<ApiProxyConfig>, TuliproxError> {
read_api_proxy_config(config, resolve_env).await
}
async fn write_config_file<T>(
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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();
+63 -4
View File
@@ -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<Arc<[shared::model::PatternTemplate]>>,
}
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<Arc<[shared::model::PatternTemplate]>>,
) -> Self {
// Plans live in plans.yml now and are injected via `set_plans` after load.
let plan_map: HashMap<String, Arc<UserPlan>> = 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<Arc<[shared::model::PatternTemplate]>>) {
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<String, Arc<UserPlan>> {
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""#)
);
}
}
+37 -1
View File
@@ -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<String, Arc<UserPlan>>) {
self.resolve_plan_with_templates(plans, None);
}
pub fn resolve_plan_with_templates(
&mut self,
plans: &HashMap<String, Arc<UserPlan>>,
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 {
+5 -3
View File
@@ -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,
+137 -2
View File
@@ -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<F: FnMut(PlaylistItem)>(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<F: FnMut(PlaylistItem)>(
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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 [
+8
View File
@@ -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.
+2
View File
@@ -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": "السجلات",
+2
View File
@@ -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",
+2
View File
@@ -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": "Логи",
@@ -30,6 +30,8 @@ pub struct ContentDialogProps {
pub on_confirm: Callback<DialogResult>,
#[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);
}
};
@@ -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<bool>,
pub on_templates_change: Callback<Option<Vec<PatternTemplate>>>,
#[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<Option<String>>,
on_filter_change: &Callback<Option<String>>,
) {
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::<ConfigContext>().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::<web_sys::HtmlTextAreaElement>() {
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! {
<div class={classes!("tp__filter-editor", if valid_filter {"tp__filter-editor-valid"} else {"tp__filter-editor-invalid"})}>
<div class={classes!("tp__filter-editor", if *valid_filter {Some("tp__filter-editor-valid")} else if props.validate_on_server {None} else {Some("tp__filter-editor-invalid")})}>
<CollapsePanel class="tp__filter-editor__templates-container" expanded={false} title={translate.t("LABEL.TEMPLATES")}>
<div class="tp__filter-editor__templates">
<div class="tp__filter-editor__templates-content">
@@ -141,10 +161,14 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
</div>
</CollapsePanel>
<div class="tp__filter-editor__editor">
<textarea class="tp__filter-editor__editor-input" value={(*input_value).clone()} oninput={handle_filter_input}/>
<textarea class="tp__filter-editor__editor-input" value={(*input_value).clone()} oninput={handle_filter_input} disabled={props.disabled}/>
</div>
<div class="tp__filter-editor__preview">
<FilterView inline={false} pretty={true} filter={parsed_filter} />
if props.validate_on_server && parsed_filter.is_none() {
<pre class="tp__filter__code">{(*input_value).clone()}</pre>
} else {
<FilterView inline={false} pretty={true} filter={parsed_filter.clone()} />
}
</div>
</div>
}
@@ -152,7 +176,31 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {
#[cfg(test)]
mod tests {
use super::parse_filter_preview;
use super::{emit_filter_input, parse_filter_preview};
use std::{cell::RefCell, rc::Rc};
use yew::Callback;
#[test]
fn filter_input_emits_latest_value_before_preview_debounce() {
let previous = Some(r#"Group = "Sports""#.to_owned());
let pending = RefCell::new(previous.clone());
let submitted = Rc::new(RefCell::new(previous));
let captured = submitted.clone();
let on_change = Callback::from(move |filter| {
captured.replace(filter);
});
let latest = r#"Group = "News""#.to_owned();
emit_filter_input(latest.clone(), &pending, &on_change);
assert_eq!(submitted.borrow().as_ref(), Some(&latest));
assert_eq!(*submitted.borrow(), *pending.borrow());
emit_filter_input("(".to_owned(), &pending, &on_change);
assert!(!parse_filter_preview(submitted.borrow().as_deref(), None).1);
emit_filter_input(String::new(), &pending, &on_change);
assert!(submitted.borrow().is_none());
assert!(pending.borrow().is_none());
emit_filter_input(" ".to_owned(), &pending, &on_change);
assert!(submitted.borrow().is_none());
}
#[test]
fn parse_filter_preview_accepts_empty_filter() {
@@ -4,6 +4,7 @@ use crate::{
components::{AppIcon, ContentDialog, FilterEditor, FilterView},
ConfigContext,
},
hooks::use_service_context,
model::{DialogAction, DialogActions, DialogResult},
};
use shared::{foundation::get_filter, model::PatternTemplate};
@@ -17,10 +18,13 @@ pub struct FilterInputProps {
pub filter: Option<String>,
#[prop_or_default]
pub on_change: Callback<Option<String>>,
#[prop_or_default]
pub validate_on_server: bool,
}
#[component]
pub fn FilterInput(props: &FilterInputProps) -> Html {
let services = use_service_context();
let config_ctx = use_context::<ConfigContext>().expect("Config context not found");
let filter_state = use_state(|| None);
@@ -30,6 +34,9 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
let editor_filter_state = use_state(|| None);
let editor_templates_state = use_state(|| None);
let editor_valid_state = use_state(|| true);
let latest_filter = use_mut_ref(|| props.filter.clone());
let validating = use_state(|| false);
let validation_pending = use_mut_ref(|| false);
{
let templates = templates_state.clone();
@@ -71,12 +78,14 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
let editor_filter_state = editor_filter_state.clone();
let editor_templates_state = editor_templates_state.clone();
let editor_valid_state = editor_valid_state.clone();
let latest_filter = latest_filter.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
e.stop_propagation();
let current_filter = (*filter_state).clone();
let current_templates = (*templates_state).clone();
let (_, valid) = parse_filter_preview(current_filter.as_deref(), current_templates.as_deref());
latest_filter.replace(current_filter.clone());
editor_filter_state.set(current_filter);
editor_templates_state.set(current_templates);
editor_valid_state.set(valid);
@@ -88,18 +97,52 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
let dialog_open = dialog_open.clone();
let filter_state = filter_state.clone();
let templates_state = templates_state.clone();
let editor_filter_state = editor_filter_state.clone();
let editor_templates_state = editor_templates_state.clone();
let editor_valid_state = editor_valid_state.clone();
let latest_filter = latest_filter.clone();
let validating = validating.clone();
let validation_pending = validation_pending.clone();
let services = services.clone();
let validate_on_server = props.validate_on_server;
let on_change = props.on_change.clone();
Callback::from(move |result: DialogResult| {
if result == DialogResult::Ok && *editor_valid_state {
let next_filter = (*editor_filter_state).clone();
filter_state.set(next_filter.clone());
templates_state.set((*editor_templates_state).clone());
on_change.emit(next_filter);
if *validation_pending.borrow() {
return;
}
if result != DialogResult::Ok {
dialog_open.set(false);
return;
}
let next_filter = latest_filter.borrow().clone();
let next_templates = (*editor_templates_state).clone();
if validate_on_server {
validation_pending.replace(true);
let validation_pending = validation_pending.clone();
validating.set(true);
let validating = validating.clone();
let services = services.clone();
let dialog_open = dialog_open.clone();
let filter_state = filter_state.clone();
let templates_state = templates_state.clone();
let on_change = on_change.clone();
yew::platform::spawn_local(async move {
match services.user.validate_filter(next_filter.clone()).await {
Ok(()) => {
filter_state.set(next_filter.clone());
templates_state.set(next_templates);
on_change.emit(next_filter);
dialog_open.set(false);
}
Err(err) => services.toastr.error(err.to_string()),
}
validation_pending.replace(false);
validating.set(false);
});
} else if parse_filter_preview(next_filter.as_deref(), next_templates.as_deref()).1 {
filter_state.set(next_filter.clone());
templates_state.set(next_templates);
on_change.emit(next_filter);
dialog_open.set(false);
}
dialog_open.set(false);
})
};
@@ -110,7 +153,8 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
DialogResult::Cancel,
Some("Close".to_owned()),
Some("secondary".to_string()),
)]),
)
.with_disabled(*validating)]),
right: vec![DialogAction::new(
"submit",
"LABEL.OK",
@@ -118,7 +162,7 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
Some("Accept".to_owned()),
Some("primary".to_string()),
)
.with_disabled(!*editor_valid_state)],
.with_disabled(*validating || (!props.validate_on_server && !*editor_valid_state))],
};
html! {
@@ -128,7 +172,7 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
<span class="tp__filter-input__preview">
{
match (*parsed_filter_state).as_ref() {
None => html! {},
None => html! { <>{(*filter_state).clone().unwrap_or_default()}</> },
Some(preview) => html! {
<FilterView inline={true} filter={preview.clone()} />
}
@@ -143,9 +187,15 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
content={html! {
<FilterEditor
filter={(*editor_filter_state).clone()}
validate_on_server={props.validate_on_server}
disabled={*validating}
on_filter_change={{
let editor_filter_state = editor_filter_state.clone();
Callback::from(move |flt: Option<String>| editor_filter_state.set(flt))
let latest_filter = latest_filter.clone();
Callback::from(move |flt: Option<String>| {
latest_filter.replace(flt.clone());
editor_filter_state.set(flt);
})
}}
on_valid_change={{
let editor_valid_state = editor_valid_state.clone();
@@ -156,6 +206,7 @@ pub fn FilterInput(props: &FilterInputProps) -> Html {
}}
actions={dialog_actions}
close_on_backdrop_click={false}
close_on_confirm={false}
on_confirm={handle_dialog_result}
/>
}
@@ -34,7 +34,7 @@ use shared::{
error::TuliproxError,
model::{
ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, ConfigInputUpdateQualityDto,
ConfigProviderDto, EpgSmartMatchConfigDto, EpgSourceDto, InputFetchMethod, InputType,
ConfigProviderDto, EpgSmartMatchConfigDto, EpgSourceDto, FlussonicHlsCatchup, InputFetchMethod, InputType,
MediaServerInputConfigDto, MediaServerLibrarySelector, OnConnectErrorPolicy, ProviderUrlSelectionPolicy,
StagedInputType, StalkerDeviceProfileDto, StalkerInputConfigDto,
},
@@ -366,6 +366,7 @@ generate_form_reducer!(
XtreamLiveStreamUsePrefix => xtream_live_stream_use_prefix: bool,
XtreamLiveStreamWithoutExtension => xtream_live_stream_without_extension: bool,
DisableHlsStreaming => disable_hls_streaming: bool,
FlussonicHlsCatchup => flussonic_hls_catchup: FlussonicHlsCatchup,
UserAgentStreamIndex => user_agent_stream_index: bool,
ResolveTmdb => resolve_tmdb: bool,
ResolveBackground => resolve_background: bool,
@@ -1525,6 +1526,28 @@ mod tests {
assert_eq!(applied.update_quality, updated);
}
#[test]
fn flussonic_catchup_reducer_loads_changes_and_serializes_select_values() -> Result<(), serde_json::Error> {
let loaded = ConfigInputOptionsDto {
flussonic_hls_catchup: FlussonicHlsCatchup::BoundedArchive,
user_agent_stream_index: true,
..ConfigInputOptionsDto::default()
};
let state = Rc::new(ConfigInputOptionsDtoFormState { form: ConfigInputOptionsDto::default(), modified: false })
.reduce(ConfigInputOptionsFormAction::SetAll(loaded.clone()));
assert_eq!(state.form, loaded);
assert!(!state.modified());
for value in [FlussonicHlsCatchup::Native, FlussonicHlsCatchup::BoundedArchive] {
let changed = state.clone().reduce(ConfigInputOptionsFormAction::FlussonicHlsCatchup(value));
assert!(changed.modified());
assert!(changed.form.user_agent_stream_index);
let saved = serde_json::to_string(changed.data())?;
let reloaded: ConfigInputOptionsDto = serde_json::from_str(&saved)?;
assert_eq!(reloaded.flussonic_hls_catchup, value);
}
Ok(())
}
#[test]
fn update_quality_translations_exist_in_every_frontend_locale() -> Result<(), serde_json::Error> {
let locales = [
@@ -10,7 +10,10 @@ use super::{
LABEL_XTREAM_LIVE_STREAM_USE_PREFIX, LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION,
};
use crate::{
app::components::{Card, FilterInput, KeyValueEditor, RadioButtonGroup, RangeSlider, TitledCard, ToolAction},
app::components::{
selection_parse_first, Card, DropDownOption, DropDownSelection, FilterInput, KeyValueEditor, RadioButtonGroup,
RangeSlider, Select, TitledCard, ToolAction,
},
config_field, config_field_bool, config_field_child, config_field_custom, config_field_optional,
config_field_optional_hide, edit_field_bool, edit_field_exp_date, edit_field_number_i16,
edit_field_number_option_u32, edit_field_number_u16, edit_field_number_u32, edit_field_text,
@@ -19,10 +22,11 @@ use crate::{
utils::content_cluster_presentation,
};
use shared::{
model::{ConfigInputUpdateQualityDto, InputFetchMethod, XtreamCluster},
model::{ConfigInputUpdateQualityDto, FlussonicHlsCatchup, InputFetchMethod, XtreamCluster},
utils::BATCH_SCHEME_PREFIX,
};
use std::{collections::HashMap, rc::Rc};
use strum::IntoEnumIterator;
use yew::{component, html, use_memo, Callback, Html, Properties, UseReducerHandle, UseStateHandle};
#[derive(Properties, Clone)]
@@ -254,6 +258,46 @@ fn update_quality_options(
)
}
fn flussonic_hls_catchup_options(selected: FlussonicHlsCatchup) -> Rc<Vec<DropDownOption>> {
Rc::new(
FlussonicHlsCatchup::iter()
.map(|value| {
let id = value.to_string();
DropDownOption::new(&id, html! { &id }, value == selected)
})
.collect(),
)
}
fn m3u_options(
state: &UseReducerHandle<ConfigInputOptionsDtoFormState>,
translate: &YewI18n,
allow_write: bool,
update_quality: Html,
) -> Html {
let label = translate.t("LABEL.FLUSSONIC_HLS_CATCHUP");
let catchup = if allow_write {
let options = flussonic_hls_catchup_options(state.form.flussonic_hls_catchup);
let state = state.clone();
config_field_child!(label, "CONFIG_INPUT_OPTIONS.FLUSSONIC_HLS_CATCHUP", {
html! { <Select name="flussonic_hls_catchup" multi_select={false} required={true} {options}
on_select={Callback::from(move |(_, selection): (String, DropDownSelection)| {
if let Some(value) = selection_parse_first::<FlussonicHlsCatchup>(&selection) {
state.dispatch(ConfigInputOptionsFormAction::FlussonicHlsCatchup(value));
}
})} /> }
})
} else {
config_field_custom!(label, state.form.flussonic_hls_catchup.to_string())
};
html! {
<>
<TitledCard title={translate.t(LABEL_LIVE_STREAMS)}>{catchup}</TitledCard>
{update_quality}
</>
}
}
#[derive(Properties, Clone)]
pub(super) struct InputOptionsFormProps {
pub state: UseReducerHandle<ConfigInputOptionsDtoFormState>,
@@ -280,7 +324,7 @@ pub(super) fn InputOptionsForm(props: &InputOptionsFormProps) -> Html {
};
let type_options = match (props.allow_write, props.kind) {
(_, OptionsKind::Basic) => Html::default(),
(_, OptionsKind::M3u) => update_quality,
(_, OptionsKind::M3u) => m3u_options(&state, &translate, props.allow_write, update_quality),
(false, OptionsKind::Stalker) => html! {
<>
<TitledCard title={translate.t("LABEL.SKIP")}>
@@ -439,6 +483,38 @@ fn xtream_options_editable(
mod tests {
use super::*;
#[test]
fn flussonic_catchup_select_exposes_only_supported_values_and_selects_loaded_mode(
) -> Result<(), Box<dyn std::error::Error>> {
for selected in [FlussonicHlsCatchup::Native, FlussonicHlsCatchup::BoundedArchive] {
let options = flussonic_hls_catchup_options(selected);
let ids: Vec<_> = options.iter().map(|option| option.id.as_str()).collect();
assert_eq!(ids, ["native", "bounded_archive"]);
let active: Vec<_> = options.iter().filter(|option| option.selected).collect();
assert_eq!(active.len(), 1);
assert_eq!(active[0].id.parse::<FlussonicHlsCatchup>()?, selected);
for option in options.iter() {
let mode = option.id.parse::<FlussonicHlsCatchup>()?;
assert_eq!(serde_json::to_value(mode)?, serde_json::json!(option.id));
}
}
assert!("other".parse::<FlussonicHlsCatchup>().is_err());
for source in [
include_str!("../../../../../public/assets/i18n/en.json"),
include_str!("../../../../../public/assets/i18n/ru.json"),
include_str!("../../../../../public/assets/i18n/ar.json"),
] {
let translations: serde_json::Value = serde_json::from_str(source)?;
for key in ["/LABEL/FLUSSONIC_HLS_CATCHUP", "/EXPLANATION/CONFIG_INPUT_OPTIONS/FLUSSONIC_HLS_CATCHUP"] {
assert!(translations
.pointer(key)
.and_then(serde_json::Value::as_str)
.is_some_and(|text| !text.is_empty()));
}
}
Ok(())
}
#[test]
fn update_quality_slider_uses_content_taxonomy_without_renaming_config_fields() {
let translations: serde_json::Value =
+1 -1
View File
@@ -161,7 +161,7 @@ pub fn UserEdit() -> Html {
</div>
<div class="tp__userlist-edit__body tp__list-create__body">
<Card>
<ProxyUserCredentialsForm server={server.clone()} plans={(*plans).clone()} targets={targets.clone()} user={(*userlist_ctx.selected_user).clone()} active_page={*userlist_ctx.active_page} on_save={handle_user_save} on_cancel={handle_cancel}/>
<ProxyUserCredentialsForm local_mode={userlist_ctx.local_mode} server={server.clone()} plans={(*plans).clone()} targets={targets.clone()} user={(*userlist_ctx.selected_user).clone()} active_page={*userlist_ctx.active_page} on_save={handle_user_save} on_cancel={handle_cancel}/>
</Card>
</div>
</div>
@@ -6,7 +6,8 @@ use crate::{
select::Select,
selection_first_owned, selection_parse_first,
userlist::{page::UserlistPage, proxy_type_input::ProxyTypeInput, ProxyTypeView},
ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, Tag, TextButton, UserStatus,
ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, FilterInput, Tag, TextButton,
UserStatus,
},
TargetUser,
},
@@ -180,6 +181,8 @@ pub struct ProxyUserCredentialsFormProps {
pub plans: Rc<Vec<UserPlanDto>>,
#[prop_or_default]
pub active_page: Option<UserlistPage>,
#[prop_or_default]
pub local_mode: bool,
pub on_save: Callback<(bool, String, ProxyUserCredentialsDto)>,
pub on_cancel: Callback<()>,
}
@@ -419,6 +422,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let plan_list = props.plans.clone();
let plan_is_update = update.clone();
let instance_output_clusters = form_state.clone();
let instance_filter = form_state.clone();
let active_plan = form_state.data().plan.as_ref().and_then(|name| props.plans.iter().find(|p| &p.name == name));
let country_services = service_ctx.clone();
let country_translate = translate.clone();
@@ -679,7 +683,14 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
} else {
html! {}
} }
{ edit_field_text_option!(form_state, translate.t("LABEL.FILTER"), filter, UserFormAction::Filter) }
{ config_field_child!(translate.t("LABEL.FILTER"), "PROXY_USER_CREDENTIALS.FILTER", {
html! {
<FilterInput filter={instance_filter.data().filter.clone()} validate_on_server={!props.local_mode}
on_change={Callback::from(move |filter: Option<String>| {
instance_filter.dispatch(UserFormAction::Filter(filter));
})} />
}
}) }
{ if let Some(plan) = active_plan {
if let Some(plan_filter) = &plan.filter {
html! {
@@ -2,8 +2,8 @@ use crate::{
app::{
components::{
convert_bool_to_chip_style, make_translated_header_callback, menu_item::MenuItem, popup_menu::PopupMenu,
AppIcon, CellValue, Chip, HideContent, MaxConnections, PagedTable, ProxyTypeView, RevealContent,
TableDefinition, UserStatus, UserlistContext, UserlistPage, PAGE_SIZES, TP_PAGE_SIZE_KEY,
AppIcon, CellValue, Chip, FilterView, HideContent, MaxConnections, PagedTable, ProxyTypeView,
RevealContent, TableDefinition, UserStatus, UserlistContext, UserlistPage, PAGE_SIZES, TP_PAGE_SIZE_KEY,
},
context::{target_users_to_api_proxy_users, TargetUser},
ConfigContext, TargetUserList,
@@ -17,13 +17,14 @@ use crate::{
};
use shared::{
defaults::default_page_size,
foundation::get_filter,
model::{permission::Permission, SortOrder},
utils::{unix_ts_to_str, Substring},
};
use std::{cmp::Ordering, collections::HashSet, rc::Rc, str::FromStr};
use yew::{platform::spawn_local, prelude::*};
const HEADERS: [&str; 19] = [
const HEADERS: [&str; 20] = [
"LABEL.EMPTY",
"LABEL.ENABLED",
"LABEL.STATUS",
@@ -43,6 +44,7 @@ const HEADERS: [&str; 19] = [
"LABEL.CREATED_AT",
"LABEL.EXP_DATE",
"LABEL.COMMENT",
"LABEL.FILTER",
];
fn get_cell_value(user: &TargetUser, col: usize) -> CellValue<'_> {
@@ -146,7 +148,18 @@ pub fn UserTable(props: &UserTableProps) -> Html {
let render_header_cell = make_translated_header_callback(translate.clone(), &HEADERS);
let templates = use_memo(config_ctx.clone(), |ctx| {
ctx.config.as_ref().and_then(|config| {
config
.templates
.as_ref()
.map(|definition| definition.templates.clone())
.or_else(|| config.sources.templates.clone())
})
});
let render_data_cell = {
let templates = templates.clone();
let translator = translate.clone();
let popup_onclick = handle_popup_onclick.clone();
let target_names = target_names.clone();
@@ -188,6 +201,13 @@ pub fn UserTable(props: &UserTableProps) -> Html {
18 => dto.credentials.comment.as_ref()
.map_or_else(|| html! {},
|comment| html! { <RevealContent preview={Some(html! {comment.substring(0, 50)})}>{comment}</RevealContent> }),
19 => dto.credentials.filter.as_ref().map_or_else(|| html! {}, |filter| {
let content = match get_filter(filter, templates.as_deref()) {
Ok(parsed) => html! { <FilterView pretty={true} filter={Some(parsed)} /> },
Err(_) => html! { <pre class="tp__filter__code">{filter}</pre> },
};
html! { <RevealContent>{content}</RevealContent> }
}),
_ => html! {""},
}
},
@@ -235,26 +255,29 @@ pub fn UserTable(props: &UserTableProps) -> Html {
// Dereference the UseStateHandle to pass the actual value as dependency.
// Yew 0.22 compares UseStateHandle by identity, not value, so use_memo
// would never detect value changes if we passed the handle directly.
use_memo(((*user_list).clone(), current_page, page_size_value), move |(targets, current_page, page_size)| {
let items = if targets.as_ref().is_none_or(|l| l.is_empty()) {
None
} else {
targets.as_ref().map(|list| {
let start = ((current_page - 1) as usize) * (*page_size as usize);
let page_items =
list.iter().skip(start).take(*page_size as usize).cloned().collect::<Vec<Rc<TargetUser>>>();
Rc::new(page_items)
})
};
TableDefinition::<TargetUser> {
items,
num_cols,
is_sortable,
on_sort,
render_header_cell: render_header_cell_cb,
render_data_cell: render_data_cell_cb,
}
})
use_memo(
((*user_list).clone(), current_page, page_size_value, (*templates).clone()),
move |(targets, current_page, page_size, _templates)| {
let items = if targets.as_ref().is_none_or(|l| l.is_empty()) {
None
} else {
targets.as_ref().map(|list| {
let start = ((current_page - 1) as usize) * (*page_size as usize);
let page_items =
list.iter().skip(start).take(*page_size as usize).cloned().collect::<Vec<Rc<TargetUser>>>();
Rc::new(page_items)
})
};
TableDefinition::<TargetUser> {
items,
num_cols,
is_sortable,
on_sort,
render_header_cell: render_header_cell_cb,
render_data_cell: render_data_cell_cb,
}
},
)
};
let handle_page_change = {
+5
View File
@@ -20,6 +20,11 @@ impl UserService {
Self { user_path: concat_path_leading_slash(&base_href, "api/v1/user"), event_service }
}
pub async fn validate_filter(&self, filter: Option<String>) -> Result<(), Error> {
let path = concat_path(&self.user_path, "filter/validate");
request_post::<Option<String>, ()>(&path, filter, None, None).await.map(|_| ())
}
pub async fn create_user(&self, target: String, user: ProxyUserCredentialsDto) -> Result<(), Error> {
let path = concat_path(&self.user_path, &target);
match request_post::<ProxyUserCredentialsDto, ()>(&path, user, None, None).await {
+9 -4
View File
@@ -179,12 +179,12 @@ impl ApiProxyConfigDto {
}
}
fn prepare_target_user(&mut self, errors: &mut Vec<String>) {
fn prepare_target_user(&mut self, errors: &mut Vec<String>, templates: Option<&[crate::model::PatternTemplate]>) {
let mut usernames = HashSet::new();
let mut tokens = HashSet::new();
for target_user in &mut self.user {
for user in &mut target_user.credentials {
if let Err(err) = user.prepare() {
if let Err(err) = user.prepare_with_templates(templates) {
errors.push(err.to_string());
}
if usernames.contains(&user.username) {
@@ -218,14 +218,19 @@ impl ApiProxyConfigDto {
}
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
pub fn prepare(&mut self) -> Result<(), TuliproxError> { self.prepare_with_templates(None) }
pub fn prepare_with_templates(
&mut self,
templates: Option<&[crate::model::PatternTemplate]>,
) -> Result<(), TuliproxError> {
let mut errors = Vec::new();
if self.server.is_empty() {
errors.push("No server info defined".to_string());
} else {
self.prepare_server_config(&mut errors);
}
self.prepare_target_user(&mut errors);
self.prepare_target_user(&mut errors, templates);
// A success or redirect code here would make auth failures look like valid responses
if !(400..=599).contains(&self.auth_error_status) {
errors.push(format!(
+23 -3
View File
@@ -58,7 +58,12 @@ pub struct ProxyUserCredentialsDto {
}
impl ProxyUserCredentialsDto {
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
pub fn prepare(&mut self) -> Result<(), TuliproxError> { self.prepare_with_templates(None) }
pub fn prepare_with_templates(
&mut self,
templates: Option<&[crate::model::PatternTemplate]>,
) -> Result<(), TuliproxError> {
self.trim();
if let Some(na) = &mut self.network_access {
na.prepare()?;
@@ -67,8 +72,7 @@ impl ProxyUserCredentialsDto {
}
}
if let Some(filter) = &self.filter {
// Templates are not available in api-proxy; fail fast on syntax errors.
crate::foundation::get_filter(filter, None)
crate::foundation::get_filter(filter, templates)
.map_err(|err| TuliproxError::ProxyUser(format!("Invalid filter for user {}: {err}", self.username)))?;
}
Ok(())
@@ -164,6 +168,22 @@ impl Default for ProxyUserCredentialsDto {
mod tests {
use super::*;
#[test]
fn prepare_with_templates_preserves_filter_references() {
let mut template = crate::model::PatternTemplate {
name: "groups".to_owned(),
value: crate::model::TemplateValue::Single("Sports".to_owned()),
placeholder: String::new(),
};
template.prepare();
let raw = r#"Group = "!groups!""#;
let mut user = ProxyUserCredentialsDto { filter: Some(raw.to_owned()), ..Default::default() };
assert!(user.prepare_with_templates(Some(&[template])).is_ok());
assert_eq!(user.filter.as_deref(), Some(raw));
assert!(user.prepare().is_err());
assert!(serde_json::to_string(&user).is_ok_and(|json| json.contains("!groups!")));
}
#[test]
fn proxy_user_credentials_output_clusters_none_preserved() {
let value = serde_json::json!({
+48
View File
@@ -265,6 +265,31 @@ impl InputFetchMethod {
pub fn is_default(value: &InputFetchMethod) -> bool { matches!(value, Self::GET) }
}
#[derive(
Debug,
Clone,
Copy,
Default,
serde::Serialize,
serde::Deserialize,
PartialEq,
Eq,
strum_macros::Display,
strum_macros::EnumString,
strum_macros::EnumIter,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum FlussonicHlsCatchup {
#[default]
Native,
BoundedArchive,
}
impl FlussonicHlsCatchup {
pub fn is_native(value: &Self) -> bool { matches!(value, Self::Native) }
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
@@ -283,6 +308,8 @@ pub struct ConfigInputOptionsDto {
pub xtream_live_stream_without_extension: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub disable_hls_streaming: bool,
#[serde(default, skip_serializing_if = "FlussonicHlsCatchup::is_native")]
pub flussonic_hls_catchup: FlussonicHlsCatchup,
#[serde(default, skip_serializing_if = "is_false")]
pub user_agent_stream_index: bool,
#[serde(default, skip_serializing_if = "is_false")]
@@ -332,6 +359,7 @@ impl Default for ConfigInputOptionsDto {
xtream_live_stream_use_prefix: default_xtream_live_stream_use_prefix(),
xtream_live_stream_without_extension: false,
disable_hls_streaming: false,
flussonic_hls_catchup: FlussonicHlsCatchup::Native,
user_agent_stream_index: false,
resolve_tmdb: false,
resolve_background: default_resolve_background(),
@@ -361,6 +389,7 @@ impl ConfigInputOptionsDto {
&& self.xtream_live_stream_use_prefix
&& !self.xtream_live_stream_without_extension
&& !self.disable_hls_streaming
&& FlussonicHlsCatchup::is_native(&self.flussonic_hls_catchup)
&& !self.user_agent_stream_index
&& !self.resolve_tmdb
&& self.resolve_background
@@ -385,6 +414,7 @@ impl ConfigInputOptionsDto {
self.xtream_live_stream_use_prefix = default_as_true();
self.xtream_live_stream_without_extension = false;
self.disable_hls_streaming = false;
self.flussonic_hls_catchup = FlussonicHlsCatchup::Native;
self.user_agent_stream_index = false;
self.resolve_tmdb = false;
self.resolve_background = default_as_true();
@@ -2099,6 +2129,24 @@ mod tests {
assert!(options.is_empty());
}
#[test]
fn flussonic_hls_catchup_round_trips_and_cleans() -> Result<(), serde_json::Error> {
let defaults: ConfigInputOptionsDto = serde_json::from_str("{}")?;
assert_eq!(defaults.flussonic_hls_catchup, super::FlussonicHlsCatchup::Native);
assert!(serde_json::to_value(defaults)?.get("flussonic_hls_catchup").is_none());
let mut options: ConfigInputOptionsDto =
serde_json::from_str(r#"{"flussonic_hls_catchup":"bounded_archive"}"#)?;
assert_eq!(options.flussonic_hls_catchup, super::FlussonicHlsCatchup::BoundedArchive);
assert!(!options.is_empty());
assert_eq!(serde_json::to_value(&options)?, serde_json::json!({"flussonic_hls_catchup": "bounded_archive"}));
assert_eq!(serde_json::from_str::<ConfigInputOptionsDto>(&serde_json::to_string(&options)?)?, options);
assert!(serde_json::from_str::<ConfigInputOptionsDto>(r#"{"flussonic_hls_catchup":"unknown"}"#).is_err());
options.clean();
assert_eq!(options.flussonic_hls_catchup, super::FlussonicHlsCatchup::Native);
assert!(options.is_empty());
Ok(())
}
#[test]
fn disable_hls_streaming_defaults_to_false() -> Result<(), serde_json::Error> {
let options: ConfigInputOptionsDto = serde_json::from_str("{}")?;