Première tentative d'une crate pmoqobuz
This commit is contained in:
136
pmoqobuz/src/api/auth.rs
Normal file
136
pmoqobuz/src/api/auth.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Module d'authentification pour l'API Qobuz
|
||||
|
||||
use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Réponse de l'endpoint /user/login
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginResponse {
|
||||
user: UserInfo,
|
||||
user_auth_token: String,
|
||||
}
|
||||
|
||||
/// Informations utilisateur retournées par l'API
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserInfo {
|
||||
id: u64,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
firstname: Option<String>,
|
||||
#[serde(default)]
|
||||
lastname: Option<String>,
|
||||
credential: CredentialInfo,
|
||||
}
|
||||
|
||||
/// Informations sur les credentials de l'utilisateur
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CredentialInfo {
|
||||
#[serde(default)]
|
||||
parameters: Option<CredentialParameters>,
|
||||
}
|
||||
|
||||
/// Paramètres du niveau d'abonnement
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CredentialParameters {
|
||||
#[serde(default)]
|
||||
short_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Informations d'authentification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthInfo {
|
||||
/// Token d'authentification
|
||||
pub token: String,
|
||||
/// ID utilisateur
|
||||
pub user_id: String,
|
||||
/// Label de l'abonnement (ex: "Studio", "Hi-Fi", etc.)
|
||||
pub subscription_label: Option<String>,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
/// Authentifie l'utilisateur avec username et password
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `username` - Email ou nom d'utilisateur Qobuz
|
||||
/// * `password` - Mot de passe
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Retourne les informations d'authentification si le login est réussi
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// * `QobuzError::Unauthorized` - Credentials invalides
|
||||
/// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible)
|
||||
pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthInfo> {
|
||||
info!("Attempting to login to Qobuz as {}", username);
|
||||
|
||||
let params = [
|
||||
("username", username),
|
||||
("password", password),
|
||||
];
|
||||
|
||||
let response: LoginResponse = self.post("/user/login", ¶ms).await?;
|
||||
|
||||
// Vérifier que l'utilisateur a un abonnement valide
|
||||
if response.user.credential.parameters.is_none() {
|
||||
return Err(QobuzError::SubscriptionRequired(
|
||||
"Free accounts are not eligible for streaming".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = response.user.id.to_string();
|
||||
let subscription_label = response
|
||||
.user
|
||||
.credential
|
||||
.parameters
|
||||
.and_then(|p| p.short_label);
|
||||
|
||||
debug!(
|
||||
"Login successful - User ID: {}, Subscription: {:?}",
|
||||
user_id, subscription_label
|
||||
);
|
||||
|
||||
// Stocker les informations d'authentification
|
||||
self.set_auth_token(response.user_auth_token.clone(), user_id.clone());
|
||||
|
||||
Ok(AuthInfo {
|
||||
token: response.user_auth_token,
|
||||
user_id,
|
||||
subscription_label,
|
||||
})
|
||||
}
|
||||
|
||||
/// Vérifie si le client est authentifié
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.user_auth_token.is_some() && self.user_id.is_some()
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur
|
||||
pub fn logout(&mut self) {
|
||||
debug!("Logging out");
|
||||
self.user_auth_token = None;
|
||||
self.user_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_authenticated() {
|
||||
let mut api = QobuzApi::new("test_app_id").unwrap();
|
||||
assert!(!api.is_authenticated());
|
||||
|
||||
api.set_auth_token("token".to_string(), "user123".to_string());
|
||||
assert!(api.is_authenticated());
|
||||
|
||||
api.logout();
|
||||
assert!(!api.is_authenticated());
|
||||
}
|
||||
}
|
||||
459
pmoqobuz/src/api/catalog.rs
Normal file
459
pmoqobuz/src/api/catalog.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists)
|
||||
|
||||
use super::QobuzApi;
|
||||
use crate::error::Result;
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée de l'API
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PaginatedResponse<T> {
|
||||
items: Vec<T>,
|
||||
#[serde(default)]
|
||||
total: Option<u32>,
|
||||
#[serde(default)]
|
||||
limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /album/get
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AlbumResponse {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: ArtistResponse,
|
||||
#[serde(default)]
|
||||
tracks_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
duration: Option<u32>,
|
||||
#[serde(default)]
|
||||
release_date_original: Option<String>,
|
||||
#[serde(default)]
|
||||
image: Option<ImageResponse>,
|
||||
#[serde(default = "default_streamable")]
|
||||
streamable: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
maximum_sampling_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
maximum_bit_depth: Option<u32>,
|
||||
#[serde(default)]
|
||||
genre: Option<GenreResponse>,
|
||||
#[serde(default)]
|
||||
label: Option<LabelResponse>,
|
||||
#[serde(default)]
|
||||
tracks: Option<PaginatedResponse<TrackResponse>>,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /track/get
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct TrackResponse {
|
||||
id: String,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
performer: Option<ArtistResponse>,
|
||||
#[serde(default)]
|
||||
artist: Option<ArtistResponse>,
|
||||
#[serde(default)]
|
||||
album: Option<AlbumResponse>,
|
||||
duration: u32,
|
||||
track_number: u32,
|
||||
media_number: u32,
|
||||
#[serde(default = "default_streamable")]
|
||||
streamable: bool,
|
||||
}
|
||||
|
||||
/// Réponse artiste
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ArtistResponse {
|
||||
id: u64,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
image: Option<ImageResponse>,
|
||||
#[serde(default)]
|
||||
albums: Option<PaginatedResponse<AlbumResponse>>,
|
||||
}
|
||||
|
||||
/// Réponse image
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImageResponse {
|
||||
#[serde(default)]
|
||||
large: Option<String>,
|
||||
}
|
||||
|
||||
/// Réponse genre
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenreResponse {
|
||||
#[serde(default)]
|
||||
id: Option<u32>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Réponse label
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LabelResponse {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Réponse playlist
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct PlaylistResponse {
|
||||
id: u64,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
tracks_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
duration: Option<u32>,
|
||||
#[serde(default)]
|
||||
images300: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
is_public: bool,
|
||||
#[serde(default)]
|
||||
owner: Option<OwnerResponse>,
|
||||
#[serde(default)]
|
||||
tracks: Option<PaginatedResponse<TrackResponse>>,
|
||||
}
|
||||
|
||||
/// Réponse propriétaire
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwnerResponse {
|
||||
id: u64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Réponse genres list
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenresResponse {
|
||||
genres: PaginatedResponse<GenreResponse>,
|
||||
}
|
||||
|
||||
/// Réponse albums featured
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeaturedAlbumsResponse {
|
||||
albums: PaginatedResponse<AlbumResponse>,
|
||||
}
|
||||
|
||||
/// Réponse playlists featured
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeaturedPlaylistsResponse {
|
||||
playlists: PaginatedResponse<PlaylistResponse>,
|
||||
}
|
||||
|
||||
/// Réponse search
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(default)]
|
||||
albums: Option<PaginatedResponse<AlbumResponse>>,
|
||||
#[serde(default)]
|
||||
artists: Option<PaginatedResponse<ArtistResponse>>,
|
||||
#[serde(default)]
|
||||
tracks: Option<PaginatedResponse<TrackResponse>>,
|
||||
#[serde(default)]
|
||||
playlists: Option<PaginatedResponse<PlaylistResponse>>,
|
||||
}
|
||||
|
||||
/// Réponse track file URL
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FileUrlResponse {
|
||||
url: String,
|
||||
mime_type: String,
|
||||
sampling_rate: u32,
|
||||
bit_depth: u32,
|
||||
format_id: u8,
|
||||
}
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
/// Récupère les détails d'un album
|
||||
pub async fn get_album(&self, album_id: &str) -> Result<Album> {
|
||||
debug!("Fetching album {}", album_id);
|
||||
let params = [("album_id", album_id)];
|
||||
let response: AlbumResponse = self.get("/album/get", ¶ms).await?;
|
||||
Ok(Self::parse_album(response))
|
||||
}
|
||||
|
||||
/// Récupère les tracks d'un album
|
||||
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
|
||||
debug!("Fetching tracks for album {}", album_id);
|
||||
let params = [("album_id", album_id)];
|
||||
let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks.take() {
|
||||
let album = Self::parse_album(response);
|
||||
Ok(tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| Self::parse_track(t, Some(album.clone())))
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails d'une track
|
||||
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
||||
debug!("Fetching track {}", track_id);
|
||||
let params = [("track_id", track_id)];
|
||||
let response: TrackResponse = self.get("/track/get", ¶ms).await?;
|
||||
Ok(Self::parse_track(response, None))
|
||||
}
|
||||
|
||||
/// Récupère l'URL de streaming d'une track
|
||||
pub async fn get_file_url(&self, track_id: &str) -> Result<StreamInfo> {
|
||||
debug!("Fetching file URL for track {}", track_id);
|
||||
let format_id = self.format_id.id().to_string();
|
||||
let params = [
|
||||
("track_id", track_id),
|
||||
("format_id", &format_id),
|
||||
("intent", "stream"),
|
||||
];
|
||||
let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?;
|
||||
|
||||
Ok(StreamInfo {
|
||||
url: response.url,
|
||||
mime_type: response.mime_type,
|
||||
sampling_rate: response.sampling_rate,
|
||||
bit_depth: response.bit_depth,
|
||||
format_id: response.format_id,
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
|
||||
})
|
||||
}
|
||||
|
||||
/// Récupère les albums d'un artiste
|
||||
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
|
||||
debug!("Fetching albums for artist {}", artist_id);
|
||||
let params = [("artist_id", artist_id), ("extra", "albums")];
|
||||
let response: ArtistResponse = self.get("/artist/get", ¶ms).await?;
|
||||
|
||||
if let Some(albums) = response.albums {
|
||||
Ok(albums
|
||||
.items
|
||||
.into_iter()
|
||||
.map(Self::parse_album)
|
||||
.filter(|a| a.streamable)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les artistes similaires
|
||||
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> {
|
||||
debug!("Fetching similar artists for {}", artist_id);
|
||||
let params = [("artist_id", artist_id)];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SimilarArtistsResponse {
|
||||
artists: PaginatedResponse<ArtistResponse>,
|
||||
}
|
||||
|
||||
let response: SimilarArtistsResponse =
|
||||
self.get("/artist/getSimilarArtists", ¶ms).await?;
|
||||
Ok(response.artists.items.into_iter().map(Self::parse_artist).collect())
|
||||
}
|
||||
|
||||
/// Récupère les détails d'une playlist
|
||||
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
|
||||
debug!("Fetching playlist {}", playlist_id);
|
||||
let params = [("playlist_id", playlist_id)];
|
||||
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||
Ok(Self::parse_playlist(response))
|
||||
}
|
||||
|
||||
/// Récupère les tracks d'une playlist
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||
let params = [("playlist_id", playlist_id), ("extra", "tracks")];
|
||||
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
Ok(tracks.items.into_iter().map(|t| Self::parse_track(t, None)).collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la liste des genres
|
||||
pub async fn get_genres(&self) -> Result<Vec<Genre>> {
|
||||
debug!("Fetching genres");
|
||||
let response: GenresResponse = self.get("/genre/list", &[]).await?;
|
||||
Ok(response.genres.items.into_iter().map(Self::parse_genre).collect())
|
||||
}
|
||||
|
||||
/// Récupère les albums featured (nouveautés, éditeur, etc.)
|
||||
pub async fn get_featured_albums(
|
||||
&self,
|
||||
genre_id: Option<&str>,
|
||||
type_: &str,
|
||||
) -> Result<Vec<Album>> {
|
||||
debug!("Fetching featured albums (type: {})", type_);
|
||||
let mut params = vec![("type", type_), ("limit", "100")];
|
||||
|
||||
if let Some(gid) = genre_id {
|
||||
params.push(("genre_ids", gid));
|
||||
}
|
||||
|
||||
let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", ¶ms).await?;
|
||||
Ok(response
|
||||
.albums
|
||||
.items
|
||||
.into_iter()
|
||||
.map(Self::parse_album)
|
||||
.filter(|a| a.streamable)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère les playlists featured
|
||||
pub async fn get_featured_playlists(
|
||||
&self,
|
||||
genre_id: Option<&str>,
|
||||
tags: Option<&str>,
|
||||
) -> Result<Vec<Playlist>> {
|
||||
debug!("Fetching featured playlists");
|
||||
let mut params = vec![("type", "editor-picks"), ("limit", "100")];
|
||||
|
||||
if let Some(gid) = genre_id {
|
||||
params.push(("genre_ids", gid));
|
||||
}
|
||||
if let Some(t) = tags {
|
||||
params.push(("tags", t));
|
||||
}
|
||||
|
||||
let response: FeaturedPlaylistsResponse =
|
||||
self.get("/playlist/getFeatured", ¶ms).await?;
|
||||
Ok(response.playlists.items.into_iter().map(Self::parse_playlist).collect())
|
||||
}
|
||||
|
||||
/// Recherche dans le catalogue
|
||||
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
|
||||
debug!("Searching for '{}' (type: {:?})", query, type_);
|
||||
let mut params = vec![("query", query), ("limit", "200")];
|
||||
|
||||
if let Some(t) = type_ {
|
||||
params.push(("type", t));
|
||||
}
|
||||
|
||||
let response: SearchResponse = self.get("/catalog/search", ¶ms).await?;
|
||||
|
||||
Ok(SearchResult {
|
||||
albums: response
|
||||
.albums
|
||||
.map(|a| {
|
||||
a.items
|
||||
.into_iter()
|
||||
.map(Self::parse_album)
|
||||
.filter(|album| album.streamable)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
artists: response
|
||||
.artists
|
||||
.map(|a| a.items.into_iter().map(Self::parse_artist).collect())
|
||||
.unwrap_or_default(),
|
||||
tracks: response
|
||||
.tracks
|
||||
.map(|t| {
|
||||
t.items
|
||||
.into_iter()
|
||||
.map(|track| Self::parse_track(track, None))
|
||||
.filter(|track| track.streamable)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
playlists: response
|
||||
.playlists
|
||||
.map(|p| p.items.into_iter().map(Self::parse_playlist).collect())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
// Fonctions de parsing publiques (utilisées aussi par le module user)
|
||||
|
||||
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
|
||||
Album {
|
||||
id: response.id,
|
||||
title: response.title,
|
||||
artist: Self::parse_artist(response.artist),
|
||||
tracks_count: response.tracks_count,
|
||||
duration: response.duration,
|
||||
release_date: response.release_date_original,
|
||||
image: response.image.and_then(|i| i.large),
|
||||
image_cached: None,
|
||||
streamable: response.streamable,
|
||||
description: response.description,
|
||||
maximum_sampling_rate: response.maximum_sampling_rate,
|
||||
maximum_bit_depth: response.maximum_bit_depth,
|
||||
genres: response
|
||||
.genre
|
||||
.map(|g| vec![g.name])
|
||||
.unwrap_or_default(),
|
||||
label: response.label.map(|l| l.name),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_track(response: TrackResponse, album: Option<Album>) -> Track {
|
||||
let performer = response
|
||||
.performer
|
||||
.or(response.artist)
|
||||
.map(Self::parse_artist);
|
||||
|
||||
let album = album.or_else(|| response.album.map(Self::parse_album));
|
||||
|
||||
Track {
|
||||
id: response.id,
|
||||
title: response.title,
|
||||
performer,
|
||||
album,
|
||||
duration: response.duration,
|
||||
track_number: response.track_number,
|
||||
media_number: response.media_number,
|
||||
streamable: response.streamable,
|
||||
mime_type: None,
|
||||
sample_rate: None,
|
||||
bit_depth: None,
|
||||
channels: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_artist(response: ArtistResponse) -> Artist {
|
||||
Artist {
|
||||
id: response.id.to_string(),
|
||||
name: response.name,
|
||||
image: response.image.and_then(|i| i.large),
|
||||
image_cached: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist {
|
||||
Playlist {
|
||||
id: response.id.to_string(),
|
||||
name: response.name,
|
||||
description: response.description,
|
||||
tracks_count: response.tracks_count,
|
||||
duration: response.duration,
|
||||
image: response.images300.and_then(|imgs| imgs.first().cloned()),
|
||||
image_cached: None,
|
||||
is_public: response.is_public,
|
||||
owner: response.owner.map(|o| PlaylistOwner {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_genre(response: GenreResponse) -> Genre {
|
||||
Genre {
|
||||
id: response.id,
|
||||
name: response.name,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
201
pmoqobuz/src/api/mod.rs
Normal file
201
pmoqobuz/src/api/mod.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! Couche d'accès à l'API REST Qobuz
|
||||
//!
|
||||
//! Ce module fournit une interface bas-niveau pour communiquer avec l'API Qobuz.
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod user;
|
||||
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::AudioFormat;
|
||||
use reqwest::{Client, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// URL de base de l'API Qobuz
|
||||
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
||||
|
||||
/// Client API bas-niveau pour communiquer avec Qobuz
|
||||
pub struct QobuzApi {
|
||||
/// Client HTTP
|
||||
client: Client,
|
||||
/// App ID pour l'authentification
|
||||
app_id: String,
|
||||
/// Token d'authentification utilisateur
|
||||
user_auth_token: Option<String>,
|
||||
/// ID utilisateur
|
||||
user_id: Option<String>,
|
||||
/// Format audio par défaut
|
||||
format_id: AudioFormat,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
/// Crée une nouvelle instance de l'API
|
||||
pub fn new(app_id: impl Into<String>) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0")
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
app_id: app_id.into(),
|
||||
user_auth_token: None,
|
||||
user_id: None,
|
||||
format_id: AudioFormat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Définit le token d'authentification
|
||||
pub fn set_auth_token(&mut self, token: String, user_id: String) {
|
||||
self.user_auth_token = Some(token);
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
|
||||
/// Définit le format audio par défaut
|
||||
pub fn set_format(&mut self, format: AudioFormat) {
|
||||
self.format_id = format;
|
||||
}
|
||||
|
||||
/// Retourne le format audio configuré
|
||||
pub fn format(&self) -> AudioFormat {
|
||||
self.format_id
|
||||
}
|
||||
|
||||
/// Retourne l'App ID
|
||||
pub fn app_id(&self) -> &str {
|
||||
&self.app_id
|
||||
}
|
||||
|
||||
/// Retourne le token d'authentification si disponible
|
||||
pub fn auth_token(&self) -> Option<&str> {
|
||||
self.user_auth_token.as_deref()
|
||||
}
|
||||
|
||||
/// Retourne l'ID utilisateur si disponible
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.user_id.as_deref()
|
||||
}
|
||||
|
||||
/// Effectue une requête GET à l'API
|
||||
pub(crate) async fn get<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
params: &[(&str, &str)],
|
||||
) -> Result<T> {
|
||||
self.request("GET", endpoint, params).await
|
||||
}
|
||||
|
||||
/// Effectue une requête POST à l'API
|
||||
pub(crate) async fn post<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
params: &[(&str, &str)],
|
||||
) -> Result<T> {
|
||||
self.request("POST", endpoint, params).await
|
||||
}
|
||||
|
||||
/// Effectue une requête à l'API (générique)
|
||||
async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
endpoint: &str,
|
||||
params: &[(&str, &str)],
|
||||
) -> Result<T> {
|
||||
let url = format!("{}{}", API_BASE_URL, endpoint);
|
||||
|
||||
debug!("{} {} with {} params", method, url, params.len());
|
||||
|
||||
let mut request = if method == "GET" {
|
||||
self.client.get(&url)
|
||||
} else {
|
||||
self.client.post(&url)
|
||||
};
|
||||
|
||||
// Ajouter les headers
|
||||
request = request.header("X-App-Id", &self.app_id);
|
||||
|
||||
if let Some(ref token) = self.user_auth_token {
|
||||
request = request.header("X-User-Auth-Token", token);
|
||||
}
|
||||
|
||||
// Ajouter les paramètres
|
||||
if method == "GET" {
|
||||
request = request.query(params);
|
||||
} else {
|
||||
request = request.form(params);
|
||||
}
|
||||
|
||||
// Envoyer la requête
|
||||
let response = request.send().await?;
|
||||
self.handle_response(response).await
|
||||
}
|
||||
|
||||
/// Traite la réponse HTTP
|
||||
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
|
||||
debug!("Response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
warn!("API error ({}): {}", status_code, error_text);
|
||||
return Err(QobuzError::from_status_code(status_code, error_text));
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
|
||||
// Vérifier si la réponse contient une erreur Qobuz
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
||||
if let Some(status_obj) = json.get("status") {
|
||||
if status_obj == "error" {
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
warn!("Qobuz API error: {}", message);
|
||||
return Err(QobuzError::ApiError {
|
||||
code: status_code,
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parser la réponse
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
warn!("Failed to parse response: {}", e);
|
||||
QobuzError::JsonParse(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_api_creation() {
|
||||
let api = QobuzApi::new("test_app_id").unwrap();
|
||||
assert_eq!(api.app_id(), "test_app_id");
|
||||
assert!(api.auth_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_auth_token() {
|
||||
let mut api = QobuzApi::new("test_app_id").unwrap();
|
||||
api.set_auth_token("test_token".to_string(), "user123".to_string());
|
||||
assert_eq!(api.auth_token(), Some("test_token"));
|
||||
assert_eq!(api.user_id(), Some("user123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_format() {
|
||||
let mut api = QobuzApi::new("test_app_id").unwrap();
|
||||
api.set_format(AudioFormat::Flac_HiRes_96);
|
||||
assert_eq!(api.format(), AudioFormat::Flac_HiRes_96);
|
||||
}
|
||||
}
|
||||
124
pmoqobuz/src/api/user.rs
Normal file
124
pmoqobuz/src/api/user.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Module d'accès aux données utilisateur (favoris)
|
||||
|
||||
use super::catalog::{AlbumResponse, ArtistResponse, PlaylistResponse, TrackResponse};
|
||||
use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PaginatedResponse<T> {
|
||||
items: Vec<T>,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /favorite/getUserFavorites
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FavoritesResponse {
|
||||
#[serde(default)]
|
||||
albums: Option<PaginatedResponse<AlbumResponse>>,
|
||||
#[serde(default)]
|
||||
artists: Option<PaginatedResponse<ArtistResponse>>,
|
||||
#[serde(default)]
|
||||
tracks: Option<PaginatedResponse<TrackResponse>>,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /playlist/getUserPlaylists
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserPlaylistsResponse {
|
||||
playlists: PaginatedResponse<PlaylistResponse>,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
/// Vérifie que l'utilisateur est authentifié
|
||||
fn ensure_authenticated(&self) -> Result<&str> {
|
||||
self.user_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))
|
||||
}
|
||||
|
||||
/// Récupère les albums favoris de l'utilisateur
|
||||
pub async fn get_favorite_albums(&self) -> Result<Vec<Album>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite albums for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(albums) = response.albums {
|
||||
Ok(albums
|
||||
.items
|
||||
.into_iter()
|
||||
.map(QobuzApi::parse_album)
|
||||
.filter(|a| a.streamable)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les artistes favoris de l'utilisateur
|
||||
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite artists for user {}", user_id);
|
||||
|
||||
let params = [
|
||||
("user_id", user_id),
|
||||
("type", "artists"),
|
||||
("limit", "1000"),
|
||||
];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(artists) = response.artists {
|
||||
Ok(artists
|
||||
.items
|
||||
.into_iter()
|
||||
.map(QobuzApi::parse_artist)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les tracks favorites de l'utilisateur
|
||||
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite tracks for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
Ok(tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| QobuzApi::parse_track(t, None))
|
||||
.filter(|t| t.streamable)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les playlists de l'utilisateur
|
||||
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching playlists for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("limit", "1000")];
|
||||
|
||||
let response: UserPlaylistsResponse =
|
||||
self.get("/playlist/getUserPlaylists", ¶ms).await?;
|
||||
|
||||
Ok(response
|
||||
.playlists
|
||||
.items
|
||||
.into_iter()
|
||||
.map(QobuzApi::parse_playlist)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user