Compare commits

...

12 Commits

Author SHA1 Message Date
Connor Johnstone
04b15a651d Removed the old script 2026-03-13 12:25:58 -04:00
Connor Johnstone
3a3290be37 Added the search functionality 2026-03-13 12:25:34 -04:00
Connor Johnstone
aa3b8c4478 Improved verbosity on build 2026-03-05 13:40:16 -05:00
Connor Johnstone
552caff9b8 Fix to tui and multi-artist 2026-03-05 13:33:05 -05:00
Connor Johnstone
38dea156d4 Added cli multi-artist 2026-03-05 13:26:23 -05:00
Connor Johnstone
0c45d8957a Fixed up the indexing a bit 2026-03-05 12:44:53 -05:00
Connor Johnstone
2ffdce4fbc Added exact artist matching 2026-03-05 11:31:53 -05:00
Connor Johnstone
70aedb49f2 Some code cleanup 2026-03-04 23:28:29 -05:00
Connor Johnstone
d59235707d Clippy fixes 2026-03-04 23:13:40 -05:00
Connor Johnstone
8eb6bb950e Added a popularity adjuster 2026-03-04 23:03:26 -05:00
Connor Johnstone
98e3367822 Added basic airsonic support 2026-03-03 14:56:00 -05:00
Connor Johnstone
51ededc612 Search for both mbid and name 2026-03-03 13:45:52 -05:00
12 changed files with 1437 additions and 809 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
/target
.env
playlists.db
drift.db

159
Cargo.lock generated
View File

