initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
437
crates/pinakes-core/src/config.rs
Normal file
437
crates/pinakes-core/src/config.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue