From 3be0fa347fe042015a6c81be05abde1999695c7c Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:30:19 +0100 Subject: [PATCH] MetadatResolve hardening rerties after errors (#611) * MetadatResolve hardening rerties after errors --- CHANGELOG.md | 8 +- README.md | 101 +- backend/src/api/config_file.rs | 2 +- .../src/api/model/active_provider_manager.rs | 44 +- backend/src/api/model/active_user_manager.rs | 24 +- .../src/api/model/metadata_update_manager.rs | 1605 +++++++++++++---- .../src/api/model/provider_lineup_manager.rs | 71 +- backend/src/model/config/app.rs | 11 +- backend/src/model/config/base.rs | 13 +- backend/src/model/config/metadata_update.rs | 253 +++ backend/src/model/config/mod.rs | 2 + backend/src/model/config/video_download.rs | 20 +- backend/src/processing/processor/mod.rs | 171 ++ .../src/processing/processor/stream_probe.rs | 201 ++- backend/src/processing/processor/xtream.rs | 11 +- .../src/processing/processor/xtream_series.rs | 172 +- .../src/processing/processor/xtream_vod.rs | 82 +- backend/src/utils/file/config_reader.rs | 2 +- frontend/public/assets/i18n/en.json | 47 +- frontend/public/assets/icons.json | 4 + .../src/app/components/config/config_page.rs | 16 +- .../app/components/config/config_update.rs | 34 +- .../src/app/components/config/config_view.rs | 15 +- .../config/metadata_update_config_view.rs | 223 +++ frontend/src/app/components/config/mod.rs | 2 + .../components/config/video_config_view.rs | 14 +- .../src/app/components/field_explanation.rs | 21 +- .../src/app/components/setup/setup_helpers.rs | 40 +- .../src/app/components/setup/step_config.rs | 1 + shared/src/model/config/base.rs | 54 +- shared/src/model/config/input.rs | 31 +- shared/src/model/config/metadata_update.rs | 434 +++++ shared/src/model/config/mod.rs | 2 + shared/src/model/config/video_download.rs | 6 - shared/src/utils/default_utils.rs | 63 + shared/src/utils/time_utils.rs | 30 + 36 files changed, 3164 insertions(+), 666 deletions(-) create mode 100644 backend/src/model/config/metadata_update.rs create mode 100644 frontend/src/app/components/config/metadata_update_config_view.rs create mode 100644 shared/src/model/config/metadata_update.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8f38566..c0445b67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,11 +103,16 @@ active URL of the specified provider. - **Setup Validation Improvement**: Setup mode validates source configuration against the global template collection and persists template definitions separately. - Added `-T, --template` to override `template_path` on startup. +- **Metadata Update Runtime Config**: Metadata worker intervals, retry/backoff limits, queue sizing, and probe cooldowns are now configurable through + a dedicated `metadata_update` config block. ## ⚙️ New Settings - **config.yml**: - - Added `video.ffprobe_enabled` (default: false) and `video.ffprobe_timeout`. + - Added `metadata_update` (optional) with queue/retry/backoff/cooldown/worker-timeout fields (duration syntax: `60`, `10m`, `1h`, `7d`). + - Added `metadata_update.ffprobe_enabled` (default: false), `metadata_update.ffprobe_timeout`, and ffprobe probe/analyze size settings. + - `metadata_update.ffprobe_analyze_duration` and `metadata_update.ffprobe_live_analyze_duration` now require explicit unit suffixes (`s|m|h|d`). + - FFprobe settings are now configured under `metadata_update` (not under `video` anymore). - Added `template_path` (optional): path to a template file (`template.yml`) or directory (`template.d` style). - **source.yml (input options)**: - Added `resolve_tmdb`: Triggers TMDB lookup if ID is missing. @@ -164,6 +169,7 @@ active URL of the specified provider. - **Mapper View**: Updated to support new `for_each` syntax. - Added **Stream Buffer** settings (Enabled, Size) to Reverse Proxy configuration UI. - Added **TMDB** settings (Rate Limit, Cache Duration, Language) and **Metadata Formats** (NFO support) to Library configuration UI. +- Introduced the **Metadata Update** config tab, with FFprobe controls relocated from **Video** into it. ## 🚀 Performance & Stability diff --git a/README.md b/README.md index 1282bb82d..f924ee4a5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Top level entries in the config files are: - `process_parallel` _optional_ - `messaging` _optional_ - `video` _optional_ +- `metadata_update` _optional_ - `schedules` _optional_ - `backup_dir` _optional_ - `mapping_path` _optional_, file or directory path for mappings (`mapping.yml` by default) @@ -359,14 +360,10 @@ It has 2 entries `extensions` and `download`. Example: `.*(?P[Ss]\\d{1,2}(.*?)[Ee]\\d{1,2}).*` - `web_search` is _optional_ - `download.episode_pattern` to remove episode suffix from titles. -- `ffprobe_enabled`: _optional_ (default `false`). Enable or disable FFprobe analysis for streams globally. -- `ffprobe_timeout`: _optional_ (default `60`). Timeout in seconds for FFprobe analysis. ```yaml video: web_search: 'https://www.imdb.com/search/title/?title={}' - ffprobe_enabled: true - ffprobe_timeout: 60 extensions: - mkv - mp4 @@ -380,12 +377,104 @@ video: episode_pattern: '.*(?P[Ss]\\d{1,2}(.*?)[Ee]\\d{1,2}).*' ``` -### 1.5a Video Analysis & Metadata Fallback +### 1.5a `metadata_update` + +`metadata_update` is optional and controls retry/backoff behavior for the metadata worker queue and global FFprobe behavior. + +Duration fields use the same format as `cache_duration`: plain seconds (`60`) or suffixed values (`10m`, `1h`, `7d`). + +```yaml +metadata_update: + queue_log_interval: 30s + progress_log_interval: 15s + max_resolve_retry_backoff: 1h + resolve_min_retry_base: 5s + max_attempts_resolve: 3 + max_attempts_probe: 3 + resolve_exhaustion_reset_gap: 1h + probe_cooldown: 7d + retry_delay: 2s + probe_retry_load_retry_delay: 1m + worker_idle_timeout: 1m + probe_retry_backoff_step_1: 10m + probe_retry_backoff_step_2: 30m + probe_retry_backoff_step_3: 1h + backoff_jitter_percent: 20 + max_queue_size: 100000 + ffprobe_enabled: true + ffprobe_timeout: 60 + ffprobe_analyze_duration: 10s + ffprobe_probe_size: 10MB + ffprobe_live_analyze_duration: 5s + ffprobe_live_probe_size: 5MB +``` + +**Field Reference (`metadata_update`):** + +- `queue_log_interval` (default `30s`): Interval for queue size/status log output of the metadata worker. +- `progress_log_interval` (default `15s`): Interval for progress log output while metadata tasks are being processed. +- `max_resolve_retry_backoff` (default `1h`): Upper limit for resolve retry backoff delay. +- `resolve_min_retry_base` (default `5s`): Minimum base delay for resolve retries before exponential backoff is applied. +- `max_attempts_resolve` (default `3`): Maximum resolve attempts before a resolve task is treated as exhausted for the current cycle. +- `max_attempts_probe` (default `3`): Maximum probe attempts before probe cooldown is activated. +- `resolve_exhaustion_reset_gap` (default `1h`): Time gap after a completed update cycle after which exhausted resolve states are reset. +- `probe_cooldown` (default `7d`): Cooldown duration after probe attempts are exhausted; probe retries are skipped during this period. +- `retry_delay` (default `2s`): Minimum retry delay for transient worker errors (for example temporary connection/resource issues). +- `probe_retry_load_retry_delay` (default `1m`): Delay before re-attempting to load persisted probe retry state after a load failure. +- `worker_idle_timeout` (default `1m`): Idle timeout for metadata worker shutdown when there is no immediate work. +- `probe_retry_backoff_step_1` (default `10m`): Probe backoff delay for attempt 1. +- `probe_retry_backoff_step_2` (default `30m`): Probe backoff delay for attempt 2. +- `probe_retry_backoff_step_3` (default `1h`): Probe backoff delay for attempt 3 and higher. +- `backoff_jitter_percent` (default `20`): Random jitter percentage applied to resolve/probe retry backoff to avoid synchronized retries. +- `max_queue_size` (default `100000`): Maximum pending metadata tasks per input before new tasks are rejected. +- `ffprobe_enabled` (default `false`): Globally enables/disables FFprobe-based stream analysis. +- `ffprobe_timeout` (default `60`): FFprobe process timeout in seconds. +- `ffprobe_analyze_duration` (default `10s`): FFprobe `-analyzeduration` + value for VOD/Series probing. Requires explicit unit suffix (`s`, `m`, + `h`, `d`). +- `ffprobe_probe_size` (default `10MB`): FFprobe `-probesize` value for VOD/Series probing. +- `ffprobe_live_analyze_duration` (default `5s`): FFprobe `-analyzeduration` + value for Live probing. Requires explicit unit suffix (`s`, `m`, `h`, + `d`). +- `ffprobe_live_probe_size` (default `5MB`): FFprobe `-probesize` value for Live probing. + +Duration fields support `s`, `m`, `h`, `d` (for example `30s`, `10m`, `1h`, `7d`) or plain seconds. +Exception: `ffprobe_analyze_duration` and `ffprobe_live_analyze_duration` require explicit unit suffix. +Size fields support `B`, `KB`, `MB`, `GB`, `TB` (for example `512KB`, `10MB`) or plain bytes. + +**Why are there 4 FFprobe fields?** + +- `ffprobe_analyze_duration` + `ffprobe_probe_size` are the default pair for non-live probes (VOD/Series and generic non-live stream probes). +- `ffprobe_live_analyze_duration` + `ffprobe_live_probe_size` are the live-specific pair for all live probes. +- This split is intentional because live probing usually needs lower values + (less provider load / lower latency), while VOD/Series can use higher values + for better metadata extraction quality. + +**Glossary (`metadata_update`):** + +- `Resolve task`: A metadata job that fetches or enriches item metadata (for example VOD/Series details, TMDB ID, dates). +- `Probe task`: A technical analysis job that inspects stream properties with FFprobe (codec, resolution, audio tracks). +- `Retry`: Re-attempt of a failed task. +- `Attempt`: One execution try of a task. If it fails, the attempt counter increases. +- `Backoff delay`: Waiting time before the next retry after a failure. +- `Exponential backoff`: Backoff strategy where retry delay grows with each + failed attempt (for example 5s, 10s, 20s, ...), up to a configured + maximum. +- `Jitter`: Small random variation added to backoff delay to avoid many tasks retrying at exactly the same moment. +- `Transient error`: Temporary failure (for example timeout, temporary no connection) that is likely to succeed on a later retry. +- `Exhausted`: State when max attempts are reached for a task type. +- `Cooldown`: Skip period after exhaustion (used for probe tasks) where retries are paused until the cooldown expires. +- `Update cycle`: One full metadata processing run for an input, from first queued item until queue idle/completion. +- `Resolve exhaustion reset gap`: Time window after which exhausted resolve states are cleared for the next meaningful update cycle. +- `Pending queue`: In-memory list of metadata tasks waiting to be processed by the worker. +- `Worker idle timeout`: Time without immediate work after which a worker may stop and release resources. + +### 1.5b Video Analysis & Metadata Fallback Tuliprox can automatically analyze streams using `ffprobe` to determine resolution, codecs, and audio channels. It also fetches missing metadata (TMDB ID, Release Date) if the provider does not supply them. -This feature is enabled globally in `video` configuration but must be activated per input options. +This feature is enabled globally in `metadata_update` configuration but must be activated per input options. **Input Config (`source.yml`):** diff --git a/backend/src/api/config_file.rs b/backend/src/api/config_file.rs index 91f3f04fe..d5f694a28 100644 --- a/backend/src/api/config_file.rs +++ b/backend/src/api/config_file.rs @@ -243,7 +243,7 @@ impl ConfigFile { let template_changed = current_template_path != next_template_path; let mut config: Config = Config::from(config_dto); - config.prepare(paths.config_path.as_str()).await?; + config.prepare(paths.config_path.as_str())?; // Compute effective runtime paths for the NEW config before apply. // This ensures prepare-phase reads/validates against the same files that will be active after apply. diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 23d0dbcee..0ce12e338 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -70,6 +70,7 @@ struct ActiveConnectionInfo { struct SharedConnections { by_key: HashMap, key_by_addr: HashMap, + shared_by_allocation_id: HashMap, } #[derive(Debug, Clone, Default)] @@ -303,6 +304,10 @@ impl ActiveProviderManager { if !still_single { None } else if let Some(shared) = connections.shared.by_key.remove(&key) { + connections + .shared + .shared_by_allocation_id + .remove(&shared.allocation_id); for shared_addr in &shared.connections { connections.shared.key_by_addr.remove(shared_addr); } @@ -329,6 +334,12 @@ impl ActiveProviderManager { let mut removed_provider_name = None; let mut remove_addr_entry = false; if let Some(per_addr) = connections.single.get_mut(&addr) { + if let Some(info) = per_addr.get(&alloc_id) { + // Revalidate victim selection under write lock. + if info.priority != v_prio || info.created_at != victim_created_at { + return None; + } + } if let Some(info) = per_addr.remove(&alloc_id) { removed_provider_name = info.allocation.get_provider_name(); remove_addr_entry = per_addr.is_empty(); @@ -511,6 +522,15 @@ impl ActiveProviderManager { if shared.connections.is_empty() { // If this was the last user of the shared allocation: connections.shared.by_key.remove(&key); + connections + .shared + .shared_by_allocation_id + .remove(&shared.allocation_id); + if let Some(name) = shared.allocation.get_provider_name() { + if let Some(list) = connections.by_provider.get_mut(&name) { + list.retain(|(_, i)| *i != shared.allocation_id); + } + } Some(shared.allocation) } else { // Update the entry back with the remaining connections @@ -556,21 +576,21 @@ impl ActiveProviderManager { if released.is_none() { // Try removing from Shared - let mut remove_key: Option = None; - // TODO O(n) over all keys, maybe better approach ist to use a Hashmap shared_by_allocation_id: HashMap - for (key, shared) in &connections.shared.by_key { - if shared.allocation_id == handle.allocation_id { - remove_key = Some(key.clone()); - break; - } - } - - if let Some(key) = remove_key { + if let Some(key) = connections + .shared + .shared_by_allocation_id + .remove(&handle.allocation_id) + { if let Some(shared) = connections.shared.by_key.remove(&key) { released = Some(shared.allocation); for addr in shared.connections { connections.shared.key_by_addr.remove(&addr); } + if let Some(name) = released.as_ref().and_then(ProviderAllocation::get_provider_name) { + if let Some(list) = connections.by_provider.get_mut(&name) { + list.retain(|(_, i)| *i != handle.allocation_id); + } + } } } } @@ -641,6 +661,10 @@ impl ActiveProviderManager { }, ); connections.shared.key_by_addr.insert(*addr, key.to_string()); + connections + .shared + .shared_by_allocation_id + .insert(handle.0.allocation_id, key.to_string()); } extras }; diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index c04cc2e8a..e59897e4a 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -123,9 +123,9 @@ impl ActiveUserManager { if is_log_user_enabled { let last_user_count = self.last_logged_user_count.load(Ordering::Relaxed); let last_connection_count = self.last_logged_user_connection_count.load(Ordering::Relaxed); - if last_user_count != user_count && last_connection_count != user_connection_count { - self.last_logged_user_count.store(last_user_count, Ordering::Relaxed); - self.last_logged_user_connection_count.store(last_connection_count, Ordering::Relaxed); + if last_user_count != user_count || last_connection_count != user_connection_count { + self.last_logged_user_count.store(user_count, Ordering::Relaxed); + self.last_logged_user_connection_count.store(user_connection_count, Ordering::Relaxed); info!("Active Users: {user_count}, Active User Connections: {user_connection_count}"); } } @@ -325,7 +325,6 @@ impl ActiveUserManager { session_token, ); - user_connections.key_by_addr.insert(fingerprint.addr, username.to_string()); let total_active = user_connections.key_by_addr.len(); if let Some(connection_data) = user_connections.by_key.get_mut(username) { @@ -529,15 +528,22 @@ impl ActiveUserManager { let ts = gc_ts.load(Ordering::Acquire); let now = current_time_secs(); - if now - ts > USER_GC_TTL { + if now.saturating_sub(ts) > USER_GC_TTL + && gc_ts + .compare_exchange(ts, now, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { if let Ok(mut user_connections) = self.connections.try_write() { user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); - user_connections.by_key.retain(|_k, v| now - v.ts < USER_CON_TTL && v.connections > 0); + user_connections + .by_key + .retain(|_k, v| now.saturating_sub(v.ts) < USER_CON_TTL && v.connections > 0); for connection_data in user_connections.by_key.values_mut() { - connection_data.sessions.retain(|s| now - s.ts < USER_CON_TTL); + connection_data.sessions.retain(|s| now.saturating_sub(s.ts) < USER_CON_TTL); } - - gc_ts.store(now, Ordering::Release); + } else { + // Lock contention: release the GC claim so a subsequent caller can retry immediately. + let _ = gc_ts.compare_exchange(now, ts, Ordering::AcqRel, Ordering::Relaxed); } } } diff --git a/backend/src/api/model/metadata_update_manager.rs b/backend/src/api/model/metadata_update_manager.rs index fb8f901b2..a01ba7ec0 100644 --- a/backend/src/api/model/metadata_update_manager.rs +++ b/backend/src/api/model/metadata_update_manager.rs @@ -2,12 +2,14 @@ use crate::api::model::ProviderHandle; use crate::api::model::{AppState, EventMessage}; use crate::processing::processor::{ update_generic_stream_metadata, update_live_stream_metadata, update_series_metadata, update_vod_metadata, + GenericProbeOutcome, SeriesProbeSettings, }; use crate::utils::debug_if_enabled; use dashmap::mapref::entry::Entry; use dashmap::DashMap; use log::{debug, error, info, warn}; use parking_lot::Mutex as ParkingMutex; +use serde::{Deserialize, Serialize}; use shared::create_bitset; use shared::error::TuliproxError; use shared::model::{ @@ -15,16 +17,16 @@ use shared::model::{ XtreamCluster, XtreamPlaylistItem, }; use shared::utils::generate_playlist_uuid; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; -use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio::sync::{mpsc, RwLock}; use tokio_util::sync::CancellationToken; use crate::api::model::BatchResultCollector; use crate::repository::{ get_input_storage_path, get_target_id_mapping_file, write_playlist_batch_item_upsert, xtream_get_file_path, - BPlusTreeQuery, + BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, }; use crate::repository::{ persist_input_live_info_batch, persist_input_series_info_batch, persist_input_vod_info_batch, TargetIdMapping, @@ -32,18 +34,82 @@ use crate::repository::{ use crate::utils::FileReadGuard; use std::cmp::min; use std::collections::{HashMap, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; +use crate::model::MetadataUpdateConfig; -const QUEUE_LOG_INTERVAL: Duration = Duration::from_secs(30); -const PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(15); -const MAX_RETRY_BACKOFF_SECS: u64 = 60; -const UPDATE_IN_PROGRESS_RETRY_SECS: u64 = 2; -const MIN_PROBE_RETRY_BASE_SECS: u16 = 5; - -const MAX_QUEUE_SIZE: usize = 100_000; +const PROBE_RETRY_STATE_FILE: &str = "probe_retry_state.db"; const TASK_ERR_NO_CONNECTION: &str = "No connection available"; const TASK_ERR_PREEMPTED: &str = "Task preempted"; const TASK_ERR_UPDATE_IN_PROGRESS: &str = "Playlist update in progress"; +#[derive(Debug, Clone)] +struct MetadataUpdateRuntimeSettings { + queue_log_interval: Duration, + progress_log_interval: Duration, + max_resolve_retry_backoff_secs: u64, + resolve_min_retry_base_secs: u64, + max_attempts_resolve: u8, + max_attempts_probe: u8, + resolve_exhaustion_reset_gap_secs: i64, + probe_cooldown_secs: i64, + retry_delay_secs: u64, + probe_retry_load_retry_delay_secs: i64, + worker_idle_timeout_secs: u64, + max_queue_size: usize, + probe_retry_backoff_step_1_secs: u64, + probe_retry_backoff_step_2_secs: u64, + probe_retry_backoff_step_3_secs: u64, + backoff_jitter_percent: u8, +} + +impl Default for MetadataUpdateRuntimeSettings { + fn default() -> Self { + let defaults = MetadataUpdateConfig::default(); + Self::from_metadata_update(&defaults) + } +} + +impl MetadataUpdateRuntimeSettings { + fn from_app_state(app_state_weak: Option<&Weak>) -> Self { + let metadata_update = app_state_weak.and_then(Weak::upgrade).map_or_else( + MetadataUpdateConfig::default, + |app_state| { + app_state + .app_config + .config + .load() + .metadata_update + .as_ref() + .map_or_else(MetadataUpdateConfig::default, Clone::clone) + }, + ); + Self::from_metadata_update(&metadata_update) + } + + fn from_metadata_update(cfg: &MetadataUpdateConfig) -> Self { + let to_i64 = |v: u64| i64::try_from(v.max(1)).unwrap_or(i64::MAX); + Self { + queue_log_interval: Duration::from_secs(cfg.queue_log_interval_secs.max(1)), + progress_log_interval: Duration::from_secs(cfg.progress_log_interval_secs.max(1)), + max_resolve_retry_backoff_secs: cfg.max_resolve_retry_backoff_secs.max(1), + resolve_min_retry_base_secs: cfg.resolve_min_retry_base_secs.max(1), + max_attempts_resolve: cfg.max_attempts_resolve.max(1), + max_attempts_probe: cfg.max_attempts_probe.max(1), + resolve_exhaustion_reset_gap_secs: to_i64(cfg.resolve_exhaustion_reset_gap_secs), + probe_cooldown_secs: to_i64(cfg.probe_cooldown_secs), + retry_delay_secs: cfg.retry_delay_secs.max(1), + probe_retry_load_retry_delay_secs: to_i64(cfg.probe_retry_load_retry_delay_secs), + worker_idle_timeout_secs: cfg.worker_idle_timeout_secs.max(1), + max_queue_size: cfg.max_queue_size.max(1), + probe_retry_backoff_step_1_secs: cfg.probe_retry_backoff_step_1_secs.max(1), + probe_retry_backoff_step_2_secs: cfg.probe_retry_backoff_step_2_secs.max(1), + probe_retry_backoff_step_3_secs: cfg.probe_retry_backoff_step_3_secs.max(1), + backoff_jitter_percent: cfg.backoff_jitter_percent.min(95), + } + } +} + create_bitset!(u8, ResolveReason, Info, Tmdb, Date, Probe, MissingDetails); /// `PlaylistItemIdType` ID can be either a String (M3U) or u32 (Xtream/TargetDB) @@ -176,22 +242,163 @@ impl TaskKey { } } +#[derive(Debug, Clone)] +struct RetryState { + attempts: u8, + next_allowed_at_ts: i64, + cooldown_until_ts: Option, + last_error: Option, +} + +impl RetryState { + fn new() -> Self { + Self { + attempts: 0, + next_allowed_at_ts: 0, + cooldown_until_ts: None, + last_error: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +enum ProbeRetryDbKey { + LiveId(u32), + LiveText(String), + Stream { scope: String, id: String }, +} + +impl ProbeRetryDbKey { + fn from_task_key(task_key: &TaskKey) -> Option { + match task_key { + TaskKey::Live(id) => Some(Self::LiveId(*id)), + TaskKey::LiveStr(id) => Some(Self::LiveText(id.as_ref().to_owned())), + TaskKey::Stream { scope, id } => Some(Self::Stream { + scope: scope.as_ref().to_owned(), + id: id.as_ref().to_owned(), + }), + _ => None, + } + } + + fn into_task_key(self) -> TaskKey { + match self { + Self::LiveId(id) => TaskKey::Live(id), + Self::LiveText(id) => TaskKey::LiveStr(Arc::from(id)), + Self::Stream { scope, id } => TaskKey::Stream { + scope: Arc::from(scope), + id: Arc::from(id), + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProbeRetryDbValue { + attempts: u8, + next_allowed_at_ts: i64, + cooldown_until_ts: Option, + last_error: Option, + updated_at_ts: i64, +} + +impl ProbeRetryDbValue { + fn from_retry_state(state: &RetryState, updated_at_ts: i64) -> Self { + Self { + attempts: state.attempts, + next_allowed_at_ts: state.next_allowed_at_ts, + cooldown_until_ts: state.cooldown_until_ts, + last_error: state.last_error.clone(), + updated_at_ts, + } + } + + fn cleared(updated_at_ts: i64) -> Self { + Self { + attempts: 0, + next_allowed_at_ts: 0, + cooldown_until_ts: None, + last_error: None, + updated_at_ts, + } + } + + fn into_retry_state(self) -> Option { + if self.attempts == 0 && self.next_allowed_at_ts <= 0 && self.cooldown_until_ts.is_none() { + return None; + } + Some(RetryState { + attempts: self.attempts, + next_allowed_at_ts: self.next_allowed_at_ts, + cooldown_until_ts: self.cooldown_until_ts, + last_error: self.last_error, + }) + } +} + +fn ensure_probe_retry_db(path: &Path) -> io::Result<()> { + if path.exists() { + return Ok(()); + } + let mut tree = BPlusTree::::new(); + tree.store(path).map(|_| ()) +} + +fn load_probe_retry_states_from_disk(path: &Path) -> io::Result> { + ensure_probe_retry_db(path)?; + + let mut result = HashMap::new(); + let mut query = BPlusTreeQuery::::try_new(path)?; + for (key, value) in query.iter() { + if let Some(state) = value.into_retry_state() { + result.insert(key.into_task_key(), state); + } + } + + Ok(result) +} + +fn persist_probe_retry_state_to_disk(path: &Path, task_key: &TaskKey, state: Option<&RetryState>) -> io::Result<()> { + let Some(db_key) = ProbeRetryDbKey::from_task_key(task_key) else { + return Ok(()); + }; + + ensure_probe_retry_db(path)?; + + let now_ts = chrono::Utc::now().timestamp(); + let value = match state { + Some(s) => ProbeRetryDbValue::from_retry_state(s, now_ts), + None => ProbeRetryDbValue::cleared(now_ts), + }; + + let mut update = BPlusTreeUpdate::::try_new_with_backoff(path)?; + update + .upsert_batch(&[(&db_key, &value)]) + .map_err(|e| io::Error::other(format!("persist probe retry state failed: {e}")))?; + Ok(()) +} + /// Per-input worker context. Each input has its own worker /// that processes tasks sequentially with rate limiting. #[derive(Clone)] struct InputWorkerContext { + worker_id: u64, sender: mpsc::Sender, pending_tasks: Arc>, + pending_task_count: Arc, } struct PendingTask { - task: Mutex, + task: ParkingMutex, generation: AtomicU64, } impl PendingTask { fn new(task: UpdateTask) -> Self { - Self { task: Mutex::new(task), generation: AtomicU64::new(0) } + Self { + task: ParkingMutex::new(task), + generation: AtomicU64::new(0), + } } } @@ -202,7 +409,7 @@ impl PendingTask { /// - Tasks for the SAME input are processed sequentially with rate limiting (defined per task) /// - Tasks for DIFFERENT inputs run in parallel /// - Workers are spawned on-demand when first task arrives for an input -/// - Workers terminate when their channel is empty and no more senders exist +/// - Workers terminate after an idle timeout and are respawned on demand pub struct MetadataUpdateManager { /// Per-input worker senders. Worker is spawned when entry is created. workers: DashMap, InputWorkerContext>, @@ -215,6 +422,8 @@ pub struct MetadataUpdateManager { update_pause_gate: Arc>, /// Global cancellation token for shutdown cancel_token: CancellationToken, + /// Monotonic worker generation id used to avoid removing a newly spawned worker context. + next_worker_id: AtomicU64, } impl Default for MetadataUpdateManager { @@ -223,6 +432,13 @@ impl Default for MetadataUpdateManager { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubmitTaskResult { + QueuedOrMerged, + QueueFull, + ChannelClosed, +} + impl MetadataUpdateManager { pub fn new(cancel_token: CancellationToken) -> Self { Self { @@ -230,6 +446,7 @@ impl MetadataUpdateManager { app_state: tokio::sync::Mutex::new(None), update_pause_gate: Arc::new(RwLock::new(())), cancel_token, + next_worker_id: AtomicU64::new(1), } } @@ -267,105 +484,234 @@ impl MetadataUpdateManager { let guard = self.app_state.lock().await; guard.clone() }; + let runtime_settings = MetadataUpdateRuntimeSettings::from_app_state(app_state_weak.as_ref()); + let max_queue_size = runtime_settings.max_queue_size; - // Atomically ensure there is exactly one worker context per input. - let mut worker_to_spawn: Option = None; - let ctx = match self.workers.entry(input_name.clone()) { - Entry::Occupied(entry) => entry.get().clone(), - Entry::Vacant(entry) => { - let (tx, rx) = mpsc::channel::(256); - let pending_tasks = Arc::new(DashMap::new()); + let task_to_queue = task; + for attempt in 0..2 { + // Atomically ensure there is exactly one worker context per input. + let mut worker_to_spawn: Option<(u64, InputWorker)> = None; + let ctx = match self.workers.entry(input_name.clone()) { + Entry::Occupied(entry) => entry.get().clone(), + Entry::Vacant(entry) => { + let (tx, rx) = mpsc::channel::(max_queue_size); + let pending_tasks = Arc::new(DashMap::new()); + let pending_task_count = Arc::new(AtomicUsize::new(0)); + let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed); - let ctx = InputWorkerContext { sender: tx.clone(), pending_tasks: pending_tasks.clone() }; - entry.insert(ctx.clone()); + let ctx = InputWorkerContext { + worker_id, + sender: tx.clone(), + pending_tasks: pending_tasks.clone(), + pending_task_count: pending_task_count.clone(), + }; + entry.insert(ctx.clone()); - worker_to_spawn = Some(InputWorker { - input_name: input_name.clone(), - sender: tx, - receiver: rx, - pending_tasks, - app_state_weak, - update_pause_gate: Arc::clone(&self.update_pause_gate), - cancel_token: self.cancel_token.clone(), - batch_buffer: BatchResultCollector::new(), - db_handles: HashMap::new(), - failed_clusters: HashSet::new(), + worker_to_spawn = Some(( + worker_id, + InputWorker { + input_name: input_name.clone(), + sender: tx, + receiver: rx, + pending_tasks, + pending_task_count, + app_state_weak: app_state_weak.clone(), + update_pause_gate: Arc::clone(&self.update_pause_gate), + cancel_token: self.cancel_token.clone(), + batch_buffer: BatchResultCollector::new(), + db_handles: HashMap::new(), + failed_clusters: HashSet::new(), + retry_states: HashMap::new(), + resolve_exhausted: HashMap::new(), + last_cycle_completed_at_ts: None, + probe_retry_state_path: None, + probe_retry_loaded: false, + probe_retry_load_retry_at_ts: None, + scheduled_requeues: Arc::new(DashMap::new()), + }, + )); + + ctx + } + }; + + if let Some((worker_id, worker)) = worker_to_spawn { + let workers_ref = self.workers.clone(); + let input_name_for_cleanup = input_name.clone(); + tokio::spawn(async move { + worker.run().await; + + // Cleanup only if this exact worker context is still active. + if let Entry::Occupied(entry) = workers_ref.entry(input_name_for_cleanup.clone()) { + if entry.get().worker_id == worker_id { + entry.remove(); + } + } }); - - ctx } - }; - if let Some(worker) = worker_to_spawn { - let workers_ref = self.workers.clone(); - let input_name_for_cleanup = input_name.clone(); - tokio::spawn(async move { - worker.run().await; - // Cleanup: remove self from workers map when done - workers_ref.remove(&input_name_for_cleanup); - }); + match Self::submit_task( + ctx.sender.clone(), + ctx.pending_tasks.clone(), + ctx.pending_task_count.clone(), + &input_name, + max_queue_size, + task_to_queue.clone(), + ) + .await + { + SubmitTaskResult::QueuedOrMerged => return, + SubmitTaskResult::QueueFull => { + warn!("Metadata queue full for input {input_name}, dropping task"); + return; + } + SubmitTaskResult::ChannelClosed => { + debug_if_enabled!( + "Detected closed metadata worker channel for input {}, recreating worker context (attempt {})", + input_name, + attempt + 1 + ); + Self::remove_worker_context_if_id(&self.workers, &input_name, ctx.worker_id); + } + } } - Self::submit_task(ctx.sender.clone(), ctx.pending_tasks.clone(), &input_name, task).await; + warn!("Failed to queue metadata task for input {input_name} after worker recovery attempts: {task_to_queue}"); + } + + fn remove_worker_context_if_id( + workers: &DashMap, InputWorkerContext>, + input_name: &Arc, + worker_id: u64, + ) { + if let Entry::Occupied(entry) = workers.entry(input_name.clone()) { + if entry.get().worker_id == worker_id { + entry.remove(); + } + } } async fn submit_task( sender: mpsc::Sender, pending_tasks: Arc>, + pending_task_count: Arc, input_name: &str, + max_queue_size: usize, task: UpdateTask, - ) { + ) -> SubmitTaskResult { let key = TaskKey::from_task(&task); if let Some(entry) = pending_tasks.get(&key) { - let mut existing = entry.task.lock().await; - let mut merged = false; - // Merge logic - match (&mut *existing, task) { - ( - UpdateTask::ResolveVod { reason: r1, delay: d1, .. }, - UpdateTask::ResolveVod { reason: r2, delay: d2, .. }, - ) - | ( - UpdateTask::ResolveSeries { reason: r1, delay: d1, .. }, - UpdateTask::ResolveSeries { reason: r2, delay: d2, .. }, - ) - | ( - UpdateTask::ProbeStream { reason: r1, delay: d1, .. }, - UpdateTask::ProbeStream { reason: r2, delay: d2, .. }, - ) => { - *r1 |= r2; - *d1 = min(*d1, d2); - merged = true; - } - ( - UpdateTask::ProbeLive { reason: r1, delay: d1, interval: i1, .. }, - UpdateTask::ProbeLive { reason: r2, delay: d2, interval: i2, .. }, - ) => { - *r1 |= r2; - *d1 = min(*d1, d2); - *i1 = min(*i1, i2); - merged = true; - } - _ => {} // Mismatched types, should not happen due to TaskKey - } - - if merged { + let mut existing = entry.task.lock(); + if Self::merge_task_payload(&mut existing, task) { entry.generation.fetch_add(1, Ordering::Relaxed); } - return; + return SubmitTaskResult::QueuedOrMerged; } - if pending_tasks.len() >= MAX_QUEUE_SIZE { - warn!("Metadata queue full for input {input_name}, dropping task"); - return; + // Lock-free admission with CAS: reserve one queue slot only if capacity allows. + if pending_task_count + .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |current| { + if current < max_queue_size { + Some(current + 1) + } else { + None + } + }) + .is_err() + { + return SubmitTaskResult::QueueFull; + } + + match pending_tasks.entry(key.clone()) { + Entry::Occupied(entry) => { + // Another producer inserted this key after our fast-path `get`. + // Release reserved capacity and merge into the existing task. + Self::decrement_pending_task_count(&pending_task_count); + let mut existing = entry.get().task.lock(); + if Self::merge_task_payload(&mut existing, task) { + entry.get().generation.fetch_add(1, Ordering::Relaxed); + } + return SubmitTaskResult::QueuedOrMerged; + } + Entry::Vacant(entry) => { + entry.insert(PendingTask::new(task)); + } } - pending_tasks.insert(key.clone(), PendingTask::new(task)); if sender.send(key.clone()).await.is_err() { - pending_tasks.remove(&key); + if pending_tasks.remove(&key).is_some() { + Self::decrement_pending_task_count(&pending_task_count); + } warn!("Failed to send task signal for input {input_name}"); + return SubmitTaskResult::ChannelClosed; } + SubmitTaskResult::QueuedOrMerged + } + + #[inline] + fn decrement_pending_task_count(pending_task_count: &AtomicUsize) { + // Guard against accidental underflow in edge/error paths. + let _ = pending_task_count.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |current| { + current.checked_sub(1) + }); + } + + fn merge_task_payload(existing: &mut UpdateTask, task: UpdateTask) -> bool { + let mut merged = false; + // Merge logic + match (existing, task) { + ( + UpdateTask::ResolveVod { reason: r1, delay: d1, .. }, + UpdateTask::ResolveVod { reason: r2, delay: d2, .. }, + ) + | ( + UpdateTask::ResolveSeries { reason: r1, delay: d1, .. }, + UpdateTask::ResolveSeries { reason: r2, delay: d2, .. }, + ) => { + *r1 |= r2; + *d1 = min(*d1, d2); + merged = true; + } + ( + UpdateTask::ProbeStream { + reason: r1, + delay: d1, + url: url1, + item_type: item_type1, + .. + }, + UpdateTask::ProbeStream { + reason: r2, + delay: d2, + url: url2, + item_type: item_type2, + .. + }, + ) => { + *r1 |= r2; + *d1 = min(*d1, d2); + // Keep the existing payload by default; only fill it from the incoming + // task when the destination payload is empty. + if url1.is_empty() && !url2.is_empty() { + *url1 = url2; + *item_type1 = item_type2; + } + merged = true; + } + ( + UpdateTask::ProbeLive { reason: r1, delay: d1, interval: i1, .. }, + UpdateTask::ProbeLive { reason: r2, delay: d2, interval: i2, .. }, + ) => { + *r1 |= r2; + *d1 = min(*d1, d2); + *i1 = min(*i1, i2); + merged = true; + } + _ => {} // Mismatched types, should not happen due to TaskKey + } + + merged } /// Queue a task using the legacy API (for backward compatibility). @@ -390,12 +736,22 @@ struct InputWorker { sender: mpsc::Sender, receiver: mpsc::Receiver, pending_tasks: Arc>, + pending_task_count: Arc, app_state_weak: Option>, update_pause_gate: Arc>, cancel_token: CancellationToken, batch_buffer: BatchResultCollector, db_handles: HashMap, failed_clusters: HashSet, + retry_states: HashMap, + resolve_exhausted: HashMap, + last_cycle_completed_at_ts: Option, + probe_retry_state_path: Option, + probe_retry_loaded: bool, + probe_retry_load_retry_at_ts: Option, + // Shared with detached delayed requeue tasks spawned in `schedule_requeue_at`. + // A plain HashMap cannot be moved safely into those `'static` tasks. + scheduled_requeues: Arc>, } impl InputWorker { @@ -405,7 +761,6 @@ impl InputWorker { let mut processed_vod_count: usize = 0; let mut processed_series_count: usize = 0; - let mut retry_attempts: HashMap = HashMap::new(); let mut last_queue_log_at = Instant::now(); let mut last_progress_log_at = Instant::now(); let mut queue_cycle_active = false; @@ -413,13 +768,27 @@ impl InputWorker { let input_name = self.input_name.clone(); let app_state_weak = self.app_state_weak.clone(); + let startup_runtime_settings = self.runtime_settings(); + self.ensure_probe_retry_state_loaded(&input_name, app_state_weak.as_ref(), &startup_runtime_settings) + .await; + // Keep one prefetched task to minimize channel waits/lock churn. let mut next_task: Option<(TaskKey, UpdateTask, u64)> = None; loop { - let task_data = if let Some(t) = next_task.take() { Some(t) } else { self.recv_task_fast_or_wait().await }; + let runtime_settings = self.runtime_settings(); + let task_data = if let Some(t) = next_task.take() { + Some(t) + } else { + self.recv_task_fast_or_wait(&runtime_settings).await + }; let Some((current_key, current_task, current_generation)) = task_data else { break }; + if !self.probe_retry_loaded { + self.ensure_probe_retry_state_loaded(&input_name, app_state_weak.as_ref(), &runtime_settings) + .await; + } + let now_ts = chrono::Utc::now().timestamp(); if !queue_cycle_active { // First entry of a new processing cycle. @@ -429,146 +798,250 @@ impl InputWorker { last_progress_log_at = Instant::now(); // Emit queue logs promptly for the new cycle. last_queue_log_at = Instant::now() - .checked_sub(QUEUE_LOG_INTERVAL + Duration::from_secs(1)) + .checked_sub(runtime_settings.queue_log_interval + Duration::from_secs(1)) .unwrap_or_else(Instant::now); if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { app_state.event_manager.send_event(EventMessage::InputMetadataUpdatesStarted(input_name.clone())); } + if self + .last_cycle_completed_at_ts + .is_some_and(|last| now_ts.saturating_sub(last) >= runtime_settings.resolve_exhaustion_reset_gap_secs) + { + self.resolve_exhausted.clear(); + } debug!("Background metadata update queue has entries for input {input_name}; starting processing"); } let delay_secs = current_task.delay(); + let mut schedule_requeue_at_ts: Option = None; + let mut remove_current_task = false; + let mut apply_rate_limit = false; + let mut probe_persist_state: Option> = None; + let mut skip_execution = false; - let mut requeue_current = false; - let mut retry_delay_secs = 0_u64; + if Self::is_resolve_task(¤t_task) && self.resolve_exhausted.contains_key(¤t_key) { + debug_if_enabled!( + "Skipping exhausted resolve task for input {}: {} (reset window: {}s)", + input_name, + current_task, + runtime_settings.resolve_exhaustion_reset_gap_secs + ); + self.scheduled_requeues.remove(¤t_key); + remove_current_task = true; + skip_execution = true; + } - // Hold READ gate while executing one background task. - // Foreground updates acquire WRITE gate and therefore pause this path. - let task_result = { - let _task_gate_guard = self.update_pause_gate.clone().read_owned().await; - Self::process_task_static( - &input_name, - app_state_weak.as_ref(), - ¤t_task, - &mut self.batch_buffer, - &mut self.db_handles, - &mut self.failed_clusters, - ) - .await - }; - - match task_result { - Ok(task_changed) => { - if Self::is_vod_task_key(¤t_key) { - processed_vod_count += 1; - } else if Self::is_series_task_key(¤t_key) { - processed_series_count += 1; + if !skip_execution { + let mut clear_probe_state = false; + if let Some(state) = self.retry_states.get(¤t_key) { + if let Some(cooldown_until_ts) = state.cooldown_until_ts { + if now_ts < cooldown_until_ts { + debug_if_enabled!( + "Skipping probe task in cooldown for input {}: {} (cooldown_until={})", + input_name, + current_task, + cooldown_until_ts + ); + self.scheduled_requeues.remove(¤t_key); + remove_current_task = true; + skip_execution = true; + } else if Self::is_probe_task(¤t_task) { + clear_probe_state = true; + } } - debug!("Processed metadata task for input {input_name}: {current_task} (changed={task_changed})"); - retry_attempts.remove(¤t_key); - if last_progress_log_at.elapsed() >= PROGRESS_LOG_INTERVAL { - // current_key is removed from pending_tasks later in this loop iteration; - // subtract it here so "remaining" reflects the post-success queue size. - let (mut remaining_vod, mut remaining_series) = Self::queue_resolve_counts(&self.pending_tasks); + + if !skip_execution && state.next_allowed_at_ts > now_ts { + schedule_requeue_at_ts = Some(state.next_allowed_at_ts); + skip_execution = true; + } + } + + if clear_probe_state { + self.retry_states.remove(¤t_key); + probe_persist_state = Some(None); + } + } + + if !skip_execution { + // Hold READ gate while executing one background task. + // Foreground updates acquire WRITE gate and therefore pause this path. + let task_result = { + let _task_gate_guard = if let Ok(guard) = self.update_pause_gate.clone().try_read_owned() { + guard + } else { + // Foreground update contention detected. + // Drop cached file read handles before blocking on the gate to avoid AB-BA deadlocks. + self.release_db_handles(); + self.update_pause_gate.clone().read_owned().await + }; + Self::process_task_static( + &input_name, + app_state_weak.as_ref(), + ¤t_task, + &mut self.batch_buffer, + &mut self.db_handles, + &mut self.failed_clusters, + ) + .await + }; + + match task_result { + Ok(task_changed) => { if Self::is_vod_task_key(¤t_key) { - remaining_vod = remaining_vod.saturating_sub(1); + processed_vod_count += 1; } else if Self::is_series_task_key(¤t_key) { - remaining_series = remaining_series.saturating_sub(1); + processed_series_count += 1; + } + debug!("Processed metadata task for input {input_name}: {current_task} (changed={task_changed})"); + self.retry_states.remove(¤t_key); + self.resolve_exhausted.remove(¤t_key); + self.scheduled_requeues.remove(¤t_key); + if Self::is_probe_task(¤t_task) { + probe_persist_state = Some(None); } - let total_vod = processed_vod_count.saturating_add(remaining_vod); - let total_series = processed_series_count.saturating_add(remaining_series); - let resolved_total = processed_vod_count.saturating_add(processed_series_count); - let total_resolve = total_vod.saturating_add(total_series); + if last_progress_log_at.elapsed() >= runtime_settings.progress_log_interval { + // current_key is removed from pending_tasks later in this loop iteration; + // subtract it here so "remaining" reflects the post-success queue size. + let (mut remaining_vod, mut remaining_series) = Self::queue_resolve_counts(&self.pending_tasks); + if Self::is_vod_task_key(¤t_key) { + remaining_vod = remaining_vod.saturating_sub(1); + } else if Self::is_series_task_key(¤t_key) { + remaining_series = remaining_series.saturating_sub(1); + } - info!("Background metadata update: {resolved_total} / {total_resolve} resolved for input {input_name} (vod: {processed_vod_count}/{total_vod}, series: {processed_series_count}/{total_series})"); - last_progress_log_at = Instant::now(); + let total_vod = processed_vod_count.saturating_add(remaining_vod); + let total_series = processed_series_count.saturating_add(remaining_series); + let resolved_total = processed_vod_count.saturating_add(processed_series_count); + let total_resolve = total_vod.saturating_add(total_series); + + info!("Background metadata update: {resolved_total} / {total_resolve} resolved for input {input_name} (vod: {processed_vod_count}/{total_vod}, series: {processed_series_count}/{total_series})"); + last_progress_log_at = Instant::now(); + } + + remove_current_task = true; + apply_rate_limit = true; } - } - Err(e) if e.message == TASK_ERR_NO_CONNECTION => { - requeue_current = true; + Err(e) => { + if Self::is_transient_worker_error(&e.message) { + if e.message == TASK_ERR_UPDATE_IN_PROGRESS { + // Drop cached readers quickly so foreground writer can progress. + self.release_db_handles(); + } - let entry = retry_attempts.entry(current_key.clone()).or_insert(0); - *entry = entry.saturating_add(1); - let attempts = *entry; - - // If this is the only queued signal, use exponential backoff. - // Otherwise, push to the back immediately to improve throughput/fairness. - if self.receiver.is_empty() { - retry_delay_secs = if Self::is_probe_task(¤t_task) { - Self::compute_probe_retry_backoff_secs(current_task.delay(), attempts) + let retry_delay_secs = + 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={})", + input_name, + current_task, + retry_delay_secs, + e.message + ); } else { - Self::compute_retry_backoff_secs(current_task.delay(), attempts) - }; - } + let is_probe_task = Self::is_probe_task(¤t_task); + let max_attempts = if is_probe_task { + runtime_settings.max_attempts_probe + } else { + runtime_settings.max_attempts_resolve + }; - if Self::is_probe_task(¤t_task) { - debug_if_enabled!( - "No provider connection for low-priority probe task {} on input {}, requeueing (attempt={}, retry_delay={}s, queue_len={})", - current_task, - &input_name, - attempts, - retry_delay_secs, - self.receiver.len() - ); - } else { - debug_if_enabled!("No provider connection for task {} on input {}, requeueing (attempt={}, retry_delay={}s, queue_len={})", - current_task, - &input_name, - attempts, - retry_delay_secs, - self.receiver.len() - ); + let state_after_update = { + let state = self + .retry_states + .entry(current_key.clone()) + .or_insert_with(RetryState::new); + state.attempts = state.attempts.saturating_add(1); + state.last_error = Some(e.message.clone()); + + if state.attempts < max_attempts { + let backoff_secs = if is_probe_task { + Self::compute_probe_retry_backoff_secs(state.attempts, &runtime_settings) + } else { + Self::compute_resolve_retry_backoff_secs( + current_task.delay(), + state.attempts, + &runtime_settings, + ) + }; + let backoff_i64 = i64::try_from(backoff_secs).unwrap_or(i64::MAX); + state.next_allowed_at_ts = now_ts.saturating_add(backoff_i64); + state.cooldown_until_ts = None; + } else if is_probe_task { + state.cooldown_until_ts = + Some(now_ts.saturating_add(runtime_settings.probe_cooldown_secs)); + state.next_allowed_at_ts = state.cooldown_until_ts.unwrap_or(now_ts); + } + + state.clone() + }; + + let attempts = state_after_update.attempts; + + if attempts >= max_attempts { + self.scheduled_requeues.remove(¤t_key); + if is_probe_task { + remove_current_task = true; + probe_persist_state = Some(Some(state_after_update.clone())); + debug_if_enabled!( + "Probe task exhausted for input {}: {} (attempts={}, cooldown_until={:?})", + input_name, + current_task, + state_after_update.attempts, + state_after_update.cooldown_until_ts + ); + } else { + self.resolve_exhausted.insert(current_key.clone(), now_ts); + self.retry_states.remove(¤t_key); + remove_current_task = true; + debug_if_enabled!( + "Resolve task exhausted for input {}: {} (attempts={})", + input_name, + current_task, + attempts + ); + } + } else { + schedule_requeue_at_ts = Some(state_after_update.next_allowed_at_ts); + if is_probe_task { + probe_persist_state = Some(Some(state_after_update.clone())); + } + debug_if_enabled!( + "Task failed for input {}, scheduling retry: {} (attempt={}, next_allowed_at={}, err={})", + input_name, + current_task, + attempts, + state_after_update.next_allowed_at_ts, + e.message + ); + } + } } } - Err(e) if e.message == TASK_ERR_UPDATE_IN_PROGRESS => { - requeue_current = true; - retry_attempts.remove(¤t_key); - // Foreground update needs write access to playlist DB files. - // Drop cached read handles before waiting, otherwise we can deadlock - // by waiting for PlaylistUpdate completion while still blocking writers. - self.release_db_handles(); - let update_completed = - Self::wait_for_playlist_update_completion(app_state_weak.as_ref(), &self.cancel_token).await; - if !update_completed { - // Fallback path (e.g. listener closed): keep retrying with a small delay. - retry_delay_secs = UPDATE_IN_PROGRESS_RETRY_SECS; - } - } - Err(e) if e.message == TASK_ERR_PREEMPTED => { - // Preempted by a user-facing request: defer and retry later. - requeue_current = true; - retry_attempts.remove(¤t_key); - retry_delay_secs = UPDATE_IN_PROGRESS_RETRY_SECS; - } - Err(e) => { - retry_attempts.remove(¤t_key); - error!("Task {current_task} failed for input {input_name}: {e}"); - } + } + + if let Some(state) = probe_persist_state { + self.persist_probe_retry_state(¤t_key, state.as_ref()).await; } // Check and flush batch if self.batch_buffer.should_flush() { self.release_db_handles(); + let _gate_guard = self.update_pause_gate.clone().read_owned().await; Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; } - if requeue_current { - if retry_delay_secs > 0 - && Self::sleep_or_cancel(&self.cancel_token, Duration::from_secs(retry_delay_secs)).await - { - break; - } + if let Some(retry_at_ts) = schedule_requeue_at_ts { + self.schedule_requeue_at(current_key.clone(), retry_at_ts); + } - if self.sender.send(current_key.clone()).await.is_err() { - // Channel closed, drop the pending task key to avoid leaks. - self.pending_tasks.remove(¤t_key); - retry_attempts.remove(¤t_key); - warn!("Failed to requeue task {current_task} for input {input_name}"); - } - } else { + if remove_current_task { self.finalize_processed_task_success(¤t_key, current_generation, &input_name).await; + } + if apply_rate_limit { // Rate limiting if delay_secs > 0 && Self::sleep_or_cancel(&self.cancel_token, Duration::from_secs(u64::from(delay_secs))).await @@ -577,59 +1050,196 @@ impl InputWorker { } } - // Try to get the next task immediately to keep locks open + // Try to get the next task immediately to keep locks open. + // Ignore phantom signals (channel key without pending map entry). if next_task.is_none() { - match self.receiver.try_recv() { - Ok(key) => { - next_task = self.load_task_snapshot(key).await; - } - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + while let Ok(key) = self.receiver.try_recv() { + if let Some(snapshot) = self.load_task_snapshot(key) { + next_task = Some(snapshot); break; } - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} } } - let queue_has_work = next_task.is_some() || !self.receiver.is_empty() || !self.pending_tasks.is_empty(); + let channel_has_work = next_task.is_some() || !self.receiver.is_empty(); + let queue_completely_empty = + !channel_has_work && self.pending_tasks.is_empty() && self.scheduled_requeues.is_empty(); - if queue_has_work { - // Avoid O(n) queue scans per task; report queue status periodically. - if last_queue_log_at.elapsed() >= QUEUE_LOG_INTERVAL { - let queue_counts = Self::queue_resolve_counts(&self.pending_tasks); - debug!( - "In queue to resolve vod: {}, series: {} (input: {input_name})", - queue_counts.0, queue_counts.1 - ); - last_queue_log_at = Instant::now(); - } - } else { - // Queue is drained: flush remaining batched updates immediately. - if !self.batch_buffer.is_empty() { - self.release_db_handles(); - Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; - } + // Avoid O(n) queue scans per task; report queue status periodically. + if (channel_has_work || !self.pending_tasks.is_empty()) + && last_queue_log_at.elapsed() >= runtime_settings.queue_log_interval + { + let queue_counts = Self::queue_resolve_counts(&self.pending_tasks); + debug!( + "In queue to resolve vod: {}, series: {} (input: {input_name})", + queue_counts.0, queue_counts.1 + ); + last_queue_log_at = Instant::now(); + } - if queue_cycle_active { - info!("All pending metadata resolves completed for input {input_name}"); - if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { - app_state - .event_manager - .send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); - } - queue_cycle_active = false; - processed_vod_count = 0; - processed_series_count = 0; + // If no immediate work is available, flush buffered results now even when delayed retries remain pending. + if !channel_has_work && !self.batch_buffer.is_empty() { + self.release_db_handles(); + let _gate_guard = self.update_pause_gate.clone().read_owned().await; + Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + } + + if queue_cycle_active && queue_completely_empty { + info!("All pending metadata resolves completed for input {input_name}"); + if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { + app_state + .event_manager + .send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); } + self.last_cycle_completed_at_ts = Some(chrono::Utc::now().timestamp()); + queue_cycle_active = false; + processed_vod_count = 0; + processed_series_count = 0; } } // Final flush self.release_db_handles(); - Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + if !self.batch_buffer.is_empty() { + let _gate_guard = self.update_pause_gate.clone().read_owned().await; + Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + } debug!("Metadata worker stopped for input {input_name}"); } + async fn ensure_probe_retry_state_loaded( + &mut self, + input_name: &str, + app_state_weak: Option<&Weak>, + runtime_settings: &MetadataUpdateRuntimeSettings, + ) { + if self.probe_retry_loaded { + return; + } + let now_ts = chrono::Utc::now().timestamp(); + if self + .probe_retry_load_retry_at_ts + .is_some_and(|retry_at_ts| now_ts < retry_at_ts) + { + return; + } + + let Some(app_state) = app_state_weak.and_then(Weak::upgrade) else { + self.probe_retry_load_retry_at_ts = + Some(now_ts.saturating_add(runtime_settings.probe_retry_load_retry_delay_secs)); + 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 { + warn!("Could not resolve storage path for probe retry state on input {input_name}"); + self.probe_retry_load_retry_at_ts = + Some(now_ts.saturating_add(runtime_settings.probe_retry_load_retry_delay_secs)); + return; + }; + + let retry_path = storage_path.join(PROBE_RETRY_STATE_FILE); + self.probe_retry_state_path = Some(retry_path.clone()); + + let loaded = tokio::task::spawn_blocking(move || load_probe_retry_states_from_disk(&retry_path)) + .await + .map_err(|err| err.to_string()) + .and_then(|result| result.map_err(|err| err.to_string())); + + let loaded = match loaded { + Ok(states) => states, + Err(err) => { + warn!("Failed to load probe retry state for input {input_name}: {err}"); + self.probe_retry_load_retry_at_ts = + Some(now_ts.saturating_add(runtime_settings.probe_retry_load_retry_delay_secs)); + return; + } + }; + + // Intentionally do not resurrect pending probe tasks solely from persisted retry state. + // The persisted state is applied once the corresponding task is naturally queued again + // (for example by the next playlist update), because state alone does not contain the + // full `UpdateTask` payload for all variants. + for (key, state) in loaded { + self.retry_states.insert(key, state); + } + self.probe_retry_loaded = true; + self.probe_retry_load_retry_at_ts = None; + } + + async fn persist_probe_retry_state(&self, key: &TaskKey, state: Option<&RetryState>) { + let Some(path) = self.probe_retry_state_path.clone() else { + return; + }; + + let key = key.clone(); + let state = state.cloned(); + let input_name = self.input_name.clone(); + let persist_result = tokio::task::spawn_blocking(move || { + persist_probe_retry_state_to_disk(&path, &key, state.as_ref()) + }) + .await; + + match persist_result { + Ok(Ok(())) => {} + Ok(Err(err)) => warn!("Failed to persist probe retry state for input {input_name}: {err}"), + Err(err) => warn!("Failed to persist probe retry state for input {input_name}: {err}"), + } + } + + fn schedule_requeue_at(&self, key: TaskKey, retry_at_ts: i64) { + let now_ts = chrono::Utc::now().timestamp(); + let retry_at_ts = retry_at_ts.max(now_ts); + + if self + .scheduled_requeues + .get(&key) + .is_some_and(|existing| *existing == retry_at_ts) + { + return; + } + + self.scheduled_requeues.insert(key.clone(), retry_at_ts); + + let sender = self.sender.clone(); + let pending_tasks = Arc::clone(&self.pending_tasks); + let pending_task_count = Arc::clone(&self.pending_task_count); + let scheduled = Arc::clone(&self.scheduled_requeues); + let cancel_token = self.cancel_token.clone(); + let input_name = self.input_name.clone(); + + tokio::spawn(async move { + let delay_secs = retry_at_ts.saturating_sub(chrono::Utc::now().timestamp()); + if delay_secs > 0 { + let delay = Duration::from_secs(u64::try_from(delay_secs).unwrap_or(u64::MAX)); + tokio::select! { + () = cancel_token.cancelled() => return, + () = tokio::time::sleep(delay) => {} + } + } + + let should_send = scheduled + .get(&key) + .is_some_and(|scheduled_at| *scheduled_at == retry_at_ts); + if !should_send { + return; + } + scheduled.remove(&key); + + if !pending_tasks.contains_key(&key) { + return; + } + + if sender.send(key.clone()).await.is_err() { + if pending_tasks.remove(&key).is_some() { + MetadataUpdateManager::decrement_pending_task_count(&pending_task_count); + } + warn!("Failed to schedule delayed retry task for input {input_name}"); + } + }); + } + async fn finalize_processed_task_success( &mut self, current_key: &TaskKey, @@ -642,49 +1252,92 @@ impl InputWorker { if latest_generation != current_generation { self.pending_tasks.insert(current_key.clone(), removed_task); if self.sender.send(current_key.clone()).await.is_err() { - self.pending_tasks.remove(current_key); + if self.pending_tasks.remove(current_key).is_some() { + MetadataUpdateManager::decrement_pending_task_count(&self.pending_task_count); + } warn!("Failed to schedule merged task replay for input {input_name}"); return false; } return true; } + // Task finished and is not reinserted. + MetadataUpdateManager::decrement_pending_task_count(&self.pending_task_count); } false } - async fn recv_task_fast_or_wait(&mut self) -> Option<(TaskKey, UpdateTask, u64)> { - match self.receiver.try_recv() { - Ok(key) => { - return self.load_task_snapshot(key).await; + async fn recv_task_fast_or_wait( + &mut self, + runtime_settings: &MetadataUpdateRuntimeSettings, + ) -> Option<(TaskKey, UpdateTask, u64)> { + // Fast path: drain immediate signals until we find a real pending task. + loop { + match self.receiver.try_recv() { + Ok(key) => { + if let Some(snapshot) = self.load_task_snapshot(key) { + return Some(snapshot); + } + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + return None; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + break; + } } - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - return None; - } - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} } // When idle, release read handles to avoid writer starvation. self.release_db_handles(); - tokio::select! { - biased; - () = self.cancel_token.cancelled() => None, - res = self.receiver.recv() => { - match res { - Some(key) => self.load_task_snapshot(key).await, - None => None, + loop { + tokio::select! { + biased; + () = self.cancel_token.cancelled() => return None, + res = tokio::time::timeout(Duration::from_secs(runtime_settings.worker_idle_timeout_secs), self.receiver.recv()) => { + match res { + Ok(Some(key)) => { + if let Some(snapshot) = self.load_task_snapshot(key) { + return Some(snapshot); + } + } + Ok(None) => return None, + Err(_) => { + loop { + match self.receiver.try_recv() { + Ok(key) => { + if let Some(snapshot) = self.load_task_snapshot(key) { + return Some(snapshot); + } + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + } + } + if self.pending_tasks.is_empty() + && self.receiver.is_empty() + && self.scheduled_requeues.is_empty() + { + return None; + } + } + } } } } } - async fn load_task_snapshot(&self, key: TaskKey) -> Option<(TaskKey, UpdateTask, u64)> { + fn load_task_snapshot(&self, key: TaskKey) -> Option<(TaskKey, UpdateTask, u64)> { let entry = self.pending_tasks.get(&key)?; let generation = entry.generation.load(Ordering::Relaxed); - let task = entry.task.lock().await.clone(); + let task = entry.task.lock().clone(); Some((key, task, generation)) } + fn runtime_settings(&self) -> MetadataUpdateRuntimeSettings { + MetadataUpdateRuntimeSettings::from_app_state(self.app_state_weak.as_ref()) + } + fn release_db_handles(&mut self) { if !self.db_handles.is_empty() { self.db_handles.clear(); @@ -694,52 +1347,39 @@ impl InputWorker { } } - fn compute_retry_backoff_secs(base_delay_secs: u16, attempts: u8) -> u64 { - let base_delay = u64::from(base_delay_secs.max(1)); + fn compute_resolve_retry_backoff_secs( + base_delay_secs: u16, + attempts: u8, + runtime_settings: &MetadataUpdateRuntimeSettings, + ) -> u64 { + let base_delay = u64::from(base_delay_secs).max(runtime_settings.resolve_min_retry_base_secs); let exp = u32::from(attempts.saturating_sub(1).min(6)); - base_delay.saturating_mul(2_u64.saturating_pow(exp)).min(MAX_RETRY_BACKOFF_SECS) + let without_jitter = base_delay + .saturating_mul(2_u64.saturating_pow(exp)) + .min(runtime_settings.max_resolve_retry_backoff_secs); + Self::apply_jitter(without_jitter, runtime_settings.backoff_jitter_percent) } - fn compute_probe_retry_backoff_secs(base_delay_secs: u16, attempts: u8) -> u64 { - let adjusted_base = base_delay_secs.max(MIN_PROBE_RETRY_BASE_SECS); - Self::compute_retry_backoff_secs(adjusted_base, attempts) + fn compute_retry_delay_secs(base_delay_secs: u16, runtime_settings: &MetadataUpdateRuntimeSettings) -> u64 { + u64::from(base_delay_secs).max(runtime_settings.retry_delay_secs) } - async fn wait_for_playlist_update_completion( - app_state_weak: Option<&Weak>, - cancel_token: &CancellationToken, - ) -> bool { - let Some(app_state) = app_state_weak.and_then(Weak::upgrade) else { - return false; + fn compute_probe_retry_backoff_secs(attempts: u8, runtime_settings: &MetadataUpdateRuntimeSettings) -> u64 { + let base_secs = match attempts { + 1 => runtime_settings.probe_retry_backoff_step_1_secs, + 2 => runtime_settings.probe_retry_backoff_step_2_secs, + _ => runtime_settings.probe_retry_backoff_step_3_secs, }; + Self::apply_jitter(base_secs, runtime_settings.backoff_jitter_percent) + } - // Subscribe before checking the guard to avoid missing a PlaylistUpdate event - // published between the guard check and receiver subscription. - let mut rx = app_state.event_manager.get_event_channel(); - - // If no update is active anymore, do not block on events. - if let Some(guard) = app_state.update_guard.try_playlist() { - drop(guard); - return true; - } - - loop { - tokio::select! { - () = cancel_token.cancelled() => return false, - recv = rx.recv() => { - match recv { - Ok(EventMessage::PlaylistUpdate(_)) => return true, - Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - if let Some(guard) = app_state.update_guard.try_playlist() { - drop(guard); - return true; - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => return false, - } - } - } - } + fn apply_jitter(base_secs: u64, jitter_percent: u8) -> u64 { + let jitter_percent = i64::from(jitter_percent); + let jitter_percent = fastrand::i64(-jitter_percent..=jitter_percent); + let base_i64 = i64::try_from(base_secs).unwrap_or(i64::MAX); + let jitter_delta = base_i64.saturating_mul(jitter_percent).saturating_div(100); + let jittered = base_i64.saturating_add(jitter_delta); + u64::try_from(jittered.max(1)).unwrap_or(1) } async fn sleep_or_cancel(cancel_token: &CancellationToken, duration: Duration) -> bool { @@ -779,6 +1419,17 @@ impl InputWorker { matches!(task, UpdateTask::ProbeLive { .. } | UpdateTask::ProbeStream { .. }) } + #[inline] + fn is_resolve_task(task: &UpdateTask) -> bool { + matches!(task, UpdateTask::ResolveVod { .. } | UpdateTask::ResolveSeries { .. }) + } + + fn is_transient_worker_error(message: &str) -> bool { + message == TASK_ERR_UPDATE_IN_PROGRESS + || message == TASK_ERR_PREEMPTED + || message == TASK_ERR_NO_CONNECTION + } + // Changed to static method async fn flush_batch_static( input_name: &str, @@ -916,23 +1567,24 @@ impl InputWorker { }; let mapping_file = get_target_id_mapping_file(&target_path); - // Read mapping under lock inside spawn_blocking to avoid blocking async executor. - let file_lock = app_state.app_config.file_locks.read_lock(&mapping_file).await; - let mapping_file_clone = mapping_file.clone(); - let mapping = match tokio::task::spawn_blocking(move || { - let _guard = file_lock; - TargetIdMapping::new(&mapping_file_clone, false) - }) - .await - { - Ok(Ok(mapping)) => mapping, - Ok(Err(e)) => { - error!("Failed to open ID mapping for target {target_name}: {e}"); - continue; - } - Err(err) => { - error!("Failed to open ID mapping for target {target_name}: {err}"); - continue; + let mapping = { + // Scope read lock strictly to mapping load. + let _file_lock = app_state.app_config.file_locks.read_lock(&mapping_file).await; + let mapping_file_clone = mapping_file.clone(); + match tokio::task::spawn_blocking(move || { + TargetIdMapping::new(&mapping_file_clone, false) + }) + .await + { + Ok(Ok(mapping)) => mapping, + Ok(Err(e)) => { + error!("Failed to open ID mapping for target {target_name}: {e}"); + continue; + } + Err(err) => { + error!("Failed to open ID mapping for target {target_name}: {err}"); + continue; + } } }; @@ -1108,28 +1760,30 @@ impl InputWorker { let updates_input: Vec<(u32, VideoStreamProperties)> = virtual_updates.into_iter().map(|(vid, props)| (vid, props.clone())).collect(); - let file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; - let xtream_path = xtream_path.clone(); - let updates = match tokio::task::spawn_blocking(move || -> Vec { - let _guard = file_lock; - let mut updates = Vec::with_capacity(updates_input.len()); - let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) else { - return updates; - }; - for (virtual_id, props) in updates_input { - if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { - item.additional_properties = Some(shared::model::StreamProperties::Video(Box::new(props))); - updates.push(item); + let updates = { + // Scope read lock to read-only query phase so write phase can acquire lock. + let _file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let xtream_path_clone = xtream_path.clone(); + match tokio::task::spawn_blocking(move || -> Vec { + let mut updates = Vec::with_capacity(updates_input.len()); + let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path_clone) else { + return updates; + }; + for (virtual_id, props) in updates_input { + if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { + item.additional_properties = Some(shared::model::StreamProperties::Video(Box::new(props))); + updates.push(item); + } + } + updates + }) + .await + { + Ok(updates) => updates, + Err(err) => { + error!("Failed to read VOD updates from disk for {target_name}: {err}"); + Vec::new() } - } - updates - }) - .await - { - Ok(updates) => updates, - Err(err) => { - error!("Failed to read VOD updates from disk for {target_name}: {err}"); - Vec::new() } }; @@ -1164,28 +1818,30 @@ impl InputWorker { let updates_input: Vec<(u32, SeriesStreamProperties)> = virtual_updates.into_iter().map(|(vid, props)| (vid, props.clone())).collect(); - let file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; - let xtream_path = xtream_path.clone(); - let updates = match tokio::task::spawn_blocking(move || -> Vec { - let _guard = file_lock; - let mut updates = Vec::with_capacity(updates_input.len()); - let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) else { - return updates; - }; - for (virtual_id, props) in updates_input { - if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { - item.additional_properties = Some(shared::model::StreamProperties::Series(Box::new(props))); - updates.push(item); + let updates = { + // Scope read lock to read-only query phase so write phase can acquire lock. + let _file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let xtream_path_clone = xtream_path.clone(); + match tokio::task::spawn_blocking(move || -> Vec { + let mut updates = Vec::with_capacity(updates_input.len()); + let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path_clone) else { + return updates; + }; + for (virtual_id, props) in updates_input { + if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { + item.additional_properties = Some(shared::model::StreamProperties::Series(Box::new(props))); + updates.push(item); + } + } + updates + }) + .await + { + Ok(updates) => updates, + Err(err) => { + error!("Failed to read Series updates from disk for {target_name}: {err}"); + Vec::new() } - } - updates - }) - .await - { - Ok(updates) => updates, - Err(err) => { - error!("Failed to read Series updates from disk for {target_name}: {err}"); - Vec::new() } }; @@ -1220,28 +1876,30 @@ impl InputWorker { let updates_input: Vec<(u32, LiveStreamProperties)> = virtual_updates.into_iter().map(|(vid, props)| (vid, props.clone())).collect(); - let file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; - let xtream_path = xtream_path.clone(); - let updates = match tokio::task::spawn_blocking(move || -> Vec { - let _guard = file_lock; - let mut updates = Vec::with_capacity(updates_input.len()); - let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) else { - return updates; - }; - for (virtual_id, props) in updates_input { - if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { - item.additional_properties = Some(shared::model::StreamProperties::Live(Box::new(props))); - updates.push(item); + let updates = { + // Scope read lock to read-only query phase so write phase can acquire lock. + let _file_lock = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let xtream_path_clone = xtream_path.clone(); + match tokio::task::spawn_blocking(move || -> Vec { + let mut updates = Vec::with_capacity(updates_input.len()); + let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path_clone) else { + return updates; + }; + for (virtual_id, props) in updates_input { + if let Ok(Some(mut item)) = query.query_zero_copy(&virtual_id) { + item.additional_properties = Some(shared::model::StreamProperties::Live(Box::new(props))); + updates.push(item); + } + } + updates + }) + .await + { + Ok(updates) => updates, + Err(err) => { + error!("Failed to read Live updates from disk for {target_name}: {err}"); + Vec::new() } - } - updates - }) - .await - { - Ok(updates) => updates, - Err(err) => { - error!("Failed to read Live updates from disk for {target_name}: {err}"); - Vec::new() } }; @@ -1508,11 +2166,13 @@ impl InputWorker { UpdateTask::ProbeLive { .. } => true, // Local library probing is fully local and must not depend on provider capacity. UpdateTask::ProbeStream { .. } => !matches!(input_type, InputType::Library), - UpdateTask::ResolveVod { .. } | UpdateTask::ResolveSeries { .. } => false, + UpdateTask::ResolveVod { reason, .. } | UpdateTask::ResolveSeries { reason, .. } => { + reason.contains(ResolveReason::Probe) + } } } - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn execute_task_inner_static( app_state: &Arc, client: &reqwest::Client, @@ -1527,6 +2187,8 @@ impl InputWorker { match task { UpdateTask::ResolveVod { id, reason, .. } => { let fetch_info = reason.contains(ResolveReason::Info); + let resolve_tmdb = + fetch_info || reason.contains(ResolveReason::Tmdb) || reason.contains(ResolveReason::Date); let will_probe = reason.contains(ResolveReason::Probe); // If we are going to probe, release the cached handle to avoid holding a READ lock @@ -1553,6 +2215,7 @@ impl InputWorker { item_title, false, // Batch collect fetch_info, + resolve_tmdb, will_probe, query_opt, ) @@ -1568,7 +2231,12 @@ impl InputWorker { } UpdateTask::ResolveSeries { id, reason, .. } => { let fetch_info = reason.contains(ResolveReason::Info); + let resolve_tmdb = reason.contains(ResolveReason::Tmdb) || reason.contains(ResolveReason::Date); let will_probe = reason.contains(ResolveReason::Probe); + let series_probe_settings = { + let config = app_state.app_config.config.load(); + SeriesProbeSettings::from_metadata_update(config.metadata_update.as_ref()) + }; if will_probe { db_handles.remove(&XtreamCluster::Series); @@ -1592,7 +2260,9 @@ impl InputWorker { item_title, false, // Batch collect fetch_info, + resolve_tmdb, will_probe, + series_probe_settings, query_opt, ) .await @@ -1609,7 +2279,17 @@ impl InputWorker { // ProbeLive always probes, so we must never use a cached handle here. db_handles.remove(&XtreamCluster::Live); - match update_live_stream_metadata(&app_state.app_config, input, id.clone(), false, None).await { + match update_live_stream_metadata( + &app_state.app_config, + input, + id.clone(), + false, + None, + active_handle, + &app_state.active_provider, + ) + .await + { Ok(Some(props)) => { collector.add_live(id.clone(), props); Ok(()) @@ -1624,8 +2304,10 @@ impl InputWorker { if !db_handles.is_empty() { db_handles.clear(); } + let task_key = TaskKey::from_task(task); + let probe_identifier = if unique_id.trim().is_empty() { url.as_str() } else { unique_id.as_str() }; - update_generic_stream_metadata( + let outcome = update_generic_stream_metadata( &app_state.app_config, input.as_ref(), unique_id, @@ -1634,7 +2316,16 @@ impl InputWorker { &app_state.active_provider, active_handle, ) - .await + .await?; + + match outcome { + GenericProbeOutcome::Updated | GenericProbeOutcome::Noop => Ok(()), + GenericProbeOutcome::ProbeFailed => Err(shared::error::info_err!( + "Probe stream task failed for key {:?} ({})", + task_key, + probe_identifier + )), + } } } } @@ -1643,6 +2334,7 @@ impl InputWorker { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; use tokio::sync::mpsc; #[tokio::test] @@ -1684,20 +2376,38 @@ mod tests { async fn submit_task_merges_existing_task_and_increments_generation() { let (tx, mut rx) = mpsc::channel::(8); let pending_tasks = Arc::new(DashMap::new()); + let pending_task_count = Arc::new(AtomicUsize::new(0)); let task_initial = UpdateTask::ResolveVod { id: ProviderIdType::Id(42), reason: ResolveReasonSet::from_variants(&[ResolveReason::Info]), delay: 10, }; - MetadataUpdateManager::submit_task(tx.clone(), pending_tasks.clone(), "input_a", task_initial).await; + let queue_size = MetadataUpdateRuntimeSettings::default().max_queue_size; + MetadataUpdateManager::submit_task( + tx.clone(), + pending_tasks.clone(), + pending_task_count.clone(), + "input_a", + queue_size, + task_initial, + ) + .await; let task_merge = UpdateTask::ResolveVod { id: ProviderIdType::Id(42), reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), delay: 2, }; - MetadataUpdateManager::submit_task(tx, pending_tasks.clone(), "input_a", task_merge).await; + MetadataUpdateManager::submit_task( + tx, + pending_tasks.clone(), + pending_task_count.clone(), + "input_a", + queue_size, + task_merge, + ) + .await; let first_signal = rx.try_recv().expect("first signal should be queued"); assert_eq!(first_signal, TaskKey::Vod(42)); @@ -1709,7 +2419,7 @@ mod tests { let entry = pending_tasks.get(&TaskKey::Vod(42)).expect("pending entry should exist"); assert_eq!(entry.generation.load(Ordering::Relaxed), 1); - let merged = entry.task.lock().await.clone(); + let merged = entry.task.lock().clone(); match merged { UpdateTask::ResolveVod { reason, delay, .. } => { assert!(reason.contains(ResolveReason::Info)); @@ -1720,6 +2430,88 @@ mod tests { } } + #[tokio::test] + async fn submit_task_probe_stream_merge_keeps_existing_payload_when_present() { + let (tx, mut rx) = mpsc::channel::(8); + let pending_tasks = Arc::new(DashMap::new()); + let pending_task_count = Arc::new(AtomicUsize::new(0)); + let queue_size = MetadataUpdateRuntimeSettings::default().max_queue_size; + + let initial = UpdateTask::ProbeStream { + probe_scope: Arc::from("scope_a"), + unique_id: "uid_1".to_string(), + url: "http://old.example/stream".to_string(), + item_type: PlaylistItemType::Video, + reason: ResolveReasonSet::from_variants(&[ResolveReason::MissingDetails]), + delay: 10, + }; + MetadataUpdateManager::submit_task( + tx.clone(), + pending_tasks.clone(), + pending_task_count.clone(), + "input_a", + queue_size, + initial, + ) + .await; + + let merged_in = UpdateTask::ProbeStream { + probe_scope: Arc::from("scope_a"), + unique_id: "uid_1".to_string(), + url: "http://new.example/stream".to_string(), + item_type: PlaylistItemType::LocalVideo, + reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + delay: 2, + }; + MetadataUpdateManager::submit_task( + tx, + pending_tasks.clone(), + pending_task_count.clone(), + "input_a", + queue_size, + merged_in, + ) + .await; + + let first_signal = rx.try_recv().expect("first signal should be queued"); + assert_eq!( + first_signal, + TaskKey::Stream { + scope: Arc::from("scope_a"), + id: Arc::from("uid_1"), + } + ); + assert!(matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty | tokio::sync::mpsc::error::TryRecvError::Disconnected) + )); + + let key = TaskKey::Stream { + scope: Arc::from("scope_a"), + id: Arc::from("uid_1"), + }; + let entry = pending_tasks.get(&key).expect("pending entry should exist"); + assert_eq!(entry.generation.load(Ordering::Relaxed), 1); + + let merged = entry.task.lock().clone(); + match merged { + UpdateTask::ProbeStream { + reason, + delay, + url, + item_type, + .. + } => { + assert!(reason.contains(ResolveReason::MissingDetails)); + assert!(reason.contains(ResolveReason::Probe)); + assert_eq!(delay, 2); + assert_eq!(url, "http://old.example/stream"); + assert_eq!(item_type, PlaylistItemType::Video); + } + other => panic!("unexpected task type after merge: {other:?}"), + } + } + #[tokio::test] async fn finalize_processed_task_success_requeues_when_generation_changed() { let (tx, rx) = mpsc::channel::(8); @@ -1743,12 +2535,20 @@ mod tests { sender: tx, receiver: rx, pending_tasks: pending_tasks.clone(), + pending_task_count: Arc::new(AtomicUsize::new(1)), app_state_weak: None, update_pause_gate: Arc::new(RwLock::new(())), cancel_token: CancellationToken::new(), batch_buffer: BatchResultCollector::new(), db_handles: HashMap::new(), failed_clusters: HashSet::new(), + retry_states: HashMap::new(), + resolve_exhausted: HashMap::new(), + last_cycle_completed_at_ts: None, + probe_retry_state_path: None, + probe_retry_loaded: false, + probe_retry_load_retry_at_ts: None, + scheduled_requeues: Arc::new(DashMap::new()), }; let requeued = worker.finalize_processed_task_success(&key, 0, "input_a").await; @@ -1777,12 +2577,20 @@ mod tests { sender: tx, receiver: rx, pending_tasks: pending_tasks.clone(), + pending_task_count: Arc::new(AtomicUsize::new(1)), app_state_weak: None, update_pause_gate: Arc::new(RwLock::new(())), cancel_token: CancellationToken::new(), batch_buffer: BatchResultCollector::new(), db_handles: HashMap::new(), failed_clusters: HashSet::new(), + retry_states: HashMap::new(), + resolve_exhausted: HashMap::new(), + last_cycle_completed_at_ts: None, + probe_retry_state_path: None, + probe_retry_loaded: false, + probe_retry_load_retry_at_ts: None, + scheduled_requeues: Arc::new(DashMap::new()), }; let requeued = worker.finalize_processed_task_success(&key, 0, "input_b").await; @@ -1831,4 +2639,53 @@ mod tests { assert!(InputWorker::task_needs_provider_connection(&task, InputType::Library)); } + + #[test] + fn probe_retry_state_disk_roundtrip() { + let dir = tempdir().expect("tempdir should be created"); + let path = dir.path().join("probe_retry_state.db"); + let key = TaskKey::Stream { + scope: Arc::from("input_a"), + id: Arc::from("stream_1"), + }; + let state = RetryState { + attempts: 3, + next_allowed_at_ts: 1_700_000_000, + cooldown_until_ts: Some(1_700_086_400), + last_error: Some("probe timeout".to_string()), + }; + + persist_probe_retry_state_to_disk(&path, &key, Some(&state)) + .expect("state persistence should succeed"); + let loaded = load_probe_retry_states_from_disk(&path).expect("state load should succeed"); + let loaded_state = loaded.get(&key).expect("probe key should be present"); + + assert_eq!(loaded_state.attempts, 3); + assert_eq!(loaded_state.cooldown_until_ts, Some(1_700_086_400)); + assert_eq!(loaded_state.last_error.as_deref(), Some("probe timeout")); + + persist_probe_retry_state_to_disk(&path, &key, None).expect("state clear should succeed"); + let cleared = load_probe_retry_states_from_disk(&path).expect("state reload should succeed"); + assert!(!cleared.contains_key(&key)); + } + + #[test] + fn probe_backoff_steps_follow_expected_windows() { + let runtime_settings = MetadataUpdateRuntimeSettings::default(); + let first = InputWorker::compute_probe_retry_backoff_secs(1, &runtime_settings); + let second = InputWorker::compute_probe_retry_backoff_secs(2, &runtime_settings); + let third = InputWorker::compute_probe_retry_backoff_secs(3, &runtime_settings); + + assert!((480..=720).contains(&first), "expected ~10m with jitter, got {first}"); + assert!((1_440..=2_160).contains(&second), "expected ~30m with jitter, got {second}"); + assert!((2_880..=4_320).contains(&third), "expected ~60m with jitter, got {third}"); + } + + #[test] + fn transient_worker_errors_include_connection_unavailable() { + assert!(InputWorker::is_transient_worker_error(TASK_ERR_UPDATE_IN_PROGRESS)); + assert!(InputWorker::is_transient_worker_error(TASK_ERR_PREEMPTED)); + assert!(InputWorker::is_transient_worker_error(TASK_ERR_NO_CONNECTION)); + assert!(!InputWorker::is_transient_worker_error("permanent error")); + } } diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index c35b9c798..cf9904ec8 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -507,12 +507,17 @@ impl MultiProviderLineup { pub(in crate::api::model) struct ProviderLineupManager { grace_period_millis: AtomicU64, grace_period_timeout_secs: AtomicU64, - inputs: Arc>>>, - providers: Arc>>, + snapshot: Arc>, provider_connections: DashMap, Arc>>, event_manager: Arc, } +#[derive(Debug)] +struct LineupSnapshot { + inputs: Vec>, + providers: Vec, +} + impl ProviderLineupManager { pub fn new(inputs: Vec>, grace_period_options: GracePeriodOptions, event_manager: &Arc) -> Self { let provider_connections: DashMap, Arc>> = DashMap::new(); @@ -523,8 +528,10 @@ impl ProviderLineupManager { Self { grace_period_millis: AtomicU64::new(grace_period_options.period_millis), grace_period_timeout_secs: AtomicU64::new(grace_period_options.timeout_secs), - inputs: Arc::new(ArcSwap::from_pointee(inputs)), - providers: Arc::new(ArcSwap::from_pointee(lineups)), + snapshot: Arc::new(ArcSwap::from_pointee(LineupSnapshot { + inputs, + providers: lineups, + })), provider_connections, event_manager: Arc::clone(event_manager), } @@ -592,12 +599,12 @@ impl ProviderLineupManager { } fn has_changed(&self, new_inputs: &[Arc]) -> bool { - let old_inputs = self.inputs.load(); - if old_inputs.len() != new_inputs.len() { + let snapshot = self.snapshot.load(); + if snapshot.inputs.len() != new_inputs.len() { return true; } for new_input in new_inputs { - let Some(old_input) = old_inputs.iter().find(|i| i.name == new_input.name) else { + let Some(old_input) = snapshot.inputs.iter().find(|i| i.name == new_input.name) else { return true; }; @@ -625,8 +632,10 @@ impl ProviderLineupManager { debug_if_enabled!("inputs {}", sanitize_sensitive_info(&display_vec(&new_inputs))); debug_if_enabled!("lineup {}", sanitize_sensitive_info(&display_vec(&new_lineups))); - self.inputs.store(Arc::new(new_inputs)); - self.providers.store(Arc::new(new_lineups)); + self.snapshot.store(Arc::new(LineupSnapshot { + inputs: new_inputs, + providers: new_lineups, + })); } pub async fn reconcile_connections(&self, mut counts: HashMap, usize>) { @@ -648,6 +657,8 @@ impl ProviderLineupManager { } // 2. Handle new providers that weren't in the registry yet (e.g. newly added/renamed). + // Same lock-ordering rule as Phase 1: clone the Arc> out of DashMap first, + // then await on the provider RwLock without holding any DashMap shard lock. for (name, count) in counts { let conn_lock = self.provider_connections .entry(name) @@ -676,8 +687,8 @@ impl ProviderLineupManager { // deterministic check: Is the name still in any current lineup? let current_names: std::collections::HashSet> = { let mut names = std::collections::HashSet::new(); - let lineups = self.providers.load(); - for lineup in lineups.iter() { + let snapshot = self.snapshot.load(); + for lineup in &snapshot.providers { match lineup { ProviderLineup::Single(s) => { names.insert(s.provider.name.clone()); } ProviderLineup::Multi(m) => { @@ -729,8 +740,8 @@ impl ProviderLineupManager { } pub async fn force_exact_acquire_connection(&self, provider_name: &Arc) -> ProviderAllocation { - let providers = self.providers.load(); - let allocation = match Self::get_provider_config_by_name(provider_name, &providers) { + let snapshot = self.snapshot.load_full(); + let allocation = match Self::get_provider_config_by_name(provider_name, &snapshot.providers) { None => ProviderAllocation::Exhausted, // No Name matched, we don't have this provider Some((_lineup, config)) => config.force_allocate().await, }; @@ -746,9 +757,9 @@ impl ProviderLineupManager { provider_name: &Arc, allow_grace: bool, ) -> ProviderAllocation { - let providers = self.providers.load(); + let snapshot = self.snapshot.load_full(); let with_grace = allow_grace && self.grace_period_millis.load(Ordering::Acquire) > 0; - let allocation = match Self::get_provider_config_by_name(provider_name, &providers) { + let allocation = match Self::get_provider_config_by_name(provider_name, &snapshot.providers) { None => ProviderAllocation::Exhausted, Some((_lineup, config)) => { config @@ -774,8 +785,8 @@ impl ProviderLineupManager { input_name: &Arc, allow_grace: bool, ) -> ProviderAllocation { - let providers = self.providers.load(); - let lineup_opt = Self::get_provider_config_by_name(input_name, &providers); + let snapshot = self.snapshot.load_full(); + let lineup_opt = Self::get_provider_config_by_name(input_name, &snapshot.providers); let with_grace = allow_grace && self.grace_period_millis.load(Ordering::Acquire) > 0; let allocation = match lineup_opt { None => ProviderAllocation::Exhausted, // No Name matched, we don't have this provider @@ -837,8 +848,8 @@ impl ProviderLineupManager { // This method is used for redirects to cycle through provider // pub async fn get_next_provider(&self, input_name: &Arc) -> Option> { - let providers = self.providers.load(); - match Self::get_provider_config_by_name(input_name, &providers) { + let snapshot = self.snapshot.load_full(); + match Self::get_provider_config_by_name(input_name, &snapshot.providers) { None => None, Some((lineup, _config)) => { let cfg = lineup.get_next(self.grace_period_timeout_secs.load(Ordering::Relaxed)).await; @@ -859,8 +870,8 @@ impl ProviderLineupManager { result.insert(name.clone(), count); } }; - let providers = self.providers.load(); - for lineup in providers.iter() { + let snapshot = self.snapshot.load_full(); + for lineup in &snapshot.providers { match lineup { ProviderLineup::Single(provider_lineup) => { let connections = provider_lineup.provider.get_current_connections().await; @@ -881,8 +892,8 @@ impl ProviderLineupManager { pub async fn active_connection_count(&self) -> usize { let mut count = 0; - let providers = self.providers.load(); - for lineup in providers.iter() { + let snapshot = self.snapshot.load_full(); + for lineup in &snapshot.providers { match lineup { ProviderLineup::Single(provider_lineup) => { count += provider_lineup.provider.get_current_connections().await; @@ -896,8 +907,8 @@ impl ProviderLineupManager { } pub async fn is_over_limit(&self, provider_name: &Arc) -> bool { - let providers = self.providers.load(); - if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &providers) { + let snapshot = self.snapshot.load_full(); + if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &snapshot.providers) { config.is_over_limit(self.grace_period_timeout_secs.load(Ordering::Relaxed)).await } else { false @@ -905,8 +916,8 @@ impl ProviderLineupManager { } pub async fn is_exhausted(&self, provider_name: &Arc) -> bool { - let providers = self.providers.load(); - if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &providers) { + let snapshot = self.snapshot.load_full(); + if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &snapshot.providers) { config.is_exhausted().await } else { false @@ -914,8 +925,10 @@ impl ProviderLineupManager { } pub fn is_provider_for_input(&self, provider_name: &str, input_name: &str) -> bool { - let lineups = self.providers.load(); - if let Some((lineup, _)) = Self::get_provider_config_by_name(&provider_name.into(), &lineups) { + let snapshot = self.snapshot.load(); + if let Some((lineup, _)) = + Self::get_provider_config_by_name(&provider_name.into(), &snapshot.providers) + { match lineup { ProviderLineup::Single(_) => return input_name == provider_name, ProviderLineup::Multi(m) => return m.name.as_ref() == input_name, diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index 55485f3b1..284c9af71 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -1,5 +1,9 @@ use crate::api::model::TransportStreamBuffer; -use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput}; +use crate::model::{ + ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, + GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, ProxyUserCredentials, + ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput, +}; use crate::utils; use crate::utils::ffmpeg::check_ffprobe_availability; use arc_swap::{ArcSwap, ArcSwapOption}; @@ -448,7 +452,10 @@ impl AppConfig { } pub async fn is_ffprobe_enabled(&self) -> bool { - let ffprobe_enabled_in_config = self.config.load().video.as_ref().is_some_and(|v| v.ffprobe_enabled); + let ffprobe_enabled_in_config = { + let config = self.config.load(); + config.metadata_update.as_ref().is_some_and(|metadata| metadata.ffprobe_enabled) + }; if !ffprobe_enabled_in_config { return false; } diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index 1cd80897a..766c2df04 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -1,5 +1,8 @@ -use crate::model::{macros, ConfigApi, LibraryConfig, ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, ScheduleConfig}; -use crate::model::{HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LogConfig, MessagingConfig, ProxyConfig, VideoConfig, WebUiConfig}; +use crate::model::{ + macros, ConfigApi, HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LibraryConfig, LogConfig, MetadataUpdateConfig, + MessagingConfig, ProxyConfig, ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, ScheduleConfig, VideoConfig, + WebUiConfig, +}; use crate::utils; use log::{error, info}; use path_clean::PathClean; @@ -72,6 +75,7 @@ pub struct Config { pub template_path: Option, pub custom_stream_response_path: Option, pub video: Option, + pub metadata_update: Option, pub schedules: Option>, pub log: Option, pub user_access_control: bool, @@ -91,7 +95,7 @@ pub struct Config { } impl Config { - pub async fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { + pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { let work_dir = &self.working_dir; self.working_dir = utils::resolve_directory_path(work_dir); @@ -110,7 +114,7 @@ impl Config { } if let Some(video) = self.video.as_mut() { - video.prepare().await; + video.prepare(); } Ok(()) @@ -194,6 +198,7 @@ impl From<&ConfigDto> for Config { template_path: dto.template_path.clone(), custom_stream_response_path: dto.custom_stream_response_path.clone(), video: dto.video.as_ref().map(Into::into), + metadata_update: dto.metadata_update.as_ref().map(Into::into), schedules: dto.schedules.as_ref().map(|s| s.iter().map(Into::into).collect()), log: dto.log.as_ref().map(Into::into), user_access_control: dto.user_access_control, diff --git a/backend/src/model/config/metadata_update.rs b/backend/src/model/config/metadata_update.rs new file mode 100644 index 000000000..5c4ebc24e --- /dev/null +++ b/backend/src/model/config/metadata_update.rs @@ -0,0 +1,253 @@ +use crate::model::macros; +use shared::model::MetadataUpdateConfigDto; +use shared::utils::{ + default_metadata_ffprobe_analyze_duration, default_metadata_ffprobe_live_analyze_duration, + default_metadata_ffprobe_live_probe_size, default_metadata_ffprobe_probe_size, + default_metadata_max_resolve_retry_backoff, default_metadata_probe_cooldown, + default_metadata_probe_retry_backoff_step_1, default_metadata_probe_retry_backoff_step_2, + default_metadata_probe_retry_backoff_step_3, default_metadata_probe_retry_load_retry_delay, + default_metadata_progress_log_interval, default_metadata_queue_log_interval, + default_metadata_resolve_exhaustion_reset_gap, default_metadata_resolve_min_retry_base, + default_metadata_retry_delay, default_metadata_worker_idle_timeout, parse_duration_seconds, parse_size_base_2, +}; + +#[derive(Debug, Clone)] +pub struct MetadataUpdateConfig { + pub queue_log_interval: String, + pub queue_log_interval_secs: u64, + pub progress_log_interval: String, + pub progress_log_interval_secs: u64, + pub max_resolve_retry_backoff: String, + pub max_resolve_retry_backoff_secs: u64, + pub resolve_min_retry_base: String, + pub resolve_min_retry_base_secs: u64, + pub resolve_exhaustion_reset_gap: String, + pub resolve_exhaustion_reset_gap_secs: u64, + pub probe_cooldown: String, + pub probe_cooldown_secs: u64, + pub retry_delay: String, + pub retry_delay_secs: u64, + pub probe_retry_load_retry_delay: String, + pub probe_retry_load_retry_delay_secs: u64, + pub worker_idle_timeout: String, + pub worker_idle_timeout_secs: u64, + pub probe_retry_backoff_step_1: String, + pub probe_retry_backoff_step_1_secs: u64, + pub probe_retry_backoff_step_2: String, + pub probe_retry_backoff_step_2_secs: u64, + pub probe_retry_backoff_step_3: String, + pub probe_retry_backoff_step_3_secs: u64, + pub max_attempts_resolve: u8, + pub max_attempts_probe: u8, + pub backoff_jitter_percent: u8, + pub max_queue_size: usize, + pub ffprobe_enabled: bool, + pub ffprobe_timeout: Option, + pub ffprobe_analyze_duration: String, + pub ffprobe_analyze_duration_micros: u64, + pub ffprobe_probe_size: String, + pub ffprobe_probe_size_bytes: u64, + pub ffprobe_live_analyze_duration: String, + pub ffprobe_live_analyze_duration_micros: u64, + pub ffprobe_live_probe_size: String, + pub ffprobe_live_probe_size_bytes: u64, +} + +impl Default for MetadataUpdateConfig { + fn default() -> Self { Self::from(&MetadataUpdateConfigDto::default()) } +} + +macros::from_impl!(MetadataUpdateConfig); + +fn parse_duration_or_default(value: &str, default_value: &str, require_unit: bool) -> u64 { + parse_duration_seconds(value, require_unit) + .or_else(|| parse_duration_seconds(default_value, require_unit)) + .map_or(1, |v| v.max(1)) +} + +fn parse_size_or_default(value: &str, default_value: &str) -> u64 { + parse_size_base_2(value) + .ok() + .map(|v| v.max(1)) + .or_else(|| parse_size_base_2(default_value).ok().map(|v| v.max(1))) + .unwrap_or(1) +} + +struct ParsedMetadataUpdateNumbers { + queue_log_interval_secs: u64, + progress_log_interval_secs: u64, + max_resolve_retry_backoff_secs: u64, + resolve_min_retry_base_secs: u64, + resolve_exhaustion_reset_gap_secs: u64, + probe_cooldown_secs: u64, + retry_delay_secs: u64, + probe_retry_load_retry_delay_secs: u64, + worker_idle_timeout_secs: u64, + probe_retry_backoff_step_1_secs: u64, + probe_retry_backoff_step_2_secs: u64, + probe_retry_backoff_step_3_secs: u64, + ffprobe_analyze_duration_micros: u64, + ffprobe_probe_size_bytes: u64, + ffprobe_live_analyze_duration_micros: u64, + ffprobe_live_probe_size_bytes: u64, +} + +fn parse_numeric_fields(cfg: &MetadataUpdateConfigDto) -> ParsedMetadataUpdateNumbers { + let queue_log_interval_secs = + parse_duration_or_default(&cfg.queue_log_interval, &default_metadata_queue_log_interval(), false); + let progress_log_interval_secs = + parse_duration_or_default(&cfg.progress_log_interval, &default_metadata_progress_log_interval(), false); + let max_resolve_retry_backoff_secs = parse_duration_or_default( + &cfg.max_resolve_retry_backoff, + &default_metadata_max_resolve_retry_backoff(), + false, + ); + let resolve_min_retry_base_secs = + parse_duration_or_default(&cfg.resolve_min_retry_base, &default_metadata_resolve_min_retry_base(), false); + let resolve_exhaustion_reset_gap_secs = parse_duration_or_default( + &cfg.resolve_exhaustion_reset_gap, + &default_metadata_resolve_exhaustion_reset_gap(), + false, + ); + let probe_cooldown_secs = parse_duration_or_default(&cfg.probe_cooldown, &default_metadata_probe_cooldown(), false); + let retry_delay_secs = parse_duration_or_default(&cfg.retry_delay, &default_metadata_retry_delay(), false); + let probe_retry_load_retry_delay_secs = parse_duration_or_default( + &cfg.probe_retry_load_retry_delay, + &default_metadata_probe_retry_load_retry_delay(), + false, + ); + let worker_idle_timeout_secs = + parse_duration_or_default(&cfg.worker_idle_timeout, &default_metadata_worker_idle_timeout(), false); + let probe_retry_backoff_step_1_secs = parse_duration_or_default( + &cfg.probe_retry_backoff_step_1, + &default_metadata_probe_retry_backoff_step_1(), + false, + ); + let probe_retry_backoff_step_2_secs = parse_duration_or_default( + &cfg.probe_retry_backoff_step_2, + &default_metadata_probe_retry_backoff_step_2(), + false, + ); + let probe_retry_backoff_step_3_secs = parse_duration_or_default( + &cfg.probe_retry_backoff_step_3, + &default_metadata_probe_retry_backoff_step_3(), + false, + ); + let ffprobe_analyze_duration_micros = parse_duration_or_default( + &cfg.ffprobe_analyze_duration, + &default_metadata_ffprobe_analyze_duration(), + true, + ) + .saturating_mul(1_000_000); + let ffprobe_probe_size_bytes = + parse_size_or_default(&cfg.ffprobe_probe_size, &default_metadata_ffprobe_probe_size()); + let ffprobe_live_analyze_duration_micros = parse_duration_or_default( + &cfg.ffprobe_live_analyze_duration, + &default_metadata_ffprobe_live_analyze_duration(), + true, + ) + .saturating_mul(1_000_000); + let ffprobe_live_probe_size_bytes = + parse_size_or_default(&cfg.ffprobe_live_probe_size, &default_metadata_ffprobe_live_probe_size()); + + ParsedMetadataUpdateNumbers { + queue_log_interval_secs, + progress_log_interval_secs, + max_resolve_retry_backoff_secs, + resolve_min_retry_base_secs, + resolve_exhaustion_reset_gap_secs, + probe_cooldown_secs, + retry_delay_secs, + probe_retry_load_retry_delay_secs, + worker_idle_timeout_secs, + probe_retry_backoff_step_1_secs, + probe_retry_backoff_step_2_secs, + probe_retry_backoff_step_3_secs, + ffprobe_analyze_duration_micros, + ffprobe_probe_size_bytes, + ffprobe_live_analyze_duration_micros, + ffprobe_live_probe_size_bytes, + } +} + +impl From<&MetadataUpdateConfigDto> for MetadataUpdateConfig { + fn from(dto: &MetadataUpdateConfigDto) -> Self { + // ConfigDto::prepare() should already normalize/validate, but conversion stays defensive. + let mut normalized = dto.clone(); + if normalized.prepare().is_err() { + normalized = MetadataUpdateConfigDto::default(); + let _ = normalized.prepare(); + } + let parsed = parse_numeric_fields(&normalized); + + Self { + queue_log_interval: normalized.queue_log_interval, + queue_log_interval_secs: parsed.queue_log_interval_secs, + progress_log_interval: normalized.progress_log_interval, + progress_log_interval_secs: parsed.progress_log_interval_secs, + max_resolve_retry_backoff: normalized.max_resolve_retry_backoff, + max_resolve_retry_backoff_secs: parsed.max_resolve_retry_backoff_secs, + resolve_min_retry_base: normalized.resolve_min_retry_base, + resolve_min_retry_base_secs: parsed.resolve_min_retry_base_secs, + resolve_exhaustion_reset_gap: normalized.resolve_exhaustion_reset_gap, + resolve_exhaustion_reset_gap_secs: parsed.resolve_exhaustion_reset_gap_secs, + probe_cooldown: normalized.probe_cooldown, + probe_cooldown_secs: parsed.probe_cooldown_secs, + retry_delay: normalized.retry_delay, + retry_delay_secs: parsed.retry_delay_secs, + probe_retry_load_retry_delay: normalized.probe_retry_load_retry_delay, + probe_retry_load_retry_delay_secs: parsed.probe_retry_load_retry_delay_secs, + worker_idle_timeout: normalized.worker_idle_timeout, + worker_idle_timeout_secs: parsed.worker_idle_timeout_secs, + probe_retry_backoff_step_1: normalized.probe_retry_backoff_step_1, + probe_retry_backoff_step_1_secs: parsed.probe_retry_backoff_step_1_secs, + probe_retry_backoff_step_2: normalized.probe_retry_backoff_step_2, + probe_retry_backoff_step_2_secs: parsed.probe_retry_backoff_step_2_secs, + probe_retry_backoff_step_3: normalized.probe_retry_backoff_step_3, + probe_retry_backoff_step_3_secs: parsed.probe_retry_backoff_step_3_secs, + max_attempts_resolve: normalized.max_attempts_resolve, + max_attempts_probe: normalized.max_attempts_probe, + backoff_jitter_percent: normalized.backoff_jitter_percent, + max_queue_size: normalized.max_queue_size, + ffprobe_enabled: normalized.ffprobe_enabled, + ffprobe_timeout: normalized.ffprobe_timeout, + ffprobe_analyze_duration: normalized.ffprobe_analyze_duration, + ffprobe_analyze_duration_micros: parsed.ffprobe_analyze_duration_micros, + ffprobe_probe_size: normalized.ffprobe_probe_size, + ffprobe_probe_size_bytes: parsed.ffprobe_probe_size_bytes, + ffprobe_live_analyze_duration: normalized.ffprobe_live_analyze_duration, + ffprobe_live_analyze_duration_micros: parsed.ffprobe_live_analyze_duration_micros, + ffprobe_live_probe_size: normalized.ffprobe_live_probe_size, + ffprobe_live_probe_size_bytes: parsed.ffprobe_live_probe_size_bytes, + } + } +} + +impl From<&MetadataUpdateConfig> for MetadataUpdateConfigDto { + fn from(instance: &MetadataUpdateConfig) -> Self { + Self { + queue_log_interval: instance.queue_log_interval.clone(), + progress_log_interval: instance.progress_log_interval.clone(), + max_resolve_retry_backoff: instance.max_resolve_retry_backoff.clone(), + resolve_min_retry_base: instance.resolve_min_retry_base.clone(), + resolve_exhaustion_reset_gap: instance.resolve_exhaustion_reset_gap.clone(), + probe_cooldown: instance.probe_cooldown.clone(), + retry_delay: instance.retry_delay.clone(), + probe_retry_load_retry_delay: instance.probe_retry_load_retry_delay.clone(), + worker_idle_timeout: instance.worker_idle_timeout.clone(), + probe_retry_backoff_step_1: instance.probe_retry_backoff_step_1.clone(), + probe_retry_backoff_step_2: instance.probe_retry_backoff_step_2.clone(), + probe_retry_backoff_step_3: instance.probe_retry_backoff_step_3.clone(), + max_attempts_resolve: instance.max_attempts_resolve, + max_attempts_probe: instance.max_attempts_probe, + backoff_jitter_percent: instance.backoff_jitter_percent, + max_queue_size: instance.max_queue_size, + ffprobe_enabled: instance.ffprobe_enabled, + ffprobe_timeout: instance.ffprobe_timeout, + ffprobe_analyze_duration: instance.ffprobe_analyze_duration.clone(), + ffprobe_probe_size: instance.ffprobe_probe_size.clone(), + ffprobe_live_analyze_duration: instance.ffprobe_live_analyze_duration.clone(), + ffprobe_live_probe_size: instance.ffprobe_live_probe_size.clone(), + } + } +} diff --git a/backend/src/model/config/mod.rs b/backend/src/model/config/mod.rs index 097a61e35..917cd4b96 100644 --- a/backend/src/model/config/mod.rs +++ b/backend/src/model/config/mod.rs @@ -3,6 +3,7 @@ mod base; mod web_ui; mod web_auth; mod messaging; +mod metadata_update; mod hdhomerun; mod ip_check; mod source; @@ -45,6 +46,7 @@ pub use input::*; pub use ip_check::*; pub use log::*; pub use messaging::*; +pub use metadata_update::*; pub use proxy::*; pub use rate_limit::*; pub use rename::*; diff --git a/backend/src/model/config/video_download.rs b/backend/src/model/config/video_download.rs index 233a83ac5..8b8c2315e 100644 --- a/backend/src/model/config/video_download.rs +++ b/backend/src/model/config/video_download.rs @@ -1,5 +1,4 @@ use crate::model::macros; -use crate::utils::ffmpeg::check_ffprobe_availability; use regex::Regex; use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; use std::collections::HashMap; @@ -43,21 +42,10 @@ pub struct VideoConfig { pub extensions: Vec, pub download: Option, pub web_search: Option, - pub ffprobe_enabled: bool, - pub ffprobe_timeout: Option, } impl VideoConfig { - pub async fn prepare(&mut self) { - if self.ffprobe_enabled { - let is_available = check_ffprobe_availability().await; - - if !is_available { - log::warn!("FFprobe is not available. Disabling video processing."); - self.ffprobe_enabled = false; - } - } - } + pub fn prepare(&mut self) {} } macros::from_impl!(VideoConfig); @@ -67,8 +55,6 @@ impl From<&VideoConfigDto> for VideoConfig { extensions: dto.extensions.clone(), download: dto.download.as_ref().map(Into::into), web_search: dto.web_search.clone(), - ffprobe_enabled: dto.ffprobe_enabled, - ffprobe_timeout: dto.ffprobe_timeout, } } } @@ -79,8 +65,6 @@ impl From<&VideoConfig> for VideoConfigDto { extensions: instance.extensions.clone(), download: instance.download.as_ref().map(Into::into), web_search: instance.web_search.clone(), - ffprobe_enabled: instance.ffprobe_enabled, - ffprobe_timeout: instance.ffprobe_timeout, } } -} \ No newline at end of file +} diff --git a/backend/src/processing/processor/mod.rs b/backend/src/processing/processor/mod.rs index 1124cd2f8..243cbfbb7 100644 --- a/backend/src/processing/processor/mod.rs +++ b/backend/src/processing/processor/mod.rs @@ -43,6 +43,10 @@ impl Default for ResolveOptions { } } +pub(crate) const FOREGROUND_BATCH_SIZE: usize = 200; +pub(crate) const FOREGROUND_RETRY_BATCH_MAX_SIZE: usize = FOREGROUND_BATCH_SIZE * 4; +pub(crate) const FOREGROUND_MIN_RETRY_DELAY_SECS: u64 = 1; + // // fn get_resolve__options(target: &ConfigTarget, fpl: &FetchedPlaylist) -> bool @@ -110,4 +114,171 @@ macro_rules! create_resolve_options_function_for_xtream_target { }; } use create_resolve_options_function_for_xtream_target; + +/// Foreground retry helper that retries each queued item at most once. +/// +/// `retry_delay_secs` is applied sequentially per item, so the total wall-clock +/// delay is roughly `retry_delay_secs * retry_item_count` plus network/DB time. +macro_rules! process_foreground_retry_once { + ( + ctx: $ctx:expr, + fpl: $fpl:expr, + filter: $filter:expr, + retry_once_ids: $retry_once_ids:ident, + retry_delay_secs: $retry_delay_secs:expr, + xtream_path: $xtream_path:ident, + db_query_holder: $db_query_holder:ident, + db_lock_holder: $db_lock_holder:ident, + batch: $batch:ident, + batch_size: $batch_size:expr, + retry_batch_max_len: $retry_batch_max_len:expr, + processed_count: $processed_count:ident, + query_error_context: $query_error_context:expr, + reasons: |$pli_reasons:ident| $reasons_expr:expr, + update: |$active_provider:ident, $pli_update:ident, $provider_id:ident, $reasons:ident, $db_query_ref:ident| $update_expr:expr, + apply_properties: |$pli_apply:ident, $updated_props:ident| $apply_expr:expr, + persist: |$updates:ident| $persist_expr:expr, + on_persist_error: |$persist_err:ident| $on_persist_error_expr:expr, + on_retry_error: |$pli_error:ident, $retry_err:ident| $on_retry_error_expr:expr, + on_after_attempt: |$pli_after:ident, $retry_succeeded:ident| $on_after_attempt_expr:expr $(,)? + ) => { + for __pli in $fpl.items_mut() { + if !($filter)(__pli) { + continue; + } + + let __provider_id = if let Ok(__uid) = __pli.header.id.parse::() { + crate::api::model::ProviderIdType::Id(__uid) + } else { + crate::api::model::ProviderIdType::from(&*__pli.header.id) + }; + + if !$retry_once_ids.remove(&__provider_id) { + continue; + } + + let mut __retry_succeeded = false; + let __reasons = { + let $pli_reasons = &mut *__pli; + $reasons_expr + }; + + if !__reasons.is_empty() { + if let Some(__active_provider) = $ctx.provider_manager.as_ref() { + // Do not hold a read lock over the retry delay window. + if $db_query_holder.is_some() { + $db_query_holder = None; + $db_lock_holder = None; + } + + tokio::time::sleep(std::time::Duration::from_secs($retry_delay_secs)).await; + + if $db_query_holder.is_none() && $xtream_path.exists() { + let __file_lock = $ctx.config.file_locks.read_lock(&$xtream_path).await; + let __xtream_path = $xtream_path.clone(); + let __query = match tokio::task::spawn_blocking(move || { + crate::repository::BPlusTreeQuery::::try_new( + &__xtream_path, + ) + }) + .await + { + Ok(Ok(__query)) => Some((__query, __file_lock)), + Ok(Err(__err)) => { + log::error!("Failed to open BPlusTreeQuery for {}: {__err}", $query_error_context); + None + } + Err(__err) => { + log::error!("Failed to open BPlusTreeQuery for {}: {__err}", $query_error_context); + None + } + }; + + if let Some((__query, __guard)) = __query { + $db_query_holder = Some(std::sync::Arc::new(parking_lot::Mutex::new(__query))); + $db_lock_holder = Some(__guard); + } + } + + let __db_query_ref = $db_query_holder.as_ref().map(std::sync::Arc::clone); + let __update_future = { + let $active_provider = __active_provider; + let $pli_update = &mut *__pli; + let $provider_id = __provider_id.clone(); + let $reasons = &__reasons; + let $db_query_ref = __db_query_ref; + $update_expr + }; + + match __update_future.await { + Ok(Some(__updated_props)) => { + { + let $pli_apply = &mut *__pli; + let $updated_props = &__updated_props; + $apply_expr + } + + $batch.push((__provider_id.clone(), __updated_props)); + + if $batch.len() >= $batch_size { + $db_query_holder = None; + $db_lock_holder = None; + + let __updates: Vec<(u32, _)> = $batch + .iter() + .filter_map(|(__id, __props)| { + // Foreground retry batches can include text provider IDs (e.g. M3U). + // Persist batch functions for these paths are keyed by numeric Xtream IDs, + // so text IDs are intentionally skipped here. + if let crate::api::model::ProviderIdType::Id(__vid) = __id { + Some((*__vid, __props.clone())) + } else { + None + } + }) + .collect(); + + if __updates.is_empty() { + $batch.clear(); + } else { + let __persist_future = { + let $updates = __updates; + $persist_expr + }; + match __persist_future.await { + Ok(()) => $batch.clear(), + Err($persist_err) => { + $on_persist_error_expr; + if $batch.len() > $retry_batch_max_len { + let __drop_count = $batch.len().saturating_sub($retry_batch_max_len); + if __drop_count > 0 { + $batch.drain(0..__drop_count); + } + } + } + } + } + } + + $processed_count += 1; + __retry_succeeded = true; + } + Ok(None) => {} + Err($retry_err) => { + let $pli_error = &*__pli; + $on_retry_error_expr; + } + } + } + } + + { + let $pli_after = &mut *__pli; + let $retry_succeeded = __retry_succeeded; + $on_after_attempt_expr; + } + } + }; +} +pub(crate) use process_foreground_retry_once; use shared::utils::default_resolve_delay_secs; diff --git a/backend/src/processing/processor/stream_probe.rs b/backend/src/processing/processor/stream_probe.rs index f97c9fa6f..01407b3b0 100644 --- a/backend/src/processing/processor/stream_probe.rs +++ b/backend/src/processing/processor/stream_probe.rs @@ -15,6 +15,13 @@ enum ProbeStorageKind { Xtream, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GenericProbeOutcome { + Updated, + Noop, + ProbeFailed, +} + fn requires_provider_connection_for_generic_probe(input_type: InputType) -> bool { !matches!(input_type, InputType::Library) } @@ -31,13 +38,13 @@ pub async fn update_generic_stream_metadata( item_type: PlaylistItemType, active_provider: &Arc, active_handle: Option<&crate::api::model::ProviderHandle>, -) -> Result<(), TuliproxError> { +) -> Result { let working_dir = &app_config.config.load().working_dir; // Check if probing is enabled globally let ffprobe_enabled = app_config.is_ffprobe_enabled().await; if !ffprobe_enabled { - return Ok(()); + return Ok(GenericProbeOutcome::Noop); } // Determine storage file path based on input type @@ -62,7 +69,7 @@ pub async fn update_generic_stream_metadata( XtreamCluster::Series } else { // Generic probing currently supports live/video/series payload shapes. - return Ok(()); + return Ok(GenericProbeOutcome::Noop); }; ( xtream_get_file_path(&storage_path, cluster), @@ -90,10 +97,20 @@ pub async fn update_generic_stream_metadata( let probe_url = stream_url.to_string(); let config = app_config.config.load(); - let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let metadata_update = config.metadata_update.clone().unwrap_or_default(); + let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); - let analyze_duration = 10_000_000; - let probe_size = 10_000_000; + let (analyze_duration, probe_size) = if item_type.is_live() { + ( + metadata_update.ffprobe_live_analyze_duration_micros, + metadata_update.ffprobe_live_probe_size_bytes, + ) + } else { + ( + metadata_update.ffprobe_analyze_duration_micros, + metadata_update.ffprobe_probe_size_bytes, + ) + }; debug_if_enabled!("Probing Generic Stream '{unique_id}'"); @@ -111,96 +128,106 @@ pub async fn update_generic_stream_metadata( let Some((_quality, raw_video, raw_audio)) = probe_data else { warn!("Probe failed or timed out for generic stream: {unique_id}"); - return Ok(()); + return Ok(GenericProbeOutcome::ProbeFailed); }; - // Acquire lock and open a tree for update - let _file_lock = app_config.file_locks.write_lock(&db_path).await; + // Hold the async file lock while the blocking DB update runs in a blocking thread. + let file_lock = app_config.file_locks.write_lock(&db_path).await; + let db_path_for_update = db_path.clone(); + let unique_id_for_update = unique_id.to_string(); + let updated = tokio::task::spawn_blocking(move || -> Result { + let mut updated = false; + match storage_kind { + ProbeStorageKind::M3u => { + let key: Arc = Arc::from(unique_id_for_update.as_str()); + let mut tree_update = BPlusTreeUpdate::, M3uPlaylistItem>::try_new(&db_path_for_update) + .map_err(|e| format!("Failed to open M3U tree update: {e}"))?; - // Update the record in BPlusTree - match storage_kind { - ProbeStorageKind::M3u => { - let key: Arc = unique_id.into(); - let mut tree_update = BPlusTreeUpdate::, M3uPlaylistItem>::try_new(&db_path) - .map_err(|e| shared::error::info_err!("Failed to open M3U tree update: {e}"))?; + if let Some(mut item) = tree_update.query(&key).map_err(|e| format!("Tree query error: {e}"))? { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&key, item) + .map_err(|e| format!("Tree update error: {e}"))?; + info!("Successfully updated M3U metadata for: {unique_id_for_update}"); + updated = true; + } else { + warn!("Item not found in M3U DB: {unique_id_for_update}"); + } + } + ProbeStorageKind::Library => { + let mut tree_update = BPlusTreeUpdate::::try_new(&db_path_for_update) + .map_err(|e| format!("Failed to open Library tree update: {e}"))?; + let uuid = UUIDType::from_valid_uuid(&unique_id_for_update); - if let Some(mut item) = tree_update - .query(&key) - .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? - { - update_properties( - &mut item.additional_properties, - item_type, - &item.name, - item.virtual_id, - raw_video, - raw_audio, - ); - tree_update - .update(&key, item) - .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; - info!("Successfully updated M3U metadata for: {unique_id}"); - } else { - warn!("Item not found in M3U DB: {unique_id}"); + if let Some(mut item) = tree_update.query(&uuid).map_err(|e| format!("Tree query error: {e}"))? { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&uuid, item) + .map_err(|e| format!("Tree update error: {e}"))?; + info!("Successfully updated Library metadata for: {unique_id_for_update}"); + updated = true; + } else { + warn!("Item not found in Library DB: {unique_id_for_update}"); + } + } + ProbeStorageKind::Xtream => { + let Ok(provider_id) = unique_id_for_update.parse::() else { + warn!("Skipping xtream generic probe update with non-numeric id: {unique_id_for_update}"); + return Ok(false); + }; + + let mut tree_update = BPlusTreeUpdate::::try_new(&db_path_for_update) + .map_err(|e| format!("Failed to open Xtream tree update: {e}"))?; + + if let Some(mut item) = tree_update + .query(&provider_id) + .map_err(|e| format!("Tree query error: {e}"))? + { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&provider_id, item) + .map_err(|e| format!("Tree update error: {e}"))?; + info!("Successfully updated Xtream metadata for: {unique_id_for_update}"); + updated = true; + } else { + warn!("Item not found in Xtream DB: {unique_id_for_update}"); + } } } - ProbeStorageKind::Library => { - let mut tree_update = BPlusTreeUpdate::::try_new(&db_path) - .map_err(|e| shared::error::info_err!("Failed to open Library tree update: {e}"))?; - let uuid = UUIDType::from_valid_uuid(unique_id); - if let Some(mut item) = tree_update - .query(&uuid) - .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? - { - update_properties( - &mut item.additional_properties, - item_type, - &item.name, - item.virtual_id, - raw_video, - raw_audio, - ); - tree_update - .update(&uuid, item) - .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; - info!("Successfully updated Library metadata for: {unique_id}"); - } else { - warn!("Item not found in Library DB: {unique_id}"); - } - } - ProbeStorageKind::Xtream => { - let Ok(provider_id) = unique_id.parse::() else { - warn!("Skipping xtream generic probe update with non-numeric id: {unique_id}"); - return Ok(()); - }; + Ok(updated) + }) + .await + .map_err(|e| shared::error::info_err!("Failed to join generic probe DB update task: {e}"))? + .map_err(|e| shared::error::info_err!("{e}"))?; - let mut tree_update = BPlusTreeUpdate::::try_new(&db_path) - .map_err(|e| shared::error::info_err!("Failed to open Xtream tree update: {e}"))?; - - if let Some(mut item) = tree_update - .query(&provider_id) - .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? - { - update_properties( - &mut item.additional_properties, - item_type, - &item.name, - item.virtual_id, - raw_video, - raw_audio, - ); - tree_update - .update(&provider_id, item) - .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; - info!("Successfully updated Xtream metadata for: {unique_id}"); - } else { - warn!("Item not found in Xtream DB: {unique_id}"); - } - } + drop(file_lock); + if updated { + Ok(GenericProbeOutcome::Updated) + } else { + Ok(GenericProbeOutcome::Noop) } - - Ok(()) } fn update_properties( diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index 19b433b3b..6cd24189d 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -7,7 +7,7 @@ use crate::repository::{get_input_storage_path, persist_input_live_info, BPlusTr use crate::utils::{debug_if_enabled}; use log::{debug, warn}; use crate::processing::parser::xtream::create_xtream_url; -use crate::api::model::ProviderIdType; +use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType}; /// Updates metadata for a single Live stream (primarily probing) #[allow(clippy::too_many_lines)] @@ -17,6 +17,8 @@ pub async fn update_live_stream_metadata( id: ProviderIdType, save: bool, db_query: Option>>>, + _active_handle: Option<&ProviderHandle>, + _active_provider: &Arc, ) -> Result, TuliproxError> { let working_dir = &app_config.config.load().working_dir; let storage_path = get_input_storage_path(&input.name, working_dir).await @@ -114,10 +116,11 @@ pub async fn update_live_stream_metadata( use_prefix, no_ext ); let config = app_config.config.load(); - let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let metadata_update = config.metadata_update.clone().unwrap_or_default(); + let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); - let analyze_duration = 5_000_000; - let probe_size = 5_000_000; + let analyze_duration = metadata_update.ffprobe_live_analyze_duration_micros; + let probe_size = metadata_update.ffprobe_live_probe_size_bytes; let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string()); debug!("Probing Live Stream ID {} for input {}", display_id, input.name); diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index cdb2c7023..cf80e84be 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -2,13 +2,15 @@ use crate::api::model::UpdateTask; use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, ResolveReason, ResolveReasonSet}; use crate::library::MetadataResolver; use crate::model::FetchedPlaylist; -use crate::model::{AppConfig, ConfigTarget}; +use crate::model::{AppConfig, ConfigTarget, MetadataUpdateConfig}; use crate::model::{ConfigInput, ConfigInputFlags, InputSource}; use crate::processing::parser::xtream::create_xtream_series_episode_url; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::processing::processor::playlist::{PlaylistProcessingContext, ProcessingPipe}; use crate::processing::processor::{ - create_resolve_options_function_for_xtream_target, ResolveOptions, ResolveOptionsFlags, + create_resolve_options_function_for_xtream_target, process_foreground_retry_once, ResolveOptions, + ResolveOptionsFlags, FOREGROUND_BATCH_SIZE as BATCH_SIZE, FOREGROUND_MIN_RETRY_DELAY_SECS, + FOREGROUND_RETRY_BATCH_MAX_SIZE as RETRY_BATCH_MAX_SIZE, }; use crate::ptt::ptt_parse_title; use crate::repository::persists_input_series_info; @@ -26,13 +28,31 @@ use shared::model::{ XtreamSeriesInfo, }; use shared::model::{PlaylistGroup, PlaylistItemType, XtreamCluster}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; create_resolve_options_function_for_xtream_target!(series); -const BATCH_SIZE: usize = 100; +#[derive(Debug, Clone, Copy)] +pub struct SeriesProbeSettings { + pub timeout_secs: u64, + pub analyze_duration_micros: u64, + pub probe_size_bytes: u64, +} + +impl SeriesProbeSettings { + pub fn from_metadata_update(metadata_update: Option<&MetadataUpdateConfig>) -> Self { + let defaults = MetadataUpdateConfig::default(); + Self { + timeout_secs: metadata_update.and_then(|cfg| cfg.ffprobe_timeout).unwrap_or(60), + analyze_duration_micros: metadata_update + .map_or(defaults.ffprobe_analyze_duration_micros, |cfg| cfg.ffprobe_analyze_duration_micros), + probe_size_bytes: metadata_update + .map_or(defaults.ffprobe_probe_size_bytes, |cfg| cfg.ffprobe_probe_size_bytes), + } + } +} #[allow(clippy::too_many_arguments)] pub async fn playlist_resolve_series( @@ -227,8 +247,13 @@ async fn process_immediate_series_info( let mut groups_to_add = Vec::new(); let mut batch: Vec<(ProviderIdType, SeriesStreamProperties)> = Vec::with_capacity(BATCH_SIZE); + let mut retry_once_ids: HashSet = HashSet::new(); let mut processed_count = 0; let mut last_log_time = Instant::now(); + let series_probe_settings = { + let config = ctx.config.config.load(); + SeriesProbeSettings::from_metadata_update(config.metadata_update.as_ref()) + }; for pli in fpl.items_mut() { if !filter(pli) { @@ -240,6 +265,7 @@ async fn process_immediate_series_info( } else { ProviderIdType::from(&*pli.header.id) }; + let mut defer_expand = false; if !skip_resolve { let reasons = check_resolve_reasons(resolve_options, resolve_tmdb_enabled, pli); @@ -250,13 +276,11 @@ async fn process_immediate_series_info( let file_lock = ctx.config.file_locks.read_lock(&xtream_path).await; let xtream_path = xtream_path.clone(); let query = match tokio::task::spawn_blocking(move || { - let guard = file_lock; - let query = BPlusTreeQuery::::try_new(&xtream_path)?; - Ok::<_, std::io::Error>((query, guard)) + BPlusTreeQuery::::try_new(&xtream_path) }) .await { - Ok(Ok((query, guard))) => Some((query, guard)), + Ok(Ok(query)) => Some((query, file_lock)), Ok(Err(err)) => { error!("Failed to open BPlusTreeQuery for Series: {err}"); None @@ -283,6 +307,7 @@ async fn process_immediate_series_info( provider_id.clone(), &reasons, db_query_ref, + series_probe_settings, ) .await { @@ -338,14 +363,18 @@ async fn process_immediate_series_info( Ok(None) => {} Err(e) => { error!("Failed to update Series metadata for {}: {e}", pli.header.title); + retry_once_ids.insert(provider_id.clone()); + defer_expand = true; } } } } } - if let Some(group_obj) = expand_series_item(pli, input) { - groups_to_add.push(group_obj); + if !defer_expand { + if let Some(group_obj) = expand_series_item(pli, input) { + groups_to_add.push(group_obj); + } } if log_enabled!(Level::Info) && last_log_time.elapsed().as_secs() >= 30 { @@ -354,6 +383,64 @@ async fn process_immediate_series_info( } } + if !retry_once_ids.is_empty() { + let retry_delay_secs = u64::from(resolve_options.resolve_delay).max(FOREGROUND_MIN_RETRY_DELAY_SECS); + process_foreground_retry_once!( + ctx: ctx, + fpl: fpl, + filter: filter, + retry_once_ids: retry_once_ids, + retry_delay_secs: retry_delay_secs, + xtream_path: xtream_path, + db_query_holder: db_query_holder, + db_lock_holder: _db_lock_holder, + batch: batch, + batch_size: BATCH_SIZE, + retry_batch_max_len: RETRY_BATCH_MAX_SIZE, + processed_count: processed_count, + query_error_context: "Series retry", + reasons: |retry_pli| { + if skip_resolve { + ResolveReasonSet::new() + } else { + check_resolve_reasons(resolve_options, resolve_tmdb_enabled, retry_pli) + } + }, + update: |active_provider, retry_pli, provider_id, reasons, db_query_ref| update_series_info_immediate( + ctx, + active_provider, + input, + retry_pli, + provider_id, + reasons, + db_query_ref, + series_probe_settings, + ), + apply_properties: |retry_pli, updated_props| { + retry_pli.header.additional_properties = + Some(StreamProperties::Series(Box::new(updated_props.clone()))); + }, + persist: |updates| persist_input_series_info_batch( + &ctx.config, + &storage_path, + XtreamCluster::Series, + &input.name, + updates, + ), + on_persist_error: |err| { + error!("persist_input_series_info_batch failed for Series retry on input '{}'. Error: {}", input.name, err); + }, + on_retry_error: |retry_pli, err| { + error!("Foreground retry failed for Series {}: {err}", retry_pli.header.title); + }, + on_after_attempt: |retry_pli, _retry_succeeded| { + if let Some(group_obj) = expand_series_item(retry_pli, input) { + groups_to_add.push(group_obj); + } + }, + ); + } + if !batch.is_empty() { // Release lock before final persist _db_lock_holder = None; @@ -408,6 +495,7 @@ fn expand_series_item(pli: &PlaylistItem, input: &ConfigInput) -> Option, @@ -416,8 +504,10 @@ async fn update_series_info_immediate( id: ProviderIdType, reasons: &ResolveReasonSet, db_query: Option>>>, + probe_settings: SeriesProbeSettings, ) -> Result, TuliproxError> { let fetch_info = reasons.contains(ResolveReason::Info); + let resolve_tmdb = reasons.contains(ResolveReason::Tmdb) || reasons.contains(ResolveReason::Date); update_series_metadata( &ctx.config, @@ -429,7 +519,9 @@ async fn update_series_info_immediate( Some(&pli.header.title), false, // save (we batch in caller) fetch_info, + resolve_tmdb, reasons.contains(ResolveReason::Probe), + probe_settings, db_query, ) .await @@ -438,12 +530,12 @@ async fn update_series_info_immediate( fn check_resolve_reasons( resolve_options: &ResolveOptions, resolve_tmdb_enabled: bool, - pli: &mut PlaylistItem, + pli: &PlaylistItem, ) -> ResolveReasonSet { let mut reasons = ResolveReasonSet::new(); - let needs_info = check_needs_info(resolve_options, pli, &mut reasons); - check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, needs_info, &mut reasons); + check_needs_info(resolve_options, pli, &mut reasons); + check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, &mut reasons); if resolve_options.has_flag(ResolveOptionsFlags::Probe) { check_needs_probe(pli, &mut reasons); @@ -451,34 +543,36 @@ fn check_resolve_reasons( reasons } -fn check_needs_info(resolve_options: &ResolveOptions, pli: &mut PlaylistItem, reasons: &mut ResolveReasonSet) -> bool { +fn check_needs_info(resolve_options: &ResolveOptions, pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { let needs_info = resolve_options.has_flag(ResolveOptionsFlags::Resolve) && !pli.has_details(); if needs_info { reasons.set(ResolveReason::Info); } - needs_info } fn check_resolve_tmdb( resolve_options: &ResolveOptions, resolve_tmdb_enabled: bool, - pli: &mut PlaylistItem, - needs_info: bool, + pli: &PlaylistItem, reasons: &mut ResolveReasonSet, ) { - if resolve_tmdb_enabled && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) && needs_info { - if let Some(StreamProperties::Series(series_stream_props)) = pli.header.additional_properties.as_ref() { - let has_tmdb = series_stream_props.tmdb.is_some(); - let has_date = series_stream_props.release_date.is_some(); - let title_present = !series_stream_props.name.is_empty() || !pli.header.title.is_empty(); + if resolve_tmdb_enabled && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) { + let (has_tmdb, has_date, title_present) = match pli.header.additional_properties.as_ref() { + Some(StreamProperties::Series(series_stream_props)) => ( + series_stream_props.tmdb.is_some(), + series_stream_props.release_date.is_some(), + !series_stream_props.name.is_empty() || !pli.header.title.is_empty(), + ), + None => (false, false, !pli.header.title.is_empty()), + _ => return, + }; - if title_present && (!has_tmdb || !has_date) { - if !has_tmdb { - reasons.set(ResolveReason::Tmdb); - } - if !has_date { - reasons.set(ResolveReason::Date); - } + if title_present && (!has_tmdb || !has_date) { + if !has_tmdb { + reasons.set(ResolveReason::Tmdb); + } + if !has_date { + reasons.set(ResolveReason::Date); } } } @@ -502,15 +596,15 @@ fn check_needs_probe(pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { reasons.set(ResolveReason::Probe); } } - /// Updates metadata for a single Series (Info + Episodes Probe) and persists it. /// /// # Arguments /// * `save` - If true, persists changes to the input database immediately (Instant strategy). /// If false, returns the properties so the caller can batch persist them (Bundled strategy). /// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. +/// * `resolve_tmdb` - If true, resolves missing TMDB/date metadata from available titles. /// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] pub async fn update_series_metadata( app_config: &Arc, client: &reqwest::Client, @@ -521,7 +615,9 @@ pub async fn update_series_metadata( playlist_title: Option<&str>, save: bool, fetch_info: bool, + resolve_tmdb: bool, do_probe: bool, + probe_settings: SeriesProbeSettings, db_query: Option>>>, ) -> Result, TuliproxError> { let working_dir = &app_config.config.load().working_dir; @@ -568,10 +664,9 @@ pub async fn update_series_metadata( } else { let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Series); if xtream_path.exists() { - let file_lock = app_config.file_locks.read_lock(&xtream_path).await; + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; let xtream_path = xtream_path.clone(); let item = match tokio::task::spawn_blocking(move || { - let _guard = file_lock; let mut query = BPlusTreeQuery::::try_new(&xtream_path)?; query.query_zero_copy(&series_id) }) @@ -657,7 +752,7 @@ pub async fn update_series_metadata( let resolve_tmdb_enabled = input.has_flag(ConfigInputFlags::ResolveTmdb); // 2. Resolve TMDB/Date if missing - if fetch_info + if resolve_tmdb && resolve_tmdb_enabled && (properties.tmdb.is_none() || properties.release_date.is_none()) && !properties.name.is_empty() @@ -721,10 +816,7 @@ pub async fn update_series_metadata( if let Some(details) = properties.details.as_mut() { if let Some(episodes) = details.episodes.as_mut() { let config = app_config.config.load(); - let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); let user_agent = config.default_user_agent.clone(); - let analyze_duration = 10_000_000; - let probe_size = 10_000_000; let input_url = input.url.as_str(); let input_username = input.username.as_deref().unwrap_or(""); @@ -767,9 +859,9 @@ pub async fn update_series_metadata( if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url( &episode_url, user_agent.as_deref(), - analyze_duration, - probe_size, - ffprobe_timeout, + probe_settings.analyze_duration_micros, + probe_settings.probe_size_bytes, + probe_settings.timeout_secs, ) .await { diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index cb8ae5dda..b708c3ea1 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -7,7 +7,9 @@ use crate::model::{AppConfig, ConfigTarget}; use crate::model::{ConfigInput, ConfigInputFlags}; use crate::processing::processor::playlist::PlaylistProcessingContext; use crate::processing::processor::{ - create_resolve_options_function_for_xtream_target, ResolveOptions, ResolveOptionsFlags, + create_resolve_options_function_for_xtream_target, process_foreground_retry_once, ResolveOptions, + ResolveOptionsFlags, FOREGROUND_BATCH_SIZE as BATCH_SIZE, FOREGROUND_MIN_RETRY_DELAY_SECS, + FOREGROUND_RETRY_BATCH_MAX_SIZE as RETRY_BATCH_MAX_SIZE, }; use crate::ptt::ptt_parse_title; use crate::repository::get_input_storage_path; @@ -23,14 +25,12 @@ use shared::model::{ MediaQuality, PlaylistEntry, PlaylistItem, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamVideoInfo, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; create_resolve_options_function_for_xtream_target!(vod); -const BATCH_SIZE: usize = 200; - #[allow(clippy::too_many_lines)] pub async fn playlist_resolve_vod( ctx: &PlaylistProcessingContext, @@ -162,6 +162,7 @@ async fn process_immediate_vod_info( let mut _db_lock_holder = None; let mut batch: Vec<(ProviderIdType, VideoStreamProperties)> = Vec::with_capacity(BATCH_SIZE); + let mut retry_once_ids: HashSet = HashSet::new(); let mut processed_count = 0; let mut last_log_time = Instant::now(); @@ -267,6 +268,7 @@ async fn process_immediate_vod_info( Ok(None) => {} Err(e) => { error!("Failed to update VOD metadata for {}: {e}", pli.header.title); + retry_once_ids.insert(provider_id.clone()); } } } @@ -278,6 +280,52 @@ async fn process_immediate_vod_info( } } + if !retry_once_ids.is_empty() { + let retry_delay_secs = u64::from(resolve_options.resolve_delay).max(FOREGROUND_MIN_RETRY_DELAY_SECS); + process_foreground_retry_once!( + ctx: ctx, + fpl: fpl, + filter: filter, + retry_once_ids: retry_once_ids, + retry_delay_secs: retry_delay_secs, + xtream_path: xtream_path, + db_query_holder: db_query_holder, + db_lock_holder: _db_lock_holder, + batch: batch, + batch_size: BATCH_SIZE, + retry_batch_max_len: RETRY_BATCH_MAX_SIZE, + processed_count: processed_count, + query_error_context: "VOD retry", + reasons: |retry_pli| check_resolve_reasons(&resolve_options, do_probe, resolve_tmdb_enabled, retry_pli), + update: |active_provider, retry_pli, provider_id, reasons, db_query_ref| update_vod_info_immediate( + ctx, + active_provider, + input, + retry_pli, + provider_id, + reasons, + db_query_ref, + ), + apply_properties: |retry_pli, updated_props| { + retry_pli.header.additional_properties = Some(StreamProperties::Video(Box::new(updated_props.clone()))); + }, + persist: |updates| persist_input_vod_info_batch( + &ctx.config, + &storage_path, + XtreamCluster::Video, + &input.name, + updates, + ), + on_persist_error: |err| { + error!("persist_input_vod_info_batch failed for VOD retry on input '{}'. Error: {err}", input.name); + }, + on_retry_error: |retry_pli, err| { + error!("Foreground retry failed for VOD {}: {err}", retry_pli.header.title); + }, + on_after_attempt: |_retry_pli, _retry_succeeded| {}, + ); + } + // Flush the remaining batch if bundled strategy if !batch.is_empty() { // Release lock before final persist @@ -312,9 +360,9 @@ fn check_resolve_reasons( // Check if we need to do anything for this item let mut reasons = ResolveReasonSet::default(); - let needs_info = check_needs_info(resolve_options, pli, &mut reasons); + check_needs_info(resolve_options, pli, &mut reasons); // TMDB check - check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, needs_info, &mut reasons); + check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, &mut reasons); // Probe check (independent of resolve info fetch; we still probe when A/V details are missing) if do_probe { @@ -352,10 +400,9 @@ fn check_resolve_tmdb( resolve_options: &ResolveOptions, resolve_tmdb_enabled: bool, pli: &PlaylistItem, - needs_info: bool, reasons: &mut ResolveReasonSet, ) { - if resolve_tmdb_enabled && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) && needs_info { + if resolve_tmdb_enabled && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) { if let Some(StreamProperties::Video(video_stream_props)) = pli.header.additional_properties.as_ref() { let has_tmdb = video_stream_props.tmdb.is_some(); let has_date = video_stream_props.details.as_ref().and_then(|d| d.release_date.as_ref()).is_some(); @@ -415,6 +462,8 @@ async fn update_vod_info_immediate( db_query: Option>>>, ) -> Result, TuliproxError> { let fetch_info = reasons.contains(ResolveReason::Info); + let resolve_tmdb = + fetch_info || reasons.contains(ResolveReason::Tmdb) || reasons.contains(ResolveReason::Date); update_vod_metadata( &ctx.config, @@ -426,6 +475,7 @@ async fn update_vod_info_immediate( Some(&pli.header.title), false, fetch_info, + resolve_tmdb, reasons.contains(ResolveReason::Probe), db_query, ) @@ -438,8 +488,9 @@ async fn update_vod_info_immediate( /// * `save` - If true, persists changes to the input database immediately (Instant strategy). /// If false, returns the properties so the caller can batch persist them (Bundled strategy). /// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. +/// * `resolve_tmdb` - If true, resolves missing TMDB/date metadata from available titles. /// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] pub async fn update_vod_metadata( app_config: &Arc, client: &reqwest::Client, @@ -450,6 +501,7 @@ pub async fn update_vod_metadata( playlist_title: Option<&str>, save: bool, fetch_info: bool, + resolve_tmdb: bool, do_probe: bool, db_query: Option>>>, ) -> Result, TuliproxError> { @@ -494,10 +546,9 @@ pub async fn update_vod_metadata( } else { let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Video); if xtream_path.exists() { - let file_lock = app_config.file_locks.read_lock(&xtream_path).await; + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; let xtream_path = xtream_path.clone(); let item = match tokio::task::spawn_blocking(move || { - let _guard = file_lock; let mut query = BPlusTreeQuery::::try_new(&xtream_path)?; query.query_zero_copy(&stream_id) }) @@ -603,7 +654,7 @@ pub async fn update_vod_metadata( let missing_tmdb = properties.tmdb.is_none(); let missing_date = properties.details.as_ref().and_then(|d| d.release_date.as_ref()).is_none(); - if fetch_info && resolve_tmdb_enabled && (missing_tmdb || missing_date) { + if resolve_tmdb && resolve_tmdb_enabled && (missing_tmdb || missing_date) { // Try local parsing first if missing_date && !properties.name.is_empty() { let meta_parse = ptt_parse_title(&properties.name); @@ -715,10 +766,11 @@ pub async fn update_vod_metadata( ); let config = app_config.config.load(); - let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let metadata_update = config.metadata_update.clone().unwrap_or_default(); + let ffprobe_timeout = metadata_update.ffprobe_timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); - let analyze_duration = 10_000_000; - let probe_size = 10_000_000; + let analyze_duration = metadata_update.ffprobe_analyze_duration_micros; + let probe_size = metadata_update.ffprobe_probe_size_bytes; // Acquire Connection logic let temp_handle = if active_handle.is_some() { diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 196b13bfa..f8900d496 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -548,7 +548,7 @@ pub async fn read_initial_app_config( let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; let mut config: Config = Config::from(config_dto); - config.prepare(config_path).await?; + config.prepare(config_path)?; config.update_runtime(); let mut app_config = AppConfig { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 3da6c8c18..376429350 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -89,6 +89,7 @@ "PROXY_CONFIG": "Proxy", "IP_CHECK_CONFIG": "IP Check", "VIDEO_CONFIG": "Video", + "METADATA_UPDATE_CONFIG": "Metadata Update", "PANEL_CONFIG": "Panel", "LIBRARY_CONFIG": "Library", "REVERSE": "Reverse", @@ -235,6 +236,26 @@ "USERLIST": "Userlist", "MAPPER": "Mapper", "SCRIPT": "Script", + "METADATA_QUEUE_LOG_INTERVAL": "Queue log interval", + "METADATA_PROGRESS_LOG_INTERVAL": "Progress log interval", + "METADATA_MAX_RESOLVE_RETRY_BACKOFF": "Max resolve retry backoff", + "METADATA_RESOLVE_MIN_RETRY_BASE": "Resolve min retry base", + "METADATA_RESOLVE_EXHAUSTION_RESET_GAP": "Resolve exhaustion reset gap", + "METADATA_PROBE_COOLDOWN": "Probe cooldown", + "METADATA_RETRY_DELAY": "Retry delay", + "METADATA_PROBE_RETRY_LOAD_RETRY_DELAY": "Probe state reload retry delay", + "METADATA_WORKER_IDLE_TIMEOUT": "Worker idle timeout", + "METADATA_PROBE_RETRY_BACKOFF_STEP_1": "Probe backoff step 1", + "METADATA_PROBE_RETRY_BACKOFF_STEP_2": "Probe backoff step 2", + "METADATA_PROBE_RETRY_BACKOFF_STEP_3": "Probe backoff step 3", + "METADATA_MAX_ATTEMPTS_RESOLVE": "Max resolve attempts", + "METADATA_MAX_ATTEMPTS_PROBE": "Max probe attempts", + "METADATA_BACKOFF_JITTER_PERCENT": "Backoff jitter percent", + "METADATA_MAX_QUEUE_SIZE": "Max queue size", + "METADATA_FFPROBE_ANALYZE_DURATION": "FFprobe analyze duration (e.g. 10s, 1m)", + "METADATA_FFPROBE_PROBE_SIZE": "FFprobe probe size (e.g. 10MB, 512KB)", + "METADATA_FFPROBE_LIVE_ANALYZE_DURATION": "FFprobe live analyze duration (e.g. 5s, 30s)", + "METADATA_FFPROBE_LIVE_PROBE_SIZE": "FFprobe live probe size (e.g. 5MB, 1MB)", "SCAN_DIRECTORIES": "Scan Directories", "ADD_DIRECTORY": "Add Directory", "SUPPORTED_EXTENSIONS": "Supported Extensions", @@ -817,6 +838,30 @@ "USER_CONFIG_DIR": "Configuration value for user config dir.", "WORKING_DIR": "Base working directory used by tuliprox." }, + "METADATA_UPDATE_CONFIG": { + "FFPROBE_ENABLED": "Enables or disables technical stream probing with ffprobe.\n\nWhen disabled, probe tasks are considered complete without reading the stream itself. Keep this disabled if you want minimum network load, and enable it when you need codec/resolution/audio metadata to be filled automatically.", + "FFPROBE_TIMEOUT": "Maximum runtime of one ffprobe call in seconds.\n\nIf a stream does not respond before this timeout, the probe attempt is treated as failed and follows retry rules.\n\nLeave empty to use the built-in default timeout (60 seconds).", + "FFPROBE_ANALYZE_DURATION": "How much media time ffprobe may analyze for VOD/Series/Episodes probes.\n\nHigher values can improve metadata detection on difficult files, but increase probe time and provider traffic.\n\nUse duration units like `5s`, `30s`, `1m`, `1h`.", + "FFPROBE_PROBE_SIZE": "How much data ffprobe may read for VOD/Series/Episodes probes.\n\nHigher values can improve detection reliability, especially on streams with delayed headers, but use more bandwidth.\n\nUse size units like `512KB`, `5MB`, `10MB`.", + "FFPROBE_LIVE_ANALYZE_DURATION": "How much media time ffprobe may analyze for Live probes.\n\nLive streams should usually use smaller values than VOD to keep probing fast and reduce waiting time.\n\nUse duration units like `2s`, `5s`, `10s`.", + "FFPROBE_LIVE_PROBE_SIZE": "How much data ffprobe may read for Live probes.\n\nSmaller values reduce load and speed up checks; larger values can help on unstable or delayed live streams.\n\nUse size units like `512KB`, `1MB`, `5MB`.", + "MAX_ATTEMPTS_RESOLVE": "Maximum number of resolve attempts per item inside one update cycle.\n\nAfter this limit is reached, the item is not retried again in the same cycle. A later cycle can start over after `resolve_exhaustion_reset_gap` has passed.", + "MAX_ATTEMPTS_PROBE": "Maximum number of probe attempts before an item enters cooldown.\n\nAfter the limit is reached, probing is paused for the configured `probe_cooldown`, then starts again from attempt 1.", + "BACKOFF_JITTER_PERCENT": "Adds random spread to retry delays to avoid many tasks retrying at exactly the same second.\n\nExample: with 20% jitter, retries are slightly shifted around the base delay. This reduces retry spikes under load.", + "RESOLVE_MIN_RETRY_BASE": "Minimum base delay used for resolve retries.\n\nEven if a task asks for an immediate retry, this minimum is enforced to prevent tight retry loops.\n\nUse duration units like `5s`, `30s`, `1m`.", + "MAX_RESOLVE_RETRY_BACKOFF": "Upper limit for resolve retry delay.\n\nExponential backoff grows on repeated failures, but never exceeds this value.\n\nUse duration units like `1m`, `10m`, `1h`.", + "PROBE_RETRY_BACKOFF_STEP_1": "Delay before the next probe after the first probe failure.\n\nThis is the first step of probe backoff.\n\nUse duration units like `10m`.", + "PROBE_RETRY_BACKOFF_STEP_2": "Delay before the next probe after the second probe failure.\n\nThis is the second step of probe backoff and is usually longer than step 1.\n\nUse duration units like `30m`.", + "PROBE_RETRY_BACKOFF_STEP_3": "Delay before the next probe after the third and later probe failures (until max attempts is reached).\n\nThis is the largest regular probe backoff step before cooldown.\n\nUse duration units like `1h`.", + "RETRY_DELAY": "Minimum delay for transient requeue scheduling.\n\nUse this to prevent immediate hot-loop retries when tasks are temporarily deferred.\n\nUse duration units like `2s`, `5s`, `10s`.", + "PROBE_RETRY_LOAD_RETRY_DELAY": "Delay before retrying to load persisted probe retry state after a load failure.\n\nThis controls how quickly the worker retries reading retry-state files if disk access failed previously.\n\nUse duration units like `30s`, `1m`, `5m`.", + "RESOLVE_EXHAUSTION_RESET_GAP": "Minimum time gap required before resolve exhaustion is reset for a new update cycle.\n\nThis avoids permanently failing items being retried too aggressively across frequent updates.\n\nUse duration units like `30m`, `1h`, `2h`.", + "PROBE_COOLDOWN": "Cooldown period after probe attempts are exhausted.\n\nDuring cooldown, the item is quarantined and not probed again. After cooldown ends, probe retries start from attempt 1 again.\n\nUse duration units like `1d`, `3d`, `7d`.", + "MAX_QUEUE_SIZE": "Maximum number of metadata tasks kept in memory for one input worker.\n\nWhen this limit is reached, additional tasks are rejected to protect system stability and memory usage.", + "QUEUE_LOG_INTERVAL": "How often queue-size status is logged.\n\nLower values produce more frequent operational logs; higher values reduce log noise.\n\nUse duration units like `15s`, `30s`, `1m`.", + "PROGRESS_LOG_INTERVAL": "How often progress logs are emitted while tasks are actively processed.\n\nLower values give more live visibility; higher values reduce log volume.\n\nUse duration units like `10s`, `30s`, `1m`.", + "WORKER_IDLE_TIMEOUT": "How long a metadata worker waits for new tasks while idle before it can stop itself.\n\nA lower value releases resources sooner; a higher value keeps workers warm for bursty workloads.\n\nUse duration units like `30s`, `1m`, `5m`." + }, "MESSAGING_CONFIG": { "NOTIFY_ON": "Configuration value for notify on." }, @@ -958,8 +1003,6 @@ }, "VIDEO_CONFIG": { "EXTENSIONS": "Allowed file extensions for this feature.", - "FFPROBE_ENABLED": "Configuration value for ffprobe enabled.", - "FFPROBE_TIMEOUT": "Configuration value for ffprobe timeout.", "WEB_SEARCH": "Configuration value for web search." }, "VIDEO_DOWNLOAD_CONFIG": { diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 90fdadeed..bb4dd8f24 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -555,5 +555,9 @@ { "keys": [ "Trakt" ], "path": "m 8.5148718,3.0007766 c -1.0450158,0.0016 -2.0347477,0.00569 -2.8165557,0.014198 -0.9749586,0.010626 -1.7535238,0.525551 -2.41504,1.1480853 -0.6615168,0.6225342 -1.2420491,1.3803092 -1.255398,2.3912657 -0.054734,4.1452564 -0.014494,8.0973684 0,11.3576864 0.00425,0.95513 0.5049474,1.64923 1.0794047,2.298813 0.5744572,0.64958 1.2158261,1.27916 2.248952,1.294359 3.8273691,0.05632 7.1951532,0.05445 11.0869282,0 0.900735,-0.01259 1.655548,-0.434309 2.373765,-0.966147 0.718218,-0.531839 1.46119,-1.152567 1.48026,-2.257537 0.01521,-0.881315 0,-2.321265 0,-2.321265 l -1.917106,0.02014 c 0,0 0.01418,1.445506 0,2.267444 0.0021,-0.123048 -0.217365,0.390857 -0.703974,0.751191 -0.48661,0.360334 -1.179113,0.58793 -1.260351,0.589066 -3.876942,0.05424 -7.2205623,0.05606 -11.0307952,0 0.088118,0.0013 -0.460611,-0.21638 -0.8410046,-0.64652 -0.3803932,-0.430138 -0.5993469,-1.048157 -0.5993021,-1.03813 -0.014538,-3.269123 -0.054357,-7.207571 0,-11.3243374 0.00121,-0.090137 0.2320137,-0.6233247 0.6527937,-1.0193089 0.4207806,-0.3959843 0.9958328,-0.6266544 1.1219997,-0.6280294 3.0843491,-0.033603 9.5518555,0 9.5518555,0 l 0.01023,-1.916777 c 0,0 -3.631619,-0.01899 -6.7666673,-0.014198 z M 20.503229,3.1183256 9.6966363,13.838738 5.6732212,9.8215966 4.7708006,10.725008 12.311113,18.256406 13.214855,17.351674 10.601369,14.742149 21.276215,4.1508427 C 21.059818,3.7752637 20.800627,3.4289481 20.503229,3.1183256 Z m 1.062566,1.6050741 -10.014458,9.9778053 2.182583,2.218905 0.911006,-0.894827 -1.29172,-1.315493 8.594953,-8.5635841 C 21.883198,5.6470001 21.755599,5.1687836 21.565795,4.7233997 Z M 22,7.0080126 14.287986,14.653657 15.187765,15.561031 22,8.8075706 Z M 8.4214271,7.119618 7.516365,8.0220384 l 3.48685,3.4954346 0.905062,-0.90242 z M 7.0359324,8.4862915 6.1325213,9.3897019 9.5969182,12.85641 10.500329,11.952999 Z" + }, + { + "keys": [ "Metadata" ], + "path": "M15.96 10.29l-2.75 3.54-1.96-2.36L8.5 15h11l-3.54-4.71zM3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" } ] diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index 7b1cb2a85..6899a8c92 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -2,8 +2,8 @@ use shared::{ error::{info_err_res, TuliproxError}, model::{ ApiProxyConfigDto, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, - MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, - VideoConfigDto, WebUiConfigDto, + MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, + SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto, }, }; use std::{fmt, str::FromStr}; @@ -21,6 +21,7 @@ pub const LABEL_IP_CHECK_CONFIG: &str = "LABEL.IP_CHECK_CONFIG"; pub const LABEL_VIDEO_CONFIG: &str = "LABEL.VIDEO_CONFIG"; pub const LABEL_PANEL_CONFIG: &str = "LABEL.PANEL_CONFIG"; pub const LABEL_LIBRARY_CONFIG: &str = "LABEL.LIBRARY_CONFIG"; +pub const LABEL_METADATA_UPDATE_CONFIG: &str = "LABEL.METADATA_UPDATE_CONFIG"; const MAIN_PAGE: &str = "main"; const API_PAGE: &str = "api"; @@ -33,6 +34,7 @@ const HDHOMERUN_PAGE: &str = "hdhomerun"; const PROXY_PAGE: &str = "proxy"; const IPCHECK_PAGE: &str = "ipcheck"; const VIDEO_PAGE: &str = "video"; +const METADATA_UPDATE_PAGE: &str = "metadata_update"; const PANEL_PAGE: &str = "panel"; const LIBRARY_PAGE: &str = "library"; @@ -43,6 +45,7 @@ pub enum ConfigPage { Log, Schedules, Video, + MetadataUpdate, Messaging, WebUi, ReverseProxy, @@ -63,6 +66,7 @@ impl FromStr for ConfigPage { LOG_PAGE => Ok(ConfigPage::Log), SCHEDULES_PAGE => Ok(ConfigPage::Schedules), VIDEO_PAGE => Ok(ConfigPage::Video), + METADATA_UPDATE_PAGE => Ok(ConfigPage::MetadataUpdate), MESSAGING_PAGE => Ok(ConfigPage::Messaging), WEBUI_PAGE => Ok(ConfigPage::WebUi), REVERSE_PROXY_PAGE => Ok(ConfigPage::ReverseProxy), @@ -84,6 +88,7 @@ impl fmt::Display for ConfigPage { ConfigPage::Log => LOG_PAGE, ConfigPage::Schedules => SCHEDULES_PAGE, ConfigPage::Video => VIDEO_PAGE, + ConfigPage::MetadataUpdate => METADATA_UPDATE_PAGE, ConfigPage::Messaging => MESSAGING_PAGE, ConfigPage::WebUi => WEBUI_PAGE, ConfigPage::ReverseProxy => REVERSE_PROXY_PAGE, @@ -105,6 +110,7 @@ pub enum ConfigForm { Log(bool, LogConfigDto), Schedules(bool, SchedulesConfigDto), Video(bool, VideoConfigDto), + MetadataUpdate(bool, MetadataUpdateConfigDto), Messaging(bool, MessagingConfigDto), WebUi(bool, WebUiConfigDto), ReverseProxy(bool, ReverseProxyConfigDto), @@ -125,6 +131,7 @@ impl ConfigForm { | ConfigForm::Log(true, _) | ConfigForm::Schedules(true, _) | ConfigForm::Video(true, _) + | ConfigForm::MetadataUpdate(true, _) | ConfigForm::Messaging(true, _) | ConfigForm::WebUi(true, _) | ConfigForm::ReverseProxy(true, _) @@ -145,6 +152,7 @@ pub struct ConfigFormSlots { pub log: Option, pub schedules: Option, pub video: Option, + pub metadata_update: Option, pub messaging: Option, pub web_ui: Option, pub reverse_proxy: Option, @@ -166,6 +174,7 @@ impl ConfigFormSlots { ConfigForm::Log(_, _) => Self::set_form_slot(&mut self.log, form), ConfigForm::Schedules(_, _) => Self::set_form_slot(&mut self.schedules, form), ConfigForm::Video(_, _) => Self::set_form_slot(&mut self.video, form), + ConfigForm::MetadataUpdate(_, _) => Self::set_form_slot(&mut self.metadata_update, form), ConfigForm::Messaging(_, _) => Self::set_form_slot(&mut self.messaging, form), ConfigForm::WebUi(_, _) => Self::set_form_slot(&mut self.web_ui, form), ConfigForm::ReverseProxy(_, _) => Self::set_form_slot(&mut self.reverse_proxy, form), @@ -177,7 +186,7 @@ impl ConfigFormSlots { } } - pub fn all_slots(&self) -> [&Option; 14] { + pub fn all_slots(&self) -> [&Option; 15] { [ &self.main, &self.api, @@ -185,6 +194,7 @@ impl ConfigFormSlots { &self.log, &self.schedules, &self.video, + &self.metadata_update, &self.messaging, &self.web_ui, &self.reverse_proxy, diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index a6dfbc32c..afb2a5ab0 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -100,6 +100,9 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { } } ConfigForm::Video(_, mut video_cfg) => set_config_field!(config, video_cfg, video), + ConfigForm::MetadataUpdate(_, mut metadata_update_cfg) => { + set_config_field!(config, metadata_update_cfg, metadata_update) + } ConfigForm::Messaging(_, mut messaging_cfg) => set_config_field!(config, messaging_cfg, messaging), ConfigForm::WebUi(_, web_ui_cfg) => update_webui_field(config, web_ui_cfg), ConfigForm::ReverseProxy(_, mut reverse_proxy_cfg) => { @@ -121,7 +124,7 @@ mod tests { use crate::app::components::config::config_page::ConfigForm; use shared::model::{ ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, LibraryConfigDto, - LibraryScanDirectoryDto, ProxyConfigDto, WebAuthConfigDto, WebUiConfigDto, + LibraryScanDirectoryDto, MetadataUpdateConfigDto, ProxyConfigDto, WebAuthConfigDto, WebUiConfigDto, }; #[test] @@ -222,4 +225,33 @@ mod tests { assert_eq!(web_ui.player_server.as_deref(), Some("http://player.local")); assert_eq!(web_ui.auth.as_ref().map(|auth| auth.secret.as_str()), Some("top-secret")); } + + #[test] + fn update_config_metadata_update_empty_form_clears_config() { + let mut config = ConfigDto::default(); + + update_config(&mut config, vec![ConfigForm::MetadataUpdate(true, MetadataUpdateConfigDto::default())]); + + assert!(config.metadata_update.is_none()); + } + + #[test] + fn update_config_metadata_update_applies_and_cleans_payload() { + let mut config = ConfigDto::default(); + let mut metadata_cfg = MetadataUpdateConfigDto { + ffprobe_enabled: true, + ffprobe_timeout: Some(60), + ..MetadataUpdateConfigDto::default() + }; + + assert!(!metadata_cfg.is_empty()); + metadata_cfg.clean(); + assert_eq!(metadata_cfg.ffprobe_timeout, None); + + update_config(&mut config, vec![ConfigForm::MetadataUpdate(true, metadata_cfg)]); + + let stored = config.metadata_update.as_ref().expect("metadata_update config should be set"); + assert!(stored.ffprobe_enabled); + assert_eq!(stored.ffprobe_timeout, None); + } } diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index 2fdf57032..7c3a2aeb9 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -5,14 +5,14 @@ use crate::{ config_page::{ ConfigForm, ConfigFormSlots, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LIBRARY_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, - LABEL_MESSAGING_CONFIG, LABEL_PANEL_CONFIG, LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, - LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG, + LABEL_MESSAGING_CONFIG, LABEL_METADATA_UPDATE_CONFIG, LABEL_PANEL_CONFIG, LABEL_PROXY_CONFIG, + LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG, }, config_update::update_config, config_view_context::ConfigViewContext, ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LibraryConfigView, LogConfigView, - MainConfigView, MessagingConfigView, PanelConfigView, ProxyConfigView, ReverseProxyConfigView, - SchedulesConfigView, VideoConfigView, WebUiConfigView, + MainConfigView, MessagingConfigView, MetadataUpdateConfigView, PanelConfigView, ProxyConfigView, + ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView, }, input::Input, validate_credentials, Card, TabItem, TabSet, TextButton, @@ -50,6 +50,7 @@ fn config_form_to_config_page(form: &ConfigForm) -> ConfigPage { ConfigForm::Log(_, _) => ConfigPage::Log, ConfigForm::Schedules(_, _) => ConfigPage::Schedules, ConfigForm::Video(_, _) => ConfigPage::Video, + ConfigForm::MetadataUpdate(_, _) => ConfigPage::MetadataUpdate, ConfigForm::Messaging(_, _) => ConfigPage::Messaging, ConfigForm::WebUi(_, _) => ConfigPage::WebUi, ConfigForm::ReverseProxy(_, _) => ConfigPage::ReverseProxy, @@ -122,6 +123,12 @@ pub fn ConfigView() -> Html { (ConfigPage::IpCheck, LABEL_IP_CHECK_CONFIG, html! { }, "IpCheckConfig"), (ConfigPage::Panel, LABEL_PANEL_CONFIG, html! { }, "Settings"), (ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { }, "VideoConfig"), + ( + ConfigPage::MetadataUpdate, + LABEL_METADATA_UPDATE_CONFIG, + html! { }, + "Metadata", + ), (ConfigPage::Library, LABEL_LIBRARY_CONFIG, html! { }, "VideoLibrary"), ]; diff --git a/frontend/src/app/components/config/metadata_update_config_view.rs b/frontend/src/app/components/config/metadata_update_config_view.rs new file mode 100644 index 000000000..600663611 --- /dev/null +++ b/frontend/src/app/components/config/metadata_update_config_view.rs @@ -0,0 +1,223 @@ +use crate::{ + app::{ + components::{ + config::{ + config_page::{ConfigForm, LABEL_METADATA_UPDATE_CONFIG}, + config_view_context::ConfigViewContext, + }, + dto_field_id, + number_input::NumberInput, + }, + context::ConfigContext, + }, + config_field, config_field_bool, config_field_optional, edit_field_bool, edit_field_number_option_u64, + edit_field_number_u8, edit_field_number_usize, edit_field_text, generate_form_reducer, + i18n::use_translation, +}; +use shared::model::MetadataUpdateConfigDto; +use yew::prelude::*; + +const LABEL_QUEUE_LOG_INTERVAL: &str = "LABEL.METADATA_QUEUE_LOG_INTERVAL"; +const LABEL_PROGRESS_LOG_INTERVAL: &str = "LABEL.METADATA_PROGRESS_LOG_INTERVAL"; +const LABEL_MAX_RESOLVE_RETRY_BACKOFF: &str = "LABEL.METADATA_MAX_RESOLVE_RETRY_BACKOFF"; +const LABEL_RESOLVE_MIN_RETRY_BASE: &str = "LABEL.METADATA_RESOLVE_MIN_RETRY_BASE"; +const LABEL_RESOLVE_EXHAUSTION_RESET_GAP: &str = "LABEL.METADATA_RESOLVE_EXHAUSTION_RESET_GAP"; +const LABEL_PROBE_COOLDOWN: &str = "LABEL.METADATA_PROBE_COOLDOWN"; +const LABEL_RETRY_DELAY: &str = "LABEL.METADATA_RETRY_DELAY"; +const LABEL_PROBE_RETRY_LOAD_RETRY_DELAY: &str = "LABEL.METADATA_PROBE_RETRY_LOAD_RETRY_DELAY"; +const LABEL_WORKER_IDLE_TIMEOUT: &str = "LABEL.METADATA_WORKER_IDLE_TIMEOUT"; +const LABEL_PROBE_RETRY_BACKOFF_STEP_1: &str = "LABEL.METADATA_PROBE_RETRY_BACKOFF_STEP_1"; +const LABEL_PROBE_RETRY_BACKOFF_STEP_2: &str = "LABEL.METADATA_PROBE_RETRY_BACKOFF_STEP_2"; +const LABEL_PROBE_RETRY_BACKOFF_STEP_3: &str = "LABEL.METADATA_PROBE_RETRY_BACKOFF_STEP_3"; +const LABEL_MAX_ATTEMPTS_RESOLVE: &str = "LABEL.METADATA_MAX_ATTEMPTS_RESOLVE"; +const LABEL_MAX_ATTEMPTS_PROBE: &str = "LABEL.METADATA_MAX_ATTEMPTS_PROBE"; +const LABEL_BACKOFF_JITTER_PERCENT: &str = "LABEL.METADATA_BACKOFF_JITTER_PERCENT"; +const LABEL_MAX_QUEUE_SIZE: &str = "LABEL.METADATA_MAX_QUEUE_SIZE"; +const LABEL_FFPROBE_ENABLED: &str = "LABEL.FFPROBE_ENABLED"; +const LABEL_FFPROBE_TIMEOUT: &str = "LABEL.FFPROBE_TIMEOUT"; +const LABEL_FFPROBE_ANALYZE_DURATION: &str = "LABEL.METADATA_FFPROBE_ANALYZE_DURATION"; +const LABEL_FFPROBE_PROBE_SIZE: &str = "LABEL.METADATA_FFPROBE_PROBE_SIZE"; +const LABEL_FFPROBE_LIVE_ANALYZE_DURATION: &str = "LABEL.METADATA_FFPROBE_LIVE_ANALYZE_DURATION"; +const LABEL_FFPROBE_LIVE_PROBE_SIZE: &str = "LABEL.METADATA_FFPROBE_LIVE_PROBE_SIZE"; + +generate_form_reducer!( + state: MetadataUpdateConfigFormState { form: MetadataUpdateConfigDto }, + action_name: MetadataUpdateConfigFormAction, + fields { + FfprobeEnabled => ffprobe_enabled: bool, + FfprobeTimeout => ffprobe_timeout: Option, + FfprobeAnalyzeDuration => ffprobe_analyze_duration: String, + FfprobeProbeSize => ffprobe_probe_size: String, + FfprobeLiveAnalyzeDuration => ffprobe_live_analyze_duration: String, + FfprobeLiveProbeSize => ffprobe_live_probe_size: String, + MaxAttemptsResolve => max_attempts_resolve: u8, + MaxAttemptsProbe => max_attempts_probe: u8, + BackoffJitterPercent => backoff_jitter_percent: u8, + ResolveMinRetryBase => resolve_min_retry_base: String, + MaxResolveRetryBackoff => max_resolve_retry_backoff: String, + ProbeRetryBackoffStep1 => probe_retry_backoff_step_1: String, + ProbeRetryBackoffStep2 => probe_retry_backoff_step_2: String, + ProbeRetryBackoffStep3 => probe_retry_backoff_step_3: String, + RetryDelay => retry_delay: String, + ProbeRetryLoadRetryDelay => probe_retry_load_retry_delay: String, + ResolveExhaustionResetGap => resolve_exhaustion_reset_gap: String, + ProbeCooldown => probe_cooldown: String, + MaxQueueSize => max_queue_size: usize, + QueueLogInterval => queue_log_interval: String, + ProgressLogInterval => progress_log_interval: String, + WorkerIdleTimeout => worker_idle_timeout: String, + } +); + +#[component] +pub fn MetadataUpdateConfigView() -> Html { + let translate = use_translation(); + let config_ctx = use_context::().expect("ConfigContext not found"); + let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + let backoff_jitter_error: UseStateHandle> = use_state(|| None); + + let form_state: UseReducerHandle = + use_reducer(|| MetadataUpdateConfigFormState { form: MetadataUpdateConfigDto::default(), modified: false }); + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.clone(), form_state.modified); + use_effect_with(deps, move |(state, modified)| { + on_form_change.emit(ConfigForm::MetadataUpdate(*modified, state.form.clone())); + }); + } + + { + let form_state = form_state.clone(); + let metadata_update_cfg = + config_ctx.config.as_ref().and_then(|c| c.config.metadata_update.clone()).unwrap_or_default(); + use_effect_with((metadata_update_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { + form_state.dispatch(MetadataUpdateConfigFormAction::SetAll(cfg.clone())); + || () + }); + } + + { + let form_state = form_state.clone(); + let backoff_jitter_error = backoff_jitter_error.clone(); + use_effect_with(form_state.form.backoff_jitter_percent, move |value| { + if *value > 100 { + backoff_jitter_error.set(Some("Backoff jitter percent must be between 0 and 100.".to_string())); + form_state.dispatch(MetadataUpdateConfigFormAction::BackoffJitterPercent(100)); + } else { + backoff_jitter_error.set(None); + } + || () + }); + } + + let render_view_mode = || { + html! { + <> + { config_field_bool!(form_state.form, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled) } + { config_field_optional!(form_state.form, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout) } + { config_field!(form_state.form, translate.t(LABEL_FFPROBE_ANALYZE_DURATION), ffprobe_analyze_duration) } + { config_field!(form_state.form, translate.t(LABEL_FFPROBE_PROBE_SIZE), ffprobe_probe_size) } + { config_field!(form_state.form, translate.t(LABEL_FFPROBE_LIVE_ANALYZE_DURATION), ffprobe_live_analyze_duration) } + { config_field!(form_state.form, translate.t(LABEL_FFPROBE_LIVE_PROBE_SIZE), ffprobe_live_probe_size) } + { config_field!(form_state.form, translate.t(LABEL_MAX_ATTEMPTS_RESOLVE), max_attempts_resolve) } + { config_field!(form_state.form, translate.t(LABEL_MAX_ATTEMPTS_PROBE), max_attempts_probe) } + { config_field!(form_state.form, translate.t(LABEL_BACKOFF_JITTER_PERCENT), backoff_jitter_percent) } + { config_field!(form_state.form, translate.t(LABEL_RESOLVE_MIN_RETRY_BASE), resolve_min_retry_base) } + { config_field!(form_state.form, translate.t(LABEL_MAX_RESOLVE_RETRY_BACKOFF), max_resolve_retry_backoff) } + { config_field!(form_state.form, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_1), probe_retry_backoff_step_1) } + { config_field!(form_state.form, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_2), probe_retry_backoff_step_2) } + { config_field!(form_state.form, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_3), probe_retry_backoff_step_3) } + { config_field!(form_state.form, translate.t(LABEL_RETRY_DELAY), retry_delay) } + { config_field!(form_state.form, translate.t(LABEL_PROBE_RETRY_LOAD_RETRY_DELAY), probe_retry_load_retry_delay) } + { config_field!(form_state.form, translate.t(LABEL_RESOLVE_EXHAUSTION_RESET_GAP), resolve_exhaustion_reset_gap) } + { config_field!(form_state.form, translate.t(LABEL_PROBE_COOLDOWN), probe_cooldown) } + { config_field!(form_state.form, translate.t(LABEL_MAX_QUEUE_SIZE), max_queue_size) } + { config_field!(form_state.form, translate.t(LABEL_QUEUE_LOG_INTERVAL), queue_log_interval) } + { config_field!(form_state.form, translate.t(LABEL_PROGRESS_LOG_INTERVAL), progress_log_interval) } + { config_field!(form_state.form, translate.t(LABEL_WORKER_IDLE_TIMEOUT), worker_idle_timeout) } + + } + }; + + let render_edit_mode = || { + let jitter_error_text = (*backoff_jitter_error).clone(); + let jitter_error_state = backoff_jitter_error.clone(); + let jitter_form_state = form_state.clone(); + let jitter_label = translate.t(LABEL_BACKOFF_JITTER_PERCENT); + let jitter_field_id = dto_field_id(&jitter_form_state.form, "backoff_jitter_percent"); + + html! { + <> + { edit_field_bool!(form_state, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled, MetadataUpdateConfigFormAction::FfprobeEnabled) } + { edit_field_number_option_u64!(form_state, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout, MetadataUpdateConfigFormAction::FfprobeTimeout) } + { edit_field_text!(form_state, translate.t(LABEL_FFPROBE_ANALYZE_DURATION), ffprobe_analyze_duration, MetadataUpdateConfigFormAction::FfprobeAnalyzeDuration) } + { edit_field_text!(form_state, translate.t(LABEL_FFPROBE_PROBE_SIZE), ffprobe_probe_size, MetadataUpdateConfigFormAction::FfprobeProbeSize) } + { edit_field_text!(form_state, translate.t(LABEL_FFPROBE_LIVE_ANALYZE_DURATION), ffprobe_live_analyze_duration, MetadataUpdateConfigFormAction::FfprobeLiveAnalyzeDuration) } + { edit_field_text!(form_state, translate.t(LABEL_FFPROBE_LIVE_PROBE_SIZE), ffprobe_live_probe_size, MetadataUpdateConfigFormAction::FfprobeLiveProbeSize) } + { edit_field_number_u8!(form_state, translate.t(LABEL_MAX_ATTEMPTS_RESOLVE), max_attempts_resolve, MetadataUpdateConfigFormAction::MaxAttemptsResolve) } + { edit_field_number_u8!(form_state, translate.t(LABEL_MAX_ATTEMPTS_PROBE), max_attempts_probe, MetadataUpdateConfigFormAction::MaxAttemptsProbe) } +
+ | { + match value { + Some(raw) if !(0..=100).contains(&raw) => { + jitter_error_state.set(Some("Backoff jitter percent must be between 0 and 100.".to_string())); + } + Some(raw) => { + jitter_error_state.set(None); + if let Ok(parsed) = u8::try_from(raw) { + jitter_form_state.dispatch(MetadataUpdateConfigFormAction::BackoffJitterPercent(parsed)); + } + } + None => { + jitter_error_state.set(None); + jitter_form_state.dispatch(MetadataUpdateConfigFormAction::BackoffJitterPercent(0)); + } + } + })} + /> + { + if let Some(error_text) = jitter_error_text { + html! {
{error_text}
} + } else { + html! {} + } + } +
+ { edit_field_text!(form_state, translate.t(LABEL_RESOLVE_MIN_RETRY_BASE), resolve_min_retry_base, MetadataUpdateConfigFormAction::ResolveMinRetryBase) } + { edit_field_text!(form_state, translate.t(LABEL_MAX_RESOLVE_RETRY_BACKOFF), max_resolve_retry_backoff, MetadataUpdateConfigFormAction::MaxResolveRetryBackoff) } + { edit_field_text!(form_state, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_1), probe_retry_backoff_step_1, MetadataUpdateConfigFormAction::ProbeRetryBackoffStep1) } + { edit_field_text!(form_state, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_2), probe_retry_backoff_step_2, MetadataUpdateConfigFormAction::ProbeRetryBackoffStep2) } + { edit_field_text!(form_state, translate.t(LABEL_PROBE_RETRY_BACKOFF_STEP_3), probe_retry_backoff_step_3, MetadataUpdateConfigFormAction::ProbeRetryBackoffStep3) } + { edit_field_text!(form_state, translate.t(LABEL_RETRY_DELAY), retry_delay, MetadataUpdateConfigFormAction::RetryDelay) } + { edit_field_text!(form_state, translate.t(LABEL_PROBE_RETRY_LOAD_RETRY_DELAY), probe_retry_load_retry_delay, MetadataUpdateConfigFormAction::ProbeRetryLoadRetryDelay) } + { edit_field_text!(form_state, translate.t(LABEL_RESOLVE_EXHAUSTION_RESET_GAP), resolve_exhaustion_reset_gap, MetadataUpdateConfigFormAction::ResolveExhaustionResetGap) } + { edit_field_text!(form_state, translate.t(LABEL_PROBE_COOLDOWN), probe_cooldown, MetadataUpdateConfigFormAction::ProbeCooldown) } + { edit_field_number_usize!(form_state, translate.t(LABEL_MAX_QUEUE_SIZE), max_queue_size, MetadataUpdateConfigFormAction::MaxQueueSize) } + { edit_field_text!(form_state, translate.t(LABEL_QUEUE_LOG_INTERVAL), queue_log_interval, MetadataUpdateConfigFormAction::QueueLogInterval) } + { edit_field_text!(form_state, translate.t(LABEL_PROGRESS_LOG_INTERVAL), progress_log_interval, MetadataUpdateConfigFormAction::ProgressLogInterval) } + { edit_field_text!(form_state, translate.t(LABEL_WORKER_IDLE_TIMEOUT), worker_idle_timeout, MetadataUpdateConfigFormAction::WorkerIdleTimeout) } + + } + }; + + html! { + + } +} diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index b380af6e1..4aa2cf161 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -12,6 +12,7 @@ mod library_config_view; mod log_config_view; mod main_config_view; mod messaging_config_view; +mod metadata_update_config_view; mod panel_config_view; mod proxy_config_view; mod reverse_proxy_config_view; @@ -30,6 +31,7 @@ pub use log_config_view::*; pub use macros::*; pub use main_config_view::*; pub use messaging_config_view::*; +pub use metadata_update_config_view::*; pub use panel_config_view::*; pub use proxy_config_view::*; pub use reverse_proxy_config_view::*; diff --git a/frontend/src/app/components/config/video_config_view.rs b/frontend/src/app/components/config/video_config_view.rs index 0bdf4b195..e163a7473 100644 --- a/frontend/src/app/components/config/video_config_view.rs +++ b/frontend/src/app/components/config/video_config_view.rs @@ -10,7 +10,7 @@ use crate::{ context::ConfigContext, }, config_field_bool, config_field_child, config_field_optional, edit_field_bool, edit_field_list, - edit_field_number_option_u64, edit_field_text_option, generate_form_reducer, + edit_field_text_option, generate_form_reducer, i18n::use_translation, }; use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; @@ -25,8 +25,6 @@ const LABEL_HEADERS: &str = "LABEL.HEADERS"; const LABEL_EXTENSIONS: &str = "LABEL.EXTENSIONS"; const LABEL_WEB_SEARCH: &str = "LABEL.WEB_SEARCH"; const LABEL_ADD_EXTENSION: &str = "LABEL.ADD_EXTENSION"; -const LABEL_FFPROBE_ENABLED: &str = "LABEL.FFPROBE_ENABLED"; -const LABEL_FFPROBE_TIMEOUT: &str = "LABEL.FFPROBE_TIMEOUT"; generate_form_reducer!( state: VideoDownloadConfigFormState { form: VideoDownloadConfigDto }, @@ -45,8 +43,6 @@ generate_form_reducer!( fields { WebSearch => web_search: Option, Extensions => extensions: Vec, - FfprobeEnabled => ffprobe_enabled: bool, - FfprobeTimeout => ffprobe_timeout: Option, } ); @@ -136,10 +132,6 @@ pub fn VideoConfigView() -> Html { let render_view_mode = || { html! { <> -
- { config_field_bool!(video_state.form, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled) } - { config_field_optional!(video_state.form, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout) } -
{ config_field_optional!(video_state.form, translate.t(LABEL_WEB_SEARCH), web_search) }
@@ -154,10 +146,6 @@ pub fn VideoConfigView() -> Html { let render_edit_mode = || { html! { <> -
- { edit_field_bool!(video_state, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled, VideoConfigFormAction::FfprobeEnabled) } - { edit_field_number_option_u64!(video_state, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout, VideoConfigFormAction::FfprobeTimeout) } -
{ edit_field_text_option!(video_state, translate.t(LABEL_WEB_SEARCH), web_search, VideoConfigFormAction::WebSearch) }
diff --git a/frontend/src/app/components/field_explanation.rs b/frontend/src/app/components/field_explanation.rs index 8d5296c9a..339d85745 100644 --- a/frontend/src/app/components/field_explanation.rs +++ b/frontend/src/app/components/field_explanation.rs @@ -59,12 +59,29 @@ fn label_key_candidates(field_id: &str) -> Vec { keys } +fn explanation_paragraphs(explanation: &str) -> Vec { + let normalized = explanation.replace("\r\n", "\n"); + let mut paragraphs = normalized + .split("\n\n") + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect::>(); + + if paragraphs.is_empty() { + paragraphs.push(explanation.to_string()); + } + + paragraphs +} + pub fn show_field_explanation(field_id: &str, field_label: &str, dialog: &DialogService, translate: &YewI18n) { // Caller is expected to pass a normalized key-compatible field_id. let explanation = explanation_key_candidates(field_id) .into_iter() .find_map(|key| t_safe(translate, &key)) .unwrap_or_else(|| "No explanation available for this field.".to_string()); + let explanation_paragraphs = explanation_paragraphs(&explanation); let title = if field_label.trim().is_empty() { label_key_candidates(field_id) @@ -93,7 +110,9 @@ pub fn show_field_explanation(field_id: &str, field_label: &str, dialog: &Dialog html! {

{title}

-

{explanation}

+ {for explanation_paragraphs.into_iter().map(|paragraph| { + html! {

{paragraph}

} + })}
}, Some(actions), diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index 68a4b257d..ff7a9fb9a 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -179,6 +179,14 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec config.log = Some(log_cfg), ConfigForm::Schedules(_, schedules_cfg) => config.schedules = schedules_cfg.schedules, ConfigForm::Video(_, video_cfg) => config.video = Some(video_cfg), + ConfigForm::MetadataUpdate(_, mut metadata_update_cfg) => { + if metadata_update_cfg.is_empty() { + config.metadata_update = None; + } else { + metadata_update_cfg.clean(); + config.metadata_update = Some(metadata_update_cfg); + } + } ConfigForm::Messaging(_, messaging_cfg) => config.messaging = Some(messaging_cfg), ConfigForm::WebUi(_, web_ui_cfg) => apply_setup_webui_form(config, web_ui_cfg), ConfigForm::ReverseProxy(_, reverse_proxy_cfg) => config.reverse_proxy = Some(reverse_proxy_cfg), @@ -414,8 +422,8 @@ mod tests { use crate::app::ConfigContext; use shared::model::{ AppConfigDto, ConfigInputDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, - LibraryConfigDto, LibraryScanDirectoryDto, ReverseProxyConfigDto, SourcesConfigDto, WebAuthConfigDto, - WebUiConfigDto, + LibraryConfigDto, LibraryScanDirectoryDto, MetadataUpdateConfigDto, ReverseProxyConfigDto, SourcesConfigDto, + WebAuthConfigDto, WebUiConfigDto, }; use std::rc::Rc; @@ -646,4 +654,32 @@ mod tests { assert_eq!(web_ui.player_server.as_deref(), Some("http://player.local")); assert_eq!(web_ui.auth.as_ref().map(|auth| auth.secret.as_str()), Some("top-secret")); } + + #[test] + fn setup_metadata_update_empty_form_clears_existing_payload() { + let mut app_config = AppConfigDto::default(); + app_config.config.metadata_update = + Some(MetadataUpdateConfigDto { ffprobe_enabled: true, ..Default::default() }); + let config_ctx = ConfigContext { config: Some(Rc::new(app_config)), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::MetadataUpdate(true, MetadataUpdateConfigDto::default())); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + assert!(app_cfg.config.metadata_update.is_none()); + } + + #[test] + fn setup_metadata_update_form_applies_cleaned_payload() { + let config_ctx = ConfigContext { config: Some(Rc::new(AppConfigDto::default())), api_proxy: None }; + let mut form_state = crate::app::components::setup::SetupConfigFormState::default(); + form_state.update_form(ConfigForm::MetadataUpdate( + true, + MetadataUpdateConfigDto { ffprobe_enabled: true, ffprobe_timeout: Some(60), ..Default::default() }, + )); + + let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); + let metadata_update = app_cfg.config.metadata_update.expect("metadata_update config should be present"); + assert!(metadata_update.ffprobe_enabled); + assert_eq!(metadata_update.ffprobe_timeout, None); + } } diff --git a/frontend/src/app/components/setup/step_config.rs b/frontend/src/app/components/setup/step_config.rs index 2660d12cb..67aabfa24 100644 --- a/frontend/src/app/components/setup/step_config.rs +++ b/frontend/src/app/components/setup/step_config.rs @@ -66,6 +66,7 @@ pub fn ConfigStep(props: &ConfigStepProps) -> Html { ConfigForm::Log(_, _) => setup_ctx.config_forms.slots.log.as_ref(), ConfigForm::Schedules(_, _) => setup_ctx.config_forms.slots.schedules.as_ref(), ConfigForm::Video(_, _) => setup_ctx.config_forms.slots.video.as_ref(), + ConfigForm::MetadataUpdate(_, _) => setup_ctx.config_forms.slots.metadata_update.as_ref(), ConfigForm::Messaging(_, _) => setup_ctx.config_forms.slots.messaging.as_ref(), ConfigForm::WebUi(_, _) => setup_ctx.config_forms.slots.web_ui.as_ref(), ConfigForm::ReverseProxy(_, _) => setup_ctx.config_forms.slots.reverse_proxy.as_ref(), diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index e126f88ec..5e8e4eb6a 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -2,7 +2,8 @@ use crate::{ error::{TuliproxError, TuliproxErrorKind}, model::{ ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto, - ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto, + MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, + WebUiConfigDto, }, utils::{ default_connect_timeout_secs, default_supported_video_extensions, is_blank_optional_string, @@ -16,6 +17,9 @@ fn default_default_user_agent() -> Option { Some(DEFAULT_USER_AGENT.to_s fn is_none_or_empty_video(video: &Option) -> bool { video.as_ref().is_none_or(VideoConfigDto::is_empty) } +fn is_none_or_empty_metadata_update(metadata_update: &Option) -> bool { + metadata_update.as_ref().is_none_or(MetadataUpdateConfigDto::is_empty) +} #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] @@ -39,6 +43,8 @@ pub struct ConfigDto { pub custom_stream_response_path: Option, #[serde(default, skip_serializing_if = "is_none_or_empty_video")] pub video: Option, + #[serde(default, skip_serializing_if = "is_none_or_empty_metadata_update")] + pub metadata_update: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub schedules: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +92,7 @@ impl Default for ConfigDto { template_path: None, custom_stream_response_path: None, video: None, + metadata_update: None, schedules: None, log: None, user_access_control: false, @@ -224,6 +231,7 @@ impl ConfigDto { self.prepare_web()?; self.prepare_hdhomerun(include_computed)?; self.prepare_video_config()?; + self.prepare_metadata_update_config()?; if let Some(reverse_proxy) = self.reverse_proxy.as_mut() { reverse_proxy.prepare(&self.working_dir)?; @@ -265,8 +273,6 @@ impl ConfigDto { extensions: default_supported_video_extensions(), download: None, web_search: None, - ffprobe_enabled: false, - ffprobe_timeout: None, }); } Some(video) => match video.prepare() { @@ -277,6 +283,20 @@ impl ConfigDto { Ok(()) } + fn prepare_metadata_update_config(&mut self) -> Result<(), TuliproxError> { + let mut metadata_update = self.metadata_update.clone().unwrap_or_default(); + + metadata_update.prepare()?; + + if metadata_update.is_empty() { + self.metadata_update = None; + } else { + self.metadata_update = Some(metadata_update); + } + + Ok(()) + } + pub fn is_valid(&self) -> bool { if self.api.host.is_empty() { return false; @@ -333,6 +353,7 @@ impl ConfigDto { mod tests { use super::*; use crate::utils::default_supported_video_extensions; + use serde_json::json; #[test] fn default_uses_connect_timeout_default_value() { @@ -347,8 +368,6 @@ mod tests { extensions: default_supported_video_extensions(), download: None, web_search: None, - ffprobe_enabled: false, - ffprobe_timeout: None, }), ..ConfigDto::default() }; @@ -363,9 +382,7 @@ mod tests { video: Some(VideoConfigDto { extensions: default_supported_video_extensions(), download: None, - web_search: None, - ffprobe_enabled: true, - ffprobe_timeout: None, + web_search: Some("https://example.org?q={}".to_string()), }), ..ConfigDto::default() }; @@ -373,4 +390,25 @@ mod tests { let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed"); assert!(serialized.contains("\"video\""), "expected video field, got: {serialized}"); } + + #[test] + fn deserializing_rejects_legacy_video_ffprobe_fields() { + let raw = json!({ + "api": { + "host": "127.0.0.1", + "port": 8901, + "web_root": "./web" + }, + "working_dir": ".", + "video": { + "extensions": ["mp4"], + "ffprobe_enabled": true + } + }); + + let result: Result = serde_json::from_value(raw); + assert!(result.is_err(), "legacy ffprobe field under video must fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("ffprobe_enabled"), "unexpected error text: {err}"); + } } diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index d2901df03..3bb4550f8 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -9,8 +9,8 @@ use crate::{ default_resolve_background, default_resolve_delay_secs, default_xtream_live_stream_use_prefix, deserialize_timestamp, get_credentials_from_url_str, get_trimmed_string, is_blank_optional_string, is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false, is_true, - is_zero_u16, parse_provider_scheme_url_parts, sanitize_sensitive_info, serialize_option_vec_flow_map_items, - trim_last_slash, Internable, PROVIDER_SCHEME_PREFIX, + is_zero_u16, parse_duration_seconds, parse_provider_scheme_url_parts, sanitize_sensitive_info, + serialize_option_vec_flow_map_items, trim_last_slash, Internable, PROVIDER_SCHEME_PREFIX, }, }; use enum_iterator::Sequence; @@ -509,29 +509,10 @@ impl ConfigInputDto { } fn parse_duration(&self, duration_str: &str) -> Result { - Ok(match duration_str.parse::() { - Ok(secs) => secs, - Err(_) => { - let len = duration_str.len(); - if len > 1 { - let (num_str, unit) = duration_str.split_at(len - 1); - match num_str.parse::() { - Ok(val) => match unit { - "s" => val, - "m" => val * 60, - "h" => val * 3600, - "d" => val * 86400, - _ => return info_err_res!("Invalid cache_duration unit in '{}': {}", self.name, unit), - }, - Err(_) => { - return info_err_res!("Invalid cache_duration format in '{}': {}", self.name, duration_str) - } - } - } else { - return info_err_res!("Invalid cache_duration format in '{}'", self.name); - } - } - }) + match parse_duration_seconds(duration_str, false) { + Some(seconds) => Ok(seconds), + None => info_err_res!("Invalid cache_duration format in '{}': {}", self.name, duration_str), + } } pub fn prepare_epg(&mut self, include_computed: bool) -> Result<(), TuliproxError> { diff --git a/shared/src/model/config/metadata_update.rs b/shared/src/model/config/metadata_update.rs new file mode 100644 index 000000000..0ab1205b3 --- /dev/null +++ b/shared/src/model/config/metadata_update.rs @@ -0,0 +1,434 @@ +use crate::{ + error::TuliproxError, + info_err_res, + utils::{ + default_metadata_backoff_jitter_percent, default_metadata_ffprobe_analyze_duration, + default_metadata_ffprobe_live_analyze_duration, default_metadata_ffprobe_live_probe_size, + default_metadata_ffprobe_probe_size, default_metadata_max_attempts_probe, + default_metadata_max_attempts_resolve, default_metadata_max_queue_size, + default_metadata_max_resolve_retry_backoff, default_metadata_probe_cooldown, + default_metadata_probe_retry_backoff_step_1, default_metadata_probe_retry_backoff_step_2, + default_metadata_probe_retry_backoff_step_3, default_metadata_probe_retry_load_retry_delay, + default_metadata_progress_log_interval, default_metadata_queue_log_interval, + default_metadata_resolve_exhaustion_reset_gap, default_metadata_resolve_min_retry_base, + default_metadata_retry_delay, default_metadata_worker_idle_timeout, deserialize_as_string, + is_default_metadata_backoff_jitter_percent, is_default_metadata_ffprobe_analyze_duration, + is_default_metadata_ffprobe_live_analyze_duration, is_default_metadata_ffprobe_live_probe_size, + is_default_metadata_ffprobe_probe_size, is_default_metadata_max_attempts_probe, + is_default_metadata_max_attempts_resolve, is_default_metadata_max_queue_size, + is_default_metadata_max_resolve_retry_backoff, is_default_metadata_probe_cooldown, + is_default_metadata_probe_retry_backoff_step_1, is_default_metadata_probe_retry_backoff_step_2, + is_default_metadata_probe_retry_backoff_step_3, is_default_metadata_probe_retry_load_retry_delay, + is_default_metadata_progress_log_interval, is_default_metadata_queue_log_interval, + is_default_metadata_resolve_exhaustion_reset_gap, is_default_metadata_resolve_min_retry_base, + is_default_metadata_retry_delay, is_default_metadata_worker_idle_timeout, is_false, parse_duration_seconds, + parse_size_base_2, + }, +}; +use std::sync::OnceLock; + +const MIN_DURATION_SECS: u64 = 1; +const MIN_ATTEMPTS: u8 = 1; +const MAX_JITTER_PERCENT: u8 = 95; +const MIN_QUEUE_SIZE: usize = 1; +const DEFAULT_FFPROBE_TIMEOUT_SECS: u64 = 60; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct MetadataUpdateConfigDto { + #[serde( + default = "default_metadata_queue_log_interval", + skip_serializing_if = "is_default_metadata_queue_log_interval" + )] + pub queue_log_interval: String, + #[serde( + default = "default_metadata_progress_log_interval", + skip_serializing_if = "is_default_metadata_progress_log_interval" + )] + pub progress_log_interval: String, + #[serde( + default = "default_metadata_max_resolve_retry_backoff", + skip_serializing_if = "is_default_metadata_max_resolve_retry_backoff" + )] + pub max_resolve_retry_backoff: String, + #[serde( + default = "default_metadata_resolve_min_retry_base", + skip_serializing_if = "is_default_metadata_resolve_min_retry_base" + )] + pub resolve_min_retry_base: String, + #[serde( + default = "default_metadata_resolve_exhaustion_reset_gap", + skip_serializing_if = "is_default_metadata_resolve_exhaustion_reset_gap" + )] + pub resolve_exhaustion_reset_gap: String, + #[serde(default = "default_metadata_probe_cooldown", skip_serializing_if = "is_default_metadata_probe_cooldown")] + pub probe_cooldown: String, + #[serde(default = "default_metadata_retry_delay", skip_serializing_if = "is_default_metadata_retry_delay")] + pub retry_delay: String, + #[serde( + default = "default_metadata_probe_retry_load_retry_delay", + skip_serializing_if = "is_default_metadata_probe_retry_load_retry_delay" + )] + pub probe_retry_load_retry_delay: String, + #[serde( + default = "default_metadata_worker_idle_timeout", + skip_serializing_if = "is_default_metadata_worker_idle_timeout" + )] + pub worker_idle_timeout: String, + #[serde( + default = "default_metadata_probe_retry_backoff_step_1", + skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_1" + )] + pub probe_retry_backoff_step_1: String, + #[serde( + default = "default_metadata_probe_retry_backoff_step_2", + skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_2" + )] + pub probe_retry_backoff_step_2: String, + #[serde( + default = "default_metadata_probe_retry_backoff_step_3", + skip_serializing_if = "is_default_metadata_probe_retry_backoff_step_3" + )] + pub probe_retry_backoff_step_3: String, + #[serde( + default = "default_metadata_max_attempts_resolve", + skip_serializing_if = "is_default_metadata_max_attempts_resolve" + )] + pub max_attempts_resolve: u8, + #[serde( + default = "default_metadata_max_attempts_probe", + skip_serializing_if = "is_default_metadata_max_attempts_probe" + )] + pub max_attempts_probe: u8, + #[serde( + default = "default_metadata_backoff_jitter_percent", + skip_serializing_if = "is_default_metadata_backoff_jitter_percent" + )] + pub backoff_jitter_percent: u8, + #[serde(default = "default_metadata_max_queue_size", skip_serializing_if = "is_default_metadata_max_queue_size")] + pub max_queue_size: usize, + #[serde(default, skip_serializing_if = "is_false")] + pub ffprobe_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ffprobe_timeout: Option, + #[serde( + default = "default_metadata_ffprobe_analyze_duration", + skip_serializing_if = "is_default_metadata_ffprobe_analyze_duration", + deserialize_with = "deserialize_as_string" + )] + pub ffprobe_analyze_duration: String, + #[serde( + default = "default_metadata_ffprobe_probe_size", + skip_serializing_if = "is_default_metadata_ffprobe_probe_size", + deserialize_with = "deserialize_as_string" + )] + pub ffprobe_probe_size: String, + #[serde( + default = "default_metadata_ffprobe_live_analyze_duration", + skip_serializing_if = "is_default_metadata_ffprobe_live_analyze_duration", + deserialize_with = "deserialize_as_string" + )] + pub ffprobe_live_analyze_duration: String, + #[serde( + default = "default_metadata_ffprobe_live_probe_size", + skip_serializing_if = "is_default_metadata_ffprobe_live_probe_size", + deserialize_with = "deserialize_as_string" + )] + pub ffprobe_live_probe_size: String, +} + +impl Default for MetadataUpdateConfigDto { + fn default() -> Self { + Self { + queue_log_interval: default_metadata_queue_log_interval(), + progress_log_interval: default_metadata_progress_log_interval(), + max_resolve_retry_backoff: default_metadata_max_resolve_retry_backoff(), + resolve_min_retry_base: default_metadata_resolve_min_retry_base(), + resolve_exhaustion_reset_gap: default_metadata_resolve_exhaustion_reset_gap(), + probe_cooldown: default_metadata_probe_cooldown(), + retry_delay: default_metadata_retry_delay(), + probe_retry_load_retry_delay: default_metadata_probe_retry_load_retry_delay(), + worker_idle_timeout: default_metadata_worker_idle_timeout(), + probe_retry_backoff_step_1: default_metadata_probe_retry_backoff_step_1(), + probe_retry_backoff_step_2: default_metadata_probe_retry_backoff_step_2(), + probe_retry_backoff_step_3: default_metadata_probe_retry_backoff_step_3(), + max_attempts_resolve: default_metadata_max_attempts_resolve(), + max_attempts_probe: default_metadata_max_attempts_probe(), + backoff_jitter_percent: default_metadata_backoff_jitter_percent(), + max_queue_size: default_metadata_max_queue_size(), + ffprobe_enabled: false, + ffprobe_timeout: None, + ffprobe_analyze_duration: default_metadata_ffprobe_analyze_duration(), + ffprobe_probe_size: default_metadata_ffprobe_probe_size(), + ffprobe_live_analyze_duration: default_metadata_ffprobe_live_analyze_duration(), + ffprobe_live_probe_size: default_metadata_ffprobe_live_probe_size(), + } + } +} + +impl MetadataUpdateConfigDto { + fn defaults() -> &'static Self { + static DEFAULTS: OnceLock = OnceLock::new(); + DEFAULTS.get_or_init(Self::default) + } + + pub fn is_empty(&self) -> bool { self == Self::defaults() } + + pub fn clean(&mut self) { + if self.ffprobe_timeout.is_some_and(|v| v == DEFAULT_FFPROBE_TIMEOUT_SECS) { + self.ffprobe_timeout = None; + } + } + + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + let queue_log_interval_secs = + Self::parse_and_clamp_duration(&self.queue_log_interval, MIN_DURATION_SECS, "queue_log_interval")?; + self.queue_log_interval = Self::canonicalize_seconds(queue_log_interval_secs); + + let progress_log_interval_secs = + Self::parse_and_clamp_duration(&self.progress_log_interval, MIN_DURATION_SECS, "progress_log_interval")?; + self.progress_log_interval = Self::canonicalize_seconds(progress_log_interval_secs); + + let max_resolve_retry_backoff_secs = Self::parse_and_clamp_duration( + &self.max_resolve_retry_backoff, + MIN_DURATION_SECS, + "max_resolve_retry_backoff", + )?; + self.max_resolve_retry_backoff = Self::canonicalize_seconds(max_resolve_retry_backoff_secs); + + let resolve_min_retry_base_secs = + Self::parse_and_clamp_duration(&self.resolve_min_retry_base, MIN_DURATION_SECS, "resolve_min_retry_base")?; + self.resolve_min_retry_base = Self::canonicalize_seconds(resolve_min_retry_base_secs); + + let resolve_exhaustion_reset_gap_secs = Self::parse_and_clamp_duration( + &self.resolve_exhaustion_reset_gap, + MIN_DURATION_SECS, + "resolve_exhaustion_reset_gap", + )?; + self.resolve_exhaustion_reset_gap = Self::canonicalize_seconds(resolve_exhaustion_reset_gap_secs); + + let probe_cooldown_secs = + Self::parse_and_clamp_duration(&self.probe_cooldown, MIN_DURATION_SECS, "probe_cooldown")?; + self.probe_cooldown = Self::canonicalize_seconds(probe_cooldown_secs); + + let retry_delay_secs = Self::parse_and_clamp_duration(&self.retry_delay, MIN_DURATION_SECS, "retry_delay")?; + self.retry_delay = Self::canonicalize_seconds(retry_delay_secs); + + let probe_retry_load_retry_delay_secs = Self::parse_and_clamp_duration( + &self.probe_retry_load_retry_delay, + MIN_DURATION_SECS, + "probe_retry_load_retry_delay", + )?; + self.probe_retry_load_retry_delay = Self::canonicalize_seconds(probe_retry_load_retry_delay_secs); + + let worker_idle_timeout_secs = + Self::parse_and_clamp_duration(&self.worker_idle_timeout, MIN_DURATION_SECS, "worker_idle_timeout")?; + self.worker_idle_timeout = Self::canonicalize_seconds(worker_idle_timeout_secs); + + let probe_retry_backoff_step_1_secs = Self::parse_and_clamp_duration( + &self.probe_retry_backoff_step_1, + MIN_DURATION_SECS, + "probe_retry_backoff_step_1", + )?; + self.probe_retry_backoff_step_1 = Self::canonicalize_seconds(probe_retry_backoff_step_1_secs); + + let probe_retry_backoff_step_2_secs = Self::parse_and_clamp_duration( + &self.probe_retry_backoff_step_2, + MIN_DURATION_SECS, + "probe_retry_backoff_step_2", + )?; + self.probe_retry_backoff_step_2 = Self::canonicalize_seconds(probe_retry_backoff_step_2_secs); + + let probe_retry_backoff_step_3_secs = Self::parse_and_clamp_duration( + &self.probe_retry_backoff_step_3, + MIN_DURATION_SECS, + "probe_retry_backoff_step_3", + )?; + self.probe_retry_backoff_step_3 = Self::canonicalize_seconds(probe_retry_backoff_step_3_secs); + + self.max_attempts_resolve = self.max_attempts_resolve.max(MIN_ATTEMPTS); + self.max_attempts_probe = self.max_attempts_probe.max(MIN_ATTEMPTS); + self.backoff_jitter_percent = self.backoff_jitter_percent.min(MAX_JITTER_PERCENT); + self.max_queue_size = self.max_queue_size.max(MIN_QUEUE_SIZE); + self.ffprobe_timeout = self.ffprobe_timeout.map(|timeout| timeout.max(MIN_DURATION_SECS)); + + let ffprobe_analyze_duration_secs = Self::parse_and_clamp_duration_with_required_unit( + &self.ffprobe_analyze_duration, + MIN_DURATION_SECS, + "ffprobe_analyze_duration", + )?; + self.ffprobe_analyze_duration = Self::canonicalize_seconds(ffprobe_analyze_duration_secs); + + let ffprobe_probe_size_bytes = parse_size_base_2(&self.ffprobe_probe_size) + .map_err(|err| crate::error::info_err!("Invalid size for `ffprobe_probe_size`: {err}"))? + .max(1); + self.ffprobe_probe_size = Self::canonicalize_size_bytes(ffprobe_probe_size_bytes); + + let ffprobe_live_analyze_duration_secs = Self::parse_and_clamp_duration_with_required_unit( + &self.ffprobe_live_analyze_duration, + MIN_DURATION_SECS, + "ffprobe_live_analyze_duration", + )?; + self.ffprobe_live_analyze_duration = Self::canonicalize_seconds(ffprobe_live_analyze_duration_secs); + + let ffprobe_live_probe_size_bytes = parse_size_base_2(&self.ffprobe_live_probe_size) + .map_err(|err| crate::error::info_err!("Invalid size for `ffprobe_live_probe_size`: {err}"))? + .max(1); + self.ffprobe_live_probe_size = Self::canonicalize_size_bytes(ffprobe_live_probe_size_bytes); + + self.clean(); + + Ok(()) + } + + fn parse_and_clamp_duration(value: &str, min_seconds: u64, field_name: &str) -> Result { + let parsed = Self::parse_duration(value, field_name)?; + Ok(parsed.max(min_seconds)) + } + + fn parse_and_clamp_duration_with_required_unit( + value: &str, + min_seconds: u64, + field_name: &str, + ) -> Result { + let parsed = Self::parse_duration_with_required_unit(value, field_name)?; + Ok(parsed.max(min_seconds)) + } + + fn parse_duration_with_required_unit(value: &str, field_name: &str) -> Result { + if value.parse::().is_ok() { + return info_err_res!( + "Invalid duration format for `{field_name}`: {value}. Use explicit unit suffix (`s`, `m`, `h`, `d`), e.g. `10s`." + ); + } + Self::parse_duration(value, field_name) + } + + fn parse_duration(value: &str, field_name: &str) -> Result { + parse_duration_seconds(value, false) + .ok_or_else(|| crate::error::info_err!("Invalid duration format for `{field_name}`: {value}")) + } + + fn canonicalize_seconds(seconds: u64) -> String { + if seconds.is_multiple_of(24 * 60 * 60) { + format!("{}d", seconds / (24 * 60 * 60)) + } else if seconds.is_multiple_of(60 * 60) { + format!("{}h", seconds / (60 * 60)) + } else if seconds.is_multiple_of(60) { + format!("{}m", seconds / 60) + } else { + format!("{seconds}s") + } + } + + fn canonicalize_size_bytes(bytes: u64) -> String { + if bytes.is_multiple_of(1_099_511_627_776) { + format!("{}TB", bytes / 1_099_511_627_776) + } else if bytes.is_multiple_of(1_073_741_824) { + format!("{}GB", bytes / 1_073_741_824) + } else if bytes.is_multiple_of(1_048_576) { + format!("{}MB", bytes / 1_048_576) + } else if bytes.is_multiple_of(1_024) { + format!("{}KB", bytes / 1_024) + } else { + format!("{bytes}B") + } + } +} + +#[cfg(test)] +mod tests { + use super::MetadataUpdateConfigDto; + + #[test] + fn default_config_is_empty() { + let cfg = MetadataUpdateConfigDto::default(); + assert!(cfg.is_empty()); + } + + #[test] + fn prepare_parses_duration_suffixes() { + let mut cfg = MetadataUpdateConfigDto { + queue_log_interval: "1m".to_string(), + progress_log_interval: "2h".to_string(), + probe_cooldown: "1d".to_string(), + ..MetadataUpdateConfigDto::default() + }; + + cfg.prepare().expect("metadata update config should parse duration values"); + + assert_eq!(cfg.queue_log_interval, "1m"); + assert_eq!(cfg.progress_log_interval, "2h"); + assert_eq!(cfg.probe_cooldown, "1d"); + } + + #[test] + fn prepare_clamps_minimum_values() { + let mut cfg = MetadataUpdateConfigDto { + queue_log_interval: "0".to_string(), + max_attempts_resolve: 0, + max_attempts_probe: 0, + max_queue_size: 0, + ffprobe_timeout: Some(0), + ffprobe_analyze_duration: "0s".to_string(), + ffprobe_probe_size: "0".to_string(), + ..MetadataUpdateConfigDto::default() + }; + + cfg.prepare().expect("metadata update config should clamp minimum values"); + + assert_eq!(cfg.queue_log_interval, "1s"); + assert_eq!(cfg.max_attempts_resolve, 1); + assert_eq!(cfg.max_attempts_probe, 1); + assert_eq!(cfg.max_queue_size, 1); + assert_eq!(cfg.ffprobe_timeout, Some(1)); + assert_eq!(cfg.ffprobe_analyze_duration, "1s"); + assert_eq!(cfg.ffprobe_probe_size, "1B"); + } + + #[test] + fn prepare_rejects_invalid_duration_unit() { + let mut cfg = + MetadataUpdateConfigDto { queue_log_interval: "1w".to_string(), ..MetadataUpdateConfigDto::default() }; + + let result = cfg.prepare(); + assert!(result.is_err(), "invalid duration unit must fail"); + } + + #[test] + fn prepare_canonicalizes_to_larger_units() { + let mut cfg = MetadataUpdateConfigDto { + probe_cooldown: "604800".to_string(), + worker_idle_timeout: "60".to_string(), + probe_retry_backoff_step_3: "3600".to_string(), + ffprobe_analyze_duration: "10s".to_string(), + ffprobe_probe_size: "10485760".to_string(), + ffprobe_live_analyze_duration: "5s".to_string(), + ffprobe_live_probe_size: "5242880".to_string(), + ..MetadataUpdateConfigDto::default() + }; + + cfg.prepare().expect("metadata update config should canonicalize durations"); + + assert_eq!(cfg.probe_cooldown, "7d"); + assert_eq!(cfg.worker_idle_timeout, "1m"); + assert_eq!(cfg.probe_retry_backoff_step_3, "1h"); + assert_eq!(cfg.ffprobe_analyze_duration, "10s"); + assert_eq!(cfg.ffprobe_probe_size, "10MB"); + assert_eq!(cfg.ffprobe_live_analyze_duration, "5s"); + assert_eq!(cfg.ffprobe_live_probe_size, "5MB"); + } + + #[test] + fn prepare_rejects_ffprobe_duration_without_unit() { + let mut cfg = MetadataUpdateConfigDto { + ffprobe_analyze_duration: "10000000".to_string(), + ..MetadataUpdateConfigDto::default() + }; + + let result = cfg.prepare(); + assert!(result.is_err(), "numeric ffprobe analyze duration without unit must fail"); + let err_text = result.unwrap_err().to_string(); + assert!(err_text.contains("ffprobe_analyze_duration")); + } +} diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index 96f4417a7..aa4dab305 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -13,6 +13,7 @@ mod ipcheck; mod log; pub mod macros; mod messaging; +mod metadata_update; mod panel_api; mod paths; mod pattern_template; @@ -56,6 +57,7 @@ pub use ipcheck::*; pub use library::*; pub use log::*; pub use messaging::*; +pub use metadata_update::*; pub use panel_api::*; pub use paths::*; pub use pattern_template::*; diff --git a/shared/src/model/config/video_download.rs b/shared/src/model/config/video_download.rs index b3070c414..f09a40180 100644 --- a/shared/src/model/config/video_download.rs +++ b/shared/src/model/config/video_download.rs @@ -44,10 +44,6 @@ pub struct VideoConfigDto { pub download: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub web_search: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub ffprobe_enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ffprobe_timeout: Option, } impl VideoConfigDto { @@ -55,8 +51,6 @@ impl VideoConfigDto { (self.extensions.is_empty() || is_default_supported_video_extensions(&self.extensions)) && is_blank_optional_str(self.web_search.as_deref()) && (self.download.is_none() || self.download.as_ref().is_some_and(|d| d.is_empty())) - && !self.ffprobe_enabled - && self.ffprobe_timeout.is_none() } pub fn clean(&mut self) { diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index a896482a2..1b30d7258 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -123,6 +123,69 @@ pub const fn is_default_probe_live_interval(v: &u32) -> bool { *v == default_pro pub const fn default_resolve_background() -> bool { true } pub const fn default_xtream_live_stream_use_prefix() -> bool { true } +pub fn default_metadata_queue_log_interval() -> String { "30s".to_string() } +pub fn is_default_metadata_queue_log_interval(v: &String) -> bool { *v == default_metadata_queue_log_interval() } +pub fn default_metadata_progress_log_interval() -> String { "15s".to_string() } +pub fn is_default_metadata_progress_log_interval(v: &String) -> bool { *v == default_metadata_progress_log_interval() } +pub fn default_metadata_max_resolve_retry_backoff() -> String { "1h".to_string() } +pub fn is_default_metadata_max_resolve_retry_backoff(v: &String) -> bool { + *v == default_metadata_max_resolve_retry_backoff() +} +pub fn default_metadata_resolve_min_retry_base() -> String { "5s".to_string() } +pub fn is_default_metadata_resolve_min_retry_base(v: &String) -> bool { + *v == default_metadata_resolve_min_retry_base() +} +pub fn default_metadata_resolve_exhaustion_reset_gap() -> String { "1h".to_string() } +pub fn is_default_metadata_resolve_exhaustion_reset_gap(v: &String) -> bool { + *v == default_metadata_resolve_exhaustion_reset_gap() +} +pub fn default_metadata_probe_cooldown() -> String { "7d".to_string() } +pub fn is_default_metadata_probe_cooldown(v: &String) -> bool { *v == default_metadata_probe_cooldown() } +pub fn default_metadata_retry_delay() -> String { "2s".to_string() } +pub fn is_default_metadata_retry_delay(v: &String) -> bool { *v == default_metadata_retry_delay() } +pub fn default_metadata_probe_retry_load_retry_delay() -> String { "1m".to_string() } +pub fn is_default_metadata_probe_retry_load_retry_delay(v: &String) -> bool { + *v == default_metadata_probe_retry_load_retry_delay() +} +pub fn default_metadata_worker_idle_timeout() -> String { "1m".to_string() } +pub fn is_default_metadata_worker_idle_timeout(v: &String) -> bool { *v == default_metadata_worker_idle_timeout() } +pub fn default_metadata_probe_retry_backoff_step_1() -> String { "10m".to_string() } +pub fn is_default_metadata_probe_retry_backoff_step_1(v: &String) -> bool { + *v == default_metadata_probe_retry_backoff_step_1() +} +pub fn default_metadata_probe_retry_backoff_step_2() -> String { "30m".to_string() } +pub fn is_default_metadata_probe_retry_backoff_step_2(v: &String) -> bool { + *v == default_metadata_probe_retry_backoff_step_2() +} +pub fn default_metadata_probe_retry_backoff_step_3() -> String { "1h".to_string() } +pub fn is_default_metadata_probe_retry_backoff_step_3(v: &String) -> bool { + *v == default_metadata_probe_retry_backoff_step_3() +} +pub const fn default_metadata_max_attempts_resolve() -> u8 { 3 } +pub const fn is_default_metadata_max_attempts_resolve(v: &u8) -> bool { *v == default_metadata_max_attempts_resolve() } +pub const fn default_metadata_max_attempts_probe() -> u8 { 3 } +pub const fn is_default_metadata_max_attempts_probe(v: &u8) -> bool { *v == default_metadata_max_attempts_probe() } +pub const fn default_metadata_backoff_jitter_percent() -> u8 { 20 } +pub const fn is_default_metadata_backoff_jitter_percent(v: &u8) -> bool { + *v == default_metadata_backoff_jitter_percent() +} +pub const fn default_metadata_max_queue_size() -> usize { 100_000 } +pub const fn is_default_metadata_max_queue_size(v: &usize) -> bool { *v == default_metadata_max_queue_size() } +pub fn default_metadata_ffprobe_analyze_duration() -> String { "10s".to_string() } +pub fn is_default_metadata_ffprobe_analyze_duration(v: &String) -> bool { + *v == default_metadata_ffprobe_analyze_duration() +} +pub fn default_metadata_ffprobe_probe_size() -> String { "10MB".to_string() } +pub fn is_default_metadata_ffprobe_probe_size(v: &String) -> bool { *v == default_metadata_ffprobe_probe_size() } +pub fn default_metadata_ffprobe_live_analyze_duration() -> String { "5s".to_string() } +pub fn is_default_metadata_ffprobe_live_analyze_duration(v: &String) -> bool { + *v == default_metadata_ffprobe_live_analyze_duration() +} +pub fn default_metadata_ffprobe_live_probe_size() -> String { "5MB".to_string() } +pub fn is_default_metadata_ffprobe_live_probe_size(v: &String) -> bool { + *v == default_metadata_ffprobe_live_probe_size() +} + ////////////////////////////// // HDHomerun Device Defaults ////////////////////////////// diff --git a/shared/src/utils/time_utils.rs b/shared/src/utils/time_utils.rs index af0b38472..eaa6322f1 100644 --- a/shared/src/utils/time_utils.rs +++ b/shared/src/utils/time_utils.rs @@ -8,6 +8,36 @@ pub fn current_time_secs() -> u64 { pub fn unix_ts_to_str(ts: i64) -> Option { unix_ts_to_str_with_format(ts, "%Y-%m-%d %H:%M:%S") } +/// Parse a duration string into seconds. +/// +/// Supported formats: +/// - plain seconds (`"30"`) when `require_unit` is `false` +/// - suffixed units: `s`, `m`, `h`, `d` (for example `"30s"`, `"5m"`, `"1h"`, `"2d"`) +pub fn parse_duration_seconds(value: &str, require_unit: bool) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Ok(seconds) = value.parse::() { + return if require_unit { None } else { Some(seconds) }; + } + + if value.len() <= 1 { + return None; + } + + let (number_part, unit_part) = value.split_at(value.len() - 1); + let number = number_part.parse::().ok()?; + match unit_part { + "s" => Some(number), + "m" => Some(number.saturating_mul(60)), + "h" => Some(number.saturating_mul(60 * 60)), + "d" => Some(number.saturating_mul(24 * 60 * 60)), + _ => None, + } +} + fn normalize_ts(ts: i64) -> Option { if ts >= 0 { // Timestamps > Jan 1, 2100 (in seconds) are assumed to be in milliseconds