Première tentative d'une crate pmoqobuz

This commit is contained in:
2025-10-11 15:59:43 +02:00
parent fb94886c53
commit 11187db71b
20 changed files with 3821 additions and 6 deletions

136
pmoqobuz/src/api/auth.rs Normal file
View 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", &params).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
View 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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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", &params).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
View 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
View 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", &params).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", &params).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", &params).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", &params).await?;
Ok(response
.playlists
.items
.into_iter()
.map(QobuzApi::parse_playlist)
.collect())
}
}