diff options
Diffstat (limited to '')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs | 233 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs (renamed from pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs) | 421 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/config.rs | 127 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs | 206 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/mod.rs | 2 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/lib.rs | 12 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs | 40 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/playcounts.rs | 53 |
8 files changed, 677 insertions, 417 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..c20bf3fa --- /dev/null +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs @@ -0,0 +1,233 @@ +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 faa651bf..42f01873 100644 --- a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs +++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/main.rs @@ -26,21 +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}, config::{self, Config}, + dj::algorithms::Discovery, filters::ExpressionParser, filters_ast::{FilterStickerNames, evaluate}, messanges::COMMAND_CHANNEL, - storage::{last_played, play_count, rating}, + 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 /// @@ -296,219 +313,6 @@ async fn searchadd(client: &mut Client, filter: &str, case_sensitive: bool) -> R } } -/// `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 -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)] -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 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 {}, - - /// 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)] -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, - }, -} - #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); @@ -597,8 +401,191 @@ async fn main() -> Result<()> { case_sensitive, } => searchadd(&mut client, &filter, case_sensitive).await, SubCommand::Dj { command } => match command { - DjCommand::Start {} => client.send_message(COMMAND_CHANNEL, "dj start").await, + 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/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs index b4fe3c53..8bb5abfb 100644 --- a/pkgs/by-name/mp/mpdpopm/src/config.rs +++ b/pkgs/by-name/mp/mpdpopm/src/config.rs @@ -116,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)] @@ -133,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, @@ -142,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 { @@ -162,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(), }) } } @@ -176,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 index 5ddfc7cb..2c3ddad6 100644 --- a/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs +++ b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs @@ -1,7 +1,10 @@ -use std::collections::HashSet; +use std::{ + collections::HashSet, + time::{Duration, SystemTime}, +}; use anyhow::{Context, Result}; -use rand::{Rng, distr, seq::SliceRandom}; +use rand::{Rng, distr}; use tracing::info; use crate::{clients::Client, storage}; @@ -13,50 +16,66 @@ pub(crate) trait Algorithm { /// 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(crate) struct Discovery { +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 { - ($first:expr, $second:expr, $third:expr) => {{ - $first.pop().map_or_else( - || { - $second.pop().map_or_else( - || { - $third.pop().map_or_else( - || { - unreachable!( - "This means that there are no songs in the libary" - ) - }, - |val| { - tracing::info!( - "Selecting a `{}` track for the next entry in the queue", - stringify!($third) - ); - Ok::<_, anyhow::Error>(val) - }, - ) - }, - |val| { - tracing::info!( - "Selecting a `{}` track for the next entry in the queue", - stringify!($second) - ); - Ok::<_, anyhow::Error>(val) - }, - ) - }, - |val| { - tracing::info!( - "Selecting a `{}` track for the next entry in the queue", - stringify!($first) - ); - Ok::<_, anyhow::Error>(val) - }, - ) + ($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) }}; } @@ -83,50 +102,74 @@ impl Algorithm for Discovery { base }; - let mut positive = vec![]; - let mut neutral = vec![]; - let mut negative = vec![]; - + let mut sorted_tracks = Vec::with_capacity(tracks.len()); for track in tracks { let weight = Self::weight_track(client, &track).await?; - match weight { - 1..=i64::MAX => positive.push(track), - 0 => neutral.push(track), - i64::MIN..0 => negative.push(track), - } + sorted_tracks.push((track, weight)); } - // Avoid an inherit ordering, that might be returned by the `Client::get_all_songs()` function. - positive.shuffle(&mut rng); - neutral.shuffle(&mut rng); - negative.shuffle(&mut rng); + 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 negative = sorted_tracks.drain(..len).collect::<Vec<_>>(); + let neutral = sorted_tracks.drain(..len).collect::<Vec<_>>(); + let positive = sorted_tracks; + + assert_eq!(negative.len(), neutral.len()); (positive, neutral, negative) }; let pick = rng.sample( - distr::weighted::WeightedIndex::new([0.65, 0.5, 0.2].iter()) - .expect("to be valid, as hardcoded"), + 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 => take!(positive, neutral, negative), - 1 => take!(neutral, positive, negative), - 2 => take!(negative, neutral, positive), + 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.clone()); + self.already_done.insert(next.0.to_owned()); - Ok(next) + Ok(next.0) } } impl Discovery { - pub(crate) fn new() -> Self { + 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, } } @@ -136,15 +179,50 @@ impl Discovery { /// 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. - async fn weight_track(client: &mut Client, track: &str) -> Result<i64> { + 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); + 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; diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs index a211a571..548ed4f4 100644 --- a/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs +++ b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs @@ -3,7 +3,7 @@ use tracing::info; use crate::{clients::Client, dj::algorithms::Algorithm}; -pub(crate) mod algorithms; +pub mod algorithms; pub(crate) struct Dj<A: Algorithm> { algo: A, diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs index cc2765dc..2394b729 100644 --- a/pkgs/by-name/mp/mpdpopm/src/lib.rs +++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs @@ -53,7 +53,7 @@ pub mod filters { use crate::{ clients::{Client, IdleClient, IdleSubSystem}, config::{Config, Connection}, - messanges::MessageQueue, + messanges::{COMMAND_CHANNEL, MessageQueue}, playcounts::PlayState, }; @@ -93,10 +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(); + let mut mqueue = MessageQueue::new(cfg.mode); idle_client - .subscribe(&cfg.commands_chan) + .subscribe(COMMAND_CHANNEL) .await .context("Failed to subscribe to idle_client")?; @@ -144,14 +144,14 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> { 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; } diff --git a/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs index c5320dd9..7db75672 100644 --- a/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs +++ b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs @@ -5,6 +5,7 @@ use tracing::info; use crate::{ clients::{Client, IdleClient}, + config::Mode, dj::{Dj, algorithms::Discovery}, }; @@ -26,7 +27,19 @@ enum SubCommand { #[derive(Subcommand)] enum DjCommand { - Start {}, + 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 {}, } @@ -35,8 +48,17 @@ pub(crate) struct MessageQueue { } impl MessageQueue { - pub(crate) fn new() -> Self { - Self { dj: None } + 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<()> { @@ -91,9 +113,17 @@ impl MessageQueue { match args.command { SubCommand::Dj { command } => match command { - DjCommand::Start {} => { + DjCommand::Start { + positive_chance, + neutral_chance, + negative_chance, + } => { info!("Dj started"); - self.dj = Some(Dj::new(Discovery::new())); + self.dj = Some(Dj::new(Discovery::new( + positive_chance, + neutral_chance, + negative_chance, + ))); self.advance_dj(client).await?; } DjCommand::Stop {} => { diff --git a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs index 417b3e7e..8fbee133 100644 --- a/pkgs/by-name/mp/mpdpopm/src/playcounts.rs +++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs @@ -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) => { + // We did not play before and we are still not playing, as such nothing really + // changed. + false } - (PlayerStatus::Stopped, PlayerStatus::Stopped) => (), - } + }; 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(|| { @@ -150,10 +179,10 @@ 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(|| { @@ -168,7 +197,7 @@ impl PlayState { }; self.last_server_stat = new_stat; - Ok(()) // No need to update the DB + Ok(previous_song_finished) } } @@ -308,6 +337,6 @@ OK assert!(check); ps.update(&mut cli).await.unwrap(); - ps.update(&mut cli).await.unwrap() + ps.update(&mut cli).await.unwrap(); } } |
