pinakes/crates/pinakes-core/src/config.rs
NotAShelf 6a73d11c4b
initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
2026-01-31 15:20:30 +03:00

437 lines
12 KiB
Rust

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<WebhookConfig>,
#[serde(default)]
pub scheduled_tasks: Vec<ScheduledTaskConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledTaskConfig {
pub id: String,
pub enabled: bool,
pub schedule: crate::scheduler::Schedule,
pub last_run: Option<String>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[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<UserAccount>,
}
#[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<SqliteConfig>,
pub postgres: Option<PostgresConfig>,
}
#[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<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanningConfig {
pub watch: bool,
pub poll_interval_secs: u64,
pub ignore_patterns: Vec<String>,
#[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 <key>`.
/// Can also be set via `PINAKES_API_KEY` environment variable.
pub api_key: Option<String>,
}
impl Config {
pub fn from_file(path: &Path) -> crate::error::Result<Self> {
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<Self> {
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());
}
}