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()
}
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(
"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<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(
conn: &Connection,
artist_mbid: &str,

View File

@@ -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<String>) {
dotenvy::dotenv().ok();
let conn = db::open(&db_path()).expect("failed to open database");

View File

@@ -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<usize> = 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<usize> = 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<usize> = 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;
}
}
_ => {}
}