From 38c6f95c94830ed8eb6c6678e303480257cf3cf5 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Sat, 24 Jan 2026 23:51:04 +0100 Subject: pkgs/mpdpopm: Init This is based on https://github.com/sp1ff/mpdpopm at commit 178df8ad3a5c39281cfd8b3cec05394f4c9256fd. --- pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs | 677 +++++++++++++ pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs | 233 +++++ pkgs/by-name/mp/mpdpopm/src/clients.rs | 1417 +++++++++++++++++++++++++++ pkgs/by-name/mp/mpdpopm/src/config.rs | 275 ++++++ pkgs/by-name/mp/mpdpopm/src/filters.lalrpop | 143 +++ pkgs/by-name/mp/mpdpopm/src/filters_ast.rs | 1166 ++++++++++++++++++++++ pkgs/by-name/mp/mpdpopm/src/lib.rs | 274 ++++++ pkgs/by-name/mp/mpdpopm/src/messages.rs | 732 ++++++++++++++ pkgs/by-name/mp/mpdpopm/src/playcounts.rs | 367 +++++++ pkgs/by-name/mp/mpdpopm/src/ratings.rs | 195 ++++ pkgs/by-name/mp/mpdpopm/src/storage/mod.rs | 212 ++++ pkgs/by-name/mp/mpdpopm/src/vars.rs | 5 + 12 files changed, 5696 insertions(+) create mode 100644 pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/clients.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/config.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/filters.lalrpop create mode 100644 pkgs/by-name/mp/mpdpopm/src/filters_ast.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/lib.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/messages.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/playcounts.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/ratings.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/storage/mod.rs create mode 100644 pkgs/by-name/mp/mpdpopm/src/vars.rs (limited to 'pkgs/by-name/mp/mpdpopm/src') diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs new file mode 100644 index 00000000..4ffcd499 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs @@ -0,0 +1,677 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! # mppopm +//! +//! mppopmd client +//! +//! # Introduction +//! +//! `mppopmd` is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). `mppopm` is a command-line client +//! for `mppopmd`. Run `mppopm --help` for detailed usage. + +use mpdpopm::{ + clients::{Client, PlayerStatus, quote}, + config::{self, Config}, + storage::{last_played, play_count, rating_count}, +}; + +use backtrace::Backtrace; +use clap::{Parser, Subcommand}; +use tracing::{debug, info, level_filters::LevelFilter, trace}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +use std::{fmt, path::PathBuf}; + +#[non_exhaustive] +pub enum Error { + NoSubCommand, + NoConfigArg, + NoRating, + NoPlayCount, + NoLastPlayed, + NoConfig { + config: std::path::PathBuf, + cause: std::io::Error, + }, + PlayerStopped, + BadPath { + path: PathBuf, + back: Backtrace, + }, + NoPlaylist, + Client { + source: mpdpopm::clients::Error, + back: Backtrace, + }, + Ratings { + source: Box, + back: Backtrace, + }, + Playcounts { + source: Box, + back: Backtrace, + }, + ExpectedInt { + source: std::num::ParseIntError, + back: Backtrace, + }, + Config { + source: crate::config::Error, + back: Backtrace, + }, +} + +impl fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NoSubCommand => write!(f, "No sub-command given"), + Error::NoConfigArg => write!(f, "No argument given for the configuration option"), + Error::NoRating => write!(f, "No rating supplied"), + Error::NoPlayCount => write!(f, "No play count supplied"), + Error::NoLastPlayed => write!(f, "No last played timestamp given"), + Error::NoConfig { config, cause } => write!(f, "Bad config ({:?}): {}", config, cause), + Error::PlayerStopped => write!(f, "The player is stopped"), + Error::BadPath { path, back: _ } => write!(f, "Bad path: {:?}", path), + Error::NoPlaylist => write!(f, "No playlist given"), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Ratings { source, back: _ } => write!(f, "Rating error: {}", source), + Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), + Error::ExpectedInt { source, back: _ } => write!(f, "Expected integer: {}", source), + Error::Config { source, back: _ } => { + write!(f, "Error reading configuration: {}", source) + } + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +type Result = std::result::Result; + +/// 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>) -> Result> { + let files = match args { + Some(iter) => iter, + None => { + let file = match client.status().await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| Error::BadPath { + path: curr.file.clone(), + back: Backtrace::new(), + })? + .to_string(), + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped); + } + }; + vec![file] + } + }; + Ok(files) +} + +/// Retrieve ratings for one or more tracks +async fn get_ratings( + client: &mut Client, + tracks: Option>, + with_uri: bool, +) -> Result<()> { + let mut ratings: Vec<(String, u8)> = Vec::new(); + + for file in map_tracks(client, tracks).await? { + let rating = rating_count::get(client, &file) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + ratings.push((file, rating.unwrap_or_default())); + } + + if ratings.len() == 1 && !with_uri { + println!("{}", ratings[0].1); + } else { + for pair in ratings { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Rate a track +async fn set_rating( + client: &mut Client, + chan: &str, + rating: u8, + arg: Option, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("rate {} \\\"{}\\\"", rating, uri), + None => format!("rate {}", rating), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set the rating for \"{}\" to \"{}\".", uri, rating), + None => info!("Set the rating for the current song to \"{}\".", rating), + } + + Ok(()) +} + +/// Rate a track by incrementing the current rating +async fn inc_rating(client: &mut Client, chan: &str, arg: Option) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("inc-rate \\\"{}\\\"", uri), + None => "inc-rate ".to_owned(), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Incremented the rating for \"{}\".", uri), + None => info!("Incremented the rating for the current song."), + } + + Ok(()) +} + +/// Retrieve the playcount for one or more tracks +async fn get_play_counts( + client: &mut Client, + tracks: Option>, + with_uri: bool, +) -> Result<()> { + let mut playcounts: Vec<(String, usize)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let playcount = play_count::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })? + .unwrap_or_default(); + playcounts.push((file, playcount)); + } + + if playcounts.len() == 1 && !with_uri { + println!("{}", playcounts[0].1); + } else { + for pair in playcounts { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_play_counts( + client: &mut Client, + chan: &str, + playcount: usize, + arg: Option, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("setpc {} \\\"{}\\\"", playcount, uri), + None => format!("setpc {}", playcount), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set the playcount for \"{}\" to \"{}\".", uri, playcount), + None => info!( + "Set the playcount for the current song to \"{}\".", + playcount + ), + } + + Ok(()) +} + +/// Retrieve the last played time for one or more tracks +async fn get_last_playeds( + client: &mut Client, + tracks: Option>, + with_uri: bool, +) -> Result<()> { + let mut lastplayeds: Vec<(String, Option)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let lastplayed = + last_played::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })?; + lastplayeds.push((file, lastplayed)); + } + + if lastplayeds.len() == 1 && !with_uri { + println!( + "{}", + match lastplayeds[0].1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } else { + for pair in lastplayeds { + println!( + "{}: {}", + pair.0, + match pair.1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_last_playeds( + client: &mut Client, + chan: &str, + lastplayed: u64, + arg: Option, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("setlp {} {}", lastplayed, uri), + None => format!("setlp {}", lastplayed), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set last played for \"{}\" to \"{}\".", uri, lastplayed), + None => info!( + "Set last played for the current song to \"{}\".", + lastplayed + ), + } + + Ok(()) +} + +/// Retrieve the list of stored playlists +async fn get_playlists(client: &mut Client) -> Result<()> { + let mut pls = client + .get_stored_playlists() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + pls.sort(); + println!("Stored playlists:"); + for pl in pls { + println!("{}", pl); + } + Ok(()) +} + +/// Add songs selected by filter to the queue +async fn findadd(client: &mut Client, chan: &str, filter: &str, case: bool) -> Result<()> { + let qfilter = quote(filter); + debug!("findadd: got ``{}'', quoted to ``{}''.", filter, qfilter); + let cmd = format!("{} {}", if case { "findadd" } else { "searchadd" }, qfilter); + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// Send an arbitrary command +async fn send_command(client: &mut Client, chan: &str, args: Vec) -> Result<()> { + client + .send_message( + chan, + args.iter() + .map(String::as_str) + .map(quote) + .collect::>() + .join(" ") + .as_str(), + ) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// `mppopmd' client +#[derive(Parser)] +struct Args { + /// path to configuration file + #[arg(short, long)] + config: Option, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, + + #[command(subcommand)] + command: SubCommand, +} + +#[derive(Subcommand)] +enum SubCommand { + /// retrieve the rating for one or more tracks + /// + /// With no arguments, retrieve the rating of the current song & print it + /// on stdout. With one argument, retrieve that track's rating & print it + /// on stdout. With multiple arguments, print their ratings on stdout, one + /// per line, prefixed by the track name. + /// + /// Ratings are expressed as an integer between 0 & 255, inclusive, with + /// the convention that 0 denotes "un-rated". + #[clap(verbatim_doc_comment)] + GetRating { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option>, + }, + + /// set the rating for one track + /// + /// With one argument, set the rating of the current song to that argument. + /// With a second argument, rate that song at the first argument. Ratings + /// may be expressed a an integer between 0 & 255, inclusive. + #[clap(verbatim_doc_comment)] + SetRating { rating: u8, track: Option }, + + /// increment the rating for one track + /// + /// With one argument, increment the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + IncRating { track: Option }, + + /// retrieve the play count for one or more tracks + /// + /// With no arguments, retrieve the play count of the current song & print it + /// on stdout. With one argument, retrieve that track's play count & print it + /// on stdout. With multiple arguments, print their play counts on stdout, one + /// per line, prefixed by the track name. + #[clap(verbatim_doc_comment)] + GetPc { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option>, + }, + + /// set the play count for one track + /// + /// With one argument, set the play count of the current song to that argument. With a + /// second argument, set the play count for that song to the first. + #[clap(verbatim_doc_comment)] + SetPc { + play_count: usize, + track: Option, + }, + + /// retrieve the last played timestamp for one or more tracks + /// + /// With no arguments, retrieve the last played timestamp of the current + /// song & print it on stdout. With one argument, retrieve that track's + /// last played time & print it on stdout. With multiple arguments, print + /// their last played times on stdout, one per line, prefixed by the track + /// name. + /// + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + GetLp { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option>, + }, + + /// set the last played timestamp for one track + /// + /// With one argument, set the last played time of the current song. With two + /// arguments, set the last played time for the second argument to the first. + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + SetLp { + last_played: u64, + track: Option, + }, + + /// retrieve the list of stored playlists + #[clap(verbatim_doc_comment)] + GetPlaylists {}, + + /// search case-sensitively for songs matching matching a filter and add them to the queue + /// + /// This command extends the MPD command `findadd' (which will search the MPD database) to allow + /// searches on attributes managed by mpdpopm: rating, playcount & last played time. + /// + /// The MPD `findadd' will search the + /// MPD database for songs that match a given filter & add them to the play queue. The filter syntax is + /// documented here . + /// + /// 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' will search + /// the MPD database for songs that match a given filter & add them to the play queue. The filter syntax + /// is documented here . + /// + /// 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 }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let config = if let Some(configpath) = &args.config { + match std::fs::read_to_string(configpath) { + Ok(text) => config::from_str(&text).map_err(|err| Error::Config { + source: err, + back: Backtrace::new(), + })?, + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + return Err(Error::NoConfig { + config: PathBuf::from(configpath), + cause: err, + }); + } + } + } else { + Config::default() + }; + + // Handle log verbosity: debug => verbose + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::WARN, + }; + + tracing::subscriber::set_global_default( + Registry::default() + .with( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(std::io::stdout), + ) + .with( + EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .unwrap(), + ), + ) + .unwrap(); + + trace!("logging configured."); + + let mut client = match config.conn { + config::Connection::Local { path } => { + Client::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + config::Connection::TCP { host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + match args.command { + SubCommand::GetRating { with_uri, tracks } => { + get_ratings(&mut client, tracks, with_uri).await + } + SubCommand::SetRating { rating, track } => { + set_rating(&mut client, &config.commands_chan, rating, track).await + } + SubCommand::IncRating { track } => { + inc_rating(&mut client, &config.commands_chan, track).await + } + SubCommand::GetPc { with_uri, tracks } => { + get_play_counts(&mut client, tracks, with_uri).await + } + SubCommand::SetPc { play_count, track } => { + set_play_counts(&mut client, &config.commands_chan, play_count, track).await + } + SubCommand::GetLp { with_uri, tracks } => { + get_last_playeds(&mut client, tracks, with_uri).await + } + SubCommand::SetLp { last_played, track } => { + set_last_playeds(&mut client, &config.commands_chan, last_played, track).await + } + SubCommand::GetPlaylists {} => get_playlists(&mut client).await, + SubCommand::Findadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, true).await + } + SubCommand::Searchadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, false).await + } + SubCommand::SendCommand { args } => { + send_command(&mut client, &config.commands_chan, args).await + } + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs new file mode 100644 index 00000000..e903774c --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs @@ -0,0 +1,233 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! # mppopmd +//! +//! Maintain ratings & playcounts for your mpd server. +//! +//! # Introduction +//! +//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). + +use mpdpopm::config; +use mpdpopm::config::Config; +use mpdpopm::mpdpopm; + +use backtrace::Backtrace; +use clap::Parser; +use tracing::{info, level_filters::LevelFilter}; +use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; + +use std::{fmt, io, path::PathBuf, sync::MutexGuard}; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// mppopmd application Error type // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[non_exhaustive] +pub enum Error { + NoConfigArg, + NoConfig { + config: std::path::PathBuf, + cause: std::io::Error, + }, + Filter { + source: tracing_subscriber::filter::FromEnvError, + back: Backtrace, + }, + Fork { + errno: errno::Errno, + back: Backtrace, + }, + PathContainsNull { + back: Backtrace, + }, + OpenLockFile { + errno: errno::Errno, + back: Backtrace, + }, + LockFile { + errno: errno::Errno, + back: Backtrace, + }, + WritePid { + errno: errno::Errno, + back: Backtrace, + }, + Config { + source: crate::config::Error, + back: Backtrace, + }, + MpdPopm { + source: Box, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NoConfigArg => write!(f, "No configuration file given"), + Error::NoConfig { config, cause } => { + write!(f, "Configuration error ({:?}): {}", config, cause) + } + Error::Fork { errno, back: _ } => write!(f, "When forking, got errno {}", errno), + Error::PathContainsNull { back: _ } => write!(f, "Path contains a null character"), + Error::OpenLockFile { errno, back: _ } => { + write!(f, "While opening lock file, got errno {}", errno) + } + Error::LockFile { errno, back: _ } => { + write!(f, "While locking the lock file, got errno {}", errno) + } + Error::WritePid { errno, back: _ } => { + write!(f, "While writing pid file, got errno {}", errno) + } + Error::Config { source, back: _ } => write!(f, "Configuration error: {}", source), + Error::MpdPopm { source, back: _ } => write!(f, "mpdpopm error: {}", source), + _ => write!(f, "Unknown mppopmd error"), + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +type Result = std::result::Result<(), Error>; + +pub struct MyMutexGuardWriter<'a>(MutexGuard<'a, std::fs::File>); + +impl io::Write for MyMutexGuardWriter<'_> { + #[inline] + fn write(&mut self, buf: &[u8]) -> io::Result { + 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 { + 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, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, +} + +/// Entry point for `mppopmd'. +/// +/// Do *not* use the #[tokio::main] attribute here! If this program is asked to daemonize (the usual +/// case), we will fork after tokio has started its thread pool, with disastrous consequences. +/// Instead, stay synchronous until we've daemonized (or figured out that we don't need to), and +/// only then fire-up the tokio runtime. +fn main() -> Result { + use mpdpopm::vars::VERSION; + + let args = Args::parse(); + + let config = if let Some(cfgpath) = &args.config { + match std::fs::read_to_string(cfgpath) { + Ok(text) => config::from_str(&text).map_err(|err| Error::Config { + source: err, + back: Backtrace::new(), + })?, + // The config file (defaulted or not) either didn't exist, or we were unable to read its + // contents... + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + return Err(Error::NoConfig { + config: PathBuf::from(cfgpath), + cause: err, + }); + } + } + } else { + Config::default() + }; + + // `--verbose' & `--debug' work as follows: if `--debug' is present, log at level Trace, no + // matter what. Else, if `--verbose' is present, log at level Debug. Else, log at level Info. + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::INFO, + }; + + let filter = EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })?; + + let formatter: Box + Send + Sync> = { + Box::new( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(io::stdout), + ) + }; + + tracing::subscriber::set_global_default(Registry::default().with(formatter).with(filter)) + .unwrap(); + + info!("mppopmd {VERSION} logging at level {lf:#?}."); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(mpdpopm(config)).map_err(|err| Error::MpdPopm { + source: Box::new(err), + back: Backtrace::new(), + }) +} diff --git a/pkgs/by-name/mp/mpdpopm/src/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs new file mode 100644 index 00000000..587063b2 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs @@ -0,0 +1,1417 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! mpd clients and associated utilities. +//! +//! # Introduction +//! +//! This module contains basic types implementing various MPD client operations (cf. the [mpd +//! protocol](http://www.musicpd.org/doc/protocol/)). Since issuing the "idle" command will tie up +//! the connection, MPD clients often use multiple connections to the server (one to listen for +//! updates, one or more on which to issue commands). This modules provides two different client +//! types: [Client] for general-purpose use and [IdleClient] for long-lived connections listening +//! for server notifiations. +//! +//! Note that there *is* another idiom (used in [libmpdel](https://github.com/mpdel/libmpdel), +//! e.g.): open a single connection & issue an "idle" command. When you want to issue a command, +//! send a "noidle", then the command, then "idle" again. This isn't a race condition, as the +//! server will buffer any changes that took place when you were not idle & send them when you +//! re-issue the "idle" command. This crate however takes the approach of two channels (like +//! [mpdfav](https://github.com/vincent-petithory/mpdfav)). + +use async_trait::async_trait; +use regex::Regex; +use snafu::{Backtrace, IntoError, OptionExt, ResultExt, prelude::*}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpStream, ToSocketAddrs, UnixStream}; +use tracing::{debug, info}; + +use lazy_static::lazy_static; + +use std::{ + collections::HashMap, + convert::TryFrom, + fmt, + marker::{Send, Unpin}, + path::{Path, PathBuf}, + str::FromStr, +}; + +// The Protocol error, below, gets used a *lot*; anywhere we receive a message from the MPD server +// that "should" never happen. To help give a bit of context beyond a stack trace, I use this +// enumeration of "operations" +/// Enumerated list of MPD operations; used in Error::Protocol to distinguish which operation it was +/// that elicited the protocol error. +#[derive(Debug)] +#[non_exhaustive] +pub enum Operation { + Connect, + Status, + GetSticker, + SetSticker, + SendToPlaylist, + SendMessage, + Update, + GetStoredPlaylists, + RspToUris, + GetStickers, + GetAllSongs, + Add, + Idle, + GetMessages, +} + +impl std::fmt::Display for Operation { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Operation::Connect => write!(f, "Connect"), + Operation::Status => write!(f, "Status"), + Operation::GetSticker => write!(f, "GetSticker"), + Operation::SetSticker => write!(f, "SetSticker"), + Operation::SendToPlaylist => write!(f, "SendToPlaylist"), + Operation::SendMessage => write!(f, "SendMessage"), + Operation::Update => write!(f, "Update"), + Operation::GetStoredPlaylists => write!(f, "GetStoredPlaylists"), + Operation::RspToUris => write!(f, "RspToUris"), + Operation::GetStickers => write!(f, "GetStickers"), + Operation::GetAllSongs => write!(f, "GetAllSongs"), + Operation::Add => write!(f, "Add"), + Operation::Idle => write!(f, "Idle"), + Operation::GetMessages => write!(f, "GetMessages"), + _ => write!(f, "Unknown client operation"), + } + } +} + +/// An MPD client error +#[derive(Debug, Snafu)] +#[non_exhaustive] +pub enum Error { + #[snafu(display("Protocol error ({}): {}", op, msg))] + Protocol { + op: Operation, + msg: String, + backtrace: Backtrace, + }, + #[snafu(display("Protocol errror ({}): {}", op, source))] + ProtocolConv { + op: Operation, + source: Box, + backtrace: Backtrace, + }, + #[snafu(display("I/O error: {}", source))] + Io { + source: std::io::Error, + backtrace: Backtrace, + }, + #[snafu(display("Encoding error: {}", source))] + Encoding { + source: std::string::FromUtf8Error, + backtrace: Backtrace, + }, + #[snafu(display("While converting sticker ``{}'': {}", sticker, source))] + StickerConversion { + sticker: String, + source: Box, + backtrace: Backtrace, + }, + #[snafu(display("``{}'' is not a recognized Idle subsystem", text))] + IdleSubSystem { text: String, backtrace: Backtrace }, +} + +pub type Result = std::result::Result; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// A description of the current track, suitable for our purposes (as in, it only tracks the +/// attributes needed for this module's functionality). +#[derive(Clone, Debug)] +pub struct CurrentSong { + /// Identifier, unique within the play queue, identifying this particular track; if the same + /// file is listed twice in the `mpd' play queue each instance will get a distinct songid + pub songid: u64, + + /// Path, relative to `mpd' music directory root of this track + pub file: std::path::PathBuf, + + /// Elapsed time, in seconds, in this track + pub elapsed: f64, + + /// Total track duration, in seconds + pub duration: f64, +} + +impl CurrentSong { + fn new(songid: u64, file: std::path::PathBuf, elapsed: f64, duration: f64) -> CurrentSong { + CurrentSong { + songid, + file, + elapsed, + duration, + } + } + /// Compute the ratio of the track that has elapsed, expressed as a floating point between 0 & 1 + pub fn played_pct(&self) -> f64 { + self.elapsed / self.duration + } +} + +/// The MPD player itself can be in one of three states: playing, paused or stopped. In the first +/// two there is a "current" song. +#[derive(Clone, Debug)] +pub enum PlayerStatus { + Play(CurrentSong), + Pause(CurrentSong), + Stopped, +} + +impl PlayerStatus { + pub fn current_song(&self) -> Option<&CurrentSong> { + match self { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => Some(curr), + PlayerStatus::Stopped => None, + } + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Connection // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// A trait representing a simple, textual request/response protocol like that +/// [employed](https://www.musicpd.org/doc/html/protocol.html) by [MPD](https://www.musicpd.org/): +/// the caller sends a textual command & the server responds with a (perhaps multi-line) textual +/// response. +/// +/// This trait also enables unit testing client implementations. Note that it is async-- cf. +/// [async_trait](https://docs.rs/async-trait/latest/async_trait/). +#[async_trait] +pub trait RequestResponse { + async fn req(&mut self, msg: &str) -> Result; + /// 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; +} + +#[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, + outmsgs: Vec, + } + + 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 { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, _hint: usize) -> Result { + 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::::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::::connect("localhost:6600".parse::().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 { + 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 RequestResponse for MpdConnection +where + T: AsyncRead + AsyncWrite + Send + Unpin, +{ + async fn req(&mut self, msg: &str) -> Result { + self.req_w_hint(msg, 512).await + } + async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result { + self.sock + .write_all(format!("{}\n", msg).as_bytes()) + .await + .context(IoSnafu)?; + let mut buf = Vec::with_capacity(hint); + + // Given the request/response nature of the MPD protocol, our callers expect a complete + // response. Therefore we need to loop here until we see either "...^OK\n" or + // "...^ACK...\n". + let mut cb = 0; // # bytes read so far + let mut more = true; // true as long as there is more to read + while more { + cb += self.sock.read_buf(&mut buf).await.context(IoSnafu)?; + + // The shortest complete response has three bytes. If the final byte in `buf' is not a + // newline, then don't bother looking further. + if cb > 2 && char::from(buf[cb - 1]) == '\n' { + // If we're here, `buf' *may* contain a complete response. Search backward for the + // previous newline. It may not exist: many responses are of the form "OK\n". + let mut idx = cb - 2; + while idx > 0 { + if char::from(buf[idx]) == '\n' { + idx += 1; + break; + } + idx -= 1; + } + + if (idx + 2 < cb && char::from(buf[idx]) == 'O' && char::from(buf[idx + 1]) == 'K') + || (idx + 3 < cb + && char::from(buf[idx]) == 'A' + && char::from(buf[idx + 1]) == 'C' + && char::from(buf[idx + 2]) == 'K') + { + more = false; + } + } + } + + // Only doing this to trouble-shoot issue 11 + String::from_utf8(buf.clone()).context(EncodingSnafu) + } +} + +/// Utility function to parse the initial response to a connection from mpd +async fn parse_connect_rsp(sock: &mut T) -> Result +where + T: AsyncReadExt + AsyncWriteExt + Send + Unpin, +{ + let mut buf = Vec::with_capacity(32); + let _cb = sock.read_buf(&mut buf).await.context(IoSnafu)?; + // Only doing this to trouble-shoot issue 11 + let text = String::from_utf8(buf.clone()).context(EncodingSnafu)?; + ensure!( + text.starts_with("OK MPD "), + ProtocolSnafu { + op: Operation::Connect, + msg: text.trim() + } + ); + info!("Connected {}.", text[7..].trim()); + Ok(text[7..].trim().to_string()) +} + +impl MpdConnection { + pub async fn connect(addr: A) -> Result> { + let mut sock = TcpStream::connect(addr).await.context(IoSnafu)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection:: { + sock, + _protocol_ver: proto_ver, + })) + } +} + +impl MpdConnection { + // NTS: we have to box the return value because a `dyn RequestResponse` isn't Sized. + pub async fn connect>(pth: P) -> Result> { + let mut sock = UnixStream::connect(pth).await.context(IoSnafu)?; + let proto_ver = parse_connect_rsp(&mut sock).await?; + Ok(Box::new(MpdConnection:: { + 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, +} + +// Thanks to +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(addrs: A) -> Result { + Self::new(MpdConnection::::connect(addrs).await?) + } + + pub async fn open>(pth: P) -> Result { + Self::new(MpdConnection::::connect(pth).await?) + } + + pub fn new(stream: Box) -> Result { + Ok(Client { stream }) + } +} + +impl Client { + /// Retrieve the current server status. + pub async fn status(&mut self) -> Result { + // We begin with sending the "status" command: "Reports the current status of the player and + // the volume level." Per the docs, "MPD may omit lines which have no (known) value", so I + // can't really count on particular lines being there. Tho nothing is said in the docs, I + // also don't want to depend on the order. + let text = self.stream.req("status").await?; + + let proto = || -> Error { + ProtocolSnafu { + op: Operation::Status, + msg: text.to_owned(), + } + .build() + }; + + // I first thought to avoid the use (and cost) of regular expressions by just doing + // sub-string searching on "state: ", but when I realized I needed to only match at the + // beginning of a line I bailed & just went ahead. This makes for more succinct code, since + // I can't count on order, either. + let state = RE_STATE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + + match state { + "stop" => Ok(PlayerStatus::Stopped), + "play" | "pause" => { + let songid = RE_SONGID + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + let elapsed = RE_ELAPSED + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + // navigate from `songid'-- don't send a "currentsong" message-- the current song + // could have changed + let text = self.stream.req(&format!("playlistid {}", songid)).await?; + + let file = RE_FILE + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str(); + let duration = RE_DURATION + .captures(&text) + .ok_or_else(proto)? + .get(1) + .ok_or_else(proto)? + .as_str() + .parse::() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Status, + } + .into_error(Box::new(err)) + })?; + + let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration); + + if state == "play" { + Ok(PlayerStatus::Play(curr)) + } else { + Ok(PlayerStatus::Pause(curr)) + } + } + _ => ProtocolSnafu { + op: Operation::Status, + msg: state.to_owned(), + } + .fail(), + } + } + + /// Retrieve a song sticker by name + pub async fn get_sticker( + &mut self, + file: &str, + sticker_name: &str, + ) -> Result> + where + ::Err: std::error::Error + Sync + Send + 'static, + { + let msg = format!("sticker get song {} {}", quote(file), quote(sticker_name)); + let text = self.stream.req(&msg).await?; + debug!("Sent message `{}'; got `{}'", &msg, &text); + + let prefix = format!("sticker: {}=", sticker_name); + if text.starts_with(&prefix) { + let s = text[prefix.len()..] + .split('\n') + .next() + .context(ProtocolSnafu { + op: Operation::GetSticker, + msg, + })?; + Ok(Some(T::from_str(s).map_err(|err| { + StickerConversionSnafu { + sticker: sticker_name.to_owned(), + } + .into_error(Box::new(err)) + })?)) + } else { + // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17) + ensure!( + text.starts_with("ACK [50@0]"), + ProtocolSnafu { + op: Operation::GetSticker, + msg, + } + ); + Ok(None) + } + } + + /// Set a song sticker by name + pub async fn set_sticker( + &mut self, + file: &str, + sticker_name: &str, + sticker_value: &T, + ) -> Result<()> { + let value_as_str = format!("{}", sticker_value); + let msg = format!( + "sticker set song {} {} {}", + quote(file), + quote(sticker_name), + quote(&value_as_str) + ); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SetSticker, + msg + } + ); + Ok(()) + } + + /// Send a file to a playlist + pub async fn send_to_playlist(&mut self, file: &str, pl: &str) -> Result<()> { + let msg = format!("playlistadd {} {}", quote(pl), quote(file)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SendToPlaylist, + msg + } + ); + Ok(()) + } + + /// Send an arbitrary message + pub async fn send_message(&mut self, chan: &str, msg: &str) -> Result<()> { + let msg = format!("sendmessage {} {}", chan, quote(msg)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::SendMessage, + msg: text + } + ); + Ok(()) + } + + /// Update a URI + pub async fn update(&mut self, uri: &str) -> Result { + let msg = format!("update \"{}\"", uri); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + // We expect a response of the form: + // updating_db: JOBID + // OK + // on success, and + // ACK ERR + // on failure. + + let prefix = "updating_db: "; + ensure!( + text.starts_with(prefix), + ProtocolSnafu { + op: Operation::Update, + msg: &text + } + ); + text[prefix.len()..].split('\n').collect::>()[0] + .to_string() + .parse::() + .map_err(|err| { + ProtocolConvSnafu { + op: Operation::Update, + } + .into_error(Box::new(err)) + }) + } + + /// Get the list of stored playlists + pub async fn get_stored_playlists(&mut self) -> Result> { + let text = self.stream.req("listplaylists").await?; + debug!("Sent listplaylists; got `{}'.", &text); + + // We expect a response of the form: + // playlist: a + // Last-Modified: 2020-03-13T17:20:16Z + // playlsit: b + // Last-Modified: 2020-03-13T17:20:16Z + // ... + // OK + // + // or + // + // ACK... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetStoredPlaylists, + msg: text + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("playlist: ").map(String::from)) + .collect::>()) + } + + /// Process a search (either find or search) response + fn search_rsp_to_uris(&self, text: &str) -> Result> { + // We expect a response of the form: + // file: P/Pogues, The - A Pistol For Paddy Garcia.mp3 + // Last-Modified: 2007-12-26T19:18:00Z + // Format: 44100:24:2 + // ... + // file: P/Pogues, The - Billy's Bones.mp3 + // ... + // OK + // + // or + // + // ACK... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::RspToUris, + msg: text.to_owned() + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::>()) + } + + /// 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> { + 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> { + 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> { + let text = self + .stream + .req(&format!("sticker find song \"\" {}", sticker)) + .await?; + + // We expect a response of the form: + // + // file: U-Z/Zafari - Addis Adaba.mp3 + // sticker: unwoundstack.com:rating=64 + // ... + // file: U-Z/Zero 7 - In Time (Album Version).mp3 + // sticker: unwoundstack.com:rating=255 + // OK + // + // or + // + // ACK ... + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetStickers, + msg: text, + } + ); + let mut m = HashMap::new(); + let mut lines = text.lines(); + loop { + let file = lines.next().context(ProtocolSnafu { + op: Operation::GetStickers, + msg: text.to_owned(), + })?; + if "OK" == file { + break; + } + let val = lines.next().context(ProtocolSnafu { + op: Operation::GetStickers, + msg: text.to_owned(), + })?; + + m.insert( + String::from(&file[6..]), + String::from(&val[10 + sticker.len()..]), + ); + } + Ok(m) + } + + /// Retrieve the song URIs of all songs in the database + /// + /// Returns a vector of String + pub async fn get_all_songs(&mut self) -> Result> { + let text = self.stream.req("find \"(base '')\"").await?; + // We expect a response of the form: + // file: 0-A/A Positive Life - Lighten Up!.mp3 + // Last-Modified: 2020-11-18T22:47:07Z + // Format: 44100:24:2 + // Time: 399 + // duration: 398.550 + // Artist: A Positive Life + // Title: Lighten Up! + // Genre: Electronic + // file: 0-A/A Positive Life - Pleidean Communication.mp3 + // ... + // OK + // + // or "ACK..." + ensure!( + !text.starts_with("ACK"), + ProtocolSnafu { + op: Operation::GetAllSongs, + msg: text, + } + ); + Ok(text + .lines() + .filter_map(|x| x.strip_prefix("file: ").map(String::from)) + .collect::>()) + } + + pub async fn add(&mut self, uri: &str) -> Result<()> { + let msg = format!("add {}", quote(uri)); + let text = self.stream.req(&msg).await?; + debug!("Sent `{}'; got `{}'.", &msg, &text); + + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Add, + msg: &text + } + ); + Ok(()) + } +} + +#[cfg(test)] +/// Let's test Client! +mod client_tests { + + use super::test_mock::Mock; + use super::*; + + /// Some basic "smoke" tests + #[tokio::test] + async fn client_smoke_test() { + let mock = Box::new(Mock::new(&[( + "sticker get song foo.mp3 stick", + "sticker: stick=splat\nOK\n", + )])); + let mut cli = Client::new(mock).unwrap(); + let val = cli + .get_sticker::("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::("foo.mp3", "stick") + .await + .unwrap() + .unwrap(); + assert_eq!(val, "2"); + let _val = cli + .get_sticker::("foo.mp3", "stick") + .await + .unwrap() + .is_none(); + let _val = cli + .get_sticker::("foo.mp3", "stick") + .await + .unwrap_err(); + let val = cli + .get_sticker::( + "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 { + let x = text.to_lowercase(); + if x == "player" { + Ok(IdleSubSystem::Player) + } else if x == "message" { + Ok(IdleSubSystem::Message) + } else { + Err(IdleSubSystemSnafu { + text: String::from(text), + } + .build()) + } + } +} + +impl fmt::Display for IdleSubSystem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + IdleSubSystem::Player => write!(f, "Player"), + IdleSubSystem::Message => write!(f, "Message"), + } + } +} + +/// [MPD](https://www.musicpd.org) client for "idle" connections. +/// +/// # Introduction +/// +/// This is an MPD client designed to "idle": it opens a long-lived connection to the MPD server and +/// waits for MPD to respond with a message indicating that there's been a change to a subsystem of +/// interest. At present, there are only two subsystems in which [mpdpopm](crate) is interested: the player +/// & messages (cf. [IdleSubSystem]). +/// +/// ```no_run +/// use std::path::Path; +/// use tokio::runtime::Runtime; +/// use mpdpopm::clients::IdleClient; +/// +/// let mut rt = Runtime::new().unwrap(); +/// rt.block_on( async { +/// let mut client = IdleClient::open(Path::new("/var/run/mpd.sock")).await.unwrap(); +/// client.subscribe("player").await.unwrap(); +/// client.idle().await.unwrap(); +/// // Arrives here when the player's state changes +/// }) +/// ``` +/// +/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that +/// resolves to an [IdleClient] instance talking to `/var/run/mpd.sock`. +/// +pub struct IdleClient { + conn: Box, +} + +impl IdleClient { + /// Create a new [mpdpopm::client::IdleClient][IdleClient] instance from something that + /// implements [ToSocketAddrs] + pub async fn connect(addr: A) -> Result { + Self::new(MpdConnection::::connect(addr).await?) + } + + pub async fn open>(pth: P) -> Result { + Self::new(MpdConnection::::connect(pth).await?) + } + + pub fn new(stream: Box) -> Result { + Ok(IdleClient { conn: stream }) + } + + /// Subscribe to an mpd channel + pub async fn subscribe(&mut self, chan: &str) -> Result<()> { + let text = self.conn.req(&format!("subscribe {}", chan)).await?; + debug!("Sent subscribe message for {}; got `{}'.", chan, text); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Connect, + msg: &text + } + ); + debug!("Subscribed to {}.", chan); + Ok(()) + } + + /// Enter idle state-- return the subsystem that changed, causing the connection to return. NB + /// this may block for some time. + pub async fn idle(&mut self) -> Result { + let text = self.conn.req("idle player message").await?; + debug!("Sent idle message; got `{}'.", text); + + // If the player state changes, we'll get: "changed: player\nOK\n" + // + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we respond + // "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + + ensure!( + text.starts_with("changed: "), + ProtocolSnafu { + op: Operation::Idle, + msg: &text + } + ); + let idx = text.find('\n').context(ProtocolSnafu { + op: Operation::Idle, + msg: text.to_owned(), + })?; + + let result = IdleSubSystem::try_from(&text[9..idx])?; + let text = text[idx + 1..].to_string(); + ensure!( + text.starts_with("OK"), + ProtocolSnafu { + op: Operation::Idle, + msg: &text + } + ); + + Ok(result) + } + + /// This method simply returns the results of a "readmessages" as a HashMap of channel name to + /// Vec of (String) messages for that channel + pub async fn get_messages(&mut self) -> Result>> { + 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> = 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 = Vec::new(); + for line in text.lines() { + match state { + State::Init => { + ensure!( + line.starts_with("channel: "), + ProtocolSnafu { + op: Operation::GetMessages, + msg: line.to_owned() + } + ); + chan = String::from(&line[9..]); + state = State::Running; + } + State::Running => { + if let Some(stripped) = line.strip_prefix("message: ") { + msgs.push(String::from(stripped)); + } else if let Some(stripped) = line.strip_prefix("channel: ") { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + chan = String::from(stripped); + msgs = Vec::new(); + } else if line == "OK" { + match m.get_mut(&chan) { + Some(v) => v.append(&mut msgs), + None => { + m.insert(chan.clone(), msgs.clone()); + } + } + state = State::Finished; + } else { + return Err(ProtocolSnafu { + op: Operation::GetMessages, + msg: text, + } + .build()); + } + } + State::Finished => { + // Should never be here! + return Err(ProtocolSnafu { + op: Operation::GetMessages, + msg: String::from(line), + } + .build()); + } + } + } + + Ok(m) + } +} + +#[cfg(test)] +/// Let's test IdleClient! +mod idle_client_tests { + + use super::test_mock::Mock; + use super::*; + + /// Some basic "smoke" tests + #[tokio::test] + async fn test_get_messages() { + let mock = Box::new(Mock::new(&[( + "readmessages", + // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we + // respond "readmessages", which should give us something like: + // + // channel: ratings + // message: 255 + // OK + // + // We remain subscribed, but we need to send a new idle message. + "channel: ratings +message: 255 +message: 128 +channel: send-to-playlist +message: foo.m3u +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("ratings").unwrap(); + assert_eq!(val.len(), 2); + let val = hm.get("send-to-playlist").unwrap(); + assert!(val.len() == 1); + } + + /// Test issue #1 + #[tokio::test] + async fn test_issue_1() { + let mock = Box::new(Mock::new(&[( + "readmessages", + "channel: playcounts +message: a +channel: playcounts +message: b +OK +", + )])); + let mut cli = IdleClient::new(mock).unwrap(); + let hm = cli.get_messages().await.unwrap(); + let val = hm.get("playcounts").unwrap(); + assert_eq!(val.len(), 2); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs new file mode 100644 index 00000000..da8e63be --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/config.rs @@ -0,0 +1,275 @@ +// Copyright (C) 2021-2025 Michael herstine +// +// 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 . + +//! # mpdpopm Configuration +//! +//! ## Introduction +//! +//! This module defines the configuration struct & handles deserialization thereof. +//! +//! ## Discussion +//! +//! In the first releases of [mpdpopm](crate) I foolishly forgot to add a version field to the +//! configuration structure. I am now paying for my sin by having to attempt serializing two +//! versions until one succeeds. +//! +//! The idiomatic approach to versioning [serde](https://docs.serde.rs/serde/) structs seems to be +//! using an +//! [enumeration](https://www.reddit.com/r/rust/comments/44dds3/handling_multiple_file_versions_with_serde_or/). This +//! implementation *now* uses that, but that leaves us with the problem of handling the initial, +//! un-tagged version. I proceed as follows: +//! +//! 1. attempt to deserialize as a member of the modern enumeration +//! 2. if that succeeds, with the most-recent version, we're good +//! 3. if that succeeds with an archaic version, convert to the most recent and warn the user +//! 4. if that fails, attempt to deserialize as the initial struct version +//! 5. if that succeeds, convert to the most recent & warn the user +//! 6. if that fails, I'm kind of stuck because I don't know what the user was trying to express; +//! bundle-up all the errors, report 'em & urge the user to use the most recent version +use crate::vars::{LOCALSTATEDIR, PREFIX}; + +use serde::{Deserialize, Serialize}; + +use std::path::PathBuf; + +/// [mpdpopm](crate) can communicate with MPD over either a local Unix socket, or over regular TCP +#[derive(Debug, Deserialize, PartialEq, Serialize)] +pub enum Connection { + /// Local Unix socket-- payload is the path to the socket + Local { path: PathBuf }, + /// TCP-- payload is the hostname & port number + TCP { host: String, port: u16 }, +} + +#[cfg(test)] +mod test_connection { + use super::Connection; + + #[test] + fn test_serde() { + use serde_lexpr::to_string; + + use std::path::PathBuf; + + let text = to_string(&Connection::Local { + path: PathBuf::from("/var/run/mpd.sock"), + }) + .unwrap(); + assert_eq!( + text, + String::from(r#"(Local (path . "/var/run/mpd.sock"))"#) + ); + let text = to_string(&Connection::TCP { + host: String::from("localhost"), + port: 6600, + }) + .unwrap(); + assert_eq!( + text, + String::from(r#"(TCP (host . "localhost") (port . 6600))"#) + ); + } +} + +impl std::default::Default for Connection { + fn default() -> Self { + Connection::TCP { + host: String::from("localhost"), + port: 6600, + } + } +} + +/// This is the most recent `mppopmd` configuration struct. +#[derive(Deserialize, Debug, Serialize)] +#[serde(default)] +pub struct Config { + /// Configuration format version-- must be "1" + // Workaround to https://github.com/rotty/lexpr-rs/issues/77 + // When this gets fixed, I can remove this element from the struct & deserialize as + // a Configurations element-- the on-disk format will be the same. + #[serde(rename = "version")] + _version: String, + + /// Location of log file + pub log: PathBuf, + + /// How to connect to mpd + pub conn: Connection, + + /// The `mpd' root music directory, relative to the host on which *this* daemon is running + pub local_music_dir: PathBuf, + + /// Percentage threshold, expressed as a number between zero & one, for considering a song to + /// have been played + pub played_thresh: f64, + + /// The interval, in milliseconds, at which to poll `mpd' for the current state + pub poll_interval_ms: u64, + + /// Channel to setup for assorted commands-- channel names must satisfy "[-a-zA-Z-9_.:]+" + pub commands_chan: String, +} + +impl Default for Config { + fn default() -> Config { + Config { + _version: String::from("1"), + log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(), + conn: Connection::default(), + local_music_dir: [PREFIX, "Music"].iter().collect(), + played_thresh: 0.6, + poll_interval_ms: 5000, + commands_chan: String::from("unwoundstack.com:commands"), + } + } +} + +#[derive(Debug)] +pub enum Error { + /// Failure to parse + ParseFail { err: Box }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::ParseFail { err } => write!(f, "Parse failure: {}", err), + _ => write!(f, "Unknown configuration error"), + } + } +} + +pub type Result = std::result::Result; + +pub fn from_str(text: &str) -> Result { + let cfg: Config = match serde_lexpr::from_str(text) { + Ok(cfg) => cfg, + Err(err_outer) => { + return Err(Error::ParseFail { + err: Box::new(err_outer), + }); + } + }; + Ok(cfg) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_from_str() { + let cfg = Config::default(); + assert_eq!(cfg.commands_chan, String::from("unwoundstack.com:commands")); + + assert_eq!( + serde_lexpr::to_string(&cfg).unwrap(), + format!( + r#"((version . "1") (log . "{}/log/mppopmd.log") (conn TCP (host . "localhost") (port . 6600)) (local_music_dir . "{}/Music") (playcount_sticker . "unwoundstack.com:playcount") (lastplayed_sticker . "unwoundstack.com:lastplayed") (played_thresh . 0.6) (poll_interval_ms . 5000) (commands_chan . "unwoundstack.com:commands") (playcount_command . "") (playcount_command_args) (rating_sticker . "unwoundstack.com:rating") (ratings_command . "") (ratings_command_args) (gen_cmds))"#, + LOCALSTATEDIR, PREFIX + ) + ); + + let cfg: Config = serde_lexpr::from_str( + r#" +((version . "1") + (log . "/usr/local/var/log/mppopmd.log") + (conn TCP (host . "localhost") (port . 6600)) + (local_music_dir . "/usr/local/Music") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (commands_chan . "unwoundstack.com:commands") + (playcount_command . "") + (playcount_command_args) + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "") + (ratings_command_args) + (gen_cmds)) +"#, + ) + .unwrap(); + assert_eq!(cfg._version, String::from("1")); + + let cfg: Config = serde_lexpr::from_str( + r#" +((version . "1") + (log . "/usr/local/var/log/mppopmd.log") + (conn Local (path . "/home/mgh/var/run/mpd/mpd.sock")) + (local_music_dir . "/usr/local/Music") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (commands_chan . "unwoundstack.com:commands") + (playcount_command . "") + (playcount_command_args) + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "") + (ratings_command_args) + (gen_cmds)) +"#, + ) + .unwrap(); + assert_eq!(cfg._version, String::from("1")); + assert_eq!( + cfg.conn, + Connection::Local { + path: PathBuf::from("/home/mgh/var/run/mpd/mpd.sock") + } + ); + + // Test fallback to "v0" of the config struct + let cfg = from_str(r#" +((log . "/home/mgh/var/log/mppopmd.log") + (host . "192.168.1.14") + (port . 6600) + (local_music_dir . "/space/mp3") + (playcount_sticker . "unwoundstack.com:playcount") + (lastplayed_sticker . "unwoundstack.com:lastplayed") + (played_thresh . 0.6) + (poll_interval_ms . 5000) + (playcount_command . "/usr/local/bin/scribbu") + (playcount_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-C" "%playcount" "%full-file")) + (commands_chan . "unwoundstack.com:commands") + (rating_sticker . "unwoundstack.com:rating") + (ratings_command . "/usr/local/bin/scribbu") + (ratings_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-r" "%rating" "%full-file")) + (gen_cmds . + (((name . "set-genre") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("genre" "-a" "-C" "-g" "%1" "%full-file")) + (update . TrackOnly)) + ((name . "set-xtag") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("xtag" "-A" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file")) + (update . TrackOnly)) + ((name . "merge-xtag") + (formal_parameters . (Literal Track)) + (default_after . 1) + (cmd . "/usr/local/bin/scribbu") + (args . ("xtag" "-m" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file")) + (update . TrackOnly))))) +"#).unwrap(); + assert_eq!(cfg.log, PathBuf::from("/home/mgh/var/log/mppopmd.log")); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop new file mode 100644 index 00000000..a591a3ba --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop @@ -0,0 +1,143 @@ +// Copyright (C) 2020-2025 Michael Herstine -*- 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 . + +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 = { + =>? { + eprintln!("matched token: ``{}''.", s); + // We need to yield a Result + match s.parse::() { + Ok(n) => Ok(Value::Uint(n)), + Err(_) => Err(ParseError::User { + error: "Internal parse error while parsing unsigned int" }) + } + }, + ,./?]|\\\\|\\"|\\')+""#> => { + 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), + } + }, + ,./?]|\\\\|\\'|\\")+'"#> => { + 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 = { + => { + eprintln!("matched unary condition: ``({}, {:#?})''", t, u); + Box::new(Term::UnaryCondition(t, u)) + }, + => { + eprintln!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u); + Box::new(Term::BinaryCondition(t, o, u)) + }, +} + +pub Conjunction: Box = { + "AND" => { + eprintln!("matched conjunction: ``({:#?}, {:#?})''", e1, e2); + Box::new(Conjunction::Simple(e1, e2)) + }, + "AND" => { + eprintln!("matched conjunction: ``({:#?}, {:#?})''", c, e); + Box::new(Conjunction::Compound(c, e)) + }, +} + +pub Disjunction: Box = { + "OR" => { + eprintln!("matched disjunction: ``({:#?}, {:#?})''", e1, e2); + Box::new(Disjunction::Simple(e1, e2)) + }, + "OR" => { + eprintln!("matched disjunction: ``({:#?}, {:#?})''", c, e); + Box::new(Disjunction::Compound(c, e)) + }, +} + +pub Expression: Box = { + "(" ")" => { + eprintln!("matched parenthesized term: ``({:#?})''", t); + Box::new(Expression::Simple(t)) + }, + "(" "!" ")" => Box::new(Expression::Negation(e)), + "(" ")" => { + eprintln!("matched parenthesized conjunction: ``({:#?})''", c); + Box::new(Expression::Conjunction(c)) + }, + "(" ")" => { + eprintln!("matched parenthesized disjunction: ``({:#?})''", c); + Box::new(Expression::Disjunction(c)) + }, +} diff --git a/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs new file mode 100644 index 00000000..15f249fb --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs @@ -0,0 +1,1166 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! Types for building the Abstract Syntax Tree when parsing filters +//! +//! This module provides support for our [lalrpop](https://github.com/lalrpop/lalrpop) grammar. + +use crate::clients::Client; +use crate::storage::{last_played, play_count, rating_count}; + +use backtrace::Backtrace; +use boolinator::Boolinator; +use chrono::prelude::*; +use tracing::debug; + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +/// The operations that can appear in a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum OpCode { + Equality, + Inequality, + Contains, + RegexMatch, + RegexExclude, + GreaterThan, + LessThan, + GreaterThanEqual, + LessThanEqual, +} + +impl std::fmt::Display for OpCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + OpCode::Equality => "==", + OpCode::Inequality => "!=", + OpCode::Contains => "contains", + OpCode::RegexMatch => "=~", + OpCode::RegexExclude => "!~", + OpCode::GreaterThan => ">", + OpCode::LessThan => "<", + OpCode::GreaterThanEqual => ">=", + OpCode::LessThanEqual => "<=", + } + ) + } +} + +/// The song attributes that can appear on the LHS of a filter term +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Selector { + Artist, + Album, + AlbumArtist, + Title, + Track, + Name, + Genre, + Date, + OriginalDate, + Composer, + Performer, + Conductor, + Work, + Grouping, + Comment, + Disc, + Label, + MusicbrainzAristID, + MusicbrainzAlbumID, + MusicbrainzAlbumArtistID, + MusicbrainzTrackID, + MusicbrainzReleaseTrackID, + MusicbrainzWorkID, + File, + Base, + ModifiedSince, + AudioFormat, + Rating, + PlayCount, + LastPlayed, +} + +impl std::fmt::Display for Selector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Selector::Artist => "artist", + Selector::Album => "album", + Selector::AlbumArtist => "albumartist", + Selector::Title => "title", + Selector::Track => "track", + Selector::Name => "name", + Selector::Genre => "genre", + Selector::Date => "date", + Selector::OriginalDate => "originaldate", + Selector::Composer => "composer", + Selector::Performer => "performer", + Selector::Conductor => "conductor", + Selector::Work => "work", + Selector::Grouping => "grouping", + Selector::Comment => "comment", + Selector::Disc => "disc", + Selector::Label => "label", + Selector::MusicbrainzAristID => "musicbrainz_aristid", + Selector::MusicbrainzAlbumID => "musicbrainz_albumid", + Selector::MusicbrainzAlbumArtistID => "musicbrainz_albumartistid", + Selector::MusicbrainzTrackID => "musicbrainz_trackid", + Selector::MusicbrainzReleaseTrackID => "musicbrainz_releasetrackid", + Selector::MusicbrainzWorkID => "musicbrainz_workid", + Selector::File => "file", + Selector::Base => "base", + Selector::ModifiedSince => "modified-since", + Selector::AudioFormat => "AudioFormat", + Selector::Rating => "rating", + Selector::PlayCount => "playcount", + Selector::LastPlayed => "lastplayed", + } + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Text(String), + UnixEpoch(i64), + Uint(usize), +} + +fn quote_value(x: &Value) -> String { + match x { + Value::Text(s) => { + let mut ret = String::new(); + + ret.push('"'); + for c in s.chars() { + if c == '"' || c == '\\' { + ret.push('\\'); + } + ret.push(c); + } + ret.push('"'); + ret + } + Value::UnixEpoch(n) => { + format!("'{}'", n) + } + Value::Uint(n) => { + format!("'{}'", n) + } + } +} + +#[derive(Clone, Debug)] +pub enum Term { + UnaryCondition(Selector, Value), + BinaryCondition(Selector, OpCode, Value), +} + +#[derive(Clone, Debug)] +pub enum Conjunction { + Simple(Box, Box), + Compound(Box, Box), +} + +#[derive(Clone, Debug)] +pub enum Disjunction { + Simple(Box, Box), + Compound(Box, Box), +} + +#[derive(Clone, Debug)] +pub enum Expression { + Simple(Box), + Negation(Box), + Conjunction(Box), + Disjunction(Box), +} + +#[cfg(test)] +mod smoke_tests { + + use super::*; + use crate::filters::*; + + #[test] + fn test_opcodes() { + assert!(ExprOpParser::new().parse("==").unwrap() == OpCode::Equality); + assert!(ExprOpParser::new().parse("!=").unwrap() == OpCode::Inequality); + assert!(ExprOpParser::new().parse("contains").unwrap() == OpCode::Contains); + assert!(ExprOpParser::new().parse("=~").unwrap() == OpCode::RegexMatch); + assert!(ExprOpParser::new().parse("!~").unwrap() == OpCode::RegexExclude); + assert!(ExprOpParser::new().parse(">").unwrap() == OpCode::GreaterThan); + assert!(ExprOpParser::new().parse("<").unwrap() == OpCode::LessThan); + assert!(ExprOpParser::new().parse(">=").unwrap() == OpCode::GreaterThanEqual); + assert!(ExprOpParser::new().parse("<=").unwrap() == OpCode::LessThanEqual); + } + + #[test] + fn test_conditions() { + assert!(TermParser::new().parse("base 'foo'").is_ok()); + assert!(TermParser::new().parse("artist == 'foo'").is_ok()); + assert!( + TermParser::new() + .parse(r#"artist =~ "foo bar \"splat\"!""#) + .is_ok() + ); + assert!(TermParser::new().parse("artist =~ 'Pogues'").is_ok()); + + match *TermParser::new() + .parse(r#"base "/Users/me/My Music""#) + .unwrap() + { + Term::UnaryCondition(a, b) => { + assert!(a == Selector::Base); + assert!(b == Value::Text(String::from(r#"/Users/me/My Music"#))); + } + _ => { + unreachable!(); + } + } + + match *TermParser::new() + .parse(r#"artist =~ "foo bar \"splat\"!""#) + .unwrap() + { + Term::BinaryCondition(t, op, s) => { + assert!(t == Selector::Artist); + assert!(op == OpCode::RegexMatch); + assert!(s == Value::Text(String::from(r#"foo bar "splat"!"#))); + } + _ => { + unreachable!(); + } + } + } + + #[test] + fn test_expressions() { + assert!(ExpressionParser::new().parse("( base 'foo' )").is_ok()); + assert!(ExpressionParser::new().parse("(base \"foo\")").is_ok()); + assert!( + ExpressionParser::new() + .parse("(!(artist == 'value'))") + .is_ok() + ); + assert!( + ExpressionParser::new() + .parse(r#"((!(artist == "foo bar")) AND (base "/My Music"))"#) + .is_ok() + ); + } + + #[test] + fn test_quoted_expr() { + eprintln!("test_quoted_expr"); + assert!( + ExpressionParser::new() + .parse(r#"(artist =~ "foo\\bar\"")"#) + .is_ok() + ); + } + + #[test] + fn test_real_expression() { + let result = ExpressionParser::new() + .parse(r#"(((Artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues')) AND ((rating > 128) OR (rating == 0)))"#); + eprintln!("{:#?}", result); + assert!(result.is_ok()); + } + + #[test] + fn test_conjunction() { + assert!(ExpressionParser::new() + .parse( + r#"((base "foo") AND (artist == "foo bar") AND (!(file == '/net/mp3/A/a.mp3')))"# + ) + .is_ok()); + + eprintln!("=============================================================================="); + eprintln!("{:#?}", ExpressionParser::new() + .parse( + r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == "/net/mp3/A/a.mp3")) OR (file == "/pub/mp3/A/a.mp3")))"# + )); + assert!(ExpressionParser::new() + .parse( + r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == '/net/mp3/A/a.mp3')) OR (file == '/pub/mp3/A/a.mp3')))"# + ) + .is_ok()); + } + + #[test] + fn test_disjunction() { + assert!(ExpressionParser::new(). + parse(r#"((artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues'))"#) + .is_ok()); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// evaluation logic // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum EvalOp { + And, + Or, + Not, +} + +impl std::fmt::Display for EvalOp { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + EvalOp::And => write!(f, "And"), + EvalOp::Or => write!(f, "Or"), + EvalOp::Not => write!(f, "Not"), + } + } +} + +#[derive(Debug)] +pub enum Error { + BadISO8601String { + text: Vec, + back: Backtrace, + }, + ExpectQuoted { + text: String, + back: Backtrace, + }, + FilterTypeErr { + text: String, + back: Backtrace, + }, + InvalidOperand { + op: OpCode, + back: Backtrace, + }, + OperatorOnStack { + op: EvalOp, + back: Backtrace, + }, + RatingOverflow { + rating: usize, + back: Backtrace, + }, + TooManyOperands { + num_ops: usize, + back: Backtrace, + }, + NumericParse { + sticker: String, + source: std::num::ParseIntError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadISO8601String { text, back: _ } => { + write!(f, "Bad ISO8601 timestamp: ``{:?}''", text) + } + Error::ExpectQuoted { text, back: _ } => write!(f, "Expected quote: ``{}''", text), + Error::FilterTypeErr { text, back: _ } => { + write!(f, "Un-expected type in filter ``{}''", text) + } + Error::InvalidOperand { op, back: _ } => write!(f, "Invalid operand {}", op), + Error::OperatorOnStack { op, back: _ } => { + write!(f, "Operator {} left on parse stack", op) + } + Error::RatingOverflow { rating, back: _ } => write!(f, "Rating {} overflows", rating), + Error::TooManyOperands { num_ops, back: _ } => { + write!(f, "Too many operands ({})", num_ops) + } + Error::NumericParse { + sticker, + source, + back: _, + } => write!(f, "While parsing sticker {}, got {}", sticker, source), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::NumericParse { + sticker: _, + source, + back: _, + } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +pub type Result = std::result::Result; + +fn peek(buf: &[u8]) -> Option { + match buf.len() { + 0 => None, + _ => Some(buf[0] as char), + } +} + +// advancing a slice by `i` indicies can *not* be this difficult +/// Pop a single byte off of `buf` +fn take1(buf: &mut &[u8], i: usize) -> Result<()> { + if i > buf.len() { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let (_first, second) = buf.split_at(i); + *buf = second; + Ok(()) +} + +/// Pop `i` bytes off of `buf` & parse them as a T +fn take2(buf: &mut &[u8], i: usize) -> Result +where + T: FromStr, +{ + // 1. check len + if i > buf.len() { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let (first, second) = buf.split_at(i); + *buf = second; + // 2. convert to a string + let s = std::str::from_utf8(first).map_err(|_| Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })?; + // 3. parse as a T + s.parse::().map_err(|_err| Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }) // Parse*Error => Error +} + +/// Parse a timestamp in ISO 8601 format to a chrono DateTime instance +/// +/// Surprisingly, I was unable to find an ISO 8601 parser in Rust. I *did* find a crate named +/// iso-8601 that promised to do this, but it seemed derelict & I couldn't see what to do with the +/// parse output in any event. The ISO 8601 format is simple enough that I've chosen to simply +/// hand-parse it. +pub fn parse_iso_8601(buf: &mut &[u8]) -> Result { + // I wonder if `nom` would be a better choice? + + // The first four characters must be the year (expanded year representation is not supported by + // this parser). + + let year: i32 = take2(buf, 4)?; + + // Now at this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // - day & month := 0, consume the 'T', move on to parsing the time + // 3. we may have a month in extended format (i.e. peek(buf) => Some('-') + // - consume the '-', parse the month & move on to parsing the day + // 4. we may have a month in basic format (take(buf, 2) => Some('\d\d') + // - parse the month & move on to parsing the day + let mut month = 1; + let mut day = 1; + let mut hour = 0; + let mut minute = 0; + let mut second = 0; + if !buf.is_empty() { + let next = peek(buf); + if next != Some('T') { + let mut ext_fmt = false; + if next == Some('-') { + take1(buf, 1)?; + ext_fmt = true; + } + month = take2(buf, 2)?; + + // At this point: + // 1. we may be done (i.e. buf.len() == 0) + // 2. we may have the timestamp (peek(buf) => Some('T')) + // 3. we may have the day (in basic or extended format) + if !buf.is_empty() && peek(buf) != Some('T') { + if ext_fmt { + take1(buf, 1)?; + } + day = take2(buf, 2)?; + } + } + + // Parse time: at this point, buf will either be empty or begin with 'T' + if !buf.is_empty() { + take1(buf, 1)?; + // If there's a T, there must at least be an hour + hour = take2(buf, 2)?; + if !buf.is_empty() { + let mut ext_fmt = false; + if peek(buf) == Some(':') { + take1(buf, 1)?; + ext_fmt = true; + } + minute = take2(buf, 2)?; + if !buf.is_empty() { + if ext_fmt { + take1(buf, 1)?; + } + second = take2(buf, 2)?; + } + } + } + + // At this point, there may be a timezone + if !buf.is_empty() { + if peek(buf) == Some('Z') { + return Ok(Utc + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } else { + let next = peek(buf); + if next != Some('-') && next != Some('+') { + return Err(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + }); + } + let west = next == Some('-'); + take1(buf, 1)?; + + let hours: i32 = take2(buf, 2)?; + let mut minutes = 0; + + if !buf.is_empty() { + if peek(buf) == Some(':') { + take1(buf, 1)?; + } + minutes = take2(buf, 2)?; + } + + if west { + return Ok(FixedOffset::west_opt(hours * 3600 + minutes * 60) + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } else { + return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60) + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()); + } + } + } + } + Ok(Local + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single() + .ok_or(Error::BadISO8601String { + text: buf.to_vec(), + back: Backtrace::new(), + })? + .timestamp()) +} + +#[cfg(test)] +mod iso_8601_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let mut b = "19700101T00:00:00Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 0); + + let mut b = "19700101T00:00:01Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert!(t == 1); + + let mut b = "20210327T02:26:53Z".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616812013); + + let mut b = "20210327T07:29:05-07:00".as_bytes(); + let t = parse_iso_8601(&mut b).unwrap(); + assert_eq!(t, 1616855345); + + let mut b = "2021".as_bytes(); + // Should resolve to midnight, Jan 1 2021 in local time; don't want to test against the + // timestamp; just make sure it parses + parse_iso_8601(&mut b).unwrap(); + } +} + +/// "Un-quote" a token +/// +/// Textual tokens must be quoted, and double-quote & backslashes within backslash-escaped. If the +/// string is quoted with single-quotes, then any single-quotes inside the string will also need +/// to be escaped. +/// +/// In fact, *any* characters within may be harmlessly backslash escaped; the MPD implementation +/// walks the the string, skipping backslashes as it goes, so this implementation will do the same. +/// I have named this method in imitation of the corresponding MPD function. +pub fn expect_quoted(qtext: &str) -> Result { + let mut iter = qtext.chars(); + let quote = iter.next(); + if quote.is_none() { + return Ok(String::new()); + } + + if quote != Some('\'') && quote != Some('"') { + return Err(Error::ExpectQuoted { + text: String::from(qtext), + back: Backtrace::new(), + }); + } + + let mut ret = String::new(); + + // Walk qtext[1..]; copying characters to `ret'. If a '\' is found, skip to the next character + // (even if that is a '\'). The last character in qtext should be the closing quote. + let mut this = iter.next(); + while this != quote { + if this == Some('\\') { + this = iter.next(); + } + match this { + Some(c) => ret.push(c), + None => { + return Err(Error::ExpectQuoted { + text: String::from(qtext), + back: Backtrace::new(), + }); + } + } + this = iter.next(); + } + + Ok(ret) +} + +#[cfg(test)] +mod quoted_tests { + + use super::*; + + #[test] + fn smoke_tests() { + let b = r#""foo bar \"splat!\"""#; + let s = expect_quoted(b).unwrap(); + assert!(s == r#"foo bar "splat!""#); + } +} + +/// Create a closure that will carry out an operator on its argument +/// +/// Call this function with an [OpCode] and a value of type `T`. `T` must be [PartialEq], +/// [`PartialOrd`] and [`Copy`]-- an integral type will do. It will return a closure that will carry +/// out the given [OpCode] against the given value. For instance, +/// `make_numeric_closure::(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 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 bool>), + OpCode::Inequality => Ok(Box::new(move |x: T| x != val) as Box bool>), + OpCode::GreaterThan => Ok(Box::new(move |x: T| x > val) as Box bool>), + OpCode::LessThan => Ok(Box::new(move |x: T| x < val) as Box bool>), + OpCode::GreaterThanEqual => Ok(Box::new(move |x: T| x >= val) as Box bool>), + OpCode::LessThanEqual => Ok(Box::new(move |x: T| x <= val) as Box bool>), + _ => Err(Error::InvalidOperand { + op, + back: Backtrace::new(), + }), + } +} + +async fn eval_numeric_sticker_term< + // The `FromStr' trait bound is really weird, but if I don't constrain the associated + // Err type to be `ParseIntError' the compiler complains about not being able to convert + // it to type `Error'. I'm probably still "thinking in C++" and imagining the compiler + // instantiating this function for each type (u8, usize, &c) instead of realizing that the Rust + // compiler is processing this as a first-class function. + // + // For instance, I can do the conversion manually, so long as I constrain the Err type + // to implement std::error::Error. I should probably be doing that, but it clutters the + // code. I'll figure it out when I need to extend this function to handle non-integral types + // :) + T: PartialEq + PartialOrd + Copy + FromStr + std::fmt::Display, +>( + sticker: &str, + client: &mut Client, + op: OpCode, + numeric_val: T, + default_val: T, +) -> Result> { + let cmp = make_numeric_closure(op, numeric_val)?; + // It would be better to idle on the sticker DB & just update our collection on change, but for + // a first impl. this will do. + // + // Call `get_stickers'; this will return a HashMap from song URIs to ratings expressed as text + // (as all stickers are). This stanza will drain that collection into a new one with the ratings + // expressed as T. + // + // The point is that conversion from text to rating, lastplayed, or whatever can fail; the + // invocation of `collect' will call `from_iter' to convert a collection of Result-s to a Result + // of a collection. + let mut m = client + .get_stickers(sticker) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain() + .map(|(k, v)| v.parse::().map(|x| (k, x))) + .collect::, _>>() + .map_err(|err| Error::NumericParse { + sticker: String::from(sticker), + source: err, + back: Backtrace::new(), + })?; + // `m' is now a map of song URI to rating/playcount/wathever (expressed as a T)... for all songs + // that have the salient sticker. + // + // This seems horribly inefficient, but I'm going to fetch all the song URIs in the music DB, + // and augment `m' with entries of `default_val' for any that are not already there. + client + .get_all_songs() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .for_each(|song| { + m.entry(song).or_insert(default_val); + }); + // Now that we don't have to worry about operations that can fail, we can use + // `filter_map'. + Ok(m.drain() + .filter_map(|(k, v)| cmp(v).as_some(k)) + .collect::>()) +} + +/// Convenience struct collecting the names for assorted stickers on which one may search +/// +/// While the search terms 'rating', 'playcount' &c are fixed & part of the filter grammar offered +/// by mpdpopm, the precise names of the corresponding stickers are configurable & hence must be +/// passed in. Three references to str is already unweildy IMO, and since I expect the number of +/// stickers on which one can search to grow further, I decided to wrap 'em up in a struct. The +/// lifetime is there to support the caller just using a reference to an existing string rather than +/// making a copy. +pub struct FilterStickerNames<'a> { + rating: &'a str, + playcount: &'a str, + lastplayed: &'a str, +} + +impl<'a> FilterStickerNames<'a> { + pub fn new() -> FilterStickerNames<'a> { + FilterStickerNames { + rating: rating_count::STICKER, + playcount: play_count::STICKER, + lastplayed: last_played::STICKER, + } + } +} + +/// Evaluate a Term +/// +/// Take a Term from the Abstract Syntax tree & resolve it to a collection of song URIs. Set `case` +/// to `true` to search case-sensitively & `false` to make the search case-insensitive. +async fn eval_term<'a>( + term: &Term, + case: bool, + client: &mut Client, + stickers: &FilterStickerNames<'a>, +) -> Result> { + match term { + Term::UnaryCondition(op, val) => Ok(client + .find1(&format!("{}", op), "e_value(val), case) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .collect()), + Term::BinaryCondition(attr, op, val) => { + if *attr == Selector::Rating { + match val { + Value::Uint(n) => { + if *n > 255 { + return Err(Error::RatingOverflow { + rating: *n, + back: Backtrace::new(), + }); + } + Ok( + eval_numeric_sticker_term(stickers.rating, client, *op, *n as u8, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else if *attr == Selector::PlayCount { + match val { + Value::Uint(n) => { + Ok( + eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else if *attr == Selector::LastPlayed { + match val { + Value::UnixEpoch(t) => { + Ok( + eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0) + .await?, + ) + } + _ => Err(Error::FilterTypeErr { + text: format!("filter ratings expect an unsigned int; got {:#?}", val), + back: Backtrace::new(), + }), + } + } else { + Ok(client + .find2( + &format!("{}", attr), + &format!("{}", op), + "e_value(val), + case, + ) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .collect()) + } + } + } +} + +/// The evaluation stack contains logical operators & sets of song URIs +#[derive(Debug)] +enum EvalStackNode { + Op(EvalOp), + Result(HashSet), +} + +async fn negate_result( + res: &HashSet, + client: &mut Client, +) -> std::result::Result, Error> { + Ok(client + .get_all_songs() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + .drain(..) + .filter_map(|song| { + // Some(thing) adds thing, None elides it + if !res.contains(&song) { + Some(song) + } else { + None + } + }) + .collect::>()) +} + +/// 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, 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> { + // 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>, so &**bc is &Conjunction<'a> + Expression::Conjunction(bc) => { + let mut conj = &**bc; + loop { + match conj { + Conjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**ber); + sp.push(&**bel); + break; + } + Conjunction::Compound(bc, be) => { + se.push(EvalStackNode::Op(EvalOp::And)); + sp.push(&**be); + conj = bc; + } + } + } + } + Expression::Disjunction(bt) => { + let mut disj = &**bt; + loop { + match disj { + Disjunction::Simple(bel, ber) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(ber); + sp.push(bel); + break; + } + Disjunction::Compound(bd, be) => { + se.push(EvalStackNode::Op(EvalOp::Or)); + sp.push(&**be); + disj = bd; + } + } + } + } + } + + reduce(&mut se, client).await?; + } + + // At this point, sp is empty, but there had better be something on se. Keep reducing the stack + // until either we can't any further (in which case we error) or there is only one element left + // (in which case we return that). + reduce(&mut se, client).await?; + + // Now, se had better have one element, and that element had better be a Result. + if 1 != se.len() { + debug!("Too many ({}) operands left on stack:", se.len()); + se.iter() + .enumerate() + .for_each(|(i, x)| debug!(" {}: {:#?}", i, x)); + return Err(Error::TooManyOperands { + num_ops: se.len(), + back: Backtrace::new(), + }); + } + + let ret = se.pop().unwrap(); + match ret { + EvalStackNode::Result(result) => Ok(result), + EvalStackNode::Op(op) => { + debug!("Operator left on stack (!?): {:#?}", op); + Err(Error::OperatorOnStack { + op, + back: Backtrace::new(), + }) + } + } +} + +#[cfg(test)] +mod evaluation_tests { + + use super::*; + use crate::filters::*; + + use crate::clients::Client; + use crate::clients::test_mock::Mock; + + #[tokio::test] + async fn smoke() { + let mock = Box::new(Mock::new(&[( + r#"find "(base \"foo\")""#, + "file: foo/a.mp3 +Artist: The Foobars +file: foo/b.mp3 +Title: b! +OK", + )])); + let mut cli = Client::new(mock).unwrap(); + + let stickers = FilterStickerNames::new("rating", "playcount", "lastplayed"); + + let expr = ExpressionParser::new().parse(r#"(base "foo")"#).unwrap(); + let result = evaluate(&expr, true, &mut cli, &stickers).await; + assert!(result.is_ok()); + + let g: HashSet = ["foo/a.mp3", "foo/b.mp3"] + .iter() + .map(|x| x.to_string()) + .collect(); + assert!(result.unwrap() == g); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs new file mode 100644 index 00000000..26645228 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs @@ -0,0 +1,274 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! # mpdpopm +//! +//! Maintain ratings & playcounts for your mpd server. +//! +//! # Introduction +//! +//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). +//! +//! # Commands +//! +//! I'm currently sending all commands over one (configurable) channel. +//! + +#![recursion_limit = "512"] // for the `select!' macro + +pub mod clients; +pub mod config; +pub mod filters_ast; +pub mod messages; +pub mod playcounts; +pub mod ratings; +pub mod storage; +pub mod vars; + +#[rustfmt::skip] +#[allow(clippy::extra_unused_lifetimes)] +#[allow(clippy::needless_lifetimes)] +#[allow(clippy::let_unit_value)] +#[allow(clippy::just_underscores_and_digits)] +pub mod filters { + include!(concat!(env!("OUT_DIR"), "/src/filters.rs")); +} + +use clients::{Client, IdleClient, IdleSubSystem}; +use config::Config; +use config::Connection; +use filters_ast::FilterStickerNames; +use messages::MessageProcessor; +use playcounts::PlayState; + +use backtrace::Backtrace; +use futures::{future::FutureExt, pin_mut, select}; +use tokio::{ + signal, + signal::unix::{SignalKind, signal}, + time::{Duration, sleep}, +}; +use tracing::{debug, error, info}; + +use std::path::PathBuf; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + BadPath { + pth: PathBuf, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, + Playcounts { + source: crate::playcounts::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +pub type Result = std::result::Result; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// Core `mppopmd' logic +pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> { + info!("mpdpopm {} beginning.", vars::VERSION); + + let filter_stickers = FilterStickerNames::new(); + + let mut client = match cfg.conn { + Connection::Local { ref path } => { + Client::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + let mut state = PlayState::new(&mut client, cfg.played_thresh) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + let mut idle_client = match cfg.conn { + Connection::Local { ref path } => { + IdleClient::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + idle_client + .subscribe(&cfg.commands_chan) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + let mut hup = signal(SignalKind::hangup()).unwrap(); + let mut kill = signal(SignalKind::terminate()).unwrap(); + let ctrl_c = signal::ctrl_c().fuse(); + + let sighup = hup.recv().fuse(); + let sigkill = kill.recv().fuse(); + + let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse(); + pin_mut!(ctrl_c, sighup, sigkill, tick); + + let mproc = MessageProcessor::new(); + + let mut done = false; + while !done { + debug!("selecting..."); + let mut msg_check_needed = false; + { + // `idle_client' mutably borrowed here + let mut idle = Box::pin(idle_client.idle().fuse()); + loop { + select! { + _ = ctrl_c => { + info!("got ctrl-C"); + done = true; + break; + }, + _ = sighup => { + info!("got SIGHUP"); + done = true; + break; + }, + _ = sigkill => { + info!("got SIGKILL"); + done = true; + break; + }, + _ = tick => { + tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse()); + state.update(&mut client) + .await + .map_err(|err| Error::Playcounts { + source: err, + back: Backtrace::new() + })? + }, + // next = cmds.next() => match next { + // Some(out) => { + // debug!("output status is {:#?}", out.out); + // match out.upd { + // Some(uri) => { + // debug!("{} needs to be updated", uri); + // client.update(&uri).await.map_err(|err| Error::Client { + // source: err, + // back: Backtrace::new(), + // })?; + // }, + // None => debug!("No database update needed"), + // } + // }, + // None => { + // debug!("No more commands to process."); + // } + // }, + res = idle => match res { + Ok(subsys) => { + debug!("subsystem {} changed", subsys); + if subsys == IdleSubSystem::Player { + state.update(&mut client) + .await + .map_err(|err| Error::Playcounts { + source: err, + back: Backtrace::new() + })? + } else if subsys == IdleSubSystem::Message { + msg_check_needed = true; + } + break; + }, + Err(err) => { + debug!("error {err:#?} on idle"); + done = true; + break; + } + } + } + } + } + + if msg_check_needed { + // Check for any messages that have come in; if there's an error there's not a lot we + // can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it + // & move on. + if let Err(err) = mproc + .check_messages( + &mut client, + &mut idle_client, + state.last_status(), + &cfg.commands_chan, + &filter_stickers, + ) + .await + { + error!("Error while processing messages: {err:#?}"); + } + } + } + + info!("mpdpopm exiting."); + + Ok(()) +} diff --git a/pkgs/by-name/mp/mpdpopm/src/messages.rs b/pkgs/by-name/mp/mpdpopm/src/messages.rs new file mode 100644 index 00000000..85b24470 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/messages.rs @@ -0,0 +1,732 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! # messages +//! +//! Process incoming messages to the [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon. +//! +//! # Introduction +//! +//! The [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon accepts commands over a dedicated +//! [channel](https://www.musicpd.org/doc/html/protocol.html#client-to-client). It also provides for +//! a generalized framework in which the [mpdpopm](https://github.com/sp1ff/mpdpopm) administrator +//! can define new commands backed by arbitrary command execution server-side. +//! +//! # Commands +//! +//! The following commands are built-in: +//! +//! - set rating: `rate RATING( TRACK)?` +//! - set playcount: `setpc PC( TRACK)?` +//! - set lastplayed: `setlp TIMESTAMP( TRACK)?` +//! +//! There is no need to provide corresponding accessors since this functionality is already provided +//! via "sticker get". Dedicated accessors could provide the same functionality with slightly more +//! convenience since the sticker name would not have to be specified (as with "sticker get") & may +//! be added at a later date. +//! +//! I'm expanding the MPD filter functionality to include attributes tracked by mpdpopm: +//! +//! - findadd replacement: `findadd FILTER [sort TYPE] [window START:END]` +//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database)) +//! +//! - searchadd replacement: `searchadd FILTER [sort TYPE] [window START:END]` +//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database)) +//! +//! Additional commands may be added through the +//! [generalized commands](crate::commands#the-generalized-command-framework) feature. + +use crate::{ + clients::{Client, IdleClient, PlayerStatus}, + filters::ExpressionParser, + filters_ast::{FilterStickerNames, evaluate}, + ratings::{RatedTrack, RatingRequest}, + storage::{self, last_played, play_count, rating_count}, +}; + +use backtrace::Backtrace; +use boolinator::Boolinator; +use tracing::debug; + +use std::collections::VecDeque; +use std::convert::TryFrom; +use std::path::PathBuf; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug)] +pub enum Error { + BadPath { + pth: PathBuf, + }, + FilterParseError { + msg: String, + }, + InvalidChar { + c: u8, + }, + NoClosingQuotes, + NoCommand, + NotImplemented { + feature: String, + }, + PlayerStopped, + TrailingBackslash, + UnknownChannel { + chan: String, + back: Backtrace, + }, + UnknownCommand { + name: String, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, + Ratings { + source: crate::storage::Error, + back: Backtrace, + }, + Playcount { + source: crate::storage::Error, + back: Backtrace, + }, + Filter { + source: crate::filters_ast::Error, + back: Backtrace, + }, + Utf8 { + source: std::str::Utf8Error, + buf: Vec, + back: Backtrace, + }, + ExpectedInt { + source: std::num::ParseIntError, + text: String, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::FilterParseError { msg } => write!(f, "Parse error: ``{}''", msg), + Error::InvalidChar { c } => write!(f, "Invalid unquoted character {}", c), + Error::NoClosingQuotes => write!(f, "Missing closing quotes"), + Error::NoCommand => write!(f, "No command specified"), + Error::NotImplemented { feature } => write!(f, "`{}' not implemented, yet", feature), + Error::PlayerStopped => write!( + f, + "Can't operate on the current track when the player is stopped" + ), + Error::TrailingBackslash => write!(f, "Trailing backslash"), + Error::UnknownChannel { chan, back: _ } => write!( + f, + "We received messages for an unknown channel `{}'; this is likely a bug; please consider filing a report to sp1ff@pobox.com", + chan + ), + Error::UnknownCommand { name, back: _ } => { + write!(f, "We received an unknown message ``{}''", name) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Ratings { source, back: _ } => write!(f, "Ratings eror: {}", source), + Error::Playcount { source, back: _ } => write!(f, "Playcount error: {}", source), + Error::Filter { source, back: _ } => write!(f, "Filter error: {}", source), + Error::Utf8 { + source, + buf, + back: _, + } => write!(f, "UTF8 error {} ({:#?})", source, buf), + Error::ExpectedInt { + source, + text, + back: _, + } => write!(f, "``{}''L {}", source, text), + } + } +} + +pub type Result = std::result::Result; + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// 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](). +/// +/// 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> { + 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 { + let nslice = self.slice.len(); + if self.slice.is_empty() || self.input == nslice { + None + } else if '"' == self.slice[self.input] as char { + // This is NextString in MPD: walk self.slice, un-escaping characters, until we find + // a closing ". Note that we un-escape by moving characters forward in the slice. + let mut inp = self.input + 1; + let mut out = self.input; + while self.slice[inp] as char != '"' { + if '\\' == self.slice[inp] as char { + inp += 1; + if inp == nslice { + return Some(Err(Error::TrailingBackslash)); + } + } + self.slice[out] = self.slice[inp]; + out += 1; + inp += 1; + if inp == nslice { + return Some(Err(Error::NoClosingQuotes)); + } + } + // The next token is in self.slice[self.input..out] and self.slice[inp] is " + let tmp = std::mem::take(&mut self.slice); + let (_, tmp) = tmp.split_at_mut(self.input); + let (result, new_slice) = tmp.split_at_mut(out - self.input); + self.slice = new_slice; + // strip any leading whitespace + self.input = inp - out + 1; // +1 to skip the closing " + while self.input < self.slice.len() && self.slice[self.input] as char == ' ' { + self.input += 1; + } + Some(Ok(result)) + } else { + // This is NextUnquoted in MPD; walk self.slice, validating characters until the end + // or the next whitespace + let mut i = self.input; + while i < nslice { + if 0x20 >= self.slice[i] { + break; + } + if self.slice[i] as char == '"' || self.slice[i] as char == '\'' { + return Some(Err(Error::InvalidChar { c: self.slice[i] })); + } + i += 1; + } + // The next token is in self.slice[self.input..i] & self.slice[i] is either one- + // past-the end or whitespace. + let tmp = std::mem::take(&mut self.slice); + let (_, tmp) = tmp.split_at_mut(self.input); + let (result, new_slice) = tmp.split_at_mut(i - self.input); + self.slice = new_slice; + // strip any leading whitespace + self.input = match self.slice.iter().position(|&x| x > 0x20) { + Some(n) => n, + None => self.slice.len(), + }; + Some(Ok(result)) + } + } +} + +/// Collective state needed for processing messages, both built-in & generalized +#[derive(Default)] +pub struct MessageProcessor {} + +impl MessageProcessor { + /// Whip up a new instance; other than cloning the iterators, should just hold references in the + /// enclosing scope + pub fn new() -> MessageProcessor { + Self::default() + } + + /// Read messages off the commands channel & dispatch 'em + pub async fn check_messages<'a>( + &self, + client: &mut Client, + idle_client: &mut IdleClient, + state: PlayerStatus, + command_chan: &str, + stickers: &FilterStickerNames<'a>, + ) -> Result<()> { + let m = idle_client + .get_messages() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + for (chan, msgs) in m { + // Only supporting a single channel, ATM + (chan == command_chan).ok_or_else(|| Error::UnknownChannel { + chan, + back: Backtrace::new(), + })?; + for msg in msgs { + self.process(msg, client, &state, stickers).await?; + } + } + + Ok(()) + } + + /// Process a single command + pub async fn process<'a>( + &self, + msg: String, + client: &mut Client, + state: &PlayerStatus, + stickers: &FilterStickerNames<'a>, + ) -> Result<()> { + if let Some(stripped) = msg.strip_prefix("rate ") { + self.rate(stripped, client, state).await + } else if let Some(stripped) = msg.strip_prefix("inc-rate ") { + self.inc_rate(stripped, client, state).await + } else if let Some(stripped) = msg.strip_prefix("setpc ") { + self.setpc(stripped, client, state).await + } else if let Some(stripped) = msg.strip_prefix("setlp ") { + self.setlp(stripped, client, state).await + } else if let Some(stripped) = msg.strip_prefix("findadd ") { + self.findadd(stripped.to_string(), client, stickers, state) + .await + } else if let Some(stripped) = msg.strip_prefix("searchadd ") { + self.searchadd(stripped.to_string(), client, stickers, state) + .await + } else { + unreachable!("Unkonwn command") + } + } + + /// Handle rating message: "RATING( TRACK)?" + async fn rate(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> { + let req = RatingRequest::try_from(msg).map_err(|err| Error::Ratings { + source: storage::Error::Rating { + source: err, + back: Backtrace::new(), + }, + back: Backtrace::new(), + })?; + + let pathb = match req.track { + RatedTrack::Current => match state { + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped {}); + } + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr.file.clone(), + }, + RatedTrack::File(p) => p, + RatedTrack::Relative(_i) => { + return Err(Error::NotImplemented { + feature: String::from("Relative track position"), + }); + } + }; + let path: &str = pathb + .to_str() + .ok_or_else(|| Error::BadPath { pth: pathb.clone() })?; + + debug!("Setting a rating of {} for `{}'.", req.rating, path); + + rating_count::set(client, path, req.rating) + .await + .map_err(|err| Error::Ratings { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + /// Handle inc-rating message: "( TRACK)?" + async fn inc_rate(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> { + let pathb = if msg.is_empty() { + // We rate the current track + match state { + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped {}); + } + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr.file.clone(), + } + } else { + PathBuf::from(msg) + }; + + let path: &str = pathb + .to_str() + .ok_or_else(|| Error::BadPath { pth: pathb.clone() })?; + + let rating = rating_count::get(client, path) + .await + .map_err(|err| Error::Ratings { + source: err, + back: Backtrace::new(), + })? + .unwrap_or(0) + .saturating_add(1); + + debug!( + "Incrementing a rating for `{}' (new value: {}).", + path, rating + ); + + rating_count::set(client, path, rating) + .await + .map_err(|err| Error::Ratings { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + /// Handle `setpc': "PC( TRACK)?" + async fn setpc(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> { + let text = msg.trim(); + let (pc, track) = match text.find(char::is_whitespace) { + Some(idx) => ( + text[..idx] + .parse::() + .map_err(|err| Error::ExpectedInt { + source: err, + text: String::from(text), + back: Backtrace::new(), + })?, + &text[idx + 1..], + ), + None => ( + text.parse::().map_err(|err| Error::ExpectedInt { + source: err, + text: String::from(text), + back: Backtrace::new(), + })?, + "", + ), + }; + let file = if track.is_empty() { + match state { + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped {}); + } + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| Error::BadPath { + pth: curr.file.clone(), + })? + .to_string(), + } + } else { + track.to_string() + }; + + play_count::set(client, &file, pc) + .await + .map_err(|err| Error::Playcount { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + /// Handle `setlp': "LASTPLAYED( TRACK)?" + async fn setlp(&self, msg: &str, client: &mut Client, state: &PlayerStatus) -> Result<()> { + let text = msg.trim(); + let (lp, track) = match text.find(char::is_whitespace) { + Some(idx) => ( + text[..idx] + .parse::() + .map_err(|err| Error::ExpectedInt { + source: err, + text: String::from(text), + back: Backtrace::new(), + })?, + &text[idx + 1..], + ), + None => ( + text.parse::().map_err(|err| Error::ExpectedInt { + source: err, + text: String::from(text), + back: Backtrace::new(), + })?, + "", + ), + }; + + let file = if track.is_empty() { + match state { + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped {}); + } + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| Error::BadPath { + pth: curr.file.clone(), + })? + .to_string(), + } + } else { + track.to_string() + }; + + last_played::set(client, &file, lp) + .await + .map_err(|err| Error::Playcount { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + /// Handle `findadd': "FILTER [sort TYPE] [window START:END]" + async fn findadd<'a>( + &self, + msg: String, + client: &mut Client, + stickers: &FilterStickerNames<'a>, + _state: &PlayerStatus, + ) -> Result<()> { + let mut buf = msg.into_bytes(); + let args: VecDeque<&str> = tokenize(&mut buf) + .map(|r| match r { + Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 { + source: err, + buf: buf.to_vec(), + back: Backtrace::new(), + })?), + Err(err) => Err(err), + }) + .collect::>>()?; + + 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; T is the + // offending token, which has the same lifetime as our input, which makes it tough to + // capture. Nor is there a convenient way in which to treat all variants other than the + // Error Trait. + let ast = match ExpressionParser::new().parse(args[0]) { + Ok(ast) => ast, + Err(err) => { + return Err(Error::FilterParseError { + msg: format!("{}", err), + }); + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, true, client, stickers) + .await + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::, crate::clients::Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(Error::Client { + source: err, + back: Backtrace::new(), + }), + } + } + + /// Handle `searchadd': "FILTER [sort TYPE] [window START:END]" + async fn searchadd<'a>( + &self, + msg: String, + client: &mut Client, + stickers: &FilterStickerNames<'a>, + _state: &PlayerStatus, + ) -> Result<()> { + // Tokenize the message + let mut buf = msg.into_bytes(); + let args: VecDeque<&str> = tokenize(&mut buf) + .map(|r| match r { + Ok(buf) => Ok(std::str::from_utf8(buf).map_err(|err| Error::Utf8 { + source: err, + buf: buf.to_vec(), + back: Backtrace::new(), + })?), + Err(err) => Err(err), + }) + .collect::>>()?; + + 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; T is the + // offending token, which has the same lifetime as our input, which makes it tough to + // capture. Nor is there a convenient way in which to treat all variants other than the + // Error Trait. + let ast = match ExpressionParser::new().parse(args[0]) { + Ok(ast) => ast, + Err(err) => { + return Err(Error::FilterParseError { + msg: format!("{}", err), + }); + } + }; + + debug!("ast: {:#?}", ast); + + let mut results = Vec::new(); + for song in evaluate(&ast, false, client, stickers) + .await + .map_err(|err| Error::Filter { + source: err, + back: Backtrace::new(), + })? + { + results.push(client.add(&song).await); + } + match results + .into_iter() + .collect::, crate::clients::Error>>() + { + Ok(_) => Ok(()), + Err(err) => Err(Error::Client { + source: err, + back: Backtrace::new(), + }), + } + } +} + +#[cfg(test)] +mod tokenize_tests { + use super::Result; + use super::tokenize; + + #[test] + fn tokenize_smoke() { + let mut buf1 = String::from("some-command").into_bytes(); + let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::>>().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::>>().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::>>().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::>>().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::>>().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::>>().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::>>().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::>>().unwrap(); + assert_eq!(1, x1.len()); + eprintln!("x1[0] is ``{}''", std::str::from_utf8(x1[0]).unwrap()); + assert_eq!( + std::str::from_utf8(x1[0]).unwrap(), + r#"(artist =~ "foo\\bar\"")"# + ); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs new file mode 100644 index 00000000..4e308d4a --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs @@ -0,0 +1,367 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! playcounts -- managing play counts & lastplayed times +//! +//! # Introduction +//! +//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called +//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be +//! set explicitly: +//! +//! - `setpc PLAYCOUNT( TRACK)?` +//! - `setlp LASTPLAYED( TRACK)?` +//! + +use crate::clients::{Client, PlayerStatus}; +use crate::storage::{self, last_played, play_count, skipped}; + +use backtrace::Backtrace; +use tracing::{debug, info}; + +use std::path::PathBuf; +use std::time::SystemTime; + +#[derive(Debug)] +pub enum Error { + PlayerStopped, + BadPath { + pth: PathBuf, + }, + SystemTime { + source: std::time::SystemTimeError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::PlayerStopped => write!(f, "The MPD player is stopped"), + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::SystemTime { source, back: _ } => { + write!(f, "Couldn't get system time: {}", source) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::SystemTime { source, back: _ } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +impl From for Error { + fn from(value: storage::Error) -> Self { + match value { + storage::Error::PlayerStopped => Self::PlayerStopped, + storage::Error::BadPath { pth } => Self::BadPath { pth }, + storage::Error::SystemTime { source, back } => Self::SystemTime { source, back }, + storage::Error::Client { source, back } => Self::Client { source, back }, + _ => unreachable!(), + } + } +} + +type Result = std::result::Result; + +/// 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 { + Ok(PlayState { + last_server_stat: client.status().await?, + have_incr_play_count: false, + last_song_was_skipped: false, + played_thresh, + }) + } + + /// Retrieve a copy of the last known player status + pub fn last_status(&self) -> PlayerStatus { + self.last_server_stat.clone() + } + + /// Poll the server-- update our status; maybe increment the current track's play count; the + /// caller must arrange to have this method invoked periodically to keep our state fresh + pub async fn update(&mut self, client: &mut Client) -> Result<()> { + let new_stat = client.status().await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match (&self.last_server_stat, &new_stat) { + (PlayerStatus::Play(last), PlayerStatus::Play(curr)) + | (PlayerStatus::Pause(last), PlayerStatus::Play(curr)) + | (PlayerStatus::Play(last), PlayerStatus::Pause(curr)) + | (PlayerStatus::Pause(last), PlayerStatus::Pause(curr)) => { + // Last we knew, we were playing, and we're playing now. + if last.songid != curr.songid { + debug!("New songid-- resetting PC incremented flag."); + + if !self.have_incr_play_count { + // We didn't mark the previous song as played. + // As such, the user must have skipped it :( + self.last_song_was_skipped = true; + } + + self.have_incr_play_count = false; + } else if last.elapsed > curr.elapsed + && self.have_incr_play_count + && curr.elapsed / curr.duration <= 0.1 + { + debug!("Re-play-- resetting PC incremented flag."); + self.have_incr_play_count = false; + } + } + (PlayerStatus::Stopped, PlayerStatus::Play(_)) + | (PlayerStatus::Stopped, PlayerStatus::Pause(_)) + | (PlayerStatus::Pause(_), PlayerStatus::Stopped) + | (PlayerStatus::Play(_), PlayerStatus::Stopped) => { + self.have_incr_play_count = false; + } + (PlayerStatus::Stopped, PlayerStatus::Stopped) => (), + } + + match &new_stat { + PlayerStatus::Play(curr) => { + let pct = curr.played_pct(); + debug!("Updating status: {:.3}% complete.", 100.0 * pct); + if !self.have_incr_play_count && pct >= self.played_thresh { + info!( + "Increment play count for '{}' (songid: {}) at {} played.", + curr.file.display(), + curr.songid, + curr.elapsed / curr.duration + ); + + let file = curr.file.to_str().ok_or_else(|| Error::BadPath { + pth: curr.file.clone(), + })?; + + let curr_pc = play_count::get(client, file).await?.unwrap_or_default(); + + debug!("Current PC is {}.", curr_pc); + + last_played::set( + client, + file, + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|err| Error::SystemTime { + source: err, + back: Backtrace::new(), + })? + .as_secs(), + ) + .await?; + self.have_incr_play_count = true; + + play_count::set(client, file, curr_pc + 1).await?; + } else if self.last_song_was_skipped { + self.last_song_was_skipped = false; + let last = self + .last_server_stat + .current_song() + .expect("To exist, as it was skipped"); + + info!( + "Marking '{}' (songid: {}) as skipped at {}.", + last.file.display(), + last.songid, + last.elapsed / last.duration + ); + + let file = last.file.to_str().ok_or_else(|| Error::BadPath { + pth: last.file.clone(), + })?; + + let skip_count = skipped::get(client, file).await?.unwrap_or_default(); + skipped::set(client, file, skip_count + 1).await?; + } + } + PlayerStatus::Pause(_) | PlayerStatus::Stopped => (), + }; + + self.last_server_stat = new_stat; + Ok(()) // No need to update the DB + } +} + +#[cfg(test)] +mod player_state_tests { + use super::*; + use crate::clients::test_mock::Mock; + + /// "Smoke" tests for player state + #[tokio::test] + async fn player_state_smoke() { + let mock = Box::new(Mock::new(&[ + ( + "status", + "repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: stop +xfade: 5 +song: 51 +songid: 52 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "status", + "volume: 100 +repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: play +xfade: 5 +song: 51 +songid: 52 +time: 5:228 +elapsed: 5.337 +bitrate: 192 +duration: 227.637 +audio: 44100:24:2 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "playlistid 52", + "file: E/Enya - Wild Child.mp3 +Last-Modified: 2008-11-09T00:06:30Z +Artist: Enya +Title: Wild Child +Album: A Day Without Rain (Japanese Retail) +Date: 2000 +Genre: Celtic +Time: 228 +duration: 227.637 +Pos: 51 +Id: 52 +OK +", + ), + ( + "status", + "volume: 100 +repeat: 0 +random: 1 +single: 0 +consume: 1 +playlist: 2 +playlistlength: 66 +mixrampdb: 0.000000 +state: play +xfade: 5 +song: 51 +songid: 52 +time: 5:228 +elapsed: 200 +bitrate: 192 +duration: 227.637 +audio: 44100:24:2 +nextsong: 11 +nextsongid: 12 +OK +", + ), + ( + "playlistid 52", + "file: E/Enya - Wild Child.mp3 +Last-Modified: 2008-11-09T00:06:30Z +Artist: Enya +Title: Wild Child +Album: A Day Without Rain (Japanese Retail) +Date: 2000 +Genre: Celtic +Time: 228 +duration: 227.637 +Pos: 51 +Id: 52 +OK +", + ), + ( + "sticker get song \"E/Enya - Wild Child.mp3\" pc", + "sticker: pc=11\nOK\n", + ), + ( + &format!( + "sticker set song \"E/Enya - Wild Child.mp3\" lp {}", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + "OK\n", + ), + ("sticker set song \"E/Enya - Wild Child.mp3\" pc 12", "OK\n"), + ])); + + let mut cli = Client::new(mock).unwrap(); + let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap(); + let check = match ps.last_status() { + PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false, + PlayerStatus::Stopped => true, + }; + assert!(check); + + ps.update(&mut cli).await.unwrap(); + ps.update(&mut cli).await.unwrap() + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/ratings.rs b/pkgs/by-name/mp/mpdpopm/src/ratings.rs new file mode 100644 index 00000000..739d3827 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/ratings.rs @@ -0,0 +1,195 @@ +// Copyright (C) 2020-2025 Michael herstine +// +// 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 . + +//! Logic for rating MPD tracks. +//! +//! # Introduction +//! +//! This module contains types implementing a basic rating functionality for +//! [MPD](http://www.musicpd.org). +//! +//! # Discussion +//! +//! Rating messages to the relevant channel take the form `RATING( TRACK)?` (the two components can +//! be separated by any whitespace). The rating can be given by an integer between 0 & 255 +//! (inclusive) represented in base ten, or as one-to-five asterisks (i.e. `\*{1,5}`). In the latter +//! case, the rating will be mapped to 1-255 as per Winamp's +//! [convention](http://forums.winamp.com/showpost.php?p=2903240&postcount=94): +//! +//! - 224-255: 5 stars when READ with windows explorer, writes 255 +//! - 160-223: 4 stars when READ with windows explorer, writes 196 +//! - 096-159: 3 stars when READ with windows explorer, writes 128 +//! - 032-095: 2 stars when READ with windows explorer, writes 64 +//! - 001-031: 1 stars when READ with windows explorer, writes 1 +//! +//! NB a rating of zero means "not rated". +//! +//! Everything after the first whitepace, if present, is taken to be the track to be rated (i.e. +//! the track may contain whitespace). If omitted, the rating is taken to apply to the current +//! track. + +use backtrace::Backtrace; + +use std::path::PathBuf; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Error // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// An enumeration of ratings errors +#[derive(Debug)] +pub enum Error { + Rating { + source: std::num::ParseIntError, + text: String, + }, + PlayerStopped, + NotImplemented { + feature: String, + }, + BadPath { + pth: PathBuf, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::Rating { source, text } => write!( + f, + "Unable to interpret ``{}'' as a rating: {}", + text, source + ), + Error::PlayerStopped => write!(f, "Player stopped"), + Error::NotImplemented { feature } => write!(f, "{} not implemented", feature), + Error::BadPath { pth, back: _ } => write!(f, "Bad path: {:?}", pth), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::Rating { text: _, source } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +pub type Result = std::result::Result; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// RatingRequest message // +//////////////////////////////////////////////////////////////////////////////////////////////////// + +/// The track to which a rating shall be applied. +#[derive(Debug, PartialEq)] +pub enum RatedTrack { + Current, + File(std::path::PathBuf), + Relative(i8), +} + +/// A request from a client to rate a track. +#[derive(Debug)] +pub struct RatingRequest { + pub rating: u8, + pub track: RatedTrack, +} + +/// Produce a RatingRequest instance from a line of MPD output. +impl std::convert::TryFrom<&str> for RatingRequest { + type Error = Error; + + /// Attempt to produce a RatingRequest instance from a line of MPD response to a + /// "readmessages" command. After the channel line, each subsequent line will be of the form + /// "message: $MESSAGE"-- this method assumes that the "message: " prefix has been stripped off + /// (i.e. we're dealing with a single line of text containing only our custom message format). + /// + /// For ratings, we expect a message of the form: "RATING (TRACK)?". + fn try_from(text: &str) -> std::result::Result { + // We expect a message of the form: "RATING (TRACK)?"; let us split `text' into those two + // components for separate processing: + let text = text.trim(); + let (rating, track) = match text.find(char::is_whitespace) { + Some(idx) => (&text[..idx], &text[idx + 1..]), + None => (text, ""), + }; + + // Rating first-- the desired rating can be specified in a few ways... + let rating = if rating.is_empty() { + // an empty string is interpreted as zero: + 0u8 + } else { + // "*{1,5}" is interpreted as one-five stars, mapped to [0,255] as per Winamp: + match rating { + "*" => 1, + "**" => 64, + "***" => 128, + "****" => 196, + "*****" => 255, + // failing that, we try just interperting `rating' as an unsigned integer: + _ => rating.parse::().map_err(|err| Error::Rating { + source: err, + text: String::from(rating), + })?, + } + }; + + // Next-- track. This, too, can be given in a few ways: + let track = if track.is_empty() { + // nothing at all just means "current track" + RatedTrack::Current + } else { + // otherwise... + match text.parse::() { + // if we can interpret `track' as an i8, we take it as an offset... + Ok(i) => RatedTrack::Relative(i), + // else, we assume it's a path. If it's not, we'll figure that out downstream. + Err(_) => RatedTrack::File(std::path::PathBuf::from(&track)), + } + }; + + Ok(RatingRequest { rating, track }) + } +} + +#[cfg(test)] +mod rating_request_tests { + use super::*; + use std::convert::TryFrom; + + /// RatingRequest smoke tests + #[test] + fn rating_request_smoke() { + let req = RatingRequest::try_from("*** foo bar splat.mp3").unwrap(); + assert_eq!(req.rating, 128); + assert_eq!( + req.track, + RatedTrack::File(PathBuf::from("foo bar splat.mp3")) + ); + let req = RatingRequest::try_from("255").unwrap(); + assert_eq!(req.rating, 255); + assert_eq!(req.track, RatedTrack::Current); + let _req = RatingRequest::try_from("******").unwrap_err(); + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs new file mode 100644 index 00000000..29cfe144 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs @@ -0,0 +1,212 @@ +use std::path::PathBuf; + +use backtrace::Backtrace; + +#[derive(Debug)] +pub enum Error { + PlayerStopped, + BadPath { + pth: PathBuf, + }, + SystemTime { + source: std::time::SystemTimeError, + back: Backtrace, + }, + Client { + source: crate::clients::Error, + back: Backtrace, + }, + Rating { + source: crate::ratings::Error, + back: Backtrace, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::PlayerStopped => write!(f, "The MPD player is stopped"), + Error::BadPath { pth } => write!(f, "Bad path: {:?}", pth), + Error::SystemTime { source, back: _ } => { + write!(f, "Couldn't get system time: {}", source) + } + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Rating { source, back: _ } => write!(f, "Rating error: {}", source), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self { + Error::SystemTime { source, back: _ } => Some(source), + Error::Client { source, back: _ } => Some(source), + _ => None, + } + } +} + +type Result = std::result::Result; + +pub mod play_count { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:playcount"; + + /// Retrieve the play count for a track + pub async fn get(client: &mut Client, file: &str) -> Result> { + match client + .get_sticker::(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the play count for a track-- this will run the associated command, if any + pub async fn set(client: &mut Client, file: &str, play_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", play_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + Ok(()) + } + + #[cfg(test)] + mod pc_lp_tests { + use super::*; + use crate::{clients::test_mock::Mock, storage::play_count}; + + /// "Smoke" tests for play counts & last played times + #[tokio::test] + async fn pc_smoke() { + let mock = Box::new(Mock::new(&[ + ("sticker get song a pc", "sticker: pc=11\nOK\n"), + ( + "sticker get song a pc", + "ACK [50@0] {sticker} no such sticker\n", + ), + ("sticker get song a pc", "splat!"), + ])); + let mut cli = Client::new(mock).unwrap(); + + assert_eq!(play_count::get(&mut cli, "a").await.unwrap().unwrap(), 11); + let val = play_count::get(&mut cli, "a").await.unwrap(); + assert!(val.is_none()); + play_count::get(&mut cli, "a").await.unwrap_err(); + } + } +} + +pub mod skipped { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + const STICKER: &str = "unwoundstack.com:skipped_count"; + + /// Retrieve the skip count for a track + pub async fn get(client: &mut Client, file: &str) -> Result> { + match client + .get_sticker::(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + Some(n) => Ok(Some(n)), + None => Ok(None), + } + } + + /// Set the skip count for a track + pub async fn set(client: &mut Client, file: &str, skip_count: usize) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", skip_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } +} + +pub mod last_played { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:lastplayed"; + + /// Retrieve the last played timestamp for a track (seconds since Unix epoch) + pub async fn get(client: &mut Client, file: &str) -> Result> { + client + .get_sticker::(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } + + /// Set the last played for a track + pub async fn set(client: &mut Client, file: &str, last_played: u64) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", last_played)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) + } +} + +pub mod rating_count { + use backtrace::Backtrace; + + use crate::clients::Client; + + use super::{Error, Result}; + + pub const STICKER: &str = "unwoundstack.com:ratings_count"; + + /// Retrieve the rating count for a track + pub async fn get(client: &mut Client, file: &str) -> Result> { + client + .get_sticker::(file, STICKER) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + }) + } + + /// Set the rating count for a track + pub async fn set(client: &mut Client, file: &str, rating_count: u8) -> Result<()> { + client + .set_sticker(file, STICKER, &format!("{}", rating_count)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) + } +} diff --git a/pkgs/by-name/mp/mpdpopm/src/vars.rs b/pkgs/by-name/mp/mpdpopm/src/vars.rs new file mode 100644 index 00000000..29b9610d --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs @@ -0,0 +1,5 @@ +pub static VERSION: &str = env!("CARGO_PKG_VERSION"); +pub static AUTHOR: &str = env!("CARGO_PKG_AUTHORS"); +pub static LOCALSTATEDIR: &str = "/home/soispha/.local/state"; +pub static PREFIX: &str = "/home/soispha/.local/share/mpdpopm"; + -- cgit 1.4.1