about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs597
1 files changed, 597 insertions, 0 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
new file mode 100644
index 00000000..d9d607d5
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
@@ -0,0 +1,597 @@
+// 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 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<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_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<String>) -> 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<String>) -> 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<String>) -> 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<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 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,
+    },
+
+    /// 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' <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 },
+
+    /// 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 },
+
+    /// Send a command to mpd.
+    #[clap(verbatim_doc_comment)]
+    SendCommand { args: Vec<String> },
+}
+
+#[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
+        }
+    }
+}