Added TUI
This commit is contained in:
@@ -61,6 +61,14 @@ pub fn get_local_tracks_for_artist(
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_all_artists(conn: &Connection) -> Result<Vec<(String, String)>, 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,
|
||||
|
||||
76
src/main.rs
76
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] <directory>");
|
||||
eprintln!(" {program} build [-v] [-n COUNT] <file>");
|
||||
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] <file>", 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}");
|
||||
|
||||
159
src/tui.rs
Normal file
159
src/tui.rs
Normal file
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user