on avance un peu pmoqobuz
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
×tamp,
|
||||
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", ¶ms).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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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
151
pmoqobuz/src/api/signing.rs
Normal 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
196
pmoqobuz/src/api/spoofer.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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(×tamp, 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", ¶ms).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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user