use std::collections::{HashMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::Connection;
use crate::config::RadioConfig;
use crate::db::queries;
use crate::remote::client::SubsonicClient;
use crate::remote::listenbrainz;
use crate::remote::musicbrainz;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SimilarityAxis {
ListenBrainz,
MusicBrainz,
Subsonic,
GenreEra,
SameArtist,
Random,
Acoustic,
}
#[derive(Debug)]
struct Candidate {
track_id: i64,
path: Option<String>,
year: Option<i32>,
axes: HashSet<SimilarityAxis>,
base_score: f64,
}
#[derive(Debug, Default)]
pub struct RadioContext {
pub allow_network: bool,
pub seed_artists: HashMap<i64, f64>,
pub queued_paths: HashSet<String>,
pub queued_ids: HashSet<i64>,
pub excluded_track_ids: HashSet<i64>,
pub current_remote_id: Option<String>,
pub current_artist_name: Option<String>,
pub seed_genres: HashSet<String>,
pub seed_avg_year: Option<i32>,
}
impl RadioContext {
pub fn build(
conn: &Connection,
queue_items: &[(Option<i64>, Option<String>)],
seed_window: usize,
history_window: usize,
) -> Self {
let mut ctx = Self {
allow_network: true,
..Self::default()
};
for (_aid, path) in queue_items {
if let Some(p) = path {
ctx.queued_paths.insert(p.clone());
}
}
let recent =
queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
let seed_count = recent.len().max(1) as f64;
let mut years: Vec<i32> = Vec::new();
let rows: HashMap<i64, queries::TrackRow> = queries::tracks_by_ids(conn, &recent)
.unwrap_or_default()
.into_iter()
.map(|t| (t.id, t))
.collect();
let dates = album_years(conn, &recent);
for (i, track_id) in recent.iter().enumerate() {
if let Some(track) = rows.get(track_id) {
let weight = (seed_count - i as f64) / seed_count;
if let Some(aid) = track.artist_id {
let entry = ctx.seed_artists.entry(aid).or_insert(0.0);
*entry = entry.max(weight);
}
if let Some(ref genre) = track.genre {
ctx.seed_genres.insert(genre.to_lowercase());
}
if let Some(&year) = dates.get(track_id) {
years.push(year);
}
}
}
if ctx.seed_artists.is_empty() {
for (artist_id, _path) in queue_items {
if let Some(aid) = artist_id {
*ctx.seed_artists.entry(*aid).or_default() += 1.0;
}
}
let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
for v in ctx.seed_artists.values_mut() {
*v /= max;
}
let artist_ids: Vec<i64> = ctx.seed_artists.keys().copied().collect();
ctx.seed_genres.extend(
queries::genres_by_artist_ids(conn, &artist_ids)
.unwrap_or_default()
.into_values()
.flatten(),
);
}
let excluded = queries::recent_track_ids(conn, queries::LOCAL_USER, history_window)
.unwrap_or_default();
ctx.excluded_track_ids = excluded.into_iter().collect();
if !years.is_empty() {
ctx.seed_avg_year = Some(years.iter().sum::<i32>() / years.len() as i32);
}
ctx
}
fn drawn_out(&self) -> Vec<i64> {
self.queued_ids
.iter()
.chain(&self.excluded_track_ids)
.copied()
.collect()
}
#[cfg(test)]
pub fn from_queue(items: &[(Option<i64>, Option<String>)]) -> Self {
let mut ctx = Self::default();
for (artist_id, path) in items {
if let Some(aid) = artist_id {
*ctx.seed_artists.entry(*aid).or_default() += 1.0;
}
if let Some(p) = path {
ctx.queued_paths.insert(p.clone());
}
}
let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
if max > 0.0 {
for v in ctx.seed_artists.values_mut() {
*v /= max;
}
}
ctx
}
}
pub fn pick_tracks(
conn: &Connection,
ctx: &RadioContext,
client: Option<&SubsonicClient>,
config: &RadioConfig,
) -> Vec<i64> {
let count = config.batch_size;
let mut candidates: Vec<Candidate> = Vec::new();
log::info!(
"radio: picking {} tracks (seed: {} artists, {} genres, {} excluded, remote_id={}, artist={})",
count,
ctx.seed_artists.len(),
ctx.seed_genres.len(),
ctx.excluded_track_ids.len(),
ctx.current_remote_id.as_deref().unwrap_or("none"),
ctx.current_artist_name.as_deref().unwrap_or("none"),
);
if ctx.allow_network {
gather_listenbrainz_candidates(conn, ctx, &mut candidates);
gather_musicbrainz_candidates(conn, ctx, &mut candidates);
if let Some(client) = client {
gather_subsonic_candidates(conn, ctx, client, &mut candidates);
}
}
gather_genre_era_candidates(conn, ctx, &mut candidates);
gather_same_artist_candidates(conn, ctx, &mut candidates);
gather_acoustic_candidates(conn, config.seed_window, &mut candidates);
gather_random_candidates(conn, ctx, &mut candidates);
log::info!("radio: {} raw candidates before scoring", candidates.len());
let mut deduped: HashMap<i64, Candidate> = HashMap::new();
for c in candidates {
let entry = deduped.entry(c.track_id).or_insert_with(|| Candidate {
track_id: c.track_id,
path: c.path.clone(),
year: c.year,
axes: HashSet::new(),
base_score: 0.0,
});
entry.axes.extend(c.axes.iter());
entry.base_score = entry.base_score.max(c.base_score);
}
let mut scored: Vec<(i64, f64)> = deduped
.into_values()
.filter(|c| !ctx.excluded_track_ids.contains(&c.track_id))
.filter(|c| {
c.path
.as_ref()
.is_none_or(|p| !ctx.queued_paths.contains(p))
})
.map(|c| {
let score = compute_score(conn, &c, ctx, config);
(c.track_id, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let picks = weighted_select(&scored, count);
log::info!("radio: picked {} tracks", picks.len());
picks
}
fn compute_score(
conn: &Connection,
candidate: &Candidate,
ctx: &RadioContext,
config: &RadioConfig,
) -> f64 {
let base = candidate.base_score;
let overlap_bonus = match candidate.axes.len() {
0 | 1 => 1.0,
2 => 1.5,
3 => 2.0,
_ => 2.5,
};
let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
let era_bonus = if let (Some(track_year), Some(seed_year)) = (candidate.year, ctx.seed_avg_year)
{
let diff = (track_year - seed_year).unsigned_abs();
if diff <= 5 {
1.3
} else if diff <= 10 {
1.1
} else {
1.0
}
} else {
1.0
};
base * overlap_bonus * recency_bonus * era_bonus
}
fn compute_recency_bonus(conn: &Connection, track_id: i64, discovery_weight: f64) -> f64 {
let last_played = queries::last_played_at(conn, queries::LOCAL_USER, track_id).unwrap_or(None);
match last_played {
None => {
1.0 + discovery_weight * 2.0
}
Some(ts) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let days_ago = (now - ts) / 86400;
if days_ago > 180 {
1.0 + discovery_weight * 1.5 } else if days_ago > 30 {
1.0 + discovery_weight * 0.8
} else if days_ago > 7 {
1.0 + discovery_weight * 0.3
} else {
1.0 }
}
}
}
fn weighted_select(scored: &[(i64, f64)], count: usize) -> Vec<i64> {
if scored.is_empty() {
return vec![];
}
let pool_size = (count * 3).min(scored.len());
let pool = &scored[..pool_size];
let mut selected = Vec::new();
let mut used = HashSet::new();
for (id, _score) in pool {
if selected.len() >= count {
break;
}
if used.insert(*id) {
selected.push(*id);
}
}
selected
}
fn gather_listenbrainz_candidates(
conn: &Connection,
ctx: &RadioContext,
candidates: &mut Vec<Candidate>,
) {
let http = reqwest::blocking::Client::new();
for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
let mbid: Option<String> = conn
.query_row(
"SELECT mbid FROM artists WHERE id = ?1",
rusqlite::params![artist_id],
|row| row.get(0),
)
.ok()
.flatten();
let mbid = match mbid {
Some(m) if !m.is_empty() => m,
_ => {
let artist_name: Option<String> = conn
.query_row(
"SELECT name FROM artists WHERE id = ?1",
rusqlite::params![artist_id],
|row| row.get(0),
)
.ok();
if let Some(name) = artist_name {
match musicbrainz::lookup_artist_mbid(&http, &name) {
Ok(Some(mbid)) => {
let _ = conn.execute(
"UPDATE artists SET mbid = ?1 WHERE id = ?2",
rusqlite::params![mbid, artist_id],
);
mbid
}
_ => continue,
}
} else {
continue;
}
}
};
if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
.unwrap_or(false)
{
add_cached_similar_candidates(
conn,
ctx,
artist_id,
weight,
SimilarityAxis::ListenBrainz,
candidates,
);
continue;
}
match listenbrainz::get_similar_artists(&http, &mbid, 20) {
Ok(similar) => {
log::info!(
"radio: listenbrainz returned {} similar for artist_id={}",
similar.len(),
artist_id
);
let mut pairs: Vec<(i64, f64)> = Vec::new();
for sa in &similar {
let local_id: Option<i64> = conn
.query_row(
"SELECT id FROM artists WHERE mbid = ?1",
rusqlite::params![sa.mbid],
|row| row.get(0),
)
.ok()
.or_else(|| {
conn.query_row(
"SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
rusqlite::params![sa.name],
|row| row.get(0),
)
.ok()
});
if let Some(local_id) = local_id
&& local_id != artist_id
{
pairs.push((local_id, sa.score));
}
}
if !pairs.is_empty() {
let _ = queries::save_similar_artists(conn, artist_id, &pairs, "listenbrainz");
}
add_local_artist_candidates(
conn,
ctx,
&pairs,
weight,
SimilarityAxis::ListenBrainz,
candidates,
);
}
Err(e) => {
log::debug!(
"radio: listenbrainz failed for artist_id={}: {}",
artist_id,
e
);
}
}
}
}
fn gather_musicbrainz_candidates(
conn: &Connection,
ctx: &RadioContext,
candidates: &mut Vec<Candidate>,
) {
let http = musicbrainz::default_client();
for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("musicbrainz"))
.unwrap_or(false)
{
add_cached_similar_candidates(
conn,
ctx,
artist_id,
weight,
SimilarityAxis::MusicBrainz,
candidates,
);
continue;
}
let mbid: Option<String> = conn
.query_row(
"SELECT mbid FROM artists WHERE id = ?1",
rusqlite::params![artist_id],
|row| row.get(0),
)
.ok()
.flatten();
let Some(mbid) = mbid.filter(|m| !m.is_empty()) else {
continue;
};
match musicbrainz::get_artist_relations(&http, &mbid) {
Ok(relations) => {
log::info!(
"radio: musicbrainz returned {} relations for artist_id={}",
relations.len(),
artist_id
);
let mut pairs: Vec<(i64, f64)> = Vec::new();
for rel in &relations {
let local_id: Option<i64> = conn
.query_row(
"SELECT id FROM artists WHERE mbid = ?1",
rusqlite::params![rel.mbid],
|row| row.get(0),
)
.ok()
.or_else(|| {
conn.query_row(
"SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
rusqlite::params![rel.name],
|row| row.get(0),
)
.ok()
});
if let Some(local_id) = local_id
&& local_id != artist_id
{
let score = match rel.category {
musicbrainz::RelationCategory::Member => 0.8,
musicbrainz::RelationCategory::Collaborator => 0.7,
musicbrainz::RelationCategory::Associated => 0.5,
};
pairs.push((local_id, score));
}
}
if !pairs.is_empty() {
let _ = queries::save_similar_artists_with_rel(
conn,
artist_id,
&pairs,
"musicbrainz",
"collaborator",
);
}
add_local_artist_candidates(
conn,
ctx,
&pairs,
weight,
SimilarityAxis::MusicBrainz,
candidates,
);
}
Err(e) => {
log::debug!(
"radio: musicbrainz relations failed for artist_id={}: {}",
artist_id,
e
);
}
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
fn gather_subsonic_candidates(
conn: &Connection,
ctx: &RadioContext,
client: &SubsonicClient,
candidates: &mut Vec<Candidate>,
) {
if let Some(ref remote_id) = ctx.current_remote_id {
match client.get_similar_songs(remote_id, 30) {
Ok(songs) => {
log::info!("radio: subsonic returned {} similar songs", songs.len());
for (i, song) in songs.iter().enumerate() {
if let Some(track_id) = resolve_subsonic_song_to_track(conn, song) {
let score = (songs.len() as f64 - i as f64) / songs.len() as f64;
let track = queries::get_track_row(conn, track_id).ok().flatten();
candidates.push(Candidate {
track_id,
path: track.as_ref().and_then(|t| t.path.clone()),
year: None,
axes: [SimilarityAxis::Subsonic].into_iter().collect(),
base_score: score * 0.9,
});
}
}
cache_subsonic_artist_relationships(conn, ctx, &songs);
}
Err(e) => {
log::debug!("radio: subsonic similar songs failed: {}", e);
}
}
}
}
fn gather_genre_era_candidates(
conn: &Connection,
ctx: &RadioContext,
candidates: &mut Vec<Candidate>,
) {
if ctx.seed_genres.is_empty() {
return;
}
let exclude = ctx.drawn_out();
let mut tracks = Vec::new();
for genre in ctx.seed_genres.iter().take(3) {
let filter = queries::RandomFilter {
genre: Some(genre),
exclude: &exclude,
..Default::default()
};
match queries::random_tracks_where(conn, 10, &filter) {
Ok(drawn) => tracks.extend(drawn),
Err(e) => log::debug!("radio: genre/era query failed: {}", e),
}
}
let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
let years = album_years(conn, &ids);
for track in tracks {
let year = years.get(&track.id).copied();
let genre_match = track
.genre
.as_ref()
.is_some_and(|g| ctx.seed_genres.contains(&g.to_lowercase()));
let era_match = match (year, ctx.seed_avg_year) {
(Some(y), Some(sy)) => (y as i64 - sy as i64).unsigned_abs() <= 10,
_ => false,
};
let base_score = match (genre_match, era_match) {
(true, true) => 0.6,
(true, false) => 0.3,
(false, true) => 0.2,
(false, false) => 0.1,
};
candidates.push(Candidate {
track_id: track.id,
path: track.path.clone(),
year,
axes: [SimilarityAxis::GenreEra].into_iter().collect(),
base_score,
});
}
}
fn gather_same_artist_candidates(
conn: &Connection,
ctx: &RadioContext,
candidates: &mut Vec<Candidate>,
) {
let mut artists: Vec<(i64, f64)> = ctx.seed_artists.iter().map(|(&a, &w)| (a, w)).collect();
artists.sort_by(|a, b| b.1.total_cmp(&a.1));
let exclude = ctx.drawn_out();
for (artist_id, weight) in artists.into_iter().take(5) {
let filter = queries::RandomFilter {
artist_id: Some(artist_id),
exclude: &exclude,
..Default::default()
};
match queries::random_tracks_where(conn, 3, &filter) {
Ok(tracks) => {
for track in tracks {
candidates.push(Candidate {
track_id: track.id,
path: track.path.clone(),
year: None,
axes: [SimilarityAxis::SameArtist].into_iter().collect(),
base_score: weight * 0.4, });
}
}
Err(e) => {
log::debug!("radio: same-artist query failed: {}", e);
}
}
}
}
fn gather_acoustic_candidates(
conn: &Connection,
seed_window: usize,
candidates: &mut Vec<Candidate>,
) {
let seed_ids =
queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
let mut seed_embeddings = Vec::new();
for tid in &seed_ids {
if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
seed_embeddings.push(emb);
}
}
if seed_embeddings.is_empty() {
return;
}
let centroid = crate::index::features::centroid(&seed_embeddings);
let knn_result = queries::find_similar_to_vector(conn, ¢roid, 30, None);
match knn_result {
Ok(ref results) => {
let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
let mut added = 0;
for &(track_id, dist) in results {
if seed_ids.contains(&track_id) {
continue;
}
let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
let track = queries::get_track_row(conn, track_id).ok().flatten();
candidates.push(Candidate {
track_id,
path: track.as_ref().and_then(|t| t.path.clone()),
year: None,
axes: [SimilarityAxis::Acoustic].into_iter().collect(),
base_score: score,
});
added += 1;
}
log::info!(
"radio: acoustic signal added {} candidates from {} seed vectors",
added,
seed_embeddings.len()
);
}
Err(e) => {
log::debug!("radio: acoustic similarity query failed: {}", e);
}
}
}
fn gather_random_candidates(
conn: &Connection,
ctx: &RadioContext,
candidates: &mut Vec<Candidate>,
) {
let exclude = ctx.drawn_out();
let filter = queries::RandomFilter {
exclude: &exclude,
..Default::default()
};
match queries::random_tracks_where(conn, 10, &filter) {
Ok(tracks) => {
for track in tracks {
candidates.push(Candidate {
track_id: track.id,
path: track.path.clone(),
year: None,
axes: [SimilarityAxis::Random].into_iter().collect(),
base_score: 0.05, });
}
}
Err(e) => {
log::debug!("radio: random fallback failed: {}", e);
}
}
}
fn album_years(conn: &Connection, track_ids: &[i64]) -> HashMap<i64, i32> {
queries::queue_item_extras(conn, track_ids)
.unwrap_or_default()
.into_iter()
.filter_map(|(id, e)| {
let year = crate::helpers::year_of(e.album_date.as_deref()?)?;
Some((id, year.parse().ok()?))
})
.collect()
}
fn add_cached_similar_candidates(
conn: &Connection,
ctx: &RadioContext,
artist_id: i64,
seed_weight: f64,
axis: SimilarityAxis,
candidates: &mut Vec<Candidate>,
) {
if let Ok(similar) = queries::get_similar_artists(conn, artist_id) {
let pairs: Vec<(i64, f64)> = similar.into_iter().map(|(a, s)| (a.id, s)).collect();
add_local_artist_candidates(conn, ctx, &pairs, seed_weight, axis, candidates);
}
}
fn add_local_artist_candidates(
conn: &Connection,
ctx: &RadioContext,
pairs: &[(i64, f64)],
seed_weight: f64,
axis: SimilarityAxis,
candidates: &mut Vec<Candidate>,
) {
let exclude = ctx.drawn_out();
for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
let filter = queries::RandomFilter {
artist_id: Some(similar_artist_id),
exclude: &exclude,
..Default::default()
};
if let Ok(tracks) = queries::random_tracks_where(conn, 3, &filter) {
for track in tracks {
candidates.push(Candidate {
track_id: track.id,
path: track.path.clone(),
year: None,
axes: [axis].into_iter().collect(),
base_score: sim_score * seed_weight * 0.8,
});
}
}
}
}
fn resolve_subsonic_song_to_track(
conn: &Connection,
song: &crate::remote::client::SubsonicSong,
) -> Option<i64> {
conn.query_row(
"SELECT id FROM tracks WHERE remote_id = ?1",
rusqlite::params![song.id],
|row| row.get::<_, i64>(0),
)
.ok()
}
fn cache_subsonic_artist_relationships(
conn: &Connection,
ctx: &RadioContext,
songs: &[crate::remote::client::SubsonicSong],
) {
for &artist_id in ctx.seed_artists.keys().take(5) {
let mut similar: HashMap<i64, f64> = HashMap::new();
let total = songs.len() as f64;
for (i, song) in songs.iter().enumerate() {
if let Some(ref song_artist_id) = song.artist_id {
let local_artist_id: Option<i64> = conn
.query_row(
"SELECT id FROM artists WHERE remote_id = ?1",
rusqlite::params![song_artist_id],
|row| row.get(0),
)
.ok();
if let Some(local_id) = local_artist_id
&& local_id != artist_id
{
let score = (total - i as f64) / total;
let entry = similar.entry(local_id).or_insert(0.0);
*entry = entry.max(score);
}
}
}
if !similar.is_empty() {
let pairs: Vec<(i64, f64)> = similar.into_iter().collect();
let _ = queries::save_similar_artists(conn, artist_id, &pairs, "subsonic");
}
}
}
pub fn spawn_autoqueue(
state: std::sync::Arc<crate::player::state::SharedPlayerState>,
tx: crossbeam_channel::Sender<crate::player::commands::PlayerCommand>,
) {
use crate::player::commands::PlayerCommand;
use crate::player::state::{QueueEntryStatus, QueueItemId};
std::thread::Builder::new()
.name("koan-radio".into())
.spawn(move || {
use std::time::{Duration, Instant};
let mut cfg: Option<(crate::config::Config, Instant)> = None;
let mut fruitless: Option<(u64, Instant)> = None;
loop {
std::thread::sleep(Duration::from_secs(2));
if !state.radio_mode() || state.cursor().is_none() {
cfg = None;
continue;
}
log::debug!("radio: awake, cursor set");
if fruitless.is_some_and(|(version, at)| {
version == state.playlist_version() && at.elapsed() < Duration::from_secs(60)
}) {
continue;
}
if cfg
.as_ref()
.is_none_or(|(_, read)| read.elapsed() > Duration::from_secs(60))
{
cfg = Some((
crate::config::Config::load().unwrap_or_default(),
Instant::now(),
));
}
let Some((cfg, _)) = &cfg else { continue };
let snapshot = state.derive_visible_queue();
let Some(playing) = snapshot
.entries
.iter()
.position(|e| e.status == QueueEntryStatus::Playing)
else {
log::debug!("radio: nothing is playing, waiting");
continue;
};
let remaining = snapshot
.entries
.iter()
.skip(playing + 1)
.filter(|e| e.status == QueueEntryStatus::Queued)
.count();
if remaining > cfg.radio.lookahead {
continue;
}
log::info!(
"radio: {} queued after the cursor, topping up to {}",
remaining,
cfg.radio.lookahead
);
let Ok(db) = crate::db::pool::shared().get() else {
continue;
};
let version = state.playlist_version();
let (items, cursor) = state.snapshot_playlist();
let item_ids: Vec<Option<i64>> = items
.iter()
.map(|item| {
item.db_id.or_else(|| {
let path = item.path.to_str()?;
queries::track_id_by_path(&db.conn, path).ok().flatten()
})
})
.collect();
let ids: Vec<i64> = item_ids.iter().flatten().copied().collect();
let queue_rows: HashMap<i64, queries::TrackRow> =
queries::tracks_by_ids(&db.conn, &ids)
.unwrap_or_default()
.into_iter()
.map(|t| (t.id, t))
.collect();
let row_of = |i: usize| item_ids[i].and_then(|id| queue_rows.get(&id));
let context: Vec<(Option<i64>, Option<String>)> = items
.iter()
.enumerate()
.map(|(i, item)| {
(
row_of(i).and_then(|t| t.artist_id),
Some(item.path.to_string_lossy().into_owned()),
)
})
.collect();
let mut ctx = RadioContext::build(
&db.conn,
&context,
cfg.radio.seed_window,
cfg.radio.history_window,
);
ctx.queued_ids = queue_rows.keys().copied().collect();
if let Some(current) = cursor.and_then(|cid| items.iter().position(|i| i.id == cid))
&& let Some(row) = row_of(current)
{
ctx.current_remote_id = row.remote_id.clone();
ctx.current_artist_name = Some(row.artist_name.clone());
}
ctx.allow_network = false;
let picks = pick_tracks(&db.conn, &ctx, None, &cfg.radio);
if picks.is_empty() {
log::warn!("radio: the picker returned nothing for this seed");
fruitless = Some((version, Instant::now()));
continue;
}
let queued: HashSet<String> = items
.iter()
.map(|i| i.path.to_string_lossy().into_owned())
.collect();
let rows: Vec<_> = queries::tracks_by_ids(&db.conn, &picks)
.unwrap_or_default()
.into_iter()
.filter(|row| {
row.path
.as_deref()
.or(row.cached_path.as_deref())
.is_none_or(|p| !queued.contains(p))
})
.collect();
if rows.is_empty() {
log::warn!("radio: every pick was already in the queue");
fruitless = Some((version, Instant::now()));
continue;
}
fruitless = None;
let new_items = crate::helpers::playlist_items_for_tracks(&db, &rows);
let pending: Vec<(i64, QueueItemId)> = new_items
.iter()
.filter(|i| matches!(i.state, crate::player::state::ItemState::Pending))
.filter_map(|i| i.db_id.map(|id| (id, i.id)))
.collect();
log::info!("radio: queueing {} tracks", new_items.len());
if tx.send(PlayerCommand::AddToPlaylist(new_items)).is_err() {
return; }
if !pending.is_empty() {
crate::helpers::spawn_downloads(pending, tx.clone(), state.clone());
}
}
})
.ok();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::connection::Database;
use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
fn test_db() -> Database {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
Database { conn }
}
#[test]
fn test_radio_context_from_queue() {
let ctx = RadioContext::from_queue(&[
(Some(1), Some("/a.flac".into())),
(Some(2), Some("/b.flac".into())),
(Some(1), Some("/c.flac".into())),
]);
assert_eq!(ctx.seed_artists.len(), 2);
assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
assert_eq!(ctx.queued_paths.len(), 3);
}
#[test]
fn test_recency_bonus_never_played() {
let db = test_db();
let mut meta = sample_meta("T1", "A1", "Al1");
meta.path = Some("/music/T1.flac".into());
upsert_track(&db.conn, &meta).unwrap();
let track_id: i64 = db
.conn
.query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
.unwrap();
let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
assert!(bonus > 1.0, "never-played should get a bonus");
}
#[test]
fn test_recency_bonus_recently_played() {
let db = test_db();
let mut meta = sample_meta("T1", "A1", "Al1");
meta.path = Some("/music/T1.flac".into());
upsert_track(&db.conn, &meta).unwrap();
let track_id: i64 = db
.conn
.query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
.unwrap();
queries::record_play(
&db.conn,
crate::db::queries::LOCAL_USER,
track_id,
Some(240_000),
)
.unwrap();
let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
assert!(
(bonus - 1.0).abs() < f64::EPSILON,
"recently played should get no bonus"
);
}
#[test]
fn test_weighted_select_empty() {
assert!(weighted_select(&[], 5).is_empty());
}
#[test]
fn test_weighted_select_fewer_than_requested() {
let scored = vec![(1, 0.9), (2, 0.5)];
let picks = weighted_select(&scored, 5);
assert_eq!(picks.len(), 2);
}
#[test]
fn test_signal_overlap_scoring() {
let db = test_db();
let config = RadioConfig::default();
let ctx = RadioContext::default();
let c1 = Candidate {
track_id: 1,
path: None,
year: None,
axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
base_score: 0.5,
};
let c3 = Candidate {
track_id: 2,
path: None,
year: None,
axes: [
SimilarityAxis::ListenBrainz,
SimilarityAxis::MusicBrainz,
SimilarityAxis::GenreEra,
]
.into_iter()
.collect(),
base_score: 0.5,
};
let score1 = compute_score(&db.conn, &c1, &ctx, &config);
let score3 = compute_score(&db.conn, &c3, &ctx, &config);
assert!(
score3 > score1,
"multi-axis candidate should score higher: {} vs {}",
score3,
score1
);
}
#[test]
fn test_pick_tracks_empty_library() {
let db = test_db();
let ctx = RadioContext::from_queue(&[]);
let config = RadioConfig::default();
let picks = pick_tracks(&db.conn, &ctx, None, &config);
assert!(picks.is_empty());
}
#[test]
fn test_pick_tracks_with_library() {
let db = test_db();
for i in 0..20 {
let mut meta = sample_meta(
&format!("Track{}", i),
&format!("Artist{}", i % 5),
&format!("Album{}", i % 3),
);
meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
meta.track_number = Some(i);
upsert_track(&db.conn, &meta).unwrap();
}
let artist_id: i64 = db
.conn
.query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
.unwrap();
let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
let config = RadioConfig {
batch_size: 5,
..RadioConfig::default()
};
let picks = pick_tracks(&db.conn, &ctx, None, &config);
assert!(
!picks.is_empty(),
"should pick at least some tracks from a populated library"
);
assert!(picks.len() <= 5);
}
#[test]
fn test_pick_tracks_excludes_history() {
let db = test_db();
for i in 0..5 {
let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
meta.path = Some(format!("/music/T{}.flac", i));
meta.track_number = Some(i);
upsert_track(&db.conn, &meta).unwrap();
}
let mut ids = Vec::new();
for i in 0..5 {
let id: i64 = db
.conn
.query_row(
"SELECT id FROM tracks WHERE path = ?1",
rusqlite::params![format!("/music/T{}.flac", i)],
|row| row.get(0),
)
.unwrap();
queries::record_play(&db.conn, crate::db::queries::LOCAL_USER, id, Some(240_000))
.unwrap();
ids.push(id);
}
let mut ctx = RadioContext::from_queue(&[]);
ctx.excluded_track_ids = ids.into_iter().collect();
let config = RadioConfig {
batch_size: 5,
..RadioConfig::default()
};
let picks = pick_tracks(&db.conn, &ctx, None, &config);
assert!(
picks.is_empty(),
"all tracks in exclusion window, got picks"
);
}
#[test]
fn test_radio_context_build_with_no_history() {
let db = test_db();
for i in 0..5 {
let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
}
let queue = vec![
(Some(1_i64), Some("/a.flac".to_string())),
(Some(2), Some("/b.flac".to_string())),
];
let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
assert_eq!(ctx.seed_artists.len(), 2);
assert_eq!(ctx.queued_paths.len(), 2);
}
fn library_with_seed_artist(db: &Database) -> i64 {
for i in 0..300 {
let mut meta = sample_meta(&format!("Other{i}"), &format!("Other{}", i % 30), "Mix");
meta.path = Some(format!("/music/other/{i}.flac"));
meta.genre = Some("Pop".into());
upsert_track(&db.conn, &meta).unwrap();
}
for i in 0..3 {
let mut meta = sample_meta(&format!("Seed{i}"), "Seed", "Seeds");
meta.path = Some(format!("/music/seed/{i}.flac"));
meta.genre = Some("IDM".into());
upsert_track(&db.conn, &meta).unwrap();
}
get_or_create_artist(&db.conn, "Seed", None).unwrap()
}
#[test]
fn same_artist_picks_are_by_that_artist() {
let db = test_db();
let seed = library_with_seed_artist(&db);
let ctx = RadioContext::from_queue(&[(Some(seed), None)]);
let mut candidates = Vec::new();
gather_same_artist_candidates(&db.conn, &ctx, &mut candidates);
let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|t| t.artist_id == Some(seed)));
}
#[test]
fn genre_picks_have_the_seed_genre() {
let db = test_db();
let seed = library_with_seed_artist(&db);
let ctx = RadioContext::build(&db.conn, &[(Some(seed), None)], 5, 200);
assert_eq!(ctx.seed_genres, HashSet::from(["idm".to_string()]));
let mut candidates = Vec::new();
gather_genre_era_candidates(&db.conn, &ctx, &mut candidates);
let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|t| t.genre.as_deref() == Some("IDM")));
}
#[test]
fn queued_tracks_are_never_picked() {
let db = test_db();
let seed = library_with_seed_artist(&db);
let mut ctx = RadioContext::from_queue(&[(Some(seed), None)]);
ctx.queued_ids = db
.conn
.prepare("SELECT id FROM tracks")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let picks = pick_tracks(&db.conn, &ctx, None, &RadioConfig::default());
assert!(picks.is_empty());
}
}