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