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; } /// 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, } impl Algorithm for Discovery { async fn next_track(&mut self, client: &mut Client) -> Result { 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::>(); 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 { 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) } }