Compare commits
12 Commits
60a1d704dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b15a651d | ||
|
|
3a3290be37 | ||
|
|
aa3b8c4478 | ||
|
|
552caff9b8 | ||
|
|
38dea156d4 | ||
|
|
0c45d8957a | ||
|
|
2ffdce4fbc | ||
|
|
70aedb49f2 | ||
|
|
d59235707d | ||
|
|
8eb6bb950e | ||
|
|
98e3367822 | ||
|
|
51ededc612 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
/target
|
||||
.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"
|
||||
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]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -48,6 +98,52 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -94,6 +190,23 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "drift"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"dotenvy",
|
||||
"lofty",
|
||||
"rand",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
"urlencoding",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -179,6 +292,12 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -195,6 +314,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -308,6 +433,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -349,22 +480,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "playlists"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"dotenvy",
|
||||
"lofty",
|
||||
"rand",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
"urlencoding",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -619,6 +734,12 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -689,6 +810,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "playlists"
|
||||
name = "drift"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -14,3 +14,4 @@ rand = "0.9"
|
||||
walkdir = "2.5"
|
||||
crossterm = "0.28"
|
||||
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,226 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fuzzy search artists in playlists.db and show their similar artists."""
|
||||
|
||||
import curses
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_db():
|
||||
"""Find playlists.db in XDG data dir."""
|
||||
data_home = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
|
||||
p = Path(data_home) / "playlists" / "playlists.db"
|
||||
if p.exists():
|
||||
return str(p)
|
||||
print(f"Could not find {p}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_artists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT a.mbid, COALESCE(a.name, a.mbid) FROM artists a "
|
||||
"LEFT JOIN tracks t ON t.artist_mbid = a.mbid "
|
||||
"GROUP BY a.mbid ORDER BY COUNT(t.path) DESC, a.name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return rows # [(mbid, display_name), ...]
|
||||
|
||||
|
||||
def get_similar(db_path, mbid):
|
||||
conn = sqlite3.connect(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT similar_name, match_score FROM similar_artists "
|
||||
"WHERE artist_mbid = ?1 ORDER BY match_score DESC",
|
||||
(mbid,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_top_tracks(db_path, mbid):
|
||||
conn = sqlite3.connect(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT t.path, tt.playcount FROM tracks t "
|
||||
"JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid "
|
||||
" AND (LOWER(t.title) = tt.name_lower "
|
||||
" OR t.recording_mbid = tt.recording_mbid) "
|
||||
"WHERE t.artist_mbid = ?1 "
|
||||
"ORDER BY tt.playcount DESC",
|
||||
(mbid,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_local_track_count(db_path, mbid):
|
||||
conn = sqlite3.connect(db_path)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM tracks WHERE artist_mbid = ?1", (mbid,)
|
||||
).fetchone()[0]
|
||||
matched = conn.execute(
|
||||
"SELECT COUNT(*) FROM tracks t "
|
||||
"JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid "
|
||||
" AND (LOWER(t.title) = tt.name_lower "
|
||||
" OR t.recording_mbid = tt.recording_mbid) "
|
||||
"WHERE t.artist_mbid = ?1",
|
||||
(mbid,),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
return total, matched
|
||||
|
||||
|
||||
def fuzzy_match(query, name):
|
||||
"""Simple fuzzy: all query chars appear in order in name."""
|
||||
name_lower = name.lower()
|
||||
qi = 0
|
||||
for ch in name_lower:
|
||||
if qi < len(query) and ch == query[qi]:
|
||||
qi += 1
|
||||
return qi == len(query)
|
||||
|
||||
|
||||
def run_tui(stdscr, db_path):
|
||||
curses.curs_set(0)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
||||
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(3, curses.COLOR_WHITE, -1)
|
||||
|
||||
artists = load_artists(db_path)
|
||||
query = ""
|
||||
selected = 0
|
||||
scroll = 0
|
||||
|
||||
while True:
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# filter
|
||||
q = query.lower()
|
||||
if q:
|
||||
filtered = [(m, n) for m, n in artists if fuzzy_match(q, n)]
|
||||
else:
|
||||
filtered = artists
|
||||
|
||||
selected = max(0, min(selected, len(filtered) - 1))
|
||||
|
||||
# prompt
|
||||
prompt = f" > {query}"
|
||||
stdscr.addnstr(0, 0, prompt, w, curses.color_pair(2) | curses.A_BOLD)
|
||||
count_str = f" {len(filtered)}/{len(artists)}"
|
||||
if len(prompt) + len(count_str) < w:
|
||||
stdscr.addstr(0, len(prompt), count_str, curses.color_pair(3))
|
||||
|
||||
# artist list
|
||||
list_h = h - 1
|
||||
if list_h < 1:
|
||||
stdscr.refresh()
|
||||
continue
|
||||
|
||||
if selected < scroll:
|
||||
scroll = selected
|
||||
if selected >= scroll + list_h:
|
||||
scroll = selected - list_h + 1
|
||||
|
||||
for i in range(list_h):
|
||||
idx = scroll + i
|
||||
if idx >= len(filtered):
|
||||
break
|
||||
_, name = filtered[idx]
|
||||
attr = curses.color_pair(1) if idx == selected else curses.A_NORMAL
|
||||
stdscr.addnstr(i + 1, 0, f" {name}", w, attr)
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
key = stdscr.get_wch()
|
||||
|
||||
if key == "\x1b": # Esc
|
||||
return
|
||||
elif key == curses.KEY_UP or key == "\x10": # Up / Ctrl-P
|
||||
selected = max(0, selected - 1)
|
||||
elif key == curses.KEY_DOWN or key == "\x0e": # Down / Ctrl-N
|
||||
selected = min(len(filtered) - 1, selected + 1)
|
||||
elif key == "\n" or key == curses.KEY_ENTER:
|
||||
if filtered:
|
||||
show_similar(stdscr, db_path, filtered[selected])
|
||||
elif key in (curses.KEY_BACKSPACE, "\x7f", "\x08"):
|
||||
query = query[:-1]
|
||||
selected = 0
|
||||
scroll = 0
|
||||
elif isinstance(key, str) and key.isprintable():
|
||||
query += key
|
||||
selected = 0
|
||||
scroll = 0
|
||||
|
||||
|
||||
def show_similar(stdscr, db_path, artist):
|
||||
mbid, name = artist
|
||||
similar = get_similar(db_path, mbid)
|
||||
top = get_top_tracks(db_path, mbid)
|
||||
total_local, matched_local = get_local_track_count(db_path, mbid)
|
||||
|
||||
curses.curs_set(0)
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
mid = w // 2
|
||||
|
||||
title_l = f" Similar to {name}"
|
||||
title_r = f" Top tracks ({matched_local}/{total_local} matched)"
|
||||
stdscr.addnstr(0, 0, title_l, mid, curses.color_pair(2) | curses.A_BOLD)
|
||||
stdscr.addnstr(0, mid, title_r, w - mid, curses.color_pair(2) | curses.A_BOLD)
|
||||
stdscr.addnstr(h - 1, 0, " [q] back", w, curses.color_pair(3))
|
||||
|
||||
scroll_l = 0
|
||||
scroll_r = 0
|
||||
list_h = h - 2
|
||||
|
||||
while True:
|
||||
# Left pane: similar artists
|
||||
for i in range(list_h):
|
||||
stdscr.move(i + 1, 0)
|
||||
stdscr.clrtoeol()
|
||||
idx = scroll_l + i
|
||||
if idx < len(similar):
|
||||
sname, score = similar[idx]
|
||||
line = f" {score:5.2f} {sname}"
|
||||
stdscr.addnstr(i + 1, 0, line, mid)
|
||||
|
||||
# Right pane: top tracks
|
||||
for i in range(list_h):
|
||||
idx = scroll_r + i
|
||||
if idx < len(top):
|
||||
path, playcount = top[idx]
|
||||
# Show just the filename without extension
|
||||
fname = Path(path).stem
|
||||
# Strip "Artist - " prefix if present
|
||||
if " - " in fname:
|
||||
fname = fname.split(" - ", 1)[1]
|
||||
line = f" {playcount:>8} {fname}"
|
||||
stdscr.addnstr(i + 1, mid, line, w - mid)
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
|
||||
if key in ("q", "Q", "\x1b"):
|
||||
return
|
||||
elif key == curses.KEY_UP or key == "\x10":
|
||||
scroll_l = max(0, scroll_l - 1)
|
||||
scroll_r = max(0, scroll_r - 1)
|
||||
elif key == curses.KEY_DOWN or key == "\x0e":
|
||||
if scroll_l + list_h < len(similar):
|
||||
scroll_l += 1
|
||||
if scroll_r + list_h < len(top):
|
||||
scroll_r += 1
|
||||
|
||||
|
||||
def main():
|
||||
db_path = find_db()
|
||||
curses.wrapper(run_tui, db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/db.rs
90
src/db.rs
@@ -2,6 +2,12 @@ use rusqlite::Connection;
|
||||
|
||||
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> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
@@ -62,7 +68,7 @@ pub fn get_available_similar_artists(
|
||||
pub fn get_local_tracks_for_artist(
|
||||
conn: &Connection,
|
||||
artist_mbid: &str,
|
||||
) -> Result<Vec<(String, Option<String>, Option<String>)>, rusqlite::Error> {
|
||||
) -> Result<Vec<LocalTrack>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT path, recording_mbid, title FROM tracks WHERE artist_mbid = ?1",
|
||||
)?;
|
||||
@@ -70,16 +76,64 @@ pub fn get_local_tracks_for_artist(
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_all_artists(conn: &Connection) -> Result<Vec<(String, String)>, rusqlite::Error> {
|
||||
pub fn get_all_artists(conn: &Connection) -> Result<Vec<(String, String, u32)>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT a.mbid, COALESCE(a.name, a.mbid) FROM artists a \
|
||||
"SELECT a.mbid, COALESCE(a.name, a.mbid), COUNT(t.path) FROM artists a \
|
||||
LEFT JOIN tracks t ON t.artist_mbid = a.mbid \
|
||||
GROUP BY a.mbid ORDER BY COUNT(t.path) DESC, a.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_all_similar_artists(
|
||||
conn: &Connection,
|
||||
artist_mbid: &str,
|
||||
) -> Result<Vec<(String, f64)>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT similar_name, match_score FROM similar_artists \
|
||||
WHERE artist_mbid = ?1 ORDER BY match_score DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([artist_mbid], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_local_top_tracks(
|
||||
conn: &Connection,
|
||||
artist_mbid: &str,
|
||||
) -> Result<Vec<(String, u64)>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.path, tt.playcount FROM tracks t \
|
||||
JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid \
|
||||
AND (LOWER(t.title) = tt.name_lower OR t.recording_mbid = tt.recording_mbid) \
|
||||
WHERE t.artist_mbid = ?1 ORDER BY tt.playcount DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([artist_mbid], |row| {
|
||||
Ok((row.get(0)?, row.get::<_, i64>(1)? as u64))
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_local_track_count(
|
||||
conn: &Connection,
|
||||
artist_mbid: &str,
|
||||
) -> Result<(u32, u32), rusqlite::Error> {
|
||||
let total: u32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM tracks WHERE artist_mbid = ?1",
|
||||
[artist_mbid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let matched: u32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM tracks t \
|
||||
JOIN top_tracks tt ON tt.artist_mbid = t.artist_mbid \
|
||||
AND (LOWER(t.title) = tt.name_lower OR t.recording_mbid = tt.recording_mbid) \
|
||||
WHERE t.artist_mbid = ?1",
|
||||
[artist_mbid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((total, matched))
|
||||
}
|
||||
|
||||
pub fn insert_top_tracks(
|
||||
conn: &Connection,
|
||||
artist_mbid: &str,
|
||||
@@ -114,6 +168,17 @@ pub fn get_top_tracks_by_name(
|
||||
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(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
@@ -128,6 +193,23 @@ pub fn insert_track(
|
||||
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(
|
||||
conn: &Connection,
|
||||
mbid: &str,
|
||||
|
||||
149
src/lastfm.rs
149
src/lastfm.rs
@@ -16,15 +16,14 @@ pub struct TopTrack {
|
||||
pub name: String,
|
||||
pub mbid: Option<String>,
|
||||
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)]
|
||||
struct ApiError {
|
||||
#[allow(dead_code)]
|
||||
error: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
// Deserialization structs for the Last.fm API responses
|
||||
@@ -62,7 +61,6 @@ struct TrackEntry {
|
||||
name: String,
|
||||
mbid: Option<String>,
|
||||
playcount: String,
|
||||
listeners: String,
|
||||
}
|
||||
|
||||
impl LastfmClient {
|
||||
@@ -87,12 +85,9 @@ impl LastfmClient {
|
||||
extra_params: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
if let Some(name) = artist_name {
|
||||
let name = name.replace('\u{2010}', "-")
|
||||
.replace('\u{2011}', "-")
|
||||
.replace('\u{2012}', "-")
|
||||
.replace('\u{2013}', "-")
|
||||
.replace('\u{2014}', "-")
|
||||
.replace('\u{2015}', "-");
|
||||
let name = name
|
||||
.replace(['\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}'], "-")
|
||||
.replace(['\u{2018}', '\u{2019}'], "'");
|
||||
let encoded = urlencoding::encode(&name);
|
||||
let url = format!(
|
||||
"{}?method={}&artist={}&api_key={}{}&format=json",
|
||||
@@ -103,23 +98,52 @@ impl LastfmClient {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Try fetching by MBID first, fall back to artist name.
|
||||
fn fetch_with_fallback(
|
||||
/// Try MBID lookup then name lookup, returning whichever yields more results.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn dual_lookup<T>(
|
||||
&self,
|
||||
method: &str,
|
||||
artist_mbid: &str,
|
||||
artist_name: Option<&str>,
|
||||
extra_params: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
parse: fn(&str) -> Result<Vec<T>, Box<dyn std::error::Error>>,
|
||||
) -> Result<Vec<T>, Box<dyn std::error::Error>> {
|
||||
let mbid_url = format!(
|
||||
"{}?method={}&mbid={}&api_key={}{}&format=json",
|
||||
BASE_URL, method, artist_mbid, self.api_key, extra_params
|
||||
);
|
||||
if let Some(body) = self.fetch_or_none(&url)? {
|
||||
return Ok(Some(body));
|
||||
let mbid_results = match self.fetch_or_none(&mbid_url)? {
|
||||
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(
|
||||
@@ -127,51 +151,27 @@ impl LastfmClient {
|
||||
artist_mbid: &str,
|
||||
artist_name: Option<&str>,
|
||||
) -> Result<Vec<SimilarArtist>, Box<dyn std::error::Error>> {
|
||||
let Some(body) = self.fetch_with_fallback(
|
||||
self.dual_lookup(
|
||||
"artist.getSimilar",
|
||||
artist_mbid,
|
||||
artist_name,
|
||||
"&limit=500",
|
||||
)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Self::parse_similar_artists,
|
||||
)
|
||||
}
|
||||
|
||||
let resp: SimilarArtistsResponse = serde_json::from_str(&body)?;
|
||||
let results: Vec<SimilarArtist> = resp
|
||||
.similarartists
|
||||
.artist
|
||||
fn parse_top_tracks(body: &str) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
||||
let resp: TopTracksResponse = serde_json::from_str(body)?;
|
||||
Ok(resp
|
||||
.toptracks
|
||||
.track
|
||||
.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),
|
||||
}
|
||||
.map(|t| TopTrack {
|
||||
name: t.name,
|
||||
mbid: t.mbid.filter(|s| !s.is_empty()),
|
||||
playcount: t.playcount.parse().unwrap_or(0),
|
||||
})
|
||||
.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)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_top_tracks(
|
||||
@@ -179,45 +179,12 @@ impl LastfmClient {
|
||||
artist_mbid: &str,
|
||||
artist_name: Option<&str>,
|
||||
) -> Result<Vec<TopTrack>, Box<dyn std::error::Error>> {
|
||||
let Some(body) = self.fetch_with_fallback(
|
||||
self.dual_lookup(
|
||||
"artist.getTopTracks",
|
||||
artist_mbid,
|
||||
artist_name,
|
||||
"&limit=1000",
|
||||
)? else {
|
||||
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)
|
||||
Self::parse_top_tracks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
624
src/main.rs
624
src/main.rs
@@ -1,17 +1,89 @@
|
||||
mod airsonic;
|
||||
mod db;
|
||||
mod filesystem;
|
||||
mod lastfm;
|
||||
mod metadata;
|
||||
mod mpd;
|
||||
mod playlist;
|
||||
mod tui;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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 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,
|
||||
},
|
||||
/// Browse artists, similar artists, and top tracks
|
||||
Search {},
|
||||
/// 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 {
|
||||
let data_dir = env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
@@ -19,42 +91,31 @@ fn db_path() -> PathBuf {
|
||||
let home = env::var("HOME").expect("HOME not set");
|
||||
PathBuf::from(home).join(".local/share")
|
||||
});
|
||||
let dir = data_dir.join("playlists");
|
||||
let dir = data_dir.join("drift");
|
||||
std::fs::create_dir_all(&dir).expect("failed to create data directory");
|
||||
dir.join("playlists.db")
|
||||
}
|
||||
|
||||
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);
|
||||
dir.join("drift.db")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let cli = Cli::parse();
|
||||
|
||||
if args.len() < 2 {
|
||||
usage(&args[0]);
|
||||
match cli.command {
|
||||
Command::Index { verbose, force, directory } => {
|
||||
cmd_index(verbose, force, &directory);
|
||||
}
|
||||
Command::Search {} => {
|
||||
cmd_search();
|
||||
}
|
||||
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]) {
|
||||
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);
|
||||
}
|
||||
|
||||
fn cmd_index(verbose: u8, force: bool, directory: &str) {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let api_key = env::var("LASTFM_API_KEY").unwrap_or_default();
|
||||
@@ -65,19 +126,47 @@ fn cmd_index(args: &[String]) {
|
||||
|
||||
let conn = db::open(&db_path()).expect("failed to open database");
|
||||
let lastfm = lastfm::LastfmClient::new(api_key);
|
||||
let dir = Path::new(rest[0].as_str());
|
||||
|
||||
for path in filesystem::walk_music_files(dir) {
|
||||
let artist_mbid = match metadata::read_artist_mbid(&path) {
|
||||
Ok(Some(mbid)) => mbid,
|
||||
Ok(None) => continue,
|
||||
let dir = std::fs::canonicalize(directory).unwrap_or_else(|e| {
|
||||
eprintln!("Error: cannot resolve directory {directory}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
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!("{}: 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;
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
Ok(exists) => exists,
|
||||
@@ -87,11 +176,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);
|
||||
|
||||
if !already_indexed || force {
|
||||
if verbose {
|
||||
if verbose >= 1 {
|
||||
println!("Indexing {display_name}...");
|
||||
}
|
||||
|
||||
@@ -123,114 +211,159 @@ fn cmd_index(args: &[String]) {
|
||||
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)");
|
||||
}
|
||||
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()) {
|
||||
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]) {
|
||||
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");
|
||||
fn resolve_artist(artists: &[(String, String, u32)], query: &str) -> Option<(String, String)> {
|
||||
let q = query.to_lowercase();
|
||||
|
||||
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;
|
||||
} 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);
|
||||
// Tier 1: exact case-insensitive match
|
||||
for (mbid, name, _) in artists {
|
||||
if name.to_lowercase() == q {
|
||||
return Some((mbid.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if rest.len() > 1 {
|
||||
eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]);
|
||||
std::process::exit(1);
|
||||
// Tier 2: contains case-insensitive
|
||||
for (mbid, name, _) in artists {
|
||||
if name.to_lowercase().contains(&q) {
|
||||
return Some((mbid.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
// 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_search() {
|
||||
let conn = db::open(&db_path()).expect("failed to open database");
|
||||
|
||||
let (artist_mbid, seed_name) = if let Some(file_arg) = rest.first() {
|
||||
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) {
|
||||
let all_artists = match db::get_all_artists(&conn) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("DB error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if artists.is_empty() {
|
||||
if all_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),
|
||||
tui::run_search_flow(&all_artists, |mbid| {
|
||||
let similar = db::get_all_similar_artists(&conn, mbid).unwrap_or_default();
|
||||
let top = db::get_local_top_tracks(&conn, mbid).unwrap_or_default();
|
||||
let counts = db::get_local_track_count(&conn, mbid).unwrap_or((0, 0));
|
||||
(similar, top, counts)
|
||||
});
|
||||
}
|
||||
|
||||
fn cmd_build(opts: BuildOptions, artist_args: Vec<String>) {
|
||||
dotenvy::dotenv().ok();
|
||||
let conn = db::open(&db_path()).expect("failed to open database");
|
||||
|
||||
let all_artists = match db::get_all_artists(&conn) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("DB error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if all_artists.is_empty() {
|
||||
eprintln!("No artists in database. Run 'index' first.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
build_playlist(&conn, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random);
|
||||
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
|
||||
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, &seeds, &opts);
|
||||
}
|
||||
|
||||
fn build_playlist(
|
||||
conn: &rusqlite::Connection,
|
||||
artist_mbid: &str,
|
||||
seed_name: &str,
|
||||
count: usize,
|
||||
verbose: bool,
|
||||
mpd: bool,
|
||||
shuffle: bool,
|
||||
random: bool,
|
||||
seeds: &[(String, String)],
|
||||
opts: &BuildOptions,
|
||||
) {
|
||||
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,
|
||||
Err(e) => {
|
||||
eprintln!("DB error: {e}");
|
||||
@@ -238,98 +371,59 @@ fn build_playlist(
|
||||
}
|
||||
};
|
||||
|
||||
// Seed artist + similar artists: (mbid, name, match_score)
|
||||
let mut artists: Vec<(String, String, f64)> = vec![
|
||||
(artist_mbid.to_string(), seed_name.to_string(), 1.0),
|
||||
];
|
||||
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()));
|
||||
for (mbid, name, score) in similar {
|
||||
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0));
|
||||
entry.1 += score;
|
||||
}
|
||||
}
|
||||
|
||||
if verbose {
|
||||
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
|
||||
let artists: Vec<(String, String, f64)> = merged
|
||||
.into_iter()
|
||||
.map(|(total, _, _, artist, path)| (total, artist, path))
|
||||
.map(|(mbid, (name, total))| (mbid, name, total / num_seeds))
|
||||
.collect();
|
||||
|
||||
let mut selected = generate_playlist(&candidates, count, seed_name);
|
||||
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
|
||||
|
||||
if random {
|
||||
selected.shuffle(&mut rand::rng());
|
||||
} else if shuffle {
|
||||
selected = interleave_artists(selected);
|
||||
if opts.verbose >= 1 {
|
||||
let mut sorted = scored.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
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();
|
||||
if music_dir.is_empty() {
|
||||
eprintln!("Error: MPD_MUSIC_DIR not set");
|
||||
@@ -342,132 +436,22 @@ fn build_playlist(
|
||||
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 {
|
||||
for track in &tracks {
|
||||
for track in tracks {
|
||||
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 lofty::file::TaggedFileExt;
|
||||
use lofty::tag::{ItemKey, ItemValue};
|
||||
use lofty::tag::ItemKey;
|
||||
|
||||
/// A single key-value metadata item from a tag.
|
||||
pub struct TagEntry {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
/// Tags that can be read from a music file.
|
||||
pub enum Tag {
|
||||
ArtistName,
|
||||
ArtistMbid,
|
||||
TrackTitle,
|
||||
TrackMbid,
|
||||
}
|
||||
|
||||
/// Read all metadata items from a music file.
|
||||
/// Returns `None` if no tags are present, otherwise a list of all tag entries.
|
||||
pub fn read_all_metadata(path: &Path) -> Result<Option<Vec<TagEntry>>, lofty::error::LoftyError> {
|
||||
impl Tag {
|
||||
fn item_key(&self) -> ItemKey {
|
||||
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 Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else {
|
||||
return Ok(None);
|
||||
return Ok(vec![None; keys.len()]);
|
||||
};
|
||||
|
||||
let entries = tag
|
||||
.items()
|
||||
.filter_map(|item| {
|
||||
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))
|
||||
Ok(keys
|
||||
.iter()
|
||||
.map(|k| tag.get_string(k.item_key()).map(String::from))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
374
src/tui.rs
374
src/tui.rs
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use crossterm::{
|
||||
cursor,
|
||||
@@ -8,7 +10,7 @@ use crossterm::{
|
||||
terminal::{self, ClearType},
|
||||
};
|
||||
|
||||
fn fuzzy_match(query: &str, name: &str) -> bool {
|
||||
pub fn fuzzy_match(query: &str, name: &str) -> bool {
|
||||
let name_lower = name.to_lowercase();
|
||||
let mut chars = name_lower.chars();
|
||||
for qch in query.chars() {
|
||||
@@ -23,135 +25,369 @@ fn fuzzy_match(query: &str, name: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn run_artist_picker(artists: &[(String, String)]) -> Option<(String, String)> {
|
||||
let mut stdout = io::stdout();
|
||||
terminal::enable_raw_mode().ok()?;
|
||||
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).ok()?;
|
||||
|
||||
let result = picker_loop(&mut stdout, artists);
|
||||
|
||||
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).ok();
|
||||
terminal::disable_raw_mode().ok();
|
||||
|
||||
result
|
||||
enum PickerResult {
|
||||
Selected(Vec<(String, String)>),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
fn picker_loop(stdout: &mut io::Stdout, artists: &[(String, String)]) -> Option<(String, String)> {
|
||||
let mut query = String::new();
|
||||
let mut selected: usize = 0;
|
||||
let mut scroll: usize = 0;
|
||||
pub fn run_artist_picker(artists: &[(String, String, u32)]) -> Vec<(String, String)> {
|
||||
let mut stdout = io::stdout();
|
||||
if terminal::enable_raw_mode().is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide);
|
||||
|
||||
let result = picker_loop(&mut stdout, artists, true);
|
||||
|
||||
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
|
||||
let _ = terminal::disable_raw_mode();
|
||||
|
||||
match result {
|
||||
PickerResult::Selected(v) => v,
|
||||
PickerResult::Cancelled => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerState {
|
||||
query: String,
|
||||
cursor: usize,
|
||||
scroll: usize,
|
||||
}
|
||||
|
||||
fn picker_loop(
|
||||
stdout: &mut io::Stdout,
|
||||
artists: &[(String, String, u32)],
|
||||
allow_toggle: bool,
|
||||
) -> PickerResult {
|
||||
picker_loop_with_state(stdout, artists, allow_toggle, &mut PickerState {
|
||||
query: String::new(),
|
||||
cursor: 0,
|
||||
scroll: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn picker_loop_with_state(
|
||||
stdout: &mut io::Stdout,
|
||||
artists: &[(String, String, u32)],
|
||||
allow_toggle: bool,
|
||||
state: &mut PickerState,
|
||||
) -> PickerResult {
|
||||
let mut toggled: HashSet<usize> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let (w, h) = terminal::size().ok()?;
|
||||
let w = w as usize;
|
||||
let h = h as usize;
|
||||
let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else {
|
||||
return PickerResult::Cancelled;
|
||||
};
|
||||
if h < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let q = query.to_lowercase();
|
||||
let filtered: Vec<&(String, String)> = if q.is_empty() {
|
||||
artists.iter().collect()
|
||||
let q = state.query.to_lowercase();
|
||||
let filtered: Vec<(usize, &(String, String, u32))> = if q.is_empty() {
|
||||
artists.iter().enumerate().collect()
|
||||
} else {
|
||||
artists.iter().filter(|(_, name)| fuzzy_match(&q, name)).collect()
|
||||
artists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, name, _))| fuzzy_match(&q, name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
if filtered.is_empty() {
|
||||
selected = 0;
|
||||
state.cursor = 0;
|
||||
} else {
|
||||
selected = selected.min(filtered.len() - 1);
|
||||
state.cursor = state.cursor.min(filtered.len() - 1);
|
||||
}
|
||||
|
||||
// Scroll bounds
|
||||
let list_h = h - 1;
|
||||
if selected < scroll {
|
||||
scroll = selected;
|
||||
if state.cursor < state.scroll {
|
||||
state.scroll = state.cursor;
|
||||
}
|
||||
if selected >= scroll + list_h {
|
||||
scroll = selected - list_h + 1;
|
||||
if state.cursor >= state.scroll + list_h {
|
||||
state.scroll = state.cursor - list_h + 1;
|
||||
}
|
||||
|
||||
// Draw
|
||||
execute!(stdout, terminal::Clear(ClearType::All)).ok();
|
||||
let _ = execute!(stdout, terminal::Clear(ClearType::All));
|
||||
|
||||
// Prompt line
|
||||
let prompt = format!(" > {query}");
|
||||
let prompt = format!(" > {}", state.query);
|
||||
let sel_str = if toggled.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", toggled.len())
|
||||
};
|
||||
let count_str = format!(" {}/{}", filtered.len(), artists.len());
|
||||
execute!(
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
cursor::MoveTo(0, 0),
|
||||
style::PrintStyledContent(prompt.as_str().cyan().bold()),
|
||||
style::PrintStyledContent(sel_str.as_str().yellow()),
|
||||
style::PrintStyledContent(count_str.as_str().dark_grey()),
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
|
||||
// Artist list
|
||||
for i in 0..list_h {
|
||||
let idx = scroll + i;
|
||||
let idx = state.scroll + i;
|
||||
if idx >= filtered.len() {
|
||||
break;
|
||||
}
|
||||
let (_, name) = filtered[idx];
|
||||
let display: String = if name.len() >= w {
|
||||
name[..w].to_string()
|
||||
let (orig_idx, (_, name, track_count)) = filtered[idx];
|
||||
let marker = if toggled.contains(&orig_idx) { "*" } else { " " };
|
||||
let count_suffix = format!(" ({track_count})");
|
||||
let name_part: String = if name.len() + 2 + count_suffix.len() >= w {
|
||||
let max = w.saturating_sub(2 + count_suffix.len());
|
||||
format!("{marker} {}", &name[..max.min(name.len())])
|
||||
} else {
|
||||
format!(" {name}")
|
||||
format!("{marker} {name}")
|
||||
};
|
||||
execute!(stdout, cursor::MoveTo(0, (i + 1) as u16)).ok();
|
||||
if idx == selected {
|
||||
execute!(
|
||||
let _ = execute!(stdout, cursor::MoveTo(0, (i + 1) as u16));
|
||||
if idx == state.cursor {
|
||||
// Render name part highlighted, then count in dark_grey on_cyan
|
||||
let padded = format!("{name_part}{count_suffix}");
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
style::PrintStyledContent(display.as_str().black().on_cyan()),
|
||||
)
|
||||
.ok();
|
||||
style::PrintStyledContent(padded.as_str().black().on_cyan()),
|
||||
);
|
||||
} else if toggled.contains(&orig_idx) {
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
style::PrintStyledContent(name_part.as_str().cyan()),
|
||||
style::PrintStyledContent(count_suffix.as_str().dark_grey()),
|
||||
);
|
||||
} else {
|
||||
execute!(stdout, style::Print(&display)).ok();
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
style::Print(&name_part),
|
||||
style::PrintStyledContent(count_suffix.as_str().dark_grey()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stdout.flush().ok();
|
||||
let _ = stdout.flush();
|
||||
|
||||
// Input
|
||||
let Event::Key(KeyEvent { code, modifiers, .. }) = event::read().ok()? else {
|
||||
let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match code {
|
||||
KeyCode::Esc => return None,
|
||||
KeyCode::Esc => return PickerResult::Cancelled,
|
||||
KeyCode::Enter => {
|
||||
if !filtered.is_empty() {
|
||||
let (mbid, name) = filtered[selected];
|
||||
return Some((mbid.clone(), name.clone()));
|
||||
if toggled.is_empty() {
|
||||
let (_, (mbid, name, _)) = filtered[state.cursor];
|
||||
return PickerResult::Selected(vec![(mbid.clone(), name.clone())]);
|
||||
} else {
|
||||
let mut indices: Vec<usize> = toggled.into_iter().collect();
|
||||
indices.sort();
|
||||
return PickerResult::Selected(
|
||||
indices
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let (ref mbid, ref name, _) = artists[i];
|
||||
(mbid.clone(), name.clone())
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Tab if allow_toggle => {
|
||||
if !filtered.is_empty() {
|
||||
let (orig_idx, _) = filtered[state.cursor];
|
||||
if !toggled.remove(&orig_idx) {
|
||||
toggled.insert(orig_idx);
|
||||
}
|
||||
if state.cursor + 1 < filtered.len() {
|
||||
state.cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
selected = selected.saturating_sub(1);
|
||||
state.cursor = state.cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if !filtered.is_empty() {
|
||||
selected = selected.min(filtered.len().saturating_sub(1));
|
||||
if selected + 1 < filtered.len() {
|
||||
selected += 1;
|
||||
}
|
||||
if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
|
||||
state.cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
selected = selected.saturating_sub(1);
|
||||
state.cursor = state.cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if !filtered.is_empty() && selected + 1 < filtered.len() {
|
||||
selected += 1;
|
||||
if !filtered.is_empty() && state.cursor + 1 < filtered.len() {
|
||||
state.cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
query.pop();
|
||||
selected = 0;
|
||||
scroll = 0;
|
||||
state.query.pop();
|
||||
state.cursor = 0;
|
||||
state.scroll = 0;
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
query.push(ch);
|
||||
selected = 0;
|
||||
scroll = 0;
|
||||
state.query.push(ch);
|
||||
state.cursor = 0;
|
||||
state.scroll = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_search_flow(
|
||||
artists: &[(String, String, u32)],
|
||||
get_detail: impl Fn(&str) -> (Vec<(String, f64)>, Vec<(String, u64)>, (u32, u32)),
|
||||
) {
|
||||
let mut stdout = io::stdout();
|
||||
if terminal::enable_raw_mode().is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide);
|
||||
|
||||
let mut picker_state = PickerState {
|
||||
query: String::new(),
|
||||
cursor: 0,
|
||||
scroll: 0,
|
||||
};
|
||||
|
||||
loop {
|
||||
let result = picker_loop_with_state(&mut stdout, artists, false, &mut picker_state);
|
||||
match result {
|
||||
PickerResult::Cancelled => break,
|
||||
PickerResult::Selected(selected) => {
|
||||
if let Some((mbid, name)) = selected.into_iter().next() {
|
||||
let (similar, top_tracks, counts) = get_detail(&mbid);
|
||||
detail_view(&mut stdout, &name, &similar, &top_tracks, counts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
|
||||
fn track_display_name(path: &str) -> String {
|
||||
let stem = Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(path);
|
||||
// Strip "Artist - " prefix if present
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
stem[pos + 3..].to_string()
|
||||
} else {
|
||||
stem.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn detail_view(
|
||||
stdout: &mut io::Stdout,
|
||||
name: &str,
|
||||
similar: &[(String, f64)],
|
||||
top_tracks: &[(String, u64)],
|
||||
(total, matched): (u32, u32),
|
||||
) {
|
||||
let mut scroll: usize = 0;
|
||||
|
||||
loop {
|
||||
let Some((w, h)) = terminal::size().ok().map(|(w, h)| (w as usize, h as usize)) else {
|
||||
return;
|
||||
};
|
||||
if h < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = execute!(stdout, terminal::Clear(ClearType::All));
|
||||
|
||||
let mid = w / 2;
|
||||
let content_h = h - 2; // header + footer
|
||||
|
||||
// Header
|
||||
let left_header = format!(" Similar to {name}");
|
||||
let right_header = format!(" Top tracks ({matched}/{total} matched)");
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
cursor::MoveTo(0, 0),
|
||||
style::PrintStyledContent(left_header.as_str().cyan().bold()),
|
||||
cursor::MoveTo(mid as u16, 0),
|
||||
style::PrintStyledContent(right_header.as_str().cyan().bold()),
|
||||
);
|
||||
|
||||
// Left pane: similar artists
|
||||
for i in 0..content_h {
|
||||
let idx = scroll + i;
|
||||
if idx >= similar.len() {
|
||||
break;
|
||||
}
|
||||
let (ref sim_name, score) = similar[idx];
|
||||
let line = format!(" {score:5.2} {sim_name}");
|
||||
let truncated: String = if line.len() > mid.saturating_sub(1) {
|
||||
line[..mid.saturating_sub(1)].to_string()
|
||||
} else {
|
||||
line
|
||||
};
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
cursor::MoveTo(0, (i + 1) as u16),
|
||||
style::Print(truncated),
|
||||
);
|
||||
}
|
||||
|
||||
// Right pane: top tracks
|
||||
for i in 0..content_h {
|
||||
let idx = scroll + i;
|
||||
if idx >= top_tracks.len() {
|
||||
break;
|
||||
}
|
||||
let (ref path, playcount) = top_tracks[idx];
|
||||
let display = track_display_name(path);
|
||||
let count_str = format!(" {playcount}");
|
||||
let max_name = (w - mid).saturating_sub(count_str.len() + 3);
|
||||
let truncated_name: String = if display.len() > max_name {
|
||||
display[..max_name].to_string()
|
||||
} else {
|
||||
display
|
||||
};
|
||||
let line = format!(" {truncated_name}{count_str}");
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
cursor::MoveTo(mid as u16, (i + 1) as u16),
|
||||
style::Print(line),
|
||||
);
|
||||
}
|
||||
|
||||
// Footer
|
||||
let footer = " [q] back";
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
cursor::MoveTo(0, (h - 1) as u16),
|
||||
style::PrintStyledContent(footer.dark_grey()),
|
||||
);
|
||||
|
||||
let _ = stdout.flush();
|
||||
|
||||
let Ok(Event::Key(KeyEvent { code, modifiers, .. })) = event::read() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return,
|
||||
KeyCode::Up => {
|
||||
scroll = scroll.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max_items = similar.len().max(top_tracks.len());
|
||||
if max_items > 0 && scroll + content_h < max_items {
|
||||
scroll += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
scroll = scroll.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let max_items = similar.len().max(top_tracks.len());
|
||||
if max_items > 0 && scroll + content_h < max_items {
|
||||
scroll += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user