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:
joren
2026-03-23 23:34:23 +01:00
commit 9402dca7ed
40 changed files with 3963 additions and 0 deletions

371
rust/src/api/client.rs Normal file
View 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(())
}
}