Compare commits
10 Commits
60a1d704dd
...
aa3b8c4478
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa3b8c4478 | ||
|
|
552caff9b8 | ||
|
|
38dea156d4 | ||
|
|
0c45d8957a | ||
|
|
2ffdce4fbc | ||
|
|
70aedb49f2 | ||
|
|
d59235707d | ||
|
|
8eb6bb950e | ||
|
|
98e3367822 | ||
|
|
51ededc612 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
playlists.db
|
drift.db
|
||||||
|
|||||||
159
Cargo.lock
generated
159
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
79
README.md
Normal 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, 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)
|
||||||
|
|||||||
200
src/airsonic.rs
Normal file
200
src/airsonic.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/db.rs
36
src/db.rs
@@ -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,
|
||||||
|
|||||||
143
src/lastfm.rs
143
src/lastfm.rs
@@ -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,41 +98,40 @@ 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(),
|
||||||
|
|
||||||
self.fetch_by_name(method, artist_name, extra_params)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_similar_artists(
|
|
||||||
&self,
|
|
||||||
artist_mbid: &str,
|
|
||||||
artist_name: Option<&str>,
|
|
||||||
) -> Result<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
|
|
||||||
let Some(body) = self.fetch_with_fallback(
|
|
||||||
"artist.getSimilar",
|
|
||||||
artist_mbid,
|
|
||||||
artist_name,
|
|
||||||
"&limit=500",
|
|
||||||
)? else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp: SimilarArtistsResponse = serde_json::from_str(&body)?;
|
let name_results = match self.fetch_by_name(method, artist_name, extra_params)? {
|
||||||
let results: Vec<SimilarArtist> = resp
|
Some(body) => parse(&body).unwrap_or_default(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if name_results.len() > mbid_results.len() {
|
||||||
|
Ok(name_results)
|
||||||
|
} else {
|
||||||
|
Ok(mbid_results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
.similarartists
|
||||||
.artist
|
.artist
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -149,47 +143,26 @@ impl LastfmClient {
|
|||||||
match_score: a.match_score.parse().unwrap_or(0.0),
|
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_similar_artists(
|
||||||
&self,
|
&self,
|
||||||
artist_mbid: &str,
|
artist_mbid: &str,
|
||||||
artist_name: Option<&str>,
|
artist_name: Option<&str>,
|
||||||
) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
|
||||||
let Some(body) = self.fetch_with_fallback(
|
self.dual_lookup(
|
||||||
"artist.getTopTracks",
|
"artist.getSimilar",
|
||||||
artist_mbid,
|
artist_mbid,
|
||||||
artist_name,
|
artist_name,
|
||||||
"&limit=1000",
|
"&limit=500",
|
||||||
)? else {
|
Self::parse_similar_artists,
|
||||||
return Ok(Vec::new());
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
let resp: TopTracksResponse = serde_json::from_str(&body)?;
|
fn parse_top_tracks(body: &str) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
||||||
let results: Vec<TopTrack> = resp
|
let resp: TopTracksResponse = serde_json::from_str(body)?;
|
||||||
|
Ok(resp
|
||||||
.toptracks
|
.toptracks
|
||||||
.track
|
.track
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -197,27 +170,21 @@ impl LastfmClient {
|
|||||||
name: t.name,
|
name: t.name,
|
||||||
mbid: t.mbid.filter(|s| !s.is_empty()),
|
mbid: t.mbid.filter(|s| !s.is_empty()),
|
||||||
playcount: t.playcount.parse().unwrap_or(0),
|
playcount: t.playcount.parse().unwrap_or(0),
|
||||||
listeners: t.listeners.parse().unwrap_or(0),
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
if results.is_empty() {
|
pub fn get_top_tracks(
|
||||||
if let Some(body) = self.fetch_by_name("artist.getTopTracks", artist_name, "&limit=1000")? {
|
&self,
|
||||||
let resp: TopTracksResponse = serde_json::from_str(&body)?;
|
artist_mbid: &str,
|
||||||
return Ok(resp
|
artist_name: Option<&str>,
|
||||||
.toptracks
|
) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
||||||
.track
|
self.dual_lookup(
|
||||||
.into_iter()
|
"artist.getTopTracks",
|
||||||
.map(|t| TopTrack {
|
artist_mbid,
|
||||||
name: t.name,
|
artist_name,
|
||||||
mbid: t.mbid.filter(|s| !s.is_empty()),
|
"&limit=1000",
|
||||||
playcount: t.playcount.parse().unwrap_or(0),
|
Self::parse_top_tracks,
|
||||||
listeners: t.listeners.parse().unwrap_or(0),
|
)
|
||||||
})
|
|
||||||
.collect());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
620
src/main.rs
620
src/main.rs
@@ -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);
|
||||||
|
}
|
||||||
match args[1].as_str() {
|
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artists } => {
|
||||||
"index" => cmd_index(&args),
|
let opts = BuildOptions {
|
||||||
"build" => cmd_build(&args),
|
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
|
||||||
_ => usage(&args[0]),
|
};
|
||||||
|
cmd_build(opts, artists);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
eprintln!("{}: could not read artist MBID: {e}", path.display());
|
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,213 +206,198 @@ 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 {
|
|
||||||
println!("Skipping {display_name} (already indexed)");
|
artists_indexed += 1;
|
||||||
|
} else {
|
||||||
|
if verbose >= 3 {
|
||||||
|
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());
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
tracks_added += 1;
|
||||||
|
|
||||||
|
if verbose >= 2 {
|
||||||
|
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 {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
if verbose >= 1 {
|
||||||
|
println!(
|
||||||
|
"Done: {artists_indexed} artists indexed, {artists_skipped} skipped, {tracks_added} tracks added, {tracks_stale} stale tracks removed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_build(args: &[String]) {
|
fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String, String)> {
|
||||||
let verbose = args.iter().any(|a| a == "-v");
|
let q = query.to_lowercase();
|
||||||
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 {
|
// Tier 1: exact case-insensitive match
|
||||||
eprintln!("Error: -s and -r are mutually exclusive");
|
for (mbid, name) in artists {
|
||||||
std::process::exit(1);
|
if name.to_lowercase() == q {
|
||||||
}
|
return Some((mbid.clone(), name.clone()));
|
||||||
|
|
||||||
// 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;
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
eprintln!("Error: -n requires a value");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rest.push(arg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if rest.len() > 1 {
|
// Tier 2: contains case-insensitive
|
||||||
eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]);
|
for (mbid, name) in artists {
|
||||||
std::process::exit(1);
|
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,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
match tui::run_artist_picker(&artists) {
|
|
||||||
Some(selection) => selection,
|
|
||||||
None => std::process::exit(0),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_playlist(
|
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
artist_mbid: &str,
|
|
||||||
seed_name: &str,
|
|
||||||
count: usize,
|
|
||||||
verbose: bool,
|
|
||||||
mpd: bool,
|
|
||||||
shuffle: bool,
|
|
||||||
random: bool,
|
|
||||||
) {
|
|
||||||
let similar = match db::get_available_similar_artists(conn, artist_mbid) {
|
|
||||||
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 all_artists.is_empty() {
|
||||||
|
eprintln!("No artists in database. Run 'index' first.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Seed artist + similar artists: (mbid, name, match_score)
|
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
|
||||||
let mut artists: Vec<(String, String, f64)> = vec![
|
let picked = tui::run_artist_picker(&all_artists);
|
||||||
(artist_mbid.to_string(), seed_name.to_string(), 1.0),
|
if picked.is_empty() {
|
||||||
];
|
std::process::exit(0);
|
||||||
artists.extend(similar);
|
}
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
// Collect scored tracks: (total, popularity, match_score, artist_name, path)
|
build_playlist(&conn, &seeds, &opts);
|
||||||
let mut playlist: Vec<(f64, f64, f64, String, String)> = Vec::new();
|
}
|
||||||
|
|
||||||
for (mbid, name, match_score) in &artists {
|
fn build_playlist(
|
||||||
let local_tracks = match db::get_local_tracks_for_artist(conn, mbid) {
|
conn: &rusqlite::Connection,
|
||||||
Ok(t) => t,
|
seeds: &[(String, String)],
|
||||||
|
opts: &BuildOptions,
|
||||||
|
) {
|
||||||
|
// 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,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("DB error for {name}: {e}");
|
eprintln!("DB error: {e}");
|
||||||
continue;
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if local_tracks.is_empty() {
|
for (mbid, name, score) in similar {
|
||||||
continue;
|
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0));
|
||||||
}
|
entry.1 += score;
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
220
src/playlist.rs
Normal 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
|
||||||
|
}
|
||||||
147
src/tui.rs
147
src/tui.rs
@@ -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())];
|
||||||
KeyCode::Up => {
|
} else {
|
||||||
selected = selected.saturating_sub(1);
|
// Return all toggled items in original order
|
||||||
}
|
let mut indices: Vec<usize> = toggled.into_iter().collect();
|
||||||
KeyCode::Down => {
|
indices.sort();
|
||||||
if !filtered.is_empty() {
|
return indices
|
||||||
selected = selected.min(filtered.len().saturating_sub(1));
|
.into_iter()
|
||||||
if selected + 1 < filtered.len() {
|
.map(|i| artists[i].clone())
|
||||||
selected += 1;
|
.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 => {
|
||||||
|
cursor = cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if !filtered.is_empty() && cursor + 1 < filtered.len() {
|
||||||
|
cursor += 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;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user