Added exact artist matching
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
playlists.db
|
||||
drift.db
|
||||
|
||||
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "playlists"
|
||||
name = "drift"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
72
README.md
Normal file
72
README.md
Normal 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, 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.
|
||||
@@ -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)
|
||||
|
||||
93
src/main.rs
93
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<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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user