From 59d0674d7782d8d538fb747e7758e01f646e47ba Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Mon, 2 Mar 2026 23:08:13 -0500 Subject: [PATCH] Added TUI --- Cargo.lock | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/db.rs | 8 +++ src/main.rs | 76 ++++++++++++++++-------- src/tui.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 23 deletions(-) create mode 100644 src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 1f12eb5..62c6f74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -69,6 +94,16 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -183,6 +218,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lofty" version = "0.23.2" @@ -231,6 +281,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "ogg_pager" version = "0.7.1" @@ -246,6 +308,29 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -268,6 +353,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "playlists" version = "0.1.0" dependencies = [ + "crossterm", "dotenvy", "lofty", "rand", @@ -340,6 +426,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ring" version = "0.17.14" @@ -368,6 +463,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.37" @@ -412,6 +520,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -461,6 +575,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -577,6 +722,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -586,6 +747,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0ecef9f..022c8d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,4 @@ rusqlite = { version = "0.34", features = ["bundled"] } ureq = "3" rand = "0.9" walkdir = "2.5" +crossterm = "0.28" diff --git a/src/db.rs b/src/db.rs index f5b534a..b380603 100644 --- a/src/db.rs +++ b/src/db.rs @@ -61,6 +61,14 @@ pub fn get_local_tracks_for_artist( rows.collect() } +pub fn get_all_artists(conn: &Connection) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT mbid, COALESCE(name, mbid) FROM artists ORDER BY name", + )?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect() +} + pub fn insert_track( conn: &Connection, path: &str, diff --git a/src/main.rs b/src/main.rs index 090bdd8..9ba6043 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod db; mod filesystem; mod lastfm; mod metadata; +mod tui; use std::collections::HashMap; use std::env; @@ -13,7 +14,7 @@ use rand::prelude::*; fn usage(program: &str) -> ! { eprintln!("Usage:"); eprintln!(" {program} index [-v] "); - eprintln!(" {program} build [-v] [-n COUNT] "); + eprintln!(" {program} build [-v] [-n COUNT] [file]"); std::process::exit(1); } @@ -137,24 +138,11 @@ fn cmd_build(args: &[String]) { } } - if rest.len() != 1 { - eprintln!("Usage: {} build [-v] [-n COUNT] ", args[0]); + if rest.len() > 1 { + eprintln!("Usage: {} build [-v] [-n COUNT] [file]", args[0]); std::process::exit(1); } - let path = Path::new(rest[0].as_str()); - let artist_mbid = match metadata::read_artist_mbid(path) { - Ok(Some(mbid)) => mbid, - Ok(None) => { - eprintln!("{}: no artist MBID found", path.display()); - std::process::exit(1); - } - Err(e) => { - eprintln!("{}: could not read artist MBID: {e}", path.display()); - std::process::exit(1); - } - }; - dotenvy::dotenv().ok(); let api_key = env::var("LASTFM_API_KEY").unwrap_or_default(); if api_key.is_empty() { @@ -165,12 +153,54 @@ fn cmd_build(args: &[String]) { let conn = db::open("playlists.db").expect("failed to open database"); let lastfm = lastfm::LastfmClient::new(api_key); - let seed_name = metadata::read_artist_name(path) - .ok() - .flatten() - .unwrap_or_else(|| artist_mbid.clone()); + let (artist_mbid, seed_name) = if let Some(file_arg) = rest.first() { + let path = Path::new(file_arg.as_str()); + let mbid = match metadata::read_artist_mbid(path) { + Ok(Some(mbid)) => mbid, + Ok(None) => { + eprintln!("{}: no artist MBID found", path.display()); + std::process::exit(1); + } + Err(e) => { + eprintln!("{}: could not read artist MBID: {e}", path.display()); + std::process::exit(1); + } + }; + let name = metadata::read_artist_name(path) + .ok() + .flatten() + .unwrap_or_else(|| mbid.clone()); + (mbid, name) + } else { + let artists = match db::get_all_artists(&conn) { + Ok(a) => a, + Err(e) => { + eprintln!("DB error: {e}"); + std::process::exit(1); + } + }; + if artists.is_empty() { + eprintln!("No artists in database. Run 'index' first."); + std::process::exit(1); + } + match tui::run_artist_picker(&artists) { + Some(selection) => selection, + None => std::process::exit(0), + } + }; - let similar = match db::get_available_similar_artists(&conn, &artist_mbid) { + build_playlist(&conn, &lastfm, &artist_mbid, &seed_name, count, verbose); +} + +fn build_playlist( + conn: &rusqlite::Connection, + lastfm: &lastfm::LastfmClient, + artist_mbid: &str, + seed_name: &str, + count: usize, + verbose: bool, +) { + let similar = match db::get_available_similar_artists(conn, artist_mbid) { Ok(a) => a, Err(e) => { eprintln!("DB error: {e}"); @@ -180,7 +210,7 @@ fn cmd_build(args: &[String]) { // Seed artist + similar artists: (mbid, name, match_score) let mut artists: Vec<(String, String, f64)> = vec![ - (artist_mbid.clone(), seed_name, 1.0), + (artist_mbid.to_string(), seed_name.to_string(), 1.0), ]; artists.extend(similar); @@ -188,7 +218,7 @@ fn cmd_build(args: &[String]) { let mut playlist: Vec<(f64, f64, f64, String, String)> = Vec::new(); for (mbid, name, match_score) in &artists { - let local_tracks = match db::get_local_tracks_for_artist(&conn, mbid) { + let local_tracks = match db::get_local_tracks_for_artist(conn, mbid) { Ok(t) => t, Err(e) => { eprintln!("DB error for {name}: {e}"); diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..d74c53b --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,159 @@ +use std::io::{self, Write}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + style::{self, Stylize}, + terminal::{self, ClearType}, +}; + +fn fuzzy_match(query: &str, name: &str) -> bool { + let name_lower = name.to_lowercase(); + let mut chars = name_lower.chars(); + for qch in query.chars() { + loop { + match chars.next() { + Some(ch) if ch == qch => break, + Some(_) => continue, + None => return false, + } + } + } + true +} + +pub fn run_artist_picker(artists: &[(String, String)]) -> Option<(String, String)> { + let mut stdout = io::stdout(); + terminal::enable_raw_mode().ok()?; + execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).ok()?; + + let result = picker_loop(&mut stdout, artists); + + execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).ok(); + terminal::disable_raw_mode().ok(); + + result +} + +fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<(String, String)> { + let mut query = String::new(); + let mut selected: usize = 0; + let mut scroll: usize = 0; + + loop { + let (w, h) = terminal::size().ok()?; + let w = w as usize; + let h = h as usize; + if h < 2 { + continue; + } + + let q = query.to_lowercase(); + let filtered: Vec<&(String, String)> = if q.is_empty() { + artists.iter().collect() + } else { + artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect() + }; + + if filtered.is_empty() { + selected = 0; + } else { + selected = selected.min(filtered.len() - 1); + } + + // Scroll bounds + let list_h = h - 1; + if selected < scroll { + scroll = selected; + } + if selected >= scroll + list_h { + scroll = selected - list_h + 1; + } + + // Draw + execute!(stdout, terminal::Clear(ClearType::All)).ok(); + + // Prompt line + let prompt = format!(" > {query}"); + let count_str = format!(" {}/{}", filtered.len(), artists.len()); + execute!( + stdout, + cursor::MoveTo(0, 0), + style::PrintStyledContent(prompt.as_str().cyan().bold()), + style::PrintStyledContent(count_str.as_str().dark_grey()), + ) + .ok(); + + // Artist list + for i in 0..list_h { + let idx = scroll + i; + if idx >= filtered.len() { + break; + } + let (_, name) = filtered[idx]; + let display: String = if name.len() >= w { + name[..w].to_string() + } else { + format!(" {name}") + }; + execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok(); + if idx == selected { + execute!( + stdout, + style::PrintStyledContent(display.as_str().black().on_cyan()), + ) + .ok(); + } else { + execute!(stdout, style::Print(&display)).ok(); + } + } + + stdout.flush().ok(); + + // Input + let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else { + continue; + }; + + match code { + KeyCode::Esc => return None, + KeyCode::Enter => { + if !filtered.is_empty() { + let (mbid, name) = filtered[selected]; + return Some((mbid.clone(), name.clone())); + } + } + KeyCode::Up => { + selected = selected.saturating_sub(1); + } + KeyCode::Down => { + if !filtered.is_empty() { + selected = selected.min(filtered.len().saturating_sub(1)); + if selected + 1 < filtered.len() { + selected += 1; + } + } + } + KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => { + selected = selected.saturating_sub(1); + } + KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { + if !filtered.is_empty() && selected + 1 < filtered.len() { + selected += 1; + } + } + KeyCode::Backspace => { + query.pop(); + selected = 0; + scroll = 0; + } + KeyCode::Char(ch) => { + query.push(ch); + selected = 0; + scroll = 0; + } + _ => {} + } + } +}