use askama::Template; use clap::{ArgAction, Args as ClapArgs, CommandFactory, Parser, Subcommand, error::ErrorKind}; use color_eyre::Result; use color_eyre::eyre::{Error, OptionExt, WrapErr, ensure, eyre}; use notify::Watcher; use serde::Deserialize; use spis::media::util::THUMBNAIL_FORMAT; use spis::{CustomCommand, PathFinder}; use spis::{ db, pipeline::{self, JOB_TRIGGER}, server::{self, Config, Listener}, }; use std::{net::TcpListener, path::PathBuf}; use tokio::sync::broadcast; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; fn file_exists(s: &str) -> Result { let pathbuf = PathBuf::from(s); if pathbuf.is_file() { Ok(pathbuf) } else { Err(format!("file '{s}' does not exist")) } } /// Simple private image server #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] #[allow(clippy::struct_excessive_bools)] pub struct SpisArgs { /// Path to configuration file #[clap(short, long, env = "SPIS_CONFIG_FILE", value_parser = file_exists)] pub config: Option, /// Path to search for media files #[clap(long, env = "SPIS_MEDIA_DIR")] pub media_dir: Option, /// Path to store data #[clap(long, env = "SPIS_DATA_DIR")] pub data_dir: Option, /// Schedule to run processing on (default: "0 0 2 * * *") #[clap(long, env = "SPIS_PROCESSING_SCHEDULE")] pub processing_schedule: Option, /// Run processing on start (default: false) #[clap(long, env = "SPIS_PROCESSING_RUN_ON_START", action = ArgAction::SetTrue)] pub processing_run_on_start: Option, /// Path webserver will serve media on (default: "/assets/media") #[clap(long, env = "SPIS_API_MEDIA_PATH")] pub api_media_path: Option, /// Path webserver will serve thumbnails on (default: "/assets/thumbnails") #[clap(long, env = "SPIS_API_THUMBNAIL_PATH")] pub api_thumbnail_path: Option, #[command(flatten)] pub listener: ServerListener, /// Disable feature favorite (default: true) #[clap(long, env = "SPIS_FEATURE_FAVORITE", action=ArgAction::SetFalse)] pub feature_favorite: Option, /// Disable feature archive (default: true) #[clap(long, env = "SPIS_FEATURE_ARCHIVE", action=ArgAction::SetFalse)] pub feature_archive: Option, /// Enable delete on archive (default: false) #[clap(long, env = "SPIS_FEATURE_DELETE_ON_ARCHIVE", action=ArgAction::SetTrue)] pub feature_delete_on_archive: Option, /// Disable feature follow symlinks (default: true) #[clap(long, env = "SPIS_FEATURE_FOLLOW_SYMLINKS", action=ArgAction::SetFalse)] pub feature_follow_symlinks: Option, /// Disable feature no exif (default: true) #[clap(long, env = "SPIS_FEATURE_NO_EXIF", action=ArgAction::SetFalse)] pub feature_allow_no_exif: Option, /// Slideshow duration in seconds (default: 5) #[clap(long, env = "SPIS_SLIDESHOW_DURATION_SECONDS")] pub slideshow_duration_seconds: Option, #[command(subcommand)] command: Option, } #[derive(Deserialize, Debug, Default)] pub struct SpisConfig { pub dirs: Option, pub processing: Option, pub api: Option, pub listener: Option, pub features: Option, pub slideshow: Option, pub custom_command: Option>, } #[derive(Deserialize, Debug, Default, Clone)] pub struct CustomCommandConfig { pub name: String, pub cmd: Vec, pub fa_icon: String, pub hotkey: Option, } impl TryFrom for CustomCommand { type Error = Error; fn try_from(value: CustomCommandConfig) -> std::result::Result { ensure!( !value.name.contains(' '), "command names cannot contain spaces" ); ensure!( value.name.to_lowercase() == value.name, "command names must be lowercase" ); ensure!(!value.cmd.is_empty(), "command cannot be empty"); if let Some(key) = value.hotkey { ensure!(key.is_lowercase(), "hotkey must be a lowercase character"); } Ok(Self { name: value.name, cmd: value.cmd, fa_icon: value.fa_icon, hotkey: value.hotkey, }) } } #[derive(Deserialize, Debug, Default)] pub struct DirsConfig { pub media: Option, pub data: Option, } #[derive(Deserialize, Debug, Default)] pub struct ProcessingConfig { pub schedule: Option, pub run_on_start: Option, } #[derive(Deserialize, Debug, Default)] pub struct ApiConfig { pub media_path: Option, pub thumbnail_path: Option, } #[derive(Deserialize, Debug, Default)] pub struct ListenerConfig { pub address: Option, pub socket: Option, } #[derive(Deserialize, Debug, Default)] pub struct FeaturesConfig { pub favorite: Option, pub archive: Option, pub delete_on_archive: Option, pub follow_symlinks: Option, pub allow_no_exif: Option, } #[derive(Deserialize, Debug, Default)] pub struct SlideshowConfig { pub duration_seconds: Option, } #[derive(Debug, Clone)] #[allow(clippy::struct_excessive_bools)] pub struct Spis { pub media_dir: PathBuf, pub data_dir: PathBuf, pub processing_schedule: String, pub processing_run_on_start: bool, pub api_media_path: String, pub api_thumbnail_path: String, pub listener: ServerListener, pub feature_favorite: bool, pub feature_archive: bool, pub feature_delete_on_archive: bool, pub feature_follow_symlinks: bool, pub feature_allow_no_exif: bool, pub slideshow_duration_seconds: usize, pub custom_commands: Vec, pub command: Option, } #[derive(Subcommand, Default, Debug, Clone)] pub enum SpisCommand { /// Runs the server [default] #[default] Run, /// Test process media files Process { /// Media file to process #[clap(value_parser = file_exists)] media: Vec, }, /// Render configuration templates Template { #[command(subcommand)] template: SpisTemplate, }, } #[derive(Subcommand, Debug, Clone)] pub enum SpisTemplate { /// Template nginx configuration Nginx { /// Nginx port #[arg(short, long, env = "NGINX_PORT")] port: u32, /// Full nginx config #[arg(short, long, default_value_t = false)] full: bool, }, /// Template systemd configuration Systemd { /// User that should run SPIS #[arg(short, long)] user: String, /// Full path to SPIS binary #[arg(short, long)] bin: String, /// Log configuration #[arg(short, long, default_value = "error,spis=info")] log: String, }, /// Template docker compose configuration DockerCompose { /// Full path to SPIS binary #[arg(short, long, default_value = "latest")] version: String, /// Log configuration #[arg(short, long, default_value = "error,spis=info")] log: String, }, } #[derive(ClapArgs, Debug, Deserialize, Clone, Default)] #[group(multiple = false)] pub struct ServerListener { /// Listen to address #[clap(long, group = "addr", env = "SPIS_SERVER_ADDRESS")] pub server_address: Option, /// Listen to UNIX socket #[clap(long, group = "addr", env = "SPIS_SERVER_SOCKET")] pub server_socket: Option, } fn validate_listener(config: &Spis) -> &'static str { match ( &config.listener.server_address, &config.listener.server_socket, ) { (Some(_), None) | (None, Some(_)) => {} _ => { let mut cmd = SpisArgs::command(); cmd.error( ErrorKind::MissingRequiredArgument, "missing either '--server-address ' or '--server-socket '", ) .exit(); } } "" } impl TryFrom<&Spis> for PathFinder { type Error = Error; fn try_from(value: &Spis) -> Result { Ok(Self::new( value .media_dir .to_str() .ok_or_eyre("failed to convert media dir to str")?, &value.api_media_path, &value.api_thumbnail_path, THUMBNAIL_FORMAT, )) } } impl Spis { pub fn resolve(args: SpisArgs) -> Result { let config_file = args.config.clone(); let config: SpisConfig = if let Some(path) = config_file { let content = std::fs::read_to_string(&path) .wrap_err_with(|| format!("Failed to read config file: {}", path.display()))?; toml::from_str(&content) .wrap_err_with(|| format!("Failed to parse config file: {}", path.display()))? } else { SpisConfig::default() }; // Merge logic: Config > Args > Default let dirs = config.dirs.unwrap_or_default(); let media_dir = dirs .media .or(args.media_dir) .unwrap_or_else(|| PathBuf::from("")); let data_dir = dirs .data .or(args.data_dir) .unwrap_or_else(|| PathBuf::from("")); let processing = config.processing.unwrap_or_default(); let processing_schedule = processing .schedule .or(args.processing_schedule) .unwrap_or_else(|| "0 0 2 * * *".to_string()); let processing_run_on_start = processing .run_on_start .or(args.processing_run_on_start) .unwrap_or(false); let api = config.api.unwrap_or_default(); let api_media_path = api .media_path .or(args.api_media_path) .unwrap_or_else(|| "/assets/media".to_string()); let api_thumbnail_path = api .thumbnail_path .or(args.api_thumbnail_path) .unwrap_or_else(|| "/assets/thumbnails".to_string()); // Listener merging let listener_config = config.listener.unwrap_or_default(); let listener = ServerListener { server_address: listener_config.address.or(args.listener.server_address), server_socket: listener_config.socket.or(args.listener.server_socket), }; let features = config.features.unwrap_or_default(); let feature_favorite = features.favorite.or(args.feature_favorite).unwrap_or(true); let feature_archive = features.archive.or(args.feature_archive).unwrap_or(true); let feature_delete_on_archive = features .delete_on_archive .or(args.feature_delete_on_archive) .unwrap_or(false); let feature_follow_symlinks = features .follow_symlinks .or(args.feature_follow_symlinks) .unwrap_or(true); let feature_allow_no_exif = features .allow_no_exif .or(args.feature_allow_no_exif) .unwrap_or(true); let slideshow = config.slideshow.unwrap_or_default(); let slideshow_duration_seconds = slideshow .duration_seconds .or(args.slideshow_duration_seconds) .unwrap_or(5); let custom_commands = config.custom_command.unwrap_or_default(); Ok(Self { media_dir, data_dir, processing_schedule, processing_run_on_start, api_media_path, api_thumbnail_path, listener, feature_favorite, feature_archive, feature_delete_on_archive, feature_follow_symlinks, feature_allow_no_exif, slideshow_duration_seconds, custom_commands, command: args.command, }) } #[must_use] pub fn thumbnail_dir(&self) -> PathBuf { self.data_dir.join("thumbnails") } #[must_use] pub fn db_file(&self) -> PathBuf { self.data_dir.join("spis.db") } } #[allow(clippy::cognitive_complexity)] #[tokio::main] async fn main() -> Result<()> { // Setup logging dotenv::dotenv().ok(); tracing_subscriber::registry() .with(fmt::layer()) .with(EnvFilter::from_default_env()) .init(); color_eyre::install().wrap_err("Failed to install color_eyre")?; // Load config and run let args = SpisArgs::parse(); let config = Spis::resolve(args)?; tracing::debug!("Got config: {:?}", config); match config.command.clone().unwrap_or_default() { SpisCommand::Process { media } => process(config, media).await, SpisCommand::Run => run(config).await, SpisCommand::Template { template } => template_render(config, template), } } async fn process(config: Spis, media: Vec) -> Result<()> { let (file_sender, mut media_receiver) = pipeline::setup_media_processing( config.thumbnail_dir(), config.feature_allow_no_exif, true, ) .wrap_err("Failed to setup media processing")?; let (done_sender, done_receiver) = tokio::sync::oneshot::channel(); tokio::spawn(async move { while let Some(media) = media_receiver.recv().await { println!( "{:?} {:?}", media.path, media.data.expect("Should get data").taken_at ); } done_sender.send(()).expect("send failed"); }); tokio::spawn(async move { for file in media { file_sender.send((None, file)).await.expect("send failed"); } drop(file_sender); }); done_receiver.await.expect("receive failed"); Ok(()) } #[allow(clippy::cognitive_complexity)] async fn run(config: Spis) -> Result<()> { std::fs::create_dir_all(config.thumbnail_dir())?; tracing::info!("Starting spis version {}", env!("CARGO_PKG_VERSION")); tracing::info!("Media dir: {:?}", config.media_dir); tracing::info!("Thumb dir: {:?}", config.thumbnail_dir()); tracing::info!("Data file: {:?}", config.db_file()); tracing::info!("Setting up DB"); let pool = db::setup_db( config .db_file() .to_str() .ok_or_else(|| eyre!("failed to get db path"))?, ) .await .wrap_err("Failed to initialize DB")?; tracing::info!("Setting up media broadcaster"); let (media_events, _) = broadcast::channel(100); tracing::info!("Setting up media processing"); let (file_sender, media_receiver) = pipeline::setup_media_processing( config.thumbnail_dir(), config.feature_allow_no_exif, false, ) .wrap_err("Failed to setup media processing")?; tracing::info!("Setting up file watcher"); let mut file_watcher = pipeline::setup_filewatcher(pool.clone(), file_sender.clone(), media_events.clone()) .wrap_err("Failed to setup file watcher")?; file_watcher .watch(&config.media_dir, notify::RecursiveMode::Recursive) .wrap_err("Failed to start file watcher")?; tracing::info!("Setting up file walker"); let job_sender = pipeline::setup_filewalker( pool.clone(), config.media_dir.clone(), file_sender.clone(), config.feature_follow_symlinks, ) .wrap_err("Failed to setup file walker")?; pipeline::setup_db_store(pool.clone(), media_events.clone(), media_receiver) .wrap_err("Failed to setup db store")?; if config.processing_run_on_start { job_sender .send(JOB_TRIGGER) .await .wrap_err("Failed to trigger job")?; } pipeline::setup_cron(job_sender.clone(), &config.processing_schedule) .wrap_err("Failed to setup cron job")?; validate_listener(&config); let listener = match ( &config.listener.server_address, &config.listener.server_socket, ) { (Some(address), None) => { tracing::info!("Start listening on http://{}", address); Listener::Tcp(TcpListener::bind(address)?) } (None, Some(socket)) => { tracing::info!("Start listening on socket {}", socket); Listener::Socket(socket.clone()) } _ => unreachable!(), }; let pathfinder = (&config).try_into()?; let config = Config { root_path: config .media_dir .to_str() .ok_or_eyre("failed to convert media dir to str")? .to_string(), features: server::Features { archive_allow: config.feature_archive, delete_on_archive: config.feature_delete_on_archive, favorite_allow: config.feature_favorite, slideshow_duration: config.slideshow_duration_seconds, custom_commands: config .custom_commands .into_iter() .map(TryInto::try_into) .collect::, _>>()?, }, pathfinder, }; server::run(listener, pool, media_events, config).await?; Ok(()) } fn template_render(config: Spis, template: SpisTemplate) -> Result<()> { let res = match template { SpisTemplate::Nginx { port, full } => NginxTemplate { config, nginx_port: port, nginx_full: full, } .render()?, SpisTemplate::Systemd { user, bin, log } => SystemdTemplate { config, spis_user: user, spis_bin: bin, spis_log: log, } .render()?, SpisTemplate::DockerCompose { version, log } => DockerComposeTemplate { config, spis_version: version, spis_log: log, } .render()?, }; println!("{res}"); Ok(()) } #[derive(Template)] #[template(path = "config/nginx.conf", escape = "none")] struct NginxTemplate { config: Spis, nginx_port: u32, nginx_full: bool, } #[derive(Template)] #[template(path = "config/systemd.service", escape = "none")] struct SystemdTemplate { config: Spis, spis_user: String, spis_bin: String, spis_log: String, } #[derive(Template)] #[template(path = "config/docker-compose.yml", escape = "none")] struct DockerComposeTemplate { config: Spis, spis_version: String, spis_log: String, }