Skip to main content

koan_core/
radio.rs

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