Added the search functionality

This commit is contained in:
Connor Johnstone
2026-03-13 12:25:34 -04:00
parent aa3b8c4478
commit 3a3290be37
3 changed files with 335 additions and 64 deletions

View File

@@ -76,16 +76,64 @@ pub fn get_local_tracks_for_artist(
rows.collect() rows.collect()
} }
pub fn get_all_artists(conn: &Connection) -> Result<Vec<(String, String)>, rusqlite::Error> { pub fn get_all_artists(conn: &Connection) -> Result<Vec<(String, String, u32)>, rusqlite::Error> {
let mut stmt = conn.prepare( 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 \ LEFT JOIN tracks t ON t.artist_mbid = a.mbid \
GROUP BY a.mbid ORDER BY COUNT(t.path) DESC, a.name", 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() rows.collect()
} }
pub fn get_all_similar_artists(
conn: &Connection,
artist_mbid: &str,
) -> Result<Vec<(String, f64)>, 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<Vec<(String, u64)>, 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( pub fn insert_top_tracks(
conn: &Connection, conn: &Connection,
artist_mbid: &str, artist_mbid: &str,

View File

@@ -36,6 +36,8 @@ enum Command {
/// Music directory to index /// Music directory to index
directory: String, directory: String,
}, },
/// Browse artists, similar artists, and top tracks
Search {},
/// Build a playlist from similar artists /// Build a playlist from similar artists
Build { Build {
/// Verbosity level (-v, -vv) /// Verbosity level (-v, -vv)
@@ -101,6 +103,9 @@ fn main() {
Command::Index { verbose, force, directory } => { Command::Index { verbose, force, directory } => {
cmd_index(verbose, force, &directory); cmd_index(verbose, force, &directory);
} }
Command::Search {} => {
cmd_search();
}
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artists } => { Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artists } => {
let opts = BuildOptions { let opts = BuildOptions {
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity, 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(); let q = query.to_lowercase();
// Tier 1: exact case-insensitive match // Tier 1: exact case-insensitive match
for (mbid, name) in artists { for (mbid, name, _) in artists {
if name.to_lowercase() == q { if name.to_lowercase() == q {
return Some((mbid.clone(), name.clone())); return Some((mbid.clone(), name.clone()));
} }
} }
// Tier 2: contains case-insensitive // Tier 2: contains case-insensitive
for (mbid, name) in artists { for (mbid, name, _) in artists {
if name.to_lowercase().contains(&q) { if name.to_lowercase().contains(&q) {
return Some((mbid.clone(), name.clone())); return Some((mbid.clone(), name.clone()));
} }
} }
// Tier 3: subsequence fuzzy match // Tier 3: subsequence fuzzy match
for (mbid, name) in artists { for (mbid, name, _) in artists {
if tui::fuzzy_match(&q, name) { if tui::fuzzy_match(&q, name) {
return Some((mbid.clone(), name.clone())); return Some((mbid.clone(), name.clone()));
} }
@@ -280,6 +285,27 @@ fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String,
None 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<String>) { fn cmd_build(opts: BuildOptions, artist_args: Vec<String>) {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let conn = db::open(&db_path()).expect("failed to open database"); let conn = db::open(&db_path()).expect("failed to open database");

View File

@@ -1,5 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::Path;
use crossterm::{ use crossterm::{
cursor, cursor,
@@ -24,67 +25,92 @@ pub fn fuzzy_match(query: &str, name: &str) -> bool {
true 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(); let mut stdout = io::stdout();
if terminal::enable_raw_mode().is_err() { if terminal::enable_raw_mode().is_err() {
return Vec::new(); return Vec::new();
} }
let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide); 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 _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode(); 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)> { struct PickerState {
let mut query = String::new(); query: String,
let mut cursor: usize = 0; cursor: usize,
let mut scroll: usize = 0; scroll: usize,
let mut toggled: HashSet<usize> = HashSet::new(); // indices into `artists` }
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<usize> = HashSet::new();
loop { loop {
let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else { 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 { if h < 2 {
continue; continue;
} }
let q = query.to_lowercase(); let q = state.query.to_lowercase();
// Each entry is (original index, &(mbid, name)) let filtered: Vec<(usize, &(String, String, u32))> = if q.is_empty() {
let filtered: Vec<(usize, &(String, String))> = if q.is_empty() {
artists.iter().enumerate().collect() artists.iter().enumerate().collect()
} else { } else {
artists artists
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, (_, name))| fuzzy_match(&q, name)) .filter(|(_, (_, name, _))| fuzzy_match(&q, name))
.collect() .collect()
}; };
if filtered.is_empty() { if filtered.is_empty() {
cursor = 0; state.cursor = 0;
} else { } else {
cursor = cursor.min(filtered.len() - 1); state.cursor = state.cursor.min(filtered.len() - 1);
} }
// Scroll bounds
let list_h = h - 1; let list_h = h - 1;
if cursor < scroll { if state.cursor < state.scroll {
scroll = cursor; state.scroll = state.cursor;
} }
if cursor >= scroll + list_h { if state.cursor >= state.scroll + list_h {
scroll = cursor - list_h + 1; state.scroll = state.cursor - list_h + 1;
} }
// Draw
let _ = execute!(stdout, terminal::Clear(ClearType::All)); let _ = execute!(stdout, terminal::Clear(ClearType::All));
// Prompt line // Prompt line
let prompt = format!(" > {query}"); let prompt = format!(" > {}", state.query);
let sel_str = if toggled.is_empty() { let sel_str = if toggled.is_empty() {
String::new() String::new()
} else { } else {
@@ -101,96 +127,267 @@ fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Vec<(St
// Artist list // Artist list
for i in 0..list_h { for i in 0..list_h {
let idx = scroll + i; let idx = state.scroll + i;
if idx >= filtered.len() { if idx >= filtered.len() {
break; break;
} }
let (orig_idx, (_, name)) = filtered[idx]; let (orig_idx, (_, name, track_count)) = filtered[idx];
let marker = if toggled.contains(&orig_idx) { "*" } else { " " }; let marker = if toggled.contains(&orig_idx) { "*" } else { " " };
let display: String = if name.len() + 2 >= w { let count_suffix = format!(" ({track_count})");
format!("{marker}{}", &name[..w.saturating_sub(2)]) 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 { } else {
format!("{marker} {name}") format!("{marker} {name}")
}; };
let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)); 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!( let _ = execute!(
stdout, stdout,
style::PrintStyledContent(display.as_str().black().on_cyan()), style::PrintStyledContent(padded.as_str().black().on_cyan()),
); );
} else if toggled.contains(&orig_idx) { } else if toggled.contains(&orig_idx) {
let _ = execute!( let _ = execute!(
stdout, stdout,
style::PrintStyledContent(display.as_str().cyan()), style::PrintStyledContent(name_part.as_str().cyan()),
style::PrintStyledContent(count_suffix.as_str().dark_grey()),
); );
} else { } 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(); let _ = stdout.flush();
// Input
let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else { let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
continue; continue;
}; };
match code { match code {
KeyCode::Esc => return Vec::new(), KeyCode::Esc => return PickerResult::Cancelled,
KeyCode::Enter => { KeyCode::Enter => {
if !filtered.is_empty() { if !filtered.is_empty() {
if toggled.is_empty() { if toggled.is_empty() {
// No toggles — return just the highlighted item let (_, (mbid, name, _)) = filtered[state.cursor];
let (_, (mbid, name)) = filtered[cursor]; return PickerResult::Selected(vec![(mbid.clone(), name.clone())]);
return vec![(mbid.clone(), name.clone())];
} else { } else {
// Return all toggled items in original order
let mut indices: Vec<usize> = toggled.into_iter().collect(); let mut indices: Vec<usize> = toggled.into_iter().collect();
indices.sort(); indices.sort();
return indices return PickerResult::Selected(
.into_iter() indices
.map(|i| artists[i].clone()) .into_iter()
.collect(); .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() { if !filtered.is_empty() {
let (orig_idx, _) = filtered[cursor]; let (orig_idx, _) = filtered[state.cursor];
if !toggled.remove(&orig_idx) { if !toggled.remove(&orig_idx) {
toggled.insert(orig_idx); toggled.insert(orig_idx);
} }
// Advance cursor if state.cursor + 1 < filtered.len() {
if cursor + 1 < filtered.len() { state.cursor += 1;
cursor += 1;
} }
} }
} }
KeyCode::Up => { KeyCode::Up => {
cursor = cursor.saturating_sub(1); state.cursor = state.cursor.saturating_sub(1);
} }
KeyCode::Down => { KeyCode::Down => {
if !filtered.is_empty() && cursor + 1 < filtered.len() { if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
cursor += 1; state.cursor += 1;
} }
} }
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => { 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) => { KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
if !filtered.is_empty() && cursor + 1 < filtered.len() { if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
cursor += 1; state.cursor += 1;
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => {
query.pop(); state.query.pop();
cursor = 0; state.cursor = 0;
scroll = 0; state.scroll = 0;
} }
KeyCode::Char(ch) => { KeyCode::Char(ch) => {
query.push(ch); state.query.push(ch);
cursor = 0; state.cursor = 0;
scroll = 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;
}
} }
_ => {} _ => {}
} }