aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/mp/mpdpopm/src/bin
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs232
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs591
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs150
3 files changed, 973 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs
new file mode 100644
index 00000000..7ec89441
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs
@@ -0,0 +1,232 @@
+use clap::{Parser, Subcommand};
+use std::path::PathBuf;
+
+/// `mppopmd' client
+#[derive(Parser)]
+pub(crate) struct Args {
+ /// path to configuration file
+ #[arg(short, long)]
+ pub(crate) config: Option<PathBuf>,
+
+ /// enable verbose logging
+ #[arg(short, long)]
+ pub(crate) verbose: bool,
+
+ /// enable debug loggin (implies --verbose)
+ #[arg(short, long)]
+ pub(crate) debug: bool,
+
+ #[command(subcommand)]
+ pub(crate) command: SubCommand,
+}
+
+#[derive(Subcommand)]
+pub(crate) enum RatingCommand {
+ /// retrieve the rating for one or more tracks
+ ///
+ /// With no arguments, retrieve the rating of the current song & print it
+ /// on stdout. With one argument, retrieve that track's rating & print it
+ /// on stdout. With multiple arguments, print their ratings on stdout, one
+ /// per line, prefixed by the track name.
+ ///
+ /// Ratings are expressed as an integer between -128 & 128, exclusive, with
+ /// the convention that 0 denotes "un-rated".
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the rating for one track
+ ///
+ /// With one argument, set the rating of the current song to that argument.
+ /// With a second argument, rate that song at the first argument. Ratings
+ /// may be expressed a an integer between 0 & 255, inclusive.
+ #[clap(verbatim_doc_comment)]
+ Set { rating: i8, track: Option<String> },
+
+ /// increment the rating for one track
+ ///
+ /// With one argument, increment the rating of the current song.
+ /// With a second argument, rate that song at the first argument.
+ #[clap(verbatim_doc_comment)]
+ Inc { track: Option<String> },
+
+ /// decrement the rating for one track
+ ///
+ /// With one argument, decrement the rating of the current song.
+ /// With a second argument, rate that song at the first argument.
+ #[clap(verbatim_doc_comment)]
+ Decr { track: Option<String> },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum PlayCountCommand {
+ /// retrieve the play count for one or more tracks
+ ///
+ /// With no arguments, retrieve the play count of the current song & print it
+ /// on stdout. With one argument, retrieve that track's play count & print it
+ /// on stdout. With multiple arguments, print their play counts on stdout, one
+ /// per line, prefixed by the track name.
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the play count for one track
+ ///
+ /// With one argument, set the play count of the current song to that argument. With a
+ /// second argument, set the play count for that song to the first.
+ #[clap(verbatim_doc_comment)]
+ Set {
+ play_count: usize,
+ track: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum LastPlayedCommand {
+ /// retrieve the last played timestamp for one or more tracks
+ ///
+ /// With no arguments, retrieve the last played timestamp of the current
+ /// song & print it on stdout. With one argument, retrieve that track's
+ /// last played time & print it on stdout. With multiple arguments, print
+ /// their last played times on stdout, one per line, prefixed by the track
+ /// name.
+ ///
+ /// The last played timestamp is expressed in seconds since Unix epoch.
+ #[clap(verbatim_doc_comment)]
+ Get {
+ /// Always show the song URI, even when there is only one track
+ #[arg(short, long)]
+ with_uri: bool,
+
+ tracks: Option<Vec<String>>,
+ },
+
+ /// set the last played timestamp for one track
+ ///
+ /// With one argument, set the last played time of the current song. With two
+ /// arguments, set the last played time for the second argument to the first.
+ /// The last played timestamp is expressed in seconds since Unix epoch.
+ #[clap(verbatim_doc_comment)]
+ Set {
+ last_played: u64,
+ track: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+pub(crate) enum PlaylistsCommand {
+ /// retrieve the list of stored playlists
+ #[clap(verbatim_doc_comment)]
+ Get {},
+}
+
+#[derive(Subcommand)]
+pub(crate) enum DjCommand {
+ /// Activate the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ #[clap(verbatim_doc_comment)]
+ Start {
+ /// The chance to select a "positive" track
+ #[arg(long, default_value_t = 0.65)]
+ positive_chance: f64,
+
+ /// The chance to select a "neutral" track
+ #[arg(long, default_value_t = 0.5)]
+ neutral_chance: f64,
+
+ /// The chance to select a "negative" track
+ #[arg(long, default_value_t = 0.2)]
+ negative_chance: f64,
+ },
+
+ /// Deactivate the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ #[clap(verbatim_doc_comment)]
+ Stop {},
+}
+
+#[derive(Subcommand)]
+pub(crate) enum SubCommand {
+ /// Change details about rating.
+ Rating {
+ #[command(subcommand)]
+ command: RatingCommand,
+ },
+
+ /// Change details about play count.
+ PlayCount {
+ #[command(subcommand)]
+ command: PlayCountCommand,
+ },
+
+ /// Change details about last played date.
+ LastPlayed {
+ #[command(subcommand)]
+ command: LastPlayedCommand,
+ },
+
+ /// Change details about generated playlists.
+ Playlists {
+ #[command(subcommand)]
+ command: PlaylistsCommand,
+ },
+
+ /// search 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 > 2)"
+ ///
+ /// Will add all songs in the library with a rating sticker > 2 to the play queue.
+ ///
+ /// mppopm also introduces OR clauses (MPD only supports AND), so that:
+ ///
+ /// mppopm searchadd "((rating > 2) AND (artist =~ \"pogues\"))"
+ ///
+ /// will add all songs whose artist tag matches the regexp "pogues" with a rating greater than
+ /// 2.
+ #[clap(verbatim_doc_comment)]
+ Searchadd {
+ filter: String,
+
+ /// Respect the casing, when performing the filter evaluation.
+ #[arg(short, long, default_value_t = false)]
+ case_sensitive: bool,
+ },
+
+ /// Modify the automatic DJ mode on the mpdpopmd daemon.
+ ///
+ /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+ /// recommendation algorithm.
+ Dj {
+ #[command(subcommand)]
+ command: DjCommand,
+ },
+
+ /// Show general stats about your music collection.
+ ///
+ /// This includes favorite artist, songs and also the negative ones.
+ Stats {},
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
new file mode 100644
index 00000000..42f01873
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
@@ -0,0 +1,591 @@
+// 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 std::{collections::HashMap, io::stdout};
+
+use clap::Parser;
+use mpdpopm::{
+ clients::{Client, PlayerStatus},
+ config::{self, Config},
+ dj::algorithms::Discovery,
+ filters::ExpressionParser,
+ filters_ast::{FilterStickerNames, evaluate},
+ messanges::COMMAND_CHANNEL,
+ storage::{last_played, play_count, rating, skip_count},
+};
+
+use anyhow::{Context, Result, anyhow, bail};
+use ratatui::{
+ Terminal, TerminalOptions, Viewport,
+ crossterm::style::Stylize,
+ layout::HorizontalAlignment,
+ prelude::CrosstermBackend,
+ style::{Color, Style},
+ text::Line,
+ widgets::{Bar, BarChart, BarGroup, Block, Borders},
+};
+use tracing::{debug, info, level_filters::LevelFilter, trace};
+use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
+
+use crate::cli::{
+ Args, DjCommand, LastPlayedCommand, PlayCountCommand, PlaylistsCommand, RatingCommand,
+ SubCommand,
+};
+
+mod cli;
+
+/// Map `tracks' argument(s) to a Vec of String containing one or more mpd URIs
+///
+/// Several sub-commands take zero or more positional arguments meant to name tracks, with the
+/// convention that zero indicates that the sub-command should use the currently playing track.
+/// This is a convenience function for mapping the value returned by [`get_many`] to a
+/// convenient representation of the user's intentions.
+///
+/// [`get_many`]: [`clap::ArgMatches::get_many`]
+async fn map_tracks(client: &mut Client, args: Option<Vec<String>>) -> Result<Vec<String>> {
+ let files = match args {
+ Some(iter) => iter,
+ None => {
+ let file = provide_file(client, None).await?;
+ vec![file]
+ }
+ };
+ Ok(files)
+}
+
+async fn provide_file(client: &mut Client, maybe_file: Option<String>) -> Result<String> {
+ let file = match maybe_file {
+ Some(file) => file,
+ None => {
+ match client
+ .status()
+ .await
+ .context("Failed to get status of client")?
+ {
+ PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => curr
+ .file
+ .to_str()
+ .ok_or_else(|| anyhow!("Path is not utf8: `{}`", curr.file.display()))?
+ .to_string(),
+ PlayerStatus::Stopped => {
+ bail!("Player is stopped");
+ }
+ }
+ }
+ };
+
+ Ok(file)
+}
+
+/// Retrieve ratings for one or more tracks
+async fn get_ratings(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut ratings: Vec<(String, i8)> = Vec::new();
+
+ for file in map_tracks(client, tracks).await? {
+ let rating = rating::get(client, &file).await?;
+
+ ratings.push((file, rating.unwrap_or_default()));
+ }
+
+ if ratings.len() == 1 && !with_uri {
+ println!("{}", ratings[0].1);
+ } else {
+ for pair in ratings {
+ println!("{}: {}", pair.0, pair.1);
+ }
+ }
+
+ Ok(())
+}
+
+/// Rate a track
+async fn set_rating(client: &mut Client, rating: i8, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ rating::set(client, &file, rating).await?;
+
+ match is_current {
+ false => info!("Set the rating for \"{}\" to \"{}\".", file, rating),
+ true => info!("Set the rating for the current song to \"{}\".", rating),
+ }
+
+ Ok(())
+}
+
+/// Rate a track by incrementing the current rating
+async fn inc_rating(client: &mut Client, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ let now = rating::get(client, &file).await?;
+
+ rating::set(client, &file, now.unwrap_or_default().saturating_add(1)).await?;
+
+ match is_current {
+ false => info!("Incremented the rating for \"{}\".", file),
+ true => info!("Incremented the rating for the current song."),
+ }
+
+ Ok(())
+}
+
+/// Rate a track by decrementing the current rating
+async fn decr_rating(client: &mut Client, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ let now = rating::get(client, &file).await?;
+
+ rating::set(client, &file, now.unwrap_or_default().saturating_sub(1)).await?;
+
+ match is_current {
+ false => info!("Decremented the rating for \"{}\".", file),
+ true => info!("Decremented the rating for the current song."),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the playcount for one or more tracks
+async fn get_play_counts(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut playcounts: Vec<(String, usize)> = Vec::new();
+ for file in map_tracks(client, tracks).await? {
+ let playcount = play_count::get(client, &file).await?.unwrap_or_default();
+ playcounts.push((file, playcount));
+ }
+
+ if playcounts.len() == 1 && !with_uri {
+ println!("{}", playcounts[0].1);
+ } else {
+ for pair in playcounts {
+ println!("{}: {}", pair.0, pair.1);
+ }
+ }
+
+ Ok(())
+}
+
+/// Set the playcount for a track
+async fn set_play_counts(client: &mut Client, playcount: usize, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ play_count::set(client, &file, playcount).await?;
+
+ match is_current {
+ false => info!("Set the playcount for \"{}\" to \"{}\".", file, playcount),
+ true => info!(
+ "Set the playcount for the current song to \"{}\".",
+ playcount
+ ),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the last played time for one or more tracks
+async fn get_last_playeds(
+ client: &mut Client,
+ tracks: Option<Vec<String>>,
+ with_uri: bool,
+) -> Result<()> {
+ let mut lastplayeds: Vec<(String, Option<u64>)> = Vec::new();
+ for file in map_tracks(client, tracks).await? {
+ let lastplayed = last_played::get(client, &file).await?;
+ lastplayeds.push((file, lastplayed));
+ }
+
+ if lastplayeds.len() == 1 && !with_uri {
+ println!(
+ "{}",
+ match lastplayeds[0].1 {
+ Some(t) => format!("{}", t),
+ None => String::from("N/A"),
+ }
+ );
+ } else {
+ for pair in lastplayeds {
+ println!(
+ "{}: {}",
+ pair.0,
+ match pair.1 {
+ Some(t) => format!("{}", t),
+ None => String::from("N/A"),
+ }
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// Set the playcount for a track
+async fn set_last_playeds(client: &mut Client, lastplayed: u64, arg: Option<String>) -> Result<()> {
+ let is_current = arg.is_none();
+ let file = provide_file(client, arg).await?;
+
+ last_played::set(client, &file, lastplayed).await?;
+
+ match is_current {
+ false => info!("Set last played for \"{}\" to \"{}\".", file, lastplayed),
+ true => info!(
+ "Set last played for the current song to \"{}\".",
+ lastplayed
+ ),
+ }
+
+ Ok(())
+}
+
+/// Retrieve the list of stored playlists
+async fn get_playlists(client: &mut Client) -> Result<()> {
+ let mut pls = client.get_stored_playlists().await?;
+ pls.sort();
+ println!("Stored playlists:");
+ for pl in pls {
+ println!("{}", pl);
+ }
+ Ok(())
+}
+
+/// Add songs selected by filter to the queue
+async fn searchadd(client: &mut Client, filter: &str, case_sensitive: bool) -> Result<()> {
+ let ast = match ExpressionParser::new().parse(filter) {
+ Ok(ast) => ast,
+ Err(err) => {
+ bail!("Failed to parse filter: `{}`", err)
+ }
+ };
+
+ debug!("ast: {:#?}", ast);
+
+ let mut results = Vec::new();
+ for song in evaluate(&ast, case_sensitive, client, &FilterStickerNames::default())
+ .await
+ .context("Failed to evaluate filter")?
+ {
+ let out = client.add(&song).await;
+
+ if out.is_ok() {
+ eprintln!("Added: `{}`", song)
+ }
+
+ results.push(out);
+ }
+
+ match results.into_iter().collect::<Result<Vec<()>>>() {
+ Ok(_) => Ok(()),
+ Err(err) => Err(err),
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let args = Args::parse();
+
+ let config = if let Some(configpath) = &args.config {
+ match std::fs::read_to_string(configpath) {
+ Ok(text) => config::from_str(&text).with_context(|| {
+ format!("Failed to parse config file at: `{}`", configpath.display())
+ })?,
+ Err(err) => {
+ // Either they did _not_, in which case they probably want to know that the config
+ // file they explicitly asked for does not exist, or there was some other problem,
+ // in which case we're out of options, anyway. Either way:
+ bail!(
+ "Failed to read config file at: `{}`, because: {err}",
+ configpath.display()
+ )
+ }
+ }
+ } else {
+ Config::default()
+ };
+
+ // Handle log verbosity: debug => verbose
+ let lf = match (args.verbose, args.debug) {
+ (_, true) => LevelFilter::TRACE,
+ (true, false) => LevelFilter::DEBUG,
+ _ => LevelFilter::WARN,
+ };
+
+ tracing::subscriber::set_global_default(
+ Registry::default()
+ .with(
+ tracing_subscriber::fmt::Layer::default()
+ .compact()
+ .with_writer(std::io::stdout),
+ )
+ .with(
+ EnvFilter::builder()
+ .with_default_directive(lf.into())
+ .from_env()
+ .unwrap(),
+ ),
+ )
+ .unwrap();
+
+ trace!("logging configured.");
+
+ let mut client = match config.conn {
+ config::Connection::Local { path } => Client::open(path).await?,
+ config::Connection::TCP { host, port } => {
+ Client::connect(format!("{}:{}", host, port)).await?
+ }
+ };
+
+ match args.command {
+ SubCommand::Rating { command } => match command {
+ RatingCommand::Get { with_uri, tracks } => {
+ get_ratings(&mut client, tracks, with_uri).await
+ }
+ RatingCommand::Set { rating, track } => set_rating(&mut client, rating, track).await,
+ RatingCommand::Inc { track } => inc_rating(&mut client, track).await,
+ RatingCommand::Decr { track } => decr_rating(&mut client, track).await,
+ },
+ SubCommand::PlayCount { command } => match command {
+ PlayCountCommand::Get { with_uri, tracks } => {
+ get_play_counts(&mut client, tracks, with_uri).await
+ }
+ PlayCountCommand::Set { play_count, track } => {
+ set_play_counts(&mut client, play_count, track).await
+ }
+ },
+ SubCommand::LastPlayed { command } => match command {
+ LastPlayedCommand::Get { with_uri, tracks } => {
+ get_last_playeds(&mut client, tracks, with_uri).await
+ }
+ LastPlayedCommand::Set { last_played, track } => {
+ set_last_playeds(&mut client, last_played, track).await
+ }
+ },
+ SubCommand::Playlists { command } => match command {
+ PlaylistsCommand::Get {} => get_playlists(&mut client).await,
+ },
+ SubCommand::Searchadd {
+ filter,
+ case_sensitive,
+ } => searchadd(&mut client, &filter, case_sensitive).await,
+ SubCommand::Dj { command } => match command {
+ DjCommand::Start {
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ } => {
+ client
+ .send_message(
+ COMMAND_CHANNEL,
+ format!(
+ "dj start \
+ --positive-chance {positive_chance} \
+ --neutral-chance {neutral_chance} \
+ --negative-chance {negative_chance}"
+ )
+ .as_str(),
+ )
+ .await
+ }
+ DjCommand::Stop {} => client.send_message(COMMAND_CHANNEL, "dj stop").await,
+ },
+ SubCommand::Stats {} => {
+ struct Rating {
+ play_count: Option<usize>,
+ skip_count: Option<usize>,
+ last_played: Option<u64>,
+ rating: Option<i8>,
+ dj_weight: i64,
+ }
+ fn vertical_bar<'a>(count: i64, amount: usize) -> Bar<'a> {
+ fn amount_style(amount: usize) -> Style {
+ let green = (255.0 * (1.0 - ((amount as f64) - 50.0) / 40.0)) as u8;
+ let color = Color::Rgb(255, green, 0);
+
+ Style::new().fg(color)
+ }
+
+ Bar::default()
+ .value(amount as u64)
+ .label(Line::from(count.to_string()))
+ .style(amount_style(amount))
+ .value_style(amount_style(amount).reversed())
+ }
+ macro_rules! top_five {
+ ($(@$convert:tt)? mode = $mode:tt, $rating_map:expr, $key:ident, $($other:ident),* $(,)?) => {
+ let mut vec = $rating_map
+ .iter()
+ .filter_map(|(track, rating)| top_five!(@convert $($convert)? rating.$key).map(|v| (track, v, rating)))
+ .collect::<Vec<_>>();
+ vec.sort_by_key(|(_, pc, _)| *pc);
+
+ top_five!(@gen_mode $mode, vec.iter())
+ .take(5)
+ .for_each(|(song, play_count, rating)| {
+ println!(
+ concat!(" - {}: {}", $(top_five!(@gen_empty $other)),*),
+ <String as Clone>::clone(&song).bold().blue(),
+ play_count.to_string().bold().white(),
+ $(
+ rating
+ .$other
+ .map(|r| format!(" ({}: {r})", stringify!($other)))
+ .unwrap_or(String::new()),
+ )*
+ )
+ });
+ };
+ (@gen_mode top, $expr:expr) => {
+ $expr.rev()
+ };
+ (@gen_mode bottom, $expr:expr) => {
+ $expr
+ };
+ (@gen_empty $tt:tt) => {
+ "{}"
+ };
+ (@convert convert_to_option $tt:expr) => {
+ Some($tt)
+ };
+ (@convert $tt:expr) => {
+ $tt
+ }
+ }
+ macro_rules! histogram {
+ ($(@$convert:tt)? $rating_map:expr, $key:ident, $title:literal) => {
+ let backend = CrosstermBackend::new(stdout());
+ let viewport = Viewport::Inline(20);
+ let mut terminal =
+ Terminal::with_options(backend, TerminalOptions { viewport })?;
+
+ let result = (|| {
+ terminal.draw(|frame| {
+ let line_chart = frame.area();
+
+ let bars: Vec<Bar> = {
+ let mut map = HashMap::new();
+ $rating_map
+ .values()
+ .filter_map(|rating| histogram!(@convert $($convert)? rating.$key))
+ .for_each(|dj_weight| {
+ map.entry(dj_weight)
+ .and_modify(|e| {
+ *e += 1;
+ })
+ .or_insert(1);
+ });
+
+ let mut vec = map.into_iter().collect::<Vec<(_, _)>>();
+ vec.sort_by_key(|(pc, _)| *pc);
+
+ vec.into_iter()
+ }
+ .map(|(dj_weight, amount)| vertical_bar(dj_weight.try_into().expect("Should be convertible"), amount))
+ .collect();
+
+ let title = Line::from($title).centered();
+ let chart = BarChart::default()
+ .data(BarGroup::default().bars(&bars))
+ .block(
+ Block::new()
+ .title(title)
+ .title_alignment(HorizontalAlignment::Left)
+ .borders(Borders::all()),
+ )
+ .bar_width(5);
+
+ frame.render_widget(chart, line_chart);
+ })?;
+
+ Ok::<_, anyhow::Error>(())
+ })();
+
+ ratatui::restore();
+ println!();
+ result?;
+ };
+ (@convert convert_to_option $val:expr) => {
+ Some($val)
+ };
+ (@convert $val:expr) => {
+ $val
+ };
+ }
+
+ let all = client.get_all_songs().await?;
+
+ let mut rating_map = HashMap::new();
+ for song in &all {
+ let rating = Rating {
+ play_count: play_count::get(&mut client, song).await?,
+ skip_count: skip_count::get(&mut client, song).await?,
+ last_played: last_played::get(&mut client, song).await?,
+ rating: rating::get(&mut client, song).await?,
+ dj_weight: Discovery::weight_track(&mut client, song).await?,
+ };
+ rating_map.insert(song, rating);
+ }
+
+ let played_songs = rating_map
+ .values()
+ .filter(|s| s.last_played.is_some())
+ .count();
+
+ println!(
+ "Songs played: {:.2}%",
+ (played_songs as f64 / all.len() as f64) * 100.0
+ );
+
+ histogram!(rating_map, play_count, "Play counts");
+
+ println!("\nMost played songs:");
+ top_five!(mode = top, rating_map, play_count, skip_count, rating);
+
+ println!("\nMost skipped songs:");
+ top_five!(mode = top, rating_map, skip_count, play_count, rating);
+
+ println!("\nTop songs based on dj weight:");
+ top_five!(@convert_to_option mode = top, rating_map, dj_weight, rating, play_count, skip_count);
+
+ println!("\nBottom 5 songs based on dj weight:");
+ top_five!(@convert_to_option mode = bottom, rating_map, dj_weight, rating, play_count, skip_count);
+
+ println!();
+ histogram!(@convert_to_option rating_map, dj_weight, "Dj weights");
+
+ Ok(())
+ }
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs
new file mode 100644
index 00000000..643611d6
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs
@@ -0,0 +1,150 @@
+// Copyright (C) 2020-2025 Michael herstine <sp1ff@pobox.com>
+//
+// This file is part of mpdpopm.
+//
+// mpdpopm is free software: you can redistribute it and/or modify it under the terms of the GNU
+// General Public License as published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// mpdpopm is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
+// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
+// Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along with mpdpopm. If not,
+// see <http://www.gnu.org/licenses/>.
+
+//! # mppopmd
+//!
+//! Maintain ratings & playcounts for your mpd server.
+//!
+//! # Introduction
+//!
+//! This is a companion daemon for [mpd](https://www.musicpd.org/) that maintains play counts &
+//! ratings. Similar to [mpdfav](https://github.com/vincent-petithory/mpdfav), but written in Rust
+//! (which I prefer to Go), it will allow you to maintain that information in your tags, as well as
+//! the sticker database, by invoking external commands to keep your tags up-to-date (something
+//! along the lines of [mpdcron](https://alip.github.io/mpdcron)).
+
+use mpdpopm::{
+ config::{self, Config},
+ mpdpopm,
+};
+
+use anyhow::{Context, Result, bail};
+use clap::Parser;
+use tracing::{info, level_filters::LevelFilter};
+use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt};
+
+use std::{io, path::PathBuf, sync::MutexGuard};
+
+pub struct MyMutexGuardWriter<'a>(MutexGuard<'a, std::fs::File>);
+
+impl io::Write for MyMutexGuardWriter<'_> {
+ #[inline]
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+ self.0.write(buf)
+ }
+
+ #[inline]
+ fn flush(&mut self) -> io::Result<()> {
+ self.0.flush()
+ }
+
+ #[inline]
+ fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> {
+ self.0.write_vectored(bufs)
+ }
+
+ #[inline]
+ fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
+ self.0.write_all(buf)
+ }
+
+ #[inline]
+ fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> io::Result<()> {
+ self.0.write_fmt(fmt)
+ }
+}
+
+/// mpd + POPM
+///
+/// `mppopmd' is a companion daemon for `mpd' that maintains playcounts & ratings,
+/// as well as implementing some handy functions. It maintains ratings & playcounts in the sticker
+/// database, but it allows you to keep that information in your tags, as well, by invoking external
+/// commands to keep your tags up-to-date.
+#[derive(Parser)]
+struct Args {
+ /// path to configuration file
+ #[arg(short, long)]
+ config: Option<PathBuf>,
+
+ /// enable verbose logging
+ #[arg(short, long)]
+ verbose: bool,
+
+ /// enable debug loggin (implies --verbose)
+ #[arg(short, long)]
+ debug: bool,
+}
+
+/// Entry point for `mpdopmd'.
+///
+/// Do *not* use the #[tokio::main] attribute here! If this program is asked to daemonize (the usual
+/// case), we will fork after tokio has started its thread pool, with disastrous consequences.
+/// Instead, stay synchronous until we've daemonized (or figured out that we don't need to), and
+/// only then fire-up the tokio runtime.
+fn main() -> Result<()> {
+ use mpdpopm::vars::VERSION;
+
+ let args = Args::parse();
+
+ let config = if let Some(cfgpath) = &args.config {
+ match std::fs::read_to_string(cfgpath) {
+ Ok(text) => config::from_str(&text).with_context(|| {
+ format!("Failed to parse config file at: `{}`", cfgpath.display())
+ })?,
+ // The config file (defaulted or not) either didn't exist, or we were unable to read its
+ // contents...
+ Err(err) => {
+ // Either they did _not_, in which case they probably want to know that the config
+ // file they explicitly asked for does not exist, or there was some other problem,
+ // in which case we're out of options, anyway. Either way:
+ bail!(
+ "No config file could be read at: `{}`, because: {err}",
+ cfgpath.display()
+ )
+ }
+ }
+ } else {
+ Config::default()
+ };
+
+ // `--verbose' & `--debug' work as follows: if `--debug' is present, log at level Trace, no
+ // matter what. Else, if `--verbose' is present, log at level Debug. Else, log at level Info.
+ let lf = match (args.verbose, args.debug) {
+ (_, true) => LevelFilter::TRACE,
+ (true, false) => LevelFilter::DEBUG,
+ _ => LevelFilter::INFO,
+ };
+
+ let filter = EnvFilter::builder()
+ .with_default_directive(lf.into())
+ .from_env()
+ .context("Failed to construct env filter")?;
+
+ let formatter: Box<dyn Layer<Registry> + Send + Sync> = {
+ Box::new(
+ tracing_subscriber::fmt::Layer::default()
+ .compact()
+ .with_writer(io::stdout),
+ )
+ };
+
+ tracing::subscriber::set_global_default(Registry::default().with(formatter).with(filter))
+ .unwrap();
+
+ info!("mppopmd {VERSION} logging at level {lf:#?}.");
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ rt.block_on(mpdpopm(config)).context("Main mpdpopm failed")
+}