about summary refs log tree commit diff stats
path: root/pkgs/by-name
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/Cargo.lock93
-rw-r--r--pkgs/by-name/mp/mpdpopm/Cargo.toml2
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs33
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs139
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/mod.rs28
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/lib.rs28
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs110
7 files changed, 429 insertions, 4 deletions
diff --git a/pkgs/by-name/mp/mpdpopm/Cargo.lock b/pkgs/by-name/mp/mpdpopm/Cargo.lock
index 96909646..8b61799a 100644
--- a/pkgs/by-name/mp/mpdpopm/Cargo.lock
+++ b/pkgs/by-name/mp/mpdpopm/Cargo.lock
@@ -403,6 +403,18 @@ dependencies = [
 ]
 
 [[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
 name = "hashbrown"
 version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -589,9 +601,11 @@ dependencies = [
  "lazy_static",
  "os_str_bytes",
  "pin-project",
+ "rand",
  "regex",
  "serde",
  "serde_json",
+ "shlex",
  "tokio",
  "toml",
  "tracing",
@@ -724,6 +738,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
 [[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
 name = "precomputed-hash"
 version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -748,6 +771,41 @@ dependencies = [
 ]
 
 [[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
 name = "redox_syscall"
 version = "0.5.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1148,6 +1206,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
 
 [[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
 name = "wasm-bindgen"
 version = "0.2.108"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1350,6 +1417,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
 
 [[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
 name = "zmij"
 version = "1.0.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/pkgs/by-name/mp/mpdpopm/Cargo.toml b/pkgs/by-name/mp/mpdpopm/Cargo.toml
index c82537e6..71232236 100644
--- a/pkgs/by-name/mp/mpdpopm/Cargo.toml
+++ b/pkgs/by-name/mp/mpdpopm/Cargo.toml
@@ -42,3 +42,5 @@ tokio = { version = "1.49", features = ["io-util", "macros", "net", "process", "
 tracing = "0.1.44"
 tracing-subscriber = { version = "0.3.22", features = ["env-filter"]}
 anyhow = "1.0.100"
+shlex = "1.3.0"
+rand = "0.9.2"
diff --git a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
index 5d4eeae4..faa651bf 100644
--- a/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/bin/mpdpopm.rs
@@ -31,6 +31,7 @@ use mpdpopm::{
     config::{self, Config},
     filters::ExpressionParser,
     filters_ast::{FilterStickerNames, evaluate},
+    messanges::COMMAND_CHANNEL,
     storage::{last_played, play_count, rating},
 };
 
@@ -425,6 +426,23 @@ enum PlaylistsCommand {
 }
 
 #[derive(Subcommand)]
+enum DjCommand {
+    /// Activate the automatic DJ mode on the mpdpopmd daemon.
+    ///
+    /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+    /// recommendation algorithm.
+    #[clap(verbatim_doc_comment)]
+    Start {},
+
+    /// Deactivate the automatic DJ mode on the mpdpopmd daemon.
+    ///
+    /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+    /// recommendation algorithm.
+    #[clap(verbatim_doc_comment)]
+    Stop {},
+}
+
+#[derive(Subcommand)]
 enum SubCommand {
     /// Change details about rating.
     Rating {
@@ -477,9 +495,18 @@ enum SubCommand {
         filter: String,
 
         /// Respect the casing, when performing the filter evaluation.
-        #[arg(short, long, default_value_t = true)]
+        #[arg(short, long, default_value_t = false)]
         case_sensitive: bool,
     },
+
+    /// Modify the automatic DJ mode on the mpdpopmd daemon.
+    ///
+    /// In this mode, the daemon will automatically add new tracks to the playlist based on a
+    /// recommendation algorithm.
+    Dj {
+        #[command(subcommand)]
+        command: DjCommand,
+    },
 }
 
 #[tokio::main]
@@ -569,5 +596,9 @@ async fn main() -> Result<()> {
             filter,
             case_sensitive,
         } => searchadd(&mut client, &filter, case_sensitive).await,
+        SubCommand::Dj { command } => match command {
+            DjCommand::Start {} => client.send_message(COMMAND_CHANNEL, "dj start").await,
+            DjCommand::Stop {} => client.send_message(COMMAND_CHANNEL, "dj stop").await,
+        },
     }
 }
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
new file mode 100644
index 00000000..fcb05817
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
@@ -0,0 +1,139 @@
+use std::collections::HashSet;
+
+use anyhow::{Context, Result};
+use rand::{Rng, distr};
+use tracing::info;
+
+use crate::{clients::Client, storage};
+
+pub(crate) trait Algorithm {
+    async fn next_track(&mut self, client: &mut Client) -> Result<String>;
+}
+
+/// Generates generic discovery playlist, that fulfills following requirements:
+///  - Will (eventually) include every not-played song. (So it can be used to rank a library)
+///  - Returns liked songs more often then not-played or negative songs.
+pub(crate) struct Discovery {
+    already_done: HashSet<String>,
+}
+
+impl Algorithm for Discovery {
+    async fn next_track(&mut self, client: &mut Client) -> Result<String> {
+        macro_rules! take {
+            ($first:expr, $second:expr, $third:expr) => {{
+                tracing::info!(
+                    "Selecting a `{}` track for the next entry in the queue",
+                    stringify!($first)
+                );
+
+                $first.pop().map_or_else(
+                    || {
+                        $second.pop().map_or_else(
+                            || {
+                                $third.pop().map_or_else(
+                                    || {
+                                        unreachable!(
+                                            "This means that there are no songs in the libary"
+                                        )
+                                    },
+                                    Ok::<_, anyhow::Error>,
+                                )
+                            },
+                            Ok::<_, anyhow::Error>,
+                        )
+                    },
+                    Ok::<_, anyhow::Error>,
+                )
+            }};
+        }
+
+        let (mut positive, mut neutral, mut negative) = {
+            let tracks = {
+                let mut base = client
+                    .get_all_songs()
+                    .await?
+                    .into_iter()
+                    .filter(|song| !self.already_done.contains(song))
+                    .collect::<Vec<_>>();
+
+                if base.is_empty() {
+                    // We could either have no tracks in the library,
+                    // or we actually already listed to everything.
+                    self.already_done = HashSet::new();
+
+                    info!("Resetting already done songs, as we have no more to choose from");
+
+                    base = client.get_all_songs().await?;
+                }
+
+                base
+            };
+
+            let mut positive = vec![];
+            let mut neutral = vec![];
+            let mut negative = vec![];
+
+            for track in tracks {
+                let weight = Self::weight_track(client, &track).await?;
+
+                match weight {
+                    1..=i64::MAX => positive.push(track),
+                    0 => neutral.push(track),
+                    i64::MIN..0 => negative.push(track),
+                }
+            }
+
+            (positive, neutral, negative)
+        };
+
+        let pick = {
+            let mut rng = rand::rng();
+            rng.sample(
+                distr::weighted::WeightedIndex::new([0.65, 0.5, 0.2].iter())
+                    .expect("to be valid, as hardcoded"),
+            )
+        };
+
+        let next = match pick {
+            0 => take!(positive, neutral, negative),
+            1 => take!(neutral, positive, negative),
+            2 => take!(negative, neutral, positive),
+            _ => unreachable!("These indexes are not possible"),
+        }?;
+
+        self.already_done.insert(next.clone());
+
+        Ok(next)
+    }
+}
+
+impl Discovery {
+    pub(crate) fn new() -> Self {
+        Self {
+            already_done: HashSet::new(),
+        }
+    }
+
+    /// Calculate a recommendation score for a track.
+    ///
+    /// The algorithm maps tracks, that the user likes to a high score and songs that the user
+    /// dislikes to a lower number.
+    /// Currently, only the rating, skip count and play count are considered. Similarity scores,
+    /// fetched from e.g. last.fm should be included in the future.
+    async fn weight_track(client: &mut Client, track: &str) -> Result<i64> {
+        let rating = i32::from(storage::rating::get(client, track).await?.unwrap_or(0));
+        let play_count = i32::try_from(storage::play_count::get(client, track).await?.unwrap_or(0))
+            .context("`play_count` too big")?;
+        let skip_count = i32::try_from(storage::skip_count::get(client, track).await?.unwrap_or(0))
+            .context("`skip_count` too big")?;
+
+        let output: f64 =
+            1.0 * f64::from(rating) + 0.3 * f64::from(play_count) + -0.6 * f64::from(skip_count);
+
+        let weight = output.round() as i64;
+
+        // info!("`{track}`: {weight}");
+
+        Ok(weight)
+    }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
new file mode 100644
index 00000000..a211a571
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/mod.rs
@@ -0,0 +1,28 @@
+use anyhow::Result;
+use tracing::info;
+
+use crate::{clients::Client, dj::algorithms::Algorithm};
+
+pub(crate) mod algorithms;
+
+pub(crate) struct Dj<A: Algorithm> {
+    algo: A,
+}
+
+impl<A: Algorithm> Dj<A> {
+    pub(crate) fn new(algo: A) -> Self {
+        Self { algo }
+    }
+
+    /// Add the next track to the playlist.
+    ///
+    /// This should be called after the previous track is finished, to avoid unbounded growth.
+    pub(crate) async fn add_track(&mut self, client: &mut Client) -> Result<()> {
+        let next = self.algo.next_track(client).await?;
+
+        info!("Adding `{next}`, due to active dj mode");
+        client.add(&next).await?;
+
+        Ok(())
+    }
+}
diff --git a/pkgs/by-name/mp/mpdpopm/src/lib.rs b/pkgs/by-name/mp/mpdpopm/src/lib.rs
index d5db57b4..cc2765dc 100644
--- a/pkgs/by-name/mp/mpdpopm/src/lib.rs
+++ b/pkgs/by-name/mp/mpdpopm/src/lib.rs
@@ -34,7 +34,9 @@
 
 pub mod clients;
 pub mod config;
+pub mod dj;
 pub mod filters_ast;
+pub mod messanges;
 pub mod playcounts;
 pub mod storage;
 pub mod vars;
@@ -51,6 +53,7 @@ pub mod filters {
 use crate::{
     clients::{Client, IdleClient, IdleSubSystem},
     config::{Config, Connection},
+    messanges::MessageQueue,
     playcounts::PlayState,
 };
 
@@ -90,6 +93,8 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
             .context("Failed to connect to TCP idle client")?,
     };
 
+    let mut mqueue = MessageQueue::new();
+
     idle_client
         .subscribe(&cfg.commands_chan)
         .await
@@ -106,6 +111,7 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
     pin_mut!(ctrl_c, sighup, sigkill, tick);
 
     let mut done = false;
+    let mut msg_check_needed = false;
     while !done {
         debug!("selecting...");
         {
@@ -132,7 +138,7 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
                         tick.set(sleep(Duration::from_millis(cfg.poll_interval_ms)).fuse());
                         state.update(&mut client)
                                 .await
-                                .context("PlayState update failed")?
+                                .context("PlayState update failed")?;
                     },
                     res = idle => match res {
                         Ok(subsys) => {
@@ -140,9 +146,14 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
                             if subsys == IdleSubSystem::Player {
                                 state.update(&mut client)
                                     .await
-                                    .context("PlayState update failed")?
+                                    .context("PlayState update failed")?;
+
+                                mqueue
+                                    .advance_dj(&mut client)
+                                    .await
+                                    .context("MessageQueue tick failed")?;
                             } else if subsys == IdleSubSystem::Message {
-                                error!("Message handling is not supported!");
+                                msg_check_needed = true;
                             }
                             break;
                         },
@@ -155,6 +166,17 @@ pub async fn mpdpopm(cfg: Config) -> std::result::Result<(), Error> {
                 }
             }
         }
+
+        if msg_check_needed {
+            msg_check_needed = false;
+
+            // Check for any messages that have come in; if there's an error there's not a lot we
+            // can do about it (suppose some client fat-fingers a command name, e.g.)-- just log it
+            // & move on.
+            if let Err(err) = mqueue.check_messages(&mut client, &mut idle_client).await {
+                error!("Error while processing messages: {err:#?}");
+            }
+        }
     }
 
     info!("mpdpopm exiting.");
diff --git a/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
new file mode 100644
index 00000000..c5320dd9
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/messanges/mod.rs
@@ -0,0 +1,110 @@
+use anyhow::{Context, Result, anyhow, bail, ensure};
+use clap::{Parser, Subcommand};
+use shlex::Shlex;
+use tracing::info;
+
+use crate::{
+    clients::{Client, IdleClient},
+    dj::{Dj, algorithms::Discovery},
+};
+
+pub const COMMAND_CHANNEL: &str = "unwoundstack.com:commands";
+
+#[derive(Parser)]
+struct Commands {
+    #[command(subcommand)]
+    command: SubCommand,
+}
+
+#[derive(Parser)]
+enum SubCommand {
+    Dj {
+        #[command(subcommand)]
+        command: DjCommand,
+    },
+}
+
+#[derive(Subcommand)]
+enum DjCommand {
+    Start {},
+    Stop {},
+}
+
+pub(crate) struct MessageQueue {
+    dj: Option<Dj<Discovery>>,
+}
+
+impl MessageQueue {
+    pub(crate) fn new() -> Self {
+        Self { dj: None }
+    }
+
+    pub(crate) async fn advance_dj(&mut self, client: &mut Client) -> Result<()> {
+        if let Some(dj) = self.dj.as_mut() {
+            dj.add_track(client).await?;
+        }
+
+        Ok(())
+    }
+
+    /// Read messages off the commands channel & dispatch 'em
+    pub(crate) async fn check_messages(
+        &mut self,
+        client: &mut Client,
+        idle_client: &mut IdleClient,
+    ) -> Result<()> {
+        let m = idle_client
+            .get_messages()
+            .await
+            .context("Failed to `get_messages` from client")?;
+
+        for (chan, msgs) in m {
+            ensure!(chan == COMMAND_CHANNEL, "Unknown channel: `{}`", chan);
+
+            for msg in msgs {
+                self.process(client, msg).await?;
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Process a single command
+    pub(crate) async fn process(&mut self, client: &mut Client, msg: String) -> Result<()> {
+        let split = {
+            let mut shl = Shlex::new(&msg);
+            let res: Vec<_> = shl.by_ref().collect();
+
+            if shl.had_error {
+                bail!("Failed to parse command '{msg}'")
+            }
+
+            assert_eq!(shl.line_no, 1, "A unexpected newline appeared");
+            assert!(!res.is_empty());
+
+            let mut base = vec!["base".to_owned()];
+            base.extend(res);
+            base
+        };
+
+        let args = Commands::parse_from(split);
+
+        match args.command {
+            SubCommand::Dj { command } => match command {
+                DjCommand::Start {} => {
+                    info!("Dj started");
+                    self.dj = Some(Dj::new(Discovery::new()));
+                    self.advance_dj(client).await?;
+                }
+                DjCommand::Stop {} => {
+                    self.dj
+                        .take()
+                        .ok_or_else(|| anyhow!("Tried to disable already disabled dj mode"))?;
+                    info!("Dj stopped");
+                }
+            },
+        }
+
+        Ok(())
+    }
+}