feat: implement batch track loading via track/getList endpoint
Replaces sequential track requests with a chunked batch enrichment workflow using MD5-signed POST requests (50-ID windows). Introduces cache-first fetching, order preservation via HashMap lookups, and graceful fallbacks for playlist and favorite loading.
This commit is contained in:
@@ -43,10 +43,21 @@ manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Chargement batch de tracks — `track/getList` — **À FAIRE** (priorité haute)
|
## 3. Chargement batch de tracks — `track/getList` — **FAIT**
|
||||||
|
|
||||||
**Problème** : lors du chargement d'une playlist, `pmoqobuz` fait N requêtes `track/get` individuelles
|
**Problème** : les tracks retournées par `/playlist/get?extra=tracks` et
|
||||||
(une par track). Sur une playlist de 200 tracks, c'est 200 requêtes séquentielles.
|
`/favorite/getUserFavorites` ont des métadonnées incomplètes (parfois sans `performer`,
|
||||||
|
jamais de `sample_rate`/`bit_depth`/`channels`). Cela déclenchait des appels individuels
|
||||||
|
`get_track` lazy à la lecture.
|
||||||
|
|
||||||
|
**Implémentation réalisée** :
|
||||||
|
- `signing::sign_track_get_list(ids_csv, timestamp, secret)` — signature MD5 pour `track/getList`
|
||||||
|
- `QobuzApi::post_json_with_query` — POST avec auth headers + query sig + JSON body
|
||||||
|
- `QobuzApi::get_tracks_batch(&[&str])` — fenêtres de 50 IDs, appels en série
|
||||||
|
- `QobuzClient::get_tracks_batch` — wrapper avec cache (skip les IDs déjà en cache)
|
||||||
|
- `get_playlist_tracks` : phase 1 pagination existante, phase 2 enrichissement si secret disponible
|
||||||
|
- `get_favorite_tracks` : même enrichissement en phase 2
|
||||||
|
- Fallback gracieux si le secret est absent ou si `track/getList` échoue
|
||||||
|
|
||||||
**Ce que fait qbz** (`get_tracks_batch`, l.1323) :
|
**Ce que fait qbz** (`get_tracks_batch`, l.1323) :
|
||||||
```
|
```
|
||||||
@@ -57,9 +68,6 @@ POST /track/getList
|
|||||||
- Fenêtre de 50 IDs max par appel (limite API Qobuz)
|
- Fenêtre de 50 IDs max par appel (limite API Qobuz)
|
||||||
- Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas)
|
- Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas)
|
||||||
|
|
||||||
**Impact pour pmoqobuz** : réduire le temps de chargement d'une playlist de plusieurs minutes à
|
|
||||||
quelques secondes. À brancher dans `catalog.rs` et dans le chargement des favoris.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Pagination concurrente des playlists — **À FAIRE** (priorité moyenne)
|
## 4. Pagination concurrente des playlists — **À FAIRE** (priorité moyenne)
|
||||||
@@ -113,7 +121,7 @@ sortis récemment. Utile pour le catalogue de la webapp.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** |
|
| 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** |
|
||||||
| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** |
|
| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** |
|
||||||
| 3 | Batch `track/getList` | Faible | Élevé (performances) | À faire |
|
| 3 | Batch `track/getList` | Faible | Élevé (performances) | **Fait** |
|
||||||
| 4 | Pagination concurrente playlists | Faible | Moyen | À faire |
|
| 4 | Pagination concurrente playlists | Faible | Moyen | À faire |
|
||||||
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |
|
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |
|
||||||
| 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire |
|
| 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire |
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.50"
|
version = "0.3.51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.50"
|
version = "0.3.51"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use super::QobuzApi;
|
|||||||
use crate::error::{QobuzError, Result};
|
use crate::error::{QobuzError, Result};
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// Réponse paginée de l'API
|
/// Réponse paginée de l'API
|
||||||
@@ -182,6 +183,17 @@ struct FileUrlResponse {
|
|||||||
format_id: u8,
|
format_id: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Réponse de l'endpoint track/getList
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TrackListResponse {
|
||||||
|
tracks: TrackListItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TrackListItems {
|
||||||
|
items: Vec<TrackResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_streamable() -> bool {
|
fn default_streamable() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -213,6 +225,66 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère les détails de plusieurs tracks en une ou plusieurs requêtes batch.
|
||||||
|
///
|
||||||
|
/// Utilise l'endpoint `track/getList` (max 50 IDs par appel). Les fenêtres
|
||||||
|
/// supérieures à 50 sont découpées et appellées en série. L'ordre de sortie
|
||||||
|
/// correspond à l'ordre des `track_ids` en entrée.
|
||||||
|
///
|
||||||
|
/// Requiert que le secret s4 soit configuré.
|
||||||
|
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||||
|
const MAX_PER_CALL: usize = 50;
|
||||||
|
if track_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if track_ids.len() <= MAX_PER_CALL {
|
||||||
|
return self.get_tracks_batch_chunk(track_ids).await;
|
||||||
|
}
|
||||||
|
debug!("get_tracks_batch: {} IDs en fenêtres de {}", track_ids.len(), MAX_PER_CALL);
|
||||||
|
let mut all = Vec::with_capacity(track_ids.len());
|
||||||
|
for chunk in track_ids.chunks(MAX_PER_CALL) {
|
||||||
|
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
|
||||||
|
all.append(&mut tracks);
|
||||||
|
}
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tracks_batch_chunk(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||||
|
use super::signing;
|
||||||
|
|
||||||
|
let secret = self.secret().ok_or_else(|| {
|
||||||
|
QobuzError::Configuration("Secret s4 requis pour track/getList".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let ids_csv = track_ids.join(",");
|
||||||
|
let timestamp = signing::get_timestamp();
|
||||||
|
let signature = signing::sign_track_get_list(&ids_csv, ×tamp, &secret);
|
||||||
|
|
||||||
|
let query_params = [
|
||||||
|
("request_ts", timestamp.as_str()),
|
||||||
|
("request_sig", signature.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Les IDs sont envoyés comme tableau d'entiers dans le body JSON
|
||||||
|
let ids_as_numbers: Vec<u64> = track_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| id.parse().ok())
|
||||||
|
.collect();
|
||||||
|
let body = serde_json::json!({ "tracks_id": ids_as_numbers });
|
||||||
|
|
||||||
|
debug!("get_tracks_batch_chunk POST {} IDs", track_ids.len());
|
||||||
|
let response: TrackListResponse = self
|
||||||
|
.post_json_with_query("/track/getList", &query_params, body)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(response
|
||||||
|
.tracks
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| Self::parse_track(t, None))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère les détails d'une track
|
/// Récupère les détails d'une track
|
||||||
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
||||||
debug!("Fetching track {}", track_id);
|
debug!("Fetching track {}", track_id);
|
||||||
@@ -339,13 +411,20 @@ impl QobuzApi {
|
|||||||
Ok(Self::parse_playlist(response))
|
Ok(Self::parse_playlist(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les tracks d'une playlist
|
/// Récupère les tracks d'une playlist.
|
||||||
|
///
|
||||||
|
/// Phase 1 : pagination de `/playlist/get?extra=tracks` pour collecter les IDs
|
||||||
|
/// et les données de base.
|
||||||
|
/// Phase 2 (si secret disponible) : enrichissement via `track/getList` pour
|
||||||
|
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth, channels).
|
||||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||||
const PAGE_SIZE: u32 = 50;
|
const PAGE_SIZE: u32 = 50;
|
||||||
let mut all_tracks = Vec::new();
|
let mut ordered_ids: Vec<String> = Vec::new();
|
||||||
|
let mut fallback_tracks: Vec<Track> = Vec::new();
|
||||||
let mut offset = 0u32;
|
let mut offset = 0u32;
|
||||||
|
|
||||||
|
// Phase 1 : pagination pour collecter les IDs et les tracks de base
|
||||||
loop {
|
loop {
|
||||||
let offset_str = offset.to_string();
|
let offset_str = offset.to_string();
|
||||||
let limit_str = PAGE_SIZE.to_string();
|
let limit_str = PAGE_SIZE.to_string();
|
||||||
@@ -360,7 +439,10 @@ impl QobuzApi {
|
|||||||
if let Some(tracks) = response.tracks {
|
if let Some(tracks) = response.tracks {
|
||||||
let total = tracks.total.unwrap_or(0);
|
let total = tracks.total.unwrap_or(0);
|
||||||
let count = tracks.items.len() as u32;
|
let count = tracks.items.len() as u32;
|
||||||
all_tracks.extend(tracks.items.into_iter().map(|t| Self::parse_track(t, None)));
|
for t in tracks.items {
|
||||||
|
ordered_ids.push(t.id.clone());
|
||||||
|
fallback_tracks.push(Self::parse_track(t, None));
|
||||||
|
}
|
||||||
offset += count;
|
offset += count;
|
||||||
if count == 0 || offset >= total {
|
if count == 0 || offset >= total {
|
||||||
break;
|
break;
|
||||||
@@ -370,8 +452,23 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Fetched {} tracks total for playlist {}", all_tracks.len(), playlist_id);
|
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
|
||||||
Ok(all_tracks)
|
|
||||||
|
if ordered_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2 : enrichissement via track/getList pour métadonnées complètes
|
||||||
|
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||||
|
let mut track_map: HashMap<String, Track> =
|
||||||
|
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||||
|
let enriched: Vec<Track> = ordered_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| track_map.remove(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
debug!("Fetched {} tracks for playlist {} via track/getList", enriched.len(), playlist_id);
|
||||||
|
Ok(enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère la liste des genres
|
/// Récupère la liste des genres
|
||||||
|
|||||||
@@ -274,6 +274,37 @@ impl QobuzApi {
|
|||||||
self.request("POST", endpoint, params).await
|
self.request("POST", endpoint, params).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effectue un POST avec signature en query params et données en JSON body.
|
||||||
|
///
|
||||||
|
/// Utilisé par les endpoints qui attendent une structure JSON complexe
|
||||||
|
/// (ex: `track/getList` avec `{"tracks_id": [...]}`).
|
||||||
|
pub(crate) async fn post_json_with_query<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
endpoint: &str,
|
||||||
|
query_params: &[(&str, &str)],
|
||||||
|
body: serde_json::Value,
|
||||||
|
) -> Result<T> {
|
||||||
|
let url = format!("{}{}", API_BASE_URL, endpoint);
|
||||||
|
debug!("POST JSON {} with {} query params", url, query_params.len());
|
||||||
|
|
||||||
|
let app_id = self.app_id.read().unwrap().clone();
|
||||||
|
let mut builder = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-App-Id", &app_id)
|
||||||
|
.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2")
|
||||||
|
.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id")
|
||||||
|
.query(query_params)
|
||||||
|
.json(&body);
|
||||||
|
|
||||||
|
if let Some(token) = self.auth_token() {
|
||||||
|
builder = builder.header("X-User-Auth-Token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = builder.send().await?;
|
||||||
|
self.handle_response(response, endpoint, query_params).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Effectue une requête à l'API (générique)
|
/// Effectue une requête à l'API (générique)
|
||||||
async fn request<T: DeserializeOwned>(
|
async fn request<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ pub fn sign_track_get_file_url(
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Signature MD5 hexadécimale
|
/// Signature MD5 hexadécimale
|
||||||
|
/// Signe une requête track/getList
|
||||||
|
///
|
||||||
|
/// Chaîne signée : `"trackgetList" + "tracks_id" + ids_csv + timestamp + secret`
|
||||||
|
/// où `ids_csv` est la liste des IDs séparés par des virgules.
|
||||||
|
pub fn sign_track_get_list(ids_csv: &str, timestamp: &str, secret: &[u8]) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(b"trackgetList");
|
||||||
|
hasher.update(b"tracks_id");
|
||||||
|
hasher.update(ids_csv.as_bytes());
|
||||||
|
hasher.update(timestamp.as_bytes());
|
||||||
|
hasher.update(secret);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
|
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use super::QobuzApi;
|
|||||||
use crate::error::{QobuzError, Result};
|
use crate::error::{QobuzError, Result};
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// Réponse paginée
|
/// Réponse paginée
|
||||||
@@ -86,7 +87,11 @@ impl QobuzApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les tracks favorites de l'utilisateur
|
/// Récupère les tracks favorites de l'utilisateur.
|
||||||
|
///
|
||||||
|
/// Si le secret s4 est disponible, les données de base retournées par
|
||||||
|
/// `/favorite/getUserFavorites` sont enrichies via `track/getList` pour
|
||||||
|
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth).
|
||||||
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
||||||
let user_id = self.ensure_authenticated()?;
|
let user_id = self.ensure_authenticated()?;
|
||||||
debug!("Fetching favorite tracks for user {}", user_id);
|
debug!("Fetching favorite tracks for user {}", user_id);
|
||||||
@@ -99,16 +104,32 @@ impl QobuzApi {
|
|||||||
|
|
||||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||||
|
|
||||||
if let Some(tracks) = response.tracks {
|
let base_tracks: Vec<Track> = match response.tracks {
|
||||||
Ok(tracks
|
Some(tracks) => tracks
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| QobuzApi::parse_track(t, None))
|
.map(|t| QobuzApi::parse_track(t, None))
|
||||||
.filter(|t| t.streamable)
|
.filter(|t| t.streamable)
|
||||||
.collect())
|
.collect(),
|
||||||
} else {
|
None => return Ok(Vec::new()),
|
||||||
Ok(Vec::new())
|
};
|
||||||
|
|
||||||
|
if base_tracks.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrichissement via track/getList pour métadonnées complètes
|
||||||
|
let ordered_ids: Vec<String> = base_tracks.iter().map(|t| t.id.clone()).collect();
|
||||||
|
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||||
|
let mut track_map: HashMap<String, Track> =
|
||||||
|
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||||
|
let enriched: Vec<Track> = ordered_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| track_map.remove(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
debug!("Fetched {} favorite tracks via track/getList", enriched.len());
|
||||||
|
Ok(enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère les playlists de l'utilisateur
|
/// Récupère les playlists de l'utilisateur
|
||||||
|
|||||||
@@ -640,6 +640,44 @@ impl QobuzClient {
|
|||||||
Ok(track)
|
Ok(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Récupère les détails d'un batch de tracks via track/getList.
|
||||||
|
///
|
||||||
|
/// Les tracks retournées sont mises en cache individuellement.
|
||||||
|
/// Voir `QobuzApi::get_tracks_batch` pour le détail du comportement.
|
||||||
|
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||||
|
// Séparer les IDs déjà en cache de ceux à récupérer
|
||||||
|
let mut cached: std::collections::HashMap<String, Track> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut missing_ids: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
for &id in track_ids {
|
||||||
|
if let Some(track) = self.cache.get_track(id).await {
|
||||||
|
cached.insert(id.to_string(), track);
|
||||||
|
} else {
|
||||||
|
missing_ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !missing_ids.is_empty() {
|
||||||
|
let fetched = self
|
||||||
|
.call_with_auth_repair("get_tracks_batch", || {
|
||||||
|
self.api.get_tracks_batch(&missing_ids)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for track in fetched {
|
||||||
|
self.cache.put_track(track.id.clone(), track.clone()).await;
|
||||||
|
cached.insert(track.id.clone(), track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restituer dans l'ordre d'entrée
|
||||||
|
Ok(track_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| cached.remove(*id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère l'URL de streaming d'une track
|
/// Récupère l'URL de streaming d'une track
|
||||||
pub async fn get_stream_url(&self, track_id: &str) -> Result<String> {
|
pub async fn get_stream_url(&self, track_id: &str) -> Result<String> {
|
||||||
// Vérifier le cache d'abord
|
// Vérifier le cache d'abord
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.50
|
0.3.51
|
||||||
|
|||||||
Reference in New Issue
Block a user