shuffle
This commit is contained in:
96
src/main.rs
96
src/main.rs
@@ -2,6 +2,7 @@ mod db;
|
|||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
|
mod mpd;
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -14,7 +15,7 @@ use rand::prelude::*;
|
|||||||
fn usage(program: &str) -> ! {
|
fn usage(program: &str) -> ! {
|
||||||
eprintln!("Usage:");
|
eprintln!("Usage:");
|
||||||
eprintln!(" {program} index [-v] <directory>");
|
eprintln!(" {program} index [-v] <directory>");
|
||||||
eprintln!(" {program} build [-v] [-n COUNT] [file]");
|
eprintln!(" {program} build [-v] [-m] [-s|-r] [-n COUNT] [file]");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +112,21 @@ fn cmd_index(args: &[String]) {
|
|||||||
|
|
||||||
fn cmd_build(args: &[String]) {
|
fn cmd_build(args: &[String]) {
|
||||||
let verbose = args.iter().any(|a| a == "-v");
|
let verbose = args.iter().any(|a| a == "-v");
|
||||||
|
let mpd = args.iter().any(|a| a == "-m");
|
||||||
|
let shuffle = args.iter().any(|a| a == "-s");
|
||||||
|
let random = args.iter().any(|a| a == "-r");
|
||||||
|
|
||||||
|
if shuffle && random {
|
||||||
|
eprintln!("Error: -s and -r are mutually exclusive");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse -n COUNT
|
// Parse -n COUNT
|
||||||
let mut count: usize = 20;
|
let mut count: usize = 20;
|
||||||
let mut rest: Vec<&String> = Vec::new();
|
let mut rest: Vec<&String> = Vec::new();
|
||||||
let mut iter = args.iter().skip(2);
|
let mut iter = args.iter().skip(2);
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
if arg == "-v" {
|
if arg == "-v" || arg == "-m" || arg == "-s" || arg == "-r" {
|
||||||
continue;
|
continue;
|
||||||
} else if arg == "-n" {
|
} else if arg == "-n" {
|
||||||
match iter.next() {
|
match iter.next() {
|
||||||
@@ -139,7 +148,7 @@ fn cmd_build(args: &[String]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rest.len() > 1 {
|
if rest.len() > 1 {
|
||||||
eprintln!("Usage: {} build [-v] [-n COUNT] [file]", args[0]);
|
eprintln!("Usage: {} build [-v] [-m] [-s|-r] [-n COUNT] [file]", args[0]);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +198,7 @@ fn cmd_build(args: &[String]) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
build_playlist(&conn, &lastfm, &artist_mbid, &seed_name, count, verbose);
|
build_playlist(&conn, &lastfm, &artist_mbid, &seed_name, count, verbose, mpd, shuffle, random);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_playlist(
|
fn build_playlist(
|
||||||
@@ -199,6 +208,9 @@ fn build_playlist(
|
|||||||
seed_name: &str,
|
seed_name: &str,
|
||||||
count: usize,
|
count: usize,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
|
mpd: bool,
|
||||||
|
shuffle: bool,
|
||||||
|
random: bool,
|
||||||
) {
|
) {
|
||||||
let similar = match db::get_available_similar_artists(conn, artist_mbid) {
|
let similar = match db::get_available_similar_artists(conn, artist_mbid) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
@@ -290,10 +302,34 @@ fn build_playlist(
|
|||||||
.map(|(total, _, _, artist, path)| (total, artist, path))
|
.map(|(total, _, _, artist, path)| (total, artist, path))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let selected = generate_playlist(&candidates, count);
|
let mut selected = generate_playlist(&candidates, count);
|
||||||
|
|
||||||
for (_, _, track_path) in &selected {
|
if random {
|
||||||
println!("{track_path}");
|
selected.shuffle(&mut rand::rng());
|
||||||
|
} else if shuffle {
|
||||||
|
selected = interleave_artists(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks: Vec<String> = selected.iter().map(|(_, _, p)| p.clone()).collect();
|
||||||
|
|
||||||
|
if mpd {
|
||||||
|
let music_dir = env::var("MPD_MUSIC_DIR").unwrap_or_default();
|
||||||
|
if music_dir.is_empty() {
|
||||||
|
eprintln!("Error: MPD_MUSIC_DIR not set");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let mut client = match mpd::MpdClient::connect() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("MPD error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.queue_playlist(&tracks, &music_dir);
|
||||||
|
} else {
|
||||||
|
for track in &tracks {
|
||||||
|
println!("{track}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,3 +396,49 @@ fn generate_playlist(
|
|||||||
|
|
||||||
result
|
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
|
||||||
|
}
|
||||||
|
|||||||
111
src/mpd.rs
Normal file
111
src/mpd.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
pub struct MpdClient {
|
||||||
|
reader: BufReader<TcpStream>,
|
||||||
|
stream: TcpStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdClient {
|
||||||
|
pub fn connect() -> Result<Self, String> {
|
||||||
|
let host = std::env::var("MPD_HOST").unwrap_or_else(|_| "127.0.0.1".into());
|
||||||
|
let port = std::env::var("MPD_PORT").unwrap_or_else(|_| "6600".into());
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
|
||||||
|
let stream = TcpStream::connect(&addr).map_err(|e| format!("connect to {addr}: {e}"))?;
|
||||||
|
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
|
||||||
|
let mut client = MpdClient { reader, stream };
|
||||||
|
|
||||||
|
// Consume greeting line ("OK MPD ...")
|
||||||
|
let mut greeting = String::new();
|
||||||
|
client
|
||||||
|
.reader
|
||||||
|
.read_line(&mut greeting)
|
||||||
|
.map_err(|e| format!("read greeting: {e}"))?;
|
||||||
|
if !greeting.starts_with("OK MPD") {
|
||||||
|
return Err(format!("unexpected greeting: {greeting}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_command(&mut self, cmd: &str) -> Result<(), String> {
|
||||||
|
self.stream
|
||||||
|
.write_all(format!("{cmd}\n").as_bytes())
|
||||||
|
.map_err(|e| format!("write '{cmd}': {e}"))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
self.reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| format!("read response to '{cmd}': {e}"))?;
|
||||||
|
if line == "OK\n" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if line.starts_with("ACK") {
|
||||||
|
return Err(line.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `update` and wait for the updating_db job to finish.
|
||||||
|
fn update_and_wait(&mut self) -> Result<(), String> {
|
||||||
|
self.send_command("update")?;
|
||||||
|
// Poll `status` until `updating_db` key disappears.
|
||||||
|
loop {
|
||||||
|
let status = self.send_command_read("status")?;
|
||||||
|
if !status.iter().any(|l| l.starts_with("updating_db:")) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a command and return all response lines (before OK/ACK).
|
||||||
|
fn send_command_read(&mut self, cmd: &str) -> Result<Vec<String>, String> {
|
||||||
|
self.stream
|
||||||
|
.write_all(format!("{cmd}\n").as_bytes())
|
||||||
|
.map_err(|e| format!("write '{cmd}': {e}"))?;
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
self.reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| format!("read response to '{cmd}': {e}"))?;
|
||||||
|
if line == "OK\n" {
|
||||||
|
return Ok(lines);
|
||||||
|
}
|
||||||
|
if line.starts_with("ACK") {
|
||||||
|
return Err(line.trim().to_string());
|
||||||
|
}
|
||||||
|
lines.push(line.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queue_playlist(&mut self, tracks: &[String], music_dir: &str) {
|
||||||
|
if let Err(e) = self.update_and_wait() {
|
||||||
|
eprintln!("MPD update: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.send_command("clear") {
|
||||||
|
eprintln!("MPD clear: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in tracks {
|
||||||
|
let uri = track
|
||||||
|
.strip_prefix(music_dir)
|
||||||
|
.map(|p| p.trim_start_matches('/'))
|
||||||
|
.unwrap_or(track);
|
||||||
|
let escaped = uri.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
if let Err(e) = self.send_command(&format!("add \"{escaped}\"")) {
|
||||||
|
eprintln!("MPD add {uri}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.send_command("play") {
|
||||||
|
eprintln!("MPD play: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user