From da8947dd76095b2ccaeab63d071bfd032bac3ea0 Mon Sep 17 00:00:00 2001 From: euzu Date: Sat, 3 Jan 2026 19:09:39 +0100 Subject: [PATCH] To align input definitions with the SourceEditor, inputs are now defined globally in the inputs section of the config file. Each source can reference one or more inputs by their name in the inputs attribute. --- backend/src/api/config_file.rs | 5 +- backend/src/model/config/source.rs | 8 +++ backend/src/processing/input_cache.rs | 9 +-- backend/src/repository/playlist_repository.rs | 67 +++++++++++-------- frontend/scss/_theme.scss | 3 + shared/src/model/config/input.rs | 2 +- shared/src/model/config/messaging.rs | 2 +- 7 files changed, 59 insertions(+), 37 deletions(-) diff --git a/backend/src/api/config_file.rs b/backend/src/api/config_file.rs index f92624167..1c0e530fa 100644 --- a/backend/src/api/config_file.rs +++ b/backend/src/api/config_file.rs @@ -88,8 +88,7 @@ impl ConfigFile { Self::load_mapping(app_state) } - async fn reload_source_file(app_state: &Arc, file: &Path) -> Result<(), TuliproxError> { - info!("Loaded sources file {}", file.display()); + async fn reload_source_file(app_state: &Arc) -> Result<(), TuliproxError> { // TODO selective update and not complete sources update ? ConfigFile::load_sources(app_state).await } @@ -115,7 +114,7 @@ impl ConfigFile { } ConfigFile::SourceFile => { app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); - ConfigFile::reload_source_file(app_state, file_path).await + ConfigFile::reload_source_file(app_state).await } } } diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index b3434e54a..ca73821e9 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -50,6 +50,7 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { fn try_from(dto: &SourcesConfigDto) -> Result { let mut inputs = Vec::>::new(); let mut batch_files = Vec::::new(); + let mut input_names = HashSet::new(); for input_dto in &dto.inputs { let mut input = ConfigInput::from(input_dto); @@ -57,11 +58,18 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { if let Some(path) = input.prepare()? { batch_files.push(path); } + input_names.insert(input.name.clone()); inputs.push(Arc::new(input)); } let mut sources = Vec::new(); for source_dto in &dto.sources { + // Validate that all input references exist + for input_name in &source_dto.inputs { + if !input_names.contains(input_name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Source references unknown input: {input_name}"); + } + } sources.push(ConfigSource::from_dto(source_dto)?); } diff --git a/backend/src/processing/input_cache.rs b/backend/src/processing/input_cache.rs index 75a101e86..410f75145 100644 --- a/backend/src/processing/input_cache.rs +++ b/backend/src/processing/input_cache.rs @@ -29,13 +29,14 @@ pub struct InputStatus { } pub fn resolve_input_storage_path(working_dir: &str, input_name: &str) -> PathBuf { - match get_input_storage_path(input_name, working_dir) { - Ok(path) => path, - Err(_) => Path::new(working_dir).join("data").join(format!("input_{input_name}")), // Fallback + if let Ok(path) = get_input_storage_path(input_name, working_dir) { path } else { + let sanitized_name: String = input_name.chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + Path::new(working_dir).join("data").join(format!("input_{sanitized_name}")) } } - pub fn load_input_status(path: &Path) -> InputStatus { let status_path = path.join(STATUS_FILE); if status_path.exists() { diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 7e8690180..48943ae4e 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -1,6 +1,6 @@ use crate::api::model::{AppState, PlaylistM3uStorage, PlaylistStorage, PlaylistStorageState, PlaylistXtreamStorage}; -use crate::model::{AppConfig, ConfigInput, ConfigTarget, TargetOutput}; use crate::model::Epg; +use crate::model::{AppConfig, ConfigInput, ConfigTarget, TargetOutput}; use crate::processing::processor::playlist::apply_filter_to_playlist; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery}; use crate::repository::epg_repository::epg_write; @@ -10,17 +10,17 @@ use crate::repository::strm_repository::write_strm_playlist; use crate::repository::target_id_mapping::{TargetIdMapping, VirtualIdRecord}; use crate::repository::xtream_repository::{load_input_xtream_playlist, persist_input_xtream_playlist, xtream_get_file_path, xtream_get_storage_path, xtream_write_playlist}; use crate::utils; +use crate::utils::json_write_documents_to_file; use log::info; use shared::create_tuliprox_error; use shared::error::TuliproxError; use shared::error::{info_err, TuliproxErrorKind}; +use shared::model::xtream_const::XTREAM_CLUSTER; use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{is_dash_url, is_hls_url}; use std::collections::HashMap; -use std::path::{Path}; +use std::path::Path; use std::sync::Arc; -use shared::model::xtream_const::XTREAM_CLUSTER; -use crate::utils::json_write_documents_to_file; struct LocalEpisodeKey { path: String, @@ -191,7 +191,8 @@ fn rewrite_local_series_info_episode_virtual_id(pli: &mut PlaylistItem, local_li #[allow(clippy::implicit_hasher)] pub fn rewrite_provider_series_info_episode_virtual_id

(pli: &mut P, provider_series: &HashMap>) -where P: PlaylistEntry +where + P: PlaylistEntry, { if let Some(episode_keys) = provider_series.get(&pli.get_uuid().to_string()) { if let Some(StreamProperties::Series(series)) = pli.get_additional_properties_mut() { @@ -349,20 +350,30 @@ pub async fn persist_input_playlist(app_config: &Arc, input: &ConfigI } _ => { + // TODO what is written and why not as BPlusTree? + // Persist M3U/Other types let working_dir = &app_config.config.load().working_dir; - let storage_path = match get_input_storage_path(&input.name, working_dir) { + let storage_path = match get_input_storage_path(&input.name, working_dir) { Ok(storage_path) => storage_path, Err(err) => { return (playlist, Some(create_tuliprox_error!( TuliproxErrorKind::Info, "Error creating input storage directory for input '{}' failed: {err}", input.name))); } }; - let file_path = storage_path.join("playlist.json"); - match json_write_documents_to_file(&file_path, &playlist) { - Ok(()) => (playlist, None), + let sanitized_input_name: String = input.name.chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + let file_path = storage_path.join(format!("{sanitized_input_name}_playlist.json")); + let file_path_clone = file_path.clone(); + let playlist_clone = playlist.clone(); + match tokio::task::spawn_blocking(move || { + json_write_documents_to_file(&file_path_clone, &playlist_clone) + }).await { + Ok(Ok(())) => (playlist, None), + Ok(Err(e)) => (playlist, Some(info_err!(format!("Failed to persist input playlist: {e}")))), Err(e) => (playlist, Some(info_err!(format!("Failed to persist input playlist: {e}")))), } - }, + } }; (result, err) @@ -375,26 +386,26 @@ pub async fn load_input_playlist(app_config: &Arc, input: &ConfigInpu match input.input_type { InputType::Xtream | InputType::XtreamBatch => { - let clusters_to_load = if let Some(c) = clusters { - c - } else { - &XTREAM_CLUSTER - }; - - load_input_xtream_playlist(app_config, &storage_path, clusters_to_load).await + let clusters_to_load = if let Some(c) = clusters { + c + } else { + &XTREAM_CLUSTER + }; + + load_input_xtream_playlist(app_config, &storage_path, clusters_to_load).await } _ => { - // Load JSON for M3U - let file_path = storage_path.join("playlist.json"); - if file_path.exists() { - let content = tokio::fs::read_to_string(&file_path).await - .map_err(|e| info_err!(format!("Failed to read input playlist cache: {e}")))?; - let playlist: Vec = serde_json::from_str(&content) - .map_err(|e| info_err!(format!("Failed to parse input playlist cache: {e}")))?; - Ok(playlist) - } else { - Ok(vec![]) - } + // Load JSON for M3U + let file_path = storage_path.join("playlist.json"); + if file_path.exists() { + let content = tokio::fs::read_to_string(&file_path).await + .map_err(|e| info_err!(format!("Failed to read input playlist cache: {e}")))?; + let playlist: Vec = serde_json::from_str(&content) + .map_err(|e| info_err!(format!("Failed to parse input playlist cache: {e}")))?; + Ok(playlist) + } else { + Ok(vec![]) + } } } } \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index cf08ef4c0..b67bbe8f5 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -648,5 +648,8 @@ body[data-theme="bright"] { --source-editor-line-color: #000000; --source-editor-pending-line-color: yellow; + + --tooltip-background-color: rgba(250, 252, 255, 0.95); + --tooltip-color: #0a0b10; } \ No newline at end of file diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 609a5e51d..b3d047403 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -302,7 +302,7 @@ pub struct ConfigInputDto { pub options: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub cache_duration: Option, - #[serde(skip, skip_serializing_if = "is_zero_u16")] + #[serde(skip)] pub cache_duration_seconds: u64, #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_option_vec_flow_map_items")] pub aliases: Option>, diff --git a/shared/src/model/config/messaging.rs b/shared/src/model/config/messaging.rs index d63d1abd9..1c593aaed 100644 --- a/shared/src/model/config/messaging.rs +++ b/shared/src/model/config/messaging.rs @@ -5,7 +5,7 @@ use crate::utils::{is_false, is_blank_optional_string, is_blank_optional_str}; #[serde(deny_unknown_fields)] pub struct TelegramMessagingConfigDto { pub bot_token: String, - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub chat_ids: Vec, #[serde(default, skip_serializing_if = "is_false")] pub markdown: bool,