From 34977ea54babdc35c4685a15e50b1087835966c7 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Mon, 2 Mar 2026 23:30:54 -0500 Subject: [PATCH] shuffle --- src/main.rs | 96 +++++++++++++++++++++++++++++++++++++++++---- src/mpd.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/mpd.rs diff --git a/src/main.rs b/src/main.rs index 9ba6043..b4a752e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod db; mod filesystem; mod lastfm; mod metadata; +mod mpd; mod tui; use std::collections::HashMap; @@ -14,7 +15,7 @@ use rand::prelude::*; fn usage(program: &str) -> ! { eprintln!("Usage:"); eprintln!(" {program} index [-v] "); - eprintln!(" {program} build [-v] [-n COUNT] [file]"); + eprintln!(" {program} build [-v] [-m] [-s|-r] [-n COUNT] [file]"); std::process::exit(1); } @@ -111,13 +112,21 @@ fn cmd_index(args: &[String]) { fn cmd_build(args: &[String]) { let verbose = args.iter().any(|a| a == "-v"); + let mpd = args.iter().any(|a| a == "-m"); + let shuffle = args.iter().any(|a| a == "-s"); + let random = args.iter().any(|a| a == "-r"); + + if shuffle && random { + eprintln!("Error: -s and -r are mutually exclusive"); + std::process::exit(1); + } // Parse -n COUNT let mut count: usize = 20; let mut rest: Vec<&String> = Vec::new(); let mut iter = args.iter().skip(2); while let Some(arg) = iter.next() { - if arg == "-v" { + if arg == "-v" || arg == "-m" || arg == "-s" || arg == "-r" { continue; } else if arg == "-n" { match iter.next() { @@ -139,7 +148,7 @@ fn cmd_build(args: &[String]) { } 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); } @@ -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( @@ -199,6 +208,9 @@ fn build_playlist( seed_name: &str, count: usize, verbose: bool, + mpd: bool, + shuffle: bool, + random: bool, ) { let similar = match db::get_available_similar_artists(conn, artist_mbid) { Ok(a) => a, @@ -290,10 +302,34 @@ fn build_playlist( .map(|(total, _, _, artist, path)| (total, artist, path)) .collect(); - let selected = generate_playlist(&candidates, count); + let mut selected = generate_playlist(&candidates, count); - for (_, _, track_path) in &selected { - println!("{track_path}"); + if random { + selected.shuffle(&mut rand::rng()); + } else if shuffle { + selected = interleave_artists(selected); + } + + let tracks: Vec = 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 } + +/// 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> = 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 = None; + + while !by_artist.is_empty() { + // Sort artists by remaining count (descending), break ties randomly + let mut artists: Vec = 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 +} diff --git a/src/mpd.rs b/src/mpd.rs new file mode 100644 index 0000000..5e8e6be --- /dev/null +++ b/src/mpd.rs @@ -0,0 +1,111 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; + +pub struct MpdClient { + reader: BufReader, + stream: TcpStream, +} + +impl MpdClient { + pub fn connect() -> Result { + 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, 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}"); + } + } +}