// Copyright (C) 2020-2025 Michael herstine // // 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 . //! # 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 mpdpopm::{ clients::{Client, PlayerStatus, quote}, config::{self, Config}, storage::{last_played, play_count, rating_count}, }; use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand}; use tracing::{debug, info, level_filters::LevelFilter, trace}; use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; use std::path::PathBuf; /// 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>) -> Result> { 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) -> Result { 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>, with_uri: bool, ) -> Result<()> { let mut ratings: Vec<(String, i8)> = Vec::new(); for file in map_tracks(client, tracks).await? { let rating = rating_count::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) -> Result<()> { let is_current = arg.is_none(); let file = provide_file(client, arg).await?; rating_count::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) -> Result<()> { let is_current = arg.is_none(); let file = provide_file(client, arg).await?; let now = rating_count::get(client, &file).await?; rating_count::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) -> Result<()> { let is_current = arg.is_none(); let file = provide_file(client, arg).await?; let now = rating_count::get(client, &file).await?; rating_count::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>, 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) -> 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>, with_uri: bool, ) -> Result<()> { let mut lastplayeds: Vec<(String, Option)> = 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) -> 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 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) -> Result<()> { client .send_message( chan, args.iter() .map(String::as_str) .map(quote) .collect::>() .join(" ") .as_str(), ) .await?; Ok(()) } /// `mppopmd' client #[derive(Parser)] struct Args { /// path to configuration file #[arg(short, long)] config: Option, /// 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>, }, /// 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 }, /// 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 }, /// 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 }, } #[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>, }, /// 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, }, } #[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>, }, /// 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, }, } #[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, }, /// Change details about last played date. LastPlayed { #[command(subcommand)] command: LastPlayedCommand, }, /// Change details about generated playlists. Playlists { #[command(subcommand)] command: PlaylistsCommand, }, /// 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' will search the /// MPD database for songs that match a given filter & add them to the play queue. The filter syntax is /// documented here . /// /// 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 }, /// 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' will search /// the MPD database for songs that match a given filter & add them to the play queue. The filter syntax /// is documented here . /// /// 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 }, /// Send a command to mpd. #[clap(verbatim_doc_comment)] SendCommand { args: Vec }, } #[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::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 } } }