Compare commits

...

10 Commits

Author SHA1 Message Date
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 1146 additions and 563 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
/target /target
.env .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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -48,6 +98,52 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -94,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"
@@ -179,6 +292,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -195,6 +314,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -308,6 +433,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -349,22 +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 = [
"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"
@@ -619,6 +734,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -689,6 +810,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "playlists" name = "drift"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
@@ -14,3 +14,4 @@ rand = "0.9"
walkdir = "2.5" walkdir = "2.5"
crossterm = "0.28" crossterm = "0.28"
urlencoding = "2.1.3" 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,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)

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}; 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> { pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
conn.execute_batch( conn.execute_batch(
@@ -62,7 +68,7 @@ pub fn get_available_similar_artists(
pub fn get_local_tracks_for_artist( pub fn get_local_tracks_for_artist(
conn: &Connection, conn: &Connection,
artist_mbid: &str, artist_mbid: &str,
) -> Result<Vec<(String, Option<String>, Option<String>)>, rusqlite::Error> { ) -> Result<Vec<LocalTrack>, rusqlite::Error> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1", "SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1",
)?; )?;
@@ -114,6 +120,17 @@ pub fn get_top_tracks_by_name(
rows.collect() 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( pub fn insert_track(
conn: &Connection, conn: &Connection,
path: &str, path: &str,
@@ -128,6 +145,23 @@ pub fn insert_track(
Ok(()) 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( pub fn insert_artist_with_similar(
conn: &Connection, conn: &Connection,
mbid: &str, mbid: &str,

View File

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

View File

@@ -1,17 +1,87 @@
mod airsonic;
mod db; mod db;
mod filesystem; mod filesystem;
mod lastfm; mod lastfm;
mod metadata; mod metadata;
mod mpd; mod mpd;
mod playlist;
mod tui; mod tui;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::env; 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 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,
},
/// 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 { fn db_path() -> PathBuf {
let data_dir = env::var("XDG_DATA_HOME") let data_dir = env::var("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
@@ -19,42 +89,28 @@ 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 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);
} }
fn main() { fn main() {
let args: Vec<String> = env::args().collect(); let cli = Cli::parse();
if args.len() < 2 { match cli.command {
usage(&args[0]); Command::Index { verbose, force, directory } => {
cmd_index(verbose, force, &directory);
}
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]) { fn cmd_index(verbose: u8, force: bool, directory: &str) {
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);
}
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let api_key = env::var("LASTFM_API_KEY").unwrap_or_default(); let api_key = env::var("LASTFM_API_KEY").unwrap_or_default();
@@ -65,19 +121,47 @@ fn cmd_index(args: &[String]) {
let conn = db::open(&db_path()).expect("failed to open database"); let conn = db::open(&db_path()).expect("failed to open database");
let lastfm = lastfm::LastfmClient::new(api_key); let lastfm = lastfm::LastfmClient::new(api_key);
let dir = Path::new(rest[0].as_str());
for path in filesystem::walk_music_files(dir) { let dir = std::fs::canonicalize(directory).unwrap_or_else(|e| {
let artist_mbid = match metadata::read_artist_mbid(&path) { eprintln!("Error: cannot resolve directory {directory}: {e}");
Ok(Some(mbid)) => mbid, std::process::exit(1);
Ok(None) => continue, });
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) => { 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; 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) { let already_indexed = match db::artist_exists(&conn, &artist_mbid) {
Ok(exists) => exists, Ok(exists) => exists,
@@ -87,11 +171,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); let display_name = artist_name.as_deref().unwrap_or(&artist_mbid);
if !already_indexed || force { if !already_indexed || force {
if verbose { if verbose >= 1 {
println!("Indexing {display_name}..."); println!("Indexing {display_name}...");
} }
@@ -123,114 +206,138 @@ fn cmd_index(args: &[String]) {
eprintln!("Last.fm top tracks error for {display_name}: {e}"); 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)"); 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()) { 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()); eprintln!("DB error inserting track {}: {e}", path.display());
}
}
}
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");
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; 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);
} }
}, tracks_added += 1;
None => {
eprintln!("Error: -n requires a value"); if verbose >= 2 {
std::process::exit(1); 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 { } else {
rest.push(arg); 0
};
if verbose >= 1 {
println!(
"Done: {artists_indexed} artists indexed, {artists_skipped} skipped, {tracks_added} tracks added, {tracks_stale} stale tracks removed"
);
} }
} }
if rest.len() > 1 { fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String, String)> {
eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]); let q = query.to_lowercase();
std::process::exit(1);
// 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_args: Vec<String>) {
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) = rest.first() { let all_artists = match db::get_all_artists(&conn) {
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) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
eprintln!("DB error: {e}"); eprintln!("DB error: {e}");
std::process::exit(1); std::process::exit(1);
} }
}; };
if artists.is_empty() { if all_artists.is_empty() {
eprintln!("No artists in database. Run 'index' first."); eprintln!("No artists in database. Run 'index' first.");
std::process::exit(1); std::process::exit(1);
} }
match tui::run_artist_picker(&artists) {
Some(selection) => selection, let seeds: Vec<(String, String)> = if artist_args.is_empty() {
None => std::process::exit(0), 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, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random); build_playlist(&conn, &seeds, &opts);
} }
fn build_playlist( fn build_playlist(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
artist_mbid: &str, seeds: &[(String, String)],
seed_name: &str, opts: &BuildOptions,
count: usize,
verbose: bool,
mpd: bool,
shuffle: bool,
random: bool,
) { ) {
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, Ok(a) => a,
Err(e) => { Err(e) => {
eprintln!("DB error: {e}"); eprintln!("DB error: {e}");
@@ -238,98 +345,59 @@ fn build_playlist(
} }
}; };
// Seed artist + similar artists: (mbid, name, match_score) for (mbid, name, score) in similar {
let mut artists: Vec<(String, String, f64)> = vec![ let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0));
(artist_mbid.to_string(), seed_name.to_string(), 1.0), entry.1 += score;
];
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()));
} }
} }
if verbose { let artists: Vec<(String, String, f64)> = merged
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
.into_iter() .into_iter()
.map(|(total, _, _, artist, path)| (total, artist, path)) .map(|(mbid, (name, total))| (mbid, name, total / num_seeds))
.collect(); .collect();
let mut selected = generate_playlist(&candidates, count, seed_name); let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
if random { if opts.verbose >= 1 {
selected.shuffle(&mut rand::rng()); let mut sorted = scored.iter().collect::<Vec<_>>();
} else if shuffle { sorted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
selected = interleave_artists(selected); 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(); let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
if music_dir.is_empty() { if music_dir.is_empty() {
eprintln!("Error: MPD_MUSIC_DIR not set"); eprintln!("Error: MPD_MUSIC_DIR not set");
@@ -342,132 +410,22 @@ fn build_playlist(
std::process::exit(1); 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 { } else {
for track in &tracks { for track in tracks {
println!("{track}"); 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 std::path::Path;
use lofty::file::TaggedFileExt; use lofty::file::TaggedFileExt;
use lofty::tag::{ItemKey, ItemValue}; use lofty::tag::ItemKey;
/// A single key-value metadata item from a tag. /// Tags that can be read from a music file.
pub struct TagEntry { pub enum Tag {
pub key: String, ArtistName,
pub value: String, ArtistMbid,
TrackTitle,
TrackMbid,
} }
/// Read all metadata items from a music file. impl Tag {
/// Returns `None` if no tags are present, otherwise a list of all tag entries. fn item_key(&self) -> ItemKey {
pub fn read_all_metadata(path: &Path) -> Result<Option<Vec<TagEntry>>, lofty::error::LoftyError> { 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 tagged_file = lofty::read_from_path(path)?;
let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else { let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
return Ok(None); return Ok(vec![None; keys.len()]);
}; };
Ok(keys
let entries = tag .iter()
.items() .map(|k| tag.get_string(k.item_key()).map(String::from))
.filter_map(|item| { .collect())
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))
} }
/// 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,3 +1,4 @@
use std::collections::HashSet;
use std::io::{self, Write}; use std::io::{self, Write};
use crossterm::{ use crossterm::{
@@ -8,7 +9,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() {
@@ -23,67 +24,80 @@ fn fuzzy_match(query: &str, name: &str) -> bool {
true true
} }
pub fn run_artist_picker(artists: &[(String, String)]) -> Option<(String, String)> { pub fn run_artist_picker(artists: &[(String, String)]) -> Vec<(String, String)> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
terminal::enable_raw_mode().ok()?; if terminal::enable_raw_mode().is_err() {
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).ok()?; return Vec::new();
}
let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide);
let result = picker_loop(&mut stdout, artists); let result = picker_loop(&mut stdout, artists);
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).ok(); let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
terminal::disable_raw_mode().ok(); let _ = terminal::disable_raw_mode();
result result
} }
fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<(String, String)> { fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Vec<(String, String)> {
let mut query = String::new(); let mut query = String::new();
let mut selected: usize = 0; let mut cursor: usize = 0;
let mut scroll: usize = 0; let mut scroll: usize = 0;
let mut toggled: HashSet<usize> = HashSet::new(); // indices into `artists`
loop { loop {
let (w, h) = terminal::size().ok()?; let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else {
let w = w as usize; return Vec::new();
let h = h as usize; };
if h < 2 { if h < 2 {
continue; continue;
} }
let q = query.to_lowercase(); let q = query.to_lowercase();
let filtered: Vec<&(String, String)> = if q.is_empty() { // Each entry is (original index, &(mbid, name))
artists.iter().collect() let filtered: Vec<(usize, &(String, String))> = if q.is_empty() {
artists.iter().enumerate().collect()
} else { } else {
artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect() artists
.iter()
.enumerate()
.filter(|(_, (_, name))| fuzzy_match(&q, name))
.collect()
}; };
if filtered.is_empty() { if filtered.is_empty() {
selected = 0; cursor = 0;
} else { } else {
selected = selected.min(filtered.len() - 1); cursor = cursor.min(filtered.len() - 1);
} }
// Scroll bounds // Scroll bounds
let list_h = h - 1; let list_h = h - 1;
if selected < scroll { if cursor < scroll {
scroll = selected; scroll = cursor;
} }
if selected >= scroll + list_h { if cursor >= scroll + list_h {
scroll = selected - list_h + 1; scroll = cursor - list_h + 1;
} }
// Draw // Draw
execute!(stdout, terminal::Clear(ClearType::All)).ok(); let _ = execute!(stdout, terminal::Clear(ClearType::All));
// Prompt line // Prompt line
let prompt = format!(" > {query}"); let prompt = format!(" > {query}");
let sel_str = if toggled.is_empty() {
String::new()
} else {
format!(" [{}]", toggled.len())
};
let count_str = format!(" {}/{}", filtered.len(), artists.len()); let count_str = format!(" {}/{}", filtered.len(), artists.len());
execute!( let _ = execute!(
stdout, stdout,
cursor::MoveTo(0, 0), cursor::MoveTo(0, 0),
style::PrintStyledContent(prompt.as_str().cyan().bold()), style::PrintStyledContent(prompt.as_str().cyan().bold()),
style::PrintStyledContent(sel_str.as_str().yellow()),
style::PrintStyledContent(count_str.as_str().dark_grey()), style::PrintStyledContent(count_str.as_str().dark_grey()),
) );
.ok();
// Artist list // Artist list
for i in 0..list_h { for i in 0..list_h {
@@ -91,66 +105,91 @@ fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<
if idx >= filtered.len() { if idx >= filtered.len() {
break; break;
} }
let (_, name) = filtered[idx]; let (orig_idx, (_, name)) = filtered[idx];
let display: String = if name.len() >= w { let marker = if toggled.contains(&orig_idx) { "*" } else { " " };
name[..w].to_string() let display: String = if name.len() + 2 >= w {
format!("{marker}{}", &name[..w.saturating_sub(2)])
} else { } else {
format!(" {name}") format!("{marker} {name}")
}; };
execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok(); let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16));
if idx == selected { if idx == cursor {
execute!( let _ = execute!(
stdout, stdout,
style::PrintStyledContent(display.as_str().black().on_cyan()), style::PrintStyledContent(display.as_str().black().on_cyan()),
) );
.ok(); } else if toggled.contains(&orig_idx) {
let _ = execute!(
stdout,
style::PrintStyledContent(display.as_str().cyan()),
);
} else { } else {
execute!(stdout, style::Print(&display)).ok(); let _ = execute!(stdout, style::Print(&display));
} }
} }
stdout.flush().ok(); let _ = stdout.flush();
// Input // Input
let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else { let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
continue; continue;
}; };
match code { match code {
KeyCode::Esc => return None, KeyCode::Esc => return Vec::new(),
KeyCode::Enter => { KeyCode::Enter => {
if !filtered.is_empty() { if !filtered.is_empty() {
let (mbid, name) = filtered[selected]; if toggled.is_empty() {
return Some((mbid.clone(), name.clone())); // No toggles — return just the highlighted item
let (_, (mbid, name)) = filtered[cursor];
return vec![(mbid.clone(), name.clone())];
} else {
// Return all toggled items in original order
let mut indices: Vec<usize> = toggled.into_iter().collect();
indices.sort();
return indices
.into_iter()
.map(|i| artists[i].clone())
.collect();
}
}
}
KeyCode::Tab => {
if !filtered.is_empty() {
let (orig_idx, _) = filtered[cursor];
if !toggled.remove(&orig_idx) {
toggled.insert(orig_idx);
}
// Advance cursor
if cursor + 1 < filtered.len() {
cursor += 1;
}
} }
} }
KeyCode::Up => { KeyCode::Up => {
selected = selected.saturating_sub(1); cursor = cursor.saturating_sub(1);
} }
KeyCode::Down => { KeyCode::Down => {
if !filtered.is_empty() { if !filtered.is_empty() && cursor + 1 < filtered.len() {
selected = selected.min(filtered.len().saturating_sub(1)); cursor += 1;
if selected + 1 < filtered.len() {
selected += 1;
}
} }
} }
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
selected = selected.saturating_sub(1); cursor = cursor.saturating_sub(1);
} }
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
if !filtered.is_empty() && selected + 1 < filtered.len() { if !filtered.is_empty() && cursor + 1 < filtered.len() {
selected += 1; cursor += 1;
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => {
query.pop(); query.pop();
selected = 0; cursor = 0;
scroll = 0; scroll = 0;
} }
KeyCode::Char(ch) => { KeyCode::Char(ch) => {
query.push(ch); query.push(ch);
selected = 0; cursor = 0;
scroll = 0; scroll = 0;
} }
_ => {} _ => {}