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
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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", ×tamp.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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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::*;
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||