Changes: - Add TimerNode (pmoaudio/src/nodes/timer_node.rs): Rate-limits audio chunk flow based on timestamps with configurable max_lead_time - Integrate TimerNode into play_and_cache.rs pipeline: PlaylistSource → TimerNode (3s pacing) → AudioSink - Improve EOF retry in playlist_source.rs: Wait for prebuffer (512KB) before decoding, retry on temporary EOF with 200ms delay - Export TimerNode in pmoaudio lib.rs and nodes/mod.rs Known issue: Cache files may still be truncated when TrackBoundary arrives before pump completes flushing. This requires allowing parallel write tasks as suggested.
305 lines
14 KiB
Rust
305 lines
14 KiB
Rust
//! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps
|
|
//!
|
|
//! Ce programme démontre l'utilisation complète de la chaîne :
|
|
//! 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. 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
|
|
//! Pipeline 1 (Download & Cache):
|
|
//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée)
|
|
//!
|
|
//! Pipeline 2 (Playback):
|
|
//! PlaylistSource → TimerNode (rate limiting) → AudioSink
|
|
//! ↓
|
|
//! Prévention EOF
|
|
//! (3s max lead)
|
|
//! ```
|
|
//!
|
|
//! Usage:
|
|
//! cargo run --example play_and_cache --features full -- <channel_id>
|
|
//!
|
|
//! Exemple:
|
|
//! 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, TimerNode};
|
|
use pmoaudio_ext::{FlacCacheSink, PlaylistSource};
|
|
use pmoaudiocache::Cache as AudioCache;
|
|
use pmocovers::Cache as CoverCache;
|
|
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
|
use std::env;
|
|
use std::sync::Arc;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
// Initialiser tracing avec beaucoup de logs
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::from_default_env()
|
|
.add_directive(tracing::Level::DEBUG.into())
|
|
.add_directive("pmoaudio=debug".parse()?)
|
|
.add_directive("pmoaudio_ext=debug".parse()?)
|
|
.add_directive("pmoplaylist=debug".parse()?)
|
|
.add_directive("pmoparadise=debug".parse()?)
|
|
.add_directive("pmoaudiocache=debug".parse()?)
|
|
)
|
|
.init();
|
|
|
|
tracing::info!("=== Radio Paradise Play & Cache ===");
|
|
|
|
// Récupérer les arguments
|
|
let args: Vec<String> = env::args().collect();
|
|
if args.len() < 2 {
|
|
eprintln!("Usage: {} <channel_id> [--null-audio]", args[0]);
|
|
eprintln!();
|
|
eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously.");
|
|
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!("Options:");
|
|
eprintln!(" --null-audio Don't play audio (for testing without audio device)");
|
|
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);
|
|
}
|
|
};
|
|
|
|
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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
|
|
std::fs::create_dir_all(&base_dir)?;
|
|
|
|
tracing::info!("Initializing caches in: {}", base_dir);
|
|
|
|
// Créer le cache audio
|
|
let audio_cache_dir = format!("{}/audio_cache", base_dir);
|
|
std::fs::create_dir_all(&audio_cache_dir)?;
|
|
let audio_cache = Arc::new(AudioCache::new(
|
|
&audio_cache_dir,
|
|
1000, // 1000 MB limit
|
|
)?);
|
|
tracing::debug!("Audio cache initialized at: {}", audio_cache_dir);
|
|
|
|
// Créer le cache de covers
|
|
let cover_cache_dir = format!("{}/cover_cache", base_dir);
|
|
std::fs::create_dir_all(&cover_cache_dir)?;
|
|
let cover_cache = Arc::new(CoverCache::new(
|
|
&cover_cache_dir,
|
|
100, // 100 MB limit
|
|
)?);
|
|
tracing::debug!("Cover cache initialized at: {}", cover_cache_dir);
|
|
|
|
// Enregistrer le cache audio dans pmoplaylist
|
|
// (requis par pmoplaylist pour valider les pks)
|
|
pmoplaylist::register_audio_cache(audio_cache.clone());
|
|
tracing::debug!("Audio cache registered in pmoplaylist");
|
|
|
|
// Utiliser le gestionnaire de playlist singleton
|
|
tracing::info!("Getting playlist manager...");
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
tracing::debug!("Playlist manager obtained");
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Créer la playlist pour ce channel
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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?;
|
|
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");
|
|
|
|
// Créer le reader pour la lecture
|
|
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
|
tracing::debug!("Read handle created");
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Récupérer les infos du bloc à télécharger
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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!("");
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Pipeline 1: Téléchargement et cache
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
tracing::info!("Creating download pipeline...");
|
|
|
|
// Créer la source Radio Paradise
|
|
let mut download_source = RadioParadiseStreamSource::new(client);
|
|
download_source.push_block_id(block.event);
|
|
tracing::debug!("RadioParadiseStreamSource created with block {}", block.event);
|
|
|
|
// Créer le sink de cache FLAC
|
|
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());
|
|
cache_sink.register_playlist(writer);
|
|
tracing::debug!("FlacCacheSink created and registered with playlist");
|
|
|
|
// Connecter source → sink
|
|
download_source.register(Box::new(cache_sink));
|
|
tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink");
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Pipeline 2: Lecture depuis la playlist
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
tracing::info!("Creating playback pipeline...");
|
|
|
|
// Créer la source playlist
|
|
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 = if use_null_audio {
|
|
AudioSink::with_null_output()
|
|
} else {
|
|
AudioSink::new()
|
|
};
|
|
tracing::debug!("AudioSink created");
|
|
|
|
// 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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
tracing::info!("");
|
|
tracing::info!("========================================");
|
|
tracing::info!("Starting both pipelines...");
|
|
tracing::info!("Pipeline 1: Downloading and caching");
|
|
tracing::info!("Pipeline 2: Playing from playlist");
|
|
tracing::info!("========================================");
|
|
tracing::info!("");
|
|
|
|
let stop_token = CancellationToken::new();
|
|
let stop_token_download = stop_token.clone();
|
|
let stop_token_playback = stop_token.clone();
|
|
|
|
// Gérer Ctrl+C
|
|
let stop_token_ctrl_c = stop_token.clone();
|
|
tokio::spawn(async move {
|
|
tokio::signal::ctrl_c().await.ok();
|
|
tracing::warn!("Received Ctrl+C, stopping...");
|
|
stop_token_ctrl_c.cancel();
|
|
});
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
// Lancer les deux pipelines en parallèle
|
|
let download_handle = tokio::spawn(async move {
|
|
tracing::info!("[DOWNLOAD] Pipeline starting...");
|
|
let result = Box::new(download_source).run(stop_token_download).await;
|
|
match &result {
|
|
Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"),
|
|
Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e),
|
|
}
|
|
result
|
|
});
|
|
|
|
let playback_handle = tokio::spawn(async move {
|
|
// 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"),
|
|
Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e),
|
|
}
|
|
result
|
|
});
|
|
|
|
// Attendre les deux pipelines
|
|
let (download_result, playback_result) = tokio::join!(download_handle, playback_handle);
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
// Vérifier les résultats
|
|
match (download_result, playback_result) {
|
|
(Ok(Ok(())), Ok(Ok(()))) => {
|
|
tracing::info!("");
|
|
tracing::info!("========================================");
|
|
tracing::info!("✓ Both pipelines completed successfully");
|
|
tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64());
|
|
tracing::info!("========================================");
|
|
}
|
|
(download_res, playback_res) => {
|
|
tracing::error!("");
|
|
tracing::error!("========================================");
|
|
if let Err(e) = download_res {
|
|
tracing::error!("✗ Download pipeline error: {:?}", e);
|
|
} else if let Ok(Err(e)) = download_res {
|
|
tracing::error!("✗ Download pipeline error: {}", e);
|
|
}
|
|
if let Err(e) = playback_res {
|
|
tracing::error!("✗ Playback pipeline error: {:?}", e);
|
|
} else if let Ok(Err(e)) = playback_res {
|
|
tracing::error!("✗ Playback pipeline error: {}", e);
|
|
}
|
|
tracing::error!("========================================");
|
|
return Err("Pipeline error".into());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|