aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/mp/mpdpopm/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs232
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs591
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs150
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/clients.rs1200
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/config.rs189
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs233
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/mod.rs28
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters.lalrpop160
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters_ast.rs1022
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/lib.rs185
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs140
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/playcounts.rs342
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/storage/mod.rs145
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/vars.rs4
14 files changed, 4621 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs
new file mode 100644
index 00000000..7ec89441
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs
@@ -0,0 +1,232 @@
+use clap::{Parser, Subcommand};
+use std::path::PathBuf;
+
+/// `mppopmd' client
+#[derive(Parser)]
+pub(crate) struct Args {
+ /// path to configuration file
+ #[arg(short, long)]
+ pub(crate) config: Option<PathBuf>,
+
+ /// enable verbose logging
+ #[arg(short, long)]
+ pub(crate) verbose: bool,
+
+ /// enable debug loggin (implies --verbose)
+ #[arg(short, long)]
+ pub(crate) debug: bool,
+
+ #[command(subcommand)]
+ pub(crate) command: SubCommand,
+}
+
+#[derive(Subcommand)]
+pub(crate) enum RatingCommand {
+ /// retrieve the rating for one or more tracks
+ ///
+ /// With no arguments, retrieve the rating of the current song & print it
+ /// on stdout. With one argument, retrieve that track's rating & print it
+ /// on stdout. With multiple arguments, print their ratings on stdout, one
+ /// per line, prefixed by the track name.
+ ///
+ /// Ratings are expressed as an integer between -128 & 128, exclusive, with
+ /// the convention that 0 denotes "un-rated".
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the rating for one track
+ ///
+ /// With one argument, set the rating of the current song to that argument.
+ /// With a second argument, rate that song at the first argument. Ratings
+ /// may be expressed a an integer between 0 & 255, inclusive.
+ #[clap(verbatim_doc_comment)]
+ Set { rating: i8, track: Option<String> },
+
+ /// increment the rating for one track
+ ///
+ /// With one argument, increment the rating of the current song.
+ /// With a second argument, rate that song at the first argument.
+ #[clap(verbatim_doc_comment)]
+ Inc { track: Option<String> },
+
+ /// decrement the rating for one track
+ ///
+ /// With one argument, decrement the rating of the current song.
+ /// With a second argument, rate that song at the first argument.
+ #[clap(verbatim_doc_comment)]
+ Decr { track: Option<String> },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum PlayCountCommand {
+ /// retrieve the play count for one or more tracks
+ ///
+ /// With no arguments, retrieve the play count of the current song & print it
+ /// on stdout. With one argument, retrieve that track's play count & print it
+ /// on stdout. With multiple arguments, print their play counts on stdout, one
+ /// per line, prefixed by the track name.
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the play count for one track
+ ///
+ /// With one argument, set the play count of the current song to that argument. With a
+ /// second argument, set the play count for that song to the first.
+ #[clap(verbatim_doc_comment)]
+ Set {
+ play_count: usize,
+ track: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum LastPlayedCommand {
+ /// retrieve the last played timestamp for one or more tracks
+ ///
+ /// With no arguments, retrieve the last played timestamp of the current
+ /// song & print it on stdout. With one argument, retrieve that track's
+ /// last played time & print it on stdout. With multiple arguments, print
+ /// their last played times on stdout, one per line, prefixed by the track
+ /// name.
+ ///
+ /// The last played timestamp is expressed in seconds since Unix epoch.
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the last played timestamp for one track
+ ///
+ /// With one argument, set the last played time of the current song. With two
+ /// arguments, set the last played time for the second argument to the first.
+ /// The last played timestamp is expressed in seconds since Unix epoch.
+ #[clap(verbatim_doc_comment)]
+ Set {
+ last_played: u64,
+ track: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum PlaylistsCommand {
+ /// retrieve the list of stored playlists
+ #[clap(verbatim_doc_comment)]
+ Get {},
+}
+
+#[derive(Subcommand)]
+pub(crate) enum DjCommand {
+ /// Activate the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ #[clap(verbatim_doc_comment)]
+ Start {
+ /// The chance to select a "positive" track
+ #[arg(long, default_value_t = 0.65)]
+ positive_chance: f64,
+
+ /// The chance to select a "neutral" track
+ #[arg(long, default_value_t = 0.5)]
+ neutral_chance: f64,
+
+ /// The chance to select a "negative" track
+ #[arg(long, default_value_t = 0.2)]
+ negative_chance: f64,
+ },
+
+ /// Deactivate the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ #[clap(verbatim_doc_comment)]
+ Stop {},
+}
+
+#[derive(Subcommand)]
+pub(crate) enum SubCommand {
+ /// Change details about rating.
+ Rating {
+ #[command(subcommand)]
+ command: RatingCommand,
+ },
+
+ /// Change details about play count.
+ PlayCount {
+ #[command(subcommand)]
+ command: PlayCountCommand,
+ },
+
+ /// Change details about last played date.
+ LastPlayed {
+ #[command(subcommand)]
+ command: LastPlayedCommand,
+ },
+
+ /// Change details about generated playlists.
+ Playlists {
+ #[command(subcommand)]
+ command: PlaylistsCommand,
+ },
+
+ /// search for songs matching matching a filter and add them to the queue
+ ///
+ /// This command extends the MPD command `searchadd' (which will search the MPD database) to allow
+ /// searches on attributes managed by mpdpopm: rating, playcount & last played time.
+ ///
+ /// The MPD `searchadd' <https://www.musicpd.org/doc/html/protocol.html#command-searchadd> will search
+ /// the MPD database for songs that match a given filter & add them to the play queue. The filter syntax
+ /// is documented here <https://www.musicpd.org/doc/html/protocol.html#filter-syntax>.
+ ///
+ /// This command adds three new terms on which you can filter: rating, playcount & lastplayed. Each is
+ /// expressed as an unsigned integer, with zero interpreted as "not set". For instance:
+ ///
+ /// mppopm searchadd "(rating > 2)"
+ ///
+ /// Will add all songs in the library with a rating sticker > 2 to the play queue.
+ ///
+ /// mppopm also introduces OR clauses (MPD only supports AND), so that:
+ ///
+ /// mppopm searchadd "((rating > 2) AND (artist =~ \"pogues\"))"
+ ///
+ /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than
+ /// 2.
+ #[clap(verbatim_doc_comment)]
+ Searchadd {
+ filter: String,
+
+ /// Respect the casing, when performing the filter evaluation.
+ #[arg(short, long, default_value_t = false)]
+ case_sensitive: bool,
+ },
+
+ /// Modify the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ Dj {
+ #[command(subcommand)]
+ command: DjCommand,
+ },
+
+ /// Show general stats about your music collection.
+ ///
+ /// This includes favorite artist, songs and also the negative ones.
+ Stats {},
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
new file mode 100644
index 00000000..42f01873
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
@@ -0,0 +1,591 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! # mppopm
+//!
+//! mppopmd client
+//!
+//! # Introduction
+//!
+//! `mppopmd` is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts &
+//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust
+//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as
+//! the sticker database, by invoking external commands to keep your tags up-to-date (something
+//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). `mppopm` is a command-line client
+//! for `mppopmd`. Run `mppopm --help` for detailed usage.
+
+use std::{collections::HashMap, io::stdout};
+
+use clap::Parser;
+use mpdpopm::{
+ clients::{Client, PlayerStatus},
+ config::{self, Config},
+ dj::algorithms::Discovery,
+ filters::ExpressionParser,
+ filters_ast::{FilterStickerNames, evaluate},
+ messanges::COMMAND_CHANNEL,
+ storage::{last_played, play_count, rating, skip_count},
+};
+
+use anyhow::{Context, Result, anyhow, bail};
+use ratatui::{
+ Terminal, TerminalOptions, Viewport,
+ crossterm::style::Stylize,
+ layout::HorizontalAlignment,
+ prelude::CrosstermBackend,
+ style::{Color, Style},
+ text::Line,
+ widgets::{Bar, BarChart, BarGroup, Block, Borders},
+};
+use tracing::{debug, info, level_filters::LevelFilter, trace};
+use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
+
+use crate::cli::{
+ Args, DjCommand, LastPlayedCommand, PlayCountCommand, PlaylistsCommand, RatingCommand,
+ SubCommand,
+};
+
+mod cli;
+
+/// Map `tracks' argument(s) to a Vec of String containing one or more mpd URIs
+///
+/// Several sub-commands take zero or more positional arguments meant to name tracks, with the
+/// convention that zero indicates that the sub-command should use the currently playing track.
+/// This is a convenience function for mapping the value returned by [`get_many`] to a
+/// convenient representation of the user's intentions.
+///
+/// [`get_many`]: [`clap::ArgMatches::get_many`]
+async fn map_tracks(client: &mut Client, args: Option<Vec<String>>) -> Result<Vec<String>> {
+ let files = match args {
+ Some(iter) => iter,
+ None => {
+ let file = provide_file(client, None).await?;
+ vec![file]
+ }
+ };
+ Ok(files)
+}
+
+async fn provide_file(client: &mut Client, maybe_file: Option<String>) -> Result<String> {
+ let file = match maybe_file {
+ Some(file) => file,
+ None => {
+ match client
+ .status()
+ .await
+ .context("Failed to get status of client")?
+ {
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr
+ .file
+ .to_str()
+ .ok_or_else(|| anyhow!("Path is not utf8: `{}`", curr.file.display()))?
+ .to_string(),
+ PlayerStatus::Stopped => {
+ bail!("Player is stopped");
+ }
+ }
+ }
+ };
+
+ Ok(file)
+}
+
+/// Retrieve ratings for one or more tracks
+async fn get_ratings(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut ratings: Vec<(String, i8)> = Vec::new();
+
+ for file in map_tracks(client, tracks).await? {
+ let rating = rating::get(client, &file).await?;
+
+ ratings.push((file, rating.unwrap_or_default()));
+ }
+
+ if ratings.len() == 1 && !with_uri {
+ println!("{}", ratings[0].1);
+ } else {
+ for pair in ratings {
+ println!("{}: {}", pair.0, pair.1);
+ }
+ }
+
+ Ok(())
+}
+
+/// Rate a track
+async fn set_rating(client: &mut Client, rating: i8, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ rating::set(client, &file, rating).await?;
+
+ match is_current {
+ false => info!("Set the rating for \"{}\" to \"{}\".", file, rating),
+ true => info!("Set the rating for the current song to \"{}\".", rating),
+ }
+
+ Ok(())
+}
+
+/// Rate a track by incrementing the current rating
+async fn inc_rating(client: &mut Client, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ let now = rating::get(client, &file).await?;
+
+ rating::set(client, &file, now.unwrap_or_default().saturating_add(1)).await?;
+
+ match is_current {
+ false => info!("Incremented the rating for \"{}\".", file),
+ true => info!("Incremented the rating for the current song."),
+ }
+
+ Ok(())
+}
+
+/// Rate a track by decrementing the current rating
+async fn decr_rating(client: &mut Client, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ let now = rating::get(client, &file).await?;
+
+ rating::set(client, &file, now.unwrap_or_default().saturating_sub(1)).await?;
+
+ match is_current {
+ false => info!("Decremented the rating for \"{}\".", file),
+ true => info!("Decremented the rating for the current song."),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the playcount for one or more tracks
+async fn get_play_counts(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut playcounts: Vec<(String, usize)> = Vec::new();
+ for file in map_tracks(client, tracks).await? {
+ let playcount = play_count::get(client, &file).await?.unwrap_or_default();
+ playcounts.push((file, playcount));
+ }
+
+ if playcounts.len() == 1 && !with_uri {
+ println!("{}", playcounts[0].1);
+ } else {
+ for pair in playcounts {
+ println!("{}: {}", pair.0, pair.1);
+ }
+ }
+
+ Ok(())
+}
+
+/// Set the playcount for a track
+async fn set_play_counts(client: &mut Client, playcount: usize, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ play_count::set(client, &file, playcount).await?;
+
+ match is_current {
+ false => info!("Set the playcount for \"{}\" to \"{}\".", file, playcount),
+ true => info!(
+ "Set the playcount for the current song to \"{}\".",
+ playcount
+ ),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the last played time for one or more tracks
+async fn get_last_playeds(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut lastplayeds: Vec<(String, Option<u64>)> = Vec::new();
+ for file in map_tracks(client, tracks).await? {
+ let lastplayed = last_played::get(client, &file).await?;
+ lastplayeds.push((file, lastplayed));
+ }
+
+ if lastplayeds.len() == 1 && !with_uri {
+ println!(
+ "{}",
+ match lastplayeds[0].1 {
+ Some(t) => format!("{}", t),
+ None => String::from("N/A"),
+ }
+ );
+ } else {
+ for pair in lastplayeds {
+ println!(
+ "{}: {}",
+ pair.0,
+ match pair.1 {
+ Some(t) => format!("{}", t),
+ None => String::from("N/A"),
+ }
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// Set the playcount for a track
+async fn set_last_playeds(client: &mut Client, lastplayed: u64, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ last_played::set(client, &file, lastplayed).await?;
+
+ match is_current {
+ false => info!("Set last played for \"{}\" to \"{}\".", file, lastplayed),
+ true => info!(
+ "Set last played for the current song to \"{}\".",
+ lastplayed
+ ),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the list of stored playlists
+async fn get_playlists(client: &mut Client) -> Result<()> {
+ let mut pls = client.get_stored_playlists().await?;
+ pls.sort();
+ println!("Stored playlists:");
+ for pl in pls {
+ println!("{}", pl);
+ }
+ Ok(())
+}
+
+/// Add songs selected by filter to the queue
+async fn searchadd(client: &mut Client, filter: &str, case_sensitive: bool) -> Result<()> {
+ let ast = match ExpressionParser::new().parse(filter) {
+ Ok(ast) => ast,
+ Err(err) => {
+ bail!("Failed to parse filter: `{}`", err)
+ }
+ };
+
+ debug!("ast: {:#?}", ast);
+
+ let mut results = Vec::new();
+ for song in evaluate(&ast, case_sensitive, client, &FilterStickerNames::default())
+ .await
+ .context("Failed to evaluate filter")?
+ {
+ let out = client.add(&song).await;
+
+ if out.is_ok() {
+ eprintln!("Added: `{}`", song)
+ }
+
+ results.push(out);
+ }
+
+ match results.into_iter().collect::<Result<Vec<()>>>() {
+ Ok(_) => Ok(()),
+ Err(err) => Err(err),
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let args = Args::parse();
+
+ let config = if let Some(configpath) = &args.config {
+ match std::fs::read_to_string(configpath) {
+ Ok(text) => config::from_str(&text).with_context(|| {
+ format!("Failed to parse config file at: `{}`", configpath.display())
+ })?,
+ Err(err) => {
+ // Either they did _not_, in which case they probably want to know that the config
+ // file they explicitly asked for does not exist, or there was some other problem,
+ // in which case we're out of options, anyway. Either way:
+ bail!(
+ "Failed to read config file at: `{}`, because: {err}",
+ configpath.display()
+ )
+ }
+ }
+ } else {
+ Config::default()
+ };
+
+ // Handle log verbosity: debug => verbose
+ let lf = match (args.verbose, args.debug) {
+ (_, true) => LevelFilter::TRACE,
+ (true, false) => LevelFilter::DEBUG,
+ _ => LevelFilter::WARN,
+ };
+
+ tracing::subscriber::set_global_default(
+ Registry::default()
+ .with(
+ tracing_subscriber::fmt::Layer::default()
+ .compact()
+ .with_writer(std::io::stdout),
+ )
+ .with(
+ EnvFilter::builder()
+ .with_default_directive(lf.into())
+ .from_env()
+ .unwrap(),
+ ),
+ )
+ .unwrap();
+
+ trace!("logging configured.");
+
+ let mut client = match config.conn {
+ config::Connection::Local { path } => Client::open(path).await?,
+ config::Connection::TCP { host, port } => {
+ Client::connect(format!("{}:{}", host, port)).await?
+ }
+ };
+
+ match args.command {
+ SubCommand::Rating { command } => match command {
+ RatingCommand::Get { with_uri, tracks } => {
+ get_ratings(&mut client, tracks, with_uri).await
+ }
+ RatingCommand::Set { rating, track } => set_rating(&mut client, rating, track).await,
+ RatingCommand::Inc { track } => inc_rating(&mut client, track).await,
+ RatingCommand::Decr { track } => decr_rating(&mut client, track).await,
+ },
+ SubCommand::PlayCount { command } => match command {
+ PlayCountCommand::Get { with_uri, tracks } => {
+ get_play_counts(&mut client, tracks, with_uri).await
+ }
+ PlayCountCommand::Set { play_count, track } => {
+ set_play_counts(&mut client, play_count, track).await
+ }
+ },
+ SubCommand::LastPlayed { command } => match command {
+ LastPlayedCommand::Get { with_uri, tracks } => {
+ get_last_playeds(&mut client, tracks, with_uri).await
+ }
+ LastPlayedCommand::Set { last_played, track } => {
+ set_last_playeds(&mut client, last_played, track).await
+ }
+ },
+ SubCommand::Playlists { command } => match command {
+ PlaylistsCommand::Get {} => get_playlists(&mut client).await,
+ },
+ SubCommand::Searchadd {
+ filter,
+ case_sensitive,
+ } => searchadd(&mut client, &filter, case_sensitive).await,
+ SubCommand::Dj { command } => match command {
+ DjCommand::Start {
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ } => {
+ client
+ .send_message(
+ COMMAND_CHANNEL,
+ format!(
+ "dj start \
+ --positive-chance {positive_chance} \
+ --neutral-chance {neutral_chance} \
+ --negative-chance {negative_chance}"
+ )
+ .as_str(),
+ )
+ .await
+ }
+ DjCommand::Stop {} => client.send_message(COMMAND_CHANNEL, "dj stop").await,
+ },
+ SubCommand::Stats {} => {
+ struct Rating {
+ play_count: Option<usize>,
+ skip_count: Option<usize>,
+ last_played: Option<u64>,
+ rating: Option<i8>,
+ dj_weight: i64,
+ }
+ fn vertical_bar<'a>(count: i64, amount: usize) -> Bar<'a> {
+ fn amount_style(amount: usize) -> Style {
+ let green = (255.0 * (1.0 - ((amount as f64) - 50.0) / 40.0)) as u8;
+ let color = Color::Rgb(255, green, 0);
+
+ Style::new().fg(color)
+ }
+
+ Bar::default()
+ .value(amount as u64)
+ .label(Line::from(count.to_string()))
+ .style(amount_style(amount))
+ .value_style(amount_style(amount).reversed())
+ }
+ macro_rules! top_five {
+ ($(@$convert:tt)? mode = $mode:tt, $rating_map:expr, $key:ident, $($other:ident),* $(,)?) => {
+ let mut vec = $rating_map
+ .iter()
+ .filter_map(|(track, rating)| top_five!(@convert $($convert)? rating.$key).map(|v| (track, v, rating)))
+ .collect::<Vec<_>>();
+ vec.sort_by_key(|(_, pc, _)| *pc);
+
+ top_five!(@gen_mode $mode, vec.iter())
+ .take(5)
+ .for_each(|(song, play_count, rating)| {
+ println!(
+ concat!(" - {}: {}", $(top_five!(@gen_empty $other)),*),
+ <String as Clone>::clone(&song).bold().blue(),
+ play_count.to_string().bold().white(),
+ $(
+ rating
+ .$other
+ .map(|r| format!(" ({}: {r})", stringify!($other)))
+ .unwrap_or(String::new()),
+ )*
+ )
+ });
+ };
+ (@gen_mode top, $expr:expr) => {
+ $expr.rev()
+ };
+ (@gen_mode bottom, $expr:expr) => {
+ $expr
+ };
+ (@gen_empty $tt:tt) => {
+ "{}"
+ };
+ (@convert convert_to_option $tt:expr) => {
+ Some($tt)
+ };
+ (@convert $tt:expr) => {
+ $tt
+ }
+ }
+ macro_rules! histogram {
+ ($(@$convert:tt)? $rating_map:expr, $key:ident, $title:literal) => {
+ let backend = CrosstermBackend::new(stdout());
+ let viewport = Viewport::Inline(20);
+ let mut terminal =
+ Terminal::with_options(backend, TerminalOptions { viewport })?;
+
+ let result = (|| {
+ terminal.draw(|frame| {
+ let line_chart = frame.area();
+
+ let bars: Vec<Bar> = {
+ let mut map = HashMap::new();
+ $rating_map
+ .values()
+ .filter_map(|rating| histogram!(@convert $($convert)? rating.$key))
+ .for_each(|dj_weight| {
+ map.entry(dj_weight)
+ .and_modify(|e| {
+ *e += 1;
+ })
+ .or_insert(1);
+ });
+
+ let mut vec = map.into_iter().collect::<Vec<(_, _)>>();
+ vec.sort_by_key(|(pc, _)| *pc);
+
+ vec.into_iter()
+ }
+ .map(|(dj_weight, amount)| vertical_bar(dj_weight.try_into().expect("Should be convertible"), amount))
+ .collect();
+
+ let title = Line::from($title).centered();
+ let chart = BarChart::default()
+ .data(BarGroup::default().bars(&bars))
+ .block(
+ Block::new()
+ .title(title)
+ .title_alignment(HorizontalAlignment::Left)
+ .borders(Borders::all()),
+ )
+ .bar_width(5);
+
+ frame.render_widget(chart, line_chart);
+ })?;
+
+ Ok::<_, anyhow::Error>(())
+ })();
+
+ ratatui::restore();
+ println!();
+ result?;
+ };
+ (@convert convert_to_option $val:expr) => {
+ Some($val)
+ };
+ (@convert $val:expr) => {
+ $val
+ };
+ }
+
+ let all = client.get_all_songs().await?;
+
+ let mut rating_map = HashMap::new();
+ for song in &all {
+ let rating = Rating {
+ play_count: play_count::get(&mut client, song).await?,
+ skip_count: skip_count::get(&mut client, song).await?,
+ last_played: last_played::get(&mut client, song).await?,
+ rating: rating::get(&mut client, song).await?,
+ dj_weight: Discovery::weight_track(&mut client, song).await?,
+ };
+ rating_map.insert(song, rating);
+ }
+
+ let played_songs = rating_map
+ .values()
+ .filter(|s| s.last_played.is_some())
+ .count();
+
+ println!(
+ "Songs played: {:.2}%",
+ (played_songs as f64 / all.len() as f64) * 100.0
+ );
+
+ histogram!(rating_map, play_count, "Play counts");
+
+ println!("\nMost played songs:");
+ top_five!(mode = top, rating_map, play_count, skip_count, rating);
+
+ println!("\nMost skipped songs:");
+ top_five!(mode = top, rating_map, skip_count, play_count, rating);
+
+ println!("\nTop songs based on dj weight:");
+ top_five!(@convert_to_option mode = top, rating_map, dj_weight, rating, play_count, skip_count);
+
+ println!("\nBottom 5 songs based on dj weight:");
+ top_five!(@convert_to_option mode = bottom, rating_map, dj_weight, rating, play_count, skip_count);
+
+ println!();
+ histogram!(@convert_to_option rating_map, dj_weight, "Dj weights");
+
+ Ok(())
+ }
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs
new file mode 100644
index 00000000..643611d6
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs
@@ -0,0 +1,150 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! # mppopmd
+//!
+//! Maintain ratings & playcounts for your mpd server.
+//!
+//! # Introduction
+//!
+//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts &
+//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust
+//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as
+//! the sticker database, by invoking external commands to keep your tags up-to-date (something
+//! along the lines of [mpdcron](https://alip.github.io/mpdcron)).
+
+use mpdpopm::{
+ config::{self, Config},
+ mpdpopm,
+};
+
+use anyhow::{Context, Result, bail};
+use clap::Parser;
+use tracing::{info, level_filters::LevelFilter};
+use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
+
+use std::{io, path::PathBuf, sync::MutexGuard};
+
+pub struct MyMutexGuardWriter<'a>(MutexGuard<'a, std::fs::File>);
+
+impl io::Write for MyMutexGuardWriter<'_> {
+ #[inline]
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+ self.0.write(buf)
+ }
+
+ #[inline]
+ fn flush(&mut self) -> io::Result<()> {
+ self.0.flush()
+ }
+
+ #[inline]
+ fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> {
+ self.0.write_vectored(bufs)
+ }
+
+ #[inline]
+ fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
+ self.0.write_all(buf)
+ }
+
+ #[inline]
+ fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> io::Result<()> {
+ self.0.write_fmt(fmt)
+ }
+}
+
+/// mpd + POPM
+///
+/// `mppopmd' is a companion daemon for `mpd' that maintains playcounts & ratings,
+/// as well as implementing some handy functions. It maintains ratings & playcounts in the sticker
+/// database, but it allows you to keep that information in your tags, as well, by invoking external
+/// commands to keep your tags up-to-date.
+#[derive(Parser)]
+struct Args {
+ /// path to configuration file
+ #[arg(short, long)]
+ config: Option<PathBuf>,
+
+ /// enable verbose logging
+ #[arg(short, long)]
+ verbose: bool,
+
+ /// enable debug loggin (implies --verbose)
+ #[arg(short, long)]
+ debug: bool,
+}
+
+/// Entry point for `mpdopmd'.
+///
+/// Do *not* use the #[tokio::main] attribute here! If this program is asked to daemonize (the usual
+/// case), we will fork after tokio has started its thread pool, with disastrous consequences.
+/// Instead, stay synchronous until we've daemonized (or figured out that we don't need to), and
+/// only then fire-up the tokio runtime.
+fn main() -> Result<()> {
+ use mpdpopm::vars::VERSION;
+
+ let args = Args::parse();
+
+ let config = if let Some(cfgpath) = &args.config {
+ match std::fs::read_to_string(cfgpath) {
+ Ok(text) => config::from_str(&text).with_context(|| {
+ format!("Failed to parse config file at: `{}`", cfgpath.display())
+ })?,
+ // The config file (defaulted or not) either didn't exist, or we were unable to read its
+ // contents...
+ Err(err) => {
+ // Either they did _not_, in which case they probably want to know that the config
+ // file they explicitly asked for does not exist, or there was some other problem,
+ // in which case we're out of options, anyway. Either way:
+ bail!(
+ "No config file could be read at: `{}`, because: {err}",
+ cfgpath.display()
+ )
+ }
+ }
+ } else {
+ Config::default()
+ };
+
+ // `--verbose' & `--debug' work as follows: if `--debug' is present, log at level Trace, no
+ // matter what. Else, if `--verbose' is present, log at level Debug. Else, log at level Info.
+ let lf = match (args.verbose, args.debug) {
+ (_, true) => LevelFilter::TRACE,
+ (true, false) => LevelFilter::DEBUG,
+ _ => LevelFilter::INFO,
+ };
+
+ let filter = EnvFilter::builder()
+ .with_default_directive(lf.into())
+ .from_env()
+ .context("Failed to construct env filter")?;
+
+ let formatter: Box<dyn Layer<Registry> + Send + Sync> = {
+ Box::new(
+ tracing_subscriber::fmt::Layer::default()
+ .compact()
+ .with_writer(io::stdout),
+ )
+ };
+
+ tracing::subscriber::set_global_default(Registry::default().with(formatter).with(filter))
+ .unwrap();
+
+ info!("mppopmd {VERSION} logging at level {lf:#?}.");
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ rt.block_on(mpdpopm(config)).context("Main mpdpopm failed")
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs
new file mode 100644
index 00000000..b934714a
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs
@@ -0,0 +1,1200 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! mpd clients and associated utilities.
+//!
+//! # Introduction
+//!
+//! This module contains basic types implementing various MPD client operations (cf. the [mpd
+//! protocol](http://www.musicpd.org/doc/protocol/)). Since issuing the "idle" command will tie up
+//! the connection, MPD clients often use multiple connections to the server (one to listen for
+//! updates, one or more on which to issue commands). This modules provides two different client
+//! types: [Client] for general-purpose use and [IdleClient] for long-lived connections listening
+//! for server notifiations.
+//!
+//! Note that there *is* another idiom (used in [libmpdel](https://github.com/mpdel/libmpdel),
+//! e.g.): open a single connection & issue an "idle" command. When you want to issue a command,
+//! send a "noidle", then the command, then "idle" again. This isn't a race condition, as the
+//! server will buffer any changes that took place when you were not idle & send them when you
+//! re-issue the "idle" command. This crate however takes the approach of two channels (like
+//! [mpdfav](https://github.com/vincent-petithory/mpdfav)).
+
+use anyhow::{Context, Error, Result, anyhow, bail, ensure};
+use async_trait::async_trait;
+use regex::Regex;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tokio::net::{TcpStream, ToSocketAddrs, UnixStream};
+use tracing::{debug, info};
+
+use lazy_static::lazy_static;
+
+use std::{
+ collections::HashMap,
+ convert::TryFrom,
+ fmt,
+ marker::{Send, Unpin},
+ path::{Path, PathBuf},
+ str::FromStr,
+};
+
+// Some default error context messages
+const ENCODING_SNAFU: &str = "Failed to interpete text as utf8";
+const IO_SNAFU: &str = "Failed read from mpd socket";
+
+/// A description of the current track, suitable for our purposes (as in, it only tracks the
+/// attributes needed for this module's functionality).
+#[derive(Clone, Debug)]
+pub struct CurrentSong {
+ /// Identifier, unique within the play queue, identifying this particular track; if the same
+ /// file is listed twice in the `mpd' play queue each instance will get a distinct songid
+ pub songid: u64,
+
+ /// Path, relative to `mpd' music directory root of this track
+ pub file: std::path::PathBuf,
+
+ /// Elapsed time, in seconds, in this track
+ pub elapsed: f64,
+
+ /// Total track duration, in seconds
+ pub duration: f64,
+}
+
+impl CurrentSong {
+ fn new(songid: u64, file: std::path::PathBuf, elapsed: f64, duration: f64) -> CurrentSong {
+ CurrentSong {
+ songid,
+ file,
+ elapsed,
+ duration,
+ }
+ }
+ /// Compute the ratio of the track that has elapsed, expressed as a floating point between 0 & 1
+ pub fn played_pct(&self) -> f64 {
+ self.elapsed / self.duration
+ }
+}
+
+/// The MPD player itself can be in one of three states: playing, paused or stopped. In the first
+/// two there is a "current" song.
+#[derive(Clone, Debug)]
+pub enum PlayerStatus {
+ Play(CurrentSong),
+ Pause(CurrentSong),
+ Stopped,
+}
+
+impl PlayerStatus {
+ pub fn current_song(&self) -> Option<&CurrentSong> {
+ match self {
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => Some(curr),
+ PlayerStatus::Stopped => None,
+ }
+ }
+}
+
+/// A trait representing a simple, textual request/response protocol like that
+/// [employed](https://www.musicpd.org/doc/html/protocol.html) by [MPD](https://www.musicpd.org/):
+/// the caller sends a textual command & the server responds with a (perhaps multi-line) textual
+/// response.
+///
+/// This trait also enables unit testing client implementations. Note that it is async-- cf.
+/// [async_trait](https://docs.rs/async-trait/latest/async_trait/).
+#[async_trait]
+pub trait RequestResponse {
+ async fn req(&mut self, msg: &str) -> Result<String>;
+ /// The hint is used to size the buffer prior to reading the response
+ async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String>;
+}
+
+#[cfg(test)]
+pub mod test_mock {
+ use super::*;
+
+ /// Mock is an implementation of [`RequestRespone`] that checks expected requests & responses,
+ /// and will panic if it sees anything unexpected
+ pub struct Mock {
+ inmsgs: Vec<String>,
+ outmsgs: Vec<String>,
+ }
+
+ impl Mock {
+ pub fn new(convo: &[(&str, &str)]) -> Mock {
+ let (left, right): (Vec<&str>, Vec<&str>) = convo.iter().copied().rev().unzip();
+ Mock {
+ inmsgs: left.iter().map(|x| x.to_string()).collect(),
+ outmsgs: right.iter().map(|x| x.to_string()).collect(),
+ }
+ }
+ }
+
+ #[async_trait]
+ impl RequestResponse for Mock {
+ async fn req(&mut self, msg: &str) -> Result<String> {
+ self.req_w_hint(msg, 512).await
+ }
+ async fn req_w_hint(&mut self, msg: &str, _hint: usize) -> Result<String> {
+ assert_eq!(msg, self.inmsgs.pop().unwrap());
+ Ok(self.outmsgs.pop().unwrap())
+ }
+ }
+
+ #[tokio::test]
+ async fn mock_smoke_test() {
+ let mut mock = Mock::new(&[("ping", "pong"), ("from", "to")]);
+ assert_eq!(mock.req("ping").await.unwrap(), "pong");
+ assert_eq!(mock.req("from").await.unwrap(), "to");
+ }
+
+ #[tokio::test]
+ #[should_panic]
+ async fn mock_negative_test() {
+ let mut mock = Mock::new(&[("ping", "pong")]);
+ assert_eq!(mock.req("ping").await.unwrap(), "pong");
+ let _should_panic = mock.req("not there!").await.unwrap();
+ }
+}
+
+/// [MPD](https://www.musicpd.org/) connections talk the same
+/// [protocol](https://www.musicpd.org/doc/html/protocol.html) over either a TCP or a Unix socket.
+///
+/// # Examples
+///
+/// Implementations are provided for tokio [UnixStream] and [TcpStream], but [MpdConnection] is a
+/// trait that can work in terms of any asynchronous communications channel (so long as it is also
+/// [Send] and [Unpin] so async executors can pass them between threads.
+///
+/// To create a connection to an `MPD` server over a Unix domain socket:
+///
+/// ```no_run
+/// use std::path::Path;
+/// use tokio::net::UnixStream;
+/// use mpdpopm::clients::MpdConnection;
+/// let local_conn = MpdConnection::<UnixStream>::connect(Path::new("/var/run/mpd/mpd.sock"));
+/// ```
+///
+/// In this example, `local_conn` is a Future that will resolve to a Result containing the
+/// [MpdConnection] Unix domain socket implementation once the socket has been established, the MPD
+/// server greets us & the protocol version has been parsed.
+///
+/// or over a TCP socket:
+///
+/// ```no_run
+/// use std::net::SocketAddrV4;
+/// use tokio::net::{TcpStream, ToSocketAddrs};
+/// use mpdpopm::clients::MpdConnection;
+/// let tcp_conn = MpdConnection::<TcpStream>::connect("localhost:6600".parse::<SocketAddrV4>().unwrap());
+/// ```
+///
+/// Here, `tcp_conn` is a Future that will resolve to a Result containing the [MpdConnection] TCP
+/// implementation on successful connection to the MPD server (i.e. the connection is established,
+/// the server greets us & we parse the protocol version).
+///
+///
+pub struct MpdConnection<T: AsyncRead + AsyncWrite + Send + Unpin> {
+ sock: T,
+ _protocol_ver: String,
+}
+
+/// MpdConnection implements RequestResponse using the usual (async) socket I/O
+///
+/// The callers need not include the trailing newline in their requests; the implementation will
+/// append it.
+#[async_trait]
+impl<T> RequestResponse for MpdConnection<T>
+where
+ T: AsyncRead + AsyncWrite + Send + Unpin,
+{
+ async fn req(&mut self, msg: &str) -> Result<String> {
+ self.req_w_hint(msg, 512).await
+ }
+ async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String> {
+ self.sock
+ .write_all(format!("{}\n", msg).as_bytes())
+ .await
+ .context(IO_SNAFU)?;
+ let mut buf = Vec::with_capacity(hint);
+
+ // Given the request/response nature of the MPD protocol, our callers expect a complete
+ // response. Therefore we need to loop here until we see either "...^OK\n" or
+ // "...^ACK...\n".
+ let mut cb = 0; // # bytes read so far
+ let mut more = true; // true as long as there is more to read
+ while more {
+ cb += self.sock.read_buf(&mut buf).await.context(IO_SNAFU)?;
+
+ // The shortest complete response has three bytes. If the final byte in `buf' is not a
+ // newline, then don't bother looking further.
+ if cb > 2 && char::from(buf[cb - 1]) == '\n' {
+ // If we're here, `buf' *may* contain a complete response. Search backward for the
+ // previous newline. It may not exist: many responses are of the form "OK\n".
+ let mut idx = cb - 2;
+ while idx > 0 {
+ if char::from(buf[idx]) == '\n' {
+ idx += 1;
+ break;
+ }
+ idx -= 1;
+ }
+
+ if (idx + 2 < cb && char::from(buf[idx]) == 'O' && char::from(buf[idx + 1]) == 'K')
+ || (idx + 3 < cb
+ && char::from(buf[idx]) == 'A'
+ && char::from(buf[idx + 1]) == 'C'
+ && char::from(buf[idx + 2]) == 'K')
+ {
+ more = false;
+ }
+ }
+ }
+
+ // Only doing this to trouble-shoot issue 11
+ String::from_utf8(buf.clone()).context(ENCODING_SNAFU)
+ }
+}
+
+/// Utility function to parse the initial response to a connection from mpd
+async fn parse_connect_rsp<T>(sock: &mut T) -> Result<String>
+where
+ T: AsyncReadExt + AsyncWriteExt + Send + Unpin,
+{
+ let mut buf = Vec::with_capacity(32);
+ let _cb = sock.read_buf(&mut buf).await.context(IO_SNAFU)?;
+
+ // Only doing this to trouble-shoot issue 11
+ let text = String::from_utf8(buf.clone()).context(ENCODING_SNAFU)?;
+
+ ensure!(
+ text.starts_with("OK MPD "),
+ "failed to connect: {}",
+ text.trim()
+ );
+ info!("Connected {}.", text[7..].trim());
+ Ok(text[7..].trim().to_string())
+}
+
+impl MpdConnection<TcpStream> {
+ pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<Box<dyn RequestResponse>> {
+ let mut sock = TcpStream::connect(addr).await.context(IO_SNAFU)?;
+ let proto_ver = parse_connect_rsp(&mut sock).await?;
+ Ok(Box::new(MpdConnection::<TcpStream> {
+ sock,
+ _protocol_ver: proto_ver,
+ }))
+ }
+}
+
+impl MpdConnection<UnixStream> {
+ // NTS: we have to box the return value because a `dyn RequestResponse` isn't Sized.
+ pub async fn connect<P: AsRef<Path>>(pth: P) -> Result<Box<dyn RequestResponse>> {
+ let mut sock = UnixStream::connect(pth).await.context(IO_SNAFU)?;
+ let proto_ver = parse_connect_rsp(&mut sock).await?;
+ Ok(Box::new(MpdConnection::<UnixStream> {
+ sock,
+ _protocol_ver: proto_ver,
+ }))
+ }
+}
+
+/// Quote an argument by backslash-escaping " & \ characters
+pub fn quote(text: &str) -> String {
+ if text.contains(&[' ', '\t', '\'', '"'][..]) {
+ let mut s = String::from("\"");
+ for c in text.chars() {
+ if c == '"' || c == '\\' {
+ s.push('\\');
+ }
+ s.push(c);
+ }
+ s.push('"');
+ s
+ } else {
+ text.to_string()
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// Client //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// General-purpose [mpd](https://www.musicpd.org)
+/// [client](https://www.musicpd.org/doc/html/protocol.html): "general-purpose" in the sense that we
+/// send commands through it; the interface is narrowly scoped to this program's needs.
+///
+/// # Introduction
+///
+/// This is the primary abstraction of the MPD client protocol, written for the convenience of
+/// [mpdpopm](crate). Construct instances with a TCP socket, a Unix socket, or any [RequestResponse]
+/// implementation. You can then carry out assorted operations in the MPD client protocol by
+/// invoking its methods.
+///
+/// ```no_run
+/// use std::path::Path;
+/// use mpdpopm::clients::Client;
+/// let client = Client::open(Path::new("/var/run/mpd.sock"));
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to a [Client] instance talking to `/var/run/mpd.sock`.
+///
+/// ```no_run
+/// use mpdpopm::clients::Client;
+/// let client = Client::connect("localhost:6600");
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to a [Client] instance talking TCP to the MPD server on localhost at port 6600.
+pub struct Client {
+ stream: Box<dyn RequestResponse>,
+}
+
+// Thanks to <https://stackoverflow.com/questions/35169259/how-to-make-a-compiled-regexp-a-global-variable>
+lazy_static! {
+ static ref RE_STATE: regex::Regex = Regex::new(r"(?m)^state: (play|pause|stop)$").unwrap();
+ static ref RE_SONGID: regex::Regex = Regex::new(r"(?m)^songid: ([0-9]+)$").unwrap();
+ static ref RE_ELAPSED: regex::Regex = Regex::new(r"(?m)^elapsed: ([.0-9]+)$").unwrap();
+ static ref RE_FILE: regex::Regex = Regex::new(r"(?m)^file: (.*)$").unwrap();
+ static ref RE_DURATION: regex::Regex = Regex::new(r"(?m)^duration: (.*)$").unwrap();
+}
+
+impl Client {
+ pub async fn connect<A: ToSocketAddrs>(addrs: A) -> Result<Client> {
+ Self::new(MpdConnection::<TcpStream>::connect(addrs).await?)
+ }
+
+ pub async fn open<P: AsRef<Path>>(pth: P) -> Result<Client> {
+ Self::new(MpdConnection::<UnixStream>::connect(pth).await?)
+ }
+
+ pub fn new(stream: Box<dyn RequestResponse>) -> Result<Client> {
+ Ok(Client { stream })
+ }
+}
+
+impl Client {
+ /// Retrieve the current server status.
+ pub async fn status(&mut self) -> Result<PlayerStatus> {
+ // We begin with sending the "status" command: "Reports the current status of the player and
+ // the volume level." Per the docs, "MPD may omit lines which have no (known) value", so I
+ // can't really count on particular lines being there. Tho nothing is said in the docs, I
+ // also don't want to depend on the order.
+ let text = self.stream.req("status").await?;
+
+ let proto = || -> Error { anyhow!("Failed to parse mpd status output (with regexes)") };
+
+ // I first thought to avoid the use (and cost) of regular expressions by just doing
+ // sub-string searching on "state: ", but when I realized I needed to only match at the
+ // beginning of a line I bailed & just went ahead. This makes for more succinct code, since
+ // I can't count on order, either.
+ let state = RE_STATE
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str();
+
+ match state {
+ "stop" => Ok(PlayerStatus::Stopped),
+ "play" | "pause" => {
+ let songid = RE_SONGID
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str()
+ .parse::<u64>()
+ .context("Failed to parse songid as u64")?;
+
+ let elapsed = RE_ELAPSED
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str()
+ .parse::<f64>()
+ .context("failed to parse `elapsed` as f64")?;
+
+ // navigate from `songid'-- don't send a "currentsong" message-- the current song
+ // could have changed
+ let text = self.stream.req(&format!("playlistid {}", songid)).await?;
+
+ let file = RE_FILE
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str();
+ let duration = RE_DURATION
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str()
+ .parse::<f64>()
+ .context("Failed to parse `duration` as f64")?;
+
+ let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration);
+
+ if state == "play" {
+ Ok(PlayerStatus::Play(curr))
+ } else {
+ Ok(PlayerStatus::Pause(curr))
+ }
+ }
+ _ => bail!("Encountered unknow state `{}`", state),
+ }
+ }
+
+ /// Retrieve a song sticker by name
+ pub async fn get_sticker<T: FromStr>(
+ &mut self,
+ file: &str,
+ sticker_name: &str,
+ ) -> Result<Option<T>>
+ where
+ <T as FromStr>::Err: std::error::Error + Sync + Send + 'static,
+ {
+ let msg = format!("sticker get song {} {}", quote(file), quote(sticker_name));
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent message `{}'; got `{}'", &msg, &text);
+
+ let prefix = format!("sticker: {}=", sticker_name);
+ if text.starts_with(&prefix) {
+ let s = text[prefix.len()..]
+ .split('\n')
+ .next()
+ .with_context(|| format!("Failed to parse `{}` as get_sticker response", text))?;
+ Ok(Some(T::from_str(s).with_context(|| {
+ format!(
+ "Failed to parse sticker value as correct type: `{}`",
+ sticker_name
+ )
+ })?))
+ } else {
+ // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17)
+ ensure!(
+ text.starts_with("ACK [50@0]"),
+ "Missing no sticker response"
+ );
+ Ok(None)
+ }
+ }
+
+ /// Set a song sticker by name
+ pub async fn set_sticker<T: std::fmt::Display>(
+ &mut self,
+ file: &str,
+ sticker_name: &str,
+ sticker_value: &T,
+ ) -> Result<()> {
+ let value_as_str = format!("{}", sticker_value);
+ let msg = format!(
+ "sticker set song {} {} {}",
+ quote(file),
+ quote(sticker_name),
+ quote(&value_as_str)
+ );
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent `{}'; got `{}'", &msg, &text);
+
+ ensure!(text.starts_with("OK"), "Set sticker, not acknowledged");
+ Ok(())
+ }
+
+ /// Send a file to a playlist
+ pub async fn send_to_playlist(&mut self, file: &str, pl: &str) -> Result<()> {
+ let msg = format!("playlistadd {} {}", quote(pl), quote(file));
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent `{}'; got `{}'.", &msg, &text);
+ ensure!(text.starts_with("OK"), "send_to_playlist not acknowledged");
+ Ok(())
+ }
+
+ /// Send an arbitrary message
+ pub async fn send_message(&mut self, chan: &str, msg: &str) -> Result<()> {
+ let msg = format!("sendmessage {} {}", chan, quote(msg));
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+ ensure!(text.starts_with("OK"), "Send_message not acknowledged");
+ Ok(())
+ }
+
+ /// Update a URI
+ pub async fn update(&mut self, uri: &str) -> Result<u64> {
+ let msg = format!("update \"{}\"", uri);
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+ // We expect a response of the form:
+ // updating_db: JOBID
+ // OK
+ // on success, and
+ // ACK ERR
+ // on failure.
+
+ let prefix = "updating_db: ";
+ ensure!(
+ text.starts_with(prefix),
+ "update response doesn't start with correct prefix"
+ );
+ text[prefix.len()..].split('\n').collect::<Vec<&str>>()[0]
+ .to_string()
+ .parse::<u64>()
+ .context("Failed to treat update job id as u64")
+ }
+
+ /// Get the list of stored playlists
+ pub async fn get_stored_playlists(&mut self) -> Result<std::vec::Vec<String>> {
+ let text = self.stream.req("listplaylists").await?;
+ debug!("Sent listplaylists; got `{}'.", &text);
+
+ // We expect a response of the form:
+ // playlist: a
+ // Last-Modified: 2020-03-13T17:20:16Z
+ // playlsit: b
+ // Last-Modified: 2020-03-13T17:20:16Z
+ // ...
+ // OK
+ //
+ // or
+ //
+ // ACK...
+ ensure!(
+ !text.starts_with("ACK"),
+ "get_stored_playlists response not acknowledged"
+ );
+ Ok(text
+ .lines()
+ .filter_map(|x| x.strip_prefix("playlist: ").map(String::from))
+ .collect::<Vec<String>>())
+ }
+
+ /// Process a search (either find or search) response
+ fn search_rsp_to_uris(&self, text: &str) -> Result<std::vec::Vec<String>> {
+ // We expect a response of the form:
+ // file: P/Pogues, The - A Pistol For Paddy Garcia.mp3
+ // Last-Modified: 2007-12-26T19:18:00Z
+ // Format: 44100:24:2
+ // ...
+ // file: P/Pogues, The - Billy's Bones.mp3
+ // ...
+ // OK
+ //
+ // or
+ //
+ // ACK...
+ ensure!(!text.starts_with("ACK"), "rsp_to_uris not acknowledged");
+ Ok(text
+ .lines()
+ .filter_map(|x| x.strip_prefix("file: ").map(String::from))
+ .collect::<Vec<String>>())
+ }
+
+ /// Search the database for songs matching filter (unary operator)
+ ///
+ /// Set `case` to true to request a case-sensitive search (false yields case-insensitive)
+ pub async fn find1(
+ &mut self,
+ cond: &str,
+ val: &str,
+ case: bool,
+ ) -> Result<std::vec::Vec<String>> {
+ let cmd = format!(
+ "{} {}",
+ if case { "find" } else { "search" },
+ quote(&format!("({} {})", cond, val))
+ );
+ let text = self.stream.req(&cmd).await?;
+ self.search_rsp_to_uris(&text)
+ }
+
+ /// Search the database for songs matching filter (case-sensitive, binary operator)
+ ///
+ /// Set `case` to true to request a case-sensitive search (false yields case-insensitive)
+ pub async fn find2(
+ &mut self,
+ attr: &str,
+ op: &str,
+ val: &str,
+ case: bool,
+ ) -> Result<std::vec::Vec<String>> {
+ let cmd = format!(
+ "{} {}",
+ if case { "find" } else { "search" },
+ quote(&format!("({} {} {})", attr, op, val))
+ );
+ debug!("find2 sending ``{}''", cmd);
+ let text = self.stream.req(&cmd).await?;
+ self.search_rsp_to_uris(&text)
+ }
+
+ /// Retrieve all instances of a given sticker under the music directory
+ ///
+ /// Return a mapping from song URI to textual sticker value
+ pub async fn get_stickers(&mut self, sticker: &str) -> Result<HashMap<String, String>> {
+ let text = self
+ .stream
+ .req(&format!("sticker find song \"\" {}", sticker))
+ .await?;
+
+ // We expect a response of the form:
+ //
+ // file: U-Z/Zafari - Addis Adaba.mp3
+ // sticker: unwoundstack.com:rating=64
+ // ...
+ // file: U-Z/Zero 7 - In Time (Album Version).mp3
+ // sticker: unwoundstack.com:rating=255
+ // OK
+ //
+ // or
+ //
+ // ACK ...
+ ensure!(!text.starts_with("ACK"), "get_stickers not ACKed");
+ let mut m = HashMap::new();
+ let mut lines = text.lines();
+ loop {
+ let file = lines.next().context("get_stickers no new line")?;
+ if "OK" == file {
+ break;
+ }
+ let val = lines.next().context("get_stickers no val")?;
+
+ m.insert(
+ String::from(&file[6..]),
+ String::from(&val[10 + sticker.len()..]),
+ );
+ }
+ Ok(m)
+ }
+
+ /// Retrieve the song URIs of all songs in the database
+ ///
+ /// Returns a vector of String
+ pub async fn get_all_songs(&mut self) -> Result<std::vec::Vec<String>> {
+ let text = self.stream.req("find \"(base '')\"").await?;
+ // We expect a response of the form:
+ // file: 0-A/A Positive Life - Lighten Up!.mp3
+ // Last-Modified: 2020-11-18T22:47:07Z
+ // Format: 44100:24:2
+ // Time: 399
+ // duration: 398.550
+ // Artist: A Positive Life
+ // Title: Lighten Up!
+ // Genre: Electronic
+ // file: 0-A/A Positive Life - Pleidean Communication.mp3
+ // ...
+ // OK
+ //
+ // or "ACK..."
+ ensure!(!text.starts_with("ACK"), "get_all_songs not ACKed");
+ Ok(text
+ .lines()
+ .filter_map(|x| x.strip_prefix("file: ").map(String::from))
+ .collect::<Vec<String>>())
+ }
+
+ pub async fn add(&mut self, uri: &str) -> Result<()> {
+ let msg = format!("add {}", quote(uri));
+ let text = self.stream.req(&msg).await?;
+ debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+ ensure!(text.starts_with("OK"), "add not Oked");
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod client_tests {
+ use super::test_mock::Mock;
+ use super::*;
+
+ /// Some basic "smoke" tests
+ #[tokio::test]
+ async fn client_smoke_test() {
+ let mock = Box::new(Mock::new(&[(
+ "sticker get song foo.mp3 stick",
+ "sticker: stick=splat\nOK\n",
+ )]));
+ let mut cli = Client::new(mock).unwrap();
+ let val = cli
+ .get_sticker::<String>("foo.mp3", "stick")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(val, "splat");
+ }
+
+ /// Test the `status' method
+ #[tokio::test]
+ async fn test_status() {
+ let mock = Box::new(Mock::new(&[
+ (
+ "status",
+ // When the server is playing or paused, the response will look something like this:
+ "volume: -1
+repeat: 0
+random: 0
+single: 0
+consume: 0
+playlist: 3
+playlistlength: 87
+mixrampdb: 0.000000
+state: play
+song: 14
+songid: 15
+time: 141:250
+bitrate: 128
+audio: 44100:24:2
+nextsong: 15
+nextsongid: 16
+elapsed: 140.585
+OK",
+ ),
+ // Should respond with a playlist id request
+ (
+ "playlistid 15",
+ // Should look something like this:
+ "file: U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3
+Last-Modified: 2004-12-24T19:26:13Z
+Artist: U2
+Title: Who's Gonna RIDE Your WILD HOR
+Genre: Pop
+Time: 316
+Pos: 41
+Id: 42
+duration: 249.994
+OK",
+ ),
+ (
+ "status",
+ // But if the state is "stop", much of that will be missing; it will look more like:
+ "volume: -1
+repeat: 0
+random: 0
+single: 0
+consume: 0
+playlist: 84
+playlistlength: 27
+mixrampdb: 0.000000
+state: stop
+OK",
+ ),
+ // Finally, let's simulate something being really wrong
+ (
+ "status",
+ "volume: -1
+repeat: 0
+state: no-idea!?",
+ ),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+ let stat = cli.status().await.unwrap();
+ match stat {
+ PlayerStatus::Play(curr) => {
+ assert_eq!(curr.songid, 15);
+ assert_eq!(
+ curr.file.to_str().unwrap(),
+ "U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3"
+ );
+ assert_eq!(curr.elapsed, 140.585);
+ assert_eq!(curr.duration, 249.994);
+ }
+ _ => panic!(),
+ }
+
+ let stat = cli.status().await.unwrap();
+ match stat {
+ PlayerStatus::Stopped => (),
+ _ => panic!(),
+ }
+
+ let stat = cli.status().await;
+ match stat {
+ Err(_) => (),
+ Ok(_) => panic!(),
+ }
+ }
+
+ /// Test the `get_sticker' method
+ #[tokio::test]
+ async fn test_get_sticker() {
+ let mock = Box::new(Mock::new(&[
+ (
+ "sticker get song foo.mp3 stick",
+ // On success, should get something like this...
+ "sticker: stick=2\nOK\n",
+ ),
+ (
+ "sticker get song foo.mp3 stick",
+ // and on failure, something like this:
+ "ACK [50@0] {sticker} no such sticker\n",
+ ),
+ (
+ "sticker get song foo.mp3 stick",
+ // Finally, let's try something nuts
+ "",
+ ),
+ (
+ "sticker get song \"filename_with\\\"doublequotes\\\".flac\" unwoundstack.com:playcount",
+ "sticker: unwoundstack.com:playcount=11\nOK\n",
+ ),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+ let val = cli
+ .get_sticker::<String>("foo.mp3", "stick")
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(val, "2");
+ let _val = cli
+ .get_sticker::<String>("foo.mp3", "stick")
+ .await
+ .unwrap()
+ .is_none();
+ let _val = cli
+ .get_sticker::<String>("foo.mp3", "stick")
+ .await
+ .unwrap_err();
+ let val = cli
+ .get_sticker::<String>(
+ "filename_with\"doublequotes\".flac",
+ "unwoundstack.com:playcount",
+ )
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(val, "11");
+ }
+
+ /// Test the `set_sticker' method
+ #[tokio::test]
+ async fn test_set_sticker() {
+ let mock = Box::new(Mock::new(&[
+ ("sticker set song foo.mp3 stick 2", "OK\n"),
+ (
+ "sticker set song foo.mp3 stick 2",
+ "ACK [50@0] {sticker} some error",
+ ),
+ (
+ "sticker set song foo.mp3 stick 2",
+ "this makes no sense as a response",
+ ),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+ let () = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap();
+ let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err();
+ let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err();
+ }
+
+ /// Test the `send_to_playlist' method
+ #[tokio::test]
+ async fn test_send_to_playlist() {
+ let mock = Box::new(Mock::new(&[
+ ("playlistadd foo.m3u foo.mp3", "OK\n"),
+ (
+ "playlistadd foo.m3u foo.mp3",
+ "ACK [101@0] {playlist} some error\n",
+ ),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+ let () = cli.send_to_playlist("foo.mp3", "foo.m3u").await.unwrap();
+ let _val = cli
+ .send_to_playlist("foo.mp3", "foo.m3u")
+ .await
+ .unwrap_err();
+ }
+
+ /// Test the `update' method
+ #[tokio::test]
+ async fn test_update() {
+ let mock = Box::new(Mock::new(&[
+ ("update \"foo.mp3\"", "updating_db: 2\nOK\n"),
+ ("update \"foo.mp3\"", "ACK [50@0] {update} blahblahblah"),
+ ("update \"foo.mp3\"", "this makes no sense as a response"),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+ let _val = cli.update("foo.mp3").await.unwrap();
+ let _val = cli.update("foo.mp3").await.unwrap_err();
+ let _val = cli.update("foo.mp3").await.unwrap_err();
+ }
+
+ /// Test retrieving stored playlists
+ #[tokio::test]
+ async fn test_get_stored_playlists() {
+ let mock = Box::new(Mock::new(&[
+ (
+ "listplaylists",
+ "playlist: saturday-afternoons-in-santa-cruz
+Last-Modified: 2020-03-13T17:20:16Z
+playlist: gaelic-punk
+Last-Modified: 2020-05-24T00:36:02Z
+playlist: morning-coffee
+Last-Modified: 2020-03-13T17:20:16Z
+OK
+",
+ ),
+ ("listplaylists", "ACK [1@0] {listplaylists} blahblahblah"),
+ ]));
+
+ let mut cli = Client::new(mock).unwrap();
+ let val = cli.get_stored_playlists().await.unwrap();
+ assert_eq!(
+ val,
+ vec![
+ String::from("saturday-afternoons-in-santa-cruz"),
+ String::from("gaelic-punk"),
+ String::from("morning-coffee")
+ ]
+ );
+ let _val = cli.get_stored_playlists().await.unwrap_err();
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// IdleClient //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[non_exhaustive]
+#[derive(Debug, PartialEq, Eq)]
+pub enum IdleSubSystem {
+ Player,
+ Message,
+}
+
+impl TryFrom<&str> for IdleSubSystem {
+ type Error = Error;
+ fn try_from(text: &str) -> std::result::Result<Self, Self::Error> {
+ let x = text.to_lowercase();
+ if x == "player" {
+ Ok(IdleSubSystem::Player)
+ } else if x == "message" {
+ Ok(IdleSubSystem::Message)
+ } else {
+ bail!("{}", text)
+ }
+ }
+}
+
+impl fmt::Display for IdleSubSystem {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ IdleSubSystem::Player => write!(f, "Player"),
+ IdleSubSystem::Message => write!(f, "Message"),
+ }
+ }
+}
+
+/// [MPD](https://www.musicpd.org) client for "idle" connections.
+///
+/// # Introduction
+///
+/// This is an MPD client designed to "idle": it opens a long-lived connection to the MPD server and
+/// waits for MPD to respond with a message indicating that there's been a change to a subsystem of
+/// interest. At present, there are only two subsystems in which [mpdpopm](crate) is interested: the player
+/// & messages (cf. [IdleSubSystem]).
+///
+/// ```no_run
+/// use std::path::Path;
+/// use tokio::runtime::Runtime;
+/// use mpdpopm::clients::IdleClient;
+///
+/// let mut rt = Runtime::new().unwrap();
+/// rt.block_on( async {
+/// let mut client = IdleClient::open(Path::new("/var/run/mpd.sock")).await.unwrap();
+/// client.subscribe("player").await.unwrap();
+/// client.idle().await.unwrap();
+/// // Arrives here when the player's state changes
+/// })
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to an [IdleClient] instance talking to `/var/run/mpd.sock`.
+///
+pub struct IdleClient {
+ conn: Box<dyn RequestResponse>,
+}
+
+impl IdleClient {
+ /// Create a new [mpdpopm::client::IdleClient][IdleClient] instance from something that
+ /// implements [ToSocketAddrs]
+ pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<IdleClient> {
+ Self::new(MpdConnection::<TcpStream>::connect(addr).await?)
+ }
+
+ pub async fn open<P: AsRef<Path>>(pth: P) -> Result<IdleClient> {
+ Self::new(MpdConnection::<UnixStream>::connect(pth).await?)
+ }
+
+ pub fn new(stream: Box<dyn RequestResponse>) -> Result<IdleClient> {
+ Ok(IdleClient { conn: stream })
+ }
+
+ /// Subscribe to an mpd channel
+ pub async fn subscribe(&mut self, chan: &str) -> Result<()> {
+ let text = self.conn.req(&format!("subscribe {}", chan)).await?;
+ debug!("Sent subscribe message for {}; got `{}'.", chan, text);
+ ensure!(text.starts_with("OK"), "subscribe not Ok: `{}`", text);
+ debug!("Subscribed to {}.", chan);
+ Ok(())
+ }
+
+ /// Enter idle state-- return the subsystem that changed, causing the connection to return. NB
+ /// this may block for some time.
+ pub async fn idle(&mut self) -> Result<IdleSubSystem> {
+ let text = self.conn.req("idle player message").await?;
+ debug!("Sent idle message; got `{}'.", text);
+
+ // If the player state changes, we'll get: "changed: player\nOK\n"
+ //
+ // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we respond
+ // "readmessages", which should give us something like:
+ //
+ // channel: ratings
+ // message: 255
+ // OK
+ //
+ // We remain subscribed, but we need to send a new idle message.
+
+ ensure!(text.starts_with("changed: "), "idle not OK: `{}`", text);
+ let idx = text.find('\n').context("idle has no newline")?;
+
+ let result = IdleSubSystem::try_from(&text[9..idx])?;
+ let text = text[idx + 1..].to_string();
+ ensure!(text.starts_with("OK"), "idle not OKed");
+
+ Ok(result)
+ }
+
+ /// This method simply returns the results of a "readmessages" as a HashMap of channel name to
+ /// Vec of (String) messages for that channel
+ pub async fn get_messages(&mut self) -> Result<HashMap<String, Vec<String>>> {
+ let text = self.conn.req("readmessages").await?;
+ debug!("Sent readmessages; got `{}'.", text);
+
+ // We expect something like:
+ //
+ // channel: ratings
+ // message: 255
+ // OK
+ //
+ // We remain subscribed, but we need to send a new idle message.
+
+ let mut m: HashMap<String, Vec<String>> = HashMap::new();
+
+ // Populate `m' with a little state machine:
+ enum State {
+ Init,
+ Running,
+ Finished,
+ }
+ let mut state = State::Init;
+ let mut chan = String::new();
+ let mut msgs: Vec<String> = Vec::new();
+ for line in text.lines() {
+ match state {
+ State::Init => {
+ ensure!(line.starts_with("channel: "), "no `channel: ` given");
+ chan = String::from(&line[9..]);
+ state = State::Running;
+ }
+ State::Running => {
+ if let Some(stripped) = line.strip_prefix("message: ") {
+ msgs.push(String::from(stripped));
+ } else if let Some(stripped) = line.strip_prefix("channel: ") {
+ match m.get_mut(&chan) {
+ Some(v) => v.append(&mut msgs),
+ None => {
+ m.insert(chan.clone(), msgs.clone());
+ }
+ }
+ chan = String::from(stripped);
+ msgs = Vec::new();
+ } else if line == "OK" {
+ match m.get_mut(&chan) {
+ Some(v) => v.append(&mut msgs),
+ None => {
+ m.insert(chan.clone(), msgs.clone());
+ }
+ }
+ state = State::Finished;
+ } else {
+ bail!("Failed to get messages: `{}`", text)
+ }
+ }
+ State::Finished => {
+ // Should never be here!
+ bail!("Failed to get messages: `{}`", text)
+ }
+ }
+ }
+
+ Ok(m)
+ }
+}
+
+#[cfg(test)]
+/// Let's test IdleClient!
+mod idle_client_tests {
+
+ use super::test_mock::Mock;
+ use super::*;
+
+ /// Some basic "smoke" tests
+ #[tokio::test]
+ async fn test_get_messages() {
+ let mock = Box::new(Mock::new(&[(
+ "readmessages",
+ // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we
+ // respond "readmessages", which should give us something like:
+ //
+ // channel: ratings
+ // message: 255
+ // OK
+ //
+ // We remain subscribed, but we need to send a new idle message.
+ "channel: ratings
+message: 255
+message: 128
+channel: send-to-playlist
+message: foo.m3u
+OK
+",
+ )]));
+ let mut cli = IdleClient::new(mock).unwrap();
+ let hm = cli.get_messages().await.unwrap();
+ let val = hm.get("ratings").unwrap();
+ assert_eq!(val.len(), 2);
+ let val = hm.get("send-to-playlist").unwrap();
+ assert!(val.len() == 1);
+ }
+
+ /// Test issue #1
+ #[tokio::test]
+ async fn test_issue_1() {
+ let mock = Box::new(Mock::new(&[(
+ "readmessages",
+ "channel: playcounts
+message: a
+channel: playcounts
+message: b
+OK
+",
+ )]));
+ let mut cli = IdleClient::new(mock).unwrap();
+ let hm = cli.get_messages().await.unwrap();
+ let val = hm.get("playcounts").unwrap();
+ assert_eq!(val.len(), 2);
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs
new file mode 100644
index 00000000..8bb5abfb
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/config.rs
@@ -0,0 +1,189 @@
+// Copyright (C) 2021-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! # mpdpopm Configuration
+//!
+//! ## Introduction
+//!
+//! This module defines the configuration struct & handles deserialization thereof.
+//!
+//! ## Discussion
+//!
+//! In the first releases of [mpdpopm](crate) I foolishly forgot to add a version field to the
+//! configuration structure. I am now paying for my sin by having to attempt serializing two
+//! versions until one succeeds.
+//!
+//! The idiomatic approach to versioning [serde](https://docs.serde.rs/serde/) structs seems to be
+//! using an
+//! [enumeration](https://www.reddit.com/r/rust/comments/44dds3/handling_multiple_file_versions_with_serde_or/). This
+//! implementation *now* uses that, but that leaves us with the problem of handling the initial,
+//! un-tagged version. I proceed as follows:
+//!
+//! 1. attempt to deserialize as a member of the modern enumeration
+//! 2. if that succeeds, with the most-recent version, we're good
+//! 3. if that succeeds with an archaic version, convert to the most recent and warn the user
+//! 4. if that fails, attempt to deserialize as the initial struct version
+//! 5. if that succeeds, convert to the most recent & warn the user
+//! 6. if that fails, I'm kind of stuck because I don't know what the user was trying to express;
+//! bundle-up all the errors, report 'em & urge the user to use the most recent version
+use crate::vars::{LOCALSTATEDIR, PREFIX};
+
+use anyhow::{Result, bail};
+use serde::{Deserialize, Serialize};
+
+use std::{env, path::PathBuf};
+
+/// [mpdpopm](crate) can communicate with MPD over either a local Unix socket, or over regular TCP
+#[derive(Debug, Deserialize, PartialEq, Serialize)]
+pub enum Connection {
+ /// Local Unix socket-- payload is the path to the socket
+ Local { path: PathBuf },
+ /// TCP-- payload is the hostname & port number
+ TCP { host: String, port: u16 },
+}
+
+impl Connection {
+ pub fn new() -> Result<Self> {
+ let env = match env::var("MPD_HOST") {
+ Ok(env) => Some(env),
+ Err(err) => match err {
+ env::VarError::NotPresent => None,
+ env::VarError::NotUnicode(_) => {
+ bail!("Failed to get `MPD_HOST` env var: {err}")
+ }
+ },
+ }
+ .unwrap_or("/run/mpd/socket".to_owned());
+
+ if env.starts_with("/") {
+ // We assume that this is a path to a local socket
+ Ok(Self::Local {
+ path: PathBuf::from(env),
+ })
+ } else {
+ todo!("Not yet able to auto-parse, MPD_HOST for remote connection")
+ }
+ }
+}
+
+impl Default for Connection {
+ fn default() -> Self {
+ Self::new().expect("Could not generate default connection")
+ }
+}
+
+#[cfg(test)]
+mod test_connection {
+ use super::Connection;
+
+ #[test]
+ fn test_serde() {
+ use serde_json::to_string;
+
+ use std::path::PathBuf;
+
+ let text = to_string(&Connection::Local {
+ path: PathBuf::from("/var/run/mpd.sock"),
+ })
+ .unwrap();
+
+ assert_eq!(
+ text,
+ String::from(r#"{"Local":{"path":"/var/run/mpd.sock"}}"#)
+ );
+
+ let text = to_string(&Connection::TCP {
+ host: String::from("localhost"),
+ port: 6600,
+ })
+ .unwrap();
+ assert_eq!(
+ text,
+ String::from(r#"{"TCP":{"host":"localhost","port":6600}}"#)
+ );
+ }
+}
+
+/// THe possible start-up mode.
+#[derive(Default, Deserialize, Debug, Serialize)]
+pub enum Mode {
+ #[default]
+ /// Don't do anything special
+ Normal,
+
+ /// Already start the DJ mode on start-up
+ Dj,
+}
+
+/// This is the most recent `mppopmd` configuration struct.
+#[derive(Deserialize, Debug, Serialize)]
+#[serde(default)]
+pub struct Config {
+ /// Configuration format version-- must be "1"
+ // Workaround to https://github.com/rotty/lexpr-rs/issues/77
+ // When this gets fixed, I can remove this element from the struct & deserialize as
+ // a Configurations element-- the on-disk format will be the same.
+ #[serde(rename = "version")]
+ _version: String,
+
+ /// Location of log file
+ pub log: PathBuf,
+
+ /// How to connect to mpd
+ pub conn: Connection,
+
+ /// The mode to start in
+ pub mode: Mode,
+
+ /// The `mpd' root music directory, relative to the host on which *this* daemon is running
+ pub local_music_dir: PathBuf,
+
+ /// Percentage threshold, expressed as a number between zero & one, for considering a song to
+ /// have been played
+ pub played_thresh: f64,
+
+ /// The interval, in milliseconds, at which to poll `mpd' for the current state
+ pub poll_interval_ms: u64,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self::new().unwrap()
+ }
+}
+
+impl Config {
+ fn new() -> Result<Self> {
+ Ok(Self {
+ _version: String::from("1"),
+ log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(),
+ conn: Connection::new()?,
+ local_music_dir: [PREFIX, "Music"].iter().collect(),
+ played_thresh: 0.6,
+ poll_interval_ms: 5000,
+ mode: Mode::default(),
+ })
+ }
+}
+
+pub fn from_str(text: &str) -> Result<Config> {
+ let cfg: Config = match serde_json::from_str(text) {
+ Ok(cfg) => cfg,
+ Err(err_outer) => {
+ bail!("Failed to parse config: `{}`", err_outer)
+ }
+ };
+ Ok(cfg)
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
new file mode 100644
index 00000000..3587df93
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
@@ -0,0 +1,233 @@
+use std::{
+ collections::HashSet,
+ time::{Duration, SystemTime},
+};
+
+use anyhow::{Context, Result};
+use rand::{RngExt, distr};
+use tracing::info;
+
+use crate::{clients::Client, storage};
+
+pub(crate) trait Algorithm {
+ async fn next_track(&mut self, client: &mut Client) -> Result<String>;
+}
+
+/// Generates generic discovery playlist, that fulfills following requirements:
+/// - Will (eventually) include every not-played song. (So it can be used to rank a library)
+/// - Returns liked songs more often then not-played or negative songs.
+pub struct Discovery {
+ already_done: HashSet<String>,
+ negative_chance: f64,
+ neutral_chance: f64,
+ positive_chance: f64,
+}
+
+impl Algorithm for Discovery {
+ async fn next_track(&mut self, client: &mut Client) -> Result<String> {
+ macro_rules! take {
+ ($rng:expr, $from:expr) => {{
+ info!(concat!(
+ "Trying to select a `",
+ stringify!($from),
+ "` track."
+ ));
+
+ assert!(!$from.is_empty());
+
+ let normalized_weights = {
+ // We normalize the weights here, because negative values don't work for the
+ // distribution function we use below.
+ // "-5" "-3" "1" "6" "19" | +5
+ // -> "0" "2" "6" "11" "24"
+ let mut weights = $from.iter().map(|(_, w)| *w).collect::<Vec<_>>();
+
+ weights.sort_by_key(|w| *w);
+
+ let first = *weights.first().expect(
+ "the value to exist, because we never run `take!` with an empty vector",
+ );
+
+ if first.is_negative() {
+ weights
+ .into_iter()
+ .rev()
+ .map(|w| w + first.abs())
+ .collect::<Vec<_>>()
+ } else {
+ weights
+ }
+ };
+
+ let sample = $rng.sample(
+ distr::weighted::WeightedIndex::new(normalized_weights.iter())
+ .expect("to be okay, because the weights are normalized"),
+ );
+
+ let output = $from.remove(sample);
+
+ info!(
+ concat!(
+ "(",
+ stringify!($from),
+ ") Selected `{}` with weight: `{}` (normalized to `{}`)"
+ ),
+ output.0, output.1, normalized_weights[sample]
+ );
+
+ Ok::<_, anyhow::Error>(output)
+ }};
+ }
+
+ let mut rng = rand::rng();
+ let (mut positive, mut neutral, mut negative) = {
+ let tracks = {
+ let mut base = client
+ .get_all_songs()
+ .await?
+ .into_iter()
+ .filter(|song| !self.already_done.contains(song))
+ .collect::<Vec<_>>();
+
+ if base.is_empty() {
+ // We could either have no tracks in the library,
+ // or we actually already listed to everything.
+ self.already_done = HashSet::new();
+
+ info!("Resetting already done songs, as we have no more to choose from");
+
+ base = client.get_all_songs().await?;
+ }
+
+ base
+ };
+
+ let mut sorted_tracks = Vec::with_capacity(tracks.len());
+ for track in tracks {
+ let weight = Self::weight_track(client, &track).await?;
+
+ sorted_tracks.push((track, weight));
+ }
+
+ sorted_tracks.sort_by_key(|(_, weight)| *weight);
+
+ let len = sorted_tracks.len() / 3;
+
+ // We split the tracks into three thirds, so that we can also force a pick from e.g.
+ // the lower third (the negative ones).
+ let negative = sorted_tracks.drain(..len).collect::<Vec<_>>();
+ let neutral = sorted_tracks.drain(..len).collect::<Vec<_>>();
+ let positive = sorted_tracks;
+
+ assert_eq!(negative.len(), neutral.len());
+
+ (positive, neutral, negative)
+ };
+
+ let pick = rng.sample(
+ distr::weighted::WeightedIndex::new(
+ [
+ self.positive_chance,
+ self.neutral_chance,
+ self.negative_chance,
+ ]
+ .iter(),
+ )
+ .expect("to be valid, as hardcoded"),
+ );
+
+ let next = match pick {
+ 0 if !positive.is_empty() => take!(rng, positive),
+ 1 if !neutral.is_empty() => take!(rng, neutral),
+ 2 if !negative.is_empty() => take!(rng, negative),
+ 0..=2 => {
+ // We couldn't actually satisfy the request, because we didn't have the required
+ // track. So we just use the first non-empty one.
+ if !positive.is_empty() {
+ take!(rng, positive)
+ } else if !neutral.is_empty() {
+ take!(rng, neutral)
+ } else if !negative.is_empty() {
+ take!(rng, negative)
+ } else {
+ assert!(positive.is_empty() && neutral.is_empty() && negative.is_empty());
+ todo!("No songs available to select from, I don't know how to select one.");
+ }
+ }
+ _ => unreachable!("These indexes are not possible"),
+ }?;
+
+ self.already_done.insert(next.0.to_owned());
+
+ Ok(next.0)
+ }
+}
+
+impl Discovery {
+ pub(crate) fn new(positive_chance: f64, neutral_chance: f64, negative_chance: f64) -> Self {
+ Self {
+ already_done: HashSet::new(),
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ }
+ }
+
+ /// Calculate a recommendation score for a track.
+ ///
+ /// The algorithm maps tracks, that the user likes to a high score and songs that the user
+ /// dislikes to a lower number.
+ /// Currently, only the rating, skip count and play count are considered. Similarity scores,
+ /// fetched from e.g. last.fm should be included in the future.
+ pub async fn weight_track(client: &mut Client, track: &str) -> Result<i64> {
+ let last_played_delta = {
+ let last_played = storage::last_played::get(client, track).await?.unwrap_or(0);
+ let now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .expect("to be before")
+ .as_secs();
+
+ let played_seconds_ago = now - last_played;
+
+ const HOUR: u64 = Duration::from_hours(1).as_secs();
+ const DAY: u64 = Duration::from_hours(24).as_secs();
+ const MONTH: u64 = Duration::from_hours(24 * 30).as_secs();
+
+ match played_seconds_ago {
+ ..HOUR => {
+ // it was played in the last hour already
+ -3
+ }
+ HOUR..DAY => {
+ // it was not played in the last hour, but in the last day
+ -2
+ }
+ DAY..MONTH => {
+ // it was not played in the last day, but in the last month
+ -1
+ }
+ MONTH.. => {
+ // it was not played in a month
+ 1
+ }
+ }
+ };
+
+ let rating = i32::from(storage::rating::get(client, track).await?.unwrap_or(0));
+ let play_count = i32::try_from(storage::play_count::get(client, track).await?.unwrap_or(0))
+ .context("`play_count` too big")?;
+ let skip_count = i32::try_from(storage::skip_count::get(client, track).await?.unwrap_or(0))
+ .context("`skip_count` too big")?;
+
+ let output: f64 = 1.0 * f64::from(rating)
+ + 0.3 * f64::from(play_count)
+ + -0.6 * f64::from(skip_count)
+ + 0.65 * f64::from(last_played_delta);
+
+ let weight = output.round() as i64;
+
+ // info!("`{track}`: {weight}");
+
+ Ok(weight)
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
new file mode 100644
index 00000000..548ed4f4
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
@@ -0,0 +1,28 @@
+use anyhow::Result;
+use tracing::info;
+
+use crate::{clients::Client, dj::algorithms::Algorithm};
+
+pub mod algorithms;
+
+pub(crate) struct Dj<A: Algorithm> {
+ algo: A,
+}
+
+impl<A: Algorithm> Dj<A> {
+ pub(crate) fn new(algo: A) -> Self {
+ Self { algo }
+ }
+
+ /// Add the next track to the playlist.
+ ///
+ /// This should be called after the previous track is finished, to avoid unbounded growth.
+ pub(crate) async fn add_track(&mut self, client: &mut Client) -> Result<()> {
+ let next = self.algo.next_track(client).await?;
+
+ info!("Adding `{next}`, due to active dj mode");
+ client.add(&next).await?;
+
+ Ok(())
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
new file mode 100644
index 00000000..970fc040
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
@@ -0,0 +1,160 @@
+// Copyright (C) 2020-2025 Michael Herstine <sp1ff@pobox.com> -*- mode: rust; rust-format-on-save: nil -*-
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+use lalrpop_util::ParseError;
+
+use crate::filters_ast::{
+ Conjunction,
+ Disjunction,
+ Expression,
+ OpCode,
+ Selector,
+ Term,
+ Value,
+ expect_quoted,
+ parse_iso_8601
+};
+use tracing::debug;
+
+grammar;
+
+pub ExprOp: OpCode = {
+ "==" => OpCode::Equality,
+ "!=" => OpCode::Inequality,
+ "contains" => OpCode::Contains,
+ "=~" => OpCode::RegexMatch,
+ "!~" => OpCode::RegexExclude,
+ ">" => OpCode::GreaterThan,
+ "<" => OpCode::LessThan,
+ ">=" => OpCode::GreaterThanEqual,
+ "<=" => OpCode::LessThanEqual,
+};
+
+pub ExprSel: Selector = {
+ r"(?i)artist" => Selector::Artist,
+ r"(?i)album" => Selector::Album,
+ r"(?i)albumartist" => Selector::AlbumArtist,
+ r"(?i)title" => Selector::Title,
+ r"(?i)track" => Selector::Track,
+ r"(?i)name" => Selector::Name,
+ r"(?i)genre" => Selector::Genre,
+ r"(?i)date" => Selector::Date,
+ r"(?i)originaldate" => Selector::OriginalDate,
+ r"(?i)composer" => Selector::Composer,
+ r"(?i)performer" => Selector::Performer,
+ r"(?i)conductor" => Selector::Conductor,
+ r"(?i)work" => Selector::Work,
+ r"(?i)grouping" => Selector::Grouping,
+ r"(?i)comment" => Selector::Comment,
+ r"(?i)disc" => Selector::Disc,
+ r"(?i)label" => Selector::Label,
+ r"(?i)musicbrainz_aristid" => Selector::MusicbrainzAristID,
+ r"(?i)musicbrainz_albumid" => Selector::MusicbrainzAlbumID,
+ r"(?i)musicbrainz_albumartistid" => Selector::MusicbrainzAlbumArtistID,
+ r"(?i)musicbrainz_trackid" => Selector::MusicbrainzTrackID,
+ r"(?i)musicbrainz_releasetrackid" => Selector::MusicbrainzReleaseTrackID,
+ r"(?i)musicbrainz_workid" => Selector::MusicbrainzWorkID,
+ r"(?i)file" => Selector::File,
+ r"(?i)base" => Selector::Base,
+ r"(?i)modified-since" => Selector::ModifiedSince,
+ r"(?i)audioformat" => Selector::AudioFormat,
+ r"(?i)rating" => Selector::Rating,
+ r"(?i)playcount" => Selector::PlayCount,
+ r"(?i)lastplayed" => Selector::LastPlayed,
+ r"(?i)skipped" => Selector::Skipped,
+};
+
+pub Token: Value = {
+ <s:r"(-)?[0-9]+"> =>? {
+ debug!("matched token: ``{}''.", s);
+ // We need to yield a Result<Value, ParseError>
+ match s.parse::<usize>() {
+ Ok(n) => Ok(Value::Uint(n)),
+ Err(_) => match s.parse::<i64>() {
+ Ok(n) => Ok(Value::Int(n)),
+ Err(_) => Err(
+ ParseError::User {
+ error: "Internal parse error while parsing unsigned int"
+ }
+ )
+ }
+ }
+ },
+ <s:r#""([ \t'a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\"|\\')+""#> => {
+ debug!("matched token: ``{}''.", s);
+ let s = expect_quoted(s).unwrap();
+ match parse_iso_8601(&mut s.as_bytes()) {
+ Ok(x) => Value::UnixEpoch(x),
+ Err(_) => Value::Text(s),
+ }
+ },
+ <s:r#"'([ \t"a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\'|\\")+'"#> => {
+ debug!("matched token: ``{}''.", s);
+ let s = expect_quoted(s).unwrap();
+ match parse_iso_8601(&mut s.as_bytes()) {
+ Ok(x) => Value::UnixEpoch(x),
+ Err(_) => Value::Text(s),
+ }
+ },
+};
+
+pub Term: Box<Term> = {
+ <t:ExprSel> <o:ExprOp> <u:Token> => {
+ debug!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u);
+ Box::new(Term::BinaryCondition(t, o, u))
+ },
+ <t:ExprSel> <u:Token> => {
+ debug!("matched unary condition: ``({}, {:#?})''", t, u);
+ Box::new(Term::UnaryCondition(t, u))
+ },
+}
+
+pub Conjunction: Box<Conjunction> = {
+ <e1:Expression> "AND" <e2:Expression> => {
+ debug!("matched conjunction: ``({:#?}, {:#?})''", e1, e2);
+ Box::new(Conjunction::Simple(e1, e2))
+ },
+ <c:Conjunction> "AND" <e:Expression> => {
+ debug!("matched conjunction: ``({:#?}, {:#?})''", c, e);
+ Box::new(Conjunction::Compound(c, e))
+ },
+}
+
+pub Disjunction: Box<Disjunction> = {
+ <e1:Expression> "OR" <e2:Expression> => {
+ debug!("matched disjunction: ``({:#?}, {:#?})''", e1, e2);
+ Box::new(Disjunction::Simple(e1, e2))
+ },
+ <c:Disjunction> "OR" <e:Expression> => {
+ debug!("matched disjunction: ``({:#?}, {:#?})''", c, e);
+ Box::new(Disjunction::Compound(c, e))
+ },
+}
+
+pub Expression: Box<Expression> = {
+ "(" <t:Term> ")" => {
+ debug!("matched parenthesized term: ``({:#?})''", t);
+ Box::new(Expression::Simple(t))
+ },
+ "(" "!" <e:Expression> ")" => Box::new(Expression::Negation(e)),
+ "(" <c:Conjunction> ")" => {
+ debug!("matched parenthesized conjunction: ``({:#?})''", c);
+ Box::new(Expression::Conjunction(c))
+ },
+ "(" <c:Disjunction> ")" => {
+ debug!("matched parenthesized disjunction: ``({:#?})''", c);
+ Box::new(Expression::Disjunction(c))
+ },
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
new file mode 100644
index 00000000..9c68d329
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
@@ -0,0 +1,1022 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! Types for building the Abstract Syntax Tree when parsing filters
+//!
+//! This module provides support for our [lalrpop](https://github.com/lalrpop/lalrpop) grammar.
+
+use crate::clients::Client;
+use crate::storage::{last_played, play_count, rating, skip_count};
+
+use anyhow::{Context, Error, Result, anyhow, bail};
+use boolinator::Boolinator;
+use chrono::prelude::*;
+use tracing::debug;
+
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+
+/// The operations that can appear in a filter term
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum OpCode {
+ Equality,
+ Inequality,
+ Contains,
+ RegexMatch,
+ RegexExclude,
+ GreaterThan,
+ LessThan,
+ GreaterThanEqual,
+ LessThanEqual,
+}
+
+impl std::fmt::Display for OpCode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{}",
+ match self {
+ OpCode::Equality => "==",
+ OpCode::Inequality => "!=",
+ OpCode::Contains => "contains",
+ OpCode::RegexMatch => "=~",
+ OpCode::RegexExclude => "!~",
+ OpCode::GreaterThan => ">",
+ OpCode::LessThan => "<",
+ OpCode::GreaterThanEqual => ">=",
+ OpCode::LessThanEqual => "<=",
+ }
+ )
+ }
+}
+
+/// The song attributes that can appear on the LHS of a filter term
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Selector {
+ Artist,
+ Album,
+ AlbumArtist,
+ Title,
+ Track,
+ Name,
+ Genre,
+ Date,
+ OriginalDate,
+ Composer,
+ Performer,
+ Conductor,
+ Work,
+ Grouping,
+ Comment,
+ Disc,
+ Label,
+ MusicbrainzAristID,
+ MusicbrainzAlbumID,
+ MusicbrainzAlbumArtistID,
+ MusicbrainzTrackID,
+ MusicbrainzReleaseTrackID,
+ MusicbrainzWorkID,
+ File,
+ Base,
+ ModifiedSince,
+ AudioFormat,
+ Rating,
+ PlayCount,
+ LastPlayed,
+ Skipped,
+}
+
+impl std::fmt::Display for Selector {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{}",
+ match self {
+ Selector::Artist => "artist",
+ Selector::Album => "album",
+ Selector::AlbumArtist => "albumartist",
+ Selector::Title => "title",
+ Selector::Track => "track",
+ Selector::Name => "name",
+ Selector::Genre => "genre",
+ Selector::Date => "date",
+ Selector::OriginalDate => "originaldate",
+ Selector::Composer => "composer",
+ Selector::Performer => "performer",
+ Selector::Conductor => "conductor",
+ Selector::Work => "work",
+ Selector::Grouping => "grouping",
+ Selector::Comment => "comment",
+ Selector::Disc => "disc",
+ Selector::Label => "label",
+ Selector::MusicbrainzAristID => "musicbrainz_aristid",
+ Selector::MusicbrainzAlbumID => "musicbrainz_albumid",
+ Selector::MusicbrainzAlbumArtistID => "musicbrainz_albumartistid",
+ Selector::MusicbrainzTrackID => "musicbrainz_trackid",
+ Selector::MusicbrainzReleaseTrackID => "musicbrainz_releasetrackid",
+ Selector::MusicbrainzWorkID => "musicbrainz_workid",
+ Selector::File => "file",
+ Selector::Base => "base",
+ Selector::ModifiedSince => "modified-since",
+ Selector::AudioFormat => "AudioFormat",
+ Selector::Rating => "rating",
+ Selector::PlayCount => "playcount",
+ Selector::LastPlayed => "lastplayed",
+ Selector::Skipped => "skipped",
+ }
+ )
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+ Text(String),
+ UnixEpoch(i64),
+ Uint(usize),
+ Int(i64),
+}
+
+fn quote_value(x: &Value) -> String {
+ match x {
+ Value::Text(s) => {
+ let mut ret = String::new();
+
+ ret.push('"');
+ for c in s.chars() {
+ if c == '"' || c == '\\' {
+ ret.push('\\');
+ }
+ ret.push(c);
+ }
+ ret.push('"');
+ ret
+ }
+ Value::UnixEpoch(n) => {
+ format!("'{}'", n)
+ }
+ Value::Uint(n) => {
+ format!("'{}'", n)
+ }
+ Value::Int(n) => {
+ format!("'{}'", n)
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub enum Term {
+ UnaryCondition(Selector, Value),
+ BinaryCondition(Selector, OpCode, Value),
+}
+
+#[derive(Clone, Debug)]
+pub enum Conjunction {
+ Simple(Box<Expression>, Box<Expression>),
+ Compound(Box<Conjunction>, Box<Expression>),
+}
+
+#[derive(Clone, Debug)]
+pub enum Disjunction {
+ Simple(Box<Expression>, Box<Expression>),
+ Compound(Box<Disjunction>, Box<Expression>),
+}
+
+#[derive(Clone, Debug)]
+pub enum Expression {
+ Simple(Box<Term>),
+ Negation(Box<Expression>),
+ Conjunction(Box<Conjunction>),
+ Disjunction(Box<Disjunction>),
+}
+
+#[cfg(test)]
+mod smoke_tests {
+ use super::*;
+ use crate::filters::*;
+
+ #[test]
+ fn test_opcodes() {
+ assert!(ExprOpParser::new().parse("==").unwrap() == OpCode::Equality);
+ assert!(ExprOpParser::new().parse("!=").unwrap() == OpCode::Inequality);
+ assert!(ExprOpParser::new().parse("contains").unwrap() == OpCode::Contains);
+ assert!(ExprOpParser::new().parse("=~").unwrap() == OpCode::RegexMatch);
+ assert!(ExprOpParser::new().parse("!~").unwrap() == OpCode::RegexExclude);
+ assert!(ExprOpParser::new().parse(">").unwrap() == OpCode::GreaterThan);
+ assert!(ExprOpParser::new().parse("<").unwrap() == OpCode::LessThan);
+ assert!(ExprOpParser::new().parse(">=").unwrap() == OpCode::GreaterThanEqual);
+ assert!(ExprOpParser::new().parse("<=").unwrap() == OpCode::LessThanEqual);
+ }
+
+ #[test]
+ fn test_conditions() {
+ assert!(TermParser::new().parse("base 'foo'").is_ok());
+ assert!(TermParser::new().parse("artist == 'foo'").is_ok());
+ assert!(
+ TermParser::new()
+ .parse(r#"artist =~ "foo bar \"splat\"!""#)
+ .is_ok()
+ );
+ assert!(TermParser::new().parse("artist =~ 'Pogues'").is_ok());
+
+ match *TermParser::new()
+ .parse(r#"base "/Users/me/My Music""#)
+ .unwrap()
+ {
+ Term::UnaryCondition(a, b) => {
+ assert!(a == Selector::Base);
+ assert!(b == Value::Text(String::from(r#"/Users/me/My Music"#)));
+ }
+ _ => {
+ unreachable!();
+ }
+ }
+
+ match *TermParser::new()
+ .parse(r#"artist =~ "foo bar \"splat\"!""#)
+ .unwrap()
+ {
+ Term::BinaryCondition(t, op, s) => {
+ assert!(t == Selector::Artist);
+ assert!(op == OpCode::RegexMatch);
+ assert!(s == Value::Text(String::from(r#"foo bar "splat"!"#)));
+ }
+ _ => {
+ unreachable!();
+ }
+ }
+ }
+
+ #[test]
+ fn test_expressions() {
+ assert!(ExpressionParser::new().parse("( base 'foo' )").is_ok());
+ assert!(ExpressionParser::new().parse("(base \"foo\")").is_ok());
+ assert!(
+ ExpressionParser::new()
+ .parse("(!(artist == 'value'))")
+ .is_ok()
+ );
+ assert!(
+ ExpressionParser::new()
+ .parse(r#"((!(artist == "foo bar")) AND (base "/My Music"))"#)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn test_quoted_expr() {
+ eprintln!("test_quoted_expr");
+ assert!(
+ ExpressionParser::new()
+ .parse(r#"(artist =~ "foo\\bar\"")"#)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn test_real_expression() {
+ let result = ExpressionParser::new()
+ .parse(r#"(((Artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues')) AND ((rating > 128) OR (rating == 0)))"#);
+ eprintln!("{:#?}", result);
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn test_conjunction() {
+ assert!(ExpressionParser::new()
+ .parse(
+ r#"((base "foo") AND (artist == "foo bar") AND (!(file == '/net/mp3/A/a.mp3')))"#
+ )
+ .is_ok());
+
+ eprintln!("==============================================================================");
+ eprintln!("{:#?}", ExpressionParser::new()
+ .parse(
+ r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == "/net/mp3/A/a.mp3")) OR (file == "/pub/mp3/A/a.mp3")))"#
+ ));
+ assert!(ExpressionParser::new()
+ .parse(
+ r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == '/net/mp3/A/a.mp3')) OR (file == '/pub/mp3/A/a.mp3')))"#
+ )
+ .is_ok());
+ }
+
+ #[test]
+ fn test_disjunction() {
+ assert!(ExpressionParser::new().
+ parse(r#"((artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues'))"#)
+ .is_ok());
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum EvalOp {
+ And,
+ Or,
+ Not,
+}
+
+impl std::fmt::Display for EvalOp {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ EvalOp::And => write!(f, "And"),
+ EvalOp::Or => write!(f, "Or"),
+ EvalOp::Not => write!(f, "Not"),
+ }
+ }
+}
+
+fn peek(buf: &[u8]) -> Option<char> {
+ match buf.len() {
+ 0 => None,
+ _ => Some(buf[0] as char),
+ }
+}
+
+// advancing a slice by `i` indicies can *not* be this difficult
+/// Pop a single byte off of `buf`
+fn take1(buf: &mut &[u8], i: usize) -> Result<()> {
+ if i > buf.len() {
+ bail!("Bad iso-8601 string: `{:#?}`", buf);
+ }
+ let (_first, second) = buf.split_at(i);
+ *buf = second;
+ Ok(())
+}
+
+/// Pop `i` bytes off of `buf` & parse them as a T
+fn take2<T>(buf: &mut &[u8], i: usize) -> Result<T>
+where
+ T: FromStr,
+ <T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static,
+{
+ // 1. check len
+ if i > buf.len() {
+ bail!("Bad iso-8601 string: `{:#?}`", buf);
+ }
+
+ let (first, second) = buf.split_at(i);
+ *buf = second;
+ // 2. convert to a string
+ let s = std::str::from_utf8(first).context("Bad iso-8601 string")?;
+ // 3. parse as a T
+ s.parse::<T>()
+ .context("Failed to parse iso-8601 string as T")
+}
+
+/// Parse a timestamp in ISO 8601 format to a chrono DateTime instance
+///
+/// Surprisingly, I was unable to find an ISO 8601 parser in Rust. I *did* find a crate named
+/// iso-8601 that promised to do this, but it seemed derelict & I couldn't see what to do with the
+/// parse output in any event. The ISO 8601 format is simple enough that I've chosen to simply
+/// hand-parse it.
+pub fn parse_iso_8601(buf: &mut &[u8]) -> Result<i64> {
+ // I wonder if `nom` would be a better choice?
+
+ // The first four characters must be the year (expanded year representation is not supported by
+ // this parser).
+
+ let year: i32 = take2(buf, 4)?;
+
+ // Now at this point:
+ // 1. we may be done (i.e. buf.len() == 0)
+ // 2. we may have the timestamp (peek(buf) => Some('T'))
+ // - day & month := 0, consume the 'T', move on to parsing the time
+ // 3. we may have a month in extended format (i.e. peek(buf) => Some('-')
+ // - consume the '-', parse the month & move on to parsing the day
+ // 4. we may have a month in basic format (take(buf, 2) => Some('\d\d')
+ // - parse the month & move on to parsing the day
+ let mut month = 1;
+ let mut day = 1;
+ let mut hour = 0;
+ let mut minute = 0;
+ let mut second = 0;
+ if !buf.is_empty() {
+ let next = peek(buf);
+ if next != Some('T') {
+ let mut ext_fmt = false;
+ if next == Some('-') {
+ take1(buf, 1)?;
+ ext_fmt = true;
+ }
+ month = take2(buf, 2)?;
+
+ // At this point:
+ // 1. we may be done (i.e. buf.len() == 0)
+ // 2. we may have the timestamp (peek(buf) => Some('T'))
+ // 3. we may have the day (in basic or extended format)
+ if !buf.is_empty() && peek(buf) != Some('T') {
+ if ext_fmt {
+ take1(buf, 1)?;
+ }
+ day = take2(buf, 2)?;
+ }
+ }
+
+ // Parse time: at this point, buf will either be empty or begin with 'T'
+ if !buf.is_empty() {
+ take1(buf, 1)?;
+ // If there's a T, there must at least be an hour
+ hour = take2(buf, 2)?;
+ if !buf.is_empty() {
+ let mut ext_fmt = false;
+ if peek(buf) == Some(':') {
+ take1(buf, 1)?;
+ ext_fmt = true;
+ }
+ minute = take2(buf, 2)?;
+ if !buf.is_empty() {
+ if ext_fmt {
+ take1(buf, 1)?;
+ }
+ second = take2(buf, 2)?;
+ }
+ }
+ }
+
+ // At this point, there may be a timezone
+ if !buf.is_empty() {
+ if peek(buf) == Some('Z') {
+ return Ok(Utc
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(anyhow!("bad iso-8601 string"))?
+ .timestamp());
+ } else {
+ let next = peek(buf);
+ if next != Some('-') && next != Some('+') {
+ bail!("bad iso-8601 string")
+ }
+ let west = next == Some('-');
+ take1(buf, 1)?;
+
+ let hours: i32 = take2(buf, 2)?;
+ let mut minutes = 0;
+
+ if !buf.is_empty() {
+ if peek(buf) == Some(':') {
+ take1(buf, 1)?;
+ }
+ minutes = take2(buf, 2)?;
+ }
+
+ if west {
+ return Ok(FixedOffset::west_opt(hours * 3600 + minutes * 60)
+ .ok_or(anyhow!("Bad iso-8601 string"))?
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(anyhow!("Bad iso-8601 string"))?
+ .timestamp());
+ } else {
+ return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60)
+ .ok_or(anyhow!("Bad iso-8601 string"))?
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(anyhow!("Bad iso-8601 string"))?
+ .timestamp());
+ }
+ }
+ }
+ }
+ Ok(Local
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(anyhow!("Bad iso-8601 string"))?
+ .timestamp())
+}
+
+#[cfg(test)]
+mod iso_8601_tests {
+
+ use super::*;
+
+ #[test]
+ fn smoke_tests() {
+ let mut b = "19700101T00:00:00Z".as_bytes();
+ let t = parse_iso_8601(&mut b).unwrap();
+ assert!(t == 0);
+
+ let mut b = "19700101T00:00:01Z".as_bytes();
+ let t = parse_iso_8601(&mut b).unwrap();
+ assert!(t == 1);
+
+ let mut b = "20210327T02:26:53Z".as_bytes();
+ let t = parse_iso_8601(&mut b).unwrap();
+ assert_eq!(t, 1616812013);
+
+ let mut b = "20210327T07:29:05-07:00".as_bytes();
+ let t = parse_iso_8601(&mut b).unwrap();
+ assert_eq!(t, 1616855345);
+
+ let mut b = "2021".as_bytes();
+ // Should resolve to midnight, Jan 1 2021 in local time; don't want to test against the
+ // timestamp; just make sure it parses
+ parse_iso_8601(&mut b).unwrap();
+ }
+}
+
+/// "Un-quote" a token
+///
+/// Textual tokens must be quoted, and double-quote & backslashes within backslash-escaped. If the
+/// string is quoted with single-quotes, then any single-quotes inside the string will also need
+/// to be escaped.
+///
+/// In fact, *any* characters within may be harmlessly backslash escaped; the MPD implementation
+/// walks the the string, skipping backslashes as it goes, so this implementation will do the same.
+/// I have named this method in imitation of the corresponding MPD function.
+pub fn expect_quoted(qtext: &str) -> Result<String> {
+ let mut iter = qtext.chars();
+ let quote = iter.next();
+ if quote.is_none() {
+ return Ok(String::new());
+ }
+
+ if quote != Some('\'') && quote != Some('"') {
+ bail!("Expected text to be quoted: `{}`", qtext);
+ }
+
+ let mut ret = String::new();
+
+ // Walk qtext[1..]; copying characters to `ret'. If a '\' is found, skip to the next character
+ // (even if that is a '\'). The last character in qtext should be the closing quote.
+ let mut this = iter.next();
+ while this != quote {
+ if this == Some('\\') {
+ this = iter.next();
+ }
+ match this {
+ Some(c) => ret.push(c),
+ None => {
+ bail!("Expected text to be quoted: `{}`", qtext);
+ }
+ }
+ this = iter.next();
+ }
+
+ Ok(ret)
+}
+
+#[cfg(test)]
+mod quoted_tests {
+
+ use super::*;
+
+ #[test]
+ fn smoke_tests() {
+ let b = r#""foo bar \"splat!\"""#;
+ let s = expect_quoted(b).unwrap();
+ assert!(s == r#"foo bar "splat!""#);
+ }
+}
+
+/// Create a closure that will carry out an operator on its argument
+///
+/// Call this function with an [OpCode] and a value of type `T`. `T` must be [PartialEq],
+/// [`PartialOrd`] and [`Copy`]-- an integral type will do. It will return a closure that will carry
+/// out the given [OpCode] against the given value. For instance,
+/// `make_numeric_closure::<u8>(OpCode::Equality, 11)` will return a closure that takes a `u8` &
+/// will return true if its argument is 11 (and false otherwise).
+///
+/// If [OpCode] is not pertinent to a numeric type, then this function will return Err.
+fn make_numeric_closure<'a, T: 'a + PartialEq + PartialOrd + Copy>(
+ op: OpCode,
+ val: T,
+) -> Result<impl Fn(T) -> bool + 'a> {
+ // Rust closures each have their own type, so this was the only way I could find to
+ // return them from match arms. This seems ugly; perhaps there's something I'm
+ // missing.
+ //
+ // I have no idea why I have to make these `move` closures; T is constrained to by Copy-able,
+ // so I would have expected the closure to just take a copy.
+ match op {
+ OpCode::Equality => Ok(Box::new(move |x: T| x == val) as Box<dyn Fn(T) -> bool>),
+ OpCode::Inequality => Ok(Box::new(move |x: T| x != val) as Box<dyn Fn(T) -> bool>),
+ OpCode::GreaterThan => Ok(Box::new(move |x: T| x > val) as Box<dyn Fn(T) -> bool>),
+ OpCode::LessThan => Ok(Box::new(move |x: T| x < val) as Box<dyn Fn(T) -> bool>),
+ OpCode::GreaterThanEqual => Ok(Box::new(move |x: T| x >= val) as Box<dyn Fn(T) -> bool>),
+ OpCode::LessThanEqual => Ok(Box::new(move |x: T| x <= val) as Box<dyn Fn(T) -> bool>),
+ _ => bail!("Invalid operant: `{op}`"),
+ }
+}
+
+async fn eval_numeric_sticker_term<
+ // The `FromStr' trait bound is really weird, but if I don't constrain the associated
+ // Err type to be `ParseIntError' the compiler complains about not being able to convert
+ // it to type `Error'. I'm probably still "thinking in C++" and imagining the compiler
+ // instantiating this function for each type (u8, usize, &c) instead of realizing that the Rust
+ // compiler is processing this as a first-class function.
+ //
+ // For instance, I can do the conversion manually, so long as I constrain the Err type
+ // to implement std::error::Error. I should probably be doing that, but it clutters the
+ // code. I'll figure it out when I need to extend this function to handle non-integral types
+ // :)
+ T: PartialEq + PartialOrd + Copy + FromStr<Err = std::num::ParseIntError> + std::fmt::Display,
+>(
+ sticker: &str,
+ client: &mut Client,
+ op: OpCode,
+ numeric_val: T,
+ default_val: T,
+) -> Result<HashSet<String>> {
+ let cmp = make_numeric_closure(op, numeric_val)?;
+ // It would be better to idle on the sticker DB & just update our collection on change, but for
+ // a first impl. this will do.
+ //
+ // Call `get_stickers'; this will return a HashMap from song URIs to ratings expressed as text
+ // (as all stickers are). This stanza will drain that collection into a new one with the ratings
+ // expressed as T.
+ //
+ // The point is that conversion from text to rating, lastplayed, or whatever can fail; the
+ // invocation of `collect' will call `from_iter' to convert a collection of Result-s to a Result
+ // of a collection.
+ let mut m = client
+ .get_stickers(sticker)
+ .await
+ .context("Failed to get stickers from client")?
+ .drain()
+ .map(|(k, v)| v.parse::<T>().map(|x| (k, x)))
+ .collect::<std::result::Result<HashMap<String, T>, _>>()
+ .context("Failed to parse sticker as T")?;
+ // `m' is now a map of song URI to rating/playcount/wathever (expressed as a T)... for all songs
+ // that have the salient sticker.
+ //
+ // This seems horribly inefficient, but I'm going to fetch all the song URIs in the music DB,
+ // and augment `m' with entries of `default_val' for any that are not already there.
+ client
+ .get_all_songs()
+ .await
+ .context("Failed to get all songs from client")?
+ .drain(..)
+ .for_each(|song| {
+ m.entry(song).or_insert(default_val);
+ });
+
+ // Now that we don't have to worry about operations that can fail, we can use
+ // `filter_map'.
+ Ok(m.drain()
+ .filter_map(|(k, v)| cmp(v).as_some(k))
+ .collect::<HashSet<String>>())
+}
+
+/// Convenience struct collecting the names for assorted stickers on which one may search
+///
+/// While the search terms 'rating', 'playcount' &c are fixed & part of the filter grammar offered
+/// by mpdpopm, the precise names of the corresponding stickers are configurable & hence must be
+/// passed in. Three references to str is already unweildy IMO, and since I expect the number of
+/// stickers on which one can search to grow further, I decided to wrap 'em up in a struct. The
+/// lifetime is there to support the caller just using a reference to an existing string rather than
+/// making a copy.
+pub struct FilterStickerNames<'a> {
+ rating: &'a str,
+ playcount: &'a str,
+ lastplayed: &'a str,
+ skipped: &'a str,
+}
+
+impl FilterStickerNames<'static> {
+ pub fn new() -> FilterStickerNames<'static> {
+ Self::default()
+ }
+}
+
+impl Default for FilterStickerNames<'static> {
+ fn default() -> Self {
+ Self {
+ rating: rating::STICKER,
+ playcount: play_count::STICKER,
+ lastplayed: last_played::STICKER,
+ skipped: skip_count::STICKER,
+ }
+ }
+}
+
+/// Evaluate a Term
+///
+/// Take a Term from the Abstract Syntax tree & resolve it to a collection of song URIs. Set `case`
+/// to `true` to search case-sensitively & `false` to make the search case-insensitive.
+async fn eval_term<'a>(
+ term: &Term,
+ case: bool,
+ client: &mut Client,
+ stickers: &FilterStickerNames<'a>,
+) -> Result<HashSet<String>> {
+ match term {
+ Term::UnaryCondition(op, val) => Ok(client
+ .find1(&format!("{}", op), &quote_value(val), case)
+ .await
+ .context("Failed to find1 on client")?
+ .drain(..)
+ .collect()),
+ Term::BinaryCondition(attr, op, val) => {
+ if *attr == Selector::Rating {
+ let value = match val {
+ Value::Int(n) => *n as i128,
+ Value::Uint(n) => *n as i128,
+ _ => bail!("filter ratings expect an int; got {:#?}", val),
+ };
+
+ let val: i8 = value.try_into().with_context(|| {
+ format!(
+ "Failed to convert `{}` into a number from -128 to 128!",
+ value
+ )
+ })?;
+ Ok(eval_numeric_sticker_term(stickers.rating, client, *op, val, 0).await?)
+ } else if *attr == Selector::PlayCount {
+ match val {
+ Value::Uint(n) => {
+ Ok(
+ eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0)
+ .await?,
+ )
+ }
+ _ => bail!("filter play_count expect an unsigned int; got {:#?}", val),
+ }
+ } else if *attr == Selector::LastPlayed {
+ match val {
+ Value::UnixEpoch(t) => {
+ Ok(
+ eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0)
+ .await?,
+ )
+ }
+ _ => bail!("filter last_played expect an unix epoch; got {:#?}", val),
+ }
+ } else if *attr == Selector::Skipped {
+ match val {
+ Value::Uint(t) => {
+ Ok(eval_numeric_sticker_term(stickers.skipped, client, *op, *t, 0).await?)
+ }
+ _ => bail!("filter skipped expect an unsigned int; got {:#?}", val),
+ }
+ } else {
+ Ok(client
+ .find2(
+ &format!("{}", attr),
+ &format!("{}", op),
+ &quote_value(val),
+ case,
+ )
+ .await
+ .context("Failed to `find2` on client")?
+ .drain(..)
+ .collect())
+ }
+ }
+ }
+}
+
+/// The evaluation stack contains logical operators & sets of song URIs
+#[derive(Debug)]
+enum EvalStackNode {
+ Op(EvalOp),
+ Result(HashSet<String>),
+}
+
+async fn negate_result(
+ res: &HashSet<String>,
+ client: &mut Client,
+) -> std::result::Result<HashSet<String>, Error> {
+ Ok(client
+ .get_all_songs()
+ .await
+ .context("Failed to get all songs from client")?
+ .drain(..)
+ .filter_map(|song| {
+ // Some(thing) adds thing, None elides it
+ if !res.contains(&song) {
+ Some(song)
+ } else {
+ None
+ }
+ })
+ .collect::<HashSet<String>>())
+}
+
+/// Reduce the evaluation stack as far as possible.
+///
+/// We can pop the stack in two cases:
+///
+/// 1. S.len() > 2 and S[-3] is either And or Or, and both S[-1] & S[-2] are Result-s
+/// 2. S.len() > 1, S[-2] is Not, and S[-1] is a Result
+async fn reduce(stack: &mut Vec<EvalStackNode>, client: &mut Client) -> Result<()> {
+ loop {
+ let mut reduced = false;
+ let n = stack.len();
+ if n > 1 {
+ // Take care to compute the reduction *before* popping the stack-- thank you, borrow
+ // checker!
+ let reduction = if let (EvalStackNode::Op(EvalOp::Not), EvalStackNode::Result(r)) =
+ (&stack[n - 2], &stack[n - 1])
+ {
+ Some(negate_result(r, client).await?)
+ } else {
+ None
+ };
+
+ if let Some(res) = reduction {
+ stack.pop();
+ stack.pop();
+ stack.push(EvalStackNode::Result(res));
+ reduced = true;
+ }
+ }
+ let n = stack.len();
+ if n > 2 {
+ // Take care to compute the reduction *before* popping the stack-- thank you, borrow
+ // checker!
+ let and_reduction = if let (
+ EvalStackNode::Op(EvalOp::And),
+ EvalStackNode::Result(r1),
+ EvalStackNode::Result(r2),
+ ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1])
+ {
+ Some(r1.intersection(r2).cloned().collect())
+ } else {
+ None
+ };
+
+ if let Some(res) = and_reduction {
+ stack.pop();
+ stack.pop();
+ stack.pop();
+ stack.push(EvalStackNode::Result(res));
+ reduced = true;
+ }
+ }
+ let n = stack.len();
+ if n > 2 {
+ let or_reduction = if let (
+ EvalStackNode::Op(EvalOp::Or),
+ EvalStackNode::Result(r1),
+ EvalStackNode::Result(r2),
+ ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1])
+ {
+ Some(r1.union(r2).cloned().collect())
+ } else {
+ None
+ };
+
+ if let Some(res) = or_reduction {
+ stack.pop();
+ stack.pop();
+ stack.pop();
+ stack.push(EvalStackNode::Result(res));
+ reduced = true;
+ }
+ }
+
+ if !reduced {
+ break;
+ }
+ }
+
+ Ok(())
+}
+
+/// Evaluate an abstract syntax tree (AST)
+pub async fn evaluate<'a>(
+ expr: &Expression,
+ case: bool,
+ client: &mut Client,
+ stickers: &FilterStickerNames<'a>,
+) -> Result<HashSet<String>> {
+ // We maintain *two* stacks, one for parsing & one for evaluation. Let sp (for "stack(parse)")
+ // be a stack of references to nodes in the parse tree.
+ let mut sp = Vec::new();
+ // Initialize it with the root; as we walk the tree, we'll pop the "most recent" node, and push
+ // children.
+ sp.push(expr);
+
+ // Let se (for "stack(eval)") be a stack of operators & URIs.
+ let mut se = Vec::new();
+
+ // Simple DFS traversal of the AST:
+ while let Some(node) = sp.pop() {
+ // and dispatch based on what we've got:
+ match node {
+ // 1. we have a simple term: this can be immediately resolved to a set of song URIs. Do
+ // so & push the resulting set onto the evaluation stack.
+ Expression::Simple(bt) => se.push(EvalStackNode::Result(
+ eval_term(bt, case, client, stickers).await?,
+ )),
+ // 2. we have a negation: push the "not" operator onto the evaluation stack & the child
+ // onto the parse stack.
+ Expression::Negation(be) => {
+ se.push(EvalStackNode::Op(EvalOp::Not));
+ sp.push(be);
+ }
+ // 3. conjunction-- push the "and" operator onto the evaluation stack & the children
+ // onto the parse stack (be sure to push the right-hand child first, so it will be
+ // popped second)
+ // bc is &Box<Conjunction<'a>>, so &**bc is &Conjunction<'a>
+ Expression::Conjunction(bc) => {
+ let mut conj = &**bc;
+ loop {
+ match conj {
+ Conjunction::Simple(bel, ber) => {
+ se.push(EvalStackNode::Op(EvalOp::And));
+ sp.push(&**ber);
+ sp.push(&**bel);
+ break;
+ }
+ Conjunction::Compound(bc, be) => {
+ se.push(EvalStackNode::Op(EvalOp::And));
+ sp.push(&**be);
+ conj = bc;
+ }
+ }
+ }
+ }
+ Expression::Disjunction(bt) => {
+ let mut disj = &**bt;
+ loop {
+ match disj {
+ Disjunction::Simple(bel, ber) => {
+ se.push(EvalStackNode::Op(EvalOp::Or));
+ sp.push(ber);
+ sp.push(bel);
+ break;
+ }
+ Disjunction::Compound(bd, be) => {
+ se.push(EvalStackNode::Op(EvalOp::Or));
+ sp.push(&**be);
+ disj = bd;
+ }
+ }
+ }
+ }
+ }
+
+ reduce(&mut se, client).await?;
+ }
+
+ // At this point, sp is empty, but there had better be something on se. Keep reducing the stack
+ // until either we can't any further (in which case we error) or there is only one element left
+ // (in which case we return that).
+ reduce(&mut se, client).await?;
+
+ // Now, se had better have one element, and that element had better be a Result.
+ if 1 != se.len() {
+ debug!("Too many ({}) operands left on stack:", se.len());
+ se.iter()
+ .enumerate()
+ .for_each(|(i, x)| debug!(" {}: {:#?}", i, x));
+ bail!("The number of operants is too big `{}`", se.len());
+ }
+
+ let ret = se.pop().unwrap();
+ match ret {
+ EvalStackNode::Result(result) => Ok(result),
+ EvalStackNode::Op(op) => {
+ debug!("Operator left on stack (!?): {:#?}", op);
+ bail!("Operator left on stack: {op}")
+ }
+ }
+}
+
+#[cfg(test)]
+mod evaluation_tests {
+
+ use super::*;
+ use crate::filters::*;
+
+ use crate::clients::Client;
+ use crate::clients::test_mock::Mock;
+
+ #[tokio::test]
+ async fn smoke() {
+ let mock = Box::new(Mock::new(&[(
+ r#"find "(base \"foo\")""#,
+ "file: foo/a.mp3
+Artist: The Foobars
+file: foo/b.mp3
+Title: b!
+OK",
+ )]));
+ let mut cli = Client::new(mock).unwrap();
+
+ let stickers = FilterStickerNames::new();
+
+ let expr = ExpressionParser::new().parse(r#"(base "foo")"#).unwrap();
+ let result = evaluate(&expr, true, &mut cli, &stickers).await;
+ assert!(result.is_ok());
+
+ let g: HashSet<String> = ["foo/a.mp3", "foo/b.mp3"]
+ .iter()
+ .map(|x| x.to_string())
+ .collect();
+ assert!(result.unwrap() == g);
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs
new file mode 100644
index 00000000..6d04a527
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs
@@ -0,0 +1,185 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! # mpdpopm
+//!
+//! Maintain ratings & playcounts for your mpd server.
+//!
+//! # Introduction
+//!
+//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts &
+//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust
+//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as
+//! the sticker database, by invoking external commands to keep your tags up-to-date (something
+//! along the lines of [mpdcron](https://alip.github.io/mpdcron)).
+//!
+//! # Commands
+//!
+//! I'm currently sending all commands over one (configurable) channel.
+//!
+
+#![recursion_limit = "512"] // for the `select!' macro
+
+pub mod clients;
+pub mod config;
+pub mod dj;
+pub mod filters_ast;
+pub mod messanges;
+pub mod playcounts;
+pub mod storage;
+pub mod vars;
+
+#[rustfmt::skip]
+#[allow(clippy::extra_unused_lifetimes)]
+#[allow(clippy::needless_lifetimes)]
+#[allow(clippy::let_unit_value)]
+#[allow(clippy::just_underscores_and_digits)]
+pub mod filters {
+ include!(concat!(env!("OUT_DIR"), "/filters.rs"));
+}
+
+use crate::{
+ clients::{Client, IdleClient, IdleSubSystem},
+ config::{Config, Connection},
+ messanges::{COMMAND_CHANNEL, MessageQueue},
+ playcounts::PlayState,
+};
+
+use anyhow::{Context, Error};
+use futures::{future::FutureExt, pin_mut, select};
+use tokio::{
+ signal,
+ signal::unix::{SignalKind, signal},
+ time::{Duration, sleep},
+};
+use tracing::{debug, error, info};
+
+/// Core `mppopmd' logic
+pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
+ info!("mpdpopm {} beginning.", vars::VERSION);
+
+ let mut client =
+ match cfg.conn {
+ Connection::Local { ref path } => Client::open(path)
+ .await
+ .with_context(|| format!("Failed to open socket at `{}`", path.display()))?,
+ Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port))
+ .await
+ .with_context(|| format!("Failed to connect to client at `{}:{}`", host, port))?,
+ };
+
+ let mut state = PlayState::new(&mut client, cfg.played_thresh)
+ .await
+ .context("Failed to construct PlayState")?;
+
+ let mut idle_client = match cfg.conn {
+ Connection::Local { ref path } => IdleClient::open(path)
+ .await
+ .context("Failed to open idle client")?,
+ Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port))
+ .await
+ .context("Failed to connect to TCP idle client")?,
+ };
+
+ let mut mqueue = MessageQueue::new(cfg.mode);
+
+ idle_client
+ .subscribe(COMMAND_CHANNEL)
+ .await
+ .context("Failed to subscribe to idle_client")?;
+
+ let mut hup = signal(SignalKind::hangup()).unwrap();
+ let mut kill = signal(SignalKind::terminate()).unwrap();
+ let ctrl_c = signal::ctrl_c().fuse();
+
+ let sighup = hup.recv().fuse();
+ let sigkill = kill.recv().fuse();
+
+ let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse();
+ pin_mut!(ctrl_c, sighup, sigkill, tick);
+
+ let mut done = false;
+ let mut msg_check_needed = false;
+ while !done {
+ debug!("selecting...");
+ {
+ // `idle_client' mutably borrowed here
+ let mut idle = Box::pin(idle_client.idle().fuse());
+ loop {
+ select! {
+ _ = ctrl_c => {
+ info!("got ctrl-C");
+ done = true;
+ break;
+ },
+ _ = sighup => {
+ info!("got SIGHUP");
+ done = true;
+ break;
+ },
+ _ = sigkill => {
+ info!("got SIGKILL");
+ done = true;
+ break;
+ },
+ _ = tick => {
+ tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse());
+ state.update(&mut client)
+ .await
+ .context("PlayState update failed")?;
+ },
+ res = idle => match res {
+ Ok(subsys) => {
+ debug!("subsystem {} changed", subsys);
+ if subsys == IdleSubSystem::Player {
+ if state.update(&mut client)
+ .await
+ .context("PlayState update failed")? {
+ mqueue
+ .advance_dj(&mut client)
+ .await
+ .context("MessageQueue tick failed")?;
+ }
+ } else if subsys == IdleSubSystem::Message {
+ msg_check_needed = true;
+ }
+ break;
+ },
+ Err(err) => {
+ debug!("error {err:#?} on idle");
+ done = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if msg_check_needed {
+ msg_check_needed = false;
+
+ // Check for any messages that have come in; if there's an error there's not a lot we
+ // can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it
+ // & move on.
+ if let Err(err) = mqueue.check_messages(&mut client, &mut idle_client).await {
+ error!("Error while processing messages: {err:#?}");
+ }
+ }
+ }
+
+ info!("mpdpopm exiting.");
+
+ Ok(())
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
new file mode 100644
index 00000000..7db75672
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
@@ -0,0 +1,140 @@
+use anyhow::{Context, Result, anyhow, bail, ensure};
+use clap::{Parser, Subcommand};
+use shlex::Shlex;
+use tracing::info;
+
+use crate::{
+ clients::{Client, IdleClient},
+ config::Mode,
+ dj::{Dj, algorithms::Discovery},
+};
+
+pub const COMMAND_CHANNEL: &str = "unwoundstack.com:commands";
+
+#[derive(Parser)]
+struct Commands {
+ #[command(subcommand)]
+ command: SubCommand,
+}
+
+#[derive(Parser)]
+enum SubCommand {
+ Dj {
+ #[command(subcommand)]
+ command: DjCommand,
+ },
+}
+
+#[derive(Subcommand)]
+enum DjCommand {
+ Start {
+ /// The chance to select a "positive" track
+ #[arg(long)]
+ positive_chance: f64,
+
+ /// The chance to select a "neutral" track
+ #[arg(long)]
+ neutral_chance: f64,
+
+ /// The chance to select a "negative" track
+ #[arg(long)]
+ negative_chance: f64,
+ },
+ Stop {},
+}
+
+pub(crate) struct MessageQueue {
+ dj: Option<Dj<Discovery>>,
+}
+
+impl MessageQueue {
+ pub(crate) fn new(mode: Mode) -> Self {
+ match mode {
+ Mode::Normal => Self { dj: None },
+ Mode::Dj => {
+ info!("Dj mode started on launch, as specified in config file");
+
+ Self {
+ dj: Some(Dj::new(Discovery::new(0.65, 0.5, 0.2))),
+ }
+ }
+ }
+ }
+
+ pub(crate) async fn advance_dj(&mut self, client: &mut Client) -> Result<()> {
+ if let Some(dj) = self.dj.as_mut() {
+ dj.add_track(client).await?;
+ }
+
+ Ok(())
+ }
+
+ /// Read messages off the commands channel & dispatch 'em
+ pub(crate) async fn check_messages(
+ &mut self,
+ client: &mut Client,
+ idle_client: &mut IdleClient,
+ ) -> Result<()> {
+ let m = idle_client
+ .get_messages()
+ .await
+ .context("Failed to `get_messages` from client")?;
+
+ for (chan, msgs) in m {
+ ensure!(chan == COMMAND_CHANNEL, "Unknown channel: `{}`", chan);
+
+ for msg in msgs {
+ self.process(client, msg).await?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Process a single command
+ pub(crate) async fn process(&mut self, client: &mut Client, msg: String) -> Result<()> {
+ let split = {
+ let mut shl = Shlex::new(&msg);
+ let res: Vec<_> = shl.by_ref().collect();
+
+ if shl.had_error {
+ bail!("Failed to parse command '{msg}'")
+ }
+
+ assert_eq!(shl.line_no, 1, "A unexpected newline appeared");
+ assert!(!res.is_empty());
+
+ let mut base = vec!["base".to_owned()];
+ base.extend(res);
+ base
+ };
+
+ let args = Commands::parse_from(split);
+
+ match args.command {
+ SubCommand::Dj { command } => match command {
+ DjCommand::Start {
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ } => {
+ info!("Dj started");
+ self.dj = Some(Dj::new(Discovery::new(
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ )));
+ self.advance_dj(client).await?;
+ }
+ DjCommand::Stop {} => {
+ self.dj
+ .take()
+ .ok_or_else(|| anyhow!("Tried to disable already disabled dj mode"))?;
+ info!("Dj stopped");
+ }
+ },
+ }
+
+ Ok(())
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
new file mode 100644
index 00000000..8fbee133
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -0,0 +1,342 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! playcounts -- managing play counts & lastplayed times
+//!
+//! # Introduction
+//!
+//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called
+//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be
+//! set explicitly:
+//!
+//! - `setpc PLAYCOUNT( TRACK)?`
+//! - `setlp LASTPLAYED( TRACK)?`
+//!
+
+use crate::clients::{Client, PlayerStatus};
+use crate::storage::{last_played, play_count, skip_count};
+
+use anyhow::{Context, Error, Result, anyhow};
+use tracing::{debug, info};
+
+use std::time::SystemTime;
+
+/// Current server state in terms of the play status (stopped/paused/playing, current track, elapsed
+/// time in current track, &c)
+#[derive(Debug)]
+pub struct PlayState {
+ /// Last known server status
+ last_server_stat: PlayerStatus,
+
+ /// true if we have already incremented the last known track's playcount
+ have_incr_play_count: bool,
+
+ /// Percentage threshold, expressed as a number between zero & one, for considering a song to
+ /// have been played
+ played_thresh: f64,
+ last_song_was_skipped: bool,
+}
+
+impl PlayState {
+ /// Create a new PlayState instance; async because it will reach out to the mpd server
+ /// to get current status.
+ pub async fn new(
+ client: &mut Client,
+ played_thresh: f64,
+ ) -> std::result::Result<PlayState, Error> {
+ Ok(PlayState {
+ last_server_stat: client.status().await?,
+ have_incr_play_count: false,
+ last_song_was_skipped: false,
+ played_thresh,
+ })
+ }
+
+ /// Retrieve a copy of the last known player status
+ pub fn last_status(&self) -> PlayerStatus {
+ self.last_server_stat.clone()
+ }
+
+ /// Poll the server-- update our status; maybe increment the current track's play count; the
+ /// caller must arrange to have this method invoked periodically to keep our state fresh
+ ///
+ /// Returns whether a song finished between the last call and this one.
+ /// That can be used to add a new song to the queue.
+ pub async fn update(&mut self, client: &mut Client) -> Result<bool> {
+ let new_stat = client
+ .status()
+ .await
+ .context("Failed to get client status")?;
+
+ let previous_song_finished = match (&self.last_server_stat, &new_stat) {
+ (PlayerStatus::Play(last), PlayerStatus::Play(curr))
+ | (PlayerStatus::Pause(last), PlayerStatus::Play(curr))
+ | (PlayerStatus::Play(last), PlayerStatus::Pause(curr))
+ | (PlayerStatus::Pause(last), PlayerStatus::Pause(curr)) => {
+ // Last we knew, we were playing, and we're playing now.
+ if last.songid != curr.songid {
+ debug!("New songid-- resetting PC incremented flag.");
+
+ if !self.have_incr_play_count {
+ // We didn't mark the previous song as played.
+ // As such, the user must have skipped it :(
+ self.last_song_was_skipped = true;
+ }
+
+ self.have_incr_play_count = false;
+
+ // We are now playing something else, as such the previous one must have
+ // finished or was skipped.
+ true
+ } else if last.elapsed > curr.elapsed
+ && self.have_incr_play_count
+ && curr.elapsed / curr.duration <= 0.1
+ {
+ debug!("Re-play-- resetting PC incremented flag.");
+ self.have_incr_play_count = false;
+
+ // We are still playing the same song, just skipped at the start again.
+ // This means that we don't need a new one.
+ false
+ } else {
+ // We are still playing the same song, so nothing changed
+ false
+ }
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Play(_))
+ | (PlayerStatus::Stopped, PlayerStatus::Pause(_)) => {
+ self.have_incr_play_count = false;
+
+ // We didn't play anything before and now we play something. This means that we
+ // obviously have something to play and thus don't need to add another song.
+ false
+ }
+ (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+ | (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
+ self.have_incr_play_count = false;
+
+ // We played a song before and now we stopped, maybe because we ran out of songs to
+ // play. So we need to add another one.
+ true
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Stopped) => {
+ // We did not play before and we are still not playing, as such nothing really
+ // changed.
+ false
+ }
+ };
+
+ match &new_stat {
+ PlayerStatus::Play(curr) => {
+ let pct = curr.played_pct();
+ debug!("Updating status: {:.3}% complete.", 100.0 * pct);
+
+ if !self.have_incr_play_count && pct >= self.played_thresh {
+ info!(
+ "Increment play count for '{}' (songid: {}) at {:.2}% played.",
+ curr.file.display(),
+ curr.songid,
+ (curr.elapsed / curr.duration) * 100.0
+ );
+
+ let file = curr.file.to_str().ok_or_else(|| {
+ anyhow!("Failed to parse path as utf8: `{}`", curr.file.display())
+ })?;
+
+ let curr_pc = play_count::get(client, file).await?.unwrap_or_default();
+
+ debug!("Current PC is {}.", curr_pc);
+
+ last_played::set(
+ client,
+ file,
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .context("Failed to get system time")?
+ .as_secs(),
+ )
+ .await?;
+ self.have_incr_play_count = true;
+
+ play_count::set(client, file, curr_pc + 1).await?;
+ } else if self.last_song_was_skipped {
+ self.last_song_was_skipped = false;
+ let last = self
+ .last_server_stat
+ .current_song()
+ .expect("To exist, as it was skipped");
+
+ info!(
+ "Marking '{}' (songid: {}) as skipped at {:.2}%.",
+ last.file.display(),
+ last.songid,
+ (last.elapsed / last.duration) * 100.0
+ );
+
+ let file = last.file.to_str().ok_or_else(|| {
+ anyhow!("Failed to parse path as utf8: `{}`", last.file.display())
+ })?;
+
+ let skip_count = skip_count::get(client, file).await?.unwrap_or_default();
+ skip_count::set(client, file, skip_count + 1).await?;
+ }
+ }
+ PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
+ };
+
+ self.last_server_stat = new_stat;
+ Ok(previous_song_finished)
+ }
+}
+
+#[cfg(test)]
+mod player_state_tests {
+ use super::*;
+ use crate::clients::test_mock::Mock;
+
+ /// "Smoke" tests for player state
+ #[tokio::test]
+ async fn player_state_smoke() {
+ let mock = Box::new(Mock::new(&[
+ (
+ "status",
+ "repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: stop
+xfade: 5
+song: 51
+songid: 52
+nextsong: 11
+nextsongid: 12
+OK
+",
+ ),
+ (
+ "status",
+ "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 5.337
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+ ),
+ (
+ "playlistid 52",
+ "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+ ),
+ (
+ "status",
+ "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 200
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+ ),
+ (
+ "playlistid 52",
+ "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+ ),
+ (
+ "sticker get song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount",
+ "sticker: unwoundstack.com:playcount=11\nOK\n",
+ ),
+ (
+ &format!(
+ "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:lastplayed {}",
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ ),
+ "OK\n",
+ ),
+ (
+ "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount 12",
+ "OK\n",
+ ),
+ ]));
+
+ let mut cli = Client::new(mock).unwrap();
+ let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap();
+ let check = match ps.last_status() {
+ PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false,
+ PlayerStatus::Stopped => true,
+ };
+ assert!(check);
+
+ ps.update(&mut cli).await.unwrap();
+ ps.update(&mut cli).await.unwrap();
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
new file mode 100644
index 00000000..a6f20d5b
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
@@ -0,0 +1,145 @@
+use anyhow::Result;
+
+pub mod play_count {
+ use anyhow::Context;
+
+ use crate::clients::Client;
+
+ use super::Result;
+
+ pub const STICKER: &str = "unwoundstack.com:playcount";
+
+ /// Retrieve the play count for a track
+ pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> {
+ match client
+ .get_sticker::<usize>(file, STICKER)
+ .await
+ .context("Failed to get sticker from client")?
+ {
+ Some(n) => Ok(Some(n)),
+ None => Ok(None),
+ }
+ }
+
+ /// Set the play count for a track-- this will run the associated command, if any
+ pub async fn set(client: &mut Client, file: &str, play_count: usize) -> Result<()> {
+ client
+ .set_sticker(file, STICKER, &format!("{}", play_count))
+ .await
+ .context("Failed to set_sticker on client")?;
+
+ Ok(())
+ }
+
+ #[cfg(test)]
+ mod pc_lp_tests {
+ use super::*;
+ use crate::{clients::test_mock::Mock, storage::play_count};
+
+ /// "Smoke" tests for play counts & last played times
+ #[tokio::test]
+ async fn pc_smoke() {
+ let mock = Box::new(Mock::new(&[
+ (
+ "sticker get song a unwoundstack.com:playcount",
+ "sticker: unwoundstack.com:playcount=11\nOK\n",
+ ),
+ (
+ "sticker get song a unwoundstack.com:playcount",
+ "ACK [50@0] {sticker} no such sticker\n",
+ ),
+ ("sticker get song a unwoundstack.com:playcount", "splat!"),
+ ]));
+ let mut cli = Client::new(mock).unwrap();
+
+ assert_eq!(play_count::get(&mut cli, "a").await.unwrap().unwrap(), 11);
+ let val = play_count::get(&mut cli, "a").await.unwrap();
+ assert!(val.is_none());
+ play_count::get(&mut cli, "a").await.unwrap_err();
+ }
+ }
+}
+
+pub mod skip_count {
+ use anyhow::Context;
+
+ use crate::clients::Client;
+
+ use super::Result;
+
+ pub(crate) const STICKER: &str = "unwoundstack.com:skipped_count";
+
+ /// Retrieve the skip count for a track
+ pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> {
+ match client
+ .get_sticker::<usize>(file, STICKER)
+ .await
+ .context("Failed to get_sticker on client")?
+ {
+ Some(n) => Ok(Some(n)),
+ None => Ok(None),
+ }
+ }
+
+ /// Set the skip count for a track
+ pub async fn set(client: &mut Client, file: &str, skip_count: usize) -> Result<()> {
+ client
+ .set_sticker(file, STICKER, &format!("{}", skip_count))
+ .await
+ .context("Failed to set_sticker on client")
+ }
+}
+
+pub mod last_played {
+ use anyhow::Context;
+
+ use crate::clients::Client;
+
+ use super::Result;
+
+ pub const STICKER: &str = "unwoundstack.com:lastplayed";
+
+ /// Retrieve the last played timestamp for a track (seconds since Unix epoch)
+ pub async fn get(client: &mut Client, file: &str) -> Result<Option<u64>> {
+ client
+ .get_sticker::<u64>(file, STICKER)
+ .await
+ .context("Falied to get_sticker on client")
+ }
+
+ /// Set the last played for a track
+ pub async fn set(client: &mut Client, file: &str, last_played: u64) -> Result<()> {
+ client
+ .set_sticker(file, STICKER, &format!("{}", last_played))
+ .await
+ .context("Failed to set_sticker on client")?;
+ Ok(())
+ }
+}
+
+pub mod rating {
+ use anyhow::Context;
+
+ use crate::clients::Client;
+
+ use super::Result;
+
+ pub const STICKER: &str = "unwoundstack.com:ratings_count";
+
+ /// Retrieve the rating count for a track
+ pub async fn get(client: &mut Client, file: &str) -> Result<Option<i8>> {
+ client
+ .get_sticker::<i8>(file, STICKER)
+ .await
+ .context("Failed to get_sticker on client")
+ }
+
+ /// Set the rating count for a track
+ pub async fn set(client: &mut Client, file: &str, rating_count: i8) -> Result<()> {
+ client
+ .set_sticker(file, STICKER, &format!("{}", rating_count))
+ .await
+ .context("Failed to set_sticker on client")?;
+ Ok(())
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/vars.rs b/pkgs/by-name/mp/mpdpopm/src/vars.rs
new file mode 100644
index 00000000..7cacec66
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs
@@ -0,0 +1,4 @@
+pub static VERSION: &str = env!("CARGO_PKG_VERSION");
+pub static AUTHOR: &str = env!("CARGO_PKG_AUTHORS");
+pub static LOCALSTATEDIR: &str = "/home/soispha/.local/state";
+pub static PREFIX: &str = "/home/soispha/.local/share/mpdpopm";