Files
furumusic/src/config.rs
T

531 lines
18 KiB
Rust
Raw Normal View History

/// 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_<FIELD_NAME>` 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] = &[];
2026-05-25 13:50:24 +03:00
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furu__config"))
.fields(&[
Field::new(
Identifier::new("key"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.primary_key()
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
Field::new(Identifier::new("value"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
2026-05-25 13:50:24 +03:00
])
.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";
2026-05-25 13:50:24 +03:00
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,
2026-05-25 16:26:45 +03:00
pub oidc_user_groups: ConfigSource,
2026-05-21 16:21:42 +03:00
pub swagger_enabled: ConfigSource,
2026-05-23 13:08:09 +03:00
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,
2026-05-26 18:16:34 +03:00
pub lastfm_api_key: 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,
2026-05-25 16:26:45 +03:00
oidc_user_groups: ConfigSource::Default,
2026-05-21 16:21:42 +03:00
swagger_enabled: ConfigSource::Default,
2026-05-23 13:08:09 +03:00
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,
2026-05-26 18:16:34 +03:00
lastfm_api_key: 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<T: std::str::FromStr>(field: &str) -> Option<T> {
let key = format!("FURU_{}", field.to_ascii_uppercase());
match std::env::var(&key) {
Ok(val) => match val.parse::<T>() {
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,
2026-05-25 16:26:45 +03:00
/// Comma-separated list of OIDC group names that are allowed to use the service.
pub oidc_user_groups: String,
2026-05-21 16:21:42 +03:00
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
2026-05-23 13:08:09 +03:00
/// 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.01.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,
2026-05-26 18:16:34 +03:00
/// Last.fm API key for weekly popularity enrichment.
pub lastfm_api_key: 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(),
2026-05-25 16:26:45 +03:00
oidc_user_groups: String::new(),
2026-05-21 16:21:42 +03:00
swagger_enabled: false,
2026-05-23 13:08:09 +03:00
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,
2026-05-26 18:16:34 +03:00
lastfm_api_key: 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,
2026-05-25 16:26:45 +03:00
oidc_user_groups,
2026-05-21 16:21:42 +03:00
swagger_enabled,
2026-05-23 13:08:09 +03:00
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,
2026-05-26 18:16:34 +03:00
lastfm_api_key,
);
impl AppConfig {
2026-05-26 14:47:10 +03:00
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();
2026-05-26 14:47:10 +03:00
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);
2026-05-26 14:47:10 +03:00
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<String, String> = 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);
2026-05-25 16:26:45 +03:00
apply_db_field!(oidc_user_groups);
2026-05-21 16:21:42 +03:00
apply_db_field!(swagger_enabled);
2026-05-23 13:08:09 +03:00
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);
2026-05-26 18:16:34 +03:00
apply_db_field!(lastfm_api_key);
}
}
2026-05-26 14:47:10 +03:00
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<String> {
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<String> {
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");
}
2026-05-26 14:47:10 +03:00
#[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.
2026-05-25 13:50:24 +03:00
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() {
2026-05-25 13:50:24 +03:00
unsafe {
set("FURU_OIDC_ISSUER", "https://example.com");
}
let cfg = AppConfig::load();
assert_eq!(cfg.oidc_issuer, "https://example.com");
2026-05-25 13:50:24 +03:00
unsafe {
unset("FURU_OIDC_ISSUER");
}
}
#[test]
fn env_override_bool_field() {
2026-05-25 13:50:24 +03:00
unsafe {
set("FURU_AUTH_SSO_ENABLED", "true");
}
let cfg = AppConfig::load();
assert!(cfg.auth_sso_enabled);
2026-05-25 13:50:24 +03:00
unsafe {
unset("FURU_AUTH_SSO_ENABLED");
}
}
#[test]
fn source_tracking_env() {
2026-05-25 13:50:24 +03:00
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);
2026-05-25 13:50:24 +03:00
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");
}
}