about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
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/mpdpopm.rs
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 '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs677
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
+        }
+    }
+}