diff options
Diffstat (limited to '')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm/cli.rs | 232 | ||||
| -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.rs | 2 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/config.rs | 138 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs | 237 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/mod.rs | 28 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/filters.lalrpop | 59 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/filters_ast.rs | 49 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/lib.rs | 60 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/messages.rs | 409 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs | 140 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/playcounts.rs | 62 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/storage/mod.rs | 8 |
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; |
