Added the playlist generator
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use shanty_data::PopularTrack;
|
||||
use shanty_db::entities::track::Model as Track;
|
||||
|
||||
use crate::types::ScoredTrack;
|
||||
|
||||
/// Popularity exponent curve (0-10 scale).
|
||||
/// 0 = no preference, 10 = heavy popular bias.
|
||||
const POPULARITY_EXPONENTS: [f64; 11] = [
|
||||
0.0, 0.06, 0.17, 0.33, 0.67, 1.30, 1.50, 1.70, 1.94, 2.22, 2.50,
|
||||
];
|
||||
|
||||
/// Score all tracks for the given artists, returning scored tracks for ranking.
|
||||
///
|
||||
/// `artists` is a list of (mbid_or_name, display_name, similarity_score) tuples.
|
||||
/// `tracks_by_artist` maps artist identifier -> their local tracks.
|
||||
/// `top_tracks_by_artist` maps artist identifier -> their Last.fm top tracks.
|
||||
pub fn score_tracks(
|
||||
artists: &[(String, String, f64)],
|
||||
tracks_by_artist: &HashMap<String, Vec<Track>>,
|
||||
top_tracks_by_artist: &HashMap<String, Vec<PopularTrack>>,
|
||||
popularity_bias: u8,
|
||||
) -> Vec<ScoredTrack> {
|
||||
let bias = popularity_bias.min(10) as usize;
|
||||
let mut scored = Vec::new();
|
||||
|
||||
for (artist_key, name, match_score) in artists {
|
||||
let local_tracks = match tracks_by_artist.get(artist_key) {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let top_tracks = top_tracks_by_artist
|
||||
.get(artist_key)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build playcount lookup by lowercase name
|
||||
let playcount_by_name: HashMap<String, u64> = top_tracks
|
||||
.iter()
|
||||
.map(|t| (t.name.to_lowercase(), t.playcount))
|
||||
.collect();
|
||||
|
||||
let max_playcount = playcount_by_name
|
||||
.values()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
for track in local_tracks {
|
||||
let title_lower = track.title.as_ref().map(|t| t.to_lowercase());
|
||||
|
||||
let playcount = title_lower
|
||||
.as_ref()
|
||||
.and_then(|t| playcount_by_name.get(t).copied())
|
||||
.or_else(|| {
|
||||
track
|
||||
.musicbrainz_id
|
||||
.as_ref()
|
||||
.and_then(|id| playcount_by_name.get(id).copied())
|
||||
});
|
||||
|
||||
// If we have popularity data, require a match; otherwise assign uniform score
|
||||
let (popularity, similarity, score) = if !playcount_by_name.is_empty() {
|
||||
let Some(playcount) = playcount else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let popularity = if playcount > 0 {
|
||||
(playcount as f64 / max_playcount as f64).powf(POPULARITY_EXPONENTS[bias])
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let similarity = (match_score.exp()) / std::f64::consts::E;
|
||||
let score = similarity * popularity;
|
||||
(popularity, similarity, score)
|
||||
} else {
|
||||
// No top tracks data — use uniform scoring based on similarity only
|
||||
let similarity = (match_score.exp()) / std::f64::consts::E;
|
||||
(1.0, similarity, similarity)
|
||||
};
|
||||
|
||||
scored.push(ScoredTrack {
|
||||
track_id: track.id,
|
||||
file_path: track.file_path.clone(),
|
||||
title: track.title.clone(),
|
||||
artist: name.clone(),
|
||||
artist_mbid: track
|
||||
.artist_id
|
||||
.map(|_| artist_key.clone())
|
||||
.or_else(|| Some(artist_key.clone())),
|
||||
album: track.album.clone(),
|
||||
duration: track.duration,
|
||||
score,
|
||||
popularity,
|
||||
similarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Cap tracks per artist based on popularity bias
|
||||
let mut by_artist: HashMap<String, Vec<ScoredTrack>> = HashMap::new();
|
||||
for t in scored {
|
||||
let key = t.artist_mbid.clone().unwrap_or_else(|| t.artist.clone());
|
||||
by_artist.entry(key).or_default().push(t);
|
||||
}
|
||||
|
||||
let cap = if popularity_bias == 0 {
|
||||
None
|
||||
} else {
|
||||
let b = popularity_bias as f64;
|
||||
let c = if b <= 5.0 {
|
||||
90.0 - 12.8 * b
|
||||
} else {
|
||||
26.0 - 3.2 * (b - 5.0)
|
||||
};
|
||||
Some((c.round() as usize).max(1))
|
||||
};
|
||||
|
||||
for group in by_artist.values_mut() {
|
||||
group.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
if let Some(cap) = cap {
|
||||
group.truncate(cap);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Normalize so each artist's total weight = their similarity
|
||||
let similarity_map: HashMap<&str, f64> = artists
|
||||
.iter()
|
||||
.map(|(key, _, sim)| (key.as_str(), *sim))
|
||||
.collect();
|
||||
|
||||
for (key, group) in &mut by_artist {
|
||||
let total: f64 = group.iter().map(|t| t.score).sum();
|
||||
if total > 0.0 {
|
||||
let sim = similarity_map.get(key.as_str()).copied().unwrap_or(1.0);
|
||||
for t in group.iter_mut() {
|
||||
t.score *= sim / total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
by_artist.into_values().flatten().collect()
|
||||
}
|
||||
Reference in New Issue
Block a user