feat: add Qobuz playlist caching with versioning and infinite scroll UI

- Add qobuz_debug to .gitignore
- Bump PMOMusic version to 0.3.26
- Add .cargo/ copy in Dockerfile for registry config
- Implement infinite scroll in ServerDrawer.vue with IntersectionObserver and sentinel element
- Add source/source_version fields to playlists for cache invalidation
- Update pmocache and pmoplaylist DB schemas with versioning (SCHEMA_VERSION)
- Add Qobuz API metadata fetching and pagination for playlist tracks
- Implement album/playlist cache invalidation based on released_at/updated_at timestamps
- Refactor adapt_playlist_items_to_qobuz → adapt_items_to_qobuz with cleaner logic
- Add debug mode to save Qobuz API responses when QOBUZ_DEBUG_DIR is set
This commit is contained in:
2026-03-24 17:10:38 +01:00
parent 402c3399e4
commit 0137a4675f
17 changed files with 688 additions and 160 deletions

View File

@@ -32,6 +32,8 @@ pub(crate) struct AlbumResponse {
#[serde(default)]
release_date_original: Option<String>,
#[serde(default)]
released_at: Option<i64>,
#[serde(default)]
image: Option<ImageResponse>,
#[serde(default = "default_streamable")]
streamable: bool,
@@ -120,6 +122,8 @@ pub(crate) struct PlaylistResponse {
#[serde(default)]
owner: Option<OwnerResponse>,
#[serde(default)]
updated_at: Option<i64>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
@@ -317,6 +321,16 @@ impl QobuzApi {
.collect())
}
/// Récupère les métadonnées d'une playlist sans les tracks (appel léger)
///
/// Utile pour vérifier `updated_at` avant de décider si le cache est valide.
pub async fn get_playlist_metadata(&self, playlist_id: &str) -> Result<Playlist> {
debug!("Fetching playlist metadata {}", 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 détails d'une playlist
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
debug!("Fetching playlist {}", playlist_id);
@@ -328,18 +342,36 @@ impl QobuzApi {
/// 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?;
const PAGE_SIZE: u32 = 50;
let mut all_tracks = Vec::new();
let mut offset = 0u32;
if let Some(tracks) = response.tracks {
Ok(tracks
.items
.into_iter()
.map(|t| Self::parse_track(t, None))
.collect())
} else {
Ok(Vec::new())
loop {
let offset_str = offset.to_string();
let limit_str = PAGE_SIZE.to_string();
let params = [
("playlist_id", playlist_id),
("extra", "tracks"),
("offset", offset_str.as_str()),
("limit", limit_str.as_str()),
];
let response: PlaylistResponse = self.get("/playlist/get", &params).await?;
if let Some(tracks) = response.tracks {
let total = tracks.total.unwrap_or(0);
let count = tracks.items.len() as u32;
all_tracks.extend(tracks.items.into_iter().map(|t| Self::parse_track(t, None)));
offset += count;
if count == 0 || offset >= total {
break;
}
} else {
break;
}
}
debug!("Fetched {} tracks total for playlist {}", all_tracks.len(), playlist_id);
Ok(all_tracks)
}
/// Récupère la liste des genres
@@ -493,6 +525,7 @@ impl QobuzApi {
tracks_count: response.tracks_count,
duration: response.duration,
release_date: response.release_date_original,
released_at: response.released_at,
image: response.image.and_then(|i| i.large),
image_cached: None,
streamable: response.streamable,
@@ -547,6 +580,7 @@ impl QobuzApi {
image: response.images300.and_then(|imgs| imgs.first().cloned()),
image_cached: None,
is_public: response.is_public,
updated_at: response.updated_at,
owner: response.owner.map(|o| PlaylistOwner {
id: o.id.parse().unwrap_or(0),
name: o.name,

View File

@@ -17,6 +17,49 @@ use std::sync::RwLock;
use std::time::Duration;
use tracing::debug;
/// Si la variable d'environnement `QOBUZ_DEBUG_DIR` est définie, sauvegarde
/// le JSON brut de chaque réponse API dans ce dossier.
fn debug_save_response(endpoint: &str, params: &[(&str, &str)], text: &str) {
let Ok(dir) = std::env::var("QOBUZ_DEBUG_DIR") else {
return;
};
let dir = std::path::Path::new(&dir);
if let Err(e) = std::fs::create_dir_all(dir) {
debug!("QOBUZ_DEBUG_DIR: cannot create dir: {}", e);
return;
}
// Construire un nom de fichier lisible : endpoint + params clés
let endpoint_slug = endpoint.trim_start_matches('/').replace('/', "_");
let param_slug: String = params
.iter()
.filter(|(k, _)| !["app_id", "user_auth_token", "request_ts", "request_sig"].contains(k))
.map(|(k, v)| format!("{}-{}", k, v.chars().take(20).collect::<String>()))
.collect::<Vec<_>>()
.join("_");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let filename = format!("{}_{}_{}.json", endpoint_slug, param_slug, ts);
let path = dir.join(&filename);
// Pretty-print si possible
let content = serde_json::from_str::<Value>(text)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| text.to_string());
if let Err(e) = std::fs::write(&path, content) {
debug!("QOBUZ_DEBUG_DIR: cannot write {}: {}", filename, e);
} else {
debug!("QOBUZ_DEBUG_DIR: saved {}", filename);
}
}
pub use spoofer::Spoofer;
/// URL de base de l'API Qobuz
@@ -270,7 +313,7 @@ impl QobuzApi {
// Envoyer la requête
let response = request.send().await?;
self.handle_response(response, endpoint).await
self.handle_response(response, endpoint, params).await
}
/// Traite la réponse HTTP
@@ -278,6 +321,7 @@ impl QobuzApi {
&self,
response: Response,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<T> {
let status = response.status();
let status_code = status.as_u16();
@@ -294,6 +338,7 @@ impl QobuzApi {
}
let text = response.text().await?;
debug_save_response(endpoint, params, &text);
// Vérifier si la réponse contient une erreur Qobuz
if let Ok(json) = serde_json::from_str::<Value>(&text) {