1use 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#[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#[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 axes: HashSet<SimilarityAxis>,
50 base_score: f64,
52}
53
54#[derive(Debug, Default)]
56pub struct RadioContext {
57 pub seed_artists: HashMap<i64, f64>,
59 pub queued_paths: HashSet<String>,
61 pub excluded_track_ids: HashSet<i64>,
63 pub current_remote_id: Option<String>,
65 pub current_artist_name: Option<String>,
67 pub seed_genres: HashSet<String>,
69 pub seed_avg_year: Option<i32>,
71}
72
73impl RadioContext {
74 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 for (_aid, path) in queue_items {
89 if let Some(p) = path {
90 ctx.queued_paths.insert(p.clone());
91 }
92 }
93
94 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 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 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 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 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 let excluded = queries::recent_track_ids(conn, history_window).unwrap_or_default();
141 ctx.excluded_track_ids = excluded.into_iter().collect();
142
143 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 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 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
185pub 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 gather_listenbrainz_candidates(conn, ctx, &mut candidates);
215
216 gather_musicbrainz_candidates(conn, ctx, &mut candidates);
218
219 if let Some(client) = client {
221 gather_subsonic_candidates(conn, ctx, client, &mut candidates);
222 }
223
224 gather_genre_era_candidates(conn, ctx, &mut candidates);
226
227 gather_same_artist_candidates(conn, ctx, &mut candidates);
229
230 gather_acoustic_candidates(conn, ctx, &mut candidates);
232
233 gather_random_candidates(conn, ctx, &mut candidates);
235
236 log::info!("radio: {} raw candidates before scoring", candidates.len());
237
238 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 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 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
272
273 let picks = weighted_select(&scored, count);
275
276 log::info!("radio: picked {} tracks", picks.len());
277 picks
278}
279
280fn compute_score(
282 conn: &Connection,
283 candidate: &Candidate,
284 ctx: &RadioContext,
285 config: &RadioConfig,
286) -> f64 {
287 let base = candidate.base_score;
288
289 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 let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
299
300 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
318fn 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 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 } 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 }
342 }
343 }
344}
345
346fn 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 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
372fn 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 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 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 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 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
423 .unwrap_or(false)
424 {
425 add_cached_similar_candidates(
427 conn,
428 ctx,
429 artist_id,
430 weight,
431 SimilarityAxis::ListenBrainz,
432 candidates,
433 );
434 continue;
435 }
436
437 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 let mut pairs: Vec<(i64, f64)> = Vec::new();
447 for sa in &similar {
448 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_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 }
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 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 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 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_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 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, });
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 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, ¢roid, 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 if seed_ids.contains(&track_id) {
768 continue;
769 }
770 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, });
816 }
817 }
818 Err(e) => {
819 log::debug!("radio: random fallback failed: {}", e);
820 }
821 }
822}
823
824fn 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
841fn 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
871fn 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
884fn 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
921pub 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 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 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 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 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 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 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 assert_eq!(ctx.seed_artists.len(), 2);
1203 assert_eq!(ctx.queued_paths.len(), 2);
1204 }
1205}