Initial project setup

- Tauri 2.0 + Svelte + TypeScript scaffolding
- Qobuz API client implementation (Rust)
  - Bundle token extraction
  - User authentication
  - Request signing (MD5 signatures)
  - Search/browse endpoints
  - Stream URL retrieval with quality fallback
- Tauri commands for frontend IPC
- Module stubs for player, queue, config
This commit is contained in:
vicrodh committed 2026-01-08 22:35:34 -06:00
commit 8a094ef467
49 files changed
+1733

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+56
View File
@@ -0,0 +1,56 @@
[package]
name = "qbz-nix"
version = "0.1.0"
description = "Native Qobuz client for Linux with Hi-Fi audio support"
authors = ["blitzkriegfc"]
edition = "2021"
[lib]
name = "qbz_nix_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Tauri
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP Client
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls"], default-features = false }
# Async Runtime
tokio = { version = "1", features = ["full"] }
# Crypto (for API signatures)
md-5 = "0.10"
base64 = "0.22"
# Regex (for bundle parsing)
regex = "1"
# Time
chrono = "0.4"
# Error handling
thiserror = "2"
anyhow = "1"
# Logging
log = "0.4"
env_logger = "0.11"
# Audio playback (will expand later)
# rodio = "0.19"
# cpal = "0.15"
# Storage
# sled = "0.34"
# Secure credential storage
# keyring = "3"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+114
View File
@@ -0,0 +1,114 @@
//! Authentication and request signing
use md5::{Digest, Md5};
use std::time::{SystemTime, UNIX_EPOCH};
use super::error::{ApiError, Result};
use super::models::UserSession;
/// Generate MD5 signature for protected API endpoints
///
/// Signature format: MD5(method + params + timestamp + secret)
pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &str) -> String {
let sig_string = format!("{}{}{}{}", method, params, timestamp, secret);
let mut hasher = Md5::new();
hasher.update(sig_string.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Generate signature for track/getFileUrl endpoint
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
let params = format!(
"format_id{}intentstreamtrack_id{}",
format_id, track_id
);
generate_signature("trackgetFileUrl", &params, timestamp, secret)
}
/// Generate signature for favorite/getUserFavorites endpoint
pub fn sign_get_favorites(timestamp: u64, secret: &str) -> String {
generate_signature("favoritegetUserFavorites", "", timestamp, secret)
}
/// Get current Unix timestamp
pub fn get_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs()
}
/// Parse user login response
pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession> {
let user = response
.get("user")
.ok_or_else(|| ApiError::AuthenticationError("No user in response".to_string()))?;
let user_auth_token = response
.get("user_auth_token")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::AuthenticationError("No auth token in response".to_string()))?
.to_string();
let user_id = user
.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| ApiError::AuthenticationError("No user id".to_string()))?;
let email = user
.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let display_name = user
.get("display_name")
.and_then(|v| v.as_str())
.or_else(|| user.get("login").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
// Check subscription
let credential = user.get("credential");
let subscription_label = credential
.and_then(|c| c.get("parameters"))
.and_then(|p| p.get("short_label"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
// Check if user has valid subscription
let has_subscription = credential
.and_then(|c| c.get("parameters"))
.map(|p| !p.is_null() && p.as_object().map(|o| !o.is_empty()).unwrap_or(false))
.unwrap_or(false);
if !has_subscription {
return Err(ApiError::IneligibleUser);
}
Ok(UserSession {
user_auth_token,
user_id,
email,
display_name,
subscription_label,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_signature() {
let sig = generate_signature("test", "params", 1234567890, "secret");
assert_eq!(sig.len(), 32); // MD5 hex is 32 chars
}
#[test]
fn test_sign_get_file_url() {
let sig = sign_get_file_url(123456, 27, 1234567890, "testsecret");
assert_eq!(sig.len(), 32);
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Bundle token extraction from Qobuz web player
//!
//! Extracts app_id and secrets from the Qobuz JavaScript bundle.
//! This is necessary because Qobuz doesn't provide a public API.
use regex::Regex;
use reqwest::Client;
use super::error::{ApiError, Result};
const LOGIN_PAGE_URL: &str = "https://play.qobuz.com/login";
const BUNDLE_BASE_URL: &str = "https://play.qobuz.com";
/// Extracted bundle tokens
#[derive(Debug, Clone)]
pub struct BundleTokens {
pub app_id: String,
pub secrets: Vec<String>,
}
/// Extract app_id and secrets from Qobuz bundle
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
// Step 1: Get login page to find bundle URL
let login_page = client
.get(LOGIN_PAGE_URL)
.send()
.await?
.text()
.await?;
let bundle_url = extract_bundle_url(&login_page)?;
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
// Step 2: Fetch the bundle
let bundle_content = client
.get(&full_bundle_url)
.send()
.await?
.text()
.await?;
// Step 3: Extract app_id
let app_id = extract_app_id(&bundle_content)?;
// Step 4: Extract secrets
let secrets = extract_secrets(&bundle_content)?;
if secrets.is_empty() {
return Err(ApiError::BundleExtractionError(
"No secrets found in bundle".to_string(),
));
}
Ok(BundleTokens { app_id, secrets })
}
fn extract_bundle_url(html: &str) -> Result<String> {
// Pattern: <script src="/resources/X.X.X-bXXX/bundle.js"></script>
let re = Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)
.expect("Invalid regex");
re.captures(html)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| ApiError::BundleExtractionError("Bundle URL not found".to_string()))
}
fn extract_app_id(bundle: &str) -> Result<String> {
// Pattern: production:{api:{appId:"XXXXXXXXX"
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#)
.expect("Invalid regex");
re.captures(bundle)
.and_then(|caps| caps.name("app_id"))
.map(|m| m.as_str().to_string())
.ok_or_else(|| ApiError::BundleExtractionError("App ID not found".to_string()))
}
fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
// Extract seeds with their timezone keys
// Pattern: X.initialSeed("SEED",window.utimezone.TIMEZONE)
let seed_re = Regex::new(
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
)
.expect("Invalid regex");
let mut seeds: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for caps in seed_re.captures_iter(bundle) {
if let (Some(seed), Some(tz)) = (caps.name("seed"), caps.name("timezone")) {
seeds.insert(tz.as_str().to_string(), seed.as_str().to_string());
}
}
if seeds.is_empty() {
return Err(ApiError::BundleExtractionError("No seeds found".to_string()));
}
// Extract info and extras for each timezone
// This is a simplified version - the actual extraction is more complex
// and involves base64 decoding and concatenation
let info_re = Regex::new(r#""(?P<timezone>[a-z]+)":\{info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#)
.expect("Invalid regex");
let mut secrets = Vec::new();
for caps in info_re.captures_iter(bundle) {
if let (Some(tz), Some(info), Some(extras)) = (
caps.name("timezone"),
caps.name("info"),
caps.name("extras"),
) {
if let Some(seed) = seeds.get(tz.as_str()) {
// Concatenate seed + info + extras, remove last 44 chars, base64 decode
let combined = format!("{}{}{}", seed, info.as_str(), extras.as_str());
if combined.len() > 44 {
let trimmed = &combined[..combined.len() - 44];
if let Ok(decoded) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
trimmed,
) {
if let Ok(secret) = String::from_utf8(decoded) {
secrets.push(secret);
}
}
}
}
}
}
// If the complex extraction fails, try a simpler pattern
// that might work for some bundle versions
if secrets.is_empty() {
let simple_re = Regex::new(r#"appSecret:"([a-f0-9]{32})""#).expect("Invalid regex");
for caps in simple_re.captures_iter(bundle) {
if let Some(secret) = caps.get(1) {
secrets.push(secret.as_str().to_string());
}
}
}
Ok(secrets)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_bundle_url() {
let html = r#"<script src="/resources/7.0.1-b001/bundle.js"></script>"#;
let result = extract_bundle_url(html);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/resources/7.0.1-b001/bundle.js");
}
#[test]
fn test_extract_app_id() {
let bundle = r#"production:{api:{appId:"123456789",appSecret:"abc"}"#;
let result = extract_app_id(bundle);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "123456789");
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Qobuz API client implementation
use reqwest::{header, Client, StatusCode};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::auth::{get_timestamp, parse_login_response, sign_get_favorites, sign_get_file_url};
use super::bundle::{extract_bundle_tokens, BundleTokens};
use super::endpoints::{self, paths};
use super::error::{ApiError, Result};
use super::models::*;
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
/// Qobuz API client
pub struct QobuzClient {
http: Client,
tokens: Arc<RwLock<Option<BundleTokens>>>,
session: Arc<RwLock<Option<UserSession>>>,
validated_secret: Arc<RwLock<Option<String>>>,
}
impl QobuzClient {
/// Create a new client
pub fn new() -> Result<Self> {
let http = Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.build()?;
Ok(Self {
http,
tokens: Arc::new(RwLock::new(None)),
session: Arc::new(RwLock::new(None)),
validated_secret: Arc::new(RwLock::new(None)),
})
}
/// Initialize client by extracting bundle tokens
pub async fn init(&self) -> Result<()> {
let tokens = extract_bundle_tokens(&self.http).await?;
*self.tokens.write().await = Some(tokens);
Ok(())
}
/// Get app ID
async fn app_id(&self) -> Result<String> {
self.tokens
.read()
.await
.as_ref()
.map(|t| t.app_id.clone())
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))
}
/// Get validated secret (validates on first use)
async fn secret(&self) -> Result<String> {
// Check if we already have a validated secret
if let Some(secret) = self.validated_secret.read().await.clone() {
return Ok(secret);
}
// Need to validate secrets
let tokens = self.tokens.read().await;
let tokens = tokens
.as_ref()
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
for secret in &tokens.secrets {
if self.test_secret(secret).await? {
*self.validated_secret.write().await = Some(secret.clone());
return Ok(secret.clone());
}
}
Err(ApiError::InvalidAppSecret)
}
/// Test if a secret is valid using a known track
async fn test_secret(&self, secret: &str) -> Result<bool> {
let test_track_id = 5966783u64; // Known test track
let timestamp = get_timestamp();
let signature = sign_get_file_url(test_track_id, 5, timestamp, secret);
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let response = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[
("track_id", test_track_id.to_string()),
("format_id", "5".to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
Ok(response.status() != StatusCode::BAD_REQUEST)
}
/// Login with email and password
pub async fn login(&self, email: &str, password: &str) -> Result<UserSession> {
let url = endpoints::build_url(paths::USER_LOGIN);
let response = self
.http
.post(&url)
.header("X-App-Id", self.app_id().await?)
.header(header::CONTENT_TYPE, "application/json")
.form(&[("email", email), ("password", password)])
.send()
.await?;
match response.status() {
StatusCode::OK => {
let json: Value = response.json().await?;
let session = parse_login_response(&json)?;
*self.session.write().await = Some(session.clone());
Ok(session)
}
StatusCode::UNAUTHORIZED => {
Err(ApiError::AuthenticationError("Invalid credentials".to_string()))
}
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
}
}
/// Check if logged in
pub async fn is_logged_in(&self) -> bool {
self.session.read().await.is_some()
}
/// Get user auth token header value
async fn auth_token(&self) -> Result<String> {
self.session
.read()
.await
.as_ref()
.map(|s| s.user_auth_token.clone())
.ok_or_else(|| ApiError::AuthenticationError("Not logged in".to_string()))
}
// === Search endpoints ===
/// Search for albums
pub async fn search_albums(&self, query: &str, limit: u32) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUM_SEARCH);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("query", query), ("limit", &limit.to_string())])
.send()
.await?
.json()
.await?;
let albums = response
.get("albums")
.ok_or_else(|| ApiError::ApiResponse("No albums in response".to_string()))?;
Ok(serde_json::from_value(albums.clone())?)
}
/// Search for tracks
pub async fn search_tracks(&self, query: &str, limit: u32) -> Result<SearchResultsPage<Track>> {
let url = endpoints::build_url(paths::TRACK_SEARCH);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("query", query), ("limit", &limit.to_string())])
.send()
.await?
.json()
.await?;
let tracks = response
.get("tracks")
.ok_or_else(|| ApiError::ApiResponse("No tracks in response".to_string()))?;
Ok(serde_json::from_value(tracks.clone())?)
}
/// Search for artists
pub async fn search_artists(&self, query: &str, limit: u32) -> Result<SearchResultsPage<Artist>> {
let url = endpoints::build_url(paths::ARTIST_SEARCH);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("query", query), ("limit", &limit.to_string())])
.send()
.await?
.json()
.await?;
let artists = response
.get("artists")
.ok_or_else(|| ApiError::ApiResponse("No artists in response".to_string()))?;
Ok(serde_json::from_value(artists.clone())?)
}
// === Get endpoints ===
/// Get album by ID
pub async fn get_album(&self, album_id: &str) -> Result<Album> {
let url = endpoints::build_url(paths::ALBUM_GET);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("album_id", album_id)])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get track by ID
pub async fn get_track(&self, track_id: u64) -> Result<Track> {
let url = endpoints::build_url(paths::TRACK_GET);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("track_id", track_id.to_string())])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get artist by ID
pub async fn get_artist(&self, artist_id: u64, with_albums: bool) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET);
let mut query = vec![("artist_id", artist_id.to_string())];
if with_albums {
query.push(("extra", "albums".to_string()));
}
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&query)
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get playlist by ID
pub async fn get_playlist(&self, playlist_id: u64) -> Result<Playlist> {
let url = endpoints::build_url(paths::PLAYLIST_GET);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.query(&[("playlist_id", playlist_id.to_string()), ("limit", "500".to_string())])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
// === Authenticated endpoints ===
/// Get stream URL for a track (requires auth + signature)
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp();
let secret = self.secret().await?;
let signature = sign_get_file_url(track_id, quality.id(), timestamp, &secret);
let response = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.header("X-User-Auth-Token", self.auth_token().await?)
.query(&[
("track_id", track_id.to_string()),
("format_id", quality.id().to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
match response.status() {
StatusCode::OK => {
let json: Value = response.json().await?;
// Check for restrictions
let restrictions: Vec<StreamRestriction> = json
.get("restrictions")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
Ok(StreamUrl {
url: json["url"].as_str().unwrap_or("").to_string(),
format_id: json["format_id"].as_u64().unwrap_or(0) as u32,
mime_type: json["mime_type"].as_str().unwrap_or("").to_string(),
sampling_rate: json["sampling_rate"].as_f64().unwrap_or(0.0),
bit_depth: json["bit_depth"].as_u64().map(|v| v as u32),
track_id,
restrictions,
})
}
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
}
}
/// Get stream URL with quality fallback
pub async fn get_stream_url_with_fallback(
&self,
track_id: u64,
preferred: Quality,
) -> Result<StreamUrl> {
let qualities = Quality::fallback_order();
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
for quality in &qualities[start_idx..] {
match self.get_stream_url(track_id, *quality).await {
Ok(url) if !url.has_restrictions() => return Ok(url),
Ok(_) => continue, // Format restricted, try lower
Err(ApiError::InvalidAppSecret) => return Err(ApiError::InvalidAppSecret),
Err(_) => continue,
}
}
Err(ApiError::NoQualityAvailable)
}
/// Get user favorites (requires auth + signature)
pub async fn get_favorites(&self, fav_type: &str, limit: u32, offset: u32) -> Result<Value> {
let url = endpoints::build_url(paths::FAVORITE_GET_USER_FAVORITES);
let timestamp = get_timestamp();
let secret = self.secret().await?;
let signature = sign_get_favorites(timestamp, &secret);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.header("X-User-Auth-Token", self.auth_token().await?)
.query(&[
("type", fav_type),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
("request_ts", &timestamp.to_string()),
("request_sig", &signature),
])
.send()
.await?
.json()
.await?;
Ok(response)
}
/// Get user's playlists
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
let url = endpoints::build_url(paths::PLAYLIST_GET_USER_PLAYLISTS);
let response: Value = self
.http
.get(&url)
.header("X-App-Id", self.app_id().await?)
.header("X-User-Auth-Token", self.auth_token().await?)
.send()
.await?
.json()
.await?;
let playlists = response
.get("playlists")
.and_then(|p| p.get("items"))
.ok_or_else(|| ApiError::ApiResponse("No playlists in response".to_string()))?;
Ok(serde_json::from_value(playlists.clone())?)
}
}
impl Default for QobuzClient {
fn default() -> Self {
Self::new().expect("Failed to create client")
}
}
+40
View File
@@ -0,0 +1,40 @@
//! API endpoint definitions
pub const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
/// Endpoint paths
pub mod paths {
// User
pub const USER_LOGIN: &str = "/user/login";
// Track
pub const TRACK_GET: &str = "/track/get";
pub const TRACK_SEARCH: &str = "/track/search";
pub const TRACK_GET_FILE_URL: &str = "/track/getFileUrl";
// Album
pub const ALBUM_GET: &str = "/album/get";
pub const ALBUM_SEARCH: &str = "/album/search";
// Artist
pub const ARTIST_GET: &str = "/artist/get";
pub const ARTIST_SEARCH: &str = "/artist/search";
// Playlist
pub const PLAYLIST_GET: &str = "/playlist/get";
pub const PLAYLIST_SEARCH: &str = "/playlist/search";
pub const PLAYLIST_GET_USER_PLAYLISTS: &str = "/playlist/getUserPlaylists";
// Favorites
pub const FAVORITE_GET_USER_FAVORITES: &str = "/favorite/getUserFavorites";
pub const FAVORITE_CREATE: &str = "/favorite/create";
pub const FAVORITE_DELETE: &str = "/favorite/delete";
// Label
pub const LABEL_GET: &str = "/label/get";
}
/// Build full URL for an endpoint
pub fn build_url(endpoint: &str) -> String {
format!("{}{}", BASE_URL, endpoint)
}
+44
View File
@@ -0,0 +1,44 @@
//! API error types
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("Authentication failed: {0}")]
AuthenticationError(String),
#[error("Invalid app ID")]
InvalidAppId,
#[error("Invalid app secret")]
InvalidAppSecret,
#[error("Failed to extract bundle tokens: {0}")]
BundleExtractionError(String),
#[error("User is not eligible (no active subscription)")]
IneligibleUser,
#[error("Track is not streamable")]
NonStreamable,
#[error("Invalid quality format: {0}")]
InvalidQuality(u32),
#[error("No valid quality available for this track")]
NoQualityAvailable,
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("JSON parsing error: {0}")]
ParseError(#[from] serde_json::Error),
#[error("API error: {0}")]
ApiResponse(String),
#[error("Rate limited, retry after {0} seconds")]
RateLimited(u64),
}
pub type Result<T> = std::result::Result<T, ApiError>;
+18
View File
@@ -0,0 +1,18 @@
//! Qobuz API client module
//!
//! This module handles all communication with the Qobuz API, including:
//! - Bundle token extraction (app_id, secrets)
//! - User authentication
//! - Request signing (MD5 signatures)
//! - All API endpoints (search, albums, tracks, playlists, etc.)
pub mod auth;
pub mod bundle;
pub mod client;
pub mod endpoints;
pub mod error;
pub mod models;
pub use client::QobuzClient;
pub use error::ApiError;
pub use models::*;
+221
View File
@@ -0,0 +1,221 @@
//! API response models
use serde::{Deserialize, Serialize};
/// Audio quality format IDs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[repr(u32)]
pub enum Quality {
Mp3 = 5,
Lossless = 6, // 16-bit/44.1kHz (CD Quality)
HiRes = 7, // 24-bit/≤96kHz
UltraHiRes = 27, // 24-bit/>96kHz
}
impl Quality {
pub fn from_id(id: u32) -> Option<Self> {
match id {
5 => Some(Quality::Mp3),
6 => Some(Quality::Lossless),
7 => Some(Quality::HiRes),
27 => Some(Quality::UltraHiRes),
_ => None,
}
}
pub fn id(&self) -> u32 {
*self as u32
}
pub fn label(&self) -> &'static str {
match self {
Quality::Mp3 => "MP3 320kbps",
Quality::Lossless => "FLAC 16-bit/44.1kHz",
Quality::HiRes => "FLAC 24-bit/≤96kHz",
Quality::UltraHiRes => "FLAC 24-bit/>96kHz",
}
}
/// Quality levels in descending order for fallback
pub fn fallback_order() -> &'static [Quality] {
&[Quality::UltraHiRes, Quality::HiRes, Quality::Lossless, Quality::Mp3]
}
}
/// User credentials and session info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSession {
pub user_auth_token: String,
pub user_id: u64,
pub email: String,
pub display_name: String,
pub subscription_label: String,
}
/// Stream URL response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamUrl {
pub url: String,
pub format_id: u32,
pub mime_type: String,
pub sampling_rate: f64,
pub bit_depth: Option<u32>,
pub track_id: u64,
pub restrictions: Vec<StreamRestriction>,
}
impl StreamUrl {
pub fn has_restrictions(&self) -> bool {
self.restrictions.iter().any(|r| {
r.code == "FormatRestrictedByFormatAvailability"
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamRestriction {
pub code: String,
}
/// Album model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: String,
pub title: String,
pub artist: Artist,
pub image: ImageSet,
pub release_date_original: Option<String>,
pub label: Option<Label>,
pub genre: Option<Genre>,
pub tracks_count: Option<u32>,
pub duration: Option<u32>,
pub hires: bool,
pub hires_streamable: bool,
pub maximum_sampling_rate: Option<f64>,
pub maximum_bit_depth: Option<u32>,
#[serde(default)]
pub tracks: Option<TracksContainer>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TracksContainer {
pub items: Vec<Track>,
pub total: u32,
}
/// Track model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
pub id: u64,
pub title: String,
pub duration: u32,
pub track_number: u32,
pub media_number: Option<u32>,
pub performer: Option<Artist>,
pub album: Option<AlbumSummary>,
pub hires: bool,
pub hires_streamable: bool,
pub maximum_sampling_rate: Option<f64>,
pub maximum_bit_depth: Option<u32>,
pub streamable: bool,
pub parental_warning: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumSummary {
pub id: String,
pub title: String,
pub image: ImageSet,
}
/// Artist model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artist {
pub id: u64,
pub name: String,
pub image: Option<ImageSet>,
#[serde(default)]
pub albums_count: Option<u32>,
}
/// Playlist model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
pub id: u64,
pub name: String,
pub description: Option<String>,
pub owner: PlaylistOwner,
pub images: Option<Vec<String>>,
pub tracks_count: u32,
pub duration: u32,
pub is_public: bool,
#[serde(default)]
pub tracks: Option<TracksContainer>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistOwner {
pub id: u64,
pub name: String,
}
/// Image set with multiple resolutions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSet {
pub small: Option<String>,
pub thumbnail: Option<String>,
pub large: Option<String>,
pub extralarge: Option<String>,
pub mega: Option<String>,
pub back: Option<String>,
}
impl ImageSet {
pub fn best(&self) -> Option<&String> {
self.mega
.as_ref()
.or(self.extralarge.as_ref())
.or(self.large.as_ref())
.or(self.thumbnail.as_ref())
.or(self.small.as_ref())
}
}
/// Label model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label {
pub id: u64,
pub name: String,
}
/// Genre model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Genre {
pub id: u64,
pub name: String,
}
/// Search results container
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub albums: Option<SearchResultsPage<Album>>,
pub tracks: Option<SearchResultsPage<Track>>,
pub artists: Option<SearchResultsPage<Artist>>,
pub playlists: Option<SearchResultsPage<Playlist>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResultsPage<T> {
pub items: Vec<T>,
pub total: u32,
pub offset: u32,
pub limit: u32,
}
/// Favorites container
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Favorites {
pub albums: Option<SearchResultsPage<Album>>,
pub tracks: Option<SearchResultsPage<Track>>,
pub artists: Option<SearchResultsPage<Artist>>,
}
+52
View File
@@ -0,0 +1,52 @@
//! Authentication commands
use tauri::State;
use crate::AppState;
#[derive(serde::Serialize)]
pub struct LoginResponse {
pub success: bool,
pub user_name: Option<String>,
pub subscription: Option<String>,
pub error: Option<String>,
}
#[tauri::command]
pub async fn login(
email: String,
password: String,
state: State<'_, AppState>,
) -> Result<LoginResponse, String> {
let client = state.client.lock().await;
match client.login(&email, &password).await {
Ok(session) => Ok(LoginResponse {
success: true,
user_name: Some(session.display_name),
subscription: Some(session.subscription_label),
error: None,
}),
Err(e) => Ok(LoginResponse {
success: false,
user_name: None,
subscription: None,
error: Some(e.to_string()),
}),
}
}
#[tauri::command]
pub async fn is_logged_in(state: State<'_, AppState>) -> Result<bool, String> {
let client = state.client.lock().await;
Ok(client.is_logged_in().await)
}
#[tauri::command]
pub async fn init_client(state: State<'_, AppState>) -> Result<bool, String> {
let client = state.client.lock().await;
match client.init().await {
Ok(_) => Ok(true),
Err(e) => Err(e.to_string()),
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Tauri commands module
//!
//! Exposes backend functionality to the frontend via IPC
pub mod auth;
pub mod search;
pub use auth::*;
pub use search::*;
+63
View File
@@ -0,0 +1,63 @@
//! Search commands
use tauri::State;
use crate::api::{Album, Artist, Track, SearchResultsPage};
use crate::AppState;
#[tauri::command]
pub async fn search_albums(
query: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Album>, String> {
let client = state.client.lock().await;
client
.search_albums(&query, limit.unwrap_or(20))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn search_tracks(
query: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Track>, String> {
let client = state.client.lock().await;
client
.search_tracks(&query, limit.unwrap_or(20))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn search_artists(
query: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Artist>, String> {
let client = state.client.lock().await;
client
.search_artists(&query, limit.unwrap_or(20))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_album(
album_id: String,
state: State<'_, AppState>,
) -> Result<Album, String> {
let client = state.client.lock().await;
client.get_album(&album_id).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_track(
track_id: u64,
state: State<'_, AppState>,
) -> Result<Track, String> {
let client = state.client.lock().await;
client.get_track(track_id).await.map_err(|e| e.to_string())
}
+11
View File
@@ -0,0 +1,11 @@
//! Configuration and state persistence
//!
//! Handles:
//! - User credentials (encrypted)
//! - Audio preferences
//! - UI preferences
//! - Local playlists
//! - Cached favorites
// TODO: Implement configuration storage
// pub mod store;
+55
View File
@@ -0,0 +1,55 @@
//! QBZ-NIX: Native Qobuz client for Linux
//!
//! A high-fidelity music streaming client for Qobuz, designed for audiophiles
//! who need bit-perfect playback without browser sample rate limitations.
pub mod api;
pub mod commands;
pub mod config;
pub mod player;
pub mod queue;
use std::sync::Arc;
use tokio::sync::Mutex;
use api::QobuzClient;
/// Application state shared across commands
pub struct AppState {
pub client: Arc<Mutex<QobuzClient>>,
}
impl AppState {
pub fn new() -> Self {
Self {
client: Arc::new(Mutex::new(QobuzClient::default())),
}
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
// Auth commands
commands::init_client,
commands::login,
commands::is_logged_in,
// Search commands
commands::search_albums,
commands::search_tracks,
commands::search_artists,
commands::get_album,
commands::get_track,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
qbz_nix_lib::run()
}
+12
View File
@@ -0,0 +1,12 @@
//! Audio player module
//!
//! Handles audio playback with support for:
//! - Multiple backends (PipeWire, ALSA direct, PulseAudio)
//! - Gapless playback
//! - DAC passthrough mode
//! - Sample rate reporting
// TODO: Implement audio playback
// pub mod engine;
// pub mod output;
// pub mod passthrough;
+10
View File
@@ -0,0 +1,10 @@
//! Queue management module
//!
//! Handles playback queue with:
//! - Queue manipulation (add, remove, reorder)
//! - Shuffle modes
//! - Repeat modes
//! - Play history
// TODO: Implement queue management
// pub mod manager;
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "QBZ-NIX",
"version": "0.1.0",
"identifier": "com.blitzkriegfc.qbz-nix",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "QBZ-NIX",
"width": 1280,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"decorations": true,
"transparent": false,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Music",
"shortDescription": "Native Qobuz client for Linux",
"longDescription": "QBZ-NIX is a native Qobuz streaming client for Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations."
}
}