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

@@ -70,6 +70,8 @@ impl ReadHandle {
let role = self.playlist.role().await;
let cover_pk = self.playlist.cover_pk().await;
let artist = self.playlist.artist().await;
let source = self.playlist.source().await;
let source_version = self.playlist.source_version().await;
let core = self.playlist.core.read().await;
let _ = persistence
.save_playlist(
@@ -78,6 +80,8 @@ impl ReadHandle {
&role,
cover_pk.as_deref(),
artist.as_deref(),
source.as_deref(),
source_version.as_deref(),
&core.config,
&core.tracks,
)
@@ -170,6 +174,21 @@ impl ReadHandle {
&self.playlist.id
}
/// Nombre total de records (sans validation cache)
pub async fn len(&self) -> usize {
self.playlist.core.read().await.len()
}
/// Retourne la source externe.
pub async fn source(&self) -> Option<String> {
self.playlist.source().await
}
/// Retourne la version de la source.
pub async fn source_version(&self) -> Option<String> {
self.playlist.source_version().await
}
/// Génère un Container DIDL-Lite
pub async fn to_container(&self) -> Result<Container> {
if !self.playlist.is_alive() {
@@ -199,6 +218,86 @@ impl ReadHandle {
})
}
/// Génère des Items DIDL-Lite avec pagination offset-based (sans déplacer le curseur)
///
/// Retourne `(items, total)` où `total` est le nombre total de records dans la playlist.
/// Contrairement à `to_items()`, cette méthode ignore le curseur et utilise `offset`.
pub async fn to_items_paged(&self, offset: usize, limit: usize) -> Result<(Vec<Item>, usize)> {
if !self.playlist.is_alive() {
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
}
let core = self.playlist.core.read().await;
let total = core.len();
let cache = crate::manager::audio_cache()?;
let mut items = Vec::new();
let mut idx = 0usize;
for i in offset..core.len() {
if items.len() >= limit {
break;
}
let record = match core.get(i) {
Some(r) => r,
None => continue,
};
if !cache.is_valid_pk(&record.cache_pk).await {
continue;
}
use pmoaudiocache::metadata_ext::{AudioTrackMetadataExt, TrackMetadataDidlExt};
let track_meta = cache.track_metadata(&record.cache_pk);
let meta = track_meta.read().await;
let url = cache.route_for(&record.cache_pk, None);
let resource = meta.to_didl_resource(url).await;
let title = meta
.get_title()
.await
.ok()
.flatten()
.unwrap_or_else(|| "Unknown".to_string());
let artist = meta.get_artist().await.ok().flatten();
let album = meta.get_album().await.ok().flatten();
let genre = meta.get_genre().await.ok().flatten();
let year = meta.get_year().await.ok().flatten();
let track_number = meta.get_track_number().await.ok().flatten();
let cover_pk = meta.get_cover_pk().await.ok().flatten();
let cover_url = if let Some(pk) = cover_pk.as_ref() {
Some(pmocache::covers_route_for(pk, None))
} else {
meta.get_cover_url().await.ok().flatten()
};
let item = Item {
id: format!("{}:{}", self.playlist.id, offset + idx),
parent_id: self.playlist.id.clone(),
restricted: Some("1".to_string()),
title: title.clone(),
creator: artist.clone(),
class: "object.item.audioItem.musicTrack".to_string(),
artist,
album,
genre,
album_art: cover_url,
album_art_pk: cover_pk,
date: year.map(|y| y.to_string()),
original_track_number: track_number.map(|n| n.to_string()),
resources: vec![resource],
descriptions: vec![],
};
items.push(item);
idx += 1;
}
Ok((items, total))
}
/// Génère des Items DIDL-Lite depuis la position actuelle
pub async fn to_items(&self, limit: usize) -> Result<Vec<Item>> {
if !self.playlist.is_alive() {

View File

@@ -553,6 +553,50 @@ impl WriteHandle {
// Helpers internes
/// Met à jour la source externe de la playlist.
pub async fn set_source(&self, source: Option<String>) -> Result<()> {
if !self.playlist.is_alive() {
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
}
self.playlist.set_source(source).await;
if self.playlist.persistent {
self.save_to_db().await?;
}
crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id);
Ok(())
}
/// Met à jour la version de la source (ex: updated_at).
pub async fn set_source_version(&self, version: Option<String>) -> Result<()> {
if !self.playlist.is_alive() {
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
}
self.playlist.set_source_version(version).await;
if self.playlist.persistent {
self.save_to_db().await?;
}
crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id);
Ok(())
}
/// Retourne la source externe.
pub async fn source(&self) -> Option<String> {
self.playlist.source().await
}
/// Retourne la version de la source.
pub async fn source_version(&self) -> Option<String> {
self.playlist.source_version().await
}
async fn save_to_db(&self) -> Result<()> {
let manager = crate::manager::PlaylistManager();
let persistence = manager
@@ -567,6 +611,8 @@ impl WriteHandle {
let cover_pk = self.playlist.cover_pk().await;
let artist = self.playlist.artist().await;
let source = self.playlist.source().await;
let source_version = self.playlist.source_version().await;
persistence
.save_playlist(
@@ -575,6 +621,8 @@ impl WriteHandle {
&role,
cover_pk.as_deref(),
artist.as_deref(),
source.as_deref(),
source_version.as_deref(),
config,
tracks,
)

View File

@@ -215,6 +215,8 @@ impl PlaylistManager {
&role,
cover_pk.as_deref(),
artist.as_deref(),
None,
None,
&core.config,
&core.tracks,
)
@@ -522,7 +524,7 @@ impl PlaylistManager {
// Pas en mémoire, essayer de charger depuis la DB
if let Some(persistence) = &self.inner.persistence {
if let Some((title, role, config, cover_pk, artist, tracks)) =
if let Some((title, role, config, cover_pk, artist, source, source_version, tracks)) =
persistence.load_playlist(&id).await?
{
// Reconstruire la playlist
@@ -537,10 +539,16 @@ impl PlaylistManager {
cover_pk,
));
// Restaurer l'artiste si présent
// Restaurer les métadonnées optionnelles
if let Some(artist_name) = artist {
playlist.set_artist(Some(artist_name)).await;
}
if source.is_some() {
playlist.set_source(source).await;
}
if source_version.is_some() {
playlist.set_source_version(source_version).await;
}
// Restaurer les tracks
{
@@ -583,7 +591,7 @@ impl PlaylistManager {
// Pas en m<>moire, essayer de ressusciter depuis la DB
if let Some(persistence) = &self.inner.persistence {
if let Some((title, role, config, cover_pk, artist, tracks)) =
if let Some((title, role, config, cover_pk, artist, source, source_version, tracks)) =
persistence.load_playlist(id).await?
{
// Reconstruire la playlist
@@ -598,10 +606,16 @@ impl PlaylistManager {
cover_pk,
));
// Restaurer l'artiste si présent
// Restaurer les métadonnées optionnelles
if let Some(artist_name) = artist {
playlist.set_artist(Some(artist_name)).await;
}
if source.is_some() {
playlist.set_source(source).await;
}
if source_version.is_some() {
playlist.set_source_version(source_version).await;
}
// Restaurer les tracks
{
@@ -995,6 +1009,8 @@ impl PlaylistManager {
let role = playlist.role().await;
let cover_pk = playlist.cover_pk().await;
let artist = playlist.artist().await;
let source = playlist.source().await;
let source_version = playlist.source_version().await;
let core = playlist.core.read().await;
let _ = persistence
.save_playlist(
@@ -1003,6 +1019,8 @@ impl PlaylistManager {
&role,
cover_pk.as_deref(),
artist.as_deref(),
source.as_deref(),
source_version.as_deref(),
&core.config,
&core.tracks,
)

View File

@@ -11,6 +11,13 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Version du schéma de la base de données des playlists.
///
/// Incrémenter cette constante à chaque modification incompatible du schéma
/// (ajout/suppression de colonne non nullable, changement de type, etc.).
/// Cela provoquera la suppression et la recréation automatique de la DB au démarrage.
const SCHEMA_VERSION: u32 = 1;
/// Gestionnaire de persistance (une base pour toutes les playlists)
pub struct PersistenceManager {
conn: Arc<Mutex<Connection>>,
@@ -26,6 +33,26 @@ impl PersistenceManager {
})?;
}
// Vérifier la version du schéma — supprimer la DB si incompatible
if db_path.exists() {
if let Ok(conn) = Connection::open(db_path) {
let version: u32 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap_or(0);
if version != SCHEMA_VERSION {
drop(conn);
tracing::warn!(
"Playlist DB schema version mismatch (found {}, expected {}), recreating",
version,
SCHEMA_VERSION
);
std::fs::remove_file(db_path).map_err(|e| {
crate::Error::PersistenceError(format!("Failed to remove old DB: {}", e))
})?;
}
}
}
let conn = Connection::open(db_path).map_err(|e| {
crate::Error::PersistenceError(format!("Failed to open database: {}", e))
})?;
@@ -38,6 +65,8 @@ impl PersistenceManager {
role TEXT NOT NULL,
cover_pk TEXT,
artist TEXT,
source TEXT,
source_version TEXT,
max_size INTEGER,
default_ttl_secs INTEGER,
created_at INTEGER NOT NULL,
@@ -75,6 +104,12 @@ impl PersistenceManager {
)
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
// Inscrire la version du schéma
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to set schema version: {}", e))
})?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
@@ -88,6 +123,8 @@ impl PersistenceManager {
role: &PlaylistRole,
cover_pk: Option<&str>,
artist: Option<&str>,
source: Option<&str>,
source_version: Option<&str>,
config: &PlaylistConfig,
tracks: &VecDeque<Arc<Record>>,
) -> Result<()> {
@@ -100,16 +137,18 @@ impl PersistenceManager {
// Upsert playlist metadata
conn.execute(
"INSERT OR REPLACE INTO playlists (id, title, role, cover_pk, artist, max_size, default_ttl_secs, created_at, last_modified)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7,
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?8),
?8)",
"INSERT OR REPLACE INTO playlists (id, title, role, cover_pk, artist, source, source_version, max_size, default_ttl_secs, created_at, last_modified)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9,
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?10),
?10)",
params![
id,
title,
role.as_str(),
cover_pk,
artist,
source,
source_version,
config.max_size.map(|s| s as i64),
config.default_ttl.map(|d| d.as_secs() as i64),
now_nanos,
@@ -154,6 +193,8 @@ impl PersistenceManager {
PlaylistConfig,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
VecDeque<Arc<Record>>,
)>,
> {
@@ -161,7 +202,7 @@ impl PersistenceManager {
// Charger les métadonnées
let mut stmt = conn.prepare(
"SELECT title, role, cover_pk, artist, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
"SELECT title, role, cover_pk, artist, source, source_version, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
)
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
@@ -172,8 +213,10 @@ impl PersistenceManager {
let role_raw: String = row.get(1)?;
let cover_pk: Option<String> = row.get(2)?;
let artist: Option<String> = row.get(3)?;
let max_size: Option<i64> = row.get(4)?;
let default_ttl_secs: Option<i64> = row.get(5)?;
let source: Option<String> = row.get(4)?;
let source_version: Option<String> = row.get(5)?;
let max_size: Option<i64> = row.get(6)?;
let default_ttl_secs: Option<i64> = row.get(7)?;
Ok((
title,
@@ -185,10 +228,12 @@ impl PersistenceManager {
},
cover_pk,
artist,
source,
source_version,
))
});
let (title, role, config, cover_pk, artist) = match result {
let (title, role, config, cover_pk, artist, source, source_version) = match result {
Ok(data) => data,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => {
@@ -231,7 +276,7 @@ impl PersistenceManager {
tracks.push_back(Arc::new(record));
}
Ok(Some((title, role, config, cover_pk, artist, tracks)))
Ok(Some((title, role, config, cover_pk, artist, source, source_version, tracks)))
}
/// Supprime une playlist

View File

@@ -36,6 +36,10 @@ pub struct Playlist {
role: RwLock<PlaylistRole>,
cover_pk: RwLock<Option<String>>,
artist: RwLock<Option<String>>,
/// Source externe (ex: "qobuz")
source: RwLock<Option<String>>,
/// Version de la source (ex: timestamp updated_at de Qobuz)
source_version: RwLock<Option<String>>,
state: Arc<AtomicU8>,
pub core: Arc<RwLock<PlaylistCore>>,
pub persistent: bool,
@@ -59,6 +63,8 @@ impl Playlist {
role: RwLock::new(role),
cover_pk: RwLock::new(cover_pk),
artist: RwLock::new(None),
source: RwLock::new(None),
source_version: RwLock::new(None),
state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)),
core: Arc::new(RwLock::new(PlaylistCore::new(config))),
persistent,
@@ -127,6 +133,28 @@ impl Playlist {
self.touch().await;
}
/// Retourne la source externe associée à la playlist.
pub async fn source(&self) -> Option<String> {
self.source.read().await.clone()
}
/// Modifie la source de la playlist.
pub async fn set_source(&self, value: Option<String>) {
*self.source.write().await = value;
self.touch().await;
}
/// Retourne la version de la source (ex: updated_at timestamp).
pub async fn source_version(&self) -> Option<String> {
self.source_version.read().await.clone()
}
/// Modifie la version de la source.
pub async fn set_source_version(&self, value: Option<String>) {
*self.source_version.write().await = value;
self.touch().await;
}
/// Timestamp du dernier changement
pub async fn last_change(&self) -> SystemTime {
*self.last_change.read().await