mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-15 20:32:27 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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(®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<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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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""#)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": "السجلات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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("{}")?;
|
||||
|
||||
Reference in New Issue
Block a user