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:
261
pmoqobuz/src/api/cmaf.rs
Normal file
261
pmoqobuz/src/api/cmaf.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! Endpoints API CMAF de Qobuz : session/start et file/url.
|
||||
//!
|
||||
//! Ces endpoints implémentent le nouveau pipeline de streaming Qobuz
|
||||
//! qui remplace progressivement `/track/getFileUrl`.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cmaf::crypto::{compute_request_sig, CMAF_SEED};
|
||||
use crate::error::{QobuzError, Result};
|
||||
|
||||
/// État d'une session CMAF active.
|
||||
pub struct CmafSession {
|
||||
pub session_id: String,
|
||||
pub infos: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /session/start.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SessionStartResponse {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
infos: Option<String>,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /file/url (CMAF).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CmafFileUrlResponse {
|
||||
/// Modèle d'URL avec le placeholder `$SEGMENT$`.
|
||||
#[serde(default)]
|
||||
pub url_template: Option<String>,
|
||||
/// Clé de contenu enveloppée, format `"qbz-1.wrapped_b64url.iv_b64url"`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// Nombre de segments audio (hors segment init).
|
||||
pub n_segments: u8,
|
||||
#[serde(default)]
|
||||
pub format_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sampling_rate: Option<u32>,
|
||||
/// Profondeur de bits (champ v1 de l'API).
|
||||
#[serde(default)]
|
||||
pub bits_depth: Option<u32>,
|
||||
/// Profondeur de bits (champ v2 de l'API).
|
||||
#[serde(default)]
|
||||
pub bit_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl CmafFileUrlResponse {
|
||||
/// Retourne la profondeur de bits en préférant `bits_depth` puis `bit_depth`.
|
||||
pub fn resolved_bit_depth(&self) -> Option<u32> {
|
||||
self.bits_depth.or(self.bit_depth)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Signe une requête /session/start.
|
||||
fn sign_session_start(timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("profile", "qbz-1".to_string());
|
||||
compute_request_sig("sessionstart", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Signe une requête /file/url.
|
||||
fn sign_file_url(track_id: &str, format_id: u32, timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("format_id", format_id.to_string());
|
||||
args.insert("intent", "stream".to_string());
|
||||
args.insert("track_id", track_id.to_string());
|
||||
compute_request_sig("fileurl", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Gestionnaire de session CMAF avec renouvellement automatique.
|
||||
///
|
||||
/// Utilise le pattern double-checked lock : fast path sous read guard,
|
||||
/// slow path sous write guard exclusif pour éviter les renouvellements
|
||||
/// concurrents qui produiraient des `infos` incohérents avec les `key`
|
||||
/// retournées par `/file/url`.
|
||||
pub struct CmafSessionManager {
|
||||
session: RwLock<Option<CmafSession>>,
|
||||
}
|
||||
|
||||
impl CmafSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self { session: RwLock::new(None) }
|
||||
}
|
||||
|
||||
/// Retourne `(session_id, infos)` d'une session valide, en en démarrant
|
||||
/// une nouvelle si la session courante est absente ou expire dans < 60s.
|
||||
pub async fn ensure_session(
|
||||
&self,
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let now = current_timestamp();
|
||||
|
||||
// Fast path : session existante avec > 60s restants.
|
||||
{
|
||||
let guard = self.session.read().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path : prend le verrou d'écriture et vérifie à nouveau.
|
||||
let mut guard = self.session.write().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
info!("[CMAF] Démarrage d'une nouvelle session");
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_session_start(timestamp);
|
||||
|
||||
let url = format!("{}/session/start", BASE_URL);
|
||||
let response = http
|
||||
.post(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.form(&[
|
||||
("profile", "qbz-1"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(QobuzError::ApiError {
|
||||
code: status.as_u16(),
|
||||
message: format!("session/start échoué: {}", body),
|
||||
});
|
||||
}
|
||||
|
||||
let resp: SessionStartResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse session/start: {}", e))
|
||||
})?;
|
||||
|
||||
let infos = resp.infos.unwrap_or_default();
|
||||
info!(
|
||||
"[CMAF] Session démarrée: id={}..., expires_at={}",
|
||||
&resp.session_id[..resp.session_id.len().min(8)],
|
||||
resp.expires_at,
|
||||
);
|
||||
|
||||
let session_id = resp.session_id.clone();
|
||||
let infos_clone = infos.clone();
|
||||
|
||||
*guard = Some(CmafSession {
|
||||
session_id: resp.session_id,
|
||||
infos,
|
||||
expires_at: resp.expires_at,
|
||||
});
|
||||
|
||||
Ok((session_id, infos_clone))
|
||||
}
|
||||
|
||||
/// Invalide la session courante (utile en cas d'erreur de déchiffrement).
|
||||
pub async fn invalidate(&self) {
|
||||
*self.session.write().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'URL de fichier CMAF pour un track.
|
||||
///
|
||||
/// Inclut le retry sur les erreurs transitoires (5xx, 429, erreurs réseau).
|
||||
/// Un 404 retourne immédiatement une erreur `NotFound`.
|
||||
pub async fn get_file_url(
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
session_id: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<CmafFileUrlResponse> {
|
||||
use crate::retry::{classify_reqwest, classify_status, retry_transient, DEFAULT_MAX_ATTEMPTS};
|
||||
|
||||
let url = format!("{}/file/url", BASE_URL);
|
||||
|
||||
let result = retry_transient(
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
"CMAF file/url",
|
||||
|e: &QobuzError| matches!(e, QobuzError::RateLimitExceeded | QobuzError::ApiError { code: 500..=599, .. }),
|
||||
|_attempt| {
|
||||
let url = url.clone();
|
||||
async move {
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_file_url(track_id, format_id, timestamp);
|
||||
|
||||
let response = http
|
||||
.get(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.header("X-Session-Id", session_id)
|
||||
.query(&[
|
||||
("track_id", track_id),
|
||||
("format_id", &format_id.to_string()),
|
||||
("intent", "stream"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
tracing::info!("[CMAF] file/url track_id={} format_id={} status={}", track_id, format_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
let code = status.as_u16();
|
||||
return Err(match code {
|
||||
404 => QobuzError::NotFound(format!("track {} non disponible", track_id)),
|
||||
429 => QobuzError::RateLimitExceeded,
|
||||
_ => QobuzError::ApiError {
|
||||
code,
|
||||
message: format!("file/url status {}", code),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let file_url: CmafFileUrlResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse file/url: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[CMAF] file/url: n_segments={}, mime={:?}, sampling_rate={:?}",
|
||||
file_url.n_segments,
|
||||
file_url.mime_type,
|
||||
file_url.sampling_rate,
|
||||
);
|
||||
|
||||
Ok(file_url)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
Reference in New Issue
Block a user