rustifydl 0.2.41

A fast, no-fuss Spotify downloader built in Rust.
Documentation
use clap::Parser;
use log::{error, info};
use regex::Regex;
use rustifydl::{DownloadOptions, download_spotify};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;

#[derive(Deserialize, Serialize)]
struct Config {
    client_id: String,
    client_secret: String,
}

#[derive(Parser, Clone)]
pub struct Cli {
    pub url: String,

    #[arg(long = "client-id")]
    pub client_id: Option<String>,

    #[arg(long = "client-secret")]
    pub client_secret: Option<String>,

    #[arg(long = "output-dir", short, default_value = "./output")]
    pub output_dir: String,

    #[arg(long = "concurrent-downloads", short, default_value_t = 15)]
    pub concurrent_downloads: usize,

    #[arg(long = "no-dupes", action = clap::ArgAction::SetTrue)]
    pub no_dupes: bool,

    #[arg(
        long = "bitrate",
        short,
        default_value = "192k",
        value_parser = clap::builder::PossibleValuesParser::new([
            "8k", "16k", "24k", "32k", "40k", "48k", "64k", "80k", "96k", "112k", "128k", "160k", "192k", "224k", "256k", "320k"
        ])
    )]
    pub bitrate: String,

    #[arg(
        long = "format",
        short,
        default_value = "mp3",
        value_parser = clap::builder::PossibleValuesParser::new([
            "mp3", "flac", "ogg", "opus", "m4a", "wav"
        ])
    )]
    pub format: String,

    #[arg(
        long = "verbosity",
        short,
        default_value = "info",
        value_parser = clap::builder::PossibleValuesParser::new([
            "info", "debug", "error", "none", "full", "no-bars"
        ])
    )]
    pub verbosity: String,

    #[arg(long = "no-tag", action = clap::ArgAction::SetTrue)]
    pub no_tag: bool,

    #[arg(long = "ytdlp-dir", short, default_value = "")]
    pub ytdlp_dir: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let args = Cli::parse();
    let (client_id, client_secret) =
        if let (Some(id), Some(secret)) = (args.client_id, args.client_secret) {
            (id, secret)
        } else {
            let config = check_api_keys().await?;
            (config.client_id, config.client_secret)
        };

    let options = DownloadOptions {
        url: args.url,
        client_id,
        client_secret,
        output_dir: args.output_dir,
        concurrent_downloads: args.concurrent_downloads,
        no_dupes: args.no_dupes,
        bitrate: args.bitrate,
        format: args.format,
        verbosity: args.verbosity,
        no_tag: args.no_tag,
    };
    download_spotify(options, args.ytdlp_dir).await?;
    Ok(())
}

async fn check_api_keys() -> Result<Config, Box<dyn std::error::Error + Send + Sync>> {
    let config_dir = dirs::config_dir().ok_or("Could not find a valid config directory.")?;

    let app_config_dir = config_dir.join("RustifyDL");
    fs::create_dir_all(&app_config_dir)?;

    let config_path = app_config_dir.join("config.toml");
    let mut client_id = String::new();
    let mut client_secret = String::new();
    if config_path.exists() && config_path.is_file() && fs::metadata(&config_path)?.len() != 0 {
        let content = fs::read_to_string(&config_path)?;
        let keys = match toml::from_str::<Config>(&content) {
            Ok(parsed_keys) => parsed_keys,
            Err(e) => {
                error!("Malformed config file: {e}");
                fs::remove_file(&config_path)?;
                info!("The malformed config file has been deleted. Please re-enter your keys.");
                loop {
                    print!("Enter Client ID: ");
                    std::io::stdout().flush()?;
                    std::io::stdin().read_line(&mut client_id)?;
                    if verify_key(client_id.trim()) {
                        break;
                    }
                    println!("Invalid Client ID. It must be a 32-character hexadecimal string.");
                }
                loop {
                    print!("Enter Client Secret: ");
                    std::io::stdout().flush()?;
                    client_secret.clear();
                    std::io::stdin().read_line(&mut client_secret)?;
                    if verify_key(client_secret.trim()) {
                        break;
                    }
                    println!(
                        "Invalid Client Secret. It must be a 32-character hexadecimal string."
                    );
                }
                let keys = Config {
                    client_id: client_id.trim().to_string(),
                    client_secret: client_secret.trim().to_string(),
                };

                let value = toml::to_string(&keys)?;
                fs::write(&config_path, value)?;
                println!("Configuration saved to: {}", config_path.display());

                return Ok(keys);
            }
        };
        if keys.client_id.trim().is_empty() {
            eprint!("Client id is empty or missing!");
            loop {
                print!("Enter Client Secret: ");
                std::io::stdout().flush()?;
                client_secret.clear();
                std::io::stdin().read_line(&mut client_secret)?;
                if verify_key(client_secret.trim()) {
                    break;
                }
                println!("Invalid Client ID. It must be a 32-character hexadecimal string.");
                let fixed_keys = Config {
                    client_id: client_id.clone(),
                    client_secret: keys.client_secret.clone(),
                };
                let value = toml::to_string(&fixed_keys)?;
                fs::write(&config_path, value)?;
            }
        }
        if keys.client_secret.trim().is_empty() {
            eprint!("Client id is empty or missing!");
            loop {
                print!("Enter Client ID: ");
                std::io::stdout().flush()?;
                client_id.clear();
                std::io::stdin().read_line(&mut client_id)?;
                if verify_key(client_id.trim()) {
                    break;
                }
                println!("Invalid Client ID. It must be a 32-character hexadecimal string.");
                let fixed_keys = Config {
                    client_id: keys.client_id.clone(),
                    client_secret: client_secret.clone(),
                };
                let value = toml::to_string(&fixed_keys)?;
                fs::write(&config_path, value)?;
            }
        }

        if verify_key(keys.client_id.trim()) && verify_key(keys.client_secret.trim()) {
            Ok(keys)
        } else {
            Err(Box::new(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "Keys are not valid! Check the config file!",
            )))
        }
    } else {
        println!(
            "No config file found or keys are empty, please enter the Spotify API keys:\n If you don't have them, here is a guide: https://developer.spotify.com/documentation/web-api"
        );
        loop {
            print!("Enter Client ID: ");
            std::io::stdout().flush()?;
            client_id.clear();
            std::io::stdin().read_line(&mut client_id)?;
            if verify_key(client_id.trim()) {
                break;
            }
            println!("Invalid Client ID. It must be a 32-character hexadecimal string.");
        }
        loop {
            print!("Enter Client Secret: ");
            std::io::stdout().flush()?;
            client_secret.clear();
            std::io::stdin().read_line(&mut client_secret)?;
            if verify_key(client_secret.trim()) {
                break;
            }
            println!("Invalid Client Secret. It must be a 32-character hexadecimal string.");
        }
        let keys = Config {
            client_id: client_id.trim().to_string(),
            client_secret: client_secret.trim().to_string(),
        };

        let value = toml::to_string(&keys)?;
        fs::write(&config_path, value)?;
        println!("Configuration saved to: {}", config_path.display());

        Ok(keys)
    }
}

fn verify_key(key: &str) -> bool {
    let re = Regex::new(r"^[[:xdigit:]]{32}$").unwrap();
    re.is_match(key)
}