531 lines
18 KiB
Rust
531 lines
18 KiB
Rust
/// 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] = &[];
|
||
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),
|
||
])
|
||
.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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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,
|
||
/// 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,
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
);
|
||
|
||
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<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);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|