Added the playlist generator
CI / check (push) Successful in 1m12s
CI / docker (push) Successful in 2m1s

This commit is contained in:
Connor Johnstone
2026-03-20 18:09:47 -04:00
parent 4008b4d838
commit 6f73bb87ce
19 changed files with 1526 additions and 21 deletions
+151
View File
@@ -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()
}