about summary refs log tree commit diff stats
path: root/pkgs/by-name/mp/mpdpopm/src/dj
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs155
-rw-r--r--pkgs/by-name/mp/mpdpopm/src/dj/mod.rs28
2 files changed, 183 insertions, 0 deletions
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..5ddfc7cb
--- /dev/null
+++ b/pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs
@@ -0,0 +1,155 @@
+use std::collections::HashSet;
+
+use anyhow::{Context, Result};
+use rand::{Rng, distr, seq::SliceRandom};
+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) => {{
+                $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"
+                                        )
+                                    },
+                                    |val| {
+                                        tracing::info!(
+                                            "Selecting a `{}` track for the next entry in the queue",
+                                            stringify!($third)
+                                        );
+                                        Ok::<_, anyhow::Error>(val)
+                                    },
+                                )
+                            },
+                            |val| {
+                                tracing::info!(
+                                    "Selecting a `{}` track for the next entry in the queue",
+                                    stringify!($second)
+                                );
+                                Ok::<_, anyhow::Error>(val)
+                            },
+                        )
+                    },
+                    |val| {
+                        tracing::info!(
+                            "Selecting a `{}` track for the next entry in the queue",
+                            stringify!($first)
+                        );
+                        Ok::<_, anyhow::Error>(val)
+                    },
+                )
+            }};
+        }
+
+        let mut rng = rand::rng();
+        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),
+                }
+            }
+
+            // Avoid an inherit ordering, that might be returned by the `Client::get_all_songs()` function.
+            positive.shuffle(&mut rng);
+            neutral.shuffle(&mut rng);
+            negative.shuffle(&mut rng);
+
+            (positive, neutral, negative)
+        };
+
+        let pick = 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(())
+    }
+}