1use std::collections::{HashMap, HashSet};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use rusqlite::Connection;
16
17use crate::config::RadioConfig;
18use crate::db::queries;
19use crate::remote::client::SubsonicClient;
20use crate::remote::listenbrainz;
21use crate::remote::musicbrainz;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum SimilarityAxis {
26 ListenBrainz,
27 MusicBrainz,
28 Subsonic,
29 GenreEra,
30 SameArtist,
31 Random,
32 Acoustic,
33}
34
35#[derive(Debug)]
37struct Candidate {
38 track_id: i64,
39 path: Option<String>,
40 year: Option<i32>,
41 axes: HashSet<SimilarityAxis>,
43 base_score: f64,
45}
46
47#[derive(Debug, Default)]
49pub struct RadioContext {
50 pub allow_network: bool,
53 pub seed_artists: HashMap<i64, f64>,
55 pub queued_paths: HashSet<String>,
57 pub queued_ids: HashSet<i64>,
60 pub excluded_track_ids: HashSet<i64>,
62 pub current_remote_id: Option<String>,
64 pub current_artist_name: Option<String>,
66 pub seed_genres: HashSet<String>,
68 pub seed_avg_year: Option<i32>,
70}
71
72impl RadioContext {
73 pub fn build(
79 conn: &Connection,
80 queue_items: &[(Option<i64>, Option<String>)],
81 seed_window: usize,
82 history_window: usize,
83 ) -> Self {
84 let mut ctx = Self {
85 allow_network: true,
88 ..Self::default()
89 };
90
91 for (_aid, path) in queue_items {
93 if let Some(p) = path {
94 ctx.queued_paths.insert(p.clone());
95 }
96 }
97
98 let recent =
100 queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
101 let seed_count = recent.len().max(1) as f64;
102 let mut years: Vec<i32> = Vec::new();
103 let rows: HashMap<i64, queries::TrackRow> = queries::tracks_by_ids(conn, &recent)
104 .unwrap_or_default()
105 .into_iter()
106 .map(|t| (t.id, t))
107 .collect();
108 let dates = album_years(conn, &recent);
109
110 for (i, track_id) in recent.iter().enumerate() {
111 if let Some(track) = rows.get(track_id) {
112 let weight = (seed_count - i as f64) / seed_count;
114 if let Some(aid) = track.artist_id {
115 let entry = ctx.seed_artists.entry(aid).or_insert(0.0);
116 *entry = entry.max(weight);
117 }
118 if let Some(ref genre) = track.genre {
119 ctx.seed_genres.insert(genre.to_lowercase());
120 }
121 if let Some(&year) = dates.get(track_id) {
122 years.push(year);
123 }
124 }
125 }
126
127 if ctx.seed_artists.is_empty() {
129 for (artist_id, _path) in queue_items {
130 if let Some(aid) = artist_id {
131 *ctx.seed_artists.entry(*aid).or_default() += 1.0;
132 }
133 }
134 let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
136 for v in ctx.seed_artists.values_mut() {
137 *v /= max;
138 }
139
140 let artist_ids: Vec<i64> = ctx.seed_artists.keys().copied().collect();
142 ctx.seed_genres.extend(
143 queries::genres_by_artist_ids(conn, &artist_ids)
144 .unwrap_or_default()
145 .into_values()
146 .flatten(),
147 );
148 }
149
150 let excluded = queries::recent_track_ids(conn, queries::LOCAL_USER, history_window)
152 .unwrap_or_default();
153 ctx.excluded_track_ids = excluded.into_iter().collect();
154
155 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 fn drawn_out(&self) -> Vec<i64> {
165 self.queued_ids
166 .iter()
167 .chain(&self.excluded_track_ids)
168 .copied()
169 .collect()
170 }
171
172 #[cfg(test)]
174 pub fn from_queue(items: &[(Option<i64>, Option<String>)]) -> Self {
175 let mut ctx = Self::default();
176 for (artist_id, path) in items {
177 if let Some(aid) = artist_id {
178 *ctx.seed_artists.entry(*aid).or_default() += 1.0;
179 }
180 if let Some(p) = path {
181 ctx.queued_paths.insert(p.clone());
182 }
183 }
184 let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
186 if max > 0.0 {
187 for v in ctx.seed_artists.values_mut() {
188 *v /= max;
189 }
190 }
191 ctx
192 }
193}
194
195pub fn pick_tracks(
206 conn: &Connection,
207 ctx: &RadioContext,
208 client: Option<&SubsonicClient>,
209 config: &RadioConfig,
210) -> Vec<i64> {
211 let count = config.batch_size;
212 let mut candidates: Vec<Candidate> = Vec::new();
213
214 log::info!(
215 "radio: picking {} tracks (seed: {} artists, {} genres, {} excluded, remote_id={}, artist={})",
216 count,
217 ctx.seed_artists.len(),
218 ctx.seed_genres.len(),
219 ctx.excluded_track_ids.len(),
220 ctx.current_remote_id.as_deref().unwrap_or("none"),
221 ctx.current_artist_name.as_deref().unwrap_or("none"),
222 );
223
224 if ctx.allow_network {
228 gather_listenbrainz_candidates(conn, ctx, &mut candidates);
230
231 gather_musicbrainz_candidates(conn, ctx, &mut candidates);
233
234 if let Some(client) = client {
236 gather_subsonic_candidates(conn, ctx, client, &mut candidates);
237 }
238 }
239
240 gather_genre_era_candidates(conn, ctx, &mut candidates);
242
243 gather_same_artist_candidates(conn, ctx, &mut candidates);
245
246 gather_acoustic_candidates(conn, config.seed_window, &mut candidates);
248
249 gather_random_candidates(conn, ctx, &mut candidates);
251
252 log::info!("radio: {} raw candidates before scoring", candidates.len());
253
254 let mut deduped: HashMap<i64, Candidate> = HashMap::new();
256 for c in candidates {
257 let entry = deduped.entry(c.track_id).or_insert_with(|| Candidate {
258 track_id: c.track_id,
259 path: c.path.clone(),
260 year: c.year,
261 axes: HashSet::new(),
262 base_score: 0.0,
263 });
264 entry.axes.extend(c.axes.iter());
265 entry.base_score = entry.base_score.max(c.base_score);
266 }
267
268 let mut scored: Vec<(i64, f64)> = deduped
270 .into_values()
271 .filter(|c| !ctx.excluded_track_ids.contains(&c.track_id))
272 .filter(|c| {
273 c.path
274 .as_ref()
275 .is_none_or(|p| !ctx.queued_paths.contains(p))
276 })
277 .map(|c| {
278 let score = compute_score(conn, &c, ctx, config);
279 (c.track_id, score)
280 })
281 .collect();
282
283 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
285
286 let picks = weighted_select(&scored, count);
288
289 log::info!("radio: picked {} tracks", picks.len());
290 picks
291}
292
293fn compute_score(
295 conn: &Connection,
296 candidate: &Candidate,
297 ctx: &RadioContext,
298 config: &RadioConfig,
299) -> f64 {
300 let base = candidate.base_score;
301
302 let overlap_bonus = match candidate.axes.len() {
304 0 | 1 => 1.0,
305 2 => 1.5,
306 3 => 2.0,
307 _ => 2.5,
308 };
309
310 let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
312
313 let era_bonus = if let (Some(track_year), Some(seed_year)) = (candidate.year, ctx.seed_avg_year)
315 {
316 let diff = (track_year - seed_year).unsigned_abs();
317 if diff <= 5 {
318 1.3
319 } else if diff <= 10 {
320 1.1
321 } else {
322 1.0
323 }
324 } else {
325 1.0
326 };
327
328 base * overlap_bonus * recency_bonus * era_bonus
329}
330
331fn compute_recency_bonus(conn: &Connection, track_id: i64, discovery_weight: f64) -> f64 {
334 let last_played = queries::last_played_at(conn, queries::LOCAL_USER, track_id).unwrap_or(None);
335 match last_played {
336 None => {
337 1.0 + discovery_weight * 2.0
339 }
340 Some(ts) => {
341 let now = SystemTime::now()
342 .duration_since(UNIX_EPOCH)
343 .unwrap_or_default()
344 .as_secs() as i64;
345 let days_ago = (now - ts) / 86400;
346 if days_ago > 180 {
347 1.0 + discovery_weight * 1.5 } else if days_ago > 30 {
349 1.0 + discovery_weight * 0.8
350 } else if days_ago > 7 {
351 1.0 + discovery_weight * 0.3
352 } else {
353 1.0 }
355 }
356 }
357}
358
359fn weighted_select(scored: &[(i64, f64)], count: usize) -> Vec<i64> {
362 if scored.is_empty() {
363 return vec![];
364 }
365
366 let pool_size = (count * 3).min(scored.len());
367 let pool = &scored[..pool_size];
368
369 let mut selected = Vec::new();
371 let mut used = HashSet::new();
372
373 for (id, _score) in pool {
374 if selected.len() >= count {
375 break;
376 }
377 if used.insert(*id) {
378 selected.push(*id);
379 }
380 }
381
382 selected
383}
384
385fn gather_listenbrainz_candidates(
388 conn: &Connection,
389 ctx: &RadioContext,
390 candidates: &mut Vec<Candidate>,
391) {
392 let http = reqwest::blocking::Client::new();
393
394 for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
395 let mbid: Option<String> = conn
397 .query_row(
398 "SELECT mbid FROM artists WHERE id = ?1",
399 rusqlite::params![artist_id],
400 |row| row.get(0),
401 )
402 .ok()
403 .flatten();
404
405 let mbid = match mbid {
406 Some(m) if !m.is_empty() => m,
407 _ => {
408 let artist_name: Option<String> = conn
410 .query_row(
411 "SELECT name FROM artists WHERE id = ?1",
412 rusqlite::params![artist_id],
413 |row| row.get(0),
414 )
415 .ok();
416 if let Some(name) = artist_name {
417 match musicbrainz::lookup_artist_mbid(&http, &name) {
418 Ok(Some(mbid)) => {
419 let _ = conn.execute(
421 "UPDATE artists SET mbid = ?1 WHERE id = ?2",
422 rusqlite::params![mbid, artist_id],
423 );
424 mbid
425 }
426 _ => continue,
427 }
428 } else {
429 continue;
430 }
431 }
432 };
433
434 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
436 .unwrap_or(false)
437 {
438 add_cached_similar_candidates(
440 conn,
441 ctx,
442 artist_id,
443 weight,
444 SimilarityAxis::ListenBrainz,
445 candidates,
446 );
447 continue;
448 }
449
450 match listenbrainz::get_similar_artists(&http, &mbid, 20) {
452 Ok(similar) => {
453 log::info!(
454 "radio: listenbrainz returned {} similar for artist_id={}",
455 similar.len(),
456 artist_id
457 );
458 let mut pairs: Vec<(i64, f64)> = Vec::new();
460 for sa in &similar {
461 let local_id: Option<i64> = conn
463 .query_row(
464 "SELECT id FROM artists WHERE mbid = ?1",
465 rusqlite::params![sa.mbid],
466 |row| row.get(0),
467 )
468 .ok()
469 .or_else(|| {
470 conn.query_row(
471 "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
472 rusqlite::params![sa.name],
473 |row| row.get(0),
474 )
475 .ok()
476 });
477
478 if let Some(local_id) = local_id
479 && local_id != artist_id
480 {
481 pairs.push((local_id, sa.score));
482 }
483 }
484
485 if !pairs.is_empty() {
486 let _ = queries::save_similar_artists(conn, artist_id, &pairs, "listenbrainz");
487 }
488
489 add_local_artist_candidates(
491 conn,
492 ctx,
493 &pairs,
494 weight,
495 SimilarityAxis::ListenBrainz,
496 candidates,
497 );
498 }
499 Err(e) => {
500 log::debug!(
501 "radio: listenbrainz failed for artist_id={}: {}",
502 artist_id,
503 e
504 );
505 }
507 }
508 }
509}
510
511fn gather_musicbrainz_candidates(
512 conn: &Connection,
513 ctx: &RadioContext,
514 candidates: &mut Vec<Candidate>,
515) {
516 let http = musicbrainz::default_client();
517
518 for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
519 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("musicbrainz"))
521 .unwrap_or(false)
522 {
523 add_cached_similar_candidates(
524 conn,
525 ctx,
526 artist_id,
527 weight,
528 SimilarityAxis::MusicBrainz,
529 candidates,
530 );
531 continue;
532 }
533
534 let mbid: Option<String> = conn
535 .query_row(
536 "SELECT mbid FROM artists WHERE id = ?1",
537 rusqlite::params![artist_id],
538 |row| row.get(0),
539 )
540 .ok()
541 .flatten();
542
543 let Some(mbid) = mbid.filter(|m| !m.is_empty()) else {
544 continue;
545 };
546
547 match musicbrainz::get_artist_relations(&http, &mbid) {
548 Ok(relations) => {
549 log::info!(
550 "radio: musicbrainz returned {} relations for artist_id={}",
551 relations.len(),
552 artist_id
553 );
554
555 let mut pairs: Vec<(i64, f64)> = Vec::new();
556
557 for rel in &relations {
558 let local_id: Option<i64> = conn
559 .query_row(
560 "SELECT id FROM artists WHERE mbid = ?1",
561 rusqlite::params![rel.mbid],
562 |row| row.get(0),
563 )
564 .ok()
565 .or_else(|| {
566 conn.query_row(
567 "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
568 rusqlite::params![rel.name],
569 |row| row.get(0),
570 )
571 .ok()
572 });
573
574 if let Some(local_id) = local_id
575 && local_id != artist_id
576 {
577 let score = match rel.category {
579 musicbrainz::RelationCategory::Member => 0.8,
580 musicbrainz::RelationCategory::Collaborator => 0.7,
581 musicbrainz::RelationCategory::Associated => 0.5,
582 };
583 pairs.push((local_id, score));
584 }
585 }
586
587 if !pairs.is_empty() {
588 let _ = queries::save_similar_artists_with_rel(
589 conn,
590 artist_id,
591 &pairs,
592 "musicbrainz",
593 "collaborator",
594 );
595 }
596
597 add_local_artist_candidates(
598 conn,
599 ctx,
600 &pairs,
601 weight,
602 SimilarityAxis::MusicBrainz,
603 candidates,
604 );
605 }
606 Err(e) => {
607 log::debug!(
608 "radio: musicbrainz relations failed for artist_id={}: {}",
609 artist_id,
610 e
611 );
612 }
613 }
614
615 std::thread::sleep(std::time::Duration::from_secs(1));
617 }
618}
619
620fn gather_subsonic_candidates(
621 conn: &Connection,
622 ctx: &RadioContext,
623 client: &SubsonicClient,
624 candidates: &mut Vec<Candidate>,
625) {
626 if let Some(ref remote_id) = ctx.current_remote_id {
627 match client.get_similar_songs(remote_id, 30) {
628 Ok(songs) => {
629 log::info!("radio: subsonic returned {} similar songs", songs.len());
630 for (i, song) in songs.iter().enumerate() {
631 if let Some(track_id) = resolve_subsonic_song_to_track(conn, song) {
632 let score = (songs.len() as f64 - i as f64) / songs.len() as f64;
633 let track = queries::get_track_row(conn, track_id).ok().flatten();
634 candidates.push(Candidate {
635 track_id,
636 path: track.as_ref().and_then(|t| t.path.clone()),
637 year: None,
638 axes: [SimilarityAxis::Subsonic].into_iter().collect(),
639 base_score: score * 0.9,
640 });
641 }
642 }
643
644 cache_subsonic_artist_relationships(conn, ctx, &songs);
646 }
647 Err(e) => {
648 log::debug!("radio: subsonic similar songs failed: {}", e);
649 }
650 }
651 }
652}
653
654fn gather_genre_era_candidates(
655 conn: &Connection,
656 ctx: &RadioContext,
657 candidates: &mut Vec<Candidate>,
658) {
659 if ctx.seed_genres.is_empty() {
660 return;
661 }
662
663 let exclude = ctx.drawn_out();
666 let mut tracks = Vec::new();
667 for genre in ctx.seed_genres.iter().take(3) {
668 let filter = queries::RandomFilter {
669 genre: Some(genre),
670 exclude: &exclude,
671 ..Default::default()
672 };
673 match queries::random_tracks_where(conn, 10, &filter) {
674 Ok(drawn) => tracks.extend(drawn),
675 Err(e) => log::debug!("radio: genre/era query failed: {}", e),
676 }
677 }
678 let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
679 let years = album_years(conn, &ids);
680
681 for track in tracks {
682 let year = years.get(&track.id).copied();
683
684 let genre_match = track
686 .genre
687 .as_ref()
688 .is_some_and(|g| ctx.seed_genres.contains(&g.to_lowercase()));
689 let era_match = match (year, ctx.seed_avg_year) {
690 (Some(y), Some(sy)) => (y as i64 - sy as i64).unsigned_abs() <= 10,
691 _ => false,
692 };
693
694 let base_score = match (genre_match, era_match) {
695 (true, true) => 0.6,
696 (true, false) => 0.3,
697 (false, true) => 0.2,
698 (false, false) => 0.1,
699 };
700
701 candidates.push(Candidate {
702 track_id: track.id,
703 path: track.path.clone(),
704 year,
705 axes: [SimilarityAxis::GenreEra].into_iter().collect(),
706 base_score,
707 });
708 }
709}
710
711fn gather_same_artist_candidates(
712 conn: &Connection,
713 ctx: &RadioContext,
714 candidates: &mut Vec<Candidate>,
715) {
716 let mut artists: Vec<(i64, f64)> = ctx.seed_artists.iter().map(|(&a, &w)| (a, w)).collect();
719 artists.sort_by(|a, b| b.1.total_cmp(&a.1));
720 let exclude = ctx.drawn_out();
721 for (artist_id, weight) in artists.into_iter().take(5) {
722 let filter = queries::RandomFilter {
723 artist_id: Some(artist_id),
724 exclude: &exclude,
725 ..Default::default()
726 };
727 match queries::random_tracks_where(conn, 3, &filter) {
728 Ok(tracks) => {
729 for track in tracks {
730 candidates.push(Candidate {
731 track_id: track.id,
732 path: track.path.clone(),
733 year: None,
734 axes: [SimilarityAxis::SameArtist].into_iter().collect(),
735 base_score: weight * 0.4, });
737 }
738 }
739 Err(e) => {
740 log::debug!("radio: same-artist query failed: {}", e);
741 }
742 }
743 }
744}
745
746fn gather_acoustic_candidates(
747 conn: &Connection,
748 seed_window: usize,
749 candidates: &mut Vec<Candidate>,
750) {
751 let seed_ids =
753 queries::recent_track_ids(conn, queries::LOCAL_USER, seed_window).unwrap_or_default();
754 let mut seed_embeddings = Vec::new();
755 for tid in &seed_ids {
756 if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
757 seed_embeddings.push(emb);
758 }
759 }
760
761 if seed_embeddings.is_empty() {
762 return;
763 }
764
765 let centroid = crate::index::features::centroid(&seed_embeddings);
766 let knn_result = queries::find_similar_to_vector(conn, ¢roid, 30, None);
767 match knn_result {
768 Ok(ref results) => {
769 let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
770 let mut added = 0;
771 for &(track_id, dist) in results {
772 if seed_ids.contains(&track_id) {
774 continue;
775 }
776 let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
778 let track = queries::get_track_row(conn, track_id).ok().flatten();
779 candidates.push(Candidate {
780 track_id,
781 path: track.as_ref().and_then(|t| t.path.clone()),
782 year: None,
783 axes: [SimilarityAxis::Acoustic].into_iter().collect(),
784 base_score: score,
785 });
786 added += 1;
787 }
788 log::info!(
789 "radio: acoustic signal added {} candidates from {} seed vectors",
790 added,
791 seed_embeddings.len()
792 );
793 }
794 Err(e) => {
795 log::debug!("radio: acoustic similarity query failed: {}", e);
796 }
797 }
798}
799
800fn gather_random_candidates(
801 conn: &Connection,
802 ctx: &RadioContext,
803 candidates: &mut Vec<Candidate>,
804) {
805 let exclude = ctx.drawn_out();
806 let filter = queries::RandomFilter {
807 exclude: &exclude,
808 ..Default::default()
809 };
810 match queries::random_tracks_where(conn, 10, &filter) {
811 Ok(tracks) => {
812 for track in tracks {
813 candidates.push(Candidate {
814 track_id: track.id,
815 path: track.path.clone(),
816 year: None,
817 axes: [SimilarityAxis::Random].into_iter().collect(),
818 base_score: 0.05, });
820 }
821 }
822 Err(e) => {
823 log::debug!("radio: random fallback failed: {}", e);
824 }
825 }
826}
827
828fn album_years(conn: &Connection, track_ids: &[i64]) -> HashMap<i64, i32> {
832 queries::queue_item_extras(conn, track_ids)
833 .unwrap_or_default()
834 .into_iter()
835 .filter_map(|(id, e)| {
836 let year = crate::helpers::year_of(e.album_date.as_deref()?)?;
837 Some((id, year.parse().ok()?))
838 })
839 .collect()
840}
841
842fn 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
857fn add_local_artist_candidates(
859 conn: &Connection,
860 ctx: &RadioContext,
861 pairs: &[(i64, f64)],
862 seed_weight: f64,
863 axis: SimilarityAxis,
864 candidates: &mut Vec<Candidate>,
865) {
866 let exclude = ctx.drawn_out();
867 for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
868 let filter = queries::RandomFilter {
869 artist_id: Some(similar_artist_id),
870 exclude: &exclude,
871 ..Default::default()
872 };
873 if let Ok(tracks) = queries::random_tracks_where(conn, 3, &filter) {
874 for track in tracks {
875 candidates.push(Candidate {
876 track_id: track.id,
877 path: track.path.clone(),
878 year: None,
879 axes: [axis].into_iter().collect(),
880 base_score: sim_score * seed_weight * 0.8,
881 });
882 }
883 }
884 }
885}
886
887fn 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
900fn 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
937pub fn spawn_autoqueue(
949 state: std::sync::Arc<crate::player::state::SharedPlayerState>,
950 tx: crossbeam_channel::Sender<crate::player::commands::PlayerCommand>,
951) {
952 use crate::player::commands::PlayerCommand;
953 use crate::player::state::{QueueEntryStatus, QueueItemId};
954
955 std::thread::Builder::new()
956 .name("koan-radio".into())
957 .spawn(move || {
958 use std::time::{Duration, Instant};
959
960 let mut cfg: Option<(crate::config::Config, Instant)> = None;
964 let mut fruitless: Option<(u64, Instant)> = None;
968 loop {
969 std::thread::sleep(Duration::from_secs(2));
970
971 if !state.radio_mode() || state.cursor().is_none() {
972 cfg = None;
973 continue;
974 }
975 log::debug!("radio: awake, cursor set");
976
977 if fruitless.is_some_and(|(version, at)| {
978 version == state.playlist_version() && at.elapsed() < Duration::from_secs(60)
979 }) {
980 continue;
981 }
982
983 if cfg
984 .as_ref()
985 .is_none_or(|(_, read)| read.elapsed() > Duration::from_secs(60))
986 {
987 cfg = Some((
988 crate::config::Config::load().unwrap_or_default(),
989 Instant::now(),
990 ));
991 }
992 let Some((cfg, _)) = &cfg else { continue };
993 let snapshot = state.derive_visible_queue();
994 let Some(playing) = snapshot
995 .entries
996 .iter()
997 .position(|e| e.status == QueueEntryStatus::Playing)
998 else {
999 log::debug!("radio: nothing is playing, waiting");
1000 continue;
1001 };
1002 let remaining = snapshot
1003 .entries
1004 .iter()
1005 .skip(playing + 1)
1006 .filter(|e| e.status == QueueEntryStatus::Queued)
1007 .count();
1008 if remaining > cfg.radio.lookahead {
1009 continue;
1010 }
1011 log::info!(
1012 "radio: {} queued after the cursor, topping up to {}",
1013 remaining,
1014 cfg.radio.lookahead
1015 );
1016
1017 let Ok(db) = crate::db::pool::shared().get() else {
1018 continue;
1019 };
1020 let version = state.playlist_version();
1021 let (items, cursor) = state.snapshot_playlist();
1022
1023 let item_ids: Vec<Option<i64>> = items
1027 .iter()
1028 .map(|item| {
1029 item.db_id.or_else(|| {
1030 let path = item.path.to_str()?;
1031 queries::track_id_by_path(&db.conn, path).ok().flatten()
1032 })
1033 })
1034 .collect();
1035 let ids: Vec<i64> = item_ids.iter().flatten().copied().collect();
1036 let queue_rows: HashMap<i64, queries::TrackRow> =
1037 queries::tracks_by_ids(&db.conn, &ids)
1038 .unwrap_or_default()
1039 .into_iter()
1040 .map(|t| (t.id, t))
1041 .collect();
1042 let row_of = |i: usize| item_ids[i].and_then(|id| queue_rows.get(&id));
1043
1044 let context: Vec<(Option<i64>, Option<String>)> = items
1048 .iter()
1049 .enumerate()
1050 .map(|(i, item)| {
1051 (
1052 row_of(i).and_then(|t| t.artist_id),
1053 Some(item.path.to_string_lossy().into_owned()),
1054 )
1055 })
1056 .collect();
1057
1058 let mut ctx = RadioContext::build(
1059 &db.conn,
1060 &context,
1061 cfg.radio.seed_window,
1062 cfg.radio.history_window,
1063 );
1064 ctx.queued_ids = queue_rows.keys().copied().collect();
1065 if let Some(current) = cursor.and_then(|cid| items.iter().position(|i| i.id == cid))
1066 && let Some(row) = row_of(current)
1067 {
1068 ctx.current_remote_id = row.remote_id.clone();
1069 ctx.current_artist_name = Some(row.artist_name.clone());
1070 }
1071 ctx.allow_network = false;
1082
1083 let picks = pick_tracks(&db.conn, &ctx, None, &cfg.radio);
1088 if picks.is_empty() {
1089 log::warn!("radio: the picker returned nothing for this seed");
1090 fruitless = Some((version, Instant::now()));
1091 continue;
1092 }
1093
1094 let queued: HashSet<String> = items
1098 .iter()
1099 .map(|i| i.path.to_string_lossy().into_owned())
1100 .collect();
1101 let rows: Vec<_> = queries::tracks_by_ids(&db.conn, &picks)
1102 .unwrap_or_default()
1103 .into_iter()
1104 .filter(|row| {
1105 row.path
1106 .as_deref()
1107 .or(row.cached_path.as_deref())
1108 .is_none_or(|p| !queued.contains(p))
1109 })
1110 .collect();
1111 if rows.is_empty() {
1112 log::warn!("radio: every pick was already in the queue");
1113 fruitless = Some((version, Instant::now()));
1114 continue;
1115 }
1116 fruitless = None;
1117
1118 let new_items = crate::helpers::playlist_items_for_tracks(&db, &rows);
1119 let pending: Vec<(i64, QueueItemId)> = new_items
1120 .iter()
1121 .filter(|i| matches!(i.state, crate::player::state::ItemState::Pending))
1122 .filter_map(|i| i.db_id.map(|id| (id, i.id)))
1123 .collect();
1124
1125 log::info!("radio: queueing {} tracks", new_items.len());
1126 if tx.send(PlayerCommand::AddToPlaylist(new_items)).is_err() {
1127 return; }
1129 if !pending.is_empty() {
1130 crate::helpers::spawn_downloads(pending, tx.clone(), state.clone());
1131 }
1132 }
1133 })
1134 .ok();
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::db::connection::Database;
1141 use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
1142
1143 fn test_db() -> Database {
1144 let conn = rusqlite::Connection::open_in_memory().unwrap();
1145 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1146 crate::db::schema::create_tables(&conn).unwrap();
1147 Database { conn }
1148 }
1149
1150 #[test]
1151 fn test_radio_context_from_queue() {
1152 let ctx = RadioContext::from_queue(&[
1153 (Some(1), Some("/a.flac".into())),
1154 (Some(2), Some("/b.flac".into())),
1155 (Some(1), Some("/c.flac".into())),
1156 ]);
1157 assert_eq!(ctx.seed_artists.len(), 2);
1158 assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
1159 assert_eq!(ctx.queued_paths.len(), 3);
1160 }
1161
1162 #[test]
1163 fn test_recency_bonus_never_played() {
1164 let db = test_db();
1165 let mut meta = sample_meta("T1", "A1", "Al1");
1166 meta.path = Some("/music/T1.flac".into());
1167 upsert_track(&db.conn, &meta).unwrap();
1168
1169 let track_id: i64 = db
1170 .conn
1171 .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1172 .unwrap();
1173
1174 let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1175 assert!(bonus > 1.0, "never-played should get a bonus");
1176 }
1177
1178 #[test]
1179 fn test_recency_bonus_recently_played() {
1180 let db = test_db();
1181 let mut meta = sample_meta("T1", "A1", "Al1");
1182 meta.path = Some("/music/T1.flac".into());
1183 upsert_track(&db.conn, &meta).unwrap();
1184
1185 let track_id: i64 = db
1186 .conn
1187 .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1188 .unwrap();
1189
1190 queries::record_play(
1191 &db.conn,
1192 crate::db::queries::LOCAL_USER,
1193 track_id,
1194 Some(240_000),
1195 )
1196 .unwrap();
1197
1198 let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1199 assert!(
1200 (bonus - 1.0).abs() < f64::EPSILON,
1201 "recently played should get no bonus"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_weighted_select_empty() {
1207 assert!(weighted_select(&[], 5).is_empty());
1208 }
1209
1210 #[test]
1211 fn test_weighted_select_fewer_than_requested() {
1212 let scored = vec![(1, 0.9), (2, 0.5)];
1213 let picks = weighted_select(&scored, 5);
1214 assert_eq!(picks.len(), 2);
1215 }
1216
1217 #[test]
1218 fn test_signal_overlap_scoring() {
1219 let db = test_db();
1220 let config = RadioConfig::default();
1221 let ctx = RadioContext::default();
1222
1223 let c1 = Candidate {
1225 track_id: 1,
1226 path: None,
1227 year: None,
1228 axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
1229 base_score: 0.5,
1230 };
1231
1232 let c3 = Candidate {
1234 track_id: 2,
1235 path: None,
1236 year: None,
1237 axes: [
1238 SimilarityAxis::ListenBrainz,
1239 SimilarityAxis::MusicBrainz,
1240 SimilarityAxis::GenreEra,
1241 ]
1242 .into_iter()
1243 .collect(),
1244 base_score: 0.5,
1245 };
1246
1247 let score1 = compute_score(&db.conn, &c1, &ctx, &config);
1248 let score3 = compute_score(&db.conn, &c3, &ctx, &config);
1249
1250 assert!(
1251 score3 > score1,
1252 "multi-axis candidate should score higher: {} vs {}",
1253 score3,
1254 score1
1255 );
1256 }
1257
1258 #[test]
1259 fn test_pick_tracks_empty_library() {
1260 let db = test_db();
1261 let ctx = RadioContext::from_queue(&[]);
1262 let config = RadioConfig::default();
1263 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1264 assert!(picks.is_empty());
1265 }
1266
1267 #[test]
1268 fn test_pick_tracks_with_library() {
1269 let db = test_db();
1270
1271 for i in 0..20 {
1273 let mut meta = sample_meta(
1274 &format!("Track{}", i),
1275 &format!("Artist{}", i % 5),
1276 &format!("Album{}", i % 3),
1277 );
1278 meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
1279 meta.track_number = Some(i);
1280 upsert_track(&db.conn, &meta).unwrap();
1281 }
1282
1283 let artist_id: i64 = db
1284 .conn
1285 .query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
1286 .unwrap();
1287
1288 let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
1289 let config = RadioConfig {
1290 batch_size: 5,
1291 ..RadioConfig::default()
1292 };
1293
1294 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1295 assert!(
1296 !picks.is_empty(),
1297 "should pick at least some tracks from a populated library"
1298 );
1299 assert!(picks.len() <= 5);
1300 }
1301
1302 #[test]
1303 fn test_pick_tracks_excludes_history() {
1304 let db = test_db();
1305
1306 for i in 0..5 {
1308 let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
1309 meta.path = Some(format!("/music/T{}.flac", i));
1310 meta.track_number = Some(i);
1311 upsert_track(&db.conn, &meta).unwrap();
1312 }
1313
1314 let mut ids = Vec::new();
1316 for i in 0..5 {
1317 let id: i64 = db
1318 .conn
1319 .query_row(
1320 "SELECT id FROM tracks WHERE path = ?1",
1321 rusqlite::params![format!("/music/T{}.flac", i)],
1322 |row| row.get(0),
1323 )
1324 .unwrap();
1325 queries::record_play(&db.conn, crate::db::queries::LOCAL_USER, id, Some(240_000))
1326 .unwrap();
1327 ids.push(id);
1328 }
1329
1330 let mut ctx = RadioContext::from_queue(&[]);
1331 ctx.excluded_track_ids = ids.into_iter().collect();
1332
1333 let config = RadioConfig {
1334 batch_size: 5,
1335 ..RadioConfig::default()
1336 };
1337
1338 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1339 assert!(
1341 picks.is_empty(),
1342 "all tracks in exclusion window, got picks"
1343 );
1344 }
1345
1346 #[test]
1347 fn test_radio_context_build_with_no_history() {
1348 let db = test_db();
1349
1350 for i in 0..5 {
1351 let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
1352 }
1353
1354 let queue = vec![
1355 (Some(1_i64), Some("/a.flac".to_string())),
1356 (Some(2), Some("/b.flac".to_string())),
1357 ];
1358 let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
1359
1360 assert_eq!(ctx.seed_artists.len(), 2);
1362 assert_eq!(ctx.queued_paths.len(), 2);
1363 }
1364
1365 fn library_with_seed_artist(db: &Database) -> i64 {
1367 for i in 0..300 {
1368 let mut meta = sample_meta(&format!("Other{i}"), &format!("Other{}", i % 30), "Mix");
1369 meta.path = Some(format!("/music/other/{i}.flac"));
1370 meta.genre = Some("Pop".into());
1371 upsert_track(&db.conn, &meta).unwrap();
1372 }
1373 for i in 0..3 {
1374 let mut meta = sample_meta(&format!("Seed{i}"), "Seed", "Seeds");
1375 meta.path = Some(format!("/music/seed/{i}.flac"));
1376 meta.genre = Some("IDM".into());
1377 upsert_track(&db.conn, &meta).unwrap();
1378 }
1379 get_or_create_artist(&db.conn, "Seed", None).unwrap()
1380 }
1381
1382 #[test]
1383 fn same_artist_picks_are_by_that_artist() {
1384 let db = test_db();
1385 let seed = library_with_seed_artist(&db);
1386 let ctx = RadioContext::from_queue(&[(Some(seed), None)]);
1387
1388 let mut candidates = Vec::new();
1389 gather_same_artist_candidates(&db.conn, &ctx, &mut candidates);
1390
1391 let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
1392 let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
1393 assert_eq!(rows.len(), 3);
1394 assert!(rows.iter().all(|t| t.artist_id == Some(seed)));
1395 }
1396
1397 #[test]
1398 fn genre_picks_have_the_seed_genre() {
1399 let db = test_db();
1400 let seed = library_with_seed_artist(&db);
1401 let ctx = RadioContext::build(&db.conn, &[(Some(seed), None)], 5, 200);
1402 assert_eq!(ctx.seed_genres, HashSet::from(["idm".to_string()]));
1403
1404 let mut candidates = Vec::new();
1405 gather_genre_era_candidates(&db.conn, &ctx, &mut candidates);
1406
1407 let ids: Vec<i64> = candidates.iter().map(|c| c.track_id).collect();
1408 let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap();
1409 assert_eq!(rows.len(), 3);
1410 assert!(rows.iter().all(|t| t.genre.as_deref() == Some("IDM")));
1411 }
1412
1413 #[test]
1414 fn queued_tracks_are_never_picked() {
1415 let db = test_db();
1416 let seed = library_with_seed_artist(&db);
1417 let mut ctx = RadioContext::from_queue(&[(Some(seed), None)]);
1418 ctx.queued_ids = db
1419 .conn
1420 .prepare("SELECT id FROM tracks")
1421 .unwrap()
1422 .query_map([], |r| r.get(0))
1423 .unwrap()
1424 .collect::<Result<_, _>>()
1425 .unwrap();
1426
1427 let picks = pick_tracks(&db.conn, &ctx, None, &RadioConfig::default());
1428 assert!(picks.is_empty());
1429 }
1430}