Ctrl K
ComunidadGuías…

src/main.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<PathBuf, String> {
    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<PathBuf>,

    /// Path to search for media files
    #[clap(long, env = "SPIS_MEDIA_DIR")]
    pub media_dir: Option<PathBuf>,

    /// Path to store data
    #[clap(long, env = "SPIS_DATA_DIR")]
    pub data_dir: Option<PathBuf>,

    /// Schedule to run processing on (default: "0 0 2 * * *")
    #[clap(long, env = "SPIS_PROCESSING_SCHEDULE")]
    pub processing_schedule: Option<String>,

    /// Run processing on start (default: false)
    #[clap(long, env = "SPIS_PROCESSING_RUN_ON_START", action = ArgAction::SetTrue)]
    pub processing_run_on_start: Option<bool>,

    /// Path webserver will serve media on (default: "/assets/media")
    #[clap(long, env = "SPIS_API_MEDIA_PATH")]
    pub api_media_path: Option<String>,

    /// Path webserver will serve thumbnails on (default: "/assets/thumbnails")
    #[clap(long, env = "SPIS_API_THUMBNAIL_PATH")]
    pub api_thumbnail_path: Option<String>,

    #[command(flatten)]
    pub listener: ServerListener,

    /// Disable feature favorite (default: true)
    #[clap(long, env = "SPIS_FEATURE_FAVORITE", action=ArgAction::SetFalse)]
    pub feature_favorite: Option<bool>,

    /// Disable feature archive (default: true)
    #[clap(long, env = "SPIS_FEATURE_ARCHIVE", action=ArgAction::SetFalse)]
    pub feature_archive: Option<bool>,

    /// Enable delete on archive (default: false)
    #[clap(long, env = "SPIS_FEATURE_DELETE_ON_ARCHIVE", action=ArgAction::SetTrue)]
    pub feature_delete_on_archive: Option<bool>,

    /// Disable feature follow symlinks (default: true)
    #[clap(long, env = "SPIS_FEATURE_FOLLOW_SYMLINKS", action=ArgAction::SetFalse)]
    pub feature_follow_symlinks: Option<bool>,

    /// Disable feature no exif (default: true)
    #[clap(long, env = "SPIS_FEATURE_NO_EXIF", action=ArgAction::SetFalse)]
    pub feature_allow_no_exif: Option<bool>,

    /// Slideshow duration in seconds (default: 5)
    #[clap(long, env = "SPIS_SLIDESHOW_DURATION_SECONDS")]
    pub slideshow_duration_seconds: Option<usize>,

    #[command(subcommand)]
    command: Option<SpisCommand>,
}

#[derive(Deserialize, Debug, Default)]
pub struct SpisConfig {
    pub dirs: Option<DirsConfig>,
    pub processing: Option<ProcessingConfig>,
    pub api: Option<ApiConfig>,
    pub listener: Option<ListenerConfig>,
    pub features: Option<FeaturesConfig>,
    pub slideshow: Option<SlideshowConfig>,
    pub custom_command: Option<Vec<CustomCommandConfig>>,
}

#[derive(Deserialize, Debug, Default, Clone)]
pub struct CustomCommandConfig {
    pub name: String,
    pub cmd: Vec<String>,
    pub fa_icon: String,
    pub hotkey: Option<char>,
}

impl TryFrom<CustomCommandConfig> for CustomCommand {
    type Error = Error;

    fn try_from(value: CustomCommandConfig) -> std::result::Result<Self, Self::Error> {
        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<PathBuf>,
    pub data: Option<PathBuf>,
}

#[derive(Deserialize, Debug, Default)]
pub struct ProcessingConfig {
    pub schedule: Option<String>,
    pub run_on_start: Option<bool>,
}

#[derive(Deserialize, Debug, Default)]
pub struct ApiConfig {
    pub media_path: Option<String>,
    pub thumbnail_path: Option<String>,
}

#[derive(Deserialize, Debug, Default)]
pub struct ListenerConfig {
    pub address: Option<String>,
    pub socket: Option<String>,
}

#[derive(Deserialize, Debug, Default)]
pub struct FeaturesConfig {
    pub favorite: Option<bool>,
    pub archive: Option<bool>,
    pub delete_on_archive: Option<bool>,
    pub follow_symlinks: Option<bool>,
    pub allow_no_exif: Option<bool>,
}

#[derive(Deserialize, Debug, Default)]
pub struct SlideshowConfig {
    pub duration_seconds: Option<usize>,
}

#[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<CustomCommandConfig>,
    pub command: Option<SpisCommand>,
}

#[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<PathBuf>,
    },

    /// 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<String>,

    /// Listen to UNIX socket
    #[clap(long, group = "addr", env = "SPIS_SERVER_SOCKET")]
    pub server_socket: Option<String>,
}

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 <SERVER_ADDRESS>' or '--server-socket <SERVER_SOCKET>'",
        )
        .exit();
        }
    }
    ""
}

impl TryFrom<&Spis> for PathFinder {
    type Error = Error;

    fn try_from(value: &Spis) -> Result<Self, Self::Error> {
        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<Self> {
        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<PathBuf>) -> 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::<Result<Vec<_>, _>>()?,
        },
        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,
}