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 allow_network: bool,
60 pub seed_artists: HashMap<i64, f64>,
62 pub queued_paths: HashSet<String>,
64 pub excluded_track_ids: HashSet<i64>,
66 pub current_remote_id: Option<String>,
68 pub current_artist_name: Option<String>,
70 pub seed_genres: HashSet<String>,
72 pub seed_avg_year: Option<i32>,
74}
75
76impl RadioContext {
77 pub fn build(
83 conn: &Connection,
84 queue_items: &[(Option<i64>, Option<String>)],
85 seed_window: usize,
86 history_window: usize,
87 ) -> Self {
88 let mut ctx = Self {
89 allow_network: true,
92 ..Self::default()
93 };
94
95 for (_aid, path) in queue_items {
97 if let Some(p) = path {
98 ctx.queued_paths.insert(p.clone());
99 }
100 }
101
102 let recent = queries::recent_track_ids(conn, seed_window).unwrap_or_default();
104 let seed_count = recent.len().max(1) as f64;
105
106 for (i, track_id) in recent.iter().enumerate() {
107 if let Ok(Some(track)) = queries::get_track_row(conn, *track_id) {
108 let weight = (seed_count - i as f64) / seed_count;
110 if let Some(aid) = track.artist_id {
111 let entry = ctx.seed_artists.entry(aid).or_insert(0.0);
112 *entry = entry.max(weight);
113 }
114 if let Some(ref genre) = track.genre {
115 ctx.seed_genres.insert(genre.clone());
116 }
117 }
118 }
119
120 if ctx.seed_artists.is_empty() {
122 for (artist_id, _path) in queue_items {
123 if let Some(aid) = artist_id {
124 *ctx.seed_artists.entry(*aid).or_default() += 1.0;
125 }
126 }
127 let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
129 for v in ctx.seed_artists.values_mut() {
130 *v /= max;
131 }
132
133 for (artist_id, _path) in queue_items {
135 if let Some(aid) = artist_id
136 && let Ok(tracks) = queries::random_tracks_excluding(conn, &[], &[*aid], &[], 1)
137 {
138 for t in tracks {
139 if let Some(ref g) = t.genre {
140 ctx.seed_genres.insert(g.clone());
141 }
142 }
143 }
144 }
145 }
146
147 let excluded = queries::recent_track_ids(conn, history_window).unwrap_or_default();
149 ctx.excluded_track_ids = excluded.into_iter().collect();
150
151 let mut years: Vec<i32> = Vec::new();
153 let seed_ids = queries::recent_track_ids(conn, seed_window).unwrap_or_default();
154 for tid in &seed_ids {
155 if let Ok(Some(track)) = queries::get_track_row(conn, *tid)
156 && let Some(album_id) = track.album_id
157 && let Ok(Some(album)) = queries::get_album(conn, album_id)
158 && let Some(ref date) = album.date
159 && let Ok(year) = date[..4.min(date.len())].parse::<i32>()
160 {
161 years.push(year);
162 }
163 }
164 if !years.is_empty() {
165 ctx.seed_avg_year = Some(years.iter().sum::<i32>() / years.len() as i32);
166 }
167
168 ctx
169 }
170
171 pub fn from_queue(items: &[(Option<i64>, Option<String>)]) -> Self {
173 let mut ctx = Self::default();
174 for (artist_id, path) in items {
175 if let Some(aid) = artist_id {
176 *ctx.seed_artists.entry(*aid).or_default() += 1.0;
177 }
178 if let Some(p) = path {
179 ctx.queued_paths.insert(p.clone());
180 }
181 }
182 let max = ctx.seed_artists.values().copied().fold(1.0_f64, f64::max);
184 if max > 0.0 {
185 for v in ctx.seed_artists.values_mut() {
186 *v /= max;
187 }
188 }
189 ctx
190 }
191}
192
193pub fn pick_tracks(
203 conn: &Connection,
204 ctx: &RadioContext,
205 client: Option<&SubsonicClient>,
206 config: &RadioConfig,
207) -> Vec<i64> {
208 let count = config.batch_size;
209 let mut candidates: Vec<Candidate> = Vec::new();
210
211 log::info!(
212 "radio: picking {} tracks (seed: {} artists, {} genres, {} excluded, remote_id={}, artist={})",
213 count,
214 ctx.seed_artists.len(),
215 ctx.seed_genres.len(),
216 ctx.excluded_track_ids.len(),
217 ctx.current_remote_id.as_deref().unwrap_or("none"),
218 ctx.current_artist_name.as_deref().unwrap_or("none"),
219 );
220
221 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, ctx, &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(Candidate {
258 track_id: c.track_id,
259 artist_id: c.artist_id,
260 path: c.path.clone(),
261 genre: c.genre.clone(),
262 year: c.year,
263 duration_ms: c.duration_ms,
264 axes: HashSet::new(),
265 base_score: 0.0,
266 });
267 entry.axes.extend(c.axes.iter());
268 entry.base_score = entry.base_score.max(c.base_score);
269 }
270
271 let mut scored: Vec<(i64, f64)> = deduped
273 .into_values()
274 .filter(|c| !ctx.excluded_track_ids.contains(&c.track_id))
275 .filter(|c| {
276 c.path
277 .as_ref()
278 .is_none_or(|p| !ctx.queued_paths.contains(p))
279 })
280 .map(|c| {
281 let score = compute_score(conn, &c, ctx, config);
282 (c.track_id, score)
283 })
284 .collect();
285
286 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
288
289 let picks = weighted_select(&scored, count);
291
292 log::info!("radio: picked {} tracks", picks.len());
293 picks
294}
295
296fn compute_score(
298 conn: &Connection,
299 candidate: &Candidate,
300 ctx: &RadioContext,
301 config: &RadioConfig,
302) -> f64 {
303 let base = candidate.base_score;
304
305 let overlap_bonus = match candidate.axes.len() {
307 0 | 1 => 1.0,
308 2 => 1.5,
309 3 => 2.0,
310 _ => 2.5,
311 };
312
313 let recency_bonus = compute_recency_bonus(conn, candidate.track_id, config.discovery_weight);
315
316 let era_bonus = if let (Some(track_year), Some(seed_year)) = (candidate.year, ctx.seed_avg_year)
318 {
319 let diff = (track_year - seed_year).unsigned_abs();
320 if diff <= 5 {
321 1.3
322 } else if diff <= 10 {
323 1.1
324 } else {
325 1.0
326 }
327 } else {
328 1.0
329 };
330
331 base * overlap_bonus * recency_bonus * era_bonus
332}
333
334fn compute_recency_bonus(conn: &Connection, track_id: i64, discovery_weight: f64) -> f64 {
337 let last_played = queries::last_played_at(conn, track_id).unwrap_or(None);
338 match last_played {
339 None => {
340 1.0 + discovery_weight * 2.0
342 }
343 Some(ts) => {
344 let now = SystemTime::now()
345 .duration_since(UNIX_EPOCH)
346 .unwrap_or_default()
347 .as_secs() as i64;
348 let days_ago = (now - ts) / 86400;
349 if days_ago > 180 {
350 1.0 + discovery_weight * 1.5 } else if days_ago > 30 {
352 1.0 + discovery_weight * 0.8
353 } else if days_ago > 7 {
354 1.0 + discovery_weight * 0.3
355 } else {
356 1.0 }
358 }
359 }
360}
361
362fn weighted_select(scored: &[(i64, f64)], count: usize) -> Vec<i64> {
365 if scored.is_empty() {
366 return vec![];
367 }
368
369 let pool_size = (count * 3).min(scored.len());
370 let pool = &scored[..pool_size];
371
372 let mut selected = Vec::new();
374 let mut used = HashSet::new();
375
376 for (id, _score) in pool {
377 if selected.len() >= count {
378 break;
379 }
380 if used.insert(*id) {
381 selected.push(*id);
382 }
383 }
384
385 selected
386}
387
388fn gather_listenbrainz_candidates(
391 conn: &Connection,
392 ctx: &RadioContext,
393 candidates: &mut Vec<Candidate>,
394) {
395 let http = reqwest::blocking::Client::new();
396
397 for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
398 let mbid: Option<String> = conn
400 .query_row(
401 "SELECT mbid FROM artists WHERE id = ?1",
402 rusqlite::params![artist_id],
403 |row| row.get(0),
404 )
405 .ok()
406 .flatten();
407
408 let mbid = match mbid {
409 Some(m) if !m.is_empty() => m,
410 _ => {
411 let artist_name: Option<String> = conn
413 .query_row(
414 "SELECT name FROM artists WHERE id = ?1",
415 rusqlite::params![artist_id],
416 |row| row.get(0),
417 )
418 .ok();
419 if let Some(name) = artist_name {
420 match musicbrainz::lookup_artist_mbid(&http, &name) {
421 Ok(Some(mbid)) => {
422 let _ = conn.execute(
424 "UPDATE artists SET mbid = ?1 WHERE id = ?2",
425 rusqlite::params![mbid, artist_id],
426 );
427 mbid
428 }
429 _ => continue,
430 }
431 } else {
432 continue;
433 }
434 }
435 };
436
437 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("listenbrainz"))
439 .unwrap_or(false)
440 {
441 add_cached_similar_candidates(
443 conn,
444 ctx,
445 artist_id,
446 weight,
447 SimilarityAxis::ListenBrainz,
448 candidates,
449 );
450 continue;
451 }
452
453 match listenbrainz::get_similar_artists(&http, &mbid, 20) {
455 Ok(similar) => {
456 log::info!(
457 "radio: listenbrainz returned {} similar for artist_id={}",
458 similar.len(),
459 artist_id
460 );
461 let mut pairs: Vec<(i64, f64)> = Vec::new();
463 for sa in &similar {
464 let local_id: Option<i64> = conn
466 .query_row(
467 "SELECT id FROM artists WHERE mbid = ?1",
468 rusqlite::params![sa.mbid],
469 |row| row.get(0),
470 )
471 .ok()
472 .or_else(|| {
473 conn.query_row(
474 "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
475 rusqlite::params![sa.name],
476 |row| row.get(0),
477 )
478 .ok()
479 });
480
481 if let Some(local_id) = local_id
482 && local_id != artist_id
483 {
484 pairs.push((local_id, sa.score));
485 }
486 }
487
488 if !pairs.is_empty() {
489 let _ = queries::save_similar_artists(conn, artist_id, &pairs, "listenbrainz");
490 }
491
492 add_local_artist_candidates(
494 conn,
495 ctx,
496 &pairs,
497 weight,
498 SimilarityAxis::ListenBrainz,
499 candidates,
500 );
501 }
502 Err(e) => {
503 log::debug!(
504 "radio: listenbrainz failed for artist_id={}: {}",
505 artist_id,
506 e
507 );
508 }
510 }
511 }
512}
513
514fn gather_musicbrainz_candidates(
515 conn: &Connection,
516 ctx: &RadioContext,
517 candidates: &mut Vec<Candidate>,
518) {
519 let http = musicbrainz::default_client();
520
521 for (&artist_id, &weight) in ctx.seed_artists.iter().take(3) {
522 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("musicbrainz"))
524 .unwrap_or(false)
525 {
526 add_cached_similar_candidates(
527 conn,
528 ctx,
529 artist_id,
530 weight,
531 SimilarityAxis::MusicBrainz,
532 candidates,
533 );
534 continue;
535 }
536
537 let mbid: Option<String> = conn
538 .query_row(
539 "SELECT mbid FROM artists WHERE id = ?1",
540 rusqlite::params![artist_id],
541 |row| row.get(0),
542 )
543 .ok()
544 .flatten();
545
546 let Some(mbid) = mbid.filter(|m| !m.is_empty()) else {
547 continue;
548 };
549
550 match musicbrainz::get_artist_relations(&http, &mbid) {
551 Ok(relations) => {
552 log::info!(
553 "radio: musicbrainz returned {} relations for artist_id={}",
554 relations.len(),
555 artist_id
556 );
557
558 let mut pairs: Vec<(i64, f64)> = Vec::new();
559
560 for rel in &relations {
561 let local_id: Option<i64> = conn
562 .query_row(
563 "SELECT id FROM artists WHERE mbid = ?1",
564 rusqlite::params![rel.mbid],
565 |row| row.get(0),
566 )
567 .ok()
568 .or_else(|| {
569 conn.query_row(
570 "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE",
571 rusqlite::params![rel.name],
572 |row| row.get(0),
573 )
574 .ok()
575 });
576
577 if let Some(local_id) = local_id
578 && local_id != artist_id
579 {
580 let score = match rel.category {
582 musicbrainz::RelationCategory::Member => 0.8,
583 musicbrainz::RelationCategory::Collaborator => 0.7,
584 musicbrainz::RelationCategory::Associated => 0.5,
585 };
586 pairs.push((local_id, score));
587 }
588 }
589
590 if !pairs.is_empty() {
591 let _ = queries::save_similar_artists_with_rel(
592 conn,
593 artist_id,
594 &pairs,
595 "musicbrainz",
596 "collaborator",
597 );
598 }
599
600 add_local_artist_candidates(
601 conn,
602 ctx,
603 &pairs,
604 weight,
605 SimilarityAxis::MusicBrainz,
606 candidates,
607 );
608 }
609 Err(e) => {
610 log::debug!(
611 "radio: musicbrainz relations failed for artist_id={}: {}",
612 artist_id,
613 e
614 );
615 }
616 }
617
618 std::thread::sleep(std::time::Duration::from_secs(1));
620 }
621}
622
623fn gather_subsonic_candidates(
624 conn: &Connection,
625 ctx: &RadioContext,
626 client: &SubsonicClient,
627 candidates: &mut Vec<Candidate>,
628) {
629 if let Some(ref remote_id) = ctx.current_remote_id {
630 match client.get_similar_songs(remote_id, 30) {
631 Ok(songs) => {
632 log::info!("radio: subsonic returned {} similar songs", songs.len());
633 for (i, song) in songs.iter().enumerate() {
634 if let Some(track_id) = resolve_subsonic_song_to_track(conn, song) {
635 let score = (songs.len() as f64 - i as f64) / songs.len() as f64;
636 let track = queries::get_track_row(conn, track_id).ok().flatten();
637 candidates.push(Candidate {
638 track_id,
639 artist_id: track.as_ref().and_then(|t| t.artist_id),
640 path: track.as_ref().and_then(|t| t.path.clone()),
641 genre: track.as_ref().and_then(|t| t.genre.clone()),
642 year: None,
643 duration_ms: track.as_ref().and_then(|t| t.duration_ms),
644 axes: [SimilarityAxis::Subsonic].into_iter().collect(),
645 base_score: score * 0.9,
646 });
647 }
648 }
649
650 cache_subsonic_artist_relationships(conn, ctx, &songs);
652 }
653 Err(e) => {
654 log::debug!("radio: subsonic similar songs failed: {}", e);
655 }
656 }
657 }
658}
659
660fn gather_genre_era_candidates(
661 conn: &Connection,
662 ctx: &RadioContext,
663 candidates: &mut Vec<Candidate>,
664) {
665 if ctx.seed_genres.is_empty() {
666 return;
667 }
668
669 let genres: Vec<String> = ctx.seed_genres.iter().cloned().collect();
670 let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
671
672 match queries::random_tracks_excluding(conn, &exclude, &[], &genres, 30) {
673 Ok(tracks) => {
674 for track in tracks {
675 let year = track
676 .album_id
677 .and_then(|aid| queries::get_album(conn, aid).ok().flatten())
678 .and_then(|a| {
679 a.date
680 .as_ref()
681 .and_then(|d| d[..4.min(d.len())].parse().ok())
682 });
683
684 let genre_match = track
686 .genre
687 .as_ref()
688 .is_some_and(|g| ctx.seed_genres.contains(g));
689 let era_match = match (year, ctx.seed_avg_year) {
690 (Some(y), Some(sy)) => (y as i64 - sy as i64).unsigned_abs() <= 10,
691 _ => false,
692 };
693
694 let base_score = match (genre_match, era_match) {
695 (true, true) => 0.6,
696 (true, false) => 0.3,
697 (false, true) => 0.2,
698 (false, false) => 0.1,
699 };
700
701 candidates.push(Candidate {
702 track_id: track.id,
703 artist_id: track.artist_id,
704 path: track.path.clone(),
705 genre: track.genre.clone(),
706 year,
707 duration_ms: track.duration_ms,
708 axes: [SimilarityAxis::GenreEra].into_iter().collect(),
709 base_score,
710 });
711 }
712 }
713 Err(e) => {
714 log::debug!("radio: genre/era query failed: {}", e);
715 }
716 }
717}
718
719fn gather_same_artist_candidates(
720 conn: &Connection,
721 ctx: &RadioContext,
722 candidates: &mut Vec<Candidate>,
723) {
724 let artist_ids: Vec<i64> = ctx.seed_artists.keys().copied().collect();
725 if artist_ids.is_empty() {
726 return;
727 }
728
729 let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
730 match queries::random_tracks_excluding(conn, &exclude, &artist_ids, &[], 15) {
731 Ok(tracks) => {
732 for track in tracks {
733 let weight = track
734 .artist_id
735 .and_then(|aid| ctx.seed_artists.get(&aid))
736 .copied()
737 .unwrap_or(0.3);
738
739 candidates.push(Candidate {
740 track_id: track.id,
741 artist_id: track.artist_id,
742 path: track.path.clone(),
743 genre: track.genre.clone(),
744 year: None,
745 duration_ms: track.duration_ms,
746 axes: [SimilarityAxis::SameArtist].into_iter().collect(),
747 base_score: weight * 0.4, });
749 }
750 }
751 Err(e) => {
752 log::debug!("radio: same-artist query failed: {}", e);
753 }
754 }
755}
756
757fn gather_acoustic_candidates(
758 conn: &Connection,
759 _ctx: &RadioContext,
760 candidates: &mut Vec<Candidate>,
761) {
762 let seed_ids = queries::recent_track_ids(conn, 5).unwrap_or_default();
764 let mut seed_embeddings = Vec::new();
765 for tid in &seed_ids {
766 if let Ok(Some(emb)) = queries::get_vector(conn, *tid) {
767 seed_embeddings.push(emb);
768 }
769 }
770
771 if seed_embeddings.is_empty() {
772 return;
773 }
774
775 let centroid = crate::index::features::centroid(&seed_embeddings);
776 let knn_result = queries::find_similar_to_vector(conn, ¢roid, 30, None);
777 match knn_result {
778 Ok(ref results) => {
779 let max_dist = results.last().map(|r| r.1).unwrap_or(1.0).max(0.001);
780 let mut added = 0;
781 for &(track_id, dist) in results {
782 if seed_ids.contains(&track_id) {
784 continue;
785 }
786 let score = (1.0 - (dist / max_dist)).max(0.0) as f64 * 0.7;
788 let track = queries::get_track_row(conn, track_id).ok().flatten();
789 candidates.push(Candidate {
790 track_id,
791 artist_id: track.as_ref().and_then(|t| t.artist_id),
792 path: track.as_ref().and_then(|t| t.path.clone()),
793 genre: track.as_ref().and_then(|t| t.genre.clone()),
794 year: None,
795 duration_ms: track.as_ref().and_then(|t| t.duration_ms),
796 axes: [SimilarityAxis::Acoustic].into_iter().collect(),
797 base_score: score,
798 });
799 added += 1;
800 }
801 log::info!(
802 "radio: acoustic signal added {} candidates from {} seed vectors",
803 added,
804 seed_embeddings.len()
805 );
806 }
807 Err(e) => {
808 log::debug!("radio: acoustic similarity query failed: {}", e);
809 }
810 }
811}
812
813fn gather_random_candidates(
814 conn: &Connection,
815 ctx: &RadioContext,
816 candidates: &mut Vec<Candidate>,
817) {
818 let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
819 match queries::random_tracks_excluding(conn, &exclude, &[], &[], 10) {
820 Ok(tracks) => {
821 for track in tracks {
822 candidates.push(Candidate {
823 track_id: track.id,
824 artist_id: track.artist_id,
825 path: track.path.clone(),
826 genre: track.genre.clone(),
827 year: None,
828 duration_ms: track.duration_ms,
829 axes: [SimilarityAxis::Random].into_iter().collect(),
830 base_score: 0.05, });
832 }
833 }
834 Err(e) => {
835 log::debug!("radio: random fallback failed: {}", e);
836 }
837 }
838}
839
840fn 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 for &(similar_artist_id, sim_score) in pairs.iter().take(10) {
867 let exclude: Vec<String> = ctx.queued_paths.iter().cloned().collect();
868 if let Ok(tracks) =
869 queries::random_tracks_excluding(conn, &exclude, &[similar_artist_id], &[], 3)
870 {
871 for track in tracks {
872 candidates.push(Candidate {
873 track_id: track.id,
874 artist_id: track.artist_id,
875 path: track.path.clone(),
876 genre: track.genre.clone(),
877 year: None,
878 duration_ms: track.duration_ms,
879 axes: [axis].into_iter().collect(),
880 base_score: sim_score * seed_weight * 0.8,
881 });
882 }
883 }
884 }
885}
886
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 fetch_and_cache_similar_artists(
940 conn: &Connection,
941 client: &SubsonicClient,
942 artist_id: i64,
943) -> Result<(), Box<dyn std::error::Error>> {
944 if queries::has_fresh_similar_artists_for_source(conn, artist_id, Some("subsonic"))
945 .unwrap_or(false)
946 {
947 return Ok(());
948 }
949
950 let track_remote_id: Option<String> = conn
951 .query_row(
952 "SELECT remote_id FROM tracks WHERE artist_id = ?1 AND remote_id IS NOT NULL LIMIT 1",
953 rusqlite::params![artist_id],
954 |row| row.get(0),
955 )
956 .ok()
957 .flatten();
958
959 let Some(track_remote_id) = track_remote_id else {
960 return Ok(());
961 };
962
963 let songs = client.get_similar_songs(&track_remote_id, 50)?;
964 let mut similar_artists: HashMap<i64, f64> = HashMap::new();
965 let total = songs.len() as f64;
966
967 for (i, song) in songs.iter().enumerate() {
968 if let Some(ref song_artist_id) = song.artist_id {
969 let local_artist_id: Option<i64> = conn
970 .query_row(
971 "SELECT id FROM artists WHERE remote_id = ?1",
972 rusqlite::params![song_artist_id],
973 |row| row.get(0),
974 )
975 .ok();
976
977 if let Some(local_id) = local_artist_id
978 && local_id != artist_id
979 {
980 let score = (total - i as f64) / total;
981 let entry = similar_artists.entry(local_id).or_insert(0.0);
982 *entry = entry.max(score);
983 }
984 }
985 }
986
987 if !similar_artists.is_empty() {
988 let pairs: Vec<(i64, f64)> = similar_artists.into_iter().collect();
989 queries::save_similar_artists(conn, artist_id, &pairs, "subsonic")?;
990 }
991
992 Ok(())
993}
994
995pub fn spawn_autoqueue(
1009 state: std::sync::Arc<crate::player::state::SharedPlayerState>,
1010 tx: crossbeam_channel::Sender<crate::player::commands::PlayerCommand>,
1011 db_path: std::path::PathBuf,
1012) {
1013 use crate::player::commands::PlayerCommand;
1014 use crate::player::state::{LoadState, QueueEntryStatus, QueueItemId};
1015
1016 std::thread::Builder::new()
1017 .name("koan-radio".into())
1018 .spawn(move || {
1019 loop {
1020 std::thread::sleep(std::time::Duration::from_secs(2));
1021
1022 if !state.radio_mode() || state.cursor().is_none() {
1023 continue;
1024 }
1025 log::debug!("radio: awake, cursor set");
1026
1027 let cfg = crate::config::Config::load().unwrap_or_default();
1028 let snapshot = state.derive_visible_queue();
1029 let Some(playing) = snapshot
1030 .entries
1031 .iter()
1032 .position(|e| e.status == QueueEntryStatus::Playing)
1033 else {
1034 log::debug!("radio: nothing is playing, waiting");
1035 continue;
1036 };
1037 let remaining = snapshot
1038 .entries
1039 .iter()
1040 .skip(playing + 1)
1041 .filter(|e| e.status == QueueEntryStatus::Queued)
1042 .count();
1043 if remaining > cfg.radio.lookahead {
1044 continue;
1045 }
1046 log::info!(
1047 "radio: {} queued after the cursor, topping up to {}",
1048 remaining,
1049 cfg.radio.lookahead
1050 );
1051
1052 let Ok(db) = crate::db::connection::Database::open(&db_path) else {
1053 continue;
1054 };
1055 let (items, cursor) = state.snapshot_playlist();
1056
1057 let context: Vec<(Option<i64>, Option<String>)> = items
1061 .iter()
1062 .map(|item| {
1063 let row = item
1064 .path
1065 .to_str()
1066 .and_then(|p| queries::track_id_by_path(&db.conn, p).ok())
1067 .flatten()
1068 .and_then(|id| queries::get_track_row(&db.conn, id).ok())
1069 .flatten();
1070 (
1071 row.as_ref().and_then(|t| t.artist_id),
1072 Some(item.path.to_string_lossy().into_owned()),
1073 )
1074 })
1075 .collect();
1076
1077 let mut ctx = RadioContext::build(
1078 &db.conn,
1079 &context,
1080 cfg.radio.seed_window,
1081 cfg.radio.history_window,
1082 );
1083 if let Some(current) = cursor.and_then(|cid| items.iter().find(|i| i.id == cid))
1084 && let Some(row) = current
1085 .path
1086 .to_str()
1087 .and_then(|p| queries::track_id_by_path(&db.conn, p).ok())
1088 .flatten()
1089 .and_then(|id| queries::get_track_row(&db.conn, id).ok())
1090 .flatten()
1091 {
1092 ctx.current_remote_id = row.remote_id.clone();
1093 ctx.current_artist_name = Some(row.artist_name.clone());
1094 }
1095 ctx.allow_network = false;
1109
1110 let picks = pick_tracks(&db.conn, &ctx, None, &cfg.radio);
1115 if picks.is_empty() {
1116 log::warn!("radio: the picker returned nothing for this seed");
1117 continue;
1118 }
1119
1120 let queued: HashSet<String> = items
1124 .iter()
1125 .map(|i| i.path.to_string_lossy().into_owned())
1126 .collect();
1127 let rows: Vec<_> = queries::tracks_by_ids(&db.conn, &picks)
1128 .unwrap_or_default()
1129 .into_iter()
1130 .filter(|row| {
1131 row.path
1132 .as_deref()
1133 .or(row.cached_path.as_deref())
1134 .is_none_or(|p| !queued.contains(p))
1135 })
1136 .collect();
1137 if rows.is_empty() {
1138 log::warn!("radio: every pick was already in the queue");
1139 continue;
1140 }
1141
1142 let new_items = crate::helpers::playlist_items_for_tracks(&db, &rows);
1143 let pending: Vec<(i64, QueueItemId)> = new_items
1144 .iter()
1145 .filter(|i| matches!(i.load_state, LoadState::Pending))
1146 .filter_map(|i| i.db_id.map(|id| (id, i.id)))
1147 .collect();
1148
1149 log::info!("radio: queueing {} tracks", new_items.len());
1150 if tx.send(PlayerCommand::AddToPlaylist(new_items)).is_err() {
1151 return; }
1153 if !pending.is_empty() {
1154 crate::helpers::spawn_downloads(pending, tx.clone(), state.clone());
1155 }
1156 }
1157 })
1158 .ok();
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use crate::db::connection::Database;
1165 use crate::db::queries::{get_or_create_artist, sample_meta, upsert_track};
1166
1167 fn test_db() -> Database {
1168 let conn = rusqlite::Connection::open_in_memory().unwrap();
1169 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1170 crate::db::schema::create_tables(&conn).unwrap();
1171 Database { conn }
1172 }
1173
1174 #[test]
1175 fn test_radio_context_from_queue() {
1176 let ctx = RadioContext::from_queue(&[
1177 (Some(1), Some("/a.flac".into())),
1178 (Some(2), Some("/b.flac".into())),
1179 (Some(1), Some("/c.flac".into())),
1180 ]);
1181 assert_eq!(ctx.seed_artists.len(), 2);
1182 assert!(ctx.seed_artists[&1] > ctx.seed_artists[&2]);
1183 assert_eq!(ctx.queued_paths.len(), 3);
1184 }
1185
1186 #[test]
1187 fn test_recency_bonus_never_played() {
1188 let db = test_db();
1189 let mut meta = sample_meta("T1", "A1", "Al1");
1190 meta.path = Some("/music/T1.flac".into());
1191 upsert_track(&db.conn, &meta).unwrap();
1192
1193 let track_id: i64 = db
1194 .conn
1195 .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1196 .unwrap();
1197
1198 let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1199 assert!(bonus > 1.0, "never-played should get a bonus");
1200 }
1201
1202 #[test]
1203 fn test_recency_bonus_recently_played() {
1204 let db = test_db();
1205 let mut meta = sample_meta("T1", "A1", "Al1");
1206 meta.path = Some("/music/T1.flac".into());
1207 upsert_track(&db.conn, &meta).unwrap();
1208
1209 let track_id: i64 = db
1210 .conn
1211 .query_row("SELECT id FROM tracks LIMIT 1", [], |row| row.get(0))
1212 .unwrap();
1213
1214 queries::record_play(&db.conn, track_id, Some(240_000)).unwrap();
1215
1216 let bonus = compute_recency_bonus(&db.conn, track_id, 0.3);
1217 assert!(
1218 (bonus - 1.0).abs() < f64::EPSILON,
1219 "recently played should get no bonus"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_weighted_select_empty() {
1225 assert!(weighted_select(&[], 5).is_empty());
1226 }
1227
1228 #[test]
1229 fn test_weighted_select_fewer_than_requested() {
1230 let scored = vec![(1, 0.9), (2, 0.5)];
1231 let picks = weighted_select(&scored, 5);
1232 assert_eq!(picks.len(), 2);
1233 }
1234
1235 #[test]
1236 fn test_signal_overlap_scoring() {
1237 let db = test_db();
1238 let config = RadioConfig::default();
1239 let ctx = RadioContext::default();
1240
1241 let c1 = Candidate {
1243 track_id: 1,
1244 artist_id: None,
1245 path: None,
1246 genre: None,
1247 year: None,
1248 duration_ms: None,
1249 axes: [SimilarityAxis::ListenBrainz].into_iter().collect(),
1250 base_score: 0.5,
1251 };
1252
1253 let c3 = Candidate {
1255 track_id: 2,
1256 artist_id: None,
1257 path: None,
1258 genre: None,
1259 year: None,
1260 duration_ms: None,
1261 axes: [
1262 SimilarityAxis::ListenBrainz,
1263 SimilarityAxis::MusicBrainz,
1264 SimilarityAxis::GenreEra,
1265 ]
1266 .into_iter()
1267 .collect(),
1268 base_score: 0.5,
1269 };
1270
1271 let score1 = compute_score(&db.conn, &c1, &ctx, &config);
1272 let score3 = compute_score(&db.conn, &c3, &ctx, &config);
1273
1274 assert!(
1275 score3 > score1,
1276 "multi-axis candidate should score higher: {} vs {}",
1277 score3,
1278 score1
1279 );
1280 }
1281
1282 #[test]
1283 fn test_pick_tracks_empty_library() {
1284 let db = test_db();
1285 let ctx = RadioContext::from_queue(&[]);
1286 let config = RadioConfig::default();
1287 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1288 assert!(picks.is_empty());
1289 }
1290
1291 #[test]
1292 fn test_pick_tracks_with_library() {
1293 let db = test_db();
1294
1295 for i in 0..20 {
1297 let mut meta = sample_meta(
1298 &format!("Track{}", i),
1299 &format!("Artist{}", i % 5),
1300 &format!("Album{}", i % 3),
1301 );
1302 meta.path = Some(format!("/music/Album{}/Track{}.flac", i % 3, i));
1303 meta.track_number = Some(i);
1304 upsert_track(&db.conn, &meta).unwrap();
1305 }
1306
1307 let artist_id: i64 = db
1308 .conn
1309 .query_row("SELECT id FROM artists LIMIT 1", [], |row| row.get(0))
1310 .unwrap();
1311
1312 let ctx = RadioContext::from_queue(&[(Some(artist_id), Some("/queued.flac".into()))]);
1313 let config = RadioConfig {
1314 batch_size: 5,
1315 ..RadioConfig::default()
1316 };
1317
1318 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1319 assert!(
1320 !picks.is_empty(),
1321 "should pick at least some tracks from a populated library"
1322 );
1323 assert!(picks.len() <= 5);
1324 }
1325
1326 #[test]
1327 fn test_pick_tracks_excludes_history() {
1328 let db = test_db();
1329
1330 for i in 0..5 {
1332 let mut meta = sample_meta(&format!("T{}", i), "Artist", "Album");
1333 meta.path = Some(format!("/music/T{}.flac", i));
1334 meta.track_number = Some(i);
1335 upsert_track(&db.conn, &meta).unwrap();
1336 }
1337
1338 let mut ids = Vec::new();
1340 for i in 0..5 {
1341 let id: i64 = db
1342 .conn
1343 .query_row(
1344 "SELECT id FROM tracks WHERE path = ?1",
1345 rusqlite::params![format!("/music/T{}.flac", i)],
1346 |row| row.get(0),
1347 )
1348 .unwrap();
1349 queries::record_play(&db.conn, id, Some(240_000)).unwrap();
1350 ids.push(id);
1351 }
1352
1353 let mut ctx = RadioContext::from_queue(&[]);
1354 ctx.excluded_track_ids = ids.into_iter().collect();
1355
1356 let config = RadioConfig {
1357 batch_size: 5,
1358 ..RadioConfig::default()
1359 };
1360
1361 let picks = pick_tracks(&db.conn, &ctx, None, &config);
1362 assert!(
1364 picks.is_empty(),
1365 "all tracks in exclusion window, got picks"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_radio_context_build_with_no_history() {
1371 let db = test_db();
1372
1373 for i in 0..5 {
1374 let _id = get_or_create_artist(&db.conn, &format!("Artist{}", i), None).unwrap();
1375 }
1376
1377 let queue = vec![
1378 (Some(1_i64), Some("/a.flac".to_string())),
1379 (Some(2), Some("/b.flac".to_string())),
1380 ];
1381 let ctx = RadioContext::build(&db.conn, &queue, 5, 200);
1382
1383 assert_eq!(ctx.seed_artists.len(), 2);
1385 assert_eq!(ctx.queued_paths.len(), 2);
1386 }
1387}