Files
furumusic/src/config.rs
T
Ultradesu 015d75c701
Build and Publish / Build and Publish Docker Image (push) Failing after 1m42s
CORE: Added Last.FM scrobbling
2026-05-27 16:40:06 +03:00

538 lines
18 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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,
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<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.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,
/// 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<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);
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<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");
}
}