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(())
}
}

5
rust/src/api/mod.rs Normal file
View 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
View 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>,
}