diff --git a/Cargo.lock b/Cargo.lock index 35b604c..9bda948 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "base64" version = "0.22.1" @@ -48,6 +98,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "crc32fast" version = "1.5.0" @@ -179,6 +275,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -195,6 +297,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -308,6 +416,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -353,6 +467,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "playlists" version = "0.1.0" dependencies = [ + "clap", "crossterm", "dotenvy", "lofty", @@ -619,6 +734,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -689,6 +810,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 8e8ab38..984a82a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ rand = "0.9" walkdir = "2.5" crossterm = "0.28" urlencoding = "2.1.3" +clap = { version = "4", features = ["derive"] } diff --git a/src/db.rs b/src/db.rs index 61d65be..ede036d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,6 +2,12 @@ use rusqlite::Connection; use crate::lastfm::{SimilarArtist, TopTrack}; +/// A local track: (path, recording_mbid, title). +pub type LocalTrack = (String, Option, Option); + +/// Track metadata: (recording_mbid, title). +pub type TrackMetadata = (Option, Option); + pub fn open(path: &std::path::Path) -> Result { let conn = Connection::open(path)?; conn.execute_batch( @@ -62,7 +68,7 @@ pub fn get_available_similar_artists( pub fn get_local_tracks_for_artist( conn: &Connection, artist_mbid: &str, -) -> Result, Option)>, rusqlite::Error> { +) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1", )?; @@ -114,7 +120,7 @@ pub fn get_top_tracks_by_name( rows.collect() } -pub fn get_track_metadata(conn: &Connection, path: &str) -> Result, Option)>, rusqlite::Error> { +pub fn get_track_metadata(conn: &Connection, path: &str) -> Result, rusqlite::Error> { conn.query_row( "SELECT recording_mbid, title FROM tracks WHERE path = ?1", [path], diff --git a/src/lastfm.rs b/src/lastfm.rs index 953dbaf..d7203f4 100644 --- a/src/lastfm.rs +++ b/src/lastfm.rs @@ -16,15 +16,14 @@ pub struct TopTrack { pub name: String, pub mbid: Option, pub playcount: u64, - pub listeners: u64, } -// Last.fm returns {"error": N, "message": "..."} on failure +// Last.fm returns {"error": N, "message": "..."} on failure. +// Only used to detect error responses via serde — fields aren't read directly. #[derive(Deserialize)] struct ApiError { #[allow(dead_code)] error: u32, - message: String, } // Deserialization structs for the Last.fm API responses @@ -62,7 +61,6 @@ struct TrackEntry { name: String, mbid: Option, playcount: String, - listeners: String, } impl LastfmClient { @@ -87,14 +85,9 @@ impl LastfmClient { extra_params: &str, ) -> Result, Box> { if let Some(name) = artist_name { - let name = name.replace('\u{2010}', "-") - .replace('\u{2011}', "-") - .replace('\u{2012}', "-") - .replace('\u{2013}', "-") - .replace('\u{2014}', "-") - .replace('\u{2015}', "-") - .replace('\u{2018}', "'") - .replace('\u{2019}', "'"); + let name = name + .replace(['\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}'], "-") + .replace(['\u{2018}', '\u{2019}'], "'"); let encoded = urlencoding::encode(&name); let url = format!( "{}?method={}&artist={}&api_key={}{}&format=json", @@ -163,7 +156,6 @@ impl LastfmClient { name: t.name, mbid: t.mbid.filter(|s| !s.is_empty()), playcount: t.playcount.parse().unwrap_or(0), - listeners: t.listeners.parse().unwrap_or(0), }) .collect()) } diff --git a/src/main.rs b/src/main.rs index 0651048..fe27b05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,76 @@ use std::collections::HashMap; use std::env; use std::path::{Path, PathBuf}; +use clap::{Parser, Subcommand}; use rand::distr::weighted::WeightedIndex; use rand::prelude::*; +#[derive(Parser)] +#[command(name = "playlists")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Index a music directory + Index { + /// Print progress + #[arg(short)] + verbose: bool, + /// Re-index already indexed artists + #[arg(short)] + force: bool, + /// Music directory to index + directory: String, + }, + /// Build a playlist from similar artists + Build { + /// Print track scores + #[arg(short)] + verbose: bool, + /// Queue in MPD + #[arg(short, conflicts_with = "airsonic")] + mpd: bool, + /// Create Airsonic playlist + #[arg(short, conflicts_with = "mpd")] + airsonic: bool, + /// Interleave artists evenly + #[arg(short, conflicts_with = "random")] + shuffle: bool, + /// Fully randomize order + #[arg(short, conflicts_with = "shuffle")] + random: bool, + /// Number of tracks + #[arg(short = 'n', default_value_t = 20, value_parser = parse_positive_usize)] + count: usize, + /// Popularity bias (0=no preference, 10=heavy popular bias) + #[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))] + popularity: u8, + /// Seed file (or pick interactively) + file: Option, + }, +} + +struct BuildOptions { + verbose: bool, + mpd: bool, + airsonic: bool, + shuffle: bool, + random: bool, + count: usize, + popularity_bias: u8, +} + +fn parse_positive_usize(s: &str) -> Result { + let n: usize = s.parse().map_err(|e| format!("{e}"))?; + if n == 0 { + return Err("value must be greater than 0".to_string()); + } + Ok(n) +} + fn db_path() -> PathBuf { let data_dir = env::var("XDG_DATA_HOME") .map(PathBuf::from) @@ -25,37 +92,23 @@ fn db_path() -> PathBuf { dir.join("playlists.db") } -fn usage(program: &str) -> ! { - eprintln!("Usage:"); - eprintln!(" {program} index [-v] [-f] "); - eprintln!(" {program} build [-v] [-m|-a] [-s|-r] [-n COUNT] [-p LEVEL] [file]"); - std::process::exit(1); -} - fn main() { - let args: Vec = env::args().collect(); + let cli = Cli::parse(); - if args.len() < 2 { - usage(&args[0]); - } - - match args[1].as_str() { - "index" => cmd_index(&args), - "build" => cmd_build(&args), - _ => usage(&args[0]), + match cli.command { + Command::Index { verbose, force, directory } => { + cmd_index(verbose, force, &directory); + } + Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, file } => { + let opts = BuildOptions { + verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity, + }; + cmd_build(opts, file.as_deref()); + } } } -fn cmd_index(args: &[String]) { - let verbose = args.iter().any(|a| a == "-v"); - let force = args.iter().any(|a| a == "-f"); - let rest: Vec<&String> = args.iter().skip(2).filter(|a| *a != "-v" && *a != "-f").collect(); - - if rest.len() != 1 { - eprintln!("Usage: {} index [-v] [-f] ", args[0]); - std::process::exit(1); - } - +fn cmd_index(verbose: bool, force: bool, directory: &str) { dotenvy::dotenv().ok(); let api_key = env::var("LASTFM_API_KEY").unwrap_or_default(); @@ -66,7 +119,7 @@ fn cmd_index(args: &[String]) { let conn = db::open(&db_path()).expect("failed to open database"); let lastfm = lastfm::LastfmClient::new(api_key); - let dir = Path::new(rest[0].as_str()); + let dir = Path::new(directory); for path in filesystem::walk_music_files(dir) { let artist_mbid = match metadata::read_artist_mbid(&path) { @@ -137,74 +190,12 @@ fn cmd_index(args: &[String]) { } } -fn cmd_build(args: &[String]) { - let verbose = args.iter().any(|a| a == "-v"); - let mpd = args.iter().any(|a| a == "-m"); - let airsonic = args.iter().any(|a| a == "-a"); - let shuffle = args.iter().any(|a| a == "-s"); - let random = args.iter().any(|a| a == "-r"); - - if shuffle && random { - eprintln!("Error: -s and -r are mutually exclusive"); - std::process::exit(1); - } - - if mpd && airsonic { - eprintln!("Error: -m and -a are mutually exclusive"); - std::process::exit(1); - } - - // Parse -n COUNT and -p LEVEL - let mut count: usize = 20; - let mut popularity_bias: u8 = 5; - let mut rest: Vec<&String> = Vec::new(); - let mut iter = args.iter().skip(2); - while let Some(arg) = iter.next() { - if arg == "-v" || arg == "-m" || arg == "-a" || arg == "-s" || arg == "-r" { - continue; - } else if arg == "-n" { - match iter.next() { - Some(val) => match val.parse::() { - Ok(n) if n > 0 => count = n, - _ => { - eprintln!("Error: -n requires a positive integer"); - std::process::exit(1); - } - }, - None => { - eprintln!("Error: -n requires a value"); - std::process::exit(1); - } - } - } else if arg == "-p" { - match iter.next() { - Some(val) => match val.parse::() { - Ok(n) if n <= 10 => popularity_bias = n, - _ => { - eprintln!("Error: -p requires an integer 0-10"); - std::process::exit(1); - } - }, - None => { - eprintln!("Error: -p requires a value"); - std::process::exit(1); - } - } - } else { - rest.push(arg); - } - } - - if rest.len() > 1 { - eprintln!("Usage: {} build [-v] [-m|-a] [-s|-r] [-n COUNT] [-p LEVEL] [file]", args[0]); - std::process::exit(1); - } - +fn cmd_build(opts: BuildOptions, file: Option<&str>) { dotenvy::dotenv().ok(); let conn = db::open(&db_path()).expect("failed to open database"); - let (artist_mbid, seed_name) = if let Some(file_arg) = rest.first() { - let path = Path::new(file_arg.as_str()); + let (artist_mbid, seed_name) = if let Some(file_arg) = file { + let path = Path::new(file_arg); let mbid = match metadata::read_artist_mbid(path) { Ok(Some(mbid)) => mbid, Ok(None) => { @@ -239,7 +230,7 @@ fn cmd_build(args: &[String]) { } }; - build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, airsonic, shuffle, random, popularity_bias); + build_playlist(&conn, &artist_mbid, &seed_name, &opts); } const POPULARITY_EXPONENTS: [f64; 11] = [ @@ -250,13 +241,7 @@ fn build_playlist( conn: &rusqlite::Connection, artist_mbid: &str, seed_name: &str, - count: usize, - verbose: bool, - mpd: bool, - airsonic: bool, - shuffle: bool, - random: bool, - popularity_bias: u8, + opts: &BuildOptions, ) { let similar = match db::get_available_similar_artists(conn, artist_mbid) { Ok(a) => a, @@ -322,7 +307,7 @@ fn build_playlist( let Some(playcount) = playcount else { continue }; let popularity = if playcount > 0 { - (playcount as f64 / max_playcount as f64).powf(POPULARITY_EXPONENTS[popularity_bias as usize]) + (playcount as f64 / max_playcount as f64).powf(POPULARITY_EXPONENTS[opts.popularity_bias as usize]) } else { 0.0 }; @@ -333,7 +318,7 @@ fn build_playlist( } } - if verbose { + if opts.verbose { let mut sorted = playlist.clone(); sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); for (total, popularity, similarity, artist, track_path) in &sorted { @@ -347,17 +332,17 @@ fn build_playlist( .map(|(total, _, _, artist, path)| (total, artist, path)) .collect(); - let mut selected = generate_playlist(&candidates, count, seed_name); + let mut selected = generate_playlist(&candidates, opts.count, seed_name); - if random { + if opts.random { selected.shuffle(&mut rand::rng()); - } else if shuffle { + } else if opts.shuffle { selected = interleave_artists(selected); } let tracks: Vec = selected.iter().map(|(_, _, p)| p.clone()).collect(); - if mpd { + if opts.mpd { let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default(); if music_dir.is_empty() { eprintln!("Error: MPD_MUSIC_DIR not set"); @@ -371,7 +356,7 @@ fn build_playlist( } }; client.queue_playlist(&tracks, &music_dir); - } else if airsonic { + } else if opts.airsonic { let client = match airsonic::AirsonicClient::new() { Ok(c) => c, Err(e) => { @@ -379,7 +364,7 @@ fn build_playlist( std::process::exit(1); } }; - if let Err(e) = client.create_playlist(seed_name, &tracks, conn, verbose) { + if let Err(e) = client.create_playlist(seed_name, &tracks, conn, opts.verbose) { eprintln!("Airsonic error: {e}"); std::process::exit(1); } @@ -422,7 +407,7 @@ fn generate_playlist( 5 => 4, _ => 5, }; - let artist_cap = ((n + divisor - 1) / divisor).max(1); + let artist_cap = n.div_ceil(divisor).max(1); while result.len() < n && !pool.is_empty() { let seed_count = *artist_counts.get(seed_name).unwrap_or(&0); diff --git a/src/metadata.rs b/src/metadata.rs index cb0f57b..1a8ca3f 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,39 +1,7 @@ use std::path::Path; use lofty::file::TaggedFileExt; -use lofty::tag::{ItemKey, ItemValue}; - -/// A single key-value metadata item from a tag. -pub struct TagEntry { - pub key: String, - pub value: String, -} - -/// Read all metadata items from a music file. -/// Returns `None` if no tags are present, otherwise a list of all tag entries. -pub fn read_all_metadata(path: &Path) -> Result>, lofty::error::LoftyError> { - let tagged_file = lofty::read_from_path(path)?; - - let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else { - return Ok(None); - }; - - let entries = tag - .items() - .filter_map(|item| { - let value = match item.value() { - ItemValue::Text(t) | ItemValue::Locator(t) => t.clone(), - ItemValue::Binary(b) => format!("<{} bytes>", b.len()), - }; - Some(TagEntry { - key: format!("{:?}", item.key()), - value, - }) - }) - .collect(); - - Ok(Some(entries)) -} +use lofty::tag::ItemKey; /// Extract the artist name from a music file. pub fn read_artist_name(path: &Path) -> Result, lofty::error::LoftyError> {