mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-20 23:02:34 +02:00
* 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
424 lines
18 KiB
Rust
424 lines
18 KiB
Rust
use crate::{
|
|
api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage},
|
|
config_loader::{prepare_sources_batch, read_sources_file_from_path_with_templates},
|
|
model::{CompiledMappings, Config, ProcessTargets, SourcesConfig},
|
|
utils,
|
|
utils::read_mappings_file_with_templates,
|
|
};
|
|
use log::{debug, error, info, warn};
|
|
use shared::{
|
|
error::TuliproxError,
|
|
model::{ConfigPaths, ConfigType, PatternTemplate},
|
|
};
|
|
use std::{
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ConfigFile {
|
|
Config,
|
|
ApiProxy,
|
|
Mapping,
|
|
Template,
|
|
Sources,
|
|
SourceFile,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PreparedMappingsReload {
|
|
mapping_file_path: String,
|
|
mapping_files: Vec<PathBuf>,
|
|
mappings: CompiledMappings,
|
|
}
|
|
|
|
/// Fully prepared sources + mappings data, ready to apply without any I/O or fallible work.
|
|
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>, Option<Vec<PatternTemplate>>),
|
|
Sources(PreparedSourcesReload),
|
|
}
|
|
|
|
/// Refreshes CLI-forced target IDs against an incoming `SourcesConfig`.
|
|
///
|
|
/// IDs are positional and can shift when source ordering changes. Targets that no
|
|
/// longer exist are warned about and dropped; the remaining ones are re-validated to
|
|
/// obtain their new IDs. The reload proceeds regardless — only the stale entries are
|
|
/// removed from the filter.
|
|
fn refresh_forced_targets(current: Arc<ProcessTargets>, sources: &SourcesConfig) -> Arc<ProcessTargets> {
|
|
if !current.enabled || current.target_names.is_empty() {
|
|
return current;
|
|
}
|
|
|
|
let (still_valid, missing): (Vec<String>, Vec<String>) = current
|
|
.target_names
|
|
.iter()
|
|
.cloned()
|
|
.partition(|name| sources.validate_targets(Some(&vec![name.clone()])).is_ok());
|
|
|
|
if !missing.is_empty() {
|
|
warn!("CLI-forced target(s) {missing:?} no longer exist in the updated sources and will be ignored.");
|
|
}
|
|
|
|
if still_valid.is_empty() {
|
|
// Every forced target was removed — disable the filter so nothing is blocked.
|
|
warn!("All CLI-forced targets have been removed from the updated sources; disabling target filter.");
|
|
match sources.validate_targets(None) {
|
|
Ok(disabled) => Arc::new(disabled),
|
|
Err(_) => current,
|
|
}
|
|
} else {
|
|
match sources.validate_targets(Some(&still_valid)) {
|
|
Ok(refreshed) => Arc::new(refreshed),
|
|
Err(err) => {
|
|
warn!(
|
|
"Failed to re-validate remaining CLI-forced targets {still_valid:?}: {err}. Keeping previous IDs."
|
|
);
|
|
current
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConfigFile {
|
|
// -----------------------------------------------------------------
|
|
// Template helpers
|
|
// -----------------------------------------------------------------
|
|
|
|
/// Load and merge global templates using the current app state.
|
|
async fn load_prepared_global_templates(
|
|
app_state: &Arc<AppState>,
|
|
) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
|
|
let paths = app_state.app_config.paths.load();
|
|
let config = app_state.app_config.config.load();
|
|
Self::load_prepared_global_templates_with_config(&paths, &config).await
|
|
}
|
|
|
|
/// Load and merge global templates using explicitly-provided config/paths.
|
|
/// Used in the prepare phase when the new config has not yet been applied to `app_state`.
|
|
async fn load_prepared_global_templates_with_config(
|
|
paths: &ConfigPaths,
|
|
config: &Config,
|
|
) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
|
|
crate::config_loader::load_prepared_global_templates(paths, config).await
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Mapping prepare / apply
|
|
// -----------------------------------------------------------------
|
|
|
|
fn prepare_mapping_reload(
|
|
mapping_file_path: Option<&str>,
|
|
prepared_templates: Option<&[PatternTemplate]>,
|
|
) -> Result<Option<PreparedMappingsReload>, TuliproxError> {
|
|
let Some(mapping_file_path) = mapping_file_path else {
|
|
return Ok(None);
|
|
};
|
|
|
|
match read_mappings_file_with_templates(mapping_file_path, true, prepared_templates) {
|
|
Ok(Some((mapping_files, mappings_cfg))) => {
|
|
let mappings = CompiledMappings::try_from(&mappings_cfg)?;
|
|
Ok(Some(PreparedMappingsReload {
|
|
mapping_file_path: mapping_file_path.to_string(),
|
|
mapping_files,
|
|
mappings,
|
|
}))
|
|
}
|
|
Ok(None) => {
|
|
info!("No mapping file loaded {mapping_file_path}");
|
|
Ok(None)
|
|
}
|
|
Err(err) => {
|
|
error!("Failed to load mapping file {err}");
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_mapping_reload(app_state: &Arc<AppState>, prepared_mapping: Option<PreparedMappingsReload>) {
|
|
if let Some(prepared_mapping) = prepared_mapping {
|
|
app_state.app_config.set_mappings(prepared_mapping.mapping_file_path.as_str(), &prepared_mapping.mappings);
|
|
for mapping_file in prepared_mapping.mapping_files {
|
|
info!("Loaded mapping file {}", mapping_file.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
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]>,
|
|
) -> Result<(), TuliproxError> {
|
|
let paths = app_state.app_config.paths.load();
|
|
let prepared_mapping = Self::prepare_mapping_reload(paths.mapping_file_path.as_deref(), prepared_templates)?;
|
|
Self::apply_mapping_reload(app_state, prepared_mapping);
|
|
Ok(())
|
|
}
|
|
|
|
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::apply_user_filter_templates(app_state, prepared_templates)
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Sources prepare / apply
|
|
// -----------------------------------------------------------------
|
|
|
|
/// Prepare sources + mappings using explicitly-provided config/paths (prepare phase only — no state changes).
|
|
async fn prepare_sources_reload_with_config(
|
|
config: &Config,
|
|
paths: &ConfigPaths,
|
|
) -> Result<PreparedSourcesReload, TuliproxError> {
|
|
let sources_file = paths.sources_file_path.clone();
|
|
let prepared_templates = Self::load_prepared_global_templates_with_config(paths, config).await?;
|
|
let mut sources_dto = read_sources_file_from_path_with_templates(
|
|
&PathBuf::from(sources_file.as_str()),
|
|
true,
|
|
true,
|
|
config.get_hdhr_device_overview().as_ref(),
|
|
prepared_templates.as_deref(),
|
|
)
|
|
.await?;
|
|
prepare_sources_batch(&mut sources_dto, true).await?;
|
|
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, templates: prepared_templates })
|
|
}
|
|
|
|
/// Apply a fully-prepared sources reload to app state (infallible under normal conditions).
|
|
async fn apply_sources_reload(
|
|
app_state: &Arc<AppState>,
|
|
prepared: PreparedSourcesReload,
|
|
) -> Result<(), TuliproxError> {
|
|
let current_forced = app_state.forced_targets.load_full();
|
|
let validated_forced = refresh_forced_targets(current_forced, &prepared.sources);
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Public entry-point for Sources / Template file-watch events.
|
|
/// Reads config from current app state (config itself has not changed).
|
|
pub async fn load_sources(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
|
let paths = app_state.app_config.paths.load();
|
|
let config = app_state.app_config.config.load();
|
|
// PREPARE — no state mutations
|
|
let prepared = Self::prepare_sources_reload_with_config(&config, &paths).await?;
|
|
// APPLY — only reached when preparation fully succeeded
|
|
Self::apply_sources_reload(app_state, prepared).await
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// API proxy
|
|
// -----------------------------------------------------------------
|
|
|
|
async fn load_api_proxy(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
|
match crate::config_loader::read_api_proxy_config(&app_state.app_config, true).await {
|
|
Ok(Some(api_proxy)) => {
|
|
app_state.app_config.set_api_proxy(api_proxy)?;
|
|
let paths = app_state.app_config.paths.load();
|
|
info!("Loaded Api Proxy File: {:?}", paths.api_proxy_file_path);
|
|
}
|
|
Ok(None) => {
|
|
let paths = app_state.app_config.paths.load();
|
|
info!("Could not load Api Proxy File: {:?}", paths.api_proxy_file_path);
|
|
}
|
|
Err(err) => {
|
|
error!("Failed to load api-proxy file {err}");
|
|
return Err(err);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Main config (config.yml) — full prepare-then-apply
|
|
// -----------------------------------------------------------------
|
|
|
|
async fn load_config(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
|
let paths = app_state.app_config.paths.load();
|
|
let config_file = paths.config_file_path.clone();
|
|
|
|
// ── Parse ────────────────────────────────────────────────────
|
|
let config_dto = crate::config_loader::read_config_file_with_options(
|
|
config_file.as_str(),
|
|
crate::config_loader::ReadConfigOptions::resolve_and_compute(),
|
|
)?;
|
|
|
|
let current_mapping_path = paths
|
|
.mapping_file_path
|
|
.clone()
|
|
.unwrap_or_else(|| utils::resolve_mapping_file_path(paths.config_path.as_str(), None));
|
|
let next_mapping_path = paths.mapping_file_path.clone().unwrap_or_else(|| {
|
|
utils::resolve_mapping_file_path(paths.config_path.as_str(), config_dto.mapping_path.as_deref())
|
|
});
|
|
let mapping_changed = current_mapping_path != next_mapping_path;
|
|
|
|
let current_template_path = paths
|
|
.template_file_path
|
|
.clone()
|
|
.unwrap_or_else(|| utils::resolve_template_file_path(paths.config_path.as_str(), None));
|
|
let next_template_path = paths.template_file_path.clone().unwrap_or_else(|| {
|
|
utils::resolve_template_file_path(paths.config_path.as_str(), config_dto.template_path.as_deref())
|
|
});
|
|
let template_changed = current_template_path != next_template_path;
|
|
|
|
let mut config: Config = Config::from(config_dto);
|
|
config.prepare(paths.config_path.as_str(), paths.home_path.as_str())?;
|
|
|
|
// Resolve the paths that will be active if this configuration is applied.
|
|
let mut effective_paths = paths.as_ref().clone();
|
|
effective_paths.mapping_file_path = Some(next_mapping_path.clone());
|
|
effective_paths.template_file_path = Some(next_template_path.clone());
|
|
|
|
// Load and validate dependent data without changing live state.
|
|
let follow_up: PreparedFollowUp = if template_changed {
|
|
// Template path changed -> sources depend on new templates, reload everything.
|
|
let prepared = Self::prepare_sources_reload_with_config(&config, &effective_paths).await?;
|
|
PreparedFollowUp::Sources(prepared)
|
|
} else if mapping_changed {
|
|
// Only mapping path changed; templates are the same -> load templates once.
|
|
let prepared_templates =
|
|
Self::load_prepared_global_templates_with_config(&effective_paths, &config).await?;
|
|
let prepared = Self::prepare_mapping_reload(
|
|
effective_paths.mapping_file_path.as_deref(),
|
|
prepared_templates.as_deref(),
|
|
)?;
|
|
PreparedFollowUp::Mapping(prepared, prepared_templates)
|
|
} else {
|
|
PreparedFollowUp::Unchanged
|
|
};
|
|
|
|
let previous_config: Config = (*app_state.app_config.config.load_full()).clone();
|
|
let previous_sources: SourcesConfig = (*app_state.app_config.sources.load_full()).clone();
|
|
let previous_forced_targets = app_state.forced_targets.load_full();
|
|
|
|
// Update live state only after every dependent value has been prepared.
|
|
if let Err(err) = update_app_state_config(app_state, config).await {
|
|
error!("Failed to apply config reload: {err}. Attempting config rollback.");
|
|
if let Err(rollback_err) = update_app_state_config(app_state, previous_config).await {
|
|
error!("Failed to rollback config after reload failure: {rollback_err}");
|
|
error!(
|
|
"Config reload and rollback both failed; runtime state may be inconsistent. Please restart the service."
|
|
);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
let follow_up_result: Result<(), TuliproxError> = match follow_up {
|
|
PreparedFollowUp::Unchanged => Ok(()),
|
|
PreparedFollowUp::Mapping(prepared, templates) => {
|
|
Self::apply_mapping_reload(app_state, prepared);
|
|
Self::apply_user_filter_templates(app_state, templates)
|
|
}
|
|
PreparedFollowUp::Sources(prepared) => Self::apply_sources_reload(app_state, prepared).await,
|
|
};
|
|
|
|
if let Err(err) = follow_up_result {
|
|
error!("Failed to apply dependent reload after config update: {err}. Attempting rollback.");
|
|
|
|
if let Err(rollback_err) = update_app_state_config(app_state, previous_config).await {
|
|
error!("Failed to rollback config after dependent reload failure: {rollback_err}");
|
|
error!(
|
|
"Dependent reload and config rollback both failed; runtime state may be inconsistent. Please restart the service."
|
|
);
|
|
}
|
|
|
|
if let Err(rollback_err) =
|
|
update_app_state_sources(app_state, previous_sources, Some(previous_forced_targets)).await
|
|
{
|
|
error!("Failed to rollback sources after dependent reload failure: {rollback_err}");
|
|
error!(
|
|
"Source rollback failed after dependent reload error; runtime state may be inconsistent. Please restart the service."
|
|
);
|
|
}
|
|
|
|
return Err(err);
|
|
}
|
|
|
|
info!("Loaded config file {config_file}");
|
|
Ok(())
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Reload dispatcher
|
|
// -----------------------------------------------------------------
|
|
|
|
async fn reload_source_file(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
|
// TODO selective update and not complete sources update ?
|
|
ConfigFile::load_sources(app_state).await
|
|
}
|
|
|
|
pub(crate) async fn reload(&self, file_path: &Path, app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
|
debug!("File change detected {}", file_path.display());
|
|
// Channels and templates are built once and cached, so an edited
|
|
// token, URL or template would otherwise not take effect until a
|
|
// restart.
|
|
tuliprox_messaging::channels::invalidate();
|
|
tuliprox_messaging::render::invalidate_cache();
|
|
match self {
|
|
ConfigFile::ApiProxy => {
|
|
ConfigFile::load_api_proxy(app_state).await?;
|
|
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy));
|
|
}
|
|
ConfigFile::Mapping => {
|
|
ConfigFile::load_mapping(app_state).await?;
|
|
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping));
|
|
}
|
|
ConfigFile::Template | ConfigFile::Sources => {
|
|
ConfigFile::load_sources(app_state).await?;
|
|
let event_type =
|
|
if matches!(self, ConfigFile::Template) { ConfigType::Template } else { ConfigType::Sources };
|
|
app_state.event_manager.send_event(EventMessage::ConfigChange(event_type));
|
|
}
|
|
ConfigFile::Config => {
|
|
Box::pin(ConfigFile::load_config(app_state)).await?;
|
|
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Config));
|
|
}
|
|
ConfigFile::SourceFile => {
|
|
ConfigFile::reload_source_file(app_state).await?;
|
|
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[ignore = "requires AppState mock infrastructure (future work)"]
|
|
#[tokio::test]
|
|
async fn test_forced_targets_preserved_on_config_reload() {
|
|
// Expected behavior:
|
|
// - Start with forced_targets = {enabled: false, inputs: [], targets: []}
|
|
// - Reload config with schedule.targets = ["my-target"]
|
|
// - Verify forced_targets remains {enabled: false, inputs: [], targets: []}
|
|
// - Verify scheduler uses filtered targets based on forced_targets AND schedule.targets
|
|
// Full integration test requires AppState mock infrastructure (future work).
|
|
}
|
|
}
|