aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/mp/mpdpopm/src
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.rs (renamed from pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs)492
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/clients.rs2
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/config.rs138
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs237
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/mod.rs28
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters.lalrpop59
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters_ast.rs49
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/lib.rs60
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messages.rs409
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs140
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/playcounts.rs62
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/storage/mod.rs8
13 files changed, 1046 insertions, 870 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.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
index d9d607d5..42f01873 100644
--- a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs
@@ -26,18 +26,38 @@
//! 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, quote},
+ clients::{Client, PlayerStatus},
config::{self, Config},
- storage::{last_played, play_count, rating_count},
+ 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 clap::{Parser, Subcommand};
+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 std::path::PathBuf;
+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
///
@@ -91,7 +111,7 @@ async fn get_ratings(
let mut ratings: Vec<(String, i8)> = Vec::new();
for file in map_tracks(client, tracks).await? {
- let rating = rating_count::get(client, &file).await?;
+ let rating = rating::get(client, &file).await?;
ratings.push((file, rating.unwrap_or_default()));
}
@@ -112,7 +132,7 @@ async fn set_rating(client: &mut Client, rating: i8, arg: Option<String>) -> Res
let is_current = arg.is_none();
let file = provide_file(client, arg).await?;
- rating_count::set(client, &file, rating).await?;
+ rating::set(client, &file, rating).await?;
match is_current {
false => info!("Set the rating for \"{}\" to \"{}\".", file, rating),
@@ -127,9 +147,9 @@ 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_count::get(client, &file).await?;
+ let now = rating::get(client, &file).await?;
- rating_count::set(client, &file, now.unwrap_or_default().saturating_add(1)).await?;
+ rating::set(client, &file, now.unwrap_or_default().saturating_add(1)).await?;
match is_current {
false => info!("Incremented the rating for \"{}\".", file),
@@ -144,9 +164,9 @@ 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_count::get(client, &file).await?;
+ let now = rating::get(client, &file).await?;
- rating_count::set(client, &file, now.unwrap_or_default().saturating_sub(1)).await?;
+ rating::set(client, &file, now.unwrap_or_default().saturating_sub(1)).await?;
match is_current {
false => info!("Decremented the rating for \"{}\".", file),
@@ -263,242 +283,34 @@ async fn get_playlists(client: &mut Client) -> Result<()> {
}
/// 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?;
- 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?;
- 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 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 0 & 255, inclusive, 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)]
-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)]
-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)]
-enum PlaylistsCommand {
- /// retrieve the list of stored playlists
- #[clap(verbatim_doc_comment)]
- Get {},
-}
-
-#[derive(Subcommand)]
-enum SubCommand {
- /// Change details about rating.
- Rating {
- #[command(subcommand)]
- command: RatingCommand,
- },
-
- /// Change details about play count.
- PlayCount {
- #[command(subcommand)]
- command: PlayCountCommand,
- },
+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)
+ }
+ };
- /// Change details about last played date.
- LastPlayed {
- #[command(subcommand)]
- command: LastPlayedCommand,
- },
+ debug!("ast: {:#?}", ast);
- /// Change details about generated playlists.
- Playlists {
- #[command(subcommand)]
- command: PlaylistsCommand,
- },
+ 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;
- /// 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 },
+ if out.is_ok() {
+ eprintln!("Added: `{}`", song)
+ }
- /// 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 },
+ results.push(out);
+ }
- /// Send a command to mpd.
- #[clap(verbatim_doc_comment)]
- SendCommand { args: Vec<String> },
+ match results.into_iter().collect::<Result<Vec<()>>>() {
+ Ok(_) => Ok(()),
+ Err(err) => Err(err),
+ }
}
#[tokio::main]
@@ -584,14 +396,196 @@ async fn main() -> Result<()> {
SubCommand::Playlists { command } => match command {
PlaylistsCommand::Get {} => 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
+ 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/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs
index b88e4041..b934714a 100644
--- a/pkgs/by-name/mp/mpdpopm/src/clients.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs
@@ -716,9 +716,7 @@ impl Client {
}
#[cfg(test)]
-/// Let's test Client!
mod client_tests {
-
use super::test_mock::Mock;
use super::*;
diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs
index 2d9c466b..8bb5abfb 100644
--- a/pkgs/by-name/mp/mpdpopm/src/config.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/config.rs
@@ -56,7 +56,16 @@ pub enum Connection {
impl Connection {
pub fn new() -> Result<Self> {
- let env = env::var("MPD_HOST")?;
+ let env = match env::var("MPD_HOST") {
+ Ok(env) => Some(env),
+ Err(err) => match err {
+ env::VarError::NotPresent => None,
+ env::VarError::NotUnicode(_) => {
+ bail!("Failed to get `MPD_HOST` env var: {err}")
+ }
+ },
+ }
+ .unwrap_or("/run/mpd/socket".to_owned());
if env.starts_with("/") {
// We assume that this is a path to a local socket
@@ -107,6 +116,17 @@ mod test_connection {
}
}
+/// THe possible start-up mode.
+#[derive(Default, Deserialize, Debug, Serialize)]
+pub enum Mode {
+ #[default]
+ /// Don't do anything special
+ Normal,
+
+ /// Already start the DJ mode on start-up
+ Dj,
+}
+
/// This is the most recent `mppopmd` configuration struct.
#[derive(Deserialize, Debug, Serialize)]
#[serde(default)]
@@ -124,6 +144,9 @@ pub struct Config {
/// How to connect to mpd
pub conn: Connection,
+ /// The mode to start in
+ pub mode: Mode,
+
/// The `mpd' root music directory, relative to the host on which *this* daemon is running
pub local_music_dir: PathBuf,
@@ -133,9 +156,6 @@ pub struct Config {
/// The interval, in milliseconds, at which to poll `mpd' for the current state
pub poll_interval_ms: u64,
-
- /// Channel to setup for assorted commands-- channel names must satisfy "[-a-zA-Z-9_.:]+"
- pub commands_chan: String,
}
impl Default for Config {
@@ -153,7 +173,7 @@ impl Config {
local_music_dir: [PREFIX, "Music"].iter().collect(),
played_thresh: 0.6,
poll_interval_ms: 5000,
- commands_chan: String::from("unwoundstack.com:commands"),
+ mode: Mode::default(),
})
}
}
@@ -167,111 +187,3 @@ pub fn from_str(text: &str) -> Result<Config> {
};
Ok(cfg)
}
-
-#[cfg(test)]
-mod test {
- use super::*;
-
- #[test]
- #[ignore = "We changed the config format to json"]
- fn test_from_str() {
- let cfg = Config::default();
- assert_eq!(cfg.commands_chan, String::from("unwoundstack.com:commands"));
-
- assert_eq!(
- serde_json::to_string(&cfg).unwrap(),
- format!(
- r#"((version . "1") (log . "{}/log/mppopmd.log") (conn TCP (host . "localhost") (port . 6600)) (local_music_dir . "{}/Music") (playcount_sticker . "unwoundstack.com:playcount") (lastplayed_sticker . "unwoundstack.com:lastplayed") (played_thresh . 0.6) (poll_interval_ms . 5000) (commands_chan . "unwoundstack.com:commands") (playcount_command . "") (playcount_command_args) (rating_sticker . "unwoundstack.com:rating") (ratings_command . "") (ratings_command_args) (gen_cmds))"#,
- LOCALSTATEDIR, PREFIX
- )
- );
-
- let cfg: Config = serde_json::from_str(
- r#"
-((version . "1")
- (log . "/usr/local/var/log/mppopmd.log")
- (conn TCP (host . "localhost") (port . 6600))
- (local_music_dir . "/usr/local/Music")
- (playcount_sticker . "unwoundstack.com:playcount")
- (lastplayed_sticker . "unwoundstack.com:lastplayed")
- (played_thresh . 0.6)
- (poll_interval_ms . 5000)
- (commands_chan . "unwoundstack.com:commands")
- (playcount_command . "")
- (playcount_command_args)
- (rating_sticker . "unwoundstack.com:rating")
- (ratings_command . "")
- (ratings_command_args)
- (gen_cmds))
-"#,
- )
- .unwrap();
- assert_eq!(cfg._version, String::from("1"));
-
- let cfg: Config = serde_json::from_str(
- r#"
-((version . "1")
- (log . "/usr/local/var/log/mppopmd.log")
- (conn Local (path . "/home/mgh/var/run/mpd/mpd.sock"))
- (local_music_dir . "/usr/local/Music")
- (playcount_sticker . "unwoundstack.com:playcount")
- (lastplayed_sticker . "unwoundstack.com:lastplayed")
- (played_thresh . 0.6)
- (poll_interval_ms . 5000)
- (commands_chan . "unwoundstack.com:commands")
- (playcount_command . "")
- (playcount_command_args)
- (rating_sticker . "unwoundstack.com:rating")
- (ratings_command . "")
- (ratings_command_args)
- (gen_cmds))
-"#,
- )
- .unwrap();
- assert_eq!(cfg._version, String::from("1"));
- assert_eq!(
- cfg.conn,
- Connection::Local {
- path: PathBuf::from("/home/mgh/var/run/mpd/mpd.sock")
- }
- );
-
- // Test fallback to "v0" of the config struct
- let cfg = from_str(r#"
-((log . "/home/mgh/var/log/mppopmd.log")
- (host . "192.168.1.14")
- (port . 6600)
- (local_music_dir . "/space/mp3")
- (playcount_sticker . "unwoundstack.com:playcount")
- (lastplayed_sticker . "unwoundstack.com:lastplayed")
- (played_thresh . 0.6)
- (poll_interval_ms . 5000)
- (playcount_command . "/usr/local/bin/scribbu")
- (playcount_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-C" "%playcount" "%full-file"))
- (commands_chan . "unwoundstack.com:commands")
- (rating_sticker . "unwoundstack.com:rating")
- (ratings_command . "/usr/local/bin/scribbu")
- (ratings_command_args . ("popm" "-v" "-a" "-f" "-o" "sp1ff@pobox.com" "-r" "%rating" "%full-file"))
- (gen_cmds .
- (((name . "set-genre")
- (formal_parameters . (Literal Track))
- (default_after . 1)
- (cmd . "/usr/local/bin/scribbu")
- (args . ("genre" "-a" "-C" "-g" "%1" "%full-file"))
- (update . TrackOnly))
- ((name . "set-xtag")
- (formal_parameters . (Literal Track))
- (default_after . 1)
- (cmd . "/usr/local/bin/scribbu")
- (args . ("xtag" "-A" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file"))
- (update . TrackOnly))
- ((name . "merge-xtag")
- (formal_parameters . (Literal Track))
- (default_after . 1)
- (cmd . "/usr/local/bin/scribbu")
- (args . ("xtag" "-m" "-o" "sp1ff@pobox.com" "-T" "%1" "%full-file"))
- (update . TrackOnly)))))
-"#).unwrap();
- assert_eq!(cfg.log, PathBuf::from("/home/mgh/var/log/mppopmd.log"));
- }
-}
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
new file mode 100644
index 00000000..0004fd17
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
@@ -0,0 +1,237 @@
+use std::{
+ collections::HashSet,
+ time::{Duration, SystemTime},
+};
+
+use anyhow::{Context, Result};
+use rand::{RngExt, distr, seq::SliceRandom};
+use tracing::info;
+
+use crate::{clients::Client, storage};
+
+pub(crate) trait Algorithm {
+ async fn next_track(&mut self, client: &mut Client) -> Result<String>;
+}
+
+/// Generates generic discovery playlist, that fulfills following requirements:
+/// - Will (eventually) include every not-played song. (So it can be used to rank a library)
+/// - Returns liked songs more often then not-played or negative songs.
+pub struct Discovery {
+ already_done: HashSet<String>,
+ negative_chance: f64,
+ neutral_chance: f64,
+ positive_chance: f64,
+}
+
+impl Algorithm for Discovery {
+ async fn next_track(&mut self, client: &mut Client) -> Result<String> {
+ macro_rules! take {
+ ($rng:expr, $from:expr) => {{
+ info!(concat!(
+ "Trying to select a `",
+ stringify!($from),
+ "` track."
+ ));
+
+ assert!(!$from.is_empty());
+
+ let normalized_weights = {
+ // We normalize the weights here, because negative values don't work for the
+ // distribution function we use below.
+ // "-5" "-3" "1" "6" "19" | +5
+ // -> "0" "2" "6" "11" "24"
+ let mut weights = $from.iter().map(|(_, w)| *w).collect::<Vec<_>>();
+
+ weights.sort_by_key(|w| *w);
+
+ let first = *weights.first().expect(
+ "the value to exist, because we never run `take!` with an empty vector",
+ );
+
+ if first.is_negative() {
+ weights
+ .into_iter()
+ .rev()
+ .map(|w| w + first.abs())
+ .collect::<Vec<_>>()
+ } else {
+ weights
+ }
+ };
+
+ let sample = $rng.sample(
+ distr::weighted::WeightedIndex::new(normalized_weights.iter())
+ .expect("to be okay, because the weights are normalized"),
+ );
+
+ let output = $from.remove(sample);
+
+ info!(
+ concat!(
+ "(",
+ stringify!($from),
+ ") Selected `{}` with weight: `{}` (normalized to `{}`)"
+ ),
+ output.0, output.1, normalized_weights[sample]
+ );
+
+ Ok::<_, anyhow::Error>(output)
+ }};
+ }
+
+ let mut rng = rand::rng();
+ let (mut positive, mut neutral, mut negative) = {
+ let tracks = {
+ let mut base = client
+ .get_all_songs()
+ .await?
+ .into_iter()
+ .filter(|song| !self.already_done.contains(song))
+ .collect::<Vec<_>>();
+
+ if base.is_empty() {
+ // We could either have no tracks in the library,
+ // or we actually already listed to everything.
+ self.already_done = HashSet::new();
+
+ info!("Resetting already done songs, as we have no more to choose from");
+
+ base = client.get_all_songs().await?;
+ }
+
+ base
+ };
+
+ let mut sorted_tracks = Vec::with_capacity(tracks.len());
+ for track in tracks {
+ let weight = Self::weight_track(client, &track).await?;
+
+ sorted_tracks.push((track, weight));
+ }
+
+ sorted_tracks.sort_by_key(|(_, weight)| *weight);
+
+ let len = sorted_tracks.len() / 3;
+
+ // We split the tracks into three thirds, so that we can also force a pick from e.g.
+ // the lower third (the negative ones).
+ let mut negative = sorted_tracks.drain(..len).collect::<Vec<_>>();
+ let mut neutral = sorted_tracks.drain(..len).collect::<Vec<_>>();
+ let mut positive = sorted_tracks;
+
+ negative.shuffle(&mut rng);
+ neutral.shuffle(&mut rng);
+ positive.shuffle(&mut rng);
+
+ assert_eq!(negative.len(), neutral.len());
+
+ (positive, neutral, negative)
+ };
+
+ let pick = rng.sample(
+ distr::weighted::WeightedIndex::new(
+ [
+ self.positive_chance,
+ self.neutral_chance,
+ self.negative_chance,
+ ]
+ .iter(),
+ )
+ .expect("to be valid, as hardcoded"),
+ );
+
+ let next = match pick {
+ 0 if !positive.is_empty() => take!(rng, positive),
+ 1 if !neutral.is_empty() => take!(rng, neutral),
+ 2 if !negative.is_empty() => take!(rng, negative),
+ 0..=2 => {
+ // We couldn't actually satisfy the request, because we didn't have the required
+ // track. So we just use the first non-empty one.
+ if !positive.is_empty() {
+ take!(rng, positive)
+ } else if !neutral.is_empty() {
+ take!(rng, neutral)
+ } else if !negative.is_empty() {
+ take!(rng, negative)
+ } else {
+ assert!(positive.is_empty() && neutral.is_empty() && negative.is_empty());
+ todo!("No songs available to select from, I don't know how to select one.");
+ }
+ }
+ _ => unreachable!("These indexes are not possible"),
+ }?;
+
+ self.already_done.insert(next.0.to_owned());
+
+ Ok(next.0)
+ }
+}
+
+impl Discovery {
+ pub(crate) fn new(positive_chance: f64, neutral_chance: f64, negative_chance: f64) -> Self {
+ Self {
+ already_done: HashSet::new(),
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ }
+ }
+
+ /// Calculate a recommendation score for a track.
+ ///
+ /// The algorithm maps tracks, that the user likes to a high score and songs that the user
+ /// dislikes to a lower number.
+ /// Currently, only the rating, skip count and play count are considered. Similarity scores,
+ /// fetched from e.g. last.fm should be included in the future.
+ pub async fn weight_track(client: &mut Client, track: &str) -> Result<i64> {
+ let last_played_delta = {
+ let last_played = storage::last_played::get(client, track).await?.unwrap_or(0);
+ let now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .expect("to be before")
+ .as_secs();
+
+ let played_seconds_ago = now - last_played;
+
+ const HOUR: u64 = Duration::from_hours(1).as_secs();
+ const DAY: u64 = Duration::from_hours(24).as_secs();
+ const MONTH: u64 = Duration::from_hours(24 * 30).as_secs();
+
+ match played_seconds_ago {
+ ..HOUR => {
+ // it was played in the last hour already
+ -3
+ }
+ HOUR..DAY => {
+ // it was not played in the last hour, but in the last day
+ -2
+ }
+ DAY..MONTH => {
+ // it was not played in the last day, but in the last month
+ -1
+ }
+ MONTH.. => {
+ // it was not played in a month
+ 1
+ }
+ }
+ };
+
+ let rating = i32::from(storage::rating::get(client, track).await?.unwrap_or(0));
+ let play_count = i32::try_from(storage::play_count::get(client, track).await?.unwrap_or(0))
+ .context("`play_count` too big")?;
+ let skip_count = i32::try_from(storage::skip_count::get(client, track).await?.unwrap_or(0))
+ .context("`skip_count` too big")?;
+
+ let output: f64 = 1.0 * f64::from(rating)
+ + 0.3 * f64::from(play_count)
+ + -0.6 * f64::from(skip_count)
+ + 0.65 * f64::from(last_played_delta);
+
+ let weight = output.round() as i64;
+
+ // info!("`{track}`: {weight}");
+
+ Ok(weight)
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
new file mode 100644
index 00000000..548ed4f4
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
@@ -0,0 +1,28 @@
+use anyhow::Result;
+use tracing::info;
+
+use crate::{clients::Client, dj::algorithms::Algorithm};
+
+pub mod algorithms;
+
+pub(crate) struct Dj<A: Algorithm> {
+ algo: A,
+}
+
+impl<A: Algorithm> Dj<A> {
+ pub(crate) fn new(algo: A) -> Self {
+ Self { algo }
+ }
+
+ /// Add the next track to the playlist.
+ ///
+ /// This should be called after the previous track is finished, to avoid unbounded growth.
+ pub(crate) async fn add_track(&mut self, client: &mut Client) -> Result<()> {
+ let next = self.algo.next_track(client).await?;
+
+ info!("Adding `{next}`, due to active dj mode");
+ client.add(&next).await?;
+
+ Ok(())
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
index a591a3ba..970fc040 100644
--- a/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
+++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
@@ -15,8 +15,18 @@
use lalrpop_util::ParseError;
-use crate::filters_ast::{Conjunction, Disjunction, Expression, OpCode, Selector, Term, Value,
- expect_quoted, parse_iso_8601};
+use crate::filters_ast::{
+ Conjunction,
+ Disjunction,
+ Expression,
+ OpCode,
+ Selector,
+ Term,
+ Value,
+ expect_quoted,
+ parse_iso_8601
+};
+use tracing::debug;
grammar;
@@ -36,7 +46,7 @@ pub ExprSel: Selector = {
r"(?i)artist" => Selector::Artist,
r"(?i)album" => Selector::Album,
r"(?i)albumartist" => Selector::AlbumArtist,
- r"(?i)titile" => Selector::Title,
+ r"(?i)title" => Selector::Title,
r"(?i)track" => Selector::Track,
r"(?i)name" => Selector::Name,
r"(?i)genre" => Selector::Genre,
@@ -63,20 +73,27 @@ pub ExprSel: Selector = {
r"(?i)rating" => Selector::Rating,
r"(?i)playcount" => Selector::PlayCount,
r"(?i)lastplayed" => Selector::LastPlayed,
+ r"(?i)skipped" => Selector::Skipped,
};
pub Token: Value = {
- <s:r"[0-9]+"> =>? {
- eprintln!("matched token: ``{}''.", s);
+ <s:r"(-)?[0-9]+"> =>? {
+ debug!("matched token: ``{}''.", s);
// We need to yield a Result<Value, ParseError>
match s.parse::<usize>() {
Ok(n) => Ok(Value::Uint(n)),
- Err(_) => Err(ParseError::User {
- error: "Internal parse error while parsing unsigned int" })
+ Err(_) => match s.parse::<i64>() {
+ Ok(n) => Ok(Value::Int(n)),
+ Err(_) => Err(
+ ParseError::User {
+ error: "Internal parse error while parsing unsigned int"
+ }
+ )
+ }
}
},
<s:r#""([ \t'a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\"|\\')+""#> => {
- eprintln!("matched token: ``{}''.", s);
+ debug!("matched token: ``{}''.", s);
let s = expect_quoted(s).unwrap();
match parse_iso_8601(&mut s.as_bytes()) {
Ok(x) => Value::UnixEpoch(x),
@@ -84,7 +101,7 @@ pub Token: Value = {
}
},
<s:r#"'([ \t"a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\'|\\")+'"#> => {
- eprintln!("matched token: ``{}''.", s);
+ debug!("matched token: ``{}''.", s);
let s = expect_quoted(s).unwrap();
match parse_iso_8601(&mut s.as_bytes()) {
Ok(x) => Value::UnixEpoch(x),
@@ -94,50 +111,50 @@ pub Token: Value = {
};
pub Term: Box<Term> = {
- <t:ExprSel> <u:Token> => {
- eprintln!("matched unary condition: ``({}, {:#?})''", t, u);
- Box::new(Term::UnaryCondition(t, u))
- },
<t:ExprSel> <o:ExprOp> <u:Token> => {
- eprintln!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u);
+ debug!("matched binary condition: ``({}, {:#?}, {:#?})''", t, o, u);
Box::new(Term::BinaryCondition(t, o, u))
},
+ <t:ExprSel> <u:Token> => {
+ debug!("matched unary condition: ``({}, {:#?})''", t, u);
+ Box::new(Term::UnaryCondition(t, u))
+ },
}
pub Conjunction: Box<Conjunction> = {
<e1:Expression> "AND" <e2:Expression> => {
- eprintln!("matched conjunction: ``({:#?}, {:#?})''", e1, e2);
+ debug!("matched conjunction: ``({:#?}, {:#?})''", e1, e2);
Box::new(Conjunction::Simple(e1, e2))
},
<c:Conjunction> "AND" <e:Expression> => {
- eprintln!("matched conjunction: ``({:#?}, {:#?})''", c, e);
+ debug!("matched conjunction: ``({:#?}, {:#?})''", c, e);
Box::new(Conjunction::Compound(c, e))
},
}
pub Disjunction: Box<Disjunction> = {
<e1:Expression> "OR" <e2:Expression> => {
- eprintln!("matched disjunction: ``({:#?}, {:#?})''", e1, e2);
+ debug!("matched disjunction: ``({:#?}, {:#?})''", e1, e2);
Box::new(Disjunction::Simple(e1, e2))
},
<c:Disjunction> "OR" <e:Expression> => {
- eprintln!("matched disjunction: ``({:#?}, {:#?})''", c, e);
+ debug!("matched disjunction: ``({:#?}, {:#?})''", c, e);
Box::new(Disjunction::Compound(c, e))
},
}
pub Expression: Box<Expression> = {
"(" <t:Term> ")" => {
- eprintln!("matched parenthesized term: ``({:#?})''", t);
+ debug!("matched parenthesized term: ``({:#?})''", t);
Box::new(Expression::Simple(t))
},
"(" "!" <e:Expression> ")" => Box::new(Expression::Negation(e)),
"(" <c:Conjunction> ")" => {
- eprintln!("matched parenthesized conjunction: ``({:#?})''", c);
+ debug!("matched parenthesized conjunction: ``({:#?})''", c);
Box::new(Expression::Conjunction(c))
},
"(" <c:Disjunction> ")" => {
- eprintln!("matched parenthesized disjunction: ``({:#?})''", c);
+ debug!("matched parenthesized disjunction: ``({:#?})''", c);
Box::new(Expression::Disjunction(c))
},
}
diff --git a/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
index bd1a67d6..9c68d329 100644
--- a/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
@@ -18,7 +18,7 @@
//! This module provides support for our [lalrpop](https://github.com/lalrpop/lalrpop) grammar.
use crate::clients::Client;
-use crate::storage::{last_played, play_count, rating_count};
+use crate::storage::{last_played, play_count, rating, skip_count};
use anyhow::{Context, Error, Result, anyhow, bail};
use boolinator::Boolinator;
@@ -95,6 +95,7 @@ pub enum Selector {
Rating,
PlayCount,
LastPlayed,
+ Skipped,
}
impl std::fmt::Display for Selector {
@@ -133,6 +134,7 @@ impl std::fmt::Display for Selector {
Selector::Rating => "rating",
Selector::PlayCount => "playcount",
Selector::LastPlayed => "lastplayed",
+ Selector::Skipped => "skipped",
}
)
}
@@ -143,6 +145,7 @@ pub enum Value {
Text(String),
UnixEpoch(i64),
Uint(usize),
+ Int(i64),
}
fn quote_value(x: &Value) -> String {
@@ -166,6 +169,9 @@ fn quote_value(x: &Value) -> String {
Value::Uint(n) => {
format!("'{}'", n)
}
+ Value::Int(n) => {
+ format!("'{}'", n)
+ }
}
}
@@ -655,6 +661,7 @@ async fn eval_numeric_sticker_term<
.for_each(|song| {
m.entry(song).or_insert(default_val);
});
+
// Now that we don't have to worry about operations that can fail, we can use
// `filter_map'.
Ok(m.drain()
@@ -674,6 +681,7 @@ pub struct FilterStickerNames<'a> {
rating: &'a str,
playcount: &'a str,
lastplayed: &'a str,
+ skipped: &'a str,
}
impl FilterStickerNames<'static> {
@@ -685,9 +693,10 @@ impl FilterStickerNames<'static> {
impl Default for FilterStickerNames<'static> {
fn default() -> Self {
Self {
- rating: rating_count::STICKER,
+ rating: rating::STICKER,
playcount: play_count::STICKER,
lastplayed: last_played::STICKER,
+ skipped: skip_count::STICKER,
}
}
}
@@ -711,18 +720,19 @@ async fn eval_term<'a>(
.collect()),
Term::BinaryCondition(attr, op, val) => {
if *attr == Selector::Rating {
- match val {
- Value::Uint(n) => {
- if *n > 255 {
- bail!("Rating of `{}` is greater than allowed!", n)
- }
- Ok(
- eval_numeric_sticker_term(stickers.rating, client, *op, *n as u8, 0)
- .await?,
- )
- }
- _ => bail!("filter ratings expect an unsigned int; got {:#?}", val),
- }
+ let value = match val {
+ Value::Int(n) => *n as i128,
+ Value::Uint(n) => *n as i128,
+ _ => bail!("filter ratings expect an int; got {:#?}", val),
+ };
+
+ let val: i8 = value.try_into().with_context(|| {
+ format!(
+ "Failed to convert `{}` into a number from -128 to 128!",
+ value
+ )
+ })?;
+ Ok(eval_numeric_sticker_term(stickers.rating, client, *op, val, 0).await?)
} else if *attr == Selector::PlayCount {
match val {
Value::Uint(n) => {
@@ -731,7 +741,7 @@ async fn eval_term<'a>(
.await?,
)
}
- _ => bail!("filter ratings expect an unsigned int; got {:#?}", val),
+ _ => bail!("filter play_count expect an unsigned int; got {:#?}", val),
}
} else if *attr == Selector::LastPlayed {
match val {
@@ -741,7 +751,14 @@ async fn eval_term<'a>(
.await?,
)
}
- _ => bail!("filter ratings expect an unsigned int; got {:#?}", val),
+ _ => bail!("filter last_played expect an unix epoch; got {:#?}", val),
+ }
+ } else if *attr == Selector::Skipped {
+ match val {
+ Value::Uint(t) => {
+ Ok(eval_numeric_sticker_term(stickers.skipped, client, *op, *t, 0).await?)
+ }
+ _ => bail!("filter skipped expect an unsigned int; got {:#?}", val),
}
} else {
Ok(client
diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs
index 4fe523ea..6d04a527 100644
--- a/pkgs/by-name/mp/mpdpopm/src/lib.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs
@@ -34,8 +34,9 @@
pub mod clients;
pub mod config;
+pub mod dj;
pub mod filters_ast;
-pub mod messages;
+pub mod messanges;
pub mod playcounts;
pub mod storage;
pub mod vars;
@@ -46,14 +47,13 @@ pub mod vars;
#[allow(clippy::let_unit_value)]
#[allow(clippy::just_underscores_and_digits)]
pub mod filters {
- include!(concat!(env!("OUT_DIR"), "/src/filters.rs"));
+ include!(concat!(env!("OUT_DIR"), "/filters.rs"));
}
use crate::{
clients::{Client, IdleClient, IdleSubSystem},
config::{Config, Connection},
- filters_ast::FilterStickerNames,
- messages::MessageProcessor,
+ messanges::{COMMAND_CHANNEL, MessageQueue},
playcounts::PlayState,
};
@@ -70,8 +70,6 @@ use tracing::{debug, error, info};
pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
info!("mpdpopm {} beginning.", vars::VERSION);
- let filter_stickers = FilterStickerNames::new();
-
let mut client =
match cfg.conn {
Connection::Local { ref path } => Client::open(path)
@@ -95,8 +93,10 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
.context("Failed to connect to TCP idle client")?,
};
+ let mut mqueue = MessageQueue::new(cfg.mode);
+
idle_client
- .subscribe(&cfg.commands_chan)
+ .subscribe(COMMAND_CHANNEL)
.await
.context("Failed to subscribe to idle_client")?;
@@ -110,12 +110,10 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
let tick = sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse();
pin_mut!(ctrl_c, sighup, sigkill, tick);
- let mproc = MessageProcessor::new();
-
let mut done = false;
+ let mut msg_check_needed = false;
while !done {
debug!("selecting...");
- let mut msg_check_needed = false;
{
// `idle_client' mutably borrowed here
let mut idle = Box::pin(idle_client.idle().fuse());
@@ -140,33 +138,20 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse());
state.update(&mut client)
.await
- .context("PlayState update failed")?
+ .context("PlayState update failed")?;
},
- // next = cmds.next() => match next {
- // Some(out) => {
- // debug!("output status is {:#?}", out.out);
- // match out.upd {
- // Some(uri) => {
- // debug!("{} needs to be updated", uri);
- // client.update(&uri).await.map_err(|err| Error::Client {
- // source: err,
- // back: Backtrace::new(),
- // })?;
- // },
- // None => debug!("No database update needed"),
- // }
- // },
- // None => {
- // debug!("No more commands to process.");
- // }
- // },
res = idle => match res {
Ok(subsys) => {
debug!("subsystem {} changed", subsys);
if subsys == IdleSubSystem::Player {
- state.update(&mut client)
+ if state.update(&mut client)
.await
- .context("PlayState update failed")?
+ .context("PlayState update failed")? {
+ mqueue
+ .advance_dj(&mut client)
+ .await
+ .context("MessageQueue tick failed")?;
+ }
} else if subsys == IdleSubSystem::Message {
msg_check_needed = true;
}
@@ -183,19 +168,12 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
}
if msg_check_needed {
+ msg_check_needed = false;
+
// Check for any messages that have come in; if there's an error there's not a lot we
// can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it
// & move on.
- if let Err(err) = mproc
- .check_messages(
- &mut client,
- &mut idle_client,
- state.last_status(),
- &cfg.commands_chan,
- &filter_stickers,
- )
- .await
- {
+ if let Err(err) = mqueue.check_messages(&mut client, &mut idle_client).await {
error!("Error while processing messages: {err:#?}");
}
}
diff --git a/pkgs/by-name/mp/mpdpopm/src/messages.rs b/pkgs/by-name/mp/mpdpopm/src/messages.rs
deleted file mode 100644
index 171a246a..00000000
--- a/pkgs/by-name/mp/mpdpopm/src/messages.rs
+++ /dev/null
@@ -1,409 +0,0 @@
-// 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/>.
-
-//! # messages
-//!
-//! Process incoming messages to the [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon.
-//!
-//! # Introduction
-//!
-//! The [mpdpopm](https://github.com/sp1ff/mpdpopm) daemon accepts commands over a dedicated
-//! [channel](https://www.musicpd.org/doc/html/protocol.html#client-to-client). It also provides for
-//! a generalized framework in which the [mpdpopm](https://github.com/sp1ff/mpdpopm) administrator
-//! can define new commands backed by arbitrary command execution server-side.
-//!
-//! # Commands
-//!
-//! The following commands are built-in:
-//!
-//! - set rating: `rate RATING( TRACK)?`
-//! - set playcount: `setpc PC( TRACK)?`
-//! - set lastplayed: `setlp TIMESTAMP( TRACK)?`
-//!
-//! There is no need to provide corresponding accessors since this functionality is already provided
-//! via "sticker get". Dedicated accessors could provide the same functionality with slightly more
-//! convenience since the sticker name would not have to be specified (as with "sticker get") & may
-//! be added at a later date.
-//!
-//! I'm expanding the MPD filter functionality to include attributes tracked by mpdpopm:
-//!
-//! - findadd replacement: `findadd FILTER [sort TYPE] [window START:END]`
-//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database))
-//!
-//! - searchadd replacement: `searchadd FILTER [sort TYPE] [window START:END]`
-//! (cf. [here](https://www.musicpd.org/doc/html/protocol.html#the-music-database))
-//!
-//! Additional commands may be added through the
-//! [generalized commands](crate::commands#the-generalized-command-framework) feature.
-
-use crate::{
- clients::{Client, IdleClient, PlayerStatus},
- filters::ExpressionParser,
- filters_ast::{FilterStickerNames, evaluate},
-};
-
-use anyhow::{Context, Error, Result, anyhow, bail};
-use boolinator::Boolinator;
-use tracing::debug;
-
-use std::collections::VecDeque;
-
-/// Break `buf` up into individual tokens while removing MPD-style quoting.
-///
-/// When a client sends a command to [mpdpopm](crate), it will look like this on the wire:
-///
-/// ```text
-/// sendmessage ${CHANNEL} "some-command \"with space\" simple \"'with single' and \\\\\""
-/// ```
-///
-/// In other words, the MPD "sendmessage" command takes two parameters: the channel and the
-/// message. The recipient (i.e. us) is responsible for breaking up the message into its constituent
-/// parts (a command name & its arguments in our case).
-///
-/// The message will perforce be quoted according ot the MPD rules:
-///
-/// 1. an un-quoted token may contain any printable ASCII character except space, tab, ' & "
-///
-/// 2. to include spaces, tabs, '-s or "-s, the token must be enclosed in "-s, and any "-s or \\-s
-/// therein must be backslash escaped
-///
-/// When the messages is delivered to us, it has already been un-escaped; i.e. we will see the
-/// string:
-///
-/// ```text
-/// some-command "with space" simple "'with single' and \\"
-/// ```
-///
-/// This function will break that string up into individual tokens with one more level
-/// of escaping removed; i.e. it will return an iterator that will yield the four tokens:
-///
-/// 1. some-command
-/// 2. with space
-/// 3. simple
-/// 4. 'with single' and \\
-///
-/// [MPD](https://github.com/MusicPlayerDaemon/MPD) has a nice
-/// [implementation](https://github.com/MusicPlayerDaemon/MPD/blob/master/src/util/Tokenizer.cxx#L170)
-/// that modifies the string in place by copying subsequent characters on top of escape characters
-/// in the same buffer, inserting nulls in between the resulting tokens,and then working in terms of
-/// pointers to the resulting null-terminated strings.
-///
-/// Once I realized that I could split slices I saw how to implement an Iterator that do the same
-/// thing (an idiomatic interface to the tokenization backed by a zero-copy implementation). I was
-/// inspired by [My Favorite Rust Function
-/// Signature](<https://www.brandonsmith.ninja/blog/favorite-rust-function>).
-///
-/// NB. This method works in terms of a slice of [`u8`] because we can't index into Strings in
-/// Rust, and MPD deals only in terms of ASCII at any rate.
-pub fn tokenize(buf: &mut [u8]) -> impl Iterator<Item = Result<&[u8]>> {
- TokenIterator::new(buf)
-}
-
-struct TokenIterator<'a> {
- /// The slice on which we operate; modified in-place as we yield tokens
- slice: &'a mut [u8],
- /// Index into [`slice`] of the first non-whitespace character
- input: usize,
-}
-
-impl<'a> TokenIterator<'a> {
- pub fn new(slice: &'a mut [u8]) -> TokenIterator<'a> {
- let input = match slice.iter().position(|&x| x > 0x20) {
- Some(n) => n,
- None => slice.len(),
- };
- TokenIterator { slice, input }
- }
-}
-
-impl<'a> Iterator for TokenIterator<'a> {
- type Item = Result<&'a [u8]>;
-
- fn next(&mut self) -> Option<Self::Item> {
- let nslice = self.slice.len();
- if self.slice.is_empty() || self.input == nslice {
- None
- } else if '"' == self.slice[self.input] as char {
- // This is NextString in MPD: walk self.slice, un-escaping characters, until we find
- // a closing ". Note that we un-escape by moving characters forward in the slice.
- let mut inp = self.input + 1;
- let mut out = self.input;
- while self.slice[inp] as char != '"' {
- if '\\' == self.slice[inp] as char {
- inp += 1;
- if inp == nslice {
- return Some(Err(anyhow!("Trailing backslash")));
- }
- }
- self.slice[out] = self.slice[inp];
- out += 1;
- inp += 1;
- if inp == nslice {
- return Some(Err(anyhow!("No closing quote")));
- }
- }
- // The next token is in self.slice[self.input..out] and self.slice[inp] is "
- let tmp = std::mem::take(&mut self.slice);
- let (_, tmp) = tmp.split_at_mut(self.input);
- let (result, new_slice) = tmp.split_at_mut(out - self.input);
- self.slice = new_slice;
- // strip any leading whitespace
- self.input = inp - out + 1; // +1 to skip the closing "
- while self.input < self.slice.len() && self.slice[self.input] as char == ' ' {
- self.input += 1;
- }
- Some(Ok(result))
- } else {
- // This is NextUnquoted in MPD; walk self.slice, validating characters until the end
- // or the next whitespace
- let mut i = self.input;
- while i < nslice {
- if 0x20 >= self.slice[i] {
- break;
- }
- if self.slice[i] as char == '"' || self.slice[i] as char == '\'' {
- return Some(Err(anyhow!("Invalid char: `{}`", self.slice[i])));
- }
- i += 1;
- }
- // The next token is in self.slice[self.input..i] & self.slice[i] is either one-
- // past-the end or whitespace.
- let tmp = std::mem::take(&mut self.slice);
- let (_, tmp) = tmp.split_at_mut(self.input);
- let (result, new_slice) = tmp.split_at_mut(i - self.input);
- self.slice = new_slice;
- // strip any leading whitespace
- self.input = match self.slice.iter().position(|&x| x > 0x20) {
- Some(n) => n,
- None => self.slice.len(),
- };
- Some(Ok(result))
- }
- }
-}
-
-/// Collective state needed for processing messages, both built-in & generalized
-#[derive(Default)]
-pub struct MessageProcessor {}
-
-impl MessageProcessor {
- /// Whip up a new instance; other than cloning the iterators, should just hold references in the
- /// enclosing scope
- pub fn new() -> MessageProcessor {
- Self::default()
- }
-
- /// Read messages off the commands channel & dispatch 'em
- pub async fn check_messages<'a>(
- &self,
- client: &mut Client,
- idle_client: &mut IdleClient,
- state: PlayerStatus,
- command_chan: &str,
- stickers: &FilterStickerNames<'a>,
- ) -> Result<()> {
- let m = idle_client
- .get_messages()
- .await
- .context("Failed to `get_messages` from client")?;
-
- for (chan, msgs) in m {
- // Only supporting a single channel, ATM
- (chan == command_chan).ok_or_else(|| anyhow!("Unknown chanell: `{}`", chan))?;
- for msg in msgs {
- self.process(msg, client, &state, stickers).await?;
- }
- }
-
- Ok(())
- }
-
- /// Process a single command
- pub async fn process<'a>(
- &self,
- msg: String,
- client: &mut Client,
- state: &PlayerStatus,
- stickers: &FilterStickerNames<'a>,
- ) -> Result<()> {
- if let Some(stripped) = msg.strip_prefix("findadd ") {
- self.findadd(stripped.to_string(), client, stickers, state)
- .await
- } else if let Some(stripped) = msg.strip_prefix("searchadd ") {
- self.searchadd(stripped.to_string(), client, stickers, state)
- .await
- } else {
- unreachable!("Unkonwn command")
- }
- }
-
- /// Handle `findadd': "FILTER [sort TYPE] [window START:END]"
- async fn findadd<'a>(
- &self,
- msg: String,
- client: &mut Client,
- stickers: &FilterStickerNames<'a>,
- _state: &PlayerStatus,
- ) -> Result<()> {
- let mut buf = msg.into_bytes();
- let args: VecDeque<&str> = tokenize(&mut buf)
- .map(|r| match r {
- Ok(buf) => Ok(std::str::from_utf8(buf)
- .context("Failed to interpete `findadd` string as utf8")?),
- Err(err) => Err(err),
- })
- .collect::<Result<VecDeque<&str>>>()?;
-
- debug!("findadd arguments: {:#?}", args);
-
- // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet.
-
- // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the
- // offending token, which has the same lifetime as our input, which makes it tough to
- // capture. Nor is there a convenient way in which to treat all variants other than the
- // Error Trait.
- let ast = match ExpressionParser::new().parse(args[0]) {
- Ok(ast) => ast,
- Err(err) => {
- bail!("Failed to parse filter: `{}`", err)
- }
- };
-
- debug!("ast: {:#?}", ast);
-
- let mut results = Vec::new();
- for song in evaluate(&ast, true, client, stickers)
- .await
- .context("Failed to evaluate filter")?
- {
- results.push(client.add(&song).await);
- }
- match results
- .into_iter()
- .collect::<std::result::Result<Vec<()>, Error>>()
- {
- Ok(_) => Ok(()),
- Err(err) => Err(err),
- }
- }
-
- /// Handle `searchadd': "FILTER [sort TYPE] [window START:END]"
- async fn searchadd<'a>(
- &self,
- msg: String,
- client: &mut Client,
- stickers: &FilterStickerNames<'a>,
- _state: &PlayerStatus,
- ) -> Result<()> {
- // Tokenize the message
- let mut buf = msg.into_bytes();
- let args: VecDeque<&str> = tokenize(&mut buf)
- .map(|r| match r {
- Ok(buf) => Ok(std::str::from_utf8(buf)
- .context("Failed to interpete `searchadd` string as utf8")?),
- Err(err) => Err(err),
- })
- .collect::<Result<VecDeque<_>>>()?;
-
- debug!("searchadd arguments: {:#?}", args);
-
- // there should be 1, 3 or 5 arguments. `sort' & `window' are not supported, yet.
-
- // ExpressionParser's not terribly ergonomic: it returns a ParesError<L, T, E>; T is the
- // offending token, which has the same lifetime as our input, which makes it tough to
- // capture. Nor is there a convenient way in which to treat all variants other than the
- // Error Trait.
- let ast = match ExpressionParser::new().parse(args[0]) {
- Ok(ast) => ast,
- Err(err) => {
- bail!("Failed to parse filter: `{err}`")
- }
- };
-
- debug!("ast: {:#?}", ast);
-
- let mut results = Vec::new();
- for song in evaluate(&ast, false, client, stickers)
- .await
- .context("Failed to evaluate ast")?
- {
- results.push(client.add(&song).await);
- }
- match results
- .into_iter()
- .collect::<std::result::Result<Vec<()>, Error>>()
- {
- Ok(_) => Ok(()),
- Err(err) => Err(err),
- }
- }
-}
-
-#[cfg(test)]
-mod tokenize_tests {
- use super::Result;
- use super::tokenize;
-
- #[test]
- fn tokenize_smoke() {
- let mut buf1 = String::from("some-command").into_bytes();
- let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x1[0], b"some-command");
-
- let mut buf2 = String::from("a b").into_bytes();
- let x2: Vec<&[u8]> = tokenize(&mut buf2).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x2[0], b"a");
- assert_eq!(x2[1], b"b");
-
- let mut buf3 = String::from("a \"b c\"").into_bytes();
- let x3: Vec<&[u8]> = tokenize(&mut buf3).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x3[0], b"a");
- assert_eq!(x3[1], b"b c");
-
- let mut buf4 = String::from("a \"b c\" d").into_bytes();
- let x4: Vec<&[u8]> = tokenize(&mut buf4).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x4[0], b"a");
- assert_eq!(x4[1], b"b c");
- assert_eq!(x4[2], b"d");
-
- let mut buf5 = String::from("simple-command \"with space\" \"with '\"").into_bytes();
- let x5: Vec<&[u8]> = tokenize(&mut buf5).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x5[0], b"simple-command");
- assert_eq!(x5[1], b"with space");
- assert_eq!(x5[2], b"with '");
-
- let mut buf6 = String::from("cmd \"with\\\\slash and space\"").into_bytes();
- let x6: Vec<&[u8]> = tokenize(&mut buf6).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x6[0], b"cmd");
- assert_eq!(x6[1], b"with\\slash and space");
-
- let mut buf7 = String::from(" cmd \"with\\\\slash and space\" ").into_bytes();
- let x7: Vec<&[u8]> = tokenize(&mut buf7).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(x7[0], b"cmd");
- assert_eq!(x7[1], b"with\\slash and space");
- }
-
- #[test]
- fn tokenize_filter() {
- let mut buf1 = String::from(r#""(artist =~ \"foo\\\\bar\\\"\")""#).into_bytes();
- let x1: Vec<&[u8]> = tokenize(&mut buf1).collect::<Result<Vec<&[u8]>>>().unwrap();
- assert_eq!(1, x1.len());
- eprintln!("x1[0] is ``{}''", std::str::from_utf8(x1[0]).unwrap());
- assert_eq!(
- std::str::from_utf8(x1[0]).unwrap(),
- r#"(artist =~ "foo\\bar\"")"#
- );
- }
-}
diff --git a/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
new file mode 100644
index 00000000..7db75672
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
@@ -0,0 +1,140 @@
+use anyhow::{Context, Result, anyhow, bail, ensure};
+use clap::{Parser, Subcommand};
+use shlex::Shlex;
+use tracing::info;
+
+use crate::{
+ clients::{Client, IdleClient},
+ config::Mode,
+ dj::{Dj, algorithms::Discovery},
+};
+
+pub const COMMAND_CHANNEL: &str = "unwoundstack.com:commands";
+
+#[derive(Parser)]
+struct Commands {
+ #[command(subcommand)]
+ command: SubCommand,
+}
+
+#[derive(Parser)]
+enum SubCommand {
+ Dj {
+ #[command(subcommand)]
+ command: DjCommand,
+ },
+}
+
+#[derive(Subcommand)]
+enum DjCommand {
+ Start {
+ /// The chance to select a "positive" track
+ #[arg(long)]
+ positive_chance: f64,
+
+ /// The chance to select a "neutral" track
+ #[arg(long)]
+ neutral_chance: f64,
+
+ /// The chance to select a "negative" track
+ #[arg(long)]
+ negative_chance: f64,
+ },
+ Stop {},
+}
+
+pub(crate) struct MessageQueue {
+ dj: Option<Dj<Discovery>>,
+}
+
+impl MessageQueue {
+ pub(crate) fn new(mode: Mode) -> Self {
+ match mode {
+ Mode::Normal => Self { dj: None },
+ Mode::Dj => {
+ info!("Dj mode started on launch, as specified in config file");
+
+ Self {
+ dj: Some(Dj::new(Discovery::new(0.65, 0.5, 0.2))),
+ }
+ }
+ }
+ }
+
+ pub(crate) async fn advance_dj(&mut self, client: &mut Client) -> Result<()> {
+ if let Some(dj) = self.dj.as_mut() {
+ dj.add_track(client).await?;
+ }
+
+ Ok(())
+ }
+
+ /// Read messages off the commands channel & dispatch 'em
+ pub(crate) async fn check_messages(
+ &mut self,
+ client: &mut Client,
+ idle_client: &mut IdleClient,
+ ) -> Result<()> {
+ let m = idle_client
+ .get_messages()
+ .await
+ .context("Failed to `get_messages` from client")?;
+
+ for (chan, msgs) in m {
+ ensure!(chan == COMMAND_CHANNEL, "Unknown channel: `{}`", chan);
+
+ for msg in msgs {
+ self.process(client, msg).await?;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Process a single command
+ pub(crate) async fn process(&mut self, client: &mut Client, msg: String) -> Result<()> {
+ let split = {
+ let mut shl = Shlex::new(&msg);
+ let res: Vec<_> = shl.by_ref().collect();
+
+ if shl.had_error {
+ bail!("Failed to parse command '{msg}'")
+ }
+
+ assert_eq!(shl.line_no, 1, "A unexpected newline appeared");
+ assert!(!res.is_empty());
+
+ let mut base = vec!["base".to_owned()];
+ base.extend(res);
+ base
+ };
+
+ let args = Commands::parse_from(split);
+
+ match args.command {
+ SubCommand::Dj { command } => match command {
+ DjCommand::Start {
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ } => {
+ info!("Dj started");
+ self.dj = Some(Dj::new(Discovery::new(
+ positive_chance,
+ neutral_chance,
+ negative_chance,
+ )));
+ self.advance_dj(client).await?;
+ }
+ DjCommand::Stop {} => {
+ self.dj
+ .take()
+ .ok_or_else(|| anyhow!("Tried to disable already disabled dj mode"))?;
+ info!("Dj stopped");
+ }
+ },
+ }
+
+ Ok(())
+ }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
index 7d646b4c..eac71948 100644
--- a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -26,7 +26,7 @@
//!
use crate::clients::{Client, PlayerStatus};
-use crate::storage::{last_played, play_count, skipped};
+use crate::storage::{last_played, play_count, skip_count};
use anyhow::{Context, Error, Result, anyhow};
use tracing::{debug, info};
@@ -71,13 +71,16 @@ impl PlayState {
/// Poll the server-- update our status; maybe increment the current track's play count; the
/// caller must arrange to have this method invoked periodically to keep our state fresh
- pub async fn update(&mut self, client: &mut Client) -> Result<()> {
+ ///
+ /// Returns whether a song finished between the last call and this one.
+ /// That can be used to add a new song to the queue.
+ pub async fn update(&mut self, client: &mut Client) -> Result<bool> {
let new_stat = client
.status()
.await
.context("Failed to get client status")?;
- match (&self.last_server_stat, &new_stat) {
+ let previous_song_finished = match (&self.last_server_stat, &new_stat) {
(PlayerStatus::Play(last), PlayerStatus::Play(curr))
| (PlayerStatus::Pause(last), PlayerStatus::Play(curr))
| (PlayerStatus::Play(last), PlayerStatus::Pause(curr))
@@ -93,33 +96,59 @@ impl PlayState {
}
self.have_incr_play_count = false;
+
+ // We are now playing something else, as such the previous one must have
+ // finished or was skipped.
+ true
} else if last.elapsed > curr.elapsed
&& self.have_incr_play_count
&& curr.elapsed / curr.duration <= 0.1
{
debug!("Re-play-- resetting PC incremented flag.");
self.have_incr_play_count = false;
+
+ // We are still playing the same song, just skipped at the start again.
+ // This means that we don't need a new one.
+ false
+ } else {
+ // We are still playing the same song, so nothing changed
+ false
}
}
(PlayerStatus::Stopped, PlayerStatus::Play(_))
- | (PlayerStatus::Stopped, PlayerStatus::Pause(_))
- | (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+ | (PlayerStatus::Stopped, PlayerStatus::Pause(_)) => {
+ self.have_incr_play_count = false;
+
+ // We didn't play anything before and now we play something. This means that we
+ // obviously have something to play and thus don't need to add another song.
+ false
+ }
+ (PlayerStatus::Pause(_), PlayerStatus::Stopped)
| (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
self.have_incr_play_count = false;
+
+ // We played a song before and now we stopped, maybe because we ran out of songs to
+ // play. So we need to add another one.
+ true
}
- (PlayerStatus::Stopped, PlayerStatus::Stopped) => (),
- }
+ (PlayerStatus::Stopped, PlayerStatus::Stopped) => {
+ // We did not play before and we are still not playing, as such nothing really
+ // changed.
+ false
+ }
+ };
match &new_stat {
PlayerStatus::Play(curr) => {
let pct = curr.played_pct();
debug!("Updating status: {:.3}% complete.", 100.0 * pct);
+
if !self.have_incr_play_count && pct >= self.played_thresh {
info!(
- "Increment play count for '{}' (songid: {}) at {} played.",
+ "Increment play count for '{}' (songid: {}) at {:.2}% played.",
curr.file.display(),
curr.songid,
- curr.elapsed / curr.duration
+ (curr.elapsed / curr.duration) * 100.0
);
let file = curr.file.to_str().ok_or_else(|| {
@@ -143,6 +172,9 @@ impl PlayState {
play_count::set(client, file, curr_pc + 1).await?;
} else if self.last_song_was_skipped {
+ // TODO(@bpeetz): This should also record _when_ the skip was (e.g. a
+ // skip at 80% is not as important as one at 2%) <2026-09-05>
+
self.last_song_was_skipped = false;
let last = self
.last_server_stat
@@ -150,25 +182,25 @@ impl PlayState {
.expect("To exist, as it was skipped");
info!(
- "Marking '{}' (songid: {}) as skipped at {}.",
+ "Marking '{}' (songid: {}) as skipped at {:.2}%.",
last.file.display(),
last.songid,
- last.elapsed / last.duration
+ (last.elapsed / last.duration) * 100.0
);
let file = last.file.to_str().ok_or_else(|| {
anyhow!("Failed to parse path as utf8: `{}`", last.file.display())
})?;
- let skip_count = skipped::get(client, file).await?.unwrap_or_default();
- skipped::set(client, file, skip_count + 1).await?;
+ let skip_count = skip_count::get(client, file).await?.unwrap_or_default();
+ skip_count::set(client, file, skip_count + 1).await?;
}
}
PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
};
self.last_server_stat = new_stat;
- Ok(()) // No need to update the DB
+ Ok(previous_song_finished)
}
}
@@ -308,6 +340,6 @@ OK
assert!(check);
ps.update(&mut cli).await.unwrap();
- ps.update(&mut cli).await.unwrap()
+ ps.update(&mut cli).await.unwrap();
}
}
diff --git a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
index 24d8dcb5..a6f20d5b 100644
--- a/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
@@ -1,4 +1,4 @@
-use anyhow::{Error, Result};
+use anyhow::Result;
pub mod play_count {
use anyhow::Context;
@@ -60,14 +60,14 @@ pub mod play_count {
}
}
-pub mod skipped {
+pub mod skip_count {
use anyhow::Context;
use crate::clients::Client;
use super::Result;
- const STICKER: &str = "unwoundstack.com:skipped_count";
+ pub(crate) const STICKER: &str = "unwoundstack.com:skipped_count";
/// Retrieve the skip count for a track
pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> {
@@ -117,7 +117,7 @@ pub mod last_played {
}
}
-pub mod rating_count {
+pub mod rating {
use anyhow::Context;
use crate::clients::Client;