@@ -8,6 +8,56 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -48,6 +98,52 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -94,6 +190,23 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "drift"
version = "0.1.0"
dependencies = [
"clap",
"crossterm",
"dotenvy",
"lofty",
"rand",
"rusqlite",
"serde",
"serde_json",
"ureq",
"urlencoding",
"walkdir",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -179,6 +292,12 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
@@ -195,6 +314,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
@@ -308,6 +433,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -349,22 +480,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "playlists"
version = "0.1.0"
dependencies = [
"crossterm",
"dotenvy",
"lofty",
"rand",
"rusqlite",
"serde",
"serde_json",
"ureq",
"urlencoding",
"walkdir",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -619,6 +734,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -689,6 +810,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"

View File

@@ -1,5 +1,5 @@
[package]
name = "playlists"
name = "drift"
version = "0.1.0"
edition = "2024"
@@ -14,3 +14,4 @@ rand = "0.9"
walkdir = "2.5"
crossterm = "0.28"
urlencoding = "2.1.3"
clap = { version = "4", features = ["derive"] }

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# drift
Discovers similar artists via Last.fm and builds playlists from your local music library. Point it at a directory, pick a seed artist, and get a playlist of tracks weighted by artist similarity and popularity.
## Metadata requirements
Your music files need **MusicBrainz artist IDs** in their tags (`MUSICBRAINZ_ARTISTID`). Without these, tracks are skipped during indexing. Track titles help with popularity scoring — drift matches them against Last.fm's top tracks to bias toward well-known songs.
Most taggers (Picard, beets, etc.) can write MusicBrainz IDs automatically.
## Setup
You need a Last.fm API key. Create one at https://www.last.fm/api/account/create, then set it:
```
echo 'LASTFM_API_KEY=your_key_here' > .env
```
Or export it directly — drift loads `.env` automatically via dotenvy.
## Usage
### Index your library
```
drift index /path/to/music
```
Scans for tagged files, fetches similar artists and top tracks from Last.fm, and stores everything in a local SQLite database (`~/.local/share/drift/drift.db`).
Stale tracks (files previously indexed but no longer on disk) are automatically removed.
Flags:
- `-v` — print new artists indexed + summary
- `-vv` — also print each track added/removed
- `-vvv` — also print skipped artists
- `-f` — re-index artists that were already indexed
### Build a playlist
```
drift build
```
Opens an interactive picker to choose a seed artist. Or pass one or more artist names directly:
```
drift build "Radiohead"
drift build "Radiohead" "Portishead"
```
With multiple seeds, artists similar to several seeds rank higher — the playlist blends their neighborhoods naturally.
Flags:
- `-n 30` — number of tracks (default 20)
- `-p 8` — popularity bias, 010 (default 5, higher = prefer popular tracks)
- `-s` — interleave artists evenly instead of score order
- `-r` — fully randomize track order
- `-v` — print track scores to stderr
### Output
By default, drift prints file paths to stdout — pipe it wherever you want.
#### MPD
```
drift build -m
```
Queues tracks directly in MPD. Requires `MPD_HOST` and `MPD_MUSIC_DIR` environment variables.
#### Airsonic
```
drift build -a
```
Creates a playlist in Airsonic/Navidrome. Requires `AIRSONIC_URL`, `AIRSONIC_USER`, and `AIRSONIC_PASS` environment variables.

View File

@@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""Fuzzy search artists in playlists.db and show their similar artists."""
import curses
import os
import sqlite3
import sys
from pathlib import Path
def find_db():
"""Find playlists.db in XDG data dir."""
data_home = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
p = Path(data_home) / "playlists" / "playlists.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()

200
src/airsonic.rs Normal file
View File

@@ -0,0 +1,200 @@
use serde::Deserialize;
pub struct AirsonicClient {
base_url: String,
user: String,
password: String,
}
#[derive(Deserialize)]
struct SubsonicResponse {
#[serde(rename = "subsonic-response")]
subsonic_response: SubsonicEnvelope,
}
#[derive(Deserialize)]
struct SubsonicEnvelope {
#[allow(dead_code)]
status: String,
#[serde(rename = "searchResult3")]
search_result3: Option<SearchResult3>,
playlists: Option<PlaylistsWrapper>,
}
#[derive(Deserialize)]
struct SearchResult3 {
#[serde(default)]
song: Vec<SongEntry>,
}
#[derive(Deserialize)]
struct SongEntry {
id: String,
#[serde(rename = "musicBrainzId")]
mbid: Option<String>,
}
#[derive(Deserialize)]
struct PlaylistsWrapper {
#[serde(default)]
playlist: PlaylistList,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum PlaylistList {
Many(Vec<PlaylistEntry>),
One(PlaylistEntry),
}
impl Default for PlaylistList {
fn default() -> Self {
PlaylistList::Many(Vec::new())
}
}
impl PlaylistList {
fn into_vec(self) -> Vec<PlaylistEntry> {
match self {
PlaylistList::Many(v) => v,
PlaylistList::One(e) => vec![e],
}
}
}
#[derive(Deserialize)]
struct PlaylistEntry {
id: String,
name: String,
}
impl AirsonicClient {
pub fn new() -> Result<Self, String> {
let base_url = std::env::var("AIRSONIC_URL")
.map_err(|_| "AIRSONIC_URL not set".to_string())?;
let user = std::env::var("AIRSONIC_USER")
.map_err(|_| "AIRSONIC_USER not set".to_string())?;
let password = std::env::var("AIRSONIC_PASSWORD")
.map_err(|_| "AIRSONIC_PASSWORD not set".to_string())?;
Ok(Self { base_url: base_url.trim_end_matches('/').to_string(), user, password })
}
fn api_url(&self, endpoint: &str, extra: &str) -> String {
format!(
"{}/rest/{}?u={}&p={}&v=1.16.1&c=playlists&f=json{}",
self.base_url, endpoint, self.user, self.password, extra
)
}
fn search_song(&self, query: &str) -> Result<Vec<SongEntry>, String> {
let encoded = urlencoding::encode(query);
let url = self.api_url("search3", &format!("&query={encoded}&songCount=50&artistCount=0&albumCount=0"));
let body: String = ureq::get(&url)
.call()
.map_err(|e| format!("search3 request: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| format!("search3 read: {e}"))?;
let resp: SubsonicResponse = serde_json::from_str(&body)
.map_err(|e| format!("search3 parse: {e}"))?;
Ok(resp.subsonic_response.search_result3
.map(|r| r.song)
.unwrap_or_default())
}
fn get_playlists(&self) -> Result<Vec<PlaylistEntry>, String> {
let url = self.api_url("getPlaylists", "");
let body: String = ureq::get(&url)
.call()
.map_err(|e| format!("getPlaylists request: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| format!("getPlaylists read: {e}"))?;
let resp: SubsonicResponse = serde_json::from_str(&body)
.map_err(|e| format!("getPlaylists parse: {e}"))?;
Ok(resp.subsonic_response.playlists
.map(|p| p.playlist.into_vec())
.unwrap_or_default())
}
fn create_or_update_playlist(&self, name: &str, existing_id: Option<&str>, song_ids: &[String]) -> Result<(), String> {
let id_params: String = song_ids.iter().map(|id| format!("&songId={id}")).collect();
let extra = match existing_id {
Some(id) => format!("&playlistId={id}{id_params}"),
None => format!("&name={}{id_params}", urlencoding::encode(name)),
};
let url = self.api_url("createPlaylist", &extra);
ureq::get(&url)
.call()
.map_err(|e| format!("createPlaylist request: {e}"))?;
Ok(())
}
pub fn create_playlist(&self, name: &str, tracks: &[String], conn: &rusqlite::Connection, verbose: bool) -> Result<(), String> {
let mut song_ids: Vec<String> = Vec::new();
for track in tracks {
let (recording_mbid, title) = match crate::db::get_track_metadata(conn, track) {
Ok(Some((Some(mbid), title)) ) => (mbid, title),
Ok(Some((None, _))) => {
eprintln!("Airsonic: no recording MBID for {track}");
continue;
}
Ok(None) => {
eprintln!("Airsonic: track not in DB: {track}");
continue;
}
Err(e) => {
eprintln!("DB error for {track}: {e}");
continue;
}
};
let query = title.as_deref().unwrap_or(&recording_mbid);
if verbose {
eprintln!("Search: {query}");
eprintln!(" recording MBID: {recording_mbid}");
}
let results = match self.search_song(query) {
Ok(r) => r,
Err(e) => {
eprintln!("Airsonic search error for {query}: {e}");
continue;
}
};
if verbose {
eprintln!(" results: {}", results.len());
for song in &results {
eprintln!(" id={} mbid={}", song.id, song.mbid.as_deref().unwrap_or("(none)"));
}
}
match results.iter().find(|s| s.mbid.as_deref() == Some(&recording_mbid)) {
Some(song) => song_ids.push(song.id.clone()),
None => eprintln!("Airsonic: no match for {query} (mbid {recording_mbid})"),
}
}
if song_ids.is_empty() {
return Err("no tracks matched on Airsonic".to_string());
}
// Check for existing playlist with the same name
let playlists = self.get_playlists()?;
let existing = playlists.iter().find(|p| p.name == name);
match existing {
Some(p) => {
eprintln!("Updating existing playlist '{name}'");
self.create_or_update_playlist(name, Some(&p.id), &song_ids)
}
None => {
eprintln!("Creating playlist '{name}'");
self.create_or_update_playlist(name, None, &song_ids)
}
}
}
}

View File

@@ -2,6 +2,12 @@ use rusqlite::Connection;
use crate::lastfm::{SimilarArtist, TopTrack};
/// A local track: (path, recording_mbid, title).
pub type LocalTrack = (String, Option<String>, Option<String>);
/// Track metadata: (recording_mbid, title).
pub type TrackMetadata = (Option<String>, Option<String>);
pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute_batch(
@@ -62,7 +68,7 @@ pub fn get_available_similar_artists(
pub fn get_local_tracks_for_artist(
conn: &Connection,
artist_mbid: &str,
) -> Result<Vec<(String, Option<String>, Option<String>)>, rusqlite::Error> {
) -> Result<Vec<LocalTrack>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1",
)?;
@@ -70,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,
@@ -114,6 +168,17 @@ pub fn get_top_tracks_by_name(
rows.collect()
}
pub fn get_track_metadata(conn: &Connection, path: &str) -> Result<Option<TrackMetadata>, rusqlite::Error> {
conn.query_row(
"SELECT recording_mbid, title FROM tracks WHERE path = ?1",
[path],
|row| Ok(Some((row.get(0)?, row.get(1)?))),
).or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})
}
pub fn insert_track(
conn: &Connection,
path: &str,
@@ -128,6 +193,23 @@ pub fn insert_track(
Ok(())
}
pub fn get_tracks_in_directory(conn: &Connection, dir_prefix: &str) -> Result<Vec<String>, rusqlite::Error> {
let pattern = format!("{dir_prefix}%");
let mut stmt = conn.prepare("SELECT path FROM tracks WHERE path LIKE ?1")?;
let rows = stmt.query_map([pattern], |row| row.get(0))?;
rows.collect()
}
pub fn delete_tracks(conn: &Connection, paths: &[String]) -> Result<usize, rusqlite::Error> {
let tx = conn.unchecked_transaction()?;
let mut deleted = 0;
for path in paths {
deleted += tx.execute("DELETE FROM tracks WHERE path = ?1", [path])?;
}
tx.commit()?;
Ok(deleted)
}
pub fn insert_artist_with_similar(
conn: &Connection,
mbid: &str,

View File

@@ -16,15 +16,14 @@ pub struct TopTrack {
pub name: String,
pub mbid: Option<String>,
pub playcount: u64,
pub listeners: u64,
}
// Last.fm returns {"error": N, "message": "..."} on failure
// Last.fm returns {"error": N, "message": "..."} on failure.
// Only used to detect error responses via serde — fields aren't read directly.
#[derive(Deserialize)]
struct ApiError {
#[allow(dead_code)]
error: u32,
message: String,
}
// Deserialization structs for the Last.fm API responses
@@ -62,7 +61,6 @@ struct TrackEntry {
name: String,
mbid: Option<String>,
playcount: String,
listeners: String,
}
impl LastfmClient {
@@ -87,12 +85,9 @@ impl LastfmClient {
extra_params: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
if let Some(name) = artist_name {
let name = name.replace('\u{2010}', "-")
.replace('\u{2011}', "-")
.replace('\u{2012}', "-")
.replace('\u{2013}', "-")
.replace('\u{2014}', "-")
.replace('\u{2015}', "-");
let name = name
.replace(['\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}'], "-")
.replace(['\u{2018}', '\u{2019}'], "'");
let encoded = urlencoding::encode(&name);
let url = format!(
"{}?method={}&artist={}&api_key={}{}&format=json",
@@ -103,23 +98,52 @@ impl LastfmClient {
Ok(None)
}
/// Try fetching by MBID first, fall back to artist name.
fn fetch_with_fallback(
/// Try MBID lookup then name lookup, returning whichever yields more results.
#[allow(clippy::type_complexity)]
fn dual_lookup<T>(
&self,
method: &str,
artist_mbid: &str,
artist_name: Option<&str>,
extra_params: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let url = format!(
parse: fn(&str) -> Result<Vec<T>, Box<dyn std::error::Error>>,
) -> Result<Vec<T>, Box<dyn std::error::Error>> {
let mbid_url = format!(
"{}?method={}&mbid={}&api_key={}{}&format=json",
BASE_URL, method, artist_mbid, self.api_key, extra_params
);
if let Some(body) = self.fetch_or_none(&url)? {
return Ok(Some(body));
let mbid_results = match self.fetch_or_none(&mbid_url)? {
Some(body) => parse(&body).unwrap_or_default(),
None => Vec::new(),
};
let name_results = match self.fetch_by_name(method, artist_name, extra_params)? {
Some(body) => parse(&body).unwrap_or_default(),
None => Vec::new(),
};
if name_results.len() > mbid_results.len() {
Ok(name_results)
} else {
Ok(mbid_results)
}
}
self.fetch_by_name(method, artist_name, extra_params)
fn parse_similar_artists(body: &str) -> Result<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
let resp: SimilarArtistsResponse = serde_json::from_str(body)?;
Ok(resp
.similarartists
.artist
.into_iter()
.map(|a| {
let mbid = a.mbid.filter(|s| !s.is_empty());
SimilarArtist {
name: a.name,
mbid,
match_score: a.match_score.parse().unwrap_or(0.0),
}
})
.collect())
}
pub fn get_similar_artists(
@@ -127,51 +151,27 @@ impl LastfmClient {
artist_mbid: &str,
artist_name: Option<&str>,
) -> Result<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
let Some(body) = self.fetch_with_fallback(
self.dual_lookup(
"artist.getSimilar",
artist_mbid,
artist_name,
"&limit=500",
)? else {
return Ok(Vec::new());
};
Self::parse_similar_artists,
)
}
let resp: SimilarArtistsResponse = serde_json::from_str(&body)?;
let results: Vec<SimilarArtist> = resp
.similarartists
.artist
fn parse_top_tracks(body: &str) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
let resp: TopTracksResponse = serde_json::from_str(body)?;
Ok(resp
.toptracks
.track
.into_iter()
.map(|a| {
let mbid = a.mbid.filter(|s| !s.is_empty());
SimilarArtist {
name: a.name,
mbid,
match_score: a.match_score.parse().unwrap_or(0.0),
}
.map(|t| TopTrack {
name: t.name,
mbid: t.mbid.filter(|s| !s.is_empty()),
playcount: t.playcount.parse().unwrap_or(0),
})
.collect();
// MBID lookup can return valid but empty results; retry with name
if results.is_empty() {
if let Some(body) = self.fetch_by_name("artist.getSimilar", artist_name, "&limit=500")? {
let resp: SimilarArtistsResponse = serde_json::from_str(&body)?;
return Ok(resp
.similarartists
.artist
.into_iter()
.map(|a| {
let mbid = a.mbid.filter(|s| !s.is_empty());
SimilarArtist {
name: a.name,
mbid,
match_score: a.match_score.parse().unwrap_or(0.0),
}
})
.collect());
}
}
Ok(results)
.collect())
}
pub fn get_top_tracks(
@@ -179,45 +179,12 @@ impl LastfmClient {
artist_mbid: &str,
artist_name: Option<&str>,
) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
let Some(body) = self.fetch_with_fallback(
self.dual_lookup(
"artist.getTopTracks",
artist_mbid,
artist_name,
"&limit=1000",
)? else {
return Ok(Vec::new());
};
let resp: TopTracksResponse = serde_json::from_str(&body)?;
let results: Vec<TopTrack> = resp
.toptracks
.track
.into_iter()
.map(|t| TopTrack {
name: t.name,
mbid: t.mbid.filter(|s| !s.is_empty()),
playcount: t.playcount.parse().unwrap_or(0),
listeners: t.listeners.parse().unwrap_or(0),
})
.collect();
if results.is_empty() {
if let Some(body) = self.fetch_by_name("artist.getTopTracks", artist_name, "&limit=1000")? {
let resp: TopTracksResponse = serde_json::from_str(&body)?;
return Ok(resp
.toptracks
.track
.into_iter()
.map(|t| TopTrack {
name: t.name,
mbid: t.mbid.filter(|s| !s.is_empty()),
playcount: t.playcount.parse().unwrap_or(0),
listeners: t.listeners.parse().unwrap_or(0),
})
.collect());
}
}
Ok(results)
Self::parse_top_tracks,
)
}
}

View File

@@ -1,17 +1,89 @@
mod airsonic;
mod db;
mod filesystem;
mod lastfm;
mod metadata;
mod mpd;
mod playlist;
mod tui;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use rand::distr::weighted::WeightedIndex;
use clap::{Parser, Subcommand};
use rand::prelude::*;
use playlist::Candidate;
#[derive(Parser)]
#[command(name = "drift")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Index a music directory
Index {
/// Verbosity level (-v, -vv, -vvv)
#[arg(short, action = clap::ArgAction::Count)]
verbose: u8,
/// Re-index already indexed artists
#[arg(short)]
force: bool,
/// 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)
#[arg(short, action = clap::ArgAction::Count)]
verbose: u8,
/// Queue in MPD
#[arg(short, conflicts_with = "airsonic")]
mpd: bool,
/// Create Airsonic playlist
#[arg(short, conflicts_with = "mpd")]
airsonic: bool,
/// Interleave artists evenly
#[arg(short, conflicts_with = "random")]
shuffle: bool,
/// Fully randomize order
#[arg(short, conflicts_with = "shuffle")]
random: bool,
/// Number of tracks
#[arg(short = 'n', default_value_t = 20, value_parser = parse_positive_usize)]
count: usize,
/// Popularity bias (0=no preference, 10=heavy popular bias)
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
popularity: u8,
/// Artist name(s) to seed (or pick interactively)
artists: Vec<String>,
},
}
struct BuildOptions {
verbose: u8,
mpd: bool,
airsonic: bool,
shuffle: bool,
random: bool,
count: usize,
popularity_bias: u8,
}
fn parse_positive_usize(s: &str) -> Result<usize, String> {
let n: usize = s.parse().map_err(|e| format!("{e}"))?;
if n == 0 {
return Err("value must be greater than 0".to_string());
}
Ok(n)
}
fn db_path() -> PathBuf {
let data_dir = env::var("XDG_DATA_HOME")
.map(PathBuf::from)
@@ -19,42 +91,31 @@ fn db_path() -> PathBuf {
let home = env::var("HOME").expect("HOME not set");
PathBuf::from(home).join(".local/share")
});
let dir = data_dir.join("playlists");
let dir = data_dir.join("drift");
std::fs::create_dir_all(&dir).expect("failed to create data directory");
dir.join("playlists.db")
}
fn usage(program: &str) -> ! {
eprintln!("Usage:");
eprintln!(" {program} index [-v] [-f] <directory>");
eprintln!(" {program} build [-v] [-m] [-s|-r] [-n COUNT] [file]");
std::process::exit(1);
dir.join("drift.db")
}
fn main() {
let args: Vec<String> = env::args().collect();
let cli = Cli::parse();
if args.len() < 2 {
usage(&args[0]);
match cli.command {
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,
};
cmd_build(opts, artists);
}
match args[1].as_str() {
"index" => cmd_index(&args),
"build" => cmd_build(&args),
_ => usage(&args[0]),
}
}
fn cmd_index(args: &[String]) {
let verbose = args.iter().any(|a| a == "-v");
let force = args.iter().any(|a| a == "-f");
let rest: Vec<&String> = args.iter().skip(2).filter(|a| *a != "-v" && *a != "-f").collect();
if rest.len() != 1 {
eprintln!("Usage: {} index [-v] [-f] <directory>", args[0]);
std::process::exit(1);
}
fn cmd_index(verbose: u8, force: bool, directory: &str) {
dotenvy::dotenv().ok();
let api_key = env::var("LASTFM_API_KEY").unwrap_or_default();
@@ -65,19 +126,47 @@ fn cmd_index(args: &[String]) {
let conn = db::open(&db_path()).expect("failed to open database");
let lastfm = lastfm::LastfmClient::new(api_key);
let dir = Path::new(rest[0].as_str());
for path in filesystem::walk_music_files(dir) {
let artist_mbid = match metadata::read_artist_mbid(&path) {
Ok(Some(mbid)) => mbid,
Ok(None) => continue,
let dir = std::fs::canonicalize(directory).unwrap_or_else(|e| {
eprintln!("Error: cannot resolve directory {directory}: {e}");
std::process::exit(1);
});
let dir_prefix = format!("{}/", dir.display());
let mut stale_paths: HashSet<String> = match db::get_tracks_in_directory(&conn, &dir_prefix) {
Ok(paths) => paths.into_iter().collect(),
Err(e) => {
eprintln!("{}: could not read artist MBID: {e}", path.display());
eprintln!("DB error loading existing tracks: {e}");
HashSet::new()
}
};
let mut artists_indexed: usize = 0;
let mut artists_skipped: usize = 0;
let mut tracks_added: usize = 0;
for path in filesystem::walk_music_files(&dir) {
let path_str = path.to_string_lossy().into_owned();
stale_paths.remove(&path_str);
let tags = match metadata::read_tags(&path, &[
metadata::Tag::ArtistMbid,
metadata::Tag::TrackMbid,
metadata::Tag::ArtistName,
metadata::Tag::TrackTitle,
]) {
Ok(t) => t,
Err(e) => {
eprintln!("{}: could not read tags: {e}", path.display());
continue;
}
};
let recording_mbid = metadata::read_track_mbid(&path).ok().flatten();
let Some(artist_mbid) = tags[0].clone() else { continue };
let recording_mbid = tags[1].clone();
let artist_name = tags[2].clone();
let track_title = tags[3].clone();
let already_indexed = match db::artist_exists(&conn, &artist_mbid) {
Ok(exists) => exists,
@@ -87,11 +176,10 @@ fn cmd_index(args: &[String]) {
}
};
let artist_name = metadata::read_artist_name(&path).ok().flatten();
let display_name = artist_name.as_deref().unwrap_or(&artist_mbid);
if !already_indexed || force {
if verbose {
if verbose >= 1 {
println!("Indexing {display_name}...");
}
@@ -123,114 +211,159 @@ fn cmd_index(args: &[String]) {
eprintln!("Last.fm top tracks error for {display_name}: {e}");
}
}
} else if verbose {
artists_indexed += 1;
} else {
if verbose >= 3 {
println!("Skipping {display_name} (already indexed)");
}
artists_skipped += 1;
}
let track_title = metadata::read_track_title(&path).ok().flatten();
let path_str = path.to_string_lossy();
if let Err(e) = db::insert_track(&conn, &path_str, &artist_mbid, recording_mbid.as_deref(), track_title.as_deref()) {
eprintln!("DB error inserting track {}: {e}", path.display());
continue;
}
tracks_added += 1;
if verbose >= 2 {
let label = track_title.as_deref().unwrap_or(&path_str);
println!(" + {label}");
}
}
// Sweep stale tracks
let stale: Vec<String> = stale_paths.into_iter().collect();
let tracks_stale = if !stale.is_empty() {
if verbose >= 2 {
for p in &stale {
println!(" - {p}");
}
}
match db::delete_tracks(&conn, &stale) {
Ok(n) => n,
Err(e) => {
eprintln!("DB error removing stale tracks: {e}");
0
}
}
} else {
0
};
if verbose >= 1 {
println!(
"Done: {artists_indexed} artists indexed, {artists_skipped} skipped, {tracks_added} tracks added, {tracks_stale} stale tracks removed"
);
}
}
fn cmd_build(args: &[String]) {
let verbose = args.iter().any(|a| a == "-v");
let mpd = args.iter().any(|a| a == "-m");
let shuffle = args.iter().any(|a| a == "-s");
let random = args.iter().any(|a| a == "-r");
fn resolve_artist(artists: &[(String, String, u32)], query: &str) -> Option<(String, String)> {
let q = query.to_lowercase();
if shuffle && random {
eprintln!("Error: -s and -r are mutually exclusive");
std::process::exit(1);
}
// Parse -n COUNT
let mut count: usize = 20;
let mut rest: Vec<&String> = Vec::new();
let mut iter = args.iter().skip(2);
while let Some(arg) = iter.next() {
if arg == "-v" || arg == "-m" || arg == "-s" || arg == "-r" {
continue;
} else if arg == "-n" {
match iter.next() {
Some(val) => match val.parse::<usize>() {
Ok(n) if n > 0 => count = n,
_ => {
eprintln!("Error: -n requires a positive integer");
std::process::exit(1);
}
},
None => {
eprintln!("Error: -n requires a value");
std::process::exit(1);
}
}
} else {
rest.push(arg);
// Tier 1: exact case-insensitive match
for (mbid, name, _) in artists {
if name.to_lowercase() == q {
return Some((mbid.clone(), name.clone()));
}
}
if rest.len() > 1 {
eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]);
std::process::exit(1);
// Tier 2: contains case-insensitive
for (mbid, name, _) in artists {
if name.to_lowercase().contains(&q) {
return Some((mbid.clone(), name.clone()));
}
}
dotenvy::dotenv().ok();
// Tier 3: subsequence fuzzy match
for (mbid, name, _) in artists {
if tui::fuzzy_match(&q, name) {
return Some((mbid.clone(), name.clone()));
}
}
None
}
fn cmd_search() {
let conn = db::open(&db_path()).expect("failed to open database");
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) {
let all_artists = match db::get_all_artists(&conn) {
Ok(a) => a,
Err(e) => {
eprintln!("DB error: {e}");
std::process::exit(1);
}
};
if artists.is_empty() {
if all_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),
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");
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);
}
build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random);
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
let picked = tui::run_artist_picker(&all_artists);
if picked.is_empty() {
std::process::exit(0);
}
picked
} else {
artist_args
.iter()
.map(|query| {
match resolve_artist(&all_artists, query) {
Some((mbid, name)) => {
eprintln!("Matched: {name}");
(mbid, name)
}
None => {
eprintln!("No artist matching \"{query}\"");
std::process::exit(1);
}
}
})
.collect()
};
build_playlist(&conn, &seeds, &opts);
}
fn build_playlist(
conn: &rusqlite::Connection,
artist_mbid: &str,
seed_name: &str,
count: usize,
verbose: bool,
mpd: bool,
shuffle: bool,
random: bool,
seeds: &[(String, String)],
opts: &BuildOptions,
) {
let similar = match db::get_available_similar_artists(conn, artist_mbid) {
// Merge similar artists from all seeds: mbid → (name, total_score)
let mut merged: HashMap<String, (String, f64)> = HashMap::new();
let num_seeds = seeds.len() as f64;
for (seed_mbid, seed_name) in seeds {
// Insert the seed itself with score 1.0
let entry = merged.entry(seed_mbid.clone()).or_insert_with(|| (seed_name.clone(), 0.0));
entry.1 += 1.0;
let similar = match db::get_available_similar_artists(conn, seed_mbid) {
Ok(a) => a,
Err(e) => {
eprintln!("DB error: {e}");
@@ -238,98 +371,59 @@ fn build_playlist(
}
};
// Seed artist + similar artists: (mbid, name, match_score)
let mut artists: Vec<(String, String, f64)> = vec![
(artist_mbid.to_string(), seed_name.to_string(), 1.0),
];
artists.extend(similar);
// Collect scored tracks: (total, popularity, match_score, artist_name, path)
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) {
Ok(t) => t,
Err(e) => {
eprintln!("DB error for {name}: {e}");
continue;
}
};
if local_tracks.is_empty() {
continue;
}
// Look up pre-indexed top tracks from DB
let top_tracks_by_name = match db::get_top_tracks_by_name(conn, mbid) {
Ok(t) => t,
Err(e) => {
eprintln!("DB error fetching top tracks for {name}: {e}");
Vec::new()
}
};
let playcount_by_name: std::collections::HashMap<String, u64> =
top_tracks_by_name.into_iter().collect();
let max_playcount = playcount_by_name
.values()
.copied()
.max()
.unwrap_or(1)
.max(1);
for (track_path, _recording_mbid, title) in &local_tracks {
// Match by title (lowercased), fall back to recording MBID
let playcount = title
.as_ref()
.and_then(|t| playcount_by_name.get(&t.to_lowercase()).copied())
.or_else(|| {
_recording_mbid
.as_ref()
.and_then(|id| playcount_by_name.get(id).copied())
});
// Skip tracks not in the artist's top 1000
let Some(playcount) = playcount else { continue };
let popularity = if playcount > 0 {
(playcount as f64 / max_playcount as f64).powf(0.15)
} else {
0.0
};
let similarity = (match_score.exp()) / std::f64::consts::E;
let total = similarity * (1.0 + popularity);
playlist.push((total, popularity, similarity, name.clone(), track_path.clone()));
for (mbid, name, score) in similar {
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0));
entry.1 += score;
}
}
if verbose {
let mut sorted = playlist.clone();
sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
for (total, popularity, similarity, artist, track_path) in &sorted {
eprintln!("{total:.4}\t{similarity:.4}\t{popularity:.4}\t{artist}\t{track_path}");
}
}
// Convert to (score, artist, path) for playlist generation
let candidates: Vec<(f64, String, String)> = playlist
let artists: Vec<(String, String, f64)> = merged
.into_iter()
.map(|(total, _, _, artist, path)| (total, artist, path))
.map(|(mbid, (name, total))| (mbid, name, total / num_seeds))
.collect();
let mut selected = generate_playlist(&candidates, count, seed_name);
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
if random {
selected.shuffle(&mut rand::rng());
} else if shuffle {
selected = interleave_artists(selected);
if opts.verbose >= 1 {
let mut sorted = scored.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
let limit = if opts.verbose >= 2 { sorted.len() } else { 50.min(sorted.len()) };
for t in &sorted[..limit] {
eprintln!("{:.4}\t{:.4}\t{:.4}\t{}\t{}", t.score, t.similarity, t.popularity, t.artist, t.path);
}
}
let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect();
let candidates: Vec<Candidate> = scored
.into_iter()
.map(|t| Candidate {
score: t.score,
artist: t.artist,
path: t.path,
})
.collect();
if mpd {
let seed_names: HashSet<String> = seeds.iter().map(|(_, name)| name.clone()).collect();
let mut selected = playlist::generate_playlist(&candidates, opts.count, &seed_names);
if opts.random {
selected.shuffle(&mut rand::rng());
} else if opts.shuffle {
selected = playlist::interleave_artists(selected);
}
let tracks: Vec<String> = selected.into_iter().map(|c| c.path).collect();
let display_name = seeds.iter().map(|(_, name)| name.as_str()).collect::<Vec<_>>().join(" + ");
output_tracks(&tracks, opts, &display_name, conn);
}
fn output_tracks(
tracks: &[String],
opts: &BuildOptions,
seed_name: &str,
conn: &rusqlite::Connection,
) {
if opts.mpd {
let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
if music_dir.is_empty() {
eprintln!("Error: MPD_MUSIC_DIR not set");
@@ -342,132 +436,22 @@ fn build_playlist(
std::process::exit(1);
}
};
client.queue_playlist(&tracks, &music_dir);
client.queue_playlist(tracks, &music_dir);
} else if opts.airsonic {
let client = match airsonic::AirsonicClient::new() {
Ok(c) => c,
Err(e) => {
eprintln!("Airsonic error: {e}");
std::process::exit(1);
}
};
if let Err(e) = client.create_playlist(seed_name, tracks, conn, opts.verbose >= 1) {
eprintln!("Airsonic error: {e}");
std::process::exit(1);
}
} else {
for track in &tracks {
for track in tracks {
println!("{track}");
}
}
}
fn generate_playlist(
candidates: &[(f64, String, String)],
n: usize,
seed_name: &str,
) -> Vec<(f64, String, String)> {
if candidates.is_empty() {
return Vec::new();
}
let mut rng = rand::rng();
let mut pool: Vec<(f64, String, String)> = candidates.to_vec();
let mut result: Vec<(f64, String, String)> = Vec::new();
let mut artist_counts: HashMap<String, usize> = HashMap::new();
let seed_min = (n / 10).max(1);
let distinct_artists: usize = {
let mut seen = std::collections::HashSet::new();
for (_, artist, _) in &pool {
seen.insert(artist.clone());
}
seen.len()
};
let divisor = match distinct_artists {
1 => 1,
2 => 2,
3 => 3,
4 => 3,
5 => 4,
_ => 5,
};
let artist_cap = ((n + divisor - 1) / divisor).max(1);
while result.len() < n && !pool.is_empty() {
let seed_count = *artist_counts.get(seed_name).unwrap_or(&0);
let remaining = n - result.len();
let seed_deficit = seed_min.saturating_sub(seed_count);
let force_seed = seed_deficit > 0 && remaining <= seed_deficit;
// Find eligible tracks (artist hasn't hit cap)
let eligible: Vec<usize> = pool
.iter()
.enumerate()
.filter(|(_, (_, artist, _))| {
if force_seed {
artist == seed_name
} else {
*artist_counts.get(artist).unwrap_or(&0) < artist_cap
}
})
.map(|(i, _)| i)
.collect();
// If no eligible tracks, relax and use all remaining
let indices: &[usize] = if eligible.is_empty() {
&(0..pool.len()).collect::<Vec<_>>()
} else {
&eligible
};
let weights: Vec<f64> = indices.iter().map(|&i| pool[i].0.max(0.001)).collect();
let dist = match WeightedIndex::new(&weights) {
Ok(d) => d,
Err(_) => break,
};
let picked = indices[dist.sample(&mut rng)];
let track = pool.remove(picked);
*artist_counts.entry(track.1.clone()).or_insert(0) += 1;
result.push(track);
}
result
}
/// Reorder tracks so that artists are evenly spread out.
/// Greedily picks from the artist with the most remaining tracks,
/// avoiding back-to-back repeats when possible.
fn interleave_artists(tracks: Vec<(f64, String, String)>) -> Vec<(f64, String, String)> {
use std::collections::BTreeMap;
let mut rng = rand::rng();
// Group by artist, shuffling within each group
let mut by_artist: BTreeMap<String, Vec<(f64, String, String)>> = BTreeMap::new();
for track in tracks {
by_artist.entry(track.1.clone()).or_default().push(track);
}
for group in by_artist.values_mut() {
group.shuffle(&mut rng);
}
let mut result = Vec::new();
let mut last_artist: Option<String> = None;
while !by_artist.is_empty() {
// Sort artists by remaining count (descending), break ties randomly
let mut artists: Vec<String> = by_artist.keys().cloned().collect();
artists.sort_by(|a, b| by_artist[b].len().cmp(&by_artist[a].len()));
// Pick the first artist that isn't the same as the last one
let pick = artists
.iter()
.find(|a| last_artist.as_ref() != Some(a))
.or(artists.first())
.cloned()
.unwrap();
let group = by_artist.get_mut(&pick).unwrap();
let track = group.pop().unwrap();
if group.is_empty() {
by_artist.remove(&pick);
}
last_artist = Some(pick);
result.push(track);
}
result
}

View File

@@ -1,80 +1,38 @@
use std::path::Path;
use lofty::file::TaggedFileExt;
use lofty::tag::{ItemKey, ItemValue};
use lofty::tag::ItemKey;
/// A single key-value metadata item from a tag.
pub struct TagEntry {
pub key: String,
pub value: String,
/// Tags that can be read from a music file.
pub enum Tag {
ArtistName,
ArtistMbid,
TrackTitle,
TrackMbid,
}
/// Read all metadata items from a music file.
/// Returns `None` if no tags are present, otherwise a list of all tag entries.
pub fn read_all_metadata(path: &Path) -> Result<Option<Vec<TagEntry>>, lofty::error::LoftyError> {
impl Tag {
fn item_key(&self) -> ItemKey {
match self {
Tag::ArtistName => ItemKey::TrackArtist,
Tag::ArtistMbid => ItemKey::MusicBrainzArtistId,
Tag::TrackTitle => ItemKey::TrackTitle,
Tag::TrackMbid => ItemKey::MusicBrainzRecordingId,
}
}
}
/// Read multiple tags from a music file in a single file open.
/// Returns a Vec in the same order as the input keys.
pub fn read_tags(path: &Path, keys: &[Tag]) -> Result<Vec<Option<String>>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
return Ok(vec![None; keys.len()]);
};
let entries = tag
.items()
.filter_map(|item| {
let value = match item.value() {
ItemValue::Text(t) | ItemValue::Locator(t) => t.clone(),
ItemValue::Binary(b) => format!("<{} bytes>", b.len()),
};
Some(TagEntry {
key: format!("{:?}", item.key()),
value,
})
})
.collect();
Ok(Some(entries))
Ok(keys
.iter()
.map(|k| tag.get_string(k.item_key()).map(String::from))
.collect())
}
/// Extract the artist name from a music file.
pub fn read_artist_name(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
};
Ok(tag.get_string(ItemKey::TrackArtist).map(String::from))
}
/// Extract the MusicBrainz artist ID from a music file.
pub fn read_artist_mbid(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
};
Ok(tag.get_string(ItemKey::MusicBrainzArtistId).map(String::from))
}
/// Extract the track title from a music file.
pub fn read_track_title(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
};
Ok(tag.get_string(ItemKey::TrackTitle).map(String::from))
}
/// Extract the MusicBrainz recording ID from a music file.
pub fn read_track_mbid(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
let tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None);
};
Ok(tag.get_string(ItemKey::MusicBrainzRecordingId).map(String::from))
}

220
src/playlist.rs Normal file
View File

@@ -0,0 +1,220 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use rand::distr::weighted::WeightedIndex;
use rand::prelude::*;
use crate::db;
const POPULARITY_EXPONENTS: [f64; 11] = [
0.0, 0.03, 0.08, 0.15, 0.25, 0.35, 0.55, 0.85, 1.30, 1.80, 2.50,
];
/// A track with its computed scores, used for verbose output and candidate conversion.
pub struct ScoredTrack {
pub path: String,
pub artist: String,
pub score: f64,
pub popularity: f64,
pub similarity: f64,
}
/// A weighted candidate for playlist selection.
pub struct Candidate {
pub score: f64,
pub artist: String,
pub path: String,
}
/// Score all tracks for the given artists, returning scored tracks for ranking.
pub fn score_tracks(
conn: &rusqlite::Connection,
artists: &[(String, String, f64)],
popularity_bias: u8,
) -> Vec<ScoredTrack> {
let mut scored = Vec::new();
for (mbid, name, match_score) in artists {
let local_tracks = match db::get_local_tracks_for_artist(conn, mbid) {
Ok(t) => t,
Err(e) => {
eprintln!("DB error for {name}: {e}");
continue;
}
};
if local_tracks.is_empty() {
continue;
}
let top_tracks_by_name = match db::get_top_tracks_by_name(conn, mbid) {
Ok(t) => t,
Err(e) => {
eprintln!("DB error fetching top tracks for {name}: {e}");
Vec::new()
}
};
let playcount_by_name: HashMap<String, u64> = top_tracks_by_name.into_iter().collect();
let max_playcount = playcount_by_name
.values()
.copied()
.max()
.unwrap_or(1)
.max(1);
for (track_path, recording_mbid, title) in &local_tracks {
let playcount = title
.as_ref()
.and_then(|t| playcount_by_name.get(&t.to_lowercase()).copied())
.or_else(|| {
recording_mbid
.as_ref()
.and_then(|id| playcount_by_name.get(id).copied())
});
let Some(playcount) = playcount else { continue };
let popularity = if playcount > 0 {
(playcount as f64 / max_playcount as f64)
.powf(POPULARITY_EXPONENTS[popularity_bias as usize])
} else {
0.0
};
let similarity = (match_score.exp()) / std::f64::consts::E;
let score = similarity * popularity;
scored.push(ScoredTrack {
path: track_path.clone(),
artist: name.clone(),
score,
popularity,
similarity,
});
}
}
scored
}
pub fn generate_playlist(
candidates: &[Candidate],
n: usize,
seed_names: &HashSet<String>,
) -> Vec<Candidate> {
if candidates.is_empty() {
return Vec::new();
}
let mut rng = rand::rng();
let mut pool: Vec<&Candidate> = candidates.iter().collect();
let mut result: Vec<Candidate> = Vec::new();
let mut artist_counts: HashMap<String, usize> = HashMap::new();
let seed_min = (n / 10).max(1);
let distinct_artists: usize = {
let mut seen = HashSet::new();
for c in &pool {
seen.insert(&c.artist);
}
seen.len()
};
let divisor = match distinct_artists {
1 => 1,
2 => 2,
3 => 3,
4 => 3,
5 => 4,
_ => 5,
};
let artist_cap = n.div_ceil(divisor).max(1);
while result.len() < n && !pool.is_empty() {
let seed_count: usize = seed_names
.iter()
.map(|name| *artist_counts.get(name).unwrap_or(&0))
.sum();
let remaining = n - result.len();
let seed_deficit = seed_min.saturating_sub(seed_count);
let force_seed = seed_deficit > 0 && remaining <= seed_deficit;
let eligible: Vec<usize> = pool
.iter()
.enumerate()
.filter(|(_, c)| {
if force_seed {
seed_names.contains(&c.artist)
} else {
*artist_counts.get(&c.artist).unwrap_or(&0) < artist_cap
}
})
.map(|(i, _)| i)
.collect();
let indices: &[usize] = if eligible.is_empty() {
&(0..pool.len()).collect::<Vec<_>>()
} else {
&eligible
};
let weights: Vec<f64> = indices.iter().map(|&i| pool[i].score.max(0.001)).collect();
let dist = match WeightedIndex::new(&weights) {
Ok(d) => d,
Err(_) => break,
};
let picked = indices[dist.sample(&mut rng)];
let track = pool.remove(picked);
*artist_counts.entry(track.artist.clone()).or_insert(0) += 1;
result.push(Candidate {
score: track.score,
artist: track.artist.clone(),
path: track.path.clone(),
});
}
result
}
/// Reorder tracks so that artists are evenly spread out.
/// Greedily picks from the artist with the most remaining tracks,
/// avoiding back-to-back repeats when possible.
pub fn interleave_artists(tracks: Vec<Candidate>) -> Vec<Candidate> {
let mut rng = rand::rng();
let mut by_artist: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
for track in tracks {
by_artist.entry(track.artist.clone()).or_default().push(track);
}
for group in by_artist.values_mut() {
group.shuffle(&mut rng);
}
let mut result = Vec::new();
let mut last_artist: Option<String> = None;
while !by_artist.is_empty() {
let mut artists: Vec<String> = by_artist.keys().cloned().collect();
artists.sort_by(|a, b| by_artist[b].len().cmp(&by_artist[a].len()));
let pick = artists
.iter()
.find(|a| last_artist.as_ref() != Some(a))
.or(artists.first())
.cloned()
.unwrap();
let group = by_artist.get_mut(&pick).unwrap();
let track = group.pop().unwrap();
if group.is_empty() {
by_artist.remove(&pick);
}
last_artist = Some(pick);
result.push(track);
}
result
}

View File

@@ -1,4 +1,6 @@
use std::collections::HashSet;
use std::io::{self, Write};
use std::path::Path;
use crossterm::{
cursor,
@@ -8,7 +10,7 @@ use crossterm::{
terminal::{self, ClearType},
};
fn fuzzy_match(query: &str, name: &str) -> bool {
pub 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() {
@@ -23,135 +25,369 @@ fn fuzzy_match(query: &str, name: &str) -> bool {
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
enum PickerResult {
Selected(Vec<(String, String)>),
Cancelled,
}
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;
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, true);
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
match result {
PickerResult::Selected(v) => v,
PickerResult::Cancelled => Vec::new(),
}
}
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 (w, h) = terminal::size().ok()?;
let w = w as usize;
let h = h as usize;
let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else {
return PickerResult::Cancelled;
};
if h < 2 {
continue;
}
let q = query.to_lowercase();
let filtered: Vec<&(String, String)> = if q.is_empty() {
artists.iter().collect()
let q = state.query.to_lowercase();
let filtered: Vec<(usize, &(String, String, u32))> = if q.is_empty() {
artists.iter().enumerate().collect()
} else {
artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect()
artists
.iter()
.enumerate()
.filter(|(_, (_, name, _))| fuzzy_match(&q, name))
.collect()
};
if filtered.is_empty() {
selected = 0;
state.cursor = 0;
} else {
selected = selected.min(filtered.len() - 1);
state.cursor = state.cursor.min(filtered.len() - 1);
}
// Scroll bounds
let list_h = h - 1;
if selected < scroll {
scroll = selected;
if state.cursor < state.scroll {
state.scroll = state.cursor;
}
if selected >= scroll + list_h {
scroll = selected - list_h + 1;
if state.cursor >= state.scroll + list_h {
state.scroll = state.cursor - list_h + 1;
}
// Draw
execute!(stdout, terminal::Clear(ClearType::All)).ok();
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 {
format!(" [{}]", toggled.len())
};
let count_str = format!(" {}/{}", filtered.len(), artists.len());
execute!(
let _ = execute!(
stdout,
cursor::MoveTo(0, 0),
style::PrintStyledContent(prompt.as_str().cyan().bold()),
style::PrintStyledContent(sel_str.as_str().yellow()),
style::PrintStyledContent(count_str.as_str().dark_grey()),
)
.ok();
);
// Artist list
for i in 0..list_h {
let idx = scroll + i;
let idx = state.scroll + i;
if idx >= filtered.len() {
break;
}
let (_, name) = filtered[idx];
let display: String = if name.len() >= w {
name[..w].to_string()
let (orig_idx, (_, name, track_count)) = filtered[idx];
let marker = if toggled.contains(&orig_idx) { "*" } else { " " };
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!(" {name}")
format!("{marker} {name}")
};
execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok();
if idx == selected {
execute!(
let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16));
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()),
)
.ok();
style::PrintStyledContent(padded.as_str().black().on_cyan()),
);
} else if toggled.contains(&orig_idx) {
let _ = execute!(
stdout,
style::PrintStyledContent(name_part.as_str().cyan()),
style::PrintStyledContent(count_suffix.as_str().dark_grey()),
);
} else {
execute!(stdout, style::Print(&display)).ok();
let _ = execute!(
stdout,
style::Print(&name_part),
style::PrintStyledContent(count_suffix.as_str().dark_grey()),
);
}
}
stdout.flush().ok();
let _ = stdout.flush();
// Input
let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else {
let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
continue;
};
match code {
KeyCode::Esc => return None,
KeyCode::Esc => return PickerResult::Cancelled,
KeyCode::Enter => {
if !filtered.is_empty() {
let (mbid, name) = filtered[selected];
return Some((mbid.clone(), name.clone()));
if toggled.is_empty() {
let (_, (mbid, name, _)) = filtered[state.cursor];
return PickerResult::Selected(vec![(mbid.clone(), name.clone())]);
} else {
let mut indices: Vec<usize> = toggled.into_iter().collect();
indices.sort();
return PickerResult::Selected(
indices
.into_iter()
.map(|i| {
let (ref mbid, ref name, _) = artists[i];
(mbid.clone(), name.clone())
})
.collect(),
);
}
}
}
KeyCode::Tab if allow_toggle => {
if !filtered.is_empty() {
let (orig_idx, _) = filtered[state.cursor];
if !toggled.remove(&orig_idx) {
toggled.insert(orig_idx);
}
if state.cursor + 1 < filtered.len() {
state.cursor += 1;
}
}
}
KeyCode::Up => {
selected = selected.saturating_sub(1);
state.cursor = state.cursor.saturating_sub(1);
}
KeyCode::Down => {
if !filtered.is_empty() {
selected = selected.min(filtered.len().saturating_sub(1));
if selected + 1 < filtered.len() {
selected += 1;
}
if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
state.cursor += 1;
}
}
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
selected = selected.saturating_sub(1);
state.cursor = state.cursor.saturating_sub(1);
}
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
if !filtered.is_empty() && selected + 1 < filtered.len() {
selected += 1;
if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
state.cursor += 1;
}
}
KeyCode::Backspace => {
query.pop();
selected = 0;
scroll = 0;
state.query.pop();
state.cursor = 0;
state.scroll = 0;
}
KeyCode::Char(ch) => {
query.push(ch);
selected = 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;
}
}
_ => {}
}