about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs597
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopmd.rs150
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/clients.rs1202
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/config.rs277
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters.lalrpop143
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/filters_ast.rs1005
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/lib.rs207
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messages.rs409
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/playcounts.rs313
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/storage/mod.rs145
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/vars.rs4
11 files changed, 4452 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
+        }
+    }
+}
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")
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/clients.rs b/pkgs/by-name/mp/mpdpopm/src/clients.rs
new file mode 100644
index 00000000..b88e4041
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/clients.rs
@@ -0,0 +1,1202 @@
+// 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/>.
+
+//! mpd clients and associated utilities.
+//!
+//! # Introduction
+//!
+//! This module contains basic types implementing various MPD client operations (cf. the [mpd
+//! protocol](http://www.musicpd.org/doc/protocol/)). Since issuing the "idle" command will tie up
+//! the connection, MPD clients often use multiple connections to the server (one to listen for
+//! updates, one or more on which to issue commands). This modules provides two different client
+//! types: [Client] for general-purpose use and [IdleClient] for long-lived connections listening
+//! for server notifiations.
+//!
+//! Note that there *is* another idiom (used in [libmpdel](https://github.com/mpdel/libmpdel),
+//! e.g.): open a single connection & issue an "idle" command. When you want to issue a command,
+//! send a "noidle", then the command, then "idle" again.  This isn't a race condition, as the
+//! server will buffer any changes that took place when you were not idle & send them when you
+//! re-issue the "idle" command. This crate however takes the approach of two channels (like
+//! [mpdfav](https://github.com/vincent-petithory/mpdfav)).
+
+use anyhow::{Context, Error, Result, anyhow, bail, ensure};
+use async_trait::async_trait;
+use regex::Regex;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tokio::net::{TcpStream, ToSocketAddrs, UnixStream};
+use tracing::{debug, info};
+
+use lazy_static::lazy_static;
+
+use std::{
+    collections::HashMap,
+    convert::TryFrom,
+    fmt,
+    marker::{Send, Unpin},
+    path::{Path, PathBuf},
+    str::FromStr,
+};
+
+// Some default error context messages
+const ENCODING_SNAFU: &str = "Failed to interpete text as utf8";
+const IO_SNAFU: &str = "Failed read from mpd socket";
+
+/// A description of the current track, suitable for our purposes (as in, it only tracks the
+/// attributes needed for this module's functionality).
+#[derive(Clone, Debug)]
+pub struct CurrentSong {
+    /// Identifier, unique within the play queue, identifying this particular track; if the same
+    /// file is listed twice in the `mpd' play queue each instance will get a distinct songid
+    pub songid: u64,
+
+    /// Path, relative to `mpd' music directory root of this track
+    pub file: std::path::PathBuf,
+
+    /// Elapsed time, in seconds, in this track
+    pub elapsed: f64,
+
+    /// Total track duration, in seconds
+    pub duration: f64,
+}
+
+impl CurrentSong {
+    fn new(songid: u64, file: std::path::PathBuf, elapsed: f64, duration: f64) -> CurrentSong {
+        CurrentSong {
+            songid,
+            file,
+            elapsed,
+            duration,
+        }
+    }
+    /// Compute the ratio of the track that has elapsed, expressed as a floating point between 0 & 1
+    pub fn played_pct(&self) -> f64 {
+        self.elapsed / self.duration
+    }
+}
+
+/// The MPD player itself can be in one of three states: playing, paused or stopped. In the first
+/// two there is a "current" song.
+#[derive(Clone, Debug)]
+pub enum PlayerStatus {
+    Play(CurrentSong),
+    Pause(CurrentSong),
+    Stopped,
+}
+
+impl PlayerStatus {
+    pub fn current_song(&self) -> Option<&CurrentSong> {
+        match self {
+            PlayerStatus::Play(curr) | PlayerStatus::Pause(curr) => Some(curr),
+            PlayerStatus::Stopped => None,
+        }
+    }
+}
+
+/// A trait representing a simple, textual request/response protocol like that
+/// [employed](https://www.musicpd.org/doc/html/protocol.html) by [MPD](https://www.musicpd.org/):
+/// the caller sends a textual command & the server responds with a (perhaps multi-line) textual
+/// response.
+///
+/// This trait also enables unit testing client implementations. Note that it is async-- cf.
+/// [async_trait](https://docs.rs/async-trait/latest/async_trait/).
+#[async_trait]
+pub trait RequestResponse {
+    async fn req(&mut self, msg: &str) -> Result<String>;
+    /// The hint is used to size the buffer prior to reading the response
+    async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String>;
+}
+
+#[cfg(test)]
+pub mod test_mock {
+    use super::*;
+
+    /// Mock is an implementation of [`RequestRespone`] that checks expected requests & responses,
+    /// and will panic if it sees anything unexpected
+    pub struct Mock {
+        inmsgs: Vec<String>,
+        outmsgs: Vec<String>,
+    }
+
+    impl Mock {
+        pub fn new(convo: &[(&str, &str)]) -> Mock {
+            let (left, right): (Vec<&str>, Vec<&str>) = convo.iter().copied().rev().unzip();
+            Mock {
+                inmsgs: left.iter().map(|x| x.to_string()).collect(),
+                outmsgs: right.iter().map(|x| x.to_string()).collect(),
+            }
+        }
+    }
+
+    #[async_trait]
+    impl RequestResponse for Mock {
+        async fn req(&mut self, msg: &str) -> Result<String> {
+            self.req_w_hint(msg, 512).await
+        }
+        async fn req_w_hint(&mut self, msg: &str, _hint: usize) -> Result<String> {
+            assert_eq!(msg, self.inmsgs.pop().unwrap());
+            Ok(self.outmsgs.pop().unwrap())
+        }
+    }
+
+    #[tokio::test]
+    async fn mock_smoke_test() {
+        let mut mock = Mock::new(&[("ping", "pong"), ("from", "to")]);
+        assert_eq!(mock.req("ping").await.unwrap(), "pong");
+        assert_eq!(mock.req("from").await.unwrap(), "to");
+    }
+
+    #[tokio::test]
+    #[should_panic]
+    async fn mock_negative_test() {
+        let mut mock = Mock::new(&[("ping", "pong")]);
+        assert_eq!(mock.req("ping").await.unwrap(), "pong");
+        let _should_panic = mock.req("not there!").await.unwrap();
+    }
+}
+
+/// [MPD](https://www.musicpd.org/) connections talk the same
+/// [protocol](https://www.musicpd.org/doc/html/protocol.html) over either a TCP or a Unix socket.
+///
+/// # Examples
+///
+/// Implementations are provided for tokio [UnixStream] and [TcpStream], but [MpdConnection] is a
+/// trait that can work in terms of any asynchronous communications channel (so long as it is also
+/// [Send] and [Unpin] so async executors can pass them between threads.
+///
+/// To create a connection to an `MPD` server over a Unix domain socket:
+///
+/// ```no_run
+/// use std::path::Path;
+/// use tokio::net::UnixStream;
+/// use mpdpopm::clients::MpdConnection;
+/// let local_conn = MpdConnection::<UnixStream>::connect(Path::new("/var/run/mpd/mpd.sock"));
+/// ```
+///
+/// In this example, `local_conn` is a Future that will resolve to a Result containing the
+/// [MpdConnection] Unix domain socket implementation once the socket has been established, the MPD
+/// server greets us & the protocol version has been parsed.
+///
+/// or over a TCP socket:
+///
+/// ```no_run
+/// use std::net::SocketAddrV4;
+/// use tokio::net::{TcpStream, ToSocketAddrs};
+/// use mpdpopm::clients::MpdConnection;
+/// let tcp_conn = MpdConnection::<TcpStream>::connect("localhost:6600".parse::<SocketAddrV4>().unwrap());
+/// ```
+///
+/// Here, `tcp_conn` is a Future that will resolve to a Result containing the [MpdConnection] TCP
+/// implementation on successful connection to the MPD server (i.e. the connection is established,
+/// the server greets us & we parse the protocol version).
+///
+///
+pub struct MpdConnection<T: AsyncRead + AsyncWrite + Send + Unpin> {
+    sock: T,
+    _protocol_ver: String,
+}
+
+/// MpdConnection implements RequestResponse using the usual (async) socket I/O
+///
+/// The callers need not include the trailing newline in their requests; the implementation will
+/// append it.
+#[async_trait]
+impl<T> RequestResponse for MpdConnection<T>
+where
+    T: AsyncRead + AsyncWrite + Send + Unpin,
+{
+    async fn req(&mut self, msg: &str) -> Result<String> {
+        self.req_w_hint(msg, 512).await
+    }
+    async fn req_w_hint(&mut self, msg: &str, hint: usize) -> Result<String> {
+        self.sock
+            .write_all(format!("{}\n", msg).as_bytes())
+            .await
+            .context(IO_SNAFU)?;
+        let mut buf = Vec::with_capacity(hint);
+
+        // Given the request/response nature of the MPD protocol, our callers expect a complete
+        // response. Therefore we need to loop here until we see either "...^OK\n" or
+        // "...^ACK...\n".
+        let mut cb = 0; // # bytes read so far
+        let mut more = true; // true as long as there is more to read
+        while more {
+            cb += self.sock.read_buf(&mut buf).await.context(IO_SNAFU)?;
+
+            // The shortest complete response has three bytes. If the final byte in `buf' is not a
+            // newline, then don't bother looking further.
+            if cb > 2 && char::from(buf[cb - 1]) == '\n' {
+                // If we're here, `buf' *may* contain a complete response. Search backward for the
+                // previous newline. It may not exist: many responses are of the form "OK\n".
+                let mut idx = cb - 2;
+                while idx > 0 {
+                    if char::from(buf[idx]) == '\n' {
+                        idx += 1;
+                        break;
+                    }
+                    idx -= 1;
+                }
+
+                if (idx + 2 < cb && char::from(buf[idx]) == 'O' && char::from(buf[idx + 1]) == 'K')
+                    || (idx + 3 < cb
+                        && char::from(buf[idx]) == 'A'
+                        && char::from(buf[idx + 1]) == 'C'
+                        && char::from(buf[idx + 2]) == 'K')
+                {
+                    more = false;
+                }
+            }
+        }
+
+        // Only doing this to trouble-shoot issue 11
+        String::from_utf8(buf.clone()).context(ENCODING_SNAFU)
+    }
+}
+
+/// Utility function to parse the initial response to a connection from mpd
+async fn parse_connect_rsp<T>(sock: &mut T) -> Result<String>
+where
+    T: AsyncReadExt + AsyncWriteExt + Send + Unpin,
+{
+    let mut buf = Vec::with_capacity(32);
+    let _cb = sock.read_buf(&mut buf).await.context(IO_SNAFU)?;
+
+    // Only doing this to trouble-shoot issue 11
+    let text = String::from_utf8(buf.clone()).context(ENCODING_SNAFU)?;
+
+    ensure!(
+        text.starts_with("OK MPD "),
+        "failed to connect: {}",
+        text.trim()
+    );
+    info!("Connected {}.", text[7..].trim());
+    Ok(text[7..].trim().to_string())
+}
+
+impl MpdConnection<TcpStream> {
+    pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<Box<dyn RequestResponse>> {
+        let mut sock = TcpStream::connect(addr).await.context(IO_SNAFU)?;
+        let proto_ver = parse_connect_rsp(&mut sock).await?;
+        Ok(Box::new(MpdConnection::<TcpStream> {
+            sock,
+            _protocol_ver: proto_ver,
+        }))
+    }
+}
+
+impl MpdConnection<UnixStream> {
+    // NTS: we have to box the return value because a `dyn RequestResponse` isn't Sized.
+    pub async fn connect<P: AsRef<Path>>(pth: P) -> Result<Box<dyn RequestResponse>> {
+        let mut sock = UnixStream::connect(pth).await.context(IO_SNAFU)?;
+        let proto_ver = parse_connect_rsp(&mut sock).await?;
+        Ok(Box::new(MpdConnection::<UnixStream> {
+            sock,
+            _protocol_ver: proto_ver,
+        }))
+    }
+}
+
+/// Quote an argument by backslash-escaping " & \ characters
+pub fn quote(text: &str) -> String {
+    if text.contains(&[' ', '\t', '\'', '"'][..]) {
+        let mut s = String::from("\"");
+        for c in text.chars() {
+            if c == '"' || c == '\\' {
+                s.push('\\');
+            }
+            s.push(c);
+        }
+        s.push('"');
+        s
+    } else {
+        text.to_string()
+    }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+//                                               Client                                           //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+/// General-purpose [mpd](https://www.musicpd.org)
+/// [client](https://www.musicpd.org/doc/html/protocol.html): "general-purpose" in the sense that we
+/// send commands through it; the interface is narrowly scoped to this program's needs.
+///
+/// # Introduction
+///
+/// This is the primary abstraction of the MPD client protocol, written for the convenience of
+/// [mpdpopm](crate). Construct instances with a TCP socket, a Unix socket, or any [RequestResponse]
+/// implementation. You can then carry out assorted operations in the MPD client protocol by
+/// invoking its methods.
+///
+/// ```no_run
+/// use std::path::Path;
+/// use mpdpopm::clients::Client;
+/// let client = Client::open(Path::new("/var/run/mpd.sock"));
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to a [Client] instance talking to `/var/run/mpd.sock`.
+///
+/// ```no_run
+/// use mpdpopm::clients::Client;
+/// let client = Client::connect("localhost:6600");
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to a [Client] instance talking TCP to the MPD server on localhost at port 6600.
+pub struct Client {
+    stream: Box<dyn RequestResponse>,
+}
+
+// Thanks to <https://stackoverflow.com/questions/35169259/how-to-make-a-compiled-regexp-a-global-variable>
+lazy_static! {
+    static ref RE_STATE: regex::Regex = Regex::new(r"(?m)^state: (play|pause|stop)$").unwrap();
+    static ref RE_SONGID: regex::Regex = Regex::new(r"(?m)^songid: ([0-9]+)$").unwrap();
+    static ref RE_ELAPSED: regex::Regex = Regex::new(r"(?m)^elapsed: ([.0-9]+)$").unwrap();
+    static ref RE_FILE: regex::Regex = Regex::new(r"(?m)^file: (.*)$").unwrap();
+    static ref RE_DURATION: regex::Regex = Regex::new(r"(?m)^duration: (.*)$").unwrap();
+}
+
+impl Client {
+    pub async fn connect<A: ToSocketAddrs>(addrs: A) -> Result<Client> {
+        Self::new(MpdConnection::<TcpStream>::connect(addrs).await?)
+    }
+
+    pub async fn open<P: AsRef<Path>>(pth: P) -> Result<Client> {
+        Self::new(MpdConnection::<UnixStream>::connect(pth).await?)
+    }
+
+    pub fn new(stream: Box<dyn RequestResponse>) -> Result<Client> {
+        Ok(Client { stream })
+    }
+}
+
+impl Client {
+    /// Retrieve the current server status.
+    pub async fn status(&mut self) -> Result<PlayerStatus> {
+        // We begin with sending the "status" command: "Reports the current status of the player and
+        // the volume level." Per the docs, "MPD may omit lines which have no (known) value", so I
+        // can't really count on particular lines being there. Tho nothing is said in the docs, I
+        // also don't want to depend on the order.
+        let text = self.stream.req("status").await?;
+
+        let proto = || -> Error { anyhow!("Failed to parse mpd status output (with regexes)") };
+
+        // I first thought to avoid the use (and cost) of regular expressions by just doing
+        // sub-string searching on "state: ", but when I realized I needed to only match at the
+        // beginning of a line I bailed & just went ahead. This makes for more succinct code, since
+        // I can't count on order, either.
+        let state = RE_STATE
+            .captures(&text)
+            .ok_or_else(proto)?
+            .get(1)
+            .ok_or_else(proto)?
+            .as_str();
+
+        match state {
+            "stop" => Ok(PlayerStatus::Stopped),
+            "play" | "pause" => {
+                let songid = RE_SONGID
+                    .captures(&text)
+                    .ok_or_else(proto)?
+                    .get(1)
+                    .ok_or_else(proto)?
+                    .as_str()
+                    .parse::<u64>()
+                    .context("Failed to parse songid as u64")?;
+
+                let elapsed = RE_ELAPSED
+                    .captures(&text)
+                    .ok_or_else(proto)?
+                    .get(1)
+                    .ok_or_else(proto)?
+                    .as_str()
+                    .parse::<f64>()
+                    .context("failed to parse `elapsed` as f64")?;
+
+                // navigate from `songid'-- don't send a "currentsong" message-- the current song
+                // could have changed
+                let text = self.stream.req(&format!("playlistid {}", songid)).await?;
+
+                let file = RE_FILE
+                    .captures(&text)
+                    .ok_or_else(proto)?
+                    .get(1)
+                    .ok_or_else(proto)?
+                    .as_str();
+                let duration = RE_DURATION
+                    .captures(&text)
+                    .ok_or_else(proto)?
+                    .get(1)
+                    .ok_or_else(proto)?
+                    .as_str()
+                    .parse::<f64>()
+                    .context("Failed to parse `duration` as f64")?;
+
+                let curr = CurrentSong::new(songid, PathBuf::from(file), elapsed, duration);
+
+                if state == "play" {
+                    Ok(PlayerStatus::Play(curr))
+                } else {
+                    Ok(PlayerStatus::Pause(curr))
+                }
+            }
+            _ => bail!("Encountered unknow state `{}`", state),
+        }
+    }
+
+    /// Retrieve a song sticker by name
+    pub async fn get_sticker<T: FromStr>(
+        &mut self,
+        file: &str,
+        sticker_name: &str,
+    ) -> Result<Option<T>>
+    where
+        <T as FromStr>::Err: std::error::Error + Sync + Send + 'static,
+    {
+        let msg = format!("sticker get song {} {}", quote(file), quote(sticker_name));
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent message `{}'; got `{}'", &msg, &text);
+
+        let prefix = format!("sticker: {}=", sticker_name);
+        if text.starts_with(&prefix) {
+            let s = text[prefix.len()..]
+                .split('\n')
+                .next()
+                .with_context(|| format!("Failed to parse `{}` as get_sticker response", text))?;
+            Ok(Some(T::from_str(s).with_context(|| {
+                format!(
+                    "Failed to parse sticker value as correct type: `{}`",
+                    sticker_name
+                )
+            })?))
+        } else {
+            // ACK_ERROR_NO_EXIST = 50 (Ack.hxx:17)
+            ensure!(
+                text.starts_with("ACK [50@0]"),
+                "Missing no sticker response"
+            );
+            Ok(None)
+        }
+    }
+
+    /// Set a song sticker by name
+    pub async fn set_sticker<T: std::fmt::Display>(
+        &mut self,
+        file: &str,
+        sticker_name: &str,
+        sticker_value: &T,
+    ) -> Result<()> {
+        let value_as_str = format!("{}", sticker_value);
+        let msg = format!(
+            "sticker set song {} {} {}",
+            quote(file),
+            quote(sticker_name),
+            quote(&value_as_str)
+        );
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent `{}'; got `{}'", &msg, &text);
+
+        ensure!(text.starts_with("OK"), "Set sticker, not acknowledged");
+        Ok(())
+    }
+
+    /// Send a file to a playlist
+    pub async fn send_to_playlist(&mut self, file: &str, pl: &str) -> Result<()> {
+        let msg = format!("playlistadd {} {}", quote(pl), quote(file));
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent `{}'; got `{}'.", &msg, &text);
+        ensure!(text.starts_with("OK"), "send_to_playlist not acknowledged");
+        Ok(())
+    }
+
+    /// Send an arbitrary message
+    pub async fn send_message(&mut self, chan: &str, msg: &str) -> Result<()> {
+        let msg = format!("sendmessage {} {}", chan, quote(msg));
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+        ensure!(text.starts_with("OK"), "Send_message not acknowledged");
+        Ok(())
+    }
+
+    /// Update a URI
+    pub async fn update(&mut self, uri: &str) -> Result<u64> {
+        let msg = format!("update \"{}\"", uri);
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+        // We expect a response of the form:
+        //   updating_db: JOBID
+        //   OK
+        // on success, and
+        //   ACK ERR
+        // on failure.
+
+        let prefix = "updating_db: ";
+        ensure!(
+            text.starts_with(prefix),
+            "update response doesn't start with correct prefix"
+        );
+        text[prefix.len()..].split('\n').collect::<Vec<&str>>()[0]
+            .to_string()
+            .parse::<u64>()
+            .context("Failed to treat update job id as u64")
+    }
+
+    /// Get the list of stored playlists
+    pub async fn get_stored_playlists(&mut self) -> Result<std::vec::Vec<String>> {
+        let text = self.stream.req("listplaylists").await?;
+        debug!("Sent listplaylists; got `{}'.", &text);
+
+        // We expect a response of the form:
+        // playlist: a
+        // Last-Modified: 2020-03-13T17:20:16Z
+        // playlsit: b
+        // Last-Modified: 2020-03-13T17:20:16Z
+        // ...
+        // OK
+        //
+        // or
+        //
+        // ACK...
+        ensure!(
+            !text.starts_with("ACK"),
+            "get_stored_playlists response not acknowledged"
+        );
+        Ok(text
+            .lines()
+            .filter_map(|x| x.strip_prefix("playlist: ").map(String::from))
+            .collect::<Vec<String>>())
+    }
+
+    /// Process a search (either find or search) response
+    fn search_rsp_to_uris(&self, text: &str) -> Result<std::vec::Vec<String>> {
+        // We expect a response of the form:
+        // file: P/Pogues, The - A Pistol For Paddy Garcia.mp3
+        // Last-Modified: 2007-12-26T19:18:00Z
+        // Format: 44100:24:2
+        // ...
+        // file: P/Pogues, The - Billy's Bones.mp3
+        // ...
+        // OK
+        //
+        // or
+        //
+        // ACK...
+        ensure!(!text.starts_with("ACK"), "rsp_to_uris not acknowledged");
+        Ok(text
+            .lines()
+            .filter_map(|x| x.strip_prefix("file: ").map(String::from))
+            .collect::<Vec<String>>())
+    }
+
+    /// Search the database for songs matching filter (unary operator)
+    ///
+    /// Set `case` to true to request a case-sensitive search (false yields case-insensitive)
+    pub async fn find1(
+        &mut self,
+        cond: &str,
+        val: &str,
+        case: bool,
+    ) -> Result<std::vec::Vec<String>> {
+        let cmd = format!(
+            "{} {}",
+            if case { "find" } else { "search" },
+            quote(&format!("({} {})", cond, val))
+        );
+        let text = self.stream.req(&cmd).await?;
+        self.search_rsp_to_uris(&text)
+    }
+
+    /// Search the database for songs matching filter (case-sensitive, binary operator)
+    ///
+    /// Set `case` to true to request a case-sensitive search (false yields case-insensitive)
+    pub async fn find2(
+        &mut self,
+        attr: &str,
+        op: &str,
+        val: &str,
+        case: bool,
+    ) -> Result<std::vec::Vec<String>> {
+        let cmd = format!(
+            "{} {}",
+            if case { "find" } else { "search" },
+            quote(&format!("({} {} {})", attr, op, val))
+        );
+        debug!("find2 sending ``{}''", cmd);
+        let text = self.stream.req(&cmd).await?;
+        self.search_rsp_to_uris(&text)
+    }
+
+    /// Retrieve all instances of a given sticker under the music directory
+    ///
+    /// Return a mapping from song URI to textual sticker value
+    pub async fn get_stickers(&mut self, sticker: &str) -> Result<HashMap<String, String>> {
+        let text = self
+            .stream
+            .req(&format!("sticker find song \"\" {}", sticker))
+            .await?;
+
+        // We expect a response of the form:
+        //
+        // file: U-Z/Zafari - Addis Adaba.mp3
+        // sticker: unwoundstack.com:rating=64
+        // ...
+        // file: U-Z/Zero 7 - In Time (Album Version).mp3
+        // sticker: unwoundstack.com:rating=255
+        // OK
+        //
+        // or
+        //
+        // ACK ...
+        ensure!(!text.starts_with("ACK"), "get_stickers not ACKed");
+        let mut m = HashMap::new();
+        let mut lines = text.lines();
+        loop {
+            let file = lines.next().context("get_stickers no new line")?;
+            if "OK" == file {
+                break;
+            }
+            let val = lines.next().context("get_stickers no val")?;
+
+            m.insert(
+                String::from(&file[6..]),
+                String::from(&val[10 + sticker.len()..]),
+            );
+        }
+        Ok(m)
+    }
+
+    /// Retrieve the song URIs of all songs in the database
+    ///
+    /// Returns a vector of String
+    pub async fn get_all_songs(&mut self) -> Result<std::vec::Vec<String>> {
+        let text = self.stream.req("find \"(base '')\"").await?;
+        // We expect a response of the form:
+        // file: 0-A/A Positive Life - Lighten Up!.mp3
+        // Last-Modified: 2020-11-18T22:47:07Z
+        // Format: 44100:24:2
+        // Time: 399
+        // duration: 398.550
+        // Artist: A Positive Life
+        // Title: Lighten Up!
+        // Genre: Electronic
+        // file: 0-A/A Positive Life - Pleidean Communication.mp3
+        // ...
+        // OK
+        //
+        // or "ACK..."
+        ensure!(!text.starts_with("ACK"), "get_all_songs not ACKed");
+        Ok(text
+            .lines()
+            .filter_map(|x| x.strip_prefix("file: ").map(String::from))
+            .collect::<Vec<String>>())
+    }
+
+    pub async fn add(&mut self, uri: &str) -> Result<()> {
+        let msg = format!("add {}", quote(uri));
+        let text = self.stream.req(&msg).await?;
+        debug!("Sent `{}'; got `{}'.", &msg, &text);
+
+        ensure!(text.starts_with("OK"), "add not Oked");
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+/// Let's test Client!
+mod client_tests {
+
+    use super::test_mock::Mock;
+    use super::*;
+
+    /// Some basic "smoke" tests
+    #[tokio::test]
+    async fn client_smoke_test() {
+        let mock = Box::new(Mock::new(&[(
+            "sticker get song foo.mp3 stick",
+            "sticker: stick=splat\nOK\n",
+        )]));
+        let mut cli = Client::new(mock).unwrap();
+        let val = cli
+            .get_sticker::<String>("foo.mp3", "stick")
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(val, "splat");
+    }
+
+    /// Test the `status' method
+    #[tokio::test]
+    async fn test_status() {
+        let mock = Box::new(Mock::new(&[
+            (
+                "status",
+                // When the server is playing or paused, the response will look something like this:
+                "volume: -1
+repeat: 0
+random: 0
+single: 0
+consume: 0
+playlist: 3
+playlistlength: 87
+mixrampdb: 0.000000
+state: play
+song: 14
+songid: 15
+time: 141:250
+bitrate: 128
+audio: 44100:24:2
+nextsong: 15
+nextsongid: 16
+elapsed: 140.585
+OK",
+            ),
+            // Should respond with a playlist id request
+            (
+                "playlistid 15",
+                // Should look something like this:
+                "file: U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3
+Last-Modified: 2004-12-24T19:26:13Z
+Artist: U2
+Title: Who's Gonna RIDE Your WILD HOR
+Genre: Pop
+Time: 316
+Pos: 41
+Id: 42
+duration: 249.994
+OK",
+            ),
+            (
+                "status",
+                // But if the state is "stop", much of that will be missing; it will look more like:
+                "volume: -1
+repeat: 0
+random: 0
+single: 0
+consume: 0
+playlist: 84
+playlistlength: 27
+mixrampdb: 0.000000
+state: stop
+OK",
+            ),
+            // Finally, let's simulate something being really wrong
+            (
+                "status",
+                "volume: -1
+repeat: 0
+state: no-idea!?",
+            ),
+        ]));
+        let mut cli = Client::new(mock).unwrap();
+        let stat = cli.status().await.unwrap();
+        match stat {
+            PlayerStatus::Play(curr) => {
+                assert_eq!(curr.songid, 15);
+                assert_eq!(
+                    curr.file.to_str().unwrap(),
+                    "U-Z/U2 - Who's Gonna RIDE Your WILD HORSES.mp3"
+                );
+                assert_eq!(curr.elapsed, 140.585);
+                assert_eq!(curr.duration, 249.994);
+            }
+            _ => panic!(),
+        }
+
+        let stat = cli.status().await.unwrap();
+        match stat {
+            PlayerStatus::Stopped => (),
+            _ => panic!(),
+        }
+
+        let stat = cli.status().await;
+        match stat {
+            Err(_) => (),
+            Ok(_) => panic!(),
+        }
+    }
+
+    /// Test the `get_sticker' method
+    #[tokio::test]
+    async fn test_get_sticker() {
+        let mock = Box::new(Mock::new(&[
+            (
+                "sticker get song foo.mp3 stick",
+                // On success, should get something like this...
+                "sticker: stick=2\nOK\n",
+            ),
+            (
+                "sticker get song foo.mp3 stick",
+                // and on failure, something like this:
+                "ACK [50@0] {sticker} no such sticker\n",
+            ),
+            (
+                "sticker get song foo.mp3 stick",
+                // Finally, let's try something nuts
+                "",
+            ),
+            (
+                "sticker get song \"filename_with\\\"doublequotes\\\".flac\" unwoundstack.com:playcount",
+                "sticker: unwoundstack.com:playcount=11\nOK\n",
+            ),
+        ]));
+        let mut cli = Client::new(mock).unwrap();
+        let val = cli
+            .get_sticker::<String>("foo.mp3", "stick")
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(val, "2");
+        let _val = cli
+            .get_sticker::<String>("foo.mp3", "stick")
+            .await
+            .unwrap()
+            .is_none();
+        let _val = cli
+            .get_sticker::<String>("foo.mp3", "stick")
+            .await
+            .unwrap_err();
+        let val = cli
+            .get_sticker::<String>(
+                "filename_with\"doublequotes\".flac",
+                "unwoundstack.com:playcount",
+            )
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(val, "11");
+    }
+
+    /// Test the `set_sticker' method
+    #[tokio::test]
+    async fn test_set_sticker() {
+        let mock = Box::new(Mock::new(&[
+            ("sticker set song foo.mp3 stick 2", "OK\n"),
+            (
+                "sticker set song foo.mp3 stick 2",
+                "ACK [50@0] {sticker} some error",
+            ),
+            (
+                "sticker set song foo.mp3 stick 2",
+                "this makes no sense as a response",
+            ),
+        ]));
+        let mut cli = Client::new(mock).unwrap();
+        let () = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap();
+        let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err();
+        let _val = cli.set_sticker("foo.mp3", "stick", &"2").await.unwrap_err();
+    }
+
+    /// Test the `send_to_playlist' method
+    #[tokio::test]
+    async fn test_send_to_playlist() {
+        let mock = Box::new(Mock::new(&[
+            ("playlistadd foo.m3u foo.mp3", "OK\n"),
+            (
+                "playlistadd foo.m3u foo.mp3",
+                "ACK [101@0] {playlist} some error\n",
+            ),
+        ]));
+        let mut cli = Client::new(mock).unwrap();
+        let () = cli.send_to_playlist("foo.mp3", "foo.m3u").await.unwrap();
+        let _val = cli
+            .send_to_playlist("foo.mp3", "foo.m3u")
+            .await
+            .unwrap_err();
+    }
+
+    /// Test the `update' method
+    #[tokio::test]
+    async fn test_update() {
+        let mock = Box::new(Mock::new(&[
+            ("update \"foo.mp3\"", "updating_db: 2\nOK\n"),
+            ("update \"foo.mp3\"", "ACK [50@0] {update} blahblahblah"),
+            ("update \"foo.mp3\"", "this makes no sense as a response"),
+        ]));
+        let mut cli = Client::new(mock).unwrap();
+        let _val = cli.update("foo.mp3").await.unwrap();
+        let _val = cli.update("foo.mp3").await.unwrap_err();
+        let _val = cli.update("foo.mp3").await.unwrap_err();
+    }
+
+    /// Test retrieving stored playlists
+    #[tokio::test]
+    async fn test_get_stored_playlists() {
+        let mock = Box::new(Mock::new(&[
+            (
+                "listplaylists",
+                "playlist: saturday-afternoons-in-santa-cruz
+Last-Modified: 2020-03-13T17:20:16Z
+playlist: gaelic-punk
+Last-Modified: 2020-05-24T00:36:02Z
+playlist: morning-coffee
+Last-Modified: 2020-03-13T17:20:16Z
+OK
+",
+            ),
+            ("listplaylists", "ACK [1@0] {listplaylists} blahblahblah"),
+        ]));
+
+        let mut cli = Client::new(mock).unwrap();
+        let val = cli.get_stored_playlists().await.unwrap();
+        assert_eq!(
+            val,
+            vec![
+                String::from("saturday-afternoons-in-santa-cruz"),
+                String::from("gaelic-punk"),
+                String::from("morning-coffee")
+            ]
+        );
+        let _val = cli.get_stored_playlists().await.unwrap_err();
+    }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////
+//                                           IdleClient                                           //
+////////////////////////////////////////////////////////////////////////////////////////////////////
+
+#[non_exhaustive]
+#[derive(Debug, PartialEq, Eq)]
+pub enum IdleSubSystem {
+    Player,
+    Message,
+}
+
+impl TryFrom<&str> for IdleSubSystem {
+    type Error = Error;
+    fn try_from(text: &str) -> std::result::Result<Self, Self::Error> {
+        let x = text.to_lowercase();
+        if x == "player" {
+            Ok(IdleSubSystem::Player)
+        } else if x == "message" {
+            Ok(IdleSubSystem::Message)
+        } else {
+            bail!("{}", text)
+        }
+    }
+}
+
+impl fmt::Display for IdleSubSystem {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            IdleSubSystem::Player => write!(f, "Player"),
+            IdleSubSystem::Message => write!(f, "Message"),
+        }
+    }
+}
+
+/// [MPD](https://www.musicpd.org) client for "idle" connections.
+///
+/// # Introduction
+///
+/// This is an MPD client designed to "idle": it opens a long-lived connection to the MPD server and
+/// waits for MPD to respond with a message indicating that there's been a change to a subsystem of
+/// interest. At present, there are only two subsystems in which [mpdpopm](crate) is interested: the player
+/// & messages (cf. [IdleSubSystem]).
+///
+/// ```no_run
+/// use std::path::Path;
+/// use tokio::runtime::Runtime;
+/// use mpdpopm::clients::IdleClient;
+///
+/// let mut rt = Runtime::new().unwrap();
+/// rt.block_on( async {
+///     let mut client = IdleClient::open(Path::new("/var/run/mpd.sock")).await.unwrap();
+///     client.subscribe("player").await.unwrap();
+///     client.idle().await.unwrap();
+///     // Arrives here when the player's state changes
+/// })
+/// ```
+///
+/// `client` is now a [Future](https://doc.rust-lang.org/stable/std/future/trait.Future.html) that
+/// resolves to an [IdleClient] instance talking to `/var/run/mpd.sock`.
+///
+pub struct IdleClient {
+    conn: Box<dyn RequestResponse>,
+}
+
+impl IdleClient {
+    /// Create a new [mpdpopm::client::IdleClient][IdleClient] instance from something that
+    /// implements [ToSocketAddrs]
+    pub async fn connect<A: ToSocketAddrs>(addr: A) -> Result<IdleClient> {
+        Self::new(MpdConnection::<TcpStream>::connect(addr).await?)
+    }
+
+    pub async fn open<P: AsRef<Path>>(pth: P) -> Result<IdleClient> {
+        Self::new(MpdConnection::<UnixStream>::connect(pth).await?)
+    }
+
+    pub fn new(stream: Box<dyn RequestResponse>) -> Result<IdleClient> {
+        Ok(IdleClient { conn: stream })
+    }
+
+    /// Subscribe to an mpd channel
+    pub async fn subscribe(&mut self, chan: &str) -> Result<()> {
+        let text = self.conn.req(&format!("subscribe {}", chan)).await?;
+        debug!("Sent subscribe message for {}; got `{}'.", chan, text);
+        ensure!(text.starts_with("OK"), "subscribe not Ok: `{}`", text);
+        debug!("Subscribed to {}.", chan);
+        Ok(())
+    }
+
+    /// Enter idle state-- return the subsystem that changed, causing the connection to return. NB
+    /// this may block for some time.
+    pub async fn idle(&mut self) -> Result<IdleSubSystem> {
+        let text = self.conn.req("idle player message").await?;
+        debug!("Sent idle message; got `{}'.", text);
+
+        // If the player state changes, we'll get: "changed: player\nOK\n"
+        //
+        // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we respond
+        // "readmessages", which should give us something like:
+        //
+        //     channel: ratings
+        //     message: 255
+        //     OK
+        //
+        // We remain subscribed, but we need to send a new idle message.
+
+        ensure!(text.starts_with("changed: "), "idle not OK: `{}`", text);
+        let idx = text.find('\n').context("idle has no newline")?;
+
+        let result = IdleSubSystem::try_from(&text[9..idx])?;
+        let text = text[idx + 1..].to_string();
+        ensure!(text.starts_with("OK"), "idle not OKed");
+
+        Ok(result)
+    }
+
+    /// This method simply returns the results of a "readmessages" as a HashMap of channel name to
+    /// Vec of (String) messages for that channel
+    pub async fn get_messages(&mut self) -> Result<HashMap<String, Vec<String>>> {
+        let text = self.conn.req("readmessages").await?;
+        debug!("Sent readmessages; got `{}'.", text);
+
+        // We expect something like:
+        //
+        //     channel: ratings
+        //     message: 255
+        //     OK
+        //
+        // We remain subscribed, but we need to send a new idle message.
+
+        let mut m: HashMap<String, Vec<String>> = HashMap::new();
+
+        // Populate `m' with a little state machine:
+        enum State {
+            Init,
+            Running,
+            Finished,
+        }
+        let mut state = State::Init;
+        let mut chan = String::new();
+        let mut msgs: Vec<String> = Vec::new();
+        for line in text.lines() {
+            match state {
+                State::Init => {
+                    ensure!(line.starts_with("channel: "), "no `channel: ` given");
+                    chan = String::from(&line[9..]);
+                    state = State::Running;
+                }
+                State::Running => {
+                    if let Some(stripped) = line.strip_prefix("message: ") {
+                        msgs.push(String::from(stripped));
+                    } else if let Some(stripped) = line.strip_prefix("channel: ") {
+                        match m.get_mut(&chan) {
+                            Some(v) => v.append(&mut msgs),
+                            None => {
+                                m.insert(chan.clone(), msgs.clone());
+                            }
+                        }
+                        chan = String::from(stripped);
+                        msgs = Vec::new();
+                    } else if line == "OK" {
+                        match m.get_mut(&chan) {
+                            Some(v) => v.append(&mut msgs),
+                            None => {
+                                m.insert(chan.clone(), msgs.clone());
+                            }
+                        }
+                        state = State::Finished;
+                    } else {
+                        bail!("Failed to get messages: `{}`", text)
+                    }
+                }
+                State::Finished => {
+                    // Should never be here!
+                    bail!("Failed to get messages: `{}`", text)
+                }
+            }
+        }
+
+        Ok(m)
+    }
+}
+
+#[cfg(test)]
+/// Let's test IdleClient!
+mod idle_client_tests {
+
+    use super::test_mock::Mock;
+    use super::*;
+
+    /// Some basic "smoke" tests
+    #[tokio::test]
+    async fn test_get_messages() {
+        let mock = Box::new(Mock::new(&[(
+            "readmessages",
+            // If a ratings message is sent, we'll get: "changed: message\nOK\n", to which we
+            // respond "readmessages", which should give us something like:
+            //
+            //     channel: ratings
+            //     message: 255
+            //     OK
+            //
+            // We remain subscribed, but we need to send a new idle message.
+            "channel: ratings
+message: 255
+message: 128
+channel: send-to-playlist
+message: foo.m3u
+OK
+",
+        )]));
+        let mut cli = IdleClient::new(mock).unwrap();
+        let hm = cli.get_messages().await.unwrap();
+        let val = hm.get("ratings").unwrap();
+        assert_eq!(val.len(), 2);
+        let val = hm.get("send-to-playlist").unwrap();
+        assert!(val.len() == 1);
+    }
+
+    /// Test issue #1
+    #[tokio::test]
+    async fn test_issue_1() {
+        let mock = Box::new(Mock::new(&[(
+            "readmessages",
+            "channel: playcounts
+message: a
+channel: playcounts
+message: b
+OK
+",
+        )]));
+        let mut cli = IdleClient::new(mock).unwrap();
+        let hm = cli.get_messages().await.unwrap();
+        let val = hm.get("playcounts").unwrap();
+        assert_eq!(val.len(), 2);
+    }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/config.rs b/pkgs/by-name/mp/mpdpopm/src/config.rs
new file mode 100644
index 00000000..2d9c466b
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/config.rs
@@ -0,0 +1,277 @@
+// Copyright (C) 2021-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/>.
+
+//! # mpdpopm Configuration
+//!
+//! ## Introduction
+//!
+//! This module defines the configuration struct & handles deserialization thereof.
+//!
+//! ## Discussion
+//!
+//! In the first releases of [mpdpopm](crate) I foolishly forgot to add a version field to the
+//! configuration structure. I am now paying for my sin by having to attempt serializing two
+//! versions until one succeeds.
+//!
+//! The idiomatic approach to versioning [serde](https://docs.serde.rs/serde/) structs seems to be
+//! using an
+//! [enumeration](https://www.reddit.com/r/rust/comments/44dds3/handling_multiple_file_versions_with_serde_or/). This
+//! implementation *now* uses that, but that leaves us with the problem of handling the initial,
+//! un-tagged version. I proceed as follows:
+//!
+//!    1. attempt to deserialize as a member of the modern enumeration
+//!    2. if that succeeds, with the most-recent version, we're good
+//!    3. if that succeeds with an archaic version, convert to the most recent and warn the user
+//!    4. if that fails, attempt to deserialize as the initial struct version
+//!    5. if that succeeds, convert to the most recent & warn the user
+//!    6. if that fails, I'm kind of stuck because I don't know what the user was trying to express;
+//!       bundle-up all the errors, report 'em & urge the user to use the most recent version
+use crate::vars::{LOCALSTATEDIR, PREFIX};
+
+use anyhow::{Result, bail};
+use serde::{Deserialize, Serialize};
+
+use std::{env, path::PathBuf};
+
+/// [mpdpopm](crate) can communicate with MPD over either a local Unix socket, or over regular TCP
+#[derive(Debug, Deserialize, PartialEq, Serialize)]
+pub enum Connection {
+    /// Local Unix socket-- payload is the path to the socket
+    Local { path: PathBuf },
+    /// TCP-- payload is the hostname & port number
+    TCP { host: String, port: u16 },
+}
+
+impl Connection {
+    pub fn new() -> Result<Self> {
+        let env = env::var("MPD_HOST")?;
+
+        if env.starts_with("/") {
+            // We assume that this is a path to a local socket
+            Ok(Self::Local {
+                path: PathBuf::from(env),
+            })
+        } else {
+            todo!("Not yet able to auto-parse, MPD_HOST for remote connection")
+        }
+    }
+}
+
+impl Default for Connection {
+    fn default() -> Self {
+        Self::new().expect("Could not generate default connection")
+    }
+}
+
+#[cfg(test)]
+mod test_connection {
+    use super::Connection;
+
+    #[test]
+    fn test_serde() {
+        use serde_json::to_string;
+
+        use std::path::PathBuf;
+
+        let text = to_string(&Connection::Local {
+            path: PathBuf::from("/var/run/mpd.sock"),
+        })
+        .unwrap();
+
+        assert_eq!(
+            text,
+            String::from(r#"{"Local":{"path":"/var/run/mpd.sock"}}"#)
+        );
+
+        let text = to_string(&Connection::TCP {
+            host: String::from("localhost"),
+            port: 6600,
+        })
+        .unwrap();
+        assert_eq!(
+            text,
+            String::from(r#"{"TCP":{"host":"localhost","port":6600}}"#)
+        );
+    }
+}
+
+/// This is the most recent `mppopmd` configuration struct.
+#[derive(Deserialize, Debug, Serialize)]
+#[serde(default)]
+pub struct Config {
+    /// Configuration format version-- must be "1"
+    // Workaround to https://github.com/rotty/lexpr-rs/issues/77
+    // When this gets fixed, I can remove this element from the struct & deserialize as
+    // a Configurations element-- the on-disk format will be the same.
+    #[serde(rename = "version")]
+    _version: String,
+
+    /// Location of log file
+    pub log: PathBuf,
+
+    /// How to connect to mpd
+    pub conn: Connection,
+
+    /// The `mpd' root music directory, relative to the host on which *this* daemon is running
+    pub local_music_dir: PathBuf,
+
+    /// Percentage threshold, expressed as a number between zero & one, for considering a song to
+    /// have been played
+    pub played_thresh: f64,
+
+    /// 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 {
+    fn default() -> Self {
+        Self::new().unwrap()
+    }
+}
+
+impl Config {
+    fn new() -> Result<Self> {
+        Ok(Self {
+            _version: String::from("1"),
+            log: [LOCALSTATEDIR, "log", "mppopmd.log"].iter().collect(),
+            conn: Connection::new()?,
+            local_music_dir: [PREFIX, "Music"].iter().collect(),
+            played_thresh: 0.6,
+            poll_interval_ms: 5000,
+            commands_chan: String::from("unwoundstack.com:commands"),
+        })
+    }
+}
+
+pub fn from_str(text: &str) -> Result<Config> {
+    let cfg: Config = match serde_json::from_str(text) {
+        Ok(cfg) => cfg,
+        Err(err_outer) => {
+            bail!("Failed to parse config: `{}`", err_outer)
+        }
+    };
+    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/filters.lalrpop b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
new file mode 100644
index 00000000..a591a3ba
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters.lalrpop
@@ -0,0 +1,143 @@
+// Copyright (C) 2020-2025 Michael Herstine <sp1ff@pobox.com>  -*- mode: rust; rust-format-on-save: nil -*-
+//
+// 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/>.
+
+use lalrpop_util::ParseError;
+
+use crate::filters_ast::{Conjunction, Disjunction, Expression, OpCode, Selector, Term, Value,
+                         expect_quoted, parse_iso_8601};
+
+grammar;
+
+pub ExprOp: OpCode = {
+    "=="       => OpCode::Equality,
+    "!="       => OpCode::Inequality,
+    "contains" => OpCode::Contains,
+    "=~"       => OpCode::RegexMatch,
+    "!~"       => OpCode::RegexExclude,
+    ">"        => OpCode::GreaterThan,
+    "<"        => OpCode::LessThan,
+    ">="       => OpCode::GreaterThanEqual,
+    "<="       => OpCode::LessThanEqual,
+};
+
+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)track"                      => Selector::Track,
+    r"(?i)name"                       => Selector::Name,
+    r"(?i)genre"                      => Selector::Genre,
+    r"(?i)date"                       => Selector::Date,
+    r"(?i)originaldate"               => Selector::OriginalDate,
+    r"(?i)composer"                   => Selector::Composer,
+    r"(?i)performer"                  => Selector::Performer,
+    r"(?i)conductor"                  => Selector::Conductor,
+    r"(?i)work"                       => Selector::Work,
+    r"(?i)grouping"                   => Selector::Grouping,
+    r"(?i)comment"                    => Selector::Comment,
+    r"(?i)disc"                       => Selector::Disc,
+    r"(?i)label"                      => Selector::Label,
+    r"(?i)musicbrainz_aristid"        => Selector::MusicbrainzAristID,
+    r"(?i)musicbrainz_albumid"        => Selector::MusicbrainzAlbumID,
+    r"(?i)musicbrainz_albumartistid"  => Selector::MusicbrainzAlbumArtistID,
+    r"(?i)musicbrainz_trackid"        => Selector::MusicbrainzTrackID,
+    r"(?i)musicbrainz_releasetrackid" => Selector::MusicbrainzReleaseTrackID,
+    r"(?i)musicbrainz_workid"         => Selector::MusicbrainzWorkID,
+    r"(?i)file"                       => Selector::File,
+    r"(?i)base"                       => Selector::Base,
+    r"(?i)modified-since"             => Selector::ModifiedSince,
+    r"(?i)audioformat"                => Selector::AudioFormat,
+    r"(?i)rating"                     => Selector::Rating,
+    r"(?i)playcount"                  => Selector::PlayCount,
+    r"(?i)lastplayed"                 => Selector::LastPlayed,
+};
+
+pub Token: Value = {
+    <s:r"[0-9]+"> =>? {
+        eprintln!("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" })
+        }
+    },
+    <s:r#""([ \t'a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\"|\\')+""#> => {
+        eprintln!("matched token: ``{}''.", s);
+        let s = expect_quoted(s).unwrap();
+        match parse_iso_8601(&mut s.as_bytes()) {
+            Ok(x) => Value::UnixEpoch(x),
+            Err(_) => Value::Text(s),
+        }
+    },
+    <s:r#"'([ \t"a-zA-Z0-9~!@#$%^&*()-=_+\[\]{}|;:<>,./?]|\\\\|\\'|\\")+'"#> => {
+        eprintln!("matched token: ``{}''.", s);
+        let s = expect_quoted(s).unwrap();
+        match parse_iso_8601(&mut s.as_bytes()) {
+            Ok(x) => Value::UnixEpoch(x),
+            Err(_) => Value::Text(s),
+        }
+    },
+};
+
+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);
+        Box::new(Term::BinaryCondition(t, o, u))
+    },
+}
+
+pub Conjunction: Box<Conjunction> = {
+    <e1:Expression> "AND" <e2:Expression> => {
+        eprintln!("matched conjunction: ``({:#?}, {:#?})''", e1, e2);
+        Box::new(Conjunction::Simple(e1, e2))
+    },
+    <c:Conjunction> "AND" <e:Expression> => {
+        eprintln!("matched conjunction: ``({:#?}, {:#?})''", c, e);
+        Box::new(Conjunction::Compound(c, e))
+    },
+}
+
+pub Disjunction: Box<Disjunction> = {
+    <e1:Expression> "OR" <e2:Expression> => {
+        eprintln!("matched disjunction: ``({:#?}, {:#?})''", e1, e2);
+        Box::new(Disjunction::Simple(e1, e2))
+    },
+    <c:Disjunction> "OR" <e:Expression> => {
+        eprintln!("matched disjunction: ``({:#?}, {:#?})''", c, e);
+        Box::new(Disjunction::Compound(c, e))
+    },
+}
+
+pub Expression: Box<Expression> = {
+    "(" <t:Term> ")" => {
+        eprintln!("matched parenthesized term: ``({:#?})''", t);
+        Box::new(Expression::Simple(t))
+    },
+    "(" "!" <e:Expression> ")" => Box::new(Expression::Negation(e)),
+    "(" <c:Conjunction> ")" => {
+        eprintln!("matched parenthesized conjunction: ``({:#?})''", c);
+        Box::new(Expression::Conjunction(c))
+    },
+    "(" <c:Disjunction>  ")" => {
+        eprintln!("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
new file mode 100644
index 00000000..bd1a67d6
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/filters_ast.rs
@@ -0,0 +1,1005 @@
+// 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/>.
+
+//! Types for building the Abstract Syntax Tree when parsing filters
+//!
+//! 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 anyhow::{Context, Error, Result, anyhow, bail};
+use boolinator::Boolinator;
+use chrono::prelude::*;
+use tracing::debug;
+
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+
+/// The operations that can appear in a filter term
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum OpCode {
+    Equality,
+    Inequality,
+    Contains,
+    RegexMatch,
+    RegexExclude,
+    GreaterThan,
+    LessThan,
+    GreaterThanEqual,
+    LessThanEqual,
+}
+
+impl std::fmt::Display for OpCode {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                OpCode::Equality => "==",
+                OpCode::Inequality => "!=",
+                OpCode::Contains => "contains",
+                OpCode::RegexMatch => "=~",
+                OpCode::RegexExclude => "!~",
+                OpCode::GreaterThan => ">",
+                OpCode::LessThan => "<",
+                OpCode::GreaterThanEqual => ">=",
+                OpCode::LessThanEqual => "<=",
+            }
+        )
+    }
+}
+
+/// The song attributes that can appear on the LHS of a filter term
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Selector {
+    Artist,
+    Album,
+    AlbumArtist,
+    Title,
+    Track,
+    Name,
+    Genre,
+    Date,
+    OriginalDate,
+    Composer,
+    Performer,
+    Conductor,
+    Work,
+    Grouping,
+    Comment,
+    Disc,
+    Label,
+    MusicbrainzAristID,
+    MusicbrainzAlbumID,
+    MusicbrainzAlbumArtistID,
+    MusicbrainzTrackID,
+    MusicbrainzReleaseTrackID,
+    MusicbrainzWorkID,
+    File,
+    Base,
+    ModifiedSince,
+    AudioFormat,
+    Rating,
+    PlayCount,
+    LastPlayed,
+}
+
+impl std::fmt::Display for Selector {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                Selector::Artist => "artist",
+                Selector::Album => "album",
+                Selector::AlbumArtist => "albumartist",
+                Selector::Title => "title",
+                Selector::Track => "track",
+                Selector::Name => "name",
+                Selector::Genre => "genre",
+                Selector::Date => "date",
+                Selector::OriginalDate => "originaldate",
+                Selector::Composer => "composer",
+                Selector::Performer => "performer",
+                Selector::Conductor => "conductor",
+                Selector::Work => "work",
+                Selector::Grouping => "grouping",
+                Selector::Comment => "comment",
+                Selector::Disc => "disc",
+                Selector::Label => "label",
+                Selector::MusicbrainzAristID => "musicbrainz_aristid",
+                Selector::MusicbrainzAlbumID => "musicbrainz_albumid",
+                Selector::MusicbrainzAlbumArtistID => "musicbrainz_albumartistid",
+                Selector::MusicbrainzTrackID => "musicbrainz_trackid",
+                Selector::MusicbrainzReleaseTrackID => "musicbrainz_releasetrackid",
+                Selector::MusicbrainzWorkID => "musicbrainz_workid",
+                Selector::File => "file",
+                Selector::Base => "base",
+                Selector::ModifiedSince => "modified-since",
+                Selector::AudioFormat => "AudioFormat",
+                Selector::Rating => "rating",
+                Selector::PlayCount => "playcount",
+                Selector::LastPlayed => "lastplayed",
+            }
+        )
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+    Text(String),
+    UnixEpoch(i64),
+    Uint(usize),
+}
+
+fn quote_value(x: &Value) -> String {
+    match x {
+        Value::Text(s) => {
+            let mut ret = String::new();
+
+            ret.push('"');
+            for c in s.chars() {
+                if c == '"' || c == '\\' {
+                    ret.push('\\');
+                }
+                ret.push(c);
+            }
+            ret.push('"');
+            ret
+        }
+        Value::UnixEpoch(n) => {
+            format!("'{}'", n)
+        }
+        Value::Uint(n) => {
+            format!("'{}'", n)
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+pub enum Term {
+    UnaryCondition(Selector, Value),
+    BinaryCondition(Selector, OpCode, Value),
+}
+
+#[derive(Clone, Debug)]
+pub enum Conjunction {
+    Simple(Box<Expression>, Box<Expression>),
+    Compound(Box<Conjunction>, Box<Expression>),
+}
+
+#[derive(Clone, Debug)]
+pub enum Disjunction {
+    Simple(Box<Expression>, Box<Expression>),
+    Compound(Box<Disjunction>, Box<Expression>),
+}
+
+#[derive(Clone, Debug)]
+pub enum Expression {
+    Simple(Box<Term>),
+    Negation(Box<Expression>),
+    Conjunction(Box<Conjunction>),
+    Disjunction(Box<Disjunction>),
+}
+
+#[cfg(test)]
+mod smoke_tests {
+    use super::*;
+    use crate::filters::*;
+
+    #[test]
+    fn test_opcodes() {
+        assert!(ExprOpParser::new().parse("==").unwrap() == OpCode::Equality);
+        assert!(ExprOpParser::new().parse("!=").unwrap() == OpCode::Inequality);
+        assert!(ExprOpParser::new().parse("contains").unwrap() == OpCode::Contains);
+        assert!(ExprOpParser::new().parse("=~").unwrap() == OpCode::RegexMatch);
+        assert!(ExprOpParser::new().parse("!~").unwrap() == OpCode::RegexExclude);
+        assert!(ExprOpParser::new().parse(">").unwrap() == OpCode::GreaterThan);
+        assert!(ExprOpParser::new().parse("<").unwrap() == OpCode::LessThan);
+        assert!(ExprOpParser::new().parse(">=").unwrap() == OpCode::GreaterThanEqual);
+        assert!(ExprOpParser::new().parse("<=").unwrap() == OpCode::LessThanEqual);
+    }
+
+    #[test]
+    fn test_conditions() {
+        assert!(TermParser::new().parse("base 'foo'").is_ok());
+        assert!(TermParser::new().parse("artist == 'foo'").is_ok());
+        assert!(
+            TermParser::new()
+                .parse(r#"artist =~ "foo bar \"splat\"!""#)
+                .is_ok()
+        );
+        assert!(TermParser::new().parse("artist =~ 'Pogues'").is_ok());
+
+        match *TermParser::new()
+            .parse(r#"base "/Users/me/My Music""#)
+            .unwrap()
+        {
+            Term::UnaryCondition(a, b) => {
+                assert!(a == Selector::Base);
+                assert!(b == Value::Text(String::from(r#"/Users/me/My Music"#)));
+            }
+            _ => {
+                unreachable!();
+            }
+        }
+
+        match *TermParser::new()
+            .parse(r#"artist =~ "foo bar \"splat\"!""#)
+            .unwrap()
+        {
+            Term::BinaryCondition(t, op, s) => {
+                assert!(t == Selector::Artist);
+                assert!(op == OpCode::RegexMatch);
+                assert!(s == Value::Text(String::from(r#"foo bar "splat"!"#)));
+            }
+            _ => {
+                unreachable!();
+            }
+        }
+    }
+
+    #[test]
+    fn test_expressions() {
+        assert!(ExpressionParser::new().parse("( base 'foo' )").is_ok());
+        assert!(ExpressionParser::new().parse("(base \"foo\")").is_ok());
+        assert!(
+            ExpressionParser::new()
+                .parse("(!(artist == 'value'))")
+                .is_ok()
+        );
+        assert!(
+            ExpressionParser::new()
+                .parse(r#"((!(artist == "foo bar")) AND (base "/My Music"))"#)
+                .is_ok()
+        );
+    }
+
+    #[test]
+    fn test_quoted_expr() {
+        eprintln!("test_quoted_expr");
+        assert!(
+            ExpressionParser::new()
+                .parse(r#"(artist =~ "foo\\bar\"")"#)
+                .is_ok()
+        );
+    }
+
+    #[test]
+    fn test_real_expression() {
+        let result = ExpressionParser::new()
+            .parse(r#"(((Artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues')) AND ((rating > 128) OR (rating == 0)))"#);
+        eprintln!("{:#?}", result);
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn test_conjunction() {
+        assert!(ExpressionParser::new()
+            .parse(
+                r#"((base "foo") AND (artist == "foo bar") AND (!(file == '/net/mp3/A/a.mp3')))"#
+            )
+            .is_ok());
+
+        eprintln!("==============================================================================");
+        eprintln!("{:#?}", ExpressionParser::new()
+            .parse(
+                r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == "/net/mp3/A/a.mp3")) OR (file == "/pub/mp3/A/a.mp3")))"#
+            ));
+        assert!(ExpressionParser::new()
+            .parse(
+                r#"((base 'foo') AND (artist == "foo bar") AND ((!(file == '/net/mp3/A/a.mp3')) OR (file == '/pub/mp3/A/a.mp3')))"#
+            )
+            .is_ok());
+    }
+
+    #[test]
+    fn test_disjunction() {
+        assert!(ExpressionParser::new().
+                parse(r#"((artist =~ 'Flogging Molly') OR (artist =~ 'Dropkick Murphys') OR (artist =~ 'Pogues'))"#)
+                .is_ok());
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum EvalOp {
+    And,
+    Or,
+    Not,
+}
+
+impl std::fmt::Display for EvalOp {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match self {
+            EvalOp::And => write!(f, "And"),
+            EvalOp::Or => write!(f, "Or"),
+            EvalOp::Not => write!(f, "Not"),
+        }
+    }
+}
+
+fn peek(buf: &[u8]) -> Option<char> {
+    match buf.len() {
+        0 => None,
+        _ => Some(buf[0] as char),
+    }
+}
+
+// advancing a slice by `i` indicies can *not* be this difficult
+/// Pop a single byte off of `buf`
+fn take1(buf: &mut &[u8], i: usize) -> Result<()> {
+    if i > buf.len() {
+        bail!("Bad iso-8601 string: `{:#?}`", buf);
+    }
+    let (_first, second) = buf.split_at(i);
+    *buf = second;
+    Ok(())
+}
+
+/// Pop `i` bytes off of `buf` & parse them as a T
+fn take2<T>(buf: &mut &[u8], i: usize) -> Result<T>
+where
+    T: FromStr,
+    <T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static,
+{
+    // 1. check len
+    if i > buf.len() {
+        bail!("Bad iso-8601 string: `{:#?}`", buf);
+    }
+
+    let (first, second) = buf.split_at(i);
+    *buf = second;
+    // 2. convert to a string
+    let s = std::str::from_utf8(first).context("Bad iso-8601 string")?;
+    // 3. parse as a T
+    s.parse::<T>()
+        .context("Failed to parse iso-8601 string as T")
+}
+
+/// Parse a timestamp in ISO 8601 format to a chrono DateTime instance
+///
+/// Surprisingly, I was unable to find an ISO 8601 parser in Rust. I *did* find a crate named
+/// iso-8601 that promised to do this, but it seemed derelict & I couldn't see what to do with the
+/// parse output in any event. The ISO 8601 format is simple enough that I've chosen to simply
+/// hand-parse it.
+pub fn parse_iso_8601(buf: &mut &[u8]) -> Result<i64> {
+    // I wonder if `nom` would be a better choice?
+
+    // The first four characters must be the year (expanded year representation is not supported by
+    // this parser).
+
+    let year: i32 = take2(buf, 4)?;
+
+    // Now at this point:
+    //   1. we may be done (i.e. buf.len() == 0)
+    //   2. we may have the timestamp (peek(buf) => Some('T'))
+    //      - day & month := 0, consume the 'T', move on to parsing the time
+    //   3. we may have a month in extended format (i.e. peek(buf) => Some('-')
+    //      - consume the '-', parse the month & move on to parsing the day
+    //   4. we may have a month in basic format (take(buf, 2) => Some('\d\d')
+    //      - parse the month & move on to parsing the day
+    let mut month = 1;
+    let mut day = 1;
+    let mut hour = 0;
+    let mut minute = 0;
+    let mut second = 0;
+    if !buf.is_empty() {
+        let next = peek(buf);
+        if next != Some('T') {
+            let mut ext_fmt = false;
+            if next == Some('-') {
+                take1(buf, 1)?;
+                ext_fmt = true;
+            }
+            month = take2(buf, 2)?;
+
+            // At this point:
+            //   1. we may be done (i.e. buf.len() == 0)
+            //   2. we may have the timestamp (peek(buf) => Some('T'))
+            //   3. we may have the day (in basic or extended format)
+            if !buf.is_empty() && peek(buf) != Some('T') {
+                if ext_fmt {
+                    take1(buf, 1)?;
+                }
+                day = take2(buf, 2)?;
+            }
+        }
+
+        // Parse time: at this point, buf will either be empty or begin with 'T'
+        if !buf.is_empty() {
+            take1(buf, 1)?;
+            // If there's a T, there must at least be an hour
+            hour = take2(buf, 2)?;
+            if !buf.is_empty() {
+                let mut ext_fmt = false;
+                if peek(buf) == Some(':') {
+                    take1(buf, 1)?;
+                    ext_fmt = true;
+                }
+                minute = take2(buf, 2)?;
+                if !buf.is_empty() {
+                    if ext_fmt {
+                        take1(buf, 1)?;
+                    }
+                    second = take2(buf, 2)?;
+                }
+            }
+        }
+
+        // At this point, there may be a timezone
+        if !buf.is_empty() {
+            if peek(buf) == Some('Z') {
+                return Ok(Utc
+                    .with_ymd_and_hms(year, month, day, hour, minute, second)
+                    .single()
+                    .ok_or(anyhow!("bad iso-8601 string"))?
+                    .timestamp());
+            } else {
+                let next = peek(buf);
+                if next != Some('-') && next != Some('+') {
+                    bail!("bad iso-8601 string")
+                }
+                let west = next == Some('-');
+                take1(buf, 1)?;
+
+                let hours: i32 = take2(buf, 2)?;
+                let mut minutes = 0;
+
+                if !buf.is_empty() {
+                    if peek(buf) == Some(':') {
+                        take1(buf, 1)?;
+                    }
+                    minutes = take2(buf, 2)?;
+                }
+
+                if west {
+                    return Ok(FixedOffset::west_opt(hours * 3600 + minutes * 60)
+                        .ok_or(anyhow!("Bad iso-8601 string"))?
+                        .with_ymd_and_hms(year, month, day, hour, minute, second)
+                        .single()
+                        .ok_or(anyhow!("Bad iso-8601 string"))?
+                        .timestamp());
+                } else {
+                    return Ok(FixedOffset::east_opt(hours * 3600 + minutes * 60)
+                        .ok_or(anyhow!("Bad iso-8601 string"))?
+                        .with_ymd_and_hms(year, month, day, hour, minute, second)
+                        .single()
+                        .ok_or(anyhow!("Bad iso-8601 string"))?
+                        .timestamp());
+                }
+            }
+        }
+    }
+    Ok(Local
+        .with_ymd_and_hms(year, month, day, hour, minute, second)
+        .single()
+        .ok_or(anyhow!("Bad iso-8601 string"))?
+        .timestamp())
+}
+
+#[cfg(test)]
+mod iso_8601_tests {
+
+    use super::*;
+
+    #[test]
+    fn smoke_tests() {
+        let mut b = "19700101T00:00:00Z".as_bytes();
+        let t = parse_iso_8601(&mut b).unwrap();
+        assert!(t == 0);
+
+        let mut b = "19700101T00:00:01Z".as_bytes();
+        let t = parse_iso_8601(&mut b).unwrap();
+        assert!(t == 1);
+
+        let mut b = "20210327T02:26:53Z".as_bytes();
+        let t = parse_iso_8601(&mut b).unwrap();
+        assert_eq!(t, 1616812013);
+
+        let mut b = "20210327T07:29:05-07:00".as_bytes();
+        let t = parse_iso_8601(&mut b).unwrap();
+        assert_eq!(t, 1616855345);
+
+        let mut b = "2021".as_bytes();
+        // Should resolve to midnight, Jan 1 2021 in local time; don't want to test against the
+        // timestamp; just make sure it parses
+        parse_iso_8601(&mut b).unwrap();
+    }
+}
+
+/// "Un-quote" a token
+///
+/// Textual tokens must be quoted, and double-quote & backslashes within backslash-escaped. If the
+/// string is quoted with single-quotes, then any single-quotes inside the string will also need
+/// to be escaped.
+///
+/// In fact, *any* characters within may be harmlessly backslash escaped; the MPD implementation
+/// walks the the string, skipping backslashes as it goes, so this implementation will do the same.
+/// I have named this method in imitation of the corresponding MPD function.
+pub fn expect_quoted(qtext: &str) -> Result<String> {
+    let mut iter = qtext.chars();
+    let quote = iter.next();
+    if quote.is_none() {
+        return Ok(String::new());
+    }
+
+    if quote != Some('\'') && quote != Some('"') {
+        bail!("Expected text to be quoted: `{}`", qtext);
+    }
+
+    let mut ret = String::new();
+
+    // Walk qtext[1..]; copying characters to `ret'. If a '\' is found, skip to the next character
+    // (even if that is a '\'). The last character in qtext should be the closing quote.
+    let mut this = iter.next();
+    while this != quote {
+        if this == Some('\\') {
+            this = iter.next();
+        }
+        match this {
+            Some(c) => ret.push(c),
+            None => {
+                bail!("Expected text to be quoted: `{}`", qtext);
+            }
+        }
+        this = iter.next();
+    }
+
+    Ok(ret)
+}
+
+#[cfg(test)]
+mod quoted_tests {
+
+    use super::*;
+
+    #[test]
+    fn smoke_tests() {
+        let b = r#""foo bar \"splat!\"""#;
+        let s = expect_quoted(b).unwrap();
+        assert!(s == r#"foo bar "splat!""#);
+    }
+}
+
+/// Create a closure that will carry out an operator on its argument
+///
+/// Call this function with an [OpCode] and a value of type `T`. `T` must be [PartialEq],
+/// [`PartialOrd`] and [`Copy`]-- an integral type will do. It will return a closure that will carry
+/// out the given [OpCode] against the given value. For instance,
+/// `make_numeric_closure::<u8>(OpCode::Equality, 11)` will return a closure that takes a `u8` &
+/// will return true if its argument is 11 (and false otherwise).
+///
+/// If [OpCode] is not pertinent to a numeric type, then this function will return Err.
+fn make_numeric_closure<'a, T: 'a + PartialEq + PartialOrd + Copy>(
+    op: OpCode,
+    val: T,
+) -> Result<impl Fn(T) -> bool + 'a> {
+    // Rust closures each have their own type, so this was the only way I could find to
+    // return them from match arms. This seems ugly; perhaps there's something I'm
+    // missing.
+    //
+    // I have no idea why I have to make these `move` closures; T is constrained to by Copy-able,
+    // so I would have expected the closure to just take a copy.
+    match op {
+        OpCode::Equality => Ok(Box::new(move |x: T| x == val) as Box<dyn Fn(T) -> bool>),
+        OpCode::Inequality => Ok(Box::new(move |x: T| x != val) as Box<dyn Fn(T) -> bool>),
+        OpCode::GreaterThan => Ok(Box::new(move |x: T| x > val) as Box<dyn Fn(T) -> bool>),
+        OpCode::LessThan => Ok(Box::new(move |x: T| x < val) as Box<dyn Fn(T) -> bool>),
+        OpCode::GreaterThanEqual => Ok(Box::new(move |x: T| x >= val) as Box<dyn Fn(T) -> bool>),
+        OpCode::LessThanEqual => Ok(Box::new(move |x: T| x <= val) as Box<dyn Fn(T) -> bool>),
+        _ => bail!("Invalid operant: `{op}`"),
+    }
+}
+
+async fn eval_numeric_sticker_term<
+    // The `FromStr' trait bound is really weird, but if I don't constrain the associated
+    // Err type to be `ParseIntError' the compiler complains about not being able to convert
+    // it to type `Error'. I'm probably still "thinking in C++" and imagining the compiler
+    // instantiating this function for each type (u8, usize, &c) instead of realizing that the Rust
+    // compiler is processing this as a first-class function.
+    //
+    // For instance, I can do the conversion manually, so long as I constrain the Err type
+    // to implement std::error::Error. I should probably be doing that, but it clutters the
+    // code. I'll figure it out when I need to extend this function to handle non-integral types
+    // :)
+    T: PartialEq + PartialOrd + Copy + FromStr<Err = std::num::ParseIntError> + std::fmt::Display,
+>(
+    sticker: &str,
+    client: &mut Client,
+    op: OpCode,
+    numeric_val: T,
+    default_val: T,
+) -> Result<HashSet<String>> {
+    let cmp = make_numeric_closure(op, numeric_val)?;
+    // It would be better to idle on the sticker DB & just update our collection on change, but for
+    // a first impl. this will do.
+    //
+    // Call `get_stickers'; this will return a HashMap from song URIs to ratings expressed as text
+    // (as all stickers are). This stanza will drain that collection into a new one with the ratings
+    // expressed as T.
+    //
+    // The point is that conversion from text to rating, lastplayed, or whatever can fail; the
+    // invocation of `collect' will call `from_iter' to convert a collection of Result-s to a Result
+    // of a collection.
+    let mut m = client
+        .get_stickers(sticker)
+        .await
+        .context("Failed to get stickers from client")?
+        .drain()
+        .map(|(k, v)| v.parse::<T>().map(|x| (k, x)))
+        .collect::<std::result::Result<HashMap<String, T>, _>>()
+        .context("Failed to parse sticker as T")?;
+    // `m' is now a map of song URI to rating/playcount/wathever (expressed as a T)... for all songs
+    // that have the salient sticker.
+    //
+    // This seems horribly inefficient, but I'm going to fetch all the song URIs in the music DB,
+    // and augment `m' with entries of `default_val' for any that are not already there.
+    client
+        .get_all_songs()
+        .await
+        .context("Failed to get all songs from client")?
+        .drain(..)
+        .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()
+        .filter_map(|(k, v)| cmp(v).as_some(k))
+        .collect::<HashSet<String>>())
+}
+
+/// Convenience struct collecting the names for assorted stickers on which one may search
+///
+/// While the search terms 'rating', 'playcount' &c are fixed & part of the filter grammar offered
+/// by mpdpopm, the precise names of the corresponding stickers are configurable & hence must be
+/// passed in. Three references to str is already unweildy IMO, and since I expect the number of
+/// stickers on which one can search to grow further, I decided to wrap 'em up in a struct. The
+/// lifetime is there to support the caller just using a reference to an existing string rather than
+/// making a copy.
+pub struct FilterStickerNames<'a> {
+    rating: &'a str,
+    playcount: &'a str,
+    lastplayed: &'a str,
+}
+
+impl FilterStickerNames<'static> {
+    pub fn new() -> FilterStickerNames<'static> {
+        Self::default()
+    }
+}
+
+impl Default for FilterStickerNames<'static> {
+    fn default() -> Self {
+        Self {
+            rating: rating_count::STICKER,
+            playcount: play_count::STICKER,
+            lastplayed: last_played::STICKER,
+        }
+    }
+}
+
+/// Evaluate a Term
+///
+/// Take a Term from the Abstract Syntax tree & resolve it to a collection of song URIs. Set `case`
+/// to `true` to search case-sensitively & `false` to make the search case-insensitive.
+async fn eval_term<'a>(
+    term: &Term,
+    case: bool,
+    client: &mut Client,
+    stickers: &FilterStickerNames<'a>,
+) -> Result<HashSet<String>> {
+    match term {
+        Term::UnaryCondition(op, val) => Ok(client
+            .find1(&format!("{}", op), &quote_value(val), case)
+            .await
+            .context("Failed to find1 on client")?
+            .drain(..)
+            .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),
+                }
+            } else if *attr == Selector::PlayCount {
+                match val {
+                    Value::Uint(n) => {
+                        Ok(
+                            eval_numeric_sticker_term(stickers.playcount, client, *op, *n, 0)
+                                .await?,
+                        )
+                    }
+                    _ => bail!("filter ratings expect an unsigned int; got {:#?}", val),
+                }
+            } else if *attr == Selector::LastPlayed {
+                match val {
+                    Value::UnixEpoch(t) => {
+                        Ok(
+                            eval_numeric_sticker_term(stickers.lastplayed, client, *op, *t, 0)
+                                .await?,
+                        )
+                    }
+                    _ => bail!("filter ratings expect an unsigned int; got {:#?}", val),
+                }
+            } else {
+                Ok(client
+                    .find2(
+                        &format!("{}", attr),
+                        &format!("{}", op),
+                        &quote_value(val),
+                        case,
+                    )
+                    .await
+                    .context("Failed to `find2` on client")?
+                    .drain(..)
+                    .collect())
+            }
+        }
+    }
+}
+
+/// The evaluation stack contains logical operators & sets of song URIs
+#[derive(Debug)]
+enum EvalStackNode {
+    Op(EvalOp),
+    Result(HashSet<String>),
+}
+
+async fn negate_result(
+    res: &HashSet<String>,
+    client: &mut Client,
+) -> std::result::Result<HashSet<String>, Error> {
+    Ok(client
+        .get_all_songs()
+        .await
+        .context("Failed to get all songs from client")?
+        .drain(..)
+        .filter_map(|song| {
+            // Some(thing) adds thing, None elides it
+            if !res.contains(&song) {
+                Some(song)
+            } else {
+                None
+            }
+        })
+        .collect::<HashSet<String>>())
+}
+
+/// Reduce the evaluation stack as far as possible.
+///
+/// We can pop the stack in two cases:
+///
+/// 1. S.len() > 2 and S[-3] is either And or Or, and both S[-1] & S[-2] are Result-s
+/// 2. S.len() > 1, S[-2] is Not, and S[-1] is a Result
+async fn reduce(stack: &mut Vec<EvalStackNode>, client: &mut Client) -> Result<()> {
+    loop {
+        let mut reduced = false;
+        let n = stack.len();
+        if n > 1 {
+            // Take care to compute the reduction *before* popping the stack-- thank you, borrow
+            // checker!
+            let reduction = if let (EvalStackNode::Op(EvalOp::Not), EvalStackNode::Result(r)) =
+                (&stack[n - 2], &stack[n - 1])
+            {
+                Some(negate_result(r, client).await?)
+            } else {
+                None
+            };
+
+            if let Some(res) = reduction {
+                stack.pop();
+                stack.pop();
+                stack.push(EvalStackNode::Result(res));
+                reduced = true;
+            }
+        }
+        let n = stack.len();
+        if n > 2 {
+            // Take care to compute the reduction *before* popping the stack-- thank you, borrow
+            // checker!
+            let and_reduction = if let (
+                EvalStackNode::Op(EvalOp::And),
+                EvalStackNode::Result(r1),
+                EvalStackNode::Result(r2),
+            ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1])
+            {
+                Some(r1.intersection(r2).cloned().collect())
+            } else {
+                None
+            };
+
+            if let Some(res) = and_reduction {
+                stack.pop();
+                stack.pop();
+                stack.pop();
+                stack.push(EvalStackNode::Result(res));
+                reduced = true;
+            }
+        }
+        let n = stack.len();
+        if n > 2 {
+            let or_reduction = if let (
+                EvalStackNode::Op(EvalOp::Or),
+                EvalStackNode::Result(r1),
+                EvalStackNode::Result(r2),
+            ) = (&stack[n - 3], &stack[n - 2], &stack[n - 1])
+            {
+                Some(r1.union(r2).cloned().collect())
+            } else {
+                None
+            };
+
+            if let Some(res) = or_reduction {
+                stack.pop();
+                stack.pop();
+                stack.pop();
+                stack.push(EvalStackNode::Result(res));
+                reduced = true;
+            }
+        }
+
+        if !reduced {
+            break;
+        }
+    }
+
+    Ok(())
+}
+
+/// Evaluate an abstract syntax tree (AST)
+pub async fn evaluate<'a>(
+    expr: &Expression,
+    case: bool,
+    client: &mut Client,
+    stickers: &FilterStickerNames<'a>,
+) -> Result<HashSet<String>> {
+    // We maintain *two* stacks, one for parsing & one for evaluation.  Let sp (for "stack(parse)")
+    // be a stack of references to nodes in the parse tree.
+    let mut sp = Vec::new();
+    // Initialize it with the root; as we walk the tree, we'll pop the "most recent" node, and push
+    // children.
+    sp.push(expr);
+
+    // Let se (for "stack(eval)") be a stack of operators & URIs.
+    let mut se = Vec::new();
+
+    // Simple DFS traversal of the AST:
+    while let Some(node) = sp.pop() {
+        // and dispatch based on what we've got:
+        match node {
+            // 1. we have a simple term: this can be immediately resolved to a set of song URIs. Do
+            // so & push the resulting set onto the evaluation stack.
+            Expression::Simple(bt) => se.push(EvalStackNode::Result(
+                eval_term(bt, case, client, stickers).await?,
+            )),
+            // 2. we have a negation: push the "not" operator onto the evaluation stack & the child
+            // onto the parse stack.
+            Expression::Negation(be) => {
+                se.push(EvalStackNode::Op(EvalOp::Not));
+                sp.push(be);
+            }
+            // 3. conjunction-- push the "and" operator onto the evaluation stack & the children
+            // onto the parse stack (be sure to push the right-hand child first, so it will be
+            // popped second)
+            // bc is &Box<Conjunction<'a>>, so &**bc is &Conjunction<'a>
+            Expression::Conjunction(bc) => {
+                let mut conj = &**bc;
+                loop {
+                    match conj {
+                        Conjunction::Simple(bel, ber) => {
+                            se.push(EvalStackNode::Op(EvalOp::And));
+                            sp.push(&**ber);
+                            sp.push(&**bel);
+                            break;
+                        }
+                        Conjunction::Compound(bc, be) => {
+                            se.push(EvalStackNode::Op(EvalOp::And));
+                            sp.push(&**be);
+                            conj = bc;
+                        }
+                    }
+                }
+            }
+            Expression::Disjunction(bt) => {
+                let mut disj = &**bt;
+                loop {
+                    match disj {
+                        Disjunction::Simple(bel, ber) => {
+                            se.push(EvalStackNode::Op(EvalOp::Or));
+                            sp.push(ber);
+                            sp.push(bel);
+                            break;
+                        }
+                        Disjunction::Compound(bd, be) => {
+                            se.push(EvalStackNode::Op(EvalOp::Or));
+                            sp.push(&**be);
+                            disj = bd;
+                        }
+                    }
+                }
+            }
+        }
+
+        reduce(&mut se, client).await?;
+    }
+
+    // At this point, sp is empty, but there had better be something on se. Keep reducing the stack
+    // until either we can't any further (in which case we error) or there is only one element left
+    // (in which case we return that).
+    reduce(&mut se, client).await?;
+
+    // Now, se had better have one element, and that element had better be a Result.
+    if 1 != se.len() {
+        debug!("Too many ({}) operands left on stack:", se.len());
+        se.iter()
+            .enumerate()
+            .for_each(|(i, x)| debug!("    {}: {:#?}", i, x));
+        bail!("The number of operants is too big `{}`", se.len());
+    }
+
+    let ret = se.pop().unwrap();
+    match ret {
+        EvalStackNode::Result(result) => Ok(result),
+        EvalStackNode::Op(op) => {
+            debug!("Operator left on stack (!?): {:#?}", op);
+            bail!("Operator left on stack: {op}")
+        }
+    }
+}
+
+#[cfg(test)]
+mod evaluation_tests {
+
+    use super::*;
+    use crate::filters::*;
+
+    use crate::clients::Client;
+    use crate::clients::test_mock::Mock;
+
+    #[tokio::test]
+    async fn smoke() {
+        let mock = Box::new(Mock::new(&[(
+            r#"find "(base \"foo\")""#,
+            "file: foo/a.mp3
+Artist: The Foobars
+file: foo/b.mp3
+Title: b!
+OK",
+        )]));
+        let mut cli = Client::new(mock).unwrap();
+
+        let stickers = FilterStickerNames::new();
+
+        let expr = ExpressionParser::new().parse(r#"(base "foo")"#).unwrap();
+        let result = evaluate(&expr, true, &mut cli, &stickers).await;
+        assert!(result.is_ok());
+
+        let g: HashSet<String> = ["foo/a.mp3", "foo/b.mp3"]
+            .iter()
+            .map(|x| x.to_string())
+            .collect();
+        assert!(result.unwrap() == g);
+    }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs
new file mode 100644
index 00000000..4fe523ea
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs
@@ -0,0 +1,207 @@
+// 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/>.
+
+//! # mpdpopm
+//!
+//! 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)).
+//!
+//! # Commands
+//!
+//! I'm currently sending all commands over one (configurable) channel.
+//!
+
+#![recursion_limit = "512"] // for the `select!' macro
+
+pub mod clients;
+pub mod config;
+pub mod filters_ast;
+pub mod messages;
+pub mod playcounts;
+pub mod storage;
+pub mod vars;
+
+#[rustfmt::skip]
+#[allow(clippy::extra_unused_lifetimes)]
+#[allow(clippy::needless_lifetimes)]
+#[allow(clippy::let_unit_value)]
+#[allow(clippy::just_underscores_and_digits)]
+pub mod filters {
+    include!(concat!(env!("OUT_DIR"), "/src/filters.rs"));
+}
+
+use crate::{
+    clients::{Client, IdleClient, IdleSubSystem},
+    config::{Config, Connection},
+    filters_ast::FilterStickerNames,
+    messages::MessageProcessor,
+    playcounts::PlayState,
+};
+
+use anyhow::{Context, Error};
+use futures::{future::FutureExt, pin_mut, select};
+use tokio::{
+    signal,
+    signal::unix::{SignalKind, signal},
+    time::{Duration, sleep},
+};
+use tracing::{debug, error, info};
+
+/// Core `mppopmd' logic
+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)
+                .await
+                .with_context(|| format!("Failed to open socket at `{}`", path.display()))?,
+            Connection::TCP { ref host, port } => Client::connect(format!("{}:{}", host, port))
+                .await
+                .with_context(|| format!("Failed to connect to client at `{}:{}`", host, port))?,
+        };
+
+    let mut state = PlayState::new(&mut client, cfg.played_thresh)
+        .await
+        .context("Failed to construct PlayState")?;
+
+    let mut idle_client = match cfg.conn {
+        Connection::Local { ref path } => IdleClient::open(path)
+            .await
+            .context("Failed to open idle client")?,
+        Connection::TCP { ref host, port } => IdleClient::connect(format!("{}:{}", host, port))
+            .await
+            .context("Failed to connect to TCP idle client")?,
+    };
+
+    idle_client
+        .subscribe(&cfg.commands_chan)
+        .await
+        .context("Failed to subscribe to idle_client")?;
+
+    let mut hup = signal(SignalKind::hangup()).unwrap();
+    let mut kill = signal(SignalKind::terminate()).unwrap();
+    let ctrl_c = signal::ctrl_c().fuse();
+
+    let sighup = hup.recv().fuse();
+    let sigkill = kill.recv().fuse();
+
+    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;
+    while !done {
+        debug!("selecting...");
+        let mut msg_check_needed = false;
+        {
+            // `idle_client' mutably borrowed here
+            let mut idle = Box::pin(idle_client.idle().fuse());
+            loop {
+                select! {
+                    _ = ctrl_c => {
+                        info!("got ctrl-C");
+                        done = true;
+                        break;
+                    },
+                    _ = sighup => {
+                        info!("got SIGHUP");
+                        done = true;
+                        break;
+                    },
+                    _ = sigkill => {
+                        info!("got SIGKILL");
+                        done = true;
+                        break;
+                    },
+                    _ = tick => {
+                        tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse());
+                        state.update(&mut client)
+                                .await
+                                .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)
+                                    .await
+                                    .context("PlayState update failed")?
+                            } else if subsys == IdleSubSystem::Message {
+                                msg_check_needed = true;
+                            }
+                            break;
+                        },
+                        Err(err) => {
+                            debug!("error {err:#?} on idle");
+                            done = true;
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        if msg_check_needed {
+            // 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
+            {
+                error!("Error while processing messages: {err:#?}");
+            }
+        }
+    }
+
+    info!("mpdpopm exiting.");
+
+    Ok(())
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/messages.rs b/pkgs/by-name/mp/mpdpopm/src/messages.rs
new file mode 100644
index 00000000..171a246a
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/messages.rs
@@ -0,0 +1,409 @@
+// 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/playcounts.rs b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
new file mode 100644
index 00000000..7d646b4c
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/playcounts.rs
@@ -0,0 +1,313 @@
+// 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/>.
+
+//! playcounts -- managing play counts & lastplayed times
+//!
+//! # Introduction
+//!
+//! Play counts & last played timestamps are maintained so long as [PlayState::update] is called
+//! regularly (every few seconds, say). For purposes of library maintenance, however, they can be
+//! set explicitly:
+//!
+//! - `setpc PLAYCOUNT( TRACK)?`
+//! - `setlp LASTPLAYED( TRACK)?`
+//!
+
+use crate::clients::{Client, PlayerStatus};
+use crate::storage::{last_played, play_count, skipped};
+
+use anyhow::{Context, Error, Result, anyhow};
+use tracing::{debug, info};
+
+use std::time::SystemTime;
+
+/// Current server state in terms of the play status (stopped/paused/playing, current track, elapsed
+/// time in current track, &c)
+#[derive(Debug)]
+pub struct PlayState {
+    /// Last known server status
+    last_server_stat: PlayerStatus,
+
+    /// true if we have already incremented the last known track's playcount
+    have_incr_play_count: bool,
+
+    /// Percentage threshold, expressed as a number between zero & one, for considering a song to
+    /// have been played
+    played_thresh: f64,
+    last_song_was_skipped: bool,
+}
+
+impl PlayState {
+    /// Create a new PlayState instance; async because it will reach out to the mpd server
+    /// to get current status.
+    pub async fn new(
+        client: &mut Client,
+        played_thresh: f64,
+    ) -> std::result::Result<PlayState, Error> {
+        Ok(PlayState {
+            last_server_stat: client.status().await?,
+            have_incr_play_count: false,
+            last_song_was_skipped: false,
+            played_thresh,
+        })
+    }
+
+    /// Retrieve a copy of the last known player status
+    pub fn last_status(&self) -> PlayerStatus {
+        self.last_server_stat.clone()
+    }
+
+    /// 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<()> {
+        let new_stat = client
+            .status()
+            .await
+            .context("Failed to get client status")?;
+
+        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))
+            | (PlayerStatus::Pause(last), PlayerStatus::Pause(curr)) => {
+                // Last we knew, we were playing, and we're playing now.
+                if last.songid != curr.songid {
+                    debug!("New songid-- resetting PC incremented flag.");
+
+                    if !self.have_incr_play_count {
+                        // We didn't mark the previous song as played.
+                        // As such, the user must have skipped it :(
+                        self.last_song_was_skipped = true;
+                    }
+
+                    self.have_incr_play_count = false;
+                } 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;
+                }
+            }
+            (PlayerStatus::Stopped, PlayerStatus::Play(_))
+            | (PlayerStatus::Stopped, PlayerStatus::Pause(_))
+            | (PlayerStatus::Pause(_), PlayerStatus::Stopped)
+            | (PlayerStatus::Play(_), PlayerStatus::Stopped) => {
+                self.have_incr_play_count = false;
+            }
+            (PlayerStatus::Stopped, PlayerStatus::Stopped) => (),
+        }
+
+        match &new_stat {
+            PlayerStatus::Play(curr) => {
+                let pct = curr.played_pct();
+                debug!("Updating status: {:.3}% complete.", 100.0 * pct);
+                if !self.have_incr_play_count && pct >= self.played_thresh {
+                    info!(
+                        "Increment play count for '{}' (songid: {}) at {} played.",
+                        curr.file.display(),
+                        curr.songid,
+                        curr.elapsed / curr.duration
+                    );
+
+                    let file = curr.file.to_str().ok_or_else(|| {
+                        anyhow!("Failed to parse path as utf8: `{}`", curr.file.display())
+                    })?;
+
+                    let curr_pc = play_count::get(client, file).await?.unwrap_or_default();
+
+                    debug!("Current PC is {}.", curr_pc);
+
+                    last_played::set(
+                        client,
+                        file,
+                        SystemTime::now()
+                            .duration_since(SystemTime::UNIX_EPOCH)
+                            .context("Failed to get system time")?
+                            .as_secs(),
+                    )
+                    .await?;
+                    self.have_incr_play_count = true;
+
+                    play_count::set(client, file, curr_pc + 1).await?;
+                } else if self.last_song_was_skipped {
+                    self.last_song_was_skipped = false;
+                    let last = self
+                        .last_server_stat
+                        .current_song()
+                        .expect("To exist, as it was skipped");
+
+                    info!(
+                        "Marking '{}' (songid: {}) as skipped at {}.",
+                        last.file.display(),
+                        last.songid,
+                        last.elapsed / last.duration
+                    );
+
+                    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?;
+                }
+            }
+            PlayerStatus::Pause(_) | PlayerStatus::Stopped => (),
+        };
+
+        self.last_server_stat = new_stat;
+        Ok(()) // No need to update the DB
+    }
+}
+
+#[cfg(test)]
+mod player_state_tests {
+    use super::*;
+    use crate::clients::test_mock::Mock;
+
+    /// "Smoke" tests for player state
+    #[tokio::test]
+    async fn player_state_smoke() {
+        let mock = Box::new(Mock::new(&[
+            (
+                "status",
+                "repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: stop
+xfade: 5
+song: 51
+songid: 52
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "status",
+                "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 5.337
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "playlistid 52",
+                "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+            ),
+            (
+                "status",
+                "volume: 100
+repeat: 0
+random: 1
+single: 0
+consume: 1
+playlist: 2
+playlistlength: 66
+mixrampdb: 0.000000
+state: play
+xfade: 5
+song: 51
+songid: 52
+time: 5:228
+elapsed: 200
+bitrate: 192
+duration: 227.637
+audio: 44100:24:2
+nextsong: 11
+nextsongid: 12
+OK
+",
+            ),
+            (
+                "playlistid 52",
+                "file: E/Enya - Wild Child.mp3
+Last-Modified: 2008-11-09T00:06:30Z
+Artist: Enya
+Title: Wild Child
+Album: A Day Without Rain (Japanese Retail)
+Date: 2000
+Genre: Celtic
+Time: 228
+duration: 227.637
+Pos: 51
+Id: 52
+OK
+",
+            ),
+            (
+                "sticker get song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount",
+                "sticker: unwoundstack.com:playcount=11\nOK\n",
+            ),
+            (
+                &format!(
+                    "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:lastplayed {}",
+                    SystemTime::now()
+                        .duration_since(SystemTime::UNIX_EPOCH)
+                        .unwrap()
+                        .as_secs()
+                ),
+                "OK\n",
+            ),
+            (
+                "sticker set song \"E/Enya - Wild Child.mp3\" unwoundstack.com:playcount 12",
+                "OK\n",
+            ),
+        ]));
+
+        let mut cli = Client::new(mock).unwrap();
+        let mut ps = PlayState::new(&mut cli, 0.6).await.unwrap();
+        let check = match ps.last_status() {
+            PlayerStatus::Play(_) | PlayerStatus::Pause(_) => false,
+            PlayerStatus::Stopped => true,
+        };
+        assert!(check);
+
+        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
new file mode 100644
index 00000000..24d8dcb5
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/storage/mod.rs
@@ -0,0 +1,145 @@
+use anyhow::{Error, Result};
+
+pub mod play_count {
+    use anyhow::Context;
+
+    use crate::clients::Client;
+
+    use super::Result;
+
+    pub const STICKER: &str = "unwoundstack.com:playcount";
+
+    /// Retrieve the play count for a track
+    pub async fn get(client: &mut Client, file: &str) -> Result<Option<usize>> {
+        match client
+            .get_sticker::<usize>(file, STICKER)
+            .await
+            .context("Failed to get sticker from client")?
+        {
+            Some(n) => Ok(Some(n)),
+            None => Ok(None),
+        }
+    }
+
+    /// Set the play count for a track-- this will run the associated command, if any
+    pub async fn set(client: &mut Client, file: &str, play_count: usize) -> Result<()> {
+        client
+            .set_sticker(file, STICKER, &format!("{}", play_count))
+            .await
+            .context("Failed to set_sticker on client")?;
+
+        Ok(())
+    }
+
+    #[cfg(test)]
+    mod pc_lp_tests {
+        use super::*;
+        use crate::{clients::test_mock::Mock, storage::play_count};
+
+        /// "Smoke" tests for play counts & last played times
+        #[tokio::test]
+        async fn pc_smoke() {
+            let mock = Box::new(Mock::new(&[
+                (
+                    "sticker get song a unwoundstack.com:playcount",
+                    "sticker: unwoundstack.com:playcount=11\nOK\n",
+                ),
+                (
+                    "sticker get song a unwoundstack.com:playcount",
+                    "ACK [50@0] {sticker} no such sticker\n",
+                ),
+                ("sticker get song a unwoundstack.com:playcount", "splat!"),
+            ]));
+            let mut cli = Client::new(mock).unwrap();
+
+            assert_eq!(play_count::get(&mut cli, "a").await.unwrap().unwrap(), 11);
+            let val = play_count::get(&mut cli, "a").await.unwrap();
+            assert!(val.is_none());
+            play_count::get(&mut cli, "a").await.unwrap_err();
+        }
+    }
+}
+
+pub mod skipped {
+    use anyhow::Context;
+
+    use crate::clients::Client;
+
+    use super::Result;
+
+    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>> {
+        match client
+            .get_sticker::<usize>(file, STICKER)
+            .await
+            .context("Failed to get_sticker on client")?
+        {
+            Some(n) => Ok(Some(n)),
+            None => Ok(None),
+        }
+    }
+
+    /// Set the skip count for a track
+    pub async fn set(client: &mut Client, file: &str, skip_count: usize) -> Result<()> {
+        client
+            .set_sticker(file, STICKER, &format!("{}", skip_count))
+            .await
+            .context("Failed to set_sticker on client")
+    }
+}
+
+pub mod last_played {
+    use anyhow::Context;
+
+    use crate::clients::Client;
+
+    use super::Result;
+
+    pub const STICKER: &str = "unwoundstack.com:lastplayed";
+
+    /// Retrieve the last played timestamp for a track (seconds since Unix epoch)
+    pub async fn get(client: &mut Client, file: &str) -> Result<Option<u64>> {
+        client
+            .get_sticker::<u64>(file, STICKER)
+            .await
+            .context("Falied to get_sticker on client")
+    }
+
+    /// Set the last played for a track
+    pub async fn set(client: &mut Client, file: &str, last_played: u64) -> Result<()> {
+        client
+            .set_sticker(file, STICKER, &format!("{}", last_played))
+            .await
+            .context("Failed to set_sticker on client")?;
+        Ok(())
+    }
+}
+
+pub mod rating_count {
+    use anyhow::Context;
+
+    use crate::clients::Client;
+
+    use super::Result;
+
+    pub const STICKER: &str = "unwoundstack.com:ratings_count";
+
+    /// Retrieve the rating count for a track
+    pub async fn get(client: &mut Client, file: &str) -> Result<Option<i8>> {
+        client
+            .get_sticker::<i8>(file, STICKER)
+            .await
+            .context("Failed to get_sticker on client")
+    }
+
+    /// Set the rating count for a track
+    pub async fn set(client: &mut Client, file: &str, rating_count: i8) -> Result<()> {
+        client
+            .set_sticker(file, STICKER, &format!("{}", rating_count))
+            .await
+            .context("Failed to set_sticker on client")?;
+        Ok(())
+    }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/vars.rs b/pkgs/by-name/mp/mpdpopm/src/vars.rs
new file mode 100644
index 00000000..7cacec66
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/vars.rs
@@ -0,0 +1,4 @@
+pub static VERSION: &str = env!("CARGO_PKG_VERSION");
+pub static AUTHOR: &str = env!("CARGO_PKG_AUTHORS");
+pub static LOCALSTATEDIR: &str = "/home/soispha/.local/state";
+pub static PREFIX: &str = "/home/soispha/.local/share/mpdpopm";