522 lines
16 KiB
Rust
522 lines
16 KiB
Rust
//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists)
|
|
|
|
use super::QobuzApi;
|
|
use crate::error::{QobuzError, 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 {
|
|
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
|
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 {
|
|
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
|
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 {
|
|
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
|
id: String,
|
|
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 {
|
|
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
|
id: String,
|
|
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 {
|
|
#[serde(deserialize_with = "crate::models::deserialize_id")]
|
|
id: String,
|
|
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: f64,
|
|
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
|
|
///
|
|
/// 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();
|
|
|
|
// Vérifier que le token d'authentification est disponible
|
|
self.auth_token()
|
|
.ok_or_else(|| QobuzError::Unauthorized("Missing auth token".to_string()))?;
|
|
|
|
// 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
|
|
// Note: app_id et user_auth_token sont envoyés automatiquement comme headers
|
|
// par la méthode request() (X-App-Id et X-User-Auth-Token)
|
|
// IMPORTANT: L'ordre doit correspondre EXACTEMENT à Python (raw.py)
|
|
let params = [
|
|
("format_id", format_id.as_str()),
|
|
("intent", intent),
|
|
("request_ts", timestamp.as_str()),
|
|
("request_sig", signature.as_str()),
|
|
("track_id", track_id),
|
|
];
|
|
|
|
// Utiliser GET (comme qobuz-player-client qui fonctionne)
|
|
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,
|
|
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,
|
|
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.parse().unwrap_or(0),
|
|
name: o.name,
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_genre(response: GenreResponse) -> Genre {
|
|
Genre {
|
|
id: response.id,
|
|
name: response.name,
|
|
children: Vec::new(),
|
|
}
|
|
}
|
|
}
|