diff options
Diffstat (limited to '')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs | 597 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs | 150 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/clients.rs | 1202 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/config.rs | 277 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/filters.lalrpop | 143 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/filters_ast.rs | 1005 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/lib.rs | 207 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/messages.rs | 409 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/playcounts.rs | 313 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/storage/mod.rs | 145 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/vars.rs | 4 |
11 files changed, 4452 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..d9d607d5 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs @@ -0,0 +1,597 @@ +// 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 anyhow::{Context, Result, anyhow, bail}; +use clap::{Parser, Subcommand}; +use tracing::{debug, info, level_filters::LevelFilter, trace}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +use std::path::PathBuf; + +/// Map `tracks' argument(s) to a Vec of String containing one or more mpd URIs +/// +/// Several sub-commands take zero or more positional arguments meant to name tracks, with the +/// convention that zero indicates that the sub-command should use the currently playing track. +/// This is a convenience function for mapping the value returned by [`get_many`] to a +/// convenient representation of the user's intentions. +/// +/// [`get_many`]: [`clap::ArgMatches::get_many`] +async fn map_tracks(client: &mut Client, args: Option<Vec<String>>) -> Result<Vec<String>> { + let files = match args { + Some(iter) => iter, + None => { + let file = provide_file(client, None).await?; + vec![file] + } + }; + Ok(files) +} + +async fn provide_file(client: &mut Client, maybe_file: Option<String>) -> Result<String> { + let file = match maybe_file { + Some(file) => file, + None => { + match client + .status() + .await + .context("Failed to get status of client")? + { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| anyhow!("Path is not utf8: `{}`", curr.file.display()))? + .to_string(), + PlayerStatus::Stopped => { + bail!("Player is stopped"); + } + } + } + }; + + Ok(file) +} + +/// Retrieve ratings for one or more tracks +async fn get_ratings( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut ratings: Vec<(String, i8)> = Vec::new(); + + for file in map_tracks(client, tracks).await? { + let rating = rating_count::get(client, &file).await?; + + ratings.push((file, rating.unwrap_or_default())); + } + + if ratings.len() == 1 && !with_uri { + println!("{}", ratings[0].1); + } else { + for pair in ratings { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Rate a track +async fn set_rating(client: &mut Client, rating: i8, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + rating_count::set(client, &file, rating).await?; + + match is_current { + false => info!("Set the rating for \"{}\" to \"{}\".", file, rating), + true => info!("Set the rating for the current song to \"{}\".", rating), + } + + Ok(()) +} + +/// Rate a track by incrementing the current rating +async fn inc_rating(client: &mut Client, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + let now = rating_count::get(client, &file).await?; + + rating_count::set(client, &file, now.unwrap_or_default().saturating_add(1)).await?; + + match is_current { + false => info!("Incremented the rating for \"{}\".", file), + true => info!("Incremented the rating for the current song."), + } + + Ok(()) +} + +/// Rate a track by decrementing the current rating +async fn decr_rating(client: &mut Client, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + let now = rating_count::get(client, &file).await?; + + rating_count::set(client, &file, now.unwrap_or_default().saturating_sub(1)).await?; + + match is_current { + false => info!("Decremented the rating for \"{}\".", file), + true => info!("Decremented the rating for the current song."), + } + + Ok(()) +} + +/// Retrieve the playcount for one or more tracks +async fn get_play_counts( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut playcounts: Vec<(String, usize)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let playcount = play_count::get(client, &file).await?.unwrap_or_default(); + playcounts.push((file, playcount)); + } + + if playcounts.len() == 1 && !with_uri { + println!("{}", playcounts[0].1); + } else { + for pair in playcounts { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_play_counts(client: &mut Client, playcount: usize, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + play_count::set(client, &file, playcount).await?; + + match is_current { + false => info!("Set the playcount for \"{}\" to \"{}\".", file, playcount), + true => info!( + "Set the playcount for the current song to \"{}\".", + playcount + ), + } + + Ok(()) +} + +/// Retrieve the last played time for one or more tracks +async fn get_last_playeds( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut lastplayeds: Vec<(String, Option<u64>)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let lastplayed = last_played::get(client, &file).await?; + lastplayeds.push((file, lastplayed)); + } + + if lastplayeds.len() == 1 && !with_uri { + println!( + "{}", + match lastplayeds[0].1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } else { + for pair in lastplayeds { + println!( + "{}: {}", + pair.0, + match pair.1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_last_playeds(client: &mut Client, lastplayed: u64, arg: Option<String>) -> Result<()> { + let is_current = arg.is_none(); + let file = provide_file(client, arg).await?; + + last_played::set(client, &file, lastplayed).await?; + + match is_current { + false => info!("Set last played for \"{}\" to \"{}\".", file, lastplayed), + true => info!( + "Set last played for the current song to \"{}\".", + lastplayed + ), + } + + Ok(()) +} + +/// Retrieve the list of stored playlists +async fn get_playlists(client: &mut Client) -> Result<()> { + let mut pls = client.get_stored_playlists().await?; + pls.sort(); + println!("Stored playlists:"); + for pl in pls { + println!("{}", pl); + } + Ok(()) +} + +/// Add songs selected by filter to the queue +async fn 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?; + 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?; + 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 RatingCommand { + /// retrieve the rating for one or more tracks + /// + /// With no arguments, retrieve the rating of the current song & print it + /// on stdout. With one argument, retrieve that track's rating & print it + /// on stdout. With multiple arguments, print their ratings on stdout, one + /// per line, prefixed by the track name. + /// + /// Ratings are expressed as an integer between 0 & 255, inclusive, with + /// the convention that 0 denotes "un-rated". + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the rating for one track + /// + /// With one argument, set the rating of the current song to that argument. + /// With a second argument, rate that song at the first argument. Ratings + /// may be expressed a an integer between 0 & 255, inclusive. + #[clap(verbatim_doc_comment)] + Set { rating: i8, track: Option<String> }, + + /// increment the rating for one track + /// + /// With one argument, increment the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + Inc { track: Option<String> }, + + /// decrement the rating for one track + /// + /// With one argument, decrement the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + Decr { track: Option<String> }, +} + +#[derive(Subcommand)] +enum PlayCountCommand { + /// retrieve the play count for one or more tracks + /// + /// With no arguments, retrieve the play count of the current song & print it + /// on stdout. With one argument, retrieve that track's play count & print it + /// on stdout. With multiple arguments, print their play counts on stdout, one + /// per line, prefixed by the track name. + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the play count for one track + /// + /// With one argument, set the play count of the current song to that argument. With a + /// second argument, set the play count for that song to the first. + #[clap(verbatim_doc_comment)] + Set { + play_count: usize, + track: Option<String>, + }, +} + +#[derive(Subcommand)] +enum LastPlayedCommand { + /// retrieve the last played timestamp for one or more tracks + /// + /// With no arguments, retrieve the last played timestamp of the current + /// song & print it on stdout. With one argument, retrieve that track's + /// last played time & print it on stdout. With multiple arguments, print + /// their last played times on stdout, one per line, prefixed by the track + /// name. + /// + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + Get { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the last played timestamp for one track + /// + /// With one argument, set the last played time of the current song. With two + /// arguments, set the last played time for the second argument to the first. + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + Set { + last_played: u64, + track: Option<String>, + }, +} + +#[derive(Subcommand)] +enum PlaylistsCommand { + /// retrieve the list of stored playlists + #[clap(verbatim_doc_comment)] + Get {}, +} + +#[derive(Subcommand)] +enum SubCommand { + /// Change details about rating. + Rating { + #[command(subcommand)] + command: RatingCommand, + }, + + /// Change details about play count. + PlayCount { + #[command(subcommand)] + command: PlayCountCommand, + }, + + /// Change details about last played date. + LastPlayed { + #[command(subcommand)] + command: LastPlayedCommand, + }, + + /// Change details about generated playlists. + Playlists { + #[command(subcommand)] + command: PlaylistsCommand, + }, + + /// search 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).with_context(|| { + format!("Failed to parse config file at: `{}`", configpath.display()) + })?, + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + bail!( + "Failed to read config file at: `{}`, because: {err}", + configpath.display() + ) + } + } + } else { + Config::default() + }; + + // Handle log verbosity: debug => verbose + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::WARN, + }; + + tracing::subscriber::set_global_default( + Registry::default() + .with( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(std::io::stdout), + ) + .with( + EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .unwrap(), + ), + ) + .unwrap(); + + trace!("logging configured."); + + let mut client = match config.conn { + config::Connection::Local { path } => Client::open(path).await?, + config::Connection::TCP { host, port } => { + Client::connect(format!("{}:{}", host, port)).await? + } + }; + + match args.command { + SubCommand::Rating { command } => match command { + RatingCommand::Get { with_uri, tracks } => { + get_ratings(&mut client, tracks, with_uri).await + } + RatingCommand::Set { rating, track } => set_rating(&mut client, rating, track).await, + RatingCommand::Inc { track } => inc_rating(&mut client, track).await, + RatingCommand::Decr { track } => decr_rating(&mut client, track).await, + }, + SubCommand::PlayCount { command } => match command { + PlayCountCommand::Get { with_uri, tracks } => { + get_play_counts(&mut client, tracks, with_uri).await + } + PlayCountCommand::Set { play_count, track } => { + set_play_counts(&mut client, play_count, track).await + } + }, + SubCommand::LastPlayed { command } => match command { + LastPlayedCommand::Get { with_uri, tracks } => { + get_last_playeds(&mut client, tracks, with_uri).await + } + LastPlayedCommand::Set { last_played, track } => { + set_last_playeds(&mut client, last_played, track).await + } + }, + SubCommand::Playlists { command } => match command { + PlaylistsCommand::Get {} => get_playlists(&mut client).await, + }, + SubCommand::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..643611d6 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs @@ -0,0 +1,150 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mppopmd +//! +//! Maintain ratings & playcounts for your mpd server. +//! +//! # Introduction +//! +//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). + +use mpdpopm::{ + config::{self, Config}, + mpdpopm, +}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use tracing::{info, level_filters::LevelFilter}; +use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; + +use std::{io, path::PathBuf, sync::MutexGuard}; + +pub struct MyMutexGuardWriter<'a>(MutexGuard<'a, std::fs::File>); + +impl io::Write for MyMutexGuardWriter<'_> { + #[inline] + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + self.0.write(buf) + } + + #[inline] + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } + + #[inline] + fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> { + self.0.write_vectored(bufs) + } + + #[inline] + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.0.write_all(buf) + } + + #[inline] + fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> io::Result<()> { + self.0.write_fmt(fmt) + } +} + +/// mpd + POPM +/// +/// `mppopmd' is a companion daemon for `mpd' that maintains playcounts & ratings, +/// as well as implementing some handy functions. It maintains ratings & playcounts in the sticker +/// database, but it allows you to keep that information in your tags, as well, by invoking external +/// commands to keep your tags up-to-date. +#[derive(Parser)] +struct Args { + /// path to configuration file + #[arg(short, long)] + config: Option<PathBuf>, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, +} + +/// Entry point for `mpdopmd'. +/// +/// Do *not* use the #[tokio::main] attribute here! If this program is asked to daemonize (the usual +/// case), we will fork after tokio has started its thread pool, with disastrous consequences. +/// Instead, stay synchronous until we've daemonized (or figured out that we don't need to), and +/// only then fire-up the tokio runtime. +fn main() -> Result<()> { + use mpdpopm::vars::VERSION; + + let args = Args::parse(); + + let config = if let Some(cfgpath) = &args.config { + match std::fs::read_to_string(cfgpath) { + Ok(text) => config::from_str(&text).with_context(|| { + format!("Failed to parse config file at: `{}`", cfgpath.display()) + })?, + // The config file (defaulted or not) either didn't exist, or we were unable to read its + // contents... + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + bail!( + "No config file could be read at: `{}`, because: {err}", + cfgpath.display() + ) + } + } + } else { + Config::default() + }; + + // `--verbose' & `--debug' work as follows: if `--debug' is present, log at level Trace, no + // matter what. Else, if `--verbose' is present, log at level Debug. Else, log at level Info. + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::INFO, + }; + + let filter = EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .context("Failed to construct env filter")?; + + let formatter: Box<dyn Layer<Registry> + Send + Sync> = { + Box::new( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(io::stdout), + ) + }; + + tracing::subscriber::set_global_default(Registry::default().with(formatter).with(filter)) + .unwrap(); + + info!("mppopmd {VERSION} logging at level {lf:#?}."); + let rt = tokio::runtime::Runtime::new().unwrap(); + + rt.block_on(mpdpopm(config)).context("Main mpdpopm failed") +} diff --git a/pkgs/by-name/mp/mpdpopm/src/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs new file mode 100644 index 00000000..b88e4041 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs @@ -0,0 +1,1202 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! mpd clients and associated utilities. +//! +//! # Introduction +//! +//! This module contains basic types implementing various MPD client operations (cf. the [mpd +//! protocol](http://www.musicpd.org/doc/protocol/)). Since issuing the "idle" command will tie up +//! the connection, MPD clients often use multiple connections to the server (one to listen for +//! updates, one or more on which to issue commands). This modules provides two different client +//! types: [Client] for general-purpose use and [IdleClient] for long-lived connections listening +//! for server notifiations. +//! +//! Note that there *is* another idiom (used in [libmpdel](https://github.com/mpdel/libmpdel), +//! e.g.): open a single connection & issue an "idle" command. When you want to issue a command, +//! send a "noidle", then the command, then "idle" again. This isn't a race condition, as the +//! server will buffer any changes that took place when you were not idle & send them when you +//! re-issue the "idle" command. This crate however takes the approach of two channels (like +//! [mpdfav](https://github.com/vincent-petithory/mpdfav)). + +use anyhow::{Context, Error, Result, anyhow, bail, ensure}; +use async_trait::async_trait; +use regex::Regex; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpStream, ToSocketAddrs, UnixStream}; +use tracing::{debug, info}; + +use lazy_static::lazy_static; + +use std::{ + collections::HashMap, + convert::TryFrom, + fmt, + marker::{Send, Unpin}, + path::{Path, PathBuf}, + str::FromStr, +}; + +// Some default error context messages +const ENCODING_SNAFU: &str = "Failed to interpete text as utf8"; +const IO_SNAFU: &str = "Failed read from mpd socket"; + +/// A description of the current track, suitable for our purposes (as in, it only tracks the +/// attributes needed for this module's functionality). +#[derive(Clone, Debug)] +pub struct CurrentSong { + /// Identifier, unique within the play queue, identifying this particular track; if the same + /// file is listed twice in the `mpd' play queue each instance will get a distinct songid + pub songid: u64, + + /// Path, relative to `mpd' music directory root of this track + pub file: std::path::PathBuf, + + /// Elapsed time, in seconds, in this track + pub elapsed: f64, + + /// Total track duration, in seconds + pub duration: f64, +} + +impl CurrentSong { + fn new(songid: u64, file: std::path::PathBuf, elapsed: f64, duration: f64) -> CurrentSong { + CurrentSong { + songid, + file, + elapsed, + duration, + } + } + /// Compute the ratio of the track that has elapsed, expressed as a floating point between 0 & 1 + pub fn played_pct(&self) -> f64 { + self.elapsed / self.duration + } +} + +/// The MPD player itself can be in one of three states: playing, paused or stopped. In the first +/// two there is a "current" song. +#[derive(Clone, Debug)] +pub enum PlayerStatus { + Play(CurrentSong), + Pause(CurrentSong), + Stopped, +} + +impl PlayerStatus { + pub fn current_song(&self) -> Option<&CurrentSong> { + match self { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => Some(curr), + PlayerStatus::Stopped => None, + } + } +} + +/// A trait representing a simple, textual request/response protocol like that +/// [employed](https://www.musicpd.org/doc/html/protocol.html) by [MPD](https://www.musicpd.org/): +/// the caller sends a textual command & the server responds with a (perhaps multi-line) textual +/// response. +/// +/// This trait also enables unit testing client implementations. Note that it is async-- cf. +/// [async_trait](https://docs.rs/async-trait/latest/async_trait/). +#[async_trait] +pub trait RequestResponse { + async fn req(&mut self, msg: &str) -> Result<String>; + /// The hint is used to size the buffer prior to reading the response + async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String>; +} + +#[cfg(test)] +pub mod test_mock { + use super::*; + + /// Mock is an implementation of [`RequestRespone`] that checks expected requests & responses, + /// and will panic if it sees anything unexpected + pub struct Mock { + inmsgs: Vec<String>, + outmsgs: Vec<String>, + } + + impl Mock { + pub fn new(convo: &[(&str, &str)]) -> Mock { + let (left, right): (Vec<&str>, Vec<&str>) = convo.iter().copied().rev().unzip(); + Mock { + inmsgs: left.iter().map(|x| x.to_string()).collect(), + outmsgs: right.iter().map(|x| x.to_string()).collect(), + } + } + } + + #[async_trait] + impl RequestResponse for Mock { + async fn req(&mut self, msg: &str) -> Result<String> { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, _hint: usize) -> Result<String> { + assert_eq!(msg, self.inmsgs.pop().unwrap()); + Ok(self.outmsgs.pop().unwrap()) + } + } + + #[tokio::test] + async fn mock_smoke_test() { + let mut mock = Mock::new(&[("ping", "pong"), ("from", "to")]); + assert_eq!(mock.req("ping").await.unwrap(), "pong"); + assert_eq!(mock.req("from").await.unwrap(), "to"); + } + + #[tokio::test] + #[should_panic] + async fn mock_negative_test() { + let mut mock = Mock::new(&[("ping", "pong")]); + assert_eq!(mock.req("ping").await.unwrap(), "pong"); + let _should_panic = mock.req("not there!").await.unwrap(); + } +} + +/// [MPD](https://www.musicpd.org/) connections talk the same +/// [protocol](https://www.musicpd.org/doc/html/protocol.html) over either a TCP or a Unix socket. +/// +/// # Examples +/// +/// Implementations are provided for tokio [UnixStream] and [TcpStream], but [MpdConnection] is a +/// trait that can work in terms of any asynchronous communications channel (so long as it is also +/// [Send] and [Unpin] so async executors can pass them between threads. +/// +/// To create a connection to an `MPD` server over a Unix domain socket: +/// +/// ```no_run +/// use std::path::Path; +/// use tokio::net::UnixStream; +/// use mpdpopm::clients::MpdConnection; +/// let local_conn = MpdConnection::<UnixStream>::connect(Path::new("/var/run/mpd/mpd.sock")); +/// ``` +/// +/// In this example, `local_conn` is a Future that will resolve to a Result containing the +/// [MpdConnection] Unix domain socket implementation once the socket has been established, the MPD +/// server greets us & the protocol version has been parsed. +/// +/// or over a TCP socket: +/// +/// ```no_run +/// use std::net::SocketAddrV4; +/// use tokio::net::{TcpStream, ToSocketAddrs}; +/// use mpdpopm::clients::MpdConnection; +/// let tcp_conn = MpdConnection::<TcpStream>::connect("localhost:6600".parse::<SocketAddrV4>().unwrap()); +/// ``` +/// +/// Here, `tcp_conn` is a Future that will resolve to a Result containing the [MpdConnection] TCP +/// implementation on successful connection to the MPD server (i.e. the connection is established, +/// the server greets us & we parse the protocol version). +/// +/// +pub struct MpdConnection<T: AsyncRead + AsyncWrite + Send + Unpin> { + sock: T, + _protocol_ver: String, +} + +/// MpdConnection implements RequestResponse using the usual (async) socket I/O +/// +/// The callers need not include the trailing newline in their requests; the implementation will +/// append it. +#[async_trait] +impl<T> RequestResponse for MpdConnection<T> +where + T: AsyncRead + AsyncWrite + Send + Unpin, +{ + async fn req(&mut self, msg: &str) -> Result<String> { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String> { + self.sock + .write_all(format!("{}\n", msg).as_bytes()) + .await + .context(IO_SNAFU)?; + let mut buf = Vec::with_capacity(hint); + + // Given the request/response nature of the MPD protocol, our callers expect a complete + // response. Therefore we need to loop here until we see either "...^OK\n" or + // "...^ACK...\n". + let mut cb = 0; // # bytes read so far + let mut more = true; // true as long as there is more to read + while more { + cb += self.sock.read_buf(&mut buf).await.context(IO_SNAFU)?; + + // The shortest complete response has three bytes. If the final byte in `buf' is not a + // newline, then don't bother looking further. + if cb > 2 && char::from(buf[cb - 1]) == '\n' { + // If we're here, `buf' *may* contain a complete response. Search backward for the + // previous newline. It may not exist: many responses are of the form "OK\n". + let mut idx = cb - 2; + while idx > 0 { + if char::from(buf[idx]) == '\n' { + idx += 1; + break; + } + idx -= 1; + } + + if (idx + 2 < cb && char::from(buf[idx]) == 'O' && char::from(buf[idx + 1]) == 'K') + || (idx + 3 < cb + && char::from(buf[idx]) == 'A' + && char::from(buf[idx + 1]) == 'C' + && char::from(buf[idx + 2]) == 'K') + { + more = false; + } + } + } + + // Only doing this to trouble-shoot issue 11 + String::from_utf8(buf.clone()).context(ENCODING_SNAFU) + } +} + +/// Utility function to parse the initial response to a connection from mpd +async fn parse_connect_rsp<T>(sock: &mut T) -> Result<String> +where + T: AsyncReadExt + AsyncWriteExt + Send + Unpin, +{ + let mut buf = Vec::with_capacity(32); + let _cb = sock.read_buf(&mut buf).await.context(IO_SNAFU)?; + + // Only doing this to trouble-shoot issue 11 + let text = String::from_utf8(buf.clone()).context(ENCODING_SNAFU)?; + + ensure!( + text.starts_with("OK MPD "), + "failed to connect: {}", + text.trim() + ); + info!("Connected {}.", text[7..].trim()); + Ok(text[7..].trim().to_string()) +} + +impl MpdConnection<TcpStream> { + pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<Box<dyn RequestResponse>> { + let mut sock = TcpStream::connect(addr).await.context(IO_SNAFU)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection::<TcpStream> { + sock, + _protocol_ver: proto_ver, + })) + } +} + +impl MpdConnection<UnixStream> { + // NTS: we have to box the return value because a `dyn RequestResponse` isn't Sized. + pub async fn connect<P: AsRef<Path>>(pth: P) -> Result<Box<dyn RequestResponse>> { + let mut sock = UnixStream::connect(pth).await.context(IO_SNAFU)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection::<UnixStream> { + sock, + _protocol_ver: proto_ver, + })) + } +} + +/// Quote an argument by backslash-escaping " & \ characters +pub fn quote(text: &str) -> String { + if text.contains(&[' ', '\t', '\'', '"'][..]) { + let mut s = String::from("\""); + for c in text.chars() { + if c == '"' || c == '\\' { + s.push('\\'); + } + s.push(c); + } + s.push('"'); + s + } else { + text.to_string() + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Client // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// General-purpose [mpd](https://www.musicpd.org) +/// [client](https://www.musicpd.org/doc/html/protocol.html): "general-purpose" in the sense that we +/// send commands through it; the interface is narrowly scoped to this program's needs. +/// +/// # Introduction +/// +/// This is the primary abstraction of the MPD client protocol, written for the convenience of +/// [mpdpopm](crate). Construct instances with a TCP socket, a Unix socket, or any [RequestResponse] +/// implementation. You can then carry out assorted operations in the MPD client protocol by +/// invoking its methods. +/// +/// ```no_run +/// use std::path::Path; +/// use mpdpopm::clients::Client; +/// let client = Client::open(Path::new("/var/run/mpd.sock")); +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to a [Client] instance talking to `/var/run/mpd.sock`. +/// +/// ```no_run +/// use mpdpopm::clients::Client; +/// let client = Client::connect("localhost:6600"); +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to a [Client] instance talking TCP to the MPD server on localhost at port 6600. +pub struct Client { + stream: Box<dyn RequestResponse>, +} + +// Thanks to <https://stackoverflow.com/questions/35169259/how-to-make-a-compiled-regexp-a-global-variable> +lazy_static! { + static ref RE_STATE: regex::Regex = Regex::new(r"(?m)^state: (play|pause|stop)$").unwrap(); + static ref RE_SONGID: regex::Regex = Regex::new(r"(?m)^songid: ([0-9]+)$").unwrap(); + static ref RE_ELAPSED: regex::Regex = Regex::new(r"(?m)^elapsed: ([.0-9]+)$").unwrap(); + static ref RE_FILE: regex::Regex = Regex::new(r"(?m)^file: (.*)$").unwrap(); + static ref RE_DURATION: regex::Regex = Regex::new(r"(?m)^duration: (.*)$").unwrap(); +} + +impl Client { + pub async fn connect<A: ToSocketAddrs>(addrs: A) -> Result<Client> { + Self::new(MpdConnection::<TcpStream>::connect(addrs).await?) + } + + pub async fn open<P: AsRef<Path>>(pth: P) -> Result<Client> { + Self::new(MpdConnection::<UnixStream>::connect(pth).await?) + } + + pub fn new(stream: Box<dyn RequestResponse>) -> Result<Client> { + Ok(Client { stream }) + } +} + +impl Client { + /// Retrieve the current server status. + pub async fn status(&mut self) -> Result<PlayerStatus> { + // We begin with sending the "status" command: "Reports the current status of the player and + // the volume level." Per the docs, "MPD may omit lines which have no (known) value", so I + // can't really count on particular lines being there. Tho nothing is said in the docs, I + // also don't want to depend on the order. + let text = self.stream.req("status").await?; + + let proto = || -> Error { anyhow!("Failed to parse mpd status output (with regexes)") }; + + // I first thought to avoid the use (and cost) of regular expressions by just doing + // sub-string searching on "state: ", but when I realized I needed to only match at the + // beginning of a line I bailed & just went ahead. This makes for more succinct code, since + // I can't count on order, either. + let state = RE_STATE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + + match state { + "stop" => Ok(PlayerStatus::Stopped), + "play" | "pause" => { + let songid = RE_SONGID + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<u64>() + .context("Failed to parse songid as u64")?; + + let elapsed = RE_ELAPSED + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<f64>() + .context("failed to parse `elapsed` as f64")?; + + // navigate from `songid'-- don't send a "currentsong" message-- the current song + // could have changed + let text = self.stream.req(&format!("playlistid {}", songid)).await?; + + let file = RE_FILE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + let duration = RE_DURATION + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::<f64>() + .context("Failed to parse `duration` as f64")?; + + let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration); + + if state == "play" { + Ok(PlayerStatus::Play(curr)) + } else { + Ok(PlayerStatus::Pause(curr)) + } + } + _ => bail!("Encountered unknow state `{}`", state), + } + } + + /// Retrieve a song sticker by name + pub async fn get_sticker<T: FromStr>( + &mut self, + file: &str, + sticker_name: &str, + ) -> Result<Option<T>> + where + <T as FromStr>::Err: std::error::Error + Sync + Send + 'static, + { + let msg = format!("sticker get song {} {}", quote(file), quote(sticker_name)); + let text = self.stream.req(&msg).await?; + debug!("Sent message `{}'; got `{}'", &msg, &text); + + let prefix = format!("sticker: {}=", sticker_name); + if text.starts_with(&prefix) { + let s = text[prefix.len()..] + .split('\n') + .next() + .with_context(|| format!("Failed to parse `{}` as get_sticker response", text))?; + Ok(Some(T::from_str(s).with_context(|| { + format!( + "Failed to parse sticker value as correct type: `{}`", + sticker_name + ) + })?)) + } else { + // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17) + ensure!( + text.starts_with("ACK [50@0]"), + "Missing no sticker response" + ); + Ok(None) + } + } + + /// Set a song sticker by name + pub async fn set_sticker<T: std::fmt::Display>( + &mut self, + file: &str, + sticker_name: &str, + sticker_value: &T, + ) -> Result<()> { + let value_as_str = format!("{}", sticker_value); + let msg = format!( + "sticker set song {} {} {}", + quote(file), + quote(sticker_name), + quote(&value_as_str) + ); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'", &msg, &text); + + ensure!(text.starts_with("OK"), "Set sticker, not acknowledged"); + Ok(()) + } + + /// Send a file to a playlist + pub async fn send_to_playlist(&mut self, file: &str, pl: &str) -> Result<()> { + let msg = format!("playlistadd {} {}", quote(pl), quote(file)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + ensure!(text.starts_with("OK"), "send_to_playlist not acknowledged"); + Ok(()) + } + + /// Send an arbitrary message + pub async fn send_message(&mut self, chan: &str, msg: &str) -> Result<()> { + let msg = format!("sendmessage {} {}", chan, quote(msg)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!(text.starts_with("OK"), "Send_message not acknowledged"); + Ok(()) + } + + /// Update a URI + pub async fn update(&mut self, uri: &str) -> Result<u64> { + let msg = format!("update \"{}\"", uri); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + // We expect a response of the form: + // updating_db: JOBID + // OK + // on success, and + // ACK ERR + // on failure. + + let prefix = "updating_db: "; + ensure!( + text.starts_with(prefix), + "update response doesn't start with correct prefix" + ); + text[prefix.len()..].split('\n').collect::<Vec<&str>>()[0] + .to_string() + .parse::<u64>() + .context("Failed to treat update job id as u64") + } + + /// Get the list of stored playlists + pub async fn get_stored_playlists(&mut self) -> Result<std::vec::Vec<String>> { + let text = self.stream.req("listplaylists").await?; + debug!("Sent listplaylists; got `{}'.", &text); + + // We expect a response of the form: + // playlist: a + // Last-Modified: 2020-03-13T17:20:16Z + // playlsit: b + // Last-Modified: 2020-03-13T17:20:16Z + // ... + // OK + // + // or + // + // ACK... + ensure!( + !text.starts_with("ACK"), + "get_stored_playlists response not acknowledged" + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("playlist: ").map(String::from)) + .collect::<Vec<String>>()) + } + + /// Process a search (either find or search) response + fn search_rsp_to_uris(&self, text: &str) -> Result<std::vec::Vec<String>> { + // We expect a response of the form: + // file: P/Pogues, The - A Pistol For Paddy Garcia.mp3 + // Last-Modified: 2007-12-26T19:18:00Z + // Format: 44100:24:2 + // ... + // file: P/Pogues, The - Billy's Bones.mp3 + // ... + // OK + // + // or + // + // ACK... + ensure!(!text.starts_with("ACK"), "rsp_to_uris not acknowledged"); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::<Vec<String>>()) + } + + /// Search the database for songs matching filter (unary operator) + /// + /// Set `case` to true to request a case-sensitive search (false yields case-insensitive) + pub async fn find1( + &mut self, + cond: &str, + val: &str, + case: bool, + ) -> Result<std::vec::Vec<String>> { + let cmd = format!( + "{} {}", + if case { "find" } else { "search" }, + quote(&format!("({} {})", cond, val)) + ); + let text = self.stream.req(&cmd).await?; + self.search_rsp_to_uris(&text) + } + + /// Search the database for songs matching filter (case-sensitive, binary operator) + /// + /// Set `case` to true to request a case-sensitive search (false yields case-insensitive) + pub async fn find2( + &mut self, + attr: &str, + op: &str, + val: &str, + case: bool, + ) -> Result<std::vec::Vec<String>> { + let cmd = format!( + "{} {}", + if case { "find" } else { "search" }, + quote(&format!("({} {} {})", attr, op, val)) + ); + debug!("find2 sending ``{}''", cmd); + let text = self.stream.req(&cmd).await?; + self.search_rsp_to_uris(&text) + } + + /// Retrieve all instances of a given sticker under the music directory + /// + /// Return a mapping from song URI to textual sticker value + pub async fn get_stickers(&mut self, sticker: &str) -> Result<HashMap<String, String>> { + let text = self + .stream + .req(&format!("sticker find song \"\" {}", sticker)) + .await?; + + // We expect a response of the form: + // + // file: U-Z/Zafari - Addis Adaba.mp3 + // sticker: unwoundstack.com:rating=64 + // ... + // file: U-Z/Zero 7 - In Time (Album Version).mp3 + // sticker: unwoundstack.com:rating=255 + // OK + // + // or + // + // ACK ... + ensure!(!text.starts_with("ACK"), "get_stickers not ACKed"); + let mut m = HashMap::new(); + let mut lines = text.lines(); + loop { + let file = lines.next().context("get_stickers no new line")?; + if "OK" == file { + break; + } + let val = lines.next().context("get_stickers no val")?; + + m.insert( + String::from(&file[6..]), + String::from(&val[10 + sticker.len()..]), + ); + } + Ok(m) + } + + /// Retrieve the song URIs of all songs in the database + /// + /// Returns a vector of String + pub async fn get_all_songs(&mut self) -> Result<std::vec::Vec<String>> { + let text = self.stream.req("find \"(base '')\"").await?; + // We expect a response of the form: + // file: 0-A/A Positive Life - Lighten Up!.mp3 + // Last-Modified: 2020-11-18T22:47:07Z + // Format: 44100:24:2 + // Time: 399 + // duration: 398.550 + // Artist: A Positive Life + // Title: Lighten Up! + // Genre: Electronic + // file: 0-A/A Positive Life - Pleidean Communication.mp3 + // ... + // OK + // + // or "ACK..." + ensure!(!text.starts_with("ACK"), "get_all_songs not ACKed"); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::<Vec<String>>()) + } + + pub async fn add(&mut self, uri: &str) -> Result<()> { + let msg = format!("add {}", quote(uri)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!(text.starts_with("OK"), "add not Oked"); + Ok(()) + } +} + +#[cfg(test)] +/// 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 { + bail!("{}", text) + } + } +} + +impl fmt::Display for IdleSubSystem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + IdleSubSystem::Player => write!(f, "Player"), + IdleSubSystem::Message => write!(f, "Message"), + } + } +} + +/// [MPD](https://www.musicpd.org) client for "idle" connections. +/// +/// # Introduction +/// +/// This is an MPD client designed to "idle": it opens a long-lived connection to the MPD server and +/// waits for MPD to respond with a message indicating that there's been a change to a subsystem of +/// interest. At present, there are only two subsystems in which [mpdpopm](crate) is interested: the player +/// & messages (cf. [IdleSubSystem]). +/// +/// ```no_run +/// use std::path::Path; +/// use tokio::runtime::Runtime; +/// use mpdpopm::clients::IdleClient; +/// +/// let mut rt = Runtime::new().unwrap(); +/// rt.block_on( async { +/// let mut client = IdleClient::open(Path::new("/var/run/mpd.sock")).await.unwrap(); +/// client.subscribe("player").await.unwrap(); +/// client.idle().await.unwrap(); +/// // Arrives here when the player's state changes +/// }) +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to an [IdleClient] instance talking to `/var/run/mpd.sock`. +/// +pub struct IdleClient { + conn: Box<dyn RequestResponse>, +} + +impl IdleClient { + /// Create a new [mpdpopm::client::IdleClient][IdleClient] instance from something that + /// implements [ToSocketAddrs] + pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<IdleClient> { + Self::new(MpdConnection::<TcpStream>::connect(addr).await?) + } + + pub async fn open<P: AsRef<Path>>(pth: P) -> Result<IdleClient> { + Self::new(MpdConnection::<UnixStream>::connect(pth).await?) + } + + pub fn new(stream: Box<dyn RequestResponse>) -> Result<IdleClient> { + Ok(IdleClient { conn: stream }) + } + + /// Subscribe to an mpd channel + pub async fn subscribe(&mut self, chan: &str) -> Result<()> { + let text = self.conn.req(&format!("subscribe {}", chan)).await?; + debug!("Sent subscribe message for {}; got `{}'.", chan, text); + ensure!(text.starts_with("OK"), "subscribe not Ok: `{}`", text); + debug!("Subscribed to {}.", chan); + Ok(()) + } + + /// Enter idle state-- return the subsystem that changed, causing the connection to return. NB + /// this may block for some time. + pub async fn idle(&mut self) -> Result<IdleSubSystem> { + let text = self.conn.req("idle player message").await?; + debug!("Sent idle message; got `{}'.", text); + + // If the player state changes, we'll get: "changed: player\nOK\n" + // + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we respond + // "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + + ensure!(text.starts_with("changed: "), "idle not OK: `{}`", text); + let idx = text.find('\n').context("idle has no newline")?; + + let result = IdleSubSystem::try_from(&text[9..idx])?; + let text = text[idx + 1..].to_string(); + ensure!(text.starts_with("OK"), "idle not OKed"); + + Ok(result) + } + + /// This method simply returns the results of a "readmessages" as a HashMap of channel name to + /// Vec of (String) messages for that channel + pub async fn get_messages(&mut self) -> Result<HashMap<String, Vec<String>>> { + let text = self.conn.req("readmessages").await?; + debug!("Sent readmessages; got `{}'.", text); + + // We expect something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + + let mut m: HashMap<String, Vec<String>> = HashMap::new(); + + // Populate `m' with a little state machine: + enum State { + Init, + Running, + Finished, + } + let mut state = State::Init; + let mut chan = String::new(); + let mut msgs: Vec<String> = Vec::new(); + for line in text.lines() { + match state { + State::Init => { + ensure!(line.starts_with("channel: "), "no `channel: ` given"); + chan = String::from(&line[9..]); + state = State::Running; + } + State::Running => { + if let Some(stripped) = line.strip_prefix("message: ") { + msgs.push(String::from(stripped)); + } else if let Some(stripped) = line.strip_prefix("channel: ") { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + chan = String::from(stripped); + msgs = Vec::new(); + } else if line == "OK" { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + state = State::Finished; + } else { + bail!("Failed to get messages: `{}`", text) + } + } + State::Finished => { + // Should never be here! + bail!("Failed to get messages: `{}`", text) + } + } + } + + Ok(m) + } +} + +#[cfg(test)] +/// Let's test IdleClient! +mod idle_client_tests { + + use super::test_mock::Mock; + use super::*; + + /// Some basic "smoke" tests + #[tokio::test] + async fn test_get_messages() { + let mock = Box::new(Mock::new(&[( + "readmessages", + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we + // respond "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + "channel: ratings +message: 255 +message: 128 +channel: send-to-playlist +message: foo.m3u +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("ratings").unwrap(); + assert_eq!(val.len(), 2); + let val = hm.get("send-to-playlist").unwrap(); + assert!(val.len() == 1); + } + + /// Test issue #1 + #[tokio::test] + async fn test_issue_1() { + let mock = Box::new(Mock::new(&[( + "readmessages", + "channel: playcounts +message: a +channel: playcounts +message: b +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("playcounts").unwrap(); + assert_eq!(val.len(), 2); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs new file mode 100644 index 00000000..2d9c466b --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/config.rs @@ -0,0 +1,277 @@ +// Copyright (C) 2021-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mpdpopm Configuration +//! +//! ## Introduction +//! +//! This module defines the configuration struct & handles deserialization thereof. +//! +//! ## Discussion +//! +//! In the first releases of [mpdpopm](crate) I foolishly forgot to add a version field to the +//! configuration structure. I am now paying for my sin by having to attempt serializing two +//! versions until one succeeds. +//! +//! The idiomatic approach to versioning [serde](https://docs.serde.rs/serde/) structs seems to be +//! using an +//! [enumeration](https://www.reddit.com/r/rust/comments/44dds3/handling_multiple_file_versions_with_serde_or/). This +//! implementation *now* uses that, but that leaves us with the problem of handling the initial, +//! un-tagged version. I proceed as follows: +//! +//! 1. attempt to deserialize as a member of the modern enumeration +//! 2. if that succeeds, with the most-recent version, we're good +//! 3. if that succeeds with an archaic version, convert to the most recent and warn the user +//! 4. if that fails, attempt to deserialize as the initial struct version +//! 5. if that succeeds, convert to the most recent & warn the user +//! 6. if that fails, I'm kind of stuck because I don't know what the user was trying to express; +//! bundle-up all the errors, report 'em & urge the user to use the most recent version +use crate::vars::{LOCALSTATEDIR, PREFIX}; + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +use std::{env, path::PathBuf}; + +/// [mpdpopm](crate) can communicate with MPD over either a local Unix socket, or over regular TCP +#[derive(Debug, Deserialize, PartialEq, Serialize)] +pub enum Connection { + /// Local Unix socket-- payload is the path to the socket + Local { path: PathBuf }, + /// TCP-- payload is the hostname & port number + TCP { host: String, port: u16 }, +} + +impl Connection { + pub fn new() -> Result<Self> { + let env = env::var("MPD_HOST")?; + + if env.starts_with("/") { + // We assume that this is a path to a local socket + Ok(Self::Local { + path: PathBuf::from(env), + }) + } else { + todo!("Not yet able to auto-parse, MPD_HOST for remote connection") + } + } +} + +impl Default for Connection { + fn default() -> Self { + Self::new().expect("Could not generate default connection") + } +} + +#[cfg(test)] +mod test_connection { + use super::Connection; + + #[test] + fn test_serde() { + use serde_json::to_string; + + use std::path::PathBuf; + + let text = to_string(&Connection::Local { + path: PathBuf::from("/var/run/mpd.sock"), + }) + .unwrap(); + + assert_eq!( + text, + String::from(r#"{"Local":{"path":"/var/run/mpd.sock"}}"#) + ); + + let text = to_string(&Connection::TCP { + host: String::from("localhost"), + port: 6600, + }) + .unwrap(); + assert_eq!( + text, + String::from(r#"{"TCP":{"host":"localhost","port":6600}}"#) + ); + } +} + +/// 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() -> Self { + Self::new().unwrap() + } +} + +impl Config { + fn new() -> Result<Self> { + Ok(Self { + _version: String::from("1"), + log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(), + conn: Connection::new()?, + local_music_dir: [PREFIX, "Music"].iter().collect(), + played_thresh: 0.6, + poll_interval_ms: 5000, + commands_chan: String::from("unwoundstack.com:commands"), + }) + } +} + +pub fn from_str(text: &str) -> Result<Config> { + let cfg: Config = match serde_json::from_str(text) { + Ok(cfg) => cfg, + Err(err_outer) => { + bail!("Failed to parse config: `{}`", err_outer) + } + }; + Ok(cfg) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[ignore = "We changed the config format to json"] + fn test_from_str() { + let cfg = Config::default(); + assert_eq!(cfg.commands_chan, String::from("unwoundstack.com:commands")); + + assert_eq!( + serde_json::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_json::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_json::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..bd1a67d6 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs @@ -0,0 +1,1005 @@ +// 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 anyhow::{Context, Error, Result, anyhow, bail}; +use boolinator::Boolinator; +use chrono::prelude::*; +use tracing::debug; + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// The operations that can appear in a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum OpCode { + Equality, + Inequality, + Contains, + RegexMatch, + RegexExclude, + GreaterThan, + LessThan, + GreaterThanEqual, + LessThanEqual, +} + +impl std::fmt::Display for OpCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OpCode::Equality => "==", + OpCode::Inequality => "!=", + OpCode::Contains => "contains", + OpCode::RegexMatch => "=~", + OpCode::RegexExclude => "!~", + OpCode::GreaterThan => ">", + OpCode::LessThan => "<", + OpCode::GreaterThanEqual => ">=", + OpCode::LessThanEqual => "<=", + } + ) + } +} + +/// The song attributes that can appear on the LHS of a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Selector { + Artist, + Album, + AlbumArtist, + Title, + Track, + Name, + Genre, + Date, + OriginalDate, + Composer, + Performer, + Conductor, + Work, + Grouping, + Comment, + Disc, + Label, + MusicbrainzAristID, + MusicbrainzAlbumID, + MusicbrainzAlbumArtistID, + MusicbrainzTrackID, + MusicbrainzReleaseTrackID, + MusicbrainzWorkID, + File, + Base, + ModifiedSince, + AudioFormat, + Rating, + PlayCount, + LastPlayed, +} + +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()); + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum EvalOp { + And, + Or, + Not, +} + +impl std::fmt::Display for EvalOp { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + EvalOp::And => write!(f, "And"), + EvalOp::Or => write!(f, "Or"), + EvalOp::Not => write!(f, "Not"), + } + } +} + +fn peek(buf: &[u8]) -> Option<char> { + match buf.len() { + 0 => None, + _ => Some(buf[0] as char), + } +} + +// advancing a slice by `i` indicies can *not* be this difficult +/// Pop a single byte off of `buf` +fn take1(buf: &mut &[u8], i: usize) -> Result<()> { + if i > buf.len() { + bail!("Bad iso-8601 string: `{:#?}`", buf); + } + let (_first, second) = buf.split_at(i); + *buf = second; + Ok(()) +} + +/// Pop `i` bytes off of `buf` & parse them as a T +fn take2<T>(buf: &mut &[u8], i: usize) -> Result<T> +where + T: FromStr, + <T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static, +{ + // 1. check len + if i > buf.len() { + bail!("Bad iso-8601 string: `{:#?}`", buf); + } + + let (first, second) = buf.split_at(i); + *buf = second; + // 2. convert to a string + let s = std::str::from_utf8(first).context("Bad iso-8601 string")?; + // 3. parse as a T + s.parse::<T>() + .context("Failed to parse iso-8601 string as T") +} + +/// Parse a timestamp in ISO 8601 format to a chrono DateTime instance +/// +/// Surprisingly, I was unable to find an ISO 8601 parser in Rust. I *did* find a crate named +/// iso-8601 that promised to do this, but it seemed derelict & I couldn't see what to do with the +/// parse output in any event. The ISO 8601 format is simple enough that I've chosen to simply +/// hand-parse it. +pub fn parse_iso_8601(buf: &mut &[u8]) -> Result<i64> { + // I wonder if `nom` would be a better choice? + + // The first four characters must be the year (expanded year representation is not supported by + // this parser). + + let year: i32 = take2(buf, 4)?; + + // Now at this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // - day & month := 0, consume the 'T', move on to parsing the time + // 3. we may have a month in extended format (i.e. peek(buf) => Some('-') + // - consume the '-', parse the month & move on to parsing the day + // 4. we may have a month in basic format (take(buf, 2) => Some('\d\d') + // - parse the month & move on to parsing the day + let mut month = 1; + let mut day = 1; + let mut hour = 0; + let mut minute = 0; + let mut second = 0; + if !buf.is_empty() { + let next = peek(buf); + if next != Some('T') { + let mut ext_fmt = false; + if next == Some('-') { + take1(buf, 1)?; + ext_fmt = true; + } + month = take2(buf, 2)?; + + // At this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // 3. we may have the day (in basic or extended format) + if !buf.is_empty() && peek(buf) != Some('T') { + if ext_fmt { + take1(buf, 1)?; + } + day = take2(buf, 2)?; + } + } + + // Parse time: at this point, buf will either be empty or begin with 'T' + if !buf.is_empty() { + take1(buf, 1)?; + // If there's a T, there must at least be an hour + hour = take2(buf, 2)?; + if !buf.is_empty() { + let mut ext_fmt = false; + if peek(buf) == Some(':') { + take1(buf, 1)?; + ext_fmt = true; + } + minute = take2(buf, 2)?; + if !buf.is_empty() { + if ext_fmt { + take1(buf, 1)?; + } + second = take2(buf, 2)?; + } + } + } + + // At this point, there may be a timezone + if !buf.is_empty() { + if peek(buf) == Some('Z') { + return Ok(Utc + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(anyhow!("bad iso-8601 string"))? + .timestamp()); + } else { + let next = peek(buf); + if next != Some('-') && next != Some('+') { + bail!("bad iso-8601 string") + } + let west = next == Some('-'); + take1(buf, 1)?; + + let hours: i32 = take2(buf, 2)?; + let mut minutes = 0; + + if !buf.is_empty() { + if peek(buf) == Some(':') { + take1(buf, 1)?; + } + minutes = take2(buf, 2)?; + } + + if west { + return Ok(FixedOffset::west_opt(hours * 3600 + minutes * 60) + .ok_or(anyhow!("Bad iso-8601 string"))? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(anyhow!("Bad iso-8601 string"))? + .timestamp()); + } else { + return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60) + .ok_or(anyhow!("Bad iso-8601 string"))? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(anyhow!("Bad iso-8601 string"))? + .timestamp()); + } + } + } + } + Ok(Local + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(anyhow!("Bad iso-8601 string"))? + .timestamp()) +} + +#[cfg(test)] +mod iso_8601_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let mut b = "19700101T00:00:00Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 0); + + let mut b = "19700101T00:00:01Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 1); + + let mut b = "20210327T02:26:53Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616812013); + + let mut b = "20210327T07:29:05-07:00".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616855345); + + let mut b = "2021".as_bytes(); + // Should resolve to midnight, Jan 1 2021 in local time; don't want to test against the + // timestamp; just make sure it parses + parse_iso_8601(&mut b).unwrap(); + } +} + +/// "Un-quote" a token +/// +/// Textual tokens must be quoted, and double-quote & backslashes within backslash-escaped. If the +/// string is quoted with single-quotes, then any single-quotes inside the string will also need +/// to be escaped. +/// +/// In fact, *any* characters within may be harmlessly backslash escaped; the MPD implementation +/// walks the the string, skipping backslashes as it goes, so this implementation will do the same. +/// I have named this method in imitation of the corresponding MPD function. +pub fn expect_quoted(qtext: &str) -> Result<String> { + let mut iter = qtext.chars(); + let quote = iter.next(); + if quote.is_none() { + return Ok(String::new()); + } + + if quote != Some('\'') && quote != Some('"') { + bail!("Expected text to be quoted: `{}`", qtext); + } + + let mut ret = String::new(); + + // Walk qtext[1..]; copying characters to `ret'. If a '\' is found, skip to the next character + // (even if that is a '\'). The last character in qtext should be the closing quote. + let mut this = iter.next(); + while this != quote { + if this == Some('\\') { + this = iter.next(); + } + match this { + Some(c) => ret.push(c), + None => { + bail!("Expected text to be quoted: `{}`", qtext); + } + } + this = iter.next(); + } + + Ok(ret) +} + +#[cfg(test)] +mod quoted_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let b = r#""foo bar \"splat!\"""#; + let s = expect_quoted(b).unwrap(); + assert!(s == r#"foo bar "splat!""#); + } +} + +/// Create a closure that will carry out an operator on its argument +/// +/// Call this function with an [OpCode] and a value of type `T`. `T` must be [PartialEq], +/// [`PartialOrd`] and [`Copy`]-- an integral type will do. It will return a closure that will carry +/// out the given [OpCode] against the given value. For instance, +/// `make_numeric_closure::<u8>(OpCode::Equality, 11)` will return a closure that takes a `u8` & +/// will return true if its argument is 11 (and false otherwise). +/// +/// If [OpCode] is not pertinent to a numeric type, then this function will return Err. +fn make_numeric_closure<'a, T: 'a + PartialEq + PartialOrd + Copy>( + op: OpCode, + val: T, +) -> Result<impl Fn(T) -> bool + 'a> { + // Rust closures each have their own type, so this was the only way I could find to + // return them from match arms. This seems ugly; perhaps there's something I'm + // missing. + // + // I have no idea why I have to make these `move` closures; T is constrained to by Copy-able, + // so I would have expected the closure to just take a copy. + match op { + OpCode::Equality => Ok(Box::new(move |x: T| x == val) as Box<dyn Fn(T) -> bool>), + OpCode::Inequality => Ok(Box::new(move |x: T| x != val) as Box<dyn Fn(T) -> bool>), + OpCode::GreaterThan => Ok(Box::new(move |x: T| x > val) as Box<dyn Fn(T) -> bool>), + OpCode::LessThan => Ok(Box::new(move |x: T| x < val) as Box<dyn Fn(T) -> bool>), + OpCode::GreaterThanEqual => Ok(Box::new(move |x: T| x >= val) as Box<dyn Fn(T) -> bool>), + OpCode::LessThanEqual => Ok(Box::new(move |x: T| x <= val) as Box<dyn Fn(T) -> bool>), + _ => bail!("Invalid operant: `{op}`"), + } +} + +async fn eval_numeric_sticker_term< + // The `FromStr' trait bound is really weird, but if I don't constrain the associated + // Err type to be `ParseIntError' the compiler complains about not being able to convert + // it to type `Error'. I'm probably still "thinking in C++" and imagining the compiler + // instantiating this function for each type (u8, usize, &c) instead of realizing that the Rust + // compiler is processing this as a first-class function. + // + // For instance, I can do the conversion manually, so long as I constrain the Err type + // to implement std::error::Error. I should probably be doing that, but it clutters the + // code. I'll figure it out when I need to extend this function to handle non-integral types + // :) + T: PartialEq + PartialOrd + Copy + FromStr<Err = std::num::ParseIntError> + std::fmt::Display, +>( + sticker: &str, + client: &mut Client, + op: OpCode, + numeric_val: T, + default_val: T, +) -> Result<HashSet<String>> { + let cmp = make_numeric_closure(op, numeric_val)?; + // It would be better to idle on the sticker DB & just update our collection on change, but for + // a first impl. this will do. + // + // Call `get_stickers'; this will return a HashMap from song URIs to ratings expressed as text + // (as all stickers are). This stanza will drain that collection into a new one with the ratings + // expressed as T. + // + // The point is that conversion from text to rating, lastplayed, or whatever can fail; the + // invocation of `collect' will call `from_iter' to convert a collection of Result-s to a Result + // of a collection. + let mut m = client + .get_stickers(sticker) + .await + .context("Failed to get stickers from client")? + .drain() + .map(|(k, v)| v.parse::<T>().map(|x| (k, x))) + .collect::<std::result::Result<HashMap<String, T>, _>>() + .context("Failed to parse sticker as T")?; + // `m' is now a map of song URI to rating/playcount/wathever (expressed as a T)... for all songs + // that have the salient sticker. + // + // This seems horribly inefficient, but I'm going to fetch all the song URIs in the music DB, + // and augment `m' with entries of `default_val' for any that are not already there. + client + .get_all_songs() + .await + .context("Failed to get all songs from client")? + .drain(..) + .for_each(|song| { + m.entry(song).or_insert(default_val); + }); + // Now that we don't have to worry about operations that can fail, we can use + // `filter_map'. + Ok(m.drain() + .filter_map(|(k, v)| cmp(v).as_some(k)) + .collect::<HashSet<String>>()) +} + +/// Convenience struct collecting the names for assorted stickers on which one may search +/// +/// While the search terms 'rating', 'playcount' &c are fixed & part of the filter grammar offered +/// by mpdpopm, the precise names of the corresponding stickers are configurable & hence must be +/// passed in. Three references to str is already unweildy IMO, and since I expect the number of +/// stickers on which one can search to grow further, I decided to wrap 'em up in a struct. The +/// lifetime is there to support the caller just using a reference to an existing string rather than +/// making a copy. +pub struct FilterStickerNames<'a> { + rating: &'a str, + playcount: &'a str, + lastplayed: &'a str, +} + +impl FilterStickerNames<'static> { + pub fn new() -> FilterStickerNames<'static> { + Self::default() + } +} + +impl Default for FilterStickerNames<'static> { + fn default() -> Self { + Self { + 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), "e_value(val), case) + .await + .context("Failed to find1 on client")? + .drain(..) + .collect()), + Term::BinaryCondition(attr, op, val) => { + if *attr == Selector::Rating { + match val { + Value::Uint(n) => { + if *n > 255 { + bail!("Rating of `{}` is greater than allowed!", n) + } + Ok( + eval_numeric_sticker_term(stickers.rating, client, *op, *n as u8, 0) + .await?, + ) + } + _ => bail!("filter ratings expect an unsigned int; got {:#?}", val), + } + } else if *attr == Selector::PlayCount { + match val { + Value::Uint(n) => { + Ok( + eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0) + .await?, + ) + } + _ => bail!("filter ratings expect an unsigned int; got {:#?}", val), + } + } else if *attr == Selector::LastPlayed { + match val { + Value::UnixEpoch(t) => { + Ok( + eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0) + .await?, + ) + } + _ => bail!("filter ratings expect an unsigned int; got {:#?}", val), + } + } else { + Ok(client + .find2( + &format!("{}", attr), + &format!("{}", op), + "e_value(val), + case, + ) + .await + .context("Failed to `find2` on client")? + .drain(..) + .collect()) + } + } + } +} + +/// The evaluation stack contains logical operators & sets of song URIs +#[derive(Debug)] +enum EvalStackNode { + Op(EvalOp), + Result(HashSet<String>), +} + +async fn negate_result( + res: &HashSet<String>, + client: &mut Client, +) -> std::result::Result<HashSet<String>, Error> { + Ok(client + .get_all_songs() + .await + .context("Failed to get all songs from client")? + .drain(..) + .filter_map(|song| { + // Some(thing) adds thing, None elides it + if !res.contains(&song) { + Some(song) + } else { + None + } + }) + .collect::<HashSet<String>>()) +} + +/// Reduce the evaluation stack as far as possible. +/// +/// We can pop the stack in two cases: +/// +/// 1. S.len() > 2 and S[-3] is either And or Or, and both S[-1] & S[-2] are Result-s +/// 2. S.len() > 1, S[-2] is Not, and S[-1] is a Result +async fn reduce(stack: &mut Vec<EvalStackNode>, client: &mut Client) -> Result<()> { + loop { + let mut reduced = false; + let n = stack.len(); + if n > 1 { + // Take care to compute the reduction *before* popping the stack-- thank you, borrow + // checker! + let reduction = if let (EvalStackNode::Op(EvalOp::Not), EvalStackNode::Result(r)) = + (&stack[n - 2], &stack[n - 1]) + { + Some(negate_result(r, client).await?) + } else { + None + }; + + if let Some(res) = reduction { + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + let n = stack.len(); + if n > 2 { + // Take care to compute the reduction *before* popping the stack-- thank you, borrow + // checker! + let and_reduction = if let ( + EvalStackNode::Op(EvalOp::And), + EvalStackNode::Result(r1), + EvalStackNode::Result(r2), + ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1]) + { + Some(r1.intersection(r2).cloned().collect()) + } else { + None + }; + + if let Some(res) = and_reduction { + stack.pop(); + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + let n = stack.len(); + if n > 2 { + let or_reduction = if let ( + EvalStackNode::Op(EvalOp::Or), + EvalStackNode::Result(r1), + EvalStackNode::Result(r2), + ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1]) + { + Some(r1.union(r2).cloned().collect()) + } else { + None + }; + + if let Some(res) = or_reduction { + stack.pop(); + stack.pop(); + stack.pop(); + stack.push(EvalStackNode::Result(res)); + reduced = true; + } + } + + if !reduced { + break; + } + } + + Ok(()) +} + +/// Evaluate an abstract syntax tree (AST) +pub async fn evaluate<'a>( + expr: &Expression, + case: bool, + client: &mut Client, + stickers: &FilterStickerNames<'a>, +) -> Result<HashSet<String>> { + // We maintain *two* stacks, one for parsing & one for evaluation. Let sp (for "stack(parse)") + // be a stack of references to nodes in the parse tree. + let mut sp = Vec::new(); + // Initialize it with the root; as we walk the tree, we'll pop the "most recent" node, and push + // children. + sp.push(expr); + + // Let se (for "stack(eval)") be a stack of operators & URIs. + let mut se = Vec::new(); + + // Simple DFS traversal of the AST: + while let Some(node) = sp.pop() { + // and dispatch based on what we've got: + match node { + // 1. we have a simple term: this can be immediately resolved to a set of song URIs. Do + // so & push the resulting set onto the evaluation stack. + Expression::Simple(bt) => se.push(EvalStackNode::Result( + eval_term(bt, case, client, stickers).await?, + )), + // 2. we have a negation: push the "not" operator onto the evaluation stack & the child + // onto the parse stack. + Expression::Negation(be) => { + se.push(EvalStackNode::Op(EvalOp::Not)); + sp.push(be); + } + // 3. conjunction-- push the "and" operator onto the evaluation stack & the children + // onto the parse stack (be sure to push the right-hand child first, so it will be + // popped second) + // bc is &Box<Conjunction<'a>>, so &**bc is &Conjunction<'a> + Expression::Conjunction(bc) => { + let mut conj = &**bc; + loop { + match conj { + Conjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**ber); + sp.push(&**bel); + break; + } + Conjunction::Compound(bc, be) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**be); + conj = bc; + } + } + } + } + Expression::Disjunction(bt) => { + let mut disj = &**bt; + loop { + match disj { + Disjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(ber); + sp.push(bel); + break; + } + Disjunction::Compound(bd, be) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(&**be); + disj = bd; + } + } + } + } + } + + reduce(&mut se, client).await?; + } + + // At this point, sp is empty, but there had better be something on se. Keep reducing the stack + // until either we can't any further (in which case we error) or there is only one element left + // (in which case we return that). + reduce(&mut se, client).await?; + + // Now, se had better have one element, and that element had better be a Result. + if 1 != se.len() { + debug!("Too many ({}) operands left on stack:", se.len()); + se.iter() + .enumerate() + .for_each(|(i, x)| debug!(" {}: {:#?}", i, x)); + bail!("The number of operants is too big `{}`", se.len()); + } + + let ret = se.pop().unwrap(); + match ret { + EvalStackNode::Result(result) => Ok(result), + EvalStackNode::Op(op) => { + debug!("Operator left on stack (!?): {:#?}", op); + bail!("Operator left on stack: {op}") + } + } +} + +#[cfg(test)] +mod evaluation_tests { + + use super::*; + use crate::filters::*; + + use crate::clients::Client; + use crate::clients::test_mock::Mock; + + #[tokio::test] + async fn smoke() { + let mock = Box::new(Mock::new(&[( + r#"find "(base \"foo\")""#, + "file: foo/a.mp3 +Artist: The Foobars +file: foo/b.mp3 +Title: b! +OK", + )])); + let mut cli = Client::new(mock).unwrap(); + + let stickers = FilterStickerNames::new(); + + let expr = ExpressionParser::new().parse(r#"(base "foo")"#).unwrap(); + let result = evaluate(&expr, true, &mut cli, &stickers).await; + assert!(result.is_ok()); + + let g: HashSet<String> = ["foo/a.mp3", "foo/b.mp3"] + .iter() + .map(|x| x.to_string()) + .collect(); + assert!(result.unwrap() == g); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs new file mode 100644 index 00000000..4fe523ea --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs @@ -0,0 +1,207 @@ +// 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 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 crate::{ + clients::{Client, IdleClient, IdleSubSystem}, + config::{Config, Connection}, + filters_ast::FilterStickerNames, + messages::MessageProcessor, + playcounts::PlayState, +}; + +use anyhow::{Context, Error}; +use futures::{future::FutureExt, pin_mut, select}; +use tokio::{ + signal, + signal::unix::{SignalKind, signal}, + time::{Duration, sleep}, +}; +use tracing::{debug, error, info}; + +/// Core `mppopmd' logic +pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> { + info!("mpdpopm {} beginning.", vars::VERSION); + + let filter_stickers = FilterStickerNames::new(); + + let mut client = + match cfg.conn { + Connection::Local { ref path } => Client::open(path) + .await + .with_context(|| format!("Failed to open socket at `{}`", path.display()))?, + Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .with_context(|| format!("Failed to connect to client at `{}:{}`", host, port))?, + }; + + let mut state = PlayState::new(&mut client, cfg.played_thresh) + .await + .context("Failed to construct PlayState")?; + + let mut idle_client = match cfg.conn { + Connection::Local { ref path } => IdleClient::open(path) + .await + .context("Failed to open idle client")?, + Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port)) + .await + .context("Failed to connect to TCP idle client")?, + }; + + idle_client + .subscribe(&cfg.commands_chan) + .await + .context("Failed to subscribe to idle_client")?; + + let mut hup = signal(SignalKind::hangup()).unwrap(); + let mut kill = signal(SignalKind::terminate()).unwrap(); + let ctrl_c = signal::ctrl_c().fuse(); + + let sighup = hup.recv().fuse(); + let sigkill = kill.recv().fuse(); + + let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse(); + pin_mut!(ctrl_c, sighup, sigkill, tick); + + let 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 + .context("PlayState update failed")? + }, + // 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 + .context("PlayState update failed")? + } else if subsys == IdleSubSystem::Message { + msg_check_needed = true; + } + break; + }, + Err(err) => { + debug!("error {err:#?} on idle"); + done = true; + break; + } + } + } + } + } + + if msg_check_needed { + // 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..171a246a --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/messages.rs @@ -0,0 +1,409 @@ +// 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}, +}; + +use anyhow::{Context, Error, Result, anyhow, bail}; +use boolinator::Boolinator; +use tracing::debug; + +use std::collections::VecDeque; + +/// 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(anyhow!("Trailing backslash"))); + } + } + self.slice[out] = self.slice[inp]; + out += 1; + inp += 1; + if inp == nslice { + return Some(Err(anyhow!("No closing quote"))); + } + } + // 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(anyhow!("Invalid char: `{}`", 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 + .context("Failed to `get_messages` from client")?; + + for (chan, msgs) in m { + // Only supporting a single channel, ATM + (chan == command_chan).ok_or_else(|| anyhow!("Unknown chanell: `{}`", chan))?; + 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("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 `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) + .context("Failed to interpete `findadd` string as utf8")?), + 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) => { + bail!("Failed to parse filter: `{}`", err) + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, true, client, stickers) + .await + .context("Failed to evaluate filter")? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::<std::result::Result<Vec<()>, Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(err), + } + } + + /// 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) + .context("Failed to interpete `searchadd` string as utf8")?), + Err(err) => Err(err), + }) + .collect::<Result<VecDeque<_>>>()?; + + 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) => { + bail!("Failed to parse filter: `{err}`") + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, false, client, stickers) + .await + .context("Failed to evaluate ast")? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::<std::result::Result<Vec<()>, Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(err), + } + } +} + +#[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..7d646b4c --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs @@ -0,0 +1,313 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! playcounts -- managing play counts & lastplayed times +//! +//! # Introduction +//! +//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called +//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be +//! set explicitly: +//! +//! - `setpc PLAYCOUNT( TRACK)?` +//! - `setlp LASTPLAYED( TRACK)?` +//! + +use crate::clients::{Client, PlayerStatus}; +use crate::storage::{last_played, play_count, skipped}; + +use anyhow::{Context, Error, Result, anyhow}; +use tracing::{debug, info}; + +use std::time::SystemTime; + +/// Current server state in terms of the play status (stopped/paused/playing, current track, elapsed +/// time in current track, &c) +#[derive(Debug)] +pub struct PlayState { + /// Last known server status + last_server_stat: PlayerStatus, + + /// true if we have already incremented the last known track's playcount + have_incr_play_count: bool, + + /// Percentage threshold, expressed as a number between zero & one, for considering a song to + /// have been played + played_thresh: f64, + last_song_was_skipped: bool, +} + +impl PlayState { + /// Create a new PlayState instance; async because it will reach out to the mpd server + /// to get current status. + pub async fn new( + client: &mut Client, + played_thresh: f64, + ) -> std::result::Result<PlayState, Error> { + Ok(PlayState { + last_server_stat: client.status().await?, + have_incr_play_count: false, + last_song_was_skipped: false, + played_thresh, + }) + } + + /// Retrieve a copy of the last known player status + pub fn last_status(&self) -> PlayerStatus { + self.last_server_stat.clone() + } + + /// Poll the server-- update our status; maybe increment the current track's play count; the + /// caller must arrange to have this method invoked periodically to keep our state fresh + pub async fn update(&mut self, client: &mut Client) -> Result<()> { + let new_stat = client + .status() + .await + .context("Failed to get client status")?; + + 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(|| { + anyhow!("Failed to parse path as utf8: `{}`", curr.file.display()) + })?; + + let curr_pc = play_count::get(client, file).await?.unwrap_or_default(); + + debug!("Current PC is {}.", curr_pc); + + last_played::set( + client, + file, + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(), + ) + .await?; + self.have_incr_play_count = true; + + play_count::set(client, file, curr_pc + 1).await?; + } else if self.last_song_was_skipped { + self.last_song_was_skipped = false; + let last = self + .last_server_stat + .current_song() + .expect("To exist, as it was skipped"); + + info!( + "Marking '{}' (songid: {}) as skipped at {}.", + last.file.display(), + last.songid, + last.elapsed / last.duration + ); + + let file = last.file.to_str().ok_or_else(|| { + anyhow!("Failed to parse path as utf8: `{}`", last.file.display()) + })?; + + 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\" unwoundstack.com:playcount", + "sticker: unwoundstack.com:playcount=11\nOK\n", + ), + ( + &format!( + "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:lastplayed {}", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + "OK\n", + ), + ( + "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount 12", + "OK\n", + ), + ])); + + let mut cli = Client::new(mock).unwrap(); + let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap(); + let check = match ps.last_status() { + PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false, + PlayerStatus::Stopped => true, + }; + assert!(check); + + ps.update(&mut cli).await.unwrap(); + ps.update(&mut cli).await.unwrap() + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs new file mode 100644 index 00000000..24d8dcb5 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs @@ -0,0 +1,145 @@ +use anyhow::{Error, Result}; + +pub mod play_count { + use anyhow::Context; + + use crate::clients::Client; + + use super::Result; + + pub const STICKER: &str = "unwoundstack.com:playcount"; + + /// Retrieve the play count for a track + pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> { + match client + .get_sticker::<usize>(file, STICKER) + .await + .context("Failed to get sticker from client")? + { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the play count for a track-- this will run the associated command, if any + pub async fn set(client: &mut Client, file: &str, play_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", play_count)) + .await + .context("Failed to set_sticker on client")?; + + Ok(()) + } + + #[cfg(test)] + mod pc_lp_tests { + use super::*; + use crate::{clients::test_mock::Mock, storage::play_count}; + + /// "Smoke" tests for play counts & last played times + #[tokio::test] + async fn pc_smoke() { + let mock = Box::new(Mock::new(&[ + ( + "sticker get song a unwoundstack.com:playcount", + "sticker: unwoundstack.com:playcount=11\nOK\n", + ), + ( + "sticker get song a unwoundstack.com:playcount", + "ACK [50@0] {sticker} no such sticker\n", + ), + ("sticker get song a unwoundstack.com:playcount", "splat!"), + ])); + let mut cli = Client::new(mock).unwrap(); + + assert_eq!(play_count::get(&mut cli, "a").await.unwrap().unwrap(), 11); + let val = play_count::get(&mut cli, "a").await.unwrap(); + assert!(val.is_none()); + play_count::get(&mut cli, "a").await.unwrap_err(); + } + } +} + +pub mod skipped { + use anyhow::Context; + + use crate::clients::Client; + + use super::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 + .context("Failed to get_sticker on client")? + { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the skip count for a track + pub async fn set(client: &mut Client, file: &str, skip_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", skip_count)) + .await + .context("Failed to set_sticker on client") + } +} + +pub mod last_played { + use anyhow::Context; + + use crate::clients::Client; + + use super::Result; + + pub const STICKER: &str = "unwoundstack.com:lastplayed"; + + /// Retrieve the last played timestamp for a track (seconds since Unix epoch) + pub async fn get(client: &mut Client, file: &str) -> Result<Option<u64>> { + client + .get_sticker::<u64>(file, STICKER) + .await + .context("Falied to get_sticker on client") + } + + /// Set the last played for a track + pub async fn set(client: &mut Client, file: &str, last_played: u64) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", last_played)) + .await + .context("Failed to set_sticker on client")?; + Ok(()) + } +} + +pub mod rating_count { + use anyhow::Context; + + use crate::clients::Client; + + use super::Result; + + pub const STICKER: &str = "unwoundstack.com:ratings_count"; + + /// Retrieve the rating count for a track + pub async fn get(client: &mut Client, file: &str) -> Result<Option<i8>> { + client + .get_sticker::<i8>(file, STICKER) + .await + .context("Failed to get_sticker on client") + } + + /// Set the rating count for a track + pub async fn set(client: &mut Client, file: &str, rating_count: i8) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", rating_count)) + .await + .context("Failed to set_sticker on client")?; + Ok(()) + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/vars.rs b/pkgs/by-name/mp/mpdpopm/src/vars.rs new file mode 100644 index 00000000..7cacec66 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs @@ -0,0 +1,4 @@ +pub static VERSION: &str = env!("CARGO_PKG_VERSION"); +pub static AUTHOR: &str = env!("CARGO_PKG_AUTHORS"); +pub static LOCALSTATEDIR: &str = "/home/soispha/.local/state"; +pub static PREFIX: &str = "/home/soispha/.local/share/mpdpopm"; |
