diff options
Diffstat (limited to '')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs | 155 | ||||
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/mod.rs | 28 |
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(()) + } +} |
