Files
qobuz-qt/rust/src/lib.rs
joren 1e4c234b5c fix: gapless toggle now actually controls audio output lifecycle
When gapless is off, the AudioOutput is dropped after each track ends
naturally, producing a real gap on the next play. When on, the output
stays alive so tracks transition seamlessly. Also re-adds URL prefetch
gating behind the same toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:39:24 +01:00

633 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! qobuz-backend: C-ABI library consumed by the Qt frontend.
//!
//! Event type constants second argument of EventCallback:
//! 0 EV_LOGIN_OK { "token": "…", "user": { … } }
//! 1 EV_LOGIN_ERR { "error": "…" }
//! 2 EV_SEARCH_OK SearchCatalogDto
//! 3 EV_SEARCH_ERR { "error": "…" }
//! 4 EV_ALBUM_OK AlbumDto
//! 5 EV_ALBUM_ERR { "error": "…" }
//! 6 EV_ARTIST_OK ArtistDto
//! 7 EV_ARTIST_ERR { "error": "…" }
//! 8 EV_PLAYLIST_OK PlaylistDto
//! 9 EV_PLAYLIST_ERR { "error": "…" }
//! 10 EV_FAV_TRACKS_OK { "items": […], "total": N }
//! 11 EV_FAV_ALBUMS_OK { "items": […], "total": N }
//! 12 EV_FAV_ARTISTS_OK { "items": […], "total": N }
//! 13 EV_PLAYLISTS_OK { "items": […], "total": N }
//! 14 EV_TRACK_CHANGED TrackDto
//! 15 EV_STATE_CHANGED { "state": "playing"|"paused"|"idle"|"error" }
//! 16 EV_POSITION { "position": u64, "duration": u64 }
//! 17 EV_TRACK_URL_OK TrackFileUrlDto
//! 18 EV_TRACK_URL_ERR { "error": "…" }
//! 19 EV_GENERIC_ERR { "error": "…" }
mod api;
mod player;
use std::{
ffi::{CStr, CString},
os::raw::{c_char, c_int, c_void},
sync::Arc,
};
use api::{Format, QobuzClient};
use player::{Player, PlayerState};
use tokio::runtime::Runtime;
use tokio::sync::Mutex;
// ---------- Send-safe raw pointer wrapper ----------
/// Wraps a `*mut c_void` so it can cross thread boundaries.
/// SAFETY: The Qt QobuzBackend object is kept alive for the Backend's lifetime
/// and callbacks only call QMetaObject::invokeMethod (thread-safe Qt API).
#[derive(Clone, Copy)]
struct SendPtr(*mut c_void);
unsafe impl Send for SendPtr {}
unsafe impl Sync for SendPtr {}
// ---------- Event type constants ----------
pub const EV_LOGIN_OK: c_int = 0;
pub const EV_LOGIN_ERR: c_int = 1;
pub const EV_SEARCH_OK: c_int = 2;
pub const EV_SEARCH_ERR: c_int = 3;
pub const EV_ALBUM_OK: c_int = 4;
pub const EV_ALBUM_ERR: c_int = 5;
pub const EV_ARTIST_OK: c_int = 6;
pub const EV_ARTIST_ERR: c_int = 7;
pub const EV_PLAYLIST_OK: c_int = 8;
pub const EV_PLAYLIST_ERR: c_int = 9;
pub const EV_FAV_TRACKS_OK: c_int = 10;
pub const EV_FAV_ALBUMS_OK: c_int = 11;
pub const EV_FAV_ARTISTS_OK: c_int = 12;
pub const EV_PLAYLISTS_OK: c_int = 13;
pub const EV_TRACK_CHANGED: c_int = 14;
pub const EV_STATE_CHANGED: c_int = 15;
pub const EV_POSITION: c_int = 16;
pub const EV_TRACK_URL_OK: c_int = 17;
pub const EV_TRACK_URL_ERR: c_int = 18;
pub const EV_GENERIC_ERR: c_int = 19;
// ---------- Callback ----------
pub type EventCallback = unsafe extern "C" fn(*mut c_void, c_int, *const c_char);
// ---------- Backend ----------
struct PrefetchedTrack {
track_id: i64,
track: api::models::TrackDto,
url: String,
}
struct BackendInner {
client: Arc<Mutex<QobuzClient>>,
player: Player,
rt: Runtime,
cb: EventCallback,
ud: SendPtr,
replaygain_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
prefetch: std::sync::Arc<tokio::sync::Mutex<Option<PrefetchedTrack>>>,
}
pub struct Backend(BackendInner);
// ---------- Helpers ----------
fn call_cb(cb: EventCallback, ud: SendPtr, ev: c_int, json: &str) {
let cstr = CString::new(json).unwrap_or_else(|_| CString::new("{}").unwrap());
unsafe { cb(ud.0, ev, cstr.as_ptr()) };
}
fn err_json(msg: &str) -> String {
serde_json::json!({ "error": msg }).to_string()
}
/// Spawn a Send + 'static future on the backend's Tokio runtime.
fn spawn<F>(inner: &BackendInner, f: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
inner.rt.spawn(f);
}
// ---------- Construction / destruction ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_new(
event_cb: EventCallback,
userdata: *mut c_void,
) -> *mut Backend {
let rt = Runtime::new().expect("tokio runtime");
let client = Arc::new(Mutex::new(QobuzClient::new().expect("QobuzClient")));
let player = Player::new();
Box::into_raw(Box::new(Backend(BackendInner {
client,
player,
rt,
cb: event_cb,
ud: SendPtr(userdata),
replaygain_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
prefetch: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
})))
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_free(ptr: *mut Backend) {
if !ptr.is_null() {
drop(Box::from_raw(ptr));
}
}
// ---------- Auth ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_login(
ptr: *mut Backend,
email: *const c_char,
password: *const c_char,
) {
let inner = &(*ptr).0;
let email = CStr::from_ptr(email).to_string_lossy().into_owned();
let password = CStr::from_ptr(password).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.login(&email, &password).await;
let (ev, json) = match result {
Ok(resp) => {
let token = resp
.oauth2.as_ref().and_then(|o| o.access_token.as_deref())
.or(resp.user_auth_token.as_deref())
.unwrap_or("")
.to_string();
let user_val = resp.user.as_ref()
.map(|u| serde_json::to_value(u).unwrap_or_default())
.unwrap_or_default();
(EV_LOGIN_OK, serde_json::json!({"token": token, "user": user_val}).to_string())
}
Err(e) => (EV_LOGIN_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_token(ptr: *mut Backend, token: *const c_char) {
let inner = &(*ptr).0;
let token = CStr::from_ptr(token).to_string_lossy().into_owned();
let client = inner.client.clone();
// blocking_lock is available on tokio::sync::Mutex when not in an async context
inner.rt.spawn(async move {
client.lock().await.set_auth_token(token);
});
}
// ---------- Search ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_search(
ptr: *mut Backend,
query: *const c_char,
offset: u32,
limit: u32,
) {
let inner = &(*ptr).0;
let query = CStr::from_ptr(query).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.search(&query, offset, limit).await;
let (ev, json) = match result {
Ok(r) => (EV_SEARCH_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_SEARCH_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Album ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_album(ptr: *mut Backend, album_id: *const c_char) {
let inner = &(*ptr).0;
let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_album(&album_id).await;
let (ev, json) = match result {
Ok(r) => (EV_ALBUM_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_ALBUM_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Artist ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_artist(artist_id).await;
let (ev, json) = match result {
Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Playlist ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_playlist(
ptr: *mut Backend,
playlist_id: i64,
offset: u32,
limit: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_playlist(playlist_id, offset, limit).await;
let (ev, json) = match result {
Ok(r) => (EV_PLAYLIST_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_PLAYLIST_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Favorites ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_fav_tracks(ptr: *mut Backend, offset: u32, limit: u32) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_fav_tracks(offset, limit).await;
let (ev, json) = match result {
Ok(r) => (EV_FAV_TRACKS_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_fav_albums(ptr: *mut Backend, offset: u32, limit: u32) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_fav_albums(offset, limit).await;
let (ev, json) = match result {
Ok(r) => (EV_FAV_ALBUMS_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_fav_artists(ptr: *mut Backend, offset: u32, limit: u32) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_fav_artists(offset, limit).await;
let (ev, json) = match result {
Ok(r) => (EV_FAV_ARTISTS_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_user_playlists(ptr: *mut Backend, offset: u32, limit: u32) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_user_playlists(offset, limit).await;
let (ev, json) = match result {
Ok(r) => {
let items = r.playlists.as_ref().and_then(|p| p.items.as_ref()).cloned().unwrap_or_default();
let total = r.playlists.as_ref().and_then(|p| p.total).unwrap_or(0);
(EV_PLAYLISTS_OK, serde_json::json!({"items": items, "total": total}).to_string())
}
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Playback ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_play_track(
ptr: *mut Backend,
track_id: i64,
format_id: i32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
let format = Format::from_id(format_id);
let cmd_tx = inner.player.cmd_tx.clone();
let status = inner.player.status.clone();
let prefetch = inner.prefetch.clone();
let rg_enabled = inner.replaygain_enabled.clone();
spawn(inner, async move {
// 1. Check prefetch cache first for zero-gap start
let cached = {
let mut lock = prefetch.lock().await;
if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) {
lock.take()
} else {
None
}
};
let (track, url) = if let Some(pf) = cached {
(pf.track, pf.url)
} else {
// Fetch track metadata
let track = match client.lock().await.get_track(track_id).await {
Ok(t) => t,
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
};
// Fetch stream URL
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
Ok(u) => u,
Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; }
};
let url = match url_dto.url {
Some(u) => u,
None => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); return; }
};
(track, url)
};
// 2. Notify track change
if let Ok(j) = serde_json::to_string(&track) {
call_cb(cb, ud, EV_TRACK_CHANGED, &j);
}
// 3. Compute ReplayGain if enabled
let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) {
track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain)
} else {
None
};
// 4. Update status + send play command
*status.current_track.lock().unwrap() = Some(track.clone());
if let Some(dur) = track.duration {
status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed);
}
let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, format, replaygain_db }));
// 5. State notification
call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_pause(ptr: *mut Backend) {
let inner = &(*ptr).0;
inner.player.pause();
call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"paused"}"#);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_resume(ptr: *mut Backend) {
let inner = &(*ptr).0;
inner.player.resume();
call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_stop(ptr: *mut Backend) {
let inner = &(*ptr).0;
inner.player.stop();
call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"idle"}"#);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8) {
(*ptr).0.player.set_volume(volume);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
(*ptr).0.player.seek(position_secs);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
(*ptr).0.player.status.get_position()
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_duration(ptr: *const Backend) -> u64 {
(*ptr).0.player.status.get_duration()
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_volume(ptr: *const Backend) -> u8 {
(*ptr).0.player.status.get_volume()
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_state(ptr: *const Backend) -> c_int {
match (*ptr).0.player.status.get_state() {
PlayerState::Playing => 1,
PlayerState::Paused => 2,
_ => 0,
}
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) -> c_int {
let finished = (*ptr).0.player.status.track_finished
.swap(false, std::sync::atomic::Ordering::SeqCst);
if finished { 1 } else { 0 }
}
// ---------- ReplayGain / Gapless ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) {
(*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_set_gapless(ptr: *mut Backend, enabled: bool) {
(*ptr).0.player.status.gapless.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_prefetch_track(
ptr: *mut Backend,
track_id: i64,
format_id: i32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let prefetch = inner.prefetch.clone();
let format = Format::from_id(format_id);
spawn(inner, async move {
let track = match client.lock().await.get_track(track_id).await {
Ok(t) => t,
Err(_) => return,
};
let url_dto = match client.lock().await.get_track_url(track_id, format).await {
Ok(u) => u,
Err(_) => return,
};
let url = match url_dto.url {
Some(u) => u,
None => return,
};
*prefetch.lock().await = Some(PrefetchedTrack { track_id, track, url });
});
}
// ---------- Favorites modification ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_add_fav_track(ptr: *mut Backend, track_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.add_fav_track(track_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_remove_fav_track(ptr: *mut Backend, track_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.remove_fav_track(track_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_add_fav_album(ptr: *mut Backend, album_id: *const c_char) {
let inner = &(*ptr).0;
let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.add_fav_album(&album_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album_id: *const c_char) {
let inner = &(*ptr).0;
let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.remove_fav_album(&album_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}
// ---------- Playlist management ----------
pub const EV_PLAYLIST_CREATED: c_int = 20;
pub const EV_PLAYLIST_DELETED: c_int = 21;
pub const EV_PLAYLIST_TRACK_ADDED: c_int = 22;
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: *const c_char) {
let inner = &(*ptr).0;
let name = CStr::from_ptr(name).to_string_lossy().into_owned();
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.create_playlist(&name).await {
Ok(p) => call_cb(cb, ud, EV_PLAYLIST_CREATED, &serde_json::to_string(&p).unwrap_or_default()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_delete_playlist(ptr: *mut Backend, playlist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.delete_playlist(playlist_id).await {
Ok(()) => call_cb(cb, ud, EV_PLAYLIST_DELETED,
&serde_json::json!({"playlist_id": playlist_id}).to_string()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist(
ptr: *mut Backend,
playlist_id: i64,
track_id: i64,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.add_track_to_playlist(playlist_id, track_id).await {
Ok(()) => call_cb(cb, ud, EV_PLAYLIST_TRACK_ADDED,
&serde_json::json!({"playlist_id": playlist_id}).to_string()),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist(
ptr: *mut Backend,
playlist_id: i64,
playlist_track_id: i64,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
if let Err(e) = client.lock().await.delete_track_from_playlist(playlist_id, playlist_track_id).await {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
}
});
}