Files
pmomusic/pmoaudiocache/src/db.rs
2025-10-13 11:31:14 +02:00

233 lines
7.8 KiB
Rust

//! Module de base de données étendu pour le cache audio
//!
//! Ce module étend la DB générique de pmocache avec des champs
//! spécifiques aux métadonnées audio pour permettre le service
//! immédiat des informations avant la fin de la conversion.
use anyhow::Result;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Mutex;
use crate::metadata::AudioMetadata;
#[cfg(feature = "pmoserver")]
use utoipa::ToSchema;
/// Entrée de cache audio avec métadonnées complètes
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct AudioCacheEntry {
/// Clé primaire unique (hash SHA1 de l'URL)
pub pk: String,
/// URL source
pub source_url: String,
/// Collection (artiste:album)
pub collection: Option<String>,
/// Nombre d'accès
pub hits: i32,
/// Dernière utilisation
pub last_used: Option<String>,
/// Métadonnées audio (stockées en JSON)
pub metadata: AudioMetadata,
/// État de conversion (pending, converting, completed, failed)
pub conversion_status: String,
}
/// Base de données SQLite pour le cache audio
///
/// Étend la DB générique avec :
/// - Métadonnées audio complètes en JSON
/// - État de conversion pour le traitement asynchrone
#[derive(Debug)]
pub struct AudioDB {
conn: Mutex<Connection>,
}
impl AudioDB {
/// Initialise une nouvelle base de données audio
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS audio_tracks (
pk TEXT PRIMARY KEY,
source_url TEXT,
collection TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT,
metadata_json TEXT,
conversion_status TEXT DEFAULT 'pending'
)",
[],
)?;
// Index sur la collection
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_audio_tracks_collection
ON audio_tracks (collection)",
[],
)?;
// Index sur le statut de conversion
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_audio_tracks_conversion
ON audio_tracks (conversion_status)",
[],
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Ajoute une entrée avec métadonnées
pub fn add(&self, pk: &str, url: &str, collection: Option<&str>, metadata: &AudioMetadata) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
let metadata_json = serde_json::to_string(metadata)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
conn.execute(
"INSERT INTO audio_tracks (pk, source_url, collection, hits, last_used, metadata_json, conversion_status)
VALUES (?1, ?2, ?3, 0, ?4, ?5, 'pending')
ON CONFLICT(pk) DO UPDATE SET
source_url = excluded.source_url,
collection = excluded.collection,
metadata_json = excluded.metadata_json,
last_used = excluded.last_used",
params![pk, url, collection, chrono::Utc::now().to_rfc3339(), metadata_json],
)?;
Ok(())
}
/// Récupère une entrée avec métadonnées
pub fn get(&self, pk: &str) -> rusqlite::Result<AudioCacheEntry> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
FROM audio_tracks WHERE pk = ?1",
[pk],
|row| {
let metadata_json: String = row.get(5)?;
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Text,
Box::new(e)
))?;
Ok(AudioCacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata,
conversion_status: row.get(6)?,
})
},
)
}
/// Met à jour le statut de conversion
pub fn update_conversion_status(&self, pk: &str, status: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE audio_tracks SET conversion_status = ?1 WHERE pk = ?2",
params![status, pk],
)?;
Ok(())
}
/// Met à jour le compteur d'accès
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE audio_tracks SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
params![chrono::Utc::now().to_rfc3339(), pk],
)?;
Ok(())
}
/// Récupère toutes les entrées d'une collection
pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result<Vec<AudioCacheEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
FROM audio_tracks WHERE collection = ?1 ORDER BY hits DESC",
)?;
let entries = stmt.query_map([collection], |row| {
let metadata_json: String = row.get(5)?;
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Text,
Box::new(e)
))?;
Ok(AudioCacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata,
conversion_status: row.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Récupère toutes les entrées
pub fn get_all(&self) -> rusqlite::Result<Vec<AudioCacheEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT pk, source_url, collection, hits, last_used, metadata_json, conversion_status
FROM audio_tracks ORDER BY hits DESC",
)?;
let entries = stmt.query_map([], |row| {
let metadata_json: String = row.get(5)?;
let metadata: AudioMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Text,
Box::new(e)
))?;
Ok(AudioCacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
metadata,
conversion_status: row.get(6)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
/// Supprime une entrée
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM audio_tracks WHERE pk = ?1", [pk])?;
Ok(())
}
/// Purge toutes les entrées
pub fn purge(&self) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM audio_tracks", [])?;
Ok(())
}
}