feat(qbz-radio): agnostic smart-radio pool builder crate
Ports the Tauri radio_engine into a standalone, frontend-agnostic
crate so any client (Slint, TUI, CLI) can build and play a smart
radio without Tauri.
- New crate qbz-radio (builder + db + engine + tests), copied
verbatim from src-tauri/radio_engine with the only change being
the dependency rewrite: crate::api::{QobuzClient, Track,
TracksContainer} -> qbz_qobuz::QobuzClient + qbz_models. Depends
only on qbz-qobuz + qbz-models + rusqlite + dirs. All four engine
tests pass.
- QbzCore::create_smart_artist_radio builds the session pool + pulls
up to 50 track ids + resolves them to full tracks. The radio DB's
rusqlite Connection is !Send, so the build + pull run on
spawn_blocking threads (the async builder driven via block_on),
mirroring the Tauri command.
- The artist "radio" action (Spotlight RADIO card + artist radio)
now uses the smart builder (playback::play_smart_artist_radio);
album radio (Radio Stations) keeps the Qobuz /radio/album
endpoint, so both radio systems are in use. play_artist_radio
(Qobuz /radio/artist) is retained as the simpler alternative.
cc
This commit is contained in:
11 files changed
+1185
-2
No files matched your search
@@ -7,6 +7,7 @@ members = [
|
|||||||
"qbz-audio",
|
"qbz-audio",
|
||||||
"qbz-player",
|
"qbz-player",
|
||||||
"qbz-qobuz",
|
"qbz-qobuz",
|
||||||
|
"qbz-radio",
|
||||||
"qbz-core",
|
"qbz-core",
|
||||||
"qbz-integrations",
|
"qbz-integrations",
|
||||||
"qbz-cache",
|
"qbz-cache",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ qbz-models = { path = "../qbz-models" }
|
|||||||
qbz-audio = { path = "../qbz-audio" }
|
qbz-audio = { path = "../qbz-audio" }
|
||||||
qbz-player = { path = "../qbz-player" }
|
qbz-player = { path = "../qbz-player" }
|
||||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||||
|
qbz-radio = { path = "../qbz-radio" }
|
||||||
qbz-integrations = { path = "../qbz-integrations" }
|
qbz-integrations = { path = "../qbz-integrations" }
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
|
|||||||
@@ -1138,6 +1138,65 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
.map_err(CoreError::Api)
|
.map_err(CoreError::Api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Smart artist radio via the local `qbz-radio` pool builder — a
|
||||||
|
/// richer alternative to the Qobuz `/radio/artist` endpoint. Builds
|
||||||
|
/// a session pool (seed tracks + similar artists + second-degree)
|
||||||
|
/// in the local radio DB, pulls up to 50 track ids from the engine,
|
||||||
|
/// and resolves them to full tracks.
|
||||||
|
///
|
||||||
|
/// The radio DB uses a `!Send` rusqlite connection, so the pool
|
||||||
|
/// build + pull run on blocking threads (mirroring the Tauri
|
||||||
|
/// command); the async builder is driven there via `block_on`.
|
||||||
|
pub async fn create_smart_artist_radio(
|
||||||
|
&self,
|
||||||
|
artist_id: u64,
|
||||||
|
) -> Result<Vec<Track>, CoreError> {
|
||||||
|
let client = {
|
||||||
|
let guard = self.client.read().await;
|
||||||
|
guard.as_ref().ok_or(CoreError::NotInitialized)?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let build_client = client.clone();
|
||||||
|
let session_id = tokio::task::spawn_blocking(move || -> Result<String, String> {
|
||||||
|
let db = qbz_radio::RadioDb::open_default()?;
|
||||||
|
let builder = qbz_radio::RadioPoolBuilder::new(
|
||||||
|
&db,
|
||||||
|
&build_client,
|
||||||
|
qbz_radio::BuildRadioOptions::default(),
|
||||||
|
);
|
||||||
|
let session = tokio::runtime::Handle::current()
|
||||||
|
.block_on(builder.create_artist_radio(artist_id))?;
|
||||||
|
Ok(session.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::Internal(format!("radio build task: {e}")))?
|
||||||
|
.map_err(CoreError::Internal)?;
|
||||||
|
|
||||||
|
let ids = tokio::task::spawn_blocking(move || -> Result<Vec<u64>, String> {
|
||||||
|
let db = qbz_radio::RadioDb::open_default()?;
|
||||||
|
let engine = qbz_radio::RadioEngine::new(db);
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for _ in 0..60 {
|
||||||
|
match engine.next_track(&session_id) {
|
||||||
|
Ok(track) => ids.push(track.track_id),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ids.into_iter().take(50).collect())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::Internal(format!("radio pull task: {e}")))?
|
||||||
|
.map_err(CoreError::Internal)?;
|
||||||
|
|
||||||
|
let mut tracks = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
if let Ok(track) = client.get_track(id).await {
|
||||||
|
tracks.push(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get artist with albums (for album pagination)
|
/// Get artist with albums (for album pagination)
|
||||||
pub async fn get_artist_with_albums(
|
pub async fn get_artist_with_albums(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "qbz-radio"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Agnostic smart-radio pool builder for QBZ (ported from the Tauri radio_engine)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
qbz-models = { path = "../qbz-models" }
|
||||||
|
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||||
|
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
log = { workspace = true }
|
||||||
|
dirs = "5"
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
use super::db::{RadioDb, RadioSeed, RadioSession};
|
||||||
|
use qbz_models::{Track, TracksContainer};
|
||||||
|
use qbz_qobuz::QobuzClient;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BuildRadioOptions {
|
||||||
|
pub rng_seed: u64,
|
||||||
|
pub artist_spacing: u32,
|
||||||
|
pub reseed_every: u32,
|
||||||
|
|
||||||
|
pub seed_tracks_limit: u32,
|
||||||
|
|
||||||
|
pub playlist_limit: usize,
|
||||||
|
pub playlist_track_limit: usize,
|
||||||
|
|
||||||
|
pub similar_artists_limit: u32,
|
||||||
|
pub similar_artist_tracks_limit: u32,
|
||||||
|
|
||||||
|
pub min_pool_size_for_second_degree: u32,
|
||||||
|
pub second_degree_artist_limit: u32,
|
||||||
|
pub second_degree_tracks_limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BuildRadioOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rng_seed: 0,
|
||||||
|
artist_spacing: 5,
|
||||||
|
reseed_every: 25,
|
||||||
|
seed_tracks_limit: 150,
|
||||||
|
|
||||||
|
playlist_limit: 3,
|
||||||
|
playlist_track_limit: 200,
|
||||||
|
|
||||||
|
similar_artists_limit: 8,
|
||||||
|
similar_artist_tracks_limit: 30,
|
||||||
|
|
||||||
|
min_pool_size_for_second_degree: 80,
|
||||||
|
second_degree_artist_limit: 3,
|
||||||
|
second_degree_tracks_limit: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RadioPoolBuilder<'a> {
|
||||||
|
pub db: &'a RadioDb,
|
||||||
|
pub client: &'a QobuzClient,
|
||||||
|
pub options: BuildRadioOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RadioPoolBuilder<'a> {
|
||||||
|
pub fn new(db: &'a RadioDb, client: &'a QobuzClient, options: BuildRadioOptions) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
client,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_qobuz_owned(owner_name: &str) -> bool {
|
||||||
|
owner_name.to_ascii_lowercase().contains("qobuz")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_artist_id(track: &Track, fallback: u64) -> u64 {
|
||||||
|
if let Some(performer) = track.performer.as_ref() {
|
||||||
|
if performer.id != 0 {
|
||||||
|
return performer.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_music_track(track: &Track) -> bool {
|
||||||
|
track.streamable && track.duration > 0 && track.album.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_rng_seed(default_seed: u64, salt: u64) -> u64 {
|
||||||
|
if default_seed != 0 {
|
||||||
|
return default_seed;
|
||||||
|
}
|
||||||
|
(std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0))
|
||||||
|
^ salt
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_artist_radio(&self, artist_id: u64) -> Result<RadioSession, String> {
|
||||||
|
let rng_seed = Self::derive_rng_seed(self.options.rng_seed, artist_id);
|
||||||
|
let session = self.db.create_session(
|
||||||
|
RadioSeed::Artist { artist_id },
|
||||||
|
rng_seed,
|
||||||
|
self.options.artist_spacing,
|
||||||
|
self.options.reseed_every,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.build_pool_for_seed_artist(&session.id, artist_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_track_radio(
|
||||||
|
&self,
|
||||||
|
track_id: u64,
|
||||||
|
artist_id: u64,
|
||||||
|
) -> Result<RadioSession, String> {
|
||||||
|
let rng_seed = Self::derive_rng_seed(self.options.rng_seed, track_id);
|
||||||
|
let session = self.db.create_session(
|
||||||
|
RadioSeed::Track {
|
||||||
|
track_id,
|
||||||
|
artist_id,
|
||||||
|
},
|
||||||
|
rng_seed,
|
||||||
|
self.options.artist_spacing,
|
||||||
|
self.options.reseed_every,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let track = self
|
||||||
|
.client
|
||||||
|
.get_track(track_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch seed track: {}", e))?;
|
||||||
|
|
||||||
|
let track_artist_id = Self::track_artist_id(&track, artist_id);
|
||||||
|
if Self::is_music_track(&track) {
|
||||||
|
self.db
|
||||||
|
.insert_pool_track(&session.id, track.id, track_artist_id, "seed_track", 0)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.build_pool_for_seed_artist(&session.id, artist_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_pool_for_seed_artist(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
seed_artist_id: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 1) Curated artist playlists (distance 1)
|
||||||
|
let artist_detail = self
|
||||||
|
.client
|
||||||
|
.get_artist_detail(seed_artist_id, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch artist detail: {}", e))?;
|
||||||
|
|
||||||
|
let mut playlists = artist_detail.playlists.unwrap_or_default();
|
||||||
|
playlists.retain(|p| Self::is_qobuz_owned(&p.owner.name));
|
||||||
|
playlists.sort_by(|a, b| {
|
||||||
|
b.tracks_count
|
||||||
|
.cmp(&a.tracks_count)
|
||||||
|
.then_with(|| a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
|
||||||
|
for playlist in playlists.into_iter().take(self.options.playlist_limit) {
|
||||||
|
let playlist = self
|
||||||
|
.client
|
||||||
|
.get_playlist(playlist.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch playlist {}: {}", playlist.id, e))?;
|
||||||
|
|
||||||
|
let tracks = playlist.tracks.map(|t| t.items).unwrap_or_default();
|
||||||
|
|
||||||
|
for t in tracks.into_iter().take(self.options.playlist_track_limit) {
|
||||||
|
if !Self::is_music_track(&t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist_id = Self::track_artist_id(&t, seed_artist_id);
|
||||||
|
self.db
|
||||||
|
.insert_pool_track(session_id, t.id, artist_id, "curated_playlist", 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Seed tracks (distance 0)
|
||||||
|
let tracks: TracksContainer = self
|
||||||
|
.client
|
||||||
|
.get_artist_tracks(seed_artist_id, self.options.seed_tracks_limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch seed artist tracks: {}", e))?;
|
||||||
|
|
||||||
|
for t in tracks.items {
|
||||||
|
if !Self::is_music_track(&t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist_id = Self::track_artist_id(&t, seed_artist_id);
|
||||||
|
self.db
|
||||||
|
.insert_pool_track(session_id, t.id, artist_id, "seed_tracks", 0)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Similar artists (distance 1)
|
||||||
|
let similar = self
|
||||||
|
.client
|
||||||
|
.get_similar_artists(seed_artist_id, self.options.similar_artists_limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch similar artists: {}", e))?;
|
||||||
|
|
||||||
|
let mut first_degree_artist_ids: Vec<u64> =
|
||||||
|
similar.items.into_iter().map(|a| a.id).collect();
|
||||||
|
first_degree_artist_ids.retain(|id| *id != 0 && *id != seed_artist_id);
|
||||||
|
first_degree_artist_ids.sort();
|
||||||
|
first_degree_artist_ids.dedup();
|
||||||
|
|
||||||
|
for artist_id in first_degree_artist_ids.iter().copied() {
|
||||||
|
let tracks = self
|
||||||
|
.client
|
||||||
|
.get_artist_tracks(artist_id, self.options.similar_artist_tracks_limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch similar artist tracks: {}", e))?;
|
||||||
|
for t in tracks.items {
|
||||||
|
if !Self::is_music_track(&t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist_id = Self::track_artist_id(&t, artist_id);
|
||||||
|
self.db
|
||||||
|
.insert_pool_track(session_id, t.id, artist_id, "similar_artist", 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Second-degree expansion (distance 2, bounded)
|
||||||
|
let pool_size = self.db.pool_size(session_id)?;
|
||||||
|
if pool_size < self.options.min_pool_size_for_second_degree
|
||||||
|
&& self.options.second_degree_artist_limit > 0
|
||||||
|
{
|
||||||
|
let mut added = 0u32;
|
||||||
|
|
||||||
|
for base_artist_id in first_degree_artist_ids.into_iter().take(2) {
|
||||||
|
if added >= self.options.second_degree_artist_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let second = self
|
||||||
|
.client
|
||||||
|
.get_similar_artists(base_artist_id, self.options.second_degree_artist_limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch second-degree similar artists: {}", e))?;
|
||||||
|
|
||||||
|
for a in second.items {
|
||||||
|
if added >= self.options.second_degree_artist_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if a.id == 0 || a.id == seed_artist_id || a.id == base_artist_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks = self
|
||||||
|
.client
|
||||||
|
.get_artist_tracks(a.id, self.options.second_degree_tracks_limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
format!("Failed to fetch second-degree artist tracks: {}", e)
|
||||||
|
})?;
|
||||||
|
for t in tracks.items {
|
||||||
|
if !Self::is_music_track(&t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let artist_id = Self::track_artist_id(&t, a.id);
|
||||||
|
self.db.insert_pool_track(
|
||||||
|
session_id,
|
||||||
|
t.id,
|
||||||
|
artist_id,
|
||||||
|
"second_degree",
|
||||||
|
2,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RadioSeed {
|
||||||
|
Artist { artist_id: u64 },
|
||||||
|
Track { track_id: u64, artist_id: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioSeed {
|
||||||
|
pub fn seed_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RadioSeed::Artist { .. } => "artist",
|
||||||
|
RadioSeed::Track { .. } => "track",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seed_id(&self) -> String {
|
||||||
|
match self {
|
||||||
|
RadioSeed::Artist { artist_id } => artist_id.to_string(),
|
||||||
|
RadioSeed::Track { track_id, .. } => track_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seed_artist_id(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
RadioSeed::Artist { artist_id } => *artist_id,
|
||||||
|
RadioSeed::Track { artist_id, .. } => *artist_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RadioSession {
|
||||||
|
pub id: String,
|
||||||
|
pub seed: RadioSeed,
|
||||||
|
pub rng_seed: u64,
|
||||||
|
pub selection_count: u32,
|
||||||
|
pub artist_spacing: u32,
|
||||||
|
pub reseed_every: u32,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RadioTrackRef {
|
||||||
|
pub track_id: u64,
|
||||||
|
pub artist_id: u64,
|
||||||
|
pub source: String,
|
||||||
|
pub distance: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RadioDb {
|
||||||
|
conn: Connection,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioDb {
|
||||||
|
pub fn open_default() -> Result<Self, String> {
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.ok_or("Could not determine data directory")?
|
||||||
|
.join("qbz")
|
||||||
|
.join("radio");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&data_dir)
|
||||||
|
.map_err(|e| format!("Failed to create radio data directory: {}", e))?;
|
||||||
|
|
||||||
|
let db_path = data_dir.join("radio_engine.db");
|
||||||
|
Self::open(&db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_at(base_dir: &Path) -> Result<Self, String> {
|
||||||
|
let radio_dir = base_dir.join("radio");
|
||||||
|
std::fs::create_dir_all(&radio_dir)
|
||||||
|
.map_err(|e| format!("Failed to create radio data directory: {}", e))?;
|
||||||
|
let db_path = radio_dir.join("radio_engine.db");
|
||||||
|
Self::open(&db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(path: &Path) -> Result<Self, String> {
|
||||||
|
let conn =
|
||||||
|
Connection::open(path).map_err(|e| format!("Failed to open radio database: {}", e))?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||||
|
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||||
|
let db = Self {
|
||||||
|
conn,
|
||||||
|
path: Some(path.to_path_buf()),
|
||||||
|
};
|
||||||
|
db.init()?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_in_memory() -> Result<Self, String> {
|
||||||
|
let conn = Connection::open_in_memory()
|
||||||
|
.map_err(|e| format!("Failed to open in-memory radio database: {}", e))?;
|
||||||
|
let db = Self { conn, path: None };
|
||||||
|
db.init()?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(&self) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS radio_session (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
seed_type TEXT NOT NULL,
|
||||||
|
seed_id TEXT NOT NULL,
|
||||||
|
seed_artist_id INTEGER NOT NULL,
|
||||||
|
rng_seed INTEGER NOT NULL,
|
||||||
|
selection_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
artist_spacing INTEGER NOT NULL DEFAULT 5,
|
||||||
|
reseed_every INTEGER NOT NULL DEFAULT 25,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radio_pool (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
track_id INTEGER NOT NULL,
|
||||||
|
artist_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
distance INTEGER NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (session_id, track_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_radio_pool_session_used_distance
|
||||||
|
ON radio_pool(session_id, used, distance);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_radio_pool_session_artist
|
||||||
|
ON radio_pool(session_id, artist_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radio_history (
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
track_id INTEGER NOT NULL,
|
||||||
|
artist_id INTEGER NOT NULL,
|
||||||
|
played_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (session_id, seq)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_radio_history_session_track
|
||||||
|
ON radio_history(session_id, track_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to initialize radio database: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ts() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(
|
||||||
|
&self,
|
||||||
|
seed: RadioSeed,
|
||||||
|
rng_seed: u64,
|
||||||
|
artist_spacing: u32,
|
||||||
|
reseed_every: u32,
|
||||||
|
) -> Result<RadioSession, String> {
|
||||||
|
let created_at = Self::now_ts();
|
||||||
|
let id = format!("radio_{}_{}", created_at, rng_seed);
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO radio_session (
|
||||||
|
id, seed_type, seed_id, seed_artist_id, rng_seed, selection_count, artist_spacing, reseed_every, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?8)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
seed.seed_type(),
|
||||||
|
seed.seed_id(),
|
||||||
|
seed.seed_artist_id() as i64,
|
||||||
|
rng_seed as i64,
|
||||||
|
artist_spacing as i64,
|
||||||
|
reseed_every as i64,
|
||||||
|
created_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to insert radio session: {}", e))?;
|
||||||
|
|
||||||
|
Ok(RadioSession {
|
||||||
|
id,
|
||||||
|
seed,
|
||||||
|
rng_seed,
|
||||||
|
selection_count: 0,
|
||||||
|
artist_spacing,
|
||||||
|
reseed_every,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_session(&self, session_id: &str) -> Result<RadioSession, String> {
|
||||||
|
let row = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
r#"
|
||||||
|
SELECT seed_type, seed_id, seed_artist_id, rng_seed, selection_count, artist_spacing, reseed_every, created_at
|
||||||
|
FROM radio_session
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
params![session_id],
|
||||||
|
|row| {
|
||||||
|
let seed_type: String = row.get(0)?;
|
||||||
|
let seed_id: String = row.get(1)?;
|
||||||
|
let seed_artist_id: i64 = row.get(2)?;
|
||||||
|
let rng_seed: i64 = row.get(3)?;
|
||||||
|
let selection_count: i64 = row.get(4)?;
|
||||||
|
let artist_spacing: i64 = row.get(5)?;
|
||||||
|
let reseed_every: i64 = row.get(6)?;
|
||||||
|
let created_at: i64 = row.get(7)?;
|
||||||
|
|
||||||
|
let seed = match seed_type.as_str() {
|
||||||
|
"artist" => RadioSeed::Artist {
|
||||||
|
artist_id: seed_id.parse::<u64>().unwrap_or(seed_artist_id as u64),
|
||||||
|
},
|
||||||
|
"track" => RadioSeed::Track {
|
||||||
|
track_id: seed_id.parse::<u64>().unwrap_or(0),
|
||||||
|
artist_id: seed_artist_id as u64,
|
||||||
|
},
|
||||||
|
_ => RadioSeed::Artist {
|
||||||
|
artist_id: seed_artist_id as u64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RadioSession {
|
||||||
|
id: session_id.to_string(),
|
||||||
|
seed,
|
||||||
|
rng_seed: rng_seed as u64,
|
||||||
|
selection_count: selection_count as u32,
|
||||||
|
artist_spacing: artist_spacing as u32,
|
||||||
|
reseed_every: reseed_every as u32,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to load radio session: {}", e))?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool_size(&self, session_id: &str) -> Result<u32, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM radio_pool WHERE session_id = ?",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.map_err(|e| format!("Failed to query radio pool size: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool_used_count(&self, session_id: &str) -> Result<u32, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM radio_pool WHERE session_id = ? AND used = 1",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.map_err(|e| format!("Failed to query radio pool used count: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool_unused_count(&self, session_id: &str) -> Result<u32, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM radio_pool WHERE session_id = ? AND used = 0",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.map_err(|e| format!("Failed to query radio pool unused count: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history_len(&self, session_id: &str) -> Result<u32, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM radio_history WHERE session_id = ?",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|v| v as u32)
|
||||||
|
.map_err(|e| format!("Failed to query radio history length: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_pool_track(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
track_id: u64,
|
||||||
|
artist_id: u64,
|
||||||
|
source: &str,
|
||||||
|
distance: u8,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if distance > 2 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT OR IGNORE INTO radio_pool (session_id, track_id, artist_id, source, distance, used)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
session_id,
|
||||||
|
track_id as i64,
|
||||||
|
artist_id as i64,
|
||||||
|
source,
|
||||||
|
distance as i64
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to insert radio pool track: {}", e))?;
|
||||||
|
|
||||||
|
// If the track already exists, keep the smallest distance (and update source accordingly).
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
UPDATE radio_pool
|
||||||
|
SET distance = ?3, source = ?4
|
||||||
|
WHERE session_id = ?1 AND track_id = ?2 AND distance > ?3
|
||||||
|
"#,
|
||||||
|
params![session_id, track_id as i64, distance as i64, source],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to update radio pool track distance: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn insert_pool_track_unchecked(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
track_id: u64,
|
||||||
|
artist_id: u64,
|
||||||
|
source: &str,
|
||||||
|
distance: u8,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
INSERT OR REPLACE INTO radio_pool (session_id, track_id, artist_id, source, distance, used)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
session_id,
|
||||||
|
track_id as i64,
|
||||||
|
artist_id as i64,
|
||||||
|
source,
|
||||||
|
distance as i64
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to insert unchecked radio pool track: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_artist_ids(&self, session_id: &str, n: u32) -> Result<Vec<u64>, String> {
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
r#"
|
||||||
|
SELECT artist_id
|
||||||
|
FROM radio_history
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY seq DESC
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to prepare recent artists query: {}", e))?;
|
||||||
|
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![session_id, n as i64], |row| row.get::<_, i64>(0))
|
||||||
|
.map_err(|e| format!("Failed to query recent artists: {}", e))?;
|
||||||
|
|
||||||
|
let mut artists = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
artists
|
||||||
|
.push(row.map_err(|e| format!("Failed to read recent artist row: {}", e))? as u64);
|
||||||
|
}
|
||||||
|
Ok(artists)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_unused_candidates(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
exclude_artist_ids: &[u64],
|
||||||
|
) -> Result<Vec<RadioTrackRef>, String> {
|
||||||
|
let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
|
||||||
|
params_vec.push(rusqlite::types::Value::Text(session_id.to_string()));
|
||||||
|
|
||||||
|
let mut sql = String::from(
|
||||||
|
r#"
|
||||||
|
SELECT track_id, artist_id, source, distance
|
||||||
|
FROM radio_pool
|
||||||
|
WHERE session_id = ? AND used = 0 AND distance <= 2
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !exclude_artist_ids.is_empty() {
|
||||||
|
sql.push_str(" AND artist_id NOT IN (");
|
||||||
|
for (idx, artist_id) in exclude_artist_ids.iter().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
sql.push(',');
|
||||||
|
}
|
||||||
|
sql.push('?');
|
||||||
|
params_vec.push(rusqlite::types::Value::Integer(*artist_id as i64));
|
||||||
|
}
|
||||||
|
sql.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.push_str(" ORDER BY distance ASC, track_id ASC");
|
||||||
|
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(&sql)
|
||||||
|
.map_err(|e| format!("Failed to prepare candidates query: {}", e))?;
|
||||||
|
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
|
||||||
|
Ok(RadioTrackRef {
|
||||||
|
track_id: row.get::<_, i64>(0)? as u64,
|
||||||
|
artist_id: row.get::<_, i64>(1)? as u64,
|
||||||
|
source: row.get::<_, String>(2)?,
|
||||||
|
distance: row.get::<_, i64>(3)? as u8,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to query candidates: {}", e))?;
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
candidates.push(row.map_err(|e| format!("Failed to read candidate row: {}", e))?);
|
||||||
|
}
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_played(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
session_selection_count: u32,
|
||||||
|
track: &RadioTrackRef,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let played_at = Self::now_ts();
|
||||||
|
let seq = (session_selection_count as i64) + 1;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute("BEGIN TRANSACTION", [])
|
||||||
|
.map_err(|e| format!("Failed to begin radio transaction: {}", e))?;
|
||||||
|
|
||||||
|
let mark_used = self.conn.execute(
|
||||||
|
"UPDATE radio_pool SET used = 1 WHERE session_id = ? AND track_id = ?",
|
||||||
|
params![session_id, track.track_id as i64],
|
||||||
|
);
|
||||||
|
if let Err(e) = mark_used {
|
||||||
|
let _ = self.conn.execute("ROLLBACK", []);
|
||||||
|
return Err(format!("Failed to mark radio track used: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let insert_history = self.conn.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO radio_history (session_id, seq, track_id, artist_id, played_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
session_id,
|
||||||
|
seq,
|
||||||
|
track.track_id as i64,
|
||||||
|
track.artist_id as i64,
|
||||||
|
played_at
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = insert_history {
|
||||||
|
let _ = self.conn.execute("ROLLBACK", []);
|
||||||
|
return Err(format!("Failed to insert radio history: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let update_session = self.conn.execute(
|
||||||
|
"UPDATE radio_session SET selection_count = ?2 WHERE id = ?1",
|
||||||
|
params![session_id, seq],
|
||||||
|
);
|
||||||
|
if let Err(e) = update_session {
|
||||||
|
let _ = self.conn.execute("ROLLBACK", []);
|
||||||
|
return Err(format!("Failed to update radio session: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute("COMMIT", [])
|
||||||
|
.map_err(|e| format!("Failed to commit radio transaction: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_track_been_played(&self, session_id: &str, track_id: u64) -> Result<bool, String> {
|
||||||
|
let result: Option<i64> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM radio_history WHERE session_id = ? AND track_id = ? LIMIT 1",
|
||||||
|
params![session_id, track_id as i64],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| format!("Failed to query radio history: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result.is_some())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use super::db::{RadioDb, RadioTrackRef};
|
||||||
|
|
||||||
|
pub struct RadioEngine {
|
||||||
|
db: RadioDb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioEngine {
|
||||||
|
pub fn new(db: RadioDb) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db(&self) -> &RadioDb {
|
||||||
|
&self.db
|
||||||
|
}
|
||||||
|
|
||||||
|
fn splitmix64(mut x: u64) -> u64 {
|
||||||
|
x = x.wrapping_add(0x9E3779B97F4A7C15);
|
||||||
|
let mut z = x;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_weight(distance: u8) -> u32 {
|
||||||
|
match distance {
|
||||||
|
0 => 6,
|
||||||
|
1 => 3,
|
||||||
|
2 => 1,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_weight(track: &RadioTrackRef, reseed_tick: bool) -> u32 {
|
||||||
|
let mut w = Self::base_weight(track.distance);
|
||||||
|
if w == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if reseed_tick {
|
||||||
|
if track.distance == 0 {
|
||||||
|
w = w.saturating_add(6);
|
||||||
|
}
|
||||||
|
if track.source == "curated_playlist" {
|
||||||
|
w = w.saturating_add(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_track(&self, session_id: &str) -> Result<RadioTrackRef, String> {
|
||||||
|
let session = self.db.load_session(session_id)?;
|
||||||
|
|
||||||
|
let mut spacing = session.artist_spacing;
|
||||||
|
let candidates = loop {
|
||||||
|
let recent_artists = self.db.get_recent_artist_ids(session_id, spacing)?;
|
||||||
|
let cands = self.db.get_unused_candidates(session_id, &recent_artists)?;
|
||||||
|
if !cands.is_empty() || spacing == 0 {
|
||||||
|
break cands;
|
||||||
|
}
|
||||||
|
spacing = spacing.saturating_sub(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err("Radio session exhausted: no eligible tracks left".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_index = session.selection_count.saturating_add(1);
|
||||||
|
let reseed_tick = session.reseed_every > 0 && (next_index % session.reseed_every == 0);
|
||||||
|
|
||||||
|
let mut total_weight: u64 = 0;
|
||||||
|
let mut weights: Vec<u32> = Vec::with_capacity(candidates.len());
|
||||||
|
for t in &candidates {
|
||||||
|
let w = Self::effective_weight(t, reseed_tick);
|
||||||
|
weights.push(w);
|
||||||
|
total_weight = total_weight.saturating_add(w as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_weight == 0 {
|
||||||
|
let chosen = candidates
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "Radio selection failed: no candidates".to_string())?;
|
||||||
|
self.db
|
||||||
|
.mark_played(session_id, session.selection_count, &chosen)?;
|
||||||
|
return Ok(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = Self::splitmix64(session.rng_seed ^ (next_index as u64));
|
||||||
|
let mut pick = (r % total_weight) as u64;
|
||||||
|
|
||||||
|
let mut chosen_idx = 0usize;
|
||||||
|
for (idx, w) in weights.iter().enumerate() {
|
||||||
|
let w = *w as u64;
|
||||||
|
if pick < w {
|
||||||
|
chosen_idx = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pick = pick.saturating_sub(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = candidates
|
||||||
|
.get(chosen_idx)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "Radio selection failed: chosen candidate missing".to_string())?;
|
||||||
|
|
||||||
|
self.db
|
||||||
|
.mark_played(session_id, session.selection_count, &chosen)?;
|
||||||
|
|
||||||
|
Ok(chosen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! qbz-radio — agnostic smart-radio pool builder.
|
||||||
|
//!
|
||||||
|
//! Ported verbatim (logic-wise) from the Tauri `radio_engine`, but
|
||||||
|
//! decoupled from Tauri: it depends only on `qbz-qobuz` (the API
|
||||||
|
//! client) and `qbz-models` (the domain types) plus `rusqlite` for
|
||||||
|
//! the local radio pool DB. Any frontend (Slint, TUI, CLI) can build
|
||||||
|
//! and play a smart radio without Tauri.
|
||||||
|
|
||||||
|
pub mod builder;
|
||||||
|
pub mod db;
|
||||||
|
pub mod engine;
|
||||||
|
|
||||||
|
pub use builder::{BuildRadioOptions, RadioPoolBuilder};
|
||||||
|
pub use db::{RadioDb, RadioSeed, RadioSession, RadioTrackRef};
|
||||||
|
pub use engine::RadioEngine;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
use super::db::{RadioDb, RadioSeed};
|
||||||
|
use super::engine::RadioEngine;
|
||||||
|
|
||||||
|
fn seed_artist_id() -> u64 {
|
||||||
|
42
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radio_no_repetition() {
|
||||||
|
let db = RadioDb::open_in_memory().unwrap();
|
||||||
|
let session = db
|
||||||
|
.create_session(
|
||||||
|
RadioSeed::Artist {
|
||||||
|
artist_id: seed_artist_id(),
|
||||||
|
},
|
||||||
|
123,
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for track_id in 1u64..=120u64 {
|
||||||
|
let artist_id = 1000 + (track_id % 10);
|
||||||
|
let distance = if track_id % 7 == 0 {
|
||||||
|
2
|
||||||
|
} else if track_id % 3 == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
db.insert_pool_track(&session.id, track_id, artist_id, "test_pool", distance)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = RadioEngine::new(db);
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..120 {
|
||||||
|
let t = engine.next_track(&session.id).unwrap();
|
||||||
|
assert!(seen.insert(t.track_id), "Track repeated: {}", t.track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radio_artist_spacing() {
|
||||||
|
let db = RadioDb::open_in_memory().unwrap();
|
||||||
|
let session = db
|
||||||
|
.create_session(
|
||||||
|
RadioSeed::Artist {
|
||||||
|
artist_id: seed_artist_id(),
|
||||||
|
},
|
||||||
|
999,
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let artists: Vec<u64> = (1u64..=6u64).collect();
|
||||||
|
let mut track_id = 1u64;
|
||||||
|
for a in &artists {
|
||||||
|
for _ in 0..30 {
|
||||||
|
db.insert_pool_track(&session.id, track_id, *a, "test_pool", 1)
|
||||||
|
.unwrap();
|
||||||
|
track_id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = RadioEngine::new(db);
|
||||||
|
let mut recent: std::collections::VecDeque<u64> = std::collections::VecDeque::new();
|
||||||
|
for _ in 0..120 {
|
||||||
|
let t = engine.next_track(&session.id).unwrap();
|
||||||
|
assert!(
|
||||||
|
!recent.contains(&t.artist_id),
|
||||||
|
"Artist repeated within spacing window: {}",
|
||||||
|
t.artist_id
|
||||||
|
);
|
||||||
|
recent.push_back(t.artist_id);
|
||||||
|
while recent.len() > 5 {
|
||||||
|
recent.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radio_distance_constraint() {
|
||||||
|
let db = RadioDb::open_in_memory().unwrap();
|
||||||
|
let session = db
|
||||||
|
.create_session(
|
||||||
|
RadioSeed::Artist {
|
||||||
|
artist_id: seed_artist_id(),
|
||||||
|
},
|
||||||
|
555,
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for track_id in 1u64..=50u64 {
|
||||||
|
db.insert_pool_track(&session.id, track_id, 1, "ok", 2)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for track_id in 1001u64..=1020u64 {
|
||||||
|
db.insert_pool_track_unchecked(&session.id, track_id, 2, "too_far", 3)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = RadioEngine::new(db);
|
||||||
|
for _ in 0..50 {
|
||||||
|
let t = engine.next_track(&session.id).unwrap();
|
||||||
|
assert!(
|
||||||
|
t.distance <= 2,
|
||||||
|
"Selected track with distance {}",
|
||||||
|
t.distance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radio_stability_over_time_pool_constant_seed_present() {
|
||||||
|
let db = RadioDb::open_in_memory().unwrap();
|
||||||
|
let session = db
|
||||||
|
.create_session(
|
||||||
|
RadioSeed::Artist {
|
||||||
|
artist_id: seed_artist_id(),
|
||||||
|
},
|
||||||
|
2025,
|
||||||
|
5,
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let seed_artist = seed_artist_id();
|
||||||
|
let mut track_id = 1u64;
|
||||||
|
|
||||||
|
for _ in 0..200 {
|
||||||
|
db.insert_pool_track(&session.id, track_id, seed_artist, "seed_tracks", 0)
|
||||||
|
.unwrap();
|
||||||
|
track_id += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for a in 100u64..=199u64 {
|
||||||
|
for _ in 0..2 {
|
||||||
|
db.insert_pool_track(&session.id, track_id, a, "similar_artist", 1)
|
||||||
|
.unwrap();
|
||||||
|
track_id += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool_size = db.pool_size(&session.id).unwrap();
|
||||||
|
assert!(pool_size >= 350);
|
||||||
|
|
||||||
|
let engine = RadioEngine::new(db);
|
||||||
|
let mut picks: Vec<u64> = Vec::new();
|
||||||
|
for _ in 0..220 {
|
||||||
|
let t = engine.next_track(&session.id).unwrap();
|
||||||
|
picks.push(t.artist_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool_size_after = engine.db().pool_size(&session.id).unwrap();
|
||||||
|
assert_eq!(pool_size, pool_size_after);
|
||||||
|
|
||||||
|
let seed_in_last_60 = picks.iter().rev().take(60).any(|a| *a == seed_artist);
|
||||||
|
assert!(seed_in_last_60, "Seed artist missing late in session");
|
||||||
|
}
|
||||||
@@ -1233,7 +1233,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
handle.clone(),
|
handle.clone(),
|
||||||
id.clone(),
|
id.clone(),
|
||||||
),
|
),
|
||||||
("artist", "radio") => playback::play_artist_radio(
|
// Artist radio uses the smart qbz-radio pool builder
|
||||||
|
// (the Qobuz /radio/artist endpoint remains available
|
||||||
|
// via playback::play_artist_radio for an alternative).
|
||||||
|
("artist", "radio") => playback::play_smart_artist_radio(
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
weak.clone(),
|
weak.clone(),
|
||||||
handle.clone(),
|
handle.clone(),
|
||||||
|
|||||||
@@ -553,7 +553,11 @@ fn play_radio_response(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a Qobuz artist radio (`/radio/artist`).
|
/// Start a Qobuz artist radio (`/radio/artist`). Kept as the simpler
|
||||||
|
/// alternative to the smart pool builder; the artist "radio" action
|
||||||
|
/// uses the smart builder, this remains available for an explicit
|
||||||
|
/// "Qobuz radio" choice.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn play_artist_radio(
|
pub fn play_artist_radio(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
@@ -573,6 +577,30 @@ pub fn play_artist_radio(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a smart artist radio via the local qbz-radio pool builder
|
||||||
|
/// (richer than the plain Qobuz `/radio/artist`).
|
||||||
|
pub fn play_smart_artist_radio(
|
||||||
|
runtime: Runtime,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
artist_id: String,
|
||||||
|
) {
|
||||||
|
handle.spawn(async move {
|
||||||
|
let Ok(aid) = artist_id.parse::<u64>() else {
|
||||||
|
log::warn!("[qbz-slint] smart radio: bad artist id {artist_id}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match runtime.core().create_smart_artist_radio(aid).await {
|
||||||
|
Ok(tracks) => {
|
||||||
|
if !play_radio_response(runtime, weak, tracks) {
|
||||||
|
log::warn!("[qbz-slint] smart artist radio {aid} returned no tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => log::error!("[qbz-slint] smart artist radio {aid} failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a Qobuz album radio (`/radio/album`).
|
/// Start a Qobuz album radio (`/radio/album`).
|
||||||
pub fn play_album_radio(
|
pub fn play_album_radio(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
|
|||||||
Reference in new issue
Block a user