Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
437 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|