use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub storage: StorageConfig, pub directories: DirectoryConfig, pub scanning: ScanningConfig, pub server: ServerConfig, #[serde(default)] pub ui: UiConfig, #[serde(default)] pub accounts: AccountsConfig, #[serde(default)] pub jobs: JobsConfig, #[serde(default)] pub thumbnails: ThumbnailConfig, #[serde(default)] pub webhooks: Vec, #[serde(default)] pub scheduled_tasks: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTaskConfig { pub id: String, pub enabled: bool, pub schedule: crate::scheduler::Schedule, pub last_run: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobsConfig { #[serde(default = "default_worker_count")] pub worker_count: usize, #[serde(default = "default_cache_ttl")] pub cache_ttl_secs: u64, } fn default_worker_count() -> usize { 2 } fn default_cache_ttl() -> u64 { 60 } impl Default for JobsConfig { fn default() -> Self { Self { worker_count: default_worker_count(), cache_ttl_secs: default_cache_ttl(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailConfig { #[serde(default = "default_thumb_size")] pub size: u32, #[serde(default = "default_thumb_quality")] pub quality: u8, #[serde(default)] pub ffmpeg_path: Option, #[serde(default = "default_video_seek")] pub video_seek_secs: u32, } fn default_thumb_size() -> u32 { 320 } fn default_thumb_quality() -> u8 { 80 } fn default_video_seek() -> u32 { 2 } impl Default for ThumbnailConfig { fn default() -> Self { Self { size: default_thumb_size(), quality: default_thumb_quality(), ffmpeg_path: None, video_seek_secs: default_video_seek(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub url: String, pub events: Vec, #[serde(default)] pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { #[serde(default = "default_theme")] pub theme: String, #[serde(default = "default_view")] pub default_view: String, #[serde(default = "default_page_size")] pub default_page_size: usize, #[serde(default = "default_view_mode")] pub default_view_mode: String, #[serde(default)] pub auto_play_media: bool, #[serde(default = "default_true")] pub show_thumbnails: bool, #[serde(default)] pub sidebar_collapsed: bool, } fn default_theme() -> String { "dark".to_string() } fn default_view() -> String { "library".to_string() } fn default_page_size() -> usize { 48 } fn default_view_mode() -> String { "grid".to_string() } fn default_true() -> bool { true } impl Default for UiConfig { fn default() -> Self { Self { theme: default_theme(), default_view: default_view(), default_page_size: default_page_size(), default_view_mode: default_view_mode(), auto_play_media: false, show_thumbnails: true, sidebar_collapsed: false, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AccountsConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserAccount { pub username: String, pub password_hash: String, #[serde(default)] pub role: UserRole, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum UserRole { Admin, Editor, #[default] Viewer, } impl UserRole { pub fn can_read(self) -> bool { true } pub fn can_write(self) -> bool { matches!(self, Self::Admin | Self::Editor) } pub fn can_admin(self) -> bool { matches!(self, Self::Admin) } } impl std::fmt::Display for UserRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Admin => write!(f, "admin"), Self::Editor => write!(f, "editor"), Self::Viewer => write!(f, "viewer"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageConfig { pub backend: StorageBackendType, pub sqlite: Option, pub postgres: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum StorageBackendType { Sqlite, Postgres, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqliteConfig { pub path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostgresConfig { pub host: String, pub port: u16, pub database: String, pub username: String, pub password: String, pub max_connections: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DirectoryConfig { pub roots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanningConfig { pub watch: bool, pub poll_interval_secs: u64, pub ignore_patterns: Vec, #[serde(default = "default_import_concurrency")] pub import_concurrency: usize, } fn default_import_concurrency() -> usize { 8 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16, /// Optional API key for bearer token authentication. /// If set, all requests (except /health) must include `Authorization: Bearer `. /// Can also be set via `PINAKES_API_KEY` environment variable. pub api_key: Option, } impl Config { pub fn from_file(path: &Path) -> crate::error::Result { let content = std::fs::read_to_string(path).map_err(|e| { crate::error::PinakesError::Config(format!("failed to read config file: {e}")) })?; toml::from_str(&content) .map_err(|e| crate::error::PinakesError::Config(format!("failed to parse config: {e}"))) } /// Try loading from file, falling back to defaults if the file doesn't exist. pub fn load_or_default(path: &Path) -> crate::error::Result { if path.exists() { Self::from_file(path) } else { let config = Self::default(); // Ensure the data directory exists for the default SQLite database config.ensure_dirs()?; Ok(config) } } /// Save the current config to a TOML file. pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let content = toml::to_string_pretty(self).map_err(|e| { crate::error::PinakesError::Config(format!("failed to serialize config: {e}")) })?; std::fs::write(path, content)?; Ok(()) } /// Ensure all directories needed by this config exist and are writable. pub fn ensure_dirs(&self) -> crate::error::Result<()> { if let Some(ref sqlite) = self.storage.sqlite && let Some(parent) = sqlite.path.parent() { std::fs::create_dir_all(parent)?; let metadata = std::fs::metadata(parent)?; if metadata.permissions().readonly() { return Err(crate::error::PinakesError::Config(format!( "directory is not writable: {}", parent.display() ))); } } Ok(()) } /// Returns the default config file path following XDG conventions. pub fn default_config_path() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(xdg).join("pinakes").join("pinakes.toml") } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home) .join(".config") .join("pinakes") .join("pinakes.toml") } else { PathBuf::from("pinakes.toml") } } /// Validate configuration values for correctness. pub fn validate(&self) -> Result<(), String> { if self.server.port == 0 { return Err("server port cannot be 0".into()); } if self.server.host.is_empty() { return Err("server host cannot be empty".into()); } if self.scanning.poll_interval_secs == 0 { return Err("poll interval cannot be 0".into()); } if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 { return Err("import_concurrency must be between 1 and 256".into()); } Ok(()) } /// Returns the default data directory following XDG conventions. pub fn default_data_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { PathBuf::from(xdg).join("pinakes") } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home) .join(".local") .join("share") .join("pinakes") } else { PathBuf::from("pinakes-data") } } } impl Default for Config { fn default() -> Self { let data_dir = Self::default_data_dir(); Self { storage: StorageConfig { backend: StorageBackendType::Sqlite, sqlite: Some(SqliteConfig { path: data_dir.join("pinakes.db"), }), postgres: None, }, directories: DirectoryConfig { roots: vec![] }, scanning: ScanningConfig { watch: false, poll_interval_secs: 300, ignore_patterns: vec![ ".*".to_string(), "node_modules".to_string(), "__pycache__".to_string(), "target".to_string(), ], import_concurrency: default_import_concurrency(), }, server: ServerConfig { host: "127.0.0.1".to_string(), port: 3000, api_key: None, }, ui: UiConfig::default(), accounts: AccountsConfig::default(), jobs: JobsConfig::default(), thumbnails: ThumbnailConfig::default(), webhooks: vec![], scheduled_tasks: vec![], } } } #[cfg(test)] mod tests { use super::*; fn test_config_with_concurrency(concurrency: usize) -> Config { let mut config = Config::default(); config.scanning.import_concurrency = concurrency; config } #[test] fn test_validate_import_concurrency_zero() { let config = test_config_with_concurrency(0); assert!(config.validate().is_err()); assert!( config .validate() .unwrap_err() .contains("import_concurrency") ); } #[test] fn test_validate_import_concurrency_too_high() { let config = test_config_with_concurrency(257); assert!(config.validate().is_err()); assert!( config .validate() .unwrap_err() .contains("import_concurrency") ); } #[test] fn test_validate_import_concurrency_valid() { let config = test_config_with_concurrency(8); assert!(config.validate().is_ok()); } #[test] fn test_validate_import_concurrency_boundary_low() { let config = test_config_with_concurrency(1); assert!(config.validate().is_ok()); } #[test] fn test_validate_import_concurrency_boundary_high() { let config = test_config_with_concurrency(256); assert!(config.validate().is_ok()); } }