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    /// Artist IDs from the seed window, with recency weight (more recent = higher).
58    pub seed_artists: HashMap<i64, f64>,
59    /// Paths already in the queue (to avoid duplicates).
60    pub queued_paths: HashSet<String>,
61    /// Track IDs in the recent play history exclusion window.
62    pub excluded_track_ids: HashSet<i64>,
63    /// The currently playing track's remote_id (for Subsonic similar songs).
64    pub current_remote_id: Option<String>,
65    /// The currently playing track's artist name (for top songs fallback).
66    pub current_artist_name: Option<String>,
67    /// Genres from the seed window.
68    pub seed_genres: HashSet<String>,
69    /// Average year of seed tracks (for era matching).
70    pub seed_avg_year: Option<i32>,
71}
72
73impl RadioContext {
74    /// Build context from queue items and play history.
75    ///
76    /// `queue_items`: (artist_id, path) pairs from the current queue.
77    /// `seed_window`: number of recent tracks to use as seeds.
78    /// `history_window`: number of recent track IDs to exclude.
79    pub fn build(
80        conn: &Connection,
81        queue_items: &[(Option<i64>, Option<String>)],
82        seed_window: usize,
83        history_window: usize,
84    ) -> Self {
85        let mut ctx = Self::default();
86
87        // Add queued paths for duplicate prevention.
88        for (_aid, path) in queue_items {
89            if let Some(p) = path {
90                ctx.queued_paths.insert(p.clone());
91            }
92        }
93
94        // Build seed from recent plays (drifting seed).
95        let recent = queries::recent_track_ids(conn, seed_window).unwrap_or_default();
96        let seed_count = recent.len().max(1) as f64;
97
98        for (i, track_id) in recent.iter().enumerate() {
99            if let Ok(Some(track)) = queries::get_track_row(conn, *track_id) {
100                // More recent = higher weight (linear decay).
101                let weight = (seed_count - i as f64) / seed_count;
102                if let Some(aid) = track.artist_id {
103                    let entry = ctx.seed_artists.entry(aid).or_insert(0.0);
104                    *entry = entry.max(weight);
105                }
106                if let Some(ref genre) = track.genre {
107                    ctx.seed_genres.insert(genre.clone());
108                }
109            }
110        }
111
112        // If no play history, fall back to queue artist weights (old behavior).
113        if ctx.seed_artists.is_empty() {
114            for (artist_id, _path) in queue_items {
115                if let Some(aid) = artist_id {
116                    *ctx.seed_artists.entry(*aid).or_default() += 1.0;
117                }
118            }
119            // Normalise.
120            let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
121            for v in ctx.seed_artists.values_mut() {
122                *v /= max;
123            }
124
125            // Collect genres from queue.
126            for (artist_id, _path) in queue_items {
127                if let Some(aid) = artist_id
128                    && let Ok(tracks) = queries::random_tracks_excluding(conn, &[], &[*aid], &[], 1)
129                {
130                    for t in tracks {
131                        if let Some(ref g) = t.genre {
132                            ctx.seed_genres.insert(g.clone());
133                        }
134                    }
135                }
136            }
137        }
138
139        // Build exclusion window from play history.
140        let excluded = queries::recent_track_ids(conn, history_window).unwrap_or_default();
141        ctx.excluded_track_ids = excluded.into_iter().collect();
142
143        // Compute average year from seed tracks.
144        let mut years: Vec<i32> = Vec::new();
145        let seed_ids = queries::recent_track_ids(conn, seed_window).unwrap_or_default();
146        for tid in &seed_ids {
147            if let Ok(Some(track)) = queries::get_track_row(conn, *tid)
148                && let Some(album_id) = track.album_id
149                && let Ok(Some(album)) = queries::get_album(conn, album_id)
150                && let Some(ref date) = album.date
151                && let Ok(year) = date[..4.min(date.len())].parse::<i32>()
152            {
153                years.push(year);
154            }
155        }
156        if !years.is_empty() {
157            ctx.seed_avg_year = Some(years.iter().sum::<i32>() / years.len() as i32);
158        }
159
160        ctx
161    }
162
163    /// Legacy builder for backward compat — used by TUI when play history is empty.
164    pub fn from_queue(items: &[(Option<i64>, Option<String>)]) -> Self {
165        let mut ctx = Self::default();
166        for (artist_id, path) in items {
167            if let Some(aid) = artist_id {
168                *ctx.seed_artists.entry(*aid).or_default() += 1.0;
169            }
170            if let Some(p) = path {
171                ctx.queued_paths.insert(p.clone());
172            }
173        }
174        // Normalise.
175        let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
176        if max > 0.0 {
177            for v in ctx.seed_artists.values_mut() {
178                *v /= max;
179            }
180        }
181        ctx
182    }
183}
184
185/// Pick tracks for radio mode. Returns track IDs to enqueue.
186///
187/// Multi-signal strategy with fallback chain:
188/// 1. ListenBrainz similar artists -> local tracks
189/// 2. MusicBrainz relationships -> local tracks by collaborators/associated acts
190/// 3. Subsonic getSimilarSongs2 (if remote configured)
191/// 4. Genre + era match -> local tracks with matching tags from similar decade
192/// 5. Same-artist fallback
193/// 6. Random from library (nuclear fallback)
194pub fn pick_tracks(
195    conn: &Connection,
196    ctx: &RadioContext,
197    client: Option<&SubsonicClient>,
198    config: &RadioConfig,
199) -> Vec<i64> {
200    let count = config.batch_size;
201    let mut candidates: Vec<Candidate> = Vec::new();
202
203    log::info!(
204        "radio: picking {} tracks (seed: {} artists, {} genres, {} excluded, remote_id={}, artist={})",
205        count,
206        ctx.seed_artists.len(),
207        ctx.seed_genres.len(),
208        ctx.excluded_track_ids.len(),
209        ctx.current_remote_id.as_deref().unwrap_or("none"),
210        ctx.current_artist_name.as_deref().unwrap_or("none"),
211    );
212
213    // --- Signal 1: ListenBrainz similar artists ---
214    gather_listenbrainz_candidates(conn, ctx, &mut candidates);
215
216    // --- Signal 2: MusicBrainz relationships ---
217    gather_musicbrainz_candidates(conn, ctx, &mut candidates);
218
219    // --- Signal 3: Subsonic similar songs ---
220    if let Some(client) = client {
221        gather_subsonic_candidates(conn, ctx, client, &mut candidates);
222    }
223
224    // --- Signal 4: Genre + era match ---
225    gather_genre_era_candidates(conn, ctx, &mut candidates);
226
227    // --- Signal 5: Same-artist tracks ---
228    gather_same_artist_candidates(conn, ctx, &mut candidates);
229
230    // --- Signal 6: Acoustic similarity (vector KNN) ---
231    gather_acoustic_candidates(conn, ctx, &mut candidates);
232
233    // --- Signal 7: Random library tracks ---
234    gather_random_candidates(conn, ctx, &mut candidates);
235
236    log::info!("radio: {} raw candidates before scoring", candidates.len());
237
238    // Deduplicate by track_id, merging axes.
239    let mut deduped: HashMap<i64, Candidate> = HashMap::new();
240    for c in candidates {
241        let entry = deduped.entry(c.track_id).or_insert(Candidate {
242            track_id: c.track_id,
243            artist_id: c.artist_id,
244            path: c.path.clone(),
245            genre: c.genre.clone(),
246            year: c.year,
247            duration_ms: c.duration_ms,
248            axes: HashSet::new(),
249            base_score: 0.0,
250        });
251        entry.axes.extend(c.axes.iter());
252        entry.base_score = entry.base_score.max(c.base_score);
253    }
254
255    // Filter out excluded tracks and already-queued.
256    let mut scored: Vec<(i64, f64)> = deduped
257        .into_values()
258        .filter(|c| !ctx.excluded_track_ids.contains(&c.track_id))
259        .filter(|c| {
260            c.path
261                .as_ref()
262                .is_none_or(|p| !ctx.queued_paths.contains(p))
263        })
264        .map(|c| {
265            let score = compute_score(conn, &c, ctx, config);
266            (c.track_id, score)
267        })
268        .collect();
269
270    // Sort by score descending.
271    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
272
273    // Weighted random selection from top candidates for variety.
274    let picks = weighted_select(&scored, count);
275
276    log::info!("radio: picked {} tracks", picks.len());
277    picks
278}
279
280/// Compute final score for a candidate.
281fn compute_score(
282    conn: &Connection,
283    candidate: &Candidate,
284    ctx: &RadioContext,
285    config: &RadioConfig,
286) -> f64 {
287    let base = candidate.base_score;
288
289    // Signal overlap bonus: tracks matching on 2+ axes score higher.
290    let overlap_bonus = match candidate.axes.len() {
291        0 | 1 => 1.0,
292        2 => 1.5,
293        3 => 2.0,
294        _ => 2.5,
295    };
296
297    // Recency bonus: boost tracks that haven't been played recently or ever.
298    let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
299
300    // Era proximity bonus (if we have year data).
301    let era_bonus = if let (Some(track_year), Some(seed_year)) = (candidate.year, ctx.seed_avg_year)
302    {
303        let diff = (track_year - seed_year).unsigned_abs();
304        if diff <= 5 {
305            1.3
306        } else if diff <= 10 {
307            1.1
308        } else {
309            1.0
310        }
311    } else {
312        1.0
313    };
314
315    base * overlap_bonus * recency_bonus * era_bonus
316}
317
318/// Compute recency bonus for a track. Higher = more desirable.
319/// Never-played tracks get the highest bonus.
320fn compute_recency_bonus(conn: &Connection, track_id: i64, discovery_weight: f64) -> f64 {
321    let last_played = queries::last_played_at(conn, track_id).unwrap_or(None);
322    match last_played {
323        None => {
324            // Never played — big bonus, scaled by discovery_weight.
325            1.0 + discovery_weight * 2.0
326        }
327        Some(ts) => {
328            let now = SystemTime::now()
329                .duration_since(UNIX_EPOCH)
330                .unwrap_or_default()
331                .as_secs() as i64;
332            let days_ago = (now - ts) / 86400;
333            if days_ago > 180 {
334                1.0 + discovery_weight * 1.5 // "oh fuck, I forgot I owned this"
335            } else if days_ago > 30 {
336                1.0 + discovery_weight * 0.8
337            } else if days_ago > 7 {
338                1.0 + discovery_weight * 0.3
339            } else {
340                1.0 // Recently played — no bonus.
341            }
342        }
343    }
344}
345
346/// Weighted random selection from scored candidates.
347/// Takes the top N*3 candidates and selects N with probability proportional to score.
348fn weighted_select(scored: &[(i64, f64)], count: usize) -> Vec<i64> {
349    if scored.is_empty() {
350        return vec![];
351    }
352
353    let pool_size = (count * 3).min(scored.len());
354    let pool = &scored[..pool_size];
355
356    // Simple selection from the top-scored pool.
357    let mut selected = Vec::new();
358    let mut used = HashSet::new();
359
360    for (id, _score) in pool {
361        if selected.len() >= count {
362            break;
363        }
364        if used.insert(*id) {
365            selected.push(*id);
366        }
367    }
368
369    selected
370}
371
372// --- Signal gatherers ---
373
374fn gather_listenbrainz_candidates(
375    conn: &Connection,
376    ctx: &RadioContext,
377    candidates: &mut Vec<Candidate>,
378) {
379    let http = reqwest::blocking::Client::new();
380
381    for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
382        // Get artist MBID from our DB.
383        let mbid: Option<String> = conn
384            .query_row(
385                "SELECT mbid FROM artists WHERE id = ?1",
386                rusqlite::params![artist_id],
387                |row| row.get(0),
388            )
389            .ok()
390            .flatten();
391
392        let mbid = match mbid {
393            Some(m) if !m.is_empty() => m,
394            _ => {
395                // Try to look up MBID via MusicBrainz search.
396                let artist_name: Option<String> = conn
397                    .query_row(
398                        "SELECT name FROM artists WHERE id = ?1",
399                        rusqlite::params![artist_id],
400                        |row| row.get(0),
401                    )
402                    .ok();
403                if let Some(name) = artist_name {
404                    match musicbrainz::lookup_artist_mbid(&http, &name) {
405                        Ok(Some(mbid)) => {
406                            // Cache the MBID.
407                            let _ = conn.execute(
408                                "UPDATE artists SET mbid = ?1 WHERE id = ?2",
409                                rusqlite::params![mbid, artist_id],
410                            );
411                            mbid
412                        }
413                        _ => continue,
414                    }
415                } else {
416                    continue;
417                }
418            }
419        };
420
421        // Check if we have fresh ListenBrainz data cached.
422        if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
423            .unwrap_or(false)
424        {
425            // Use cached data.
426            add_cached_similar_candidates(
427                conn,
428                ctx,
429                artist_id,
430                weight,
431                SimilarityAxis::ListenBrainz,
432                candidates,
433            );
434            continue;
435        }
436
437        // Fetch from API.
438        match listenbrainz::get_similar_artists(&http, &mbid, 20) {
439            Ok(similar) => {
440                log::info!(
441                    "radio: listenbrainz returned {} similar for artist_id={}",
442                    similar.len(),
443                    artist_id
444                );
445                // Match to local artists and cache.
446                let mut pairs: Vec<(i64, f64)> = Vec::new();
447                for sa in &similar {
448                    // Try to find by MBID first, then by name.
449                    let local_id: Option<i64> = conn
450                        .query_row(
451                            "SELECT id FROM artists WHERE mbid = ?1",
452                            rusqlite::params![sa.mbid],
453                            |row| row.get(0),
454                        )
455                        .ok()
456                        .or_else(|| {
457                            conn.query_row(
458                                "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
459                                rusqlite::params![sa.name],
460                                |row| row.get(0),
461                            )
462                            .ok()
463                        });
464
465                    if let Some(local_id) = local_id
466                        && local_id != artist_id
467                    {
468                        pairs.push((local_id, sa.score));
469                    }
470                }
471
472                if !pairs.is_empty() {
473                    let _ = queries::save_similar_artists(conn, artist_id, &pairs, "listenbrainz");
474                }
475
476                // Add candidates from the matched local artists.
477                add_local_artist_candidates(
478                    conn,
479                    ctx,
480                    &pairs,
481                    weight,
482                    SimilarityAxis::ListenBrainz,
483                    candidates,
484                );
485            }
486            Err(e) => {
487                log::debug!(
488                    "radio: listenbrainz failed for artist_id={}: {}",
489                    artist_id,
490                    e
491                );
492                // Fall through — other signals will pick up the slack.
493            }
494        }
495    }
496}
497
498fn gather_musicbrainz_candidates(
499    conn: &Connection,
500    ctx: &RadioContext,
501    candidates: &mut Vec<Candidate>,
502) {
503    let http = musicbrainz::default_client();
504
505    for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
506        // Check cache first.
507        if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("musicbrainz"))
508            .unwrap_or(false)
509        {
510            add_cached_similar_candidates(
511                conn,
512                ctx,
513                artist_id,
514                weight,
515                SimilarityAxis::MusicBrainz,
516                candidates,
517            );
518            continue;
519        }
520
521        let mbid: Option<String> = conn
522            .query_row(
523                "SELECT mbid FROM artists WHERE id = ?1",
524                rusqlite::params![artist_id],
525                |row| row.get(0),
526            )
527            .ok()
528            .flatten();
529
530        let Some(mbid) = mbid.filter(|m| !m.is_empty()) else {
531            continue;
532        };
533
534        match musicbrainz::get_artist_relations(&http, &mbid) {
535            Ok(relations) => {
536                log::info!(
537                    "radio: musicbrainz returned {} relations for artist_id={}",
538                    relations.len(),
539                    artist_id
540                );
541
542                let mut pairs: Vec<(i64, f64)> = Vec::new();
543
544                for rel in &relations {
545                    let local_id: Option<i64> = conn
546                        .query_row(
547                            "SELECT id FROM artists WHERE mbid = ?1",
548                            rusqlite::params![rel.mbid],
549                            |row| row.get(0),
550                        )
551                        .ok()
552                        .or_else(|| {
553                            conn.query_row(
554                                "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
555                                rusqlite::params![rel.name],
556                                |row| row.get(0),
557                            )
558                            .ok()
559                        });
560
561                    if let Some(local_id) = local_id
562                        && local_id != artist_id
563                    {
564                        // Score by relationship type.
565                        let score = match rel.category {
566                            musicbrainz::RelationCategory::Member => 0.8,
567                            musicbrainz::RelationCategory::Collaborator => 0.7,
568                            musicbrainz::RelationCategory::Associated => 0.5,
569                        };
570                        pairs.push((local_id, score));
571                    }
572                }
573
574                if !pairs.is_empty() {
575                    let _ = queries::save_similar_artists_with_rel(
576                        conn,
577                        artist_id,
578                        &pairs,
579                        "musicbrainz",
580                        "collaborator",
581                    );
582                }
583
584                add_local_artist_candidates(
585                    conn,
586                    ctx,
587                    &pairs,
588                    weight,
589                    SimilarityAxis::MusicBrainz,
590                    candidates,
591                );
592            }
593            Err(e) => {
594                log::debug!(
595                    "radio: musicbrainz relations failed for artist_id={}: {}",
596                    artist_id,
597                    e
598                );
599            }
600        }
601
602        // Rate limit: sleep 1s between MusicBrainz requests.
603        std::thread::sleep(std::time::Duration::from_secs(1));
604    }
605}
606
607fn gather_subsonic_candidates(
608    conn: &Connection,
609    ctx: &RadioContext,
610    client: &SubsonicClient,
611    candidates: &mut Vec<Candidate>,
612) {
613    if let Some(ref remote_id) = ctx.current_remote_id {
614        match client.get_similar_songs(remote_id, 30) {
615            Ok(songs) => {
616                log::info!("radio: subsonic returned {} similar songs", songs.len());
617                for (i, song) in songs.iter().enumerate() {
618                    if let Some(track_id) = resolve_subsonic_song_to_track(conn, song) {
619                        let score = (songs.len() as f64 - i as f64) / songs.len() as f64;
620                        let track = queries::get_track_row(conn, track_id).ok().flatten();
621                        candidates.push(Candidate {
622                            track_id,
623                            artist_id: track.as_ref().and_then(|t| t.artist_id),
624                            path: track.as_ref().and_then(|t| t.path.clone()),
625                            genre: track.as_ref().and_then(|t| t.genre.clone()),
626                            year: None,
627                            duration_ms: track.as_ref().and_then(|t| t.duration_ms),
628                            axes: [SimilarityAxis::Subsonic].into_iter().collect(),
629                            base_score: score * 0.9,
630                        });
631                    }
632                }
633
634                // Cache artist relationships from subsonic results.
635                cache_subsonic_artist_relationships(conn, ctx, &songs);
636            }
637            Err(e) => {
638                log::debug!("radio: subsonic similar songs failed: {}", e);
639            }
640        }
641    }
642}
643
644fn gather_genre_era_candidates(
645    conn: &Connection,
646    ctx: &RadioContext,
647    candidates: &mut Vec<Candidate>,
648) {
649    if ctx.seed_genres.is_empty() {
650        return;
651    }
652
653    let genres: Vec<String> = ctx.seed_genres.iter().cloned().collect();
654    let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
655
656    match queries::random_tracks_excluding(conn, &exclude, &[], &genres, 30) {
657        Ok(tracks) => {
658            for track in tracks {
659                let year = track
660                    .album_id
661                    .and_then(|aid| queries::get_album(conn, aid).ok().flatten())
662                    .and_then(|a| {
663                        a.date
664                            .as_ref()
665                            .and_then(|d| d[..4.min(d.len())].parse().ok())
666                    });
667
668                // Score higher if both genre AND era match.
669                let genre_match = track
670                    .genre
671                    .as_ref()
672                    .is_some_and(|g| ctx.seed_genres.contains(g));
673                let era_match = match (year, ctx.seed_avg_year) {
674                    (Some(y), Some(sy)) => (y as i64 - sy as i64).unsigned_abs() <= 10,
675                    _ => false,
676                };
677
678                let base_score = match (genre_match, era_match) {
679                    (true, true) => 0.6,
680                    (true, false) => 0.3,
681                    (false, true) => 0.2,
682                    (false, false) => 0.1,
683                };
684
685                candidates.push(Candidate {
686                    track_id: track.id,
687                    artist_id: track.artist_id,
688                    path: track.path.clone(),
689                    genre: track.genre.clone(),
690                    year,
691                    duration_ms: track.duration_ms,
692                    axes: [SimilarityAxis::GenreEra].into_iter().collect(),
693                    base_score,
694                });
695            }
696        }
697        Err(e) => {
698            log::debug!("radio: genre/era query failed: {}", e);
699        }
700    }
701}
702
703fn gather_same_artist_candidates(
704    conn: &Connection,
705    ctx: &RadioContext,
706    candidates: &mut Vec<Candidate>,
707) {
708    let artist_ids: Vec<i64> = ctx.seed_artists.keys().copied().collect();
709    if artist_ids.is_empty() {
710        return;
711    }
712
713    let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
714    match queries::random_tracks_excluding(conn, &exclude, &artist_ids, &[], 15) {
715        Ok(tracks) => {
716            for track in tracks {
717                let weight = track
718                    .artist_id
719                    .and_then(|aid| ctx.seed_artists.get(&aid))
720                    .copied()
721                    .unwrap_or(0.3);
722
723                candidates.push(Candidate {
724                    track_id: track.id,
725                    artist_id: track.artist_id,
726                    path: track.path.clone(),
727                    genre: track.genre.clone(),
728                    year: None,
729                    duration_ms: track.duration_ms,
730                    axes: [SimilarityAxis::SameArtist].into_iter().collect(),
731                    base_score: weight * 0.4, // Lower base — same-artist is the fallback.
732                });
733            }
734        }
735        Err(e) => {
736            log::debug!("radio: same-artist query failed: {}", e);
737        }
738    }
739}
740
741fn gather_acoustic_candidates(
742    conn: &Connection,
743    _ctx: &RadioContext,
744    candidates: &mut Vec<Candidate>,
745) {
746    // Collect vectors for recent seed tracks (same ones driving seed_artists).
747    let seed_ids = queries::recent_track_ids(conn, 5).unwrap_or_default();
748    let mut seed_embeddings = Vec::new();
749    for tid in &seed_ids {
750        if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
751            seed_embeddings.push(emb);
752        }
753    }
754
755    if seed_embeddings.is_empty() {
756        return;
757    }
758
759    let centroid = crate::index::features::centroid(&seed_embeddings);
760    let knn_result = queries::find_similar_to_vector(conn, &centroid, 30, None);
761    match knn_result {
762        Ok(ref results) => {
763            let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
764            let mut added = 0;
765            for &(track_id, dist) in results {
766                // Skip seed tracks themselves.
767                if seed_ids.contains(&track_id) {
768                    continue;
769                }
770                // Score: inverse of normalised distance. Closer = higher score.
771                let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
772                let track = queries::get_track_row(conn, track_id).ok().flatten();
773                candidates.push(Candidate {
774                    track_id,
775                    artist_id: track.as_ref().and_then(|t| t.artist_id),
776                    path: track.as_ref().and_then(|t| t.path.clone()),
777                    genre: track.as_ref().and_then(|t| t.genre.clone()),
778                    year: None,
779                    duration_ms: track.as_ref().and_then(|t| t.duration_ms),
780                    axes: [SimilarityAxis::Acoustic].into_iter().collect(),
781                    base_score: score,
782                });
783                added += 1;
784            }
785            log::info!(
786                "radio: acoustic signal added {} candidates from {} seed vectors",
787                added,
788                seed_embeddings.len()
789            );
790        }
791        Err(e) => {
792            log::debug!("radio: acoustic similarity query failed: {}", e);
793        }
794    }
795}
796
797fn gather_random_candidates(
798    conn: &Connection,
799    ctx: &RadioContext,
800    candidates: &mut Vec<Candidate>,
801) {
802    let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
803    match queries::random_tracks_excluding(conn, &exclude, &[], &[], 10) {
804        Ok(tracks) => {
805            for track in tracks {
806                candidates.push(Candidate {
807                    track_id: track.id,
808                    artist_id: track.artist_id,
809                    path: track.path.clone(),
810                    genre: track.genre.clone(),
811                    year: None,
812                    duration_ms: track.duration_ms,
813                    axes: [SimilarityAxis::Random].into_iter().collect(),
814                    base_score: 0.05, // Nuclear fallback — still better than silence.
815                });
816            }
817        }
818        Err(e) => {
819            log::debug!("radio: random fallback failed: {}", e);
820        }
821    }
822}
823
824// --- Helpers ---
825
826/// Add candidates from cached similar artist data.
827fn add_cached_similar_candidates(
828    conn: &Connection,
829    ctx: &RadioContext,
830    artist_id: i64,
831    seed_weight: f64,
832    axis: SimilarityAxis,
833    candidates: &mut Vec<Candidate>,
834) {
835    if let Ok(similar) = queries::get_similar_artists(conn, artist_id) {
836        let pairs: Vec<(i64, f64)> = similar.into_iter().map(|(a, s)| (a.id, s)).collect();
837        add_local_artist_candidates(conn, ctx, &pairs, seed_weight, axis, candidates);
838    }
839}
840
841/// Add candidates from a list of (artist_id, similarity_score) pairs.
842fn add_local_artist_candidates(
843    conn: &Connection,
844    ctx: &RadioContext,
845    pairs: &[(i64, f64)],
846    seed_weight: f64,
847    axis: SimilarityAxis,
848    candidates: &mut Vec<Candidate>,
849) {
850    for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
851        let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
852        if let Ok(tracks) =
853            queries::random_tracks_excluding(conn, &exclude, &[similar_artist_id], &[], 3)
854        {
855            for track in tracks {
856                candidates.push(Candidate {
857                    track_id: track.id,
858                    artist_id: track.artist_id,
859                    path: track.path.clone(),
860                    genre: track.genre.clone(),
861                    year: None,
862                    duration_ms: track.duration_ms,
863                    axes: [axis].into_iter().collect(),
864                    base_score: sim_score * seed_weight * 0.8,
865                });
866            }
867        }
868    }
869}
870
871/// Resolve a SubsonicSong to a local track ID by remote_id.
872fn resolve_subsonic_song_to_track(
873    conn: &Connection,
874    song: &crate::remote::client::SubsonicSong,
875) -> Option<i64> {
876    conn.query_row(
877        "SELECT id FROM tracks WHERE remote_id = ?1",
878        rusqlite::params![song.id],
879        |row| row.get::<_, i64>(0),
880    )
881    .ok()
882}
883
884/// Extract and cache artist relationships from Subsonic similar songs response.
885fn cache_subsonic_artist_relationships(
886    conn: &Connection,
887    ctx: &RadioContext,
888    songs: &[crate::remote::client::SubsonicSong],
889) {
890    for &artist_id in ctx.seed_artists.keys().take(5) {
891        let mut similar: HashMap<i64, f64> = HashMap::new();
892        let total = songs.len() as f64;
893
894        for (i, song) in songs.iter().enumerate() {
895            if let Some(ref song_artist_id) = song.artist_id {
896                let local_artist_id: Option<i64> = conn
897                    .query_row(
898                        "SELECT id FROM artists WHERE remote_id = ?1",
899                        rusqlite::params![song_artist_id],
900                        |row| row.get(0),
901                    )
902                    .ok();
903
904                if let Some(local_id) = local_artist_id
905                    && local_id != artist_id
906                {
907                    let score = (total - i as f64) / total;
908                    let entry = similar.entry(local_id).or_insert(0.0);
909                    *entry = entry.max(score);
910                }
911            }
912        }
913
914        if !similar.is_empty() {
915            let pairs: Vec<(i64, f64)> = similar.into_iter().collect();
916            let _ = queries::save_similar_artists(conn, artist_id, &pairs, "subsonic");
917        }
918    }
919}
920
921/// Populate the similar artists cache for a given artist using Subsonic.
922/// Kept for backward compat with the TUI trigger.
923pub fn fetch_and_cache_similar_artists(
924    conn: &Connection,
925    client: &SubsonicClient,
926    artist_id: i64,
927) -> Result<(), Box<dyn std::error::Error>> {
928    if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("subsonic"))
929        .unwrap_or(false)
930    {
931        return Ok(());
932    }
933
934    let track_remote_id: Option<String> = conn
935        .query_row(
936            "SELECT remote_id FROM tracks WHERE artist_id = ?1 AND remote_id IS NOT NULL LIMIT 1",
937            rusqlite::params![artist_id],
938            |row| row.get(0),
939        )
940        .ok()
941        .flatten();
942
943    let Some(track_remote_id) = track_remote_id else {
944        return Ok(());
945    };
946
947    let songs = client.get_similar_songs(&track_remote_id, 50)?;
948    let mut similar_artists: HashMap<i64, f64> = HashMap::new();
949    let total = songs.len() as f64;
950
951    for (i, song) in songs.iter().enumerate() {
952        if let Some(ref song_artist_id) = song.artist_id {
953            let local_artist_id: Option<i64> = conn
954                .query_row(
955                    "SELECT id FROM artists WHERE remote_id = ?1",
956                    rusqlite::params![song_artist_id],
957                    |row| row.get(0),
958                )
959                .ok();
960
961            if let Some(local_id) = local_artist_id
962                && local_id != artist_id
963            {
964                let score = (total - i as f64) / total;
965                let entry = similar_artists.entry(local_id).or_insert(0.0);
966                *entry = entry.max(score);
967            }
968        }
969    }
970
971    if !similar_artists.is_empty() {
972        let pairs: Vec<(i64, f64)> = similar_artists.into_iter().collect();
973        queries::save_similar_artists(conn, artist_id, &pairs, "subsonic")?;
974    }
975
976    Ok(())
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::db::connection::Database;
983    use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
984
985    fn test_db() -> Database {
986        let conn = rusqlite::Connection::open_in_memory().unwrap();
987        conn.pragma_update(None, "foreign_keys", "on").unwrap();
988        crate::db::schema::create_tables(&conn).unwrap();
989        Database { conn }
990    }
991
992    #[test]
993    fn test_radio_context_from_queue() {
994        let ctx = RadioContext::from_queue(&[
995            (Some(1), Some("/a.flac".into())),
996            (Some(2), Some("/b.flac".into())),
997            (Some(1), Some("/c.flac".into())),
998        ]);
999        assert_eq!(ctx.seed_artists.len(), 2);
1000        assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
1001        assert_eq!(ctx.queued_paths.len(), 3);
1002    }
1003
1004    #[test]
1005    fn test_recency_bonus_never_played() {
1006        let db = test_db();
1007        let mut meta = sample_meta("T1", "A1", "Al1");
1008        meta.path = Some("/music/T1.flac".into());
1009        upsert_track(&db.conn, &meta).unwrap();
1010
1011        let track_id: i64 = db
1012            .conn
1013            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1014            .unwrap();
1015
1016        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1017        assert!(bonus > 1.0, "never-played should get a bonus");
1018    }
1019
1020    #[test]
1021    fn test_recency_bonus_recently_played() {
1022        let db = test_db();
1023        let mut meta = sample_meta("T1", "A1", "Al1");
1024        meta.path = Some("/music/T1.flac".into());
1025        upsert_track(&db.conn, &meta).unwrap();
1026
1027        let track_id: i64 = db
1028            .conn
1029            .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1030            .unwrap();
1031
1032        queries::record_play(&db.conn, track_id, Some(240_000)).unwrap();
1033
1034        let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1035        assert!(
1036            (bonus - 1.0).abs() < f64::EPSILON,
1037            "recently played should get no bonus"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_weighted_select_empty() {
1043        assert!(weighted_select(&[], 5).is_empty());
1044    }
1045
1046    #[test]
1047    fn test_weighted_select_fewer_than_requested() {
1048        let scored = vec![(1, 0.9), (2, 0.5)];
1049        let picks = weighted_select(&scored, 5);
1050        assert_eq!(picks.len(), 2);
1051    }
1052
1053    #[test]
1054    fn test_signal_overlap_scoring() {
1055        let db = test_db();
1056        let config = RadioConfig::default();
1057        let ctx = RadioContext::default();
1058
1059        // Candidate with 1 axis.
1060        let c1 = Candidate {
1061            track_id: 1,
1062            artist_id: None,
1063            path: None,
1064            genre: None,
1065            year: None,
1066            duration_ms: None,
1067            axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
1068            base_score: 0.5,
1069        };
1070
1071        // Candidate with 3 axes.
1072        let c3 = Candidate {
1073            track_id: 2,
1074            artist_id: None,
1075            path: None,
1076            genre: None,
1077            year: None,
1078            duration_ms: None,
1079            axes: [
1080                SimilarityAxis::ListenBrainz,
1081                SimilarityAxis::MusicBrainz,
1082                SimilarityAxis::GenreEra,
1083            ]
1084            .into_iter()
1085            .collect(),
1086            base_score: 0.5,
1087        };
1088
1089        let score1 = compute_score(&db.conn, &c1, &ctx, &config);
1090        let score3 = compute_score(&db.conn, &c3, &ctx, &config);
1091
1092        assert!(
1093            score3 > score1,
1094            "multi-axis candidate should score higher: {} vs {}",
1095            score3,
1096            score1
1097        );
1098    }
1099
1100    #[test]
1101    fn test_pick_tracks_empty_library() {
1102        let db = test_db();
1103        let ctx = RadioContext::from_queue(&[]);
1104        let config = RadioConfig::default();
1105        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1106        assert!(picks.is_empty());
1107    }
1108
1109    #[test]
1110    fn test_pick_tracks_with_library() {
1111        let db = test_db();
1112
1113        // Populate library.
1114        for i in 0..20 {
1115            let mut meta = sample_meta(
1116                &format!("Track{}", i),
1117                &format!("Artist{}", i % 5),
1118                &format!("Album{}", i % 3),
1119            );
1120            meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
1121            meta.track_number = Some(i);
1122            upsert_track(&db.conn, &meta).unwrap();
1123        }
1124
1125        let artist_id: i64 = db
1126            .conn
1127            .query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
1128            .unwrap();
1129
1130        let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
1131        let config = RadioConfig {
1132            batch_size: 5,
1133            ..RadioConfig::default()
1134        };
1135
1136        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1137        assert!(
1138            !picks.is_empty(),
1139            "should pick at least some tracks from a populated library"
1140        );
1141        assert!(picks.len() <= 5);
1142    }
1143
1144    #[test]
1145    fn test_pick_tracks_excludes_history() {
1146        let db = test_db();
1147
1148        // Insert a few tracks.
1149        for i in 0..5 {
1150            let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
1151            meta.path = Some(format!("/music/T{}.flac", i));
1152            meta.track_number = Some(i);
1153            upsert_track(&db.conn, &meta).unwrap();
1154        }
1155
1156        // Record all as recently played.
1157        let mut ids = Vec::new();
1158        for i in 0..5 {
1159            let id: i64 = db
1160                .conn
1161                .query_row(
1162                    "SELECT id FROM tracks WHERE path = ?1",
1163                    rusqlite::params![format!("/music/T{}.flac", i)],
1164                    |row| row.get(0),
1165                )
1166                .unwrap();
1167            queries::record_play(&db.conn, id, Some(240_000)).unwrap();
1168            ids.push(id);
1169        }
1170
1171        let mut ctx = RadioContext::from_queue(&[]);
1172        ctx.excluded_track_ids = ids.into_iter().collect();
1173
1174        let config = RadioConfig {
1175            batch_size: 5,
1176            ..RadioConfig::default()
1177        };
1178
1179        let picks = pick_tracks(&db.conn, &ctx, None, &config);
1180        // All tracks are excluded, so nothing should be picked.
1181        assert!(
1182            picks.is_empty(),
1183            "all tracks in exclusion window, got picks"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_radio_context_build_with_no_history() {
1189        let db = test_db();
1190
1191        for i in 0..5 {
1192            let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
1193        }
1194
1195        let queue = vec![
1196            (Some(1_i64), Some("/a.flac".to_string())),
1197            (Some(2), Some("/b.flac".to_string())),
1198        ];
1199        let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
1200
1201        // Should fall back to queue weights since no play history.
1202        assert_eq!(ctx.seed_artists.len(), 2);
1203        assert_eq!(ctx.queued_paths.len(), 2);
1204    }
1205}