diff --git a/backend/src/main.rs b/backend/src/main.rs index e53de178f..85ac97acc 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -22,9 +22,7 @@ use crate::{ model::{AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig}, processing::processor::exec_processing, repository::run_startup_migrations, - utils::{ - apply_config_log_level, config_file_reader, db_viewer, init_logger, request::create_client, resolve_env_var, - }, + utils::{config_file_reader, db_viewer, init_logger, request::create_client, resolve_env_var}, }; use arc_swap::{access::Access, ArcSwap}; use chrono::{DateTime, Utc}; @@ -145,10 +143,6 @@ const BUILD_TIMESTAMP: &str = env!("VERGEN_BUILD_TIMESTAMP"); async fn main() { let args = Args::parse(); - // initialize the logger from CLI arg / TULIPROX_LOG env var. - // Must happen before everything or we miss log output. - init_logger(args.log_level.as_deref()); - db_viewer(&args.db_viewer_args()); if args.genpwd { @@ -161,8 +155,7 @@ async fn main() { let mut config_paths = get_file_paths(&args); - // Phase 2: apply config-file log level if not already set by CLI / env. - apply_config_log_level(args.log_level.as_deref(), config_paths.config_file_path.as_str()); + init_logger(args.log_level.as_deref(), config_paths.config_file_path.as_str()); if args.healthcheck { let healthy = healthcheck(config_paths.config_file_path.as_str()).await; @@ -318,7 +311,8 @@ fn get_file_paths(args: &Args) -> ConfigPaths { match utils::read_config_file(&config_file, true, false) { Ok(cfg) => resolve_storage_path(&home_path, cfg.storage_dir.as_deref()), Err(err) => { - exit!("Can't read config file {config_file} while resolving storage path: {err}") + eprintln!("Can't read config file {config_file} while resolving storage path: {err}"); + std::process::exit(1); } } } else { diff --git a/backend/src/utils/db_viewer.rs b/backend/src/utils/db_viewer.rs index 3afa99f6e..a1ef11998 100644 --- a/backend/src/utils/db_viewer.rs +++ b/backend/src/utils/db_viewer.rs @@ -1,6 +1,7 @@ use crate::api::model::{MetadataRetryDbKey, MetadataRetryDbValue}; use crate::repository::{BPlusTreeDiskIterator, BPlusTreeQuery, VirtualIdRecord}; -use log::error; +use env_logger::{Builder, Target}; +use log::{error, LevelFilter}; use serde::{Deserialize, Serialize}; use shared::model::{EpgChannel, M3uPlaylistItem, XtreamPlaylistItem}; use std::io::Write; @@ -75,6 +76,8 @@ pub fn db_viewer(args: &DbViewerArgs<'_>) { return; } + init_db_viewer_logger(); + let mut any_processed = false; for request in requests { if let Some(filename) = request.filename { @@ -90,6 +93,12 @@ pub fn db_viewer(args: &DbViewerArgs<'_>) { } } +fn init_db_viewer_logger() { + let mut log_builder = Builder::from_default_env(); + log_builder.target(Target::Stderr); + log_builder.filter_level(LevelFilter::Info); + let _ = log_builder.try_init(); +} fn try_dump_typed_db(path: &Path) -> bool where diff --git a/backend/src/utils/logging.rs b/backend/src/utils/logging.rs index 9340c8d1c..9168c1a2f 100644 --- a/backend/src/utils/logging.rs +++ b/backend/src/utils/logging.rs @@ -2,7 +2,7 @@ use crate::model::LogLevelConfig; use crate::utils::config_file_reader; use chrono::{Local, Offset, SecondsFormat}; use env_logger::{Builder, Target}; -use log::{error, info, LevelFilter}; +use log::{info, LevelFilter}; use std::fs::File; use std::io::Write; @@ -27,104 +27,68 @@ fn get_log_level(log_level: &str) -> LevelFilter { } } -fn apply_log_format(builder: &mut Builder) { - builder.format(|buf, record| { +pub fn init_logger(user_log_level: Option<&str>, config_file: &str) { + + // tracing_subscriber::registry() + // .with(console_subscriber::spawn()) // Console layer + // .with(EnvFilter::from_default_env()) + // .with(fmt::layer()) // stdout logging + // .init(); + + let env_log_level = std::env::var("TULIPROX_LOG").ok(); + + let mut log_builder = Builder::from_default_env(); + log_builder.target(Target::Stdout); + log_builder.format(move |buf, record| { let now = Local::now(); let timestamp = now.to_rfc3339_opts(SecondsFormat::Secs, now.offset().fix().local_minus_utc() == 0); - writeln!(buf, "[{timestamp} {} {}] {}", record.level(), record.target(), record.args()) + writeln!( + buf, + "[{timestamp} {} {}] {}", + record.level(), + record.target(), + record.args() + ) }); -} -fn apply_level_spec(log_builder: &mut Builder, log_level: &str) -> (LevelFilter, Vec) { + // priority CLI-Argument, Env-Var, Config, Default + let log_level = user_log_level + .map(std::string::ToString::to_string) // cli-argument + .or(env_log_level) // env + .or_else(|| { // config + File::open(config_file).ok() + .and_then(|file| serde_saphyr::from_reader::<_, LogLevelConfig>(config_file_reader(file, true)) + .map_err(|e| eprintln!("Failed to parse log config file: {e}")) + .ok()) + .and_then(|cfg| cfg.log.and_then(|l| l.log_level)) + }) + .unwrap_or_else(|| "info".to_string()); // Default + let mut log_levels = vec![]; - let effective_max_level; - if log_level.contains('=') { - let mut max_level = LevelFilter::Off; for pair in log_level.split(',') { if pair.contains('=') { let mut kv_iter = pair.split('=').map(str::trim); if let (Some(module), Some(level)) = (kv_iter.next(), kv_iter.next()) { - let module_level = get_log_level(level); - log_levels.push(format!("{module}={module_level}")); - log_builder.filter_module(module, module_level); - if module_level > max_level { - max_level = module_level; - } + let log_level = get_log_level(level); + log_levels.push(format!("{module}={log_level}")); + log_builder.filter_module(module, log_level); } } else { let level = get_log_level(pair); log_levels.push(level.to_string()); log_builder.filter_level(level); - if level > max_level { - max_level = level; - } } } - effective_max_level = max_level; } else { - effective_max_level = get_log_level(log_level); - log_builder.filter_level(effective_max_level); - log_levels.push(log_level.to_string()); + // Set the log level based on the parsed value + log_builder.filter_level(get_log_level(&log_level)); + log_levels.push(log_level); } - - (effective_max_level, log_levels) -} - -/// Initialize the real logger using CLI argument and `TULIPROX_LOG` env var only. -/// Call this BEFORE config paths are resolved so that all `log::error!` / `exit!` calls -/// during path resolution are visible. -/// Config-file log level is applied later via `apply_config_log_level`. -pub fn init_logger(user_log_level: Option<&str>) { - let env_log_level = std::env::var("TULIPROX_LOG").ok(); - let log_level = user_log_level - .map(std::string::ToString::to_string) - .or(env_log_level) - .unwrap_or_else(|| "info".to_string()); - - let mut log_builder = Builder::from_default_env(); - log_builder.target(Target::Stdout); - apply_log_format(&mut log_builder); - - let (effective_max_level, log_levels) = apply_level_spec(&mut log_builder, &log_level); - for module in LOG_ERROR_LEVEL_MOD { log_builder.filter_module(module, LevelFilter::Error); } - - log_builder.init(); // panics if called twice — correct, we only call this once - log::set_max_level(effective_max_level); + let _ = log_builder.try_init(); info!("Log timezone system localtime (TZ)"); info!("Log Level {}", &log_levels.join(", ")); } - -/// Apply the log level from the config file if no CLI argument or env var -/// overrides it. Uses `log::set_max_level` so it works after `init_logger` has -/// already registered the `env_logger` instance. -/// Call this AFTER config paths are resolved. -pub fn apply_config_log_level(user_log_level: Option<&str>, config_file: &str) { - let env_log_level = std::env::var("TULIPROX_LOG").ok(); - - // Only read from config if neither CLI nor env overrides the level. - if user_log_level.is_some() || env_log_level.is_some() { - return; - } - - let Some(log_level) = File::open(config_file) - .ok() - .and_then(|file| { - serde_saphyr::from_reader::<_, LogLevelConfig>(config_file_reader(file, true)) - .map_err(|e| error!("Failed to parse log config file: {e}")) - .ok() - }) - .and_then(|cfg| cfg.log.and_then(|l| l.log_level)) - else { - return; - }; - - // Build a temporary builder just to parse the level spec (no try_init). - let mut dummy = Builder::from_default_env(); - let (effective_max_level, log_levels) = apply_level_spec(&mut dummy, &log_level); - log::set_max_level(effective_max_level); - info!("Log Level updated from config: {}", &log_levels.join(", ")); -} diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 8a9ecfaba..cf5377d38 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -337,6 +337,10 @@ "DISCORD": { "TEMPLATE_MESSAGE": "Template message body for Discord notifications." }, + "DISCORD_MESSAGING_CONFIG": { + "TEMPLATES": "Handlebars templates for Discord message bodies.", + "URL": "Discord webhook URL for sending notification messages." + }, "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.", @@ -366,6 +370,20 @@ "PROPRIETARY_DISCOVERY": "Enables proprietary HDHomeRun discovery protocols.", "SSDP_DISCOVERY": "Enables standard SSDP discovery for HDHomeRun emulation on the local network." }, + "HD_HOME_RUN_DEVICE_CONFIG": { + "DEVICE_ID": "Unique device ID used for HDHomeRun identification. Auto-generated if not set.", + "DEVICE_TYPE": "Device type string advertised to clients.", + "DEVICE_UDN": "Unique device name (UUID) for this virtual HDHomeRun device. Auto-generated if not set.", + "FIRMWARE_NAME": "Firmware filename advertised to HDHomeRun clients.", + "FIRMWARE_VERSION": "Firmware version string advertised to HDHomeRun clients.", + "FRIENDLY_NAME": "Human-readable display name shown to HDHomeRun clients.", + "MANUFACTURER": "Manufacturer name advertised to HDHomeRun clients.", + "MODEL_NAME": "Model name string advertised to HDHomeRun clients.", + "MODEL_NUMBER": "Model number string advertised to HDHomeRun clients.", + "NAME": "Internal identifier for this virtual HDHomeRun device. Must be unique.", + "PORT": "HTTP port this virtual device listens on. Auto-assigned if 0.", + "TUNER_COUNT": "Number of virtual tuners advertised by this device. Minimum 1." + }, "HD_HOME_RUN_TARGET_OUTPUT": { "DEVICE": "The virtual Device ID exposed by the HDHomeRun emulation.", "USERNAME": "Login username for this account or service." @@ -400,6 +418,7 @@ }, "LIBRARY_METADATA_READ_CONFIG": { "JELLYFIN": "Reads NFO metadata files formatted for Jellyfin.", + "KODI": "Reads NFO metadata files formatted for Kodi.", "PLEX": "Reads XML metadata files or sidecars formatted for Plex." }, "LIBRARY_PLAYLIST_CONFIG": { @@ -436,6 +455,7 @@ }, "MESSAGING_CONFIG": { "NOTIFY_ON": "Select events (e.g. 'info', 'stats', 'error') that trigger notifications for this specific type.", + "REST_HEADERS": "Custom HTTP headers sent with REST notification requests (JSON format).", "TELEGRAM_CHAT_IDS": "List of telegram chat IDs to receive notifications. Format: '' or ':'." }, "METADATA_LOG_CONFIG": { @@ -445,7 +465,7 @@ "SANITIZE_SENSITIVE_INFO": "Attempts to mask passwords and tokens in the logging output for security." }, "METADATA_UPDATE_CONFIG": { - "METADATA_PATH": "Directory for metadata cache and retry-state files.\n\nRelative paths are resolved against `storage_dir`.\n\nExamples: `metadata`, `/app/data/metadata`.", + "CACHE_PATH": "Directory for metadata cache and retry-state files.\n\nRelative paths are resolved against `storage_dir`.\n\nExamples: `metadata`, `/app/data/metadata`.", "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.", "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`.", "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`.", @@ -479,6 +499,11 @@ "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)." }, + "PUSHOVER_MESSAGING_CONFIG": { + "TOKEN": "Pushover application API token.", + "URL": "Optional custom Pushover API URL. Leave empty to use the default.", + "USER": "Pushover user key identifying the notification recipient." + }, "PROVIDER_FORM": { "URLS": "List of base URLs for this provider. They are used in order and rotated when failover is triggered.", "DNS": { @@ -490,6 +515,7 @@ }, "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": { + "BACKOFF_JITTER_PERCENT": "Random jitter added to probe retry delays.\n\nA value of 20 means delays vary by ±20%. Helps spread retries across workers to avoid thundering-herd problems.\n\nMust be between 0 and 95.", "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`.", "MAX_ATTEMPTS": "How many probe tries are allowed before cooldown starts.\n\nLower values reduce provider load, higher values can recover more transient failures.", "RETRY_BACKOFF_STEP_1": "Wait time after the first probe failure.\n\nUse this as a gentle first retry step.\n\nExample: `10m`.", @@ -535,7 +561,10 @@ "BACKOFF_MULTIPLIER": "Multiplies the base backoff after each failed attempt to create an exponential delay." }, "REST_MESSAGING_CONFIG": { - "HEADERS": "Custom HTTP headers to send with the request (JSON format)." + "HEADERS": "Custom HTTP headers to send with the request (JSON format).", + "METHOD": "HTTP method used for the notification request (e.g. POST, PUT). Default: POST.", + "TEMPLATES": "Handlebars templates for REST notification message bodies.", + "URL": "Target URL that receives the notification webhook." }, "REVERSE_PROXY_CONFIG": { "FAILOVER_REDIRECT_PATTERNS": "Regex patterns determining which HTTP error responses trigger an automatic failover rotation.", @@ -589,6 +618,14 @@ "PROCESSING_ORDER": "Determines the priority or the sequence in which target channels are matched and listed." }, "TELEGRAM_CONFIG": { + "BOT_TOKEN": "Telegram Bot API token. Obtain this from @BotFather.", + "MARKDOWN": "If enabled, messages are sent as MarkdownV2 formatted text.", + "TEMPLATES": "Handlebars templates for Telegram message bodies." + }, + "TELEGRAM_MESSAGING_CONFIG": { + "BOT_TOKEN": "Telegram Bot API token. Obtain this from @BotFather.", + "CHAT_IDS": "List of Telegram chat IDs to receive notifications. Format: '' or ':'.", + "MARKDOWN": "If enabled, messages are sent as MarkdownV2 formatted text.", "TEMPLATES": "Handlebars templates for Telegram message bodies." }, "TMDB_CONFIG": {