aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/mp/mpdpopm/src
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-01-24 23:51:04 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-01-24 23:51:19 +0100
commit38c6f95c94830ed8eb6c6678e303480257cf3cf5 (patch)
treee5bd55a6306c5f787ff49d528810c62b5c5dbb32 /pkgs/by-name/mp/mpdpopm/src
parentmodule/mpd: Set-up a sticker file (diff)
downloadnixos-config-38c6f95c94830ed8eb6c6678e303480257cf3cf5.zip
pkgs/mpdpopm: Init
This is based on https://github.com/sp1ff/mpdpopm at commit 178df8ad3a5c39281cfd8b3cec05394f4c9256fd.
Diffstat (limited to 'pkgs/by-name/mp/mpdpopm/src')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs677
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs233
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/clients.rs1417
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/config.rs275
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters.lalrpop143
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters_ast.rs1166
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/lib.rs274
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messages.rs732
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/playcounts.rs367
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/ratings.rs195
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/storage/mod.rs212
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/vars.rs5
12 files changed, 5696 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
new file mode 100644
index 00000000..4ffcd499
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
@@ -0,0 +1,677 @@
+// 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 mpdpopm::{
+ clients::{Client, PlayerStatus, quote},
+ config::{self, Config},
+ storage::{last_played, play_count, rating_count},
+};
+
+use backtrace::Backtrace;
+use clap::{Parser, Subcommand};
+use tracing::{debug, info, level_filters::LevelFilter, trace};
+use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
+
+use std::{fmt, path::PathBuf};
+
+#[non_exhaustive]
+pub enum Error {
+ NoSubCommand,
+ NoConfigArg,
+ NoRating,
+ NoPlayCount,
+ NoLastPlayed,
+ NoConfig {
+ config: std::path::PathBuf,
+ cause: std::io::Error,
+ },
+ PlayerStopped,
+ BadPath {
+ path: PathBuf,
+ back: Backtrace,
+ },
+ NoPlaylist,
+ Client {
+ source: mpdpopm::clients::Error,
+ back: Backtrace,
+ },
+ Ratings {
+ source: Box<mpdpopm::storage::Error>,
+ back: Backtrace,
+ },
+ Playcounts {
+ source: Box<mpdpopm::storage::Error>,
+ back: Backtrace,
+ },
+ ExpectedInt {
+ source: std::num::ParseIntError,
+ back: Backtrace,
+ },
+ Config {
+ source: crate::config::Error,
+ back: Backtrace,
+ },
+}
+
+impl fmt::Display for Error {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::NoSubCommand => write!(f, "No sub-command given"),
+ Error::NoConfigArg => write!(f, "No argument given for the configuration option"),
+ Error::NoRating => write!(f, "No rating supplied"),
+ Error::NoPlayCount => write!(f, "No play count supplied"),
+ Error::NoLastPlayed => write!(f, "No last played timestamp given"),
+ Error::NoConfig { config, cause } => write!(f, "Bad config ({:?}): {}", config, cause),
+ Error::PlayerStopped => write!(f, "The player is stopped"),
+ Error::BadPath { path, back: _ } => write!(f, "Bad path: {:?}", path),
+ Error::NoPlaylist => write!(f, "No playlist given"),
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ Error::Ratings { source, back: _ } => write!(f, "Rating error: {}", source),
+ Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source),
+ Error::ExpectedInt { source, back: _ } => write!(f, "Expected integer: {}", source),
+ Error::Config { source, back: _ } => {
+ write!(f, "Error reading configuration: {}", source)
+ }
+ }
+ }
+}
+
+impl fmt::Debug for Error {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", self)
+ }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+/// 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 = match client.status().await.map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })? {
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr
+ .file
+ .to_str()
+ .ok_or_else(|| Error::BadPath {
+ path: curr.file.clone(),
+ back: Backtrace::new(),
+ })?
+ .to_string(),
+ PlayerStatus::Stopped => {
+ return Err(Error::PlayerStopped);
+ }
+ };
+ vec![file]
+ }
+ };
+ Ok(files)
+}
+
+/// 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, u8)> = Vec::new();
+
+ for file in map_tracks(client, tracks).await? {
+ let rating = rating_count::get(client, &file)
+ .await
+ .map_err(|err| Error::Ratings {
+ source: Box::new(err),
+ back: Backtrace::new(),
+ })?;
+
+ 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,
+ chan: &str,
+ rating: u8,
+ arg: Option<String>,
+) -> Result<()> {
+ let cmd = match &arg {
+ Some(uri) => format!("rate {} \\\"{}\\\"", rating, uri),
+ None => format!("rate {}", rating),
+ };
+ client
+ .send_message(chan, &cmd)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ match &arg {
+ Some(uri) => info!("Set the rating for \"{}\" to \"{}\".", uri, rating),
+ None => 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, chan: &str, arg: Option<String>) -> Result<()> {
+ let cmd = match &arg {
+ Some(uri) => format!("inc-rate \\\"{}\\\"", uri),
+ None => "inc-rate ".to_owned(),
+ };
+ client
+ .send_message(chan, &cmd)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ match &arg {
+ Some(uri) => info!("Incremented the rating for \"{}\".", uri),
+ None => info!("Incremented 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
+ .map_err(|err| Error::Playcounts {
+ source: Box::new(err),
+ back: Backtrace::new(),
+ })?
+ .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,
+ chan: &str,
+ playcount: usize,
+ arg: Option<String>,
+) -> Result<()> {
+ let cmd = match &arg {
+ Some(uri) => format!("setpc {} \\\"{}\\\"", playcount, uri),
+ None => format!("setpc {}", playcount),
+ };
+ client
+ .send_message(chan, &cmd)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ match &arg {
+ Some(uri) => info!("Set the playcount for \"{}\" to \"{}\".", uri, playcount),
+ None => 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
+ .map_err(|err| Error::Playcounts {
+ source: Box::new(err),
+ back: Backtrace::new(),
+ })?;
+ 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,
+ chan: &str,
+ lastplayed: u64,
+ arg: Option<String>,
+) -> Result<()> {
+ let cmd = match &arg {
+ Some(uri) => format!("setlp {} {}", lastplayed, uri),
+ None => format!("setlp {}", lastplayed),
+ };
+ client
+ .send_message(chan, &cmd)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ match &arg {
+ Some(uri) => info!("Set last played for \"{}\" to \"{}\".", uri, lastplayed),
+ None => 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ pls.sort();
+ println!("Stored playlists:");
+ for pl in pls {
+ println!("{}", pl);
+ }
+ Ok(())
+}
+
+/// Add songs selected by filter to the queue
+async fn findadd(client: &mut Client, chan: &str, filter: &str, case: bool) -> Result<()> {
+ let qfilter = quote(filter);
+ debug!("findadd: got ``{}'', quoted to ``{}''.", filter, qfilter);
+ let cmd = format!("{} {}", if case { "findadd" } else { "searchadd" }, qfilter);
+ client
+ .send_message(chan, &cmd)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ Ok(())
+}
+
+/// Send an arbitrary command
+async fn send_command(client: &mut Client, chan: &str, args: Vec<String>) -> Result<()> {
+ client
+ .send_message(
+ chan,
+ args.iter()
+ .map(String::as_str)
+ .map(quote)
+ .collect::<Vec<String>>()
+ .join(" ")
+ .as_str(),
+ )
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ Ok(())
+}
+
+/// `mppopmd' client
+#[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,
+
+ #[command(subcommand)]
+ command: SubCommand,
+}
+
+#[derive(Subcommand)]
+enum SubCommand {
+ /// 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 0 & 255, inclusive, with
+ /// the convention that 0 denotes "un-rated".
+ #[clap(verbatim_doc_comment)]
+ GetRating {
+ /// 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)]
+ SetRating { rating: u8, 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)]
+ IncRating { track: Option<String> },
+
+ /// 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)]
+ GetPc {
+ /// 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)]
+ SetPc {
+ play_count: usize,
+ track: Option<String>,
+ },
+
+ /// 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)]
+ GetLp {
+ /// 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)]
+ SetLp {
+ last_played: u64,
+ track: Option<String>,
+ },
+
+ /// retrieve the list of stored playlists
+ #[clap(verbatim_doc_comment)]
+ GetPlaylists {},
+
+ /// search case-sensitively for songs matching matching a filter and add them to the queue
+ ///
+ /// This command extends the MPD command `findadd' (which will search the MPD database) to allow
+ /// searches on attributes managed by mpdpopm: rating, playcount & last played time.
+ ///
+ /// The MPD `findadd' <https://www.musicpd.org/doc/html/protocol.html#command-findadd> 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 findadd "(rating > 128)"
+ ///
+ /// Will add all songs in the library with a rating sticker > 128 to the play queue.
+ ///
+ /// mppopm also introduces OR clauses (MPD only supports AND), so that:
+ ///
+ /// mppopm findadd "((rating > 128) AND (artist =~ \"pogues\"))"
+ ///
+ /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than
+ /// 128.
+ ///
+ /// `findadd' is case-sensitive; for case-insensitive searching see the `searchadd' command.
+ #[clap(verbatim_doc_comment)]
+ Findadd { filter: String },
+
+ /// search case-insensitively 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 > 128)"
+ ///
+ /// Will add all songs in the library with a rating sticker > 128 to the play queue.
+ ///
+ /// mppopm also introduces OR clauses (MPD only supports AND), so that:
+ ///
+ /// mppopm searchadd "((rating > 128) AND (artist =~ \"pogues\"))"
+ ///
+ /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than
+ /// 128.
+ ///
+ /// `searchadd' is case-insensitive; for case-sensitive searching see the `findadd' command.
+ #[clap(verbatim_doc_comment)]
+ Searchadd { filter: String },
+
+ /// Send a command to mpd.
+ #[clap(verbatim_doc_comment)]
+ SendCommand { args: Vec<String> },
+}
+
+#[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).map_err(|err| Error::Config {
+ source: err,
+ back: Backtrace::new(),
+ })?,
+ 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:
+ return Err(Error::NoConfig {
+ config: PathBuf::from(configpath),
+ cause: err,
+ });
+ }
+ }
+ } 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.map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ }
+ config::Connection::TCP { host, port } => Client::connect(format!("{}:{}", host, port))
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?,
+ };
+
+ match args.command {
+ SubCommand::GetRating { with_uri, tracks } => {
+ get_ratings(&mut client, tracks, with_uri).await
+ }
+ SubCommand::SetRating { rating, track } => {
+ set_rating(&mut client, &config.commands_chan, rating, track).await
+ }
+ SubCommand::IncRating { track } => {
+ inc_rating(&mut client, &config.commands_chan, track).await
+ }
+ SubCommand::GetPc { with_uri, tracks } => {
+ get_play_counts(&mut client, tracks, with_uri).await
+ }
+ SubCommand::SetPc { play_count, track } => {
+ set_play_counts(&mut client, &config.commands_chan, play_count, track).await
+ }
+ SubCommand::GetLp { with_uri, tracks } => {
+ get_last_playeds(&mut client, tracks, with_uri).await
+ }
+ SubCommand::SetLp { last_played, track } => {
+ set_last_playeds(&mut client, &config.commands_chan, last_played, track).await
+ }
+ SubCommand::GetPlaylists {} => get_playlists(&mut client).await,
+ SubCommand::Findadd { filter } => {
+ findadd(&mut client, &config.commands_chan, &filter, true).await
+ }
+ SubCommand::Searchadd { filter } => {
+ findadd(&mut client, &config.commands_chan, &filter, false).await
+ }
+ SubCommand::SendCommand { args } => {
+ send_command(&mut client, &config.commands_chan, args).await
+ }
+ }
+}
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..e903774c
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs
@@ -0,0 +1,233 @@
+// 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;
+use mpdpopm::config::Config;
+use mpdpopm::mpdpopm;
+
+use backtrace::Backtrace;
+use clap::Parser;
+use tracing::{info, level_filters::LevelFilter};
+use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
+
+use std::{fmt, io, path::PathBuf, sync::MutexGuard};
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// mppopmd application Error type //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[non_exhaustive]
+pub enum Error {
+ NoConfigArg,
+ NoConfig {
+ config: std::path::PathBuf,
+ cause: std::io::Error,
+ },
+ Filter {
+ source: tracing_subscriber::filter::FromEnvError,
+ back: Backtrace,
+ },
+ Fork {
+ errno: errno::Errno,
+ back: Backtrace,
+ },
+ PathContainsNull {
+ back: Backtrace,
+ },
+ OpenLockFile {
+ errno: errno::Errno,
+ back: Backtrace,
+ },
+ LockFile {
+ errno: errno::Errno,
+ back: Backtrace,
+ },
+ WritePid {
+ errno: errno::Errno,
+ back: Backtrace,
+ },
+ Config {
+ source: crate::config::Error,
+ back: Backtrace,
+ },
+ MpdPopm {
+ source: Box<mpdpopm::Error>,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::NoConfigArg => write!(f, "No configuration file given"),
+ Error::NoConfig { config, cause } => {
+ write!(f, "Configuration error ({:?}): {}", config, cause)
+ }
+ Error::Fork { errno, back: _ } => write!(f, "When forking, got errno {}", errno),
+ Error::PathContainsNull { back: _ } => write!(f, "Path contains a null character"),
+ Error::OpenLockFile { errno, back: _ } => {
+ write!(f, "While opening lock file, got errno {}", errno)
+ }
+ Error::LockFile { errno, back: _ } => {
+ write!(f, "While locking the lock file, got errno {}", errno)
+ }
+ Error::WritePid { errno, back: _ } => {
+ write!(f, "While writing pid file, got errno {}", errno)
+ }
+ Error::Config { source, back: _ } => write!(f, "Configuration error: {}", source),
+ Error::MpdPopm { source, back: _ } => write!(f, "mpdpopm error: {}", source),
+ _ => write!(f, "Unknown mppopmd error"),
+ }
+ }
+}
+
+impl fmt::Debug for Error {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", self)
+ }
+}
+
+type Result = std::result::Result<(), Error>;
+
+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 `mppopmd'.
+///
+/// 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).map_err(|err| Error::Config {
+ source: err,
+ back: Backtrace::new(),
+ })?,
+ // 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:
+ return Err(Error::NoConfig {
+ config: PathBuf::from(cfgpath),
+ cause: err,
+ });
+ }
+ }
+ } 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()
+ .map_err(|err| Error::Filter {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ 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)).map_err(|err| Error::MpdPopm {
+ source: Box::new(err),
+ back: Backtrace::new(),
+ })
+}
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..587063b2
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs
@@ -0,0 +1,1417 @@
+// 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 async_trait::async_trait;
+use regex::Regex;
+use snafu::{Backtrace, IntoError, OptionExt, ResultExt, prelude::*};
+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,
+};
+
+// The Protocol error, below, gets used a *lot*; anywhere we receive a message from the MPD server
+// that "should" never happen. To help give a bit of context beyond a stack trace, I use this
+// enumeration of "operations"
+/// Enumerated list of MPD operations; used in Error::Protocol to distinguish which operation it was
+/// that elicited the protocol error.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Operation {
+ Connect,
+ Status,
+ GetSticker,
+ SetSticker,
+ SendToPlaylist,
+ SendMessage,
+ Update,
+ GetStoredPlaylists,
+ RspToUris,
+ GetStickers,
+ GetAllSongs,
+ Add,
+ Idle,
+ GetMessages,
+}
+
+impl std::fmt::Display for Operation {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Operation::Connect => write!(f, "Connect"),
+ Operation::Status => write!(f, "Status"),
+ Operation::GetSticker => write!(f, "GetSticker"),
+ Operation::SetSticker => write!(f, "SetSticker"),
+ Operation::SendToPlaylist => write!(f, "SendToPlaylist"),
+ Operation::SendMessage => write!(f, "SendMessage"),
+ Operation::Update => write!(f, "Update"),
+ Operation::GetStoredPlaylists => write!(f, "GetStoredPlaylists"),
+ Operation::RspToUris => write!(f, "RspToUris"),
+ Operation::GetStickers => write!(f, "GetStickers"),
+ Operation::GetAllSongs => write!(f, "GetAllSongs"),
+ Operation::Add => write!(f, "Add"),
+ Operation::Idle => write!(f, "Idle"),
+ Operation::GetMessages => write!(f, "GetMessages"),
+ _ => write!(f, "Unknown client operation"),
+ }
+ }
+}
+
+/// An MPD client error
+#[derive(Debug, Snafu)]
+#[non_exhaustive]
+pub enum Error {
+ #[snafu(display("Protocol error ({}): {}", op, msg))]
+ Protocol {
+ op: Operation,
+ msg: String,
+ backtrace: Backtrace,
+ },
+ #[snafu(display("Protocol errror ({}): {}", op, source))]
+ ProtocolConv {
+ op: Operation,
+ source: Box<dyn std::error::Error>,
+ backtrace: Backtrace,
+ },
+ #[snafu(display("I/O error: {}", source))]
+ Io {
+ source: std::io::Error,
+ backtrace: Backtrace,
+ },
+ #[snafu(display("Encoding error: {}", source))]
+ Encoding {
+ source: std::string::FromUtf8Error,
+ backtrace: Backtrace,
+ },
+ #[snafu(display("While converting sticker ``{}'': {}", sticker, source))]
+ StickerConversion {
+ sticker: String,
+ source: Box<dyn std::error::Error>,
+ backtrace: Backtrace,
+ },
+ #[snafu(display("``{}'' is not a recognized Idle subsystem", text))]
+ IdleSubSystem { text: String, backtrace: Backtrace },
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// 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,
+ }
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// Connection //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// 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(IoSnafu)?;
+ 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(IoSnafu)?;
+
+ // 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(EncodingSnafu)
+ }
+}
+
+/// 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(IoSnafu)?;
+ // Only doing this to trouble-shoot issue 11
+ let text = String::from_utf8(buf.clone()).context(EncodingSnafu)?;
+ ensure!(
+ text.starts_with("OK MPD "),
+ ProtocolSnafu {
+ op: Operation::Connect,
+ msg: 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(IoSnafu)?;
+ 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(IoSnafu)?;
+ 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 {
+ ProtocolSnafu {
+ op: Operation::Status,
+ msg: text.to_owned(),
+ }
+ .build()
+ };
+
+ // 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>()
+ .map_err(|err| {
+ ProtocolConvSnafu {
+ op: Operation::Status,
+ }
+ .into_error(Box::new(err))
+ })?;
+
+ let elapsed = RE_ELAPSED
+ .captures(&text)
+ .ok_or_else(proto)?
+ .get(1)
+ .ok_or_else(proto)?
+ .as_str()
+ .parse::<f64>()
+ .map_err(|err| {
+ ProtocolConvSnafu {
+ op: Operation::Status,
+ }
+ .into_error(Box::new(err))
+ })?;
+
+ // 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>()
+ .map_err(|err| {
+ ProtocolConvSnafu {
+ op: Operation::Status,
+ }
+ .into_error(Box::new(err))
+ })?;
+
+ let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration);
+
+ if state == "play" {
+ Ok(PlayerStatus::Play(curr))
+ } else {
+ Ok(PlayerStatus::Pause(curr))
+ }
+ }
+ _ => ProtocolSnafu {
+ op: Operation::Status,
+ msg: state.to_owned(),
+ }
+ .fail(),
+ }
+ }
+
+ /// 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()
+ .context(ProtocolSnafu {
+ op: Operation::GetSticker,
+ msg,
+ })?;
+ Ok(Some(T::from_str(s).map_err(|err| {
+ StickerConversionSnafu {
+ sticker: sticker_name.to_owned(),
+ }
+ .into_error(Box::new(err))
+ })?))
+ } else {
+ // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17)
+ ensure!(
+ text.starts_with("ACK [50@0]"),
+ ProtocolSnafu {
+ op: Operation::GetSticker,
+ msg,
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::SetSticker,
+ msg
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::SendToPlaylist,
+ msg
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::SendMessage,
+ msg: text
+ }
+ );
+ 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),
+ ProtocolSnafu {
+ op: Operation::Update,
+ msg: &text
+ }
+ );
+ text[prefix.len()..].split('\n').collect::<Vec<&str>>()[0]
+ .to_string()
+ .parse::<u64>()
+ .map_err(|err| {
+ ProtocolConvSnafu {
+ op: Operation::Update,
+ }
+ .into_error(Box::new(err))
+ })
+ }
+
+ /// 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"),
+ ProtocolSnafu {
+ op: Operation::GetStoredPlaylists,
+ msg: text
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::RspToUris,
+ msg: text.to_owned()
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::GetStickers,
+ msg: text,
+ }
+ );
+ let mut m = HashMap::new();
+ let mut lines = text.lines();
+ loop {
+ let file = lines.next().context(ProtocolSnafu {
+ op: Operation::GetStickers,
+ msg: text.to_owned(),
+ })?;
+ if "OK" == file {
+ break;
+ }
+ let val = lines.next().context(ProtocolSnafu {
+ op: Operation::GetStickers,
+ msg: text.to_owned(),
+ })?;
+
+ 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"),
+ ProtocolSnafu {
+ op: Operation::GetAllSongs,
+ msg: text,
+ }
+ );
+ 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"),
+ ProtocolSnafu {
+ op: Operation::Add,
+ msg: &text
+ }
+ );
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+/// Let's test Client!
+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 {
+ Err(IdleSubSystemSnafu {
+ text: String::from(text),
+ }
+ .build())
+ }
+ }
+}
+
+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"),
+ ProtocolSnafu {
+ op: Operation::Connect,
+ msg: &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: "),
+ ProtocolSnafu {
+ op: Operation::Idle,
+ msg: &text
+ }
+ );
+ let idx = text.find('\n').context(ProtocolSnafu {
+ op: Operation::Idle,
+ msg: text.to_owned(),
+ })?;
+
+ let result = IdleSubSystem::try_from(&text[9..idx])?;
+ let text = text[idx + 1..].to_string();
+ ensure!(
+ text.starts_with("OK"),
+ ProtocolSnafu {
+ op: Operation::Idle,
+ msg: &text
+ }
+ );
+
+ 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: "),
+ ProtocolSnafu {
+ op: Operation::GetMessages,
+ msg: line.to_owned()
+ }
+ );
+ 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 {
+ return Err(ProtocolSnafu {
+ op: Operation::GetMessages,
+ msg: text,
+ }
+ .build());
+ }
+ }
+ State::Finished => {
+ // Should never be here!
+ return Err(ProtocolSnafu {
+ op: Operation::GetMessages,
+ msg: String::from(line),
+ }
+ .build());
+ }
+ }
+ }
+
+ 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..da8e63be
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/config.rs
@@ -0,0 +1,275 @@
+// 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 serde::{Deserialize, Serialize};
+
+use std::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 },
+}
+
+#[cfg(test)]
+mod test_connection {
+ use super::Connection;
+
+ #[test]
+ fn test_serde() {
+ use serde_lexpr::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))"#)
+ );
+ }
+}
+
+impl std::default::Default for Connection {
+ fn default() -> Self {
+ Connection::TCP {
+ host: String::from("localhost"),
+ port: 6600,
+ }
+ }
+}
+
+/// 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 `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,
+
+ /// Channel to setup for assorted commands-- channel names must satisfy "[-a-zA-Z-9_.:]+"
+ pub commands_chan: String,
+}
+
+impl Default for Config {
+ fn default() -> Config {
+ Config {
+ _version: String::from("1"),
+ log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(),
+ conn: Connection::default(),
+ local_music_dir: [PREFIX, "Music"].iter().collect(),
+ played_thresh: 0.6,
+ poll_interval_ms: 5000,
+ commands_chan: String::from("unwoundstack.com:commands"),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum Error {
+ /// Failure to parse
+ ParseFail { err: Box<dyn std::error::Error> },
+}
+
+impl std::fmt::Display for Error {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::ParseFail { err } => write!(f, "Parse failure: {}", err),
+ _ => write!(f, "Unknown configuration error"),
+ }
+ }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+pub fn from_str(text: &str) -> Result<Config> {
+ let cfg: Config = match serde_lexpr::from_str(text) {
+ Ok(cfg) => cfg,
+ Err(err_outer) => {
+ return Err(Error::ParseFail {
+ err: Box::new(err_outer),
+ });
+ }
+ };
+ Ok(cfg)
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_from_str() {
+ let cfg = Config::default();
+ assert_eq!(cfg.commands_chan, String::from("unwoundstack.com:commands"));
+
+ assert_eq!(
+ serde_lexpr::to_string(&cfg).unwrap(),
+ format!(
+ r#"((version . "1") (log . "{}/log/mppopmd.log") (conn TCP (host . "localhost") (port . 6600)) (local_music_dir . "{}/Music") (playcount_sticker . "unwoundstack.com:playcount") (lastplayed_sticker . "unwoundstack.com:lastplayed") (played_thresh . 0.6) (poll_interval_ms . 5000) (commands_chan . "unwoundstack.com:commands") (playcount_command . "") (playcount_command_args) (rating_sticker . "unwoundstack.com:rating") (ratings_command . "") (ratings_command_args) (gen_cmds))"#,
+ LOCALSTATEDIR, PREFIX
+ )
+ );
+
+ let cfg: Config = serde_lexpr::from_str(
+ r#"
+((version . "1")
+ (log . "/usr/local/var/log/mppopmd.log")
+ (conn TCP (host . "localhost") (port . 6600))
+ (local_music_dir . "/usr/local/Music")
+ (playcount_sticker . "unwoundstack.com:playcount")
+ (lastplayed_sticker . "unwoundstack.com:lastplayed")
+ (played_thresh . 0.6)
+ (poll_interval_ms . 5000)
+ (commands_chan . "unwoundstack.com:commands")
+ (playcount_command . "")
+ (playcount_command_args)
+ (rating_sticker . "unwoundstack.com:rating")
+ (ratings_command . "")
+ (ratings_command_args)
+ (gen_cmds))
+"#,
+ )
+ .unwrap();
+ assert_eq!(cfg._version, String::from("1"));
+
+ let cfg: Config = serde_lexpr::from_str(
+ r#"
+((version . "1")
+ (log . "/usr/local/var/log/mppopmd.log")
+ (conn Local (path . "/home/mgh/var/run/mpd/mpd.sock"))
+ (local_music_dir . "/usr/local/Music")
+ (playcount_sticker . "unwoundstack.com:playcount")
+ (lastplayed_sticker . "unwoundstack.com:lastplayed")
+ (played_thresh . 0.6)
+ (poll_interval_ms . 5000)
+ (commands_chan . "unwoundstack.com:commands")
+ (playcount_command . "")
+ (playcount_command_args)
+ (rating_sticker . "unwoundstack.com:rating")
+ (ratings_command . "")
+ (ratings_command_args)
+ (gen_cmds))
+"#,
+ )
+ .unwrap();
+ assert_eq!(cfg._version, String::from("1"));
+ assert_eq!(
+ cfg.conn,
+ Connection::Local {
+ path: PathBuf::from("/home/mgh/var/run/mpd/mpd.sock")
+ }
+ );
+
+ // Test fallback to "v0" of the config struct
+ let cfg = from_str(r#"
+((log . "/home/mgh/var/log/mppopmd.log")
+ (host . "192.168.1.14")
+ (port . 6600)
+ (local_music_dir . "/space/mp3")
+ (playcount_sticker . "unwoundstack.com:playcount")
+ (lastplayed_sticker . "unwoundstack.com:lastplayed")
+ (played_thresh . 0.6)
+ (poll_interval_ms . 5000)
+ (playcount_command . "/usr/local/bin/scribbu")
+ (playcount_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-C" "%playcount" "%full-file"))
+ (commands_chan . "unwoundstack.com:commands")
+ (rating_sticker . "unwoundstack.com:rating")
+ (ratings_command . "/usr/local/bin/scribbu")
+ (ratings_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-r" "%rating" "%full-file"))
+ (gen_cmds .
+ (((name . "set-genre")
+ (formal_parameters . (Literal Track))
+ (default_after . 1)
+ (cmd . "/usr/local/bin/scribbu")
+ (args . ("genre" "-a" "-C" "-g" "%1" "%full-file"))
+ (update . TrackOnly))
+ ((name . "set-xtag")
+ (formal_parameters . (Literal Track))
+ (default_after . 1)
+ (cmd . "/usr/local/bin/scribbu")
+ (args . ("xtag" "-A" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file"))
+ (update . TrackOnly))
+ ((name . "merge-xtag")
+ (formal_parameters . (Literal Track))
+ (default_after . 1)
+ (cmd . "/usr/local/bin/scribbu")
+ (args . ("xtag" "-m" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file"))
+ (update . TrackOnly)))))
+"#).unwrap();
+ assert_eq!(cfg.log, PathBuf::from("/home/mgh/var/log/mppopmd.log"));
+ }
+}
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..a591a3ba
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
@@ -0,0 +1,143 @@
+// 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};
+
+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)titile" => 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,
+};
+
+pub Token: Value = {
+ <s:r"[0-9]+"> =>? {
+ eprintln!("matched token: ``{}''.", s);
+ // We need to yield a Result<Value, ParseError>
+ match s.parse::<usize>() {
+ Ok(n) => Ok(Value::Uint(n)),
+ Err(_) => Err(ParseError::User {
+ error: "Internal parse error while parsing unsigned int" })
+ }
+ },
+ <s:r#""([ \t'a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\"|\\')+""#> => {
+ eprintln!("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~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\'|\\")+'"#> => {
+ eprintln!("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> <u:Token> => {
+ eprintln!("matched unary condition: ``({}, {:#?})''", t, u);
+ Box::new(Term::UnaryCondition(t, u))
+ },
+ <t:ExprSel> <o:ExprOp> <u:Token> => {
+ eprintln!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u);
+ Box::new(Term::BinaryCondition(t, o, u))
+ },
+}
+
+pub Conjunction: Box<Conjunction> = {
+ <e1:Expression> "AND" <e2:Expression> => {
+ eprintln!("matched conjunction: ``({:#?}, {:#?})''", e1, e2);
+ Box::new(Conjunction::Simple(e1, e2))
+ },
+ <c:Conjunction> "AND" <e:Expression> => {
+ eprintln!("matched conjunction: ``({:#?}, {:#?})''", c, e);
+ Box::new(Conjunction::Compound(c, e))
+ },
+}
+
+pub Disjunction: Box<Disjunction> = {
+ <e1:Expression> "OR" <e2:Expression> => {
+ eprintln!("matched disjunction: ``({:#?}, {:#?})''", e1, e2);
+ Box::new(Disjunction::Simple(e1, e2))
+ },
+ <c:Disjunction> "OR" <e:Expression> => {
+ eprintln!("matched disjunction: ``({:#?}, {:#?})''", c, e);
+ Box::new(Disjunction::Compound(c, e))
+ },
+}
+
+pub Expression: Box<Expression> = {
+ "(" <t:Term> ")" => {
+ eprintln!("matched parenthesized term: ``({:#?})''", t);
+ Box::new(Expression::Simple(t))
+ },
+ "(" "!" <e:Expression> ")" => Box::new(Expression::Negation(e)),
+ "(" <c:Conjunction> ")" => {
+ eprintln!("matched parenthesized conjunction: ``({:#?})''", c);
+ Box::new(Expression::Conjunction(c))
+ },
+ "(" <c:Disjunction> ")" => {
+ eprintln!("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..15f249fb
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
@@ -0,0 +1,1166 @@
+// 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_count};
+
+use backtrace::Backtrace;
+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,
+}
+
+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",
+ }
+ )
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+ Text(String),
+ UnixEpoch(i64),
+ Uint(usize),
+}
+
+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)
+ }
+ }
+}
+
+#[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());
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// evaluation logic //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[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"),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum Error {
+ BadISO8601String {
+ text: Vec<u8>,
+ back: Backtrace,
+ },
+ ExpectQuoted {
+ text: String,
+ back: Backtrace,
+ },
+ FilterTypeErr {
+ text: String,
+ back: Backtrace,
+ },
+ InvalidOperand {
+ op: OpCode,
+ back: Backtrace,
+ },
+ OperatorOnStack {
+ op: EvalOp,
+ back: Backtrace,
+ },
+ RatingOverflow {
+ rating: usize,
+ back: Backtrace,
+ },
+ TooManyOperands {
+ num_ops: usize,
+ back: Backtrace,
+ },
+ NumericParse {
+ sticker: String,
+ source: std::num::ParseIntError,
+ back: Backtrace,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::BadISO8601String { text, back: _ } => {
+ write!(f, "Bad ISO8601 timestamp: ``{:?}''", text)
+ }
+ Error::ExpectQuoted { text, back: _ } => write!(f, "Expected quote: ``{}''", text),
+ Error::FilterTypeErr { text, back: _ } => {
+ write!(f, "Un-expected type in filter ``{}''", text)
+ }
+ Error::InvalidOperand { op, back: _ } => write!(f, "Invalid operand {}", op),
+ Error::OperatorOnStack { op, back: _ } => {
+ write!(f, "Operator {} left on parse stack", op)
+ }
+ Error::RatingOverflow { rating, back: _ } => write!(f, "Rating {} overflows", rating),
+ Error::TooManyOperands { num_ops, back: _ } => {
+ write!(f, "Too many operands ({})", num_ops)
+ }
+ Error::NumericParse {
+ sticker,
+ source,
+ back: _,
+ } => write!(f, "While parsing sticker {}, got {}", sticker, source),
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match &self {
+ Error::NumericParse {
+ sticker: _,
+ source,
+ back: _,
+ } => Some(source),
+ Error::Client { source, back: _ } => Some(source),
+ _ => None,
+ }
+ }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+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() {
+ return Err(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ });
+ }
+ 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,
+{
+ // 1. check len
+ if i > buf.len() {
+ return Err(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ });
+ }
+ let (first, second) = buf.split_at(i);
+ *buf = second;
+ // 2. convert to a string
+ let s = std::str::from_utf8(first).map_err(|_| Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?;
+ // 3. parse as a T
+ s.parse::<T>().map_err(|_err| Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ }) // Parse*Error => Error
+}
+
+/// 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(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .timestamp());
+ } else {
+ let next = peek(buf);
+ if next != Some('-') && next != Some('+') {
+ return Err(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ });
+ }
+ 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(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .timestamp());
+ } else {
+ return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60)
+ .ok_or(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .timestamp());
+ }
+ }
+ }
+ }
+ Ok(Local
+ .with_ymd_and_hms(year, month, day, hour, minute, second)
+ .single()
+ .ok_or(Error::BadISO8601String {
+ text: buf.to_vec(),
+ back: Backtrace::new(),
+ })?
+ .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('"') {
+ return Err(Error::ExpectQuoted {
+ text: String::from(qtext),
+ back: Backtrace::new(),
+ });
+ }
+
+ 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 => {
+ return Err(Error::ExpectQuoted {
+ text: String::from(qtext),
+ back: Backtrace::new(),
+ });
+ }
+ }
+ 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>),
+ _ => Err(Error::InvalidOperand {
+ op,
+ back: Backtrace::new(),
+ }),
+ }
+}
+
+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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .drain()
+ .map(|(k, v)| v.parse::<T>().map(|x| (k, x)))
+ .collect::<std::result::Result<HashMap<String, T>, _>>()
+ .map_err(|err| Error::NumericParse {
+ sticker: String::from(sticker),
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ // `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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .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,
+}
+
+impl<'a> FilterStickerNames<'a> {
+ pub fn new() -> FilterStickerNames<'a> {
+ FilterStickerNames {
+ rating: rating_count::STICKER,
+ playcount: play_count::STICKER,
+ lastplayed: last_played::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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .drain(..)
+ .collect()),
+ Term::BinaryCondition(attr, op, val) => {
+ if *attr == Selector::Rating {
+ match val {
+ Value::Uint(n) => {
+ if *n > 255 {
+ return Err(Error::RatingOverflow {
+ rating: *n,
+ back: Backtrace::new(),
+ });
+ }
+ Ok(
+ eval_numeric_sticker_term(stickers.rating, client, *op, *n as u8, 0)
+ .await?,
+ )
+ }
+ _ => Err(Error::FilterTypeErr {
+ text: format!("filter ratings expect an unsigned int; got {:#?}", val),
+ back: Backtrace::new(),
+ }),
+ }
+ } else if *attr == Selector::PlayCount {
+ match val {
+ Value::Uint(n) => {
+ Ok(
+ eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0)
+ .await?,
+ )
+ }
+ _ => Err(Error::FilterTypeErr {
+ text: format!("filter ratings expect an unsigned int; got {:#?}", val),
+ back: Backtrace::new(),
+ }),
+ }
+ } else if *attr == Selector::LastPlayed {
+ match val {
+ Value::UnixEpoch(t) => {
+ Ok(
+ eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0)
+ .await?,
+ )
+ }
+ _ => Err(Error::FilterTypeErr {
+ text: format!("filter ratings expect an unsigned int; got {:#?}", val),
+ back: Backtrace::new(),
+ }),
+ }
+ } else {
+ Ok(client
+ .find2(
+ &format!("{}", attr),
+ &format!("{}", op),
+ &quote_value(val),
+ case,
+ )
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .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));
+ return Err(Error::TooManyOperands {
+ num_ops: se.len(),
+ back: Backtrace::new(),
+ });
+ }
+
+ let ret = se.pop().unwrap();
+ match ret {
+ EvalStackNode::Result(result) => Ok(result),
+ EvalStackNode::Op(op) => {
+ debug!("Operator left on stack (!?): {:#?}", op);
+ Err(Error::OperatorOnStack {
+ op,
+ back: Backtrace::new(),
+ })
+ }
+ }
+}
+
+#[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("rating", "playcount", "lastplayed");
+
+ 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..26645228
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs
@@ -0,0 +1,274 @@
+// 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 filters_ast;
+pub mod messages;
+pub mod playcounts;
+pub mod ratings;
+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"), "/src/filters.rs"));
+}
+
+use clients::{Client, IdleClient, IdleSubSystem};
+use config::Config;
+use config::Connection;
+use filters_ast::FilterStickerNames;
+use messages::MessageProcessor;
+use playcounts::PlayState;
+
+use backtrace::Backtrace;
+use futures::{future::FutureExt, pin_mut, select};
+use tokio::{
+ signal,
+ signal::unix::{SignalKind, signal},
+ time::{Duration, sleep},
+};
+use tracing::{debug, error, info};
+
+use std::path::PathBuf;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ BadPath {
+ pth: PathBuf,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+ Playcounts {
+ source: crate::playcounts::Error,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth),
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match &self {
+ Error::Client { source, back: _ } => Some(source),
+ _ => None,
+ }
+ }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// Core `mppopmd' logic
+pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
+ info!("mpdpopm {} beginning.", vars::VERSION);
+
+ let filter_stickers = FilterStickerNames::new();
+
+ let mut client = match cfg.conn {
+ Connection::Local { ref path } => {
+ Client::open(path).await.map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ }
+ Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port))
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?,
+ };
+
+ let mut state = PlayState::new(&mut client, cfg.played_thresh)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ let mut idle_client = match cfg.conn {
+ Connection::Local { ref path } => {
+ IdleClient::open(path).await.map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ }
+ Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port))
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?,
+ };
+
+ idle_client
+ .subscribe(&cfg.commands_chan)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ 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 mproc = MessageProcessor::new();
+
+ let mut done = false;
+ while !done {
+ debug!("selecting...");
+ let mut msg_check_needed = false;
+ {
+ // `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
+ .map_err(|err| Error::Playcounts {
+ source: err,
+ back: Backtrace::new()
+ })?
+ },
+ // next = cmds.next() => match next {
+ // Some(out) => {
+ // debug!("output status is {:#?}", out.out);
+ // match out.upd {
+ // Some(uri) => {
+ // debug!("{} needs to be updated", uri);
+ // client.update(&uri).await.map_err(|err| Error::Client {
+ // source: err,
+ // back: Backtrace::new(),
+ // })?;
+ // },
+ // None => debug!("No database update needed"),
+ // }
+ // },
+ // None => {
+ // debug!("No more commands to process.");
+ // }
+ // },
+ res = idle => match res {
+ Ok(subsys) => {
+ debug!("subsystem {} changed", subsys);
+ if subsys == IdleSubSystem::Player {
+ state.update(&mut client)
+ .await
+ .map_err(|err| Error::Playcounts {
+ source: err,
+ back: Backtrace::new()
+ })?
+ } else if subsys == IdleSubSystem::Message {
+ msg_check_needed = true;
+ }
+ break;
+ },
+ Err(err) => {
+ debug!("error {err:#?} on idle");
+ done = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if msg_check_needed {
+ // 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) = mproc
+ .check_messages(
+ &mut client,
+ &mut idle_client,
+ state.last_status(),
+ &cfg.commands_chan,
+ &filter_stickers,
+ )
+ .await
+ {
+ error!("Error while processing messages: {err:#?}");
+ }
+ }
+ }
+
+ info!("mpdpopm exiting.");
+
+ Ok(())
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/messages.rs b/pkgs/by-name/mp/mpdpopm/src/messages.rs
new file mode 100644
index 00000000..85b24470
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/messages.rs
@@ -0,0 +1,732 @@
+// 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/>.
+
+//! # messages
+//!
+//! Process incoming messages to the [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon.
+//!
+//! # Introduction
+//!
+//! The [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon accepts commands over a dedicated
+//! [channel](https://www.musicpd.org/doc/html/protocol.html#client-to-client). It also provides for
+//! a generalized framework in which the [mpdpopm](https://github.com/sp1ff/mpdpopm) administrator
+//! can define new commands backed by arbitrary command execution server-side.
+//!
+//! # Commands
+//!
+//! The following commands are built-in:
+//!
+//! - set rating: `rate RATING( TRACK)?`
+//! - set playcount: `setpc PC( TRACK)?`
+//! - set lastplayed: `setlp TIMESTAMP( TRACK)?`
+//!
+//! There is no need to provide corresponding accessors since this functionality is already provided
+//! via "sticker get". Dedicated accessors could provide the same functionality with slightly more
+//! convenience since the sticker name would not have to be specified (as with "sticker get") & may
+//! be added at a later date.
+//!
+//! I'm expanding the MPD filter functionality to include attributes tracked by mpdpopm:
+//!
+//! - findadd replacement: `findadd FILTER [sort TYPE] [window START:END]`
+//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database))
+//!
+//! - searchadd replacement: `searchadd FILTER [sort TYPE] [window START:END]`
+//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database))
+//!
+//! Additional commands may be added through the
+//! [generalized commands](crate::commands#the-generalized-command-framework) feature.
+
+use crate::{
+ clients::{Client, IdleClient, PlayerStatus},
+ filters::ExpressionParser,
+ filters_ast::{FilterStickerNames, evaluate},
+ ratings::{RatedTrack, RatingRequest},
+ storage::{self, last_played, play_count, rating_count},
+};
+
+use backtrace::Backtrace;
+use boolinator::Boolinator;
+use tracing::debug;
+
+use std::collections::VecDeque;
+use std::convert::TryFrom;
+use std::path::PathBuf;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[derive(Debug)]
+pub enum Error {
+ BadPath {
+ pth: PathBuf,
+ },
+ FilterParseError {
+ msg: String,
+ },
+ InvalidChar {
+ c: u8,
+ },
+ NoClosingQuotes,
+ NoCommand,
+ NotImplemented {
+ feature: String,
+ },
+ PlayerStopped,
+ TrailingBackslash,
+ UnknownChannel {
+ chan: String,
+ back: Backtrace,
+ },
+ UnknownCommand {
+ name: String,
+ back: Backtrace,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+ Ratings {
+ source: crate::storage::Error,
+ back: Backtrace,
+ },
+ Playcount {
+ source: crate::storage::Error,
+ back: Backtrace,
+ },
+ Filter {
+ source: crate::filters_ast::Error,
+ back: Backtrace,
+ },
+ Utf8 {
+ source: std::str::Utf8Error,
+ buf: Vec<u8>,
+ back: Backtrace,
+ },
+ ExpectedInt {
+ source: std::num::ParseIntError,
+ text: String,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth),
+ Error::FilterParseError { msg } => write!(f, "Parse error: ``{}''", msg),
+ Error::InvalidChar { c } => write!(f, "Invalid unquoted character {}", c),
+ Error::NoClosingQuotes => write!(f, "Missing closing quotes"),
+ Error::NoCommand => write!(f, "No command specified"),
+ Error::NotImplemented { feature } => write!(f, "`{}' not implemented, yet", feature),
+ Error::PlayerStopped => write!(
+ f,
+ "Can't operate on the current track when the player is stopped"
+ ),
+ Error::TrailingBackslash => write!(f, "Trailing backslash"),
+ Error::UnknownChannel { chan, back: _ } => write!(
+ f,
+ "We received messages for an unknown channel `{}'; this is likely a bug; please consider filing a report to sp1ff@pobox.com",
+ chan
+ ),
+ Error::UnknownCommand { name, back: _ } => {
+ write!(f, "We received an unknown message ``{}''", name)
+ }
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ Error::Ratings { source, back: _ } => write!(f, "Ratings eror: {}", source),
+ Error::Playcount { source, back: _ } => write!(f, "Playcount error: {}", source),
+ Error::Filter { source, back: _ } => write!(f, "Filter error: {}", source),
+ Error::Utf8 {
+ source,
+ buf,
+ back: _,
+ } => write!(f, "UTF8 error {} ({:#?})", source, buf),
+ Error::ExpectedInt {
+ source,
+ text,
+ back: _,
+ } => write!(f, "``{}''L {}", source, text),
+ }
+ }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// Break `buf` up into individual tokens while removing MPD-style quoting.
+///
+/// When a client sends a command to [mpdpopm](crate), it will look like this on the wire:
+///
+/// ```text
+/// sendmessage ${CHANNEL} "some-command \"with space\" simple \"'with single' and \\\\\""
+/// ```
+///
+/// In other words, the MPD "sendmessage" command takes two parameters: the channel and the
+/// message. The recipient (i.e. us) is responsible for breaking up the message into its constituent
+/// parts (a command name & its arguments in our case).
+///
+/// The message will perforce be quoted according ot the MPD rules:
+///
+/// 1. an un-quoted token may contain any printable ASCII character except space, tab, ' & "
+///
+/// 2. to include spaces, tabs, '-s or "-s, the token must be enclosed in "-s, and any "-s or \\-s
+/// therein must be backslash escaped
+///
+/// When the messages is delivered to us, it has already been un-escaped; i.e. we will see the
+/// string:
+///
+/// ```text
+/// some-command "with space" simple "'with single' and \\"
+/// ```
+///
+/// This function will break that string up into individual tokens with one more level
+/// of escaping removed; i.e. it will return an iterator that will yield the four tokens:
+///
+/// 1. some-command
+/// 2. with space
+/// 3. simple
+/// 4. 'with single' and \\
+///
+/// [MPD](https://github.com/MusicPlayerDaemon/MPD) has a nice
+/// [implementation](https://github.com/MusicPlayerDaemon/MPD/blob/master/src/util/Tokenizer.cxx#L170)
+/// that modifies the string in place by copying subsequent characters on top of escape characters
+/// in the same buffer, inserting nulls in between the resulting tokens,and then working in terms of
+/// pointers to the resulting null-terminated strings.
+///
+/// Once I realized that I could split slices I saw how to implement an Iterator that do the same
+/// thing (an idiomatic interface to the tokenization backed by a zero-copy implementation). I was
+/// inspired by [My Favorite Rust Function
+/// Signature](<https://www.brandonsmith.ninja/blog/favorite-rust-function>).
+///
+/// NB. This method works in terms of a slice of [`u8`] because we can't index into Strings in
+/// Rust, and MPD deals only in terms of ASCII at any rate.
+pub fn tokenize(buf: &mut [u8]) -> impl Iterator<Item = Result<&[u8]>> {
+ TokenIterator::new(buf)
+}
+
+struct TokenIterator<'a> {
+ /// The slice on which we operate; modified in-place as we yield tokens
+ slice: &'a mut [u8],
+ /// Index into [`slice`] of the first non-whitespace character
+ input: usize,
+}
+
+impl<'a> TokenIterator<'a> {
+ pub fn new(slice: &'a mut [u8]) -> TokenIterator<'a> {
+ let input = match slice.iter().position(|&x| x > 0x20) {
+ Some(n) => n,
+ None => slice.len(),
+ };
+ TokenIterator { slice, input }
+ }
+}
+
+impl<'a> Iterator for TokenIterator<'a> {
+ type Item = Result<&'a [u8]>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let nslice = self.slice.len();
+ if self.slice.is_empty() || self.input == nslice {
+ None
+ } else if '"' == self.slice[self.input] as char {
+ // This is NextString in MPD: walk self.slice, un-escaping characters, until we find
+ // a closing ". Note that we un-escape by moving characters forward in the slice.
+ let mut inp = self.input + 1;
+ let mut out = self.input;
+ while self.slice[inp] as char != '"' {
+ if '\\' == self.slice[inp] as char {
+ inp += 1;
+ if inp == nslice {
+ return Some(Err(Error::TrailingBackslash));
+ }
+ }
+ self.slice[out] = self.slice[inp];
+ out += 1;
+ inp += 1;
+ if inp == nslice {
+ return Some(Err(Error::NoClosingQuotes));
+ }
+ }
+ // The next token is in self.slice[self.input..out] and self.slice[inp] is "
+ let tmp = std::mem::take(&mut self.slice);
+ let (_, tmp) = tmp.split_at_mut(self.input);
+ let (result, new_slice) = tmp.split_at_mut(out - self.input);
+ self.slice = new_slice;
+ // strip any leading whitespace
+ self.input = inp - out + 1; // +1 to skip the closing "
+ while self.input < self.slice.len() && self.slice[self.input] as char == ' ' {
+ self.input += 1;
+ }
+ Some(Ok(result))
+ } else {
+ // This is NextUnquoted in MPD; walk self.slice, validating characters until the end
+ // or the next whitespace
+ let mut i = self.input;
+ while i < nslice {
+ if 0x20 >= self.slice[i] {
+ break;
+ }
+ if self.slice[i] as char == '"' || self.slice[i] as char == '\'' {
+ return Some(Err(Error::InvalidChar { c: self.slice[i] }));
+ }
+ i += 1;
+ }
+ // The next token is in self.slice[self.input..i] & self.slice[i] is either one-
+ // past-the end or whitespace.
+ let tmp = std::mem::take(&mut self.slice);
+ let (_, tmp) = tmp.split_at_mut(self.input);
+ let (result, new_slice) = tmp.split_at_mut(i - self.input);
+ self.slice = new_slice;
+ // strip any leading whitespace
+ self.input = match self.slice.iter().position(|&x| x > 0x20) {
+ Some(n) => n,
+ None => self.slice.len(),
+ };
+ Some(Ok(result))
+ }
+ }
+}
+
+/// Collective state needed for processing messages, both built-in & generalized
+#[derive(Default)]
+pub struct MessageProcessor {}
+
+impl MessageProcessor {
+ /// Whip up a new instance; other than cloning the iterators, should just hold references in the
+ /// enclosing scope
+ pub fn new() -> MessageProcessor {
+ Self::default()
+ }
+
+ /// Read messages off the commands channel & dispatch 'em
+ pub async fn check_messages<'a>(
+ &self,
+ client: &mut Client,
+ idle_client: &mut IdleClient,
+ state: PlayerStatus,
+ command_chan: &str,
+ stickers: &FilterStickerNames<'a>,
+ ) -> Result<()> {
+ let m = idle_client
+ .get_messages()
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ for (chan, msgs) in m {
+ // Only supporting a single channel, ATM
+ (chan == command_chan).ok_or_else(|| Error::UnknownChannel {
+ chan,
+ back: Backtrace::new(),
+ })?;
+ for msg in msgs {
+ self.process(msg, client, &state, stickers).await?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Process a single command
+ pub async fn process<'a>(
+ &self,
+ msg: String,
+ client: &mut Client,
+ state: &PlayerStatus,
+ stickers: &FilterStickerNames<'a>,
+ ) -> Result<()> {
+ if let Some(stripped) = msg.strip_prefix("rate ") {
+ self.rate(stripped, client, state).await
+ } else if let Some(stripped) = msg.strip_prefix("inc-rate ") {
+ self.inc_rate(stripped, client, state).await
+ } else if let Some(stripped) = msg.strip_prefix("setpc ") {
+ self.setpc(stripped, client, state).await
+ } else if let Some(stripped) = msg.strip_prefix("setlp ") {
+ self.setlp(stripped, client, state).await
+ } else if let Some(stripped) = msg.strip_prefix("findadd ") {
+ self.findadd(stripped.to_string(), client, stickers, state)
+ .await
+ } else if let Some(stripped) = msg.strip_prefix("searchadd ") {
+ self.searchadd(stripped.to_string(), client, stickers, state)
+ .await
+ } else {
+ unreachable!("Unkonwn command")
+ }
+ }
+
+ /// Handle rating message: "RATING( TRACK)?"
+ async fn rate(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> {
+ let req = RatingRequest::try_from(msg).map_err(|err| Error::Ratings {
+ source: storage::Error::Rating {
+ source: err,
+ back: Backtrace::new(),
+ },
+ back: Backtrace::new(),
+ })?;
+
+ let pathb = match req.track {
+ RatedTrack::Current => match state {
+ PlayerStatus::Stopped => {
+ return Err(Error::PlayerStopped {});
+ }
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr.file.clone(),
+ },
+ RatedTrack::File(p) => p,
+ RatedTrack::Relative(_i) => {
+ return Err(Error::NotImplemented {
+ feature: String::from("Relative track position"),
+ });
+ }
+ };
+ let path: &str = pathb
+ .to_str()
+ .ok_or_else(|| Error::BadPath { pth: pathb.clone() })?;
+
+ debug!("Setting a rating of {} for `{}'.", req.rating, path);
+
+ rating_count::set(client, path, req.rating)
+ .await
+ .map_err(|err| Error::Ratings {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ Ok(())
+ }
+
+ /// Handle inc-rating message: "( TRACK)?"
+ async fn inc_rate(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> {
+ let pathb = if msg.is_empty() {
+ // We rate the current track
+ match state {
+ PlayerStatus::Stopped => {
+ return Err(Error::PlayerStopped {});
+ }
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr.file.clone(),
+ }
+ } else {
+ PathBuf::from(msg)
+ };
+
+ let path: &str = pathb
+ .to_str()
+ .ok_or_else(|| Error::BadPath { pth: pathb.clone() })?;
+
+ let rating = rating_count::get(client, path)
+ .await
+ .map_err(|err| Error::Ratings {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .unwrap_or(0)
+ .saturating_add(1);
+
+ debug!(
+ "Incrementing a rating for `{}' (new value: {}).",
+ path, rating
+ );
+
+ rating_count::set(client, path, rating)
+ .await
+ .map_err(|err| Error::Ratings {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ Ok(())
+ }
+
+ /// Handle `setpc': "PC( TRACK)?"
+ async fn setpc(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> {
+ let text = msg.trim();
+ let (pc, track) = match text.find(char::is_whitespace) {
+ Some(idx) => (
+ text[..idx]
+ .parse::<usize>()
+ .map_err(|err| Error::ExpectedInt {
+ source: err,
+ text: String::from(text),
+ back: Backtrace::new(),
+ })?,
+ &text[idx + 1..],
+ ),
+ None => (
+ text.parse::<usize>().map_err(|err| Error::ExpectedInt {
+ source: err,
+ text: String::from(text),
+ back: Backtrace::new(),
+ })?,
+ "",
+ ),
+ };
+ let file = if track.is_empty() {
+ match state {
+ PlayerStatus::Stopped => {
+ return Err(Error::PlayerStopped {});
+ }
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr
+ .file
+ .to_str()
+ .ok_or_else(|| Error::BadPath {
+ pth: curr.file.clone(),
+ })?
+ .to_string(),
+ }
+ } else {
+ track.to_string()
+ };
+
+ play_count::set(client, &file, pc)
+ .await
+ .map_err(|err| Error::Playcount {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ Ok(())
+ }
+
+ /// Handle `setlp': "LASTPLAYED( TRACK)?"
+ async fn setlp(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> {
+ let text = msg.trim();
+ let (lp, track) = match text.find(char::is_whitespace) {
+ Some(idx) => (
+ text[..idx]
+ .parse::<u64>()
+ .map_err(|err| Error::ExpectedInt {
+ source: err,
+ text: String::from(text),
+ back: Backtrace::new(),
+ })?,
+ &text[idx + 1..],
+ ),
+ None => (
+ text.parse::<u64>().map_err(|err| Error::ExpectedInt {
+ source: err,
+ text: String::from(text),
+ back: Backtrace::new(),
+ })?,
+ "",
+ ),
+ };
+
+ let file = if track.is_empty() {
+ match state {
+ PlayerStatus::Stopped => {
+ return Err(Error::PlayerStopped {});
+ }
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr
+ .file
+ .to_str()
+ .ok_or_else(|| Error::BadPath {
+ pth: curr.file.clone(),
+ })?
+ .to_string(),
+ }
+ } else {
+ track.to_string()
+ };
+
+ last_played::set(client, &file, lp)
+ .await
+ .map_err(|err| Error::Playcount {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ Ok(())
+ }
+
+ /// Handle `findadd': "FILTER [sort TYPE] [window START:END]"
+ async fn findadd<'a>(
+ &self,
+ msg: String,
+ client: &mut Client,
+ stickers: &FilterStickerNames<'a>,
+ _state: &PlayerStatus,
+ ) -> Result<()> {
+ let mut buf = msg.into_bytes();
+ let args: VecDeque<&str> = tokenize(&mut buf)
+ .map(|r| match r {
+ Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 {
+ source: err,
+ buf: buf.to_vec(),
+ back: Backtrace::new(),
+ })?),
+ Err(err) => Err(err),
+ })
+ .collect::<Result<VecDeque<&str>>>()?;
+
+ debug!("findadd arguments: {:#?}", args);
+
+ // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet.
+
+ // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the
+ // offending token, which has the same lifetime as our input, which makes it tough to
+ // capture. Nor is there a convenient way in which to treat all variants other than the
+ // Error Trait.
+ let ast = match ExpressionParser::new().parse(args[0]) {
+ Ok(ast) => ast,
+ Err(err) => {
+ return Err(Error::FilterParseError {
+ msg: format!("{}", err),
+ });
+ }
+ };
+
+ debug!("ast: {:#?}", ast);
+
+ let mut results = Vec::new();
+ for song in evaluate(&ast, true, client, stickers)
+ .await
+ .map_err(|err| Error::Filter {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ {
+ results.push(client.add(&song).await);
+ }
+ match results
+ .into_iter()
+ .collect::<std::result::Result<Vec<()>, crate::clients::Error>>()
+ {
+ Ok(_) => Ok(()),
+ Err(err) => Err(Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ }),
+ }
+ }
+
+ /// Handle `searchadd': "FILTER [sort TYPE] [window START:END]"
+ async fn searchadd<'a>(
+ &self,
+ msg: String,
+ client: &mut Client,
+ stickers: &FilterStickerNames<'a>,
+ _state: &PlayerStatus,
+ ) -> Result<()> {
+ // Tokenize the message
+ let mut buf = msg.into_bytes();
+ let args: VecDeque<&str> = tokenize(&mut buf)
+ .map(|r| match r {
+ Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 {
+ source: err,
+ buf: buf.to_vec(),
+ back: Backtrace::new(),
+ })?),
+ Err(err) => Err(err),
+ })
+ .collect::<Result<VecDeque<&str>>>()?;
+
+ debug!("searchadd arguments: {:#?}", args);
+
+ // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet.
+
+ // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the
+ // offending token, which has the same lifetime as our input, which makes it tough to
+ // capture. Nor is there a convenient way in which to treat all variants other than the
+ // Error Trait.
+ let ast = match ExpressionParser::new().parse(args[0]) {
+ Ok(ast) => ast,
+ Err(err) => {
+ return Err(Error::FilterParseError {
+ msg: format!("{}", err),
+ });
+ }
+ };
+
+ debug!("ast: {:#?}", ast);
+
+ let mut results = Vec::new();
+ for song in evaluate(&ast, false, client, stickers)
+ .await
+ .map_err(|err| Error::Filter {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ {
+ results.push(client.add(&song).await);
+ }
+ match results
+ .into_iter()
+ .collect::<std::result::Result<Vec<()>, crate::clients::Error>>()
+ {
+ Ok(_) => Ok(()),
+ Err(err) => Err(Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ }),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tokenize_tests {
+ use super::Result;
+ use super::tokenize;
+
+ #[test]
+ fn tokenize_smoke() {
+ let mut buf1 = String::from("some-command").into_bytes();
+ let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x1[0], b"some-command");
+
+ let mut buf2 = String::from("a b").into_bytes();
+ let x2: Vec<&[u8]> = tokenize(&mut buf2).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x2[0], b"a");
+ assert_eq!(x2[1], b"b");
+
+ let mut buf3 = String::from("a \"b c\"").into_bytes();
+ let x3: Vec<&[u8]> = tokenize(&mut buf3).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x3[0], b"a");
+ assert_eq!(x3[1], b"b c");
+
+ let mut buf4 = String::from("a \"b c\" d").into_bytes();
+ let x4: Vec<&[u8]> = tokenize(&mut buf4).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x4[0], b"a");
+ assert_eq!(x4[1], b"b c");
+ assert_eq!(x4[2], b"d");
+
+ let mut buf5 = String::from("simple-command \"with space\" \"with '\"").into_bytes();
+ let x5: Vec<&[u8]> = tokenize(&mut buf5).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x5[0], b"simple-command");
+ assert_eq!(x5[1], b"with space");
+ assert_eq!(x5[2], b"with '");
+
+ let mut buf6 = String::from("cmd \"with\\\\slash and space\"").into_bytes();
+ let x6: Vec<&[u8]> = tokenize(&mut buf6).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x6[0], b"cmd");
+ assert_eq!(x6[1], b"with\\slash and space");
+
+ let mut buf7 = String::from(" cmd \"with\\\\slash and space\" ").into_bytes();
+ let x7: Vec<&[u8]> = tokenize(&mut buf7).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(x7[0], b"cmd");
+ assert_eq!(x7[1], b"with\\slash and space");
+ }
+
+ #[test]
+ fn tokenize_filter() {
+ let mut buf1 = String::from(r#""(artist =~ \"foo\\\\bar\\\"\")""#).into_bytes();
+ let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap();
+ assert_eq!(1, x1.len());
+ eprintln!("x1[0] is ``{}''", std::str::from_utf8(x1[0]).unwrap());
+ assert_eq!(
+ std::str::from_utf8(x1[0]).unwrap(),
+ r#"(artist =~ "foo\\bar\"")"#
+ );
+ }
+}
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..4e308d4a
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -0,0 +1,367 @@
+// 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::{self, last_played, play_count, skipped};
+
+use backtrace::Backtrace;
+use tracing::{debug, info};
+
+use std::path::PathBuf;
+use std::time::SystemTime;
+
+#[derive(Debug)]
+pub enum Error {
+ PlayerStopped,
+ BadPath {
+ pth: PathBuf,
+ },
+ SystemTime {
+ source: std::time::SystemTimeError,
+ back: Backtrace,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::PlayerStopped => write!(f, "The MPD player is stopped"),
+ Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth),
+ Error::SystemTime { source, back: _ } => {
+ write!(f, "Couldn't get system time: {}", source)
+ }
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match &self {
+ Error::SystemTime { source, back: _ } => Some(source),
+ Error::Client { source, back: _ } => Some(source),
+ _ => None,
+ }
+ }
+}
+
+impl From<storage::Error> for Error {
+ fn from(value: storage::Error) -> Self {
+ match value {
+ storage::Error::PlayerStopped => Self::PlayerStopped,
+ storage::Error::BadPath { pth } => Self::BadPath { pth },
+ storage::Error::SystemTime { source, back } => Self::SystemTime { source, back },
+ storage::Error::Client { source, back } => Self::Client { source, back },
+ _ => unreachable!(),
+ }
+ }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+/// 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, crate::clients::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
+ pub async fn update(&mut self, client: &mut Client) -> Result<()> {
+ let new_stat = client.status().await.map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ 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;
+ } 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;
+ }
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Play(_))
+ | (PlayerStatus::Stopped, PlayerStatus::Pause(_))
+ | (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+ | (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
+ self.have_incr_play_count = false;
+ }
+ (PlayerStatus::Stopped, PlayerStatus::Stopped) => (),
+ }
+
+ 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 {} played.",
+ curr.file.display(),
+ curr.songid,
+ curr.elapsed / curr.duration
+ );
+
+ let file = curr.file.to_str().ok_or_else(|| Error::BadPath {
+ pth: curr.file.clone(),
+ })?;
+
+ 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)
+ .map_err(|err| Error::SystemTime {
+ source: err,
+ back: Backtrace::new(),
+ })?
+ .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 {}.",
+ last.file.display(),
+ last.songid,
+ last.elapsed / last.duration
+ );
+
+ let file = last.file.to_str().ok_or_else(|| Error::BadPath {
+ pth: last.file.clone(),
+ })?;
+
+ let skip_count = skipped::get(client, file).await?.unwrap_or_default();
+ skipped::set(client, file, skip_count + 1).await?;
+ }
+ }
+ PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
+ };
+
+ self.last_server_stat = new_stat;
+ Ok(()) // No need to update the DB
+ }
+}
+
+#[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\" pc",
+ "sticker: pc=11\nOK\n",
+ ),
+ (
+ &format!(
+ "sticker set song \"E/Enya - Wild Child.mp3\" lp {}",
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ ),
+ "OK\n",
+ ),
+ ("sticker set song \"E/Enya - Wild Child.mp3\" pc 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/ratings.rs b/pkgs/by-name/mp/mpdpopm/src/ratings.rs
new file mode 100644
index 00000000..739d3827
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/ratings.rs
@@ -0,0 +1,195 @@
+// 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/>.
+
+//! Logic for rating MPD tracks.
+//!
+//! # Introduction
+//!
+//! This module contains types implementing a basic rating functionality for
+//! [MPD](http://www.musicpd.org).
+//!
+//! # Discussion
+//!
+//! Rating messages to the relevant channel take the form `RATING( TRACK)?` (the two components can
+//! be separated by any whitespace). The rating can be given by an integer between 0 & 255
+//! (inclusive) represented in base ten, or as one-to-five asterisks (i.e. `\*{1,5}`). In the latter
+//! case, the rating will be mapped to 1-255 as per Winamp's
+//! [convention](http://forums.winamp.com/showpost.php?p=2903240&postcount=94):
+//!
+//! - 224-255: 5 stars when READ with windows explorer, writes 255
+//! - 160-223: 4 stars when READ with windows explorer, writes 196
+//! - 096-159: 3 stars when READ with windows explorer, writes 128
+//! - 032-095: 2 stars when READ with windows explorer, writes 64
+//! - 001-031: 1 stars when READ with windows explorer, writes 1
+//!
+//! NB a rating of zero means "not rated".
+//!
+//! Everything after the first whitepace, if present, is taken to be the track to be rated (i.e.
+//! the track may contain whitespace). If omitted, the rating is taken to apply to the current
+//! track.
+
+use backtrace::Backtrace;
+
+use std::path::PathBuf;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// Error //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// An enumeration of ratings errors
+#[derive(Debug)]
+pub enum Error {
+ Rating {
+ source: std::num::ParseIntError,
+ text: String,
+ },
+ PlayerStopped,
+ NotImplemented {
+ feature: String,
+ },
+ BadPath {
+ pth: PathBuf,
+ back: Backtrace,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::Rating { source, text } => write!(
+ f,
+ "Unable to interpret ``{}'' as a rating: {}",
+ text, source
+ ),
+ Error::PlayerStopped => write!(f, "Player stopped"),
+ Error::NotImplemented { feature } => write!(f, "{} not implemented", feature),
+ Error::BadPath { pth, back: _ } => write!(f, "Bad path: {:?}", pth),
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match &self {
+ Error::Rating { text: _, source } => Some(source),
+ Error::Client { source, back: _ } => Some(source),
+ _ => None,
+ }
+ }
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+// RatingRequest message //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// The track to which a rating shall be applied.
+#[derive(Debug, PartialEq)]
+pub enum RatedTrack {
+ Current,
+ File(std::path::PathBuf),
+ Relative(i8),
+}
+
+/// A request from a client to rate a track.
+#[derive(Debug)]
+pub struct RatingRequest {
+ pub rating: u8,
+ pub track: RatedTrack,
+}
+
+/// Produce a RatingRequest instance from a line of MPD output.
+impl std::convert::TryFrom<&str> for RatingRequest {
+ type Error = Error;
+
+ /// Attempt to produce a RatingRequest instance from a line of MPD response to a
+ /// "readmessages" command. After the channel line, each subsequent line will be of the form
+ /// "message: $MESSAGE"-- this method assumes that the "message: " prefix has been stripped off
+ /// (i.e. we're dealing with a single line of text containing only our custom message format).
+ ///
+ /// For ratings, we expect a message of the form: "RATING (TRACK)?".
+ fn try_from(text: &str) -> std::result::Result<Self, Self::Error> {
+ // We expect a message of the form: "RATING (TRACK)?"; let us split `text' into those two
+ // components for separate processing:
+ let text = text.trim();
+ let (rating, track) = match text.find(char::is_whitespace) {
+ Some(idx) => (&text[..idx], &text[idx + 1..]),
+ None => (text, ""),
+ };
+
+ // Rating first-- the desired rating can be specified in a few ways...
+ let rating = if rating.is_empty() {
+ // an empty string is interpreted as zero:
+ 0u8
+ } else {
+ // "*{1,5}" is interpreted as one-five stars, mapped to [0,255] as per Winamp:
+ match rating {
+ "*" => 1,
+ "**" => 64,
+ "***" => 128,
+ "****" => 196,
+ "*****" => 255,
+ // failing that, we try just interperting `rating' as an unsigned integer:
+ _ => rating.parse::<u8>().map_err(|err| Error::Rating {
+ source: err,
+ text: String::from(rating),
+ })?,
+ }
+ };
+
+ // Next-- track. This, too, can be given in a few ways:
+ let track = if track.is_empty() {
+ // nothing at all just means "current track"
+ RatedTrack::Current
+ } else {
+ // otherwise...
+ match text.parse::<i8>() {
+ // if we can interpret `track' as an i8, we take it as an offset...
+ Ok(i) => RatedTrack::Relative(i),
+ // else, we assume it's a path. If it's not, we'll figure that out downstream.
+ Err(_) => RatedTrack::File(std::path::PathBuf::from(&track)),
+ }
+ };
+
+ Ok(RatingRequest { rating, track })
+ }
+}
+
+#[cfg(test)]
+mod rating_request_tests {
+ use super::*;
+ use std::convert::TryFrom;
+
+ /// RatingRequest smoke tests
+ #[test]
+ fn rating_request_smoke() {
+ let req = RatingRequest::try_from("*** foo bar splat.mp3").unwrap();
+ assert_eq!(req.rating, 128);
+ assert_eq!(
+ req.track,
+ RatedTrack::File(PathBuf::from("foo bar splat.mp3"))
+ );
+ let req = RatingRequest::try_from("255").unwrap();
+ assert_eq!(req.rating, 255);
+ assert_eq!(req.track, RatedTrack::Current);
+ let _req = RatingRequest::try_from("******").unwrap_err();
+ }
+}
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..29cfe144
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
@@ -0,0 +1,212 @@
+use std::path::PathBuf;
+
+use backtrace::Backtrace;
+
+#[derive(Debug)]
+pub enum Error {
+ PlayerStopped,
+ BadPath {
+ pth: PathBuf,
+ },
+ SystemTime {
+ source: std::time::SystemTimeError,
+ back: Backtrace,
+ },
+ Client {
+ source: crate::clients::Error,
+ back: Backtrace,
+ },
+ Rating {
+ source: crate::ratings::Error,
+ back: Backtrace,
+ },
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Error::PlayerStopped => write!(f, "The MPD player is stopped"),
+ Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth),
+ Error::SystemTime { source, back: _ } => {
+ write!(f, "Couldn't get system time: {}", source)
+ }
+ Error::Client { source, back: _ } => write!(f, "Client error: {}", source),
+ Error::Rating { source, back: _ } => write!(f, "Rating error: {}", source),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match &self {
+ Error::SystemTime { source, back: _ } => Some(source),
+ Error::Client { source, back: _ } => Some(source),
+ _ => None,
+ }
+ }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+pub mod play_count {
+ use backtrace::Backtrace;
+
+ use crate::clients::Client;
+
+ use super::{Error, 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })? {
+ 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+
+ 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 pc", "sticker: pc=11\nOK\n"),
+ (
+ "sticker get song a pc",
+ "ACK [50@0] {sticker} no such sticker\n",
+ ),
+ ("sticker get song a pc", "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 skipped {
+ use backtrace::Backtrace;
+
+ use crate::clients::Client;
+
+ use super::{Error, Result};
+
+ 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })? {
+ 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })
+ }
+}
+
+pub mod last_played {
+ use backtrace::Backtrace;
+
+ use crate::clients::Client;
+
+ use super::{Error, 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })
+ }
+
+ /// 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
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ Ok(())
+ }
+}
+
+pub mod rating_count {
+ use backtrace::Backtrace;
+
+ use crate::clients::Client;
+
+ use super::{Error, 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<u8>> {
+ client
+ .get_sticker::<u8>(file, STICKER)
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })
+ }
+
+ /// Set the rating count for a track
+ pub async fn set(client: &mut Client, file: &str, rating_count: u8) -> Result<()> {
+ client
+ .set_sticker(file, STICKER, &format!("{}", rating_count))
+ .await
+ .map_err(|err| Error::Client {
+ source: err,
+ back: Backtrace::new(),
+ })?;
+ 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..29b9610d
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs
@@ -0,0 +1,5 @@
+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";
+