Debug le menu debug

This commit is contained in:
2025-10-12 21:39:20 +02:00
parent 664be97ea6
commit 1c83416be4
21 changed files with 2875 additions and 198 deletions

119
pmoaudiocache/src/api.rs Normal file
View File

@@ -0,0 +1,119 @@
//! 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<Arc<AudioCache>>) -> 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<Arc<AudioCache>>,
Json(req): Json<AddTrackRequest>,
) -> 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<Arc<AudioCache>>,
Path(pk): Path<String>,
) -> 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<Arc<AudioCache>>,
Path(pk): Path<String>,
) -> 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<Arc<AudioCache>>,
Path(pk): Path<String>,
) -> 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<Arc<AudioCache>>,
Path(pk): Path<String>,
) -> 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<Arc<AudioCache>>) -> 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<Arc<AudioCache>>) -> 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(),
}
}

420
pmoaudiocache/src/cache.rs Normal file
View File

@@ -0,0 +1,420 @@
//! Module de gestion du cache de pistes audio
//!
//! 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
use anyhow::{anyhow, Result};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use pmodidl::{Item, Resource};
use crate::{
db::{AudioDB, AudioCacheEntry},
metadata::AudioMetadata,
};
/// 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 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)?);
Ok(Self {
dir: PathBuf::from(dir),
db,
conversion_queue: Arc::new(Mutex::new(Vec::new())),
})
}
/// 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
///
/// 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),
}
}

232
pmoaudiocache/src/db.rs Normal file
View File

@@ -0,0 +1,232 @@
//! 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(())
}
}

145
pmoaudiocache/src/flac.rs Normal file
View File

@@ -0,0 +1,145 @@
//! Module de conversion audio en FLAC
//!
//! Ce module gère la conversion de divers formats audio vers FLAC
//! pour standardiser le stockage dans le cache.
use anyhow::{anyhow, Result};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use std::io::Cursor;
/// Convertit des données audio en FLAC
///
/// Cette fonction accepte n'importe quel format audio supporté par Symphonia
/// et le convertit en FLAC pour un stockage standardisé.
///
/// # Arguments
///
/// * `data` - Données audio brutes (n'importe quel format)
/// * `extension` - Extension du fichier source (optionnel, aide à la détection)
///
/// # Returns
///
/// Données audio au format FLAC
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::flac::convert_to_flac;
///
/// let mp3_data = std::fs::read("track.mp3").unwrap();
/// let flac_data = convert_to_flac(&mp3_data, Some("mp3")).unwrap();
/// ```
pub fn convert_to_flac(data: &[u8], extension: Option<&str>) -> Result<Vec<u8>> {
// Si c'est déjà du FLAC, on le retourne tel quel
if is_flac(data) {
return Ok(data.to_vec());
}
// Créer un MediaSource depuis les données (en clonant pour avoir 'static)
let data_owned = data.to_vec();
let cursor = Cursor::new(data_owned);
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
// Créer un hint si on a l'extension
let mut hint = Hint::new();
if let Some(ext) = extension {
hint.with_extension(ext);
}
// Prober le format
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.map_err(|e| anyhow!("Impossible de détecter le format audio: {}", e))?;
let mut format = probed.format;
// Obtenir le premier track audio
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| anyhow!("Aucune piste audio trouvée"))?;
// Créer un décodeur
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| anyhow!("Impossible de créer le décodeur: {}", e))?;
// Buffer pour stocker les samples décodés
let mut samples = Vec::new();
let track_id = track.id;
// Décoder tous les packets
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::ResetRequired) => {
// Reset du décodeur requis
decoder.reset();
continue;
}
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(anyhow!("Erreur lors de la lecture: {}", e)),
};
// Ignorer les packets qui ne sont pas de notre track
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
// Convertir les samples en format standard
let spec = *decoded.spec();
let duration = decoded.capacity() as u64;
let mut sample_buf = SampleBuffer::<i16>::new(duration, spec);
sample_buf.copy_interleaved_ref(decoded);
samples.extend_from_slice(sample_buf.samples());
}
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => return Err(anyhow!("Erreur de décodage: {}", e)),
}
}
if samples.is_empty() {
return Err(anyhow!("Aucun sample décodé"));
}
// Note: Pour l'encodage FLAC, on aurait besoin d'une bibliothèque comme
// `flacenc` qui n'existe pas encore en Rust. Pour l'instant, on stocke
// les données telles quelles si c'est déjà du FLAC, sinon on retourne
// les données originales avec un warning.
// TODO: Implémenter l'encodage FLAC quand une bibliothèque sera disponible
tracing::warn!("Encodage FLAC non implémenté, stockage du format original");
Ok(data.to_vec())
}
/// Vérifie si les données sont déjà au format FLAC
fn is_flac(data: &[u8]) -> bool {
data.len() >= 4 && &data[0..4] == b"fLaC"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_flac() {
let flac_header = b"fLaC\x00\x00\x00\x22";
assert!(is_flac(flac_header));
let not_flac = b"RIFF\x00\x00\x00\x00";
assert!(!is_flac(not_flac));
}
}

