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