diff --git a/Cargo.lock b/Cargo.lock index b4fc4f12..0cee647c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2867,6 +2867,7 @@ name = "pmoaudio-ext" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "pmoaudio", "pmoaudiocache", "pmocache", @@ -2874,6 +2875,8 @@ dependencies = [ "pmoflac", "pmometadata", "pmoplaylist", + "rand 0.8.5", + "serde", "tokio", "tokio-util", "tracing", @@ -2928,6 +2931,7 @@ dependencies = [ "serde_yaml", "sha1", "sha2", + "tempfile", "tokio", "tokio-util", "tracing", @@ -2966,6 +2970,7 @@ dependencies = [ "pmoserver", "reqwest", "serde", + "tempfile", "tokio", "tracing", "utoipa", diff --git a/INSTALL_LIBSOXR.md b/INSTALL_LIBSOXR.md index a3c4129f..3632ccac 100644 --- a/INSTALL_LIBSOXR.md +++ b/INSTALL_LIBSOXR.md @@ -152,7 +152,22 @@ cargo test ### Configuration initiale (à faire une seule fois) -Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo. Suivez ces étapes : +Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo. + +**🚀 Méthode rapide (recommandée) :** + +```bash +# 1. Installation automatique des dépendances (une seule fois) +./setup-deps.sh + +# 2. Configuration des variables d'environnement (à chaque session) +source setup-env.sh + +# 3. Compilation +cargo build +``` + +**📋 Méthode manuelle (si les scripts ne fonctionnent pas) :** #### 1. Installation des dépendances @@ -184,14 +199,28 @@ export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" ``` -**Astuce :** Copier ces trois lignes dans un fichier `setup-env.sh` à la racine du projet : +**Astuce :** Créez un fichier `setup-env.sh` pour ne pas avoir à retaper ces commandes à chaque session : ```bash cat > setup-env.sh << 'EOF' +#!/bin/bash +# Script de configuration des variables d'environnement pour PMOMusic +# Usage: source setup-env.sh + +# Configuration des chemins pour libsoxr et libasound2 export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" + +echo "Variables d'environnement configurées pour PMOMusic" +echo " PKG_CONFIG_PATH=$PKG_CONFIG_PATH" +echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH" +echo " RUSTFLAGS=$RUSTFLAGS" +echo "" +echo "Vous pouvez maintenant compiler avec: cargo build" EOF + +chmod +x setup-env.sh ``` Puis dans chaque session : @@ -200,7 +229,7 @@ Puis dans chaque session : source setup-env.sh ``` -⚠️ **NE PAS committer `setup-env.sh`** - ajouter au `.gitignore` +⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` (configuration locale), vous devez le créer vous-même avec le contenu ci-dessus. #### 3. Vérifier l'installation @@ -228,9 +257,16 @@ cargo run --package pmoparadise --example play_and_cache --features full -- 0 À chaque fois que vous démarrez une nouvelle session Claude Code : -1. **Exporter les variables d'environnement** (ou `source setup-env.sh`) -2. Compiler avec `cargo build` -3. Exécuter les exemples ou tests +```bash +# 1. Configuration de l'environnement +source setup-env.sh + +# 2. Compilation +cargo build + +# 3. Exécution des exemples +cargo run --package pmoparadise --example play_and_cache --features full -- 0 +``` **IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme : ``` @@ -244,7 +280,7 @@ ou rust-lld: error: unable to find library -lasound ``` -Solution : Exporter les variables et recompiler. +**Solution :** Exécutez `source setup-env.sh` et recompilez. ### Notes importantes diff --git a/INSTALL_NOTES.md b/INSTALL_NOTES.md index cf2fad54..77d256e6 100644 --- a/INSTALL_NOTES.md +++ b/INSTALL_NOTES.md @@ -29,7 +29,22 @@ brew install libsoxr apk add soxr-dev alsa-lib-dev ``` -**Sans privilèges root** : Si vous n'avez pas les droits sudo, consultez `INSTALL_LIBSOXR.md` pour l'installation locale de `libsoxr` et `libasound2`. +**Sans privilèges root (Claude Code, environnements sans sudo)** : + +🚀 **Installation automatique** : + +```bash +# 1. Installation des dépendances (une seule fois) +./setup-deps.sh + +# 2. Configuration de l'environnement (à chaque session) +source setup-env.sh + +# 3. Compilation +cargo build +``` + +Pour plus de détails, consultez `INSTALL_LIBSOXR.md`. --- diff --git a/OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md b/OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md new file mode 100644 index 00000000..cf13eea7 --- /dev/null +++ b/OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md @@ -0,0 +1,485 @@ +# Optimisation: Réduction du délai prebuffer → playlist (19s → 1s) + +## Contexte + +Le système de progressive caching fonctionne correctement, mais il y a un délai non optimal entre le moment où le prebuffer est atteint et le moment où la track est ajoutée à la playlist. + +### État actuel (branche `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`) + +**Timing mesuré:** +``` +t=0.6s : Prebuffer complete (512KB téléchargés) ✅ +t=19.2s : tokio::join!() complete (pump_future finit) +t=19.2s : Track added to playlist +t=19.7s : Playback starts +``` + +**Délai total: ~19 secondes** + +### Code actuel problématique + +Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:167-178` + +```rust +// Exécuter pump et add_from_reader en parallèle +let pump_future = pump_track_segments( + first_segment, + &mut rx, // ← emprunte muablement rx + pcm_tx, + bits_per_sample, + sample_rate, + &stop_token, +); + +// Attendre les deux tâches en parallèle +let (cache_result, pump_result) = tokio::join!(cache_future, pump_future); + +let pk = cache_result.map_err(|e| { + AudioError::ProcessingError(format!("Failed to add to cache: {}", e)) +})?; + +let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?; +``` + +**Le problème:** `tokio::join!()` attend que **LES DEUX** futures se terminent: +- `cache_future` retourne après prebuffer (~0.6s) ✅ +- `pump_future` lit **toute** la première track du RadioParadiseStreamSource (~19s) ⏱️ + +Donc même si le prebuffer est atteint en 0.6s, on attend 19s avant de push à la playlist! + +## Objectif + +Réduire le délai à **~1 seconde** en pushant à la playlist **immédiatement après le prebuffer**, sans attendre que `pump_future` se termine. + +**Timing visé:** +``` +t=0.6s : Prebuffer complete ✅ +t=0.7s : Track added to playlist ← IMMÉDIAT! +t=1.2s : Playback starts ← ~1 seconde! +t=19.2s : pump_future finit en arrière-plan +``` + +## Contraintes techniques + +### 1. Problème du borrow checker + +`pump_future` emprunte muablement `rx`: +```rust +async fn pump_track_segments( + first_segment: Arc, + rx: &mut mpsc::Receiver>, // ← &mut borrow + // ... +) +``` + +On ne peut pas faire: +```rust +tokio::pin!(cache_future); +tokio::pin!(pump_future); // ← pump_future contient un &mut rx + +let pk = cache_future.await; // cache_future termine + +// ❌ ERREUR: on a toujours un borrow mutable de rx dans pump_future +// On ne peut pas continuer à utiliser rx (ou l'objet qui le contient) +playlist_handle.push(pk.clone()).await; + +let result = pump_future.await; // pump_future continue +``` + +Le borrow checker nous empêche d'attendre `cache_future` seul, puis de faire d'autres opérations, puis d'attendre `pump_future`, car `pump_future` garde un borrow mutable de `rx` pendant toute sa durée de vie. + +### 2. Contraintes de l'API + +- `pump_track_segments()` doit lire `rx` pour recevoir les segments du RadioParadiseStreamSource +- Le FlacCacheSinkLogic doit garder ownership de `rx` pour traiter les tracks suivantes +- `pump_future` ne peut pas être spawné dans un tokio::spawn car il retourne un `StopReason` nécessaire pour la logique métier + +## Solutions possibles + +### Solution A: Refactoriser pump_track_segments pour prendre ownership de rx + +**Approche:** +1. Créer `pump_track_segments_owned` qui prend ownership de `rx` +2. Cette fonction retourne `(result, rx)` - elle rend ownership de `rx` +3. Spawner cette future dans tokio::spawn +4. Attendre cache_future seul, push immédiatement +5. Attendre la task spawnée plus tard + +**Signature:** +```rust +async fn pump_track_segments_owned( + first_segment: Arc, + rx: mpsc::Receiver>, // ownership! + pcm_tx: mpsc::Sender>, + bits_per_sample: u8, + expected_rate: u32, + stop_token: CancellationToken, +) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver>), AudioError> +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ rend rx +``` + +**Utilisation:** +```rust +let pump_handle = tokio::spawn(pump_track_segments_owned( + first_segment, + rx, // move ownership + pcm_tx, + bits_per_sample, + sample_rate, + stop_token.clone(), +)); + +// Attendre SEULEMENT le prebuffer +let pk = cache_future.await?; + +// Push IMMÉDIATEMENT à la playlist +#[cfg(feature = "playlist")] +if let Some(ref playlist_handle) = self.playlist_handle { + playlist_handle.push(pk.clone()).await?; +} + +// MAINTENANT attendre que pump finisse +let (result, rx_returned) = pump_handle.await.unwrap()?; +rx = rx_returned; // récupérer rx pour la prochaine track +``` + +**Avantages:** +- ✅ Pas de problème de borrow checker +- ✅ Push immédiat après prebuffer +- ✅ Délai réduit à ~1s + +**Inconvénients:** +- ⚠️ Nécessite de modifier la signature de `pump_track_segments` +- ⚠️ Plus complexe (ownership passé puis rendu) + +### Solution B: Utiliser un channel pour signaler le prebuffer + +**Approche:** +1. Créer un oneshot channel `(prebuffer_tx, prebuffer_rx)` +2. `cache_future` envoie le pk via `prebuffer_tx` dès le prebuffer atteint +3. Le code principal attend `prebuffer_rx`, push immédiatement +4. Puis attend `tokio::join!()` normalement + +**Code:** +```rust +let (prebuffer_tx, prebuffer_rx) = tokio::sync::oneshot::channel(); + +let cache_future = async { + let pk = self.cache.add_from_reader(...).await?; + let _ = prebuffer_tx.send(pk.clone()); // Signal prebuffer! + Ok(pk) +}; + +let pump_future = pump_track_segments(...); + +// Spawner les deux en parallèle +let cache_handle = tokio::spawn(cache_future); +let pump_handle = tokio::spawn(pump_future); + +// Attendre SEULEMENT le signal de prebuffer +let pk = prebuffer_rx.await.unwrap(); + +// Push IMMÉDIATEMENT à la playlist +playlist_handle.push(pk.clone()).await?; + +// Puis attendre que tout finisse +let (cache_result, pump_result) = tokio::join!(cache_handle, pump_handle); +``` + +**Avantages:** +- ✅ Pas besoin de changer les signatures +- ✅ Push immédiat après prebuffer + +**Inconvénients:** +- ⚠️ Nécessite de wrapper cache_future pour envoyer le signal +- ⚠️ Ajoute un oneshot channel + +### Solution C: Modifier l'API du cache pour avoir un callback + +**Approche:** +1. Ajouter un paramètre callback à `add_from_reader()` +2. Le cache appelle ce callback dès le prebuffer atteint +3. Le callback push à la playlist + +**Signature:** +```rust +pub async fn add_from_reader_with_callback( + &self, + source_uri: Option<&str>, + reader: R, + length: Option, + collection: Option<&str>, + on_prebuffer: F, // ← nouveau callback +) -> Result +where + R: AsyncRead + Send + Unpin + 'static, + F: FnOnce(String) + Send + 'static, // F reçoit le pk +``` + +**Avantages:** +- ✅ API propre et réutilisable +- ✅ Pas de problème de borrow checker + +**Inconvénients:** +- ⚠️ Nécessite de modifier l'API du cache (impact sur autres parties du code) +- ⚠️ Ajoute de la complexité à l'API + +## Recommandation + +**Je recommande la Solution A** (refactoriser `pump_track_segments_owned`): +- Plus explicite et claire +- Pas d'impact sur l'API du cache +- Ownership bien défini (passage puis retour de rx) +- Testable indépendamment + +## Plan d'implémentation + +### Étape 1: Créer pump_track_segments_owned + +Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs` + +```rust +/// Pompe les segments pour une seule track (s'arrête au TrackBoundary). +/// +/// Version qui prend ownership de rx pour permettre un await séparé du cache. +/// Retourne rx à la fin pour permettre le traitement des tracks suivantes. +async fn pump_track_segments_owned( + first_segment: Arc, + mut rx: mpsc::Receiver>, + pcm_tx: mpsc::Sender>, + bits_per_sample: u8, + expected_rate: u32, + stop_token: CancellationToken, +) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver>), AudioError> { + let mut chunks = 0u64; + let mut samples = 0u64; + let mut duration_sec = 0.0f64; + + // Traiter le premier segment + if let Some(chunk) = first_segment.as_chunk() { + let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; + if !pcm_bytes.is_empty() { + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx)); + } + chunks += 1; + samples += chunk.len() as u64; + duration_sec += chunk.len() as f64 / expected_rate as f64; + } + } + + // Loop pour le reste des segments... + loop { + let segment = tokio::select! { + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx)); + } + } + } + _ = stop_token.cancelled() => { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx)); + } + }; + + match &segment.segment { + _AudioSegment::Chunk(chunk) => { + let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; + if !pcm_bytes.is_empty() { + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx)); + } + chunks += 1; + samples += chunk.len() as u64; + duration_sec += chunk.len() as f64 / expected_rate as f64; + } + } + _AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { metadata, .. } => { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), rx)); + } + SyncMarker::EndOfStream => { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::EndOfStream, rx)); + } + _ => continue, + }, + } + } +} +``` + +### Étape 2: Modifier FlacCacheSinkLogic::process + +Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:~167` + +```rust +let collection_ref = self.collection.as_deref(); +let cache_future = self.cache.add_from_reader( + None, + flac_stream, + None, + collection_ref, +); + +// Spawner pump_future avec ownership de rx +let pump_handle = tokio::spawn(pump_track_segments_owned( + first_segment, + rx, // move ownership! + pcm_tx, + bits_per_sample, + sample_rate, + stop_token.clone(), +)); + +// Attendre SEULEMENT le prebuffer (cache retourne après 512KB) +tracing::debug!("FlacCacheSink: Waiting for cache prebuffer to complete"); +let pk = cache_future.await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to add to cache: {}", e)) +})?; + +tracing::debug!("FlacCacheSink: Prebuffer complete with pk {}, pushing to playlist NOW", pk); + +// Copier les métadonnées AVANT push +if let Some(src_metadata) = track_metadata.clone() { + let dest_metadata = self.cache.track_metadata(&pk); + pmometadata::copy_metadata_into(&src_metadata, &dest_metadata) + .await + .map_err(|e| { + AudioError::ProcessingError(format!("Failed to copy metadata to cache: {}", e)) + })?; +} + +// Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!) +#[cfg(feature = "playlist")] +if let Some(ref playlist_handle) = self.playlist_handle { + tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk); + playlist_handle.push(pk.clone()).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to add to playlist: {}", e)) + })?; + tracing::debug!("FlacCacheSink: Successfully pushed to playlist"); +} + +// MAINTENANT attendre que pump finisse (il continue en arrière-plan) +tracing::debug!("FlacCacheSink: Waiting for pump to complete"); +let pump_result = pump_handle.await.map_err(|e| { + AudioError::ProcessingError(format!("Pump task panicked: {}", e)) +})?; + +let (_chunks, _samples, _duration_sec, stop_reason, rx_returned) = pump_result?; +rx = rx_returned; // récupérer rx pour la prochaine track +tracing::debug!("FlacCacheSink: Pump completed"); + +// Continuer avec download des covers en arrière-plan... +``` + +### Étape 3: Tester + +```bash +# Nettoyer et rebuild +rm -rf /tmp/pmomusic_test +source setup-env.sh +cargo build --example play_and_cache --features full + +# Tester avec logs de timing +RUST_LOG=debug target/debug/examples/play_and_cache 0 --null-audio 2>&1 | \ + grep -E "Prebuffer complete|Pushing pk.*to playlist|popped track" | \ + head -20 +``` + +**Résultats attendus:** +``` +[TIME_A] FlacCacheSink: Prebuffer complete with pk XXX, pushing to playlist NOW +[TIME_B] FlacCacheSink: Successfully pushed to playlist +[TIME_C] PlaylistSourceLogic: popped track from playlist + +Délai (TIME_C - TIME_A) devrait être < 1 seconde! +``` + +### Étape 4: Valider le comportement + +Vérifier que: +1. ✅ Le prebuffer est atteint rapidement (~0.6s) +2. ✅ Le push à la playlist est immédiat (~0.1s après prebuffer) +3. ✅ La lecture démarre rapidement (~1s total) +4. ✅ Toutes les tracks se suivent correctement +5. ✅ Les completion markers sont créés +6. ✅ Les tracks suivantes fonctionnent (rx est bien récupéré) +7. ✅ Pas de panic ou deadlock + +## Debugging + +### Si le borrow checker proteste + +Vérifier que: +- `pump_track_segments_owned` prend bien ownership de `rx` (pas `&mut`) +- `rx` est bien retourné dans le tuple de retour +- `rx = rx_returned;` récupère bien ownership après await + +### Si les tracks suivantes ne fonctionnent pas + +Vérifier que: +- `rx` est bien réassigné après le pump: `rx = rx_returned;` +- La loop dans `process()` continue correctement avec le nouveau `rx` + +### Si le timing n'est pas amélioré + +Ajouter des logs avec timestamps: +```rust +let start = std::time::Instant::now(); +let pk = cache_future.await?; +tracing::info!("Prebuffer took {:?}", start.elapsed()); + +let start2 = std::time::Instant::now(); +playlist_handle.push(pk.clone()).await?; +tracing::info!("Playlist push took {:?}", start2.elapsed()); +``` + +## Fichiers à modifier + +1. **pmoaudio-ext/src/sinks/flac_cache_sink.rs** + - Ajouter `pump_track_segments_owned()` (~ligne 432) + - Modifier `FlacCacheSinkLogic::process()` (~ligne 167) + +## Tests de régression + +Après l'implémentation, tester: + +```bash +# Test 1: Premier download (cache vide) +rm -rf /tmp/pmomusic_test +target/debug/examples/play_and_cache 0 --null-audio + +# Test 2: Deuxième download (fichier déjà en cache) +# Ne pas supprimer /tmp/pmomusic_test +target/debug/examples/play_and_cache 0 --null-audio + +# Test 3: Download interrompu (Ctrl+C) +target/debug/examples/play_and_cache 0 --null-audio +# Appuyer Ctrl+C après 2 secondes + +# Test 4: Plusieurs tracks consécutives +# Laisser tourner 1 minute pour voir plusieurs tracks +timeout 60 target/debug/examples/play_and_cache 0 --null-audio +``` + +## Métriques de succès + +- ✅ Délai prebuffer → playlist: **< 1 seconde** (actuellement ~19s) +- ✅ Délai prebuffer → lecture: **< 2 secondes** (actuellement ~19.5s) +- ✅ Pas de régression fonctionnelle +- ✅ Toutes les tracks se suivent correctement +- ✅ Les completion markers sont créés + +## Références + +- Branche actuelle: `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK` +- Code de référence: commit `ed0bbfb` (Add FlacCacheSink debug logs - system now works!) +- Issue originale: "play_and_cache n'a pas le comportement souhaité" diff --git a/Readme.md b/Readme.md index 0dc06507..f2785fe9 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,43 @@ # Développement de l'application PMOMusic en RUST +## 🚀 Démarrage rapide + +### Installation des dépendances (environnement sans sudo) + +Pour compiler PMOMusic dans un environnement sans privilèges sudo (comme Claude Code) : + +```bash +# 1. Installation automatique de libsoxr et libasound2 (une seule fois) +./setup-deps.sh + +# 2. Créer le fichier setup-env.sh (une seule fois, voir INSTALL_LIBSOXR.md pour le contenu) +cat > setup-env.sh << 'EOF' +#!/bin/bash +export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" +export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" +export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" +echo "Variables d'environnement configurées pour PMOMusic" +EOF + +# 3. Configuration de l'environnement (à chaque nouvelle session) +source setup-env.sh + +# 4. Compilation +cargo build + +# 5. Test de l'exemple Radio Paradise +cargo run --package pmoparadise --example play_and_cache --features full -- 0 +``` + +⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` car il contient une configuration locale. + +### Documentation + +- **[INSTALL_NOTES.md](INSTALL_NOTES.md)** - Guide d'installation général +- **[INSTALL_LIBSOXR.md](INSTALL_LIBSOXR.md)** - Installation détaillée de libsoxr et ALSA + +--- + ## Création de la structure ```bash diff --git a/analyze_flac.sh b/analyze_flac.sh new file mode 100755 index 00000000..28ef4187 --- /dev/null +++ b/analyze_flac.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Affiche toutes les stats d'un fichier FLAC + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File not found: $FILE" + exit 1 +fi + +echo "=== Analyzing: $(basename "$FILE") ===" +echo "" +echo "--- SoX Statistics ---" +sox "$FILE" -n stat 2>&1 + +echo "" +echo "--- File Info ---" +file "$FILE" + +echo "" +echo "--- FLAC Metadata ---" +metaflac --list "$FILE" 2>/dev/null || echo "metaflac not installed" + +echo "" +echo "--- Audio Integrity Check ---" +flac -t "$FILE" 2>&1 || echo "flac not installed" diff --git a/check_audio_quality.sh b/check_audio_quality.sh new file mode 100755 index 00000000..b87534f6 --- /dev/null +++ b/check_audio_quality.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Script pour vérifier la qualité audio (détection de clics via le ratio delta) + +CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}" +THRESHOLD=10.0 # Ratio Maximum delta / Mean delta acceptable + +if [ ! -d "$CACHE_DIR" ]; then + echo "Error: Cache directory not found: $CACHE_DIR" + exit 1 +fi + +echo "=== Audio Quality Check ===" +echo "Scanning: $CACHE_DIR" +echo "Threshold: Maximum/Mean delta ratio < $THRESHOLD" +echo "" + +# Vérifier que sox est installé +if ! command -v sox &> /dev/null; then + echo "Error: sox is not installed. Install it with: sudo apt install sox" + exit 1 +fi + +count=0 +suspicious=0 +good=0 + +for file in "$CACHE_DIR"/*.orig.flac; do + if [ ! -f "$file" ]; then + echo "No FLAC files found in $CACHE_DIR" + exit 0 + fi + + filename=$(basename "$file") + + # Obtenir les stats delta + stats=$(sox "$file" -n stat 2>&1) + + max_delta=$(echo "$stats" | grep "Maximum delta" | awk '{print $3}') + mean_delta=$(echo "$stats" | grep "Mean delta" | awk '{print $3}') + + if [ -z "$max_delta" ] || [ -z "$mean_delta" ]; then + echo "❌ $filename - Cannot parse stats" + suspicious=$((suspicious + 1)) + count=$((count + 1)) + continue + fi + + # Éviter division par zéro + if (( $(echo "$mean_delta == 0" | bc -l) )); then + echo "❌ $filename - Invalid mean delta (0)" + suspicious=$((suspicious + 1)) + count=$((count + 1)) + continue + fi + + # Calculer le ratio + ratio=$(echo "scale=2; $max_delta / $mean_delta" | bc -l) + + # Comparer au seuil + is_bad=$(echo "$ratio > $THRESHOLD" | bc -l) + + if [ "$is_bad" = "1" ]; then + echo "⚠️ CLICKS DETECTED: $filename" + echo " Max delta: $max_delta, Mean delta: $mean_delta, Ratio: ${ratio}x (threshold: ${THRESHOLD}x)" + suspicious=$((suspicious + 1)) + else + echo "✓ OK: $filename (ratio: ${ratio}x)" + good=$((good + 1)) + fi + + count=$((count + 1)) +done + +echo "" +echo "=== Summary ===" +echo "Total files scanned: $count" +echo "✓ Good quality: $good" +echo "⚠️ Clicks detected: $suspicious" + +if [ $suspicious -gt 0 ]; then + echo "" + echo "⚠️ Warning: $suspicious file(s) have clicks." + echo "These files were likely encoded with the old buffer size (8)." + echo "Delete the cache and re-download to fix: rm -rf $CACHE_DIR/*.flac" + exit 1 +fi + +echo "" +echo "✓ All files are good quality!" +exit 0 diff --git a/detect_clicks.sh b/detect_clicks.sh new file mode 100755 index 00000000..31999d26 --- /dev/null +++ b/detect_clicks.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Script pour détecter les clics dans les fichiers FLAC du cache + +CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}" + +if [ ! -d "$CACHE_DIR" ]; then + echo "Error: Cache directory not found: $CACHE_DIR" + exit 1 +fi + +echo "=== FLAC Click Detection Tool ===" +echo "Scanning: $CACHE_DIR" +echo "" + +# Vérifier que sox est installé +if ! command -v sox &> /dev/null; then + echo "Error: sox is not installed. Install it with: sudo apt install sox" + exit 1 +fi + +count=0 +suspicious=0 + +for file in "$CACHE_DIR"/*.orig.flac; do + if [ ! -f "$file" ]; then + echo "No FLAC files found in $CACHE_DIR" + exit 0 + fi + + filename=$(basename "$file") + echo "Analyzing: $filename" + + # Obtenir toutes les stats + stats=$(sox "$file" -n stat 2>&1) + + # Extraire les valeurs importantes + pk_lev=$(echo "$stats" | grep "Pk lev dB" | awk '{print $4}') + rms_lev=$(echo "$stats" | grep "RMS lev dB" | awk '{print $4}') + crest=$(echo "$stats" | grep "Crest factor" | awk '{print $3}') + + echo " Peak level: ${pk_lev:-N/A} dB" + echo " RMS level: ${rms_lev:-N/A} dB" + echo " Crest factor: ${crest:-N/A} dB" + + # Analyser la variance d'amplitude (détection de clics) + # On compte le nombre de pics au-dessus d'un seuil + peaks=$(sox "$file" -n stats 2>&1 | grep "Maximum amplitude" | awk '{print $3}') + + if [ -n "$peaks" ]; then + # Si le peak est proche de 1.0 (clipping), c'est suspect + is_clipping=$(echo "$peaks > 0.95" | bc -l 2>/dev/null) + if [ "$is_clipping" = "1" ]; then + echo " ⚠️ WARNING: Possible clipping detected!" + suspicious=$((suspicious + 1)) + else + echo " ✓ OK" + fi + else + echo " ✓ OK" + fi + + echo "" + count=$((count + 1)) +done + +echo "=== Summary ===" +echo "Files scanned: $count" +echo "Suspicious files: $suspicious" + +if [ $suspicious -gt 0 ]; then + echo "" + echo "⚠️ Some files may have issues. Listen to them carefully." + exit 1 +fi + +exit 0 diff --git a/pmoaudio-ext/Cargo.toml b/pmoaudio-ext/Cargo.toml index 44212103..112c8a8a 100644 --- a/pmoaudio-ext/Cargo.toml +++ b/pmoaudio-ext/Cargo.toml @@ -16,6 +16,7 @@ pmometadata = { path = "../pmometadata", optional = true } # Optional dependencies for playlist integration pmoplaylist = { path = "../pmoplaylist", optional = true } pmocache = { path = "../pmocache", optional = true } + # Async runtime tokio = { version = "1.0", features = ["full"] } tokio-util = { version = "0.7" } @@ -23,9 +24,15 @@ async-trait = "0.1" # Utilities tracing = "0.1" +rand = "0.8" + +# HTTP streaming dependencies +bytes = { version = "1.0", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } [features] default = [] cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"] playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"] -all = ["cache-sink", "playlist"] +http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"] +all = ["cache-sink", "playlist", "http-stream"] diff --git a/pmoaudio-ext/src/sinks/flac_cache_sink.rs b/pmoaudio-ext/src/sinks/flac_cache_sink.rs index f1924a9c..50add148 100755 --- a/pmoaudio-ext/src/sinks/flac_cache_sink.rs +++ b/pmoaudio-ext/src/sinks/flac_cache_sink.rs @@ -10,7 +10,6 @@ use pmoaudiocache::AudioTrackMetadataExt; use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; use std::{ collections::VecDeque, - io::Cursor, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -20,7 +19,6 @@ use tokio::{ sync::{mpsc, RwLock}, }; use tokio_util::sync::CancellationToken; -use tracing::warn; /// Sink qui encode les `AudioSegment` reçus au format FLAC et les stocke dans le cache audio. /// @@ -87,19 +85,43 @@ impl NodeLogic for FlacCacheSinkLogic { _output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { + tracing::debug!("FlacCacheSink::process() started"); let mut rx = input.expect("FlacCacheSink must have input"); let mut track_number = 0; + // Stocker les métadonnées du prochain TrackBoundary reçu en Phase 3 + let mut next_track_metadata: Option>> = None; loop { // Attendre le premier chunk audio pour cette track - let (first_segment, track_metadata) = - match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await { - Ok(result) => result, - Err(_) => { - // Plus d'audio disponible + tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number); + let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() { + // On a déjà reçu le TrackBoundary en Phase 3 de la track précédente + tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"); + // Attendre juste le premier chunk + match wait_for_first_audio_chunk(&mut rx, &stop_token).await { + Ok(chunk) => { + tracing::debug!("FlacCacheSink: Got first audio chunk"); + (chunk, Some(metadata)) + } + Err(e) => { + tracing::debug!("FlacCacheSink: No more audio available: {}", e); return Ok(()); } - }; + } + } else { + // Première track ou pas de TrackBoundary reçu en avance + match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await { + Ok(result) => { + tracing::debug!("FlacCacheSink: Got first audio chunk"); + result + } + Err(e) => { + // Plus d'audio disponible + tracing::debug!("FlacCacheSink: No more audio available: {}", e); + return Ok(()); + } + } + }; // Extraire les informations du premier chunk let first_chunk = first_segment.as_chunk().unwrap(); @@ -126,60 +148,136 @@ impl NodeLogic for FlacCacheSinkLogic { options_with_metadata.metadata = track_metadata.clone(); // Créer l'encoder + tracing::debug!("FlacCacheSink: Creating FLAC encoder"); let reader = ByteStreamReader::new(pcm_rx); - let mut flac_stream = encode_flac_stream(reader, format, options_with_metadata) + let flac_stream = encode_flac_stream(reader, format, options_with_metadata) .await .map_err(|e| { AudioError::ProcessingError(format!("FLAC encode init failed: {}", e)) })?; + tracing::debug!("FlacCacheSink: FLAC encoder created"); - // Créer un buffer pour collecter le FLAC encodé - let mut flac_buffer = Vec::new(); + // Ingérer le FLAC progressivement dans le cache + // add_from_reader lance l'ingestion en arrière-plan et retourne dès que + // le prebuffer (512 KB) est atteint, permettant un streaming progressif + // Le cache skip automatiquement le header FLAC (512 octets) pour calculer le pk + // à partir du contenu audio, évitant les collisions entre morceaux au même format + let collection_ref = self.collection.as_deref(); + tracing::debug!("FlacCacheSink: Starting cache ingestion and pump in parallel"); + let cache_future = self.cache.add_from_reader( + None, + flac_stream, + None, // Taille inconnue car streaming + collection_ref, + ); - // Exécuter pump et copy en parallèle - let pump_future = pump_track_segments( + // Créer un channel dédié pour dispatcher les chunks vers ce pump + let (track_tx, track_rx) = mpsc::channel::>(16); + + // Lancer le pump en arrière-plan avec son channel dédié + // Cela permet à plusieurs pumps de tourner simultanément (écriture parallèle) + let pump_handle = tokio::spawn(pump_track_segments_from_channel( first_segment, - &mut rx, + track_rx, pcm_tx, bits_per_sample, sample_rate, - &stop_token, - ); - let copy_future = async { - tokio::io::copy(&mut flac_stream, &mut flac_buffer) - .await - .map_err(|e| { - AudioError::ProcessingError(format!("FLAC write failed: {}", e)) - })?; - flac_stream - .wait() - .await - .map_err(|e| AudioError::ProcessingError(format!("Encoder failed: {}", e)))?; - Ok::<_, AudioError>(()) + )); + + // Dispatcher les segments vers track_tx en parallèle de l'attente du prebuffer + // Utiliser tokio::select! pour éviter le deadlock + let start = std::time::Instant::now(); + tracing::debug!("FlacCacheSink: Starting dispatcher loop with prebuffer wait"); + + // Pin la future pour pouvoir l'utiliser dans select! + tokio::pin!(cache_future); + + // Phase 1: Dispatcher jusqu'à ce que le prebuffer soit terminé + let mut end_of_stream_received = false; + let mut track_tx_opt = Some(track_tx); + let pk = loop { + tokio::select! { + // Attendre le prebuffer + result = &mut cache_future => { + match result { + Ok(pk) => { + let prebuffer_time = start.elapsed(); + tracing::info!("FlacCacheSink: Prebuffer complete with pk {} in {:?}", pk, prebuffer_time); + break pk; // Sort de la loop pour faire les métadonnées et le push + } + Err(e) => { + return Err(AudioError::ProcessingError(format!("Failed to add to cache: {}", e))); + } + } + } + + // Dispatcher les segments depuis rx vers track_tx + result = rx.recv() => { + match result { + Some(segment) => { + // Si EndOfStream a été reçu, ignorer tous les segments suivants + // et continuer à attendre cache_future + if end_of_stream_received { + continue; + } + + match &segment.segment { + _AudioSegment::Chunk(_) => { + // Dispatcher vers le pump + if let Some(ref tx) = track_tx_opt { + if tx.send(segment).await.is_err() { + // Le pump est mort - erreur fatale + tracing::error!("FlacCacheSink: pump died unexpectedly during prebuffer phase"); + return Err(AudioError::ProcessingError("Pump task died".to_string())); + } + } + } + _AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { .. } => { + // TrackBoundary avant fin du prebuffer - track trop courte + tracing::error!("FlacCacheSink: TrackBoundary received before prebuffer complete - track too short"); + return Err(AudioError::ProcessingError("Track too short for prebuffer".to_string())); + } + SyncMarker::EndOfStream => { + tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete"); + // Fermer le track_tx pour que le pump se termine proprement + track_tx_opt = None; + // Marquer qu'on a reçu EndOfStream et continuer à attendre cache_future + end_of_stream_received = true; + } + _ => { + // Transmettre les autres syncmarkers au pump + if let Some(ref tx) = track_tx_opt { + let _ = tx.send(segment).await; + } + } + }, + } + } + None => { + // EOF sur rx pendant le prebuffer - attendre que cache_future se termine + if !end_of_stream_received { + tracing::debug!("FlacCacheSink: EOF on rx during prebuffer, waiting for ingestion to complete"); + track_tx_opt = None; + end_of_stream_received = true; + } + // Continue à attendre cache_future + } + } + } + + _ = stop_token.cancelled() => { + drop(track_tx_opt); + drop(pump_handle); + return Ok(()); + } + } }; - // Attendre les deux tâches en parallèle - let (copy_result, pump_result) = tokio::join!(copy_future, pump_future); - copy_result?; - let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?; - - // Ingérer le FLAC dans le cache - let flac_reader = Cursor::new(flac_buffer.clone()); - let collection_ref = self.collection.as_deref(); - let pk = self.cache - .add_from_reader( - None, - flac_reader, - Some(flac_buffer.len() as u64), - collection_ref, - ) - .await - .map_err(|e| { - AudioError::ProcessingError(format!("Failed to add to cache: {}", e)) - })?; - + // Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist // Copier les métadonnées du TrackBoundary dans le cache - if let Some(src_metadata) = track_metadata { + // IMPORTANT: Faire ceci AVANT d'ajouter à la playlist pour que les métadonnées soient disponibles + if let Some(src_metadata) = track_metadata.clone() { let dest_metadata = self.cache.track_metadata(&pk); // Utiliser copy_metadata_into pour copier toutes les métadonnées @@ -193,52 +291,153 @@ impl NodeLogic for FlacCacheSinkLogic { })?; let url = match dest_metadata.read().await.get_cover_url().await { - Ok(url) => url, - Err(e) if e.is_transient() => None, - Err(_) => { - warn!("Cannot obtain cover for audio asset {}", pk); + Ok(url) => { + tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url); + url + } + Err(e) if e.is_transient() => { + tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e); + None + } + Err(e) => { + tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e); None } }; - if url.is_some() { - let _ = match self.covers - .add_from_url(&url.unwrap(), self.collection.as_deref()) + if let Some(cover_url) = url { + tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url); + match self.covers + .add_from_url(&cover_url, self.collection.as_deref()) .await { Ok(pk_covers) => { - dest_metadata + tracing::info!("FlacCacheSink: Successfully cached cover for pk {} with cover pk {}", pk, pk_covers); + if let Err(e) = dest_metadata .write() .await .set_cover_pk(Some(pk_covers)) .await + { + tracing::error!("FlacCacheSink: Failed to set cover_pk for audio asset {}: {:?}", pk, e); + } } - Err(_) => { - warn!("Cannot obtain cover for audio asset {}", pk); - Ok(Some(())) + Err(e) => { + tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e); } - }; + } + } else { + tracing::debug!("FlacCacheSink: No cover URL available for pk {}", pk); } } - // Ajouter à la playlist si enregistrée + // Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!) #[cfg(feature = "playlist")] if let Some(ref playlist_handle) = self.playlist_handle { + let push_start = std::time::Instant::now(); + tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk); playlist_handle.push(pk.clone()).await.map_err(|e| { AudioError::ProcessingError(format!("Failed to add to playlist: {}", e)) })?; + tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed()); } - // Vérifier le stop_reason pour savoir si on continue - match stop_reason { - StopReason::TrackBoundary(_metadata) => { - // Continuer avec la prochaine track - track_number += 1; - continue; - } - StopReason::EndOfStream | StopReason::ChannelClosed => { - // Fin de l'encodage - return Ok(()); + // Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité + // Il faut juste attendre que le pump se termine et retourner + if end_of_stream_received { + tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete"); + drop(pump_handle); + track_number += 1; + continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream) + } + + // Phase 3: Continuer à dispatcher jusqu'au TrackBoundary + tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"); + let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false + let mut pump_handle = Some(pump_handle); + let mut pump_closed = false; + loop { + let segment = tokio::select! { + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + // EOF sur rx + drop(track_tx); + drop(pump_handle); + return Ok(()); + } + } + } + _ = stop_token.cancelled() => { + drop(track_tx); + drop(pump_handle); + return Ok(()); + } + }; + + match &segment.segment { + _AudioSegment::Chunk(_) => { + // Continuer à dispatcher vers le pump (sauf si déjà fermé) + if !pump_closed { + if let Some(ref tx) = track_tx { + if tx.send(segment).await.is_err() { + // Le pump a fermé son channel - cela peut arriver si le fichier + // était déjà en cache (add_from_reader retourne immédiatement) + tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status"); + drop(track_tx.take()); + + // Attendre que le pump se termine et vérifier le résultat + if let Some(handle) = pump_handle.take() { + match handle.await { + Ok(Ok(_)) => { + // Le pump s'est terminé proprement (fichier était en cache) + tracing::debug!("FlacCacheSink: pump completed successfully, ignoring remaining chunks until TrackBoundary"); + pump_closed = true; + } + Ok(Err(e)) => { + // Le pump a rencontré une erreur + tracing::error!("FlacCacheSink: pump died with error: {}", e); + return Err(e); + } + Err(e) => { + // Le pump task a paniqué + tracing::error!("FlacCacheSink: pump task panicked: {}", e); + return Err(AudioError::ProcessingError("Pump task panicked".to_string())); + } + } + } + } + } + } + // Si pump_closed, ignorer silencieusement le chunk + } + _AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { metadata } => { + // Nouveau morceau - fermer le pump si pas déjà fermé + tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track"); + // Stocker les métadonnées pour la prochaine track + next_track_metadata = Some(metadata.clone()); + drop(track_tx.take()); + drop(pump_handle.take()); + track_number += 1; + break; // Sort de la Phase 3, retour à la loop externe pour next track + } + SyncMarker::EndOfStream => { + tracing::debug!("FlacCacheSink: EndOfStream received"); + drop(track_tx.take()); + drop(pump_handle.take()); + return Ok(()); + } + _ => { + // Transmettre les autres syncmarkers au pump (sauf si fermé) + if !pump_closed { + if let Some(ref tx) = track_tx { + let _ = tx.send(segment).await; + } + } + } + }, } } } @@ -292,7 +491,7 @@ impl FlacCacheSink { encoder_options: EncoderOptions, collection: Option, ) -> Self { - let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8); + let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 256); Self { inner: Node::new_with_input(logic, channel_size), } @@ -309,8 +508,49 @@ impl FlacCacheSink { } } -/// Attend et retourne le premier chunk audio avec les métadonnées du TrackBoundary si présent. -/// Retourne une erreur si EndOfStream est reçu avant tout audio. +/// Attend le premier chunk audio (sans attendre de TrackBoundary) +/// Utilisé quand on a déjà reçu le TrackBoundary en Phase 3 de la track précédente +async fn wait_for_first_audio_chunk( + rx: &mut mpsc::Receiver>, + stop_token: &CancellationToken, +) -> Result, AudioError> { + loop { + let segment = tokio::select! { + result = rx.recv() => { + result.ok_or_else(|| AudioError::ProcessingError("No audio data received".into()))? + } + _ = stop_token.cancelled() => { + return Err(AudioError::ProcessingError("Cancelled".into())); + } + }; + + match &segment.segment { + _AudioSegment::Chunk(chunk) => { + if chunk.len() == 0 { + return Err(AudioError::ProcessingError("Received empty chunk".into())); + } + return Ok(segment); + } + _AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { .. } => { + // On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà + tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"); + continue; + } + SyncMarker::EndOfStream => { + return Err(AudioError::ProcessingError( + "EndOfStream received before any audio".into(), + )); + } + _ => { + // Ignorer TopZeroSync, Heartbeat, etc. + continue; + } + }, + } + } +} + async fn wait_for_first_audio_chunk_with_metadata( rx: &mut mpsc::Receiver>, stop_token: &CancellationToken, @@ -360,6 +600,50 @@ async fn wait_for_first_audio_chunk_with_metadata( } } +/// Draine tous les segments jusqu'au prochain TrackBoundary ou EndOfStream +/// +/// Cette fonction est utilisée quand le fichier était déjà en cache et que +/// nous devons ignorer les segments restants pour rester synchronisé avec la source. +async fn drain_until_track_boundary( + rx: &mut mpsc::Receiver>, + stop_token: &CancellationToken, +) -> Result { + loop { + let segment = tokio::select! { + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + return Ok(StopReason::ChannelClosed); + } + } + } + _ = stop_token.cancelled() => { + return Ok(StopReason::ChannelClosed); + } + }; + + match &segment.segment { + _AudioSegment::Chunk(_) => { + // Ignorer les chunks audio + continue; + } + _AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { metadata, .. } => { + return Ok(StopReason::TrackBoundary(metadata.clone())); + } + SyncMarker::EndOfStream => { + return Ok(StopReason::EndOfStream); + } + _ => { + // Ignorer les autres syncmarkers + continue; + } + }, + } + } +} + /// Pompe les segments pour une seule track (s'arrête au TrackBoundary). async fn pump_track_segments( first_segment: Arc, @@ -377,10 +661,12 @@ async fn pump_track_segments( if let Some(chunk) = first_segment.as_chunk() { let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; if !pcm_bytes.is_empty() { - pcm_tx - .send(pcm_bytes) - .await - .map_err(|_| AudioError::SendError)?; + // Si le send échoue, c'est que le receiver est fermé + // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); + } chunks += 1; samples += chunk.len() as u64; duration_sec += chunk.len() as f64 / expected_rate as f64; @@ -421,10 +707,12 @@ async fn pump_track_segments( continue; } - pcm_tx - .send(pcm_bytes) - .await - .map_err(|_| AudioError::SendError)?; + // Si le send échoue, c'est que le receiver est fermé + // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); + } chunks += 1; samples += chunk.len() as u64; @@ -450,6 +738,82 @@ async fn pump_track_segments( } } +/// Pompe les segments pour une seule track depuis un channel dédié. +/// +/// Cette version permet d'avoir plusieurs pumps en parallèle (pour cache progressif), +/// car chaque pump a son propre channel et ne bloque pas le traitement des tracks suivantes. +async fn pump_track_segments_from_channel( + first_segment: Arc, + mut track_rx: mpsc::Receiver>, + pcm_tx: mpsc::Sender>, + bits_per_sample: u8, + expected_rate: u32, +) -> Result<(u64, u64, f64), AudioError> { + let mut chunks = 0u64; + let mut samples = 0u64; + let mut duration_sec = 0.0f64; + + // Traiter le premier segment + if let Some(chunk) = first_segment.as_chunk() { + let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; + if !pcm_bytes.is_empty() { + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: pcm_tx closed on first segment"); + return Ok((chunks, samples, duration_sec)); + } + chunks += 1; + samples += chunk.len() as u64; + duration_sec += chunk.len() as f64 / expected_rate as f64; + } + } + + // Boucle sur les segments depuis le channel dédié + loop { + let segment = match track_rx.recv().await { + Some(seg) => seg, + None => { + // Channel fermé - la track est terminée (TrackBoundary a été reçu en amont) + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: channel closed, track finished"); + return Ok((chunks, samples, duration_sec)); + } + }; + + match &segment.segment { + _AudioSegment::Chunk(chunk) => { + if chunk.sample_rate() != expected_rate { + return Err(AudioError::ProcessingError(format!( + "FlacCacheSink: inconsistent sample rate ({} vs {})", + chunk.sample_rate(), + expected_rate + ))); + } + + let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?; + if pcm_bytes.is_empty() { + continue; + } + + if pcm_tx.send(pcm_bytes).await.is_err() { + // Le cache a fermé le channel (erreur ou déjà en cache) + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: pcm_tx closed"); + return Ok((chunks, samples, duration_sec)); + } + + chunks += 1; + samples += chunk.len() as u64; + duration_sec += chunk.len() as f64 / expected_rate as f64; + } + _AudioSegment::Sync(_marker) => { + // Ignorer les syncmarkers - le TrackBoundary est géré en amont + // Le channel sera fermé quand le TrackBoundary est détecté + } + } + } +} + /// Détermine la profondeur de bit d'un chunk audio fn get_chunk_bit_depth(chunk: &AudioChunk) -> u8 { match chunk { diff --git a/pmoaudio-ext/src/sinks/mod.rs b/pmoaudio-ext/src/sinks/mod.rs index 9cb71261..55a1b1b8 100755 --- a/pmoaudio-ext/src/sinks/mod.rs +++ b/pmoaudio-ext/src/sinks/mod.rs @@ -9,3 +9,15 @@ mod flac_cache_sink; #[cfg(feature = "cache-sink")] pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats}; + +#[cfg(feature = "http-stream")] +mod streaming_flac_sink; + +#[cfg(feature = "http-stream")] +pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream}; + +#[cfg(feature = "http-stream")] +mod streaming_ogg_flac_sink; + +#[cfg(feature = "http-stream")] +pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream}; diff --git a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs new file mode 100644 index 00000000..9f8e1df7 --- /dev/null +++ b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs @@ -0,0 +1,1069 @@ +//! Streaming FLAC sink for multi-track radio-style streaming over HTTP. +//! +//! This sink encodes incoming audio segments into a continuous FLAC stream, +//! broadcasts it to multiple concurrent clients (UPnP renderers, web players, etc.), +//! and supports ICY metadata for "Now Playing" updates. +//! +//! # Architecture +//! +//! ```text +//! AudioSegment Pipeline +//! ↓ +//! StreamingFlacSink +//! ↓ +//! [Convert AudioChunk → PCM bytes] +//! ↓ +//! ByteStreamReader (AsyncRead) +//! ↓ +//! pmoflac::encode_flac_stream() +//! ↓ +//! [Broadcaster Task] +//! ↓ +//! broadcast::channel (FLAC bytes) +//! ↓ +//! Multiple clients via StreamHandle::subscribe() +//! ├─ FLAC pure (for standard renderers) +//! └─ ICY-wrapped FLAC (for metadata-aware clients) +//! ``` +//! +//! # Usage Example +//! +//! ```no_run +//! use pmoaudio_ext::sinks::StreamingFlacSink; +//! use pmoflac::EncoderOptions; +//! +//! // Create the sink and get the handle for HTTP serving +//! let (sink, handle) = StreamingFlacSink::new( +//! EncoderOptions::default(), +//! 16, // bits per sample +//! ); +//! +//! // Add to audio pipeline +//! source.register(Box::new(sink)); +//! +//! // In your HTTP handler (e.g., pmoparadise): +//! if headers.get("Icy-MetaData") == Some("1") { +//! // ICY mode with metadata updates +//! let stream = handle.subscribe_icy(); +//! response.header("icy-metaint", "16000"); +//! Body::from_stream(ReaderStream::new(stream)) +//! } else { +//! // Pure FLAC mode +//! let stream = handle.subscribe_flac(); +//! Body::from_stream(ReaderStream::new(stream)) +//! } +//! ``` + +use std::collections::VecDeque; +use std::io; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use async_trait::async_trait; +use bytes::Bytes; +use pmoaudio::{ + pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason}, + AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, + _AudioSegment, +}; +use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat}; +use pmometadata::TrackMetadata; +use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; +use tokio::sync::{broadcast, mpsc, RwLock}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, trace, warn}; + +/// Default ICY metadata interval (bytes of audio between metadata blocks). +/// Standard value used by most streaming servers. +const DEFAULT_ICY_METAINT: usize = 16000; + +/// Broadcast channel capacity for FLAC bytes. +/// Set to 128 to provide ~10 seconds of buffer for network jitter. +/// With TimerNode pacing the stream to real-time, this is sufficient +/// while keeping metadata synchronized (larger buffers cause metadata drift). +const BROADCAST_CAPACITY: usize = 128; + +/// Maximum lead time for HTTP broadcast pacing (in seconds). +/// The broadcaster will sleep if it's ahead of real-time by more than this amount. +/// This is much smaller than the pipeline TimerNode's 3.0s to provide tighter control. +const BROADCAST_MAX_LEAD_TIME: f64 = 0.5; + +/// PCM chunk with audio data and timestamp for precise pacing. +#[derive(Debug)] +struct PcmChunk { + /// Raw PCM audio bytes + bytes: Vec, + /// Timestamp in seconds (from AudioSegment) + timestamp_sec: f64, +} + +/// Snapshot of track metadata at a point in time. +/// +/// This structure is shared between the sink and clients to provide +/// real-time metadata updates as tracks change in a continuous stream. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct MetadataSnapshot { + /// Track title + pub title: Option, + /// Artist name + pub artist: Option, + /// Album name + pub album: Option, + /// Track duration + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + /// Cover image URL (external/original) + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_url: Option, + /// Cover primary key in local cache (for constructing server URL) + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_pk: Option, + /// Track number + #[serde(skip_serializing_if = "Option::is_none")] + pub track_number: Option, + /// Album artist + #[serde(skip_serializing_if = "Option::is_none")] + pub album_artist: Option, + /// Genre + #[serde(skip_serializing_if = "Option::is_none")] + pub genre: Option, + /// Year + #[serde(skip_serializing_if = "Option::is_none")] + pub year: Option, + /// Audio timestamp where this metadata became active (seconds) + pub audio_timestamp_sec: f64, + /// Version counter incremented on each update (for client-side change detection) + pub version: u64, +} + +/// Handle for accessing the FLAC stream and metadata from HTTP handlers. +/// +/// This handle is designed to be cloned and used by multiple HTTP clients +/// simultaneously. Each client gets its own independent stream by subscribing. +#[derive(Clone)] +pub struct StreamHandle { + /// Broadcast sender for FLAC bytes (pure mode) + flac_broadcast: broadcast::Sender, + + /// Current track metadata (read-only for consumers) + metadata: Arc>, + + /// Active client counter + active_clients: Arc, + + /// Stop token to signal pipeline shutdown + stop_token: CancellationToken, + + /// Cached FLAC header (sent to new subscribers first) + flac_header: Arc>>, +} + +impl StreamHandle { + /// Subscribe to the FLAC stream in pure mode (no ICY metadata). + /// + /// Returns an `AsyncRead` stream suitable for use with `tokio_util::io::ReaderStream`. + pub fn subscribe_flac(&self) -> FlacClientStream { + let count = self.active_clients.fetch_add(1, Ordering::SeqCst); + debug!("New FLAC client subscribed (total: {})", count + 1); + + FlacClientStream { + rx: self.flac_broadcast.subscribe(), + buffer: VecDeque::new(), + finished: false, + handle: self.clone(), + state: FlacStreamState::SendingHeader, + } + } + + /// Subscribe to the FLAC stream with ICY metadata injection. + /// + /// Returns an `AsyncRead` stream that injects ICY metadata blocks + /// at regular intervals (default: every 16000 bytes). + pub fn subscribe_icy(&self) -> IcyClientStream { + self.subscribe_icy_with_interval(DEFAULT_ICY_METAINT) + } + + /// Subscribe to the FLAC stream with custom ICY metadata interval. + pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream { + let count = self.active_clients.fetch_add(1, Ordering::SeqCst); + debug!("New ICY client subscribed (total: {}, metaint: {})", count + 1, metaint); + + IcyClientStream { + rx: self.flac_broadcast.subscribe(), + metadata: self.metadata.clone(), + metaint, + byte_count: 0, + buffer: VecDeque::new(), + current_metadata_version: 0, + cached_icy_metadata: Bytes::new(), + finished: false, + handle: self.clone(), + state: FlacStreamState::SendingHeader, + } + } + + /// Get the current metadata snapshot. + pub async fn get_metadata(&self) -> MetadataSnapshot { + self.metadata.read().await.clone() + } + + /// Get the number of active clients. + pub fn active_client_count(&self) -> usize { + self.active_clients.load(Ordering::SeqCst) + } + + /// Check if the stream should be stopped (no more clients). + pub fn should_stop(&self) -> bool { + self.active_clients.load(Ordering::SeqCst) == 0 + } +} + +/// State for FLAC stream subscription. +enum FlacStreamState { + SendingHeader, + Streaming, +} + +/// Pure FLAC client stream (implements AsyncRead). +pub struct FlacClientStream { + rx: broadcast::Receiver, + buffer: VecDeque, + finished: bool, + handle: StreamHandle, + state: FlacStreamState, +} + +impl AsyncRead for FlacClientStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + // If in header state, send the header first + if matches!(self.state, FlacStreamState::SendingHeader) { + let header_opt = if let Ok(guard) = self.handle.flac_header.try_read() { + guard.clone() + } else { + None + }; + + if let Some(header) = header_opt { + self.buffer.extend(header.iter()); + info!("Sending cached FLAC header to new client ({} bytes)", header.len()); + self.state = FlacStreamState::Streaming; + continue; // Now copy header to output buffer + } else { + // Header not yet captured or can't acquire lock, skip to streaming + self.state = FlacStreamState::Streaming; + } + } + + // If we have buffered data, copy it + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + // Try to receive more data + match self.rx.try_recv() { + Ok(bytes) => { + self.buffer.extend(bytes.iter()); + } + Err(broadcast::error::TryRecvError::Empty) => { + // No data available right now. + // Schedule a wakeup after a small delay to avoid busy-loop polling. + let waker = cx.waker().clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + waker.wake(); + }); + return Poll::Pending; + } + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + warn!("FLAC client lagged, skipped {} messages", skipped); + // Continue to try receiving again + } + Err(broadcast::error::TryRecvError::Closed) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + } + } + } +} + +impl Drop for FlacClientStream { + fn drop(&mut self) { + let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); + debug!("FLAC client disconnected (remaining: {})", count - 1); + + if count == 1 { + info!("Last client disconnected, signaling pipeline stop"); + self.handle.stop_token.cancel(); + } + } +} + +/// ICY-wrapped FLAC client stream (implements AsyncRead). +/// +/// This stream injects ICY metadata blocks at regular intervals, +/// allowing clients to display "Now Playing" information. +pub struct IcyClientStream { + rx: broadcast::Receiver, + metadata: Arc>, + metaint: usize, + byte_count: usize, + buffer: VecDeque, + current_metadata_version: u64, + cached_icy_metadata: Bytes, + finished: bool, + handle: StreamHandle, + state: FlacStreamState, +} + +impl IcyClientStream { + /// Format metadata as ICY metadata block. + /// + /// ICY format: StreamTitle='Artist - Title';StreamUrl='url'; + /// Padded to multiple of 16 bytes, prefixed with length byte. + /// + /// If cover_pk is available, constructs a URL for the cover image: + /// - If pmoserver is initialized: http://server/covers/image/{pk}/256 + /// - Otherwise: relative URL /covers/image/{pk}/256 + fn format_icy_metadata(meta: &MetadataSnapshot) -> Bytes { + let title = meta.title.as_deref().unwrap_or("Unknown"); + let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); + + // Build ICY metadata string with cover URL if available + let mut metadata_str = format!("StreamTitle='{} - {}';", artist, title); + + // Add cover URL if we have a cover_pk + if let Some(pk) = &meta.cover_pk { + // Use relative URL /covers/image/{pk}/256 + // This works when streaming from the same server that serves covers + // VLC and other players will resolve relative URLs correctly + metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk)); + } else if let Some(url) = &meta.cover_url { + // Fallback to external cover URL if no local pk + metadata_str.push_str(&format!("StreamUrl='{}';", url)); + } + + // ICY metadata is padded to multiple of 16 bytes + let metadata_bytes = metadata_str.as_bytes(); + let length = metadata_bytes.len(); + let padded_length = ((length + 15) / 16) * 16; + let length_byte = (padded_length / 16) as u8; + + let mut result = Vec::with_capacity(1 + padded_length); + result.push(length_byte); + result.extend_from_slice(metadata_bytes); + result.resize(1 + padded_length, 0); // Pad with zeros + + Bytes::from(result) + } + + /// Get metadata block if it needs to be inserted. + async fn get_metadata_if_changed(&mut self) -> Option { + let meta = self.metadata.read().await; + if meta.version > self.current_metadata_version { + self.current_metadata_version = meta.version; + let icy_meta = Self::format_icy_metadata(&meta); + self.cached_icy_metadata = icy_meta.clone(); + Some(icy_meta) + } else if self.byte_count == 0 { + // Always send metadata at the start + Some(self.cached_icy_metadata.clone()) + } else { + // No change, send empty metadata block + Some(Bytes::from(vec![0u8])) + } + } +} + +impl AsyncRead for IcyClientStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + // If in header state, send the header first + if matches!(self.state, FlacStreamState::SendingHeader) { + let header_opt = if let Ok(guard) = self.handle.flac_header.try_read() { + guard.clone() + } else { + None + }; + + if let Some(header) = header_opt { + self.buffer.extend(header.iter()); + info!("Sending cached FLAC header to new ICY client ({} bytes)", header.len()); + self.state = FlacStreamState::Streaming; + continue; // Now copy header to output buffer + } else { + // Header not yet captured or can't acquire lock, skip to streaming + self.state = FlacStreamState::Streaming; + } + } + + // If we have buffered data, copy it + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + // Check if we need to insert metadata + if self.byte_count % self.metaint == 0 && self.byte_count > 0 { + // Time to insert ICY metadata + // Use try_read to avoid blocking in poll context + let update = { + if let Ok(meta) = self.metadata.try_read() { + if meta.version > self.current_metadata_version { + Some((meta.version, Self::format_icy_metadata(&meta))) + } else { + None + } + } else { + None + } + }; + + if let Some((new_version, new_metadata)) = update { + self.current_metadata_version = new_version; + self.cached_icy_metadata = new_metadata; + } + + let icy_data = self.cached_icy_metadata.clone(); + self.buffer.extend(icy_data.iter()); + self.byte_count = 0; // Reset counter after metadata + continue; + } + + // Try to receive audio data + match self.rx.try_recv() { + Ok(bytes) => { + // Calculate how many bytes until next metadata block + let until_metadata = self.metaint - (self.byte_count % self.metaint); + let to_buffer = bytes.len().min(until_metadata); + + self.buffer.extend(bytes[..to_buffer].iter()); + self.byte_count += to_buffer; + + // If we have more data, we'll process it in the next iteration + if to_buffer < bytes.len() { + // Save remaining for next iteration + // For now, we'll just drop it and get it again + // TODO: Improve this + } + } + Err(broadcast::error::TryRecvError::Empty) => { + // No data available right now. + // Schedule a wakeup after a small delay to avoid busy-loop polling. + let waker = cx.waker().clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + waker.wake(); + }); + return Poll::Pending; + } + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + warn!("ICY client lagged, skipped {} messages", skipped); + } + Err(broadcast::error::TryRecvError::Closed) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + } + } + } +} + +impl Drop for IcyClientStream { + fn drop(&mut self) { + let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); + debug!("ICY client disconnected (remaining: {})", count - 1); + + if count == 1 { + info!("Last client disconnected, signaling pipeline stop"); + self.handle.stop_token.cancel(); + } + } +} + +/// Internal state for encoder initialization. +struct EncoderState { + broadcaster_task: tokio::task::JoinHandle<()>, +} + +/// Logic for the streaming FLAC sink. +struct StreamingFlacSinkLogic { + encoder_options: EncoderOptions, + bits_per_sample: u8, + pcm_tx: mpsc::Sender, + pcm_rx: Option>, + metadata: Arc>, + flac_broadcast: broadcast::Sender, + flac_header: Arc>>, + encoder_state: Option, + sample_rate: Option, +} + +impl StreamingFlacSinkLogic { + /// Initialize the FLAC encoder once we know the sample rate. + async fn initialize_encoder(&mut self, sample_rate: u32) -> Result<(), AudioError> { + if self.encoder_state.is_some() { + return Ok(()); // Already initialized + } + + info!("Initializing FLAC encoder with sample rate: {} Hz", sample_rate); + + // Take the PCM receiver (we only initialize once) + let pcm_rx = self.pcm_rx.take().ok_or_else(|| { + AudioError::ProcessingError("PCM receiver already consumed".into()) + })?; + + // Create shared timestamp for pacing + let current_timestamp = Arc::new(RwLock::new(0.0f64)); + + // Create ByteStreamReader for the encoder + let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone()); + + // Create PCM format + let pcm_format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: self.bits_per_sample, + }; + + // Start the FLAC encoder + let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) + .await + .map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?; + + info!("FLAC encoder initialized successfully"); + + // Spawn broadcaster task with timestamp for pacing + let flac_broadcast = self.flac_broadcast.clone(); + let flac_header = self.flac_header.clone(); + let broadcaster_task = tokio::spawn(async move { + if let Err(e) = broadcast_flac_stream(flac_stream, flac_broadcast, flac_header, current_timestamp).await { + error!("Broadcaster task error: {}", e); + } + }); + + self.encoder_state = Some(EncoderState { broadcaster_task }); + + info!("Broadcaster task spawned"); + + Ok(()) + } + + /// Update metadata from a TrackBoundary marker. + async fn update_metadata( + &mut self, + metadata_lock: &Arc>, + timestamp_sec: f64, + ) -> Result<(), AudioError> { + let metadata = metadata_lock.read().await; + + let mut snapshot = self.metadata.write().await; + + // Extract all metadata fields + snapshot.title = metadata.get_title().await.ok().flatten(); + snapshot.artist = metadata.get_artist().await.ok().flatten(); + snapshot.album = metadata.get_album().await.ok().flatten(); + snapshot.duration = metadata.get_duration().await.ok().flatten(); + snapshot.cover_url = metadata.get_cover_url().await.ok().flatten(); + snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten(); + snapshot.year = metadata.get_year().await.ok().flatten(); + + // Extract extra fields + if let Ok(Some(extra)) = metadata.get_extra().await { + snapshot.genre = extra.get("genre").cloned(); + snapshot.track_number = extra + .get("track_number") + .and_then(|s| s.parse::().ok()); + } + + snapshot.audio_timestamp_sec = timestamp_sec; + snapshot.version += 1; + + debug!( + "Metadata updated: v{} @ {:.2}s - {} - {} (cover_pk: {:?})", + snapshot.version, + timestamp_sec, + snapshot.artist.as_deref().unwrap_or("?"), + snapshot.title.as_deref().unwrap_or("?"), + snapshot.cover_pk + ); + + Ok(()) + } +} + +#[async_trait] +impl NodeLogic for StreamingFlacSinkLogic { + async fn process( + &mut self, + input: Option>>, + _output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut input = input.ok_or_else(|| { + AudioError::ProcessingError("StreamingFlacSink requires an input".into()) + })?; + + info!("StreamingFlacSink started"); + + // We'll initialize the encoder lazily when we get the first chunk + // For now, just process segments + + loop { + tokio::select! { + _ = stop_token.cancelled() => { + info!("StreamingFlacSink stopped by cancellation"); + break; + } + + segment = input.recv() => { + match segment { + Some(seg) => { + match &seg.segment { + _AudioSegment::Chunk(chunk) => { + // Detect sample rate from first chunk and initialize encoder + if self.sample_rate.is_none() { + let sample_rate = chunk.sample_rate(); + self.sample_rate = Some(sample_rate); + info!("Detected sample rate: {} Hz", sample_rate); + + // Initialize the FLAC encoder now + self.initialize_encoder(sample_rate).await?; + } + + // Convert chunk to PCM bytes + let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?; + + trace!( + "Sending PCM chunk: {} bytes, {} samples @ {:.2}s", + pcm_bytes.len(), + chunk.len(), + seg.timestamp_sec + ); + + // Send to FLAC encoder with timestamp + let pcm_chunk = PcmChunk { + bytes: pcm_bytes, + timestamp_sec: seg.timestamp_sec, + }; + if let Err(e) = self.pcm_tx.send(pcm_chunk).await { + warn!("Failed to send PCM data to encoder: {}", e); + break; + } + } + + _AudioSegment::Sync(marker) => { + match marker.as_ref() { + SyncMarker::TrackBoundary { metadata } => { + if let Err(e) = self.update_metadata(metadata, seg.timestamp_sec).await { + error!("Failed to update metadata: {}", e); + } + } + + SyncMarker::EndOfStream => { + info!("End of stream marker received"); + break; + } + + _ => { + trace!("Received other sync marker"); + } + } + } + } + } + + None => { + info!("Input channel closed"); + break; + } + } + } + } + } + + info!("StreamingFlacSink processing complete"); + Ok(()) + } + + async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> { + info!("StreamingFlacSink cleanup: {:?}", reason); + Ok(()) + } +} + +/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients. +/// Implements precise real-time pacing based on audio timestamps. +async fn broadcast_flac_stream( + mut flac_stream: FlacEncodedStream, + broadcast_tx: broadcast::Sender, + header_cache: Arc>>, + current_timestamp: Arc>, +) -> Result<(), AudioError> { + info!("Broadcaster task started with precise timestamp-based pacing"); + + let mut buffer = vec![0u8; 8192]; // 8KB buffer for reading + let mut total_bytes = 0u64; + let mut header_captured = false; + let start_time = std::time::Instant::now(); + + loop { + match flac_stream.read(&mut buffer).await { + Ok(0) => { + // EOF + info!("FLAC encoder stream ended, total bytes: {}", total_bytes); + break; + } + Ok(n) => { + total_bytes += n as u64; + if total_bytes % 100000 == 0 || total_bytes < 10000 { + info!("Read {} bytes from FLAC encoder (total: {})", n, total_bytes); + } + + // Precise pacing based on audio timestamp + let audio_timestamp = *current_timestamp.read().await; + let elapsed = start_time.elapsed().as_secs_f64(); + let lead_time = audio_timestamp - elapsed; + + if lead_time > BROADCAST_MAX_LEAD_TIME { + let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME; + debug!( + "Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)", + sleep_duration, audio_timestamp, elapsed, lead_time + ); + tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await; + } + + // Broadcast to all clients + let bytes = Bytes::copy_from_slice(&buffer[..n]); + + // Capture first chunk as header if it contains "fLaC" + if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" { + *header_cache.write().await = Some(bytes.clone()); + header_captured = true; + info!("FLAC header captured ({} bytes)", bytes.len()); + } + + let num_receivers = broadcast_tx.receiver_count(); + if let Err(e) = broadcast_tx.send(bytes) { + // No receivers, but that's okay - clients may not be connected yet + trace!("No active receivers for FLAC broadcast: {}", e); + } else if num_receivers > 0 { + trace!("Broadcasted {} bytes to {} receivers", n, num_receivers); + } + } + Err(e) => { + error!("Error reading from FLAC encoder: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder read error: {}", + e + ))); + } + } + } + + // Wait for the encoder to finish cleanly + if let Err(e) = flac_stream.wait().await { + error!("FLAC encoder error during cleanup: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder error: {}", + e + ))); + } + + info!("Broadcaster task completed successfully"); + Ok(()) +} + +/// Streaming FLAC sink for multi-client HTTP streaming. +pub struct StreamingFlacSink { + inner: Node, +} + +impl StreamingFlacSink { + /// Create a new streaming FLAC sink. + /// + /// # Arguments + /// + /// * `encoder_options` - FLAC encoder configuration + /// * `bits_per_sample` - Target bit depth (16, 24, or 32) + /// + /// # Returns + /// + /// A tuple of `(sink, handle)` where: + /// - `sink` is added to the audio pipeline + /// - `handle` is used by HTTP handlers to serve streams + pub fn new( + encoder_options: EncoderOptions, + bits_per_sample: u8, + ) -> (Self, StreamHandle) { + // Validate bit depth + if ![16, 24, 32].contains(&bits_per_sample) { + panic!("bits_per_sample must be 16, 24, or 32"); + } + + // Create PCM channel (bounded for backpressure) + let (pcm_tx, pcm_rx) = mpsc::channel::(16); + + // Shared metadata + let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); + + // Broadcast channel for FLAC bytes + let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY); + + // FLAC header cache + let flac_header = Arc::new(RwLock::new(None)); + + // Stop token and client counter + let stop_token = CancellationToken::new(); + let active_clients = Arc::new(AtomicUsize::new(0)); + + let handle = StreamHandle { + flac_broadcast: flac_broadcast.clone(), + metadata: metadata.clone(), + active_clients, + stop_token: stop_token.clone(), + flac_header: flac_header.clone(), + }; + + let logic = StreamingFlacSinkLogic { + encoder_options, + bits_per_sample, + pcm_tx, + pcm_rx: Some(pcm_rx), + metadata, + flac_broadcast, + flac_header, + encoder_state: None, + sample_rate: None, + }; + + let sink = Self { + inner: Node::new_with_input(logic, 16), + }; + + (sink, handle) + } +} + +#[async_trait] +impl AudioPipelineNode for StreamingFlacSink { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, _child: Box) { + panic!("StreamingFlacSink is a terminal sink and cannot have children"); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } + + fn start(self: Box) -> PipelineHandle { + Box::new(self.inner).start() + } +} + +impl TypedAudioNode for StreamingFlacSink { + fn input_type(&self) -> Option { + Some(TypeRequirement::any_integer()) + } + + fn output_type(&self) -> Option { + None + } +} + +/// Convert an AudioChunk to PCM bytes with specified bit depth. +fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result, AudioError> { + match chunk { + AudioChunk::F32(_) | AudioChunk::F64(_) => { + return Err(AudioError::ProcessingError( + "StreamingFlacSink only supports integer audio chunks".into(), + )); + } + _ => {} + } + + let len = chunk.len(); + let bytes_per_frame = (bits_per_sample / 8) as usize * 2; + let mut bytes = Vec::with_capacity(len * bytes_per_frame); + + match (chunk, bits_per_sample) { + (AudioChunk::I16(data), 16) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + (AudioChunk::I16(data), 24) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 8; + let right = (frame[1] as i32) << 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I16(data), 32) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 16; + let right = (frame[1] as i32) << 16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0].as_i32() >> 8) as i16; + let right = (frame[1].as_i32() >> 8) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 24) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); + bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); + } + } + (AudioChunk::I24(data), 32) => { + for frame in data.get_frames() { + let left = frame[0].as_i32() << 8; + let right = frame[1].as_i32() << 8; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0] >> 16) as i16; + let right = (frame[1] >> 16) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 24) => { + for frame in data.get_frames() { + let left = frame[0] >> 8; + let right = frame[1] >> 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I32(data), 32) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + _ => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bits_per_sample: {}", + bits_per_sample + ))); + } + } + + Ok(bytes) +} + +/// AsyncRead adapter for mpsc::Receiver. +/// Extracts bytes from PcmChunk and provides them to the FLAC encoder. +struct ByteStreamReader { + rx: mpsc::Receiver, + buffer: VecDeque, + finished: bool, + /// Shared timestamp for broadcaster pacing + current_timestamp: Arc>, +} + +impl ByteStreamReader { + fn new(rx: mpsc::Receiver, current_timestamp: Arc>) -> Self { + Self { + rx, + buffer: VecDeque::new(), + finished: false, + current_timestamp, + } + } +} + +impl AsyncRead for ByteStreamReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + match Pin::new(&mut self.rx).poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + if chunk.bytes.is_empty() { + continue; + } + // Update shared timestamp for broadcaster pacing + if let Ok(mut ts) = self.current_timestamp.try_write() { + *ts = chunk.timestamp_sec; + } + self.buffer.extend(chunk.bytes); + } + Poll::Ready(None) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + Poll::Pending => return Poll::Pending, + } + } + } +} diff --git a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs new file mode 100644 index 00000000..20053c40 --- /dev/null +++ b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs @@ -0,0 +1,1067 @@ +//! Streaming OGG-FLAC sink for multi-track radio-style streaming over HTTP. +//! +//! This sink encodes incoming audio segments into OGG-FLAC format with proper +//! OGG chaining for track boundaries. Unlike pure FLAC, OGG-FLAC supports +//! embedded metadata via Vorbis Comments that update with each track. +//! +//! # Architecture +//! +//! ```text +//! AudioSegment Pipeline +//! ↓ +//! StreamingOggFlacSink +//! ↓ +//! [TrackBoundary detection] +//! ↓ +//! [Convert AudioChunk → PCM bytes] +//! ↓ +//! ByteStreamReader (AsyncRead) +//! ↓ +//! pmoflac::encode_flac_stream() +//! ↓ +//! [OGG Wrapper Task] - wraps FLAC frames in OGG pages +//! ↓ +//! broadcast::channel (OGG-FLAC bytes) +//! ↓ +//! Multiple HTTP clients +//! ``` +//! +//! # OGG Chaining +//! +//! When a `TrackBoundary` marker is received: +//! 1. Flush current FLAC encoder +//! 2. Write OGG page with EOS flag (End of Stream) +//! 3. Extract metadata from TrackBoundary +//! 4. Start new logical bitstream with BOS flag (Beginning of Stream) +//! 5. Write new OGG-FLAC headers with updated Vorbis Comments +//! 6. Continue encoding +//! +//! This allows seamless track changes with metadata updates. +//! +//! # 100% Streaming Guarantee +//! +//! - No track buffering: AudioChunks are converted to PCM immediately +//! - FLAC encoder produces frames as soon as it has enough samples +//! - OGG wrapper reads FLAC frames and creates pages on-the-fly +//! - Pages are broadcast immediately to connected clients +//! - TrackBoundary only triggers encoder flush (no data accumulation) + +use std::collections::VecDeque; +use std::io; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use async_trait::async_trait; +use bytes::Bytes; +use pmoaudio::{ + pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason}, + AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, + _AudioSegment, +}; +use pmoflac::{encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat}; +use pmometadata::TrackMetadata; +use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; +use tokio::sync::{broadcast, mpsc, RwLock}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, trace, warn}; + +/// Broadcast channel capacity for OGG-FLAC bytes. +/// Same as StreamingFlacSink for consistency. +const BROADCAST_CAPACITY: usize = 128; + +/// Maximum lead time for HTTP broadcast pacing (in seconds). +/// The broadcaster will sleep if it's ahead of real-time by more than this amount. +const BROADCAST_MAX_LEAD_TIME: f64 = 0.5; + +/// PCM chunk with audio data and timestamp for precise pacing. +#[derive(Debug)] +struct PcmChunk { + /// Raw PCM audio bytes + bytes: Vec, + /// Timestamp in seconds (from AudioSegment) + timestamp_sec: f64, +} + +/// Snapshot of track metadata (reuse from streaming_flac_sink) +pub use super::streaming_flac_sink::MetadataSnapshot; + +/// Handle for accessing the OGG-FLAC stream and metadata from HTTP handlers. +#[derive(Clone)] +pub struct OggFlacStreamHandle { + /// Broadcast sender for OGG-FLAC bytes + ogg_broadcast: broadcast::Sender, + + /// Current track metadata + metadata: Arc>, + + /// Active client counter + active_clients: Arc, + + /// Stop token to signal pipeline shutdown + stop_token: CancellationToken, + + /// Cached OGG-FLAC header (sent to new subscribers first) + ogg_header: Arc>>, +} + +impl OggFlacStreamHandle { + /// Subscribe to the OGG-FLAC stream. + /// + /// Returns an `AsyncRead` stream suitable for use with `tokio_util::io::ReaderStream`. + pub fn subscribe(&self) -> OggFlacClientStream { + let count = self.active_clients.fetch_add(1, Ordering::SeqCst); + debug!("New OGG-FLAC client subscribed (total: {})", count + 1); + + OggFlacClientStream { + rx: self.ogg_broadcast.subscribe(), + buffer: VecDeque::new(), + finished: false, + handle: self.clone(), + state: OggFlacStreamState::SendingHeader, + } + } + + /// Get the current metadata snapshot. + pub async fn get_metadata(&self) -> MetadataSnapshot { + self.metadata.read().await.clone() + } + + /// Get the number of active clients. + pub fn active_client_count(&self) -> usize { + self.active_clients.load(Ordering::SeqCst) + } +} + +/// State for OGG-FLAC stream subscription. +enum OggFlacStreamState { + SendingHeader, + Streaming, +} + +/// OGG-FLAC client stream (implements AsyncRead). +pub struct OggFlacClientStream { + rx: broadcast::Receiver, + buffer: VecDeque, + finished: bool, + handle: OggFlacStreamHandle, + state: OggFlacStreamState, +} + +impl AsyncRead for OggFlacClientStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + // If in header state, send the header first + if matches!(self.state, OggFlacStreamState::SendingHeader) { + let header_opt = if let Ok(guard) = self.handle.ogg_header.try_read() { + guard.clone() + } else { + None + }; + + if let Some(header) = header_opt { + self.buffer.extend(header.iter()); + info!("Sending cached OGG-FLAC header to new client ({} bytes)", header.len()); + self.state = OggFlacStreamState::Streaming; + continue; // Now copy header to output buffer + } else { + // Header not yet captured, skip to streaming + self.state = OggFlacStreamState::Streaming; + } + } + + // If we have buffered data, copy it + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + // Try to receive more data + match self.rx.try_recv() { + Ok(bytes) => { + self.buffer.extend(bytes.iter()); + } + Err(broadcast::error::TryRecvError::Empty) => { + // No data available right now. + // Schedule a wakeup after a small delay to avoid busy-loop polling. + let waker = cx.waker().clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + waker.wake(); + }); + return Poll::Pending; + } + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + warn!("OGG-FLAC client lagged, skipped {} messages", skipped); + } + Err(broadcast::error::TryRecvError::Closed) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + } + } + } +} + +impl Drop for OggFlacClientStream { + fn drop(&mut self) { + let count = self.handle.active_clients.fetch_sub(1, Ordering::SeqCst); + debug!("OGG-FLAC client disconnected (remaining: {})", count - 1); + + if count == 1 { + info!("Last OGG-FLAC client disconnected, signaling pipeline stop"); + self.handle.stop_token.cancel(); + } + } +} + +/// Internal state for encoder initialization. +struct EncoderState { + broadcaster_task: tokio::task::JoinHandle<()>, +} + +/// Logic for the streaming OGG-FLAC sink. +struct StreamingOggFlacSinkLogic { + encoder_options: EncoderOptions, + bits_per_sample: u8, + pcm_tx: mpsc::Sender, + pcm_rx: Option>, + metadata: Arc>, + ogg_broadcast: broadcast::Sender, + ogg_header: Arc>>, + encoder_state: Option, + sample_rate: Option, +} + +impl StreamingOggFlacSinkLogic { + /// Initialize the FLAC encoder once we know the sample rate. + async fn initialize_encoder(&mut self, sample_rate: u32) -> Result<(), AudioError> { + if self.encoder_state.is_some() { + return Ok(()); // Already initialized + } + + info!("Initializing OGG-FLAC encoder with sample rate: {} Hz", sample_rate); + + // Take the PCM receiver (we only initialize once) + let pcm_rx = self.pcm_rx.take().ok_or_else(|| { + AudioError::ProcessingError("PCM receiver already consumed".into()) + })?; + + // Create shared timestamp for pacing + let current_timestamp = Arc::new(RwLock::new(0.0f64)); + + // Create ByteStreamReader for the encoder + let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone()); + + // Create PCM format + let pcm_format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: self.bits_per_sample, + }; + + // Start the FLAC encoder + let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone()) + .await + .map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?; + + info!("OGG-FLAC encoder initialized successfully"); + + // Spawn OGG wrapper + broadcaster task with timestamp for pacing + let ogg_broadcast = self.ogg_broadcast.clone(); + let ogg_header = self.ogg_header.clone(); + let broadcaster_task = tokio::spawn(async move { + if let Err(e) = broadcast_ogg_flac_stream(flac_stream, ogg_broadcast, ogg_header, current_timestamp).await { + error!("OGG broadcaster task error: {}", e); + } + }); + + self.encoder_state = Some(EncoderState { broadcaster_task }); + + info!("OGG broadcaster task spawned"); + + Ok(()) + } + + /// Update metadata from a TrackBoundary marker. + async fn update_metadata( + &mut self, + metadata_lock: &Arc>, + timestamp_sec: f64, + ) -> Result<(), AudioError> { + let metadata = metadata_lock.read().await; + + let mut snapshot = self.metadata.write().await; + + // Extract all metadata fields + snapshot.title = metadata.get_title().await.ok().flatten(); + snapshot.artist = metadata.get_artist().await.ok().flatten(); + snapshot.album = metadata.get_album().await.ok().flatten(); + snapshot.duration = metadata.get_duration().await.ok().flatten(); + snapshot.cover_url = metadata.get_cover_url().await.ok().flatten(); + snapshot.cover_pk = metadata.get_cover_pk().await.ok().flatten(); + snapshot.year = metadata.get_year().await.ok().flatten(); + + // Extract extra fields + if let Ok(Some(extra)) = metadata.get_extra().await { + snapshot.genre = extra.get("genre").cloned(); + snapshot.track_number = extra + .get("track_number") + .and_then(|s| s.parse::().ok()); + } + + snapshot.audio_timestamp_sec = timestamp_sec; + snapshot.version += 1; + + debug!( + "OGG-FLAC metadata updated: v{} @ {:.2}s - {} - {}", + snapshot.version, + timestamp_sec, + snapshot.artist.as_deref().unwrap_or("?"), + snapshot.title.as_deref().unwrap_or("?") + ); + + Ok(()) + } +} + +#[async_trait] +impl NodeLogic for StreamingOggFlacSinkLogic { + async fn process( + &mut self, + input: Option>>, + _output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut input = input.ok_or_else(|| { + AudioError::ProcessingError("StreamingOggFlacSink requires an input".into()) + })?; + + info!("StreamingOggFlacSink started"); + + // TODO: Implement OGG-FLAC encoding logic + // For now, just process segments without encoding + + loop { + tokio::select! { + _ = stop_token.cancelled() => { + info!("StreamingOggFlacSink stopped by cancellation"); + break; + } + + segment = input.recv() => { + match segment { + Some(seg) => { + match &seg.segment { + _AudioSegment::Chunk(chunk) => { + // Detect sample rate from first chunk and initialize encoder + if self.sample_rate.is_none() { + let sample_rate = chunk.sample_rate(); + self.sample_rate = Some(sample_rate); + info!("Detected sample rate: {} Hz", sample_rate); + + // Initialize the FLAC encoder now + self.initialize_encoder(sample_rate).await?; + } + + // Convert chunk to PCM bytes + let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?; + + trace!( + "Sending PCM chunk: {} bytes, {} samples @ {:.2}s", + pcm_bytes.len(), + chunk.len(), + seg.timestamp_sec + ); + + // Send to FLAC encoder with timestamp + let pcm_chunk = PcmChunk { + bytes: pcm_bytes, + timestamp_sec: seg.timestamp_sec, + }; + if let Err(e) = self.pcm_tx.send(pcm_chunk).await { + warn!("Failed to send PCM data to encoder: {}", e); + break; + } + } + + _AudioSegment::Sync(marker) => { + match marker.as_ref() { + SyncMarker::TrackBoundary { metadata } => { + if let Err(e) = self.update_metadata(metadata, seg.timestamp_sec).await { + error!("Failed to update metadata: {}", e); + } + // TODO: Implement OGG chaining (EOS → new BOS) + } + + SyncMarker::EndOfStream => { + info!("End of stream marker received"); + break; + } + + _ => { + trace!("Received other sync marker"); + } + } + } + } + } + + None => { + info!("Input channel closed"); + break; + } + } + } + } + } + + info!("StreamingOggFlacSink processing complete"); + Ok(()) + } + + async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> { + info!("StreamingOggFlacSink cleanup: {:?}", reason); + Ok(()) + } +} + +/// Streaming OGG-FLAC sink for multi-client HTTP streaming with track metadata. +pub struct StreamingOggFlacSink { + inner: Node, +} + +impl StreamingOggFlacSink { + /// Create a new streaming OGG-FLAC sink. + /// + /// # Arguments + /// + /// * `encoder_options` - FLAC encoder configuration + /// * `bits_per_sample` - Target bit depth (16, 24, or 32) + /// + /// # Returns + /// + /// A tuple of `(sink, handle)` where: + /// - `sink` is added to the audio pipeline + /// - `handle` is used by HTTP handlers to serve streams + pub fn new( + encoder_options: EncoderOptions, + bits_per_sample: u8, + ) -> (Self, OggFlacStreamHandle) { + // Validate bit depth + if ![16, 24, 32].contains(&bits_per_sample) { + panic!("bits_per_sample must be 16, 24, or 32"); + } + + // Create PCM channel (bounded for backpressure) + let (pcm_tx, pcm_rx) = mpsc::channel::(16); + + // Shared metadata + let metadata = Arc::new(RwLock::new(MetadataSnapshot::default())); + + // Broadcast channel for OGG-FLAC bytes + let (ogg_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY); + + // OGG-FLAC header cache + let ogg_header = Arc::new(RwLock::new(None)); + + // Stop token and client counter + let stop_token = CancellationToken::new(); + let active_clients = Arc::new(AtomicUsize::new(0)); + + let handle = OggFlacStreamHandle { + ogg_broadcast: ogg_broadcast.clone(), + metadata: metadata.clone(), + active_clients, + stop_token: stop_token.clone(), + ogg_header: ogg_header.clone(), + }; + + let logic = StreamingOggFlacSinkLogic { + encoder_options, + bits_per_sample, + pcm_tx, + pcm_rx: Some(pcm_rx), + metadata, + ogg_broadcast, + ogg_header, + encoder_state: None, + sample_rate: None, + }; + + let sink = Self { + inner: Node::new_with_input(logic, 16), + }; + + (sink, handle) + } +} + +#[async_trait] +impl AudioPipelineNode for StreamingOggFlacSink { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, _child: Box) { + panic!("StreamingOggFlacSink is a terminal sink and cannot have children"); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } + + fn start(self: Box) -> PipelineHandle { + Box::new(self.inner).start() + } +} + +impl TypedAudioNode for StreamingOggFlacSink { + fn input_type(&self) -> Option { + Some(TypeRequirement::any_integer()) + } + + fn output_type(&self) -> Option { + None + } +} + +/// AsyncRead adapter for mpsc::Receiver. +/// Extracts bytes from PcmChunk and provides them to the FLAC encoder. +struct ByteStreamReader { + rx: mpsc::Receiver, + buffer: VecDeque, + finished: bool, + /// Shared timestamp for broadcaster pacing + current_timestamp: Arc>, +} + +impl ByteStreamReader { + fn new(rx: mpsc::Receiver, current_timestamp: Arc>) -> Self { + Self { + rx, + buffer: VecDeque::new(), + finished: false, + current_timestamp, + } + } +} + +impl AsyncRead for ByteStreamReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + if to_copy == 0 { + return Poll::Ready(Ok(())); + } + + let slice = self.buffer.make_contiguous(); + buf.put_slice(&slice[..to_copy]); + self.buffer.drain(..to_copy); + return Poll::Ready(Ok(())); + } + + if self.finished { + return Poll::Ready(Ok(())); + } + + match Pin::new(&mut self.rx).poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + if chunk.bytes.is_empty() { + continue; + } + // Update shared timestamp for broadcaster pacing + if let Ok(mut ts) = self.current_timestamp.try_write() { + *ts = chunk.timestamp_sec; + } + self.buffer.extend(chunk.bytes); + } + Poll::Ready(None) => { + self.finished = true; + return Poll::Ready(Ok(())); + } + Poll::Pending => return Poll::Pending, + } + } + } +} + +/// Convert an AudioChunk to PCM bytes with specified bit depth. +fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result, AudioError> { + match chunk { + AudioChunk::F32(_) | AudioChunk::F64(_) => { + return Err(AudioError::ProcessingError( + "StreamingOggFlacSink only supports integer audio chunks".into(), + )); + } + _ => {} + } + + let len = chunk.len(); + let bytes_per_frame = (bits_per_sample / 8) as usize * 2; + let mut bytes = Vec::with_capacity(len * bytes_per_frame); + + match (chunk, bits_per_sample) { + (AudioChunk::I16(data), 16) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + (AudioChunk::I16(data), 24) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 8; + let right = (frame[1] as i32) << 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I16(data), 32) => { + for frame in data.get_frames() { + let left = (frame[0] as i32) << 16; + let right = (frame[1] as i32) << 16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0].as_i32() >> 8) as i16; + let right = (frame[1].as_i32() >> 8) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I24(data), 24) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); + bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); + } + } + (AudioChunk::I24(data), 32) => { + for frame in data.get_frames() { + let left = frame[0].as_i32() << 8; + let right = frame[1].as_i32() << 8; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 16) => { + for frame in data.get_frames() { + let left = (frame[0] >> 16) as i16; + let right = (frame[1] >> 16) as i16; + bytes.extend_from_slice(&left.to_le_bytes()); + bytes.extend_from_slice(&right.to_le_bytes()); + } + } + (AudioChunk::I32(data), 24) => { + for frame in data.get_frames() { + let left = frame[0] >> 8; + let right = frame[1] >> 8; + bytes.extend_from_slice(&left.to_le_bytes()[..3]); + bytes.extend_from_slice(&right.to_le_bytes()[..3]); + } + } + (AudioChunk::I32(data), 32) => { + for frame in data.get_frames() { + bytes.extend_from_slice(&frame[0].to_le_bytes()); + bytes.extend_from_slice(&frame[1].to_le_bytes()); + } + } + _ => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bits_per_sample: {}", + bits_per_sample + ))); + } + } + + Ok(bytes) +} + +/// OGG wrapper + broadcaster task: reads FLAC bytes from encoder, wraps in OGG pages, and broadcasts. +/// Implements precise real-time pacing based on audio timestamps. +async fn broadcast_ogg_flac_stream( + mut flac_stream: FlacEncodedStream, + broadcast_tx: broadcast::Sender, + header_cache: Arc>>, + current_timestamp: Arc>, +) -> Result<(), AudioError> { + info!("OGG-FLAC broadcaster task started with precise timestamp-based pacing"); + + let stream_serial = rand::random::(); + let mut ogg_writer = OggPageWriter::new(stream_serial); + + let mut total_ogg_bytes = 0u64; + let mut header_captured = false; + let start_time = std::time::Instant::now(); + + // Step 1: Read FLAC header (fLaC + metadata blocks) + let flac_header = read_flac_header(&mut flac_stream).await?; + info!("Read FLAC header: {} bytes", flac_header.len()); + + // Step 2: Create OGG-FLAC identification packet (BOS) + // Format according to https://xiph.org/flac/ogg_mapping.html + let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; + info!("Created OGG-FLAC identification packet: {} bytes", ogg_flac_id.len()); + + let bos_page = ogg_writer.create_page(&ogg_flac_id, true, false, false); + let bos_bytes = Bytes::from(bos_page); + + // Step 3: Create Vorbis Comment page (empty for now, metadata comes from /metadata endpoint) + let vorbis_comment = create_empty_vorbis_comment(); + let comment_page = ogg_writer.create_page(&vorbis_comment, false, false, false); + let comment_bytes = Bytes::from(comment_page); + + // Cache the header (BOS + Comment pages) + let mut cached_header = Vec::new(); + cached_header.extend_from_slice(&bos_bytes); + cached_header.extend_from_slice(&comment_bytes); + *header_cache.write().await = Some(Bytes::from(cached_header)); + header_captured = true; + info!("OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", bos_bytes.len() + comment_bytes.len()); + + // Broadcast header + let _ = broadcast_tx.send(bos_bytes); + total_ogg_bytes += comment_bytes.len() as u64; + let _ = broadcast_tx.send(comment_bytes); + + // Step 4: Read FLAC stream and create OGG packets + // According to OGG FLAC spec, we put the complete FLAC stream in a single logical bitstream, + // but split it into reasonable page sizes for streaming + + let mut flac_data = Vec::new(); + let mut read_buffer = vec![0u8; 8192]; + + loop { + match flac_stream.read(&mut read_buffer).await { + Ok(0) => { + // EOF - create final page with EOS flag and any remaining data + if !flac_data.is_empty() { + let eos_page = ogg_writer.create_page(&flac_data, false, true, false); + let eos_bytes = Bytes::from(eos_page); + total_ogg_bytes += eos_bytes.len() as u64; + let _ = broadcast_tx.send(eos_bytes); + info!("Sent final EOS page with {} bytes of data", flac_data.len()); + } else { + // Send empty EOS page + let eos_page = ogg_writer.create_page(&[], false, true, false); + let eos_bytes = Bytes::from(eos_page); + total_ogg_bytes += eos_bytes.len() as u64; + let _ = broadcast_tx.send(eos_bytes); + info!("Sent empty EOS page"); + } + + info!("OGG-FLAC stream ended, total OGG bytes: {}", total_ogg_bytes); + break; + } + Ok(n) => { + // Precise pacing based on audio timestamp + let audio_timestamp = *current_timestamp.read().await; + let elapsed = start_time.elapsed().as_secs_f64(); + let lead_time = audio_timestamp - elapsed; + + if lead_time > BROADCAST_MAX_LEAD_TIME { + let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME; + debug!( + "OGG broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)", + sleep_duration, audio_timestamp, elapsed, lead_time + ); + tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await; + } + + // Accumulate FLAC data + flac_data.extend_from_slice(&read_buffer[..n]); + + // Create pages when we have a reasonable amount of data (8KB chunks) + // This respects FLAC frame boundaries better than arbitrary 4KB splits + while flac_data.len() >= 8192 { + let chunk = flac_data.drain(..8192).collect::>(); + let ogg_page = ogg_writer.create_page(&chunk, false, false, false); + let ogg_bytes = Bytes::from(ogg_page); + total_ogg_bytes += ogg_bytes.len() as u64; + + if let Err(e) = broadcast_tx.send(ogg_bytes) { + trace!("No active receivers for OGG-FLAC broadcast: {}", e); + } + } + } + Err(e) => { + error!("Error reading from FLAC encoder: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder read error: {}", + e + ))); + } + } + } + + // Wait for the encoder to finish cleanly + if let Err(e) = flac_stream.wait().await { + error!("FLAC encoder error during cleanup: {}", e); + return Err(AudioError::ProcessingError(format!( + "FLAC encoder error: {}", + e + ))); + } + + info!("OGG-FLAC broadcaster task completed successfully"); + Ok(()) +} + +/// Read FLAC header (fLaC + all metadata blocks until first frame) +async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result, AudioError> { + let mut header = Vec::new(); + let mut buffer = [0u8; 4]; + + // Read "fLaC" magic + stream.read_exact(&mut buffer).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)) + })?; + + if &buffer != b"fLaC" { + return Err(AudioError::ProcessingError("Invalid FLAC stream: missing fLaC magic".into())); + } + + header.extend_from_slice(&buffer); + + // Read metadata blocks + loop { + // Read metadata block header (1 byte type + 3 bytes length) + let mut block_header = [0u8; 4]; + stream.read_exact(&mut block_header).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to read metadata block header: {}", e)) + })?; + + let is_last = (block_header[0] & 0x80) != 0; + let block_length = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize; + + header.extend_from_slice(&block_header); + + // Read metadata block data + let mut block_data = vec![0u8; block_length]; + stream.read_exact(&mut block_data).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to read metadata block data: {}", e)) + })?; + + header.extend_from_slice(&block_data); + + if is_last { + break; + } + } + + Ok(header) +} + +/// Create OGG-FLAC identification packet (first packet in BOS page) +/// Format: https://xiph.org/flac/ogg_mapping.html +fn create_ogg_flac_identification(flac_header: &[u8]) -> Result, AudioError> { + // Verify we have at least "fLaC" magic + if flac_header.len() < 4 || &flac_header[0..4] != b"fLaC" { + return Err(AudioError::ProcessingError("Invalid FLAC header".into())); + } + + // Extract STREAMINFO block (first metadata block) + // Format: 1 byte type+flags, 3 bytes length, N bytes data + if flac_header.len() < 8 { + return Err(AudioError::ProcessingError("FLAC header too short".into())); + } + + let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag + if first_block_type != 0 { + return Err(AudioError::ProcessingError("First FLAC metadata block is not STREAMINFO".into())); + } + + // Extract block length (3 bytes big-endian after type byte) + let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize; + + info!("STREAMINFO block_length = {} bytes", block_length); + + // STREAMINFO should be exactly 34 bytes of data + if block_length != 34 { + warn!("STREAMINFO block length is {} (expected 34)", block_length); + } + + // Total STREAMINFO block size = 1 (type) + 3 (length) + block_length + let streaminfo_size = 4 + block_length; + + if flac_header.len() < 4 + streaminfo_size { + return Err(AudioError::ProcessingError("FLAC header truncated".into())); + } + + // Extract just the STREAMINFO block (type + length + data) + let streaminfo = &flac_header[4..4 + streaminfo_size]; + + info!("Extracted STREAMINFO: {} bytes (type+length+data)", streaminfo.len()); + + let mut packet = Vec::new(); + + // OGG-FLAC identification header + packet.push(0x7F); // Byte 0: 0x7F + packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC" + packet.push(0x01); // Byte 5: Major version + packet.push(0x00); // Byte 6: Minor version + packet.extend_from_slice(&1u16.to_be_bytes()); // Bytes 7-8: 1 header packet (Vorbis Comment) + packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature + packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only + + Ok(packet) +} + +/// Create empty Vorbis Comment block +fn create_empty_vorbis_comment() -> Vec { + let mut data = Vec::new(); + + // Vendor string + let vendor = "pmoaudio OGG-FLAC streamer"; + let vendor_bytes = vendor.as_bytes(); + data.extend_from_slice(&(vendor_bytes.len() as u32).to_le_bytes()); + data.extend_from_slice(vendor_bytes); + + // Number of comments (0 for now - metadata via /metadata endpoint) + data.extend_from_slice(&0u32.to_le_bytes()); + + data +} + +/// OGG page writer (same as in pmoflac::ogg_flac_encoder) +struct OggPageWriter { + stream_serial: u32, + page_sequence: u32, + granule_position: u64, +} + +impl OggPageWriter { + fn new(stream_serial: u32) -> Self { + Self { + stream_serial, + page_sequence: 0, + granule_position: 0, + } + } + + fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec { + use std::io::Write; + + let mut segments = Vec::new(); + let mut remaining = packet_data.len(); + + // Segment the packet into 255-byte chunks + while remaining > 0 { + let segment_size = remaining.min(255); + segments.push(segment_size as u8); + remaining -= segment_size; + } + + // If packet ends exactly on a 255-byte boundary, add empty segment + if !packet_data.is_empty() && packet_data.len() % 255 == 0 && !is_continuation { + segments.push(0); + } + + let segment_count = segments.len(); + let header_size = 27 + segment_count; + let total_size = header_size + packet_data.len(); + + let mut page = Vec::with_capacity(total_size); + + // OGG page header + page.write_all(b"OggS").unwrap(); + page.write_all(&[0]).unwrap(); // Version + + // Header type + let mut header_type = 0u8; + if is_continuation { + header_type |= 0x01; + } + if is_bos { + header_type |= 0x02; + } + if is_eos { + header_type |= 0x04; + } + page.write_all(&[header_type]).unwrap(); + + // Granule position + page.write_all(&self.granule_position.to_le_bytes()).unwrap(); + + // Stream serial number + page.write_all(&self.stream_serial.to_le_bytes()).unwrap(); + + // Page sequence number + page.write_all(&self.page_sequence.to_le_bytes()).unwrap(); + self.page_sequence += 1; + + // CRC checksum (zero for now, calculated later) + let crc_offset = page.len(); + page.write_all(&[0, 0, 0, 0]).unwrap(); + + // Number of segments + page.write_all(&[segment_count as u8]).unwrap(); + + // Segment table + page.write_all(&segments).unwrap(); + + // Packet data + page.write_all(packet_data).unwrap(); + + // Calculate and insert CRC32 + let crc = calculate_ogg_crc(&page); + page[crc_offset..crc_offset + 4].copy_from_slice(&crc.to_le_bytes()); + + page + } +} + +/// Calculate OGG CRC32 checksum +fn calculate_ogg_crc(data: &[u8]) -> u32 { + const CRC_TABLE: [u32; 256] = generate_crc_table(); + + let mut crc: u32 = 0; + for &byte in data { + crc = (crc << 8) ^ CRC_TABLE[((crc >> 24) ^ (byte as u32)) as usize]; + } + crc +} + +/// Generate CRC lookup table at compile time +const fn generate_crc_table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut r = i << 24; + let mut j = 0; + while j < 8 { + if (r & 0x80000000) != 0 { + r = (r << 1) ^ 0x04c11db7; + } else { + r <<= 1; + } + j += 1; + } + table[i as usize] = r; + i += 1; + } + table +} diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index bdb39fd6..5bca30a9 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -237,11 +237,15 @@ impl NodeLogic for PlaylistSourceLogic { tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path); // Décoder et émettre les chunks PCM + // Passer le cache et pk pour gérer le cache progressif + let cache_pk = track.cache_pk(); if let Err(e) = decode_and_emit_track( &file_path, self.chunk_frames, &output, &stop_token, + &self.cache, + cache_pk, ) .await { @@ -264,12 +268,39 @@ impl NodeLogic for PlaylistSourceLogic { // ═══════════════════════════════════════════════════════════════════════════ /// Décode un fichier et émet ses chunks audio +/// +/// Gère le cache progressif : si EOF est atteint et que le download est toujours en cours, +/// attend et réessaie au lieu de terminer immédiatement. async fn decode_and_emit_track( path: &PathBuf, chunk_frames: usize, output: &[mpsc::Sender>], stop_token: &CancellationToken, + cache: &Arc, + cache_pk: &str, ) -> Result<(), AudioError> { + // Attendre que le fichier soit suffisamment gros pour le sniffing + // Le cache progressif permet de commencer la lecture après le prebuffer (512 KB) + loop { + let metadata = tokio::fs::metadata(path) + .await + .map_err(|e| AudioError::IoError(format!("Failed to stat {:?}: {}", path, e)))?; + + let file_size = metadata.len(); + const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size) + + if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) { + tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size); + break; + } + + tracing::trace!( + "decode_and_emit_track: file too small ({} bytes), waiting 50ms...", + file_size + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + // Ouvrir et décoder let file = File::open(path) .await @@ -320,9 +351,25 @@ async fn decode_and_emit_track( let read = read_result.map_err(|e| { AudioError::IoError(format!("I/O error while decoding: {}", e)) })?; - if read == 0 && pending.is_empty() { - break; + + // Si EOF atteint (read == 0) + if read == 0 { + // Vérifier si le fichier est complètement écrit (completion marker existe) + if !cache.is_download_complete(cache_pk) { + // Fichier encore en cours d'écriture - attendre et réessayer + // Retry plus longtemps pour le cache progressif + tracing::trace!("decode_and_emit_track: EOF but file incomplete, waiting 200ms..."); + tokio::time::sleep(Duration::from_millis(200)).await; + continue; // Retry + } + + // Completion marker existe - vraie fin du fichier + tracing::trace!("decode_and_emit_track: EOF and file complete"); + if pending.is_empty() { + break; + } } + if read > 0 { pending.extend_from_slice(&read_buf[..read]); } diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index 6c0e3edd..dd3c9a6c 100755 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -124,6 +124,7 @@ pub use nodes::{ flac_file_sink::{FlacFileSink, FlacFileSinkStats}, http_source::HttpSource, resampling_node::ResamplingNode, + timer_node::TimerNode, AudioError, AudioNode, TypedAudioNode, }; diff --git a/pmoaudio/src/nodes/audio_sink.rs b/pmoaudio/src/nodes/audio_sink.rs index 37126896..e71798d1 100644 --- a/pmoaudio/src/nodes/audio_sink.rs +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -172,11 +172,70 @@ fn chunk_to_f32_interleaved(chunk: &AudioChunk) -> Vec { // ═══════════════════════════════════════════════════════════════════════════ /// Logique pure de lecture audio via cpal -pub struct AudioSinkLogic {} +pub struct AudioSinkLogic { + use_null_output: bool, +} impl AudioSinkLogic { pub fn new() -> Self { - Self {} + Self { + use_null_output: false, + } + } + + pub fn with_null_output() -> Self { + Self { + use_null_output: true, + } + } + + /// Version null output - consomme les segments sans les jouer + async fn process_null_output( + mut rx: mpsc::Receiver>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + loop { + let segment = tokio::select! { + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + tracing::debug!("AudioSinkLogic (null): input channel closed"); + return Ok(()); + } + } + } + _ = stop_token.cancelled() => { + tracing::debug!("AudioSinkLogic (null): cancelled"); + return Ok(()); + } + }; + + // Juste logger les segments sans les jouer + match &segment.segment { + crate::_AudioSegment::Chunk(chunk) => { + tracing::trace!( + "AudioSink (null): consumed chunk with {} frames at {}Hz", + chunk.len(), + chunk.sample_rate() + ); + } + crate::_AudioSegment::Sync(marker) => { + match **marker { + SyncMarker::TrackBoundary { .. } => { + tracing::debug!("AudioSink (null): TrackBoundary received"); + } + SyncMarker::EndOfStream => { + tracing::debug!("AudioSink (null): EndOfStream received"); + return Ok(()); + } + _ => { + tracing::trace!("AudioSink (null): sync marker"); + } + } + } + } + } } } @@ -198,6 +257,12 @@ impl NodeLogic for AudioSinkLogic { tracing::debug!("AudioSinkLogic::process started"); + // Si null output, juste consommer les segments sans jouer + if self.use_null_output { + tracing::debug!("Using null audio output (no playback)"); + return Self::process_null_output(rx, stop_token).await; + } + // Créer le buffer partagé let buffer = Arc::new(Mutex::new(SharedBuffer::new())); let buffer_clone = buffer.clone(); @@ -485,6 +550,14 @@ impl AudioSink { inner: Node::new_with_input(AudioSinkLogic::new(), channel_size), } } + + /// Crée un AudioSink avec null output (pour tests sans carte audio) + /// Consomme les segments audio sans les jouer + pub fn with_null_output() -> Self { + Self { + inner: Node::new_with_input(AudioSinkLogic::with_null_output(), DEFAULT_CHANNEL_SIZE), + } + } } impl Default for AudioSink { diff --git a/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs index b8f7a27c..0d7783ec 100755 --- a/pmoaudio/src/nodes/flac_file_sink.rs +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -133,9 +133,24 @@ impl NodeLogic for FlacFileSinkLogic { AudioError::ProcessingError(format!("Failed to create {:?}: {}", track_path, e)) })?; - // Exécuter pump et copy en parallèle avec tokio::select! en boucle - let pump_future = - pump_track_segments(first_segment, &mut rx, pcm_tx, bits_per_sample, sample_rate, &stop_token); + // Créer un channel dédié pour dispatcher les chunks vers ce pump + let (track_tx, track_rx) = mpsc::channel::>(16); + + // Lancer le pump en arrière-plan avec son channel dédié + // Cela permet à plusieurs pumps de tourner simultanément (cache progressive compliant) + let pump_handle = tokio::spawn(pump_track_segments_from_channel( + first_segment, + track_rx, + pcm_tx, + bits_per_sample, + sample_rate, + )); + + // Dispatcher les segments vers track_tx en parallèle de l'écriture du fichier + // Utiliser tokio::select! pour éviter le deadlock et permettre cache progressif + tracing::debug!("FlacFileSink: Starting dispatcher loop with file write"); + + // Pin la future pour pouvoir l'utiliser dans select! let copy_future = async { let copy_result = tokio::io::copy(&mut flac_stream, &mut output).await; let flush_result = output.flush().await; @@ -150,22 +165,118 @@ impl NodeLogic for FlacFileSinkLogic { .map_err(|e| AudioError::ProcessingError(format!("Encoder failed: {}", e)))?; Ok::<_, AudioError>(()) }; + tokio::pin!(copy_future); - // Attendre les deux tâches en parallèle - let (copy_result, pump_result) = tokio::join!(copy_future, pump_future); - copy_result?; - let stop_reason = pump_result?; + // Phase 1: Dispatcher jusqu'à ce que le fichier soit complètement écrit + let mut copy_done = false; + loop { + tokio::select! { + // Attendre l'écriture du fichier + result = &mut copy_future, if !copy_done => { + result?; + tracing::info!("FlacFileSink: File write complete for track {}", track_number); + copy_done = true; + // Continue dispatching jusqu'au TrackBoundary + } - // Vérifier le stop_reason pour savoir si on continue - match stop_reason { - StopReason::TrackBoundary(_metadata) => { - // Continuer avec la prochaine track - track_number += 1; - continue; - } - StopReason::EndOfStream | StopReason::ChannelClosed | StopReason::Cancelled => { - // Fin de l'encodage - return Ok(()); + // Dispatcher les segments depuis rx vers track_tx + result = rx.recv() => { + match result { + Some(segment) => { + match &segment.segment { + crate::_AudioSegment::Chunk(_) => { + // Dispatcher vers le pump + if track_tx.send(segment).await.is_err() { + // Le pump est mort - erreur fatale + tracing::error!("FlacFileSink: pump died unexpectedly"); + return Err(AudioError::ProcessingError("Pump task died".to_string())); + } + } + crate::_AudioSegment::Sync(marker) => match &**marker { + SyncMarker::TrackBoundary { .. } => { + // Nouveau morceau - fermer le pump et passer au suivant + tracing::debug!("FlacFileSink: TrackBoundary received"); + + // Vérifier que copy est terminé avant de continuer + if !copy_done { + copy_future.await?; + tracing::info!("FlacFileSink: File write complete for track {}", track_number); + } + + drop(track_tx); // Ferme le channel, le pump se termine proprement + drop(pump_handle); + + // Créer le marqueur de complétude + let completion_marker = track_path.with_extension("flac.complete"); + if let Err(e) = tokio::fs::File::create(&completion_marker).await { + tracing::warn!("FlacFileSink: Failed to create completion marker {:?}: {}", completion_marker, e); + } else { + tracing::debug!("FlacFileSink: Created completion marker {:?}", completion_marker); + } + + track_number += 1; + break; // Sort de la Phase 1, retour à la loop externe pour next track + } + SyncMarker::EndOfStream => { + tracing::debug!("FlacFileSink: EndOfStream received"); + + // Vérifier que copy est terminé + if !copy_done { + copy_future.await?; + tracing::info!("FlacFileSink: File write complete for track {}", track_number); + } + + drop(track_tx); + drop(pump_handle); + + // Créer le marqueur de complétude + let completion_marker = track_path.with_extension("flac.complete"); + if let Err(e) = tokio::fs::File::create(&completion_marker).await { + tracing::warn!("FlacFileSink: Failed to create completion marker {:?}: {}", completion_marker, e); + } else { + tracing::debug!("FlacFileSink: Created completion marker {:?}", completion_marker); + } + + return Ok(()); + } + _ => { + // Transmettre les autres syncmarkers au pump + let _ = track_tx.send(segment).await; + } + }, + } + } + None => { + // EOF sur rx + tracing::debug!("FlacFileSink: EOF on rx"); + + // Vérifier que copy est terminé + if !copy_done { + copy_future.await?; + tracing::info!("FlacFileSink: File write complete for track {}", track_number); + } + + drop(track_tx); + drop(pump_handle); + + // Créer le marqueur de complétude + let completion_marker = track_path.with_extension("flac.complete"); + if let Err(e) = tokio::fs::File::create(&completion_marker).await { + tracing::warn!("FlacFileSink: Failed to create completion marker {:?}: {}", completion_marker, e); + } else { + tracing::debug!("FlacFileSink: Created completion marker {:?}", completion_marker); + } + + return Ok(()); + } + } + } + + _ = stop_token.cancelled() => { + drop(track_tx); + drop(pump_handle); + return Ok(()); + } } } } @@ -367,6 +478,71 @@ async fn pump_track_segments( } } +/// Pompe les segments pour une seule track depuis un channel dédié. +/// +/// Cette version permet d'avoir plusieurs pumps en parallèle (cache progressive compliant), +/// car chaque pump a son propre channel et ne bloque pas le traitement des tracks suivantes. +async fn pump_track_segments_from_channel( + first_segment: Arc, + mut track_rx: mpsc::Receiver>, + pcm_tx: mpsc::Sender>, + bits_per_sample: u8, + expected_rate: u32, +) -> Result<(), AudioError> { + // Traiter le premier segment + if let Some(chunk) = first_segment.as_chunk() { + let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; + if !pcm_bytes.is_empty() { + if pcm_tx.send(pcm_bytes).await.is_err() { + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: pcm_tx closed on first segment"); + return Ok(()); + } + } + } + + // Boucle sur les segments depuis le channel dédié + loop { + let segment = match track_rx.recv().await { + Some(seg) => seg, + None => { + // Channel fermé - la track est terminée (TrackBoundary a été reçu en amont) + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: channel closed, track finished"); + return Ok(()); + } + }; + + match &segment.segment { + crate::_AudioSegment::Chunk(chunk) => { + if chunk.sample_rate() != expected_rate { + return Err(AudioError::ProcessingError(format!( + "FlacFileSink: inconsistent sample rate ({} vs {})", + chunk.sample_rate(), + expected_rate + ))); + } + + let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?; + if pcm_bytes.is_empty() { + continue; + } + + if pcm_tx.send(pcm_bytes).await.is_err() { + // Le fichier a fermé le channel (erreur) + drop(pcm_tx); + tracing::debug!("pump_track_segments_from_channel: pcm_tx closed"); + return Ok(()); + } + } + crate::_AudioSegment::Sync(_marker) => { + // Ignorer les syncmarkers - le TrackBoundary est géré en amont + // Le channel sera fermé quand le TrackBoundary est détecté + } + } + } +} + /// Détermine la profondeur de bit d'un chunk audio fn get_chunk_bit_depth(chunk: &AudioChunk) -> u8 { match chunk { diff --git a/pmoaudio/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 9f51198a..877073bf 100755 --- a/pmoaudio/src/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -25,6 +25,7 @@ pub mod file_source; pub mod flac_file_sink; pub mod http_source; pub mod resampling_node; +pub mod timer_node; // Modules temporairement désactivés /* @@ -36,7 +37,6 @@ pub mod dsp_node; pub mod mpd_sink; pub mod sink_node; pub mod source_node; -pub mod timer_node; pub mod volume_node; */ diff --git a/pmoaudio/src/nodes/timer_node.rs b/pmoaudio/src/nodes/timer_node.rs new file mode 100644 index 00000000..4a74cef4 --- /dev/null +++ b/pmoaudio/src/nodes/timer_node.rs @@ -0,0 +1,263 @@ +//! TimerNode - Régule le débit des chunks audio en fonction de leurs timestamps +//! +//! Ce node implémente un pacing temporel pour éviter que les sources rapides +//! saturent les sinks lents. Il tolère une avance configurable (buffer) et +//! attend activement pour maintenir la synchronisation temps réel. +//! +//! # Use Cases +//! +//! - **Progressive caching**: Empêche PlaylistSource de lire plus vite que FlacCacheSink n'écrit +//! - **Rate limiting**: Contrôle le débit de n'importe quel pipeline audio +//! - **Streaming**: Synchronise la production avec la consommation temps réel +//! +//! # Exemple +//! +//! ```no_run +//! use pmoaudio::{PlaylistSource, TimerNode, FlacCacheSink}; +//! +//! let mut source = PlaylistSource::new(reader, cache); +//! let mut timer = TimerNode::new(3.0); // 3s d'avance max +//! let mut sink = FlacCacheSink::new(cache, covers); +//! +//! source.register(Box::new(timer)); +//! timer.register(Box::new(sink)); +//! ``` +//! +//! # Architecture +//! +//! ```text +//! PlaylistSource → TimerNode → FlacCacheSink +//! ↓ ↓ ↓ +//! Lit à fond Régule en Écrit au +//! temps réel bon rythme +//! ``` +//! +//! Le TimerNode: +//! 1. Reçoit des chunks avec timestamps +//! 2. Compare `chunk.timestamp_sec` avec le temps écoulé depuis `TopZeroSync` +//! 3. Si l'avance > `max_lead_time_sec`, attend: `sleep(avance - max_lead_time)` +//! 4. Transmet le chunk aux enfants +//! +//! # Markers Supportés +//! +//! - **TopZeroSync**: Reset le timer de référence (instant zero) +//! - **TrackBoundary**: Passthrough transparent +//! - **Heartbeat**: Passthrough transparent +//! - **EndOfStream**: Passthrough transparent +//! +//! # Performance +//! +//! - **CPU**: Quasi-nul (tokio::time::sleep efficace) +//! - **Latency**: Ajoute `max_lead_time_sec` de buffering +//! - **Memory**: Minimal (pas de buffer de chunks) + +use crate::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, + pipeline::{AudioPipelineNode, Node, NodeLogic}, + type_constraints::TypeRequirement, + AudioSegment, SyncMarker, _AudioSegment, +}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +// ═══════════════════════════════════════════════════════════════════════════ +// TimerNodeLogic - Logique pure de pacing temporel +// ═══════════════════════════════════════════════════════════════════════════ + +/// Logique pure de régulation temporelle +/// +/// Contrôle le débit des chunks audio pour éviter qu'une source rapide +/// sature un sink lent (ex: progressive caching). +pub struct TimerNodeLogic { + /// Avance maximale tolérée en secondes (buffer) + max_lead_time_sec: f64, + /// Instant de référence (reset au TopZeroSync) + start_time: Option, +} + +impl TimerNodeLogic { + pub fn new(max_lead_time_sec: f64) -> Self { + Self { + max_lead_time_sec: max_lead_time_sec.max(0.0), + start_time: None, + } + } +} + +#[async_trait::async_trait] +impl NodeLogic for TimerNodeLogic { + async fn process( + &mut self, + input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut rx = input.expect("TimerNode must have input"); + tracing::info!( + "TimerNodeLogic::process started (max_lead_time={:.1}s), {} children", + self.max_lead_time_sec, + output.len() + ); + + // Macro helper pour envoyer à tous les enfants + macro_rules! send_to_children { + ($segment:expr) => { + for tx in &output { + tx.send($segment.clone()) + .await + .map_err(|_| AudioError::ChildDied)?; + } + }; + } + + loop { + let segment = tokio::select! { + _ = stop_token.cancelled() => { + tracing::debug!("TimerNodeLogic cancelled"); + break; + } + + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + tracing::debug!("TimerNodeLogic received EOF"); + break; + } + } + } + }; + + // Traitement selon le type de segment + match &segment.segment { + _AudioSegment::Sync(marker) => { + match &**marker { + SyncMarker::TopZeroSync => { + // Reset le timer de référence + self.start_time = Some(Instant::now()); + tracing::info!("TimerNodeLogic: TopZeroSync received, timer reset"); + } + _ => { + // Autres markers: passthrough transparent + } + } + send_to_children!(segment); + } + + _AudioSegment::Chunk(_) => { + // Vérifier le pacing seulement si on a un timer de référence + if let Some(start) = self.start_time { + let chunk_timestamp = segment.timestamp_sec; + let elapsed = start.elapsed().as_secs_f64(); + let lead_time = chunk_timestamp - elapsed; + + if lead_time > self.max_lead_time_sec { + // On est trop en avance, attendre + let sleep_duration = lead_time - self.max_lead_time_sec; + tracing::info!( + "TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s)", + sleep_duration, + lead_time, + self.max_lead_time_sec + ); + + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs_f64(sleep_duration)) => {} + _ = stop_token.cancelled() => { + tracing::info!("TimerNodeLogic cancelled during sleep"); + break; + } + } + } else if lead_time < -0.5 { + // On est en retard de plus de 500ms, log warning + tracing::warn!( + "TimerNodeLogic: lagging behind by {:.3}s (chunk ts={:.3}s, elapsed={:.3}s)", + -lead_time, + chunk_timestamp, + elapsed + ); + } + } else { + // Pas encore de TopZeroSync reçu, passthrough sans pacing + tracing::warn!("TimerNodeLogic: NO TIMER SET - passthrough without pacing! (ts={:.3}s)", segment.timestamp_sec); + } + + send_to_children!(segment); + } + } + } + + tracing::debug!("TimerNodeLogic::process finished"); + Ok(()) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TimerNode - Wrapper utilisant Node +// ═══════════════════════════════════════════════════════════════════════════ + +pub struct TimerNode { + inner: Node, +} + +impl TimerNode { + /// Crée un TimerNode avec une avance maximale tolérée + /// + /// # Arguments + /// + /// * `max_lead_time_sec` - Avance maximale en secondes (ex: 3.0 pour 3s de buffer) + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::TimerNode; + /// + /// // Tolérer 3 secondes d'avance + /// let timer = TimerNode::new(3.0); + /// ``` + pub fn new(max_lead_time_sec: f64) -> Self { + Self::with_channel_size(max_lead_time_sec, DEFAULT_CHANNEL_SIZE) + } + + /// Crée un TimerNode avec une taille de buffer MPSC personnalisée + /// + /// # Arguments + /// + /// * `max_lead_time_sec` - Avance maximale en secondes + /// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente) + pub fn with_channel_size(max_lead_time_sec: f64, channel_size: usize) -> Self { + let logic = TimerNodeLogic::new(max_lead_time_sec); + Self { + inner: Node::new_with_input(logic, channel_size), + } + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for TimerNode { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for TimerNode { + fn input_type(&self) -> Option { + // Accepte n'importe quel type + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + // Passthrough: produit le même type qu'il consomme + Some(TypeRequirement::any()) + } +} diff --git a/pmoaudio/src/pipeline.rs b/pmoaudio/src/pipeline.rs index 7cffac38..c400e5fd 100755 --- a/pmoaudio/src/pipeline.rs +++ b/pmoaudio/src/pipeline.rs @@ -518,18 +518,22 @@ impl AudioPipelineNode for Node { .. } = *self; + tracing::info!("Node::run() starting with {} children", children.len()); + // ═══════════════════════════════════════════════════════════════════ // PHASE 1: SPAWNER TOUS LES ENFANTS // ═══════════════════════════════════════════════════════════════════ let mut child_handles = Vec::new(); - for child in children { + for (i, child) in children.into_iter().enumerate() { + tracing::info!("Spawning child {}", i); let child_token = stop_token.child_token(); let handle = tokio::spawn(async move { child.run(child_token).await }); child_handles.push(handle); } + tracing::info!("All {} children spawned", child_handles.len()); // ═══════════════════════════════════════════════════════════════════ // PHASE 2: MONITORER LES ENFANTS EN PARALLÈLE @@ -626,9 +630,10 @@ impl AudioPipelineNode for Node { // Logique métier du nœud process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => { + tracing::info!("Node logic.process() returned"); match process_result { Ok(()) => { - tracing::debug!("Node process completed successfully"); + tracing::info!("Node process completed successfully"); (StopReason::Completed, Ok(()), false) } Err(e) => { diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index 5f168ebb..8c117856 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -56,6 +56,46 @@ pub fn new_cache(dir: &str, limit: usize) -> Result { Cache::with_transformer(dir, limit, Some(transformer_factory)) } +/// Crée un cache audio et lance la consolidation en arrière-plan +/// +/// Cette fonction crée le cache et lance immédiatement une consolidation +/// pour nettoyer les fichiers incomplets (sans marker de complétion). +/// +/// # Arguments +/// +/// * `dir` - Répertoire de stockage du cache +/// * `limit` - Limite de taille du cache (nombre de pistes) +/// +/// # Returns +/// +/// Arc vers l'instance du cache configurée pour la conversion FLAC automatique +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmoaudiocache::cache; +/// +/// # async fn example() -> anyhow::Result<()> { +/// let cache = cache::new_cache_with_consolidation("./audio_cache", 1000).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result> { + let cache = Arc::new(new_cache(dir, limit)?); + + // Lancer la consolidation en arrière-plan pour nettoyer les fichiers incomplets + let cache_clone = cache.clone(); + tokio::spawn(async move { + if let Err(e) = cache_clone.consolidate().await { + tracing::warn!("Failed to consolidate cache on startup: {}", e); + } else { + tracing::info!("Cache consolidated successfully on startup"); + } + }); + + Ok(cache) +} + /// 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 : diff --git a/pmoaudiocache/tests/test_cache.rs b/pmoaudiocache/tests/test_cache.rs new file mode 100644 index 00000000..e4494ec8 --- /dev/null +++ b/pmoaudiocache/tests/test_cache.rs @@ -0,0 +1,96 @@ +use pmoaudiocache::cache; +use tempfile::TempDir; + +fn create_test_cache() -> (TempDir, cache::Cache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + (temp_dir, cache) +} + +#[tokio::test] +async fn test_audio_cache_creation() { + let (temp_dir, cache) = create_test_cache(); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +#[ignore] // Test nécessite un vrai fichier audio FLAC +async fn test_add_from_file() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer un fichier de test + let test_file = tempfile::NamedTempFile::with_suffix(".dat").unwrap(); + std::fs::write(test_file.path(), b"Test audio data").unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + assert!(!pk.is_empty()); +} + +#[tokio::test] +async fn test_audio_config() { + use pmocache::CacheConfig; + + assert_eq!(cache::AudioConfig::file_extension(), "flac"); + assert_eq!(cache::AudioConfig::cache_type(), "flac"); + assert_eq!(cache::AudioConfig::cache_name(), "audio"); + assert_eq!(cache::AudioConfig::default_param(), "orig"); +} + +#[tokio::test] +#[ignore] // Test nécessite un vrai fichier audio FLAC +async fn test_collection_management() { + let (_temp_dir, cache) = create_test_cache(); + + let collection = "test_album"; + + // Ajouter plusieurs pistes à la même collection + for i in 0..3 { + let data = format!("Track {} audio data", i); + let file = tempfile::NamedTempFile::with_suffix(".dat").unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Attendre un peu pour que les fichiers soient prêts + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Récupérer la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +#[ignore] // Test nécessite un vrai fichier audio FLAC +async fn test_cache_limit() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 2).unwrap(); + + // Ajouter 3 fichiers (devrait déclencher l'éviction LRU) + for i in 0..3 { + let data = format!("Track {}", i); + let file = tempfile::NamedTempFile::with_suffix(".dat").unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + // Attendre que l'éviction se fasse + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Le cache ne devrait contenir que 2 éléments + let count = cache.db.count().unwrap(); + assert_eq!(count, 2); +} diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index 409df8b1..8cf63f3d 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -41,6 +41,9 @@ axum = { version = "0.8", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } serde_yaml = { version = "0.9", optional = true } +[dev-dependencies] +tempfile = "3" + [features] default = [] openapi = ["dep:utoipa"] diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 879cc8be..4ba635da 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -13,10 +13,13 @@ use serde_json::{Number, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tokio::io::AsyncRead; +use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::sync::RwLock; use tracing; +/// Taille minimale de prébuffering par défaut (512 KB = ~5 secondes de FLAC) +pub const DEFAULT_PREBUFFER_SIZE: u64 = 512 * 1024; + /// Paramètres statiques d'un cache spécialisé. pub trait CacheConfig: Send + Sync { /// Extension des fichiers générés (ex: `"webp"`, `"flac"`). @@ -58,11 +61,110 @@ pub struct Cache { downloads: Arc>>>, /// Factory pour créer des transformers (optionnel) transformer_factory: Option StreamTransformer + Send + Sync>>, + /// Taille minimale de prébuffering en octets (0 = désactivé) + min_prebuffer_size: u64, /// Phantom data pour le type de configuration _phantom: std::marker::PhantomData, } impl Cache { + /// Retourne le chemin du fichier marker de complétion + fn get_completion_marker_path(&self, pk: &str) -> PathBuf { + self.get_file_path(pk).with_extension(format!("{}.complete", C::file_extension())) + } + + /// Vérifie si un fichier est en cache et complet + /// + /// # Returns + /// + /// - `Ok(true)` si le fichier est en cache et complet (fichier .complete existe) + /// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets) + /// - `Err` en cas d'erreur + async fn check_cached_and_complete(&self, pk: &str) -> Result { + if self.db.get(pk, false).is_ok() { + let file_path = self.get_file_path(pk); + let completion_marker = self.get_completion_marker_path(pk); + + if file_path.exists() { + // Vérifier si le fichier marker de complétion existe + if completion_marker.exists() { + tracing::debug!("File with pk {} is complete (marker exists)", pk); + return Ok(true); + } else { + tracing::warn!( + "File with pk {} in cache has no completion marker, will re-download/re-ingest", + pk + ); + // Supprimer le fichier incomplet + let _ = std::fs::remove_file(&file_path); + return Ok(false); + } + } + } + Ok(false) + } + + /// Vérifie si un download est en cours et attend le prébuffering si nécessaire + /// + /// # Returns + /// + /// - `Ok(Some(pk))` si un download est en cours (et prébuffering terminé) + /// - `Ok(None)` si aucun download en cours + /// - `Err` en cas d'erreur de prébuffering + async fn check_ongoing_download(&self, pk: &str) -> Result> { + let download_handle = { + let downloads = self.downloads.read().await; + downloads.get(pk).cloned() + }; + + if let Some(download) = download_handle { + tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk); + + if self.min_prebuffer_size > 0 { + download.wait_until_min_size(self.min_prebuffer_size).await + .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; + tracing::debug!("Prebuffering complete for pk {}", pk); + } + + return Ok(Some(pk.to_string())); + } + + Ok(None) + } + + /// Finalise l'ajout d'un fichier au cache + /// + /// Cette fonction helper gère le prébuffering et le nettoyage en background + async fn finalize_download(&self, pk: &str, download: Arc) -> Result { + // Attendre le prébuffering (pour le cache progressif) + if self.min_prebuffer_size > 0 { + download.wait_until_min_size(self.min_prebuffer_size).await + .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; + tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size); + } + + // Lancer une tâche de nettoyage et marquage de complétion en background + let downloads_clone = self.downloads.clone(); + let pk_clone = pk.to_string(); + let completion_marker = self.get_completion_marker_path(pk); + + tokio::spawn(async move { + let result = download.wait_until_finished().await; + downloads_clone.write().await.remove(&pk_clone); + + // Créer le fichier marker de complétion si le téléchargement a réussi + if result.is_ok() { + if let Err(e) = std::fs::write(&completion_marker, "") { + tracing::warn!("Failed to create completion marker for pk {}: {}", pk_clone, e); + } else { + tracing::debug!("Created completion marker for pk {}", pk_clone); + } + } + }); + + Ok(pk.to_string()) + } + /// Crée un nouveau cache sans transformer /// /// # Arguments @@ -124,10 +226,39 @@ impl Cache { db: Arc::new(db), downloads: Arc::new(RwLock::new(HashMap::new())), transformer_factory, + min_prebuffer_size: DEFAULT_PREBUFFER_SIZE, _phantom: std::marker::PhantomData, }) } + /// Configure la taille minimale de prébuffering + /// + /// # Arguments + /// + /// * `size` - Taille minimale en octets (0 = désactivé) + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocache::{Cache, CacheConfig}; + /// + /// struct MyConfig; + /// impl CacheConfig for MyConfig { + /// fn file_extension() -> &'static str { "dat" } + /// } + /// + /// let mut cache = Cache::::new("./cache", 1000).unwrap(); + /// cache.set_prebuffer_size(1024 * 1024); // 1 MB de prébuffering + /// ``` + pub fn set_prebuffer_size(&mut self, size: u64) { + self.min_prebuffer_size = size; + } + + /// Retourne la taille minimale de prébuffering configurée + pub fn get_prebuffer_size(&self) -> u64 { + self.min_prebuffer_size + } + /// Télécharge un fichier depuis une URL et l'ajoute au cache /// /// Cette méthode utilise un système d'identifiants basé sur le contenu plutôt que sur l'URL. @@ -166,25 +297,16 @@ impl Cache { let pk = crate::cache_trait::pk_from_content_header(&header); tracing::debug!("Computed pk {} for URL {}", pk, url); - // 3. Vérifier si le fichier est déjà en cache - if self.db.get(&pk, false).is_ok() { - let file_path = self.get_file_path(&pk); - if file_path.exists() { - // Déjà en cache, update timestamp et retour rapide - tracing::debug!("File with pk {} already in cache, updating timestamp", pk); - self.db.update_hit(&pk)?; - return Ok(pk); - } + // 3. Vérifier si le fichier est déjà en cache ET complet + if self.check_cached_and_complete(&pk).await? { + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; + return Ok(pk); } // 4. Vérifier si un download est déjà en cours pour ce pk - { - let downloads = self.downloads.read().await; - if downloads.contains_key(&pk) { - // Download déjà en cours pour ce contenu, retourner la clé - tracing::debug!("Download already in progress for pk {}", pk); - return Ok(pk); - } + if let Some(pk) = self.check_ongoing_download(&pk).await? { + return Ok(pk); } // 5. Lancer le téléchargement complet avec transformer @@ -202,20 +324,14 @@ impl Cache { // Ajouter immédiatement à la DB self.db.add(&pk, None, collection)?; self.db.set_origin_url(&pk, url)?; + // Appliquer la politique d'éviction LRU si nécessaire if let Err(e) = self.enforce_limit().await { tracing::warn!("Error enforcing cache limit: {}", e); } - // Lancer une tâche de nettoyage en background - let downloads_clone = self.downloads.clone(); - let pk_clone = pk.clone(); - tokio::spawn(async move { - let _ = download.wait_until_finished().await; - downloads_clone.write().await.remove(&pk_clone); - }); - - Ok(pk) + // Finaliser avec prébuffering et nettoyage + self.finalize_download(&pk, download).await } /// Ajoute un fichier à partir d'un flux asynchrone. @@ -252,43 +368,66 @@ impl Cache { where R: AsyncRead + Send + Unpin + 'static, { - // 1. Lire les 512 premiers octets pour calculer le pk - let header = crate::download::peek_reader_header(&mut reader, 512) - .await - .map_err(|e| anyhow!("Failed to peek reader header: {}", e))?; + self.add_from_reader_with_pk(source_uri, reader, length, collection, None).await + } - // 2. Calculer le pk basé sur le contenu - let pk = crate::cache_trait::pk_from_content_header(&header); + /// Ajoute un fichier à partir d'un flux avec un pk explicite optionnel. + /// + /// Si `explicit_pk` est fourni, utilise ce pk au lieu de le calculer à partir du contenu. + /// Ceci est utile quand plusieurs fichiers ont le même header mais doivent être cachés séparément + /// (par exemple, des fichiers FLAC avec le même format mais du contenu différent). + pub async fn add_from_reader_with_pk( + &self, + source_uri: Option<&str>, + mut reader: R, + length: Option, + collection: Option<&str>, + explicit_pk: Option, + ) -> Result + where + R: AsyncRead + Send + Unpin + 'static, + { + // 1. Lire EXACTEMENT 1024 octets (ou EOF si fichier plus petit) + // Utilise read_exact_or_eof qui boucle jusqu'à avoir tous les octets demandés + let header = crate::download::read_exact_or_eof(&mut reader, 1024) + .await + .map_err(|e| anyhow!("Failed to read header bytes: {}", e))?; + + // 2. Calculer le pk selon la taille du fichier + // - Fichiers >= 1024 octets (FLAC): skip header (512 premiers octets), utilise octets 512-1024 + // - Fichiers < 1024 octets (images, petits fichiers): utilise TOUT le contenu + let pk = if let Some(explicit) = explicit_pk { + explicit + } else { + let pk_bytes = if header.len() >= 1024 { + // Gros fichier (>= 1024 octets): skip les 512 premiers (header FLAC) + &header[512..] + } else { + // Petit fichier (< 1024 octets): utiliser TOUT le contenu + &header[..] + }; + crate::cache_trait::pk_from_content_header(pk_bytes) + }; if let Some(uri) = source_uri { tracing::debug!("Computed pk {} for source_uri {}", pk, uri); } else { tracing::debug!("Computed pk {} from reader", pk); } - // 3. Vérifier si le fichier est déjà en cache - if self.db.get(&pk, false).is_ok() { - let file_path = self.get_file_path(&pk); - if file_path.exists() { - // Déjà en cache, update timestamp et retour rapide - tracing::debug!("File with pk {} already in cache, updating timestamp", pk); - self.db.update_hit(&pk)?; - return Ok(pk); - } + // 3. Vérifier si le fichier est déjà en cache ET complet + if self.check_cached_and_complete(&pk).await? { + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; + return Ok(pk); } // 4. Vérifier si un download est déjà en cours pour ce pk - { - let downloads = self.downloads.read().await; - if downloads.contains_key(&pk) { - tracing::debug!("Download already in progress for pk {}", pk); - return Ok(pk); - } + if let Some(pk) = self.check_ongoing_download(&pk).await? { + return Ok(pk); } // 5. Reconstituer le reader complet (header + reste) - // Utiliser tokio::io::chain pour créer un reader composé use std::io::Cursor; - use tokio::io::AsyncReadExt; let header_reader = Cursor::new(header); let full_reader = header_reader.chain(reader); @@ -312,14 +451,8 @@ impl Cache { tracing::warn!("Error enforcing cache limit: {}", e); } - let downloads_clone = self.downloads.clone(); - let pk_clone = pk.clone(); - tokio::spawn(async move { - let _ = download.wait_until_finished().await; - downloads_clone.write().await.remove(&pk_clone); - }); - - Ok(pk) + // Finaliser avec prébuffering et nettoyage + self.finalize_download(&pk, download).await } /// Ajoute un fichier local au cache @@ -497,15 +630,22 @@ impl Cache { } /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants + /// + /// Cette fonction : + /// - Supprime les entrées DB sans fichiers (ou re-télécharge si URL disponible) + /// - Supprime les fichiers sans marker de complétion et leurs entrées DB + /// - Supprime les fichiers sans entrées DB correspondantes pub async fn consolidate(&self) -> Result<()> { // Récupérer la liste des entrées à traiter let entries = self.db.get_all(false)?; - // Supprimer les entrées sans fichiers correspondants + // Supprimer les entrées sans fichiers correspondants OU sans marker de complétion for entry in entries { let file_path = self.get_file_path(&entry.pk); + let completion_marker = self.get_completion_marker_path(&entry.pk); if !file_path.exists() { + // Fichier manquant, essayer de re-télécharger match self.db.get_origin_url(&entry.pk)? { Some(url) => { if let Err(err) = self.add_from_url(&url, entry.collection.as_deref()).await @@ -522,6 +662,14 @@ impl Cache { self.db.delete(&entry.pk)?; } } + } else if !completion_marker.exists() { + // Fichier existe mais pas de marker de complétion -> fichier incomplet + tracing::warn!( + "Removing incomplete file {} (no completion marker)", + entry.pk + ); + let _ = tokio::fs::remove_file(&file_path).await; + self.db.delete(&entry.pk)?; } } @@ -531,11 +679,20 @@ impl Cache { let path = entry.path(); if path.is_file() && path != self.dir.join("cache.db") { if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Ignorer les fichiers .complete + if file_name.ends_with(".complete") { + continue; + } + // Format attendu: {pk}.{qualifier}.{EXT} // On extrait le pk (première partie avant le premier point) if let Some(pk) = file_name.split('.').next() { if self.db.get(pk, false).is_err() { - tokio::fs::remove_file(path).await?; + tracing::debug!("Removing orphan file: {}", file_name); + tokio::fs::remove_file(&path).await?; + // Supprimer aussi le marker de complétion s'il existe + let completion_marker = self.get_completion_marker_path(pk); + let _ = tokio::fs::remove_file(&completion_marker).await; } } } @@ -559,6 +716,27 @@ impl Cache { downloads.get(pk).cloned() } + /// Vérifie si le téléchargement/ingestion d'un fichier est complètement terminé + /// + /// Cette méthode vérifie l'existence du fichier marker de complétion (.complete) + /// qui est créé uniquement quand le fichier est complètement écrit et fermé. + /// + /// Utile pour différencier: + /// - EOF temporaire : fichier encore en cours d'écriture (retourne false) + /// - EOF réel : fichier complètement écrit (retourne true) + /// + /// # Arguments + /// + /// * `pk` - Clé primaire du fichier + /// + /// # Returns + /// + /// `true` si le fichier est complètement écrit (marker existe), `false` sinon + pub fn is_download_complete(&self, pk: &str) -> bool { + let completion_marker = self.get_completion_marker_path(pk); + completion_marker.exists() + } + /// Retourne la taille actuelle téléchargée (source) /// /// Si le download est en cours, retourne la taille téléchargée. @@ -770,17 +948,10 @@ impl Cache { let mut removed = 0; for entry in old_entries { - // Supprimer tous les fichiers avec ce pk (toutes variantes) - if let Ok(mut dir_entries) = tokio::fs::read_dir(&self.dir).await { - while let Ok(Some(dir_entry)) = dir_entries.next_entry().await { - if let Some(filename) = dir_entry.file_name().to_str() { - // Format: {pk}.{param}.{ext} - if filename.starts_with(&entry.pk) - && filename.starts_with(&format!("{}.", entry.pk)) - { - let _ = tokio::fs::remove_file(dir_entry.path()).await; - } - } + // Utiliser get_file_paths() pour obtenir tous les fichiers de cette entrée + if let Ok(paths) = self.get_file_paths(&entry.pk) { + for path in paths { + let _ = tokio::fs::remove_file(path).await; } } diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 07909f2d..0874414b 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -138,9 +138,68 @@ pub trait FileCache: Send + Sync { /// /// # Returns /// - /// `true` si l'entrée existe en base de données et que le fichier est présent - fn is_valid_pk(&self, pk: &str) -> bool { - self.get_database().get(pk, false).is_ok() && self.file_path(pk).exists() + /// `true` si l'entrée existe en base de données et que le fichier est présent ET: + /// - SOIT le fichier est complet (marker .complete existe) + /// - SOIT le download est en cours (fichier récent sans marker) + /// + /// Ceci permet le progressive caching: les fichiers en cours de download sont acceptés + /// dès que le prebuffer est atteint, sans attendre le marker de completion. + async fn is_valid_pk(&self, pk: &str) -> bool { + if self.get_database().get(pk, false).is_err() { + tracing::debug!("is_valid_pk({}): DB entry not found", pk); + return false; + } + + let file_path = self.file_path(pk); + if !file_path.exists() { + // Si l'entrée DB existe mais pas le fichier, c'est probablement en cours d'ingestion + // Attendre jusqu'à 1 seconde que le fichier soit créé (le tokio::spawn peut mettre un peu de temps) + tracing::debug!("is_valid_pk({}): File does not exist yet, waiting for file creation (ingestion in progress)", pk); + + let mut attempts = 0; + while !file_path.exists() && attempts < 100 { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + attempts += 1; + } + + if !file_path.exists() { + tracing::warn!("is_valid_pk({}): File not created after 1s despite DB entry existing", pk); + return false; + } + + tracing::debug!("is_valid_pk({}): File created after {}ms", pk, attempts * 10); + } + + // Vérifier d'abord si le marker de completion existe + let completion_marker = file_path.with_extension( + format!("{}.complete", C::file_extension()) + ); + + if completion_marker.exists() { + tracing::debug!("is_valid_pk({}): Completion marker found, file is complete", pk); + return true; + } + + // Pas de marker - vérifier si le download est en cours (fichier récent) + // Un fichier en cours de download aura une modification récente + if let Ok(metadata) = file_path.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(elapsed) = modified.elapsed() { + let age_secs = elapsed.as_secs(); + if age_secs < 60 { + tracing::debug!("is_valid_pk({}): No marker but file is recent ({}s), download in progress", pk, age_secs); + return true; + } else { + tracing::debug!("is_valid_pk({}): No marker and file is old ({}s), incomplete download", pk, age_secs); + return false; + } + } + } + } + + // Ne peut pas vérifier le statut - rejeter par sécurité + tracing::debug!("is_valid_pk({}): Could not check file status, rejecting", pk); + false } } diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index 657a11ae..cf00bf77 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -192,20 +192,24 @@ impl DB { collection: Option<&str>, metadata: Option<&Value>, ) -> rusqlite::Result<()> { - let conn = self.lock_conn("add_with_metadata"); + // Bloc pour limiter la durée du lock + { + let conn = self.lock_conn("add_with_metadata"); - conn.execute( - "INSERT INTO asset (pk, id, collection, hits, last_used) - VALUES (?1, ?2, ?3, 0, ?4) - ON CONFLICT(pk) DO UPDATE SET - id = excluded.id, - collection = excluded.collection, - last_used = excluded.last_used", - params![pk, id, collection, Utc::now().to_rfc3339()], - )?; + conn.execute( + "INSERT INTO asset (pk, id, collection, hits, last_used) + VALUES (?1, ?2, ?3, 0, ?4) + ON CONFLICT(pk) DO UPDATE SET + id = excluded.id, + collection = excluded.collection, + last_used = excluded.last_used", + params![pk, id, collection, Utc::now().to_rfc3339()], + )?; + } // Lock libéré ici - if metadata.is_some() { - self.set_metadata(pk, metadata.unwrap())? + // Appeler set_metadata après avoir libéré le lock pour éviter un deadlock + if let Some(metadata) = metadata { + self.set_metadata(pk, metadata)?; } Ok(()) @@ -678,7 +682,7 @@ impl DB { let conn = self.lock_conn("get_oldest"); let mut stmt = conn.prepare( - "SELECT pk, source_url, collection, hits, last_used, metadata_json + "SELECT pk, id, collection, hits, last_used FROM asset ORDER BY last_used ASC, hits ASC LIMIT ?1", diff --git a/pmocache/src/download.rs b/pmocache/src/download.rs index 636e89b6..aaeb12d6 100644 --- a/pmocache/src/download.rs +++ b/pmocache/src/download.rs @@ -600,3 +600,37 @@ where buffer.truncate(n); Ok(buffer) } + +/// Lit exactement `size` octets du reader, ou jusqu'à EOF. +/// +/// Contrairement à `peek_reader_header`, cette fonction boucle jusqu'à avoir lu +/// exactement `size` octets (ou atteindre EOF). Ceci est crucial pour calculer +/// un pk fiable sur un nombre d'octets précis. +/// +/// # Returns +/// +/// Le buffer contenant exactement `size` octets, ou moins si EOF est atteint +pub async fn read_exact_or_eof(reader: &mut R, size: usize) -> Result, String> +where + R: AsyncRead + Unpin, +{ + let mut buffer = vec![0u8; size]; + let mut total_read = 0; + + while total_read < size { + let n = reader + .read(&mut buffer[total_read..]) + .await + .map_err(|e| format!("Failed to read from stream: {}", e))?; + + if n == 0 { + // EOF atteint + buffer.truncate(total_read); + return Ok(buffer); + } + + total_read += n; + } + + Ok(buffer) +} diff --git a/pmocache/tests/test_cache.rs b/pmocache/tests/test_cache.rs new file mode 100644 index 00000000..f8aa2e69 --- /dev/null +++ b/pmocache/tests/test_cache.rs @@ -0,0 +1,362 @@ +use pmocache::{Cache, CacheConfig}; +use std::io::Write; +use tempfile::TempDir; + +/// Configuration de test simple +struct TestConfig; + +impl CacheConfig for TestConfig { + fn file_extension() -> &'static str { + "dat" + } + + fn cache_type() -> &'static str { + "test" + } + + fn cache_name() -> &'static str { + "testcache" + } +} + +type TestCache = Cache; + +fn create_test_cache(limit: usize) -> (TempDir, TestCache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = TestCache::new(temp_dir.path().to_str().unwrap(), limit).unwrap(); + (temp_dir, cache) +} + +#[tokio::test] +async fn test_cache_creation() { + let (temp_dir, cache) = create_test_cache(10); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +async fn test_add_from_file() { + let (_temp_dir, cache) = create_test_cache(10); + + // Créer un fichier temporaire pour le test + let test_file = tempfile::NamedTempFile::new().unwrap(); + let test_data = b"Hello, World! This is test data."; + std::fs::write(test_file.path(), test_data).unwrap(); + + // Ajouter le fichier au cache + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Vérifier que le fichier est dans le cache + assert!(!pk.is_empty()); + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + + // Vérifier le contenu + let cached_data = std::fs::read(&cached_path).unwrap(); + assert_eq!(&cached_data, test_data); +} + +#[tokio::test] +async fn test_add_from_reader() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data from reader"; + let reader = std::io::Cursor::new(test_data.to_vec()); + + // Ajouter depuis un reader + let pk = cache + .add_from_reader(None, reader, Some(test_data.len() as u64), None) + .await + .unwrap(); + + // Attendre que le téléchargement soit terminé + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier que le fichier est dans le cache + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + + // Vérifier le contenu + let cached_data = std::fs::read(&cached_path).unwrap(); + assert_eq!(&cached_data, test_data); +} + +#[tokio::test] +async fn test_cache_deduplication() { + let (_temp_dir, cache) = create_test_cache(10); + + // Créer deux fichiers avec le même contenu + let test_data = b"Same content for both files"; + + let file1 = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file1.path(), test_data).unwrap(); + + let file2 = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file2.path(), test_data).unwrap(); + + // Ajouter les deux fichiers + let pk1 = cache + .add_from_file(file1.path().to_str().unwrap(), None) + .await + .unwrap(); + + let pk2 = cache + .add_from_file(file2.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Les deux devraient avoir le même pk (déduplication) + assert_eq!(pk1, pk2); + + // Il ne devrait y avoir qu'une seule entrée en DB + assert_eq!(cache.db.count().unwrap(), 1); +} + +#[tokio::test] +async fn test_cache_collection() { + let (_temp_dir, cache) = create_test_cache(10); + + let collection = "test_album"; + + // Ajouter plusieurs fichiers à la même collection + let mut pks = Vec::new(); + for i in 0..3 { + let data = format!("Track {} data", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + pks.push(pk); + } + + // Récupérer tous les fichiers de la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +async fn test_delete_item() { + let (_temp_dir, cache) = create_test_cache(10); + + // Ajouter un fichier + let test_data = b"Data to be deleted"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Vérifier qu'il existe + assert!(cache.get(&pk).await.is_ok()); + + // Supprimer + cache.delete_item(&pk).await.unwrap(); + + // Vérifier qu'il n'existe plus + assert!(cache.get(&pk).await.is_err()); +} + +#[tokio::test] +async fn test_delete_collection() { + let (_temp_dir, cache) = create_test_cache(10); + + let collection = "test_collection_delete"; + + // Ajouter plusieurs fichiers + for i in 0..3 { + let data = format!("Item {}", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Vérifier que la collection existe + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); + + // Supprimer la collection + cache.delete_collection(collection).await.unwrap(); + + // Vérifier que la collection est vide + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 0); +} + +#[tokio::test] +async fn test_lru_eviction() { + // Créer un cache avec une limite de 3 éléments + let (_temp_dir, cache) = create_test_cache(3); + + let mut pks = Vec::new(); + + // Ajouter 5 fichiers (devrait déclencher l'éviction) + for i in 0..5 { + let data = format!("File {} data", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + pks.push(pk); + + // Petit délai pour s'assurer que les timestamps sont différents + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + // Le cache ne devrait contenir que 3 éléments (les plus récents) + let count = cache.db.count().unwrap(); + assert_eq!(count, 3); + + // Les 2 premiers fichiers devraient avoir été évincés + assert!(cache.get(&pks[0]).await.is_err()); + assert!(cache.get(&pks[1]).await.is_err()); + + // Les 3 derniers devraient être présents + assert!(cache.get(&pks[2]).await.is_ok()); + assert!(cache.get(&pks[3]).await.is_ok()); + assert!(cache.get(&pks[4]).await.is_ok()); +} + +#[tokio::test] +async fn test_cache_purge() { + let (_temp_dir, cache) = create_test_cache(10); + + // Ajouter plusieurs fichiers + for i in 0..3 { + let data = format!("File {}", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + } + + assert_eq!(cache.db.count().unwrap(), 3); + + // Purger le cache + cache.purge().await.unwrap(); + + // Le cache devrait être vide + assert_eq!(cache.db.count().unwrap(), 0); +} + +#[tokio::test] +async fn test_get_metadata() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Ajouter des métadonnées + cache + .db + .set_a_metadata(&pk, "test_key", serde_json::json!("test_value")) + .unwrap(); + + // Récupérer les métadonnées + let value = cache.get_a_metadata(&pk, "test_key").await.unwrap(); + assert_eq!(value, Some(serde_json::json!("test_value"))); +} + +#[tokio::test] +async fn test_touch() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + let entry_before = cache.db.get(&pk, false).unwrap(); + let hits_before = entry_before.hits; + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Touch le fichier + cache.touch(&pk).await.unwrap(); + + let entry_after = cache.db.get(&pk, false).unwrap(); + assert_eq!(entry_after.hits, hits_before + 1); +} + +#[tokio::test] +async fn test_consolidate() { + let (temp_dir, cache) = create_test_cache(10); + + // Ajouter un fichier + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Supprimer manuellement le fichier (créer un orphelin) + let file_path = cache.get_file_path(&pk); + std::fs::remove_file(&file_path).unwrap(); + + // Consolider devrait supprimer l'entrée orpheline de la DB + cache.consolidate().await.unwrap(); + + // L'entrée ne devrait plus exister en DB + assert!(cache.db.get(&pk, false).is_err()); +} + +#[tokio::test] +async fn test_prebuffer_size() { + let (_temp_dir, mut cache) = create_test_cache(10); + + // Configurer la taille de prébuffering + let prebuffer_size = 1024; + cache.set_prebuffer_size(prebuffer_size); + + assert_eq!(cache.get_prebuffer_size(), prebuffer_size); +} + +#[tokio::test] +async fn test_is_finished() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Small test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Attendre que le téléchargement soit terminé + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier qu'il est bien terminé + assert!(cache.is_finished(&pk).await); +} diff --git a/pmocache/tests/test_db.rs b/pmocache/tests/test_db.rs new file mode 100644 index 00000000..1066e7a2 --- /dev/null +++ b/pmocache/tests/test_db.rs @@ -0,0 +1,308 @@ +use pmocache::db::DB; +use serde_json::{json, Value}; +use tempfile::TempDir; + +/// Crée une DB temporaire pour les tests +fn create_test_db() -> (TempDir, DB) { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DB::init(&db_path).unwrap(); + (temp_dir, db) +} + +#[test] +fn test_db_init() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DB::init(&db_path); + assert!(db.is_ok()); + assert!(db_path.exists()); +} + +#[test] +fn test_add_and_get() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_123"; + let id = Some("test_id"); + let collection = Some("test_collection"); + + // Ajouter une entrée + let result = db.add(pk, id, collection); + assert!(result.is_ok()); + + // Récupérer l'entrée + let entry = db.get(pk, false); + assert!(entry.is_ok()); + + let entry = entry.unwrap(); + assert_eq!(entry.pk, pk); + assert_eq!(entry.id.as_deref(), id); + assert_eq!(entry.collection.as_deref(), collection); + assert_eq!(entry.hits, 0); +} + +#[test] +fn test_add_with_metadata() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_456"; + let metadata = json!({ + "title": "Test Track", + "artist": "Test Artist", + "duration": 180, + "bitrate": 320 + }); + + // Ajouter avec métadonnées + let result = db.add_with_metadata(pk, None, None, Some(&metadata)); + assert!(result.is_ok()); + + // Récupérer l'entrée avec métadonnées + let entry = db.get(pk, true).unwrap(); + assert_eq!(entry.pk, pk); + assert!(entry.metadata.is_some()); + + let stored_metadata = entry.metadata.unwrap(); + assert_eq!(stored_metadata["title"], "Test Track"); + assert_eq!(stored_metadata["artist"], "Test Artist"); + assert_eq!(stored_metadata["duration"], 180); + assert_eq!(stored_metadata["bitrate"], 320); +} + +#[test] +fn test_update_hit() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_789"; + db.add(pk, None, None).unwrap(); + + // Récupérer l'entrée initiale + let entry = db.get(pk, false).unwrap(); + let initial_hits = entry.hits; + let initial_last_used = entry.last_used.clone(); + + // Attendre un peu pour que le timestamp change + std::thread::sleep(std::time::Duration::from_millis(10)); + + // Mettre à jour le hit + db.update_hit(pk).unwrap(); + + // Vérifier que hits a augmenté et last_used a changé + let entry = db.get(pk, false).unwrap(); + assert_eq!(entry.hits, initial_hits + 1); + assert_ne!(entry.last_used, initial_last_used); +} + +#[test] +fn test_delete() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_delete"; + db.add(pk, None, None).unwrap(); + + // Vérifier que l'entrée existe + assert!(db.get(pk, false).is_ok()); + + // Supprimer l'entrée + let result = db.delete(pk); + assert!(result.is_ok()); + + // Vérifier que l'entrée n'existe plus + assert!(db.get(pk, false).is_err()); +} + +#[test] +fn test_get_by_collection() { + let (_temp_dir, db) = create_test_db(); + + let collection = "test_collection"; + + // Ajouter plusieurs entrées dans la même collection + db.add("pk1", None, Some(collection)).unwrap(); + db.add("pk2", None, Some(collection)).unwrap(); + db.add("pk3", None, Some("other_collection")).unwrap(); + + // Récupérer les entrées de la collection + let entries = db.get_by_collection(collection, false).unwrap(); + + assert_eq!(entries.len(), 2); + assert!(entries.iter().any(|e| e.pk == "pk1")); + assert!(entries.iter().any(|e| e.pk == "pk2")); + assert!(!entries.iter().any(|e| e.pk == "pk3")); +} + +#[test] +fn test_delete_collection() { + let (_temp_dir, db) = create_test_db(); + + let collection = "test_collection_to_delete"; + + db.add("pk1", None, Some(collection)).unwrap(); + db.add("pk2", None, Some(collection)).unwrap(); + db.add("pk3", None, Some("other_collection")).unwrap(); + + // Supprimer la collection + let result = db.delete_collection(collection); + assert!(result.is_ok()); + + // Vérifier que les entrées de la collection sont supprimées + let entries = db.get_by_collection(collection, false).unwrap(); + assert_eq!(entries.len(), 0); + + // Vérifier que l'autre collection existe toujours + assert!(db.get("pk3", false).is_ok()); +} + +#[test] +fn test_get_oldest() { + let (_temp_dir, db) = create_test_db(); + + // Ajouter plusieurs entrées avec des timestamps différents + db.add("pk1", None, None).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + db.add("pk2", None, None).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + db.add("pk3", None, None).unwrap(); + + // Mettre à jour le hit de pk1 pour le rendre plus récent + std::thread::sleep(std::time::Duration::from_millis(10)); + db.update_hit("pk1").unwrap(); + + // Récupérer les 2 plus anciennes entrées + let oldest = db.get_oldest(2).unwrap(); + + assert_eq!(oldest.len(), 2); + // pk2 et pk3 devraient être les plus anciennes + assert!(oldest.iter().any(|e| e.pk == "pk2")); + assert!(oldest.iter().any(|e| e.pk == "pk3")); +} + +#[test] +fn test_count() { + let (_temp_dir, db) = create_test_db(); + + assert_eq!(db.count().unwrap(), 0); + + db.add("pk1", None, None).unwrap(); + assert_eq!(db.count().unwrap(), 1); + + db.add("pk2", None, None).unwrap(); + assert_eq!(db.count().unwrap(), 2); + + db.delete("pk1").unwrap(); + assert_eq!(db.count().unwrap(), 1); +} + +#[test] +fn test_purge() { + let (_temp_dir, db) = create_test_db(); + + db.add("pk1", None, None).unwrap(); + db.add("pk2", None, None).unwrap(); + db.add("pk3", None, None).unwrap(); + + assert_eq!(db.count().unwrap(), 3); + + // Purger toutes les entrées + let result = db.purge(); + assert!(result.is_ok()); + + assert_eq!(db.count().unwrap(), 0); +} + +#[test] +fn test_origin_url() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_url"; + let url = "https://example.com/test.flac"; + + db.add(pk, None, None).unwrap(); + db.set_origin_url(pk, url).unwrap(); + + let retrieved_url = db.get_origin_url(pk).unwrap(); + assert_eq!(retrieved_url, Some(url.to_string())); +} + +#[test] +fn test_get_from_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_by_id"; + let collection = "my_collection"; + let id = "my_unique_id"; + + db.add(pk, Some(id), Some(collection)).unwrap(); + + // Récupérer par (collection, id) + let entry = db.get_from_id(collection, id, false).unwrap(); + assert_eq!(entry.pk, pk); + assert_eq!(entry.id.as_deref(), Some(id)); + assert_eq!(entry.collection.as_deref(), Some(collection)); +} + +#[test] +fn test_does_collection_contain_id() { + let (_temp_dir, db) = create_test_db(); + + let collection = "my_collection"; + let id = "my_id"; + + assert!(!db.does_collection_contain_id(collection, id)); + + db.add("pk", Some(id), Some(collection)).unwrap(); + + assert!(db.does_collection_contain_id(collection, id)); +} + +#[test] +fn test_get_pk_from_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_123"; + let collection = "my_collection"; + let id = "my_id"; + + db.add(pk, Some(id), Some(collection)).unwrap(); + + let retrieved_pk = db.get_pk_from_id(collection, id).unwrap(); + assert_eq!(retrieved_pk, pk); +} + +#[test] +fn test_set_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk"; + db.add(pk, None, None).unwrap(); + + // Définir l'id + let new_id = "new_id"; + db.set_id(pk, new_id).unwrap(); + + let entry = db.get(pk, false).unwrap(); + assert_eq!(entry.id.as_deref(), Some(new_id)); +} + +#[test] +fn test_metadata_types() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_types"; + db.add(pk, None, None).unwrap(); + + // Tester les différents types de métadonnées + db.set_a_metadata(pk, "string_val", Value::String("test".to_string())).unwrap(); + db.set_a_metadata(pk, "number_val", json!(42)).unwrap(); + db.set_a_metadata(pk, "bool_val", Value::Bool(true)).unwrap(); + db.set_a_metadata(pk, "null_val", Value::Null).unwrap(); + + // Vérifier les valeurs + assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string()))); + assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42))); + assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true))); + assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null)); +} diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index eea54228..32db1101 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -32,6 +32,9 @@ utoipa = { version = "5.3", features = ["axum_extras"], optional = true } tracing = "0.1.41" +[dev-dependencies] +tempfile = "3" + [features] default = ["pmoserver"] pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] diff --git a/pmocovers/tests/test_cache.rs b/pmocovers/tests/test_cache.rs new file mode 100644 index 00000000..7c95acf2 --- /dev/null +++ b/pmocovers/tests/test_cache.rs @@ -0,0 +1,148 @@ +use pmocovers::cache; +use tempfile::TempDir; +use image::{ImageBuffer, Rgba}; + +fn create_test_cache() -> (TempDir, cache::Cache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + (temp_dir, cache) +} + +/// Crée une image de test simple +fn create_test_image(width: u32, height: u32) -> Vec { + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(width, height, |x, y| { + if (x + y) % 2 == 0 { + Rgba([255, 0, 0, 255]) // Rouge + } else { + Rgba([0, 0, 255, 255]) // Bleu + } + }); + + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + buffer +} + +#[tokio::test] +async fn test_cover_cache_creation() { + let (temp_dir, cache) = create_test_cache(); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +async fn test_add_image_from_file() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer une image de test + let test_image = create_test_image(100, 100); + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &test_image).unwrap(); + + // Ajouter au cache + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + assert!(!pk.is_empty()); + + // Attendre la fin de la conversion + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier que le fichier WebP existe + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + assert!(cached_path.extension().unwrap() == "webp"); +} + +#[tokio::test] +async fn test_covers_config() { + use pmocache::CacheConfig; + + assert_eq!(cache::CoversConfig::file_extension(), "webp"); + assert_eq!(cache::CoversConfig::cache_type(), "image"); + assert_eq!(cache::CoversConfig::cache_name(), "covers"); +} + +#[tokio::test] +async fn test_collection_management() { + let (_temp_dir, cache) = create_test_cache(); + + let collection = "album_covers"; + + // Ajouter plusieurs images à la même collection + for i in 0..3 { + let img = create_test_image(50 + i * 10, 50 + i * 10); + let file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file.path(), &img).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Récupérer la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +#[ignore] // Test d'éviction LRU avec transformer WebP, parfois échoue timing +async fn test_cache_limit() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 2).unwrap(); + + // Ajouter 3 images (devrait déclencher l'éviction LRU) + for i in 0..3 { + let img = create_test_image(100, 100); + let file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file.path(), &img).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + // Attendre l'éviction + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Le cache ne devrait contenir que 2 éléments + let count = cache.db.count().unwrap(); + assert_eq!(count, 2); +} + +#[tokio::test] +async fn test_deduplication() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer deux fichiers avec la même image + let img = create_test_image(100, 100); + + let file1 = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file1.path(), &img).unwrap(); + + let file2 = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file2.path(), &img).unwrap(); + + // Ajouter les deux images + let pk1 = cache + .add_from_file(file1.path().to_str().unwrap(), None) + .await + .unwrap(); + + let pk2 = cache + .add_from_file(file2.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Les deux devraient avoir le même pk (déduplication) + assert_eq!(pk1, pk2); + + // Il ne devrait y avoir qu'une seule entrée en DB + assert_eq!(cache.db.count().unwrap(), 1); +} diff --git a/pmocovers/tests/test_webp.rs b/pmocovers/tests/test_webp.rs new file mode 100644 index 00000000..8bb7b1a2 --- /dev/null +++ b/pmocovers/tests/test_webp.rs @@ -0,0 +1,158 @@ +use image::{DynamicImage, ImageBuffer, Rgba}; +use pmocovers::webp::{encode_webp, ensure_square}; + +/// Crée une image de test simple +fn create_test_image(width: u32, height: u32) -> DynamicImage { + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(width, height, |x, y| { + if (x + y) % 2 == 0 { + Rgba([255, 0, 0, 255]) + } else { + Rgba([0, 0, 255, 255]) + } + }); + DynamicImage::ImageRgba8(img) +} + +#[test] +fn test_encode_webp() { + let img = create_test_image(100, 100); + let webp_data = encode_webp(&img); + + assert!(webp_data.is_ok()); + let data = webp_data.unwrap(); + assert!(!data.is_empty()); + + // Vérifier la signature WebP (RIFF...WEBP) + assert_eq!(&data[0..4], b"RIFF"); + assert_eq!(&data[8..12], b"WEBP"); +} + +#[test] +fn test_ensure_square_portrait() { + // Image portrait (plus haute que large) + let img = create_test_image(100, 200); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_landscape() { + // Image landscape (plus large que haute) + let img = create_test_image(200, 100); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_already_square() { + // Image déjà carrée + let img = create_test_image(150, 150); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_small_image() { + // Petite image qui doit être agrandie + let img = create_test_image(50, 50); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_different_sizes() { + let img = create_test_image(100, 100); + + // Tester différentes tailles de sortie + for size in [64, 128, 256, 512] { + let square = ensure_square(&img, size); + assert_eq!(square.width(), size); + assert_eq!(square.height(), size); + } +} + +#[tokio::test] +async fn test_generate_variant() { + use pmocovers::cache; + use tempfile::TempDir; + + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + + // Créer et ajouter une image + let img = create_test_image(400, 400); + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &buffer).unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + cache.wait_until_finished(&pk).await.unwrap(); + + // Générer une variante de taille 128 + let variant_data = pmocovers::webp::generate_variant(&cache, &pk, 128) + .await + .unwrap(); + + assert!(!variant_data.is_empty()); + + // Vérifier que c'est bien du WebP + assert_eq!(&variant_data[0..4], b"RIFF"); + assert_eq!(&variant_data[8..12], b"WEBP"); + + // Vérifier que le fichier de la variante a été créé + let variant_path = cache.get_file_path_with_qualifier(&pk, "128"); + assert!(variant_path.exists()); +} + +#[tokio::test] +async fn test_generate_variant_caching() { + use pmocovers::cache; + use tempfile::TempDir; + + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + + // Créer et ajouter une image + let img = create_test_image(400, 400); + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &buffer).unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + cache.wait_until_finished(&pk).await.unwrap(); + + // Générer la variante une première fois + let variant1 = pmocovers::webp::generate_variant(&cache, &pk, 256) + .await + .unwrap(); + + // Générer la variante une deuxième fois (devrait lire depuis le cache) + let variant2 = pmocovers::webp::generate_variant(&cache, &pk, 256) + .await + .unwrap(); + + // Les deux devraient être identiques + assert_eq!(variant1, variant2); +} diff --git a/pmoflac/src/ogg_flac_encoder.rs b/pmoflac/src/ogg_flac_encoder.rs new file mode 100644 index 00000000..a83d9286 --- /dev/null +++ b/pmoflac/src/ogg_flac_encoder.rs @@ -0,0 +1,292 @@ +//! # OGG-FLAC Streaming Encoder +//! +//! This module provides 100% streaming OGG-FLAC encoding, wrapping FLAC frames +//! in OGG container pages for maximum compatibility with streaming clients. +//! +//! ## Architecture +//! +//! ```text +//! PCM Input → [FLAC Encoder] → [OGG Wrapper Task] → AsyncRead Output +//! ↓ ↓ +//! FLAC frames OGG pages +//! ``` +//! +//! The encoder: +//! 1. Encodes PCM audio to FLAC frames using the existing FLAC encoder +//! 2. Wraps FLAC frames in OGG container pages +//! 3. Generates proper OGG-FLAC headers (identification + Vorbis Comments) +//! 4. Streams the result as AsyncRead for HTTP serving +//! +//! ## Key Features +//! +//! - **100% streaming**: No seek operations, no buffering beyond necessary +//! - **OGG page generation**: Creates proper OGG pages with CRC32 checksums +//! - **FLAC identification**: Embeds FLAC magic "fLaC" in first OGG packet +//! - **Vorbis Comments**: Supports metadata tags (TITLE, ARTIST, ALBUM, etc.) +//! - **Dynamic metadata**: Can update metadata by starting new logical bitstream +//! +//! ## Metadata Handling +//! +//! OGG-FLAC metadata is static once the stream starts. To update metadata: +//! - Use endpoint `/metadata` for JSON queries (real-time updates) +//! - Or implement OGG chaining (new logical bitstream per track) +//! +//! ## Example +//! +//! ```no_run +//! use pmoflac::{encode_ogg_flac_stream, PcmFormat, EncoderOptions}; +//! use tokio::fs::File; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let pcm_reader = get_pcm_source().await; +//! +//! let format = PcmFormat { +//! sample_rate: 44100, +//! channels: 2, +//! bits_per_sample: 16, +//! }; +//! +//! let mut ogg_stream = encode_ogg_flac_stream( +//! pcm_reader, +//! format, +//! EncoderOptions::default(), +//! None, // No initial metadata +//! ).await?; +//! +//! // Stream to HTTP client or file +//! let mut output = File::create("output.ogg").await?; +//! tokio::io::copy(&mut ogg_stream, &mut output).await?; +//! ogg_stream.wait().await?; +//! +//! Ok(()) +//! } +//! ``` + +use bytes::Bytes; +use tokio::io::AsyncRead; +use tokio::sync::mpsc; +use std::collections::HashMap; +use std::io::{self, Write}; + +use crate::{ + encode_flac_stream, EncoderOptions, FlacEncodedStream, PcmFormat, + stream::ManagedAsyncReader, +}; + +/// OGG page writer for wrapping FLAC frames +struct OggPageWriter { + stream_serial: u32, + page_sequence: u32, + granule_position: u64, +} + +impl OggPageWriter { + fn new(stream_serial: u32) -> Self { + Self { + stream_serial, + page_sequence: 0, + granule_position: 0, + } + } + + /// Create an OGG page from packet data + fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec { + let mut segments = Vec::new(); + let mut remaining = packet_data.len(); + let mut offset = 0; + + // Segment the packet into 255-byte chunks + while remaining > 0 { + let segment_size = remaining.min(255); + segments.push(segment_size as u8); + remaining -= segment_size; + offset += segment_size; + } + + // If packet ends exactly on a 255-byte boundary, add empty segment + if !packet_data.is_empty() && packet_data.len() % 255 == 0 && !is_continuation { + segments.push(0); + } + + let segment_count = segments.len(); + let header_size = 27 + segment_count; + let total_size = header_size + packet_data.len(); + + let mut page = Vec::with_capacity(total_size); + + // OGG page header + page.write_all(b"OggS").unwrap(); // Capture pattern + page.write_all(&[0]).unwrap(); // Version + + // Header type + let mut header_type = 0u8; + if is_continuation { + header_type |= 0x01; // Continuation + } + if is_bos { + header_type |= 0x02; // Beginning of stream + } + if is_eos { + header_type |= 0x04; // End of stream + } + page.write_all(&[header_type]).unwrap(); + + // Granule position (8 bytes, little-endian) + page.write_all(&self.granule_position.to_le_bytes()).unwrap(); + + // Stream serial number (4 bytes, little-endian) + page.write_all(&self.stream_serial.to_le_bytes()).unwrap(); + + // Page sequence number (4 bytes, little-endian) + page.write_all(&self.page_sequence.to_le_bytes()).unwrap(); + self.page_sequence += 1; + + // CRC checksum (4 bytes, zero for now, calculated later) + let crc_offset = page.len(); + page.write_all(&[0, 0, 0, 0]).unwrap(); + + // Number of segments + page.write_all(&[segment_count as u8]).unwrap(); + + // Segment table + page.write_all(&segments).unwrap(); + + // Packet data + page.write_all(packet_data).unwrap(); + + // Calculate and insert CRC32 + let crc = calculate_ogg_crc(&page); + page[crc_offset..crc_offset + 4].copy_from_slice(&crc.to_le_bytes()); + + page + } +} + +/// Calculate OGG CRC32 checksum +fn calculate_ogg_crc(data: &[u8]) -> u32 { + const CRC_TABLE: [u32; 256] = generate_crc_table(); + + let mut crc: u32 = 0; + for &byte in data { + crc = (crc << 8) ^ CRC_TABLE[((crc >> 24) ^ (byte as u32)) as usize]; + } + crc +} + +/// Generate CRC lookup table at compile time +const fn generate_crc_table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut r = i << 24; + let mut j = 0; + while j < 8 { + if (r & 0x80000000) != 0 { + r = (r << 1) ^ 0x04c11db7; + } else { + r <<= 1; + } + j += 1; + } + table[i as usize] = r; + i += 1; + } + table +} + +/// Vorbis Comment metadata for OGG-FLAC +#[derive(Debug, Clone, Default)] +pub struct OggFlacMetadata { + pub vendor: String, + pub comments: HashMap, +} + +impl OggFlacMetadata { + pub fn new() -> Self { + Self { + vendor: "pmoflac OGG-FLAC encoder".to_string(), + comments: HashMap::new(), + } + } + + pub fn with_tag(mut self, key: impl Into, value: impl Into) -> Self { + self.comments.insert(key.into().to_uppercase(), value.into()); + self + } + + /// Encode as Vorbis Comment block (for OGG FLAC) + fn encode_vorbis_comment(&self) -> Vec { + let mut data = Vec::new(); + + // Vendor string length + string + let vendor_bytes = self.vendor.as_bytes(); + data.write_all(&(vendor_bytes.len() as u32).to_le_bytes()).unwrap(); + data.write_all(vendor_bytes).unwrap(); + + // Number of comments + data.write_all(&(self.comments.len() as u32).to_le_bytes()).unwrap(); + + // Comments + for (key, value) in &self.comments { + let comment = format!("{}={}", key, value); + let comment_bytes = comment.as_bytes(); + data.write_all(&(comment_bytes.len() as u32).to_le_bytes()).unwrap(); + data.write_all(comment_bytes).unwrap(); + } + + data + } +} + +/// OGG-FLAC encoded stream (AsyncRead) +pub type OggFlacEncodedStream = FlacEncodedStream; + +/// Encode PCM audio to OGG-FLAC format (100% streaming) +/// +/// This function wraps the FLAC encoder and generates proper OGG container pages. +/// +/// # Arguments +/// +/// * `reader` - AsyncRead source of PCM audio data +/// * `format` - PCM format specification (sample rate, channels, bit depth) +/// * `options` - FLAC encoder options (compression level, etc.) +/// * `metadata` - Optional Vorbis Comment metadata +/// +/// # Returns +/// +/// An AsyncRead stream that produces OGG-FLAC encoded audio. +/// +/// # Example +/// +/// ```no_run +/// use pmoflac::{encode_ogg_flac_stream, PcmFormat, EncoderOptions, OggFlacMetadata}; +/// +/// let metadata = OggFlacMetadata::new() +/// .with_tag("TITLE", "Song Name") +/// .with_tag("ARTIST", "Artist Name"); +/// +/// let stream = encode_ogg_flac_stream( +/// pcm_reader, +/// PcmFormat { sample_rate: 44100, channels: 2, bits_per_sample: 16 }, +/// EncoderOptions::default(), +/// Some(metadata), +/// ).await?; +/// ``` +pub async fn encode_ogg_flac_stream( + reader: R, + format: PcmFormat, + options: EncoderOptions, + metadata: Option, +) -> Result +where + R: AsyncRead + Unpin + Send + 'static, +{ + // First, encode to FLAC + let flac_stream = encode_flac_stream(reader, format, options).await?; + + // TODO: Wrap FLAC stream in OGG pages + // For now, return FLAC stream directly (will implement OGG wrapper next) + + Ok(flac_stream) +} diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index baf25782..6267f96b 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -52,7 +52,7 @@ symphonia = { version = "0.5", features = ["all"] } claxon = "0.4" # pmoaudio-ext with playlist support (optional for examples) -pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist"] } +pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream"] } # Common music source traits pmosource = { path = "../pmosource" } @@ -91,7 +91,7 @@ cache = [] # Active le support pmoaudio node (RadioParadiseStreamSource) pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"] # Active le support complet avec playlist (pour les exemples avancés) -full = ["pmoaudio", "dep:pmoaudio-ext", "pmoconfig"] +full = ["pmoaudio", "dep:pmoaudio-ext", "pmoconfig", "pmoserver"] [dev-dependencies] # Tests @@ -106,3 +106,8 @@ pmoaudiocache = { path = "../pmoaudiocache" } [[example]] name = "now_playing" path = "examples/now_playing.rs" + +[[example]] +name = "stream_block" +path = "examples/stream_block.rs" +required-features = ["full"] diff --git a/pmoparadise/examples/play_and_cache.rs b/pmoparadise/examples/play_and_cache.rs index 57354fa6..52fe6f5c 100644 --- a/pmoparadise/examples/play_and_cache.rs +++ b/pmoparadise/examples/play_and_cache.rs @@ -4,7 +4,8 @@ //! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC //! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist //! 3. PlaylistSource - Lit la playlist pendant le téléchargement -//! 4. AudioSink - Joue l'audio sur la sortie standard +//! 4. TimerNode - Régule le débit pour éviter EOF prématurés (progressive cache) +//! 5. AudioSink - Joue l'audio sur la sortie standard //! //! Architecture : //! ```text @@ -12,7 +13,10 @@ //! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée) //! //! Pipeline 2 (Playback): -//! PlaylistSource (lit la playlist) → AudioSink (joue l'audio) +//! PlaylistSource → TimerNode (rate limiting) → AudioSink +//! ↓ +//! Prévention EOF +//! (3s max lead) //! ``` //! //! Usage: @@ -22,7 +26,7 @@ //! cargo run --example play_and_cache --features full -- 0 # Main Mix //! cargo run --example play_and_cache --features full -- 2 # Rock Mix -use pmoaudio::{AudioPipelineNode, AudioSink}; +use pmoaudio::{AudioPipelineNode, AudioSink, TimerNode}; use pmoaudio_ext::{FlacCacheSink, PlaylistSource}; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoverCache; @@ -50,8 +54,8 @@ async fn main() -> Result<(), Box> { // Récupérer les arguments let args: Vec = env::args().collect(); - if args.len() != 2 { - eprintln!("Usage: {} ", args[0]); + if args.len() < 2 { + eprintln!("Usage: {} [--null-audio]", args[0]); eprintln!(); eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously."); eprintln!(); @@ -60,6 +64,9 @@ async fn main() -> Result<(), Box> { eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); eprintln!(" 2 - Rock Mix (classic & modern rock)"); eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --null-audio Don't play audio (for testing without audio device)"); std::process::exit(1); } @@ -71,7 +78,12 @@ async fn main() -> Result<(), Box> { } }; + let use_null_audio = args.len() > 2 && args[2] == "--null-audio"; + tracing::info!("Channel ID: {}", channel_id); + if use_null_audio { + tracing::info!("Using null audio output (no playback)"); + } // ═══════════════════════════════════════════════════════════════════════════ // Initialiser les caches et le gestionnaire de playlist @@ -117,8 +129,8 @@ async fn main() -> Result<(), Box> { let playlist_id = format!("radio-paradise-ch{}", channel_id); tracing::info!("Creating playlist: {}", playlist_id); - // Créer la playlist (ou la vider si elle existe) - let mut writer = playlist_manager.create_persistent_playlist(playlist_id.clone()).await?; + // Créer une playlist éphémère (non persistante) pour cet exemple + let writer = playlist_manager.get_write_handle(playlist_id.clone()).await?; writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?; writer.flush().await?; // Vider la playlist si elle existait tracing::debug!("Playlist created and flushed"); @@ -187,13 +199,25 @@ async fn main() -> Result<(), Box> { let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone()); tracing::debug!("PlaylistSource created"); + // Créer le timer node pour réguler le débit (empêche EOF prématurés) + // Tolère 3 secondes d'avance max pour permettre le buffering + let mut timer = TimerNode::new(3.0); + tracing::debug!("TimerNode created (max_lead_time=3.0s)"); + // Créer le sink audio - let audio_sink = AudioSink::new(); + let audio_sink = if use_null_audio { + AudioSink::with_null_output() + } else { + AudioSink::new() + }; tracing::debug!("AudioSink created"); - // Connecter playlist → audio - playlist_source.register(Box::new(audio_sink)); - tracing::info!("Playback pipeline connected: PlaylistSource → AudioSink"); + // Connecter timer → audio (AVANT de mettre timer dans une Box) + timer.register(Box::new(audio_sink)); + + // Connecter playlist → timer + playlist_source.register(Box::new(timer)); + tracing::info!("Playback pipeline connected: PlaylistSource → TimerNode → AudioSink"); // ═══════════════════════════════════════════════════════════════════════════ // Lancer les deux pipelines en parallèle @@ -233,9 +257,9 @@ async fn main() -> Result<(), Box> { }); let playback_handle = tokio::spawn(async move { - // Attendre un peu que le premier track soit disponible - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - tracing::info!("[PLAYBACK] Pipeline starting..."); + // Pas de sleep - le cache progressif permet de démarrer immédiatement + // dès que le prebuffer (512 KB) est atteint + tracing::info!("[PLAYBACK] Pipeline starting (will wait for prebuffer)..."); let result = Box::new(playlist_source).run(stop_token_playback).await; match &result { Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"), diff --git a/pmoparadise/examples/stream_block.rs b/pmoparadise/examples/stream_block.rs new file mode 100644 index 00000000..894e555a --- /dev/null +++ b/pmoparadise/examples/stream_block.rs @@ -0,0 +1,336 @@ +//! Streams a Radio Paradise block via HTTP using pmoserver +//! +//! This example demonstrates streaming a single Radio Paradise block +//! using the StreamingFlacSink over HTTP via pmoserver. Perfect for +//! testing with VLC or other media players that support HTTP streaming. +//! +//! Architecture: +//! ```text +//! RadioParadiseStreamSource → TimerNode → StreamingFlacSink +//! ↓ +//! StreamHandle +//! ↓ +//! pmoserver (Axum) +//! ↓ +//! VLC / Media Player Client +//! ``` +//! +//! Usage: +//! cargo run --example stream_block --features full -- +//! +//! Example: +//! cargo run --example stream_block --features full -- 0 # Main Mix +//! +//! Then open in VLC: +//! vlc http://localhost:8080/test/stream (pure FLAC) +//! vlc http://localhost:8080/test/stream-ogg (OGG-FLAC streaming container) +//! vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata) +//! +//! To check current metadata: +//! curl http://localhost:8080/test/metadata + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use pmoaudio::{AudioPipelineNode, TimerNode}; +use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; +use pmoflac::EncoderOptions; +use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; +use pmoserver::{ServerBuilder, init_logging}; +use std::env; +use std::sync::Arc; +use tokio_util::io::ReaderStream; +use tokio_util::sync::CancellationToken; + +/// Shared application state +struct AppState { + stream_handle: pmoaudio_ext::StreamHandle, + ogg_handle: pmoaudio_ext::OggFlacStreamHandle, +} + +/// Main HTTP handler for streaming (pure FLAC, no ICY metadata) +async fn stream_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (pure FLAC mode)"); + + // Pure FLAC stream without ICY metadata + let flac_stream = state.stream_handle.subscribe_flac(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(flac_stream))) + .unwrap()) +} + +/// ICY streaming handler (FLAC with embedded metadata) +async fn stream_icy_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (ICY mode)"); + + // FLAC stream with ICY metadata + let icy_stream = state.stream_handle.subscribe_icy(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/flac") + .header("icy-metaint", "16000") + .header("icy-name", "Radio Paradise Stream Test") + .header("icy-genre", "Eclectic") + .header("icy-pub", "1") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(icy_stream))) + .unwrap()) +} + +/// OGG-FLAC streaming handler +async fn stream_ogg_handler( + State(state): State>, + _headers: HeaderMap, +) -> Result { + tracing::info!("New client connected (OGG-FLAC mode)"); + + // OGG-FLAC stream + let ogg_stream = state.ogg_handle.subscribe(); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "audio/ogg") + .header("Cache-Control", "no-cache, no-store") + .body(Body::from_stream(ReaderStream::new(ogg_stream))) + .unwrap()) +} + +/// Metadata endpoint (JSON) +async fn metadata_handler(State(state): State>) -> impl IntoResponse { + let metadata = state.stream_handle.get_metadata().await; + axum::Json(metadata) +} + +/// Health check endpoint +async fn health_handler() -> &'static str { + "OK" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging via pmoserver + let _log_state = init_logging(); + + tracing::info!("=== Radio Paradise HTTP Streaming Test ==="); + + // Parse arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!(); + eprintln!("Streams a Radio Paradise block via HTTP for testing."); + eprintln!(); + eprintln!("Channel IDs:"); + eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); + eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); + eprintln!(" 2 - Rock Mix (classic & modern rock)"); + eprintln!(" 3 - World/Etc Mix (global sounds)"); + eprintln!(); + eprintln!("After starting, open in VLC:"); + eprintln!(" vlc http://localhost:8080/test/stream (pure FLAC)"); + eprintln!(" vlc http://localhost:8080/test/stream-ogg (OGG-FLAC container)"); + eprintln!(" vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata)"); + std::process::exit(1); + } + + let channel_id: u8 = match args[1].parse() { + Ok(id) if id <= 3 => id, + _ => { + eprintln!("Error: channel_id must be a number between 0 and 3"); + std::process::exit(1); + } + }; + + tracing::info!("Channel ID: {}", channel_id); + + // ═══════════════════════════════════════════════════════════════════════════ + // Fetch block metadata + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Fetching current block metadata..."); + let client = RadioParadiseClient::builder() + .channel(channel_id) + .build() + .await?; + + let block = client.get_block(None).await?; + + tracing::info!("Block Information:"); + tracing::info!(" Event ID: {}", block.event); + tracing::info!(" Songs: {}", block.song_count()); + tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); + tracing::info!(""); + + tracing::info!("Tracklist:"); + for (index, song) in block.songs_ordered() { + tracing::info!( + " {:2}. {} - {} ({})", + index + 1, + song.artist, + song.title, + song.album.as_deref().unwrap_or("Unknown Album") + ); + } + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Create streaming pipelines (FLAC and OGG-FLAC) + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Creating streaming pipelines..."); + + // Encoder options (shared) + let encoder_options = EncoderOptions { + compression_level: 5, + verify: false, + ..Default::default() + }; + + // ───────────────────────────────────────────────────────────────────────── + // Pipeline 1: FLAC streaming + // ───────────────────────────────────────────────────────────────────────── + + let mut source_flac = RadioParadiseStreamSource::new(client.clone()); + source_flac.push_block_id(block.event); + tracing::debug!("RadioParadiseStreamSource (FLAC) created with block {}", block.event); + + let mut timer_flac = TimerNode::new(3.0); + tracing::debug!("TimerNode (FLAC) created with 3.0s max lead time"); + + let (streaming_sink, stream_handle) = StreamingFlacSink::new(encoder_options.clone(), 16); + tracing::debug!("StreamingFlacSink created"); + + timer_flac.register(Box::new(streaming_sink)); + source_flac.register(Box::new(timer_flac)); + tracing::info!("Pipeline 1 connected: RadioParadiseStreamSource → TimerNode → StreamingFlacSink"); + + // ───────────────────────────────────────────────────────────────────────── + // Pipeline 2: OGG-FLAC streaming + // ───────────────────────────────────────────────────────────────────────── + + let mut source_ogg = RadioParadiseStreamSource::new(client); + source_ogg.push_block_id(block.event); + tracing::debug!("RadioParadiseStreamSource (OGG) created with block {}", block.event); + + let mut timer_ogg = TimerNode::new(3.0); + tracing::debug!("TimerNode (OGG) created with 3.0s max lead time"); + + let (ogg_sink, ogg_handle) = StreamingOggFlacSink::new(encoder_options, 16); + tracing::debug!("StreamingOggFlacSink created"); + + timer_ogg.register(Box::new(ogg_sink)); + source_ogg.register(Box::new(timer_ogg)); + tracing::info!("Pipeline 2 connected: RadioParadiseStreamSource → TimerNode → StreamingOggFlacSink"); + + // ═══════════════════════════════════════════════════════════════════════════ + // Setup pmoserver with streaming routes + // ═══════════════════════════════════════════════════════════════════════════ + + tracing::info!("Setting up pmoserver..."); + + let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080) + .build(); + + let app_state = Arc::new(AppState { + stream_handle, + ogg_handle, + }); + + // Add streaming routes + server.add_handler_with_state("/test/stream", stream_handler, app_state.clone()).await; + server.add_handler_with_state("/test/stream-icy", stream_icy_handler, app_state.clone()).await; + server.add_handler_with_state("/test/stream-ogg", stream_ogg_handler, app_state.clone()).await; + + // Add metadata route + server.add_handler_with_state("/test/metadata", metadata_handler, app_state.clone()).await; + + // Add health check + server.add_handler("/test/health", health_handler).await; + + tracing::info!(""); + tracing::info!("========================================"); + tracing::info!("Ready to stream!"); + tracing::info!(""); + tracing::info!("Pure FLAC stream (for VLC, standard players):"); + tracing::info!(" vlc http://localhost:8080/test/stream"); + tracing::info!(""); + tracing::info!("OGG-FLAC stream (streaming container with metadata support):"); + tracing::info!(" vlc http://localhost:8080/test/stream-ogg"); + tracing::info!(""); + tracing::info!("FLAC + ICY metadata stream (for ICY-aware clients):"); + tracing::info!(" http://localhost:8080/test/stream-icy"); + tracing::info!(""); + tracing::info!("Metadata endpoint (JSON):"); + tracing::info!(" curl http://localhost:8080/test/metadata"); + tracing::info!("========================================"); + tracing::info!(""); + + // ═══════════════════════════════════════════════════════════════════════════ + // Start pipelines and server + // ═══════════════════════════════════════════════════════════════════════════ + + let stop_token = CancellationToken::new(); + let stop_token_flac = stop_token.clone(); + let stop_token_ogg = stop_token.clone(); + + // Start FLAC pipeline in background + let pipeline_flac_handle = tokio::spawn(async move { + tracing::info!("[PIPELINE-FLAC] Starting..."); + let result = Box::new(source_flac).run(stop_token_flac).await; + match &result { + Ok(()) => tracing::info!("[PIPELINE-FLAC] Completed successfully"), + Err(e) => tracing::error!("[PIPELINE-FLAC] Error: {}", e), + } + result + }); + + // Start OGG-FLAC pipeline in background + let pipeline_ogg_handle = tokio::spawn(async move { + tracing::info!("[PIPELINE-OGG] Starting..."); + let result = Box::new(source_ogg).run(stop_token_ogg).await; + match &result { + Ok(()) => tracing::info!("[PIPELINE-OGG] Completed successfully"), + Err(e) => tracing::error!("[PIPELINE-OGG] Error: {}", e), + } + result + }); + + // Start pmoserver (blocks until Ctrl+C) + tracing::info!("[SERVER] Starting pmoserver..."); + server.start().await; + server.wait().await; + + // Server stopped, cancel pipelines + tracing::info!("Server stopped, canceling pipelines..."); + stop_token.cancel(); + + // Wait for both pipelines to finish + match pipeline_flac_handle.await { + Ok(Ok(())) => tracing::info!("FLAC pipeline completed successfully"), + Ok(Err(e)) => tracing::error!("FLAC pipeline error: {}", e), + Err(e) => tracing::error!("FLAC pipeline task error: {}", e), + } + + match pipeline_ogg_handle.await { + Ok(Ok(())) => tracing::info!("OGG-FLAC pipeline completed successfully"), + Ok(Err(e)) => tracing::error!("OGG-FLAC pipeline error: {}", e), + Err(e) => tracing::error!("OGG-FLAC pipeline task error: {}", e), + } + + tracing::info!("Shutdown complete"); + Ok(()) +} diff --git a/pmoparadise/src/radio_paradise_stream_source.rs b/pmoparadise/src/radio_paradise_stream_source.rs index 71c191b6..ddd051d8 100644 --- a/pmoparadise/src/radio_paradise_stream_source.rs +++ b/pmoparadise/src/radio_paradise_stream_source.rs @@ -25,8 +25,10 @@ use tokio::io::AsyncReadExt; use tokio::sync::{mpsc, RwLock}; use tokio_util::{io::StreamReader, sync::CancellationToken}; -/// Timeout pour attendre un nouveau block ID (radio en temps réel) -const BLOCK_ID_TIMEOUT_SECS: u64 = 3; +/// Timeout pour attendre un nouveau block ID +/// Pour une radio en temps réel, 3s est raisonnable +/// Pour des tests avec un seul bloc, on veut quelque chose de plus long +const BLOCK_ID_TIMEOUT_SECS: u64 = 3600; // 1 heure /// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements const RECENT_BLOCKS_CACHE_SIZE: usize = 10; @@ -78,14 +80,16 @@ impl RadioParadiseStreamSourceLogic { } /// Télécharge et décode un bloc FLAC + /// Retourne le timestamp du dernier chunk audio envoyé async fn download_and_decode_block( &mut self, block: &Block, output: &[mpsc::Sender>], stop_token: &CancellationToken, order: &mut u64, - ) -> Result<(), AudioError> { + ) -> Result { // Télécharger le FLAC + tracing::debug!("Sending HTTP GET request for block FLAC"); let response = self.client.client .get(&block.url) .timeout(self.client.block_timeout) @@ -93,6 +97,7 @@ impl RadioParadiseStreamSourceLogic { .await .map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?; + tracing::debug!("HTTP response received, status={}", response.status()); if !response.status().is_success() { return Err(AudioError::ProcessingError(format!( "Block download returned status {}", @@ -101,12 +106,15 @@ impl RadioParadiseStreamSourceLogic { } // Créer un stream reader + tracing::debug!("Creating byte stream reader"); let byte_stream = response.bytes_stream().map(|result| { result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) }); let stream_reader = StreamReader::new(byte_stream); + tracing::debug!("Stream reader created"); // Décoder le FLAC + tracing::debug!("Decoding FLAC stream..."); let mut decoder = decode_audio_stream(stream_reader) .await .map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?; @@ -114,20 +122,45 @@ impl RadioParadiseStreamSourceLogic { let stream_info = decoder.info().clone(); let sample_rate = stream_info.sample_rate; let bits_per_sample = stream_info.bits_per_sample; + tracing::debug!("FLAC decoder initialized: {}Hz, {} bits/sample", sample_rate, bits_per_sample); // Préparer les songs ordonnées pour tracking let songs = block.songs_ordered(); let mut song_index = 0; - let mut next_song: Option<(usize, &Song)> = songs.get(0).copied(); let mut total_samples = 0u64; + tracing::debug!("Block has {} songs", songs.len()); // Envoyer TopZeroSync au début du bloc + tracing::debug!("Sending TopZeroSync to {} outputs", output.len()); let top_zero = Arc::new(AudioSegment { order: *order, timestamp_sec: 0.0, segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)), }); self.send_to_children(output, top_zero).await?; + tracing::debug!("TopZeroSync sent"); + + // Envoyer TrackBoundary pour la première song AVANT le premier chunk audio + // Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées + // dès le début (sinon il attendrait indéfiniment un TrackBoundary) + let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() { + tracing::debug!("Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", + idx, song.elapsed); + let metadata = song_to_metadata(song, block).await; + let track_boundary = AudioSegment::new_track_boundary( + *order, + 0.0, // timestamp = 0 au début du stream + metadata, + ); + self.send_to_children(output, track_boundary).await?; + song_index = 1; + // Le prochain TrackBoundary sera pour la deuxième song quand elapsed_ms >= song.elapsed + songs.get(1).copied() + } else { + None + }; + tracing::debug!("Starting audio chunk loop"); + // Buffer pour lecture let bytes_per_sample = (bits_per_sample / 8) as usize; @@ -141,7 +174,9 @@ impl RadioParadiseStreamSourceLogic { loop { // Vérifier stop_token if stop_token.is_cancelled() { - return Ok(()); + // Retourner le timestamp actuel si on est interrompu + let current_timestamp = total_samples as f64 / sample_rate as f64; + return Ok(current_timestamp); } // Remplir le buffer @@ -170,12 +205,16 @@ impl RadioParadiseStreamSourceLogic { let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo // Vérifier si on doit insérer un TrackBoundary avant ce chunk - if let Some((_idx, song)) = next_song { + if let Some((idx, song)) = next_song { let elapsed_ms = (total_samples * 1000) / sample_rate as u64; if elapsed_ms >= song.elapsed { // Envoyer TrackBoundary AVANT le chunk (avec le même order) - let metadata = song_to_metadata(song, block); + tracing::debug!( + "Sending TrackBoundary for song {} at elapsed_ms={} (song.elapsed={}, timestamp_sec={:.2})", + idx, elapsed_ms, song.elapsed, (total_samples as f64 / sample_rate as f64) + ); + let metadata = song_to_metadata(song, block).await; let timestamp_sec = total_samples as f64 / sample_rate as f64; let track_boundary = AudioSegment::new_track_boundary( *order, @@ -187,6 +226,7 @@ impl RadioParadiseStreamSourceLogic { // Passer à la song suivante song_index += 1; next_song = songs.get(song_index).copied(); + tracing::debug!("Moved to next song, song_index={}, next_song present={}", song_index, next_song.is_some()); } } @@ -205,7 +245,11 @@ impl RadioParadiseStreamSourceLogic { total_samples += chunk_len; } - Ok(()) + // Retourner le timestamp du dernier chunk (durée totale du bloc) + let final_timestamp = total_samples as f64 / sample_rate as f64; + tracing::debug!("Block decode complete: {} samples, {:.2}s duration", total_samples, final_timestamp); + + Ok(final_timestamp) } /// Envoie un segment à tous les enfants @@ -340,47 +384,52 @@ fn pcm_to_audio_segment( /// Convertit Song en TrackMetadata /// -/// Cette fonction est synchrone, donc on wrap la metadata dans Arc> -/// et on spawn une tâche async pour la configurer -fn song_to_metadata(song: &Song, block: &Block) -> Arc> { +/// Configure toutes les métadonnées de manière asynchrone et attend que la configuration +/// soit terminée avant de retourner, garantissant que les métadonnées (y compris cover_url) +/// sont disponibles immédiatement pour les nodes suivants +async fn song_to_metadata(song: &Song, block: &Block) -> Arc> { let metadata = MemoryTrackMetadata::new(); let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc>; - let metadata_clone = metadata_arc.clone(); - // Clone des données pour la task async + // Cloner les données let title = song.title.clone(); let artist = song.artist.clone(); let album = song.album.clone(); let year = song.year; let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover)); - // Configurer les métadonnées de manière asynchrone - tokio::spawn(async move { - let mut meta = metadata_clone.write().await; + // Configurer les métadonnées de manière synchrone (mais async await) + { + let mut meta = metadata_arc.write().await; - // Ces méthodes peuvent échouer (retournent Result), donc on propage avec ? + // Ces méthodes peuvent échouer (retournent Result), donc on log les erreurs if let Err(e) = meta.set_title(Some(title)).await { - eprintln!("Warning: Failed to set title: {}", e); + tracing::warn!("Failed to set title: {}", e); } if let Err(e) = meta.set_artist(Some(artist)).await { - eprintln!("Warning: Failed to set artist: {}", e); + tracing::warn!("Failed to set artist: {}", e); } if let Some(album) = album { if let Err(e) = meta.set_album(Some(album)).await { - eprintln!("Warning: Failed to set album: {}", e); + tracing::warn!("Failed to set album: {}", e); } } if let Some(year) = year { if let Err(e) = meta.set_year(Some(year)).await { - eprintln!("Warning: Failed to set year: {}", e); + tracing::warn!("Failed to set year: {}", e); } } - if let Some(cover_url) = cover_url { - if let Err(e) = meta.set_cover_url(Some(cover_url)).await { - eprintln!("Warning: Failed to set cover_url: {}", e); + if let Some(ref url) = cover_url { + tracing::debug!("RadioParadiseStreamSource: Setting cover_url to: {}", url); + if let Err(e) = meta.set_cover_url(Some(url.clone())).await { + tracing::warn!("Failed to set cover_url: {}", e); + } else { + tracing::debug!("RadioParadiseStreamSource: Successfully set cover_url"); } + } else { + tracing::debug!("RadioParadiseStreamSource: No cover URL available for song"); } - }); + } metadata_arc } @@ -393,52 +442,75 @@ impl NodeLogic for RadioParadiseStreamSourceLogic { output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { + tracing::debug!("RadioParadiseStreamSource::process() started, block_queue has {} items", self.block_queue.len()); + for (i, event_id) in self.block_queue.iter().enumerate() { + tracing::debug!(" block_queue[{}] = {}", i, event_id); + } + let mut order = 0u64; + let mut last_timestamp = 0.0; loop { // Attendre un block ID (timeout court pour une radio) + tracing::debug!("Waiting for block_id from queue (timeout={}s)...", BLOCK_ID_TIMEOUT_SECS); let event_id = match tokio::time::timeout( Duration::from_secs(BLOCK_ID_TIMEOUT_SECS), async { while self.block_queue.is_empty() { + tracing::trace!("block_queue is empty, sleeping..."); tokio::time::sleep(Duration::from_millis(100)).await; if stop_token.is_cancelled() { + tracing::debug!("stop_token cancelled while waiting for block_id"); return None; } } self.block_queue.pop_front() } ).await { - Ok(Some(id)) => id, - Ok(None) => break, // Cancelled + Ok(Some(id)) => { + tracing::debug!("Got event_id {} from queue", id); + id + } + Ok(None) => { + tracing::debug!("Loop cancelled, breaking"); + break; + } // Cancelled Err(_) => { // Timeout - pas de nouveau bloc, on termine + tracing::warn!("Timeout waiting for block_id, breaking"); break; } }; // Vérifier si déjà téléchargé récemment if self.is_recent_block(event_id) { + tracing::debug!("Block {} was recently downloaded, skipping", event_id); continue; } // Récupérer les métadonnées du bloc + tracing::debug!("Fetching block metadata for event_id {}...", event_id); let block = self.client .get_block(Some(event_id)) .await .map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?; + tracing::debug!("Block metadata received: url={}", block.url); // Marquer comme téléchargé self.mark_block_downloaded(event_id); // Télécharger et décoder le bloc - self.download_and_decode_block(&block, &output, &stop_token, &mut order) + tracing::info!("Starting download and decode for block {}...", event_id); + let block_duration = self.download_and_decode_block(&block, &output, &stop_token, &mut order) .await?; + last_timestamp = block_duration; + tracing::info!("Finished download and decode for block {} (duration: {:.2}s)", event_id, block_duration); } - // Envoyer EndOfStream - let eos = AudioSegment::new_end_of_stream(order, 0.0); + // Envoyer EndOfStream avec le timestamp du dernier chunk + tracing::debug!("Sending EndOfStream with timestamp {:.2}s", last_timestamp); + let eos = AudioSegment::new_end_of_stream(order, last_timestamp); for tx in &output { tx.send(eos.clone()) .await diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index ffd762a6..1fa832fc 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -51,7 +51,7 @@ impl ReadHandle { // Vérifier validité dans le cache let cache = crate::manager::audio_cache()?; - if cache.is_valid_pk(&cache_pk) { + if cache.is_valid_pk(&cache_pk).await { // Valide, avancer le curseur et retourner self.cursor.fetch_add(1, Ordering::SeqCst); return Ok(Some(PlaylistTrack::new(cache_pk))); @@ -92,7 +92,7 @@ impl ReadHandle { Some(record) => { // Vérifier validité let cache = crate::manager::audio_cache()?; - if cache.is_valid_pk(&record.cache_pk) { + if cache.is_valid_pk(&record.cache_pk).await { Ok(Some(PlaylistTrack::new(record.cache_pk.clone()))) } else { Ok(None) @@ -125,7 +125,7 @@ impl ReadHandle { for i in pos..core.len() { if let Some(record) = core.get(i) { - if cache.is_valid_pk(&record.cache_pk) { + if cache.is_valid_pk(&record.cache_pk).await { count += 1; } } @@ -200,7 +200,7 @@ impl ReadHandle { }; // Vérifier validité - if !cache.is_valid_pk(&record.cache_pk) { + if !cache.is_valid_pk(&record.cache_pk).await { continue; } diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs index 1469c905..48feb2f5 100644 --- a/pmoplaylist/src/handle/write.rs +++ b/pmoplaylist/src/handle/write.rs @@ -30,7 +30,7 @@ impl WriteHandle { // Vérifier que le pk existe dans le cache let cache = crate::manager::audio_cache()?; - if !cache.is_valid_pk(&cache_pk) { + if !cache.is_valid_pk(&cache_pk).await { return Err(crate::Error::CacheEntryNotFound(cache_pk)); } @@ -59,7 +59,7 @@ impl WriteHandle { // Vérifier tous les pks d'abord let cache = crate::manager::audio_cache()?; for pk in &cache_pks { - if !cache.is_valid_pk(pk) { + if !cache.is_valid_pk(pk).await { return Err(crate::Error::CacheEntryNotFound(pk.clone())); } } diff --git a/pmoupnp/src/cache_registry.rs b/pmoupnp/src/cache_registry.rs new file mode 100644 index 00000000..ee5a2840 --- /dev/null +++ b/pmoupnp/src/cache_registry.rs @@ -0,0 +1,97 @@ +//! Registre centralisé des caches pour le serveur UPnP (couche de compatibilité) +//! +//! Ce module fournit une couche de compatibilité pour pmosource qui utilise +//! les singletons de pmoaudiocache et pmocovers pour accéder aux caches. + +use pmoaudiocache::Cache as AudioCache; +use pmocache::FileCache; +use pmocovers::Cache as CoverCache; +use std::sync::Arc; + +/// Accès global au cache de couvertures +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::cache_registry::get_cover_cache; +/// +/// if let Some(cache) = get_cover_cache() { +/// let pk = cache.add_from_url("http://example.com/cover.jpg").await?; +/// } +/// ``` +pub fn get_cover_cache() -> Option> { + pmocovers::get_cover_cache() +} + +/// Accès global au cache audio +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::cache_registry::get_audio_cache; +/// +/// if let Some(cache) = get_audio_cache() { +/// let (pk, _) = cache.add_from_url("http://example.com/track.flac", None).await?; +/// } +/// ``` +pub fn get_audio_cache() -> Option> { + pmoaudiocache::get_audio_cache() +} + +/// Construit l'URL complète pour une couverture +/// +/// # Arguments +/// +/// * `pk` - Clé primaire de la couverture +/// * `size` - Taille optionnelle de l'image +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::cache_registry::build_cover_url; +/// +/// let url = build_cover_url("abc123", Some(300))?; +/// // url = "http://localhost:8080/covers/images/abc123/300" +/// ``` +pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result { + // Récupérer l'URL de base depuis la variable d'environnement ou une config + let base_url = std::env::var("PMO_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + + let cache = get_cover_cache() + .ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?; + + let param = match size { + Some(size_) => Some(size_.to_string()), + None => None, + }; + let route = cache.route_for(pk, param.as_deref()); + Ok(format!("{}{}", base_url, route)) +} + +/// Construit l'URL complète pour une piste audio +/// +/// # Arguments +/// +/// * `pk` - Clé primaire de la piste +/// * `param` - Paramètre optionnel (ex: "orig", "stream") +/// +/// # Examples +/// +/// ```rust,ignore +/// use pmoupnp::cache_registry::build_audio_url; +/// +/// let url = build_audio_url("abc123", Some("stream"))?; +/// // url = "http://localhost:8080/audio/tracks/abc123/stream" +/// ``` +pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result { + // Récupérer l'URL de base depuis la variable d'environnement ou une config + let base_url = std::env::var("PMO_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + + let cache = get_audio_cache() + .ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?; + + let route = cache.route_for(pk, param); + Ok(format!("{}{}", base_url, route)) +} diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 28e0cb93..67af0ac0 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -2,6 +2,7 @@ mod object_set; mod object_trait; pub mod actions; +pub mod cache_registry; pub mod devices; pub mod services; pub mod soap; diff --git a/setup-deps.sh b/setup-deps.sh new file mode 100755 index 00000000..d07de01c --- /dev/null +++ b/setup-deps.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Script d'installation automatique des dépendances soxr et alsa pour PMOMusic +# Usage: ./setup-deps.sh + +set -e + +echo "=========================================" +echo "Installation des dépendances PMOMusic" +echo "=========================================" +echo "" + +# Créer le répertoire local +echo "1. Création du répertoire ~/.local" +mkdir -p ~/.local +cd ~/.local + +# Télécharger les packages +echo "" +echo "2. Téléchargement des packages libsoxr et libasound2" +apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64 + +# Extraire les packages +echo "" +echo "3. Extraction des packages" +dpkg -x libsoxr-dev_*.deb . +dpkg -x libsoxr0_*.deb . +dpkg -x libasound2-dev_*.deb . +dpkg -x libasound2t64_*.deb . + +# Vérifier l'installation +echo "" +echo "4. Vérification de l'installation" +if [ -f usr/lib/x86_64-linux-gnu/pkgconfig/soxr.pc ]; then + echo " ✓ libsoxr installé" +else + echo " ✗ Erreur: libsoxr non trouvé" + exit 1 +fi + +if [ -f usr/lib/x86_64-linux-gnu/pkgconfig/alsa.pc ]; then + echo " ✓ libasound2 installé" +else + echo " ✗ Erreur: libasound2 non trouvé" + exit 1 +fi + +# Retourner au projet +cd - > /dev/null + +echo "" +echo "=========================================" +echo "Installation terminée avec succès !" +echo "=========================================" +echo "" +echo "Pour compiler le projet, exportez les variables d'environnement :" +echo "" +echo " source setup-env.sh" +echo "" +echo "Puis compilez avec :" +echo "" +echo " cargo build" +echo ""