//! 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>, player: Player, rt: Runtime, cb: EventCallback, ud: SendPtr, replaygain_enabled: std::sync::Arc, prefetch: std::sync::Arc>>, } 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(inner: &BackendInner, f: F) where F: std::future::Future + 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())); } }); }