Skip to main content

koan_core/
radio.rs

1//! Radio mode: picks tracks from the local library by several similarity axes:
2//! - ListenBrainz similar artists (no API key)
3//! - MusicBrainz relationships (collaborators, band members, associated acts)
4//! - Subsonic getSimilarSongs2 (when remote is configured)
5//! - Genre/era matching from local metadata
6//! - Acoustic similarity from stored feature vectors
7//! - Play history, which favours tracks not played recently
8//!
9//! The seed *drifts* — recent plays are weighted more heavily than the initial track,
10//! so the radio evolves through your library instead of orbiting one point.
11
12use std::collections::{HashMap, HashSet};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use rusqlite::Connection;
16
17use crate::config::RadioConfig;
18use crate::db::queries;
19use crate::remote::client::SubsonicClient;
20use crate::remote::listenbrainz;
21use crate::remote::musicbrainz;
22
23/// Which similarity axis led to a candidate being selected.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum SimilarityAxis {
26    ListenBrainz,
27    MusicBrainz,
28    Subsonic,
29    GenreEra,
30    SameArtist,
31    Random,
32    Acoustic,
33}
34
35/// A candidate track with its scoring breakdown.
36#[derive(Debug)]
37struct Candidate {
38    track_id: i64,
39    path: Option<String>,
40    year: Option<i32>,
41    /// Similarity axes that contributed to this candidate.
42    axes: HashSet<SimilarityAxis>,
43    /// Base similarity score (0.0..1.0).
44    base_score: f64,
45}
46
47/// Context extracted from the current queue and play history to guide radio picks.
48#[derive(Debug, Default)]
49pub struct RadioContext {
50    /// Whether the slow, network-backed signals may run. False when the queue
51    /// is about to run dry and a pick is needed this instant.
52    pub allow_network: bool,
53    /// Artist IDs from the seed window, with recency weight (more recent = higher).
54    pub seed_artists: HashMap<i64, f64>,
55    /// Paths already in the queue (to avoid duplicates).
56    pub queued_paths: HashSet<String>,
57    /// Track ids already in the queue. Left out of every draw, which a path
58    /// cannot do for a remote track: it has none until it is downloaded.
59    pub queued_ids: HashSet<i64>,
60    /// Track IDs in the recent play history exclusion window.
61    pub excluded_track_ids: HashSet<i64>,
62    /// The currently playing track's remote_id (for Subsonic similar songs).
63    pub current_remote_id: Option<String>,
64    /// The currently playing track's artist name (for top songs fallback).
65    pub current_artist_name: Option<String>,
66    /// Genres from the seed window, lowercased.
67    pub seed_genres: HashSet<String>,
68    /// Average year of seed tracks (for era matching).
69    pub seed_avg_year: Option<i32>,
70}
71
72impl RadioContext {
73    /// Build context from queue items and play history.
74    ///
75    /// `queue_items`: (artist_id, path) pairs from the current queue.
76    /// `seed_window`: number of recent tracks to use as seeds.
77    /// `history_window`: number of recent track IDs to exclude.
78    pub fn build(
79        conn: &Connection,
80        queue_items: &[(Option<i64>, Option<String>)],
81        seed_window: usize,
82        history_window: usize,
83    ) -> Self {
84        let mut ctx = Self {
85            // Enrichment on by default; the caller turns it off when it cannot
86            // wait for it.
87            allow_network: true,
88            ..Self::default()
89        };
90
91        // Add queued paths for duplicate prevention.
92        for (_aid, path) in queue_items {
93            if let Some(p) = path {
94                ctx.queued_paths.insert(p.clone());
95            }
96        }
97
98        // Build seed from recent plays (drifting seed).
99        let recent =
100            queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
101        let seed_count = recent.len().max(1) as f64;
102        let mut years: Vec<i32> = Vec::new();
103        let rows: HashMap<i64, queries::TrackRow> = queries::tracks_by_ids(conn, &recent)
104            .unwrap_or_default()
105            .into_iter()
106            .map(|t| (t.id, t))
107            .collect();
108        let dates = album_years(conn, &recent);
109
110        for (i, track_id) in recent.iter().enumerate() {
111            if let Some(track) = rows.get(track_id) {
112                // More recent = higher weight (linear decay).
113                let weight = (seed_count - i as f64) / seed_count;
114                if let Some(aid) = track.artist_id {
115                    let entry = ctx.seed_artists.entry(aid).or_insert(0.0);
116                    *entry = entry.max(weight);
117                }
118                if let Some(ref genre) = track.genre {
119                    ctx.seed_genres.insert(genre.to_lowercase());
120                }
121                if let Some(&year) = dates.get(track_id) {
122                    years.push(year);
123                }
124            }
125        }
126
127        // With no play history, weight the artists in the queue instead.
128        if ctx.seed_artists.is_empty() {
129            for (artist_id, _path) in queue_items {
130                if let Some(aid) = artist_id {
131                    *ctx.seed_artists.entry(*aid).or_default() += 1.0;
132                }
133            }
134            // Normalise.
135            let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
136            for v in ctx.seed_artists.values_mut() {
137                *v /= max;
138            }
139
140            // The queued artists' genres.
141            let artist_ids: Vec<i64> = ctx.seed_artists.keys().copied().collect();
142            ctx.seed_genres.extend(
143                queries::genres_by_artist_ids(conn, &artist_ids)
144                    .unwrap_or_default()
145                    .into_values()
146                    .flatten(),
147            );
148        }
149
150        // Build exclusion window from play history.
151        let excluded = queries::recent_track_ids(conn, queries::LOCAL_USER, history_window)
152            .unwrap_or_default();
153        ctx.excluded_track_ids = excluded.into_iter().collect();
154
155        // Average year of the seed tracks.
156        if !years.is_empty() {
157            ctx.seed_avg_year = Some(years.iter().sum::<i32>() / years.len() as i32);
158        }
159
160        ctx
161    }
162
163    /// What no draw may return: the queue, and what was played recently.
164    fn drawn_out(&self) -> Vec<i64> {
165        self.queued_ids
166            .iter()
167            .chain(&self.excluded_track_ids)
168            .copied()
169            .collect()
170    }
171
172    /// Context from queue items alone, with no database behind it.
173    #[cfg(test)]
174    pub fn from_queue(items: &[(Option<i64>, Option<String>)]) -> Self {
175        let mut ctx = Self::default();
176        for (artist_id, path) in items {
177            if let Some(aid) = artist_id {
178                *ctx.seed_artists.entry(*aid).or_default() += 1.0;
179            }
180            if let Some(p) = path {
181                ctx.queued_paths.insert(p.clone());
182            }
183        }
184        // Normalise.
185        let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
186        if max > 0.0 {
187            for v in ctx.seed_artists.values_mut() {
188                *v /= max;
189            }
190        }
191        ctx
192    }
193}
194
195/// Pick tracks for radio mode. Returns track IDs to enqueue.
196///
197/// Multi-signal strategy with fallback chain:
198/// 1. ListenBrainz similar artists -> local tracks
199/// 2. MusicBrainz relationships -> local tracks by collaborators/associated acts
200/// 3. Subsonic getSimilarSongs2 (if remote configured)
201/// 4. Genre + era match -> local tracks with matching tags from similar decade
202/// 5. Same-artist fallback
203/// 6. Acoustic similarity (vector KNN)
204/// 7. Random from library, the last resort
205pub fn pick_tracks(
206    conn: &Connection,
207    ctx: &RadioContext,
208    client: Option<&SubsonicClient>,
209    config: &RadioConfig,
210) -> Vec<i64> {
211    let count = config.batch_size;
212    let mut candidates: Vec<Candidate> = Vec::new();
213
214    log::info!(
215        "radio: picking {} tracks (seed: {} artists, {} genres, {} excluded, remote_id={}, artist={})",
216        count,
217        ctx.seed_artists.len(),
218        ctx.seed_genres.len(),
219        ctx.excluded_track_ids.len(),
220        ctx.current_remote_id.as_deref().unwrap_or("none"),
221        ctx.current_artist_name.as_deref().unwrap_or("none"),
222    );
223
224    // Signals 1-3 go to the network, and MusicBrainz is held to one request a
225    // second per seed artist, so they run only for a caller that can wait for
226    // them. The local signals are a database read.
227    if ctx.allow_network {
228        // --- Signal 1: ListenBrainz similar artists ---
229        gather_listenbrainz_candidates(conn, ctx, &mut candidates);
230
231        // --- Signal 2: MusicBrainz relationships ---
232        gather_musicbrainz_candidates(conn, ctx, &mut candidates);
233
234        // --- Signal 3: Subsonic similar songs ---
235        if let Some(client) = client {
236            gather_subsonic_candidates(conn, ctx, client, &mut candidates);
237        }
238    }
239
240    // --- Signal 4: Genre + era match ---
241    gather_genre_era_candidates(conn, ctx, &mut candidates);
242
243    // --- Signal 5: Same-artist tracks ---
244    gather_same_artist_candidates(conn, ctx, &mut candidates);
245
246    // --- Signal 6: Acoustic similarity (vector KNN) ---
247    gather_acoustic_candidates(conn, config.seed_window, &mut candidates);
248
249    // --- Signal 7: Random library tracks ---
250    gather_random_candidates(conn, ctx, &mut candidates);
251
252    log::info!("radio: {} raw candidates before scoring", candidates.len());
253
254    // Deduplicate by track_id, merging axes.
255    let mut deduped: HashMap<i64, Candidate> = HashMap::new();
256    for c in candidates {
257        let entry = deduped.entry(c.track_id).or_insert_with(|| Candidate {
258            track_id: c.track_id,
259            path: c.path.clone(),
260            year: c.year,
261            axes: HashSet::new(),
262            base_score: 0.0,
263        });
264        entry.axes.extend(c.axes.iter());
265        entry.base_score = entry.base_score.max(c.base_score);
266    }
267
268    // Filter out excluded tracks and already-queued.
269    let mut scored: Vec<(i64, f64)> = deduped
270        .into_values()
271        .filter(|c| !ctx.excluded_track_ids.contains(&c.track_id))
272        .filter(|c| {
273            c.path
274                .as_ref()
275                .is_none_or(|p| !ctx.queued_paths.contains(p))
276        })
277        .map(|c| {
278            let score = compute_score(conn, &c, ctx, config);
279            (c.track_id, score)
280        })
281        .collect();
282
283    // Sort by score descending.
284    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
285
286    // Weighted random selection from top candidates for variety.
287    let picks = weighted_select(&scored, count);
288
289    log::info!("radio: picked {} tracks", picks.len());
290    picks
291}
292
293/// Compute final score for a candidate.
294fn compute_score(
295    conn: &Connection,
296    candidate: &Candidate,
297    ctx: &RadioContext,
298    config: &RadioConfig,
299) -> f64 {
300    let base = candidate.base_score;
301
302    // Signal overlap bonus: tracks matching on 2+ axes score higher.
303    let overlap_bonus = match candidate.axes.len() {
304        0 | 1 => 1.0,
305        2 => 1.5,
306        3 => 2.0,
307        _ => 2.5,
308    };
309
310    // Recency bonus: boost tracks that haven't been played recently or ever.
311    let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
312
313    // Era proximity bonus (if we have year data).
314    let era_bonus = if let (Some(track_year), Some(seed_year)) = (candidate.year, ctx.seed_avg_year)
315    {
316        let diff = (track_year - seed_year).unsigned_abs();
317        if diff <= 5 {
318            1.3
319        } else if diff <= 10 {
320            1.1
321        } else {
322            1.0
323        }
324    } else {
325        1.0
326    };
327
328    base * overlap_bonus * recency_bonus * era_bonus
329}
330
331/// Compute recency bonus for a track. Higher = more desirable.
332/// Never-played tracks get the highest bonus.
333fn compute_recency_bonus(conn: &Connection, track_id: i64, discovery_weight: f64) -> f64 {
334    let last_played = queries::last_played_at(conn, queries::LOCAL_USER, track_id).unwrap_or(None);
335    match last_played {
336        None => {
337            // Never played — big bonus, scaled by discovery_weight.
338            1.0 + discovery_weight * 2.0
339        }
340        Some(ts) => {
341            let now = SystemTime::now()
342                .duration_since(UNIX_EPOCH)
343                .unwrap_or_default()
344                .as_secs() as i64;
345            let days_ago = (now - ts) / 86400;
346            if days_ago > 180 {
347                1.0 + discovery_weight * 1.5 // Not heard in six months.
348            } else if days_ago > 30 {
349                1.0 + discovery_weight * 0.8
350            } else if days_ago > 7 {
351                1.0 + discovery_weight * 0.3
352            } else {
353                1.0 // Recently played — no bonus.
354            }
355        }
356    }
357}
358
359/// Weighted random selection from scored candidates.
360/// Takes the top N*3 candidates and selects N with probability proportional to score.
361fn weighted_select(scored: &[(i64, f64)], count: usize) -> Vec<i64> {
362    if scored.is_empty() {
363        return vec![];
364    }
365
366    let pool_size = (count * 3).min(scored.len());
367    let pool = &scored[..pool_size];
368
369    // Simple selection from the top-scored pool.
370    let mut selected = Vec::new();
371    let mut used = HashSet::new();
372
373    for (id, _score) in pool {
374        if selected.len() >= count {
375            break;
376        }
377        if used.insert(*id) {
378            selected.push(*id);
379        }
380    }
381
382    selected
383}
384
385// --- Signal gatherers ---
386
387fn gather_listenbrainz_candidates(
388    conn: &Connection,
389    ctx: &RadioContext,
390    candidates: &mut Vec<Candidate>,
391) {
392    let http = reqwest::blocking::Client::new();
393
394    for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
395        // Get artist MBID from our DB.
396        let mbid: Option<String> = conn
397            .query_row(
398                "SELECT mbid FROM artists WHERE id = ?1",
399                rusqlite::params![artist_id],
400                |row| row.get(0),
401            )
402            .ok()
403            .flatten();
404
405        let mbid = match mbid {
406            Some(m) if !m.is_empty() => m,
407            _ => {
408                // Try to look up MBID via MusicBrainz search.
409                let artist_name: Option<String> = conn
410                    .query_row(
411                        "SELECT name FROM artists WHERE id = ?1",
412                        rusqlite::params![artist_id],
413                        |row| row.get(0),
414                    )
415                    .ok();
416                if let Some(name) = artist_name {
417                    match musicbrainz::lookup_artist_mbid(&http, &name) {
418                        Ok(Some(mbid)) => {
419                            // Cache the MBID.
420                            let _ = conn.execute(
421                                "UPDATE artists SET mbid = ?1 WHERE id = ?2",
422                                rusqlite::params![mbid, artist_id],
423                            );
424                            mbid
425                        }
426                        _ => continue,
427                    }
428                } else {
429                    continue;
430                }
431            }
432        };
433
434        // Check if we have fresh ListenBrainz data cached.
435        if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
436            .unwrap_or(false)
437        {
438            // Use cached data.
439            add_cached_similar_candidates(
440                conn,
441                ctx,
442                artist_id,
443                weight,
444                SimilarityAxis::ListenBrainz,
445                candidates,
446            );
447            continue;
448        }
449
450        // Fetch from API.
451        match listenbrainz::get_similar_artists(&http, &mbid, 20) {
452            Ok(similar) => {
453                log::info!(
454                    "radio: listenbrainz returned {} similar for artist_id={}",
455                    similar.len(),
456                    artist_id
457                );
458                // Match to local artists and cache.
459                let mut pairs: Vec<(i64, f64)> = Vec::new();
460                for sa in &similar {
461                    // Try to find by MBID first, then by name.
462                    let local_id: Option<i64> = conn
463                        .query_row(
464                            "SELECT id FROM artists WHERE mbid = ?1",
465                            rusqlite::params![sa.mbid],
466                            |row| row.get(0),
467                        )
468                        .ok()
469                        .or_else(|| {
470                            conn.query_row(
471                                "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
472                                rusqlite::params![sa.name],
473                                |row| row.get(0),
474                            )
475                            .ok()
476                        });
477
478                    if let Some(local_id) = local_id
479                        && local_id != artist_id
480                    {
481                        pairs.push((local_id, sa.score));
482                    }
483                }
484
485                if !pairs.is_empty() {
486                    let _ = queries::save_similar_artists(conn, artist_id, &pairs, "listenbrainz");
487                }
488
489                // Add candidates from the matched local artists.
490                add_local_artist_candidates(
491                    conn,
492                    ctx,
493                    &pairs,
494                    weight,
495                    SimilarityAxis::ListenBrainz,
496                    candidates,
497                );
498            }
499            Err(e) => {
500                log::debug!(
501                    "radio: listenbrainz failed for artist_id={}: {}",
502                    artist_id,
503                    e
504                );
505                // Fall through — other signals will pick up the slack.
506            }
507        }
508    }
509}
510
511fn gather_musicbrainz_candidates(
512    conn: &Connection,
513    ctx: &RadioContext,
514    candidates: &mut Vec<Candidate>,
515) {
516    let http = musicbrainz::default_client();
517
518    for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
519        // Check cache first.
520        if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("musicbrainz"))
521            .unwrap_or(false)
522        {
523            add_cached_similar_candidates(
524                conn,
525                ctx,
526                artist_id,
527                weight,
528                SimilarityAxis::MusicBrainz,
529                candidates,
530            );
531            continue;
532        }
533
534        let mbid: Option<String> = conn
535            .query_row(
536                "SELECT mbid FROM artists WHERE id = ?1",
537                rusqlite::params![artist_id],
538                |row| row.get(0),
539            )
540            .ok()
541            .flatten();
542
543        let Some(mbid) = mbid.filter(|m| !m.is_empty()) else {
544            continue;
545        };
546
547        match musicbrainz::get_artist_relations(&http, &mbid) {
548            Ok(relations) => {
549                log::info!(
550                    "radio: musicbrainz returned {} relations for artist_id={}",
551                    relations.len(),
552                    artist_id
553                );
554
555                let mut pairs: Vec<(i64, f64)> = Vec::new();
556
557                for rel in &relations {
558                    let local_id: Option<i64> = conn
559                        .query_row(
560                            "SELECT id FROM artists WHERE mbid = ?1",
561                            rusqlite::params![rel.mbid],
562                            |row| row.get(0),
563                        )
564                        .ok()
565                        .or_else(|| {
566                            conn.query_row(
567                                "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
568                                rusqlite::params![rel.name],
569                                |row| row.get(0),
570                            )
571                            .ok()
572                        });
573
574                    if let Some(local_id) = local_id
575                        && local_id != artist_id
576                    {
577                        // Score by relationship type.
578                        let score = match rel.category {
579                            musicbrainz::RelationCategory::Member => 0.8,
580                            musicbrainz::RelationCategory::Collaborator => 0.7,
581                            musicbrainz::RelationCategory::Associated => 0.5,
582                        };
583                        pairs.push((local_id, score));
584                    }
585                }
586
587                if !pairs.is_empty() {
588                    let _ = queries::save_similar_artists_with_rel(
589                        conn,
590                        artist_id,
591                        &pairs,
592                        "musicbrainz",
593                        "collaborator",
594                    );
595                }
596
597                add_local_artist_candidates(
598                    conn,
599                    ctx,
600                    &pairs,
601                    weight,
602                    SimilarityAxis::MusicBrainz,
603                    candidates,
604                );
605            }
606            Err(e) => {
607                log::debug!(
608                    "radio: musicbrainz relations failed for artist_id={}: {}",
609                    artist_id,
610                    e
611                );
612            }
613        }
614
615        // Rate limit: sleep 1s between MusicBrainz requests.
616        std::thread::sleep(std::time::Duration::from_secs(1));
617    }
618}
619
620fn gather_subsonic_candidates(
621    conn: &Connection,
622    ctx: &RadioContext,
623    client: &SubsonicClient,
624    candidates: &mut Vec<Candidate>,
625) {
626    if let Some(ref remote_id) = ctx.current_remote_id {
627        match client.get_similar_songs(remote_id, 30) {
628            Ok(songs) => {
629                log::info!("radio: subsonic returned {} similar songs", songs.len());
630                for (i, song) in songs.iter().enumerate() {
631                    if let Some(track_id) = resolve_subsonic_song_to_track(conn, song) {
632                        let score = (songs.len() as f64 - i as f64) / songs.len() as f64;
633                        let track = queries::get_track_row(conn, track_id).ok().flatten();
634                        candidates.push(Candidate {
635                            track_id,
636                            path: track.as_ref().and_then(|t| t.path.clone()),
637                            year: None,
638                            axes: [SimilarityAxis::Subsonic].into_iter().collect(),
639                            base_score: score * 0.9,
640                        });
641                    }
642                }
643
644                // Cache artist relationships from subsonic results.
645                cache_subsonic_artist_relationships(conn, ctx, &songs);
646            }
647            Err(e) => {
648                log::debug!("radio: subsonic similar songs failed: {}", e);
649            }
650        }
651    }
652}
653
654fn gather_genre_era_candidates(
655    conn: &Connection,
656    ctx: &RadioContext,
657    candidates: &mut Vec<Candidate>,
658) {
659    if ctx.seed_genres.is_empty() {
660        return;
661    }
662
663    // Drawn by genre, ten from each of three of them, so every candidate here
664    // has one of the seed's genres.
665    let exclude = ctx.drawn_out();
666    let mut tracks = Vec::new();
667    for genre in ctx.seed_genres.iter().take(3) {
668        let filter = queries::RandomFilter {
669            genre: Some(genre),
670            exclude: &exclude,
671            ..Default::default()
672        };
673        match queries::random_tracks_where(conn, 10, &filter) {
674            Ok(drawn) => tracks.extend(drawn),
675            Err(e) => log::debug!("radio: genre/era query failed: {}", e),
676        }
677    }
678    let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
679    let years = album_years(conn, &ids);
680
681    for track in tracks {
682        let year = years.get(&track.id).copied();
683
684        // Score higher if both genre AND era match.
685        let genre_match = track
686            .genre
687            .as_ref()
688            .is_some_and(|g| ctx.seed_genres.contains(&g.to_lowercase()));
689        let era_match = match (year, ctx.seed_avg_year) {
690            (Some(y), Some(sy)) => (y as i64 - sy as i64).unsigned_abs() <= 10,
691            _ => false,
692        };
693
694        let base_score = match (genre_match, era_match) {
695            (true, true) => 0.6,
696            (true, false) => 0.3,
697            (false, true) => 0.2,
698            (false, false) => 0.1,
699        };
700
701        candidates.push(Candidate {
702            track_id: track.id,
703            path: track.path.clone(),
704            year,
705            axes: [SimilarityAxis::GenreEra].into_iter().collect(),
706            base_score,
707        });
708    }
709}
710
711fn gather_same_artist_candidates(
712    conn: &Connection,
713    ctx: &RadioContext,
714    candidates: &mut Vec<Candidate>,
715) {
716    // The five most heavily weighted seed artists, three tracks each, drawn
717    // through the artist index.
718    let mut artists: Vec<(i64, f64)> = ctx.seed_artists.iter().map(|(&a, &w)| (a, w)).collect();
719    artists.sort_by(|a, b| b.1.total_cmp(&a.1));
720    let exclude = ctx.drawn_out();
721    for (artist_id, weight) in artists.into_iter().take(5) {
722        let filter = queries::RandomFilter {
723            artist_id: Some(artist_id),
724            exclude: &exclude,
725            ..Default::default()
726        };
727        match queries::random_tracks_where(conn, 3, &filter) {
728            Ok(tracks) => {
729                for track in tracks {
730                    candidates.push(Candidate {
731                        track_id: track.id,
732                        path: track.path.clone(),
733                        year: None,
734                        axes: [SimilarityAxis::SameArtist].into_iter().collect(),
735                        base_score: weight * 0.4, // Lower base — same-artist is the fallback.
736                    });
737                }
738            }
739            Err(e) => {
740                log::debug!("radio: same-artist query failed: {}", e);
741            }
742        }
743    }
744}
745
746fn gather_acoustic_candidates(
747    conn: &Connection,
748    seed_window: usize,
749    candidates: &mut Vec<Candidate>,
750) {
751    // The same seeds that drive seed_artists — one window, one answer.
752    let seed_ids =
753        queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
754    let mut seed_embeddings = Vec::new();
755    for tid in &seed_ids {
756        if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
757            seed_embeddings.push(emb);
758        }
759    }
760
761    if seed_embeddings.is_empty() {
762        return;
763    }
764
765    let centroid = crate::index::features::centroid(&seed_embeddings);
766    let knn_result = queries::find_similar_to_vector(conn, &centroid, 30, None);
767    match knn_result {
768        Ok(ref results) => {
769            let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
770            let mut added = 0;
771            for &(track_id, dist) in results {
772                // Skip seed tracks themselves.
773                if seed_ids.contains(&track_id) {
774                    continue;
775                }
776                // Score: inverse of normalised distance. Closer = higher score.
777                let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
778                let track = queries::get_track_row(conn, track_id).ok().flatten();
779                candidates.push(Candidate {
780                    track_id,
781                    path: track.as_ref().and_then(|t| t.path.clone()),
782                    year: None,
783                    axes: [SimilarityAxis::Acoustic].into_iter().collect(),
784                    base_score: score,
785                });
786                added += 1;
787            }
788            log::info!(
789                "radio: acoustic signal added {} candidates from {} seed vectors",
790                added,
791                seed_embeddings.len()
792            );
793        }
794        Err(e) => {
795            log::debug!("radio: acoustic similarity query failed: {}", e);
796        }
797    }
798}
799
800fn gather_random_candidates(
801    conn: &Connection,
802    ctx: &RadioContext,
803    candidates: &mut Vec<Candidate>,
804) {
805    let exclude = ctx.drawn_out();
806    let filter = queries::RandomFilter {
807        exclude: &exclude,
808        ..Default::default()
809    };
810    match queries::random_tracks_where(conn, 10, &filter) {
811        Ok(tracks) => {
812            for track in tracks {
813                candidates.push(Candidate {
814                    track_id: track.id,
815                    path: track.path.clone(),
816                    year: None,
817                    axes: [SimilarityAxis::Random].into_iter().collect(),
818                    base_score: 0.05, // Last resort: any track beats silence.
819                });
820            }
821        }
822        Err(e) => {
823            log::debug!("radio: random fallback failed: {}", e);
824        }
825    }
826}
827
828// --- Helpers ---
829
830/// The release year of each track's album, for the tracks that have one.
831fn album_years(conn: &Connection, track_ids: &[i64]) -> HashMap<i64, i32> {
832    queries::queue_item_extras(conn, track_ids)
833        .unwrap_or_default()
834        .into_iter()
835        .filter_map(|(id, e)| {
836            let year = crate::helpers::year_of(e.album_date.as_deref()?)?;
837            Some((id, year.parse().ok()?))
838        })
839        .collect()
840}
841
842/// Add candidates from cached similar artist data.
843fn add_cached_similar_candidates(
844    conn: &Connection,
845    ctx: &RadioContext,
846    artist_id: i64,
847    seed_weight: f64,
848    axis: SimilarityAxis,
849    candidates: &mut Vec<Candidate>,
850) {
851    if let Ok(similar) = queries::get_similar_artists(conn, artist_id) {
852        let pairs: Vec<(i64, f64)> = similar.into_iter().map(|(a, s)| (a.id, s)).collect();
853        add_local_artist_candidates(conn, ctx, &pairs, seed_weight, axis, candidates);
854    }
855}
856
857/// Add candidates from a list of (artist_id, similarity_score) pairs.
858fn add_local_artist_candidates(
859    conn: &Connection,
860    ctx: &RadioContext,
861    pairs: &[(i64, f64)],
862    seed_weight: f64,
863    axis: SimilarityAxis,
864    candidates: &mut Vec<Candidate>,
865) {
866    let exclude = ctx.drawn_out();
867    for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
868        let filter = queries::RandomFilter {
869            artist_id: Some(similar_artist_id),
870            exclude: &exclude,
871            ..Default::default()
872        };
873        if let Ok(tracks) = queries::random_tracks_where(conn, 3, &filter) {
874            for track in tracks {
875                candidates.push(Candidate {
876                    track_id: track.id,
877                    path: track.path.clone(),
878                    year: None,
879                    axes: [axis].into_iter().collect(),
880                    base_score: sim_score * seed_weight * 0.8,
881                });
882            }
883        }
884    }
885}
886
887/// Resolve a SubsonicSong to a local track ID by remote_id.
888fn resolve_subsonic_song_to_track(
889    conn: &Connection,
890    song: &crate::remote::client::SubsonicSong,
891) -> Option<i64> {
892    conn.query_row(
893        "SELECT id FROM tracks WHERE remote_id = ?1",
894        rusqlite::params![song.id],
895        |row| row.get::<_, i64>(0),
896    )
897    .ok()
898}
899
900/// Extract and cache artist relationships from Subsonic similar songs response.
901fn cache_subsonic_artist_relationships(
902    conn: &Connection,
903    ctx: &RadioContext,
904    songs: &[crate::remote::client::SubsonicSong],
905) {
906    for &artist_id in ctx.seed_artists.keys().take(5) {
907        let mut similar: HashMap<i64, f64> = HashMap::new();
908        let total = songs.len() as f64;
909
910        for (i, song) in songs.iter().enumerate() {
911            if let Some(ref song_artist_id) = song.artist_id {
912                let local_artist_id: Option<i64> = conn
913                    .query_row(
914                        "SELECT id FROM artists WHERE remote_id = ?1",
915                        rusqlite::params![song_artist_id],
916                        |row| row.get(0),
917                    )
918                    .ok();
919
920                if let Some(local_id) = local_artist_id
921                    && local_id != artist_id
922                {
923                    let score = (total - i as f64) / total;
924                    let entry = similar.entry(local_id).or_insert(0.0);
925                    *entry = entry.max(score);
926                }
927            }
928        }
929
930        if !similar.is_empty() {
931            let pairs: Vec<(i64, f64)> = similar.into_iter().collect();
932            let _ = queries::save_similar_artists(conn, artist_id, &pairs, "subsonic");
933        }
934    }
935}
936
937// ---------------------------------------------------------------------------
938// Auto-queue
939// ---------------------------------------------------------------------------
940
941/// Keep the queue topped up while radio mode is on.
942///
943/// Radio mode is a flag on `SharedPlayerState`. Owning the loop here means
944/// every front end that sets it gets the same behaviour instead of
945/// reimplementing it.
946///
947/// Runs on its own thread and exits when the player goes away.
948pub fn spawn_autoqueue(
949    state: std::sync::Arc<crate::player::state::SharedPlayerState>,
950    tx: crossbeam_channel::Sender<crate::player::commands::PlayerCommand>,
951) {
952    use crate::player::commands::PlayerCommand;
953    use crate::player::state::{QueueEntryStatus, QueueItemId};
954
955    std::thread::Builder::new()
956        .name("koan-radio".into())
957        .spawn(move || {
958            use std::time::{Duration, Instant};
959
960            // Read when radio is switched on, and again a minute later if it
961            // is still on: settings changed while it plays still apply, and a
962            // two-second loop does not parse two files every pass.
963            let mut cfg: Option<(crate::config::Config, Instant)> = None;
964            // A top-up that found nothing, and the queue it found nothing for.
965            // Nothing is tried again until the queue moves or a minute passes,
966            // either of which may give the picker something new to go on.
967            let mut fruitless: Option<(u64, Instant)> = None;
968            loop {
969                std::thread::sleep(Duration::from_secs(2));
970
971                if !state.radio_mode() || state.cursor().is_none() {
972                    cfg = None;
973                    continue;
974                }
975                log::debug!("radio: awake, cursor set");
976
977                if fruitless.is_some_and(|(version, at)| {
978                    version == state.playlist_version() && at.elapsed() < Duration::from_secs(60)
979                }) {
980                    continue;
981                }
982
983                if cfg
984                    .as_ref()
985                    .is_none_or(|(_, read)| read.elapsed() > Duration::from_secs(60))
986                {
987                    cfg = Some((
988                        crate::config::Config::load().unwrap_or_default(),
989                        Instant::now(),
990                    ));
991                }
992                let Some((cfg, _)) = &cfg else { continue };
993                let snapshot = state.derive_visible_queue();
994                let Some(playing) = snapshot
995                    .entries
996                    .iter()
997                    .position(|e| e.status == QueueEntryStatus::Playing)
998                else {
999                    log::debug!("radio: nothing is playing, waiting");
1000                    continue;
1001                };
1002                let remaining = snapshot
1003                    .entries
1004                    .iter()
1005                    .skip(playing + 1)
1006                    .filter(|e| e.status == QueueEntryStatus::Queued)
1007                    .count();
1008                if remaining > cfg.radio.lookahead {
1009                    continue;
1010                }
1011                log::info!(
1012                    "radio: {} queued after the cursor, topping up to {}",
1013                    remaining,
1014                    cfg.radio.lookahead
1015                );
1016
1017                let Ok(db) = crate::db::pool::shared().get() else {
1018                    continue;
1019                };
1020                let version = state.playlist_version();
1021                let (items, cursor) = state.snapshot_playlist();
1022
1023                // Each item's row, read once for the whole queue. An item
1024                // without a database id was played from a file, and is looked
1025                // up by its path.
1026                let item_ids: Vec<Option<i64>> = items
1027                    .iter()
1028                    .map(|item| {
1029                        item.db_id.or_else(|| {
1030                            let path = item.path.to_str()?;
1031                            queries::track_id_by_path(&db.conn, path).ok().flatten()
1032                        })
1033                    })
1034                    .collect();
1035                let ids: Vec<i64> = item_ids.iter().flatten().copied().collect();
1036                let queue_rows: HashMap<i64, queries::TrackRow> =
1037                    queries::tracks_by_ids(&db.conn, &ids)
1038                        .unwrap_or_default()
1039                        .into_iter()
1040                        .map(|t| (t.id, t))
1041                        .collect();
1042                let row_of = |i: usize| item_ids[i].and_then(|id| queue_rows.get(&id));
1043
1044                // The seed drifts: recent items weigh more than the first thing
1045                // queued, so the radio moves through the library rather than
1046                // orbiting one track.
1047                let context: Vec<(Option<i64>, Option<String>)> = items
1048                    .iter()
1049                    .enumerate()
1050                    .map(|(i, item)| {
1051                        (
1052                            row_of(i).and_then(|t| t.artist_id),
1053                            Some(item.path.to_string_lossy().into_owned()),
1054                        )
1055                    })
1056                    .collect();
1057
1058                let mut ctx = RadioContext::build(
1059                    &db.conn,
1060                    &context,
1061                    cfg.radio.seed_window,
1062                    cfg.radio.history_window,
1063                );
1064                ctx.queued_ids = queue_rows.keys().copied().collect();
1065                if let Some(current) = cursor.and_then(|cid| items.iter().position(|i| i.id == cid))
1066                    && let Some(row) = row_of(current)
1067                {
1068                    ctx.current_remote_id = row.remote_id.clone();
1069                    ctx.current_artist_name = Some(row.artist_name.clone());
1070                }
1071                // Local signals only.
1072                //
1073                // ListenBrainz and MusicBrainz each rate-limit to one request a
1074                // second per seed artist, and both are called in line before a
1075                // single pick comes back — so a queue that needs a track in the
1076                // next few seconds gets one long after the music has stopped.
1077                // Genre/era, same-artist, acoustic similarity and plain random
1078                // are all database reads and answer immediately, which is worth
1079                // more than a better-chosen track that arrives too late.
1080
1081                ctx.allow_network = false;
1082
1083                // No client, and no similar-artist prefetch: both are HTTP
1084                // round trips in front of a pick that is needed now. Cached
1085                // similar artists are still read from the database by the local
1086                // signals; nothing is fetched.
1087                let picks = pick_tracks(&db.conn, &ctx, None, &cfg.radio);
1088                if picks.is_empty() {
1089                    log::warn!("radio: the picker returned nothing for this seed");
1090                    fruitless = Some((version, Instant::now()));
1091                    continue;
1092                }
1093
1094                // Never queue something already in the queue: the picker scores
1095                // by similarity and has no idea what is sitting below the
1096                // cursor.
1097                let queued: HashSet<String> = items
1098                    .iter()
1099                    .map(|i| i.path.to_string_lossy().into_owned())
1100                    .collect();
1101                let rows: Vec<_> = queries::tracks_by_ids(&db.conn, &picks)
1102                    .unwrap_or_default()
1103                    .into_iter()
1104                    .filter(|row| {
1105                        row.path
1106                            .as_deref()
1107                            .or(row.cached_path.as_deref())
1108                            .is_none_or(|p| !queued.contains(p))
1109                    })
1110                    .collect();
1111                if rows.is_empty() {
1112                    log::warn!("radio: every pick was already in the queue");
1113                    fruitless = Some((version, Instant::now()));
1114                    continue;
1115                }
1116                fruitless = None;
1117
1118                let new_items = crate::helpers::playlist_items_for_tracks(&db, &rows);
1119                let pending: Vec<(i64, QueueItemId)> = new_items
1120                    .iter()
1121                    .filter(|i| matches!(i.state, crate::player::state::ItemState::Pending))
1122                    .filter_map(|i| i.db_id.map(|id| (id, i.id)))
1123                    .collect();
1124
1125                log::info!("radio: queueing {} tracks", new_items.len());
1126                if tx.send(PlayerCommand::AddToPlaylist(new_items)).is_err() {
1127                    return; // Player gone; so is the app.
1128                }
1129                if !pending.is_empty() {
1130                    crate::helpers::spawn_downloads(pending, tx.clone(), state.clone());
1131                }
1132            }
1133        })
1134        .ok();
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::db::connection::Database;
1141    use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
1142
1143    fn test_db() -> Database {
1144        let conn = rusqlite::Connection::open_in_memory().unwrap();
1145        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1146        crate::db::schema::create_tables(&conn).unwrap();
1147        Database { conn }
1148    }
1149
1150    #[test]
1151    fn test_radio_context_from_queue() {
1152        let ctx = RadioContext::from_queue(&[
1153            (Some(1), Some("/a.flac".into())),
1154            (Some(2), Some("/b.flac".into())),
1155            (Some(1), Some("/c.flac".into())),
1156        ]);
1157        assert_eq!(ctx.seed_artists.len(), 2);
1158        assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
1159        assert_eq!(ctx.queued_paths.len(), 3);
1160    }
1161
1162    #[test]
1163    fn test_recency_bonus_never_played() {
1164        let db = test_db();
1165        let mut meta = sample_meta("T1", "A1", "Al1");
1166        meta.path = Some("/music/T1.flac".into());
1167        upsert_track(&db.conn, &meta).unwrap();
1168
1169        let track_id: i64 = db
1170            .conn
1171            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1172            .unwrap();
1173
1174        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1175        assert!(bonus > 1.0, "never-played should get a bonus");
1176    }
1177
1178    #[test]
1179    fn test_recency_bonus_recently_played() {
1180        let db = test_db();
1181        let mut meta = sample_meta("T1", "A1", "Al1");
1182        meta.path = Some("/music/T1.flac".into());
1183        upsert_track(&db.conn, &meta).unwrap();
1184
1185        let track_id: i64 = db
1186            .conn
1187            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1188            .unwrap();
1189
1190        queries::record_play(
1191            &db.conn,
1192            crate::db::queries::LOCAL_USER,
1193            track_id,
1194            Some(240_000),
1195        )
1196        .unwrap();
1197
1198        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1199        assert!(
1200            (bonus - 1.0).abs() < f64::EPSILON,
1201            "recently played should get no bonus"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_weighted_select_empty() {
1207        assert!(weighted_select(&[], 5).is_empty());
1208    }
1209
1210    #[test]
1211    fn test_weighted_select_fewer_than_requested() {
1212        let scored = vec![(1, 0.9), (2, 0.5)];
1213        let picks = weighted_select(&scored, 5);
1214        assert_eq!(picks.len(), 2);
1215    }
1216
1217    #[test]
1218    fn test_signal_overlap_scoring() {
1219        let db = test_db();
1220        let config = RadioConfig::default();
1221        let ctx = RadioContext::default();
1222
1223        // Candidate with 1 axis.
1224        let c1 = Candidate {
1225            track_id: 1,
1226            path: None,
1227            year: None,
1228            axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
1229            base_score: 0.5,
1230        };
1231
1232        // Candidate with 3 axes.
1233        let c3 = Candidate {
1234            track_id: 2,
1235            path: None,
1236            year: None,
1237            axes: [
1238                SimilarityAxis::ListenBrainz,
1239                SimilarityAxis::MusicBrainz,
1240                SimilarityAxis::GenreEra,
1241            ]
1242            .into_iter()
1243            .collect(),
1244            base_score: 0.5,
1245        };
1246
1247        let score1 = compute_score(&db.conn, &c1, &ctx, &config);
1248        let score3 = compute_score(&db.conn, &c3, &ctx, &config);
1249
1250        assert!(
1251            score3 > score1,
1252            "multi-axis candidate should score higher: {} vs {}",
1253            score3,
1254            score1
1255        );
1256    }
1257
1258    #[test]
1259    fn test_pick_tracks_empty_library() {
1260        let db = test_db();
1261        let ctx = RadioContext::from_queue(&[]);
1262        let config = RadioConfig::default();
1263        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1264        assert!(picks.is_empty());
1265    }
1266
1267    #[test]
1268    fn test_pick_tracks_with_library() {
1269        let db = test_db();
1270
1271        // Populate library.
1272        for i in 0..20 {
1273            let mut meta = sample_meta(
1274                &format!("Track{}", i),
1275                &format!("Artist{}", i % 5),
1276                &format!("Album{}", i % 3),
1277            );
1278            meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
1279            meta.track_number = Some(i);
1280            upsert_track(&db.conn, &meta).unwrap();
1281        }
1282
1283        let artist_id: i64 = db
1284            .conn
1285            .query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
1286            .unwrap();
1287
1288        let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
1289        let config = RadioConfig {
1290            batch_size: 5,
1291            ..RadioConfig::default()
1292        };
1293
1294        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1295        assert!(
1296            !picks.is_empty(),
1297            "should pick at least some tracks from a populated library"
1298        );
1299        assert!(picks.len() <= 5);
1300    }
1301
1302    #[test]
1303    fn test_pick_tracks_excludes_history() {
1304        let db = test_db();
1305
1306        // Insert a few tracks.
1307        for i in 0..5 {
1308            let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
1309            meta.path = Some(format!("/music/T{}.flac", i));
1310            meta.track_number = Some(i);
1311            upsert_track(&db.conn, &meta).unwrap();
1312        }
1313
1314        // Record all as recently played.
1315        let mut ids = Vec::new();
1316        for i in 0..5 {
1317            let id: i64 = db
1318                .conn
1319                .query_row(
1320                    "SELECT id FROM tracks WHERE path = ?1",
1321                    rusqlite::params![format!("/music/T{}.flac", i)],
1322                    |row| row.get(0),
1323                )
1324                .unwrap();
1325            queries::record_play(&db.conn, crate::db::queries::LOCAL_USER, id, Some(240_000))
1326                .unwrap();
1327            ids.push(id);
1328        }
1329
1330        let mut ctx = RadioContext::from_queue(&[]);
1331        ctx.excluded_track_ids = ids.into_iter().collect();
1332
1333        let config = RadioConfig {
1334            batch_size: 5,
1335            ..RadioConfig::default()
1336        };
1337
1338        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1339        // All tracks are excluded, so nothing should be picked.
1340        assert!(
1341            picks.is_empty(),
1342            "all tracks in exclusion window, got picks"
1343        );
1344    }
1345
1346    #[test]
1347    fn test_radio_context_build_with_no_history() {
1348        let db = test_db();
1349
1350        for i in 0..5 {
1351            let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
1352        }
1353
1354        let queue = vec![
1355            (Some(1_i64), Some("/a.flac".to_string())),
1356            (Some(2), Some("/b.flac".to_string())),
1357        ];
1358        let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
1359
1360        // Should fall back to queue weights since no play history.
1361        assert_eq!(ctx.seed_artists.len(), 2);
1362        assert_eq!(ctx.queued_paths.len(), 2);
1363    }
1364
1365    /// Three tracks by one artist among three hundred by others.
1366    fn library_with_seed_artist(db: &Database) -> i64 {
1367        for i in 0..300 {
1368            let mut meta = sample_meta(&format!("Other{i}"), &format!("Other{}", i % 30), "Mix");
1369            meta.path = Some(format!("/music/other/{i}.flac"));
1370            meta.genre = Some("Pop".into());
1371            upsert_track(&db.conn, &meta).unwrap();
1372        }
1373        for i in 0..3 {
1374            let mut meta = sample_meta(&format!("Seed{i}"), "Seed", "Seeds");
1375            meta.path = Some(format!("/music/seed/{i}.flac"));
1376            meta.genre = Some("IDM".into());
1377            upsert_track(&db.conn, &meta).unwrap();
1378        }
1379        get_or_create_artist(&db.conn, "Seed", None).unwrap()
1380    }
1381
1382    #[test]
1383    fn same_artist_picks_are_by_that_artist() {
1384        let db = test_db();
1385        let seed = library_with_seed_artist(&db);
1386        let ctx = RadioContext::from_queue(&[(Some(seed), None)]);
1387
1388        let mut candidates = Vec::new();
1389        gather_same_artist_candidates(&db.conn, &ctx, &mut candidates);
1390
1391        let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
1392        let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
1393        assert_eq!(rows.len(), 3);
1394        assert!(rows.iter().all(|t| t.artist_id == Some(seed)));
1395    }
1396
1397    #[test]
1398    fn genre_picks_have_the_seed_genre() {
1399        let db = test_db();
1400        let seed = library_with_seed_artist(&db);
1401        let ctx = RadioContext::build(&db.conn, &[(Some(seed), None)], 5, 200);
1402        assert_eq!(ctx.seed_genres, HashSet::from(["idm".to_string()]));
1403
1404        let mut candidates = Vec::new();
1405        gather_genre_era_candidates(&db.conn, &ctx, &mut candidates);
1406
1407        let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
1408        let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
1409        assert_eq!(rows.len(), 3);
1410        assert!(rows.iter().all(|t| t.genre.as_deref() == Some("IDM")));
1411    }
1412
1413    #[test]
1414    fn queued_tracks_are_never_picked() {
1415        let db = test_db();
1416        let seed = library_with_seed_artist(&db);
1417        let mut ctx = RadioContext::from_queue(&[(Some(seed), None)]);
1418        ctx.queued_ids = db
1419            .conn
1420            .prepare("SELECT id FROM tracks")
1421            .unwrap()
1422            .query_map([], |r| r.get(0))
1423            .unwrap()
1424            .collect::<Result<_, _>>()
1425            .unwrap();
1426
1427        let picks = pick_tracks(&db.conn, &ctx, None, &RadioConfig::default());
1428        assert!(picks.is_empty());
1429    }
1430}