diff options
Diffstat (limited to 'pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs')
| -rw-r--r-- | pkgs/by-name/mp/mpdpopm/src/dj/algorithms.rs | 139 |
1 files changed, 139 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..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) + } +} |
