use std::path::PathBuf; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { #[serde(default = "default_library_path")] pub library_path: PathBuf, #[serde(default = "default_database_url")] pub database_url: String, #[serde(default = "default_download_path")] pub download_path: PathBuf, #[serde(default = "default_organization_format")] pub organization_format: String, /// Which secondary release group types to include. Empty = studio releases only. /// Options: "Compilation", "Live", "Soundtrack", "Remix", "DJ-mix", "Demo", etc. #[serde(default)] pub allowed_secondary_types: Vec, #[serde(default)] pub web: WebConfig, #[serde(default)] pub tagging: TaggingConfig, #[serde(default)] pub download: DownloadConfig, #[serde(default)] pub indexing: IndexingConfig, #[serde(default)] pub metadata: MetadataConfig, #[serde(default)] pub scheduling: SchedulingConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebConfig { #[serde(default = "default_port")] pub port: u16, #[serde(default = "default_bind")] pub bind: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaggingConfig { #[serde(default)] pub auto_tag: bool, #[serde(default = "default_true")] pub write_tags: bool, #[serde(default = "default_confidence")] pub confidence: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DownloadConfig { #[serde(default = "default_format")] pub format: String, #[serde(default = "default_search_source")] pub search_source: String, #[serde(default)] pub cookies_path: Option, /// Requests per hour (unauthenticated). Actual YouTube limit is ~300. #[serde(default = "default_rate_limit")] pub rate_limit: u32, /// Requests per hour (with cookies). Actual YouTube limit is ~2000. #[serde(default = "default_rate_limit_auth")] pub rate_limit_auth: u32, /// Enable automatic cookie refresh via headless Firefox. #[serde(default)] pub cookie_refresh_enabled: bool, /// How often to refresh cookies (hours). #[serde(default = "default_cookie_refresh_hours")] pub cookie_refresh_hours: u32, /// Port for noVNC during interactive YouTube login. #[serde(default = "default_vnc_port")] pub vnc_port: u16, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IndexingConfig { /// Number of concurrent file processors during library scan. #[serde(default = "default_concurrency")] pub concurrency: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetadataConfig { /// Source for structured metadata: "musicbrainz" (default). #[serde(default = "default_metadata_source")] pub metadata_source: String, /// Source for artist images: "wikipedia" (default). #[serde(default = "default_artist_image_source")] pub artist_image_source: String, /// Source for artist bios: "wikipedia" (default) or "lastfm". #[serde(default = "default_artist_bio_source")] pub artist_bio_source: String, /// Source for lyrics: "lrclib" (default). #[serde(default = "default_lyrics_source")] pub lyrics_source: String, /// Source for cover art: "coverartarchive" (default). #[serde(default = "default_cover_art_source")] pub cover_art_source: String, /// Last.fm API key for fetching artist bios. Set via SHANTY_LASTFM_API_KEY env var. /// Required if artist_bio_source is "lastfm". #[serde(skip)] pub lastfm_api_key: Option, /// fanart.tv API key for artist images/banners. Set via SHANTY_FANART_API_KEY env var. /// Required if artist_image_source is "fanarttv". #[serde(skip)] pub fanart_api_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulingConfig { /// Enable automatic pipeline runs. #[serde(default)] pub pipeline_enabled: bool, /// Hours between pipeline runs (after previous completion). #[serde(default = "default_pipeline_interval_hours")] pub pipeline_interval_hours: u32, /// Enable automatic new release checking for monitored artists. #[serde(default)] pub monitor_enabled: bool, /// Hours between monitor checks. #[serde(default = "default_monitor_interval_hours")] pub monitor_interval_hours: u32, } impl Default for SchedulingConfig { fn default() -> Self { Self { pipeline_enabled: true, pipeline_interval_hours: default_pipeline_interval_hours(), monitor_enabled: true, monitor_interval_hours: default_monitor_interval_hours(), } } } // --- Defaults --- impl Default for AppConfig { fn default() -> Self { Self { library_path: default_library_path(), database_url: default_database_url(), download_path: default_download_path(), organization_format: default_organization_format(), allowed_secondary_types: vec![], web: WebConfig::default(), tagging: TaggingConfig::default(), download: DownloadConfig::default(), indexing: IndexingConfig::default(), metadata: MetadataConfig::default(), scheduling: SchedulingConfig::default(), } } } impl Default for WebConfig { fn default() -> Self { Self { port: default_port(), bind: default_bind(), } } } impl Default for TaggingConfig { fn default() -> Self { Self { auto_tag: false, write_tags: true, confidence: default_confidence(), } } } impl Default for DownloadConfig { fn default() -> Self { Self { format: default_format(), search_source: default_search_source(), cookies_path: None, rate_limit: default_rate_limit(), rate_limit_auth: default_rate_limit_auth(), cookie_refresh_enabled: false, cookie_refresh_hours: default_cookie_refresh_hours(), vnc_port: default_vnc_port(), } } } impl Default for IndexingConfig { fn default() -> Self { Self { concurrency: default_concurrency(), } } } impl Default for MetadataConfig { fn default() -> Self { Self { metadata_source: default_metadata_source(), artist_image_source: default_artist_image_source(), artist_bio_source: default_artist_bio_source(), lyrics_source: default_lyrics_source(), cover_art_source: default_cover_art_source(), lastfm_api_key: None, fanart_api_key: None, } } } fn default_library_path() -> PathBuf { dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music")) } fn default_database_url() -> String { let data_dir = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("shanty"); std::fs::create_dir_all(&data_dir).ok(); format!("sqlite://{}?mode=rwc", data_dir.join("shanty.db").display()) } fn default_download_path() -> PathBuf { let dir = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("shanty") .join("downloads"); std::fs::create_dir_all(&dir).ok(); dir } fn default_organization_format() -> String { "{artist}/{album}/{track_number} - {title}.{ext}".to_string() } fn default_port() -> u16 { 8085 } fn default_bind() -> String { "0.0.0.0".to_string() } fn default_confidence() -> f64 { 0.8 } fn default_format() -> String { "opus".to_string() } fn default_search_source() -> String { "ytmusic".to_string() } fn default_true() -> bool { true } fn default_rate_limit() -> u32 { 250 } fn default_rate_limit_auth() -> u32 { 1800 } fn default_concurrency() -> usize { 4 } fn default_metadata_source() -> String { "musicbrainz".to_string() } fn default_artist_image_source() -> String { "wikipedia".to_string() } fn default_artist_bio_source() -> String { "wikipedia".to_string() } fn default_lyrics_source() -> String { "lrclib".to_string() } fn default_cover_art_source() -> String { "coverartarchive".to_string() } fn default_pipeline_interval_hours() -> u32 { 3 } fn default_monitor_interval_hours() -> u32 { 12 } fn default_cookie_refresh_hours() -> u32 { 6 } fn default_vnc_port() -> u16 { 6080 } /// Return the application data directory (e.g. ~/.local/share/shanty). pub fn data_dir() -> PathBuf { dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("shanty") } // --- Loading and Saving --- impl AppConfig { /// Resolve the config file path. pub fn config_path(override_path: Option<&str>) -> PathBuf { override_path .map(PathBuf::from) .or_else(|| std::env::var("SHANTY_CONFIG").ok().map(PathBuf::from)) .unwrap_or_else(|| { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("shanty") .join("config.yaml") }) } /// Load config from file, falling back to defaults. pub fn load(path: Option<&str>) -> Self { let config_path = Self::config_path(path); if config_path.exists() { match std::fs::read_to_string(&config_path) { Ok(contents) => match serde_yaml::from_str(&contents) { Ok(config) => { tracing::info!(path = %config_path.display(), "loaded config"); return Self::apply_env_overrides(config); } Err(e) => { tracing::warn!(path = %config_path.display(), error = %e, "failed to parse config, using defaults"); } }, Err(e) => { tracing::warn!(path = %config_path.display(), error = %e, "failed to read config, using defaults"); } } } else { tracing::info!(path = %config_path.display(), "no config file found, using defaults"); } Self::apply_env_overrides(AppConfig::default()) } /// Save config to YAML file. pub fn save(&self, path: Option<&str>) -> Result<(), String> { let config_path = Self::config_path(path); if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create config directory: {e}"))?; } let yaml = serde_yaml::to_string(self).map_err(|e| format!("failed to serialize config: {e}"))?; std::fs::write(&config_path, yaml) .map_err(|e| format!("failed to write config to {}: {e}", config_path.display()))?; tracing::info!(path = %config_path.display(), "config saved"); Ok(()) } fn apply_env_overrides(mut config: Self) -> Self { if let Ok(v) = std::env::var("SHANTY_DATABASE_URL") { config.database_url = v; } if let Ok(v) = std::env::var("SHANTY_LIBRARY_PATH") { config.library_path = PathBuf::from(v); } if let Ok(v) = std::env::var("SHANTY_DOWNLOAD_PATH") { config.download_path = PathBuf::from(v); } if let Ok(v) = std::env::var("SHANTY_WEB_PORT") && let Ok(port) = v.parse() { config.web.port = port; } if let Ok(v) = std::env::var("SHANTY_WEB_BIND") { config.web.bind = v; } if let Ok(v) = std::env::var("SHANTY_LASTFM_API_KEY") { config.metadata.lastfm_api_key = Some(v); } if let Ok(v) = std::env::var("SHANTY_FANART_API_KEY") { config.metadata.fanart_api_key = Some(v); } config } }