diff options
Diffstat (limited to '')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs | 677 |
1 files changed, 677 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs new file mode 100644 index 00000000..4ffcd499 --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs @@ -0,0 +1,677 @@ +// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com> +// +// This file is part of mpdpopm. +// +// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU +// General Public License as published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along with mpdpopm. If not, +// see <http://www.gnu.org/licenses/>. + +//! # mppopm +//! +//! mppopmd client +//! +//! # Introduction +//! +//! `mppopmd` is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts & +//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust +//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as +//! the sticker database, by invoking external commands to keep your tags up-to-date (something +//! along the lines of [mpdcron](https://alip.github.io/mpdcron)). `mppopm` is a command-line client +//! for `mppopmd`. Run `mppopm --help` for detailed usage. + +use mpdpopm::{ + clients::{Client, PlayerStatus, quote}, + config::{self, Config}, + storage::{last_played, play_count, rating_count}, +}; + +use backtrace::Backtrace; +use clap::{Parser, Subcommand}; +use tracing::{debug, info, level_filters::LevelFilter, trace}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +use std::{fmt, path::PathBuf}; + +#[non_exhaustive] +pub enum Error { + NoSubCommand, + NoConfigArg, + NoRating, + NoPlayCount, + NoLastPlayed, + NoConfig { + config: std::path::PathBuf, + cause: std::io::Error, + }, + PlayerStopped, + BadPath { + path: PathBuf, + back: Backtrace, + }, + NoPlaylist, + Client { + source: mpdpopm::clients::Error, + back: Backtrace, + }, + Ratings { + source: Box<mpdpopm::storage::Error>, + back: Backtrace, + }, + Playcounts { + source: Box<mpdpopm::storage::Error>, + back: Backtrace, + }, + ExpectedInt { + source: std::num::ParseIntError, + back: Backtrace, + }, + Config { + source: crate::config::Error, + back: Backtrace, + }, +} + +impl fmt::Display for Error { + #[allow(unreachable_patterns)] // the _ arm is *currently* unreachable + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::NoSubCommand => write!(f, "No sub-command given"), + Error::NoConfigArg => write!(f, "No argument given for the configuration option"), + Error::NoRating => write!(f, "No rating supplied"), + Error::NoPlayCount => write!(f, "No play count supplied"), + Error::NoLastPlayed => write!(f, "No last played timestamp given"), + Error::NoConfig { config, cause } => write!(f, "Bad config ({:?}): {}", config, cause), + Error::PlayerStopped => write!(f, "The player is stopped"), + Error::BadPath { path, back: _ } => write!(f, "Bad path: {:?}", path), + Error::NoPlaylist => write!(f, "No playlist given"), + Error::Client { source, back: _ } => write!(f, "Client error: {}", source), + Error::Ratings { source, back: _ } => write!(f, "Rating error: {}", source), + Error::Playcounts { source, back: _ } => write!(f, "Playcount error: {}", source), + Error::ExpectedInt { source, back: _ } => write!(f, "Expected integer: {}", source), + Error::Config { source, back: _ } => { + write!(f, "Error reading configuration: {}", source) + } + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +type Result<T> = std::result::Result<T, Error>; + +/// Map `tracks' argument(s) to a Vec of String containing one or more mpd URIs +/// +/// Several sub-commands take zero or more positional arguments meant to name tracks, with the +/// convention that zero indicates that the sub-command should use the currently playing track. +/// This is a convenience function for mapping the value returned by [`get_many`] to a +/// convenient representation of the user's intentions. +/// +/// [`get_many`]: [`clap::ArgMatches::get_many`] +async fn map_tracks(client: &mut Client, args: Option<Vec<String>>) -> Result<Vec<String>> { + let files = match args { + Some(iter) => iter, + None => { + let file = match client.status().await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? { + PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr + .file + .to_str() + .ok_or_else(|| Error::BadPath { + path: curr.file.clone(), + back: Backtrace::new(), + })? + .to_string(), + PlayerStatus::Stopped => { + return Err(Error::PlayerStopped); + } + }; + vec![file] + } + }; + Ok(files) +} + +/// Retrieve ratings for one or more tracks +async fn get_ratings( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut ratings: Vec<(String, u8)> = Vec::new(); + + for file in map_tracks(client, tracks).await? { + let rating = rating_count::get(client, &file) + .await + .map_err(|err| Error::Ratings { + source: Box::new(err), + back: Backtrace::new(), + })?; + + ratings.push((file, rating.unwrap_or_default())); + } + + if ratings.len() == 1 && !with_uri { + println!("{}", ratings[0].1); + } else { + for pair in ratings { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Rate a track +async fn set_rating( + client: &mut Client, + chan: &str, + rating: u8, + arg: Option<String>, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("rate {} \\\"{}\\\"", rating, uri), + None => format!("rate {}", rating), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set the rating for \"{}\" to \"{}\".", uri, rating), + None => info!("Set the rating for the current song to \"{}\".", rating), + } + + Ok(()) +} + +/// Rate a track by incrementing the current rating +async fn inc_rating(client: &mut Client, chan: &str, arg: Option<String>) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("inc-rate \\\"{}\\\"", uri), + None => "inc-rate ".to_owned(), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Incremented the rating for \"{}\".", uri), + None => info!("Incremented the rating for the current song."), + } + + Ok(()) +} + +/// Retrieve the playcount for one or more tracks +async fn get_play_counts( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut playcounts: Vec<(String, usize)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let playcount = play_count::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })? + .unwrap_or_default(); + playcounts.push((file, playcount)); + } + + if playcounts.len() == 1 && !with_uri { + println!("{}", playcounts[0].1); + } else { + for pair in playcounts { + println!("{}: {}", pair.0, pair.1); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_play_counts( + client: &mut Client, + chan: &str, + playcount: usize, + arg: Option<String>, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("setpc {} \\\"{}\\\"", playcount, uri), + None => format!("setpc {}", playcount), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set the playcount for \"{}\" to \"{}\".", uri, playcount), + None => info!( + "Set the playcount for the current song to \"{}\".", + playcount + ), + } + + Ok(()) +} + +/// Retrieve the last played time for one or more tracks +async fn get_last_playeds( + client: &mut Client, + tracks: Option<Vec<String>>, + with_uri: bool, +) -> Result<()> { + let mut lastplayeds: Vec<(String, Option<u64>)> = Vec::new(); + for file in map_tracks(client, tracks).await? { + let lastplayed = + last_played::get(client, &file) + .await + .map_err(|err| Error::Playcounts { + source: Box::new(err), + back: Backtrace::new(), + })?; + lastplayeds.push((file, lastplayed)); + } + + if lastplayeds.len() == 1 && !with_uri { + println!( + "{}", + match lastplayeds[0].1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } else { + for pair in lastplayeds { + println!( + "{}: {}", + pair.0, + match pair.1 { + Some(t) => format!("{}", t), + None => String::from("N/A"), + } + ); + } + } + + Ok(()) +} + +/// Set the playcount for a track +async fn set_last_playeds( + client: &mut Client, + chan: &str, + lastplayed: u64, + arg: Option<String>, +) -> Result<()> { + let cmd = match &arg { + Some(uri) => format!("setlp {} {}", lastplayed, uri), + None => format!("setlp {}", lastplayed), + }; + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + + match &arg { + Some(uri) => info!("Set last played for \"{}\" to \"{}\".", uri, lastplayed), + None => info!( + "Set last played for the current song to \"{}\".", + lastplayed + ), + } + + Ok(()) +} + +/// Retrieve the list of stored playlists +async fn get_playlists(client: &mut Client) -> Result<()> { + let mut pls = client + .get_stored_playlists() + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + pls.sort(); + println!("Stored playlists:"); + for pl in pls { + println!("{}", pl); + } + Ok(()) +} + +/// Add songs selected by filter to the queue +async fn findadd(client: &mut Client, chan: &str, filter: &str, case: bool) -> Result<()> { + let qfilter = quote(filter); + debug!("findadd: got ``{}'', quoted to ``{}''.", filter, qfilter); + let cmd = format!("{} {}", if case { "findadd" } else { "searchadd" }, qfilter); + client + .send_message(chan, &cmd) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// Send an arbitrary command +async fn send_command(client: &mut Client, chan: &str, args: Vec<String>) -> Result<()> { + client + .send_message( + chan, + args.iter() + .map(String::as_str) + .map(quote) + .collect::<Vec<String>>() + .join(" ") + .as_str(), + ) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?; + Ok(()) +} + +/// `mppopmd' client +#[derive(Parser)] +struct Args { + /// path to configuration file + #[arg(short, long)] + config: Option<PathBuf>, + + /// enable verbose logging + #[arg(short, long)] + verbose: bool, + + /// enable debug loggin (implies --verbose) + #[arg(short, long)] + debug: bool, + + #[command(subcommand)] + command: SubCommand, +} + +#[derive(Subcommand)] +enum SubCommand { + /// retrieve the rating for one or more tracks + /// + /// With no arguments, retrieve the rating of the current song & print it + /// on stdout. With one argument, retrieve that track's rating & print it + /// on stdout. With multiple arguments, print their ratings on stdout, one + /// per line, prefixed by the track name. + /// + /// Ratings are expressed as an integer between 0 & 255, inclusive, with + /// the convention that 0 denotes "un-rated". + #[clap(verbatim_doc_comment)] + GetRating { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the rating for one track + /// + /// With one argument, set the rating of the current song to that argument. + /// With a second argument, rate that song at the first argument. Ratings + /// may be expressed a an integer between 0 & 255, inclusive. + #[clap(verbatim_doc_comment)] + SetRating { rating: u8, track: Option<String> }, + + /// increment the rating for one track + /// + /// With one argument, increment the rating of the current song. + /// With a second argument, rate that song at the first argument. + #[clap(verbatim_doc_comment)] + IncRating { track: Option<String> }, + + /// retrieve the play count for one or more tracks + /// + /// With no arguments, retrieve the play count of the current song & print it + /// on stdout. With one argument, retrieve that track's play count & print it + /// on stdout. With multiple arguments, print their play counts on stdout, one + /// per line, prefixed by the track name. + #[clap(verbatim_doc_comment)] + GetPc { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the play count for one track + /// + /// With one argument, set the play count of the current song to that argument. With a + /// second argument, set the play count for that song to the first. + #[clap(verbatim_doc_comment)] + SetPc { + play_count: usize, + track: Option<String>, + }, + + /// retrieve the last played timestamp for one or more tracks + /// + /// With no arguments, retrieve the last played timestamp of the current + /// song & print it on stdout. With one argument, retrieve that track's + /// last played time & print it on stdout. With multiple arguments, print + /// their last played times on stdout, one per line, prefixed by the track + /// name. + /// + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + GetLp { + /// Always show the song URI, even when there is only one track + #[arg(short, long)] + with_uri: bool, + + tracks: Option<Vec<String>>, + }, + + /// set the last played timestamp for one track + /// + /// With one argument, set the last played time of the current song. With two + /// arguments, set the last played time for the second argument to the first. + /// The last played timestamp is expressed in seconds since Unix epoch. + #[clap(verbatim_doc_comment)] + SetLp { + last_played: u64, + track: Option<String>, + }, + + /// retrieve the list of stored playlists + #[clap(verbatim_doc_comment)] + GetPlaylists {}, + + /// search case-sensitively for songs matching matching a filter and add them to the queue + /// + /// This command extends the MPD command `findadd' (which will search the MPD database) to allow + /// searches on attributes managed by mpdpopm: rating, playcount & last played time. + /// + /// The MPD `findadd' <https://www.musicpd.org/doc/html/protocol.html#command-findadd> will search the + /// MPD database for songs that match a given filter & add them to the play queue. The filter syntax is + /// documented here <https://www.musicpd.org/doc/html/protocol.html#filter-syntax>. + /// + /// This command adds three new terms on which you can filter: rating, playcount & lastplayed. Each is + /// expressed as an unsigned integer, with zero interpreted as "not set". For instance: + /// + /// mppopm findadd "(rating > 128)" + /// + /// Will add all songs in the library with a rating sticker > 128 to the play queue. + /// + /// mppopm also introduces OR clauses (MPD only supports AND), so that: + /// + /// mppopm findadd "((rating > 128) AND (artist =~ \"pogues\"))" + /// + /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than + /// 128. + /// + /// `findadd' is case-sensitive; for case-insensitive searching see the `searchadd' command. + #[clap(verbatim_doc_comment)] + Findadd { filter: String }, + + /// search case-insensitively for songs matching matching a filter and add them to the queue + /// + /// This command extends the MPD command `searchadd' (which will search the MPD database) to allow + /// searches on attributes managed by mpdpopm: rating, playcount & last played time. + /// + /// The MPD `searchadd' <https://www.musicpd.org/doc/html/protocol.html#command-searchadd> will search + /// the MPD database for songs that match a given filter & add them to the play queue. The filter syntax + /// is documented here <https://www.musicpd.org/doc/html/protocol.html#filter-syntax>. + /// + /// This command adds three new terms on which you can filter: rating, playcount & lastplayed. Each is + /// expressed as an unsigned integer, with zero interpreted as "not set". For instance: + /// + /// mppopm searchadd "(rating > 128)" + /// + /// Will add all songs in the library with a rating sticker > 128 to the play queue. + /// + /// mppopm also introduces OR clauses (MPD only supports AND), so that: + /// + /// mppopm searchadd "((rating > 128) AND (artist =~ \"pogues\"))" + /// + /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than + /// 128. + /// + /// `searchadd' is case-insensitive; for case-sensitive searching see the `findadd' command. + #[clap(verbatim_doc_comment)] + Searchadd { filter: String }, + + /// Send a command to mpd. + #[clap(verbatim_doc_comment)] + SendCommand { args: Vec<String> }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let config = if let Some(configpath) = &args.config { + match std::fs::read_to_string(configpath) { + Ok(text) => config::from_str(&text).map_err(|err| Error::Config { + source: err, + back: Backtrace::new(), + })?, + Err(err) => { + // Either they did _not_, in which case they probably want to know that the config + // file they explicitly asked for does not exist, or there was some other problem, + // in which case we're out of options, anyway. Either way: + return Err(Error::NoConfig { + config: PathBuf::from(configpath), + cause: err, + }); + } + } + } else { + Config::default() + }; + + // Handle log verbosity: debug => verbose + let lf = match (args.verbose, args.debug) { + (_, true) => LevelFilter::TRACE, + (true, false) => LevelFilter::DEBUG, + _ => LevelFilter::WARN, + }; + + tracing::subscriber::set_global_default( + Registry::default() + .with( + tracing_subscriber::fmt::Layer::default() + .compact() + .with_writer(std::io::stdout), + ) + .with( + EnvFilter::builder() + .with_default_directive(lf.into()) + .from_env() + .unwrap(), + ), + ) + .unwrap(); + + trace!("logging configured."); + + let mut client = match config.conn { + config::Connection::Local { path } => { + Client::open(path).await.map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })? + } + config::Connection::TCP { host, port } => Client::connect(format!("{}:{}", host, port)) + .await + .map_err(|err| Error::Client { + source: err, + back: Backtrace::new(), + })?, + }; + + match args.command { + SubCommand::GetRating { with_uri, tracks } => { + get_ratings(&mut client, tracks, with_uri).await + } + SubCommand::SetRating { rating, track } => { + set_rating(&mut client, &config.commands_chan, rating, track).await + } + SubCommand::IncRating { track } => { + inc_rating(&mut client, &config.commands_chan, track).await + } + SubCommand::GetPc { with_uri, tracks } => { + get_play_counts(&mut client, tracks, with_uri).await + } + SubCommand::SetPc { play_count, track } => { + set_play_counts(&mut client, &config.commands_chan, play_count, track).await + } + SubCommand::GetLp { with_uri, tracks } => { + get_last_playeds(&mut client, tracks, with_uri).await + } + SubCommand::SetLp { last_played, track } => { + set_last_playeds(&mut client, &config.commands_chan, last_played, track).await + } + SubCommand::GetPlaylists {} => get_playlists(&mut client).await, + SubCommand::Findadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, true).await + } + SubCommand::Searchadd { filter } => { + findadd(&mut client, &config.commands_chan, &filter, false).await + } + SubCommand::SendCommand { args } => { + send_command(&mut client, &config.commands_chan, args).await + } + } +} |
