Refactoring du pmoaudiocache

This commit is contained in:
2025-10-17 23:08:06 +02:00
parent b3f4c45df9
commit b9e998fa8a
10 changed files with 482 additions and 1158 deletions

View File

@@ -1,420 +1,203 @@
//! Module de gestion du cache de pistes audio
//! Module de gestion du cache audio avec conversion FLAC
//!
//! Ce module gère le cache audio avec :
//! - Stockage immédiat des métadonnées en DB
//! - Conversion FLAC asynchrone en arrière-plan
//! - Service DIDL-Lite immédiat avant fin de conversion
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
//! spécifiques aux fichiers audio : conversion FLAC automatique et stockage
//! des métadonnées en JSON dans la base de données.
use anyhow::{anyhow, Result};
use std::path::PathBuf;
use anyhow::Result;
use pmocache::{CacheConfig, StreamTransformer};
use std::sync::Arc;
use tokio::sync::Mutex;
use pmodidl::{Item, Resource};
use crate::{
db::{AudioDB, AudioCacheEntry},
metadata::AudioMetadata,
};
/// Configuration pour le cache audio
pub struct AudioConfig;
/// Cache de pistes audio avec conversion asynchrone
///
/// Permet de servir les métadonnées immédiatement pendant que
/// la conversion FLAC s'effectue en arrière-plan.
#[derive(Debug)]
pub struct AudioCache {
dir: PathBuf,
pub(crate) db: Arc<AudioDB>,
conversion_queue: Arc<Mutex<Vec<String>>>, // PKs en attente de conversion
impl CacheConfig for AudioConfig {
fn file_extension() -> &'static str {
"flac"
}
fn table_name() -> &'static str {
"audio_tracks"
}
fn cache_type() -> &'static str {
"audio"
}
fn cache_name() -> &'static str {
"audio"
}
fn default_param() -> &'static str {
"orig"
}
}
impl AudioCache {
/// Crée un nouveau cache audio
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre de pistes)
pub fn new(dir: &str, limit: usize) -> Result<Self> {
std::fs::create_dir_all(dir)?;
let db_path = PathBuf::from(dir).join("audio_cache.db");
let db = Arc::new(AudioDB::init(&db_path)?);
/// Type alias pour le cache audio avec conversion FLAC
pub type Cache = pmocache::Cache<AudioConfig>;
Ok(Self {
dir: PathBuf::from(dir),
db,
conversion_queue: Arc::new(Mutex::new(Vec::new())),
/// Créateur de transformer FLAC
///
/// Convertit automatiquement tout fichier audio téléchargé en format FLAC.
///
/// # Workflow
///
/// 1. Télécharger les bytes
/// 2. Extraire les métadonnées via `AudioMetadata::from_bytes()`
/// 3. Convertir en FLAC via `flac::convert_to_flac()`
/// 4. Écrire le fichier FLAC
/// 5. Mettre à jour la progression
///
/// Note: Les métadonnées sont retournées via l'objet file path et devront
/// être stockées séparément après le download complet.
fn create_flac_transformer() -> StreamTransformer {
Box::new(|response, mut file, progress| {
Box::pin(async move {
// 1. Télécharger tout en mémoire
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
// 2. Extraire les métadonnées audio (pour validation)
let _metadata = crate::metadata::AudioMetadata::from_bytes(&bytes)
.map_err(|e| format!("Metadata extraction error: {}", e))?;
// 3. Convertir en FLAC
let flac_data = crate::flac::convert_to_flac(&bytes, None)
.map_err(|e| format!("FLAC conversion error: {}", e))?;
// 4. Écrire le fichier FLAC
use tokio::io::AsyncWriteExt;
file.write_all(&flac_data).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
// 5. Mettre à jour la progression
progress(flac_data.len() as u64);
Ok(())
})
}
/// Ajoute une piste audio depuis une URL
///
/// **Phase 1 (immédiate) :** Télécharge et stocke les métadonnées en DB
/// **Phase 2 (async) :** Conversion FLAC en arrière-plan
///
/// Les métadonnées sont disponibles immédiatement via `get_metadata()`
///
/// # Arguments
///
/// * `url` - URL de la piste audio
/// * `external_metadata` - Métadonnées optionnelles depuis le service (Qobuz, etc.)
///
/// # Returns
///
/// * `(pk, metadata)` - Clé et métadonnées (disponibles immédiatement)
pub async fn add_from_url(
&self,
url: &str,
external_metadata: Option<AudioMetadata>,
) -> Result<(String, AudioMetadata)> {
let response = reqwest::get(url).await?;
let data = response.bytes().await?;
self.add_from_bytes(url, &data, external_metadata).await
}
/// Ajoute une piste depuis des données brutes
///
/// # Phase 1 (immédiate, <1s)
/// 1. Extraire métadonnées du fichier
/// 2. Fusionner avec métadonnées externes si fournies
/// 3. Stocker métadonnées en DB
/// 4. Stocker fichier original temporairement
///
/// # Phase 2 (asynchrone)
/// 5. Conversion FLAC en arrière-plan
/// 6. Mise à jour du statut de conversion
///
/// # Arguments
///
/// * `url` - URL source
/// * `data` - Données audio brutes
/// * `external_metadata` - Métadonnées optionnelles depuis le service
pub async fn add_from_bytes(
&self,
url: &str,
data: &[u8],
external_metadata: Option<AudioMetadata>,
) -> Result<(String, AudioMetadata)> {
let pk = pmocache::pk_from_url(url);
// Phase 1 : Extraction et stockage immédiat des métadonnées
let mut metadata = AudioMetadata::from_bytes(data)?;
// Fusionner avec métadonnées externes si fournies (priorité aux externes)
if let Some(external) = external_metadata {
metadata = merge_metadata(metadata, external);
}
let collection = metadata.collection_key();
// Stocker les métadonnées immédiatement en DB
self.db.add(&pk, url, collection.as_deref(), &metadata)?;
// Stocker le fichier original temporairement
let temp_path = self.temp_file_path(&pk);
tokio::fs::write(&temp_path, data).await?;
// Phase 2 : Lancer la conversion asynchrone
self.start_conversion(pk.clone(), temp_path).await;
Ok((pk, metadata))
}
/// Lance la conversion FLAC en arrière-plan
async fn start_conversion(&self, pk: String, temp_path: PathBuf) {
let db = Arc::clone(&self.db);
let final_path = self.flac_file_path(&pk);
tokio::spawn(async move {
// Marquer comme en cours de conversion
let _ = db.update_conversion_status(&pk, "converting");
// Conversion FLAC
match tokio::fs::read(&temp_path).await {
Ok(data) => {
match crate::flac::convert_to_flac(&data, None) {
Ok(flac_data) => {
// Écrire le fichier FLAC
if let Ok(_) = tokio::fs::write(&final_path, flac_data).await {
// Supprimer le fichier temporaire
let _ = tokio::fs::remove_file(&temp_path).await;
// Marquer comme complété
let _ = db.update_conversion_status(&pk, "completed");
} else {
let _ = db.update_conversion_status(&pk, "failed");
}
}
Err(_) => {
let _ = db.update_conversion_status(&pk, "failed");
}
}
}
Err(_) => {
let _ = db.update_conversion_status(&pk, "failed");
}
}
});
}
/// Récupère les métadonnées d'une piste (disponible immédiatement)
///
/// Cette méthode retourne les métadonnées même si la conversion FLAC
/// n'est pas terminée. Permet de servir du DIDL-Lite immédiatement.
pub async fn get_metadata(&self, pk: &str) -> Result<AudioMetadata> {
self.db.update_hit(pk)?;
let entry = self.db.get(pk)?;
Ok(entry.metadata)
}
/// Récupère les métadonnées et le statut de conversion
pub async fn get_entry(&self, pk: &str) -> Result<AudioCacheEntry> {
self.db.update_hit(pk)?;
Ok(self.db.get(pk)?)
}
/// Récupère le chemin du fichier audio (attend la fin de conversion si nécessaire)
pub async fn get_file(&self, pk: &str) -> Result<PathBuf> {
let entry = self.db.get(pk)?;
match entry.conversion_status.as_str() {
"completed" => {
let flac_path = self.flac_file_path(pk);
if flac_path.exists() {
self.db.update_hit(pk)?;
Ok(flac_path)
} else {
Err(anyhow!("File not found"))
}
}
"converting" | "pending" => {
// Attendre un court instant (permet de servir rapidement après 1 seconde)
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Re-vérifier le statut
let entry = self.db.get(pk)?;
if entry.conversion_status == "completed" {
let flac_path = self.flac_file_path(pk);
if flac_path.exists() {
self.db.update_hit(pk)?;
return Ok(flac_path);
}
}
Err(anyhow!("Conversion not completed yet"))
}
"failed" => Err(anyhow!("Conversion failed")),
_ => Err(anyhow!("Unknown conversion status")),
}
}
/// Génère un objet DIDL-Lite pour une piste
///
/// Peut être appelé immédiatement après `add_from_bytes()` même si
/// la conversion n'est pas terminée.
///
/// # Arguments
///
/// * `pk` - Clé de la piste
/// * `base_url` - URL de base du serveur (ex: "http://localhost:8080")
pub async fn get_didl(&self, pk: &str, base_url: &str) -> Result<String> {
let entry = self.get_entry(pk).await?;
let metadata = entry.metadata;
let stream_url = format!("{}/audio/tracks/{}/stream", base_url, pk);
let duration = if let Some(duration_secs) = metadata.duration_secs {
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
Some(format!("{}:{:02}:{:02}", hours, minutes, seconds))
} else {
None
};
let resource = Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: None,
sample_frequency: metadata.sample_rate.map(|sr| sr.to_string()),
nr_audio_channels: metadata.channels.map(|c| c.to_string()),
duration,
url: stream_url,
};
let item = Item {
id: pk.to_string(),
parent_id: "0".to_string(),
restricted: None,
title: metadata.title.unwrap_or_default(),
creator: None,
class: "object.item.audioItem.musicTrack".to_string(),
artist: metadata.artist,
album: metadata.album,
genre: metadata.genre,
album_art: None,
album_art_pk: None,
date: metadata.year.map(|y| format!("{:04}-01-01", y)),
original_track_number: metadata.track_number.map(|n| n.to_string()),
resources: vec![resource],
descriptions: Vec::new(),
};
// Utiliser quick_xml pour serializer en XML
let xml = quick_xml::se::to_string(&item)
.map_err(|e| anyhow!("XML serialization error: {}", e))?;
Ok(xml)
}
/// Récupère toutes les pistes d'une collection
pub async fn get_collection(&self, collection: &str) -> Result<Vec<AudioCacheEntry>> {
Ok(self.db.get_by_collection(collection)?)
}
/// Liste toutes les collections
pub async fn list_collections(&self) -> Result<Vec<(String, usize)>> {
let entries = self.db.get_all()?;
let mut collections: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for entry in entries {
if let Some(collection) = entry.collection {
*collections.entry(collection).or_insert(0) += 1;
}
}
let mut result: Vec<(String, usize)> = collections.into_iter().collect();
result.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
/// Purge le cache
pub async fn purge(&self) -> Result<()> {
// Supprimer tous les fichiers
let mut entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db.purge()?;
Ok(())
}
/// Supprime une piste du cache
///
/// Supprime les fichiers (temp et FLAC) et l'entrée de la base de données
pub async fn delete(&self, pk: &str) -> Result<()> {
// Supprimer les fichiers
let temp_path = self.temp_file_path(pk);
let flac_path = self.flac_file_path(pk);
if temp_path.exists() {
tokio::fs::remove_file(&temp_path).await?;
}
if flac_path.exists() {
tokio::fs::remove_file(&flac_path).await?;
}
// Supprimer l'entrée DB
self.db.delete(pk)?;
Ok(())
}
/// Consolide le cache
///
/// - Supprime les entrées DB sans fichiers correspondants
/// - Supprime les fichiers sans entrées DB
/// - Nettoie les conversions en échec
pub async fn consolidate(&self) -> Result<()> {
// Récupérer toutes les entrées
let entries = self.db.get_all()?;
// Supprimer les entrées sans fichiers ou en échec
for entry in entries {
let flac_path = self.flac_file_path(&entry.pk);
let temp_path = self.temp_file_path(&entry.pk);
// Si la conversion a échoué, supprimer l'entrée
if entry.conversion_status == "failed" {
self.delete(&entry.pk).await?;
continue;
}
// Si le fichier FLAC devrait exister mais n'existe pas
if entry.conversion_status == "completed" && !flac_path.exists() {
self.db.delete(&entry.pk)?;
if temp_path.exists() {
tokio::fs::remove_file(&temp_path).await?;
}
}
}
// Supprimer les fichiers orphelins (sans entrée DB)
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if !path.is_file() {
continue;
}
// Ignorer le fichier de base de données
if path == self.dir.join("audio_cache.db") {
continue;
}
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
// Extraire le pk du nom de fichier
let pk = if file_name.ends_with(".flac") {
file_name.trim_end_matches(".flac")
} else if file_name.ends_with(".temp") {
file_name.trim_end_matches(".temp")
} else {
continue;
};
// Si l'entrée n'existe pas en DB, supprimer le fichier
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
Ok(())
}
/// Retourne le répertoire du cache
pub fn cache_dir(&self) -> String {
self.dir.to_string_lossy().to_string()
}
// Helpers privés
fn temp_file_path(&self, pk: &str) -> PathBuf {
self.dir.join(format!("{}.temp", pk))
}
fn flac_file_path(&self, pk: &str) -> PathBuf {
self.dir.join(format!("{}.flac", pk))
}
})
}
/// Fusionne les métadonnées du fichier avec les métadonnées externes
/// Crée un cache audio avec conversion FLAC automatique
///
/// Priorité aux métadonnées externes (source de confiance : Qobuz, etc.)
fn merge_metadata(file_meta: AudioMetadata, external_meta: AudioMetadata) -> AudioMetadata {
AudioMetadata {
title: external_meta.title.or(file_meta.title),
artist: external_meta.artist.or(file_meta.artist),
album: external_meta.album.or(file_meta.album),
year: external_meta.year.or(file_meta.year),
track_number: external_meta.track_number.or(file_meta.track_number),
track_total: external_meta.track_total.or(file_meta.track_total),
disc_number: external_meta.disc_number.or(file_meta.disc_number),
disc_total: external_meta.disc_total.or(file_meta.disc_total),
genre: external_meta.genre.or(file_meta.genre),
// Pour les infos techniques, on garde celles du fichier
duration_secs: file_meta.duration_secs.or(external_meta.duration_secs),
sample_rate: file_meta.sample_rate.or(external_meta.sample_rate),
channels: file_meta.channels.or(external_meta.channels),
bitrate: file_meta.bitrate.or(external_meta.bitrate),
}
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre de pistes)
/// * `base_url` - URL de base pour la génération d'URLs
///
/// # Returns
///
/// Instance du cache configurée pour la conversion FLAC automatique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080").unwrap();
/// ```
pub fn new_cache(dir: &str, limit: usize, base_url: &str) -> Result<Cache> {
let transformer_factory = Arc::new(|| create_flac_transformer());
Cache::with_transformer(dir, limit, base_url, Some(transformer_factory))
}
/// Ajoute une piste audio depuis une URL avec extraction et stockage des métadonnées
///
/// Cette fonction étend `add_from_url` du cache en ajoutant :
/// 1. Téléchargement et conversion FLAC (via transformer)
/// 2. Extraction et stockage des métadonnées en JSON dans la DB
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `url` - URL du fichier audio
/// * `collection` - Collection optionnelle (ex: "pink_floyd:wish_you_were_here")
///
/// # Returns
///
/// Clé primaire (pk) du fichier ajouté
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// # async fn example() -> anyhow::Result<()> {
/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
/// let pk = cache::add_with_metadata_extraction(
/// &cache,
/// "http://example.com/track.flac",
/// Some("artist:album")
/// ).await?;
/// # Ok(())
/// # }
/// ```
pub async fn add_with_metadata_extraction(
cache: &Cache,
url: &str,
collection: Option<&str>,
) -> Result<String> {
// Ajouter au cache (déclenche le download et la conversion)
let pk = cache.add_from_url(url, collection).await?;
// Attendre que le fichier soit téléchargé et converti
cache.wait_until_finished(&pk).await?;
// Lire le fichier FLAC pour extraire les métadonnées
let file_path = cache.file_path(&pk);
let flac_bytes = tokio::fs::read(&file_path).await?;
// Extraire les métadonnées
let metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?;
// Sérialiser en JSON
let metadata_json = serde_json::to_string(&metadata)?;
// Stocker dans la DB
cache.db.update_metadata(&pk, &metadata_json)
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
// Mettre à jour la collection si les métadonnées en fournissent une
if collection.is_none() {
if let Some(auto_collection) = metadata.collection_key() {
cache.db.add(&pk, url, Some(&auto_collection))
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
}
}
Ok(pk)
}
/// Récupère les métadonnées audio d'un fichier en cache
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `pk` - Clé primaire du fichier
///
/// # Returns
///
/// Les métadonnées audio désérialisées depuis le JSON stocké en DB
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::cache;
///
/// # async fn example(cache: &pmoaudiocache::cache::Cache, pk: &str) -> anyhow::Result<()> {
/// let metadata = cache::get_metadata(cache, pk)?;
/// println!("Title: {:?}", metadata.title);
/// println!("Artist: {:?}", metadata.artist);
/// # Ok(())
/// # }
/// ```
pub fn get_metadata(cache: &Cache, pk: &str) -> Result<crate::metadata::AudioMetadata> {
let metadata_json = cache.db.get_metadata_json(pk)
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?
.ok_or_else(|| anyhow::anyhow!("No metadata found for pk: {}", pk))?;
let metadata: crate::metadata::AudioMetadata = serde_json::from_str(&metadata_json)
.map_err(|e| anyhow::anyhow!("Metadata deserialization error: {}", e))?;
Ok(metadata)
}