/// Application-level configuration for furumusic. /// /// Every field is available both as a `FURU_`-prefixed environment variable /// and through the admin UI. The resolution order is: /// /// env var > DB override > compiled default /// /// Adding a new field to [`AppConfig`] automatically makes it settable via /// the `FURU_` env var thanks to the [`impl_env_overrides`] macro. use std::collections::HashMap; use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model}; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- // ConfigSource — tracks where each field's effective value came from // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigSource { Default, Database, Env, } impl ConfigSource { pub fn code(self) -> &'static str { match self { Self::Default => "default", Self::Database => "database", Self::Env => "env", } } } // --------------------------------------------------------------------------- // ConfigEntry — DB model for the furu__config table // --------------------------------------------------------------------------- #[derive(Debug, Clone)] #[cot::db::model] pub struct ConfigEntry { #[model(primary_key)] key: String, value: String, } impl ConfigEntry { pub fn new(key: String, value: String) -> Self { Self { key, value } } } // --------------------------------------------------------------------------- // Migration // --------------------------------------------------------------------------- pub mod db_migrations { use super::*; #[derive(Debug, Copy, Clone)] pub struct M0001CreateConfig; impl migrations::Migration for M0001CreateConfig { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0001_create_config"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[]; const OPERATIONS: &'static [Operation] = &[Operation::create_model() .table_name(Identifier::new("furu__config")) .fields(&[ Field::new( Identifier::new("key"), as DatabaseField>::TYPE, ) .primary_key() .set_null( as DatabaseField>::NULLABLE), Field::new(Identifier::new("value"), ::TYPE) .set_null(::NULLABLE), ]) .build()]; } // -- M0002: rename furu__config → furumusic__config_entry --------------- #[cot::db::migrations::migration_op] async fn rename_config_table(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { ctx.db .raw("ALTER TABLE furu__config RENAME TO furumusic__config_entry") .await?; Ok(()) } #[derive(Debug, Copy, Clone)] pub struct M0002RenameConfigTable; impl migrations::Migration for M0002RenameConfigTable { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0002_rename_config_table"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[migrations::MigrationDependency::migration( "furumusic", "m_0001_create_config", )]; const OPERATIONS: &'static [Operation] = &[Operation::custom(rename_config_table).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable]; } // --------------------------------------------------------------------------- // ConfigSources — parallel struct tracking the source of each field // --------------------------------------------------------------------------- pub struct ConfigSources { pub database_url: ConfigSource, pub oidc_issuer: ConfigSource, pub oidc_client_id: ConfigSource, pub oidc_client_secret: ConfigSource, pub log_level: ConfigSource, pub auth_password_enabled: ConfigSource, pub auth_sso_enabled: ConfigSource, pub oidc_button_text: ConfigSource, pub oidc_admin_groups: ConfigSource, pub oidc_user_groups: ConfigSource, pub swagger_enabled: ConfigSource, pub agent_enabled: ConfigSource, pub agent_inbox_dir: ConfigSource, pub agent_storage_dir: ConfigSource, pub agent_llm_url: ConfigSource, pub agent_llm_model: ConfigSource, pub agent_llm_auth: ConfigSource, pub agent_confidence_threshold: ConfigSource, pub agent_context_limit: ConfigSource, pub agent_concurrency: ConfigSource, pub lastfm_api_key: ConfigSource, pub lastfm_shared_secret: ConfigSource, } impl Default for ConfigSources { fn default() -> Self { Self { database_url: ConfigSource::Default, oidc_issuer: ConfigSource::Default, oidc_client_id: ConfigSource::Default, oidc_client_secret: ConfigSource::Default, log_level: ConfigSource::Default, auth_password_enabled: ConfigSource::Default, auth_sso_enabled: ConfigSource::Default, oidc_button_text: ConfigSource::Default, oidc_admin_groups: ConfigSource::Default, oidc_user_groups: ConfigSource::Default, swagger_enabled: ConfigSource::Default, agent_enabled: ConfigSource::Default, agent_inbox_dir: ConfigSource::Default, agent_storage_dir: ConfigSource::Default, agent_llm_url: ConfigSource::Default, agent_llm_model: ConfigSource::Default, agent_llm_auth: ConfigSource::Default, agent_confidence_threshold: ConfigSource::Default, agent_context_limit: ConfigSource::Default, agent_concurrency: ConfigSource::Default, lastfm_api_key: ConfigSource::Default, lastfm_shared_secret: ConfigSource::Default, } } } // --------------------------------------------------------------------------- // Env-var helper // --------------------------------------------------------------------------- /// Read a single env var with the `FURU_` prefix, returning `None` when the /// variable is absent and logging a warning when it is present but cannot be /// parsed. fn env_override(field: &str) -> Option { let key = format!("FURU_{}", field.to_ascii_uppercase()); match std::env::var(&key) { Ok(val) => match val.parse::() { Ok(v) => Some(v), Err(_) => { tracing::warn!("ignoring invalid value for {key}: {val:?}"); None } }, Err(_) => None, } } // --------------------------------------------------------------------------- // Macro: generates apply_env_overrides + apply_env_overrides_tracked // --------------------------------------------------------------------------- /// Generates two methods on [`AppConfig`]: /// /// - `apply_env_overrides`: overwrites fields from `FURU_*` env vars (no source tracking). /// - `apply_env_overrides_tracked`: same but also marks sources as [`ConfigSource::Env`]. macro_rules! impl_env_overrides { ($($field:ident),* $(,)?) => { impl AppConfig { /// Apply `FURU_*` environment variable overrides to self. pub fn apply_env_overrides(&mut self) { $( if let Some(v) = env_override(stringify!($field)) { self.$field = v; } )* } /// Apply `FURU_*` environment variable overrides and record sources. pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) { $( if let Some(v) = env_override(stringify!($field)) { self.$field = v; sources.$field = ConfigSource::Env; } )* } } }; } // --------------------------------------------------------------------------- // AppConfig // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { /// PostgreSQL connection URL. pub database_url: String, /// OIDC issuer URL. pub oidc_issuer: String, /// OIDC client ID. pub oidc_client_id: String, /// OIDC client secret. pub oidc_client_secret: String, /// Tracing log level filter (e.g. "info", "debug", "warn,furumusic=debug"). pub log_level: String, /// Whether password-based login is enabled. pub auth_password_enabled: bool, /// Whether SSO (OIDC) login is enabled. pub auth_sso_enabled: bool, /// Label shown on the SSO login button. pub oidc_button_text: String, /// Comma-separated list of OIDC group names that grant admin role. pub oidc_admin_groups: String, /// Comma-separated list of OIDC group names that are allowed to use the service. pub oidc_user_groups: String, /// Whether the Swagger UI is served at /swagger/. pub swagger_enabled: bool, /// Whether the AI agent background loop is enabled. pub agent_enabled: bool, /// Directory to scan for incoming audio files. pub agent_inbox_dir: String, /// Directory for organized permanent storage. pub agent_storage_dir: String, /// LLM API URL (OpenAI-compatible). pub agent_llm_url: String, /// LLM model name. pub agent_llm_model: String, /// LLM Authorization header value (e.g. "Bearer sk-..."). pub agent_llm_auth: String, /// Confidence threshold for auto-approval (0.0–1.0). pub agent_confidence_threshold: f64, /// LLM context window size in tokens. Chat history resets when approaching this limit. pub agent_context_limit: u64, /// Number of files to process in parallel via the LLM. pub agent_concurrency: u64, /// Last.fm API key for weekly popularity enrichment. pub lastfm_api_key: String, /// Last.fm shared secret for authenticated scrobbling calls. pub lastfm_shared_secret: String, } impl Default for AppConfig { fn default() -> Self { Self { database_url: String::new(), oidc_issuer: String::new(), oidc_client_id: String::new(), oidc_client_secret: String::new(), log_level: "info".into(), auth_password_enabled: true, auth_sso_enabled: false, oidc_button_text: "Sign in with SSO".into(), oidc_admin_groups: String::new(), oidc_user_groups: String::new(), swagger_enabled: false, agent_enabled: false, agent_inbox_dir: String::new(), agent_storage_dir: String::new(), agent_llm_url: "http://localhost:8080".into(), agent_llm_model: "default".into(), agent_llm_auth: String::new(), agent_confidence_threshold: 0.85, agent_context_limit: 8192, agent_concurrency: 2, lastfm_api_key: String::new(), lastfm_shared_secret: String::new(), } } } // Register every field that should be overridable via FURU_* env vars. impl_env_overrides!( database_url, oidc_issuer, oidc_client_id, oidc_client_secret, log_level, auth_password_enabled, auth_sso_enabled, oidc_button_text, oidc_admin_groups, oidc_user_groups, swagger_enabled, agent_enabled, agent_inbox_dir, agent_storage_dir, agent_llm_url, agent_llm_model, agent_llm_auth, agent_confidence_threshold, agent_context_limit, agent_concurrency, lastfm_api_key, lastfm_shared_secret, ); impl AppConfig { fn normalize_host_paths(&mut self) { self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir); self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir); } /// Build config: start from defaults, then overlay env vars. /// Used at startup before the DB is available (to get `database_url`). pub fn load() -> Self { let mut cfg = Self::default(); cfg.apply_env_overrides(); cfg.normalize_host_paths(); cfg } /// Build config with full 3-layer resolution (default → DB → env) and /// track the source of each field. pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) { let mut cfg = Self::default(); let mut sources = ConfigSources::default(); cfg.apply_db_overrides(db, &mut sources).await; cfg.apply_env_overrides_tracked(&mut sources); cfg.normalize_host_paths(); (cfg, sources) } /// Query all rows from `furu__config` and overlay matching fields. async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) { let rows = match ConfigEntry::objects().all(db).await { Ok(rows) => rows, Err(e) => { tracing::warn!("failed to read furu__config: {e}"); return; } }; let map: HashMap = rows .into_iter() .map(|entry| (entry.key.to_string(), entry.value)) .collect(); macro_rules! apply_db_field { ($field:ident) => { if let Some(val) = map.get(stringify!($field)) { match val.parse() { Ok(v) => { self.$field = v; sources.$field = ConfigSource::Database; } Err(_) => { tracing::warn!( "ignoring invalid DB config value for {}: {:?}", stringify!($field), val, ); } } } }; } apply_db_field!(database_url); apply_db_field!(oidc_issuer); apply_db_field!(oidc_client_id); apply_db_field!(oidc_client_secret); apply_db_field!(log_level); apply_db_field!(auth_password_enabled); apply_db_field!(auth_sso_enabled); apply_db_field!(oidc_button_text); apply_db_field!(oidc_admin_groups); apply_db_field!(oidc_user_groups); apply_db_field!(swagger_enabled); apply_db_field!(agent_enabled); apply_db_field!(agent_inbox_dir); apply_db_field!(agent_storage_dir); apply_db_field!(agent_llm_url); apply_db_field!(agent_llm_model); apply_db_field!(agent_llm_auth); apply_db_field!(agent_confidence_threshold); apply_db_field!(agent_context_limit); apply_db_field!(agent_concurrency); apply_db_field!(lastfm_api_key); apply_db_field!(lastfm_shared_secret); } } fn normalize_host_path(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return String::new(); } normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned()) } #[cfg(not(windows))] fn normalize_windows_user_path(value: &str) -> Option { let normalized = value.replace('\\', "/"); let mut parts = normalized.split('/').filter(|part| !part.is_empty()); let drive = parts.next()?; if drive.len() != 2 || !drive.ends_with(':') { return None; } if !parts.next()?.eq_ignore_ascii_case("Users") { return None; } let user = parts.next()?; if user.is_empty() { return None; } let mut out = format!("/Users/{user}"); for part in parts { out.push('/'); out.push_str(part); } Some(out) } #[cfg(windows)] fn normalize_windows_user_path(_value: &str) -> Option { None } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_are_sane() { let cfg = AppConfig::default(); assert!(cfg.database_url.is_empty()); assert_eq!(cfg.log_level, "info"); } #[cfg(not(windows))] #[test] fn normalizes_windows_user_path_on_unix() { assert_eq!( normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"), "/Users/ab/repos/furumusic/media/uploads" ); } #[cfg(not(windows))] #[test] fn leaves_unix_path_unchanged() { assert_eq!( normalize_host_path("/Users/ab/repos/furumusic/media/uploads"), "/Users/ab/repos/furumusic/media/uploads" ); } // SAFETY: tests run with --test-threads=1 so no concurrent env access. unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; } unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; } #[test] fn env_override_string_field() { unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); } let cfg = AppConfig::load(); assert_eq!(cfg.oidc_issuer, "https://example.com"); unsafe { unset("FURU_OIDC_ISSUER"); } } #[test] fn env_override_bool_field() { unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); } let cfg = AppConfig::load(); assert!(cfg.auth_sso_enabled); unsafe { unset("FURU_AUTH_SSO_ENABLED"); } } #[test] fn source_tracking_env() { unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); } let mut cfg = AppConfig::default(); let mut sources = ConfigSources::default(); cfg.apply_env_overrides_tracked(&mut sources); assert_eq!(cfg.oidc_issuer, "https://tracked.example.com"); assert_eq!(sources.oidc_issuer, ConfigSource::Env); assert_eq!(sources.database_url, ConfigSource::Default); unsafe { unset("FURU_OIDC_ISSUER"); } } #[test] fn config_source_codes() { assert_eq!(ConfigSource::Default.code(), "default"); assert_eq!(ConfigSource::Database.code(), "database"); assert_eq!(ConfigSource::Env.code(), "env"); } }