From 2ffdce4fbc2999185388935b9f6d58a284ddc031 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 5 Mar 2026 11:31:53 -0500 Subject: [PATCH] Added exact artist matching --- .gitignore | 2 +- Cargo.lock | 34 ++++++++--------- Cargo.toml | 2 +- README.md | 72 ++++++++++++++++++++++++++++++++++++ scripts/search.py | 6 +-- src/main.rs | 93 +++++++++++++++++++++++++++++------------------ src/metadata.rs | 15 -------- src/tui.rs | 2 +- 8 files changed, 152 insertions(+), 74 deletions(-) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index 76e7bee..1d83362 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target .env -playlists.db +drift.db diff --git a/Cargo.lock b/Cargo.lock index 9bda948..3cd603d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 984a82a..23f604e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "playlists" +name = "drift" version = "0.1.0" edition = "2024" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f56525a --- /dev/null +++ b/README.md @@ -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, 0–10 (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. diff --git a/scripts/search.py b/scripts/search.py index df84258..c6bc4c6 100755 --- a/scripts/search.py +++ b/scripts/search.py @@ -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) diff --git a/src/main.rs b/src/main.rs index 9f50fbb..39b6f83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + /// Artist name to seed (or pick interactively) + artist: Option, }, } @@ -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), diff --git a/src/metadata.rs b/src/metadata.rs index 23ae3d2..d2e777d 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -22,14 +22,6 @@ impl Tag { } } -fn read_tag(path: &Path, key: ItemKey) -> Result, 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>, lofty::error::LoftyError> { @@ -43,11 +35,4 @@ pub fn read_tags(path: &Path, keys: &[Tag]) -> Result>, lofty .collect()) } -pub fn read_artist_name(path: &Path) -> Result, lofty::error::LoftyError> { - read_tag(path, ItemKey::TrackArtist) -} - -pub fn read_artist_mbid(path: &Path) -> Result, lofty::error::LoftyError> { - read_tag(path, ItemKey::MusicBrainzArtistId) -} diff --git a/src/tui.rs b/src/tui.rs index d74c53b..8be07fa 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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() {