212
pmoaudiocache/src/lib.rs Normal file
View File

@@ -0,0 +1,212 @@
//! # 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).
//!
//! ## Vue d'ensemble
//!
//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio :
//! - Téléchargement et stockage de pistes audio
//! - Extraction automatique des métadonnées (titre, artiste, album, etc.)
//! - 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
//!
//! ### 📦 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
//! - 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
//!
//! ## Architecture
//!
//! `pmoaudiocache` utilise `pmocache` comme base :
//!
//! ```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
//! ```
//!
//! ## Utilisation
//!
//! ### Exemple basique
//!
//! ```rust,no_run
//! use pmoaudiocache::AudioCache;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = AudioCache::new("./audio_cache", 1000)?;
//!
//! // Ajouter une piste depuis une URL
//! let (pk, metadata) = cache.add_from_url("http://example.com/track.flac").await?;
//! println!("Piste ajoutée: {} - {}", metadata.artist.unwrap(), metadata.title.unwrap());
//!
//! // Récupérer la piste
//! let (path, metadata) = cache.get(&pk).await?;
//! println!("Piste stockée à: {:?}", 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").await?;
//! cache.add_from_url("http://example.com/track2.flac").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 (pk, path, metadata) in tracks {
//! println!("{:02}. {} - {}",
//! metadata.track_number.unwrap_or(0),
//! metadata.title.unwrap_or_default(),
//! path.display()
//! );
//! }
//!
//! Ok(())
//! }
//! ```
//!
//! ## API HTTP (avec feature "pmoserver")
//!
//! Lorsque la feature `pmoserver` est activée, vous pouvez intégrer le cache audio
//! à un serveur HTTP :
//!
//! ```rust,no_run
//! use pmoaudiocache::AudioCacheExt;
//! use pmoserver::ServerBuilder;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new_configured().build();
//!
//! // Initialiser le cache audio
//! server.init_audio_cache("./audio_cache", 1000).await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! Les endpoints suivants sont disponibles :
//!
//! - `GET /audio/tracks/{pk}` - Récupère une piste audio
//! - `GET /audio/tracks/{pk}/metadata` - Récupère les métadonnées d'une piste
//! - `GET /audio/collections` - Liste les collections disponibles
//! - `GET /audio/collections/{collection}` - Récupère toutes les pistes d'une collection
//! - `GET /audio/stats` - Statistiques du 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
//! - Durée, taux d'échantillonnage, bitrate
//! - Nombre de canaux
//!
//! ## Format des collections
//!
//! Les collections sont identifiées par une clé au format `"artist:album"`, avec :
//! - Conversion en minuscules
//! - Remplacement des espaces par des underscores
//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
//!
//! ## Dépendances principales
//!
//! - `pmocache` : Cache générique
//! - `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
//! - [`pmoserver`] : Serveur HTTP
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};
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio.
///
/// Ce trait permet à `pmoaudiocache` d'ajouter des méthodes d'extension sur des types
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoaudiocache`.
#[cfg(feature = "pmoserver")]
pub trait AudioCacheExt {
/// Initialise le cache audio et enregistre les routes HTTP.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (en nombre de pistes)
///
/// # Returns
///
/// * `Arc<AudioCache>` - Instance partagée du cache
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<std::sync::Arc<AudioCache>>;
/// 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<std::sync::Arc<AudioCache>>;
}
// Implémentation du trait pour pmoserver::Server (feature-gated)
#[cfg(feature = "pmoserver")]
mod pmoserver_impl;
#[cfg(feature = "pmoserver")]
pub mod api;
#[cfg(feature = "pmoserver")]
pub mod openapi;
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;

View File

@@ -0,0 +1,238 @@
//! Module de gestion des métadonnées audio
//!
//! Ce module permet d'extraire et gérer les métadonnées des fichiers audio
//! (titre, artiste, album, durée, etc.)
use anyhow::Result;
use lofty::config::ParseOptions;
use lofty::prelude::*;
use lofty::probe::Probe;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[cfg(feature = "pmoserver")]
use utoipa::ToSchema;
/// Métadonnées d'une piste audio
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct AudioMetadata {
/// Titre de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
pub title: Option<String>,
/// Artiste de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Pink Floyd"))]
pub artist: Option<String>,
/// Album de la piste
#[cfg_attr(feature = "pmoserver", schema(example = "Wish You Were Here"))]
pub album: Option<String>,
/// Année de sortie
#[cfg_attr(feature = "pmoserver", schema(example = 1975))]
pub year: Option<u32>,
/// Numéro de piste
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub track_number: Option<u32>,
/// Nombre total de pistes
#[cfg_attr(feature = "pmoserver", schema(example = 5))]
pub track_total: Option<u32>,
/// Numéro de disque
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub disc_number: Option<u32>,
/// Nombre total de disques
#[cfg_attr(feature = "pmoserver", schema(example = 1))]
pub disc_total: Option<u32>,
/// Genre musical
#[cfg_attr(feature = "pmoserver", schema(example = "Progressive Rock"))]
pub genre: Option<String>,
/// Durée en secondes
#[cfg_attr(feature = "pmoserver", schema(example = 334))]
pub duration_secs: Option<u64>,
/// Taux d'échantillonnage (Hz)
#[cfg_attr(feature = "pmoserver", schema(example = 44100))]
pub sample_rate: Option<u32>,
/// Nombre de canaux
#[cfg_attr(feature = "pmoserver", schema(example = 2))]
pub channels: Option<u8>,
/// Bitrate moyen (kbps)
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
pub bitrate: Option<u32>,
}
impl AudioMetadata {
/// Extrait les métadonnées d'un fichier audio
///
/// # Arguments
///
/// * `path` - Chemin vers le fichier audio
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoaudiocache::metadata::AudioMetadata;
/// use std::path::Path;
///
/// let metadata = AudioMetadata::from_file(Path::new("track.flac")).unwrap();
/// println!("Titre: {:?}", metadata.title);
/// ```
pub fn from_file(path: &Path) -> Result<Self> {
let tagged_file = Probe::open(path)?
.options(ParseOptions::new())
.read()?;
let properties = tagged_file.properties();
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
let mut metadata = Self {
title: None,
artist: None,
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: Some(properties.duration().as_secs()),
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
};
if let Some(tag) = tag {
metadata.title = tag.title().map(|s| s.to_string());
metadata.artist = tag.artist().map(|s| s.to_string());
metadata.album = tag.album().map(|s| s.to_string());
metadata.year = tag.year();
metadata.track_number = tag.track();
metadata.track_total = tag.track_total();
metadata.disc_number = tag.disk();
metadata.disc_total = tag.disk_total();
metadata.genre = tag.genre().map(|s| s.to_string());
}
Ok(metadata)
}
/// Crée des métadonnées depuis des données brutes audio
///
/// # Arguments
///
/// * `data` - Données audio brutes
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let cursor = std::io::Cursor::new(data);
let tagged_file = Probe::new(cursor)
.guess_file_type()?
.options(ParseOptions::new())
.read()?;
let properties = tagged_file.properties();
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
let mut metadata = Self {
title: None,
artist: None,
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: Some(properties.duration().as_secs()),
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
};
if let Some(tag) = tag {
metadata.title = tag.title().map(|s| s.to_string());
metadata.artist = tag.artist().map(|s| s.to_string());
metadata.album = tag.album().map(|s| s.to_string());
metadata.year = tag.year();
metadata.track_number = tag.track();
metadata.track_total = tag.track_total();
metadata.disc_number = tag.disk();
metadata.disc_total = tag.disk_total();
metadata.genre = tag.genre().map(|s| s.to_string());
}
Ok(metadata)
}
/// Génère une clé de collection basée sur l'artiste et l'album
///
/// Retourne une clé au format "artist:album" si les deux sont disponibles,
/// sinon retourne None
pub fn collection_key(&self) -> Option<String> {
match (&self.artist, &self.album) {
(Some(artist), Some(album)) => {
let normalized_artist = artist.to_lowercase().replace(" ", "_");
let normalized_album = album.to_lowercase().replace(" ", "_");
Some(format!("{}:{}", normalized_artist, normalized_album))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collection_key() {
let 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),
track_total: Some(5),
disc_number: Some(1),
disc_total: Some(1),
genre: Some("Progressive Rock".to_string()),
duration_secs: Some(334),
sample_rate: Some(44100),
channels: Some(2),
bitrate: Some(1411),
};
assert_eq!(
metadata.collection_key(),
Some("pink_floyd:wish_you_were_here".to_string())
);
}
#[test]
fn test_collection_key_missing_album() {
let metadata = AudioMetadata {
title: Some("Test".to_string()),
artist: Some("Artist".to_string()),
album: None,
year: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
genre: None,
duration_secs: None,
sample_rate: None,
channels: None,
bitrate: None,
};
assert_eq!(metadata.collection_key(), None);
}
}

View File

@@ -0,0 +1,23 @@
//! Documentation OpenAPI pour l'API du cache audio
use utoipa::OpenApi;
#[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,
)
),
tags(
(name = "audio", description = "Gestion des pistes audio")
)
)]
pub struct ApiDoc;

View File

@@ -0,0 +1,214 @@
//! 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<Arc<AudioCache>>, req: Request<Body>) -> 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<Arc<AudioCache>>, req: Request<Body>) -> 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<Arc<AudioCache>>, req: Request<Body>) -> 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<Arc<AudioCache>>, req: Request<Body>) -> 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<Arc<AudioCache>>) -> 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<Arc<AudioCache>>) -> Response {
match cache.list_collections().await {
Ok(collections) => Json(collections).into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Cannot list collections",
)
.into_response(),
}
}
impl AudioCacheExt for Server {
async fn init_audio_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<Arc<AudioCache>> {
let cache = Arc::new(AudioCache::new(cache_dir, limit)?);
// 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());
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
);
Ok(cache)
}
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<AudioCache>> {
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
}
}