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