Files
pmomusic/pmoqobuz/src/cmaf/parser.rs
Eric Coissac a97bfe6148 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.
2026-06-11 08:52:28 +02:00

254 lines
8.3 KiB
Rust

use super::error::CmafError;
const QBZ_INIT_UUID: [u8; 16] = [
0xc7, 0xc7, 0x5d, 0xf0, 0xfd, 0xd9, 0x51, 0xe9,
0x8f, 0xc2, 0x29, 0x71, 0xe4, 0xac, 0xf8, 0xd2,
];
const QBZ_SEGMENT_UUID: [u8; 16] = [
0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75,
0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2,
];
const FLAC_MAGIC: &[u8; 4] = b"fLaC";
/// Taille en octets et nombre d'échantillons d'un segment.
#[derive(Debug, Clone)]
pub struct SegmentTableEntry {
/// Taille des données FLAC déchiffrées de ce segment.
pub byte_len: u32,
/// Nombre d'échantillons audio dans ce segment.
pub sample_count: u32,
}
/// Header FLAC et table des segments extraits du segment d'initialisation.
pub struct InitInfo {
pub flac_header: Vec<u8>,
/// Tailles par segment (indices 0..n_segments-1 correspondent aux segments 1..n_segments).
pub segment_table: Vec<SegmentTableEntry>,
}
/// Une entrée de frame dans le segment UUID box.
pub struct FrameEntry {
pub size: u32,
pub flags: u16,
pub iv: [u8; 8],
}
/// Informations crypto parsées depuis le UUID box d'un segment audio.
pub struct SegmentCrypto {
/// Offset vers le début des données audio (payload mdat).
pub data_offset: usize,
/// Fin du contenu de la mdat box.
pub mdat_end: usize,
pub entries: Vec<FrameEntry>,
}
/// Parcourt les boxes ISO BMFF et trouve le premier UUID box correspondant à `target_uuid`.
/// Retourne `(payload_start, box_end)` où payload_start est après les 16 octets UUID.
fn find_uuid_box(data: &[u8], target_uuid: &[u8; 16]) -> Option<(usize, usize)> {
let mut pos = 0;
while pos + 8 <= data.len() {
let size = read_box_size(data, pos);
if size < 8 || pos + size > data.len() {
break;
}
if &data[pos + 4..pos + 8] == b"uuid" && pos + 24 <= data.len() {
if &data[pos + 8..pos + 24] == target_uuid.as_ref() {
return Some((pos + 24, pos + size));
}
}
pos += size;
}
None
}
/// Parse le segment d'initialisation (segment 0) pour extraire le header FLAC et la table des segments.
pub fn parse_init_segment(data: &[u8]) -> Result<InitInfo, CmafError> {
let (payload_start, box_end) = find_uuid_box(data, &QBZ_INIT_UUID)
.ok_or_else(|| CmafError::ParseError("segment init: QBZ_INIT_UUID box non trouvé".into()))?;
let payload = &data[payload_start..box_end];
parse_init_uuid_payload(payload)
}
/// Parse un segment audio pour extraire les informations crypto par frame.
pub fn parse_segment_crypto(data: &[u8]) -> Result<SegmentCrypto, CmafError> {
let mut uuid_box_start: Option<usize> = None;
let mut mdat_end = data.len();
let mut pos = 0;
while pos + 8 <= data.len() {
let size = read_box_size(data, pos);
if size < 8 || pos + size > data.len() {
break;
}
let box_type = &data[pos + 4..pos + 8];
if box_type == b"uuid" && pos + 24 <= data.len() {
if &data[pos + 8..pos + 24] == QBZ_SEGMENT_UUID.as_ref() {
uuid_box_start = Some(pos);
}
} else if box_type == b"mdat" {
mdat_end = pos + size;
}
pos += size;
}
let box_start = uuid_box_start
.ok_or_else(|| CmafError::ParseError("segment audio: QBZ_SEGMENT_UUID box non trouvé".into()))?;
parse_segment_uuid_payload(data, box_start, mdat_end)
}
fn parse_init_uuid_payload(payload: &[u8]) -> Result<InitInfo, CmafError> {
// Layout payload:
// [4B padding/version]
// [4B track_id]
// [4B file_id]
// [4B sample_rate]
// [1B bits_per_sample]
// [1B channels + 2B padding]
// [6B total_samples_count]
// [2B raw_data_len]
// [raw_data_len bytes: contient le header FLAC]
// [1B key_id_len]
// [key_id_len bytes: key_id]
// [2B segment_count]
// Par segment: [4B byte_len][4B sample_count]
if payload.len() < 28 {
return Err(CmafError::ParseError("payload init UUID trop court".into()));
}
let mut a = 4; // version/padding
a += 4; // track_id
a += 4; // file_id
a += 4; // sample_rate
a += 1; // bits_per_sample
a += 3; // channels + padding
a += 6; // total_samples_count
if a + 2 > payload.len() {
return Err(CmafError::ParseError("payload init UUID tronqué au raw_len".into()));
}
let raw_len = u16::from_be_bytes([payload[a], payload[a + 1]]) as usize;
a += 2;
let raw_data = &payload[a..a + raw_len.min(payload.len() - a)];
a += raw_len;
let flac_pos = raw_data
.windows(4)
.position(|w| w == FLAC_MAGIC)
.ok_or_else(|| CmafError::ParseError("payload init UUID: magic fLaC non trouvé".into()))?;
// fLaC (4) + STREAMINFO block header (4) + STREAMINFO data (34) = 42 octets
let header_len = 4 + 4 + 34;
if flac_pos + header_len > raw_data.len() {
return Err(CmafError::ParseError("payload init UUID: STREAMINFO tronqué".into()));
}
let mut flac_header = raw_data[flac_pos..flac_pos + header_len].to_vec();
// Marquer le dernier bloc de métadonnées
flac_header[4] |= 0x80;
if a + 1 > payload.len() {
return Ok(InitInfo { flac_header, segment_table: Vec::new() });
}
let key_id_len = payload[a] as usize;
a += 1 + key_id_len;
let mut segment_table = Vec::new();
if a + 2 <= payload.len() {
let seg_count = u16::from_be_bytes([payload[a], payload[a + 1]]) as usize;
a += 2;
for _ in 0..seg_count {
if a + 8 > payload.len() {
break;
}
let byte_len = u32::from_be_bytes([payload[a], payload[a + 1], payload[a + 2], payload[a + 3]]);
a += 4;
let sample_count = u32::from_be_bytes([payload[a], payload[a + 1], payload[a + 2], payload[a + 3]]);
a += 4;
segment_table.push(SegmentTableEntry { byte_len, sample_count });
}
}
tracing::debug!(
"Init UUID: {} segments dans la table, header FLAC {} octets",
segment_table.len(),
flac_header.len()
);
Ok(InitInfo { flac_header, segment_table })
}
fn parse_segment_uuid_payload(
data: &[u8],
uuid_box_start: usize,
mdat_end: usize,
) -> Result<SegmentCrypto, CmafError> {
// Layout après box header (8) + UUID (16) = offset 24 depuis uuid_box_start:
// [4B version/padding]
// [4B data_offset_raw] — offset depuis uuid_box_start vers les données audio
// [1B iv_size]
// [3B frame_count (24-bit BE)]
// Par frame: [4B size][2B skip][2B flags][iv_size bytes IV]
let base = uuid_box_start + 24;
if base + 12 > data.len() {
return Err(CmafError::ParseError(
"payload segment UUID trop court pour le header".into(),
));
}
let mut a = base + 4; // skip 4-byte version/padding
let data_offset_raw = u32::from_be_bytes([data[a], data[a + 1], data[a + 2], data[a + 3]]);
let data_offset = uuid_box_start + data_offset_raw as usize;
a += 4;
let iv_size = data[a] as usize;
a += 1;
let frame_count =
((data[a] as usize) << 16) | ((data[a + 1] as usize) << 8) | (data[a + 2] as usize);
a += 3;
let entry_size = 4 + 2 + 2 + iv_size;
if a + frame_count * entry_size > data.len() {
return Err(CmafError::ParseError(format!(
"segment UUID: données insuffisantes pour {frame_count} entrées de {entry_size} octets"
)));
}
let mut entries = Vec::with_capacity(frame_count);
for _ in 0..frame_count {
let size = u32::from_be_bytes([data[a], data[a + 1], data[a + 2], data[a + 3]]);
a += 4;
a += 2; // 2 octets inconnus
let flags = u16::from_be_bytes([data[a], data[a + 1]]);
a += 2;
let mut iv = [0u8; 8];
let copy_len = iv_size.min(8);
iv[..copy_len].copy_from_slice(&data[a..a + copy_len]);
a += iv_size;
entries.push(FrameEntry { size, flags, iv });
}
Ok(SegmentCrypto { data_offset, mdat_end, entries })
}
fn read_box_size(data: &[u8], pos: usize) -> usize {
if pos + 8 > data.len() {
return 0;
}
let s = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
match s {
0 => data.len() - pos,
1..=7 => 0,
s => s as usize,
}
}