Added cli multi-artist
This commit is contained in:
@@ -42,12 +42,15 @@ Flags:
|
|||||||
drift build
|
drift build
|
||||||
```
|
```
|
||||||
|
|
||||||
Opens an interactive picker to choose a seed artist. Or pass an artist name directly:
|
Opens an interactive picker to choose a seed artist. Or pass one or more artist names directly:
|
||||||
|
|
||||||
```
|
```
|
||||||
drift build "Radiohead"
|
drift build "Radiohead"
|
||||||
|
drift build "Radiohead" "Portishead"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With multiple seeds, artists similar to several seeds rank higher — the playlist blends their neighborhoods naturally.
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
- `-n 30` — number of tracks (default 20)
|
- `-n 30` — number of tracks (default 20)
|
||||||
- `-p 8` — popularity bias, 0–10 (default 5, higher = prefer popular tracks)
|
- `-p 8` — popularity bias, 0–10 (default 5, higher = prefer popular tracks)
|
||||||
|
|||||||
72
src/main.rs
72
src/main.rs
@@ -7,7 +7,7 @@ mod mpd;
|
|||||||
mod playlist;
|
mod playlist;
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ enum Command {
|
|||||||
/// Popularity bias (0=no preference, 10=heavy popular bias)
|
/// Popularity bias (0=no preference, 10=heavy popular bias)
|
||||||
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
|
#[arg(short, default_value_t = 5, value_parser = clap::value_parser!(u8).range(0..=10))]
|
||||||
popularity: u8,
|
popularity: u8,
|
||||||
/// Artist name to seed (or pick interactively)
|
/// Artist name(s) to seed (or pick interactively)
|
||||||
artist: Option<String>,
|
artists: Vec<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ fn main() {
|
|||||||
Command::Index { verbose, force, directory } => {
|
Command::Index { verbose, force, directory } => {
|
||||||
cmd_index(verbose, force, &directory);
|
cmd_index(verbose, force, &directory);
|
||||||
}
|
}
|
||||||
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artist } => {
|
Command::Build { verbose, mpd, airsonic, shuffle, random, count, popularity, artists } => {
|
||||||
let opts = BuildOptions {
|
let opts = BuildOptions {
|
||||||
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
|
verbose, mpd, airsonic, shuffle, random, count, popularity_bias: popularity,
|
||||||
};
|
};
|
||||||
cmd_build(opts, artist.as_deref());
|
cmd_build(opts, artists);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,24 +280,32 @@ fn resolve_artist(artists: &[(String, String)], query: &str) -> Option<(String,
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_build(opts: BuildOptions, artist: Option<&str>) {
|
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 artists = match db::get_all_artists(&conn) {
|
let all_artists = match db::get_all_artists(&conn) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("DB error: {e}");
|
eprintln!("DB error: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if artists.is_empty() {
|
if all_artists.is_empty() {
|
||||||
eprintln!("No artists in database. Run 'index' first.");
|
eprintln!("No artists in database. Run 'index' first.");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (artist_mbid, seed_name) = if let Some(query) = artist {
|
let seeds: Vec<(String, String)> = if artist_args.is_empty() {
|
||||||
match resolve_artist(&artists, query) {
|
match tui::run_artist_picker(&all_artists) {
|
||||||
|
Some(selection) => vec![selection],
|
||||||
|
None => std::process::exit(0),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
artist_args
|
||||||
|
.iter()
|
||||||
|
.map(|query| {
|
||||||
|
match resolve_artist(&all_artists, query) {
|
||||||
Some((mbid, name)) => {
|
Some((mbid, name)) => {
|
||||||
eprintln!("Matched: {name}");
|
eprintln!("Matched: {name}");
|
||||||
(mbid, name)
|
(mbid, name)
|
||||||
@@ -307,23 +315,28 @@ fn cmd_build(opts: BuildOptions, artist: Option<&str>) {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
match tui::run_artist_picker(&artists) {
|
.collect()
|
||||||
Some(selection) => selection,
|
|
||||||
None => std::process::exit(0),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
build_playlist(&conn, &artist_mbid, &seed_name, &opts);
|
build_playlist(&conn, &seeds, &opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_playlist(
|
fn build_playlist(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
artist_mbid: &str,
|
seeds: &[(String, String)],
|
||||||
seed_name: &str,
|
|
||||||
opts: &BuildOptions,
|
opts: &BuildOptions,
|
||||||
) {
|
) {
|
||||||
let similar = match db::get_available_similar_artists(conn, artist_mbid) {
|
// Merge similar artists from all seeds: mbid → (name, total_score, count)
|
||||||
|
let mut merged: HashMap<String, (String, f64, usize)> = HashMap::new();
|
||||||
|
|
||||||
|
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, 0));
|
||||||
|
entry.1 += 1.0;
|
||||||
|
entry.2 += 1;
|
||||||
|
|
||||||
|
let similar = match db::get_available_similar_artists(conn, seed_mbid) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("DB error: {e}");
|
eprintln!("DB error: {e}");
|
||||||
@@ -331,10 +344,17 @@ fn build_playlist(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut artists: Vec<(String, String, f64)> = vec![
|
for (mbid, name, score) in similar {
|
||||||
(artist_mbid.to_string(), seed_name.to_string(), 1.0),
|
let entry = merged.entry(mbid).or_insert_with(|| (name, 0.0, 0));
|
||||||
];
|
entry.1 += score;
|
||||||
artists.extend(similar);
|
entry.2 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let artists: Vec<(String, String, f64)> = merged
|
||||||
|
.into_iter()
|
||||||
|
.map(|(mbid, (name, total, count))| (mbid, name, total / count as f64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
|
let scored = playlist::score_tracks(conn, &artists, opts.popularity_bias);
|
||||||
|
|
||||||
@@ -355,7 +375,8 @@ fn build_playlist(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut selected = playlist::generate_playlist(&candidates, opts.count, seed_name);
|
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 {
|
if opts.random {
|
||||||
selected.shuffle(&mut rand::rng());
|
selected.shuffle(&mut rand::rng());
|
||||||
@@ -365,7 +386,8 @@ fn build_playlist(
|
|||||||
|
|
||||||
let tracks: Vec<String> = selected.into_iter().map(|c| c.path).collect();
|
let tracks: Vec<String> = selected.into_iter().map(|c| c.path).collect();
|
||||||
|
|
||||||
output_tracks(&tracks, opts, seed_name, conn);
|
let display_name = seeds.iter().map(|(_, name)| name.as_str()).collect::<Vec<_>>().join(" + ");
|
||||||
|
output_tracks(&tracks, opts, &display_name, conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn output_tracks(
|
fn output_tracks(
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ pub fn score_tracks(
|
|||||||
pub fn generate_playlist(
|
pub fn generate_playlist(
|
||||||
candidates: &[Candidate],
|
candidates: &[Candidate],
|
||||||
n: usize,
|
n: usize,
|
||||||
seed_name: &str,
|
seed_names: &HashSet<String>,
|
||||||
) -> Vec<Candidate> {
|
) -> Vec<Candidate> {
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -132,7 +132,10 @@ pub fn generate_playlist(
|
|||||||
let artist_cap = n.div_ceil(divisor).max(1);
|
let artist_cap = n.div_ceil(divisor).max(1);
|
||||||
|
|
||||||
while result.len() < n && !pool.is_empty() {
|
while result.len() < n && !pool.is_empty() {
|
||||||
let seed_count = *artist_counts.get(seed_name).unwrap_or(&0);
|
let seed_count: usize = seed_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| *artist_counts.get(name).unwrap_or(&0))
|
||||||
|
.sum();
|
||||||
let remaining = n - result.len();
|
let remaining = n - result.len();
|
||||||
let seed_deficit = seed_min.saturating_sub(seed_count);
|
let seed_deficit = seed_min.saturating_sub(seed_count);
|
||||||
let force_seed = seed_deficit > 0 && remaining <= seed_deficit;
|
let force_seed = seed_deficit > 0 && remaining <= seed_deficit;
|
||||||
@@ -142,7 +145,7 @@ pub fn generate_playlist(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, c)| {
|
.filter(|(_, c)| {
|
||||||
if force_seed {
|
if force_seed {
|
||||||
c.artist == seed_name
|
seed_names.contains(&c.artist)
|
||||||
} else {
|
} else {
|
||||||
*artist_counts.get(&c.artist).unwrap_or(&0) < artist_cap
|
*artist_counts.get(&c.artist).unwrap_or(&0) < artist_cap
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user