Add mobile chunked streaming with segmented playback fallback
This commit is contained in:
230
src/api.rs
230
src/api.rs
@@ -2,8 +2,92 @@ use crate::config::Config;
|
||||
use crate::crypto;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::types::*;
|
||||
use aes::Aes128;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use cbc::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
||||
use hkdf::Hkdf;
|
||||
use reqwest::Client;
|
||||
use sha2::Sha256;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<Aes128>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrackStream {
|
||||
DirectUrl {
|
||||
url: String,
|
||||
},
|
||||
Segmented {
|
||||
url_template: String,
|
||||
n_segments: u32,
|
||||
encryption_key_hex: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn b64url_decode(s: &str) -> Result<Vec<u8>> {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(s.trim_end_matches('='))
|
||||
.map_err(|e| QobuzError::CryptoError(format!("base64url decode failed: {}", e)))
|
||||
}
|
||||
|
||||
fn derive_track_key_hex(
|
||||
session_infos: &str,
|
||||
app_secret_hex: &str,
|
||||
key_field: &str,
|
||||
) -> Result<String> {
|
||||
let infos_parts: Vec<&str> = session_infos.splitn(2, '.').collect();
|
||||
if infos_parts.len() != 2 {
|
||||
return Err(QobuzError::CryptoError(format!(
|
||||
"invalid session infos format: {}",
|
||||
session_infos
|
||||
)));
|
||||
}
|
||||
|
||||
let salt = b64url_decode(infos_parts[0])?;
|
||||
let info = b64url_decode(infos_parts[1])?;
|
||||
let ikm = hex::decode(app_secret_hex)
|
||||
.map_err(|e| QobuzError::CryptoError(format!("invalid app secret hex: {}", e)))?;
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||
let mut kek = [0u8; 16];
|
||||
hk.expand(&info, &mut kek)
|
||||
.map_err(|e| QobuzError::CryptoError(format!("HKDF expand failed: {e:?}")))?;
|
||||
|
||||
let key_parts: Vec<&str> = key_field.splitn(3, '.').collect();
|
||||
if key_parts.len() != 3 || key_parts[0] != "qbz-1" {
|
||||
return Err(QobuzError::CryptoError(format!(
|
||||
"unexpected key field format: {}",
|
||||
key_field
|
||||
)));
|
||||
}
|
||||
|
||||
let ciphertext = b64url_decode(key_parts[1])?;
|
||||
let iv_bytes = b64url_decode(key_parts[2])?;
|
||||
if ciphertext.len() < 16 || iv_bytes.len() < 16 {
|
||||
return Err(QobuzError::CryptoError(format!(
|
||||
"invalid key field lengths: ciphertext={} iv={}",
|
||||
ciphertext.len(),
|
||||
iv_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut buf = ciphertext;
|
||||
let iv: [u8; 16] = iv_bytes[..16]
|
||||
.try_into()
|
||||
.map_err(|_| QobuzError::CryptoError("invalid IV length".to_string()))?;
|
||||
let decrypted = Aes128CbcDec::new(&kek.into(), &iv.into())
|
||||
.decrypt_padded_mut::<NoPadding>(&mut buf)
|
||||
.map_err(|e| QobuzError::CryptoError(format!("AES-CBC decrypt failed: {e:?}")))?;
|
||||
|
||||
if decrypted.len() < 16 {
|
||||
return Err(QobuzError::CryptoError(
|
||||
"decrypted track key too short".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hex::encode(&decrypted[..16]))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QobuzApi {
|
||||
@@ -311,7 +395,110 @@ impl QobuzApi {
|
||||
Ok(album)
|
||||
}
|
||||
|
||||
pub async fn get_track_url(
|
||||
async fn start_playback_session(&self, access_token: &str) -> Result<PlaybackSession> {
|
||||
let timestamp = self.get_timestamp();
|
||||
let signature =
|
||||
crypto::generate_request_signature("session/start", &[("profile", "qbz-1")], timestamp);
|
||||
|
||||
let url = format!(
|
||||
"{}/api.json/0.2/session/start?app_id={}&request_ts={}&request_sig={}",
|
||||
self.base_url, self.app_id, timestamp, signature
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(self.build_auth_headers(Some(access_token)))
|
||||
.form(&[("profile", "qbz-1")])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(QobuzError::ApiError(format!(
|
||||
"Failed to start playback session: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let session: PlaybackSession = response.json().await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn get_track_stream_mobile(
|
||||
&self,
|
||||
access_token: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<TrackStream> {
|
||||
let session = self.start_playback_session(access_token).await?;
|
||||
|
||||
let timestamp = self.get_timestamp();
|
||||
let format_id_str = format_id.to_string();
|
||||
let signature = crypto::generate_request_signature(
|
||||
"file/url",
|
||||
&[
|
||||
("format_id", &format_id_str),
|
||||
("intent", "stream"),
|
||||
("track_id", track_id),
|
||||
],
|
||||
timestamp,
|
||||
);
|
||||
|
||||
let url = format!(
|
||||
"{}/api.json/0.2/file/url?app_id={}&track_id={}&format_id={}&intent=stream&request_ts={}&request_sig={}",
|
||||
self.base_url, self.app_id, track_id, format_id, timestamp, signature
|
||||
);
|
||||
|
||||
let mut headers = self.build_auth_headers(Some(access_token));
|
||||
headers.insert(
|
||||
"X-Session-Id",
|
||||
session.session_id.parse().map_err(|e| {
|
||||
QobuzError::ApiError(format!("Invalid X-Session-Id header value: {}", e))
|
||||
})?,
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).headers(headers).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(QobuzError::ApiError(format!(
|
||||
"Failed to get mobile file URL: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let mut file: FileUrlResponse = response.json().await?;
|
||||
|
||||
if let (Some(key_field), Some(infos)) = (file.key.clone(), session.infos.as_deref()) {
|
||||
match derive_track_key_hex(infos, &crypto::APP_SECRET, &key_field) {
|
||||
Ok(unwrapped) => file.key = Some(unwrapped),
|
||||
Err(e) => {
|
||||
warn!("Failed to unwrap track key for {}: {}", track_id, e);
|
||||
file.key = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = file.url {
|
||||
return Ok(TrackStream::DirectUrl { url });
|
||||
}
|
||||
|
||||
if let (Some(url_template), Some(n_segments)) = (file.url_template, file.n_segments) {
|
||||
return Ok(TrackStream::Segmented {
|
||||
url_template,
|
||||
n_segments,
|
||||
encryption_key_hex: file.key,
|
||||
});
|
||||
}
|
||||
|
||||
Err(QobuzError::ApiError(
|
||||
"Mobile file/url response did not contain url or url_template".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_track_url_legacy(
|
||||
&self,
|
||||
access_token: &str,
|
||||
track_id: &str,
|
||||
@@ -360,6 +547,47 @@ impl QobuzApi {
|
||||
Ok(url_response.url)
|
||||
}
|
||||
|
||||
pub async fn get_track_stream(
|
||||
&self,
|
||||
access_token: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<TrackStream> {
|
||||
match self
|
||||
.get_track_stream_mobile(access_token, track_id, format_id)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Mobile file/url failed for track {} (format {}), falling back to legacy endpoint: {}",
|
||||
track_id, format_id, e
|
||||
);
|
||||
let url = self
|
||||
.get_track_url_legacy(access_token, track_id, format_id)
|
||||
.await?;
|
||||
Ok(TrackStream::DirectUrl { url })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_track_url(
|
||||
&self,
|
||||
access_token: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<String> {
|
||||
match self
|
||||
.get_track_stream(access_token, track_id, format_id)
|
||||
.await?
|
||||
{
|
||||
TrackStream::DirectUrl { url } => Ok(url),
|
||||
TrackStream::Segmented { .. } => Err(QobuzError::ApiError(
|
||||
"Track uses segmented stream; use get_track_stream instead".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_track(&self, access_token: &str, track_id: &str) -> Result<Track> {
|
||||
let timestamp = self.get_timestamp();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user