Files
pmomusic/pmoplaylist/src/persistence/mod.rs

340 lines
12 KiB
Rust
Raw Normal View History

2025-10-27 12:32:31 +01:00
//! Gestion de la persistance SQLite pour les playlists
use crate::playlist::core::PlaylistConfig;
use crate::playlist::record::Record;
2025-12-17 08:31:47 +01:00
use crate::playlist::PlaylistRole;
2025-10-27 12:32:31 +01:00
use crate::Result;
use rusqlite::{params, Connection};
use std::collections::VecDeque;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
2025-12-17 08:31:47 +01:00
use std::str::FromStr;
2025-10-27 12:32:31 +01:00
/// 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))
})?;
2025-10-27 12:32:31 +01:00
}
let conn = Connection::open(db_path).map_err(|e| {
crate::Error::PersistenceError(format!("Failed to open database: {}", e))
})?;
2025-10-27 12:32:31 +01:00
// Créer les tables
conn.execute(
"CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
2025-12-17 08:31:47 +01:00
role TEXT NOT NULL,
2025-10-27 12:32:31 +01:00
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))
})?;
2025-10-27 12:32:31 +01:00
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))
})?;
2025-10-27 12:32:31 +01:00
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)))?;
2025-10-27 12:32:31 +01:00
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)))?;
2025-10-27 12:32:31 +01:00
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
2025-10-27 12:32:31 +01:00
/// Sauvegarde une playlist complète
pub async fn save_playlist(
&self,
id: &str,
title: &str,
2025-12-17 08:31:47 +01:00
role: &PlaylistRole,
2025-10-27 12:32:31 +01:00
config: &PlaylistConfig,
tracks: &VecDeque<Arc<Record>>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
2025-10-27 12:32:31 +01:00
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as i64;
2025-10-27 12:32:31 +01:00
// Upsert playlist metadata
conn.execute(
2025-12-17 08:31:47 +01:00
"INSERT OR REPLACE INTO playlists (id, title, role, max_size, default_ttl_secs, created_at, last_modified)
VALUES (?1, ?2, ?3, ?4, ?5,
COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?6),
?6)",
2025-10-27 12:32:31 +01:00
params![
id,
title,
2025-12-17 08:31:47 +01:00
role.as_str(),
2025-10-27 12:32:31 +01:00
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)))?;
2025-10-27 12:32:31 +01:00
// 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))
})?;
2025-10-27 12:32:31 +01:00
// 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))
})?;
2025-10-27 12:32:31 +01:00
}
2025-10-27 12:32:31 +01:00
Ok(())
}
2025-10-27 12:32:31 +01:00
/// Charge une playlist
pub async fn load_playlist(
&self,
id: &str,
2025-12-17 08:31:47 +01:00
) -> Result<Option<(String, PlaylistRole, PlaylistConfig, VecDeque<Arc<Record>>)>> {
2025-10-27 12:32:31 +01:00
let conn = self.conn.lock().unwrap();
2025-10-27 12:32:31 +01:00
// Charger les métadonnées
let mut stmt = conn
2025-12-17 08:31:47 +01:00
.prepare(
"SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
)
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
})?;
2025-10-27 12:32:31 +01:00
let result = stmt.query_row(params![id], |row| {
let title: String = row.get(0)?;
2025-12-17 08:31:47 +01:00
let role_raw: String = row.get(1)?;
let max_size: Option<i64> = row.get(2)?;
let default_ttl_secs: Option<i64> = row.get(3)?;
2025-10-27 12:32:31 +01:00
Ok((
title,
2025-12-17 08:31:47 +01:00
PlaylistRole::from_str(&role_raw)
.unwrap_or_else(|_| PlaylistRole::custom(role_raw)),
2025-10-27 12:32:31 +01:00
PlaylistConfig {
max_size: max_size.map(|s| s as usize),
default_ttl: default_ttl_secs.map(|s| Duration::from_secs(s as u64)),
},
))
});
2025-12-17 08:31:47 +01:00
let (title, role, config) = match result {
2025-10-27 12:32:31 +01:00
Ok(data) => data,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => {
return Err(crate::Error::PersistenceError(format!(
"Failed to load playlist: {}",
e
)))
}
2025-10-27 12:32:31 +01:00
};
2025-10-27 12:32:31 +01:00
// 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,
})
2025-10-27 12:32:31 +01:00
})
.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to query tracks: {}", e))
})?;
2025-10-27 12:32:31 +01:00
let mut tracks = VecDeque::new();
for row in rows {
let record = row.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to read track: {}", e))
})?;
2025-10-27 12:32:31 +01:00
tracks.push_back(Arc::new(record));
}
2025-12-17 08:31:47 +01:00
Ok(Some((title, role, config, tracks)))
2025-10-27 12:32:31 +01:00
}
2025-10-27 12:32:31 +01:00
/// 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))
})?;
2025-10-27 12:32:31 +01:00
Ok(())
}
2025-10-27 12:32:31 +01:00
/// 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))
})?;
2025-10-27 12:32:31 +01:00
let mut ids = Vec::new();
for row in rows {
ids.push(row.map_err(|e| {
crate::Error::PersistenceError(format!("Failed to read id: {}", e))
})?);
2025-10-27 12:32:31 +01:00
}
2025-10-27 12:32:31 +01:00
Ok(ids)
}
2025-10-27 12:32:31 +01:00
/// 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))
})?;
2025-10-27 12:32:31 +01:00
Ok(())
}
2025-11-17 03:03:06 +01:00
/// 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)
2025-11-17 22:31:27 +01:00
conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| {
crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e))
})?;
2025-11-17 03:03:06 +01:00
// Vérifier l'intégrité des clés étrangères
2025-11-17 22:31:27 +01:00
let mut stmt = conn.prepare("PRAGMA foreign_key_check").map_err(|e| {
crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e))
})?;
2025-11-17 03:03:06 +01:00
let violations: Vec<(String, i64, String, i64)> = stmt
.query_map([], |row| {
2025-11-17 22:31:27 +01:00
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
2025-11-17 03:03:06 +01:00
})
.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| {
2025-11-17 22:31:27 +01:00
crate::Error::PersistenceError(format!(
"Failed to delete orphaned tracks: {}",
e
))
2025-11-17 03:03:06 +01:00
})?;
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(())
}
2025-10-27 12:32:31 +01:00
}