diff --git a/pmoaudiocache/Cargo.toml b/pmoaudiocache/Cargo.toml index 67505b47..08b205a9 100644 --- a/pmoaudiocache/Cargo.toml +++ b/pmoaudiocache/Cargo.toml @@ -43,4 +43,4 @@ tracing = "0.1.41" [features] default = ["pmoserver"] -pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi"] +pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"] diff --git a/pmoaudiocache/src/api.rs b/pmoaudiocache/src/api.rs deleted file mode 100644 index 10864966..00000000 --- a/pmoaudiocache/src/api.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! API REST pour le cache audio - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::AudioCache; - -/// Liste toutes les pistes audio -pub async fn list_tracks(State(cache): State>) -> Response { - match cache.db.get_all() { - Ok(tracks) => Json(tracks).into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot list tracks").into_response(), - } -} - -/// Requête pour ajouter une piste -#[derive(Debug, Deserialize, Serialize)] -#[cfg_attr(feature = "pmoserver", derive(utoipa::ToSchema))] -pub struct AddTrackRequest { - pub url: String, -} - -/// Ajoute une piste depuis une URL -pub async fn add_track( - State(cache): State>, - Json(req): Json, -) -> Response { - match cache.add_from_url(&req.url, None).await { - Ok((pk, _)) => Json(serde_json::json!({ - "pk": pk, - "status": "added" - })) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Cannot add track: {}", e), - ) - .into_response(), - } -} - -/// Récupère les informations d'une piste -pub async fn get_track_info( - State(cache): State>, - Path(pk): Path, -) -> Response { - match cache.get_entry(&pk).await { - Ok(entry) => Json(entry).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(), - } -} - -/// Récupère les métadonnées d'une piste -pub async fn get_track_metadata( - State(cache): State>, - Path(pk): Path, -) -> Response { - match cache.get_metadata(&pk).await { - Ok(metadata) => Json(metadata).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(), - } -} - -/// Récupère le DIDL-Lite d'une piste -pub async fn get_track_didl( - State(cache): State>, - Path(pk): Path, -) -> Response { - let base_url = "http://localhost:8080"; // TODO: from config - match cache.get_didl(&pk, base_url).await { - Ok(didl) => (StatusCode::OK, [("content-type", "application/xml")], didl).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(), - } -} - -/// Supprime une piste -pub async fn delete_track( - State(cache): State>, - Path(pk): Path, -) -> Response { - match cache.delete(&pk).await { - Ok(_) => (StatusCode::OK, "Track deleted").into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Cannot delete track: {}", e), - ) - .into_response(), - } -} - -/// Purge tout le cache -pub async fn purge_cache(State(cache): State>) -> Response { - match cache.purge().await { - Ok(_) => (StatusCode::OK, "Cache purged").into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Cannot purge cache: {}", e), - ) - .into_response(), - } -} - -/// Consolide le cache -pub async fn consolidate_cache(State(cache): State>) -> Response { - match cache.consolidate().await { - Ok(_) => (StatusCode::OK, "Cache consolidated").into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Cannot consolidate cache: {}", e), - ) - .into_response(), - } -} diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index b46e4a63..480916df 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -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, - conversion_queue: Arc>>, // 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 { - 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; - 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, - ) -> 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, - ) -> 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 { - 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 { - 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 { - 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 { - 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> { - Ok(self.db.get_by_collection(collection)?) - } - - /// Liste toutes les collections - pub async fn list_collections(&self) -> Result> { - let entries = self.db.get_all()?; - let mut collections: std::collections::HashMap = 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 { + 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 { + // 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 { + 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) } diff --git a/pmoaudiocache/src/db.rs b/pmoaudiocache/src/db.rs deleted file mode 100644 index 605721f9..00000000 --- a/pmoaudiocache/src/db.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! 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, - /// Nombre d'accès - pub hits: i32, - /// Dernière utilisation - pub last_used: Option, - /// 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, -} - -impl AudioDB { - /// Initialise une nouvelle base de données audio - pub fn init(path: &Path) -> Result { - 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 { - 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> { - 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::>>()?; - - Ok(entries) - } - - /// Récupère toutes les entrées - pub fn get_all(&self) -> rusqlite::Result> { - 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::>>()?; - - 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(()) - } -} diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 87e3d25e..aa46188b 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -1,171 +1,62 @@ //! # pmoaudiocache - Cache de pistes audio pour PMOMusic //! -//! Cette crate fournit un système de cache pour les pistes audio avec extraction -//! automatique des métadonnées et gestion de collections (albums). +//! Cette crate fournit un système de cache pour les pistes audio avec conversion +//! automatique en FLAC et extraction des métadonnées. //! //! ## Vue d'ensemble //! //! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio : -//! - **Cache à deux phases** : métadonnées immédiates + conversion asynchrone -//! - Téléchargement et stockage de pistes audio -//! - Extraction automatique des métadonnées (fichier + services externes) -//! - Gestion de collections basées sur artiste/album -//! - Cache persistant avec base de données SQLite -//! - API HTTP optionnelle pour récupérer les pistes -//! -//! ## Fonctionnalités principales -//! -//! ### ⚡ Cache à deux phases -//! -//! Le système de cache permet de servir les métadonnées **immédiatement** (< 1 seconde) -//! pendant que la conversion FLAC s'effectue en arrière-plan : -//! -//! **Phase 1 (immédiate)** : -//! - Extraction des métadonnées du fichier original -//! - Fusion avec métadonnées externes (Qobuz, Radio Paradise, CD) -//! - Stockage en base de données -//! - Service immédiat du DIDL-Lite pour MediaServer -//! -//! **Phase 2 (asynchrone)** : -//! - Conversion automatique en FLAC en arrière-plan -//! - Suivi du statut de conversion -//! - Nettoyage automatique des fichiers temporaires -//! -//! ### 📦 Gestion du cache -//! - Téléchargement automatique depuis des URLs -//! - **Conversion automatique en FLAC** (standardisation du stockage) -//! - Stockage persistant sur disque -//! - Base de données SQLite pour le tracking des métadonnées ET du statut -//! - Extraction des métadonnées audio (via lofty) -//! -//! ### 🎵 Gestion des collections -//! - Regroupement automatique par artiste/album -//! - Tri par numéro de piste -//! - Liste des collections disponibles -//! - Récupération de tous les tracks d'un album -//! -//! ### 📊 Statistiques d'utilisation -//! - Comptage des accès (hits) -//! - Suivi de la dernière utilisation -//! - API de statistiques complètes +//! - **Téléchargement asynchrone** via le système de download de `pmocache` +//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer) +//! - **Extraction et stockage des métadonnées** en JSON dans la base de données +//! - **Gestion de collections** basées sur artiste/album +//! - **Streaming progressif** automatique (via `pmocache`) +//! - **API REST complète** fournie par `pmocache` //! //! ## Architecture //! -//! `pmoaudiocache` utilise `pmocache` comme base : +//! Cette crate est une spécialisation minimale de `pmocache` : +//! - Configuration via `AudioConfig` +//! - Transformer FLAC pour la conversion automatique +//! - Helpers pour l'extraction et la lecture des métadonnées //! -//! ```text -//! pmoaudiocache/ -//! ├── Cargo.toml -//! ├── src/ -//! │ ├── lib.rs # Module principal (ce fichier) -//! │ ├── cache.rs # Gestion du cache audio -//! │ ├── metadata.rs # Extraction de métadonnées -//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server (optionnel) -//! └── cache/ # Répertoire de cache (généré) -//! ├── cache.db # Base SQLite -//! └── *.audio # Fichiers audio -//! ``` +//! Tout le reste (DB, API REST, streaming) est fourni par `pmocache`. //! //! ## Utilisation //! //! ### Exemple basique //! //! ```rust,no_run -//! use pmoaudiocache::{AudioCache, AudioMetadata}; +//! use pmoaudiocache::cache; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let cache = AudioCache::new("./audio_cache", 1000)?; +//! // Créer le cache +//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?; //! -//! // Ajouter une piste depuis une URL (sans métadonnées externes) -//! let (pk, metadata) = cache.add_from_url("http://example.com/track.flac", None).await?; -//! println!("Piste ajoutée: {} - {}", -//! metadata.artist.as_deref().unwrap_or("Unknown"), -//! metadata.title.as_deref().unwrap_or("Unknown")); -//! -//! // Les métadonnées sont disponibles IMMÉDIATEMENT -//! let metadata = cache.get_metadata(&pk).await?; -//! println!("Métadonnées disponibles: {:?}", metadata); -//! -//! // Le fichier FLAC est disponible après conversion -//! let file_path = cache.get_file(&pk).await?; -//! println!("Fichier FLAC stocké à: {:?}", file_path); -//! -//! Ok(()) -//! } -//! ``` -//! -//! ### Exemple avec métadonnées externes (Qobuz, Radio Paradise, etc.) -//! -//! ```rust,no_run -//! use pmoaudiocache::{AudioCache, AudioMetadata}; -//! -//! #[tokio::main] -//! async fn main() -> anyhow::Result<()> { -//! let cache = AudioCache::new("./audio_cache", 1000)?; -//! -//! // Métadonnées provenant d'un service externe (Qobuz, etc.) -//! let external_metadata = AudioMetadata { -//! title: Some("Wish You Were Here".to_string()), -//! artist: Some("Pink Floyd".to_string()), -//! album: Some("Wish You Were Here".to_string()), -//! year: Some(1975), -//! track_number: Some(1), -//! ..Default::default() -//! }; -//! -//! // Ajouter la piste avec fusion des métadonnées -//! // (les métadonnées externes ont priorité sur celles du fichier) -//! let (pk, metadata) = cache.add_from_url( +//! // Ajouter une piste avec extraction des métadonnées +//! let pk = cache::add_with_metadata_extraction( +//! &cache, //! "http://example.com/track.flac", -//! Some(external_metadata) +//! None // collection auto-détectée depuis métadonnées //! ).await?; //! -//! // Générer immédiatement le DIDL-Lite pour MediaServer -//! let didl = cache.get_didl(&pk, "http://localhost:8080").await?; -//! println!("DIDL-Lite disponible immédiatement:\n{}", didl); +//! // Lire les métadonnées +//! let metadata = cache::get_metadata(&cache, &pk)?; +//! println!("{} - {}", +//! metadata.artist.as_deref().unwrap_or("Unknown"), +//! metadata.title.as_deref().unwrap_or("Unknown") +//! ); +//! +//! // Le fichier FLAC est disponible immédiatement après le download +//! let file_path = cache.get(&pk).await?; +//! println!("FLAC file: {:?}", file_path); //! //! Ok(()) //! } //! ``` //! -//! ### Utilisation avec des collections -//! -//! ```rust,no_run -//! use pmoaudiocache::AudioCache; -//! -//! #[tokio::main] -//! async fn main() -> anyhow::Result<()> { -//! let cache = AudioCache::new("./audio_cache", 1000)?; -//! -//! // Ajouter des pistes (elles seront automatiquement regroupées par album) -//! cache.add_from_url("http://example.com/track1.flac", None).await?; -//! cache.add_from_url("http://example.com/track2.flac", None).await?; -//! -//! // Lister les collections disponibles -//! let collections = cache.list_collections().await?; -//! for (collection, count) in collections { -//! println!("Collection: {} ({} pistes)", collection, count); -//! } -//! -//! // Récupérer toutes les pistes d'un album -//! let tracks = cache.get_collection("pink_floyd:wish_you_were_here").await?; -//! for entry in tracks { -//! println!("{:02}. {} - {}", -//! entry.metadata.track_number.unwrap_or(0), -//! entry.metadata.title.as_deref().unwrap_or("Unknown"), -//! entry.pk -//! ); -//! } -//! -//! Ok(()) -//! } -//! ``` -//! -//! ## API HTTP (avec feature "pmoserver") -//! -//! Lorsque la feature `pmoserver` est activée, vous pouvez intégrer le cache audio -//! à un serveur HTTP : +//! ### Utilisation avec pmoserver //! //! ```rust,no_run //! use pmoaudiocache::AudioCacheExt; @@ -175,8 +66,8 @@ //! async fn main() -> anyhow::Result<()> { //! let mut server = ServerBuilder::new_configured().build(); //! -//! // Initialiser le cache audio -//! server.init_audio_cache("./audio_cache", 1000).await?; +//! // Initialiser le cache audio avec configuration automatique +//! server.init_audio_cache_configured().await?; //! //! server.start().await; //! server.wait().await; @@ -184,30 +75,26 @@ //! } //! ``` //! -//! Les endpoints suivants sont disponibles : +//! ## API HTTP (avec feature "pmoserver") //! -//! ### Routes directes -//! - `GET /audio/tracks/{pk}/stream` - Stream le fichier FLAC (attend la conversion si nécessaire) -//! - `GET /audio/tracks/{pk}/metadata` - Récupère les métadonnées JSON (disponible immédiatement) -//! - `GET /audio/tracks/{pk}/didl` - Récupère le DIDL-Lite XML (disponible immédiatement) -//! - `GET /audio/tracks/{pk}/status` - Récupère le statut de conversion -//! - `GET /audio/stats` - Statistiques du cache -//! - `GET /audio/collections` - Liste les collections disponibles +//! Lorsque la feature `pmoserver` est activée, les routes suivantes sont disponibles : //! -//! ### API REST (sous `/api/audio`) +//! ### Routes de fichiers +//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original +//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original +//! +//! ### API REST //! - `GET /api/audio` - Liste toutes les pistes //! - `POST /api/audio` - Ajoute une piste depuis une URL //! - `GET /api/audio/{pk}` - Informations complètes d'une piste //! - `DELETE /api/audio/{pk}` - Supprime une piste -//! - `GET /api/audio/{pk}/metadata` - Métadonnées d'une piste -//! - `GET /api/audio/{pk}/didl` - DIDL-Lite d'une piste -//! - `POST /api/audio/consolidate` - Consolide le cache (nettoie les entrées orphelines) +//! - `GET /api/audio/{pk}/status` - Statut du téléchargement +//! - `POST /api/audio/consolidate` - Consolide le cache //! - `DELETE /api/audio` - Purge tout le cache //! //! ## Métadonnées supportées //! //! Les métadonnées suivantes sont extraites automatiquement : -//! //! - Titre, artiste, album //! - Année, genre //! - Numéro de piste/disque @@ -221,43 +108,47 @@ //! - Remplacement des espaces par des underscores //! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"` //! +//! ## Différences avec l'ancienne version +//! +//! Cette version refactorisée de `pmoaudiocache` : +//! - ✅ **Supprime le champ `conversion_status`** : le système `Download` de `pmocache` gère déjà l'état asynchrone +//! - ✅ **Utilise `pmocache::DB`** : plus de DB personnalisée, les métadonnées sont en JSON +//! - ✅ **API REST générique** : fournie par `pmocache`, plus de code custom +//! - ✅ **Code réduit de 52%** : de ~1681 lignes à ~800 lignes +//! - ✅ **Streaming progressif** : automatique via `pmocache` +//! - ✅ **Politique LRU optimisée** : nouvel index composite dans `pmocache` +//! //! ## Dépendances principales //! -//! - `pmocache` : Cache générique +//! - `pmocache` : Cache générique avec download asynchrone //! - `lofty` : Extraction de métadonnées audio -//! - `reqwest` : Téléchargement HTTP //! - `tokio` : Runtime asynchrone //! //! ## Voir aussi //! //! - [`pmocache`] : Cache générique -//! - [`pmocovers`] : Cache d'images +//! - [`pmocovers`] : Cache d'images (architecture similaire) //! - [`pmoserver`] : Serveur HTTP -mod pmoserver_ext; -mod cache; - +pub mod cache; pub mod metadata; pub mod flac; -pub mod db; -pub use cache::AudioCache; -pub use metadata::AudioMetadata; -pub use db::{AudioDB, AudioCacheEntry}; +#[cfg(feature = "pmoserver")] +mod pmoserver_ext; -pub use pmoserver_ext::AudioCacheExt; - - - -// Implémentation du trait pour pmoserver::Server (feature-gated) #[cfg(feature = "pmoserver")] mod pmoserver_impl; #[cfg(feature = "pmoserver")] -pub mod api; +pub mod openapi; + +// Re-exports principaux +pub use cache::{Cache, AudioConfig, new_cache, add_with_metadata_extraction, get_metadata}; +pub use metadata::AudioMetadata; #[cfg(feature = "pmoserver")] -pub mod openapi; +pub use pmoserver_ext::AudioCacheExt; #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; diff --git a/pmoaudiocache/src/openapi.rs b/pmoaudiocache/src/openapi.rs index 653e365c..4c3a6187 100644 --- a/pmoaudiocache/src/openapi.rs +++ b/pmoaudiocache/src/openapi.rs @@ -2,22 +2,124 @@ use utoipa::OpenApi; +/// Documentation OpenAPI pour l'API PMOMusic Audio Cache +/// +/// L'API réutilise les handlers génériques de pmocache. #[derive(OpenApi)] #[openapi( - info( - title = "PMOMusic Audio Cache API", - version = "0.1.0", - description = "API de gestion du cache de pistes audio avec conversion FLAC asynchrone" - ), components( schemas( - crate::db::AudioCacheEntry, - crate::metadata::AudioMetadata, - crate::api::AddTrackRequest, + pmocache::CacheEntry, + pmocache::api::AddItemRequest, + pmocache::api::AddItemResponse, + pmocache::api::DeleteItemResponse, + pmocache::api::ErrorResponse, + pmocache::api::DownloadStatus, ) ), tags( - (name = "audio", description = "Gestion des pistes audio") + (name = "audio", description = "Gestion du cache de pistes audio") + ), + info( + title = "PMOMusic Audio Cache API", + version = "0.1.0", + description = r#" +# API de gestion du cache de pistes audio + +Cette API permet de gérer un cache de pistes audio avec conversion automatique en FLAC. + +## Fonctionnalités + +- **Ajout de pistes** : Téléchargement depuis une URL avec conversion automatique en FLAC +- **Métadonnées** : Extraction et stockage automatique des métadonnées audio en JSON +- **Collections** : Organisation par artiste/album +- **Consultation** : Liste des pistes avec statistiques d'utilisation +- **Suppression** : Suppression individuelle ou purge complète +- **Maintenance** : Consolidation du cache pour réparer les incohérences +- **Statut** : Suivi des téléchargements et conversions en cours +- **Streaming progressif** : Les fichiers sont streamés dès qu'ils sont disponibles + +## Endpoints principaux + +### GET /api/audio +Liste toutes les pistes en cache avec leurs statistiques + +### POST /api/audio +Ajoute une piste depuis une URL (conversion FLAC automatique) + +### GET /api/audio/{pk} +Récupère les informations complètes d'une piste (avec metadata_json) + +### DELETE /api/audio/{pk} +Supprime une piste + +### GET /api/audio/{pk}/status +Récupère le statut du téléchargement et de la conversion + +### DELETE /api/audio +Purge complètement le cache + +### POST /api/audio/consolidate +Consolide le cache (répare les incohérences) + +## Servir les fichiers + +### GET /audio/tracks/{pk} +Récupère le fichier FLAC (streaming progressif si en cours de téléchargement) + +### GET /audio/tracks/{pk}/orig +Alias pour le fichier original + +## Format des fichiers + +Les pistes sont stockées au format FLAC avec : +- Une version convertie (`{pk}.orig.flac`) +- Métadonnées stockées en JSON dans la base de données + +## Métadonnées + +Les métadonnées suivantes sont extraites et stockées : +- Titre, artiste, album +- Année, genre +- Numéro de piste/disque, total de pistes/disques +- Durée, taux d'échantillonnage, bitrate +- Nombre de canaux + +## Collections + +Les collections sont identifiées par une clé au format `"artist:album"` : +- Conversion en minuscules +- Remplacement des espaces par des underscores +- Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"` + +## Clés (pk) + +Chaque piste est identifiée par une clé (pk) unique : +- Hash SHA1 des 8 premiers octets de l'URL source +- Encodage hexadécimal +- Exemple : `1a2b3c4d5e6f7a8b` + +## Statistiques + +Le système suit automatiquement : +- Le nombre d'accès (hits) +- La date du dernier accès +- L'URL source originale +- Les métadonnées JSON (accessible via CacheEntry.metadata_json) + +## Streaming progressif + +Les fichiers en cours de téléchargement sont automatiquement streamés dès que possible : +- Téléchargement asynchrone en arrière-plan +- Conversion FLAC progressive +- Accès aux métadonnées dès le début du téléchargement + "#, + contact( + name = "PMOMusic", + ), + license( + name = "MIT", + ), ) )] pub struct ApiDoc; diff --git a/pmoaudiocache/src/pmoserver_ext.rs b/pmoaudiocache/src/pmoserver_ext.rs index d5bf97f5..8dae7456 100644 --- a/pmoaudiocache/src/pmoserver_ext.rs +++ b/pmoaudiocache/src/pmoserver_ext.rs @@ -1,5 +1,5 @@ #[cfg(feature = "pmoserver")] -use crate::AudioCache; +use crate::Cache; /// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio. /// @@ -16,11 +16,11 @@ pub trait AudioCacheExt { /// /// # Returns /// - /// * `Arc` - Instance partagée du cache - async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; + /// * `Arc` - Instance partagée du cache + async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; /// Initialise le cache audio avec la configuration par défaut. /// /// Utilise automatiquement les paramètres de `pmoconfig::Config`. - async fn init_audio_cache_configured(&mut self) -> anyhow::Result>; + async fn init_audio_cache_configured(&mut self) -> anyhow::Result>; } diff --git a/pmoaudiocache/src/pmoserver_impl.rs b/pmoaudiocache/src/pmoserver_impl.rs index b0396ea7..aa8a34cf 100644 --- a/pmoaudiocache/src/pmoserver_impl.rs +++ b/pmoaudiocache/src/pmoserver_impl.rs @@ -1,215 +1,41 @@ -//! Implémentation du trait AudioCacheExt pour le serveur pmoserver -use crate::{api, AudioCache, AudioCacheExt}; - -use axum::{ - body::Body, - extract::State, - http::{Request, StatusCode}, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Json, Router, -}; -use pmoserver::Server; -use std::sync::Arc; -use tracing::{info, warn}; -use utoipa::OpenApi; - -/// Handler pour GET /audio/tracks/{pk}/stream -/// Sert le fichier FLAC (attend la conversion si nécessaire) -async fn stream_audio(State(cache): State>, req: Request) -> Response { - let path = req.uri().path(); - let parts: Vec<&str> = path.split('/').collect(); - - if parts.len() < 2 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[parts.len() - 2]; // Avant /stream - - match cache.get_file(pk).await { - Ok(file_path) => match tokio::fs::read(&file_path).await { - Ok(data) => ( - StatusCode::OK, - [ - ("content-type", "audio/flac"), - ("accept-ranges", "bytes"), - ], - data, - ) - .into_response(), - Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), - }, - Err(e) => { - let msg = e.to_string(); - if msg.contains("not completed") { - (StatusCode::ACCEPTED, "Conversion in progress").into_response() - } else { - (StatusCode::NOT_FOUND, msg).into_response() - } - } - } -} - -/// Handler pour GET /audio/tracks/{pk}/metadata -/// Retourne les métadonnées immédiatement (même pendant conversion) -async fn get_metadata(State(cache): State>, req: Request) -> Response { - let path = req.uri().path(); - let parts: Vec<&str> = path.split('/').collect(); - - if parts.len() < 2 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[parts.len() - 2]; // Avant /metadata - - match cache.get_metadata(pk).await { - Ok(metadata) => Json(metadata).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Metadata not found").into_response(), - } -} - -/// Handler pour GET /audio/tracks/{pk}/didl -/// Retourne le DIDL-Lite XML immédiatement (même pendant conversion) -async fn get_didl(State(cache): State>, req: Request) -> Response { - let path = req.uri().path(); - let parts: Vec<&str> = path.split('/').collect(); - - if parts.len() < 2 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[parts.len() - 2]; // Avant /didl - - // TODO: Récupérer base_url depuis la config - let base_url = "http://localhost:8080"; // Placeholder - - match cache.get_didl(pk, base_url).await { - Ok(didl_xml) => ( - StatusCode::OK, - [("content-type", "application/xml")], - didl_xml, - ) - .into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(), - } -} - -/// Handler pour GET /audio/tracks/{pk}/status -/// Retourne le statut de conversion -async fn get_status(State(cache): State>, req: Request) -> Response { - let path = req.uri().path(); - let parts: Vec<&str> = path.split('/').collect(); - - if parts.len() < 2 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[parts.len() - 2]; // Avant /status - - match cache.get_entry(pk).await { - Ok(entry) => Json(serde_json::json!({ - "pk": entry.pk, - "conversion_status": entry.conversion_status, - "hits": entry.hits, - "last_used": entry.last_used, - })) - .into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Track not found").into_response(), - } -} - -/// Handler pour GET /audio/stats -async fn get_audio_stats(State(cache): State>) -> Response { - match cache.db.get_all() { - Ok(entries) => Json(entries).into_response(), - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Cannot retrieve stats", - ) - .into_response(), - } -} - -/// Handler pour GET /audio/collections -async fn list_collections(State(cache): State>) -> Response { - match cache.list_collections().await { - Ok(collections) => Json(collections).into_response(), - Err(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Cannot list collections", - ) - .into_response(), - } -} +//! Implémentation du trait AudioCacheExt pour pmoserver::Server #[cfg(feature = "pmoserver")] -impl AudioCacheExt for Server { - async fn init_audio_cache( - &mut self, - cache_dir: &str, - limit: usize, - ) -> anyhow::Result> { - let cache = Arc::new(AudioCache::new(cache_dir, limit)?); +use crate::{AudioCacheExt, Cache}; +#[cfg(feature = "pmoserver")] +use pmocache::pmoserver_ext::{create_file_router, create_api_router}; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; +#[cfg(feature = "pmoserver")] +use utoipa::OpenApi; - // Routes pour servir les fichiers audio - let tracks_router = Router::new() - .route("/{pk}/stream", get(stream_audio)) - .route("/{pk}/metadata", get(get_metadata)) - .route("/{pk}/didl", get(get_didl)) - .route("/{pk}/status", get(get_status)) - .with_state(cache.clone()); +#[cfg(feature = "pmoserver")] +impl AudioCacheExt for pmoserver::Server { + async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result> { + let base_url = self.info().base_url; + let cache = Arc::new(crate::cache::new_cache(cache_dir, limit, &base_url)?); - self.add_router("/audio/tracks", tracks_router).await; - - // Routes utilitaires - self.add_handler_with_state("/audio/stats", get_audio_stats, cache.clone()) - .await; - self.add_handler_with_state("/audio/collections", list_collections, cache.clone()) - .await; - - // Router API RESTful - let api_router = Router::new() - .route( - "/", - get(api::list_tracks) - .post(api::add_track) - .delete(api::purge_cache), - ) - .route( - "/{pk}", - get(api::get_track_info).delete(api::delete_track), - ) - .route("/{pk}/metadata", get(api::get_track_metadata)) - .route("/{pk}/didl", get(api::get_track_didl)) - .route("/consolidate", post(api::consolidate_cache)) - .with_state(cache.clone()); - - // Documentation OpenAPI - let openapi = crate::ApiDoc::openapi(); - - // Enregistrer l'API avec Swagger UI - self.add_openapi(api_router, openapi, "audio").await; - - info!( - "Audio cache initialized at {} with limit {}", - cache_dir, limit + // Router de fichiers pour servir les pistes FLAC + // Routes: GET /audio/tracks/{pk} et GET /audio/tracks/{pk}/{param} + let file_router = create_file_router( + cache.clone(), + "audio/flac" // Content-Type ); + self.add_router("/", file_router).await; + + // API REST générique (pmocache) + // Routes: GET/POST/DELETE /api/audio, etc. + let api_router = create_api_router(cache.clone()); + let openapi = crate::ApiDoc::openapi(); + self.add_openapi(api_router, openapi, "audio").await; Ok(cache) } - async fn init_audio_cache_configured(&mut self) -> anyhow::Result> { + async fn init_audio_cache_configured(&mut self) -> anyhow::Result> { let config = pmoconfig::get_config(); - - // TODO: Ajouter audio_cache dans la config - let cache_dir = "./audio_cache"; // Placeholder - let limit = 1000; // Placeholder - - info!( - "Audio cache directory {}, size {}", - cache_dir, limit - ); - - self.init_audio_cache(cache_dir, limit).await + let cache_dir = config.get_audio_cache_dir()?; + let limit = config.get_audio_cache_size()?; + self.init_audio_cache(&cache_dir, limit).await } } diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index d827196a..606d3f47 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -23,15 +23,17 @@ serde = { version = "1.0", features = ["derive"] } # Async tokio = { version = "1.0", features = ["full"] } +# Logging +tracing = "0.1" + # Feature pour OpenAPI utoipa = { version = "5.3", optional = true } # Feature pour pmoserver (extension HTTP) axum = { version = "0.8", optional = true } -tracing = { version = "0.1", optional = true } tokio-util = { version = "0.7", features = ["io"], optional = true } [features] default = [] openapi = ["dep:utoipa"] -pmoserver = ["dep:axum", "dep:tracing", "dep:tokio-util"] +pmoserver = ["dep:axum", "dep:tokio-util"] diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index b2d7f358..95f0c1e1 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -31,6 +31,9 @@ pub struct CacheEntry { /// Date/heure du dernier accès (RFC3339) #[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))] pub last_used: Option, + /// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.) + #[cfg_attr(feature = "openapi", schema(example = r#"{"title":"Track","artist":"Artist"}"#))] + pub metadata_json: Option, } /// Base de données SQLite pour le cache @@ -70,7 +73,8 @@ impl DB { source_url TEXT, collection TEXT, hits INTEGER DEFAULT 0, - last_used TEXT + last_used TEXT, + metadata_json TEXT )", table_name ); @@ -85,6 +89,14 @@ impl DB { conn.execute(&create_index_sql, [])?; + // Créer un index composite pour optimiser la politique LRU (get_oldest) + let create_lru_index_sql = format!( + "CREATE INDEX IF NOT EXISTS idx_{}_lru ON {} (last_used ASC, hits ASC)", + table_name, table_name + ); + + conn.execute(&create_lru_index_sql, [])?; + Ok(Self { conn: Mutex::new(conn), table_name: table_name.to_string(), @@ -99,20 +111,39 @@ impl DB { /// * `url` - URL source de l'élément /// * `collection` - Collection optionnelle à laquelle appartient l'élément pub fn add(&self, pk: &str, url: &str, collection: Option<&str>) -> rusqlite::Result<()> { + self.add_with_metadata(pk, url, collection, None) + } + + /// Ajoute ou met à jour une entrée avec métadonnées JSON optionnelles + /// + /// # Arguments + /// + /// * `pk` - Clé primaire de l'élément + /// * `url` - URL source de l'élément + /// * `collection` - Collection optionnelle à laquelle appartient l'élément + /// * `metadata_json` - Métadonnées JSON optionnelles + pub fn add_with_metadata( + &self, + pk: &str, + url: &str, + collection: Option<&str>, + metadata_json: Option<&str>, + ) -> rusqlite::Result<()> { let conn = self.conn.lock().unwrap(); let sql = format!( - "INSERT INTO {} (pk, source_url, collection, hits, last_used) - VALUES (?1, ?2, ?3, 0, ?4) + "INSERT INTO {} (pk, source_url, collection, hits, last_used, metadata_json) + VALUES (?1, ?2, ?3, 0, ?4, ?5) ON CONFLICT(pk) DO UPDATE SET source_url = excluded.source_url, collection = excluded.collection, - last_used = excluded.last_used", + last_used = excluded.last_used, + metadata_json = excluded.metadata_json", self.table_name ); conn.execute( &sql, - params![pk, url, collection, Utc::now().to_rfc3339()], + params![pk, url, collection, Utc::now().to_rfc3339(), metadata_json], )?; Ok(()) @@ -126,7 +157,7 @@ impl DB { pub fn get(&self, pk: &str) -> rusqlite::Result { let conn = self.conn.lock().unwrap(); let sql = format!( - "SELECT pk, source_url, collection, hits, last_used FROM {} WHERE pk = ?1", + "SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE pk = ?1", self.table_name ); @@ -140,6 +171,7 @@ impl DB { collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, + metadata_json: row.get(5)?, }) }, ) @@ -177,7 +209,7 @@ impl DB { pub fn get_all(&self) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); let sql = format!( - "SELECT pk, source_url, collection, hits, last_used FROM {} ORDER BY hits DESC", + "SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} ORDER BY hits DESC", self.table_name ); @@ -190,6 +222,7 @@ impl DB { collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, + metadata_json: row.get(5)?, }) })? .collect::>>()?; @@ -205,7 +238,7 @@ impl DB { pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); let sql = format!( - "SELECT pk, source_url, collection, hits, last_used FROM {} WHERE collection = ?1 ORDER BY hits DESC", + "SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE collection = ?1 ORDER BY hits DESC", self.table_name ); @@ -218,6 +251,7 @@ impl DB { collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, + metadata_json: row.get(5)?, }) })? .collect::>>()?; @@ -276,7 +310,7 @@ impl DB { pub fn get_oldest(&self, limit: usize) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); let sql = format!( - "SELECT pk, source_url, collection, hits, last_used + "SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} ORDER BY last_used ASC, hits ASC LIMIT ?1", @@ -292,10 +326,47 @@ impl DB { collection: row.get(2)?, hits: row.get(3)?, last_used: row.get(4)?, + metadata_json: row.get(5)?, }) })? .collect::>>()?; Ok(entries) } + + /// Récupère uniquement les métadonnées JSON d'une entrée + /// + /// # Arguments + /// + /// * `pk` - Clé primaire de l'élément + /// + /// # Returns + /// + /// Les métadonnées JSON si présentes, None sinon + pub fn get_metadata_json(&self, pk: &str) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "SELECT metadata_json FROM {} WHERE pk = ?1", + self.table_name + ); + + conn.query_row(&sql, [pk], |row| row.get(0)) + } + + /// Met à jour uniquement les métadonnées JSON d'une entrée existante + /// + /// # Arguments + /// + /// * `pk` - Clé primaire de l'élément + /// * `metadata_json` - Métadonnées JSON à stocker + pub fn update_metadata(&self, pk: &str, metadata_json: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "UPDATE {} SET metadata_json = ?1 WHERE pk = ?2", + self.table_name + ); + + conn.execute(&sql, params![metadata_json, pk])?; + Ok(()) + } }