Initial implementation of qobuz-qt
- Rust backend (qobuz-backend static lib): Qobuz API client (reqwest/tokio), Symphonia audio decoder, CPAL audio output, extern "C" FFI bridge - Qt 6 frontend mirroring spotify-qt layout: toolbar with playback controls, left library dock, central track list, right search panel - Auth: email/password login with MD5-signed requests; session token persisted via QSettings - Playback: double-click a track → Rust fetches stream URL → Symphonia decodes → CPAL outputs to default audio device - Dark Fusion palette matching spotify-qt feel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
371
rust/src/api/client.rs
Normal file
371
rust/src/api/client.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use anyhow::{bail, Result};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::models::*;
|
||||
|
||||
const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2/";
|
||||
const USER_AGENT: &str =
|
||||
"Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717";
|
||||
const APP_VERSION: &str = "9.7.0.3";
|
||||
pub const DEFAULT_APP_ID: &str = "312369995";
|
||||
pub const DEFAULT_APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368";
|
||||
|
||||
pub struct QobuzClient {
|
||||
http: Client,
|
||||
pub auth_token: Option<String>,
|
||||
app_id: String,
|
||||
app_secret: String,
|
||||
}
|
||||
|
||||
impl QobuzClient {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::new_with_config(None, None)
|
||||
}
|
||||
|
||||
pub fn new_with_config(app_id: Option<&str>, app_secret: Option<&str>) -> Result<Self> {
|
||||
let app_id = app_id.unwrap_or(DEFAULT_APP_ID).to_string();
|
||||
let app_secret = app_secret.unwrap_or(DEFAULT_APP_SECRET).to_string();
|
||||
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.default_headers({
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
h.insert("X-App-Id", app_id.parse()?);
|
||||
h.insert("X-App-Version", APP_VERSION.parse()?);
|
||||
h.insert("X-Device-Platform", "android".parse()?);
|
||||
h.insert("X-Device-Model", "Nexus 6P".parse()?);
|
||||
h.insert("X-Device-Os-Version", "9".parse()?);
|
||||
h
|
||||
})
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
auth_token: None,
|
||||
app_id,
|
||||
app_secret,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_auth_token(&mut self, token: String) {
|
||||
self.auth_token = Some(token);
|
||||
}
|
||||
|
||||
fn ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn request_sig(&self, method: &str, params: &mut Vec<(&str, String)>, ts: u64) -> String {
|
||||
params.sort_by_key(|(k, _)| *k);
|
||||
let mut s = method.replace('/', "");
|
||||
for (k, v) in params.iter() {
|
||||
s.push_str(k);
|
||||
s.push_str(v);
|
||||
}
|
||||
s.push_str(&ts.to_string());
|
||||
s.push_str(&self.app_secret);
|
||||
format!("{:x}", md5::compute(s.as_bytes()))
|
||||
}
|
||||
|
||||
fn url(&self, method: &str) -> String {
|
||||
format!("{}{}", BASE_URL, method)
|
||||
}
|
||||
|
||||
async fn check_response(resp: reqwest::Response) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await?;
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown API error");
|
||||
bail!("HTTP {}: {}", status, msg);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn get_request(&self, method: &str) -> reqwest::RequestBuilder {
|
||||
let mut builder = self.http.get(self.url(method));
|
||||
builder = builder.query(&[("app_id", self.app_id.as_str())]);
|
||||
if let Some(token) = &self.auth_token {
|
||||
builder = builder.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
pub async fn login(&mut self, email: &str, password: &str) -> Result<OAuthLoginResponse> {
|
||||
match self.oauth2_login(email, password).await {
|
||||
Ok(r) => Ok(r),
|
||||
Err(_) => self.legacy_login(email, password).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn oauth2_login(&mut self, email: &str, password: &str) -> Result<OAuthLoginResponse> {
|
||||
let ts = Self::ts();
|
||||
let mut sign_params: Vec<(&str, String)> = vec![
|
||||
("password", password.to_string()),
|
||||
("username", email.to_string()),
|
||||
];
|
||||
let sig = self.request_sig("oauth2login", &mut sign_params, ts);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.url("oauth2/login"))
|
||||
.query(&[
|
||||
("app_id", self.app_id.as_str()),
|
||||
("username", email),
|
||||
("password", password),
|
||||
("request_ts", ts.to_string().as_str()),
|
||||
("request_sig", sig.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await?;
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed");
|
||||
bail!("oauth2 login failed ({}): {}", status, msg);
|
||||
}
|
||||
|
||||
self.extract_and_store_token(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
async fn legacy_login(&mut self, email: &str, password: &str) -> Result<OAuthLoginResponse> {
|
||||
let ts = Self::ts();
|
||||
let mut sign_params: Vec<(&str, String)> = vec![
|
||||
("email", email.to_string()),
|
||||
("password", password.to_string()),
|
||||
];
|
||||
let sig = self.request_sig("userlogin", &mut sign_params, ts);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.url("user/login"))
|
||||
.query(&[
|
||||
("app_id", self.app_id.as_str()),
|
||||
("email", email),
|
||||
("password", password),
|
||||
("request_ts", ts.to_string().as_str()),
|
||||
("request_sig", sig.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await?;
|
||||
if !status.is_success() {
|
||||
let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed");
|
||||
bail!("user login failed ({}): {}", status, msg);
|
||||
}
|
||||
|
||||
self.extract_and_store_token(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
fn extract_and_store_token(&mut self, login: OAuthLoginResponse) -> Result<OAuthLoginResponse> {
|
||||
if let Some(token) = login
|
||||
.oauth2
|
||||
.as_ref()
|
||||
.and_then(|o| o.access_token.clone())
|
||||
.or_else(|| login.user_auth_token.clone())
|
||||
{
|
||||
self.auth_token = Some(token);
|
||||
}
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
// --- User ---
|
||||
|
||||
pub async fn get_user(&self) -> Result<UserDto> {
|
||||
let resp = self.get_request("user/get").send().await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
let user: UserDto = serde_json::from_value(body["user"].clone())
|
||||
.or_else(|_| serde_json::from_value(body.clone()))?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// --- Track ---
|
||||
|
||||
pub async fn get_track(&self, track_id: i64) -> Result<TrackDto> {
|
||||
let resp = self
|
||||
.get_request("track/get")
|
||||
.query(&[("track_id", track_id.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
pub async fn get_track_url(&self, track_id: i64, format: Format) -> Result<TrackFileUrlDto> {
|
||||
let ts = Self::ts();
|
||||
let intent = "stream";
|
||||
let mut sign_params: Vec<(&str, String)> = vec![
|
||||
("format_id", format.id().to_string()),
|
||||
("intent", intent.to_string()),
|
||||
("track_id", track_id.to_string()),
|
||||
];
|
||||
let sig = self.request_sig("trackgetFileUrl", &mut sign_params, ts);
|
||||
|
||||
let resp = self
|
||||
.get_request("track/getFileUrl")
|
||||
.query(&[
|
||||
("track_id", track_id.to_string()),
|
||||
("format_id", format.id().to_string()),
|
||||
("intent", intent.to_string()),
|
||||
("request_ts", ts.to_string()),
|
||||
("request_sig", sig),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
// --- Album ---
|
||||
|
||||
pub async fn get_album(&self, album_id: &str) -> Result<AlbumDto> {
|
||||
let resp = self
|
||||
.get_request("album/get")
|
||||
.query(&[("album_id", album_id), ("limit", "50"), ("offset", "0")])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
// --- Artist ---
|
||||
|
||||
pub async fn get_artist(&self, artist_id: i64) -> Result<ArtistDto> {
|
||||
let resp = self
|
||||
.get_request("artist/get")
|
||||
.query(&[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("extra", "albums,biography".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
|
||||
pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> {
|
||||
let resp = self
|
||||
.get_request("catalog/search")
|
||||
.query(&[
|
||||
("query", query),
|
||||
("offset", &offset.to_string()),
|
||||
("limit", &limit.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
// --- Favorites / Library ---
|
||||
|
||||
pub async fn get_user_playlists(&self, offset: u32, limit: u32) -> Result<UserPlaylistsDto> {
|
||||
let resp = self
|
||||
.get_request("playlist/getUserPlaylists")
|
||||
.query(&[("offset", &offset.to_string()), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
pub async fn get_playlist(&self, playlist_id: i64, offset: u32, limit: u32) -> Result<PlaylistDto> {
|
||||
let resp = self
|
||||
.get_request("playlist/get")
|
||||
.query(&[
|
||||
("playlist_id", &playlist_id.to_string()),
|
||||
("extra", &"tracks".to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
("limit", &limit.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body)?)
|
||||
}
|
||||
|
||||
pub async fn get_fav_tracks(&self, offset: u32, limit: u32) -> Result<SearchResultItems<TrackDto>> {
|
||||
let resp = self
|
||||
.get_request("favorite/getUserFavorites")
|
||||
.query(&[("type", "tracks"), ("offset", &offset.to_string()), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body["tracks"].clone())?)
|
||||
}
|
||||
|
||||
pub async fn get_fav_albums(&self, offset: u32, limit: u32) -> Result<SearchResultItems<AlbumDto>> {
|
||||
let resp = self
|
||||
.get_request("favorite/getUserFavorites")
|
||||
.query(&[("type", "albums"), ("offset", &offset.to_string()), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body["albums"].clone())?)
|
||||
}
|
||||
|
||||
pub async fn get_fav_artists(&self, offset: u32, limit: u32) -> Result<SearchResultItems<FavArtistDto>> {
|
||||
let resp = self
|
||||
.get_request("favorite/getUserFavorites")
|
||||
.query(&[("type", "artists"), ("offset", &offset.to_string()), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let body = Self::check_response(resp).await?;
|
||||
Ok(serde_json::from_value(body["artists"].clone())?)
|
||||
}
|
||||
|
||||
pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
|
||||
let resp = self
|
||||
.get_request("favorite/create")
|
||||
.query(&[("type", "tracks"), ("track_ids", &track_id.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_fav_track(&self, track_id: i64) -> Result<()> {
|
||||
let resp = self
|
||||
.get_request("favorite/delete")
|
||||
.query(&[("type", "tracks"), ("track_ids", &track_id.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_fav_album(&self, album_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.get_request("favorite/create")
|
||||
.query(&[("type", "albums"), ("album_ids", album_id)])
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_fav_album(&self, album_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.get_request("favorite/delete")
|
||||
.query(&[("type", "albums"), ("album_ids", album_id)])
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
rust/src/api/mod.rs
Normal file
5
rust/src/api/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
||||
pub use client::QobuzClient;
|
||||
pub use models::*;
|
||||
260
rust/src/api/models.rs
Normal file
260
rust/src/api/models.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct OAuthDto {
|
||||
pub token_type: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct OAuthLoginResponse {
|
||||
pub status: Option<String>,
|
||||
pub user: Option<UserDto>,
|
||||
pub oauth2: Option<OAuthDto>,
|
||||
pub user_auth_token: Option<String>,
|
||||
}
|
||||
|
||||
// --- User ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct UserDto {
|
||||
pub id: Option<i64>,
|
||||
pub login: Option<String>,
|
||||
pub firstname: Option<String>,
|
||||
pub lastname: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub country_code: Option<String>,
|
||||
pub subscription: Option<SubscriptionDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct SubscriptionDto {
|
||||
pub description: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub is_recurring: Option<bool>,
|
||||
pub offer: Option<String>,
|
||||
}
|
||||
|
||||
// --- Track ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct TrackDto {
|
||||
pub id: i64,
|
||||
pub title: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
pub track_number: Option<i32>,
|
||||
pub album: Option<AlbumDto>,
|
||||
pub performer: Option<ArtistDto>,
|
||||
pub composer: Option<ArtistDto>,
|
||||
pub work: Option<String>,
|
||||
pub media_number: Option<i32>,
|
||||
pub streamable: Option<bool>,
|
||||
pub purchasable: Option<bool>,
|
||||
pub hires: Option<bool>,
|
||||
pub hires_streamable: Option<bool>,
|
||||
pub audio_info: Option<AudioInfoDto>,
|
||||
pub maximum_bit_depth: Option<i32>,
|
||||
pub maximum_sampling_rate: Option<f64>,
|
||||
pub maximum_channel_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct AudioInfoDto {
|
||||
pub replaygain_track_gain: Option<f64>,
|
||||
pub replaygain_track_peak: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct TrackFileUrlDto {
|
||||
pub track_id: Option<i64>,
|
||||
pub duration: Option<i64>,
|
||||
pub url: Option<String>,
|
||||
pub format_id: Option<i32>,
|
||||
pub mime_type: Option<String>,
|
||||
pub sampling_rate: Option<f64>,
|
||||
pub bit_depth: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Album ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct AlbumDto {
|
||||
pub id: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<ArtistDto>,
|
||||
pub tracks_count: Option<i32>,
|
||||
pub duration: Option<i64>,
|
||||
pub genre: Option<GenreDto>,
|
||||
pub image: Option<ImageDto>,
|
||||
pub label: Option<LabelDto>,
|
||||
pub release_date_original: Option<String>,
|
||||
pub maximum_bit_depth: Option<i32>,
|
||||
pub maximum_sampling_rate: Option<f64>,
|
||||
pub hires_streamable: Option<bool>,
|
||||
pub streamable: Option<bool>,
|
||||
pub tracks: Option<TracksWrapper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct TracksWrapper {
|
||||
pub items: Option<Vec<TrackDto>>,
|
||||
pub total: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Artist ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct ArtistDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub albums_count: Option<i32>,
|
||||
pub image: Option<ImageDto>,
|
||||
pub biography: Option<BiographyDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct BiographyDto {
|
||||
pub content: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
// --- Genre ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct GenreDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
// --- Image ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct ImageDto {
|
||||
pub small: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub large: Option<String>,
|
||||
pub back: Option<String>,
|
||||
}
|
||||
|
||||
// --- Label ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct LabelDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SearchCatalogDto {
|
||||
pub query: Option<String>,
|
||||
pub albums: Option<SearchResultItems<AlbumDto>>,
|
||||
pub tracks: Option<SearchResultItems<TrackDto>>,
|
||||
pub artists: Option<SearchResultItems<ArtistDto>>,
|
||||
pub playlists: Option<SearchResultItems<PlaylistDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct SearchResultItems<T> {
|
||||
pub items: Option<Vec<T>>,
|
||||
pub total: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
// --- Playlist ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct PlaylistDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub tracks_count: Option<i32>,
|
||||
pub duration: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
pub owner: Option<PlaylistOwnerDto>,
|
||||
pub image_rectangle: Option<Vec<String>>,
|
||||
pub tracks: Option<TracksWrapper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct PlaylistOwnerDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// --- User library ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UserPlaylistsDto {
|
||||
pub playlists: Option<SearchResultItems<PlaylistDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FavArtistDto {
|
||||
pub id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub albums_count: Option<i32>,
|
||||
pub image: Option<ImageDto>,
|
||||
}
|
||||
|
||||
// --- Format ---
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Format {
|
||||
Mp3 = 5,
|
||||
Cd = 6,
|
||||
HiRes96 = 7,
|
||||
HiRes192 = 27,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn id(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
5 => Format::Mp3,
|
||||
6 => Format::Cd,
|
||||
7 => Format::HiRes96,
|
||||
27 => Format::HiRes192,
|
||||
_ => Format::Cd,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Format::Mp3 => "MP3 320",
|
||||
Format::Cd => "CD 16-bit",
|
||||
Format::HiRes96 => "Hi-Res 24-bit/96kHz",
|
||||
Format::HiRes192 => "Hi-Res 24-bit/192kHz",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [Format] {
|
||||
&[Format::HiRes192, Format::HiRes96, Format::Cd, Format::Mp3]
|
||||
}
|
||||
}
|
||||
|
||||
// --- QWS ---
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct QwsTokenResponse {
|
||||
pub jwt_qws: Option<QwsToken>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
pub struct QwsToken {
|
||||
pub exp: Option<i64>,
|
||||
pub jwt: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
486
rust/src/lib.rs
Normal file
486
rust/src/lib.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
//! 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 BackendInner {
|
||||
client: Arc<Mutex<QobuzClient>>,
|
||||
player: Player,
|
||||
rt: Runtime,
|
||||
cb: EventCallback,
|
||||
ud: SendPtr,
|
||||
}
|
||||
|
||||
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),
|
||||
})))
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
spawn(inner, async move {
|
||||
// 1. 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; }
|
||||
};
|
||||
|
||||
// 2. 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; }
|
||||
};
|
||||
|
||||
// 3. Notify track change
|
||||
if let Ok(j) = serde_json::to_string(&track) {
|
||||
call_cb(cb, ud, EV_TRACK_CHANGED, &j);
|
||||
}
|
||||
|
||||
// 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 }));
|
||||
|
||||
// 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_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 }
|
||||
}
|
||||
|
||||
// ---------- 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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
207
rust/src/player/decoder.rs
Normal file
207
rust/src/player/decoder.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use anyhow::Result;
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use symphonia::core::{
|
||||
codecs::DecoderOptions,
|
||||
errors::Error as SymphoniaError,
|
||||
formats::FormatOptions,
|
||||
io::{MediaSource, MediaSourceStream},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
};
|
||||
|
||||
use crate::player::{output::AudioOutput, PlayerStatus};
|
||||
|
||||
/// First 512 KiB of stream kept in memory to support backward seeks during probing.
|
||||
const HEAD_SIZE: usize = 512 * 1024;
|
||||
|
||||
struct HttpStreamSource {
|
||||
reader: reqwest::blocking::Response,
|
||||
head: Vec<u8>,
|
||||
reader_pos: u64,
|
||||
pos: u64,
|
||||
content_length: Option<u64>,
|
||||
}
|
||||
|
||||
impl HttpStreamSource {
|
||||
fn new(response: reqwest::blocking::Response, content_length: Option<u64>) -> Self {
|
||||
Self {
|
||||
reader: response,
|
||||
head: Vec::new(),
|
||||
reader_pos: 0,
|
||||
pos: 0,
|
||||
content_length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for HttpStreamSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let pos = self.pos as usize;
|
||||
if pos < self.head.len() {
|
||||
let avail = self.head.len() - pos;
|
||||
let n = buf.len().min(avail);
|
||||
buf[..n].copy_from_slice(&self.head[pos..pos + n]);
|
||||
self.pos += n as u64;
|
||||
return Ok(n);
|
||||
}
|
||||
|
||||
let n = self.reader.read(buf)?;
|
||||
if n > 0 {
|
||||
if self.reader_pos < HEAD_SIZE as u64 {
|
||||
let capacity = HEAD_SIZE.saturating_sub(self.head.len());
|
||||
let to_buf = n.min(capacity);
|
||||
if to_buf > 0 {
|
||||
self.head.extend_from_slice(&buf[..to_buf]);
|
||||
}
|
||||
}
|
||||
self.reader_pos += n as u64;
|
||||
self.pos += n as u64;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for HttpStreamSource {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
let cl = self.content_length.unwrap_or(u64::MAX);
|
||||
let target: u64 = match from {
|
||||
SeekFrom::Start(n) => n,
|
||||
SeekFrom::End(n) if n < 0 => cl.saturating_sub((-n) as u64),
|
||||
SeekFrom::End(_) => cl,
|
||||
SeekFrom::Current(n) if n >= 0 => self.pos.saturating_add(n as u64),
|
||||
SeekFrom::Current(n) => self.pos.saturating_sub((-n) as u64),
|
||||
};
|
||||
|
||||
if target == self.pos {
|
||||
return Ok(self.pos);
|
||||
}
|
||||
|
||||
if target < self.reader_pos {
|
||||
if target < self.head.len() as u64 {
|
||||
self.pos = target;
|
||||
return Ok(self.pos);
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"backward seek past head buffer",
|
||||
));
|
||||
}
|
||||
|
||||
let mut remaining = target - self.reader_pos;
|
||||
while remaining > 0 {
|
||||
let mut discard = [0u8; 8192];
|
||||
let want = (remaining as usize).min(discard.len());
|
||||
match self.reader.read(&mut discard[..want]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if self.reader_pos < HEAD_SIZE as u64 {
|
||||
let capacity = HEAD_SIZE.saturating_sub(self.head.len());
|
||||
let to_buf = n.min(capacity);
|
||||
if to_buf > 0 {
|
||||
self.head.extend_from_slice(&discard[..to_buf]);
|
||||
}
|
||||
}
|
||||
self.reader_pos += n as u64;
|
||||
remaining -= n as u64;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
self.pos = self.reader_pos;
|
||||
Ok(self.pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for HttpStreamSource {
|
||||
fn is_seekable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
self.content_length
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream and decode audio from `url`. Runs on a dedicated OS thread.
|
||||
pub fn play_track(
|
||||
url: &str,
|
||||
status: &PlayerStatus,
|
||||
stop: &Arc<AtomicBool>,
|
||||
paused: &Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let response = reqwest::blocking::get(url)?;
|
||||
let content_length = response.content_length();
|
||||
let source = HttpStreamSource::new(response, content_length);
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
.map_err(|e| anyhow::anyhow!("probe failed: {e}"))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| anyhow::anyhow!("no audio tracks"))?
|
||||
.clone();
|
||||
|
||||
let track_id = track.id;
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
|
||||
|
||||
let mut audio_output = AudioOutput::try_open(sample_rate, channels)?;
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
while paused.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break;
|
||||
}
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("format error: {e}")),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ts) = packet.ts().checked_div(sample_rate as u64) {
|
||||
status.position_secs.store(ts, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let volume = status.volume.load(Ordering::Relaxed) as f32 / 100.0;
|
||||
audio_output.write(decoded, volume, stop)?;
|
||||
}
|
||||
Err(SymphoniaError::IoError(_)) => break,
|
||||
Err(SymphoniaError::DecodeError(e)) => eprintln!("decode error: {e}"),
|
||||
Err(e) => return Err(anyhow::anyhow!("decode error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
186
rust/src/player/mod.rs
Normal file
186
rust/src/player/mod.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
mod decoder;
|
||||
pub mod output;
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::api::{Format, TrackDto};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlayerCommand {
|
||||
Play(TrackInfo),
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
Seek(u64),
|
||||
SetVolume(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackInfo {
|
||||
pub track: TrackDto,
|
||||
pub url: String,
|
||||
pub format: Format,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PlayerState {
|
||||
Idle,
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlayerStatus {
|
||||
pub state: Arc<std::sync::Mutex<PlayerState>>,
|
||||
pub position_secs: Arc<AtomicU64>,
|
||||
pub duration_secs: Arc<AtomicU64>,
|
||||
pub volume: Arc<AtomicU8>,
|
||||
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
|
||||
/// Set to true by the decode thread when a track finishes naturally.
|
||||
pub track_finished: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PlayerStatus {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)),
|
||||
position_secs: Arc::new(AtomicU64::new(0)),
|
||||
duration_secs: Arc::new(AtomicU64::new(0)),
|
||||
volume: Arc::new(AtomicU8::new(80)),
|
||||
current_track: Arc::new(std::sync::Mutex::new(None)),
|
||||
track_finished: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> PlayerState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_position(&self) -> u64 {
|
||||
self.position_secs.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_duration(&self) -> u64 {
|
||||
self.duration_secs.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_volume(&self) -> u8 {
|
||||
self.volume.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn get_current_track(&self) -> Option<TrackDto> {
|
||||
self.current_track.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
pub cmd_tx: std::sync::mpsc::SyncSender<PlayerCommand>,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new() -> Self {
|
||||
let (cmd_tx, cmd_rx) = std::sync::mpsc::sync_channel(32);
|
||||
let status = PlayerStatus::new();
|
||||
let status_clone = status.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
player_loop(cmd_rx, status_clone);
|
||||
});
|
||||
|
||||
Self { cmd_tx, status }
|
||||
}
|
||||
|
||||
pub fn send(&self, cmd: PlayerCommand) {
|
||||
self.cmd_tx.send(cmd).ok();
|
||||
}
|
||||
|
||||
pub fn play_track(&self, track: TrackDto, url: String, format: Format) {
|
||||
self.send(PlayerCommand::Play(TrackInfo { track, url, format }));
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.send(PlayerCommand::Pause);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
self.send(PlayerCommand::Resume);
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.send(PlayerCommand::Stop);
|
||||
}
|
||||
|
||||
pub fn set_volume(&self, vol: u8) {
|
||||
self.status.volume.store(vol, Ordering::Relaxed);
|
||||
self.send(PlayerCommand::SetVolume(vol));
|
||||
}
|
||||
}
|
||||
|
||||
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
|
||||
let mut stop_flag = Arc::new(AtomicBool::new(true));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(cmd) => match cmd {
|
||||
PlayerCommand::Play(info) => {
|
||||
stop_flag.store(true, Ordering::SeqCst);
|
||||
stop_flag = Arc::new(AtomicBool::new(false));
|
||||
paused.store(false, Ordering::SeqCst);
|
||||
|
||||
*status.state.lock().unwrap() = PlayerState::Playing;
|
||||
*status.current_track.lock().unwrap() = Some(info.track.clone());
|
||||
if let Some(dur) = info.track.duration {
|
||||
status.duration_secs.store(dur as u64, Ordering::Relaxed);
|
||||
}
|
||||
status.position_secs.store(0, Ordering::Relaxed);
|
||||
|
||||
let status_c = status.clone();
|
||||
let stop_c = stop_flag.clone();
|
||||
let paused_c = paused.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
match decoder::play_track(&info.url, &status_c, &stop_c, &paused_c) {
|
||||
Ok(()) => {
|
||||
if !stop_c.load(Ordering::SeqCst) {
|
||||
*status_c.state.lock().unwrap() = PlayerState::Idle;
|
||||
status_c.track_finished.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("playback error: {e}");
|
||||
*status_c.state.lock().unwrap() =
|
||||
PlayerState::Error(e.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
PlayerCommand::Pause => {
|
||||
paused.store(true, Ordering::SeqCst);
|
||||
*status.state.lock().unwrap() = PlayerState::Paused;
|
||||
}
|
||||
PlayerCommand::Resume => {
|
||||
paused.store(false, Ordering::SeqCst);
|
||||
*status.state.lock().unwrap() = PlayerState::Playing;
|
||||
}
|
||||
PlayerCommand::Stop => {
|
||||
stop_flag.store(true, Ordering::SeqCst);
|
||||
*status.state.lock().unwrap() = PlayerState::Idle;
|
||||
*status.current_track.lock().unwrap() = None;
|
||||
status.position_secs.store(0, Ordering::Relaxed);
|
||||
status.duration_secs.store(0, Ordering::Relaxed);
|
||||
}
|
||||
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {}
|
||||
},
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
81
rust/src/player/output.rs
Normal file
81
rust/src/player/output.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
StreamConfig,
|
||||
};
|
||||
use rb::{RbConsumer, RbProducer, SpscRb, RB};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use symphonia::core::audio::AudioBufferRef;
|
||||
|
||||
const RING_BUFFER_SIZE: usize = 32 * 1024;
|
||||
|
||||
pub struct AudioOutput {
|
||||
ring_buf_producer: rb::Producer<f32>,
|
||||
_stream: cpal::Stream,
|
||||
}
|
||||
|
||||
impl AudioOutput {
|
||||
pub fn try_open(sample_rate: u32, channels: usize) -> Result<Self> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow!("no output device"))?;
|
||||
|
||||
let config = StreamConfig {
|
||||
channels: channels as u16,
|
||||
sample_rate: cpal::SampleRate(sample_rate),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
let ring = SpscRb::new(RING_BUFFER_SIZE);
|
||||
let (producer, consumer) = (ring.producer(), ring.consumer());
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
let n = consumer.read(data).unwrap_or(0);
|
||||
for s in &mut data[n..] {
|
||||
*s = 0.0;
|
||||
}
|
||||
},
|
||||
|e| eprintln!("audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
stream.play()?;
|
||||
|
||||
Ok(Self {
|
||||
ring_buf_producer: producer,
|
||||
_stream: stream,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write(
|
||||
&mut self,
|
||||
decoded: AudioBufferRef<'_>,
|
||||
volume: f32,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let mut sample_buf = symphonia::core::audio::SampleBuffer::<f32>::new(
|
||||
decoded.capacity() as u64,
|
||||
*decoded.spec(),
|
||||
);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
let samples: Vec<f32> = sample_buf.samples().iter().map(|s| s * volume).collect();
|
||||
|
||||
let mut remaining = &samples[..];
|
||||
while !remaining.is_empty() {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
match self.ring_buf_producer.write_blocking(remaining) {
|
||||
Some(n) => remaining = &remaining[n..],
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user