diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab8fd0cb..cd98b7a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,26 @@ - Removed `forced_retry_interval_secs`. - FFprobe settings moved from `video.*` to `metadata_update.ffprobe.*`. - `metadata_update.ffprobe.analyze_duration` and `metadata_update.ffprobe.live_analyze_duration` now require explicit unit suffixes (`s|m|h|d`). + - **`library.metadata.path` moved to `metadata_update.cache_path`** (default `metadata`). + The TMDB cache is now shared across all metadata resolution paths (Xtream VOD/Series and local library). + Remove `path` from `library.metadata` in your `config.yml` and set it under `metadata_update` instead: + + ```yaml + # Before + library: + metadata: + path: /data/library_metadata + fallback_to_filename: true + + # After + metadata_update: + cache_path: /data/library_metadata # moved here + + library: + metadata: + fallback_to_filename: true + ``` + - **Input Batch Changes**: `name` attribute is now mandatory for input type batch to ensure stable playlist UUIDs. - **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. - **EpgSmartMatch**: Field `name_prefix` syntax needs to be changed from `name_prefix: !suffix "."` to `name_prefix: { suffix: "." }`. @@ -116,6 +136,7 @@ active URL of the specified provider. - **config.yml**: - Added `metadata_update` (optional) with grouped sections: `log`, `resolve`, `probe`, `ffprobe`, `tmdb`. + - Added `metadata_update.cache_path` (default `metadata`): shared storage directory for TMDB cache and metadata files (moved from `library.metadata.path`). - Added `metadata_update.tmdb.cooldown` (default `7d`) for successful TMDB no-match cooldown behavior. - 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` require explicit unit suffixes (`s|m|h|d`). diff --git a/README.md b/README.md index 7ff12c6a2..4ff9fd136 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ Duration fields use the same format as `cache_duration`: plain seconds (`60`) or ```yaml metadata_update: + cache_path: metadata # Storage path for TMDB cache and metadata files (default: "metadata") log: queue_interval: 30s progress_interval: 15s @@ -473,6 +474,8 @@ metadata_update: **Group overview:** +- `cache_path` (default `metadata`): Directory where TMDB cache files and metadata are stored. Relative paths are resolved against `working_dir`. + Used by all metadata resolution paths (Xtream VOD/Series, local library). - `resolve`: Controls retries for metadata lookup tasks (for example title/date/TMDB resolution). - `probe`: Controls retries and cooldown for technical stream probing tasks. - `ffprobe`: Controls how deep and how long FFprobe inspects streams. @@ -1181,7 +1184,6 @@ library: - "m4v" - "webm" metadata: - path: "${env:TULIPROX_HOME}/library_metadata" read_existing: kodi: true jellyfin: false @@ -1195,6 +1197,7 @@ library: ``` TMDB resolver settings for library enrichment are configured globally under `metadata_update.tmdb`. +The metadata storage path is configured via `metadata_update.cache_path` (default `metadata`). **CLI Usage**: diff --git a/backend/src/api/endpoints/library_api.rs b/backend/src/api/endpoints/library_api.rs index 33973310b..9832f29ba 100644 --- a/backend/src/api/endpoints/library_api.rs +++ b/backend/src/api/endpoints/library_api.rs @@ -1,9 +1,9 @@ use crate::{ api::{ - library_scan::spawn_library_scan, + library_scan::{spawn_library_scan, LibraryScanTaskOptions}, model::{AppState, EventMessage}, }, - library::LibraryProcessor, + library::{resolve_metadata_storage_path, LibraryProcessor}, }; use axum::response::IntoResponse; use log::{debug, warn}; @@ -34,10 +34,10 @@ async fn scan_library( }; // Check if Library is enabled - let (lib_config, metadata_update_config) = { + let (lib_config, metadata_update_config, working_dir) = { let config = app_state.app_config.config.load(); match config.library.as_ref() { - Some(lib) if lib.enabled => (lib.clone(), config.metadata_update.clone()), + Some(lib) if lib.enabled => (lib.clone(), config.metadata_update.clone(), config.working_dir.clone()), _ => { let response = LibraryScanSummary { status: LibraryScanSummaryStatus::Error, @@ -56,7 +56,14 @@ async fn scan_library( let client = app_state.http_client.load_full().as_ref().clone(); let event_manager = Arc::clone(&app_state.event_manager); - spawn_library_scan(event_manager, lib_config, metadata_update_config, client, request.force_rescan, "", permit); + spawn_library_scan( + event_manager, + lib_config, + metadata_update_config, + client, + LibraryScanTaskOptions { force_rescan: request.force_rescan, message_prefix: "", working_dir }, + permit, + ); axum::http::StatusCode::ACCEPTED.into_response() } @@ -70,7 +77,8 @@ async fn get_library_status( if config.enabled { let client = app_state.http_client.load_full().as_ref().clone(); // Get statistics from processor - let processor = LibraryProcessor::new(config.clone(), config_snapshot.metadata_update.as_ref(), client); + let processor = + LibraryProcessor::new(config.clone(), config_snapshot.metadata_update.as_ref(), client, &config_snapshot.working_dir); let entries = processor.get_all_entries().await; let movies = entries.iter().filter(|e| e.metadata.is_movie()).count(); @@ -81,7 +89,11 @@ async fn get_library_status( total_items: entries.len(), movies, series, - path: Some(config.metadata.path.clone()), + path: Some( + resolve_metadata_storage_path(config_snapshot.metadata_update.as_ref(), &config_snapshot.working_dir) + .to_string_lossy() + .to_string(), + ), }; return axum::Json(response).into_response(); diff --git a/backend/src/api/library_scan.rs b/backend/src/api/library_scan.rs index 33e84ad18..5dfba5930 100644 --- a/backend/src/api/library_scan.rs +++ b/backend/src/api/library_scan.rs @@ -7,19 +7,25 @@ use log::{error, info}; use shared::model::{LibraryScanSummary, LibraryScanSummaryStatus}; use std::sync::Arc; +pub(crate) struct LibraryScanTaskOptions { + pub force_rescan: bool, + pub message_prefix: &'static str, + pub working_dir: String, +} + pub(crate) fn spawn_library_scan( event_manager: Arc, lib_config: LibraryConfig, metadata_update_config: Option, client: reqwest::Client, - force_rescan: bool, - message_prefix: &'static str, + options: LibraryScanTaskOptions, permit: UpdateGuardPermit, ) { + let LibraryScanTaskOptions { force_rescan, message_prefix, working_dir } = options; let prefix = message_prefix.to_string(); tokio::spawn(async move { let _permit = permit; - let processor = LibraryProcessor::new(lib_config, metadata_update_config.as_ref(), client); + let processor = LibraryProcessor::new(lib_config, metadata_update_config.as_ref(), client, &working_dir); match processor.scan(force_rescan).await { Ok(result) => { info!("{prefix}Library scan completed successfully"); diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 5fe6d7a60..f6212f4dd 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -55,6 +55,128 @@ use tower_http::services::ServeDir; const METADATA_TRIGGER_WAIT_CYCLE_LIMIT: u32 = 900; +fn collect_rescheduled_targets( + targets_set: &HashSet, + running_trigger_targets: &Arc>>, + pending_trigger_targets: &Arc>>, +) -> Vec { + let mut running = running_trigger_targets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut pending = pending_trigger_targets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut targets_to_respawn = Vec::new(); + for target in targets_set { + running.remove(target); + if pending.remove(target) { + running.insert(target.clone()); + targets_to_respawn.push(target.clone()); + } + } + targets_to_respawn +} + +fn spawn_metadata_trigger_update( + app_state: &Arc, + targets_to_spawn: &[String], + running_trigger_targets: &Arc>>, + pending_trigger_targets: &Arc>>, +) { + if targets_to_spawn.is_empty() { + return; + } + + let client = app_state.http_client.load().as_ref().clone(); + let app_config = Arc::clone(&app_state.app_config); + let event_manager = Arc::clone(&app_state.event_manager); + let playlist_state = Arc::clone(&app_state.playlists); + let disabled_headers = app_state.get_disabled_headers(); + let update_guard = app_state.update_guard.clone(); + let app_state_clone = Arc::clone(app_state); + let running_trigger_targets_clone = Arc::clone(running_trigger_targets); + let pending_trigger_targets_clone = Arc::clone(pending_trigger_targets); + let mut current_targets = targets_to_spawn.to_vec(); + + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + loop { + if current_targets.is_empty() { + break; + } + + info!("Triggering playlist update for targets due to metadata change completion: {current_targets:?}"); + let targets_set: HashSet = current_targets.iter().cloned().collect(); + + let (proc_targets, pre_processed_inputs) = { + let sources = app_config.sources.load(); + match sources.validate_targets(Some(¤t_targets)) { + Ok(process_targets) => { + let mut pre_processed_inputs: HashSet> = HashSet::new(); + for source in &sources.sources { + for target in &source.targets { + if targets_set.contains(&target.name) { + for input in &source.inputs { + pre_processed_inputs.insert(input.clone()); + } + } + } + } + (Some(Arc::new(process_targets)), Some(pre_processed_inputs)) + } + Err(_) => (None, None), + } + }; + + if let Some(proc_targets) = proc_targets { + let mut wait_cycles: u32 = 0; + loop { + if let Some(lock) = update_guard.try_playlist() { + exec_processing( + &client, + Arc::clone(&app_config), + proc_targets, + Some(Arc::clone(&event_manager)), + Some(app_state_clone.clone()), + Some(Arc::clone(&playlist_state)), + Some(update_guard.clone()), + disabled_headers.clone(), + Some(app_state_clone.active_provider.clone()), + Some(app_state_clone.metadata_manager.clone()), + pre_processed_inputs.clone(), + Some(lock), + ) + .await; + break; + } + + wait_cycles = wait_cycles.saturating_add(1); + if wait_cycles >= METADATA_TRIGGER_WAIT_CYCLE_LIMIT { + warn!( + "Aborting metadata-triggered update after waiting ~{}s for playlist lock ({} cycles)", + wait_cycles * 2, + wait_cycles + ); + break; + } + if wait_cycles.is_multiple_of(30) { + debug!( + "Metadata-triggered update is still waiting for active playlist update to finish (waited ~{}s)", + wait_cycles * 2 + ); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } else { + warn!("Failed to validate targets for triggered update: {current_targets:?}"); + } + + current_targets = collect_rescheduled_targets( + &targets_set, + &running_trigger_targets_clone, + &pending_trigger_targets_clone, + ); + } + }); +} + fn get_web_dir_path(web_ui_enabled: bool, web_root: &str) -> Result { let web_dir_path = if web_root.is_empty() { get_default_web_root_path() } else { PathBuf::from(web_root) }; if web_ui_enabled && (!&web_dir_path.exists() || !&web_dir_path.is_dir()) { @@ -440,6 +562,10 @@ fn exec_input_update_listener(app_state: &Arc, targets: &Arc> = HashMap::new(); // Set: Tracks which targets are pending an update once their inputs are done let mut pending_targets: HashSet = HashSet::new(); + // Set: Tracks which targets have an active spawned playlist-update task (dedup guard) + let running_trigger_targets: Arc>> = Arc::new(std::sync::Mutex::new(HashSet::new())); + // Set: Tracks follow-up triggers that arrive while a target is already running. + let pending_trigger_targets: Arc>> = Arc::new(std::sync::Mutex::new(HashSet::new())); loop { match rx.recv().await { @@ -484,89 +610,34 @@ fn exec_input_update_listener(app_state: &Arc, targets: &Arc = targets_to_trigger.iter().map(Clone::clone).collect(); - - if let Ok(process_targets) = sources.validate_targets(Some(&targets_to_trigger)) { - let proc_targets = Arc::new(process_targets); - // Collect all inputs for these targets - let mut pre_processed_inputs: HashSet> = HashSet::new(); - for source in &sources.sources { - for target in &source.targets { - if targets_set.contains(&target.name) { - for input in &source.inputs { - pre_processed_inputs.insert(input.clone()); - } - } - } + // Deduplicate: only spawn for targets that don't already have a running trigger task + let targets_to_spawn: Vec = { + let mut running = running_trigger_targets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut pending = + pending_trigger_targets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut to_spawn = Vec::new(); + for target in targets_to_trigger { + if running.insert(target.clone()) { + to_spawn.push(target); + } else { + pending.insert(target); } - - let update_guard = app_state.update_guard.clone(); - let app_state_clone = app_state.clone(); - - // SPAWN the trigger logic so we don't block the event loop with sleep or heavy processing setup - tokio::spawn(async move { - // Small delay to ensure any lingering updates or file locks from the background thread are fully released - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let mut wait_cycles: u32 = 0; - loop { - // Low-priority behavior: never enqueue as a lock waiter. - // Only start when no playlist update is currently active. - if let Some(lock) = update_guard.try_playlist() { - exec_processing( - &client, - app_config, - proc_targets, - Some(event_manager), - Some(app_state_clone.clone()), - Some(playlist_state), - Some(update_guard), - disabled_headers, - Some(app_state_clone.active_provider.clone()), - Some(app_state_clone.metadata_manager.clone()), - Some(pre_processed_inputs), - Some(lock), // Keep permit during processing - ) - .await; - break; - } - - wait_cycles = wait_cycles.saturating_add(1); - if wait_cycles >= METADATA_TRIGGER_WAIT_CYCLE_LIMIT { - warn!( - "Aborting metadata-triggered update after waiting ~{}s for playlist lock ({} cycles)", - wait_cycles * 2, - wait_cycles - ); - break; - } - if wait_cycles.is_multiple_of(30) { - debug!( - "Metadata-triggered update is still waiting for active playlist update to finish (waited ~{}s)", - wait_cycles * 2 - ); - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - }); - } else { - warn!("Failed to validate targets for triggered update: {targets_to_trigger:?}"); } + to_spawn + }; + + if !targets_to_spawn.is_empty() { + spawn_metadata_trigger_update( + &app_state, + &targets_to_spawn, + &running_trigger_targets, + &pending_trigger_targets, + ); } } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - warn!("Input update listener lagged by {skipped} messages. Resetting tracking state (active_targets={}, pending={}) to avoid inconsistencies.", + warn!("Input update listener lagged by {skipped} messages. Resetting input-tracking state (active_targets={}, pending={}) while preserving trigger dedup state.", active_target_inputs.len(), pending_targets.len()); active_target_inputs.clear(); pending_targets.clear(); diff --git a/backend/src/api/model/metadata_update_manager.rs b/backend/src/api/model/metadata_update_manager.rs index 1bfb80edf..f834de836 100644 --- a/backend/src/api/model/metadata_update_manager.rs +++ b/backend/src/api/model/metadata_update_manager.rs @@ -918,6 +918,7 @@ impl InputWorker { let mut last_queue_log_at = Instant::now(); let mut last_progress_log_at = Instant::now(); let mut queue_cycle_active = false; + let mut cycle_had_changes = false; let input_name = self.input_name.clone(); let app_state_weak = self.app_state_weak.clone(); @@ -951,6 +952,7 @@ impl InputWorker { if !queue_cycle_active { // First entry of a new processing cycle. queue_cycle_active = true; + cycle_had_changes = false; processed_vod_count = 0; processed_series_count = 0; last_progress_log_at = Instant::now(); @@ -1102,9 +1104,13 @@ impl InputWorker { } else if Self::is_series_task_key(¤t_key) { processed_series_count += 1; } + let trigger_playlist_update = + Self::should_trigger_playlist_update_for_task(&task_for_execution, task_outcome.task_changed); + cycle_had_changes |= trigger_playlist_update; debug!( - "Processed metadata task for input {input_name}: {task_for_execution} (changed={}, tmdb_pending={})", + "Processed metadata task for input {input_name}: {task_for_execution} (changed={}, trigger_playlist_update={}, tmdb_pending={})", task_outcome.task_changed, + trigger_playlist_update, task_outcome.tmdb_pending ); @@ -1425,14 +1431,19 @@ impl InputWorker { } if queue_cycle_active && queue_completely_empty { - info!("All pending metadata resolves completed for input {input_name}"); - if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { - app_state.event_manager.send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); - } self.last_cycle_completed_at_ts = Some(chrono::Utc::now().timestamp()); queue_cycle_active = false; processed_vod_count = 0; processed_series_count = 0; + if cycle_had_changes { + info!("All pending metadata resolves completed for input {input_name} (with changes)"); + if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { + app_state.event_manager.send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); + } + } else { + debug!("All pending metadata resolves completed for input {input_name} (no changes, skipping playlist update trigger)"); + } + cycle_had_changes = false; } } @@ -1856,6 +1867,11 @@ impl InputWorker { matches!(task, UpdateTask::ResolveVod { .. } | UpdateTask::ResolveSeries { .. }) } + #[inline] + fn should_trigger_playlist_update_for_task(task: &UpdateTask, task_changed: bool) -> bool { + task_changed && !Self::is_probe_task(task) && !Self::is_probe_only_resolve_task(task) + } + #[inline] fn task_has_tmdb_reason(task: &UpdateTask) -> bool { match task { @@ -3365,4 +3381,26 @@ mod tests { }; assert_eq!(InputWorker::retry_domain_for_task(&task), RetryDomain::Resolve); } + + #[test] + fn playlist_trigger_ignores_probe_only_changes() { + let task = UpdateTask::ProbeLive { + id: ProviderIdType::Id(22), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + delay: 0, + interval: 60, + }; + assert!(!InputWorker::should_trigger_playlist_update_for_task(&task, true)); + } + + #[test] + fn playlist_trigger_keeps_info_changes() { + let task = UpdateTask::ResolveSeries { + id: ProviderIdType::Id(33), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Info]), + delay: 0, + }; + assert!(InputWorker::should_trigger_playlist_update_for_task(&task, true)); + assert!(!InputWorker::should_trigger_playlist_update_for_task(&task, false)); + } } diff --git a/backend/src/api/scheduler.rs b/backend/src/api/scheduler.rs index f4360a93f..1f07f4746 100644 --- a/backend/src/api/scheduler.rs +++ b/backend/src/api/scheduler.rs @@ -1,5 +1,5 @@ use crate::{ - api::{library_scan::spawn_library_scan, model::AppState}, + api::{library_scan::{spawn_library_scan, LibraryScanTaskOptions}, model::AppState}, model::{AppConfig, ProcessTargets, ScheduleConfig}, processing::processor::exec_processing, utils::exit, @@ -128,8 +128,11 @@ fn run_library_scan(client: &reqwest::Client, app_state: &Arc) { lib_config.clone(), config.metadata_update.clone(), client.clone(), - false, - "Scheduled ", + LibraryScanTaskOptions { + force_rescan: false, + message_prefix: "Scheduled ", + working_dir: config.working_dir.clone(), + }, permit, ); } diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index b54d70b63..556d77595 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -20,13 +20,7 @@ impl MetadataResolver { metadata_update_config: Option<&MetadataUpdateConfig>, client: reqwest::Client, ) -> Self { - let storage = match library_config { - None => None, - Some(c) => { - let storage_path = std::path::PathBuf::from(&c.metadata.path); - Some(MetadataStorage::new(storage_path)) - } - }; + let storage = metadata_update_config.map(|c| MetadataStorage::new(std::path::PathBuf::from(&c.cache_path))); Self::from_config(library_config, metadata_update_config, client, storage) } @@ -229,7 +223,6 @@ mod tests { scan_directories: vec![], supported_extensions: vec![], metadata: LibraryMetadataConfig { - path: "/tmp/vod".to_string(), read_existing: LibraryMetadataReadConfig { kodi: true, jellyfin: true, diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index f5386eb8e..e7e3db79a 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -24,8 +24,9 @@ impl MetadataStorage { pub async fn initialize(&self) -> std::io::Result<()> { if !fs::try_exists(&self.storage_dir).await.unwrap_or(false) { info!("Creating metadata storage directory: {}", self.storage_dir.display()); - fs::create_dir_all(&self.storage_dir).await?; } + fs::create_dir_all(&self.storage_dir).await?; + fs::create_dir_all(self.storage_dir.join("library")).await?; Ok(()) } @@ -76,11 +77,14 @@ impl MetadataStorage { // Loads all metadata entries from storage pub async fn load_all(&self) -> Vec { let mut entries = Vec::new(); + let library_dir = self.storage_dir.join("library"); - let mut read_dir = match fs::read_dir(&self.storage_dir).await { + let mut read_dir = match fs::read_dir(&library_dir).await { Ok(dir) => dir, Err(e) => { - error!("Failed to read metadata directory: {e}"); + if e.kind() != std::io::ErrorKind::NotFound { + error!("Failed to read metadata directory {}: {e}", library_dir.display()); + } return entries; } }; @@ -147,18 +151,17 @@ impl MetadataStorage { .collect() } - // Gets the metadata file path for a UUID fn get_metadata_file_path(&self, uuid: &str) -> PathBuf { - self.storage_dir.join(format!("{uuid}.json")) + self.storage_dir.join("library").join(format!("{uuid}.json")) } fn get_tmdb_movie_data_file_path(&self, tmdb_id: u32) -> PathBuf { - self.storage_dir.join(format!("movie_{tmdb_id}.tmdb")) + self.storage_dir.join("movie").join(format!("movie_{tmdb_id}.tmdb")) } fn get_tmdb_series_data_file_path(&self, tmdb_id: u32) -> PathBuf { - self.storage_dir.join(format!("series_{tmdb_id}.tmdb")) + self.storage_dir.join("series").join(format!("series_{tmdb_id}.tmdb")) } // write raw tmdb movie info @@ -354,4 +357,4 @@ mod tests { let deleted = storage.load_by_uuid(&entry.uuid).await; assert!(deleted.is_none()); } -} \ No newline at end of file +} diff --git a/backend/src/library/processor.rs b/backend/src/library/processor.rs index 41c96faa6..1039a0062 100644 --- a/backend/src/library/processor.rs +++ b/backend/src/library/processor.rs @@ -6,8 +6,10 @@ use crate::library::scanner::LibraryScanner; use crate::library::{MediaGroup, MediaGrouper}; use crate::model::{AppConfig, LibraryConfig, MetadataUpdateConfig}; use log::{debug, error, info, warn}; +use path_clean::PathClean; use shared::model::{LibraryMetadataFormat, LibraryScanResult}; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; // Action taken when processing a file @@ -27,6 +29,27 @@ pub struct LibraryProcessor { app_config: Option>, // Need access to global config for FFprobe settings } +pub fn resolve_metadata_storage_path( + metadata_update_config: Option<&MetadataUpdateConfig>, + working_dir: &str, +) -> PathBuf { + let configured_path = metadata_update_config.map_or_else( + || PathBuf::from(shared::utils::default_metadata_path()), + |c| { + if c.cache_path.is_empty() { + PathBuf::from(shared::utils::default_metadata_path()) + } else { + PathBuf::from(c.cache_path.clone()) + } + }, + ); + if configured_path.is_absolute() { + configured_path.clean() + } else { + PathBuf::from(working_dir).join(configured_path).clean() + } +} + impl LibraryProcessor { // Creates a new Library processor from application config pub fn from_app_config(app_config: &AppConfig) -> Option { @@ -38,12 +61,17 @@ impl LibraryProcessor { config .library .as_ref() - .map(|lib_cfg| Self::new(lib_cfg.clone(), config.metadata_update.as_ref(), client)) + .map(|lib_cfg| Self::new(lib_cfg.clone(), config.metadata_update.as_ref(), client, &config.working_dir)) } // Creates a new Library processor with the given configuration - pub fn new(config: LibraryConfig, metadata_update_config: Option<&MetadataUpdateConfig>, client: reqwest::Client) -> Self { - let storage_path = std::path::PathBuf::from(&config.metadata.path); + pub fn new( + config: LibraryConfig, + metadata_update_config: Option<&MetadataUpdateConfig>, + client: reqwest::Client, + working_dir: &str, + ) -> Self { + let storage_path = resolve_metadata_storage_path(metadata_update_config, working_dir); let scanner = LibraryScanner::new(config.clone()); let storage = MetadataStorage::new(storage_path); let resolver = MetadataResolver::from_config(Some(&config), metadata_update_config, client, Some(storage.clone())); diff --git a/backend/src/library/scanner.rs b/backend/src/library/scanner.rs index 9704479c3..3dbbae45f 100644 --- a/backend/src/library/scanner.rs +++ b/backend/src/library/scanner.rs @@ -291,7 +291,6 @@ mod tests { "avi".to_string(), ], metadata: LibraryMetadataConfig { - path: "/tmp/vod_metadata".to_string(), read_existing: LibraryMetadataReadConfig { kodi: false, jellyfin: false, diff --git a/backend/src/main.rs b/backend/src/main.rs index ed9cedb0e..eb26f35fe 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -274,10 +274,8 @@ fn print_info(app_config: &AppConfig) { info!("Cache dir: {}", cache.dir); } } - if let Some(lib_config) = config.library.as_ref() { - if lib_config.enabled { - info!("Library Metadata path: {}", lib_config.metadata.path); - } + if let Some(metadata_update) = config.metadata_update.as_ref() { + info!("Metadata path: {}", metadata_update.cache_path); } if let Some(resource_path) = paths.custom_stream_response_path.as_ref() { info!("Resource path: {resource_path}"); diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index 766c2df04..606d37ba8 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -23,7 +23,7 @@ fn create_directories(cfg: &Config, temp_path: &Path) { cfg.user_config_dir.clone(), cfg.video.as_ref().and_then(|v| v.download.as_ref()).map(|d| d.directory.clone()), cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { Some(c.dir.clone()) } else { None })), - cfg.library.as_ref().filter(|l| l.enabled).map(|l| l.metadata.path.clone()), + cfg.metadata_update.as_ref().map(|m| m.cache_path.clone()), ]; let mut paths: Vec = paths_strings.iter() @@ -109,6 +109,16 @@ impl Config { library.prepare(&self.working_dir)?; } + if let Some(metadata_update) = self.metadata_update.as_mut() { + let meta_path = PathBuf::from(&metadata_update.cache_path); + let meta_path = if meta_path.is_relative() { + PathBuf::from(&self.working_dir).join(meta_path) + } else { + meta_path + }; + metadata_update.cache_path = meta_path.to_string_lossy().to_string(); + } + if let Some(messaging) = self.messaging.as_mut() { messaging.prepare(config_path); } diff --git a/backend/src/model/config/library.rs b/backend/src/model/config/library.rs index 23c9b29a9..24109556c 100644 --- a/backend/src/model/config/library.rs +++ b/backend/src/model/config/library.rs @@ -1,7 +1,7 @@ use crate::model::macros; use shared::error::{info_err_res, TuliproxError}; use shared::model::{ConfigDto, LibraryConfigDto, LibraryContentType, LibraryMetadataFormat}; -use shared::utils::{default_metadata_path, Internable}; +use shared::utils::Internable; use std::path::PathBuf; use std::sync::Arc; @@ -15,7 +15,6 @@ pub struct LibraryScanDirectory { #[derive(Debug, Clone)] pub struct LibraryMetadataConfig { - pub path: String, pub read_existing: LibraryMetadataReadConfig, pub fallback_to_filename: bool, pub formats: Vec, @@ -68,16 +67,6 @@ impl LibraryConfig { pub fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { if self.enabled { - if self.metadata.path.is_empty() { - self.metadata.path = default_metadata_path(); - } - - // Resolve metadata path to absolute path based on working_dir - let meta_path = PathBuf::from(&self.metadata.path); - let meta_path = - if meta_path.is_relative() { PathBuf::from(working_dir).join(meta_path) } else { meta_path }; - self.metadata.path = meta_path.to_string_lossy().to_string(); - self.canonicalize_scan_directories(working_dir)?; } Ok(()) @@ -108,7 +97,6 @@ impl From<&LibraryConfigDto> for LibraryConfig { .collect(), supported_extensions: dto.supported_extensions.iter().map(|ext| ext.to_lowercase()).collect(), metadata: LibraryMetadataConfig { - path: dto.metadata.path.clone(), read_existing: LibraryMetadataReadConfig { kodi: dto.metadata.read_existing.kodi, jellyfin: dto.metadata.read_existing.jellyfin, diff --git a/backend/src/model/config/metadata_update.rs b/backend/src/model/config/metadata_update.rs index 4902b98d0..78da1c9f8 100644 --- a/backend/src/model/config/metadata_update.rs +++ b/backend/src/model/config/metadata_update.rs @@ -16,6 +16,7 @@ use shared::utils::{ #[derive(Debug, Clone)] pub struct MetadataUpdateConfig { + pub cache_path: String, pub log: MetadataLogConfig, pub resolve: ResolveConfig, pub probe: ProbeConfig, @@ -135,6 +136,7 @@ impl From<&MetadataUpdateConfigDto> for MetadataUpdateConfig { } Self { + cache_path: normalized.cache_path, log: MetadataLogConfig::from(&normalized.log), resolve: ResolveConfig::from(&normalized.resolve), probe: ProbeConfig::from(&normalized.probe), @@ -160,6 +162,7 @@ impl From<&MetadataUpdateConfigDto> for MetadataUpdateConfig { impl From<&MetadataUpdateConfig> for MetadataUpdateConfigDto { fn from(instance: &MetadataUpdateConfig) -> Self { Self { + cache_path: instance.cache_path.clone(), log: MetadataLogConfigDto::from(&instance.log), resolve: ResolveConfigDto::from(&instance.resolve), probe: ProbeConfigDto::from(&instance.probe), diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 2871f29bb..e5fbbcdb1 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -14,7 +14,8 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; if !library_config.enabled { return (vec![], vec![]); } - let storage_path = std::path::PathBuf::from(&library_config.metadata.path); + let storage_path = config.metadata_update.as_ref() + .map_or_else(|| std::path::PathBuf::from(shared::utils::default_metadata_path()), |m| std::path::PathBuf::from(&m.cache_path)); let mut metadata_iter = MetadataAsyncIter::new(&storage_path).await; let mut group_movies = PlaylistGroup { id: 0, diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index e29a85fd3..0a5a32c11 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -764,7 +764,7 @@ pub async fn update_series_metadata( let metadata_update_config = config.metadata_update.as_ref(); let tmdb_storage = metadata_update_config .filter(|cfg| cfg.tmdb.enabled) - .map(|_| MetadataStorage::new(storage_path.clone())); + .map(|cfg| MetadataStorage::new(std::path::PathBuf::from(&cfg.cache_path))); let meta_resolver = MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage); diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index fcf1a3934..4d9c0e730 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -681,7 +681,7 @@ pub async fn update_vod_metadata( let metadata_update_config = config.metadata_update.as_ref(); let tmdb_storage = metadata_update_config .filter(|cfg| cfg.tmdb.enabled) - .map(|_| MetadataStorage::new(storage_path.clone())); + .map(|cfg| MetadataStorage::new(std::path::PathBuf::from(&cfg.cache_path))); let meta_resolver = MetadataResolver::from_config(library_config, metadata_update_config, client.clone(), tmdb_storage); diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 8d1e753bf..aee5772f1 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -798,6 +798,46 @@ fn get_quality(strm_target_output: &StrmTargetOutput, pli: &PlaylistItem, separa String::new() } +/// Returns `true` if `s` contains a known TMDB path marker. +fn strm_contains_tmdb_marker(s: &str) -> bool { + s.contains(" {tmdb=") + || s.contains(" {tmdb-") + || s.contains(" [tmdbid=") + || s.contains(" [tmdbid-") + || s.contains("_{tmdb=") + || s.contains("_{tmdb-") + || s.contains("_[tmdbid=") + || s.contains("_[tmdbid-") +} + +/// Strips all TMDB markers (e.g. ` {tmdb=123}`, ` [tmdbid-456]`) from a path string, +/// returning the equivalent no-tmdb path used for identity matching. +fn strip_tmdb_markers(s: &str) -> String { + let mut result = s.to_string(); + for marker_prefix in &[ + " {tmdb=", + " {tmdb-", + " [tmdbid=", + " [tmdbid-", + "_{tmdb=", + "_{tmdb-", + "_[tmdbid=", + "_[tmdbid-", + ] { + while let Some(start) = result.find(marker_prefix) { + let close_char = if marker_prefix.contains('{') { '}' } else { ']' }; + let search_from = start + marker_prefix.len(); + if let Some(rel_close) = result[search_from..].find(close_char) { + let end = search_from + rel_close + 1; + result.replace_range(start..end, ""); + } else { + break; + } + } + } + result +} + pub async fn write_strm_playlist( app_config: &AppConfig, target: &ConfigTarget, @@ -831,6 +871,15 @@ pub async fn write_strm_playlist( }; let mut processed_strm: HashSet = HashSet::with_capacity(existing_strm.len()); + // Build a lookup map: stripped_path → enriched_path for all existing STRM paths + // that already contain a TMDB marker. Used to preserve enriched filenames when the + // current playlist item has no tmdb_id yet (avoids rename back to plain name). + let enriched_strm: std::collections::HashMap = existing_strm + .iter() + .filter(|p| strm_contains_tmdb_marker(p)) + .map(|p| (strip_tmdb_markers(p), p.clone())) + .collect(); + let mut failed = vec![]; prepare_strm_output_directory(&root_path).await?; @@ -847,9 +896,19 @@ pub async fn write_strm_playlist( let output_path = truncate_filename(&root_path.join(&strm_file.dir_path), 255); let file_path = output_path.join(format!("{}.strm", truncate_string(&strm_file.file_name, 250))); - let file_exists = file_path.exists(); let relative_file_path = get_relative_path_str(&file_path, &root_path); + let (target_relative_file_path, target_file_path) = if strm_file.strm_info.tmdb_id.is_none() { + if let Some(enriched_path) = enriched_strm.get(&relative_file_path) { + (enriched_path.clone(), root_path.join(enriched_path)) + } else { + (relative_file_path.clone(), file_path) + } + } else { + (relative_file_path.clone(), file_path) + }; + let file_exists = target_file_path.exists(); + // create content let url = get_strm_url(target_force_redirect, user_and_server_info.as_ref(), &strm_file.strm_info); let mut content = target_output.strm_props.as_ref().map_or_else(Vec::new, std::clone::Clone::clone); @@ -859,24 +918,25 @@ pub async fn write_strm_playlist( let content_hash = hash_bytes(content_as_bytes); // check if file exists and has same hash - if file_exists && has_strm_file_same_hash(&file_path, content_hash).await { - processed_strm.insert(relative_file_path); + if file_exists && has_strm_file_same_hash(&target_file_path, content_hash).await { + processed_strm.insert(target_relative_file_path); continue; // skip creation } // if we can't create the directory skip this entry - if !ensure_strm_file_directory(&mut failed, &output_path).await { + let target_output_path = target_file_path.parent().map_or_else(|| output_path.clone(), std::path::Path::to_path_buf); + if !ensure_strm_file_directory(&mut failed, &target_output_path).await { continue; } match write_strm_file( - &file_path, + &target_file_path, content_as_bytes, strm_file.strm_info.get_file_ts(), ).await { Ok(()) => { - processed_strm.insert(relative_file_path); + processed_strm.insert(target_relative_file_path); } Err(err) => { failed.push(err); diff --git a/backend/src/utils/logging.rs b/backend/src/utils/logging.rs index 1b6391514..15459a13f 100644 --- a/backend/src/utils/logging.rs +++ b/backend/src/utils/logging.rs @@ -10,7 +10,8 @@ const LOG_ERROR_LEVEL_MOD: &[&str] = &[ "reqwest::async_impl::client", "reqwest::connect", "hyper_util::client", - "tungstenite::protocol" + "tungstenite::protocol", + "rustls_platform_verifier::verification" ]; fn get_log_level(log_level: &str) -> LevelFilter { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index e9a86cd3b..2ccd804e1 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -299,6 +299,10 @@ "URL": "Target URL for this setting.", "USERNAME": "Login username for this account or service." }, + "CONFIG_PROVIDER": { + "NAME": "Unique provider identifier used by provider:// references.", + "URLS": "Ordered list of provider base URLs. Tuliprox rotates through them on failover." + }, "CONFIG_INPUT_OPTIONS": { "PROBE_DELAY": "Delay in seconds between probe requests (default 2) to reduce provider pressure.", "PROBE_LIVE": "Enables background technical analysis for live channels to detect codecs and resolution.", @@ -331,6 +335,7 @@ "DISCORD": { "TEMPLATE_MESSAGE": "Template message body for Discord notifications." }, + "DNS_MAX_ADDRS": "Optional limit of resolved IP addresses kept per hostname. Leave empty for no explicit limit.", "EPG_CONFIG": { "NAME_PREFIX_SEPARATOR": "List of characters (e.g. ':', '|') that separate channel prefixes from the main title during Smart Match.", "STRIP": "Terms to ignore during EPG Smart Matching (e.g. '4K', 'HD', 'HEVC')." @@ -368,7 +373,8 @@ "EPG_SOURCES": "Associated EPG (Electronic Program Guide) sources for this input.", "FETCH_METHOD": "HTTP method used to retrieve the input (e.g., GET, POST).", "HEADERS": "Custom HTTP headers sent when retrieving the input playlist.", - "INPUT_TYPE": "Defines the streaming protocol or source format (e.g. xtream, m3u)." + "INPUT_TYPE": "Defines the streaming protocol or source format (e.g. xtream, m3u).", + "PROVIDERS": "Provider profiles define reusable upstream URLs and DNS behavior that this input can reference." }, "IP_CHECK_CONFIG": { "PATTERN_IPV4": "Optional regex to extract IPv4 address from the response (default matches standard decimal format).", @@ -458,6 +464,20 @@ "URL": "Source URL used to download playlist data.", "USERNAME": "Source username used for loading playlists from a protected provider." }, + "PROVIDER_DNS": { + "ENABLED": "Enables DNS/IP-connect behavior for this provider.", + "KEEP_VHOST": "If enabled, keeps the original hostname in the Host header while connecting by resolved IP.", + "REFRESH_SECS": "DNS refresh interval in seconds for provider hostnames (minimum effective value is 10)." + }, + "PROVIDER_FORM": { + "URLS": "List of base URLs for this provider. They are used in order and rotated when failover is triggered.", + "DNS": { + "ON_CONNECT_ERROR": "Defines connect-error behavior: try the next resolved IP first, or rotate directly to the next provider URL.", + "ON_RESOLVE_ERROR": "Defines resolve-error behavior: keep last known good IPs, or fall back to hostname-based connections.", + "PREFER": "IP family preference for DNS resolution (`system`, `ipv4`, `ipv6`).", + "SCHEMES": "Restricts DNS/IP-connect handling to selected URL schemes (`http`, `https`)." + } + }, "PROBE": "Probe controls technical stream analysis retries.\n\nUse this group to define retry steps, retry limits, and cooldown behavior for FFprobe-based stream checks.", "PROBE_CONFIG": { "COOLDOWN": "Pause time after probe retries are exhausted.\n\nDuring cooldown, that item is skipped for probing. After cooldown ends, probing can start again.\n\nExamples: `1d`, `3d`, `7d`.", @@ -660,9 +680,11 @@ "ADD_MAPPING": "Add Mapping", "ADD_PATTERN": "Add Pattern", "ADD_PROPERTY": "Add Property", + "ADD_PROVIDER": "Add Provider", "ADD_QUALITY_TO_FILENAME": "Quality", "ADD_SERVER": "Add Server", "ADD_TRAKT_LIST": "Add a Trakt list", + "ADD_URL": "Add Url", "ADD_WATCH": "Add Watch", "ADVANCED": "Advanced", "ALIAS": "Alias", @@ -742,6 +764,23 @@ "DISK_BASED_PROCESSING": "Disk Based Processing", "DOCUMENTATION": "Documentation", "DOCUMENTATION_CONTENT": "Get started with tuliprox: features, setup, and examples", + "DNS_CONNECT_ROTATE_PROVIDER_URL": "Rotate Provider Url", + "DNS_CONNECT_TRY_NEXT_IP": "Try Next IP", + "DNS_ENABLED": "DNS Enabled", + "DNS_KEEP_VHOST": "DNS Keep VHost", + "DNS_MAX_ADDRS": "DNS Max Addrs", + "DNS_ON_CONNECT_ERROR": "DNS On Connect Error", + "DNS_ON_RESOLVE_ERROR": "DNS On Resolve Error", + "DNS_PREFER": "DNS Prefer", + "DNS_PREFER_IPV4": "IPv4", + "DNS_PREFER_IPV6": "IPv6", + "DNS_PREFER_SYSTEM": "System", + "DNS_REFRESH_SECS": "DNS Refresh (secs)", + "DNS_RESOLVE_FALLBACK_TO_HOSTNAME": "Fallback To Hostname", + "DNS_RESOLVE_KEEP_LAST_GOOD": "Keep Last Good", + "DNS_SCHEME_HTTP": "HTTP", + "DNS_SCHEME_HTTPS": "HTTPS", + "DNS_SCHEMES": "DNS Schemes", "DOWNLOAD": "Download", "DURATION": "Duration", "EDIT": "Edit", @@ -946,6 +985,9 @@ "PROPRIETARY_DISCOVERY": "Proprietary Discovery", "PROTOCOL": "Protocol", "PROVIDER": "Provider", + "PROVIDER_DNS": "Provider DNS", + "PROVIDER_NAME": "Provider Name", + "PROVIDER_URLS": "Provider URLs", "PROXY": "Proxy", "PROXY_CONFIG": "Proxy", "PUSHOVER": "Pushover", diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index bb4dd8f24..9ff8972ff 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -559,5 +559,9 @@ { "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" + }, + { + "keys": [ "Dns" ], + "path": "M20 13H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1zM7 19c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM20 3H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1V4c0-.55-.45-1-1-1zM7 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" } ] diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index 4e35b4973..6aea01bbf 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -1,9 +1,9 @@ @use "../../../size" as size; $movie-card-height: 24rem; -$movie-card-width: 16rem; +$movie-card-width: 14rem; $live-card-width: 17.5rem; $episode-card-height: 13rem; -$episode-card-width: 18rem; +$episode-card-width: 14rem; $series-backdrop-height: 300px; $series-backdrop-mobile-height: 150px; @@ -139,7 +139,7 @@ $series-backdrop-mobile-height: 150px; &-series { display: grid; grid-template-columns: repeat(auto-fill, minmax($movie-card-width, 1fr)); - gap: var(--gap-default); + gap: var(--gap-large); box-sizing: border-box; width: 100%; } @@ -147,7 +147,7 @@ $series-backdrop-mobile-height: 150px; &-episodes { display: grid; grid-template-columns: repeat(auto-fill, minmax($episode-card-width, 1fr)); - gap: var(--gap-default); + gap: var(--gap-large); box-sizing: border-box; width: 100%; } diff --git a/frontend/src/app/components/config/library_config_view.rs b/frontend/src/app/components/config/library_config_view.rs index abf218cba..b69e9e189 100644 --- a/frontend/src/app/components/config/library_config_view.rs +++ b/frontend/src/app/components/config/library_config_view.rs @@ -27,7 +27,6 @@ const LABEL_SCAN_DIRECTORIES: &str = "LABEL.SCAN_DIRECTORIES"; const LABEL_ADD_DIRECTORY: &str = "LABEL.ADD_DIRECTORY"; const LABEL_SUPPORTED_EXTENSIONS: &str = "LABEL.SUPPORTED_EXTENSIONS"; const LABEL_METADATA: &str = "LABEL.METADATA"; -const LABEL_METADATA_PATH: &str = "LABEL.METADATA_PATH"; const LABEL_PLAYLIST: &str = "LABEL.PLAYLIST"; const LABEL_MOVIE_CATEGORY: &str = "LABEL.MOVIE_CATEGORY"; const LABEL_SERIES_CATEGORY: &str = "LABEL.SERIES_CATEGORY"; @@ -73,7 +72,6 @@ generate_form_reducer!( state: LibraryMetadataConfigFormState { form: LibraryMetadataConfigDto }, action_name: LibraryMetadataConfigFormAction, fields { - Path => path: String, FallbackToFilename => fallback_to_filename: bool, Formats => formats: Vec, } @@ -364,7 +362,6 @@ pub fn LibraryConfigView() -> Html { { render_extensions(&form_state.form.supported_extensions) }

