From 3a3290be37161720e61df14851bf2240f61d82f7 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Fri, 13 Mar 2026 12:25:34 -0400 Subject: [PATCH] Added the search functionality --- src/db.rs | 54 ++++++++- src/main.rs | 34 +++++- src/tui.rs | 311 ++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 335 insertions(+), 64 deletions(-) diff --git a/src/db.rs b/src/db.rs index 975a18d..0777d59 100644 --- a/src/db.rs +++ b/src/db.rs @@ -76,16 +76,64 @@ pub fn get_local_tracks_for_artist( rows.collect() } -pub fn get_all_artists(conn: &Connection) -> Result, rusqlite::Error> { +pub fn get_all_artists(conn: &Connection) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT a.mbid, COALESCE(a.name, a.mbid) FROM artists a \ + "SELECT a.mbid, COALESCE(a.name, a.mbid), COUNT(t.path) FROM artists a \ LEFT JOIN tracks t ON t.artist_mbid = a.mbid \ GROUP BY a.mbid ORDER BY COUNT(t.path) DESC, a.name", )?; - let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; rows.collect() } +pub fn get_all_similar_artists( + conn: &Connection, + artist_mbid: &str, +) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT similar_name, match_score FROM similar_artists \ + WHERE artist_mbid = ?1 ORDER BY match_score DESC", + )?; + let rows = stmt.query_map([artist_mbid], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect() +} + +pub fn get_local_top_tracks( + conn: &Connection, + artist_mbid: &str, +) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT t.path, tt.playcount FROM tracks t \ + JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid \ + AND (LOWER(t.title) = tt.name_lower OR t.recording_mbid = tt.recording_mbid) \ + WHERE t.artist_mbid = ?1 ORDER BY tt.playcount DESC", + )?; + let rows = stmt.query_map([artist_mbid], |row| { + Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)) + })?; + rows.collect() +} + +pub fn get_local_track_count( + conn: &Connection, + artist_mbid: &str, +) -> Result<(u32, u32), rusqlite::Error> { + let total: u32 = conn.query_row( + "SELECT COUNT(*) FROM tracks WHERE artist_mbid = ?1", + [artist_mbid], + |row| row.get(0), + )?; + let matched: u32 = conn.query_row( + "SELECT COUNT(*) FROM tracks t \ + JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid \ + AND (LOWER(t.title) = tt.name_lower OR t.recording_mbid = tt.recording_mbid) \ + WHERE t.artist_mbid = ?1", + [artist_mbid], + |row| row.get(0), + )?; + Ok((total, matched)) +} + pub fn insert_top_tracks( conn: &Connection, artist_mbid: &str, diff --git a/src/main.rs b/src/main.rs index c2ef2f5..f797ece 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,8 @@ enum Command { /// Music directory to index directory: String, }, + /// Browse artists, similar artists, and top tracks + Search {}, /// Build a playlist from similar artists Build { /// Verbosity level (-v, -vv) @@ -101,6 +103,9 @@ fn main() { Command::Index { verbose, force, directory } => { cmd_index(verbose, force, &directory); } + Command::Search {} => { + cmd_search(); + } Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artists } => { let opts = BuildOptions { verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity, @@ -253,25 +258,25 @@ fn cmd_index(verbose: u8, force: bool, directory: &str) { } } -fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String, String)> { +fn resolve_artist(artists: &[(String, String, u32)], query: &str) -> Option<(String, String)> { let q = query.to_lowercase(); // Tier 1: exact case-insensitive match - for (mbid, name) in artists { + for (mbid, name, _) in artists { if name.to_lowercase() == q { return Some((mbid.clone(), name.clone())); } } // Tier 2: contains case-insensitive - for (mbid, name) in artists { + for (mbid, name, _) in artists { if name.to_lowercase().contains(&q) { return Some((mbid.clone(), name.clone())); } } // Tier 3: subsequence fuzzy match - for (mbid, name) in artists { + for (mbid, name, _) in artists { if tui::fuzzy_match(&q, name) { return Some((mbid.clone(), name.clone())); } @@ -280,6 +285,27 @@ fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String, None } +fn cmd_search() { + let conn = db::open(&db_path()).expect("failed to open database"); + let all_artists = match db::get_all_artists(&conn) { + Ok(a) => a, + Err(e) => { + eprintln!("DB error: {e}"); + std::process::exit(1); + } + }; + if all_artists.is_empty() { + eprintln!("No artists in database. Run 'index' first."); + std::process::exit(1); + } + tui::run_search_flow(&all_artists, |mbid| { + let similar = db::get_all_similar_artists(&conn, mbid).unwrap_or_default(); + let top = db::get_local_top_tracks(&conn, mbid).unwrap_or_default(); + let counts = db::get_local_track_count(&conn, mbid).unwrap_or((0, 0)); + (similar, top, counts) + }); +} + fn cmd_build(opts: BuildOptions, artist_args: Vec) { dotenvy::dotenv().ok(); let conn = db::open(&db_path()).expect("failed to open database"); diff --git a/src/tui.rs b/src/tui.rs index d5e48d6..b0d8f20 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; use std::io::{self, Write}; +use std::path::Path; use crossterm::{ cursor, @@ -24,67 +25,92 @@ pub fn fuzzy_match(query: &str, name: &str) -> bool { true } -pub fn run_artist_picker(artists: &[(String, String)]) -> Vec<(String, String)> { +enum PickerResult { + Selected(Vec<(String, String)>), + Cancelled, +} + +pub fn run_artist_picker(artists: &[(String, String, u32)]) -> Vec<(String, String)> { let mut stdout = io::stdout(); if terminal::enable_raw_mode().is_err() { return Vec::new(); } let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide); - let result = picker_loop(&mut stdout, artists); + let result = picker_loop(&mut stdout, artists, true); let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen); let _ = terminal::disable_raw_mode(); - result + match result { + PickerResult::Selected(v) => v, + PickerResult::Cancelled => Vec::new(), + } } -fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Vec<(String, String)> { - let mut query = String::new(); - let mut cursor: usize = 0; - let mut scroll: usize = 0; - let mut toggled: HashSet = HashSet::new(); // indices into `artists` +struct PickerState { + query: String, + cursor: usize, + scroll: usize, +} + +fn picker_loop( + stdout: &mut io::Stdout, + artists: &[(String, String, u32)], + allow_toggle: bool, +) -> PickerResult { + picker_loop_with_state(stdout, artists, allow_toggle, &mut PickerState { + query: String::new(), + cursor: 0, + scroll: 0, + }) +} + +fn picker_loop_with_state( + stdout: &mut io::Stdout, + artists: &[(String, String, u32)], + allow_toggle: bool, + state: &mut PickerState, +) -> PickerResult { + let mut toggled: HashSet = HashSet::new(); loop { let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else { - return Vec::new(); + return PickerResult::Cancelled; }; if h < 2 { continue; } - let q = query.to_lowercase(); - // Each entry is (original index, &(mbid, name)) - let filtered: Vec<(usize, &(String, String))> = if q.is_empty() { + let q = state.query.to_lowercase(); + let filtered: Vec<(usize, &(String, String, u32))> = if q.is_empty() { artists.iter().enumerate().collect() } else { artists .iter() .enumerate() - .filter(|(_, (_, name))| fuzzy_match(&q, name)) + .filter(|(_, (_, name, _))| fuzzy_match(&q, name)) .collect() }; if filtered.is_empty() { - cursor = 0; + state.cursor = 0; } else { - cursor = cursor.min(filtered.len() - 1); + state.cursor = state.cursor.min(filtered.len() - 1); } - // Scroll bounds let list_h = h - 1; - if cursor < scroll { - scroll = cursor; + if state.cursor < state.scroll { + state.scroll = state.cursor; } - if cursor >= scroll + list_h { - scroll = cursor - list_h + 1; + if state.cursor >= state.scroll + list_h { + state.scroll = state.cursor - list_h + 1; } - // Draw let _ = execute!(stdout, terminal::Clear(ClearType::All)); // Prompt line - let prompt = format!(" > {query}"); + let prompt = format!(" > {}", state.query); let sel_str = if toggled.is_empty() { String::new() } else { @@ -101,96 +127,267 @@ fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Vec<(St // Artist list for i in 0..list_h { - let idx = scroll + i; + let idx = state.scroll + i; if idx >= filtered.len() { break; } - let (orig_idx, (_, name)) = filtered[idx]; + let (orig_idx, (_, name, track_count)) = filtered[idx]; let marker = if toggled.contains(&orig_idx) { "*" } else { " " }; - let display: String = if name.len() + 2 >= w { - format!("{marker}{}", &name[..w.saturating_sub(2)]) + let count_suffix = format!(" ({track_count})"); + let name_part: String = if name.len() + 2 + count_suffix.len() >= w { + let max = w.saturating_sub(2 + count_suffix.len()); + format!("{marker} {}", &name[..max.min(name.len())]) } else { format!("{marker} {name}") }; let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)); - if idx == cursor { + if idx == state.cursor { + // Render name part highlighted, then count in dark_grey on_cyan + let padded = format!("{name_part}{count_suffix}"); let _ = execute!( stdout, - style::PrintStyledContent(display.as_str().black().on_cyan()), + style::PrintStyledContent(padded.as_str().black().on_cyan()), ); } else if toggled.contains(&orig_idx) { let _ = execute!( stdout, - style::PrintStyledContent(display.as_str().cyan()), + style::PrintStyledContent(name_part.as_str().cyan()), + style::PrintStyledContent(count_suffix.as_str().dark_grey()), ); } else { - let _ = execute!(stdout, style::Print(&display)); + let _ = execute!( + stdout, + style::Print(&name_part), + style::PrintStyledContent(count_suffix.as_str().dark_grey()), + ); } } let _ = stdout.flush(); - // Input let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else { continue; }; match code { - KeyCode::Esc => return Vec::new(), + KeyCode::Esc => return PickerResult::Cancelled, KeyCode::Enter => { if !filtered.is_empty() { if toggled.is_empty() { - // No toggles — return just the highlighted item - let (_, (mbid, name)) = filtered[cursor]; - return vec![(mbid.clone(), name.clone())]; + let (_, (mbid, name, _)) = filtered[state.cursor]; + return PickerResult::Selected(vec![(mbid.clone(), name.clone())]); } else { - // Return all toggled items in original order let mut indices: Vec = toggled.into_iter().collect(); indices.sort(); - return indices - .into_iter() - .map(|i| artists[i].clone()) - .collect(); + return PickerResult::Selected( + indices + .into_iter() + .map(|i| { + let (ref mbid, ref name, _) = artists[i]; + (mbid.clone(), name.clone()) + }) + .collect(), + ); } } } - KeyCode::Tab => { + KeyCode::Tab if allow_toggle => { if !filtered.is_empty() { - let (orig_idx, _) = filtered[cursor]; + let (orig_idx, _) = filtered[state.cursor]; if !toggled.remove(&orig_idx) { toggled.insert(orig_idx); } - // Advance cursor - if cursor + 1 < filtered.len() { - cursor += 1; + if state.cursor + 1 < filtered.len() { + state.cursor += 1; } } } KeyCode::Up => { - cursor = cursor.saturating_sub(1); + state.cursor = state.cursor.saturating_sub(1); } KeyCode::Down => { - if !filtered.is_empty() && cursor + 1 < filtered.len() { - cursor += 1; + if !filtered.is_empty() && state.cursor + 1 < filtered.len() { + state.cursor += 1; } } KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => { - cursor = cursor.saturating_sub(1); + state.cursor = state.cursor.saturating_sub(1); } KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { - if !filtered.is_empty() && cursor + 1 < filtered.len() { - cursor += 1; + if !filtered.is_empty() && state.cursor + 1 < filtered.len() { + state.cursor += 1; } } KeyCode::Backspace => { - query.pop(); - cursor = 0; - scroll = 0; + state.query.pop(); + state.cursor = 0; + state.scroll = 0; } KeyCode::Char(ch) => { - query.push(ch); - cursor = 0; - scroll = 0; + state.query.push(ch); + state.cursor = 0; + state.scroll = 0; + } + _ => {} + } + } +} + +pub fn run_search_flow( + artists: &[(String, String, u32)], + get_detail: impl Fn(&str) -> (Vec<(String, f64)>, Vec<(String, u64)>, (u32, u32)), +) { + let mut stdout = io::stdout(); + if terminal::enable_raw_mode().is_err() { + return; + } + let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide); + + let mut picker_state = PickerState { + query: String::new(), + cursor: 0, + scroll: 0, + }; + + loop { + let result = picker_loop_with_state(&mut stdout, artists, false, &mut picker_state); + match result { + PickerResult::Cancelled => break, + PickerResult::Selected(selected) => { + if let Some((mbid, name)) = selected.into_iter().next() { + let (similar, top_tracks, counts) = get_detail(&mbid); + detail_view(&mut stdout, &name, &similar, &top_tracks, counts); + } + } + } + } + + let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); +} + +fn track_display_name(path: &str) -> String { + let stem = Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(path); + // Strip "Artist - " prefix if present + if let Some(pos) = stem.find(" - ") { + stem[pos + 3..].to_string() + } else { + stem.to_string() + } +} + +fn detail_view( + stdout: &mut io::Stdout, + name: &str, + similar: &[(String, f64)], + top_tracks: &[(String, u64)], + (total, matched): (u32, u32), +) { + let mut scroll: usize = 0; + + loop { + let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else { + return; + }; + if h < 3 { + continue; + } + + let _ = execute!(stdout, terminal::Clear(ClearType::All)); + + let mid = w / 2; + let content_h = h - 2; // header + footer + + // Header + let left_header = format!(" Similar to {name}"); + let right_header = format!(" Top tracks ({matched}/{total} matched)"); + let _ = execute!( + stdout, + cursor::MoveTo(0, 0), + style::PrintStyledContent(left_header.as_str().cyan().bold()), + cursor::MoveTo(mid as u16, 0), + style::PrintStyledContent(right_header.as_str().cyan().bold()), + ); + + // Left pane: similar artists + for i in 0..content_h { + let idx = scroll + i; + if idx >= similar.len() { + break; + } + let (ref sim_name, score) = similar[idx]; + let line = format!(" {score:5.2} {sim_name}"); + let truncated: String = if line.len() > mid.saturating_sub(1) { + line[..mid.saturating_sub(1)].to_string() + } else { + line + }; + let _ = execute!( + stdout, + cursor::MoveTo(0, (i + 1) as u16), + style::Print(truncated), + ); + } + + // Right pane: top tracks + for i in 0..content_h { + let idx = scroll + i; + if idx >= top_tracks.len() { + break; + } + let (ref path, playcount) = top_tracks[idx]; + let display = track_display_name(path); + let count_str = format!(" {playcount}"); + let max_name = (w - mid).saturating_sub(count_str.len() + 3); + let truncated_name: String = if display.len() > max_name { + display[..max_name].to_string() + } else { + display + }; + let line = format!(" {truncated_name}{count_str}"); + let _ = execute!( + stdout, + cursor::MoveTo(mid as u16, (i + 1) as u16), + style::Print(line), + ); + } + + // Footer + let footer = " [q] back"; + let _ = execute!( + stdout, + cursor::MoveTo(0, (h - 1) as u16), + style::PrintStyledContent(footer.dark_grey()), + ); + + let _ = stdout.flush(); + + let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else { + continue; + }; + + match code { + KeyCode::Char('q') | KeyCode::Esc => return, + KeyCode::Up => { + scroll = scroll.saturating_sub(1); + } + KeyCode::Down => { + let max_items = similar.len().max(top_tracks.len()); + if max_items > 0 && scroll + content_h < max_items { + scroll += 1; + } + } + KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => { + scroll = scroll.saturating_sub(1); + } + KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { + let max_items = similar.len().max(top_tracks.len()); + if max_items > 0 && scroll + content_h < max_items { + scroll += 1; + } } _ => {} }