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:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user