{translate.t(LABEL_METADATA)}

- { config_field!(metadata, translate.t(LABEL_METADATA_PATH), path) } { config_field_bool!(metadata, translate.t(LABEL_FALLBACK_TO_FILENAME), fallback_to_filename) } { config_field_child!(translate.t(LABEL_FORMATS), "LIBRARY_CONFIG.FORMATS", { html! { @@ -406,7 +403,6 @@ pub fn LibraryConfigView() -> Html {

{translate.t(LABEL_METADATA)}

- { edit_field_text!(metadata_state, translate.t(LABEL_METADATA_PATH), path, LibraryMetadataConfigFormAction::Path) } { edit_field_bool!(metadata_state, translate.t(LABEL_FALLBACK_TO_FILENAME), fallback_to_filename, LibraryMetadataConfigFormAction::FallbackToFilename) } { config_field_child!(translate.t(LABEL_FORMATS), "LIBRARY_CONFIG.FORMATS", { let metadata_state = metadata_state.clone(); diff --git a/frontend/src/app/components/config/metadata_update_config_view.rs b/frontend/src/app/components/config/metadata_update_config_view.rs index 97ae4770e..c3f245149 100644 --- a/frontend/src/app/components/config/metadata_update_config_view.rs +++ b/frontend/src/app/components/config/metadata_update_config_view.rs @@ -38,6 +38,7 @@ 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_METADATA_PATH: &str = "LABEL.METADATA_PATH"; 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"; @@ -61,6 +62,7 @@ generate_form_reducer!( state: MetadataUpdateConfigFormState { form: MetadataUpdateConfigDto }, action_name: MetadataUpdateConfigFormAction, fields { + Path => cache_path: String, RetryDelay => retry_delay: String, MaxQueueSize => max_queue_size: usize, WorkerIdleTimeout => worker_idle_timeout: String, @@ -244,6 +246,7 @@ pub fn MetadataUpdateConfigView() -> Html {

{translate.t(LABEL_SETTINGS)}

+ { config_field!(form_state.form, translate.t(LABEL_METADATA_PATH), cache_path) } { config_field!(form_state.form, translate.t(LABEL_RETRY_DELAY), retry_delay) } { config_field!(form_state.form, translate.t(LABEL_MAX_QUEUE_SIZE), max_queue_size) } { config_field!(form_state.form, translate.t(LABEL_WORKER_IDLE_TIMEOUT), worker_idle_timeout) } @@ -311,6 +314,7 @@ pub fn MetadataUpdateConfigView() -> Html {

{translate.t(LABEL_SETTINGS)}

+ { edit_field_text!(form_state, translate.t(LABEL_METADATA_PATH), cache_path, MetadataUpdateConfigFormAction::Path) } { edit_field_text!(form_state, translate.t(LABEL_RETRY_DELAY), retry_delay, MetadataUpdateConfigFormAction::RetryDelay) } { 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_WORKER_IDLE_TIMEOUT), worker_idle_timeout, MetadataUpdateConfigFormAction::WorkerIdleTimeout) } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index bac7ce793..fb3174359 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -311,6 +311,22 @@ fn editor_state_to_sources_config(base_sources: &SourcesConfigDto, editor_state: } } + // Aggregate per-input providers into the source-level provider list. + // Source-level providers (from base_sources) take precedence; per-input providers + // with duplicate names are ignored. + let mut all_providers: Vec = sources_config.provider.take().unwrap_or_default(); + let mut seen_provider_names: HashSet = all_providers.iter().map(|p| p.name.to_string()).collect(); + for input in &gen_inputs { + if let Some(input_providers) = &input.provider { + for p in input_providers { + if seen_provider_names.insert(p.name.to_string()) { + all_providers.push(p.clone()); + } + } + } + } + sources_config.provider = if all_providers.is_empty() { None } else { Some(all_providers) }; + sources_config.inputs = gen_inputs; sources_config.sources = gen_sources; sources_config diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 517f67c4b..dc6744fdd 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -1,8 +1,8 @@ use crate::{ app::components::{ config::HasFormData, key_value_editor::KeyValueEditor, select::Select, AliasItemForm, BlockId, BlockInstance, - Card, DropDownOption, DropDownSelection, EditMode, EpgSourceItemForm, IconButton, Panel, RadioButtonGroup, - SourceEditorContext, TextButton, TitledCard, + Card, DropDownOption, DropDownSelection, EditMode, EpgSourceItemForm, IconButton, Panel, ProviderItemForm, + RadioButtonGroup, SourceEditorContext, TextButton, TitledCard, }, config_field_child, edit_field_bool, edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_number_u32, edit_field_text, edit_field_text_option, generate_form_reducer, html_if, @@ -13,8 +13,8 @@ use shared::{ error::TuliproxError, info_err_res, model::{ - ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, EpgConfigDto, EpgSourceDto, InputFetchMethod, - InputType, StagedInputDto, + ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigProviderDto, EpgConfigDto, EpgSourceDto, + InputFetchMethod, InputType, StagedInputDto, }, }; use std::{collections::HashMap, fmt::Display, rc::Rc, str::FromStr}; @@ -40,6 +40,8 @@ const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS"; const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE"; const LABEL_ADD_EPG_SOURCE: &str = "LABEL.ADD_EPG_SOURCE"; const LABEL_ADD_ALIAS: &str = "LABEL.ADD_ALIAS"; +const LABEL_ADD_PROVIDER: &str = "LABEL.ADD_PROVIDER"; +const LABEL_PROVIDERS: &str = "LABEL.PROVIDER"; const LABEL_SKIP: &str = "LABEL.SKIP"; const LABEL_XTREAM_SKIP_LIVE: &str = "LABEL.LIVE"; const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.VOD"; @@ -60,6 +62,7 @@ const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; const LABEL_STAGED: &str = "LABEL.STAGED"; const LABEL_ADVANCED: &str = "LABEL.ADVANCED"; const LABEL_ALIAS: &str = "LABEL.ALIAS"; +const LABEL_PROVIDER: &str = "LABEL.PROVIDER"; const LABEL_LIVE_STREAMS: &str = "LABEL.LIVE_STREAMS"; #[derive(Copy, Clone, PartialEq, Eq)] @@ -69,6 +72,7 @@ enum InputFormPage { Staged, Advanced, Alias, + Provider, } impl InputFormPage { @@ -77,6 +81,7 @@ impl InputFormPage { const STAGED: &str = "Staged"; const ADVANCED: &str = "Advanced"; const ALIAS: &str = "Alias"; + const PROVIDER: &str = "Provider"; } impl FromStr for InputFormPage { @@ -89,6 +94,7 @@ impl FromStr for InputFormPage { Self::STAGED => Ok(InputFormPage::Staged), Self::ADVANCED => Ok(InputFormPage::Advanced), Self::ALIAS => Ok(InputFormPage::Alias), + Self::PROVIDER => Ok(InputFormPage::Provider), _ => info_err_res!("Unknown input form page: {s}"), } } @@ -105,6 +111,7 @@ impl Display for InputFormPage { InputFormPage::Staged => Self::STAGED, InputFormPage::Advanced => Self::ADVANCED, InputFormPage::Alias => Self::ALIAS, + InputFormPage::Provider => Self::PROVIDER, } ) } @@ -204,15 +211,18 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let staged_input_state: UseReducerHandle = use_reducer(|| StagedInputDtoFormState { form: StagedInputDto::default(), modified: false }); - // State for EPG sources, Aliases, and Headers + // State for EPG sources, Aliases, Headers, and Providers let epg_sources_state = use_state(Vec::::new); let aliases_state = use_state(Vec::::new); let headers_state = use_state(HashMap::::new); + let providers_state = use_state(Vec::::new); // State for showing item forms let show_epg_form_state = use_state(|| false); let show_alias_form_state = use_state(|| false); + let show_provider_form_state = use_state(|| false); let edit_alias = use_state(|| None::); + let edit_provider = use_state(|| None::); let staged_input_types = use_memo(staged_input_state.form.input_type, |input_type| { let default_it = input_type; @@ -234,6 +244,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let epg_sources_state = epg_sources_state.clone(); let aliases_state = aliases_state.clone(); let headers_state = headers_state.clone(); + let providers_state = providers_state.clone(); let deps = (props.block_id, props.input.clone()); let view_visible = view_visible.clone(); @@ -263,6 +274,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { // Load aliases aliases_state.set(input.aliases.clone().unwrap_or_default()); + + // Load providers + providers_state.set(input.provider.clone().unwrap_or_default()); } else { input_form_state.dispatch(ConfigInputFormAction::SetAll(ConfigInputDto::default())); input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(ConfigInputOptionsDto::default())); @@ -270,6 +284,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { headers_state.set(HashMap::new()); epg_sources_state.set(Vec::new()); aliases_state.set(Vec::new()); + providers_state.set(Vec::new()); } || () }); @@ -420,6 +435,80 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }; + let handle_add_provider_item = { + let providers = providers_state.clone(); + let show_provider_form = show_provider_form_state.clone(); + let edit_provider = edit_provider.clone(); + Callback::from(move |provider: ConfigProviderDto| { + let mut items = (*providers).clone(); + if let Some(e) = edit_provider.as_ref() { + if let Some(pos) = items.iter().position(|x| x.name == e.name) { + if let Some(slot) = items.get_mut(pos) { + *slot = provider; + } + } else { + items.push(provider); + } + edit_provider.set(None); + } else { + items.push(provider); + } + providers.set(items); + show_provider_form.set(false); + }) + }; + + let handle_close_add_provider_item = { + let show_provider_form = show_provider_form_state.clone(); + let edit_provider = edit_provider.clone(); + Callback::from(move |()| { + show_provider_form.set(false); + edit_provider.set(None); + }) + }; + + let handle_show_add_provider_item = { + let show_provider_form = show_provider_form_state.clone(); + let edit_provider = edit_provider.clone(); + Callback::from(move |_| { + show_provider_form.set(true); + edit_provider.set(None); + }) + }; + + let handle_remove_provider_list_item = { + let provider_list = providers_state.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if let Ok(index) = idx.parse::() { + let mut items = (*provider_list).clone(); + if index < items.len() { + items.remove(index); + provider_list.set(items); + } + } + }) + }; + + let handle_edit_provider_list_item = { + let provider_list = providers_state.clone(); + let show_provider_form = show_provider_form_state.clone(); + let edit_provider = edit_provider.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if let Ok(index) = idx.parse::() { + let items = (*provider_list).clone(); + if index < items.len() { + let item = items.get(index).cloned(); + edit_provider.set(item); + show_provider_form.set(true); + } + } + }) + }; + let library_input = input_form_state.form.input_type.is_library(); let xtream_input = input_form_state.form.input_type.is_xtream(); @@ -653,6 +742,68 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; + let render_provider = || { + let providers = providers_state.clone(); + let show_provider_form = show_provider_form_state.clone(); + let edit_provider = edit_provider.clone(); + + html! { + + if *show_provider_form { + + } else { + { config_field_child!(translate.t(LABEL_PROVIDERS), "INPUT_FORM.PROVIDERS", { + let provider_list = providers.clone(); + html! { +
+
+ { + for (*provider_list).iter().enumerate().map(|(idx, provider)| { + html! { +
+
+ + +
+
+ + {provider.name.as_ref()} + +
+
+ } + }) + } +
+
+ +
+
+ } + })} + } +
+ } + }; + let render_advanced = || { let headers = headers_state.clone(); let epg_sources = epg_sources_state.clone(); @@ -730,6 +881,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let headers_state = headers_state.clone(); let epg_sources_state = epg_sources_state.clone(); let aliases_state = aliases_state.clone(); + let providers_state = providers_state.clone(); Callback::from(move |_| { let mut input = input_form_state.data().clone(); @@ -757,6 +909,10 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let aliases = (*aliases_state).clone(); input.aliases = if aliases.is_empty() { None } else { Some(aliases) }; + // Handle Providers + let providers = (*providers_state).clone(); + input.provider = if providers.is_empty() { None } else { Some(providers) }; + if let Some(on_apply) = &on_apply { on_apply.emit(input); } else if let (Some(ctx), Some(block_id)) = (&source_editor_ctx, block_id) { @@ -795,12 +951,15 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { {render_options()} - - {render_staged()} + + {render_provider()} {render_advanced()} + + {render_staged()} + })} @@ -808,7 +967,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; - let button_disabled = *show_alias_form_state || *show_epg_form_state; + let button_disabled = *show_alias_form_state || *show_epg_form_state || *show_provider_form_state; let render_sidebar = || { html! { @@ -820,8 +979,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { { html_if!(!library_input, { <> - + + })} diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index a997b905f..1e3aaf305 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -11,6 +11,7 @@ mod output_hdhomerun_form; mod output_m3u_form; mod output_strm_form; mod output_xtream_form; +mod provider_item_form; mod rules; mod sidebar; mod target_form; @@ -28,6 +29,7 @@ pub(crate) use output_hdhomerun_form::*; pub(crate) use output_m3u_form::*; pub(crate) use output_strm_form::*; pub(crate) use output_xtream_form::*; +pub(crate) use provider_item_form::*; pub(crate) use rules::*; pub(crate) use sidebar::*; pub(crate) use target_form::*; diff --git a/frontend/src/app/components/source_editor/provider_item_form.rs b/frontend/src/app/components/source_editor/provider_item_form.rs new file mode 100644 index 000000000..64eeeb23b --- /dev/null +++ b/frontend/src/app/components/source_editor/provider_item_form.rs @@ -0,0 +1,436 @@ +use crate::{ + app::components::{select::Select, Card, DropDownOption, DropDownSelection, Tag, TagList, TextButton}, + config_field_child, edit_field_bool, edit_field_number_u64, edit_field_text, generate_form_reducer, + i18n::use_translation, +}; +use shared::{ + model::{ConfigProviderDto, DnsPrefer, DnsScheme, OnConnectErrorPolicy, OnResolveErrorPolicy, ProviderDnsDto}, + utils::Internable, +}; +use std::{collections::HashSet, rc::Rc, sync::Arc}; +use yew::{component, html, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; + +const LABEL_PROVIDER_NAME: &str = "LABEL.PROVIDER_NAME"; +const LABEL_PROVIDER_URLS: &str = "LABEL.PROVIDER_URLS"; +const LABEL_ADD_URL: &str = "LABEL.ADD_URL"; +const LABEL_PROVIDER_DNS: &str = "LABEL.PROVIDER_DNS"; +const LABEL_DNS_ENABLED: &str = "LABEL.DNS_ENABLED"; +const LABEL_DNS_REFRESH_SECS: &str = "LABEL.DNS_REFRESH_SECS"; +const LABEL_DNS_PREFER: &str = "LABEL.DNS_PREFER"; +const LABEL_DNS_MAX_ADDRS: &str = "LABEL.DNS_MAX_ADDRS"; +const LABEL_DNS_SCHEMES: &str = "LABEL.DNS_SCHEMES"; +const LABEL_DNS_KEEP_VHOST: &str = "LABEL.DNS_KEEP_VHOST"; +const LABEL_DNS_ON_RESOLVE_ERROR: &str = "LABEL.DNS_ON_RESOLVE_ERROR"; +const LABEL_DNS_ON_CONNECT_ERROR: &str = "LABEL.DNS_ON_CONNECT_ERROR"; +const LABEL_DNS_PREFER_SYSTEM: &str = "LABEL.DNS_PREFER_SYSTEM"; +const LABEL_DNS_PREFER_IPV4: &str = "LABEL.DNS_PREFER_IPV4"; +const LABEL_DNS_PREFER_IPV6: &str = "LABEL.DNS_PREFER_IPV6"; +const LABEL_DNS_SCHEME_HTTP: &str = "LABEL.DNS_SCHEME_HTTP"; +const LABEL_DNS_SCHEME_HTTPS: &str = "LABEL.DNS_SCHEME_HTTPS"; +const LABEL_DNS_RESOLVE_KEEP_LAST_GOOD: &str = "LABEL.DNS_RESOLVE_KEEP_LAST_GOOD"; +const LABEL_DNS_RESOLVE_FALLBACK_TO_HOSTNAME: &str = "LABEL.DNS_RESOLVE_FALLBACK_TO_HOSTNAME"; +const LABEL_DNS_CONNECT_TRY_NEXT_IP: &str = "LABEL.DNS_CONNECT_TRY_NEXT_IP"; +const LABEL_DNS_CONNECT_ROTATE_PROVIDER_URL: &str = "LABEL.DNS_CONNECT_ROTATE_PROVIDER_URL"; +const VALIDATION_PROVIDER_REQUIRED: &str = "Provider name and at least one URL are required."; + +const DNS_PREFER_IPV4: &str = "ipv4"; +const DNS_PREFER_IPV6: &str = "ipv6"; +const DNS_PREFER_SYSTEM: &str = "system"; +const DNS_SCHEME_HTTP: &str = "http"; +const DNS_SCHEME_HTTPS: &str = "https"; +const DNS_RESOLVE_KEEP_LAST_GOOD: &str = "keep_last_good"; +const DNS_RESOLVE_FALLBACK_TO_HOSTNAME: &str = "fallback_to_hostname"; +const DNS_CONNECT_TRY_NEXT_IP: &str = "try_next_ip"; +const DNS_CONNECT_ROTATE_PROVIDER_URL: &str = "rotate_provider_url"; + +generate_form_reducer!( + state: ProviderFormState { form: ConfigProviderDto }, + action_name: ProviderFormAction, + fields { + Name => name: Arc, + } +); + +generate_form_reducer!( + state: ProviderDnsFormState { form: ProviderDnsDto }, + action_name: ProviderDnsFormAction, + fields { + Enabled => enabled: bool, + RefreshSecs => refresh_secs: u64, + Prefer => prefer: DnsPrefer, + MaxAddrs => max_addrs: Option, + Schemes => schemes: Option>, + KeepVhost => keep_vhost: bool, + OnResolveError => on_resolve_error: OnResolveErrorPolicy, + OnConnectError => on_connect_error: OnConnectErrorPolicy, + } +); + +fn dns_prefer_to_id(prefer: DnsPrefer) -> &'static str { + match prefer { + DnsPrefer::Ipv4 => DNS_PREFER_IPV4, + DnsPrefer::Ipv6 => DNS_PREFER_IPV6, + DnsPrefer::System => DNS_PREFER_SYSTEM, + } +} + +fn dns_prefer_from_id(id: &str) -> DnsPrefer { + match id { + DNS_PREFER_IPV4 => DnsPrefer::Ipv4, + DNS_PREFER_IPV6 => DnsPrefer::Ipv6, + _ => DnsPrefer::System, + } +} + +fn on_resolve_error_to_id(policy: OnResolveErrorPolicy) -> &'static str { + match policy { + OnResolveErrorPolicy::KeepLastGood => DNS_RESOLVE_KEEP_LAST_GOOD, + OnResolveErrorPolicy::FallbackToHostname => DNS_RESOLVE_FALLBACK_TO_HOSTNAME, + } +} + +fn on_resolve_error_from_id(id: &str) -> OnResolveErrorPolicy { + match id { + DNS_RESOLVE_FALLBACK_TO_HOSTNAME => OnResolveErrorPolicy::FallbackToHostname, + _ => OnResolveErrorPolicy::KeepLastGood, + } +} + +fn on_connect_error_to_id(policy: OnConnectErrorPolicy) -> &'static str { + match policy { + OnConnectErrorPolicy::TryNextIp => DNS_CONNECT_TRY_NEXT_IP, + OnConnectErrorPolicy::RotateProviderUrl => DNS_CONNECT_ROTATE_PROVIDER_URL, + } +} + +fn on_connect_error_from_id(id: &str) -> OnConnectErrorPolicy { + match id { + DNS_CONNECT_ROTATE_PROVIDER_URL => OnConnectErrorPolicy::RotateProviderUrl, + _ => OnConnectErrorPolicy::TryNextIp, + } +} + +fn scheme_to_id(scheme: DnsScheme) -> &'static str { + match scheme { + DnsScheme::Http => DNS_SCHEME_HTTP, + DnsScheme::Https => DNS_SCHEME_HTTPS, + } +} + +fn schemes_from_ids(ids: Vec) -> Option> { + let selected = ids.into_iter().collect::>(); + let mut schemes = Vec::new(); + if selected.contains(DNS_SCHEME_HTTP) { + schemes.push(DnsScheme::Http); + } + if selected.contains(DNS_SCHEME_HTTPS) { + schemes.push(DnsScheme::Https); + } + if schemes.is_empty() { + None + } else { + Some(schemes) + } +} + +fn has_meaningful_dns_config(dns: &ProviderDnsDto) -> bool { + dns.enabled + || dns.refresh_secs != ProviderDnsDto::default().refresh_secs + || dns.prefer != DnsPrefer::default() + || dns.max_addrs.is_some() + || dns.schemes.as_ref().is_some_and(|schemes| !schemes.is_empty()) + || dns.keep_vhost + || dns.overrides.as_ref().is_some_and(|overrides| !overrides.is_empty()) + || dns.resolved.as_ref().is_some_and(|resolved| !resolved.is_empty()) + || dns.on_resolve_error != OnResolveErrorPolicy::default() + || dns.on_connect_error != OnConnectErrorPolicy::default() +} + +#[derive(Properties, PartialEq, Clone)] +pub struct ProviderItemFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, +} + +#[component] +pub fn ProviderItemForm(props: &ProviderItemFormProps) -> Html { + let translate = use_translation(); + + let initial = + props.initial.clone().unwrap_or_else(|| ConfigProviderDto { name: "".intern(), urls: Vec::new(), dns: None }); + + let form_state: UseReducerHandle = + use_reducer(|| ProviderFormState { form: initial.clone(), modified: false }); + let dns_state: UseReducerHandle = + use_reducer(|| ProviderDnsFormState { form: initial.dns.clone().unwrap_or_default(), modified: false }); + + let urls_state = use_state(|| { + initial.urls.iter().map(|u| Rc::new(Tag { label: u.to_string(), class: None })).collect::>() + }); + let validation_error = use_state(|| None::); + + { + let form_state = form_state.clone(); + let dns_state = dns_state.clone(); + let urls_state = urls_state.clone(); + let validation_error = validation_error.clone(); + use_effect_with(props.initial.clone(), move |initial| { + let initial = + initial.clone().unwrap_or_else(|| ConfigProviderDto { name: "".intern(), urls: Vec::new(), dns: None }); + form_state.dispatch(ProviderFormAction::SetAll(initial.clone())); + dns_state.dispatch(ProviderDnsFormAction::SetAll(initial.dns.clone().unwrap_or_default())); + urls_state.set( + initial.urls.iter().map(|u| Rc::new(Tag { label: u.to_string(), class: None })).collect::>(), + ); + validation_error.set(None); + || () + }); + } + + let handle_urls_change = { + let urls_state = urls_state.clone(); + let validation_error = validation_error.clone(); + Callback::from(move |tags: Vec>| { + urls_state.set(tags); + validation_error.set(None); + }) + }; + + let handle_submit = { + let form_state = form_state.clone(); + let dns_state = dns_state.clone(); + let urls_state = urls_state.clone(); + let validation_error = validation_error.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + let mut data = form_state.form.clone(); + data.name = data.name.trim().to_string().intern(); + data.urls = (*urls_state) + .iter() + .map(|t| t.label.trim().to_string().intern()) + .filter(|u: &Arc| !u.is_empty()) + .collect(); + let dns_form = dns_state.form.clone(); + data.dns = if has_meaningful_dns_config(&dns_form) { Some(dns_form) } else { None }; + if data.name.is_empty() || data.urls.is_empty() { + validation_error.set(Some(VALIDATION_PROVIDER_REQUIRED.to_string())); + return; + } + validation_error.set(None); + on_submit.emit(data); + }) + }; + + let handle_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| { + on_cancel.emit(()); + }) + }; + + let form_valid = + !form_state.form.name.trim().is_empty() && (*urls_state).iter().any(|tag| !tag.label.trim().is_empty()); + + let dns_prefer_options = Rc::new(vec![ + DropDownOption::new( + DNS_PREFER_SYSTEM, + html! { { translate.t(LABEL_DNS_PREFER_SYSTEM) } }, + dns_prefer_to_id(dns_state.form.prefer) == DNS_PREFER_SYSTEM, + ), + DropDownOption::new( + DNS_PREFER_IPV4, + html! { { translate.t(LABEL_DNS_PREFER_IPV4) } }, + dns_prefer_to_id(dns_state.form.prefer) == DNS_PREFER_IPV4, + ), + DropDownOption::new( + DNS_PREFER_IPV6, + html! { { translate.t(LABEL_DNS_PREFER_IPV6) } }, + dns_prefer_to_id(dns_state.form.prefer) == DNS_PREFER_IPV6, + ), + ]); + let dns_prefer_state = dns_state.clone(); + let handle_dns_prefer_select = Callback::from(move |(_, selection): (String, DropDownSelection)| { + let prefer = match selection { + DropDownSelection::Single(id) => dns_prefer_from_id(&id), + DropDownSelection::Multi(ids) => ids.first().map_or(DnsPrefer::default(), |id| dns_prefer_from_id(id)), + DropDownSelection::Empty => DnsPrefer::default(), + }; + dns_prefer_state.dispatch(ProviderDnsFormAction::Prefer(prefer)); + }); + + let selected_dns_schemes = dns_state + .form + .schemes + .as_ref() + .map_or_else(HashSet::new, |schemes| schemes.iter().copied().map(scheme_to_id).collect::>()); + let dns_scheme_options = Rc::new(vec![ + DropDownOption::new( + DNS_SCHEME_HTTP, + html! { { translate.t(LABEL_DNS_SCHEME_HTTP) } }, + selected_dns_schemes.contains(DNS_SCHEME_HTTP), + ), + DropDownOption::new( + DNS_SCHEME_HTTPS, + html! { { translate.t(LABEL_DNS_SCHEME_HTTPS) } }, + selected_dns_schemes.contains(DNS_SCHEME_HTTPS), + ), + ]); + let dns_schemes_state = dns_state.clone(); + let handle_dns_schemes_select = Callback::from(move |(_, selection): (String, DropDownSelection)| { + let schemes = match selection { + DropDownSelection::Single(id) => schemes_from_ids(vec![id]), + DropDownSelection::Multi(ids) => schemes_from_ids(ids), + DropDownSelection::Empty => None, + }; + dns_schemes_state.dispatch(ProviderDnsFormAction::Schemes(schemes)); + }); + + let dns_on_resolve_error_options = Rc::new(vec![ + DropDownOption::new( + DNS_RESOLVE_KEEP_LAST_GOOD, + html! { { translate.t(LABEL_DNS_RESOLVE_KEEP_LAST_GOOD) } }, + on_resolve_error_to_id(dns_state.form.on_resolve_error) == DNS_RESOLVE_KEEP_LAST_GOOD, + ), + DropDownOption::new( + DNS_RESOLVE_FALLBACK_TO_HOSTNAME, + html! { { translate.t(LABEL_DNS_RESOLVE_FALLBACK_TO_HOSTNAME) } }, + on_resolve_error_to_id(dns_state.form.on_resolve_error) == DNS_RESOLVE_FALLBACK_TO_HOSTNAME, + ), + ]); + let dns_on_resolve_error_state = dns_state.clone(); + let handle_dns_on_resolve_error_select = Callback::from(move |(_, selection): (String, DropDownSelection)| { + let policy = match selection { + DropDownSelection::Single(id) => on_resolve_error_from_id(&id), + DropDownSelection::Multi(ids) => { + ids.first().map_or(OnResolveErrorPolicy::default(), |id| on_resolve_error_from_id(id)) + } + DropDownSelection::Empty => OnResolveErrorPolicy::default(), + }; + dns_on_resolve_error_state.dispatch(ProviderDnsFormAction::OnResolveError(policy)); + }); + + let dns_on_connect_error_options = Rc::new(vec![ + DropDownOption::new( + DNS_CONNECT_TRY_NEXT_IP, + html! { { translate.t(LABEL_DNS_CONNECT_TRY_NEXT_IP) } }, + on_connect_error_to_id(dns_state.form.on_connect_error) == DNS_CONNECT_TRY_NEXT_IP, + ), + DropDownOption::new( + DNS_CONNECT_ROTATE_PROVIDER_URL, + html! { { translate.t(LABEL_DNS_CONNECT_ROTATE_PROVIDER_URL) } }, + on_connect_error_to_id(dns_state.form.on_connect_error) == DNS_CONNECT_ROTATE_PROVIDER_URL, + ), + ]); + let dns_on_connect_error_state = dns_state.clone(); + let handle_dns_on_connect_error_select = Callback::from(move |(_, selection): (String, DropDownSelection)| { + let policy = match selection { + DropDownSelection::Single(id) => on_connect_error_from_id(&id), + DropDownSelection::Multi(ids) => { + ids.first().map_or(OnConnectErrorPolicy::default(), |id| on_connect_error_from_id(id)) + } + DropDownSelection::Empty => OnConnectErrorPolicy::default(), + }; + dns_on_connect_error_state.dispatch(ProviderDnsFormAction::OnConnectError(policy)); + }); + + html! { + + { edit_field_text!(form_state, translate.t(LABEL_PROVIDER_NAME), name, ProviderFormAction::Name) } + { config_field_child!(translate.t(LABEL_PROVIDER_URLS), "PROVIDER_FORM.URLS", { + html! { + + } + })} + if let Some(validation_error) = &*validation_error { +
+ { validation_error.clone() } +
+ } + +

{ translate.t(LABEL_PROVIDER_DNS) }

+ { edit_field_bool!(dns_state, translate.t(LABEL_DNS_ENABLED), enabled, ProviderDnsFormAction::Enabled) } +
+ { edit_field_number_u64!(dns_state, translate.t(LABEL_DNS_REFRESH_SECS), refresh_secs, ProviderDnsFormAction::RefreshSecs) } +
+ | { + let parsed = value.and_then(|v| usize::try_from(v).ok()); + dns_state.dispatch(ProviderDnsFormAction::MaxAddrs(parsed)); + } + })} + /> +
+
+ { config_field_child!(translate.t(LABEL_DNS_PREFER), "PROVIDER_FORM.DNS.PREFER", { + html! { + + } + })} + { edit_field_bool!(dns_state, translate.t(LABEL_DNS_KEEP_VHOST), keep_vhost, ProviderDnsFormAction::KeepVhost) } + { config_field_child!(translate.t(LABEL_DNS_ON_RESOLVE_ERROR), "PROVIDER_FORM.DNS.ON_RESOLVE_ERROR", { + html! { + + } + })} +
+ + +
+
+ } +} diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index e5d220f94..5ea9a00ff 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -406,6 +406,8 @@ pub struct ConfigInputDto { pub exp_date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub panel_api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option>, } impl Default for ConfigInputDto { @@ -431,6 +433,7 @@ impl Default for ConfigInputDto { staged: None, exp_date: None, panel_api: None, + provider: None, } } } diff --git a/shared/src/model/config/library.rs b/shared/src/model/config/library.rs index 45fca44f9..abeb68b08 100644 --- a/shared/src/model/config/library.rs +++ b/shared/src/model/config/library.rs @@ -2,9 +2,8 @@ use crate::{ error::TuliproxError, info_err_res, utils::{ - default_as_true, default_metadata_path, default_movie_category, default_series_category, - default_storage_formats, default_supported_library_extensions, is_default_supported_library_extensions, - is_true, + default_as_true, default_movie_category, default_series_category, default_storage_formats, + default_supported_library_extensions, is_default_supported_library_extensions, is_true, }, }; use serde::{Deserialize, Serialize}; @@ -71,8 +70,6 @@ pub enum LibraryContentType { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(deny_unknown_fields)] pub struct LibraryMetadataConfigDto { - #[serde(default = "default_metadata_path")] - pub path: String, #[serde(default)] pub read_existing: LibraryMetadataReadConfigDto, #[serde(default = "default_as_true")] @@ -83,16 +80,9 @@ pub struct LibraryMetadataConfigDto { impl LibraryMetadataConfigDto { pub fn is_empty(&self) -> bool { - self.fallback_to_filename - && self.path == default_metadata_path() - && self.read_existing.is_empty() - && self.formats.is_empty() - } - pub fn clean(&mut self) { - if self.path.trim().is_empty() { - self.path = default_metadata_path(); - } + self.fallback_to_filename && self.read_existing.is_empty() && self.formats.is_empty() } + pub fn clean(&mut self) {} } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] @@ -153,11 +143,6 @@ impl LibraryConfigDto { } } - // Validate metadata storage location - if self.metadata.path.is_empty() { - return info_err_res!("Library Metadata storage location cannot be empty"); - } - Ok(()) } } diff --git a/shared/src/model/config/metadata_update.rs b/shared/src/model/config/metadata_update.rs index 860dbe73f..8ab4edb1f 100644 --- a/shared/src/model/config/metadata_update.rs +++ b/shared/src/model/config/metadata_update.rs @@ -6,7 +6,7 @@ use crate::{ 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_max_resolve_retry_backoff, default_metadata_path, 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, @@ -17,7 +17,7 @@ use crate::{ 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_max_queue_size, is_default_metadata_max_resolve_retry_backoff, is_default_metadata_path, 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, @@ -38,6 +38,8 @@ 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_path", skip_serializing_if = "is_default_metadata_path")] + pub cache_path: String, #[serde(default, skip_serializing_if = "MetadataLogConfigDto::is_empty")] pub log: MetadataLogConfigDto, #[serde(default, skip_serializing_if = "ResolveConfigDto::is_empty")] @@ -62,6 +64,7 @@ pub struct MetadataUpdateConfigDto { impl Default for MetadataUpdateConfigDto { fn default() -> Self { Self { + cache_path: default_metadata_path(), log: MetadataLogConfigDto::default(), resolve: ResolveConfigDto::default(), probe: ProbeConfigDto::default(), @@ -451,7 +454,8 @@ impl TmdbConfigDto { impl MetadataUpdateConfigDto { pub fn is_empty(&self) -> bool { - self.log.is_empty() + is_default_metadata_path(&self.cache_path) + && self.log.is_empty() && self.resolve.is_empty() && self.probe.is_empty() && self.ffprobe.is_empty() @@ -462,11 +466,17 @@ impl MetadataUpdateConfigDto { } pub fn clean(&mut self) { + if self.cache_path.trim().is_empty() { + self.cache_path = default_metadata_path(); + } self.ffprobe.clean(); self.tmdb.clean(); } pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if self.cache_path.trim().is_empty() { + return info_err_res!("metadata_update.cache_path cannot be empty"); + } self.log.prepare()?; self.resolve.prepare()?; self.probe.prepare()?; diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index 07738b291..94bdd13dc 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -75,7 +75,9 @@ pub fn default_tmdb_api_key() -> Option { Some(TMDB_API_KEY.to_string()) pub fn is_tmdb_default_api_key(s: &Option) -> bool { s.as_ref().is_none_or(|s| s == TMDB_API_KEY) } pub fn is_default_tmdb_language(v: &String) -> bool { v == DEFAULT_TMDB_LANGUAGE } -pub fn default_metadata_path() -> String { "library_metadata".to_string() } +pub const DEFAULT_METADATA_PATH: &str = "metadata"; +pub fn default_metadata_path() -> String { DEFAULT_METADATA_PATH.to_string() } +pub fn is_default_metadata_path(s: &str) -> bool { s == DEFAULT_METADATA_PATH } pub const DEFAULT_TMDB_RATE_LIMIT_MS: u64 = 250; pub const DEFAULT_TMDB_CACHE_DURATION_DAYS: u32 = 30;