mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 13:52:20 +02:00
Feature/staged input and path improvements (#632)
* No template file not found error message * Setup wizard improvmenets * path improvements * staged input fix * path improvements * Provider dns resolved ips now writing to a file instead of source.yml * Playlist update hard kill after 1h * matadata update tmdb fix * Web UI fixes * GeoIp update schedule * batch:// prefix for aliases
This commit is contained in:
@@ -708,7 +708,9 @@ mod tests {
|
||||
api_proxy: Arc::new(ArcSwapOption::default()),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
|
||||
@@ -266,7 +266,7 @@ fn build_http_client_with_fallback(
|
||||
pub fn create_cache(config: &Config) -> Option<Arc<Mutex<LRUResourceCache>>> {
|
||||
let lru_cache = config.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()).and_then(|c| {
|
||||
if c.enabled {
|
||||
Some(LRUResourceCache::new(c.size, c.dir.as_str()))
|
||||
Some(LRUResourceCache::new(c.size, c.directory.as_str()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -341,20 +341,23 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
pub(in crate::api::model) async fn set_config(&self, config: Config) -> Result<UpdateChanges, TuliproxError> {
|
||||
let old_storage_dir = self.app_config.config.load().storage_dir.clone();
|
||||
let changes = self.detect_changes_for_config(&config);
|
||||
config.update_runtime();
|
||||
|
||||
let use_geoip = config.is_geoip_enabled();
|
||||
let working_dir = config.working_dir.clone();
|
||||
let storage_dir = config.storage_dir.clone();
|
||||
|
||||
self.active_users.update_config(&config);
|
||||
self.app_config.set_config(config)?;
|
||||
self.active_provider.update_config(&self.app_config).await;
|
||||
self.update_config().await?;
|
||||
|
||||
if changes.flags.contains(UpdateChangesFlags::Geoip) {
|
||||
let geoip_reload_needed =
|
||||
changes.flags.contains(UpdateChangesFlags::Geoip) || (use_geoip && old_storage_dir != storage_dir);
|
||||
if geoip_reload_needed {
|
||||
let new_geoip = if use_geoip {
|
||||
let path = get_geoip_path(&working_dir);
|
||||
let path = get_geoip_path(&storage_dir);
|
||||
let _file_lock = self.app_config.file_locks.read_lock(&path).await;
|
||||
GeoIp::load(&path).ok().map(Arc::new)
|
||||
} else {
|
||||
@@ -381,7 +384,7 @@ impl AppState {
|
||||
.reverse_proxy
|
||||
.as_ref()
|
||||
.and_then(|r| r.cache.as_ref())
|
||||
.map_or((false, 0, ""), |c| (c.enabled, c.size, c.dir.as_str()));
|
||||
.map_or((false, 0, ""), |c| (c.enabled, c.size, c.directory.as_str()));
|
||||
|
||||
if let Some(cache) = self.cache.load().as_ref() {
|
||||
if enabled {
|
||||
@@ -401,6 +404,22 @@ impl AppState {
|
||||
sources: SourcesConfig,
|
||||
) -> Result<UpdateChanges, TuliproxError> {
|
||||
let changes = self.detect_changes_for_sources(&sources);
|
||||
// Carry over DNS caches from old providers so resolved IPs survive hot-reloads
|
||||
// without waiting for the background resolver or the persisted-file seed.
|
||||
{
|
||||
let old_sources = self.app_config.sources.load();
|
||||
for new_provider in &sources.provider {
|
||||
if let Some(old_provider) = old_sources.get_provider_by_name(&new_provider.name) {
|
||||
if new_provider.get_dns_config().is_some_and(|cfg| cfg.enabled) {
|
||||
for (host, ips) in old_provider.snapshot_resolved() {
|
||||
if !ips.is_empty() && new_provider.dns_cache.ip_count(&host) == 0 {
|
||||
new_provider.dns_cache.store_resolved(&host, ips);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.app_config.set_sources(sources)?;
|
||||
self.active_provider.update_config(&self.app_config).await;
|
||||
|
||||
@@ -420,6 +439,9 @@ impl AppState {
|
||||
let old_config = self.app_config.config.load();
|
||||
let changed_schedules =
|
||||
change_detect!(schedules_changed, old_config.schedules.as_ref(), config.schedules.as_ref());
|
||||
let library_enabled = config.library.as_ref().is_some_and(|library| library.enabled);
|
||||
let old_library_enabled = old_config.library.as_ref().is_some_and(|library| library.enabled);
|
||||
let changed_library_enabled = library_enabled != old_library_enabled;
|
||||
let changed_hdhomerun =
|
||||
change_detect!(hdhomerun_changed, old_config.hdhomerun.as_ref(), config.hdhomerun.as_ref());
|
||||
let changed_file_watch =
|
||||
@@ -430,7 +452,7 @@ impl AppState {
|
||||
let geoip_enabled_old = old_config.is_geoip_enabled();
|
||||
|
||||
let mut changes = UpdateChanges { flags: UpdateChangesFlagsSet::new(), targets: None };
|
||||
changes.set_flag_if(changed_schedules, UpdateChangesFlags::Scheduler);
|
||||
changes.set_flag_if(changed_schedules || changed_library_enabled || geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Scheduler);
|
||||
changes.set_flag_if(changed_hdhomerun, UpdateChangesFlags::Hdhomerun);
|
||||
changes.set_flag_if(changed_file_watch, UpdateChangesFlags::FileWatch);
|
||||
changes.set_flag_if(geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Geoip);
|
||||
@@ -582,19 +604,30 @@ fn schedules_changed(a: &[ScheduleConfig], b: &[ScheduleConfig]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return true;
|
||||
}
|
||||
let mut used = vec![false; b.len()];
|
||||
|
||||
for schedule in a {
|
||||
let Some(found) = b.iter().find(|&s| s.schedule == schedule.schedule) else {
|
||||
let Some(found_idx) = b.iter().enumerate().find_map(|(idx, candidate)| {
|
||||
if used[idx]
|
||||
|| candidate.schedule != schedule.schedule
|
||||
|| candidate.task_type != schedule.task_type
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let targets_match = match (schedule.targets.as_ref(), candidate.targets.as_ref()) {
|
||||
(None, None) => true,
|
||||
(Some(_), None) | (None, Some(_)) => false,
|
||||
(Some(a_targets), Some(b_targets)) => small_vecs_equal_unordered(a_targets, b_targets),
|
||||
};
|
||||
if targets_match {
|
||||
Some(idx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) else {
|
||||
return true;
|
||||
};
|
||||
match (schedule.targets.as_ref(), found.targets.as_ref()) {
|
||||
(None, None) => {}
|
||||
(Some(_), None) | (None, Some(_)) => return true,
|
||||
(Some(a_targets), Some(b_targets)) => {
|
||||
if !small_vecs_equal_unordered(a_targets, b_targets) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
used[found_idx] = true;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -635,7 +668,9 @@ pub struct HdHomerunAppState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{should_use_manual_redirect_for_proxy, should_use_manual_redirects_for_env_vars};
|
||||
use super::{schedules_changed, should_use_manual_redirect_for_proxy, should_use_manual_redirects_for_env_vars};
|
||||
use crate::model::ScheduleConfig;
|
||||
use shared::model::ScheduleTaskType;
|
||||
|
||||
#[test]
|
||||
fn should_use_manual_redirect_for_proxy_only_http_or_https() {
|
||||
@@ -672,4 +707,48 @@ mod tests {
|
||||
"http://localhost".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_changed_detects_task_type_changes() {
|
||||
let a = vec![ScheduleConfig {
|
||||
schedule: "0 0 3 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::PlaylistUpdate,
|
||||
targets: None,
|
||||
}];
|
||||
let b = vec![ScheduleConfig {
|
||||
schedule: "0 0 3 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::GeoIpUpdate,
|
||||
targets: None,
|
||||
}];
|
||||
assert!(schedules_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_changed_treats_same_entries_as_unchanged() {
|
||||
let a = vec![
|
||||
ScheduleConfig {
|
||||
schedule: "0 0 3 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::GeoIpUpdate,
|
||||
targets: None,
|
||||
},
|
||||
ScheduleConfig {
|
||||
schedule: "0 0 8 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::PlaylistUpdate,
|
||||
targets: Some(vec!["a".to_string(), "b".to_string()]),
|
||||
},
|
||||
];
|
||||
let b = vec![
|
||||
ScheduleConfig {
|
||||
schedule: "0 0 8 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::PlaylistUpdate,
|
||||
targets: Some(vec!["b".to_string(), "a".to_string()]),
|
||||
},
|
||||
ScheduleConfig {
|
||||
schedule: "0 0 3 * * * *".to_string(),
|
||||
task_type: ScheduleTaskType::GeoIpUpdate,
|
||||
targets: None,
|
||||
},
|
||||
];
|
||||
assert!(!schedules_changed(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +596,7 @@ impl MetadataUpdateManager {
|
||||
/// * `task` - The task to process
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn queue_task(&self, input_name: Arc<str>, task: UpdateTask) {
|
||||
debug!("[Task] Queuing task for input {input_name}: {task}");
|
||||
// Read app state once and reuse for worker creation when needed.
|
||||
let app_state_weak = {
|
||||
let guard = self.app_state.lock().await;
|
||||
@@ -747,8 +748,12 @@ impl MetadataUpdateManager {
|
||||
}
|
||||
} else {
|
||||
let mut existing = entry.task.lock();
|
||||
let before = format!("{existing}");
|
||||
if Self::merge_task_payload(&mut existing, task_to_submit) {
|
||||
entry.generation.fetch_add(1, Ordering::Relaxed);
|
||||
debug!("[Task] Merged task for input {input_name}: before={before}, after={existing}");
|
||||
} else {
|
||||
debug!("[Task] Task already pending for input {input_name} (no merge needed): {existing}");
|
||||
}
|
||||
return SubmitTaskResult::QueuedOrMerged;
|
||||
}
|
||||
@@ -987,8 +992,8 @@ impl InputWorker {
|
||||
let mut task_for_execution = current_task.clone();
|
||||
|
||||
if Self::is_resolve_task(&task_for_execution) && self.resolve_exhausted.contains_key(¤t_key) {
|
||||
debug_if_enabled!(
|
||||
"Skipping exhausted resolve task for input {}: {} (reset window: {}s)",
|
||||
debug!(
|
||||
"[Metadata-Task] Skipping task (resolve exhausted) for input {}: {} (reset window: {}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
runtime_settings.resolve_exhaustion_reset_gap_secs
|
||||
@@ -1003,10 +1008,8 @@ impl InputWorker {
|
||||
if !skip_execution
|
||||
&& self.should_skip_recent_no_change_task(¤t_key, &task_for_execution, &runtime_settings)
|
||||
{
|
||||
debug_if_enabled!(
|
||||
"Skipping recently-resolved no-change task for input {}: {}",
|
||||
input_name,
|
||||
task_for_execution
|
||||
debug!(
|
||||
"[Metadata-Task] Skipping task (recently completed with no changes) for input {input_name}: {task_for_execution}",
|
||||
);
|
||||
remove_current_task = true;
|
||||
skip_execution = true;
|
||||
@@ -1019,19 +1022,21 @@ impl InputWorker {
|
||||
if let Some(cooldown_until_ts) = tmdb_state.cooldown_until_ts {
|
||||
if now_ts < cooldown_until_ts {
|
||||
if let Some(stripped_task) = Self::strip_tmdb_reasons(&task_for_execution) {
|
||||
debug_if_enabled!(
|
||||
"TMDB cooldown active for input {}: {} (cooldown_until={}), continuing with non-TMDB reasons",
|
||||
debug!(
|
||||
"[Task] TMDB cooldown active for input {}: {}, continuing with non-TMDB reasons (cooldown_until={}, remaining={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
cooldown_until_ts.saturating_sub(now_ts)
|
||||
);
|
||||
task_for_execution = stripped_task;
|
||||
} else {
|
||||
debug_if_enabled!(
|
||||
"Skipping TMDB-only task in cooldown for input {}: {} (cooldown_until={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Skipping task (TMDB-only in cooldown) for input {}: {} (cooldown_until={}, remaining={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
cooldown_until_ts.saturating_sub(now_ts)
|
||||
);
|
||||
self.scheduled_requeues.remove(¤t_key);
|
||||
remove_current_task = true;
|
||||
@@ -1053,11 +1058,12 @@ impl InputWorker {
|
||||
if active_retry_domain == RetryDomain::Probe {
|
||||
if let Some(cooldown_until_ts) = active_state.cooldown_until_ts {
|
||||
if now_ts < cooldown_until_ts {
|
||||
debug_if_enabled!(
|
||||
"Skipping probe task in cooldown for input {}: {} (cooldown_until={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Skipping task (probe cooldown) for input {}: {} (cooldown_until={}, remaining={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
cooldown_until_ts.saturating_sub(now_ts)
|
||||
);
|
||||
self.scheduled_requeues.remove(¤t_key);
|
||||
remove_current_task = true;
|
||||
@@ -1069,6 +1075,14 @@ impl InputWorker {
|
||||
}
|
||||
|
||||
if !skip_execution && active_state.next_allowed_at_ts > now_ts {
|
||||
debug!(
|
||||
"[Task] Deferring task (retry backoff) for input {}: {} (next_allowed_at={}, wait={}s, attempts={})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
active_state.next_allowed_at_ts,
|
||||
active_state.next_allowed_at_ts.saturating_sub(now_ts),
|
||||
active_state.attempts
|
||||
);
|
||||
schedule_requeue_at_ts = Some(active_state.next_allowed_at_ts);
|
||||
skip_execution = true;
|
||||
}
|
||||
@@ -1103,6 +1117,12 @@ impl InputWorker {
|
||||
}
|
||||
|
||||
if !skip_execution {
|
||||
debug!(
|
||||
"[Task] Executing task for input {}: {} (retry_domain={:?})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
Self::retry_domain_for_task(&task_for_execution)
|
||||
);
|
||||
let task_result = {
|
||||
let Some(_pause_guard) = self.wait_for_update_pause_window().await else {
|
||||
break;
|
||||
@@ -1129,7 +1149,7 @@ impl InputWorker {
|
||||
Self::should_trigger_playlist_update_for_task(&task_for_execution, task_outcome.task_changed);
|
||||
cycle_had_changes |= trigger_playlist_update;
|
||||
debug!(
|
||||
"Processed metadata task for input {input_name}: {task_for_execution} (changed={}, trigger_playlist_update={}, tmdb_pending={})",
|
||||
"[Metadata-Task] Task succeeded for input {input_name}: {task_for_execution} (changed={}, trigger_playlist_update={}, tmdb_pending={})",
|
||||
task_outcome.task_changed,
|
||||
trigger_playlist_update,
|
||||
task_outcome.tmdb_pending
|
||||
@@ -1151,11 +1171,12 @@ impl InputWorker {
|
||||
tmdb_state.cooldown_until_ts = Some(cooldown_until_ts);
|
||||
tmdb_state.last_error =
|
||||
Some("TMDB lookup completed without matching result".to_string());
|
||||
debug_if_enabled!(
|
||||
"TMDB resolve produced no match for input {}: {} (cooldown_until={})",
|
||||
debug!(
|
||||
"[Metadata-Task] TMDB resolve produced no match (existing retry state), entering cooldown for input {}: {} (cooldown_until={}, cooldown_duration={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
runtime_settings.tmdb_cooldown_secs
|
||||
);
|
||||
} else {
|
||||
state_bundle.clear_domain(RetryDomain::Tmdb);
|
||||
@@ -1183,11 +1204,12 @@ impl InputWorker {
|
||||
};
|
||||
self.retry_states.insert(current_key.clone(), state_bundle.clone());
|
||||
state_after_success = Some(state_bundle);
|
||||
debug_if_enabled!(
|
||||
"TMDB resolve produced no match for input {}: {} (cooldown_until={})",
|
||||
debug!(
|
||||
"[Metadata-Task] TMDB resolve produced no match (new retry state), entering cooldown for input {}: {} (cooldown_until={}, cooldown_duration={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
runtime_settings.tmdb_cooldown_secs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1207,6 +1229,10 @@ impl InputWorker {
|
||||
&& !task_outcome.tmdb_pending
|
||||
{
|
||||
let reasons = Self::task_reason(&task_for_execution);
|
||||
debug!(
|
||||
"[Task] Caching no-change result for input {}: {} (reasons={}, ttl={}s)",
|
||||
input_name, task_for_execution, reasons, runtime_settings.no_change_cache_ttl_secs
|
||||
);
|
||||
self.recently_completed_no_change.insert(current_key.clone(), (Instant::now(), reasons));
|
||||
} else {
|
||||
self.recently_completed_no_change.remove(¤t_key);
|
||||
@@ -1237,6 +1263,10 @@ impl InputWorker {
|
||||
}
|
||||
Err(e) => {
|
||||
if Self::is_permanent_not_found_error(&e.message) {
|
||||
debug!(
|
||||
"[Task] Task failed with permanent not-found for input {}: {} (error={})",
|
||||
input_name, task_for_execution, e.message
|
||||
);
|
||||
let retry_domain = Self::retry_domain_for_task(&task_for_execution);
|
||||
self.scheduled_requeues.remove(¤t_key);
|
||||
if retry_domain == RetryDomain::Probe {
|
||||
@@ -1253,11 +1283,12 @@ impl InputWorker {
|
||||
};
|
||||
metadata_persist_state = Some(Some(state_bundle_after_update));
|
||||
remove_current_task = true;
|
||||
debug_if_enabled!(
|
||||
"Probe task entered cooldown after permanent not-found for input {}: {} (cooldown_until={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Probe task entering cooldown after permanent not-found for input {}: {} (cooldown_until={}, cooldown_duration={}s)",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
cooldown_until_ts
|
||||
cooldown_until_ts,
|
||||
runtime_settings.probe_cooldown_secs
|
||||
);
|
||||
} else {
|
||||
self.resolve_exhausted.insert(current_key.clone(), now_ts);
|
||||
@@ -1277,10 +1308,11 @@ impl InputWorker {
|
||||
}
|
||||
metadata_persist_state = Some(state_after_clear);
|
||||
remove_current_task = true;
|
||||
debug_if_enabled!(
|
||||
"Resolve task marked exhausted after permanent not-found for input {}: {}",
|
||||
debug!(
|
||||
"[Metadata-Task] Resolve task marked exhausted after permanent not-found for input {}: {} (error={})",
|
||||
input_name,
|
||||
task_for_execution
|
||||
task_for_execution,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
} else if Self::is_transient_worker_error(&e.message) {
|
||||
@@ -1293,8 +1325,8 @@ impl InputWorker {
|
||||
Self::compute_retry_delay_secs(current_task.delay(), &runtime_settings);
|
||||
let retry_delay_i64 = i64::try_from(retry_delay_secs).unwrap_or(i64::MAX);
|
||||
schedule_requeue_at_ts = Some(now_ts.saturating_add(retry_delay_i64));
|
||||
debug_if_enabled!(
|
||||
"Transient task deferral for input {}: {} (retry_in={}s, err={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Task deferred (transient error) for input {}: {} (retry_in={}s, error={})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
retry_delay_secs,
|
||||
@@ -1350,12 +1382,14 @@ impl InputWorker {
|
||||
let cooldown_until = state_after_update
|
||||
.cooldown_until_ts
|
||||
.map_or_else(|| "none".to_string(), |ts| ts.to_string());
|
||||
debug_if_enabled!(
|
||||
"Probe task exhausted for input {}: {} (attempts={}, cooldown_until={})",
|
||||
debug!(
|
||||
"Metadata-[Task] Probe task exhausted (max attempts reached) for input {}: {} (attempts={}/{}, cooldown_until={}, error={})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
state_after_update.attempts,
|
||||
cooldown_until
|
||||
max_attempts,
|
||||
cooldown_until,
|
||||
e.message
|
||||
);
|
||||
} else {
|
||||
self.resolve_exhausted.insert(current_key.clone(), now_ts);
|
||||
@@ -1375,22 +1409,26 @@ impl InputWorker {
|
||||
}
|
||||
metadata_persist_state = Some(state_after_clear);
|
||||
remove_current_task = true;
|
||||
debug_if_enabled!(
|
||||
"Resolve task exhausted for input {}: {} (attempts={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Resolve task exhausted (max attempts reached) for input {}: {} (attempts={}/{}, error={})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
attempts
|
||||
attempts,
|
||||
max_attempts,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
} else {
|
||||
schedule_requeue_at_ts = Some(state_after_update.next_allowed_at_ts);
|
||||
metadata_persist_state = Some(Some(state_bundle_after_update));
|
||||
debug_if_enabled!(
|
||||
"Task failed for input {}, scheduling retry: {} (attempt={}, next_allowed_at={}, err={})",
|
||||
debug!(
|
||||
"[Metadata-Task] Task failed, scheduling retry for input {}: {} (attempt={}/{}, next_allowed_at={}, backoff={}s, error={})",
|
||||
input_name,
|
||||
task_for_execution,
|
||||
attempts,
|
||||
max_attempts,
|
||||
state_after_update.next_allowed_at_ts,
|
||||
state_after_update.next_allowed_at_ts.saturating_sub(now_ts),
|
||||
e.message
|
||||
);
|
||||
}
|
||||
@@ -1413,6 +1451,13 @@ impl InputWorker {
|
||||
}
|
||||
|
||||
if let Some(retry_at_ts) = schedule_requeue_at_ts {
|
||||
debug!(
|
||||
"[Task] Scheduling requeue for input {}: {:?} (retry_at_ts={}, in={}s)",
|
||||
input_name,
|
||||
current_key,
|
||||
retry_at_ts,
|
||||
retry_at_ts.saturating_sub(chrono::Utc::now().timestamp())
|
||||
);
|
||||
self.schedule_requeue_at(current_key.clone(), retry_at_ts);
|
||||
}
|
||||
|
||||
@@ -1510,8 +1555,8 @@ impl InputWorker {
|
||||
return;
|
||||
};
|
||||
|
||||
let working_dir = app_state.app_config.config.load().working_dir.clone();
|
||||
let Ok(storage_path) = get_input_storage_path(input_name, &working_dir).await else {
|
||||
let storage_dir = app_state.app_config.config.load().storage_dir.clone();
|
||||
let Ok(storage_path) = get_input_storage_path(input_name, &storage_dir).await else {
|
||||
warn!("Could not resolve storage path for metadata retry state on input {input_name}");
|
||||
self.metadata_retry_load_retry_at_ts =
|
||||
Some(now_ts.saturating_add(runtime_settings.metadata_retry_load_retry_delay_secs));
|
||||
@@ -2026,7 +2071,7 @@ impl InputWorker {
|
||||
|
||||
let Some(app_state) = app_state_weak.and_then(Weak::upgrade) else { return };
|
||||
let app_config = &app_state.app_config;
|
||||
let working_dir = &app_config.config.load().working_dir;
|
||||
let cfg = app_config.config.load();
|
||||
let vod_updates = batch_buffer.take_vod_updates();
|
||||
let series_updates = batch_buffer.take_series_updates();
|
||||
let live_updates = batch_buffer.take_live_updates();
|
||||
@@ -2035,7 +2080,7 @@ impl InputWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(storage_path) = get_input_storage_path(input_name, working_dir).await {
|
||||
if let Ok(storage_path) = get_input_storage_path(input_name, &cfg.storage_dir).await {
|
||||
if !vod_updates.is_empty() {
|
||||
let mut updates: Vec<(u32, VideoStreamProperties)> = Vec::with_capacity(vod_updates.len());
|
||||
for (id, props) in &vod_updates {
|
||||
@@ -2531,8 +2576,8 @@ impl InputWorker {
|
||||
}
|
||||
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = db_handles.entry(cluster) {
|
||||
let working_dir = &app_state.app_config.config.load().working_dir;
|
||||
if let Ok(storage_path) = get_input_storage_path(input_name, working_dir).await {
|
||||
let cfg = app_state.app_config.config.load();
|
||||
if let Ok(storage_path) = get_input_storage_path(input_name, &cfg.storage_dir).await {
|
||||
let file_path = xtream_get_file_path(&storage_path, cluster);
|
||||
if file_path.exists() {
|
||||
let lock = app_state.app_config.file_locks.read_lock(&file_path).await;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::model::ConfigProvider;
|
||||
use crate::utils::read_sources_file_from_path;
|
||||
use crate::repository::{
|
||||
load_persisted_dns_resolved, next_dns_writer_generation, prune_persisted_dns_resolved_to_runtime,
|
||||
queue_provider_resolved_snapshot, spawn_dns_resolved_writer, DnsResolvedWriteTx,
|
||||
};
|
||||
use log::{debug, warn};
|
||||
use shared::model::{DnsPrefer, OnResolveErrorPolicy, SourcesConfigDto};
|
||||
use shared::model::{DnsPrefer, OnResolveErrorPolicy};
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::net::lookup_host;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -105,129 +105,12 @@ async fn resolve_provider(provider: &Arc<ConfigProvider>) -> ProviderResolveStat
|
||||
stats
|
||||
}
|
||||
|
||||
fn serialize_sources_for_persist(sources: &SourcesConfigDto) -> io::Result<String> {
|
||||
let mut serialized = String::new();
|
||||
let options = serde_saphyr::SerializerOptions {
|
||||
prefer_block_scalars: false,
|
||||
..Default::default()
|
||||
};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut serialized, sources, options)
|
||||
.map_err(|err| io::Error::other(format!("Could not serialize source.yml: {err}")))?;
|
||||
Ok(serialized)
|
||||
}
|
||||
|
||||
async fn write_sources_file_force(path: &Path, sources: &SourcesConfigDto) -> io::Result<()> {
|
||||
let serialized = serialize_sources_for_persist(sources)?;
|
||||
let parent_dir = path.parent().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"Could not write source.yml '{}': missing parent directory",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let dest_file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("source.yml");
|
||||
let mut tmp_path = parent_dir.to_path_buf();
|
||||
tmp_path.push(format!(
|
||||
".{dest_file_name}.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
|
||||
fs::write(&tmp_path, serialized).await?;
|
||||
match fs::rename(&tmp_path, path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Try to rename again after removing destination (Windows often needs this)
|
||||
if let Ok(()) = fs::remove_file(path).await {
|
||||
if fs::rename(&tmp_path, path).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// Rename still failed after removing dest - fall through to clean up
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&tmp_path).await;
|
||||
Err(io::Error::other(format!(
|
||||
"Could not replace '{}' with '{}': {err}",
|
||||
path.display(),
|
||||
tmp_path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_provider_resolved_to_source_file(app_state: &Arc<AppState>, provider: &Arc<ConfigProvider>) {
|
||||
let source_file = {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
paths.sources_file_path.clone()
|
||||
};
|
||||
let source_path = PathBuf::from(&source_file);
|
||||
let _lock = app_state.app_config.file_locks.write_lock(&source_path).await;
|
||||
|
||||
let mut sources_dto = match read_sources_file_from_path(&source_path, false, false, None).await {
|
||||
Ok(dto) => dto,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Provider dns '{}' failed to read source.yml '{}': {err}",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_dtos) = sources_dto.provider.as_mut() else {
|
||||
debug!(
|
||||
"Provider dns '{}' source.yml '{}' has no provider section to persist resolved values",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(provider_dto) = provider_dtos.iter_mut().find(|dto| dto.name.as_ref() == provider.name.as_ref()) else {
|
||||
warn!(
|
||||
"Provider dns '{}' not found in source.yml '{}', cannot persist resolved values",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dns_dto) = provider_dto.dns.as_mut() else {
|
||||
debug!(
|
||||
"Provider dns '{}' has no dns section in source.yml '{}', skipping resolved persist",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let resolved_hosts = {
|
||||
let snapshot = provider.snapshot_resolved_ordered();
|
||||
dns_dto.resolved = (!snapshot.is_empty()).then_some(snapshot);
|
||||
dns_dto.resolved.as_ref().map_or(0, indexmap::IndexMap::len)
|
||||
};
|
||||
|
||||
match write_sources_file_force(&source_path, &sources_dto).await {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Provider dns '{}' persisted dns.resolved to '{}' (hosts={resolved_hosts})",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Provider dns '{}' failed to persist dns.resolved to '{}': {err}",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_provider_dns_task(app_state: Arc<AppState>, provider_name: Arc<str>, cancel: CancellationToken) {
|
||||
fn spawn_provider_dns_task(
|
||||
app_state: Arc<AppState>,
|
||||
provider_name: Arc<str>,
|
||||
cancel: CancellationToken,
|
||||
writer_tx: DnsResolvedWriteTx,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut refresh_secs = 300_u64;
|
||||
{
|
||||
@@ -260,7 +143,7 @@ fn spawn_provider_dns_task(app_state: Arc<AppState>, provider_name: Arc<str>, ca
|
||||
};
|
||||
refresh_secs = provider.get_dns_config().map_or(300, |dns| dns.refresh_secs.max(10));
|
||||
let stats = resolve_provider(&provider).await;
|
||||
persist_provider_resolved_to_source_file(&app_state, &provider).await;
|
||||
queue_provider_resolved_snapshot(&writer_tx, &provider).await;
|
||||
let cache_hosts = provider.snapshot_resolved().len();
|
||||
debug!(
|
||||
"Provider dns tick '{}' finished: total_hosts={} resolved={} overridden={} empty={} failed={} cache_hosts={} elapsed_ms={}",
|
||||
@@ -282,6 +165,7 @@ fn spawn_provider_dns_task(app_state: Arc<AppState>, provider_name: Arc<str>, ca
|
||||
}
|
||||
|
||||
pub fn exec_provider_dns(app_state: &Arc<AppState>, cancel: &CancellationToken) {
|
||||
let generation = next_dns_writer_generation();
|
||||
let sources = app_state.app_config.sources.load();
|
||||
let provider_names: Vec<_> = sources
|
||||
.provider
|
||||
@@ -292,13 +176,33 @@ pub fn exec_provider_dns(app_state: &Arc<AppState>, cancel: &CancellationToken)
|
||||
drop(sources);
|
||||
|
||||
if provider_names.is_empty() {
|
||||
let app_for_prune = Arc::clone(app_state);
|
||||
tokio::spawn(async move {
|
||||
prune_persisted_dns_resolved_to_runtime(&app_for_prune).await;
|
||||
});
|
||||
debug!("Provider dns manager: no enabled providers found");
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Provider dns manager: starting {} provider task(s)", provider_names.len());
|
||||
let app_for_start = Arc::clone(app_state);
|
||||
let cancel_for_start = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
// Seed runtime caches first, then start writer/tasks to avoid startup races.
|
||||
load_persisted_dns_resolved(&app_for_start).await;
|
||||
if cancel_for_start.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
for provider_name in provider_names {
|
||||
spawn_provider_dns_task(Arc::clone(app_state), provider_name, cancel.clone());
|
||||
}
|
||||
let writer_tx = spawn_dns_resolved_writer(Arc::clone(&app_for_start), cancel_for_start.clone(), generation);
|
||||
debug!("Provider dns manager: starting {} provider task(s)", provider_names.len());
|
||||
|
||||
for provider_name in provider_names {
|
||||
spawn_provider_dns_task(
|
||||
Arc::clone(&app_for_start),
|
||||
provider_name,
|
||||
cancel_for_start.clone(),
|
||||
writer_tx.clone(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ use reqwest::{
|
||||
};
|
||||
use shared::{
|
||||
create_bitset,
|
||||
model::{PlaylistItemType, DEFAULT_USER_AGENT},
|
||||
utils::{filter_request_header, sanitize_sensitive_info},
|
||||
};
|
||||
use std::{
|
||||
@@ -38,6 +37,8 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use url::Url;
|
||||
use shared::model::PlaylistItemType;
|
||||
use shared::utils::DEFAULT_USER_AGENT;
|
||||
|
||||
const RETRY_SECONDS: u64 = 5;
|
||||
const ERR_MAX_RETRY_COUNT: u32 = 5;
|
||||
|
||||
Reference in New Issue
Block a user