lr2-oxytabler 0.9.1

Table manager for Lunatic Rave 2
// Windows: don't spawn a console.
#![cfg_attr(not(test), windows_subsystem = "windows")]

use crate::{output::OutputFolder, output::OutputFolderKey, time::UnixEpochTs};
use anyhow::{Context as _, Result, ensure};
use std::path::{Path, PathBuf};

mod changelog;
mod cli_update;
mod db;
mod fetch;
mod gui;
mod gui_dummy;
mod lr2folder;
mod mass_edit;
mod migrations;
mod output;
mod table;
mod time;
mod url;

pub(crate) fn build_reqwest() -> Result<reqwest::Client> {
    const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
    reqwest::ClientBuilder::new()
        // jounin.jp, where new overjoy and insane 2 tables are hosted, blocks requests with no
        // user-agent.
        .user_agent(APP_USER_AGENT)
        .build()
        .context("failed to build reqwest client")
}

pub(crate) fn open_db(
    path: &Path,
    outputs: &[OutputFolder],
) -> Result<(rusqlite::Connection, Vec<table::Table>)> {
    fn validate_good_outputs(outputs: &[OutputFolder]) -> Result<()> {
        if let Some(p) = OutputFolder::is_any_parent_of_another(outputs) {
            anyhow::bail!(
                "intersecting output paths found: '{}:{}' and '{}:{}'",
                p.0.0,
                p.0.1.display(),
                p.1.0,
                p.1.1.display()
            );
        }

        for OutputFolder(_key, path) in outputs {
            lr2folder::validate_good_playlists_folder(path)
                .context("bad playlists folder selected")?;
        }

        Ok(())
    }

    fn validate_outputs_for_tables(
        tables: &[table::Table],
        outputs: &[OutputFolder],
    ) -> Result<()> {
        for table::Table(table, add_data) in tables {
            ensure!(
                outputs.iter().any(|o| o.0 == add_data.output),
                "missing --output '{}' required for table '{}'",
                add_data.output.0,
                table.name,
            );
        }
        Ok(())
    }

    validate_good_outputs(outputs)?;
    let (db, tables) = db::open_from_file(path)?;
    validate_outputs_for_tables(&tables, outputs)?;
    Ok((db, tables))
}

pub(crate) fn save_db(
    db: &mut rusqlite::Connection,
    outputs: &[OutputFolder],
    tables: &mut Vec<table::Table>,
    write_tags: bool,
) -> Result<()> {
    table::Table::commit_removals(tables);
    let tx = db
        .transaction()
        .context("failed to start transaction for updating db")?;
    db::save_db(&tx, tables)?;
    if write_tags {
        db::update_tags_inplace(&tx)?;
    }
    lr2folder::write_files(outputs, tables)?;
    tx.commit()
        .context("failed to commit after writing playlists")?;
    Ok(())
}

fn main() -> Result<()> {
    const HELP: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "
Usage:
  gui:
    --db <path>                           Path to song.db
    --output <key:path>                   Folders to write playlists to (multiple allowed)
    --save-on-update-finish <true/false>  Enable or disable saving on update completion (default: 'true')
    --write-tags <true/false>             Enable or disable writing tags (default: 'false')
  update:
    --db <path>                Path to song.db
    --output <key:path>        Folders to write playlists to (multiple allowed)
    --write-tags <true/false>  Enable or disable tag writing (default: 'false')");

    fn fixup_outputs(mut outputs: Vec<OutputFolder>) -> Vec<OutputFolder> {
        // Only keep the last output path specified for key.
        outputs.reverse();
        outputs.sort_by(|a, b| a.0.0.cmp(&b.0.0)); // stable sort
        outputs.dedup_by(|a, b| a.0.0 == b.0.0);
        outputs
    }

    env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or("lr2_oxytabler=info"),
    )
    .init();

    let mut args = pico_args::Arguments::from_env();

    if args.contains("--help") {
        println!("{HELP}");
        return Ok(());
    }

    match args.subcommand()?.as_deref() {
        Some("gui") => {
            let app = (|| {
                let db = args.value_from_str::<_, PathBuf>("--db")?;
                let outputs = fixup_outputs(args.values_from_str::<_, OutputFolder>("--output")?);
                let write_tags_on_save = args
                    .opt_value_from_str::<_, bool>("--write-tags")?
                    .unwrap_or(false);
                let save_on_update_finish = args
                    .opt_value_from_str::<_, bool>("--save-on-update-finish")?
                    .unwrap_or(true);
                let left = args.finish();
                ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
                gui::App::new(&db, outputs, save_on_update_finish, write_tags_on_save)
            })();
            match app {
                Ok(app) => app.run(),
                Err(e) => {
                    let e = format!("{e:#}");
                    log::error!("{e}");
                    gui_dummy::run(e)
                }
            }
        }
        Some("update") => {
            let db = args.value_from_str::<_, PathBuf>("--db")?;
            let outputs = fixup_outputs(args.values_from_str::<_, OutputFolder>("--output")?);
            let write_tags = args
                .opt_value_from_str::<_, bool>("--write-tags")?
                .unwrap_or(false);
            let left = args.finish();
            ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
            cli_update::run(&db, &outputs, write_tags)
        }
        Some(cmd) => anyhow::bail!("invalid cmd: {cmd}"),
        None => {
            let left = args.finish();
            ensure!(left.is_empty(), "Unsupported arguments: {left:?}");
            log::warn!("hint: see --help:\n\n{HELP}");
            gui_dummy::run(HELP.to_string())
        }
    }
}