mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-25 10:32:15 +02:00
MetadatResolve hardening rerties after errors (#611)
* MetadatResolve hardening rerties after errors
This commit is contained in:
+7
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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<episode>[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<episode>[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`):**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -70,6 +70,7 @@ struct ActiveConnectionInfo {
|
||||
struct SharedConnections {
|
||||
by_key: HashMap<String, SharedAllocation>,
|
||||
key_by_addr: HashMap<ClientConnectionId, String>,
|
||||
shared_by_allocation_id: HashMap<AllocationId, String>,
|
||||
}
|
||||
|
||||
#[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<String> = None;
|
||||
// TODO O(n) over all keys, maybe better approach ist to use a Hashmap shared_by_allocation_id: HashMap<AllocationId, String>
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -507,12 +507,17 @@ impl MultiProviderLineup {
|
||||
pub(in crate::api::model) struct ProviderLineupManager {
|
||||
grace_period_millis: AtomicU64,
|
||||
grace_period_timeout_secs: AtomicU64,
|
||||
inputs: Arc<ArcSwap<Vec<Arc<ConfigInput>>>>,
|
||||
providers: Arc<ArcSwap<Vec<ProviderLineup>>>,
|
||||
snapshot: Arc<ArcSwap<LineupSnapshot>>,
|
||||
provider_connections: DashMap<Arc<str>, Arc<RwLock<ProviderConfigConnection>>>,
|
||||
event_manager: Arc<EventManager>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LineupSnapshot {
|
||||
inputs: Vec<Arc<ConfigInput>>,
|
||||
providers: Vec<ProviderLineup>,
|
||||
}
|
||||
|
||||
impl ProviderLineupManager {
|
||||
pub fn new(inputs: Vec<Arc<ConfigInput>>, grace_period_options: GracePeriodOptions, event_manager: &Arc<EventManager>) -> Self {
|
||||
let provider_connections: DashMap<Arc<str>, Arc<RwLock<ProviderConfigConnection>>> = 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<ConfigInput>]) -> 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<Arc<str>, 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<RwLock<_>> 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<Arc<str>> = {
|
||||
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<str>) -> 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<str>,
|
||||
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<str>,
|
||||
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<str>) -> Option<Arc<ProviderConfig>> {
|
||||
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<str>) -> 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<str>) -> 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub custom_stream_response_path: Option<String>,
|
||||
pub video: Option<VideoConfig>,
|
||||
pub metadata_update: Option<MetadataUpdateConfig>,
|
||||
pub schedules: Option<Vec<ScheduleConfig>>,
|
||||
pub log: Option<LogConfig>,
|
||||
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,
|
||||
|
||||
@@ -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<u64>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
pub download: Option<VideoDownloadConfig>,
|
||||
pub web_search: Option<String>,
|
||||
pub ffprobe_enabled: bool,
|
||||
pub ffprobe_timeout: Option<u64>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_<cluster>_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::<u32>() {
|
||||
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::<u32, shared::model::XtreamPlaylistItem>::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;
|
||||
|
||||
@@ -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<ActiveProviderManager>,
|
||||
active_handle: Option<&crate::api::model::ProviderHandle>,
|
||||
) -> Result<(), TuliproxError> {
|
||||
) -> Result<GenericProbeOutcome, TuliproxError> {
|
||||
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<bool, String> {
|
||||
let mut updated = false;
|
||||
match storage_kind {
|
||||
ProbeStorageKind::M3u => {
|
||||
let key: Arc<str> = Arc::from(unique_id_for_update.as_str());
|
||||
let mut tree_update = BPlusTreeUpdate::<Arc<str>, 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<str> = unique_id.into();
|
||||
let mut tree_update = BPlusTreeUpdate::<Arc<str>, 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::<UUIDType, XtreamPlaylistItem>::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::<u32>() else {
|
||||
warn!("Skipping xtream generic probe update with non-numeric id: {unique_id_for_update}");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut tree_update = BPlusTreeUpdate::<u32, XtreamPlaylistItem>::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::<UUIDType, XtreamPlaylistItem>::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::<u32>() 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::<u32, XtreamPlaylistItem>::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(
|
||||
|
||||
@@ -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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
_active_handle: Option<&ProviderHandle>,
|
||||
_active_provider: &Arc<ActiveProviderManager>,
|
||||
) -> Result<Option<LiveStreamProperties>, 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);
|
||||
|
||||
@@ -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<ProviderIdType> = 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::<u32, XtreamPlaylistItem>::try_new(&xtream_path)?;
|
||||
Ok::<_, std::io::Error>((query, guard))
|
||||
BPlusTreeQuery::<u32, XtreamPlaylistItem>::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<Playlis
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update_series_info_immediate(
|
||||
ctx: &PlaylistProcessingContext,
|
||||
active_provider: &Arc<ActiveProviderManager>,
|
||||
@@ -416,8 +504,10 @@ async fn update_series_info_immediate(
|
||||
id: ProviderIdType,
|
||||
reasons: &ResolveReasonSet,
|
||||
db_query: Option<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
probe_settings: SeriesProbeSettings,
|
||||
) -> Result<Option<SeriesStreamProperties>, 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<AppConfig>,
|
||||
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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
) -> Result<Option<SeriesStreamProperties>, 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::<u32, XtreamPlaylistItem>::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
|
||||
{
|
||||
|
||||
@@ -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<ProviderIdType> = 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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
) -> Result<Option<VideoStreamProperties>, 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<AppConfig>,
|
||||
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<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
|
||||
) -> Result<Option<VideoStreamProperties>, 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::<u32, XtreamPlaylistItem>::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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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<ConfigForm>,
|
||||
pub schedules: Option<ConfigForm>,
|
||||
pub video: Option<ConfigForm>,
|
||||
pub metadata_update: Option<ConfigForm>,
|
||||
pub messaging: Option<ConfigForm>,
|
||||
pub web_ui: Option<ConfigForm>,
|
||||
pub reverse_proxy: Option<ConfigForm>,
|
||||
@@ -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<ConfigForm>; 14] {
|
||||
pub fn all_slots(&self) -> [&Option<ConfigForm>; 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,
|
||||
|
||||
@@ -100,6 +100,9 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec<ConfigForm>) {
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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! { <IpCheckConfigView/> }, "IpCheckConfig"),
|
||||
(ConfigPage::Panel, LABEL_PANEL_CONFIG, html! { <PanelConfigView/> }, "Settings"),
|
||||
(ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { <VideoConfigView/> }, "VideoConfig"),
|
||||
(
|
||||
ConfigPage::MetadataUpdate,
|
||||
LABEL_METADATA_UPDATE_CONFIG,
|
||||
html! { <MetadataUpdateConfigView/> },
|
||||
"Metadata",
|
||||
),
|
||||
(ConfigPage::Library, LABEL_LIBRARY_CONFIG, html! { <LibraryConfigView/> }, "VideoLibrary"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<u64>,
|
||||
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::<ConfigContext>().expect("ConfigContext not found");
|
||||
let config_view_ctx = use_context::<ConfigViewContext>().expect("ConfigViewContext not found");
|
||||
let backoff_jitter_error: UseStateHandle<Option<String>> = use_state(|| None);
|
||||
|
||||
let form_state: UseReducerHandle<MetadataUpdateConfigFormState> =
|
||||
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) }
|
||||
<div class="tp__form-field tp__form-field__number">
|
||||
<NumberInput
|
||||
label={Some(jitter_label)}
|
||||
name={"backoff_jitter_percent"}
|
||||
field_id={Some(jitter_field_id)}
|
||||
value={Some(i64::from(jitter_form_state.form.backoff_jitter_percent))}
|
||||
on_change={Callback::from(move |value: Option<i64>| {
|
||||
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! { <div class="tp__error-text">{error_text}</div> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
{ 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! {
|
||||
<div class="tp__metadata-update-config-view tp__config-view-page">
|
||||
<div class="tp__config-view-page__title">{translate.t(LABEL_METADATA_UPDATE_CONFIG)}</div>
|
||||
<div class="tp__metadata-update-config-view__body tp__config-view-page__body">
|
||||
{
|
||||
if *config_view_ctx.edit_mode {
|
||||
render_edit_mode()
|
||||
} else {
|
||||
render_view_mode()
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
Extensions => extensions: Vec<String>,
|
||||
FfprobeEnabled => ffprobe_enabled: bool,
|
||||
FfprobeTimeout => ffprobe_timeout: Option<u64>,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -136,10 +132,6 @@ pub fn VideoConfigView() -> Html {
|
||||
let render_view_mode = || {
|
||||
html! {
|
||||
<>
|
||||
<div class="tp__video-config-view__body tp__config-view-page__body">
|
||||
{ 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) }
|
||||
</div>
|
||||
<div class="tp__video-config-view__body tp__config-view-page__body">
|
||||
{ config_field_optional!(video_state.form, translate.t(LABEL_WEB_SEARCH), web_search) }
|
||||
</div>
|
||||
@@ -154,10 +146,6 @@ pub fn VideoConfigView() -> Html {
|
||||
let render_edit_mode = || {
|
||||
html! {
|
||||
<>
|
||||
<div class="tp__video-config-view__body tp__config-view-page__body">
|
||||
{ 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) }
|
||||
</div>
|
||||
<div class="tp__video-config-view__body tp__config-view-page__body">
|
||||
{ edit_field_text_option!(video_state, translate.t(LABEL_WEB_SEARCH), web_search, VideoConfigFormAction::WebSearch) }
|
||||
</div>
|
||||
|
||||
@@ -59,12 +59,29 @@ fn label_key_candidates(field_id: &str) -> Vec<String> {
|
||||
keys
|
||||
}
|
||||
|
||||
fn explanation_paragraphs(explanation: &str) -> Vec<String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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! {
|
||||
<div class="tp__field-explanation-dialog">
|
||||
<h2>{title}</h2>
|
||||
<p>{explanation}</p>
|
||||
{for explanation_paragraphs.into_iter().map(|paragraph| {
|
||||
html! { <p style="white-space: pre-wrap;">{paragraph}</p> }
|
||||
})}
|
||||
</div>
|
||||
},
|
||||
Some(actions),
|
||||
|
||||
@@ -179,6 +179,14 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec<Co
|
||||
ConfigForm::Log(_, log_cfg) => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String> { Some(DEFAULT_USER_AGENT.to_s
|
||||
fn is_none_or_empty_video(video: &Option<VideoConfigDto>) -> bool {
|
||||
video.as_ref().is_none_or(VideoConfigDto::is_empty)
|
||||
}
|
||||
fn is_none_or_empty_metadata_update(metadata_update: &Option<MetadataUpdateConfigDto>) -> 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<String>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty_video")]
|
||||
pub video: Option<VideoConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty_metadata_update")]
|
||||
pub metadata_update: Option<MetadataUpdateConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schedules: Option<Vec<ScheduleConfigDto>>,
|
||||
#[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<ConfigDto, _> = 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64, TuliproxError> {
|
||||
Ok(match duration_str.parse::<u64>() {
|
||||
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::<u64>() {
|
||||
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> {
|
||||
|
||||
@@ -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<u64>,
|
||||
#[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<MetadataUpdateConfigDto> = 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<u64, TuliproxError> {
|
||||
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<u64, TuliproxError> {
|
||||
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<u64, TuliproxError> {
|
||||
if value.parse::<u64>().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<u64, TuliproxError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -44,10 +44,6 @@ pub struct VideoConfigDto {
|
||||
pub download: Option<VideoDownloadConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub web_search: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub ffprobe_enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ffprobe_timeout: Option<u64>,
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
//////////////////////////////
|
||||
|
||||
@@ -8,6 +8,36 @@ pub fn current_time_secs() -> u64 {
|
||||
|
||||
pub fn unix_ts_to_str(ts: i64) -> Option<String> { 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<u64> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(seconds) = value.parse::<u64>() {
|
||||
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::<u64>().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<i64> {
|
||||
if ts >= 0 {
|
||||
// Timestamps > Jan 1, 2100 (in seconds) are assumed to be in milliseconds
|
||||
|
||||
Reference in New Issue
Block a user