commit 8a094ef467fecf252b1237949da7295a42311f41 Author: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu Jan 8 22:35:34 2026 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f3c3f538a --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ + +# Build outputs +build/ +dist/ +/package + +# Rust +target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* + +# Tauri +src-tauri/target/ + +# Svelte +.svelte-kit/ + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/README.md b/README.md new file mode 100644 index 000000000..df30f1409 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# QBZ-NIX + +Native Qobuz client for Linux with Hi-Fi audio support. + +## Why? + +Browser-based players (Chromium, Firefox) have a hardcoded sample rate cap (~48kHz). Qobuz offers up to 192kHz Hi-Res content. QBZ-NIX bypasses this limitation with native audio playback. + +## Features + +### Core +- Native audio playback (no browser limitations) +- Search, browse albums, artists, tracks +- Playback queue management +- Quality selection with automatic fallback + +### Audio +- Support for all Qobuz quality tiers (MP3, CD, Hi-Res, Ultra Hi-Res) +- Sample rate / bit depth display +- DAC passthrough mode for bit-perfect playback +- Multiple audio backends (PipeWire, ALSA, PulseAudio) + +### Planned +- Playlist management (local + Qobuz sync) +- Favorites synchronization +- Equalizer +- Last.fm integration +- Lyrics display + +## Tech Stack + +- **Backend:** Rust + Tauri +- **Frontend:** Svelte + TypeScript +- **Audio:** cpal/rodio with ALSA direct support + +## Development + +### Prerequisites + +- Rust (latest stable) +- Node.js 18+ +- Linux with audio support (PipeWire or PulseAudio) + +### Setup + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run tauri dev + +# Build for production +npm run tauri build +``` + +## Project Structure + +``` +qbz-nix/ +├── src/ # Frontend (Svelte) +├── src-tauri/ +│ └── src/ +│ ├── api/ # Qobuz API client +│ ├── player/ # Audio playback +│ ├── queue/ # Queue management +│ ├── config/ # Settings persistence +│ └── commands/ # Tauri IPC commands +└── static/ # Static assets +``` + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 000000000..09334d1e3 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "qbz-nix", + "version": "0.1.0", + "description": "Native Qobuz client for Linux with Hi-Fi audio support", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "tauri": "tauri" + }, + "license": "MIT", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3", + "@tauri-apps/cli": "^2" + } +} diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 000000000..b21bd681d --- /dev/null +++ b/src-tauri/.gitignore @@ -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 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 000000000..3b07306a2 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 000000000..4cdbf49a7 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 000000000..6be5e50e9 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..e81becee5 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 000000000..a437dd517 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 000000000..0ca4f2719 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 000000000..b81f82039 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 000000000..624c7bfba Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 000000000..c021d2ba7 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 000000000..621970023 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 000000000..f9bc04839 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 000000000..d5fbfb2ab Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 000000000..63440d798 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 000000000..f3f705af2 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 000000000..455638826 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 000000000..12a5bcee2 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 000000000..b3636e4b2 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 000000000..e1cd2619e Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/api/auth.rs b/src-tauri/src/api/auth.rs new file mode 100644 index 000000000..8d0630e09 --- /dev/null +++ b/src-tauri/src/api/auth.rs @@ -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", ¶ms, 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 { + 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); + } +} diff --git a/src-tauri/src/api/bundle.rs b/src-tauri/src/api/bundle.rs new file mode 100644 index 000000000..cb4795e44 --- /dev/null +++ b/src-tauri/src/api/bundle.rs @@ -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, +} + +/// Extract app_id and secrets from Qobuz bundle +pub async fn extract_bundle_tokens(client: &Client) -> Result { + // 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 { + // Pattern: + let re = Regex::new(r#""#) + .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 { + // Pattern: production:{api:{appId:"XXXXXXXXX" + let re = Regex::new(r#"production:\{api:\{appId:"(?P\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> { + // Extract seeds with their timezone keys + // Pattern: X.initialSeed("SEED",window.utimezone.TIMEZONE) + let seed_re = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + ) + .expect("Invalid regex"); + + let mut seeds: std::collections::HashMap = 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[a-z]+)":\{info:"(?P[\w=]+)",extras:"(?P[\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#""#; + 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"); + } +} diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs new file mode 100644 index 000000000..1f1f2e3b1 --- /dev/null +++ b/src-tauri/src/api/client.rs @@ -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>>, + session: Arc>>, + validated_secret: Arc>>, +} + +impl QobuzClient { + /// Create a new client + pub fn new() -> Result { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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", ×tamp.to_string()), + ("request_sig", &signature), + ]) + .send() + .await? + .json() + .await?; + + Ok(response) + } + + /// Get user's playlists + pub async fn get_user_playlists(&self) -> Result> { + 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") + } +} diff --git a/src-tauri/src/api/endpoints.rs b/src-tauri/src/api/endpoints.rs new file mode 100644 index 000000000..c7fe17baf --- /dev/null +++ b/src-tauri/src/api/endpoints.rs @@ -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) +} diff --git a/src-tauri/src/api/error.rs b/src-tauri/src/api/error.rs new file mode 100644 index 000000000..18f0cea8b --- /dev/null +++ b/src-tauri/src/api/error.rs @@ -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 = std::result::Result; diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs new file mode 100644 index 000000000..fa6a2a1f9 --- /dev/null +++ b/src-tauri/src/api/mod.rs @@ -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::*; diff --git a/src-tauri/src/api/models.rs b/src-tauri/src/api/models.rs new file mode 100644 index 000000000..c7b6e8173 --- /dev/null +++ b/src-tauri/src/api/models.rs @@ -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 { + 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, + pub track_id: u64, + pub restrictions: Vec, +} + +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, + pub label: Option