- Switch artist view to artist/page API (proper sections: Albums, Singles & EPs, Live, Compilations; version in titles like "Deluxe") - Fix artist sections categorization using releases[].type from artist/page - Add getUser() backend call; fetch on session restore when userId=0 to fix playlist ownership (Remove from playlist / Delete playlist were missing) - Fix multi-disc double-click / Play Now queue start index (disc headers were counted in row index but excluded from currentTracksJson) - Hide redundant Album column when viewing an album - Make artist name in context header clickable (navigates to artist page) - Fix gap between title and artist name in context header (addStretch) - Fix queue panel track titles to include version field - Fix album list to show version in title column Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
440 lines
15 KiB
Rust
440 lines
15 KiB
Rust
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 post_request(&self, method: &str) -> reqwest::RequestBuilder {
|
|
let mut builder = self.http.post(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
|
|
}
|
|
|
|
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".to_string()),
|
|
("albums_limit", "200".to_string()),
|
|
])
|
|
.send()
|
|
.await?;
|
|
let body = Self::check_response(resp).await?;
|
|
Ok(serde_json::from_value(body)?)
|
|
}
|
|
|
|
pub async fn get_artist_page(&self, artist_id: i64) -> Result<Value> {
|
|
let resp = self
|
|
.get_request("artist/page")
|
|
.query(&[("artist_id", artist_id.to_string())])
|
|
.send()
|
|
.await?;
|
|
Self::check_response(resp).await
|
|
}
|
|
|
|
// --- 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())?)
|
|
}
|
|
|
|
// --- Playlist management ---
|
|
|
|
pub async fn create_playlist(&self, name: &str) -> Result<PlaylistDto> {
|
|
let resp = self
|
|
.post_request("playlist/create")
|
|
.form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")])
|
|
.send()
|
|
.await?;
|
|
let body = Self::check_response(resp).await?;
|
|
Ok(serde_json::from_value(body)?)
|
|
}
|
|
|
|
pub async fn add_track_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> {
|
|
let resp = self
|
|
.post_request("playlist/addTracks")
|
|
.form(&[
|
|
("playlist_id", playlist_id.to_string()),
|
|
("track_ids", track_id.to_string()),
|
|
("no_duplicate", "true".to_string()),
|
|
])
|
|
.send()
|
|
.await?;
|
|
Self::check_response(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_playlist(&self, playlist_id: i64) -> Result<()> {
|
|
let resp = self
|
|
.get_request("playlist/delete")
|
|
.query(&[("playlist_id", &playlist_id.to_string())])
|
|
.send()
|
|
.await?;
|
|
Self::check_response(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> {
|
|
let resp = self
|
|
.post_request("playlist/deleteTracks")
|
|
.form(&[
|
|
("playlist_id", playlist_id.to_string()),
|
|
("playlist_track_ids", playlist_track_id.to_string()),
|
|
])
|
|
.send()
|
|
.await?;
|
|
Self::check_response(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|