Added exact artist matching

This commit is contained in:
Connor Johnstone
2026-03-05 11:31:53 -05:00
parent 70aedb49f2
commit 2ffdce4fbc
8 changed files with 152 additions and 74 deletions

2
.gitignore vendored
View File

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

34
Cargo.lock generated
View File

@@ -190,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"
@@ -463,23 +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 = [
"clap",
"crossterm",
"dotenvy",
"lofty",
"rand",
"rusqlite",
"serde",
"serde_json",
"ureq",
"urlencoding",
"walkdir",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"

View File

@@ -1,5 +1,5 @@
[package]
name = "playlists"
name = "drift"
version = "0.1.0"
edition = "2024"

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
# 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`).
Flags:
- `-v` — print progress
- `-f` — re-index artists that were already indexed
### Build a playlist
```
drift build
```
Opens an interactive picker to choose a seed artist. Or pass an artist name directly:
```
drift build "Radiohead"
```
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,5 +1,5 @@
#!/usr/bin/env python3
"""Fuzzy search artists in playlists.db and show their similar artists."""
"""Fuzzy search artists in drift.db and show their similar artists."""
import curses
import os
@@ -9,9 +9,9 @@ from pathlib import Path
def find_db():
"""Find playlists.db in XDG data dir."""
"""Find drift.db in XDG data dir."""
data_home = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
p = Path(data_home) / "playlists" / "playlists.db"
p = Path(data_home) / "drift" / "drift.db"
if p.exists():
return str(p)
print(f"Could not find {p}", file=sys.stderr)

View File

@@ -16,7 +16,7 @@ use rand::prelude::*;
use playlist::Candidate;
#[derive(Parser)]
#[command(name = "playlists")]
#[command(name = "drift")]
struct Cli {
#[command(subcommand)]
command: Command,
@@ -58,8 +58,8 @@ enum Command {
/// 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,
/// Seed file (or pick interactively)
file: Option<String>,
/// Artist name to seed (or pick interactively)
artist: Option<String>,
},
}
@@ -88,9 +88,9 @@ 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")
dir.join("drift.db")
}
fn main() {
@@ -100,11 +100,11 @@ fn main() {
Command::Index { verbose, force, directory } => {
cmd_index(verbose, force, &directory);
}
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, file } => {
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artist } => {
let opts = BuildOptions {
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
};
cmd_build(opts, file.as_deref());
cmd_build(opts, artist.as_deref());
}
}
}
@@ -195,40 +195,61 @@ fn cmd_index(verbose: bool, force: bool, directory: &str) {
}
}
fn cmd_build(opts: BuildOptions, file: Option<&str>) {
fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String, String)> {
let q = query.to_lowercase();
// Tier 1: exact case-insensitive match
for (mbid, name) in artists {
if name.to_lowercase() == q {
return Some((mbid.clone(), name.clone()));
}
}
// Tier 2: contains case-insensitive
for (mbid, name) in artists {
if name.to_lowercase().contains(&q) {
return Some((mbid.clone(), name.clone()));
}
}
// 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_build(opts: BuildOptions, artist: Option<&str>) {
dotenvy::dotenv().ok();
let conn = db::open(&db_path()).expect("failed to open database");
let (artist_mbid, seed_name) = if let Some(file_arg) = file {
let path = Path::new(file_arg);
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.");
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);
}
let (artist_mbid, seed_name) = if let Some(query) = artist {
match resolve_artist(&artists, query) {
Some((mbid, name)) => {
eprintln!("Matched: {name}");
(mbid, name)
}
None => {
eprintln!("No artist matching \"{query}\"");
std::process::exit(1);
}
}
} else {
match tui::run_artist_picker(&artists) {
Some(selection) => selection,
None => std::process::exit(0),

View File

@@ -22,14 +22,6 @@ impl Tag {
}
}
fn read_tag(path: &Path, key: ItemKey) -> 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(key).map(String::from))
}
/// 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> {
@@ -43,11 +35,4 @@ pub fn read_tags(path: &Path, keys: &[Tag]) -> Result<Vec<Option<String>>, lofty
.collect())
}
pub fn read_artist_name(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
read_tag(path, ItemKey::TrackArtist)
}
pub fn read_artist_mbid(path: &Path) -> Result<Option<String>, lofty::error::LoftyError> {
read_tag(path, ItemKey::MusicBrainzArtistId)
}

View File

@@ -8,7 +8,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() {