- 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
426 lines
15 KiB
Rust
426 lines
15 KiB
Rust
//! Gestion de la persistance SQLite pour les playlists
|
|
|
|
use crate::playlist::core::PlaylistConfig;
|
|
use crate::playlist::record::Record;
|
|
use crate::playlist::PlaylistRole;
|
|
use crate::Result;
|
|
use rusqlite::{params, Connection};
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
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>>,
|
|
}
|
|
|
|
impl PersistenceManager {
|
|
/// Initialise le gestionnaire de persistance
|
|
pub fn new(db_path: &Path) -> Result<Self> {
|
|
// Créer le répertoire parent si nécessaire
|
|
if let Some(parent) = db_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to create directory: {}", e))
|
|
})?;
|
|
}
|
|
|
|
// 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))
|
|
})?;
|
|
|
|
// Créer les tables
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS playlists (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
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,
|
|
last_modified INTEGER NOT NULL
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to create playlists table: {}", e))
|
|
})?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tracks (
|
|
playlist_id TEXT NOT NULL,
|
|
added_at INTEGER NOT NULL PRIMARY KEY,
|
|
cache_pk TEXT NOT NULL,
|
|
ttl_secs INTEGER,
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
|
)",
|
|
[],
|
|
)
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to create tracks table: {}", e))
|
|
})?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_tracks_playlist ON tracks(playlist_id, added_at)",
|
|
[],
|
|
)
|
|
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_tracks_cache_pk ON tracks(cache_pk)",
|
|
[],
|
|
)
|
|
.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)),
|
|
})
|
|
}
|
|
|
|
/// Sauvegarde une playlist complète
|
|
pub async fn save_playlist(
|
|
&self,
|
|
id: &str,
|
|
title: &str,
|
|
role: &PlaylistRole,
|
|
cover_pk: Option<&str>,
|
|
artist: Option<&str>,
|
|
source: Option<&str>,
|
|
source_version: Option<&str>,
|
|
config: &PlaylistConfig,
|
|
tracks: &VecDeque<Arc<Record>>,
|
|
) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let now_nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos() as i64;
|
|
|
|
// Upsert playlist metadata
|
|
conn.execute(
|
|
"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,
|
|
],
|
|
)
|
|
.map_err(|e| crate::Error::PersistenceError(format!("Failed to save playlist: {}", e)))?;
|
|
|
|
// Supprimer les anciens tracks
|
|
conn.execute("DELETE FROM tracks WHERE playlist_id = ?1", params![id])
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to delete old tracks: {}", e))
|
|
})?;
|
|
|
|
// Insérer les nouveaux tracks
|
|
for record in tracks {
|
|
conn.execute(
|
|
"INSERT INTO tracks (playlist_id, added_at, cache_pk, ttl_secs)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
params![
|
|
id,
|
|
record.added_at_nanos(),
|
|
&record.cache_pk,
|
|
record.ttl.map(|d| d.as_secs() as i64),
|
|
],
|
|
)
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to insert track: {}", e))
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Charge une playlist
|
|
pub async fn load_playlist(
|
|
&self,
|
|
id: &str,
|
|
) -> Result<
|
|
Option<(
|
|
String,
|
|
PlaylistRole,
|
|
PlaylistConfig,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
VecDeque<Arc<Record>>,
|
|
)>,
|
|
> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
// Charger les métadonnées
|
|
let mut stmt = conn.prepare(
|
|
"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))
|
|
})?;
|
|
|
|
let result = stmt.query_row(params![id], |row| {
|
|
let title: String = row.get(0)?;
|
|
let role_raw: String = row.get(1)?;
|
|
let cover_pk: Option<String> = row.get(2)?;
|
|
let artist: Option<String> = row.get(3)?;
|
|
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,
|
|
PlaylistRole::from_str(&role_raw)
|
|
.unwrap_or_else(|_| PlaylistRole::custom(role_raw)),
|
|
PlaylistConfig {
|
|
max_size: max_size.map(|s| s as usize),
|
|
default_ttl: default_ttl_secs.map(|s| Duration::from_secs(s as u64)),
|
|
},
|
|
cover_pk,
|
|
artist,
|
|
source,
|
|
source_version,
|
|
))
|
|
});
|
|
|
|
let (title, role, config, cover_pk, artist, source, source_version) = match result {
|
|
Ok(data) => data,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
|
Err(e) => {
|
|
return Err(crate::Error::PersistenceError(format!(
|
|
"Failed to load playlist: {}",
|
|
e
|
|
)))
|
|
}
|
|
};
|
|
|
|
// Charger les tracks
|
|
let mut stmt = conn.prepare(
|
|
"SELECT added_at, cache_pk, ttl_secs FROM tracks WHERE playlist_id = ?1 ORDER BY added_at ASC"
|
|
).map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?;
|
|
|
|
let rows = stmt
|
|
.query_map(params![id], |row| {
|
|
let added_at_nanos: i64 = row.get(0)?;
|
|
let cache_pk: String = row.get(1)?;
|
|
let ttl_secs: Option<i64> = row.get(2)?;
|
|
|
|
let added_at = UNIX_EPOCH + Duration::from_nanos(added_at_nanos as u64);
|
|
let ttl = ttl_secs.map(|s| Duration::from_secs(s as u64));
|
|
|
|
Ok(Record {
|
|
cache_pk,
|
|
added_at,
|
|
ttl,
|
|
})
|
|
})
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to query tracks: {}", e))
|
|
})?;
|
|
|
|
let mut tracks = VecDeque::new();
|
|
for row in rows {
|
|
let record = row.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to read track: {}", e))
|
|
})?;
|
|
tracks.push_back(Arc::new(record));
|
|
}
|
|
|
|
Ok(Some((title, role, config, cover_pk, artist, source, source_version, tracks)))
|
|
}
|
|
|
|
/// Supprime une playlist
|
|
pub async fn delete_playlist(&self, id: &str) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute("DELETE FROM playlists WHERE id = ?1", params![id])
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to delete playlist: {}", e))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Liste toutes les playlists persistantes
|
|
pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let mut stmt = conn.prepare("SELECT id FROM playlists").map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
|
})?;
|
|
|
|
let rows = stmt.query_map([], |row| row.get(0)).map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to query playlists: {}", e))
|
|
})?;
|
|
|
|
let mut ids = Vec::new();
|
|
for row in rows {
|
|
ids.push(row.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to read id: {}", e))
|
|
})?);
|
|
}
|
|
|
|
Ok(ids)
|
|
}
|
|
|
|
/// Récupère le timestamp de création d'une playlist
|
|
pub async fn get_playlist_created_at(&self, id: &str) -> Result<Option<i64>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT created_at FROM playlists WHERE id = ?1")
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
|
})?;
|
|
|
|
let result = stmt.query_row(params![id], |row| row.get(0));
|
|
|
|
match result {
|
|
Ok(created_at) => Ok(Some(created_at)),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(crate::Error::PersistenceError(format!(
|
|
"Failed to get created_at: {}",
|
|
e
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Supprime tous les tracks contenant un cache_pk donné
|
|
pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute("DELETE FROM tracks WHERE cache_pk = ?1", params![cache_pk])
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to remove tracks: {}", e))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Consolide la base de données des playlists
|
|
///
|
|
/// Cette fonction nettoie les incohérences:
|
|
/// - Active les contraintes de clés étrangères
|
|
/// - Supprime les tracks orphelins (référençant des playlists inexistantes)
|
|
/// - Nettoie les tracks avec TTL expirés
|
|
pub async fn consolidate(&self) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
// Activer les contraintes de clés étrangères (désactivées par défaut dans SQLite)
|
|
conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e))
|
|
})?;
|
|
|
|
// Vérifier l'intégrité des clés étrangères
|
|
let mut stmt = conn.prepare("PRAGMA foreign_key_check").map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e))
|
|
})?;
|
|
|
|
let violations: Vec<(String, i64, String, i64)> = stmt
|
|
.query_map([], |row| {
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
})
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to check foreign keys: {}", e))
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to read FK violations: {}", e))
|
|
})?;
|
|
|
|
if !violations.is_empty() {
|
|
tracing::warn!(
|
|
"Found {} foreign key violations, cleaning up orphaned tracks",
|
|
violations.len()
|
|
);
|
|
|
|
// Supprimer les tracks orphelins (ceux qui référencent des playlists inexistantes)
|
|
let deleted = conn
|
|
.execute(
|
|
"DELETE FROM tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)",
|
|
[],
|
|
)
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!(
|
|
"Failed to delete orphaned tracks: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
if deleted > 0 {
|
|
tracing::info!("Removed {} orphaned tracks during consolidation", deleted);
|
|
}
|
|
}
|
|
|
|
// Nettoyer les tracks avec TTL expirés
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs() as i64;
|
|
|
|
let deleted_expired = conn
|
|
.execute(
|
|
"DELETE FROM tracks WHERE ttl_secs IS NOT NULL AND (added_at + ttl_secs) < ?1",
|
|
params![now],
|
|
)
|
|
.map_err(|e| {
|
|
crate::Error::PersistenceError(format!("Failed to delete expired tracks: {}", e))
|
|
})?;
|
|
|
|
if deleted_expired > 0 {
|
|
tracing::info!(
|
|
"Removed {} expired tracks during consolidation",
|
|
deleted_expired
|
|
);
|
|
}
|
|
|
|
tracing::info!("Playlist database consolidation completed successfully");
|
|
Ok(())
|
|
}
|
|
}
|