feat: implement Qobuz CMAF streaming and AES decryption

Introduces a new CMAF streaming module for Qobuz that handles progressive segment fetching and full-track decryption. The implementation adds HKDF session key derivation, AES-128-CBC key unwrapping, and AES-128-CTR frame decryption, alongside an ISO BMFF parser for extracting FLAC headers and segment metadata. It also integrates automatic session renewal with double-checked locking, a generic async retry mechanism for transient HTTP errors, and centralized error handling. Finally, it exposes `CmafStreamInfo` and async streaming methods in the public API, updating cryptographic dependencies accordingly.
This commit is contained in:
2026-06-11 08:46:11 +02:00
parent 7c10b22204
commit a97bfe6148
12 changed files with 1395 additions and 1 deletions

View File

@@ -4,10 +4,12 @@
pub mod auth;
pub mod catalog;
pub mod cmaf;
pub mod signing;
pub mod spoofer;
pub mod user;
use crate::api::cmaf::CmafSessionManager;
use crate::error::{QobuzError, Result};
use crate::models::AudioFormat;
use reqwest::{Client, Response};
@@ -95,6 +97,8 @@ pub struct QobuzApi {
user_id: RwLock<Option<String>>,
/// Format audio par défaut
format_id: AudioFormat,
/// Gestionnaire de session CMAF (renouvellement automatique thread-safe)
pub(crate) cmaf_session: CmafSessionManager,
}
impl QobuzApi {
@@ -114,6 +118,7 @@ impl QobuzApi {
user_auth_token: RwLock::new(None),
user_id: RwLock::new(None),
format_id: AudioFormat::default(),
cmaf_session: CmafSessionManager::new(),
})
}
@@ -316,6 +321,57 @@ impl QobuzApi {
self.handle_response(response, endpoint, params).await
}
/// Retourne le client HTTP interne (utilisé par les endpoints CMAF).
pub(crate) fn http_client(&self) -> &Client {
&self.client
}
/// Assure qu'une session CMAF valide existe et retourne `(session_id, infos)`.
///
/// Crée ou renouvelle automatiquement la session si elle est absente ou expire
/// dans moins de 60 secondes. Les appels concurrents sont sérialisés pour
/// éviter des sessions incohérentes.
pub async fn ensure_cmaf_session(&self) -> Result<(String, String)> {
let app_id = self.app_id.read().unwrap().clone();
let auth_token = self
.user_auth_token
.read()
.unwrap()
.clone()
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
self.cmaf_session
.ensure_session(&self.client, &app_id, &auth_token)
.await
}
/// Récupère l'URL de fichier CMAF pour un track avec le format donné.
pub async fn get_cmaf_file_url(
&self,
track_id: &str,
format_id: u32,
) -> Result<crate::api::cmaf::CmafFileUrlResponse> {
let app_id = self.app_id.read().unwrap().clone();
let auth_token = self
.user_auth_token
.read()
.unwrap()
.clone()
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
let (session_id, _infos) = self.ensure_cmaf_session().await?;
crate::api::cmaf::get_file_url(
&self.client,
&app_id,
&auth_token,
&session_id,
track_id,
format_id,
)
.await
}
/// Traite la réponse HTTP
async fn handle_response<T: DeserializeOwned>(
&self,