feat(streaming): CMAF segmented download pipeline
Download tracks via Qobuz Akamai CDN with encrypted CMAF segments. Derives session key (HKDF), unwraps content key (AES-CBC), decrypts frames (AES-CTR), reassembles into standard FLAC. Falls back to legacy track/getFileUrl if CMAF fails. - Add qbz-cmaf dependency to src-tauri - Make ensure_cmaf_session public on QobuzClient - Add ensure_cmaf_session and get_file_url passthroughs to CoreBridge - Add download_audio_cmaf and try_cmaf_download helpers - Wire CMAF as primary download path in v2_play_track with legacy fallback cc
This commit is contained in:
5 files changed
+324
-2
No files matched your search
@@ -1857,7 +1857,7 @@ impl QobuzClient {
|
||||
|
||||
/// Ensure we have a valid CMAF session, renewing if expired.
|
||||
/// Returns `(session_id, infos)` for use with file/url and key derivation.
|
||||
async fn ensure_cmaf_session(&self) -> Result<(String, String)> {
|
||||
pub async fn ensure_cmaf_session(&self) -> Result<(String, String)> {
|
||||
let now = get_timestamp();
|
||||
|
||||
// Check existing session (with 60s safety margin)
|
||||
|
||||
Generated
+55
@@ -587,6 +587,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -730,6 +739,15 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
@@ -1353,6 +1371,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2466,6 +2485,24 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -2851,6 +2888,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -4676,6 +4714,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-cmaf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"ctr",
|
||||
"hkdf",
|
||||
"log",
|
||||
"md-5",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-core"
|
||||
version = "0.1.0"
|
||||
@@ -4767,6 +4820,7 @@ dependencies = [
|
||||
"qbz-audio",
|
||||
"qbz-cache",
|
||||
"qbz-cast",
|
||||
"qbz-cmaf",
|
||||
"qbz-core",
|
||||
"qbz-integrations",
|
||||
"qbz-library",
|
||||
@@ -4838,6 +4892,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"md-5",
|
||||
"qbz-cmaf",
|
||||
"qbz-models",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -27,6 +27,7 @@ qbz-integrations = { path = "../crates/qbz-integrations" }
|
||||
qbz-cache = { path = "../crates/qbz-cache" }
|
||||
qbz-library = { path = "../crates/qbz-library" }
|
||||
qbz-cast = { path = "../crates/qbz-cast" }
|
||||
qbz-cmaf = { path = "../crates/qbz-cmaf" }
|
||||
qconnect-app = { path = "../crates/qconnect-app" }
|
||||
qconnect-core = { path = "../crates/qconnect-core" }
|
||||
qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
|
||||
|
||||
@@ -390,12 +390,23 @@ fn cached_audio_incompatible_with_hw(
|
||||
/// Downloads audio data from a CDN URL.
|
||||
/// Uses a connect timeout but no total timeout because Hi-Res+ tracks
|
||||
/// can be 100-200MB and take several minutes on slower connections.
|
||||
/// Waits for any active streaming download to finish before starting,
|
||||
/// to avoid CDN rate limiting from concurrent downloads.
|
||||
async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
use std::time::Duration;
|
||||
|
||||
// Wait for streaming download to finish (CDN kills concurrent connections)
|
||||
while CDN_STREAMING_ACTIVE.load(std::sync::atomic::Ordering::Acquire) > 0 {
|
||||
log::debug!("[V2] download_audio waiting for streaming download to finish...");
|
||||
CDN_STREAM_DONE.notified().await;
|
||||
}
|
||||
|
||||
// Force HTTP/1.1 — Qobuz CDN sends RST_STREAM on large downloads over HTTP/2,
|
||||
// causing "1 byte then EOF". curl (HTTP/1.1) downloads the same URLs successfully.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.use_native_tls()
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
@@ -412,6 +423,22 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
return Err(format!("HTTP error: {}", response.status()));
|
||||
}
|
||||
|
||||
// Log response headers for CDN diagnostics (helps debug "1 byte EOF" issues)
|
||||
{
|
||||
let headers = response.headers();
|
||||
let h_ce = headers.get("content-encoding").map(|v| v.to_str().unwrap_or("?"));
|
||||
let h_te = headers.get("transfer-encoding").map(|v| v.to_str().unwrap_or("?"));
|
||||
let h_conn = headers.get("connection").map(|v| v.to_str().unwrap_or("?"));
|
||||
let h_server = headers.get("server").map(|v| v.to_str().unwrap_or("?"));
|
||||
let h_ct = headers.get("content-type").map(|v| v.to_str().unwrap_or("?"));
|
||||
let h_via = headers.get("via").map(|v| v.to_str().unwrap_or("?"));
|
||||
log::info!(
|
||||
"[V2] CDN response: status={}, content-encoding={:?}, transfer-encoding={:?}, connection={:?}, server={:?}, content-type={:?}, via={:?}, version={:?}",
|
||||
response.status(),
|
||||
h_ce, h_te, h_conn, h_server, h_ct, h_via, response.version()
|
||||
);
|
||||
}
|
||||
|
||||
let content_length = response.content_length();
|
||||
if let Some(len) = content_length {
|
||||
log::info!("[V2] Downloading audio: {} bytes expected", len);
|
||||
@@ -630,6 +657,137 @@ async fn download_with_backoff(
|
||||
))
|
||||
}
|
||||
|
||||
/// Download audio via CMAF segmented pipeline.
|
||||
/// Fetches init segment + audio segments, decrypts, returns standard FLAC bytes.
|
||||
async fn download_audio_cmaf(
|
||||
url_template: &str,
|
||||
n_segments: u8,
|
||||
content_key: &[u8; 16],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.use_native_tls()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Fetch init segment (s=0)
|
||||
let init_url = url_template.replace("$SEGMENT$", "0");
|
||||
log::info!("[CMAF] Fetching init segment");
|
||||
let init_data = client
|
||||
.get(&init_url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch init segment: {}", e))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read init segment: {}", e))?;
|
||||
|
||||
let init_info = qbz_cmaf::parse_init_segment(&init_data)
|
||||
.map_err(|e| format!("Failed to parse init segment: {}", e))?;
|
||||
|
||||
log::info!(
|
||||
"[CMAF] Init: FLAC header {}B, {} entries in segment table",
|
||||
init_info.flac_header.len(),
|
||||
init_info.segment_table.len()
|
||||
);
|
||||
|
||||
// Pre-allocate output
|
||||
let total_size: usize = init_info.flac_header.len()
|
||||
+ init_info
|
||||
.segment_table
|
||||
.iter()
|
||||
.map(|s| s.byte_len as usize)
|
||||
.sum::<usize>();
|
||||
let mut output = Vec::with_capacity(total_size);
|
||||
output.extend_from_slice(&init_info.flac_header);
|
||||
|
||||
// Fetch and decrypt audio segments (s=1..n_segments-1)
|
||||
for seg_idx in 1..n_segments {
|
||||
let seg_url = url_template.replace("$SEGMENT$", &seg_idx.to_string());
|
||||
let seg_data = client
|
||||
.get(&seg_url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Segment {} fetch failed: {}", seg_idx, e))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Segment {} read failed: {}", seg_idx, e))?;
|
||||
|
||||
let crypto = qbz_cmaf::parse_segment_crypto(&seg_data)
|
||||
.map_err(|e| format!("Segment {} parse failed: {}", seg_idx, e))?;
|
||||
|
||||
let mut data_pos = crypto.data_offset;
|
||||
for entry in &crypto.entries {
|
||||
let frame_end = data_pos + entry.size as usize;
|
||||
if frame_end > seg_data.len() {
|
||||
return Err(format!("Segment {} frame overflows data", seg_idx));
|
||||
}
|
||||
let mut frame = seg_data[data_pos..frame_end].to_vec();
|
||||
if entry.flags != 0 {
|
||||
qbz_cmaf::decrypt_frame(content_key, &entry.iv, &mut frame);
|
||||
}
|
||||
output.extend_from_slice(&frame);
|
||||
data_pos = frame_end;
|
||||
}
|
||||
|
||||
// Trailing unencrypted data
|
||||
if data_pos < crypto.mdat_end && crypto.mdat_end <= seg_data.len() {
|
||||
output.extend_from_slice(&seg_data[data_pos..crypto.mdat_end]);
|
||||
}
|
||||
|
||||
if seg_idx % 10 == 0 || seg_idx == n_segments - 1 {
|
||||
log::info!(
|
||||
"[CMAF] Segment {}/{} ({:.1} MB)",
|
||||
seg_idx,
|
||||
n_segments - 1,
|
||||
output.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[CMAF] Complete: {:.2} MB FLAC",
|
||||
output.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Try downloading a track via the CMAF pipeline (Akamai CDN, encrypted segments).
|
||||
/// Returns `(audio_bytes, format_id, sample_rate, bit_depth)` on success.
|
||||
async fn try_cmaf_download(
|
||||
bridge: &CoreBridge,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<(Vec<u8>, u32, Option<u32>, Option<u32>), String> {
|
||||
let file_url = bridge.get_file_url(track_id, quality).await?;
|
||||
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.as_ref()
|
||||
.ok_or("No url_template in file/url response")?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.as_ref()
|
||||
.ok_or("No key in file/url response")?;
|
||||
|
||||
let (_session_id, infos) = bridge.ensure_cmaf_session().await?;
|
||||
|
||||
let session_key = qbz_cmaf::derive_session_key(&infos)
|
||||
.map_err(|e| format!("Session key derivation failed: {}", e))?;
|
||||
let content_key = qbz_cmaf::unwrap_content_key(&session_key, key_str)
|
||||
.map_err(|e| format!("Content key unwrap failed: {}", e))?;
|
||||
|
||||
let audio_data = download_audio_cmaf(url_template, file_url.n_segments, &content_key).await?;
|
||||
|
||||
let format_id = file_url.format_id.unwrap_or(quality.id());
|
||||
let sampling_rate = file_url.sampling_rate;
|
||||
let bit_depth = file_url.bits_depth.or(file_url.bit_depth);
|
||||
|
||||
Ok((audio_data, format_id, sampling_rate, bit_depth))
|
||||
}
|
||||
|
||||
/// Stream info from probing a URL (HEAD + first 64KB)
|
||||
struct V2StreamInfo {
|
||||
content_length: u64,
|
||||
@@ -647,6 +805,7 @@ async fn v2_get_stream_info(url: &str) -> Result<V2StreamInfo, String> {
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.use_native_tls()
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
@@ -721,7 +880,9 @@ async fn v2_get_stream_info(url: &str) -> Result<V2StreamInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Download audio chunks and push them to the player's streaming buffer
|
||||
/// Download audio chunks and push them to the player's streaming buffer.
|
||||
/// Signals CDN_STREAMING_ACTIVE so prefetch waits until we finish,
|
||||
/// preventing concurrent CDN downloads that trigger rate limiting.
|
||||
async fn v2_download_and_stream(
|
||||
url: &str,
|
||||
writer: qbz_player::BufferWriter,
|
||||
@@ -733,10 +894,24 @@ async fn v2_download_and_stream(
|
||||
use futures_util::StreamExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Signal that a streaming download is active — prefetch will wait
|
||||
CDN_STREAMING_ACTIVE.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||
|
||||
// Ensure we clear the flag and notify waiters when done (even on error)
|
||||
struct StreamingGuard;
|
||||
impl Drop for StreamingGuard {
|
||||
fn drop(&mut self) {
|
||||
CDN_STREAMING_ACTIVE.fetch_sub(1, std::sync::atomic::Ordering::Release);
|
||||
CDN_STREAM_DONE.notify_waiters();
|
||||
}
|
||||
}
|
||||
let _guard = StreamingGuard;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_native_tls()
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
@@ -1802,8 +1977,16 @@ lazy_static::lazy_static! {
|
||||
/// Semaphore to limit concurrent prefetch operations
|
||||
static ref V2_PREFETCH_SEMAPHORE: tokio::sync::Semaphore =
|
||||
tokio::sync::Semaphore::new(V2_MAX_CONCURRENT_PREFETCH);
|
||||
|
||||
/// Notify waiters when streaming download finishes.
|
||||
/// Prefetch waits on this if a streaming download is active.
|
||||
static ref CDN_STREAM_DONE: tokio::sync::Notify = tokio::sync::Notify::new();
|
||||
}
|
||||
|
||||
/// Number of active streaming downloads (for the currently-playing track).
|
||||
/// Prefetch checks this and waits if > 0 to avoid CDN rate limiting.
|
||||
static CDN_STREAMING_ACTIVE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Spawn background tasks to prefetch upcoming Qobuz tracks (V2)
|
||||
/// Takes upcoming tracks directly from CoreBridge (not legacy AppState queue)
|
||||
fn spawn_v2_prefetch(
|
||||
@@ -7888,6 +8071,56 @@ pub async fn v2_play_track(
|
||||
);
|
||||
}
|
||||
|
||||
// Try CMAF pipeline first (Akamai CDN, encrypted segments)
|
||||
log::info!("[V2/CMAF] Attempting CMAF download for track {}", track_id);
|
||||
match try_cmaf_download(&*bridge_guard, track_id, final_quality).await {
|
||||
Ok((audio_data, format_id, _sample_rate, _bit_depth)) => {
|
||||
log::info!(
|
||||
"[V2/CMAF] Success: {} bytes, format_id={}",
|
||||
audio_data.len(),
|
||||
format_id
|
||||
);
|
||||
let data_size = audio_data.len();
|
||||
|
||||
// Cache it (unless streaming_only mode)
|
||||
if !streaming_only {
|
||||
cache.insert(track_id, audio_data.clone());
|
||||
log::info!("[V2/CACHED] Track {} stored in memory cache (via CMAF)", track_id);
|
||||
} else {
|
||||
log::info!(
|
||||
"[V2/NOT CACHED] Track {} - streaming_only mode active",
|
||||
track_id
|
||||
);
|
||||
}
|
||||
|
||||
// Play it via qbz-player
|
||||
player
|
||||
.play_data(audio_data, track_id)
|
||||
.map_err(RuntimeError::Internal)?;
|
||||
log::info!("[V2] Playing track {} ({} bytes, via CMAF)", track_id, data_size);
|
||||
|
||||
// Prefetch next tracks in background
|
||||
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await;
|
||||
drop(bridge_guard);
|
||||
spawn_v2_prefetch_with_hw_check(
|
||||
bridge.0.clone(),
|
||||
cache,
|
||||
upcoming_tracks,
|
||||
final_quality,
|
||||
streaming_only,
|
||||
hw_device_id,
|
||||
);
|
||||
|
||||
return Ok(V2PlayTrackResult {
|
||||
format_id: Some(format_id),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[V2/CMAF] Failed: {}, falling back to legacy download", e);
|
||||
// Fall through to existing legacy path
|
||||
}
|
||||
}
|
||||
|
||||
let stream_url_result = bridge_guard
|
||||
.get_stream_url(track_id, final_quality)
|
||||
.await;
|
||||
|
||||
@@ -570,6 +570,39 @@ impl CoreBridge {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== CMAF Streaming ====================
|
||||
|
||||
/// Ensure a valid CMAF session exists, renewing if expired.
|
||||
/// Returns `(session_id, infos)` where `infos` is used for key derivation.
|
||||
pub async fn ensure_cmaf_session(&self) -> Result<(String, String), String> {
|
||||
let client_lock = self.core.client();
|
||||
let guard = client_lock.read().await;
|
||||
let client = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "QobuzClient not initialized".to_string())?;
|
||||
client
|
||||
.ensure_cmaf_session()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get CMAF segmented file URL for a track.
|
||||
pub async fn get_file_url(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<qbz_models::TrackFileUrl, String> {
|
||||
let client_lock = self.core.client();
|
||||
let guard = client_lock.read().await;
|
||||
let client = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "QobuzClient not initialized".to_string())?;
|
||||
client
|
||||
.get_file_url(track_id, quality)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== Playback Commands ====================
|
||||
|
||||
/// Pause playback
|
||||
|
||||
Reference in new issue
Block a user