Added exact artist matching
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "drift"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"crossterm",
|
||||||
|
"dotenvy",
|
||||||
|
"lofty",
|
||||||
|
"rand",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"ureq",
|
||||||
|
"urlencoding",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -463,23 +480,6 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "playlists"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"clap",
|
|
||||||
"crossterm",
|
|
||||||
"dotenvy",
|
|
||||||
"lofty",
|
|
||||||
"rand",
|
|
||||||
"rusqlite",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"ureq",
|
|
||||||
"urlencoding",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "playlists"
|
name = "drift"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
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
|
#!/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 curses
|
||||||
import os
|
import os
|
||||||
@@ -9,9 +9,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
def find_db():
|
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")
|
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():
|
if p.exists():
|
||||||
return str(p)
|
return str(p)
|
||||||
print(f"Could not find {p}", file=sys.stderr)
|
print(f"Could not find {p}", file=sys.stderr)
|
||||||
|
|||||||
75
src/main.rs
75
src/main.rs
@@ -16,7 +16,7 @@ use rand::prelude::*;
|
|||||||
use playlist::Candidate;
|
use playlist::Candidate;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "playlists")]
|
#[command(name = "drift")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -58,8 +58,8 @@ enum Command {
|
|||||||
/// Popularity bias (0=no preference, 10=heavy popular bias)
|
/// Popularity bias (0=no preference, 10=heavy popular bias)
|
||||||
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
|
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
|
||||||
popularity: u8,
|
popularity: u8,
|
||||||
/// Seed file (or pick interactively)
|
/// Artist name to seed (or pick interactively)
|
||||||
file: Option<String>,
|
artist: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +88,9 @@ fn db_path() -> PathBuf {
|
|||||||
let home = env::var("HOME").expect("HOME not set");
|
let home = env::var("HOME").expect("HOME not set");
|
||||||
PathBuf::from(home).join(".local/share")
|
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");
|
std::fs::create_dir_all(&dir).expect("failed to create data directory");
|
||||||
dir.join("playlists.db")
|
dir.join("drift.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -100,11 +100,11 @@ fn main() {
|
|||||||
Command::Index { verbose, force, directory } => {
|
Command::Index { verbose, force, directory } => {
|
||||||
cmd_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 {
|
let opts = BuildOptions {
|
||||||
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
|
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
|
||||||
};
|
};
|
||||||
cmd_build(opts, file.as_deref());
|
cmd_build(opts, artist.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,29 +195,37 @@ 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();
|
dotenvy::dotenv().ok();
|
||||||
let conn = db::open(&db_path()).expect("failed to open database");
|
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) {
|
let artists = match db::get_all_artists(&conn) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -229,6 +237,19 @@ fn cmd_build(opts: BuildOptions, file: Option<&str>) {
|
|||||||
eprintln!("No artists in database. Run 'index' first.");
|
eprintln!("No artists in database. Run 'index' first.");
|
||||||
std::process::exit(1);
|
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) {
|
match tui::run_artist_picker(&artists) {
|
||||||
Some(selection) => selection,
|
Some(selection) => selection,
|
||||||
None => std::process::exit(0),
|
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.
|
/// Read multiple tags from a music file in a single file open.
|
||||||
/// Returns a Vec in the same order as the input keys.
|
/// 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> {
|
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())
|
.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},
|
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 name_lower = name.to_lowercase();
|
||||||
let mut chars = name_lower.chars();
|
let mut chars = name_lower.chars();
|
||||||
for qch in query.chars() {
|
for qch in query.chars() {
|
||||||
|
|||||||
Reference in New Issue
Block a user