Compare commits
2 Commits
aa3b8c4478
...
04b15a651d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b15a651d | ||
|
|
3a3290be37 |
@@ -1,226 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fuzzy search artists in drift.db and show their similar artists."""
|
|
||||||
|
|
||||||
import curses
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def find_db():
|
|
||||||
"""Find drift.db in XDG data dir."""
|
|
||||||
data_home = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
|
|
||||||
p = Path(data_home) / "drift" / "drift.db"
|
|
||||||
if p.exists():
|
|
||||||
return str(p)
|
|
||||||
print(f"Could not find {p}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def load_artists(db_path):
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT a.mbid, COALESCE(a.name, a.mbid) 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"
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
return rows # [(mbid, display_name), ...]
|
|
||||||
|
|
||||||
|
|
||||||
def get_similar(db_path, mbid):
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT similar_name, match_score FROM similar_artists "
|
|
||||||
"WHERE artist_mbid = ?1 ORDER BY match_score DESC",
|
|
||||||
(mbid,),
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def get_top_tracks(db_path, mbid):
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
rows = conn.execute(
|
|
||||||
"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",
|
|
||||||
(mbid,),
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def get_local_track_count(db_path, mbid):
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
total = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM tracks WHERE artist_mbid = ?1", (mbid,)
|
|
||||||
).fetchone()[0]
|
|
||||||
matched = conn.execute(
|
|
||||||
"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",
|
|
||||||
(mbid,),
|
|
||||||
).fetchone()[0]
|
|
||||||
conn.close()
|
|
||||||
return total, matched
|
|
||||||
|
|
||||||
|
|
||||||
def fuzzy_match(query, name):
|
|
||||||
"""Simple fuzzy: all query chars appear in order in name."""
|
|
||||||
name_lower = name.lower()
|
|
||||||
qi = 0
|
|
||||||
for ch in name_lower:
|
|
||||||
if qi < len(query) and ch == query[qi]:
|
|
||||||
qi += 1
|
|
||||||
return qi == len(query)
|
|
||||||
|
|
||||||
|
|
||||||
def run_tui(stdscr, db_path):
|
|
||||||
curses.curs_set(0)
|
|
||||||
curses.use_default_colors()
|
|
||||||
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
||||||
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
|
||||||
curses.init_pair(3, curses.COLOR_WHITE, -1)
|
|
||||||
|
|
||||||
artists = load_artists(db_path)
|
|
||||||
query = ""
|
|
||||||
selected = 0
|
|
||||||
scroll = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
stdscr.erase()
|
|
||||||
h, w = stdscr.getmaxyx()
|
|
||||||
|
|
||||||
# filter
|
|
||||||
q = query.lower()
|
|
||||||
if q:
|
|
||||||
filtered = [(m, n) for m, n in artists if fuzzy_match(q, n)]
|
|
||||||
else:
|
|
||||||
filtered = artists
|
|
||||||
|
|
||||||
selected = max(0, min(selected, len(filtered) - 1))
|
|
||||||
|
|
||||||
# prompt
|
|
||||||
prompt = f" > {query}"
|
|
||||||
stdscr.addnstr(0, 0, prompt, w, curses.color_pair(2) | curses.A_BOLD)
|
|
||||||
count_str = f" {len(filtered)}/{len(artists)}"
|
|
||||||
if len(prompt) + len(count_str) < w:
|
|
||||||
stdscr.addstr(0, len(prompt), count_str, curses.color_pair(3))
|
|
||||||
|
|
||||||
# artist list
|
|
||||||
list_h = h - 1
|
|
||||||
if list_h < 1:
|
|
||||||
stdscr.refresh()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if selected < scroll:
|
|
||||||
scroll = selected
|
|
||||||
if selected >= scroll + list_h:
|
|
||||||
scroll = selected - list_h + 1
|
|
||||||
|
|
||||||
for i in range(list_h):
|
|
||||||
idx = scroll + i
|
|
||||||
if idx >= len(filtered):
|
|
||||||
break
|
|
||||||
_, name = filtered[idx]
|
|
||||||
attr = curses.color_pair(1) if idx == selected else curses.A_NORMAL
|
|
||||||
stdscr.addnstr(i + 1, 0, f" {name}", w, attr)
|
|
||||||
|
|
||||||
stdscr.refresh()
|
|
||||||
|
|
||||||
key = stdscr.get_wch()
|
|
||||||
|
|
||||||
if key == "\x1b": # Esc
|
|
||||||
return
|
|
||||||
elif key == curses.KEY_UP or key == "\x10": # Up / Ctrl-P
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == curses.KEY_DOWN or key == "\x0e": # Down / Ctrl-N
|
|
||||||
selected = min(len(filtered) - 1, selected + 1)
|
|
||||||
elif key == "\n" or key == curses.KEY_ENTER:
|
|
||||||
if filtered:
|
|
||||||
show_similar(stdscr, db_path, filtered[selected])
|
|
||||||
elif key in (curses.KEY_BACKSPACE, "\x7f", "\x08"):
|
|
||||||
query = query[:-1]
|
|
||||||
selected = 0
|
|
||||||
scroll = 0
|
|
||||||
elif isinstance(key, str) and key.isprintable():
|
|
||||||
query += key
|
|
||||||
selected = 0
|
|
||||||
scroll = 0
|
|
||||||
|
|
||||||
|
|
||||||
def show_similar(stdscr, db_path, artist):
|
|
||||||
mbid, name = artist
|
|
||||||
similar = get_similar(db_path, mbid)
|
|
||||||
top = get_top_tracks(db_path, mbid)
|
|
||||||
total_local, matched_local = get_local_track_count(db_path, mbid)
|
|
||||||
|
|
||||||
curses.curs_set(0)
|
|
||||||
stdscr.erase()
|
|
||||||
h, w = stdscr.getmaxyx()
|
|
||||||
|
|
||||||
mid = w // 2
|
|
||||||
|
|
||||||
title_l = f" Similar to {name}"
|
|
||||||
title_r = f" Top tracks ({matched_local}/{total_local} matched)"
|
|
||||||
stdscr.addnstr(0, 0, title_l, mid, curses.color_pair(2) | curses.A_BOLD)
|
|
||||||
stdscr.addnstr(0, mid, title_r, w - mid, curses.color_pair(2) | curses.A_BOLD)
|
|
||||||
stdscr.addnstr(h - 1, 0, " [q] back", w, curses.color_pair(3))
|
|
||||||
|
|
||||||
scroll_l = 0
|
|
||||||
scroll_r = 0
|
|
||||||
list_h = h - 2
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# Left pane: similar artists
|
|
||||||
for i in range(list_h):
|
|
||||||
stdscr.move(i + 1, 0)
|
|
||||||
stdscr.clrtoeol()
|
|
||||||
idx = scroll_l + i
|
|
||||||
if idx < len(similar):
|
|
||||||
sname, score = similar[idx]
|
|
||||||
line = f" {score:5.2f} {sname}"
|
|
||||||
stdscr.addnstr(i + 1, 0, line, mid)
|
|
||||||
|
|
||||||
# Right pane: top tracks
|
|
||||||
for i in range(list_h):
|
|
||||||
idx = scroll_r + i
|
|
||||||
if idx < len(top):
|
|
||||||
path, playcount = top[idx]
|
|
||||||
# Show just the filename without extension
|
|
||||||
fname = Path(path).stem
|
|
||||||
# Strip "Artist - " prefix if present
|
|
||||||
if " - " in fname:
|
|
||||||
fname = fname.split(" - ", 1)[1]
|
|
||||||
line = f" {playcount:>8} {fname}"
|
|
||||||
stdscr.addnstr(i + 1, mid, line, w - mid)
|
|
||||||
|
|
||||||
stdscr.refresh()
|
|
||||||
key = stdscr.get_wch()
|
|
||||||
|
|
||||||
if key in ("q", "Q", "\x1b"):
|
|
||||||
return
|
|
||||||
elif key == curses.KEY_UP or key == "\x10":
|
|
||||||
scroll_l = max(0, scroll_l - 1)
|
|
||||||
scroll_r = max(0, scroll_r - 1)
|
|
||||||
elif key == curses.KEY_DOWN or key == "\x0e":
|
|
||||||
if scroll_l + list_h < len(similar):
|
|
||||||
scroll_l += 1
|
|
||||||
if scroll_r + list_h < len(top):
|
|
||||||
scroll_r += 1
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
db_path = find_db()
|
|
||||||
curses.wrapper(run_tui, db_path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
54
src/db.rs
54
src/db.rs
@@ -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,
|
||||||
|
|||||||
34
src/main.rs
34
src/main.rs
@@ -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");
|
||||||
|
|||||||
311
src/tui.rs
311
src/tui.rs
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user