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, &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    candidates: &mut Vec<Candidate>,
761) {
762    // Collect vectors for recent seed tracks (same ones driving seed_artists).
763    let seed_ids = queries::recent_track_ids(conn, 5).unwrap_or_default();
764    let mut seed_embeddings = Vec::new();
765    for tid in &seed_ids {
766        if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
767            seed_embeddings.push(emb);
768        }
769    }
770
771    if seed_embeddings.is_empty() {
772        return;
773    }
774
775    let centroid = crate::index::features::centroid(&seed_embeddings);
776    let knn_result = queries::find_similar_to_vector(conn, &centroid, 30, None);
777    match knn_result {
778        Ok(ref results) => {
779            let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
780            let mut added = 0;
781            for &(track_id, dist) in results {
782                // Skip seed tracks themselves.
783                if seed_ids.contains(&track_id) {
784                    continue;
785                }
786                // Score: inverse of normalised distance. Closer = higher score.
787                let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
788                let track = queries::get_track_row(conn, track_id).ok().flatten();
789                candidates.push(Candidate {
790                    track_id,
791                    artist_id: track.as_ref().and_then(|t| t.artist_id),
792                    path: track.as_ref().and_then(|t| t.path.clone()),
793                    genre: track.as_ref().and_then(|t| t.genre.clone()),
794                    year: None,
795                    duration_ms: track.as_ref().and_then(|t| t.duration_ms),
796                    axes: [SimilarityAxis::Acoustic].into_iter().collect(),
797                    base_score: score,
798                });
799                added += 1;
800            }
801            log::info!(
802                "radio: acoustic signal added {} candidates from {} seed vectors",
803                added,
804                seed_embeddings.len()
805            );
806        }
807        Err(e) => {
808            log::debug!("radio: acoustic similarity query failed: {}", e);
809        }
810    }
811}
812
813fn gather_random_candidates(
814    conn: &Connection,
815    ctx: &RadioContext,
816    candidates: &mut Vec<Candidate>,
817) {
818    let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
819    match queries::random_tracks_excluding(conn, &exclude, &[], &[], 10) {
820        Ok(tracks) => {
821            for track in tracks {
822                candidates.push(Candidate {
823                    track_id: track.id,
824                    artist_id: track.artist_id,
825                    path: track.path.clone(),
826                    genre: track.genre.clone(),
827                    year: None,
828                    duration_ms: track.duration_ms,
829                    axes: [SimilarityAxis::Random].into_iter().collect(),
830                    base_score: 0.05, // Nuclear fallback — still better than silence.
831                });
832            }
833        }
834        Err(e) => {
835            log::debug!("radio: random fallback failed: {}", e);
836        }
837    }
838}
839
840// --- Helpers ---
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    for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
867        let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
868        if let Ok(tracks) =
869            queries::random_tracks_excluding(conn, &exclude, &[similar_artist_id], &[], 3)
870        {
871            for track in tracks {
872                candidates.push(Candidate {
873                    track_id: track.id,
874                    artist_id: track.artist_id,
875                    path: track.path.clone(),
876                    genre: track.genre.clone(),
877                    year: None,
878                    duration_ms: track.duration_ms,
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/// Populate the similar artists cache for a given artist using Subsonic.
938/// Kept for backward compat with the TUI trigger.
939pub fn fetch_and_cache_similar_artists(
940    conn: &Connection,
941    client: &SubsonicClient,
942    artist_id: i64,
943) -> Result<(), Box<dyn std::error::Error>> {
944    if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("subsonic"))
945        .unwrap_or(false)
946    {
947        return Ok(());
948    }
949
950    let track_remote_id: Option<String> = conn
951        .query_row(
952            "SELECT remote_id FROM tracks WHERE artist_id = ?1 AND remote_id IS NOT NULL LIMIT 1",
953            rusqlite::params![artist_id],
954            |row| row.get(0),
955        )
956        .ok()
957        .flatten();
958
959    let Some(track_remote_id) = track_remote_id else {
960        return Ok(());
961    };
962
963    let songs = client.get_similar_songs(&track_remote_id, 50)?;
964    let mut similar_artists: HashMap<i64, f64> = HashMap::new();
965    let total = songs.len() as f64;
966
967    for (i, song) in songs.iter().enumerate() {
968        if let Some(ref song_artist_id) = song.artist_id {
969            let local_artist_id: Option<i64> = conn
970                .query_row(
971                    "SELECT id FROM artists WHERE remote_id = ?1",
972                    rusqlite::params![song_artist_id],
973                    |row| row.get(0),
974                )
975                .ok();
976
977            if let Some(local_id) = local_artist_id
978                && local_id != artist_id
979            {
980                let score = (total - i as f64) / total;
981                let entry = similar_artists.entry(local_id).or_insert(0.0);
982                *entry = entry.max(score);
983            }
984        }
985    }
986
987    if !similar_artists.is_empty() {
988        let pairs: Vec<(i64, f64)> = similar_artists.into_iter().collect();
989        queries::save_similar_artists(conn, artist_id, &pairs, "subsonic")?;
990    }
991
992    Ok(())
993}
994
995// ---------------------------------------------------------------------------
996// Auto-queue
997// ---------------------------------------------------------------------------
998
999/// Keep the queue topped up while radio mode is on.
1000///
1001/// Radio mode is a flag on `SharedPlayerState`, and for a long time only the
1002/// TUI acted on it — so any other client could switch it on and nothing would
1003/// happen. Owning the loop here means every front end gets the same behaviour
1004/// instead of reimplementing it, and there is one place to fix when it is
1005/// wrong.
1006///
1007/// Runs on its own thread and exits when the player goes away.
1008pub fn spawn_autoqueue(
1009    state: std::sync::Arc<crate::player::state::SharedPlayerState>,
1010    tx: crossbeam_channel::Sender<crate::player::commands::PlayerCommand>,
1011    db_path: std::path::PathBuf,
1012) {
1013    use crate::player::commands::PlayerCommand;
1014    use crate::player::state::{LoadState, QueueEntryStatus, QueueItemId};
1015
1016    std::thread::Builder::new()
1017        .name("koan-radio".into())
1018        .spawn(move || {
1019            loop {
1020                std::thread::sleep(std::time::Duration::from_secs(2));
1021
1022                if !state.radio_mode() || state.cursor().is_none() {
1023                    continue;
1024                }
1025                log::debug!("radio: awake, cursor set");
1026
1027                let cfg = crate::config::Config::load().unwrap_or_default();
1028                let snapshot = state.derive_visible_queue();
1029                let Some(playing) = snapshot
1030                    .entries
1031                    .iter()
1032                    .position(|e| e.status == QueueEntryStatus::Playing)
1033                else {
1034                    log::debug!("radio: nothing is playing, waiting");
1035                    continue;
1036                };
1037                let remaining = snapshot
1038                    .entries
1039                    .iter()
1040                    .skip(playing + 1)
1041                    .filter(|e| e.status == QueueEntryStatus::Queued)
1042                    .count();
1043                if remaining > cfg.radio.lookahead {
1044                    continue;
1045                }
1046                log::info!(
1047                    "radio: {} queued after the cursor, topping up to {}",
1048                    remaining,
1049                    cfg.radio.lookahead
1050                );
1051
1052                let Ok(db) = crate::db::connection::Database::open(&db_path) else {
1053                    continue;
1054                };
1055                let (items, cursor) = state.snapshot_playlist();
1056
1057                // The seed drifts: recent items weigh more than the first thing
1058                // queued, so the radio moves through the library rather than
1059                // orbiting one track.
1060                let context: Vec<(Option<i64>, Option<String>)> = items
1061                    .iter()
1062                    .map(|item| {
1063                        let row = item
1064                            .path
1065                            .to_str()
1066                            .and_then(|p| queries::track_id_by_path(&db.conn, p).ok())
1067                            .flatten()
1068                            .and_then(|id| queries::get_track_row(&db.conn, id).ok())
1069                            .flatten();
1070                        (
1071                            row.as_ref().and_then(|t| t.artist_id),
1072                            Some(item.path.to_string_lossy().into_owned()),
1073                        )
1074                    })
1075                    .collect();
1076
1077                let mut ctx = RadioContext::build(
1078                    &db.conn,
1079                    &context,
1080                    cfg.radio.seed_window,
1081                    cfg.radio.history_window,
1082                );
1083                if let Some(current) = cursor.and_then(|cid| items.iter().find(|i| i.id == cid))
1084                    && let Some(row) = current
1085                        .path
1086                        .to_str()
1087                        .and_then(|p| queries::track_id_by_path(&db.conn, p).ok())
1088                        .flatten()
1089                        .and_then(|id| queries::get_track_row(&db.conn, id).ok())
1090                        .flatten()
1091                {
1092                    ctx.current_remote_id = row.remote_id.clone();
1093                    ctx.current_artist_name = Some(row.artist_name.clone());
1094                }
1095                // Local signals only.
1096                //
1097                // ListenBrainz and MusicBrainz each rate-limit to one request a
1098                // second per seed artist, and both are called in line before a
1099                // single pick comes back — so a queue that needs a track in the
1100                // next few seconds gets one long after the music has stopped.
1101                // Genre/era, same-artist, acoustic similarity and plain random
1102                // are all database reads and answer immediately, which is worth
1103                // more than a better-chosen track that arrives too late.
1104                //
1105                // The network signals stay in `pick_tracks` for whenever they
1106                // can be moved off this path and into a background pass that
1107                // fills the similar-artists cache.
1108                ctx.allow_network = false;
1109
1110                // No client, and no similar-artist prefetch: both are HTTP
1111                // round trips in front of a pick that is needed now. Cached
1112                // similar artists are still read from the database by the local
1113                // signals; only the fetching is gone.
1114                let picks = pick_tracks(&db.conn, &ctx, None, &cfg.radio);
1115                if picks.is_empty() {
1116                    log::warn!("radio: the picker returned nothing for this seed");
1117                    continue;
1118                }
1119
1120                // Never queue something already in the queue: the picker scores
1121                // by similarity and has no idea what is sitting below the
1122                // cursor.
1123                let queued: HashSet<String> = items
1124                    .iter()
1125                    .map(|i| i.path.to_string_lossy().into_owned())
1126                    .collect();
1127                let rows: Vec<_> = queries::tracks_by_ids(&db.conn, &picks)
1128                    .unwrap_or_default()
1129                    .into_iter()
1130                    .filter(|row| {
1131                        row.path
1132                            .as_deref()
1133                            .or(row.cached_path.as_deref())
1134                            .is_none_or(|p| !queued.contains(p))
1135                    })
1136                    .collect();
1137                if rows.is_empty() {
1138                    log::warn!("radio: every pick was already in the queue");
1139                    continue;
1140                }
1141
1142                let new_items = crate::helpers::playlist_items_for_tracks(&db, &rows);
1143                let pending: Vec<(i64, QueueItemId)> = new_items
1144                    .iter()
1145                    .filter(|i| matches!(i.load_state, LoadState::Pending))
1146                    .filter_map(|i| i.db_id.map(|id| (id, i.id)))
1147                    .collect();
1148
1149                log::info!("radio: queueing {} tracks", new_items.len());
1150                if tx.send(PlayerCommand::AddToPlaylist(new_items)).is_err() {
1151                    return; // Player gone; so is the app.
1152                }
1153                if !pending.is_empty() {
1154                    crate::helpers::spawn_downloads(pending, tx.clone(), state.clone());
1155                }
1156            }
1157        })
1158        .ok();
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use crate::db::connection::Database;
1165    use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
1166
1167    fn test_db() -> Database {
1168        let conn = rusqlite::Connection::open_in_memory().unwrap();
1169        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1170        crate::db::schema::create_tables(&conn).unwrap();
1171        Database { conn }
1172    }
1173
1174    #[test]
1175    fn test_radio_context_from_queue() {
1176        let ctx = RadioContext::from_queue(&[
1177            (Some(1), Some("/a.flac".into())),
1178            (Some(2), Some("/b.flac".into())),
1179            (Some(1), Some("/c.flac".into())),
1180        ]);
1181        assert_eq!(ctx.seed_artists.len(), 2);
1182        assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
1183        assert_eq!(ctx.queued_paths.len(), 3);
1184    }
1185
1186    #[test]
1187    fn test_recency_bonus_never_played() {
1188        let db = test_db();
1189        let mut meta = sample_meta("T1", "A1", "Al1");
1190        meta.path = Some("/music/T1.flac".into());
1191        upsert_track(&db.conn, &meta).unwrap();
1192
1193        let track_id: i64 = db
1194            .conn
1195            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1196            .unwrap();
1197
1198        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1199        assert!(bonus > 1.0, "never-played should get a bonus");
1200    }
1201
1202    #[test]
1203    fn test_recency_bonus_recently_played() {
1204        let db = test_db();
1205        let mut meta = sample_meta("T1", "A1", "Al1");
1206        meta.path = Some("/music/T1.flac".into());
1207        upsert_track(&db.conn, &meta).unwrap();
1208
1209        let track_id: i64 = db
1210            .conn
1211            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1212            .unwrap();
1213
1214        queries::record_play(&db.conn, track_id, Some(240_000)).unwrap();
1215
1216        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1217        assert!(
1218            (bonus - 1.0).abs() < f64::EPSILON,
1219            "recently played should get no bonus"
1220        );
1221    }
1222
1223    #[test]
1224    fn test_weighted_select_empty() {
1225        assert!(weighted_select(&[], 5).is_empty());
1226    }
1227
1228    #[test]
1229    fn test_weighted_select_fewer_than_requested() {
1230        let scored = vec![(1, 0.9), (2, 0.5)];
1231        let picks = weighted_select(&scored, 5);
1232        assert_eq!(picks.len(), 2);
1233    }
1234
1235    #[test]
1236    fn test_signal_overlap_scoring() {
1237        let db = test_db();
1238        let config = RadioConfig::default();
1239        let ctx = RadioContext::default();
1240
1241        // Candidate with 1 axis.
1242        let c1 = Candidate {
1243            track_id: 1,
1244            artist_id: None,
1245            path: None,
1246            genre: None,
1247            year: None,
1248            duration_ms: None,
1249            axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
1250            base_score: 0.5,
1251        };
1252
1253        // Candidate with 3 axes.
1254        let c3 = Candidate {
1255            track_id: 2,
1256            artist_id: None,
1257            path: None,
1258            genre: None,
1259            year: None,
1260            duration_ms: None,
1261            axes: [
1262                SimilarityAxis::ListenBrainz,
1263                SimilarityAxis::MusicBrainz,
1264                SimilarityAxis::GenreEra,
1265            ]
1266            .into_iter()
1267            .collect(),
1268            base_score: 0.5,
1269        };
1270
1271        let score1 = compute_score(&db.conn, &c1, &ctx, &config);
1272        let score3 = compute_score(&db.conn, &c3, &ctx, &config);
1273
1274        assert!(
1275            score3 > score1,
1276            "multi-axis candidate should score higher: {} vs {}",
1277            score3,
1278            score1
1279        );
1280    }
1281
1282    #[test]
1283    fn test_pick_tracks_empty_library() {
1284        let db = test_db();
1285        let ctx = RadioContext::from_queue(&[]);
1286        let config = RadioConfig::default();
1287        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1288        assert!(picks.is_empty());
1289    }
1290
1291    #[test]
1292    fn test_pick_tracks_with_library() {
1293        let db = test_db();
1294
1295        // Populate library.
1296        for i in 0..20 {
1297            let mut meta = sample_meta(
1298                &format!("Track{}", i),
1299                &format!("Artist{}", i % 5),
1300                &format!("Album{}", i % 3),
1301            );
1302            meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
1303            meta.track_number = Some(i);
1304            upsert_track(&db.conn, &meta).unwrap();
1305        }
1306
1307        let artist_id: i64 = db
1308            .conn
1309            .query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
1310            .unwrap();
1311
1312        let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
1313        let config = RadioConfig {
1314            batch_size: 5,
1315            ..RadioConfig::default()
1316        };
1317
1318        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1319        assert!(
1320            !picks.is_empty(),
1321            "should pick at least some tracks from a populated library"
1322        );
1323        assert!(picks.len() <= 5);
1324    }
1325
1326    #[test]
1327    fn test_pick_tracks_excludes_history() {
1328        let db = test_db();
1329
1330        // Insert a few tracks.
1331        for i in 0..5 {
1332            let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
1333            meta.path = Some(format!("/music/T{}.flac", i));
1334            meta.track_number = Some(i);
1335            upsert_track(&db.conn, &meta).unwrap();
1336        }
1337
1338        // Record all as recently played.
1339        let mut ids = Vec::new();
1340        for i in 0..5 {
1341            let id: i64 = db
1342                .conn
1343                .query_row(
1344                    "SELECT id FROM tracks WHERE path = ?1",
1345                    rusqlite::params![format!("/music/T{}.flac", i)],
1346                    |row| row.get(0),
1347                )
1348                .unwrap();
1349            queries::record_play(&db.conn, id, Some(240_000)).unwrap();
1350            ids.push(id);
1351        }
1352
1353        let mut ctx = RadioContext::from_queue(&[]);
1354        ctx.excluded_track_ids = ids.into_iter().collect();
1355
1356        let config = RadioConfig {
1357            batch_size: 5,
1358            ..RadioConfig::default()
1359        };
1360
1361        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1362        // All tracks are excluded, so nothing should be picked.
1363        assert!(
1364            picks.is_empty(),
1365            "all tracks in exclusion window, got picks"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_radio_context_build_with_no_history() {
1371        let db = test_db();
1372
1373        for i in 0..5 {
1374            let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
1375        }
1376
1377        let queue = vec![
1378            (Some(1_i64), Some("/a.flac".to_string())),
1379            (Some(2), Some("/b.flac".to_string())),
1380        ];
1381        let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
1382
1383        // Should fall back to queue weights since no play history.
1384        assert_eq!(ctx.seed_artists.len(), 2);
1385        assert_eq!(ctx.queued_paths.len(), 2);
1386    }
1387}