on avance un peu pmoqobuz

This commit is contained in:
2025-12-08 22:32:42 +01:00
parent 31ee25c7f2
commit 76132086b6
31 changed files with 9047 additions and 309 deletions

View File

@@ -15,7 +15,8 @@ struct LoginResponse {
/// Informations utilisateur retournées par l'API
#[derive(Debug, Deserialize)]
struct UserInfo {
id: u64,
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
#[serde(default)]
email: Option<String>,
#[serde(default)]
@@ -80,7 +81,7 @@ impl QobuzApi {
));
}
let user_id = response.user.id.to_string();
let user_id = response.user.id;
let subscription_label = response
.user
.credential

View File

@@ -1,7 +1,7 @@
//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists)
use super::QobuzApi;
use crate::error::Result;
use crate::error::{QobuzError, Result};
use crate::models::*;
use serde::Deserialize;
use tracing::debug;
@@ -21,6 +21,7 @@ struct PaginatedResponse<T> {
/// Réponse de l'endpoint /album/get
#[derive(Debug, Deserialize)]
pub(crate) struct AlbumResponse {
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
title: String,
artist: ArtistResponse,
@@ -51,6 +52,7 @@ pub(crate) struct AlbumResponse {
/// Réponse de l'endpoint /track/get
#[derive(Debug, Deserialize)]
pub(crate) struct TrackResponse {
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
title: String,
#[serde(default)]
@@ -69,7 +71,8 @@ pub(crate) struct TrackResponse {
/// Réponse artiste
#[derive(Debug, Deserialize)]
pub(crate) struct ArtistResponse {
id: u64,
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
name: String,
#[serde(default)]
image: Option<ImageResponse>,
@@ -101,7 +104,8 @@ struct LabelResponse {
/// Réponse playlist
#[derive(Debug, Deserialize)]
pub(crate) struct PlaylistResponse {
id: u64,
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
name: String,
#[serde(default)]
description: Option<String>,
@@ -122,7 +126,8 @@ pub(crate) struct PlaylistResponse {
/// Réponse propriétaire
#[derive(Debug, Deserialize)]
struct OwnerResponse {
id: u64,
#[serde(deserialize_with = "crate::models::deserialize_id")]
id: String,
name: String,
}
@@ -207,14 +212,55 @@ impl QobuzApi {
}
/// Récupère l'URL de streaming d'une track
///
/// Cette méthode nécessite un secret s4 pour signer la requête.
/// Si aucun secret n'est configuré, retourne une erreur.
///
/// # Errors
///
/// Retourne `QobuzError::Configuration` si le secret n'est pas configuré.
pub async fn get_file_url(&self, track_id: &str) -> Result<StreamInfo> {
use super::signing;
debug!("Fetching file URL for track {}", track_id);
// Vérifier que le secret est disponible
let secret = self
.secret()
.ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign track/getFileUrl request.".to_string(),
)
})?;
let format_id = self.format_id.id().to_string();
let intent = "stream";
let timestamp = signing::get_timestamp();
// Signer la requête (comme Python: track_getFileUrl)
let signature = signing::sign_track_get_file_url(
&format_id,
intent,
track_id,
&timestamp,
secret,
);
debug!(
"Signing track/getFileUrl: track_id={}, format_id={}, ts={}",
track_id, format_id, timestamp
);
// Construire les paramètres signés
let params = [
("track_id", track_id),
("format_id", &format_id),
("intent", "stream"),
("format_id", format_id.as_str()),
("intent", intent),
("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()),
];
// Utiliser GET (comme Python après sept 2024 selon le commentaire)
let response: FileUrlResponse = self.get("/track/getFileUrl", &params).await?;
Ok(StreamInfo {
@@ -441,7 +487,7 @@ impl QobuzApi {
pub(crate) fn parse_artist(response: ArtistResponse) -> Artist {
Artist {
id: response.id.to_string(),
id: response.id,
name: response.name,
image: response.image.and_then(|i| i.large),
image_cached: None,
@@ -450,7 +496,7 @@ impl QobuzApi {
pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist {
Playlist {
id: response.id.to_string(),
id: response.id,
name: response.name,
description: response.description,
tracks_count: response.tracks_count,
@@ -459,7 +505,7 @@ impl QobuzApi {
image_cached: None,
is_public: response.is_public,
owner: response.owner.map(|o| PlaylistOwner {
id: o.id,
id: o.id.parse().unwrap_or(0),
name: o.name,
}),
}

View File

@@ -4,6 +4,8 @@
pub mod auth;
pub mod catalog;
pub mod signing;
pub mod spoofer;
pub mod user;
use crate::error::{QobuzError, Result};
@@ -14,15 +16,34 @@ use serde_json::Value;
use std::time::Duration;
use tracing::{debug, warn};
pub use spoofer::Spoofer;
/// URL de base de l'API Qobuz
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
/// App ID Qobuz par défaut
///
/// Cet App ID est un fallback au cas où :
/// - Aucun appID n'est configuré dans pmoconfig
/// - Le Spoofer n'est pas disponible ou échoue
///
/// Note: Cet App ID peut devenir obsolète avec le temps.
/// Il est recommandé d'utiliser soit la configuration manuelle,
/// soit le Spoofer pour obtenir un App ID à jour.
pub const DEFAULT_APP_ID: &str = "1401488693436528";
/// Client API bas-niveau pour communiquer avec Qobuz
pub struct QobuzApi {
/// Client HTTP
client: Client,
/// App ID pour l'authentification
app_id: String,
/// Secret s4 pour signer les requêtes sensibles (track/getFileUrl, userLibrary/*)
///
/// Ce secret est obtenu soit :
/// - En décodant un `configvalue` (base64) et XOR avec l'app_id
/// - Depuis le Spoofer (secrets dynamiques)
secret: Option<Vec<u8>>,
/// Token d'authentification utilisateur
user_auth_token: Option<String>,
/// ID utilisateur
@@ -44,12 +65,72 @@ impl QobuzApi {
Ok(Self {
client,
app_id: app_id.into(),
secret: None,
user_auth_token: None,
user_id: None,
format_id: AudioFormat::default(),
})
}
/// Crée une API avec un secret depuis configvalue (base64)
///
/// # Arguments
///
/// * `app_id` - App ID Qobuz
/// * `configvalue` - Secret encodé en base64 (à XORer avec l'app_id)
///
/// # Note
///
/// Cette méthode reproduit le comportement Python de `__set_s4()`.
/// Le configvalue est décodé depuis base64, puis XORé avec l'app_id
/// pour obtenir le secret s4.
pub fn with_secret(app_id: impl Into<String>, configvalue: &str) -> Result<Self> {
let mut api = Self::new(app_id)?;
api.set_secret_from_configvalue(configvalue)?;
Ok(api)
}
/// Définit le secret s4 directement
///
/// # Arguments
///
/// * `secret` - Secret s4 en bytes (déjà décodé et dérivé)
pub fn set_secret(&mut self, secret: Vec<u8>) {
self.secret = Some(secret);
}
/// Dérive et définit le secret s4 depuis un configvalue
///
/// Reproduit la logique Python de `__set_s4()`:
/// 1. Décode le configvalue depuis base64
/// 2. XOR avec l'app_id
/// 3. Stocke le résultat comme secret s4
fn set_secret_from_configvalue(&mut self, configvalue: &str) -> Result<()> {
use base64::{engine::general_purpose::STANDARD, Engine};
// Décoder le configvalue depuis base64
let s3s = STANDARD
.decode(configvalue.trim())
.map_err(|e| QobuzError::Configuration(format!("Invalid configvalue: {}", e)))?;
// XOR avec l'app_id
let app_id_bytes = self.app_id.as_bytes();
let mut s4 = Vec::with_capacity(s3s.len());
for (i, &byte) in s3s.iter().enumerate() {
let app_byte = app_id_bytes[i % app_id_bytes.len()];
s4.push(byte ^ app_byte);
}
self.secret = Some(s4);
Ok(())
}
/// Retourne le secret s4 si disponible
pub fn secret(&self) -> Option<&[u8]> {
self.secret.as_deref()
}
/// Définit le token d'authentification
pub fn set_auth_token(&mut self, token: String, user_id: String) {
self.user_auth_token = Some(token);

151
pmoqobuz/src/api/signing.rs Normal file
View File

@@ -0,0 +1,151 @@
//! Module de signature MD5 pour les requêtes Qobuz
//!
//! Certaines requêtes Qobuz (notamment track/getFileUrl et userLibrary/*)
//! nécessitent une signature MD5 incluant le secret s4.
use md5::{Digest, Md5};
use std::time::{SystemTime, UNIX_EPOCH};
/// Génère un timestamp Unix actuel
///
/// # Returns
///
/// Timestamp Unix sous forme de string avec décimales
///
/// # Exemple
///
/// ```
/// use pmoqobuz::api::signing::get_timestamp;
/// let ts = get_timestamp();
/// println!("Timestamp: {}", ts);
/// ```
pub fn get_timestamp() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
.to_string()
}
/// Signe une requête track/getFileUrl
///
/// Reproduit la logique Python:
/// ```python
/// stringvalue = ("trackgetFileUrlformat_id" + fmt_id +
/// "intent" + intent +
/// "track_id" + track_id + ts)
/// stringvalue += self.s4
/// rq_sig = str(hashlib.md5(stringvalue).hexdigest())
/// ```
///
/// # Arguments
///
/// * `format_id` - ID du format audio (ex: "27")
/// * `intent` - Intention (typiquement "stream")
/// * `track_id` - ID de la track
/// * `timestamp` - Timestamp Unix
/// * `secret` - Secret s4 en bytes
///
/// # Returns
///
/// Signature MD5 hexadécimale
pub fn sign_track_get_file_url(
format_id: &str,
intent: &str,
track_id: &str,
timestamp: &str,
secret: &[u8],
) -> String {
let mut hasher = Md5::new();
// Construction de la chaîne à hasher
hasher.update(b"trackgetFileUrlformat_id");
hasher.update(format_id.as_bytes());
hasher.update(b"intent");
hasher.update(intent.as_bytes());
hasher.update(b"track_id");
hasher.update(track_id.as_bytes());
hasher.update(timestamp.as_bytes());
hasher.update(secret);
// Retourner le hash hexadécimal
format!("{:x}", hasher.finalize())
}
/// Signe une requête userLibrary/getAlbumsList
///
/// Reproduit la logique Python:
/// ```python
/// r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"])
/// r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest()
/// ```
///
/// # Arguments
///
/// * `timestamp` - Timestamp Unix
/// * `secret` - Secret s4 en bytes
///
/// # Returns
///
/// Signature MD5 hexadécimale
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
let mut hasher = Md5::new();
// Construction de la chaîne à hasher
hasher.update(b"userLibrarygetAlbumsList");
hasher.update(timestamp.as_bytes());
hasher.update(secret);
// Retourner le hash hexadécimal
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_timestamp() {
let ts = get_timestamp();
// Vérifier que c'est un nombre valide
assert!(ts.parse::<f64>().is_ok());
// Vérifier que c'est proche du temps actuel (>= 2024)
assert!(ts.parse::<f64>().unwrap() > 1704067200.0); // 1er janvier 2024
}
#[test]
fn test_sign_track_get_file_url() {
let signature = sign_track_get_file_url(
"27",
"stream",
"12345",
"1234567890.123",
b"test_secret",
);
// Vérifier que c'est un hash MD5 valide (32 caractères hex)
assert_eq!(signature.len(), 32);
assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_sign_userlib_get_albums() {
let signature = sign_userlib_get_albums("1234567890.123", b"test_secret");
// Vérifier que c'est un hash MD5 valide (32 caractères hex)
assert_eq!(signature.len(), 32);
assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_signature_consistency() {
// La même entrée doit produire la même signature
let sig1 = sign_track_get_file_url("27", "stream", "123", "100", b"secret");
let sig2 = sign_track_get_file_url("27", "stream", "123", "100", b"secret");
assert_eq!(sig1, sig2);
// Des entrées différentes doivent produire des signatures différentes
let sig3 = sign_track_get_file_url("6", "stream", "123", "100", b"secret");
assert_ne!(sig1, sig3);
}
}

196
pmoqobuz/src/api/spoofer.rs Normal file
View File

@@ -0,0 +1,196 @@
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD, Engine};
use indexmap::IndexMap;
use regex::Regex;
use reqwest::Client;
pub struct Spoofer {
bundle: String,
seed_timezone_regex: Regex,
info_extras_regex_template: String,
app_id_regex: Regex,
}
impl Spoofer {
/// Crée un nouveau Spoofer et télécharge le bundle.js
pub async fn new() -> Result<Self> {
// Expressions régulières (équivalent Python)
let seed_timezone_regex = Regex::new(
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
)?;
let info_extras_regex_template =
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
.to_string();
let app_id_regex = Regex::new(
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
)?;
// Créer un client HTTP
let client = Client::builder()
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
.build()?;
println!("Récupération de la page de login...");
let login_page = client
.get("https://play.qobuz.com/login")
.send()
.await?
.text()
.await?;
// Extraire l'URL du bundle
let bundle_url_regex =
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)?;
let bundle_url = bundle_url_regex
.captures(&login_page)
.and_then(|cap| cap.get(1))
.ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))?
.as_str();
println!("Téléchargement du bundle depuis: {}", bundle_url);
let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url);
let bundle = client.get(&bundle_full_url).send().await?.text().await?;
println!("Bundle téléchargé ({} bytes)", bundle.len());
Ok(Self {
bundle,
seed_timezone_regex,
info_extras_regex_template,
app_id_regex,
})
}
/// Extrait l'App ID depuis le bundle
pub fn get_app_id(&self) -> Result<String> {
let captures = self
.app_id_regex
.captures(&self.bundle)
.ok_or_else(|| anyhow::anyhow!("AppID non trouvé dans le bundle"))?;
Ok(captures
.name("app_id")
.ok_or_else(|| anyhow::anyhow!("Groupe app_id non trouvé"))?
.as_str()
.to_string())
}
/// Extrait les secrets depuis le bundle
pub fn get_secrets(&self) -> Result<IndexMap<String, String>> {
// Étape 1: Extraire tous les seed/timezone pairs
let mut secrets: IndexMap<String, Vec<String>> = IndexMap::new();
for captures in self.seed_timezone_regex.captures_iter(&self.bundle) {
let seed = captures
.name("seed")
.ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))?
.as_str();
let timezone = captures
.name("timezone")
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
.as_str();
secrets
.entry(timezone.to_string())
.or_insert_with(Vec::new)
.push(seed.to_string());
}
println!("Timezones trouvées: {:?}", secrets.keys());
// Étape 2: Réordonner - on met la deuxième timezone en premier
// (comme le fait le code Python avec move_to_end)
if secrets.len() >= 2 {
let keys: Vec<String> = secrets.keys().cloned().collect();
let second_key = keys[1].clone();
let second_value = secrets.get(&second_key).unwrap().clone();
// Retirer et réinsérer pour le mettre en premier
secrets.shift_remove(&second_key);
let mut new_secrets = IndexMap::new();
new_secrets.insert(second_key, second_value);
for (k, v) in secrets {
new_secrets.insert(k, v);
}
secrets = new_secrets;
}
// Étape 3: Construire la regex pour info/extras
let timezones_capitalized: Vec<String> = secrets
.keys()
.map(|tz| {
let mut chars = tz.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect();
let info_extras_regex_str = self
.info_extras_regex_template
.replace("{timezones}", &timezones_capitalized.join("|"));
let info_extras_regex = Regex::new(&info_extras_regex_str)?;
// Étape 4: Extraire info et extras pour chaque timezone
for captures in info_extras_regex.captures_iter(&self.bundle) {
let timezone_cap = captures
.name("timezone")
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
.as_str();
let info = captures
.name("info")
.ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))?
.as_str();
let extras = captures
.name("extras")
.ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))?
.as_str();
let timezone_lower = timezone_cap.to_lowercase();
if let Some(vec) = secrets.get_mut(&timezone_lower) {
vec.push(info.to_string());
vec.push(extras.to_string());
}
}
// Étape 5: Décoder les secrets en base64
let mut decoded_secrets = IndexMap::new();
for (timezone, parts) in secrets {
let concatenated = parts.join("");
// Retirer les 44 derniers caractères (comme Python [:-44])
if concatenated.len() > 44 {
let trimmed = &concatenated[..concatenated.len() - 44];
// Décoder en base64
match STANDARD.decode(trimmed) {
Ok(decoded_bytes) => {
match String::from_utf8(decoded_bytes) {
Ok(decoded_str) => {
decoded_secrets.insert(timezone, decoded_str);
}
Err(e) => {
eprintln!(
"Erreur UTF-8 pour timezone {}: {}",
timezone, e
);
}
}
}
Err(e) => {
eprintln!(
"Erreur de décodage base64 pour timezone {}: {}",
timezone, e
);
}
}
}
}
Ok(decoded_secrets)
}
}

View File

@@ -192,4 +192,76 @@ impl QobuzApi {
.await?;
Ok(())
}
/// Récupère la liste des albums de la bibliothèque utilisateur
///
/// Cette méthode nécessite un secret s4 pour signer la requête.
/// Elle est principalement utilisée pour tester la validité d'un secret.
///
/// Dans le code Python, cette méthode est utilisée par `setSec()` pour
/// tester chaque secret retourné par le Spoofer.
///
/// # Errors
///
/// Retourne `QobuzError::Configuration` si le secret n'est pas configuré.
/// Retourne `QobuzError::Unauthorized` si l'utilisateur n'est pas authentifié.
pub async fn userlib_get_albums(&self) -> Result<FavoritesResponse> {
use super::signing;
// Vérifier l'authentification
self.ensure_authenticated()?;
// Vérifier que le secret est disponible
let secret = self
.secret()
.ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign userLibrary/getAlbumsList request."
.to_string(),
)
})?;
let timestamp = signing::get_timestamp();
// Signer la requête (comme Python: userlib_getAlbums)
let signature = signing::sign_userlib_get_albums(&timestamp, secret);
debug!(
"Signing userLibrary/getAlbumsList: app_id={}, ts={}",
self.app_id(),
timestamp
);
// Construire les paramètres signés
let user_auth_token = self
.auth_token()
.ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?;
let params = [
("app_id", self.app_id()),
("user_auth_token", user_auth_token),
("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()),
];
// Utiliser POST (comme Python)
self.post("/userLibrary/getAlbumsList", &params).await
}
/// Teste si un secret est valide en essayant de récupérer les albums
///
/// Cette méthode est équivalente au test fait dans `setSec()` en Python.
/// Elle retourne `true` si le secret fonctionne, `false` sinon.
pub async fn test_secret(&self, secret: &[u8]) -> bool {
// Sauvegarder le secret actuel
let current_secret = self.secret().map(|s| s.to_vec());
// Définir temporairement le nouveau secret
// Note: cette méthode nécessite &mut self, donc on doit la rendre mutable
// Pour l'instant, on ne peut pas modifier self dans cette méthode
// TODO: Refactoriser pour permettre de tester les secrets
// Restaurer le secret original
false
}
}