kvasir-cli 0.1.1

Command-line client for the kvasir music library.
mod style;

use std::path::PathBuf;
use std::process::ExitCode;

use clap::{Parser, Subcommand};
use kvasir_core::{acquire, create_session, parse_info, Quality, Session};
use style::{banner, error, format_seconds, info, note, pending, success, warn};

#[derive(Parser)]
#[command(
    name = env!("CARGO_BIN_NAME"),
    about = "Look up, resolve, and fetch music through kvasir.",
    after_help = "Quality is 128, 320, or flac. A bare 1, 3, or 9 still works."
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Show the Deezer account behind DEEZER_ARL.
    Whoami,
    /// Search the Deezer gateway.
    Search { query: String },
    /// Resolve a Deezer, Spotify, Tidal, or YouTube URL to Deezer tracks.
    Resolve { url: String },
    /// Download, decrypt, inspect, and tag one Deezer track.
    Acquire {
        track_id: String,
        /// 128, 320, flac, or the numeric shorthand 1, 3, 9.
        #[arg(long, default_value = "128")]
        quality: String,
        /// Write the tagged audio here. Omit to print a summary only.
        #[arg(long)]
        out: Option<PathBuf>,
    },
}

#[tokio::main]
async fn main() -> ExitCode {
    println!("{}", banner());
    match run(Cli::parse()).await {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("{}", error(&err.to_string()));
            if err.to_string().contains("Missing DEEZER_ARL") {
                eprintln!("{}", note("Set the 192-character Deezer arl cookie in the environment or in .env."));
            }
            ExitCode::from(1)
        }
    }
}

async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
    let session = open_session().await?;
    match cli.command {
        Command::Whoami => whoami(&session).await?,
        Command::Search { query } => search(&session, &query).await?,
        Command::Resolve { url } => resolve(&session, &url).await?,
        Command::Acquire { track_id, quality, out } => acquire_track(&session, &track_id, &quality, out).await?,
    }
    Ok(())
}

async fn open_session() -> Result<Session, Box<dyn std::error::Error>> {
    let arl = std::env::var("DEEZER_ARL").map_err(|_| "Missing DEEZER_ARL")?;
    println!("{}", pending("opening a Deezer session"));
    Ok(create_session(Some(arl.trim())).await?)
}

async fn whoami(session: &Session) -> Result<(), Box<dyn std::error::Error>> {
    let user = session.get_user().await?;
    let name = field(&user, "BLOG_NAME");
    let shown = if name.is_empty() { "your account".into() } else { name };
    println!("{}", success(&format!("Logged in as {shown}")));
    println!(
        "{}",
        note(&format!("id {} · {}", field(&user, "USER_ID"), field(&user, "COUNTRY")))
    );
    Ok(())
}

async fn search(session: &Session, query: &str) -> Result<(), Box<dyn std::error::Error>> {
    let result = session.search_music(query, &["TRACK"], 5).await?;
    let tracks = result.pointer("/TRACK/data").and_then(|value| value.as_array()).cloned().unwrap_or_default();
    if tracks.is_empty() {
        println!("{}", warn("Nothing to show."));
        return Ok(());
    }
    println!("{}", success(&format!("{} track(s)", tracks.len())));
    for track in tracks {
        let title = track.get("SNG_TITLE").and_then(|value| value.as_str()).unwrap_or("Unknown");
        let artist = track.get("ART_NAME").and_then(|value| value.as_str()).unwrap_or("Unknown");
        let album = track.get("ALB_TITLE").and_then(|value| value.as_str()).unwrap_or("Unknown");
        let seconds = json_id(track.get("DURATION")).parse::<u64>().unwrap_or(0);
        println!("  {title} — {artist}");
        println!("{}", note(&format!("Album: {album} · {}", format_seconds(seconds))));
    }
    Ok(())
}

async fn resolve(session: &Session, url: &str) -> Result<(), Box<dyn std::error::Error>> {
    println!("{}", pending(&format!("resolving {url}")));
    let parsed = parse_info(session, url).await?;
    let count = parsed.tracks.len();
    let label = if count == 1 { "track" } else { "tracks" };
    println!("{}", info(&format!("{} {}", parsed.info.kind, parsed.info.id)));
    println!("{}", success(&format!("{count} {label}")));
    for track in parsed.tracks.iter().take(8) {
        println!("  {} · {} — {}", track.sng_id(), track.artist_name(), track.title());
    }
    if count > 8 {
        println!("{}", note(&format!("{} more", count - 8)));
    }
    Ok(())
}

async fn acquire_track(
    session: &Session,
    track_id: &str,
    quality: &str,
    out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
    let quality = parse_quality(quality)?;
    println!("{}", pending(&format!("fetching {track_id} at {}", quality.format_name())));
    let acquired = acquire(session, track_id, quality).await?;
    let tagged = if acquired.tagged { "tagged" } else { "untagged" };
    println!(
        "{}",
        success(&format!(
            "{} — {} · {} · {tagged}",
            acquired.catalogue.title,
            acquired.analysis.format.id.as_str(),
            bytes_label(acquired.bytes.len())
        ))
    );
    if acquired.issues.is_empty() {
        println!("{}", note("catalogue and bytes agree"));
    } else {
        println!("{}", warn(&format!("{} disagreement(s)", acquired.issues.len())));
        for issue in &acquired.issues {
            println!("{}", note(&format!("{issue:?}")));
        }
    }
    if let Some(path) = out {
        std::fs::write(&path, &acquired.bytes)?;
        println!("{}", success(&format!("wrote {}", path.display())));
    }
    Ok(())
}

fn parse_quality(value: &str) -> Result<Quality, Box<dyn std::error::Error>> {
    match value.trim().to_ascii_lowercase().as_str() {
        "128" | "mp3" | "mp3_128" => return Ok(Quality::Mp3_128),
        "320" | "mp3_320" => return Ok(Quality::Mp3_320),
        "flac" | "lossless" => return Ok(Quality::Flac),
        _ => {}
    }
    if let Ok(code) = value.parse::<i64>() {
        return Ok(Quality::from_code(code)?);
    }
    Ok(Quality::parse(value))
}

fn bytes_label(len: usize) -> String {
    if len >= 1024 * 1024 {
        format!("{:.1} MB", len as f64 / (1024.0 * 1024.0))
    } else if len >= 1024 {
        format!("{} KB", len / 1024)
    } else {
        format!("{len} bytes")
    }
}

fn field(value: &serde_json::Value, key: &str) -> String {
    match value.get(key) {
        Some(serde_json::Value::String(text)) => text.clone(),
        Some(serde_json::Value::Number(number)) => number.to_string(),
        _ => String::new(),
    }
}

fn json_id(value: Option<&serde_json::Value>) -> String {
    match value {
        Some(serde_json::Value::String(text)) => text.clone(),
        Some(serde_json::Value::Number(number)) => number.to_string(),
        _ => String::new(),
    }
}