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:
vicrodh committed 2026-05-21 07:49:01 -06:00
1 parent c19a5b84dd
commit 4a9cbdeb90
11 files changed
+1185 -2

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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
+59
View File
@@ -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,
+14
View File
@@ -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"
+275
View File
@@ -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(())
}
}
+510
View File
@@ -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())
}
}
+111
View File
@@ -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)
}
}
+18
View File
@@ -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;
+163
View File
@@ -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");
}
+4 -1
View File
@@ -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(),
+29 -1
View File
@@ -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,