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-player",
|
||||
"qbz-qobuz",
|
||||
"qbz-radio",
|
||||
"qbz-core",
|
||||
"qbz-integrations",
|
||||
"qbz-cache",
|
||||
|
||||
@@ -10,6 +10,7 @@ qbz-models = { path = "../qbz-models" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-player = { path = "../qbz-player" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-radio = { path = "../qbz-radio" }
|
||||
qbz-integrations = { path = "../qbz-integrations" }
|
||||
|
||||
# Async
|
||||
|
||||
@@ -1138,6 +1138,65 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
.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)
|
||||
pub async fn get_artist_with_albums(
|
||||
&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(),
|
||||
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(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
|
||||
@@ -553,7 +553,11 @@ fn play_radio_response(
|
||||
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(
|
||||
runtime: Runtime,
|
||||
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`).
|
||||
pub fn play_album_radio(
|
||||
runtime: Runtime,
|
||||
|
||||
Reference in new issue
Block a user