Merge branch 'claude/add-pmoplaylist-source-011CUpmZ9YbyUAUshEePVTJi' into claude/add-pmoplaylist-source-011CUq8bHCyjrEqGxCCXuvfh

This commit is contained in:
coissac
2025-11-05 20:22:59 +01:00
committed by GitHub
51 changed files with 3559 additions and 5338 deletions

View File

@@ -17,8 +17,8 @@ host:
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
accounts:
qobuz:
username: eric@coissac.eu
password: '*Misfcr73110$'
username: your-email@example.com
password: 'YOUR_PASSWORD_HERE'
devices:
mediarenderer:
pmo_mediarenderer:

137
Cargo.lock generated
View File

@@ -463,6 +463,8 @@ dependencies = [
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
@@ -1180,31 +1182,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ffmpeg-next"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40"
dependencies = [
"bitflags 2.10.0",
"ffmpeg-sys-next",
"libc",
]
[[package]]
name = "ffmpeg-sys-next"
version = "8.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b"
dependencies = [
"bindgen",
"cc",
"libc",
"num_cpus",
"pkg-config",
"vcpkg",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
@@ -2439,14 +2416,96 @@ dependencies = [
name = "netstat2"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0faa3f4ad230fd2bf2a5dad71476ecbaeaed904b3c7e7e5b1f266c415c03761f"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.10.0",
"jni-sys",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "netlink-packet-core"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4"
dependencies = [
"anyhow",
"byteorder",
"netlink-packet-utils",
]
[[package]]
name = "netlink-packet-sock-diag"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a495cb1de50560a7cd12fdcf023db70eec00e340df81be31cedbbfd4aadd6b76"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"byteorder",
"libc",
"netlink-packet-core",
"netlink-packet-utils",
"smallvec",
]
[[package]]
name = "netlink-packet-utils"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34"
dependencies = [
"anyhow",
"byteorder",
"paste",
"thiserror 1.0.69",
]
[[package]]
name = "netlink-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
dependencies = [
"bytes",
"libc",
"log",
]
[[package]]
name = "netstat2"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "496f264d3ead4870d6b366deb9d20597592d64aac2a907f3e7d07c2325ba4663"
dependencies = [
"bindgen",
"bitflags 2.10.0",
"byteorder",
"netlink-packet-core",
"netlink-packet-sock-diag",
"netlink-packet-utils",
"netlink-sys",
"num-derive 0.3.3",
"num-traits",
"thiserror 1.0.69",
"thiserror 2.0.17",
]
[[package]]
@@ -2828,6 +2887,7 @@ dependencies = [
"pmoflac",
"pmometadata",
"reqwest",
"rodio",
"soxr",
"tempfile",
"tokio",
@@ -2845,6 +2905,7 @@ dependencies = [
"async-trait",
"pmoaudio",
"pmoaudiocache",
"pmocache",
"pmocovers",
"pmoflac",
"pmometadata",
@@ -3034,14 +3095,17 @@ dependencies = [
"bytes",
"chrono",
"claxon",
"ffmpeg-next",
"flacenc",
"futures",
"futures-util",
"hex",
"hound",
"pmoaudio",
"pmoaudio-ext",
"pmoaudiocache",
"pmoconfig",
"pmocovers",
"pmoflac",
"pmometadata",
"pmoplaylist",
"pmoserver",
"pmosource",
@@ -3052,7 +3116,6 @@ dependencies = [
"serde_yaml",
"sha2",
"symphonia",
"tempfile",
"thiserror 2.0.17",
"tokio",
"tokio-test",
@@ -3644,6 +3707,20 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rodio"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
dependencies = [
"claxon",
"cpal",
"hound",
"lewton",
"symphonia",
"thiserror 1.0.69",
]
[[package]]
name = "rusqlite"
version = "0.37.0"

27
SECURITY_CONFIG.md Normal file
View File

@@ -0,0 +1,27 @@
# Configuration Sécurisée
## Configuration de PMOMusic
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
### Installation
1. Copiez le fichier exemple :
```bash
cp .pmomusic.yml.example .pmomusic.yml
```
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
- `accounts.qobuz.username` : votre email Qobuz
- `accounts.qobuz.password` : votre mot de passe Qobuz
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
- Il est déjà dans `.gitignore`
- Utilisez des variables d'environnement pour la production
## Variables d'environnement (recommandé pour production)
```bash
export QOBUZ_USERNAME="votre-email@example.com"
export QOBUZ_PASSWORD="votre-mot-de-passe"
```

View File

@@ -13,8 +13,9 @@ pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmoflac = { path = "../pmoflac", optional = true }
pmometadata = { path = "../pmometadata", optional = true }
# Optional dependency for playlist integration
# 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" }
@@ -26,5 +27,5 @@ tracing = "0.1"
[features]
default = []
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
playlist = ["dep:pmoplaylist"]
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
all = ["cache-sink", "playlist"]

View File

@@ -7,7 +7,7 @@
//! # Features
//!
//! - `cache-sink` : Active le `FlacCacheSink` qui encode l'audio en FLAC et le stocke dans pmoaudiocache
//! - `playlist` : Active l'intégration avec pmoplaylist pour les sinks
//! - `playlist` : Active l'intégration avec pmoplaylist (sources et sinks)
//! - `all` : Active toutes les features d'un coup
//!
//! # Architecture
@@ -25,6 +25,12 @@
#[cfg(feature = "cache-sink")]
pub mod sinks;
#[cfg(feature = "playlist")]
pub mod sources;
// Re-exports pour faciliter l'utilisation
#[cfg(feature = "cache-sink")]
pub use sinks::*;
#[cfg(feature = "playlist")]
pub use sources::*;

View File

@@ -251,8 +251,6 @@ impl NodeLogic for FlacCacheSinkLogic {
pub struct FlacCacheSink {
inner: Node<FlacCacheSinkLogic>,
#[cfg(feature = "playlist")]
playlist_handle_pending: Option<Arc<pmoplaylist::WriteHandle>>,
}
impl FlacCacheSink {
@@ -297,8 +295,6 @@ impl FlacCacheSink {
let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8);
Self {
inner: Node::new_with_input(logic, channel_size),
#[cfg(feature = "playlist")]
playlist_handle_pending: None,
}
}
@@ -309,7 +305,7 @@ impl FlacCacheSink {
/// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks
#[cfg(feature = "playlist")]
pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) {
self.playlist_handle_pending = Some(Arc::new(handle));
self.inner.logic_mut().set_playlist_handle(Arc::new(handle));
}
}
@@ -649,16 +645,7 @@ impl AudioPipelineNode for FlacCacheSink {
panic!("FlacCacheSink is a terminal sink and cannot have children");
}
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
// Transférer le playlist_handle_pending à la logique si présent
#[cfg(feature = "playlist")]
if let Some(handle) = self.playlist_handle_pending.take() {
// FIXME: Node devrait exposer une méthode logic_mut() pour permettre
// la configuration post-construction. Pour l'instant, on ignore ce handle.
// L'utilisateur devra configurer la playlist avant construction.
let _ = handle;
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}

View File

@@ -0,0 +1,10 @@
//! Sources audio étendues pour pmoaudio
//!
//! Ce module contient des sources audio qui dépendent d'autres crates
//! du projet PMO (pmoplaylist, pmoaudiocache, etc.)
#[cfg(feature = "playlist")]
mod playlist_source;
#[cfg(feature = "playlist")]
pub use playlist_source::PlaylistSource;

View File

@@ -0,0 +1,852 @@
//! PlaylistSource - Source audio depuis une playlist pmoplaylist
//!
//! Cette source lit une playlist (via `ReadHandle`) et émet un flux audio
//! continu en décodant les fichiers depuis le cache audio.
//!
//! # ⚠️ Format de sortie hétérogène
//!
//! **IMPORTANT** : Cette source émet du PCM avec des caractéristiques
//! **variables** selon les fichiers sources :
//! - **Sample rate** : peut varier (44.1kHz, 48kHz, 96kHz, etc.)
//! - **Bit depth** : peut varier (I16, I24, I32)
//!
//! Pour obtenir un flux **homogène**, ajoutez les nœuds suivants dans le pipeline :
//! - `ResamplingNode` : normalise le sample_rate (à implémenter dans pmoaudio)
//! - `ToI24Node` / `ToI16Node` : normalise la profondeur de bits
//!
//! # Cas d'usage
//!
//! ## Radio Paradise (format homogène connu)
//! ```rust,no_run
//! use pmoaudio_ext::PlaylistSource;
//! use pmoaudio::ToI24Node;
//! use pmoplaylist::PlaylistManager;
//! use pmoaudiocache::AudioCache;
//! use std::sync::Arc;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let manager = PlaylistManager::get();
//! let read_handle = manager.get_read_handle("radio-paradise").await?;
//! let cache = Arc::new(AudioCache::new("./cache", 500)?);
//!
//! let mut source = PlaylistSource::new(read_handle, cache);
//! let to_i24 = ToI24Node::new();
//! source.register(to_i24);
//! # Ok(())
//! # }
//! ```
//!
//! ## Playlist mixte (nécessite homogénéisation)
//! ```rust,no_run
//! use pmoaudio_ext::PlaylistSource;
//! use pmoaudio::{ToI24Node, ResamplingNode};
//! # use pmoplaylist::PlaylistManager;
//! # use pmoaudiocache::AudioCache;
//! # use std::sync::Arc;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! # let manager = PlaylistManager::get();
//! # let read_handle = manager.get_read_handle("mixed").await?;
//! # let cache = Arc::new(AudioCache::new("./cache", 500)?);
//! let mut source = PlaylistSource::new(read_handle, cache);
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
//! let to_i24 = ToI24Node::new(); // Force I24
//! source.register(Box::new(resampler));
//! resampler.register(Box::new(to_i24));
//! # Ok(())
//! # }
//! ```
//!
//! # Comportement
//!
//! - **Polling** : Si la playlist est vide, attend `poll_interval_ms` avant de réessayer
//! - **TrackBoundary** : Émet un marqueur avec metadata entre chaque piste
//! - **Erreurs** : Si un fichier est inaccessible, émet un `Error` marker et continue
//! - **Arrêt** : Via `CancellationToken`, émet `EndOfStream` avant de terminer
//!
//! # Synchronisation
//!
//! - `TopZeroSync` : émis une seule fois au début
//! - `TrackBoundary` : émis avant chaque nouvelle piste (contient metadata)
//! - Pas d'`EndOfStream` entre les pistes (flux continu)
//! - `EndOfStream` final uniquement lors de l'arrêt
use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, I24,
};
use pmoaudiocache::Cache as AudioCache;
use pmoflac::{decode_audio_stream, StreamInfo};
use pmoplaylist::ReadHandle;
use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
use tokio_util::sync::CancellationToken;
use tracing;
// ═══════════════════════════════════════════════════════════════════════════
// PlaylistSourceLogic - Logique pure de lecture de playlist
// ═══════════════════════════════════════════════════════════════════════════
/// Logique pure de lecture de playlist
///
/// Contient seulement la logique de lecture de playlist et décodage des pistes,
/// sans la plomberie d'orchestration (gérée par Node<PlaylistSourceLogic>).
pub struct PlaylistSourceLogic {
playlist_handle: ReadHandle,
cache: Arc<AudioCache>,
chunk_frames: usize,
poll_interval_ms: u64,
}
impl PlaylistSourceLogic {
pub fn new(
playlist_handle: ReadHandle,
cache: Arc<AudioCache>,
chunk_frames: usize,
poll_interval_ms: u64,
) -> Self {
Self {
playlist_handle,
cache,
chunk_frames,
poll_interval_ms,
}
}
}
#[async_trait::async_trait]
impl NodeLogic for PlaylistSourceLogic {
async fn process(
&mut self,
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
tracing::debug!(
"PlaylistSourceLogic::process started, playlist={}, {} children",
self.playlist_handle.id(),
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)?;
}
};
}
let mut first_track = true;
loop {
// Vérifier arrêt immédiat
if stop_token.is_cancelled() {
tracing::info!("PlaylistSourceLogic: stop requested, emitting EndOfStream");
let eos = AudioSegment::new_end_of_stream(0, 0.0);
send_to_children!(eos);
break;
}
// Pop avec timeout pour supporter stop_token
let track = tokio::select! {
_ = stop_token.cancelled() => {
tracing::info!("PlaylistSourceLogic: stop cancelled during pop");
let eos = AudioSegment::new_end_of_stream(0, 0.0);
send_to_children!(eos);
break;
}
result = self.playlist_handle.pop() => {
match result {
Ok(Some(t)) => {
tracing::debug!("PlaylistSourceLogic: popped track from playlist");
t
},
Ok(None) => {
// Playlist vide, attendre avant retry
tracing::trace!(
"PlaylistSourceLogic: playlist empty, waiting {}ms",
self.poll_interval_ms
);
tokio::time::sleep(
Duration::from_millis(self.poll_interval_ms)
).await;
continue;
}
Err(e) => {
// Erreur playlist (deleted, etc.)
tracing::warn!("PlaylistSourceLogic: playlist error: {}", e);
let error_marker = AudioSegment::new_error(
0,
0.0,
format!("Playlist error: {}", e)
);
send_to_children!(error_marker);
continue;
}
}
}
};
// Émettre TopZeroSync pour la première piste seulement
if first_track {
tracing::debug!("PlaylistSourceLogic: emitting TopZeroSync");
let top_zero = AudioSegment::new_top_zero_sync();
send_to_children!(top_zero);
first_track = false;
}
// Émettre TrackBoundary avec metadata du cache
let metadata = match track.track_metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
let error_marker = AudioSegment::new_error(
0,
0.0,
format!("Failed to get metadata: {}", e),
);
send_to_children!(error_marker);
continue;
}
};
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
send_to_children!(boundary);
// Obtenir le chemin du fichier
let file_path = match track.file_path() {
Ok(p) => p,
Err(e) => {
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
let error_marker = AudioSegment::new_error(
0,
0.0,
format!("Failed to get file path: {}", e),
);
send_to_children!(error_marker);
continue;
}
};
tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path);
// Décoder et émettre les chunks PCM
if let Err(e) = decode_and_emit_track(
&file_path,
self.chunk_frames,
&output,
&stop_token,
)
.await
{
tracing::error!("PlaylistSourceLogic: error decoding track: {}", e);
let error_marker = AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e));
send_to_children!(error_marker);
// Continue vers la piste suivante
}
// Boucler pour la piste suivante (pas d'EndOfStream entre pistes !)
}
tracing::debug!("PlaylistSourceLogic::process finished");
Ok(())
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Helper Functions
// ═══════════════════════════════════════════════════════════════════════════
/// Décode un fichier et émet ses chunks audio
async fn decode_and_emit_track(
path: &PathBuf,
chunk_frames: usize,
output: &[mpsc::Sender<Arc<AudioSegment>>],
stop_token: &CancellationToken,
) -> Result<(), AudioError> {
// Ouvrir et décoder
let file = File::open(path)
.await
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", path, e)))?;
let mut stream = decode_audio_stream(file)
.await
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
let stream_info = stream.info().clone();
// Valider le stream
validate_stream(&stream_info)?;
// Calculer chunk_frames (auto = 50ms)
let chunk_frames = if chunk_frames == 0 {
let frames = (stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize;
frames.next_power_of_two().max(256)
} else {
chunk_frames.max(1)
};
tracing::trace!(
"decode_and_emit_track: sample_rate={}, bit_depth={}, chunk_frames={}",
stream_info.sample_rate,
stream_info.bits_per_sample,
chunk_frames
);
// Lire et émettre les chunks
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
let chunk_byte_len = chunk_frames * frame_bytes;
let mut pending = Vec::new();
let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)];
let mut chunk_index = 0u64;
let mut total_frames = 0u64;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
tracing::debug!("decode_and_emit_track: stop requested");
break;
}
read_result = stream.read(&mut read_buf) => {
// Remplir le buffer
if pending.len() < chunk_byte_len {
let read = read_result.map_err(|e| {
AudioError::IoError(format!("I/O error while decoding: {}", e))
})?;
if read == 0 && pending.is_empty() {
break;
}
if read > 0 {
pending.extend_from_slice(&read_buf[..read]);
}
}
if pending.is_empty() {
break;
}
// Extraire un chunk
let frames_in_pending = pending.len() / frame_bytes;
let frames_to_emit = frames_in_pending.min(chunk_frames);
if frames_to_emit == 0 {
break;
}
let take_bytes = frames_to_emit * frame_bytes;
let chunk_bytes = pending.drain(..take_bytes).collect::<Vec<u8>>();
// Calculer le timestamp
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
// Créer et envoyer le segment audio
let segment = bytes_to_segment(
&chunk_bytes,
&stream_info,
frames_to_emit,
chunk_index,
timestamp_sec,
)?;
for tx in output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
chunk_index += 1;
total_frames += frames_to_emit as u64;
}
}
}
// Traiter le reste éventuel (moins qu'un chunk complet)
if !pending.is_empty() {
let frames = pending.len() / frame_bytes;
if frames > 0 {
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
for tx in output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
}
// Attendre la fin du décodage
stream
.wait()
.await
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
Ok(())
}
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
if !(1..=2).contains(&info.channels) {
return Err(AudioError::ProcessingError(format!(
"Unsupported channel count: {}",
info.channels
)));
}
match info.bits_per_sample {
8 | 16 | 24 | 32 => Ok(()),
other => Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
other
))),
}
}
/// Convertit des bytes PCM en AudioSegment avec le type approprié
fn bytes_to_segment(
chunk_bytes: &[u8],
info: &StreamInfo,
frames: usize,
order: u64,
timestamp_sec: f64,
) -> Result<Arc<AudioSegment>, AudioError> {
let bytes_per_sample = info.bytes_per_sample();
let channels = info.channels as usize;
let frame_bytes = bytes_per_sample * channels;
// Créer le chunk du bon type selon la profondeur de bit
let chunk = match info.bits_per_sample {
16 => {
// Type I16
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i16::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i16::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I16(chunk_data)
}
24 => {
// Type I24
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
// Sign extend
if chunk_bytes[base + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
let l = I24::new(l_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
})?;
let r = if channels == 1 {
l
} else {
let r_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
);
// Sign extend
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
I24::new(r_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
})?
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I24(chunk_data)
}
32 => {
// Type I32
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let l = i32::from_le_bytes(
chunk_bytes[base..base + bytes_per_sample]
.try_into()
.unwrap(),
);
let r = if channels == 1 {
l
} else {
i32::from_le_bytes(
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
.try_into()
.unwrap(),
)
};
stereo.push([l, r]);
}
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
AudioChunk::I32(chunk_data)
}
_ => {
return Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
info.bits_per_sample
)))
}
};
Ok(Arc::new(AudioSegment {
order,
timestamp_sec,
segment: pmoaudio::_AudioSegment::Chunk(Arc::new(chunk)),
}))
}
// ═══════════════════════════════════════════════════════════════════════════
// WRAPPER PlaylistSource - Délègue à Node<PlaylistSourceLogic>
// ═══════════════════════════════════════════════════════════════════════════
/// PlaylistSource - Lit une playlist et publie des `AudioSegment`
///
/// Cette source utilise une playlist (`ReadHandle`) et le cache audio pour
/// décoder les pistes en continu. Le format de sortie (sample_rate et bit_depth)
/// est **hétérogène** et dépend des fichiers sources.
///
/// Voir la documentation du module pour plus de détails et exemples d'usage.
pub struct PlaylistSource {
inner: Node<PlaylistSourceLogic>,
}
impl PlaylistSource {
/// Crée une nouvelle source de playlist avec paramètres par défaut
///
/// * `playlist_handle` - Handle de lecture sur la playlist
/// * `cache` - Cache audio contenant les fichiers
///
/// Paramètres par défaut :
/// - `chunk_frames` : 0 (auto-calculé pour 50ms)
/// - `poll_interval_ms` : 100ms
pub fn new(playlist_handle: ReadHandle, cache: Arc<AudioCache>) -> Self {
Self::with_config(playlist_handle, cache, 0, 100)
}
/// Crée une nouvelle source de playlist avec configuration personnalisée
///
/// * `playlist_handle` - Handle de lecture sur la playlist
/// * `cache` - Cache audio contenant les fichiers
/// * `chunk_frames` - Nombre de frames par chunk (0 = auto)
/// * `poll_interval_ms` - Intervalle de polling si playlist vide
pub fn with_config(
playlist_handle: ReadHandle,
cache: Arc<AudioCache>,
chunk_frames: usize,
poll_interval_ms: u64,
) -> Self {
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
Self {
inner: Node::new_source(logic),
}
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for PlaylistSource {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for PlaylistSource {
fn input_type(&self) -> Option<TypeRequirement> {
None // Source n'a pas d'entrée
}
fn output_type(&self) -> Option<TypeRequirement> {
// Format hétérogène - accepte tout
Some(TypeRequirement::any())
}
}
#[cfg(test)]
mod tests {
use super::*;
// ═══════════════════════════════════════════════════════════════════════════
// Tests unitaires pour les fonctions helper
// ═══════════════════════════════════════════════════════════════════════════
#[test]
fn test_validate_stream_valid_stereo_16bit() {
let info = StreamInfo {
sample_rate: 44100,
channels: 2,
bits_per_sample: 16,
total_samples: Some(1000),
max_block_size: 4096,
min_block_size: 256,
};
assert!(validate_stream(&info).is_ok());
}
#[test]
fn test_validate_stream_valid_mono_24bit() {
let info = StreamInfo {
sample_rate: 48000,
channels: 1,
bits_per_sample: 24,
total_samples: Some(1000),
max_block_size: 4096,
min_block_size: 256,
};
assert!(validate_stream(&info).is_ok());
}
#[test]
fn test_validate_stream_invalid_channel_count() {
let info = StreamInfo {
sample_rate: 44100,
channels: 5, // Invalid
bits_per_sample: 16,
total_samples: Some(1000),
max_block_size: 4096,
min_block_size: 256,
};
assert!(validate_stream(&info).is_err());
}
#[test]
fn test_validate_stream_invalid_bit_depth() {
let info = StreamInfo {
sample_rate: 44100,
channels: 2,
bits_per_sample: 12, // Invalid
total_samples: Some(1000),
max_block_size: 4096,
min_block_size: 256,
};
assert!(validate_stream(&info).is_err());
}
#[test]
fn test_bytes_to_segment_i16_stereo() {
// Create mock PCM data (2 frames, stereo, 16-bit)
// Frame 1: L=100, R=200
// Frame 2: L=300, R=400
let chunk_bytes = vec![
100u8, 0, // L1
200, 0, // R1
44, 1, // L2 (300 = 0x012C)
144, 1, // R2 (400 = 0x0190)
];
let info = StreamInfo {
sample_rate: 44100,
channels: 2,
bits_per_sample: 16,
total_samples: Some(2),
max_block_size: 4096,
min_block_size: 256,
};
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 0, 0.0).unwrap();
assert_eq!(segment.order, 0);
assert_eq!(segment.timestamp_sec, 0.0);
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
AudioChunk::I16(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], [100, 200]);
assert_eq!(frames[1], [300, 400]);
assert_eq!(data.get_sample_rate(), 44100);
}
_ => panic!("Expected I16 chunk"),
}
}
_ => panic!("Expected audio chunk"),
}
}
#[test]
fn test_bytes_to_segment_i16_mono() {
// Create mock PCM data (2 frames, mono, 16-bit)
let chunk_bytes = vec![
100u8, 0, // Frame 1
200, 0, // Frame 2
];
let info = StreamInfo {
sample_rate: 48000,
channels: 1,
bits_per_sample: 16,
total_samples: Some(2),
max_block_size: 4096,
min_block_size: 256,
};
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 5, 1.5).unwrap();
assert_eq!(segment.order, 5);
assert_eq!(segment.timestamp_sec, 1.5);
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
AudioChunk::I16(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 2);
// Mono is duplicated to both channels
assert_eq!(frames[0], [100, 100]);
assert_eq!(frames[1], [200, 200]);
}
_ => panic!("Expected I16 chunk"),
}
}
_ => panic!("Expected audio chunk"),
}
}
#[test]
fn test_bytes_to_segment_i24_stereo() {
// Create mock PCM data (1 frame, stereo, 24-bit)
// Frame 1: L=1000 (0x0003E8), R=-1000 (0xFFFC18)
let chunk_bytes = vec![
0xE8, 0x03, 0x00, // L (1000)
0x18, 0xFC, 0xFF, // R (-1000, sign-extended)
];
let info = StreamInfo {
sample_rate: 96000,
channels: 2,
bits_per_sample: 24,
total_samples: Some(1),
max_block_size: 4096,
min_block_size: 256,
};
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
AudioChunk::I24(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0][0].as_i32(), 1000);
assert_eq!(frames[0][1].as_i32(), -1000);
}
_ => panic!("Expected I24 chunk"),
}
}
_ => panic!("Expected audio chunk"),
}
}
#[test]
fn test_bytes_to_segment_i32_stereo() {
// Create mock PCM data (1 frame, stereo, 32-bit)
let chunk_bytes = vec![
0x00, 0x10, 0x00, 0x00, // L (4096)
0x00, 0x20, 0x00, 0x00, // R (8192)
];
let info = StreamInfo {
sample_rate: 44100,
channels: 2,
bits_per_sample: 32,
total_samples: Some(1),
max_block_size: 4096,
min_block_size: 256,
};
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
match &segment.segment {
pmoaudio::_AudioSegment::Chunk(chunk) => {
match chunk.as_ref() {
AudioChunk::I32(data) => {
let frames = data.get_frames();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], [4096, 8192]);
}
_ => panic!("Expected I32 chunk"),
}
}
_ => panic!("Expected audio chunk"),
}
}
#[test]
fn test_bytes_to_segment_unsupported_bit_depth() {
let chunk_bytes = vec![0u8; 8];
let info = StreamInfo {
sample_rate: 44100,
channels: 2,
bits_per_sample: 8, // Currently unsupported by bytes_to_segment
total_samples: Some(1),
max_block_size: 4096,
min_block_size: 256,
};
let result = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0);
assert!(result.is_err());
}
// ═══════════════════════════════════════════════════════════════════════════
// Tests d'intégration pour PlaylistSource
// ═══════════════════════════════════════════════════════════════════════════
// Note: Les tests d'intégration complets nécessitent une vraie playlist et un cache.
// Ces tests peuvent être ajoutés dans un module d'intégration séparé avec des
// fixtures FLAC de test.
#[test]
fn test_playlist_source_type_check() {
// Test de création basique - vérifie que le code compile
// Ce test ne peut pas être exécuté sans mock ou fixture réelles
// car ReadHandle n'implémente pas Clone
use std::sync::Arc;
// Vérification de type - ces lignes ne sont jamais exécutées
if false {
let _handle: ReadHandle = unreachable!();
let _cache: Arc<AudioCache> = unreachable!();
let _source = PlaylistSource::new(_handle, _cache);
}
}
}

View File

@@ -0,0 +1,60 @@
//! Exemple simple de lecture audio avec AudioSink
//!
//! Cet exemple montre comment utiliser AudioSink pour jouer un fichier audio
//! sur la sortie audio standard de la machine.
//!
//! Usage:
//! cargo run --example play_audio -- <fichier.flac>
use pmoaudio::{AudioSink, FileSource};
use std::env;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser le logging
tracing_subscriber::fmt::init();
// Récupérer le chemin du fichier depuis les arguments
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <fichier.flac>", args[0]);
eprintln!("\nExemple:");
eprintln!(" {} music.flac", args[0]);
std::process::exit(1);
}
let file_path = &args[1];
println!("Lecture de: {}", file_path);
// Créer la source audio (lit le fichier FLAC)
let mut source = FileSource::new(file_path).await?;
// Créer le sink audio (joue sur la sortie audio)
let sink = AudioSink::new();
// Connecter la source au sink
source.register(Box::new(sink));
println!("Démarrage de la lecture...");
println!("Appuyez sur Ctrl+C pour arrêter");
// Créer un token d'annulation pour pouvoir arrêter proprement
let stop_token = CancellationToken::new();
let stop_token_clone = stop_token.clone();
// Gérer Ctrl+C pour arrêt propre
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});
// Lancer le pipeline
match Box::new(source).run(stop_token).await {
Ok(()) => println!("\nLecture terminée"),
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
}
Ok(())
}

View File

@@ -0,0 +1,78 @@
//! Exemple de lecture audio avec resampling et conversion de format
//!
//! Cet exemple montre comment construire un pipeline audio complet:
//! FileSource → ResamplingNode → ToI24Node → AudioSink
//!
//! Usage:
//! cargo run --example play_with_resampling -- <fichier.flac> [sample_rate]
use pmoaudio::{AudioSink, FileSource, ResamplingNode, ToI24Node};
use std::env;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser le logging
tracing_subscriber::fmt::init();
// Récupérer les arguments
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <fichier.flac> [sample_rate]", args[0]);
eprintln!("\nExemple:");
eprintln!(" {} music.flac # Lecture normale", args[0]);
eprintln!(" {} music.flac 48000 # Resample vers 48kHz", args[0]);
std::process::exit(1);
}
let file_path = &args[1];
let target_sample_rate = if args.len() >= 3 {
args[2].parse::<u32>()?
} else {
48000 // Par défaut
};
println!("Lecture de: {}", file_path);
println!("Sample rate cible: {} Hz", target_sample_rate);
// Créer la source audio
let mut source = FileSource::new(file_path).await?;
// Créer le nœud de resampling
let mut resampler = ResamplingNode::new(target_sample_rate);
// Créer le nœud de conversion vers I24
let mut converter = ToI24Node::new();
// Créer le sink audio avec volume à 80%
let sink = AudioSink::with_volume(0.8);
// Construire le pipeline: Source → Resampler → Converter → Sink
source.register(Box::new(resampler));
resampler.register(Box::new(converter));
converter.register(Box::new(sink));
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
target_sample_rate);
println!("Démarrage de la lecture...");
println!("Appuyez sur Ctrl+C pour arrêter");
// Créer un token d'annulation
let stop_token = CancellationToken::new();
let stop_token_clone = stop_token.clone();
// Gérer Ctrl+C
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});
// Lancer le pipeline
match Box::new(source).run(stop_token).await {
Ok(()) => println!("\nLecture terminée"),
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
}
Ok(())
}

View File

@@ -123,6 +123,7 @@ pub use nodes::{
file_source::FileSource,
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
http_source::HttpSource,
resampling_node::ResamplingNode,
AudioError, AudioNode, TypedAudioNode,
};

View File

@@ -24,6 +24,7 @@ pub mod converter_nodes;
pub mod file_source;
pub mod flac_file_sink;
pub mod http_source;
pub mod resampling_node;
// Modules temporairement désactivés
/*

View File

@@ -0,0 +1,577 @@
//! ResamplingNode - Node de resampling pour normaliser le sample rate
//!
//! Ce node prend en entrée des chunks audio avec des sample rates variables
//! et les resample vers un sample rate cible fixe.
//!
//! # Usage
//!
//! ```rust,no_run
//! use pmoaudio::{ResamplingNode, FileSource};
//!
//! let mut source = FileSource::new("audio.flac");
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
//! source.register(Box::new(resampler));
//! ```
//!
//! # Comportement
//!
//! - Détecte automatiquement les changements de sample rate
//! - Recrée le resampler quand nécessaire
//! - Passe les chunks directement si déjà au bon sample rate
//! - Préserve les sync markers (TrackBoundary, etc.)
//!
//! # Performance
//!
//! Le resampling est effectué via libsoxr (très haute qualité).
//! La qualité est adaptée selon la profondeur de bits :
//! - 8-bit : Medium quality
//! - 16-bit : High quality
//! - 24-bit/32-bit : Very high quality
use crate::{
dsp::resampling::{build_resampler, resampling, Resampler},
nodes::{AudioError, TypedAudioNode},
pipeline::{AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing;
// ═══════════════════════════════════════════════════════════════════════════
// ResamplingLogic - Logique pure de resampling
// ═══════════════════════════════════════════════════════════════════════════
/// Logique pure de resampling
///
/// Maintient un resampler et le met à jour selon les changements de sample rate.
pub struct ResamplingLogic {
target_sample_rate: u32,
current_resampler: Option<ResamplerState>,
}
struct ResamplerState {
source_hz: u32,
resampler: Resampler,
}
impl ResamplingLogic {
pub fn new(target_sample_rate: u32) -> Self {
Self {
target_sample_rate,
current_resampler: None,
}
}
/// Resample un chunk audio vers le sample rate cible
fn resample_chunk(&mut self, chunk: &AudioChunk) -> Result<AudioChunk, AudioError> {
let source_sr = chunk.sample_rate();
let bit_depth = match chunk {
AudioChunk::I16(_) => BitDepth::B16,
AudioChunk::I24(_) => BitDepth::B24,
AudioChunk::I32(_) => BitDepth::B32,
AudioChunk::F32(_) => BitDepth::B32, // Traiter comme 32-bit
AudioChunk::F64(_) => BitDepth::B32, // Traiter comme 32-bit
};
// Si déjà au bon sample rate, retourner tel quel
if source_sr == self.target_sample_rate {
return Ok(chunk.clone());
}
// Vérifier si on doit recréer le resampler
let need_new_resampler = match &self.current_resampler {
None => true,
Some(state) => state.source_hz != source_sr,
};
if need_new_resampler {
tracing::debug!(
"ResamplingLogic: creating resampler {}Hz → {}Hz (bit_depth={:?})",
source_sr,
self.target_sample_rate,
bit_depth
);
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
self.current_resampler = Some(ResamplerState {
source_hz: source_sr,
resampler,
});
}
let state = self.current_resampler.as_mut().unwrap();
// Extraire les canaux L/R en i32
let (left, right) = extract_channels_i32(chunk)?;
// Appliquer le resampling
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
// Recréer le chunk avec le nouveau sample rate
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
}
}
#[async_trait::async_trait]
impl NodeLogic for ResamplingLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut rx = input.expect("ResamplingNode must have input");
tracing::debug!(
"ResamplingLogic::process started, target={}Hz, {} children",
self.target_sample_rate,
output.len()
);
loop {
let segment = tokio::select! {
_ = stop_token.cancelled() => {
tracing::debug!("ResamplingLogic cancelled");
break;
}
result = rx.recv() => {
match result {
Some(seg) => seg,
None => {
tracing::debug!("ResamplingLogic received EOF");
break;
}
}
}
};
// Resample si c'est un chunk audio, sinon passer tel quel
let output_segment = if segment.is_audio_chunk() {
if let Some(chunk) = segment.as_chunk() {
let resampled_chunk = self.resample_chunk(chunk)?;
Arc::new(AudioSegment {
order: segment.order,
timestamp_sec: segment.timestamp_sec,
segment: crate::_AudioSegment::Chunk(Arc::new(resampled_chunk)),
})
} else {
segment
}
} else {
segment
};
// Envoyer à tous les enfants
for tx in &output {
tx.send(output_segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
Ok(())
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Helper Functions
// ═══════════════════════════════════════════════════════════════════════════
/// Extrait les canaux L/R d'un AudioChunk en i32
fn extract_channels_i32(chunk: &AudioChunk) -> Result<(Vec<i32>, Vec<i32>), AudioError> {
match chunk {
AudioChunk::I16(data) => {
let frames = data.get_frames();
let left = frames.iter().map(|frame| frame[0] as i32).collect();
let right = frames.iter().map(|frame| frame[1] as i32).collect();
Ok((left, right))
}
AudioChunk::I24(data) => {
let frames = data.get_frames();
let left = frames.iter().map(|frame| frame[0].as_i32()).collect();
let right = frames.iter().map(|frame| frame[1].as_i32()).collect();
Ok((left, right))
}
AudioChunk::I32(data) => {
let frames = data.get_frames();
let left = frames.iter().map(|frame| frame[0]).collect();
let right = frames.iter().map(|frame| frame[1]).collect();
Ok((left, right))
}
AudioChunk::F32(data) => {
let frames = data.get_frames();
// Convertir f32 → i32 (dénormaliser)
let left = frames
.iter()
.map(|frame| (frame[0] * i32::MAX as f32) as i32)
.collect();
let right = frames
.iter()
.map(|frame| (frame[1] * i32::MAX as f32) as i32)
.collect();
Ok((left, right))
}
AudioChunk::F64(data) => {
let frames = data.get_frames();
// Convertir f64 → i32 (dénormaliser)
let left = frames
.iter()
.map(|frame| (frame[0] * i32::MAX as f64) as i32)
.collect();
let right = frames
.iter()
.map(|frame| (frame[1] * i32::MAX as f64) as i32)
.collect();
Ok((left, right))
}
}
}
/// Reconstruit un AudioChunk du même type avec les canaux resamplez
fn reconstruct_chunk(
original: &AudioChunk,
left: Vec<i32>,
right: Vec<i32>,
new_sample_rate: u32,
) -> Result<AudioChunk, AudioError> {
if left.len() != right.len() {
return Err(AudioError::ProcessingError(
"Left and right channel lengths differ after resampling".into(),
));
}
let gain_db = original.gain_db();
match original {
AudioChunk::I16(_) => {
let mut stereo = Vec::with_capacity(left.len());
for i in 0..left.len() {
stereo.push([left[i] as i16, right[i] as i16]);
}
Ok(AudioChunk::I16(AudioChunkData::new(
stereo,
new_sample_rate,
gain_db,
)))
}
AudioChunk::I24(_) => {
let mut stereo = Vec::with_capacity(left.len());
for i in 0..left.len() {
let l = I24::new(left[i])
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
let r = I24::new(right[i])
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
stereo.push([l, r]);
}
Ok(AudioChunk::I24(AudioChunkData::new(
stereo,
new_sample_rate,
gain_db,
)))
}
AudioChunk::I32(_) => {
let mut stereo = Vec::with_capacity(left.len());
for i in 0..left.len() {
stereo.push([left[i], right[i]]);
}
Ok(AudioChunk::I32(AudioChunkData::new(
stereo,
new_sample_rate,
gain_db,
)))
}
AudioChunk::F32(_) => {
let mut stereo = Vec::with_capacity(left.len());
for i in 0..left.len() {
stereo.push([
left[i] as f32 / i32::MAX as f32,
right[i] as f32 / i32::MAX as f32,
]);
}
Ok(AudioChunk::F32(AudioChunkData::new(
stereo,
new_sample_rate,
gain_db,
)))
}
AudioChunk::F64(_) => {
let mut stereo = Vec::with_capacity(left.len());
for i in 0..left.len() {
stereo.push([
left[i] as f64 / i32::MAX as f64,
right[i] as f64 / i32::MAX as f64,
]);
}
Ok(AudioChunk::F64(AudioChunkData::new(
stereo,
new_sample_rate,
gain_db,
)))
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// WRAPPER ResamplingNode - Délègue à Node<ResamplingLogic>
// ═══════════════════════════════════════════════════════════════════════════
/// ResamplingNode - Normalise le sample rate vers une valeur cible
///
/// Ce node prend en entrée des chunks audio avec des sample rates variables
/// et les resample vers un sample rate fixe.
pub struct ResamplingNode {
inner: Node<ResamplingLogic>,
}
impl ResamplingNode {
/// Crée un nouveau node de resampling
///
/// * `target_sample_rate` - Sample rate de sortie en Hz (ex: 48000)
pub fn new(target_sample_rate: u32) -> Box<dyn AudioPipelineNode> {
Self::with_channel_size(target_sample_rate, 16)
}
/// Crée un nouveau node de resampling avec taille de canal personnalisée
///
/// * `target_sample_rate` - Sample rate de sortie en Hz
/// * `channel_size` - Taille du canal de communication
pub fn with_channel_size(
target_sample_rate: u32,
channel_size: usize,
) -> Box<dyn AudioPipelineNode> {
let logic = ResamplingLogic::new(target_sample_rate);
Box::new(Self {
inner: Node::new_with_input(logic, channel_size),
})
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for ResamplingNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child)
}
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for ResamplingNode {
fn input_type(&self) -> Option<TypeRequirement> {
// Accepte n'importe quel type
Some(TypeRequirement::any())
}
fn output_type(&self) -> Option<TypeRequirement> {
// Produit le même type que l'entrée (mais sample rate changé)
Some(TypeRequirement::any())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AudioChunk, AudioChunkData, SyncMarker};
#[test]
fn test_extract_channels_i16() {
let chunk = AudioChunk::I16(AudioChunkData::new(
vec![[100, 200], [300, 400]],
48000,
0.0,
));
let (left, right) = extract_channels_i32(&chunk).unwrap();
assert_eq!(left, vec![100i32, 300i32]);
assert_eq!(right, vec![200i32, 400i32]);
}
#[test]
fn test_extract_channels_i24() {
let chunk = AudioChunk::I24(AudioChunkData::new(
vec![
[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()],
[I24::new(3_000_000).unwrap(), I24::new(4_000_000).unwrap()],
],
48000,
0.0,
));
let (left, right) = extract_channels_i32(&chunk).unwrap();
assert_eq!(left, vec![1_000_000i32, 3_000_000i32]);
assert_eq!(right, vec![2_000_000i32, 4_000_000i32]);
}
#[test]
fn test_reconstruct_chunk_i16() {
let original = AudioChunk::I16(AudioChunkData::new(
vec![[100, 200]],
44100,
0.0,
));
let left = vec![100i32, 300i32];
let right = vec![200i32, 400i32];
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
if let AudioChunk::I16(data) = result {
assert_eq!(data.get_sample_rate(), 48000);
let frames = data.get_frames();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], [100i16, 200i16]);
assert_eq!(frames[1], [300i16, 400i16]);
} else {
panic!("Expected I16 chunk");
}
}
#[test]
fn test_reconstruct_chunk_i24() {
let original = AudioChunk::I24(AudioChunkData::new(
vec![[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()]],
44100,
0.0,
));
let left = vec![1_000_000i32, 3_000_000i32];
let right = vec![2_000_000i32, 4_000_000i32];
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
if let AudioChunk::I24(data) = result {
assert_eq!(data.get_sample_rate(), 48000);
let frames = data.get_frames();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0][0].as_i32(), 1_000_000);
assert_eq!(frames[0][1].as_i32(), 2_000_000);
} else {
panic!("Expected I24 chunk");
}
}
#[test]
fn test_resample_chunk_no_change_if_same_rate() {
let mut logic = ResamplingLogic::new(48000);
let chunk = AudioChunk::I16(AudioChunkData::new(
vec![[100, 200], [300, 400]],
48000, // Déjà à 48kHz
0.0,
));
let result = logic.resample_chunk(&chunk).unwrap();
// Doit retourner le même chunk sans resampling
if let AudioChunk::I16(data) = result {
assert_eq!(data.get_sample_rate(), 48000);
assert_eq!(data.get_frames().len(), 2);
} else {
panic!("Expected I16 chunk");
}
}
#[tokio::test]
async fn test_resampling_logic_passes_sync_markers() {
let mut logic = ResamplingLogic::new(48000);
let (input_tx, input_rx) = mpsc::channel(10);
let (output_tx, mut output_rx) = mpsc::channel(10);
let stop_token = CancellationToken::new();
// Créer un TrackBoundary
let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new()
));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
// Envoyer le boundary
input_tx.send(boundary.clone()).await.unwrap();
drop(input_tx);
// Lancer le traitement
tokio::spawn(async move {
logic
.process(Some(input_rx), vec![output_tx], stop_token)
.await
.unwrap();
});
// Vérifier que le boundary passe tel quel
let result = output_rx.recv().await.unwrap();
assert!(result.as_sync_marker().is_some());
if let Some(marker) = result.as_sync_marker() {
assert!(matches!(**marker, SyncMarker::TrackBoundary { .. }));
}
}
#[tokio::test]
async fn test_resampling_node_integration() {
// Test d'intégration complet avec ResamplingNode
let (input_tx, input_rx) = mpsc::channel(10);
let (output_tx, mut output_rx) = mpsc::channel(10);
let stop_token = CancellationToken::new();
let mut logic = ResamplingLogic::new(48000);
// Créer un chunk à 44.1kHz
let chunk_44k = AudioChunk::I16(AudioChunkData::new(
vec![[1000, 2000]; 100], // 100 frames
44100,
0.0,
));
let segment = Arc::new(AudioSegment {
order: 0,
timestamp_sec: 0.0,
segment: crate::_AudioSegment::Chunk(Arc::new(chunk_44k)),
});
input_tx.send(segment).await.unwrap();
drop(input_tx);
// Lancer le traitement
tokio::spawn(async move {
logic
.process(Some(input_rx), vec![output_tx], stop_token)
.await
.unwrap();
});
// Vérifier le résultat
let result = output_rx.recv().await.unwrap();
assert!(result.is_audio_chunk());
if let Some(chunk) = result.as_chunk() {
// Le chunk doit être I16 (même type)
assert!(matches!(chunk.as_ref(), AudioChunk::I16(_)));
// Le sample rate doit être 48000
assert_eq!(chunk.sample_rate(), 48000);
// Le nombre de frames doit avoir changé (ratio ~1.088)
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
if let AudioChunk::I16(data) = chunk.as_ref() {
let frames = data.get_frames().len();
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
}
} else {
panic!("Expected audio chunk");
}
}
}

View File

@@ -482,6 +482,15 @@ impl<L: NodeLogic> Node<L> {
pub fn logic(&self) -> &L {
&self.logic
}
/// Retourne une référence mutable vers la logique métier du nœud
///
/// Permet de configurer la logique après construction mais avant run().
/// Utile pour définir des options qui ne peuvent pas être connues
/// au moment de la construction du nœud.
pub fn logic_mut(&mut self) -> &mut L {
&mut self.logic
}
}
#[async_trait::async_trait]

View File

@@ -1,5 +1,4 @@
use anyhow::Result;
use sha1::{Digest, Sha1};
use std::{
path::{Path, PathBuf},
sync::Arc,

View File

@@ -51,12 +51,8 @@ symphonia = { version = "0.5", features = ["all"] }
# Audio decoding - claxon for FLAC streaming
claxon = "0.4"
# FFmpeg for progressive streaming (decoding + encoding) - optional
ffmpeg-next = { version = "8.0", optional = true }
# Per-track feature dependencies
hound = { version = "3.5", optional = true }
tempfile = { version = "3.8", optional = true }
# pmoaudio-ext with playlist support (optional for examples)
pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist"] }
# Common music source traits
pmosource = { path = "../pmosource" }
@@ -74,12 +70,16 @@ pmoserver = { path = "../pmoserver", optional = true }
utoipa = { version = "5.4.0", optional = true }
axum = { version = "0.8.4", optional = true }
# pmoaudio node support
pmoaudio = { path = "../pmoaudio", optional = true }
pmoflac = { path = "../pmoflac", optional = true }
pmometadata = { path = "../pmometadata", optional = true }
futures-util = { version = "0.3", optional = true }
[features]
default = ["metadata-only", "pmoconfig"]
# Mode métadonnées seules (pas de décodage FLAC)
metadata-only = []
# Active l'extraction par-track (WAV export, etc.)
per-track = ["dep:hound", "dep:tempfile"]
# Active l'API REST pmoserver
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
# Feature pour activer le support serveur (cache registry)
@@ -88,8 +88,10 @@ server = ["pmosource/server", "pmoconfig"]
pmoconfig = ["dep:pmoconfig"]
# Feature cache (deprecated - toujours actif maintenant)
cache = []
# Active le streaming progressif avec FFmpeg (latence réduite)
ffmpeg = ["dep:ffmpeg-next"]
# 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"]
[dev-dependencies]
# Tests
@@ -104,17 +106,3 @@ pmoaudiocache = { path = "../pmoaudiocache" }
[[example]]
name = "now_playing"
path = "examples/now_playing.rs"
[[example]]
name = "stream_block"
path = "examples/stream_block.rs"
[[example]]
name = "extract_track"
path = "examples/extract_track.rs"
required-features = ["per-track"]
[[example]]
name = "with_cache"
path = "examples/with_cache.rs"
required-features = ["cache"]

View File

@@ -0,0 +1,244 @@
# RadioParadiseStreamSource - Documentation Technique
## Vue d'ensemble
`RadioParadiseStreamSource` est un nœud source pour `pmoaudio` qui télécharge et décode les blocs FLAC de Radio Paradise en temps réel, avec gestion automatique des transitions entre pistes (TrackBoundary).
## Architecture
### Pattern Node<L>
Suit l'architecture séparée logique/pipeline de `pmoaudio` :
```
RadioParadiseStreamSource (wrapper)
└── Node<RadioParadiseStreamSourceLogic>
└── RadioParadiseStreamSourceLogic (logique métier)
```
### RadioParadiseStreamSourceLogic
Responsabilités :
- **File d'attente** : `VecDeque<EventId>` pour les blocks à télécharger
- **Cache anti-redondance** : `VecDeque<EventId>` pour 10 blocs récents (FIFO)
- **Téléchargement** : Fetch bloc FLAC (bitrate=4 uniquement)
- **Décodage** : Stream FLAC via `pmoflac::decode_audio_stream`
- **Timing** : Calcul précis pour insertion TrackBoundary
## Flux d'exécution
```
┌─────────────────────────────────────────────────────────┐
│ 1. Attente block ID (timeout 3s) │
│ └─> VecDeque::pop_front() │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 2. Vérification cache │
│ └─> VecDeque::contains(&event_id) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 3. Téléchargement métadonnées │
│ └─> client.get_block(event_id) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 4. Téléchargement FLAC (bitrate=4) │
│ └─> client.download_block_file(&block, 4) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 5. Décodage streaming │
│ └─> pmoflac::decode_audio_stream(reader) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 6. Découpage en chunks │
│ └─> pcm_to_audio_chunk(pcm, sr, bps) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 7. Insertion TrackBoundary (timing sample-based) │
│ └─> elapsed_ms = (total_samples * 1000) / sr │
└─────────────────────────────────────────────────────────┘
```
## Timing TrackBoundary
### Algorithme
```rust
let elapsed_ms = (total_samples * 1000) / sample_rate as u64;
if elapsed_ms >= song.elapsed {
// Envoyer TrackBoundary AVANT le chunk (même order)
send_track_boundary(*order, song, block).await;
}
```
### Exemple concret
Bloc FLAC contenant 3 chansons :
- Song 0 : `elapsed = 0ms`
- Song 1 : `elapsed = 180000ms` (3min)
- Song 2 : `elapsed = 420000ms` (7min)
Timeline :
```
0ms 180000ms 420000ms
│ │ │
Song 0 TrackBoundary TrackBoundary
└─> Song 1 └─> Song 2
```
## SyncMarker Order
**Règle** : TrackBoundary a le **même order** que le chunk suivant.
```rust
// TrackBoundary order = 42
AudioSegment::new_sync(42, SyncMarker::TrackBoundary { ... })
// Chunk suivant order = 42
AudioSegment::new_audio(42, AudioChunk::I16(...))
```
## Gestion du cache
### Stratégie FIFO simple
```rust
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
fn mark_block_downloaded(&mut self, event_id: EventId) {
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
self.recent_blocks.pop_front();
}
// Puis ajouter le nouveau bloc
self.recent_blocks.push_back(event_id);
}
```
**Avantages VecDeque** :
- ✅ Ordre FIFO garanti (le plus ancien est toujours retiré)
- ✅ Simple et prévisible
- ✅ Robuste : `while` garantit exactement 10 éléments max, même en cas d'état anormal
- ✅ Ne dépasse jamais la capacité pré-allouée (retire avant d'ajouter)
- ✅ Pour 10 éléments, `contains()` en O(n) reste très performant
## Support FLAC
### Formats supportés
- **16-bit** : `AudioChunk::I16`
- **24-bit** : `AudioChunk::I24`
### Conversion PCM
```rust
match bits_per_sample {
16 => {
let samples: Vec<i16> = pcm_data
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
AudioChunk::I16(...)
}
24 => {
let samples: Vec<I24> = pcm_data
.chunks_exact(3)
.map(|chunk| {
let value = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], 0]) >> 8;
I24::from_i32(value)
})
.collect();
AudioChunk::I24(...)
}
}
```
## Métadonnées
### TrackMetadata
Champs extraits de `Song` :
- `title` : Titre de la chanson
- `artist` : Artiste
- `album` : Album (optionnel)
- `year` : Année (optionnel)
- `cover_url` : URL de la pochette (async via tokio::spawn)
### Gestion asynchrone du cover
```rust
tokio::spawn(async move {
if let Ok(mut meta) = metadata_clone.write().await {
let _ = meta.set_cover_url(Some(cover_url)).await;
}
});
```
## API Publique
### Création
```rust
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self
```
### Configuration
```rust
pub fn push_block_id(&mut self, event_id: EventId)
```
Ajoute un block ID à télécharger dans la file d'attente.
### Exécution
```rust
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>
```
Hérite de `AudioPipelineNode`.
## Exemple d'utilisation
Voir `examples/radio_paradise_stream.rs` pour :
- Utilisation basique
- Intégration avec nowplaying stream
- Connexion à un sink
## Constantes
```rust
const BLOCK_ID_TIMEOUT_SECS: u64 = 3; // Timeout attente nouveau block
const RECENT_BLOCKS_CACHE_SIZE: usize = 10; // Taille cache anti-redondance
```
## Dépendances
- `pmoaudio` : Pipeline audio, types AudioChunk/AudioSegment
- `pmoflac` : Décodage FLAC streaming
- `pmometadata` : Métadonnées pistes
- `futures-util` : StreamExt pour le décodage
- `tokio` : Runtime async
- `tokio-util` : StreamReader, CancellationToken
## Feature gate
```toml
[features]
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"]
```
Activer avec : `cargo build -p pmoparadise --features pmoaudio`

View File

@@ -0,0 +1,205 @@
//! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC
//!
//! Ce programme démontre l'utilisation de la chaîne :
//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise
//! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé
//!
//! La nouvelle architecture AudioPipelineNode permet de :
//! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise
//! - Détecter les limites de pistes (TrackBoundary)
//! - Sauvegarder automatiquement chaque piste dans un fichier séparé
//! - Gérer proprement l'arrêt du pipeline avec un CancellationToken
//!
//! Usage:
//! cargo run --example download_block -- <channel_id>
//!
//! Exemple:
//! cargo run --example download_block -- 0 # Main Mix
//! cargo run --example download_block -- 1 # Mellow Mix
//! cargo run --example download_block -- 2 # Rock Mix
//! cargo run --example download_block -- 3 # World/Etc Mix
use pmoaudio::{AudioPipelineNode, FlacFileSink};
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
use std::env;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser tracing pour le debug
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
// Récupérer les arguments
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Usage: {} <channel_id>", args[0]);
eprintln!();
eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files.");
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!("Example:");
eprintln!(" {} 0 # Download Main Mix", args[0]);
eprintln!(" {} 2 # Download Rock Mix", args[0]);
std::process::exit(1);
}
let channel_id: u8 = match args[1].parse() {
Ok(id) => id,
Err(_) => {
eprintln!("Error: channel_id must be a number between 0 and 3");
std::process::exit(1);
}
};
if channel_id > 3 {
eprintln!("Error: channel_id must be between 0 and 3");
std::process::exit(1);
}
println!("=== Radio Paradise Block Downloader ===");
println!();
println!("Channel ID: {}", channel_id);
println!();
// Créer le client Radio Paradise pour le channel spécifié
println!("Fetching current block metadata...");
let client = RadioParadiseClient::builder()
.channel(channel_id)
.build()
.await?;
// Récupérer le bloc actuel
let block = client.get_block(None).await?;
println!("Block Information:");
println!(" Event ID: {}", block.event);
println!(" Songs: {}", block.song_count());
println!(
" Duration: {:.1} minutes",
block.length as f64 / 60000.0
);
println!();
// Afficher la liste des pistes
println!("Tracklist:");
for (index, song) in block.songs_ordered() {
println!(
" {:2}. {} - {} ({})",
index + 1,
song.artist,
song.title,
song.album.as_deref().unwrap_or("Unknown Album")
);
}
println!();
// Créer le répertoire de sortie
let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event);
std::fs::create_dir_all(&output_dir)?;
println!("Output directory: {}", output_dir);
println!();
// Créer le pipeline: RadioParadiseStreamSource → FlacFileSink
let mut source = RadioParadiseStreamSource::new(client);
// Ajouter le bloc à télécharger
source.push_block_id(block.event);
// Créer le sink qui sauvegarde chaque piste dans un fichier séparé
let base_path = format!("{}/track.flac", output_dir);
let sink = FlacFileSink::new(&base_path);
// Construire la chaîne: source → sink
source.register(Box::new(sink));
// Créer un token d'arrêt
let stop_token = CancellationToken::new();
// Gérer Ctrl+C pour arrêt propre
let stop_token_clone = stop_token.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
println!("\n\nReceived Ctrl+C, stopping...");
stop_token_clone.cancel();
});
// Lancer tout le pipeline
println!("Downloading and processing block...");
println!("Press Ctrl+C to stop.");
println!();
let start = std::time::Instant::now();
let result = Box::new(source).run(stop_token).await;
let elapsed = start.elapsed();
// Vérifier le résultat
match result {
Ok(()) => {
println!();
println!(
"✓ Download completed successfully in {:.2}s",
elapsed.as_secs_f64()
);
println!(" Output directory: {}", output_dir);
println!();
// Afficher les fichiers créés
let entries = std::fs::read_dir(&output_dir)?;
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s == "flac")
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.path());
println!("Files created:");
for (i, entry) in files.iter().enumerate() {
let path = entry.path();
let metadata = std::fs::metadata(&path)?;
let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
println!(
" {:2}. {} ({:.2} MB)",
i + 1,
path.file_name().unwrap().to_string_lossy(),
size_mb
);
}
println!();
// Calculer la taille totale
let total_size: u64 = files
.iter()
.filter_map(|e| std::fs::metadata(e.path()).ok())
.map(|m| m.len())
.sum();
println!(
"Total size: {:.2} MB",
total_size as f64 / (1024.0 * 1024.0)
);
}
Err(e) => {
eprintln!();
eprintln!("✗ Download error: {}", e);
eprintln!();
return Err(e.into());
}
}
Ok(())
}

View File

@@ -1,120 +0,0 @@
//! Example: Extract individual tracks from a FLAC block (requires `per-track` feature)
//!
//! This example demonstrates:
//! - Per-track extraction from FLAC blocks
//! - Exporting tracks to WAV files
//! - Alternative player-based seeking (recommended)
//!
//! **Warning**: This approach downloads and decodes entire blocks.
//! For most use cases, player-based seeking is more efficient.
//!
//! Run with: cargo run --example extract_track --features per-track
#[cfg(feature = "per-track")]
use pmoparadise::{RadioParadiseClient, Result};
#[cfg(feature = "per-track")]
use std::path::Path;
#[cfg(feature = "per-track")]
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
#[cfg(feature = "logging")]
tracing_subscriber::fmt::init();
println!("Radio Paradise - Per-Track Extraction Demo");
println!("===========================================\n");
println!("WARNING: This feature downloads entire blocks (50-100MB)");
println!(" and performs CPU-intensive FLAC decoding.");
println!(" For most use cases, player-based seeking is better.\n");
// Create client
let client = RadioParadiseClient::new().await?;
// Get current block
let block = client.get_block(None).await?;
println!("Block Information:");
println!(" Event: {}", block.event);
println!(" Songs: {}", block.song_count());
println!(" URL: {}\n", block.url);
// Display all tracks
println!("Available Tracks:");
for (index, song) in block.songs_ordered() {
println!(
" {}. {} - {} ({:.1}s)",
index,
song.artist,
song.title,
song.duration as f64 / 1000.0
);
}
println!();
// Extract first track
let track_index = 0;
if let Some((_, song)) = block.songs_ordered().first() {
println!("Extracting Track {}:", track_index);
println!(" Artist: {}", song.artist);
println!(" Title: {}", song.title);
println!(" Album: {}\n", song.album);
println!("Downloading and decoding... (this may take a while)");
// Open track stream
let mut track_stream = client.open_track_stream(&block, track_index).await?;
println!("Track Metadata:");
println!(" Sample Rate: {} Hz", track_stream.metadata.sample_rate);
println!(" Channels: {}", track_stream.metadata.channels);
println!(
" Bits Per Sample: {}",
track_stream.metadata.bits_per_sample
);
println!(" Total Samples: {}", track_stream.metadata.total_samples);
println!();
// Export to WAV
let output_path = Path::new("track.wav");
println!("Exporting to {:?}...", output_path);
track_stream.export_wav(output_path)?;
println!("✓ Export complete!\n");
}
// Show alternative: player-based seeking
println!("RECOMMENDED ALTERNATIVE: Player-Based Seeking");
println!("=============================================\n");
for (index, song) in block.songs_ordered().into_iter().take(3) {
let (start, duration) = client.track_position_seconds(&block, index)?;
println!("Track {}: {} - {}", index, song.artist, song.title);
println!(" mpv command:");
println!(
" mpv --start={:.3} --length={:.3} '{}'",
start, duration, block.url
);
println!(" ffmpeg command (extract to file):");
println!(
" ffmpeg -ss {:.3} -t {:.3} -i '{}' -c copy track_{}.flac",
start, duration, block.url, index
);
println!();
}
println!("These methods are much more efficient as they:");
println!(" - Don't download the entire block");
println!(" - Use the player's optimized seeking");
println!(" - Start playback immediately");
println!(" - Preserve original quality (with -c copy)");
Ok(())
}
#[cfg(not(feature = "per-track"))]
fn main() {
eprintln!("ERROR: This example requires the 'per-track' feature.");
eprintln!("Run with: cargo run --example extract_track --features per-track");
std::process::exit(1);
}

View File

@@ -0,0 +1,276 @@
//! 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. AudioSink - Joue l'audio sur la sortie standard
//!
//! Architecture :
//! ```text
//! Pipeline 1 (Download & Cache):
//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée)
//!
//! Pipeline 2 (Playback):
//! PlaylistSource (lit la playlist) → AudioSink (joue l'audio)
//! ```
//!
//! 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};
use pmoaudio_ext::{FlacCacheSink, PlaylistSource};
use pmoaudiocache::Cache as AudioCache;
use pmocovers::Cache as CoverCache;
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
use pmoplaylist::Manager as PlaylistManager;
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>", 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)");
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);
// ═══════════════════════════════════════════════════════════════════════════
// 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);
// 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 sink audio avec volume à 80%
let audio_sink = AudioSink::with_volume(0.8);
tracing::debug!("AudioSink created with volume 0.8");
// Connecter playlist → audio
playlist_source.register(Box::new(audio_sink));
tracing::info!("Playback pipeline connected: PlaylistSource → 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 {
// Attendre un peu que le premier track soit disponible
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
tracing::info!("[PLAYBACK] Pipeline starting...");
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(())
}

View File

@@ -1,71 +0,0 @@
//! Example showing how to access and save the Radio Paradise source image
//!
//! This example demonstrates:
//! - Getting source information via the MusicSource trait
//! - Accessing the embedded WebP image
//! - Optionally saving it to a file
use pmoaudiocache::cache as audio_cache;
use pmocovers::cache as covers_cache;
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
use pmosource::MusicSource;
use std::fs;
use std::io::Write;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create the client and source
let client = RadioParadiseClient::new().await?;
// Build lightweight caches under the system temp dir for this example
let base_dir = std::env::temp_dir().join(format!(
"pmoparadise_show_source_image_{}",
std::process::id()
));
let covers_dir = base_dir.join("covers");
let audio_dir = base_dir.join("audio");
std::fs::create_dir_all(&covers_dir)?;
std::fs::create_dir_all(&audio_dir)?;
let cover_cache = Arc::new(covers_cache::new_cache(
covers_dir.to_string_lossy().as_ref(),
32,
)?);
let audio_cache = Arc::new(audio_cache::new_cache(
audio_dir.to_string_lossy().as_ref(),
32,
)?);
let source = RadioParadiseSource::new_default(client, cover_cache, audio_cache);
// Display source information
println!("Music Source Information");
println!("========================");
println!("Name: {}", source.name());
println!("ID: {}", source.id());
println!("Image MIME type: {}", source.default_image_mime_type());
// Get the embedded image
let image_data = source.default_image();
println!("Embedded image size: {} bytes", image_data.len());
// Verify WebP format
if image_data.len() >= 12 {
let is_webp = &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP";
println!("Valid WebP format: {}", is_webp);
}
// Optional: save to file
if std::env::args().any(|arg| arg == "--save") {
let filename = format!("{}_default.webp", source.id());
let mut file = fs::File::create(&filename)?;
file.write_all(image_data)?;
println!("\nImage saved to: {}", filename);
println!("You can view it with: open {}", filename);
} else {
println!("\nTo save the image to disk, run with: --save");
}
Ok(())
}

View File

@@ -1,100 +0,0 @@
//! Example: Stream a Radio Paradise block with prefetching
//!
//! This example demonstrates:
//! - Streaming block audio data
//! - Writing to a file or piping to a player
//! - Prefetching the next block for gapless playback
//! - Continuous playback loop
//!
//! Run with: cargo run --example stream_block
//!
//! To play directly with mpv:
//! cargo run --example stream_block | mpv --no-cache --demuxer=+lavf -
use futures::StreamExt;
use pmoparadise::{RadioParadiseClient, Result};
use std::io::Write;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging (optional)
#[cfg(feature = "logging")]
tracing_subscriber::fmt::init();
eprintln!("Radio Paradise - Block Streaming Demo");
eprintln!("======================================\n");
// Create client
let mut client = RadioParadiseClient::builder().build().await?;
eprintln!("Client configured for FLAC streaming\n");
// Get current block
let current_block = client.get_block(None).await?;
eprintln!("Current Block:");
eprintln!(" Event: {}", current_block.event);
eprintln!(" Songs: {}", current_block.song_count());
eprintln!(
" Duration: {:.1} minutes",
current_block.length as f64 / 60000.0
);
eprintln!(" URL: {}\n", current_block.url);
// Display tracklist
eprintln!("Tracklist:");
for (index, song) in current_block.songs_ordered() {
eprintln!(" {}. {} - {}", index + 1, song.artist, song.title);
}
eprintln!();
// Prefetch next block in advance
eprintln!("Prefetching next block...");
client.prefetch_next(&current_block).await?;
eprintln!(
"Next block prefetched: {}\n",
client.next_block_url().unwrap()
);
// Stream the block
eprintln!("Streaming block... (writing to stdout)");
eprintln!("Tip: Pipe to a player like: cargo run --example stream_block | mpv -\n");
let mut stream = client.stream_block_from_metadata(&current_block).await?;
let mut total_bytes = 0u64;
let mut stdout = std::io::stdout();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
total_bytes += chunk.len() as u64;
// Write to stdout (can be piped to a player)
stdout.write_all(&chunk)?;
stdout.flush()?;
// Progress indicator (to stderr so it doesn't interfere with piped audio)
if total_bytes % (1024 * 1024) == 0 {
eprintln!(
" Downloaded: {:.1} MB",
total_bytes as f64 / 1024.0 / 1024.0
);
}
}
eprintln!("\nBlock streaming complete!");
eprintln!(
"Total downloaded: {:.2} MB",
total_bytes as f64 / 1024.0 / 1024.0
);
// In a real application, you would now:
// 1. Get the next block using prefetched metadata
// 2. Stream it seamlessly
// 3. Prefetch the following block
// 4. Repeat for continuous playback
eprintln!("\nFor continuous playback, you would now stream the next block:");
eprintln!(" Event: {}", current_block.end_event);
Ok(())
}

View File

@@ -1,129 +0,0 @@
//! Test progressive streaming implementation
//!
//! This example tests the streaming implementation and measures performance
//!
//! Run with:
//! ```bash
//! RUST_LOG=info cargo run --example test_streaming
//! ```
use pmoparadise::RadioParadiseClient;
use std::time::Instant;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing with timestamps
tracing_subscriber::fmt()
.with_target(false)
.with_thread_ids(false)
.with_level(true)
.init();
println!("🎵 Testing Progressive FLAC Streaming");
println!("=====================================\n");
// Create the Radio Paradise client
println!("📡 Connecting to Radio Paradise...");
let client = RadioParadiseClient::new().await?;
println!("✅ Connected!\n");
// Get current block
println!("🎧 Fetching current block metadata...");
let block = client.get_block(None).await?;
println!("\n📊 Block Information:");
println!(" Event ID: {}", block.event);
println!(" Songs: {}", block.song_count());
println!(" Duration: ~{} seconds\n", block.length / 1000);
// List songs
println!("🎵 Songs in this block:");
for (idx, song) in block.songs_ordered() {
println!(
" {}. {} - {} ({}s at {}s)",
idx + 1,
song.artist,
song.title,
song.duration / 1000,
song.elapsed / 1000
);
}
println!();
// Now test the streaming decoder
println!("⚡ Starting progressive streaming test...");
println!(" (This will download and decode the block progressively)");
println!();
let start_time = Instant::now();
let block_url = block.url.parse()?;
let http_stream = client.stream_block(&block_url).await?;
use pmoparadise::streaming::StreamingPCMDecoder;
// Decode in a blocking task
let decode_task = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(u64, usize)>> {
let mut decoder = StreamingPCMDecoder::new(http_stream)?;
println!(
" 🎼 Stream info: {}Hz, {} channels, {} bits",
decoder.sample_rate(),
decoder.channels(),
decoder.bits_per_sample()
);
let mut chunk_times = Vec::new();
let mut chunk_count = 0;
while let Some(chunk) = decoder.decode_chunk()? {
chunk_count += 1;
chunk_times.push((chunk.position_ms, chunk.samples.len()));
if chunk_count % 50 == 0 {
println!(
" 📦 Chunk {} at {}ms ({} samples)",
chunk_count,
chunk.position_ms,
chunk.samples.len()
);
}
}
Ok(chunk_times)
});
let chunk_times = decode_task
.await
.map_err(|e| anyhow::anyhow!("Join error: {}", e))??;
let total_time = start_time.elapsed();
println!("\n✅ Streaming Complete!");
println!("\n📈 Performance Metrics:");
println!(" Total chunks decoded: {}", chunk_times.len());
println!(" Total time: {:.2}s", total_time.as_secs_f64());
if let Some((first_pos, _)) = chunk_times.first() {
println!(" First chunk at: {}ms", first_pos);
}
if let Some((last_pos, _)) = chunk_times.last() {
println!(
" Last chunk at: {}ms (~{:.1}s)",
last_pos,
last_pos / 1000
);
}
println!("\n💡 Analysis:");
println!(" With the old approach (download all first):");
println!(" - Would need to wait for full download (~12-16s)");
println!(" - Then decode all samples");
println!(" - Total: ~15-20s before first track");
println!();
println!(" With progressive streaming:");
println!(" - First chunks arrive in ~2-3s");
println!(" - First track (3min) ready in ~6-8s");
println!(" - Improvement: ~2x faster! ⚡");
Ok(())
}

View File

@@ -1,107 +0,0 @@
//! Example demonstrating Radio Paradise with cache support
//!
//! This example shows how to use the RadioParadiseSource with pmocovers
//! and pmoaudiocache to cache both cover images and audio tracks.
//!
//! Run with:
//! ```bash
//! cargo run --example with_cache --features cache
//! ```
use pmoaudiocache::AudioCache;
use pmocovers::Cache as CoverCache;
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
use pmosource::MusicSource;
use std::sync::Arc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing
tracing_subscriber::fmt::init();
println!("🎵 Radio Paradise with Cache Support");
println!("=====================================\n");
// Create the Radio Paradise client
println!("📡 Connecting to Radio Paradise...");
let client = RadioParadiseClient::new().await?;
println!("✅ Connected!\n");
// Initialize caches
println!("💾 Initializing caches...");
let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?);
let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
println!("✅ Caches initialized!\n");
// Create the source with caching enabled
let source = RadioParadiseSource::new_with_cache(
client.clone(),
"http://localhost:8080",
50,
Some(cover_cache.clone()),
Some(audio_cache.clone()),
);
println!("📻 Source: {}", source.name());
println!("🆔 ID: {}", source.id());
println!("📝 Supports FIFO: {}\n", source.supports_fifo());
// Fetch current playing information
println!("🎧 Fetching current track information...");
let now_playing = client.now_playing().await?;
let block = Arc::new(now_playing.block.clone());
println!("\n🎵 Now Playing:");
println!(" Event: {}", block.event);
if let Some(song) = &now_playing.current_song {
println!(" Title: {}", song.title);
println!(" Artist: {}", song.artist);
println!(" Album: {}", song.album);
}
println!();
// Add current song to the source
println!(" Adding current track to FIFO with caching...");
if let Some(song) = &now_playing.current_song {
source
.add_song(
block.clone(),
song,
now_playing.current_song_index.unwrap_or(0),
)
.await?;
println!("✅ Track added and caching started!");
println!(" - Cover image will be cached to: ./cache/covers/");
println!(" - Audio will be cached to: ./cache/audio/\n");
}
// Wait a bit for caching to start
println!("⏳ Waiting for cache operations to complete...");
sleep(Duration::from_secs(5)).await;
// Get items from FIFO
println!("\n📋 Items in FIFO:");
let items = source.get_items(0, 10).await?;
for (i, item) in items.iter().enumerate() {
println!(
" {}. {} - {}",
i + 1,
item.artist.as_deref().unwrap_or("Unknown"),
item.title
);
// Show resolved URI (will use cached version if available)
if let Ok(uri) = source.resolve_uri(&item.id).await {
println!(" URI: {}", uri);
}
}
println!("\n✨ Example complete!");
println!("\n💡 Tips:");
println!(" - Run the example again to see faster loading from cache");
println!(" - Check ./cache/covers/ for cached cover images");
println!(" - Check ./cache/audio/ for cached FLAC files");
Ok(())
}

143
pmoparadise/src/channels.rs Normal file
View File

@@ -0,0 +1,143 @@
//! Radio Paradise channel definitions
//!
//! This module defines the available Radio Paradise channels and their metadata.
use std::str::FromStr;
/// Logical identifier for a Radio Paradise channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParadiseChannelKind {
Main,
Mellow,
Rock,
Eclectic,
}
impl ParadiseChannelKind {
pub const fn id(self) -> u8 {
match self {
Self::Main => 0,
Self::Mellow => 1,
Self::Rock => 2,
Self::Eclectic => 3,
}
}
pub const fn slug(self) -> &'static str {
match self {
Self::Main => "main",
Self::Mellow => "mellow",
Self::Rock => "rock",
Self::Eclectic => "eclectic",
}
}
pub const fn display_name(self) -> &'static str {
match self {
Self::Main => "Main Mix",
Self::Mellow => "Mellow Mix",
Self::Rock => "Rock Mix",
Self::Eclectic => "Eclectic Mix",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::Main => "Eclectic mix of rock, world, electronica, and more",
Self::Mellow => "Mellower, less aggressive music",
Self::Rock => "Heavier, more guitar-driven music",
Self::Eclectic => "Curated worldwide selection",
}
}
}
impl FromStr for ParadiseChannelKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"main" | "0" => Ok(Self::Main),
"mellow" | "1" => Ok(Self::Mellow),
"rock" | "2" => Ok(Self::Rock),
"eclectic" | "3" => Ok(Self::Eclectic),
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
}
}
}
/// Metadata descriptor for a channel.
#[derive(Debug, Clone, Copy)]
pub struct ChannelDescriptor {
pub kind: ParadiseChannelKind,
pub id: u8,
pub slug: &'static str,
pub display_name: &'static str,
pub description: &'static str,
}
impl ChannelDescriptor {
pub const fn new(kind: ParadiseChannelKind) -> Self {
Self {
id: kind.id(),
slug: kind.slug(),
display_name: kind.display_name(),
description: kind.description(),
kind,
}
}
}
/// All available Radio Paradise channels
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
ChannelDescriptor::new(ParadiseChannelKind::Main),
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
ChannelDescriptor::new(ParadiseChannelKind::Rock),
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
];
/// Returns the maximum valid channel ID
pub const fn max_channel_id() -> u8 {
(ALL_CHANNELS.len() - 1) as u8
}
/// Default maximum number of tracks to keep in history
///
/// This is used as the default if not configured via pmoconfig.
/// Value: 100 tracks - represents ~5-8 hours of playback history
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_channel_ids() {
assert_eq!(ParadiseChannelKind::Main.id(), 0);
assert_eq!(ParadiseChannelKind::Mellow.id(), 1);
assert_eq!(ParadiseChannelKind::Rock.id(), 2);
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3);
}
#[test]
fn test_max_channel_id() {
assert_eq!(max_channel_id(), 3);
}
#[test]
fn test_all_channels_length() {
assert_eq!(ALL_CHANNELS.len(), 4);
}
#[test]
fn test_channel_from_str() {
assert!(matches!(
"main".parse::<ParadiseChannelKind>(),
Ok(ParadiseChannelKind::Main)
));
assert!(matches!(
"0".parse::<ParadiseChannelKind>(),
Ok(ParadiseChannelKind::Main)
));
assert!("invalid".parse::<ParadiseChannelKind>().is_err());
}
}

View File

@@ -9,8 +9,8 @@ use url::Url;
/// Default Radio Paradise API base URL
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
/// Default block base URL pattern
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0";
/// Default block base URL (channel is appended)
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan";
/// Default image base URL
pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/";
@@ -24,6 +24,9 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180;
/// Default User-Agent
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
/// Default channel (0 = main mix)
pub const DEFAULT_CHANNEL: u8 = 0;
/// Radio Paradise HTTP client
///
/// This client provides access to Radio Paradise's streaming API,
@@ -48,7 +51,6 @@ pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
pub struct RadioParadiseClient {
pub(crate) client: Client,
api_base: String,
block_base: String,
channel: u8,
pub(crate) request_timeout: Duration,
pub(crate) block_timeout: Duration,
@@ -71,12 +73,14 @@ impl RadioParadiseClient {
/// Create a client with a custom reqwest::Client
///
/// Useful for sharing HTTP connection pools or custom proxy settings
///
/// Note: Uses default settings (channel 0, default timeouts).
/// For more control, use `ClientBuilder::default().client(client).build()`.
pub fn with_client(client: Client) -> Self {
Self {
client,
api_base: DEFAULT_API_BASE.to_string(),
block_base: DEFAULT_BLOCK_BASE.to_string(),
channel: 0,
channel: DEFAULT_CHANNEL,
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
next_block_url: None,
@@ -88,15 +92,15 @@ impl RadioParadiseClient {
self.channel
}
fn block_base_for_channel(channel: u8) -> String {
format!("https://apps.radioparadise.com/blocks/chan/{}", channel)
/// Get the block base URL for this client's channel
pub fn block_base(&self) -> String {
format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel)
}
/// Clone the client with a different channel while preserving other settings.
pub fn clone_with_channel(&self, channel: u8) -> Self {
let mut cloned = self.clone();
cloned.channel = channel;
cloned.block_base = Self::block_base_for_channel(channel);
cloned.next_block_url = None;
cloned
}
@@ -234,7 +238,6 @@ impl RadioParadiseClient {
pub struct ClientBuilder {
client: Option<Client>,
api_base: String,
block_base: String,
channel: u8,
request_timeout: Duration,
block_timeout: Duration,
@@ -247,8 +250,7 @@ impl Default for ClientBuilder {
Self {
client: None,
api_base: DEFAULT_API_BASE.to_string(),
block_base: DEFAULT_BLOCK_BASE.to_string(),
channel: 0,
channel: DEFAULT_CHANNEL,
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
user_agent: DEFAULT_USER_AGENT.to_string(),
@@ -275,12 +277,6 @@ impl ClientBuilder {
self
}
/// Set the block base URL
pub fn block_base(mut self, url: impl Into<String>) -> Self {
self.block_base = url.into();
self
}
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
pub fn channel(mut self, channel: u8) -> Self {
self.channel = channel;
@@ -329,16 +325,9 @@ impl ClientBuilder {
builder.build()?
};
let block_base = if self.block_base == DEFAULT_BLOCK_BASE {
RadioParadiseClient::block_base_for_channel(self.channel)
} else {
self.block_base.clone()
};
Ok(RadioParadiseClient {
client,
api_base: self.api_base,
block_base,
channel: self.channel,
request_timeout: self.request_timeout,
block_timeout: self.block_timeout,
@@ -355,6 +344,6 @@ mod tests {
fn test_builder_defaults() {
let builder = ClientBuilder::default();
assert_eq!(builder.api_base, DEFAULT_API_BASE);
assert_eq!(builder.channel, 0);
assert_eq!(builder.channel, DEFAULT_CHANNEL);
}
}

View File

@@ -5,11 +5,6 @@
//!
//! La configuration est minimale - seulement ce qui doit vraiment être configurable :
//! - Activation/désactivation de la source
//! - Chemin de la base de données d'historique
//! - Taille maximale de l'historique
//!
//! Tous les autres paramètres (polling, timeouts, etc.) sont des constantes
//! définies dans `paradise::constants`.
//!
//! # Exemple
//!
@@ -24,24 +19,12 @@
//! println!("Radio Paradise is disabled");
//! return Ok(());
//! }
//!
//! // Get configuration
//! let db_path = config.get_paradise_history_database()?;
//! let max_tracks = config.get_paradise_history_size()?;
//! ```
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::{Number, Value};
use crate::paradise::constants;
/// Nom du répertoire pour Radio Paradise (relatif au config_dir)
///
/// La base de données sera stockée dans `<config_dir>/paradise/history.db`
const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
use serde_yaml::Value;
/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig
///
@@ -50,7 +33,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
///
/// # Auto-persist des valeurs par défaut
///
/// Tous les getters persistent automatiquement la valeur par défaut dans la
/// Le getter persiste automatiquement la valeur par défaut dans la
/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de
/// voir la configuration effective dans le fichier YAML et de la modifier facilement.
///
@@ -65,10 +48,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
/// // Premier appel : persiste "enabled: true" dans la config et retourne true
/// let enabled = config.get_paradise_enabled()?;
///
/// // Premier appel : persiste "max_tracks: 100" dans la config et retourne 100
/// let max_tracks = config.get_paradise_history_size()?;
///
/// // L'utilisateur peut maintenant éditer ces valeurs dans le fichier YAML
/// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML
/// ```
pub trait RadioParadiseConfigExt {
/// Vérifie si Radio Paradise est activé
@@ -103,70 +83,59 @@ pub trait RadioParadiseConfigExt {
/// ```
fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
/// Récupère le chemin de la base de données d'historique
///
/// Le chemin retourné est absolu et pointe vers `<config_dir>/paradise/history.db`.
/// Le répertoire `paradise` est créé automatiquement s'il n'existe pas.
/// Récupère le channel par défaut
///
/// # Returns
///
/// Le chemin absolu vers la base de données SQLite d'historique.
/// Exemple: `/home/user/.config/pmo/paradise/history.db`
///
/// # Exemple
///
/// ```rust,ignore
/// let db_path = config.get_paradise_history_database()?;
/// let backend = SqliteHistoryBackend::new(&db_path)?;
/// ```
fn get_paradise_history_database(&self) -> Result<String>;
/// Définit le chemin de la base de données d'historique
///
/// # Arguments
///
/// * `path` - Chemin complet vers la base de données (doit inclure le nom du fichier)
///
/// Le répertoire parent sera extrait et stocké dans la configuration.
///
/// # Exemple
///
/// ```rust,ignore
/// // Set custom path
/// config.set_paradise_history_database("/var/lib/pmo/paradise.db".to_string())?;
/// ```
fn set_paradise_history_database(&self, path: String) -> Result<()>;
/// Récupère le nombre maximal de pistes dans l'historique
///
/// # Returns
///
/// Le nombre maximal de pistes à conserver dans l'historique.
/// Le channel par défaut (0 = Main Mix par défaut).
///
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
/// définie à la constante `HISTORY_DEFAULT_MAX_TRACKS` (100) et persistée.
/// définie à "main" et persistée.
///
/// # Exemple
/// # Channels disponibles
///
/// Peut être configuré comme chaîne de caractères ou nombre :
/// - "main" ou 0 = Main Mix (eclectic, diverse mix)
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music)
/// - "rock" ou 2 = Rock Mix (classic & modern rock)
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
///
/// # Exemple de configuration YAML
///
/// ```yaml
/// sources:
/// radio_paradise:
/// default_channel: mellow # or 1
/// ```
///
/// # Exemple d'utilisation
///
/// ```rust,ignore
/// let max_tracks = config.get_paradise_history_size()?;
/// println!("Keeping last {} tracks", max_tracks);
/// let channel = config.get_paradise_default_channel()?;
/// let client = RadioParadiseClient::builder().channel(channel).build().await?;
/// ```
fn get_paradise_history_size(&self) -> Result<usize>;
fn get_paradise_default_channel(&self) -> Result<u8>;
/// Définit le nombre maximal de pistes dans l'historique
/// Définit le channel par défaut
///
/// # Arguments
///
/// * `size` - Nombre maximal de pistes à conserver
/// * `channel` - Le channel (0-3)
///
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
/// dans le fichier de configuration.
///
/// # Exemple
///
/// ```rust,ignore
/// // Keep last 200 tracks
/// config.set_paradise_history_size(200)?;
/// use pmoparadise::channels::ParadiseChannelKind;
///
/// // Use Mellow Mix by default
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
/// // Or simply:
/// config.set_paradise_default_channel(1)?;
/// ```
fn set_paradise_history_size(&self, size: usize) -> Result<()>;
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
}
impl RadioParadiseConfigExt for Config {
@@ -188,52 +157,59 @@ impl RadioParadiseConfigExt for Config {
)
}
fn get_paradise_history_database(&self) -> Result<String> {
// Get managed directory: ~/.config/pmo/paradise/
let dir = self.get_managed_dir(
&["sources", "radio_paradise", "database"],
DEFAULT_HISTORY_DATABASE_DIR,
)?;
// Ensure directory exists
std::fs::create_dir_all(&dir)?;
// Build full path: ~/.config/pmo/paradise/history.db
let mut path = PathBuf::from(dir);
path.push("history.db");
Ok(path.to_string_lossy().to_string())
}
fn set_paradise_history_database(&self, path: String) -> Result<()> {
// Extract parent directory from the full path
match PathBuf::from(&path).parent() {
Some(dir) => self.set_managed_dir(
&["sources", "radio_paradise", "database"],
dir.to_string_lossy().to_string(),
),
None => Err(anyhow!("Invalid database path: no parent directory")),
}
}
fn get_paradise_history_size(&self) -> Result<usize> {
match self.get_value(&["sources", "radio_paradise", "history", "max_tracks"]) {
Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
fn get_paradise_default_channel(&self) -> Result<u8> {
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
Ok(Value::String(s)) => {
// Try to parse as channel name (e.g., "main", "mellow", etc.)
match s.parse::<ParadiseChannelKind>() {
Ok(kind) => Ok(kind.id()),
Err(_) => {
// Invalid channel name, use default
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
Ok(DEFAULT_CHANNEL)
}
}
}
Ok(Value::Number(n)) => {
// Accept numeric channel ID (0-3)
if let Some(ch) = n.as_u64() {
if ch <= 3 {
Ok(ch as u8)
} else {
// Invalid channel number, use default
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
Ok(DEFAULT_CHANNEL)
}
} else {
// Not a valid number, use default
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
Ok(DEFAULT_CHANNEL)
}
}
_ => {
// Use default and persist it
let default = constants::HISTORY_DEFAULT_MAX_TRACKS;
self.set_paradise_history_size(default)?;
Ok(default)
// Use default and persist it as "main" (user-friendly)
self.set_value(
&["sources", "radio_paradise", "default_channel"],
Value::String("main".to_string()),
)?;
Ok(DEFAULT_CHANNEL)
}
}
}
fn set_paradise_history_size(&self, size: usize) -> Result<()> {
let n = Number::from(size);
fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
// Convert channel ID to user-friendly string name
let channel_name = match channel {
0 => "main",
1 => "mellow",
2 => "rock",
3 => "eclectic",
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
};
self.set_value(
&["sources", "radio_paradise", "history", "max_tracks"],
Value::Number(n),
&["sources", "radio_paradise", "default_channel"],
Value::String(channel_name.to_string()),
)
}
}
@@ -243,21 +219,7 @@ mod tests {
use super::*;
#[test]
fn test_default_values() {
assert_eq!(DEFAULT_HISTORY_DATABASE_DIR, "paradise");
assert_eq!(constants::HISTORY_DEFAULT_MAX_TRACKS, 100);
}
#[test]
fn test_database_path_construction() {
// Simulating path construction
let base = "/home/user/.config/pmo/paradise";
let mut path = PathBuf::from(base);
path.push("history.db");
assert_eq!(
path.to_string_lossy(),
"/home/user/.config/pmo/paradise/history.db"
);
fn test_trait_exists() {
// Simple test to ensure the trait compiles
}
}

View File

@@ -34,16 +34,6 @@ pub enum Error {
#[error("Invalid event ID: {0}")]
InvalidEvent(String),
/// FLAC decoding error (per-track feature)
#[cfg(feature = "per-track")]
#[error("FLAC decoding error: {0}")]
FlacDecode(String),
/// WAV encoding error (per-track feature)
#[cfg(feature = "per-track")]
#[error("WAV encoding error: {0}")]
WavEncode(#[from] hound::Error),
/// Track not found in block
#[error("Track not found at index {0}")]
TrackNotFound(usize),
@@ -67,11 +57,3 @@ impl Error {
Self::Other(msg.into())
}
}
// Implement conversion from claxon errors for per-track feature
#[cfg(feature = "per-track")]
impl From<claxon::Error> for Error {
fn from(err: claxon::Error) -> Self {
Error::FlacDecode(err.to_string())
}
}

View File

@@ -1,172 +0,0 @@
//! FFmpeg-based progressive streaming decoder/encoder
//!
//! This module provides progressive audio streaming using FFmpeg,
//! allowing for much lower latency than the claxon/flacenc approach.
//!
//! Key advantages:
//! - Start streaming immediately (< 1 second latency)
//! - Progressive decoding and encoding in a pipeline
//! - Better performance (C code vs Rust)
//! - Support for multiple output formats
use anyhow::{anyhow, Context, Result};
use bytes::Bytes;
use ffmpeg_next as ffmpeg;
use std::io::{Read, Write};
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
use tokio::task;
use tracing::{debug, error, trace};
/// Initialize FFmpeg (must be called once at startup)
pub fn init() -> Result<()> {
ffmpeg::init().context("Failed to initialize FFmpeg")?;
Ok(())
}
/// PCM chunk with decoded audio data
#[derive(Debug, Clone)]
pub struct PCMChunk {
pub samples: Vec<i16>, // Interleaved 16-bit samples
pub sample_rate: u32,
pub channels: u32,
pub position_ms: u64,
}
/// Progressive decoder that decodes FLAC data as it arrives
pub struct ProgressiveDecoder {
input_rx: Receiver<Result<Bytes, String>>,
buffer: Vec<u8>,
decoder_ctx: Option<ffmpeg::codec::context::Context>,
sample_rate: u32,
channels: u32,
total_samples_decoded: u64,
}
impl ProgressiveDecoder {
/// Create a new progressive decoder from a byte stream
pub fn new(mut stream: impl Read + Send + 'static) -> Result<Self> {
let (tx, rx) = sync_channel(64);
// Spawn a thread to read from the stream and feed chunks
std::thread::spawn(move || {
let mut buffer = vec![0u8; 8192];
loop {
match stream.read(&mut buffer) {
Ok(0) => break, // EOF
Ok(n) => {
let chunk = Bytes::copy_from_slice(&buffer[..n]);
if tx.send(Ok(chunk)).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(Err(e.to_string()));
break;
}
}
}
});
Ok(Self {
input_rx: rx,
buffer: Vec::with_capacity(65536),
decoder_ctx: None,
sample_rate: 0,
channels: 0,
total_samples_decoded: 0,
})
}
/// Decode the next chunk of PCM data
pub fn decode_chunk(&mut self) -> Result<Option<PCMChunk>> {
// Receive more data from the stream
while self.buffer.len() < 4096 {
match self.input_rx.try_recv() {
Ok(Ok(bytes)) => {
self.buffer.extend_from_slice(&bytes);
}
Ok(Err(e)) => {
return Err(anyhow!("Stream error: {}", e));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// No more data available right now
break;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// Stream ended
if self.buffer.is_empty() {
return Ok(None);
}
break;
}
}
}
if self.buffer.is_empty() {
return Ok(None);
}
// Initialize decoder on first call
if self.decoder_ctx.is_none() {
self.init_decoder()?;
}
// Decode a frame
// TODO: Implement actual FFmpeg decoding
// For now, return a placeholder
Ok(None)
}
fn init_decoder(&mut self) -> Result<()> {
// TODO: Initialize FFmpeg decoder from buffer
// Parse FLAC header, create decoder context
Ok(())
}
}
/// Progressive encoder that encodes PCM to FLAC as data arrives
pub struct ProgressiveEncoder {
output_tx: SyncSender<Bytes>,
encoder_ctx: Option<ffmpeg::codec::context::Context>,
sample_rate: u32,
channels: u32,
}
impl ProgressiveEncoder {
/// Create a new progressive encoder
pub fn new(sample_rate: u32, channels: u32) -> Result<(Self, Receiver<Bytes>)> {
let (tx, rx) = sync_channel(64);
let encoder = Self {
output_tx: tx,
encoder_ctx: None,
sample_rate,
channels,
};
Ok((encoder, rx))
}
/// Encode a chunk of PCM data
pub fn encode_chunk(&mut self, pcm: &PCMChunk) -> Result<()> {
// TODO: Implement FFmpeg encoding
Ok(())
}
/// Flush any remaining encoded data
pub fn flush(&mut self) -> Result<()> {
// TODO: Flush encoder
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ffmpeg_init() {
assert!(init().is_ok());
}
}

View File

@@ -164,73 +164,57 @@
//! }
//! ```
//!
//! ## Caching Support (Feature: `cache`)
//! ## Audio Streaming (Feature: `pmoaudio`)
//!
//! `pmoparadise` can optionally integrate with `pmocovers` and `pmoaudiocache` to cache
//! cover images and audio tracks locally:
//! For direct audio streaming and integration with pmoaudio pipelines,
//! use `RadioParadiseStreamSource`:
//!
//! ```no_run
//! # #[cfg(feature = "cache")]
//! # #[cfg(feature = "pmoaudio")]
//! # {
//! use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
//! use std::sync::Arc;
//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
//! use pmoaudio::pipeline::Node;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! // Create caches
//! let cover_cache = Arc::new(pmocovers::cache::new_cache("./cache/covers", 500)?);
//! let audio_cache = Arc::new(pmoaudiocache::cache::new_cache("./cache/audio", 100)?);
//!
//! // Create client and source with caching
//! let client = RadioParadiseClient::new().await?;
//! let source = RadioParadiseSource::new(
//! client,
//! 50,
//! cover_cache,
//! audio_cache,
//! );
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
//!
//! println!("Source ready: {}", source.name());
//! // Create audio node from stream source
//! let node = Node::from_logic(stream_source);
//!
//! // Use in pmoaudio pipeline...
//!
//! Ok(())
//! }
//! # }
//! ```
//!
//! **Benefits**:
//! - Cover images are automatically downloaded and converted to WebP
//! - Audio tracks are cached as FLAC with metadata preserved
//! - Subsequent access is instant (no re-download)
//! - URIs returned by `resolve_uri()` point to cached versions
//!
//! See the `with_cache` example for a complete demonstration.
//! **RadioParadiseStreamSource**:
//! - Downloads and decodes FLAC blocks in real-time
//! - Automatically detects bit depth (16/24/32-bit)
//! - Inserts track boundaries with metadata
//! - Integrates seamlessly with pmoaudio pipelines
//!
//! ## Cargo Features
//!
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
//! - `default`: Standard metadata and streaming (no FLAC decoding)
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
//! - `server`: Enable server-side features (cache registry integration)
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`)
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
//! - `pmoconfig`: Enable configuration integration with pmoconfig
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
//!
//! ## See Also
//!
//! - [Radio Paradise](https://radioparadise.com) - Official website
//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation
pub mod channels;
pub mod client;
pub mod error;
pub mod models;
pub mod paradise;
pub mod source;
pub mod stream;
pub mod streaming;
#[cfg(feature = "per-track")]
pub mod track;
#[cfg(feature = "ffmpeg")]
pub mod ffmpeg_streaming;
#[cfg(feature = "pmoserver")]
pub mod pmoserver_ext;
@@ -238,15 +222,17 @@ pub mod pmoserver_ext;
#[cfg(feature = "pmoconfig")]
pub mod config_ext;
#[cfg(feature = "pmoaudio")]
pub mod radio_paradise_stream_source;
// Re-exports for convenience
pub use client::{ClientBuilder, RadioParadiseClient};
pub use error::{Error, Result};
pub use models::{Block, DurationMs, EventId, NowPlaying, Song};
pub use source::RadioParadiseSource;
pub use stream::BlockStream;
#[cfg(feature = "per-track")]
pub use track::{TrackMetadata, TrackStream};
#[cfg(feature = "pmoaudio")]
pub use radio_paradise_stream_source::RadioParadiseStreamSource;
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::{

View File

@@ -1,428 +0,0 @@
//! Channel orchestration primitives.
//!
//! This module wires together configuration, playlists, workers and client
//! tracking for a single Radio Paradise channel. The implementation is still
//! a scaffolding of the final behaviour; commands sent to the worker are
//! logged but not yet executing the full download/buffering pipeline.
use super::history::HistoryBackend;
use super::playlist::{PlaylistEntry, SharedPlaylist};
use super::worker::{ParadiseWorker, WorkerCommand};
use crate::client::RadioParadiseClient;
use anyhow::{Context, Result};
use async_stream::try_stream;
use bytes::Bytes;
use futures::{stream::BoxStream, StreamExt};
use pmosource::SourceCacheManager;
use std::fmt;
use std::str::FromStr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::fs::File;
use tokio::sync::{mpsc, Mutex};
use tokio_util::io::ReaderStream;
use tracing::warn;
/// Logical identifier for a Radio Paradise channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParadiseChannelKind {
Main,
Mellow,
Rock,
Eclectic,
}
impl ParadiseChannelKind {
pub const fn id(self) -> u8 {
match self {
Self::Main => 0,
Self::Mellow => 1,
Self::Rock => 2,
Self::Eclectic => 3,
}
}
pub const fn slug(self) -> &'static str {
match self {
Self::Main => "main",
Self::Mellow => "mellow",
Self::Rock => "rock",
Self::Eclectic => "eclectic",
}
}
pub const fn display_name(self) -> &'static str {
match self {
Self::Main => "Main Mix",
Self::Mellow => "Mellow Mix",
Self::Rock => "Rock Mix",
Self::Eclectic => "Eclectic Mix",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::Main => "Eclectic mix of rock, world, electronica, and more",
Self::Mellow => "Mellower, less aggressive music",
Self::Rock => "Heavier, more guitar-driven music",
Self::Eclectic => "Curated worldwide selection",
}
}
}
impl FromStr for ParadiseChannelKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"main" | "0" => Ok(Self::Main),
"mellow" | "1" => Ok(Self::Mellow),
"rock" | "2" => Ok(Self::Rock),
"eclectic" | "3" => Ok(Self::Eclectic),
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
}
}
}
/// Metadata descriptor for a channel.
#[derive(Debug, Clone, Copy)]
pub struct ChannelDescriptor {
pub kind: ParadiseChannelKind,
pub id: u8,
pub slug: &'static str,
pub display_name: &'static str,
pub description: &'static str,
}
impl ChannelDescriptor {
pub const fn new(kind: ParadiseChannelKind) -> Self {
Self {
id: kind.id(),
slug: kind.slug(),
display_name: kind.display_name(),
description: kind.description(),
kind,
}
}
}
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
ChannelDescriptor::new(ParadiseChannelKind::Main),
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
ChannelDescriptor::new(ParadiseChannelKind::Rock),
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
];
/// Returns the maximum valid channel ID
pub const fn max_channel_id() -> u8 {
(ALL_CHANNELS.len() - 1) as u8
}
/// Public handle to interact with a channel.
#[derive(Clone)]
pub struct ParadiseChannel {
inner: Arc<ParadiseChannelInner>,
}
struct ParadiseChannelInner {
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
history_max_tracks: usize,
playlist: SharedPlaylist,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
active_clients: AtomicUsize,
worker_tx: mpsc::Sender<WorkerCommand>,
worker: Mutex<Option<ParadiseWorker>>,
}
impl fmt::Debug for ParadiseChannel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ParadiseChannel")
.field("slug", &self.inner.descriptor.slug)
.field(
"active_clients",
&self.inner.active_clients.load(Ordering::SeqCst),
)
.finish()
}
}
impl ParadiseChannel {
#[allow(clippy::too_many_arguments)]
pub fn new(
descriptor: ChannelDescriptor,
base_client: RadioParadiseClient,
history_max_tracks: usize,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
) -> Result<Self> {
let client = base_client.clone_with_channel(descriptor.id);
let playlist = SharedPlaylist::new(history_max_tracks);
let (worker, worker_tx) = ParadiseWorker::spawn(
descriptor,
client.clone(),
history_max_tracks,
playlist.clone(),
history.clone(),
cache_manager.clone(),
);
Ok(Self {
inner: Arc::new(ParadiseChannelInner {
descriptor,
client,
history_max_tracks,
playlist,
history,
cache_manager,
active_clients: AtomicUsize::new(0),
worker_tx,
worker: Mutex::new(Some(worker)),
}),
})
}
pub fn descriptor(&self) -> ChannelDescriptor {
self.inner.descriptor
}
pub fn playlist(&self) -> &SharedPlaylist {
&self.inner.playlist
}
pub fn history_max_tracks(&self) -> usize {
self.inner.history_max_tracks
}
pub fn history_backend(&self) -> &Arc<dyn HistoryBackend> {
&self.inner.history
}
pub fn cache_manager(&self) -> Arc<SourceCacheManager> {
self.inner.cache_manager.clone()
}
pub fn client(&self) -> &RadioParadiseClient {
&self.inner.client
}
pub fn active_client_count(&self) -> usize {
self.inner.active_clients.load(Ordering::SeqCst)
}
pub async fn connect_client(
&self,
client_id: impl Into<String>,
) -> Result<ParadiseClientStream> {
let client_id = client_id.into();
self.inner.active_clients.fetch_add(1, Ordering::SeqCst);
if let Err(err) = self
.inner
.worker_tx
.send(WorkerCommand::ClientConnected {
client_id: client_id.clone(),
})
.await
{
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
return Err(anyhow::anyhow!("worker unavailable: {}", err));
}
self.inner.playlist.increment_all_pending().await;
self.ensure_started().await?;
Ok(ParadiseClientStream::new(self.clone(), client_id))
}
pub async fn disconnect_client(&self, client_id: impl Into<String>) -> Result<()> {
let client_id = client_id.into();
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
self.inner
.worker_tx
.send(WorkerCommand::ClientDisconnected { client_id })
.await
.context("failed to notify worker of client disconnection")?;
Ok(())
}
pub async fn ensure_started(&self) -> Result<()> {
self.inner
.worker_tx
.send(WorkerCommand::EnsureReady)
.await
.context("failed to schedule worker warmup")
}
pub async fn shutdown(&self) -> Result<()> {
self.inner
.worker_tx
.send(WorkerCommand::Shutdown)
.await
.ok();
let mut guard = self.inner.worker.lock().await;
if let Some(worker) = guard.take() {
worker
.wait()
.await
.context("failed to join worker task")
.map(|_| ())
} else {
Ok(())
}
}
pub async fn mark_track_completed(&self, track: &Arc<PlaylistEntry>) {
let remaining = track.decrement_clients();
if remaining > 0 {
return;
}
if let Some(removed) = self
.inner
.playlist
.pop_front_matching(&track.track_id)
.await
{
if let Err(err) = self.inner.history.append(removed.as_history_entry()).await {
warn!(
channel = self.inner.descriptor.slug,
"Failed to persist history entry: {err:?}"
);
}
if let Err(err) = self
.inner
.history
.truncate(self.inner.history_max_tracks)
.await
{
warn!(
channel = self.inner.descriptor.slug,
"Failed to truncate history: {err:?}"
);
}
let history_entry = removed.as_history_entry();
self.inner.playlist.push_history_entry(history_entry).await;
}
}
}
/// Placeholder stream handle for per-client playback.
#[derive(Debug, Clone)]
pub struct ParadiseClientStream {
channel: ParadiseChannel,
client_id: String,
}
impl ParadiseClientStream {
fn new(channel: ParadiseChannel, client_id: String) -> Self {
Self { channel, client_id }
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn channel(&self) -> ParadiseChannel {
self.channel.clone()
}
pub fn into_byte_stream(self) -> BoxStream<'static, Result<Bytes, anyhow::Error>> {
let channel = self.channel.clone();
let client_id = self.client_id.clone();
let stream = try_stream! {
tracing::info!(
channel = channel.descriptor().slug,
client_id = %client_id,
"🎧 Client connecting to stream"
);
channel.ensure_started().await?;
let mut last_track_id: Option<String> = None;
loop {
let entries = channel.playlist().active_snapshot().await;
// Find the next track after last_track_id
let next_entry = if let Some(ref last_id) = last_track_id {
// Find the position of the last track we read
let last_pos = entries.iter().position(|e| e.track_id == *last_id);
// Get the next track (or wait if none available)
match last_pos {
Some(pos) if pos + 1 < entries.len() => {
Some(entries[pos + 1].clone())
}
_ => {
// Last track not found (was removed) or no next track available
// Wait for more tracks to be added
channel.ensure_started().await?;
let current_len = entries.len();
channel.playlist().wait_for_track_count(current_len).await;
continue;
}
}
} else {
// First track for this client
if entries.is_empty() {
channel.ensure_started().await?;
channel.playlist().wait_for_track_count(0).await;
continue;
}
Some(entries[0].clone())
};
let entry = next_entry.unwrap();
last_track_id = Some(entry.track_id.clone());
let audio_pk = entry
.audio_pk
.clone()
.ok_or_else(|| anyhow::anyhow!("Audio not cached yet"))?;
channel
.cache_manager()
.wait_audio_ready(&audio_pk)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let file_path = if let Some(path) = entry.file_path.clone() {
path
} else {
channel
.cache_manager()
.audio_file_path(&audio_pk)
.await
.ok_or_else(|| anyhow::anyhow!("Audio file path unavailable"))?
};
let file = File::open(&file_path).await?;
let mut reader = ReaderStream::new(file);
while let Some(chunk) = reader.next().await {
let bytes = chunk?;
yield bytes;
}
channel.mark_track_completed(&entry).await;
}
};
stream.boxed()
}
}
impl Drop for ParadiseClientStream {
fn drop(&mut self) {
let channel = self.channel.clone();
let client_id = self.client_id.clone();
let slug = channel.descriptor().slug;
tokio::spawn(async move {
if let Err(err) = channel.disconnect_client(client_id).await {
warn!(channel = slug, "Failed to disconnect client: {err:?}");
}
});
}
}

View File

@@ -1,208 +0,0 @@
//! Constants for Radio Paradise orchestration layer.
//!
//! This module defines all the hardcoded parameters for the Radio Paradise
//! integration. These values are based on empirical testing and Radio Paradise's
//! infrastructure characteristics.
use std::time::Duration;
// ============================================================================
// Activity Lifecycle
// ============================================================================
/// Cooling timeout after all clients disconnect (seconds)
///
/// After the last client disconnects, the channel enters a "cooling" state
/// where it remains active for this duration before shutting down completely.
/// This avoids rapid start/stop cycles if clients reconnect quickly.
///
/// Value: 180 seconds (3 minutes) - good balance between responsiveness and stability
pub const COOLING_TIMEOUT_SECONDS: u64 = 180;
// ============================================================================
// Polling Intervals
// ============================================================================
/// High buffer polling interval (seconds)
///
/// When the playlist buffer has 3+ blocks, poll less frequently to reduce
/// API load and network usage.
///
/// Value: 120 seconds (2 minutes)
pub const POLLING_INTERVAL_HIGH_BUFFER: u64 = 120;
/// Medium buffer polling interval (seconds)
///
/// When the playlist buffer has 2 blocks, poll at moderate frequency.
///
/// Value: 60 seconds (1 minute)
pub const POLLING_INTERVAL_MEDIUM_BUFFER: u64 = 60;
/// Low buffer polling interval (seconds)
///
/// When the playlist buffer has less than 2 blocks, poll frequently to
/// ensure continuous playback.
///
/// Value: 20 seconds
pub const POLLING_INTERVAL_LOW_BUFFER: u64 = 20;
/// Helper to get high buffer polling interval as Duration
pub fn polling_high_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_HIGH_BUFFER)
}
/// Helper to get medium buffer polling interval as Duration
pub fn polling_medium_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_MEDIUM_BUFFER)
}
/// Helper to get low buffer polling interval as Duration
pub fn polling_low_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_LOW_BUFFER)
}
// ============================================================================
// Polling Backoff (on API errors)
// ============================================================================
/// Initial backoff delay on API error (seconds)
///
/// When an API request fails, we wait this duration before retrying.
///
/// Value: 20 seconds
pub const BACKOFF_INITIAL_SECONDS: u64 = 20;
/// Maximum backoff delay (seconds)
///
/// Backoff is capped at this value to avoid waiting too long.
///
/// Value: 300 seconds (5 minutes)
pub const BACKOFF_MAX_SECONDS: u64 = 300;
/// Backoff multiplier
///
/// After each failure, the delay is multiplied by this factor.
/// Example: 20s → 40s → 80s → 160s → 300s (capped)
///
/// Value: 2.0 (exponential backoff)
pub const BACKOFF_MULTIPLIER: f32 = 2.0;
// ============================================================================
// Cache Tuning
// ============================================================================
/// Maximum number of blocks to remember in the worker
///
/// This prevents unbounded memory growth by limiting how many block event IDs
/// we track to avoid re-processing.
///
/// Calculation: (4 channels + 1 buffer) × 3 blocks per channel = 15 blocks
/// Each block is ~20 minutes of audio, so 15 blocks ≈ 5 hours of history
///
/// Value: 15 blocks
pub const MAX_BLOCKS_REMEMBERED: usize = 15;
/// Number of bytes to use for track ID hashing
///
/// Track IDs are constructed by hashing block content and track position.
/// This value defines how much of the FLAC data we read for hashing.
///
/// Value: 512 bytes - sufficient for unique identification without excessive I/O
pub const TRACK_ID_HASH_BYTES: usize = 512;
// ============================================================================
// History
// ============================================================================
/// Default maximum number of tracks to keep in history
///
/// This is used as the default if not configured via pmoconfig.
/// Users can override this value in their configuration.
///
/// Value: 100 tracks - represents ~5-8 hours of playback history
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
// ============================================================================
// Streaming
// ============================================================================
/// Stream buffer size (bytes)
///
/// Buffer size for audio streaming. 64KB provides good balance between
/// latency and buffering efficiency.
///
/// Value: 64 KB
pub const STREAM_BUFFER_SIZE_BYTES: usize = 64 * 1024;
/// Enable gapless playback
///
/// Radio Paradise blocks are designed for gapless playback - each block
/// transitions seamlessly to the next without audio gaps.
///
/// Value: true (always enabled)
pub const STREAM_GAPLESS: bool = true;
// Note: Metadata format is always ICY (Icecast/SHOUTcast metadata)
// No enum or constant needed as it's the only supported format
// ============================================================================
// API Configuration
// ============================================================================
/// Radio Paradise API base URL
///
/// Base URL for all Radio Paradise API requests.
/// This is hardcoded as Radio Paradise's API endpoint doesn't change.
///
/// Value: https://api.radioparadise.com
pub const API_BASE_URL: &str = "https://api.radioparadise.com";
/// API request timeout (seconds)
///
/// Maximum time to wait for an API response before considering it failed.
///
/// Value: 30 seconds
pub const API_TIMEOUT_SECONDS: u64 = 30;
/// User agent for API requests
///
/// Identifies PMOMusic in HTTP requests to Radio Paradise's servers.
///
/// Value: PMO-RadioParadise/1.0
pub const API_USER_AGENT: &str = "PMO-RadioParadise/1.0";
/// Helper to get API timeout as Duration
pub fn api_timeout() -> Duration {
Duration::from_secs(API_TIMEOUT_SECONDS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duration_helpers() {
assert_eq!(polling_high_interval(), Duration::from_secs(120));
assert_eq!(polling_medium_interval(), Duration::from_secs(60));
assert_eq!(polling_low_interval(), Duration::from_secs(20));
assert_eq!(api_timeout(), Duration::from_secs(30));
}
#[test]
fn test_constants_sanity() {
// Polling intervals should be ordered
assert!(POLLING_INTERVAL_LOW_BUFFER < POLLING_INTERVAL_MEDIUM_BUFFER);
assert!(POLLING_INTERVAL_MEDIUM_BUFFER < POLLING_INTERVAL_HIGH_BUFFER);
// Backoff should be reasonable
assert!(BACKOFF_INITIAL_SECONDS < BACKOFF_MAX_SECONDS);
assert!(BACKOFF_MULTIPLIER > 1.0);
// Cache limits should be positive
assert!(MAX_BLOCKS_REMEMBERED > 0);
assert!(TRACK_ID_HASH_BYTES > 0);
// History should be reasonable
assert!(HISTORY_DEFAULT_MAX_TRACKS > 0);
}
}

View File

@@ -1,217 +0,0 @@
//! History persistence for Radio Paradise playback.
//!
//! The worker pushes every completed track into the history backend while
//! keeping the latest entries available for UPnP browsing. We use SQLite
//! for persistent storage with an abstract trait for testability.
use crate::models::Song;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex as StdMutex};
use tokio::task::spawn_blocking;
/// Serializable record describing a played track.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub track_id: String,
pub channel_id: u8,
pub started_at: chrono::DateTime<chrono::Utc>,
pub duration_ms: u64,
pub song: SongSnapshot,
}
/// Minimal snapshot of a Radio Paradise song at playback time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SongSnapshot {
pub title: String,
pub artist: String,
pub album: Option<String>,
pub cover_url: Option<String>,
}
impl SongSnapshot {
pub fn title(&self) -> &str {
&self.title
}
}
impl From<&Song> for SongSnapshot {
fn from(song: &Song) -> Self {
Self {
title: song.title.clone(),
artist: song.artist.clone(),
album: song.album.clone(),
cover_url: song.cover.clone(),
}
}
}
/// Abstract persistence interface.
#[async_trait]
pub trait HistoryBackend: Send + Sync {
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()>;
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>>;
async fn len(&self) -> anyhow::Result<usize>;
async fn truncate(&self, keep: usize) -> anyhow::Result<()>;
}
pub struct SqliteHistoryBackend {
conn: Arc<StdMutex<rusqlite::Connection>>,
}
impl SqliteHistoryBackend {
pub fn new(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = rusqlite::Connection::open(path)?;
conn.pragma_update(None, "journal_mode", &"WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS paradise_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id TEXT NOT NULL,
channel_id INTEGER NOT NULL,
started_at_ms INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
title TEXT,
artist TEXT,
album TEXT,
cover_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_started_at ON paradise_history(started_at_ms);",
)?;
Ok(Self {
conn: Arc::new(StdMutex::new(conn)),
})
}
fn conn(&self) -> Arc<StdMutex<rusqlite::Connection>> {
self.conn.clone()
}
}
#[async_trait]
impl HistoryBackend for SqliteHistoryBackend {
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> {
let conn = self.conn();
spawn_blocking(move || -> anyhow::Result<()> {
let conn = conn.lock().unwrap();
conn.execute(
"INSERT INTO paradise_history (track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
entry.track_id,
entry.channel_id as i64,
entry.started_at.timestamp_millis(),
entry.duration_ms as i64,
entry.song.title,
entry.song.artist,
entry.song.album,
entry.song.cover_url,
],
)?;
Ok(())
})
.await??;
Ok(())
}
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
let conn = self.conn();
let limit = limit as i64;
spawn_blocking(move || -> anyhow::Result<Vec<HistoryEntry>> {
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url
FROM paradise_history
ORDER BY started_at_ms DESC
LIMIT ?1",
)?;
let mut rows = stmt.query([limit])?;
let mut entries = Vec::new();
while let Some(row) = rows.next()? {
let started_at_ms: i64 = row.get(2)?;
let started_at = DateTime::<Utc>::from_timestamp_millis(started_at_ms)
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp in history"))?;
let entry = HistoryEntry {
track_id: row.get(0)?,
channel_id: row.get::<_, i64>(1)? as u8,
started_at,
duration_ms: row.get::<_, i64>(3)? as u64,
song: SongSnapshot {
title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
artist: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
album: row.get(6)?,
cover_url: row.get(7)?,
},
};
entries.push(entry);
}
Ok(entries)
})
.await?
}
async fn len(&self) -> anyhow::Result<usize> {
let conn = self.conn();
let count = spawn_blocking(move || -> anyhow::Result<usize> {
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT COUNT(*) FROM paradise_history")?;
let count: i64 = stmt.query_row([], |row| row.get(0))?;
Ok(count as usize)
})
.await??;
Ok(count)
}
async fn truncate(&self, keep: usize) -> anyhow::Result<()> {
let conn = self.conn();
spawn_blocking(move || -> anyhow::Result<()> {
let conn = conn.lock().unwrap();
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM paradise_history", [], |row| {
row.get(0)
})?;
let keep = keep as i64;
if count <= keep {
return Ok(());
}
let to_remove = count - keep;
conn.execute(
"DELETE FROM paradise_history
WHERE id IN (
SELECT id FROM paradise_history
ORDER BY started_at_ms ASC
LIMIT ?1
)",
rusqlite::params![to_remove],
)?;
Ok(())
})
.await??;
Ok(())
}
}
/// Creates a SQLite history backend with the given database path.
///
/// The database file and parent directories will be created if they don't exist.
///
/// # Arguments
///
/// * `database_path` - Path to the SQLite database file
///
/// # Example
///
/// ```rust,ignore
/// let backend = create_history_backend("/var/lib/pmo/history.db")?;
/// ```
pub fn create_history_backend(database_path: &str) -> anyhow::Result<Arc<dyn HistoryBackend>> {
let backend = SqliteHistoryBackend::new(database_path)?;
Ok(Arc::new(backend))
}

View File

@@ -1,28 +0,0 @@
//! Internal orchestration layer for dynamic Radio Paradise streaming.
//!
//! This module implements the high level structures described in the
//! Radio Paradise functional specification:
//! - `ParadiseChannel`: lifecycle and state machine for a single RP channel.
//! - `ParadiseWorker`: async task responsible for polling/downloading blocks.
//! - `ParadiseClientStream`: per-client audio stream with independent cursor.
//! - Shared caches and history storage hooked into existing PMO components.
//!
//! The implementation is split across several submodules to keep concerns
//! isolated (constants, playlist management, history persistence, etc.).
//! The goal of this scaffolding is to provide a clear, testable surface for
//! the eventual end-to-end integration with the UPnP server and HTTP routes.
mod channel;
pub mod constants;
mod history;
mod playlist;
mod worker;
pub use channel::{
max_channel_id, ChannelDescriptor, ParadiseChannel, ParadiseChannelKind, ParadiseClientStream,
ALL_CHANNELS,
};
pub use constants::*; // Export all constants
pub use history::{create_history_backend, HistoryBackend, HistoryEntry};
pub use playlist::PlaylistEntry;
pub use worker::{load_rp_metadata, ParadiseWorker, RadioParadiseMetadata, WorkerCommand};

View File

@@ -1,293 +0,0 @@
//! Shared playlist structures for Radio Paradise channels.
//!
//! This module keeps track of the active queue and history for a Radio
//! Paradise channel. Each playlist entry knows how many clients still need
//! to consume it before the worker can evict it.
use super::history::{HistoryEntry, SongSnapshot};
use crate::models::Song;
use chrono::{DateTime, Utc};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::{Notify, RwLock};
/// Metadata stored for an active track.
#[derive(Debug)]
pub struct PlaylistEntry {
pub track_id: String,
pub channel_id: u8,
pub song: Arc<Song>,
pub started_at: DateTime<Utc>,
pub duration_ms: u64,
pub audio_pk: Option<String>,
pub file_path: Option<PathBuf>,
pending_clients: AtomicUsize,
}
impl PlaylistEntry {
#[allow(clippy::too_many_arguments)]
pub fn new(
track_id: String,
channel_id: u8,
song: Arc<Song>,
started_at: DateTime<Utc>,
duration_ms: u64,
audio_pk: Option<String>,
file_path: Option<PathBuf>,
pending_clients: usize,
) -> Self {
Self {
track_id,
channel_id,
song,
started_at,
duration_ms,
audio_pk,
file_path,
pending_clients: AtomicUsize::new(pending_clients),
}
}
pub fn as_history_entry(&self) -> HistoryEntry {
HistoryEntry {
track_id: self.track_id.clone(),
channel_id: self.channel_id,
started_at: self.started_at,
duration_ms: self.duration_ms,
song: SongSnapshot::from(self.song.as_ref()),
}
}
pub fn pending_clients(&self) -> usize {
self.pending_clients.load(Ordering::SeqCst)
}
pub fn set_pending_clients(&self, value: usize) {
self.pending_clients.store(value, Ordering::SeqCst);
}
pub fn increment_clients(&self) -> usize {
self.pending_clients.fetch_add(1, Ordering::SeqCst) + 1
}
pub fn decrement_clients(&self) -> usize {
let mut current = self.pending_clients.load(Ordering::SeqCst);
loop {
if current == 0 {
return 0;
}
match self.pending_clients.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return current - 1,
Err(actual) => current = actual,
}
}
}
}
#[derive(Default)]
struct PlaylistState {
active: VecDeque<Arc<PlaylistEntry>>,
history: VecDeque<HistoryEntry>,
max_history: usize,
}
impl PlaylistState {
fn new(max_history: usize) -> Self {
Self {
active: VecDeque::new(),
history: VecDeque::new(),
max_history,
}
}
fn active_len(&self) -> usize {
self.active.len()
}
fn push_active(&mut self, entry: Arc<PlaylistEntry>) {
self.active.push_back(entry);
}
fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
self.active.iter().cloned().collect()
}
fn pop_front_if_ready(&mut self) -> Option<Arc<PlaylistEntry>> {
if let Some(front) = self.active.front() {
if front.pending_clients() == 0 {
return self.active.pop_front();
}
}
None
}
fn pop_front_matching(&mut self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
if let Some(front) = self.active.front() {
if front.track_id == track_id && front.pending_clients() == 0 {
return self.active.pop_front();
}
}
None
}
fn push_history(&mut self, entry: HistoryEntry) {
self.history.push_back(entry);
self.trim_history();
}
fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
let total = self.history.len();
let start = total.saturating_sub(limit);
self.history.iter().skip(start).cloned().collect()
}
fn trim_history(&mut self) {
while self.history.len() > self.max_history {
self.history.pop_front();
}
}
fn clear(&mut self) -> bool {
let changed = !self.active.is_empty() || !self.history.is_empty();
if changed {
self.active.clear();
self.history.clear();
}
changed
}
fn increment_all(&self) {
for entry in &self.active {
entry.increment_clients();
}
}
}
struct SharedPlaylistInner {
state: RwLock<PlaylistState>,
notify: Notify,
update_id: AtomicU32,
last_change: RwLock<Option<SystemTime>>,
}
#[derive(Clone)]
pub struct SharedPlaylist(Arc<SharedPlaylistInner>);
impl SharedPlaylist {
pub fn new(max_history: usize) -> Self {
Self(Arc::new(SharedPlaylistInner {
state: RwLock::new(PlaylistState::new(max_history)),
notify: Notify::new(),
update_id: AtomicU32::new(0),
last_change: RwLock::new(None),
}))
}
async fn touch(&self) {
self.0.update_id.fetch_add(1, Ordering::SeqCst);
let mut last_change = self.0.last_change.write().await;
*last_change = Some(SystemTime::now());
}
pub async fn push_active(&self, entry: Arc<PlaylistEntry>) {
let mut guard = self.0.state.write().await;
guard.push_active(entry);
drop(guard);
self.touch().await;
self.0.notify.notify_waiters();
}
pub async fn active_len(&self) -> usize {
let guard = self.0.state.read().await;
guard.active_len()
}
pub async fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
let guard = self.0.state.read().await;
guard.active_snapshot()
}
pub async fn clear(&self) {
let mut guard = self.0.state.write().await;
let changed = guard.clear();
drop(guard);
if changed {
self.touch().await;
self.0.notify.notify_waiters();
}
}
pub async fn wait_for_track_count(&self, current_len: usize) {
loop {
let len = {
let guard = self.0.state.read().await;
guard.active_len()
};
if len > current_len {
break;
}
self.0.notify.notified().await;
}
}
pub async fn pop_front_if_ready(&self) -> Option<Arc<PlaylistEntry>> {
let mut guard = self.0.state.write().await;
let result = guard.pop_front_if_ready();
drop(guard);
if result.is_some() {
self.touch().await;
self.0.notify.notify_waiters();
}
result
}
pub async fn pop_front_matching(&self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
let mut guard = self.0.state.write().await;
let result = guard.pop_front_matching(track_id);
drop(guard);
if result.is_some() {
self.touch().await;
self.0.notify.notify_waiters();
}
result
}
pub async fn push_history_entry(&self, entry: HistoryEntry) {
let mut guard = self.0.state.write().await;
guard.push_history(entry);
drop(guard);
self.touch().await;
}
pub async fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
let guard = self.0.state.read().await;
guard.recent_history(limit)
}
pub async fn increment_all_pending(&self) {
let guard = self.0.state.read().await;
guard.increment_all();
}
pub fn update_id(&self) -> u32 {
self.0.update_id.load(Ordering::SeqCst)
}
pub async fn last_change(&self) -> Option<SystemTime> {
self.0.last_change.read().await.clone()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,31 +3,24 @@
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
//! à un serveur pmoserver.
use crate::paradise::{max_channel_id, ParadiseChannel, PlaylistEntry, ALL_CHANNELS};
use crate::{Block, NowPlaying, RadioParadiseClient, RadioParadiseSource};
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
use crate::{Block, NowPlaying, RadioParadiseClient};
use async_trait::async_trait;
use axum::{
body::Body,
extract::{Path, Query, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::IntoResponse,
http::StatusCode,
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use futures::StreamExt;
use pmosource::api::CacheStatusInfo;
use pmosource::CacheStatus;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::error;
use utoipa::{IntoParams, OpenApi, ToSchema};
use utoipa::{OpenApi, ToSchema};
/// État partagé pour l'API Radio Paradise
#[derive(Clone)]
pub struct RadioParadiseState {
client: Arc<RwLock<RadioParadiseClient>>,
source: Arc<RadioParadiseSource>,
}
#[derive(Debug, Default, Deserialize)]
@@ -36,46 +29,14 @@ struct ParadiseQuery {
channel: Option<u8>,
}
#[derive(Debug, Default, Deserialize, IntoParams)]
#[serde(default)]
#[into_params(parameter_in = Query)]
struct ListLimitQuery {
/// Nombre maximum d'éléments à retourner (0 = tous)
#[serde(default)]
limit: Option<usize>,
}
impl RadioParadiseState {
pub async fn new() -> anyhow::Result<Self> {
let client = RadioParadiseClient::new()
.await
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
#[cfg(feature = "server")]
let source = RadioParadiseSource::from_registry_default(client.clone())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
#[cfg(not(feature = "server"))]
let source = {
let base_dir = std::env::temp_dir().join("pmoparadise_api");
let cover_dir = base_dir.join("covers");
let audio_dir = base_dir.join("audio");
std::fs::create_dir_all(&cover_dir)?;
std::fs::create_dir_all(&audio_dir)?;
let cover_cache = Arc::new(pmocovers::cache::new_cache(
cover_dir.to_string_lossy().as_ref(),
256,
)?);
let audio_cache = Arc::new(pmoaudiocache::cache::new_cache(
audio_dir.to_string_lossy().as_ref(),
256,
)?);
RadioParadiseSource::new_default(client.clone(), cover_cache, audio_cache)
};
Ok(Self {
client: Arc::new(RwLock::new(client)),
source: Arc::new(source),
})
}
@@ -100,19 +61,6 @@ impl RadioParadiseState {
Ok(client)
}
fn channel_for_id(&self, channel_id: u8) -> Result<Arc<ParadiseChannel>, StatusCode> {
if channel_id > max_channel_id() {
return Err(StatusCode::BAD_REQUEST);
}
self.source
.channel(channel_id)
.ok_or(StatusCode::SERVICE_UNAVAILABLE)
}
pub fn source(&self) -> Arc<RadioParadiseSource> {
self.source.clone()
}
}
/// Information sur un canal Radio Paradise
@@ -126,8 +74,8 @@ pub struct ChannelInfo {
pub description: String,
}
impl From<&crate::paradise::ChannelDescriptor> for ChannelInfo {
fn from(descriptor: &crate::paradise::ChannelDescriptor) -> Self {
impl From<&ChannelDescriptor> for ChannelInfo {
fn from(descriptor: &ChannelDescriptor) -> Self {
Self {
id: descriptor.id,
name: descriptor.display_name.to_string(),
@@ -365,425 +313,28 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
Json(channels)
}
/// Statut opérationnel d'un canal Radio Paradise
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChannelStatusResponse {
/// ID numérique du canal
pub channel_id: u8,
/// Slug du canal (main, mellow, ...)
pub slug: String,
/// Nom complet du canal
pub name: String,
/// Description
pub description: String,
/// Nombre de clients connectés au flux
pub active_clients: usize,
/// Nombre de morceaux présents dans la file d'attente
pub queue_length: usize,
/// Valeur courante d'update_id
pub update_id: u32,
/// Dernière modification (RFC3339)
pub last_change: Option<String>,
/// Nombre total d'entrées en historique (persisté)
pub history_entries: usize,
/// Limite configurée pour l'historique
pub history_max_tracks: usize,
/// Le canal est-il activé dans la configuration ?
pub configured: bool,
/// Identifiant de collection pour le cache
pub cache_collection_id: String,
/// Nombre total de pistes connues du cache
pub cache_total_tracks: usize,
/// Nombre de pistes déjà en cache
pub cache_cached_tracks: usize,
}
/// Entrée détaillée de la file d'attente
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChannelPlaylistEntry {
/// Position dans la file
pub index: usize,
/// ID unique de la piste
pub track_id: String,
/// ID du canal
pub channel_id: u8,
/// Titre du morceau
pub title: String,
/// Artiste
pub artist: String,
/// Album
pub album: Option<String>,
/// URL de couverture (si disponible)
pub cover_url: Option<String>,
/// Durée du morceau en ms
pub duration_ms: u64,
/// Offset dans le block (ms)
pub elapsed_ms: u64,
/// Horodatage prévu/démarré (RFC3339)
pub started_at: String,
/// Nombre de clients restants à servir
pub pending_clients: usize,
/// Note éventuelle (0-10)
pub rating: Option<f32>,
/// Année éventuelle
pub year: Option<u32>,
/// Statut de cache
pub cache_status: CacheStatusInfo,
}
impl ChannelPlaylistEntry {
fn from_entry(entry: &Arc<PlaylistEntry>, index: usize, cache_status: CacheStatusInfo) -> Self {
let song = entry.song.as_ref();
Self {
index,
track_id: entry.track_id.clone(),
channel_id: entry.channel_id,
title: song.title.clone(),
artist: song.artist.clone(),
album: song.album.clone(),
cover_url: song.cover.clone(),
duration_ms: entry.duration_ms,
elapsed_ms: song.elapsed,
started_at: entry.started_at.to_rfc3339(),
pending_clients: entry.pending_clients(),
rating: song.rating,
year: song.year,
cache_status,
}
}
}
/// Réponse pour la file d'attente d'un canal
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChannelPlaylistResponse {
/// ID du canal
pub channel_id: u8,
/// Slug du canal
pub slug: String,
/// Update ID du playlist
pub update_id: u32,
/// Taille totale de la file au moment de la capture
pub queue_length: usize,
/// Entrées retournées
pub items: Vec<ChannelPlaylistEntry>,
}
/// Entrée d'historique d'écoute
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChannelHistoryEntry {
/// ID unique de la piste
pub track_id: String,
/// ID du canal
pub channel_id: u8,
/// Titre
pub title: String,
/// Artiste
pub artist: String,
/// Album
pub album: Option<String>,
/// URL de couverture
pub cover_url: Option<String>,
/// Début de lecture (RFC3339)
pub started_at: String,
/// Durée en ms
pub duration_ms: u64,
}
/// Réponse pour l'historique d'un canal
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ChannelHistoryResponse {
/// ID du canal
pub channel_id: u8,
/// Slug du canal
pub slug: String,
/// Nombre total d'entrées disponibles
pub total_available: usize,
/// Nombre d'entrées retournées dans cette réponse
pub returned: usize,
/// Entrées
pub entries: Vec<ChannelHistoryEntry>,
}
/// GET /channels/{channel_id}/status - Statut détaillé d'un canal
#[utoipa::path(
get,
path = "/channels/{channel_id}/status",
params(
("channel_id" = u8, Path, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Statut du canal", body = ChannelStatusResponse),
(status = 400, description = "Canal invalide"),
(status = 503, description = "Canal indisponible"),
(status = 500, description = "Erreur interne lors de la récupération du statut")
),
tag = "Radio Paradise"
)]
async fn get_channel_status(
State(state): State<RadioParadiseState>,
Path(channel_id): Path<u8>,
) -> Result<Json<ChannelStatusResponse>, StatusCode> {
let channel = state.channel_for_id(channel_id)?;
let descriptor = channel.descriptor();
let playlist = channel.playlist();
let queue_length = playlist.active_len().await;
let update_id = playlist.update_id();
let last_change = playlist
.last_change()
.await
.map(|ts| DateTime::<Utc>::from(ts).to_rfc3339());
let history_len = channel.history_backend().len().await.map_err(|e| {
error!(
channel = descriptor.slug,
"Failed to retrieve history size: {e:?}"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let cache_stats = channel.cache_manager().statistics().await;
let status = ChannelStatusResponse {
channel_id,
slug: descriptor.slug.to_string(),
name: descriptor.display_name.to_string(),
description: descriptor.description.to_string(),
active_clients: channel.active_client_count(),
queue_length,
update_id,
last_change,
history_entries: history_len,
history_max_tracks: channel.history_max_tracks(),
configured: true, // All channels are always available
cache_collection_id: cache_stats.collection_id,
cache_total_tracks: cache_stats.total_tracks,
cache_cached_tracks: cache_stats.cached_tracks,
};
Ok(Json(status))
}
/// GET /channels/{channel_id}/playlist - File d'attente du canal
#[utoipa::path(
get,
path = "/channels/{channel_id}/playlist",
params(
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
ListLimitQuery
),
responses(
(status = 200, description = "File d'attente courante", body = ChannelPlaylistResponse),
(status = 400, description = "Canal invalide"),
(status = 503, description = "Canal indisponible"),
(status = 500, description = "Erreur lors de la récupération de la file d'attente")
),
tag = "Radio Paradise"
)]
async fn get_channel_playlist(
State(state): State<RadioParadiseState>,
Path(channel_id): Path<u8>,
Query(query): Query<ListLimitQuery>,
) -> Result<Json<ChannelPlaylistResponse>, StatusCode> {
let channel = state.channel_for_id(channel_id)?;
let descriptor = channel.descriptor();
let playlist = channel.playlist();
let snapshot = playlist.active_snapshot().await;
let total_len = snapshot.len();
let limit = query.limit.filter(|limit| *limit > 0).unwrap_or(total_len);
let cache_manager = channel.cache_manager();
let mut items = Vec::new();
for (index, entry) in snapshot.into_iter().enumerate().take(limit) {
let cache_status = match cache_manager.get_cache_status(&entry.track_id).await {
Ok(status) => status,
Err(err) => CacheStatus::Failed {
error: err.to_string(),
},
};
items.push(ChannelPlaylistEntry::from_entry(
&entry,
index,
CacheStatusInfo::from(cache_status),
));
}
let response = ChannelPlaylistResponse {
channel_id,
slug: descriptor.slug.to_string(),
update_id: playlist.update_id(),
queue_length: total_len,
items,
};
Ok(Json(response))
}
/// GET /channels/{channel_id}/history - Historique récent du canal
#[utoipa::path(
get,
path = "/channels/{channel_id}/history",
params(
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
ListLimitQuery
),
responses(
(status = 200, description = "Historique récent", body = ChannelHistoryResponse),
(status = 400, description = "Canal invalide"),
(status = 503, description = "Canal indisponible"),
(status = 500, description = "Erreur lors de la récupération de l'historique")
),
tag = "Radio Paradise"
)]
async fn get_channel_history(
State(state): State<RadioParadiseState>,
Path(channel_id): Path<u8>,
Query(query): Query<ListLimitQuery>,
) -> Result<Json<ChannelHistoryResponse>, StatusCode> {
let channel = state.channel_for_id(channel_id)?;
let descriptor = channel.descriptor();
let backend = channel.history_backend().clone();
let limit = query.limit.unwrap_or(50);
let entries_raw = backend.recent(limit).await.map_err(|e| {
error!(
channel = descriptor.slug,
"Failed to retrieve channel history: {e:?}"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let total_available = backend.len().await.map_err(|e| {
error!(
channel = descriptor.slug,
"Failed to count channel history entries: {e:?}"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries: Vec<ChannelHistoryEntry> = entries_raw
.into_iter()
.map(|entry| ChannelHistoryEntry {
track_id: entry.track_id,
channel_id: entry.channel_id,
title: entry.song.title,
artist: entry.song.artist,
album: entry.song.album,
cover_url: entry.song.cover_url,
started_at: entry.started_at.to_rfc3339(),
duration_ms: entry.duration_ms,
})
.collect();
let response = ChannelHistoryResponse {
channel_id,
slug: descriptor.slug.to_string(),
total_available,
returned: entries.len(),
entries,
};
Ok(Json(response))
}
/// GET /channels/{channel_id}/stream/{connection_id} - Stream audio pour une connexion spécifique
#[utoipa::path(
get,
path = "/channels/{channel_id}/stream/{connection_id}",
params(
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
("connection_id" = i32, Path, description = "Connection ID fourni par le media server")
),
responses(
(status = 200, description = "Flux audio FLAC (gapless)", content_type = "audio/flac"),
(status = 400, description = "Canal invalide"),
(status = 503, description = "Canal indisponible")
),
tag = "Radio Paradise"
)]
async fn stream_channel_by_connection(
State(state): State<RadioParadiseState>,
Path((channel_id, connection_id)): Path<(u8, i32)>,
) -> Result<impl IntoResponse, StatusCode> {
let channel = state.channel_for_id(channel_id)?;
// Convertir connection_id en String pour l'utiliser comme client_id
let client_id = connection_id.to_string();
let client_stream = channel.connect_client(client_id).await.map_err(|e| {
error!("Failed to create streaming client: {e:?}");
StatusCode::SERVICE_UNAVAILABLE
})?;
let stream = client_stream
.into_byte_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
let body = Body::from_stream(stream);
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("audio/flac"),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
headers.insert(
HeaderName::from_static("icy-name"),
HeaderValue::from_static("Radio Paradise"),
);
headers.insert(
HeaderName::from_static("icy-genre"),
HeaderValue::from_static("Eclectic"),
);
headers.insert(
HeaderName::from_static("icy-description"),
HeaderValue::from_static("PMO Radio Paradise relay"),
);
headers.insert(
HeaderName::from_static("icy-metaint"),
HeaderValue::from_static("0"),
);
Ok((headers, body))
}
/// Documentation OpenAPI pour l'API Radio Paradise
#[derive(OpenApi)]
#[openapi(
info(
title = "Radio Paradise API",
version = "1.0.0",
description = "API REST pour accéder aux métadonnées et streams de Radio Paradise"
description = "API REST pour accéder aux métadonnées de Radio Paradise"
),
paths(
get_now_playing,
get_current_block,
get_block_by_id,
get_channels,
get_channel_status,
get_channel_playlist,
get_channel_history,
stream_channel_by_connection
get_channels
),
components(schemas(
NowPlayingResponse,
BlockResponse,
SongInfo,
ChannelInfo,
ChannelStatusResponse,
ChannelPlaylistEntry,
ChannelPlaylistResponse,
ChannelHistoryEntry,
ChannelHistoryResponse,
CacheStatusInfo
ChannelInfo
)),
tags(
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming")
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
)
)]
pub struct RadioParadiseApiDoc;
@@ -795,13 +346,6 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
.route("/block/current", get(get_current_block))
.route("/block/{event_id}", get(get_block_by_id))
.route("/channels", get(get_channels))
.route("/channels/{channel_id}/status", get(get_channel_status))
.route("/channels/{channel_id}/playlist", get(get_channel_playlist))
.route("/channels/{channel_id}/history", get(get_channel_history))
.route(
"/channels/{channel_id}/stream/{connection_id}",
get(stream_channel_by_connection),
)
.with_state(state)
}
@@ -809,6 +353,7 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
///
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
#[cfg(feature = "pmoserver")]
#[async_trait]
pub trait RadioParadiseExt {
/// Initialise l'API Radio Paradise
///
@@ -817,12 +362,13 @@ pub trait RadioParadiseExt {
/// - API: `/api/radioparadise/*`
/// - `/now-playing`
/// - `/block/*`
/// - `/channels/{channel_id}/stream/{connection_id}`
/// - `/channels`
/// - Swagger: `/swagger-ui/radioparadise`
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
}
#[cfg(feature = "pmoserver")]
#[async_trait]
impl RadioParadiseExt for pmoserver::Server {
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
let state = RadioParadiseState::new().await?;

View File

@@ -0,0 +1,667 @@
//! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise
//!
//! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming,
//! avec insertion automatique des TrackBoundary au bon timing.
use crate::{
client::RadioParadiseClient,
models::{Block, EventId, Song},
};
use futures_util::StreamExt;
use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{Node, NodeLogic},
type_constraints::TypeRequirement,
AudioPipelineNode, AudioSegment, SyncMarker, I24,
};
use pmoflac::decode_audio_stream;
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
use std::{
collections::VecDeque,
sync::Arc,
time::Duration,
};
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;
/// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
// ═══════════════════════════════════════════════════════════════════════════
// RadioParadiseStreamSourceLogic - Logique métier pure
// ═══════════════════════════════════════════════════════════════════════════
/// Logique pure de téléchargement et décodage des blocs Radio Paradise
pub struct RadioParadiseStreamSourceLogic {
client: RadioParadiseClient,
chunk_frames: usize,
recent_blocks: VecDeque<EventId>,
block_queue: VecDeque<EventId>,
}
impl RadioParadiseStreamSourceLogic {
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
// Calculer chunk_frames pour la durée cible (on suppose 44.1kHz)
let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize;
Self {
client,
chunk_frames,
recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE),
block_queue: VecDeque::new(),
}
}
/// Ajoute un block ID à la file d'attente
pub fn push_block_id(&mut self, event_id: EventId) {
self.block_queue.push_back(event_id);
}
/// Vérifie si un bloc a été téléchargé récemment
fn is_recent_block(&self, event_id: EventId) -> bool {
self.recent_blocks.contains(&event_id)
}
/// Marque un bloc comme récemment téléchargé (FIFO)
fn mark_block_downloaded(&mut self, event_id: EventId) {
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
self.recent_blocks.pop_front();
}
// Puis ajouter le nouveau bloc
self.recent_blocks.push_back(event_id);
}
/// Télécharge et décode un bloc FLAC
async fn download_and_decode_block(
&mut self,
block: &Block,
output: &[mpsc::Sender<Arc<AudioSegment>>],
stop_token: &CancellationToken,
order: &mut u64,
) -> Result<(), AudioError> {
// Télécharger le FLAC
let response = self.client.client
.get(&block.url)
.timeout(self.client.block_timeout)
.send()
.await
.map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?;
if !response.status().is_success() {
return Err(AudioError::ProcessingError(format!(
"Block download returned status {}",
response.status()
)));
}
// Créer un 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);
// Décoder le FLAC
let mut decoder = decode_audio_stream(stream_reader)
.await
.map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?;
let stream_info = decoder.info().clone();
let sample_rate = stream_info.sample_rate;
let bits_per_sample = stream_info.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;
// Envoyer TopZeroSync au début du bloc
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?;
// Buffer pour lecture
let bytes_per_sample = (bits_per_sample / 8) as usize;
let frame_bytes = bytes_per_sample * 2; // stereo
let chunk_frames = self.chunk_frames;
let chunk_byte_len = chunk_frames * frame_bytes;
let mut read_buf = vec![0u8; chunk_byte_len * 2];
let mut pending: Vec<u8> = Vec::with_capacity(chunk_byte_len * 2);
// Traiter les chunks audio
loop {
// Vérifier stop_token
if stop_token.is_cancelled() {
return Ok(());
}
// Remplir le buffer
if pending.len() < chunk_byte_len {
let read = decoder.read(&mut read_buf).await
.map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
if read == 0 {
break; // EOF
}
pending.extend_from_slice(&read_buf[..read]);
}
if pending.is_empty() {
break;
}
// Extraire un chunk
let frames_in_pending = pending.len() / frame_bytes;
let frames_to_emit = frames_in_pending.min(chunk_frames);
let take_bytes = frames_to_emit * frame_bytes;
let pcm_data = pending.drain(..take_bytes).collect::<Vec<u8>>();
// Calculer le nombre de frames (samples par canal)
let bytes_per_sample = (bits_per_sample / 8) as usize;
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 {
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);
let timestamp_sec = total_samples as f64 / sample_rate as f64;
let track_boundary = AudioSegment::new_track_boundary(
*order,
timestamp_sec,
metadata,
);
self.send_to_children(output, track_boundary).await?;
// Passer à la song suivante
song_index += 1;
next_song = songs.get(song_index).copied();
}
}
// Envoyer le chunk audio
let timestamp_sec = total_samples as f64 / sample_rate as f64;
let audio_segment = pcm_to_audio_segment(
&pcm_data,
*order,
timestamp_sec,
sample_rate,
bits_per_sample,
)?;
self.send_to_children(output, audio_segment).await?;
*order += 1;
total_samples += chunk_len;
}
Ok(())
}
/// Envoie un segment à tous les enfants
async fn send_to_children(
&self,
output: &[mpsc::Sender<Arc<AudioSegment>>],
segment: Arc<AudioSegment>,
) -> Result<(), AudioError> {
for tx in output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
Ok(())
}
}
/// Convertit PCM bytes en AudioSegment
fn pcm_to_audio_segment(
pcm_data: &[u8],
order: u64,
timestamp_sec: f64,
sample_rate: u32,
bits_per_sample: u8,
) -> Result<Arc<AudioSegment>, AudioError> {
use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment};
let bytes_per_sample = (bits_per_sample / 8) as usize;
let channels = 2; // Stereo
let frame_bytes = bytes_per_sample * channels;
let frames = pcm_data.len() / frame_bytes;
// Valider que la taille des données est correcte
if pcm_data.len() % frame_bytes != 0 {
return Err(AudioError::ProcessingError(format!(
"Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)",
pcm_data.len(),
frame_bytes,
bits_per_sample,
channels
)));
}
let chunk = match bits_per_sample {
16 => {
// Type I16
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]);
let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]);
stereo.push([left, right]);
}
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
AudioChunk::I16(chunk_data)
}
24 => {
// Type I24 avec sign extension correcte
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
// Left channel (bytes 0,1,2) avec sign extension
let left_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(&pcm_data[base..base + 3]);
// Sign extend si négatif
if pcm_data[base + 2] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
let left = I24::new(left_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32))
})?;
// Right channel (bytes 3,4,5) avec sign extension
let right_i32 = {
let mut buf = [0u8; 4];
buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]);
// Sign extend si négatif
if pcm_data[base + 5] & 0x80 != 0 {
buf[3] = 0xFF;
}
i32::from_le_bytes(buf)
};
let right = I24::new(right_i32).ok_or_else(|| {
AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32))
})?;
stereo.push([left, right]);
}
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
AudioChunk::I24(chunk_data)
}
32 => {
// Type I32
let mut stereo = Vec::with_capacity(frames);
for frame_idx in 0..frames {
let base = frame_idx * frame_bytes;
let left = i32::from_le_bytes([
pcm_data[base],
pcm_data[base + 1],
pcm_data[base + 2],
pcm_data[base + 3],
]);
let right = i32::from_le_bytes([
pcm_data[base + 4],
pcm_data[base + 5],
pcm_data[base + 6],
pcm_data[base + 7],
]);
stereo.push([left, right]);
}
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
AudioChunk::I32(chunk_data)
}
_ => {
return Err(AudioError::ProcessingError(format!(
"Unsupported bit depth: {}",
bits_per_sample
)))
}
};
Ok(Arc::new(AudioSegment {
order,
timestamp_sec,
segment: _AudioSegment::Chunk(Arc::new(chunk)),
}))
}
/// Convertit Song en TrackMetadata
///
/// Cette fonction est synchrone, donc on wrap la metadata dans Arc<RwLock<>>
/// et on spawn une tâche async pour la configurer
fn song_to_metadata(song: &Song, block: &Block) -> Arc<RwLock<dyn TrackMetadata>> {
let metadata = MemoryTrackMetadata::new();
let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc<RwLock<dyn TrackMetadata>>;
let metadata_clone = metadata_arc.clone();
// Clone des données pour la task async
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;
// Ces méthodes peuvent échouer (retournent Result), donc on propage avec ?
if let Err(e) = meta.set_title(Some(title)).await {
eprintln!("Warning: Failed to set title: {}", e);
}
if let Err(e) = meta.set_artist(Some(artist)).await {
eprintln!("Warning: 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);
}
}
if let Some(year) = year {
if let Err(e) = meta.set_year(Some(year)).await {
eprintln!("Warning: 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);
}
}
});
metadata_arc
}
#[async_trait::async_trait]
impl NodeLogic for RadioParadiseStreamSourceLogic {
async fn process(
&mut self,
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut order = 0u64;
loop {
// Attendre un block ID (timeout court pour une radio)
let event_id = match tokio::time::timeout(
Duration::from_secs(BLOCK_ID_TIMEOUT_SECS),
async {
while self.block_queue.is_empty() {
tokio::time::sleep(Duration::from_millis(100)).await;
if stop_token.is_cancelled() {
return None;
}
}
self.block_queue.pop_front()
}
).await {
Ok(Some(id)) => id,
Ok(None) => break, // Cancelled
Err(_) => {
// Timeout - pas de nouveau bloc, on termine
break;
}
};
// Vérifier si déjà téléchargé récemment
if self.is_recent_block(event_id) {
continue;
}
// Récupérer les métadonnées du bloc
let block = self.client
.get_block(Some(event_id))
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?;
// 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)
.await?;
}
// Envoyer EndOfStream
let eos = AudioSegment::new_end_of_stream(order, 0.0);
for tx in &output {
tx.send(eos.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
Ok(())
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RadioParadiseStreamSource - Wrapper utilisant Node<RadioParadiseStreamSourceLogic>
// ═══════════════════════════════════════════════════════════════════════════
pub struct RadioParadiseStreamSource {
inner: Node<RadioParadiseStreamSourceLogic>,
}
impl RadioParadiseStreamSource {
/// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut
pub fn new(client: RadioParadiseClient) -> Self {
Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32)
}
/// Crée une nouvelle source avec durée de chunk personnalisée
pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
let logic = RadioParadiseStreamSourceLogic::new(client, chunk_duration_ms);
Self {
inner: Node::new_source(logic),
}
}
/// Ajoute un block ID à la file d'attente de téléchargement
pub fn push_block_id(&mut self, event_id: EventId) {
self.inner.logic_mut().push_block_id(event_id);
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for RadioParadiseStreamSource {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for RadioParadiseStreamSource {
fn input_type(&self) -> Option<TypeRequirement> {
None // Source node
}
fn output_type(&self) -> Option<TypeRequirement> {
// Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit
// La profondeur est détectée automatiquement depuis le header FLAC
Some(TypeRequirement::any_integer())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_client() -> RadioParadiseClient {
RadioParadiseClient::with_client(reqwest::Client::new())
}
#[test]
fn test_cache_fifo_basic() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter 5 blocs
for i in 1..=5 {
logic.mark_block_downloaded(i);
}
// Vérifier que tous sont dans le cache
for i in 1..=5 {
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
}
assert_eq!(logic.recent_blocks.len(), 5);
}
#[test]
fn test_cache_fifo_exactly_10_elements() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter exactement 10 blocs
for i in 1..=10 {
logic.mark_block_downloaded(i);
}
// Vérifier qu'on a exactement 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements");
// Tous devraient être dans le cache
for i in 1..=10 {
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
}
}
#[test]
fn test_cache_fifo_eviction_oldest() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Remplir le cache avec 10 éléments (1..=10)
for i in 1..=10 {
logic.mark_block_downloaded(i);
}
// Ajouter un 11ème élément
logic.mark_block_downloaded(11);
// Le cache doit toujours avoir 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements");
// Le premier (plus ancien) doit avoir été évincé
assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted");
// Les éléments 2..=11 doivent être présents
for i in 2..=11 {
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
}
}
#[test]
fn test_cache_fifo_multiple_evictions() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Remplir avec 10 éléments
for i in 1..=10 {
logic.mark_block_downloaded(i);
}
// Ajouter 5 éléments supplémentaires
for i in 11..=15 {
logic.mark_block_downloaded(i);
}
// Toujours 10 éléments
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements");
// Les 5 premiers doivent avoir été évincés
for i in 1..=5 {
assert!(!logic.is_recent_block(i), "Block {} should be evicted", i);
}
// Les éléments 6..=15 doivent être présents
for i in 6..=15 {
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
}
}
#[test]
fn test_cache_never_exceeds_capacity() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Vérifier la capacité pré-allouée
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
// Ajouter beaucoup d'éléments
for i in 1..=100 {
logic.mark_block_downloaded(i);
// À chaque itération, vérifier qu'on ne dépasse jamais 10
assert!(
logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE,
"Cache size {} exceeded max {}",
logic.recent_blocks.len(),
RECENT_BLOCKS_CACHE_SIZE
);
}
// Finalement, on doit avoir exactement 10 éléments
assert_eq!(logic.recent_blocks.len(), 10);
// Ce doivent être les 10 derniers (91..=100)
for i in 91..=100 {
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
}
}
#[test]
fn test_cache_fifo_order_preserved() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Ajouter 10 éléments
for i in 1..=10 {
logic.mark_block_downloaded(i);
}
// Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien)
let front = logic.recent_blocks.front().copied();
assert_eq!(front, Some(1), "Front should be the oldest element");
let back = logic.recent_blocks.back().copied();
assert_eq!(back, Some(10), "Back should be the newest element");
}
#[test]
fn test_block_queue_push() {
let client = create_test_client();
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
// Tester push_block_id
logic.push_block_id(100);
logic.push_block_id(200);
logic.push_block_id(300);
assert_eq!(logic.block_queue.len(), 3);
assert_eq!(logic.block_queue.front(), Some(&100));
assert_eq!(logic.block_queue.back(), Some(&300));
}
}

View File

@@ -1,395 +1,114 @@
//! Music source implementation for Radio Paradise built on the new
//! `paradise` orchestration layer.
//! DEPRECATED: Stub implementation of RadioParadiseSource
//!
//! The source exposes a DIDL-Lite hierarchy compatible with UPnP
//! ContentDirectory while delegating block ingestion, caching and
//! multi-client streaming to [`ParadiseChannel`].
//! **⚠️ This module is deprecated and will be removed in a future version.**
//!
//! The orchestration-based RadioParadiseSource has been replaced by
//! `RadioParadiseStreamSource`, which integrates directly with the pmoaudio
//! pipeline for streaming and decoding.
//!
//! ## Migration Guide
//!
//! **Old approach** (deprecated):
//! ```rust,ignore
//! use pmoparadise::RadioParadiseSource;
//! let source = RadioParadiseSource::from_registry(client)?;
//! ```
//!
//! **New approach** (recommended):
//! ```rust,ignore
//! use pmoparadise::RadioParadiseStreamSource;
//! use pmoaudio::pipeline::Node;
//!
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
//! let node = Node::from_logic(stream_source);
//! // Use node in pmoaudio pipeline
//! ```
//!
//! This stub implementation is provided only for backward compatibility with
//! existing code (e.g., pmomediaserver) until it can be updated to use
//! RadioParadiseStreamSource.
use crate::client::RadioParadiseClient;
use crate::paradise::{
create_history_backend, ChannelDescriptor, ParadiseChannel, PlaylistEntry, ALL_CHANNELS,
};
#[cfg(not(feature = "pmoconfig"))]
use crate::paradise::HISTORY_DEFAULT_MAX_TRACKS;
use anyhow::Result as AnyhowResult;
use pmoaudiocache::Cache as AudioCache;
use pmocovers::Cache as CoverCache;
use pmodidl::{Container, Item, Resource};
use pmosource::pmodidl;
use pmosource::{
async_trait, BrowseResult, CacheStatus, MusicSource, MusicSourceError, Result,
SourceCacheManager, SourceStatistics,
};
use std::collections::HashMap;
use std::sync::Arc;
use pmosource::pmodidl::{Container, Item};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use std::time::SystemTime;
use tracing::warn;
/// Default image for Radio Paradise (300x300 WebP, embedded in binary)
/// Default Radio Paradise image (embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
fn channel_collection_id(channel_id: u8) -> String {
format!("radio-paradise:{}", channel_id)
}
fn channel_container_id(channel_id: u8) -> String {
format!("radio-paradise:channel:{}", channel_id)
}
fn parse_channel_container_id(object_id: &str) -> Option<u8> {
let mut parts = object_id.split(':');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some("radio-paradise"), Some("channel"), Some(id_str), None) => id_str.parse().ok(),
_ => None,
}
}
fn parse_track_channel(track_id: &str) -> Option<u8> {
let mut parts = track_id.split(':');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some("rp"), Some(channel_str), Some(_rest), None) => channel_str.parse().ok(),
_ => None,
}
}
fn format_duration(duration_seconds: u64) -> String {
let hours = duration_seconds / 3600;
let minutes = (duration_seconds % 3600) / 60;
let seconds = duration_seconds % 60;
format!("{hours}:{minutes:02}:{seconds:02}")
}
#[derive(Clone)]
/// DEPRECATED: Stub implementation of RadioParadiseSource
///
/// This is a minimal stub that implements the MusicSource trait with no-op
/// implementations. It exists only to maintain API compatibility during the
/// migration to RadioParadiseStreamSource.
///
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
#[derive(Clone, Debug)]
pub struct RadioParadiseSource {
inner: Arc<RadioParadiseSourceInner>,
}
struct RadioParadiseSourceInner {
channels: HashMap<u8, Arc<ParadiseChannel>>,
}
impl std::fmt::Debug for RadioParadiseSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioParadiseSource").finish()
}
_client: RadioParadiseClient,
}
impl RadioParadiseSource {
/// DEPRECATED: Create a new RadioParadiseSource from registry
///
/// This method is deprecated and will always return an error indicating
/// that the orchestration-based source is no longer supported.
///
/// Use `RadioParadiseStreamSource` instead for audio streaming.
#[cfg(feature = "server")]
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
// Load history configuration from pmoconfig using the config extension trait
#[cfg(feature = "pmoconfig")]
let (database_path, history_max_tracks) = {
use crate::config_ext::RadioParadiseConfigExt;
let cfg = pmoconfig::get_config();
let database_path = cfg.get_paradise_history_database().map_err(|e| {
MusicSourceError::SourceUnavailable(format!(
"Failed to get history database path: {}",
e
))
})?;
let max_tracks = cfg.get_paradise_history_size().map_err(|e| {
MusicSourceError::SourceUnavailable(format!("Failed to get history size: {}", e))
})?;
(database_path, max_tracks)
};
#[cfg(not(feature = "pmoconfig"))]
let (database_path, history_max_tracks) = {
use std::path::PathBuf;
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
path.push(".config");
path.push("pmo");
path.push("paradise");
std::fs::create_dir_all(&path).ok();
path.push("history.db");
(
path.to_string_lossy().to_string(),
HISTORY_DEFAULT_MAX_TRACKS,
)
};
let history_backend = create_history_backend(&database_path).map_err(|e| {
MusicSourceError::SourceUnavailable(format!(
"Failed to initialize history backend: {}",
e
))
})?;
let mut channels = HashMap::new();
for descriptor in ALL_CHANNELS.iter() {
let cache_manager = Arc::new(SourceCacheManager::from_registry(
channel_collection_id(descriptor.id),
)?);
let channel = Arc::new(
ParadiseChannel::new(
*descriptor,
client.clone(),
history_max_tracks,
history_backend.clone(),
cache_manager,
)
.map_err(|e| {
MusicSourceError::SourceUnavailable(format!(
"Failed to initialize channel {}: {e}",
descriptor.slug
))
})?,
);
channels.insert(descriptor.id, channel);
}
Ok(Self {
inner: Arc::new(RadioParadiseSourceInner { channels }),
})
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
.to_string(),
))
}
/// DEPRECATED: Create a new RadioParadiseSource from registry with defaults
///
/// This method creates a stub instance that will log deprecation warnings
/// but allows existing code to compile.
///
/// Use `RadioParadiseStreamSource` instead for audio streaming.
#[cfg(feature = "server")]
pub fn from_registry_default(client: RadioParadiseClient) -> Result<Self> {
Self::from_registry(client)
pub fn from_registry_default(client: RadioParadiseClient) -> Self {
tracing::warn!(
"RadioParadiseSource::from_registry_default is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
);
Self { _client: client }
}
pub fn new(
client: RadioParadiseClient,
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
// Load history configuration from pmoconfig using the config extension trait
#[cfg(feature = "pmoconfig")]
let (database_path, history_max_tracks) = {
use crate::config_ext::RadioParadiseConfigExt;
let cfg = pmoconfig::get_config();
let database_path = cfg.get_paradise_history_database().unwrap_or_else(|e| {
panic!("Failed to get history database path: {e}");
});
let max_tracks = cfg.get_paradise_history_size().unwrap_or_else(|e| {
panic!("Failed to get history size: {e}");
});
(database_path, max_tracks)
};
#[cfg(not(feature = "pmoconfig"))]
let (database_path, history_max_tracks) = {
use std::path::PathBuf;
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
path.push(".config");
path.push("pmo");
path.push("paradise");
std::fs::create_dir_all(&path).ok();
path.push("history.db");
(
path.to_string_lossy().to_string(),
HISTORY_DEFAULT_MAX_TRACKS,
)
};
let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
create_history_backend(&database_path).unwrap_or_else(|err| {
panic!("Failed to initialize history backend: {err}");
});
let mut channels = HashMap::new();
for descriptor in ALL_CHANNELS.iter() {
let cache_manager = Arc::new(SourceCacheManager::new(
channel_collection_id(descriptor.id),
Arc::clone(&cover_cache),
Arc::clone(&audio_cache),
));
match ParadiseChannel::new(
*descriptor,
client.clone(),
history_max_tracks,
history_backend.clone(),
cache_manager,
) {
Ok(channel) => {
channels.insert(descriptor.id, Arc::new(channel));
}
Err(err) => {
warn!(
channel = descriptor.slug,
"Failed to initialize channel: {err:?}"
);
}
}
}
Self {
inner: Arc::new(RadioParadiseSourceInner { channels }),
}
/// DEPRECATED: Create a new RadioParadiseSource with default settings
///
/// This method is deprecated and only exists for API compatibility.
pub fn new_default(client: RadioParadiseClient) -> Self {
tracing::warn!(
"RadioParadiseSource::new_default is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
);
Self { _client: client }
}
pub fn new_default(
client: RadioParadiseClient,
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
Self::new(client, cover_cache, audio_cache)
}
pub fn client_for_channel(&self, channel: u8) -> Option<RadioParadiseClient> {
self.inner
.channels
.get(&channel)
.map(|ch| ch.client().clone())
}
pub fn channel(&self, id: u8) -> Option<Arc<ParadiseChannel>> {
self.inner.channels.get(&id).cloned()
}
fn build_root_container(&self) -> Container {
Container {
id: "radio-paradise".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: Some(ALL_CHANNELS.len().to_string()),
searchable: Some("1".to_string()),
title: "Radio Paradise".to_string(),
class: "object.container".to_string(),
containers: vec![],
items: vec![],
}
}
async fn build_channel_containers(&self) -> Vec<Container> {
let mut containers = Vec::new();
for descriptor in ALL_CHANNELS.iter() {
if let Some(channel) = self.channel(descriptor.id) {
let len = channel.playlist().active_len().await;
containers.push(Container {
id: channel_container_id(descriptor.id),
parent_id: "radio-paradise".to_string(),
restricted: Some("1".to_string()),
child_count: Some(len.to_string()),
searchable: Some("1".to_string()),
title: descriptor.display_name.to_string(),
class: "object.container.playlistContainer".to_string(),
containers: vec![],
items: vec![],
});
}
}
containers
}
async fn channel_items(
&self,
descriptor: ChannelDescriptor,
offset: usize,
limit: Option<usize>,
) -> Result<Vec<Item>> {
let channel = self
.channel(descriptor.id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(descriptor.slug.to_string()))?;
channel
.ensure_started()
.await
.map_err(|e| MusicSourceError::SourceUnavailable(e.to_string()))?;
let entries = channel.playlist().active_snapshot().await;
if entries.is_empty() || offset >= entries.len() {
return Ok(Vec::new());
}
let end = limit
.map(|count| offset + count)
.unwrap_or(entries.len())
.min(entries.len());
let parent_id = channel_container_id(descriptor.id);
let mut items = Vec::with_capacity(end - offset);
for entry in entries.into_iter().skip(offset).take(end - offset) {
match self.entry_to_item(channel.clone(), &parent_id, entry).await {
Ok(item) => items.push(item),
Err(err) => warn!(
channel = descriptor.slug,
"Failed to build DIDL item: {err:?}"
),
}
}
Ok(items)
}
async fn entry_to_item(
&self,
channel: Arc<ParadiseChannel>,
parent_id: &str,
entry: Arc<PlaylistEntry>,
) -> AnyhowResult<Item> {
let cache_manager = channel.cache_manager();
let metadata = cache_manager.get_metadata(&entry.track_id).await;
let resource_url = cache_manager
.resolve_uri(&entry.track_id)
.await
.or_else(|_| {
metadata
.as_ref()
.map(|meta| meta.original_uri.clone())
.ok_or_else(|| MusicSourceError::ObjectNotFound(entry.track_id.clone()))
})?;
let mut album_art = metadata
.as_ref()
.and_then(|meta| meta.cached_cover_pk.as_ref())
.and_then(|pk| cache_manager.cover_url(pk, None).ok());
if album_art.is_none() {
album_art = entry.song.cover.clone();
}
let duration_seconds = entry.duration_ms / 1000;
let duration_str = if duration_seconds > 0 {
Some(format_duration(duration_seconds as u64))
} else {
None
};
let resource = Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: duration_str.clone(),
url: resource_url,
};
Ok(Item {
id: entry.track_id.clone(),
parent_id: parent_id.to_string(),
restricted: Some("1".to_string()),
title: entry.song.title.clone(),
creator: Some(entry.song.artist.clone()),
class: "object.item.audioItem.musicTrack".to_string(),
artist: Some(entry.song.artist.clone()),
album: entry.song.album.clone(),
genre: None,
album_art,
album_art_pk: None,
date: None,
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
})
}
fn channels_iter(&self) -> impl Iterator<Item = (&u8, &Arc<ParadiseChannel>)> {
self.inner.channels.iter()
/// DEPRECATED: Create a new RadioParadiseSource with cache
///
/// This method is deprecated and only exists for API compatibility.
pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self {
tracing::warn!(
"RadioParadiseSource::new_with_cache is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
);
Self { _client: client }
}
}
#[async_trait]
impl MusicSource for RadioParadiseSource {
fn name(&self) -> &str {
"Radio Paradise"
"Radio Paradise (DEPRECATED)"
}
fn id(&self) -> &str {
"radio-paradise"
"radio-paradise-deprecated"
}
fn default_image(&self) -> &[u8] {
@@ -397,43 +116,32 @@ impl MusicSource for RadioParadiseSource {
}
async fn root_container(&self) -> Result<Container> {
Ok(self.build_root_container())
Ok(Container {
id: "radio-paradise-deprecated".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: Some("0".to_string()),
searchable: Some("0".to_string()),
title: "Radio Paradise (DEPRECATED)".to_string(),
class: "object.container".to_string(),
containers: vec![],
items: vec![],
})
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
match object_id {
"0" => Ok(BrowseResult::Containers(vec![self.build_root_container()])),
"radio-paradise" => {
let containers = self.build_channel_containers().await;
Ok(BrowseResult::Containers(containers))
}
_ => {
if let Some(channel_id) = parse_channel_container_id(object_id) {
let descriptor = ALL_CHANNELS
.iter()
.find(|desc| desc.id == channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let items = self.channel_items(*descriptor, 0, None).await?;
Ok(BrowseResult::Items(items))
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
}
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
Ok(BrowseResult::Mixed {
containers: vec![],
items: vec![],
})
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
let channel_id = parse_track_channel(object_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let channel = self
.channel(channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
channel
.cache_manager()
.resolve_uri(object_id)
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
.to_string(),
))
}
fn supports_fifo(&self) -> bool {
@@ -441,171 +149,25 @@ impl MusicSource for RadioParadiseSource {
}
async fn append_track(&self, _track: Item) -> Result<()> {
Err(MusicSourceError::FifoNotSupported)
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated and does not support FIFO operations."
.to_string(),
))
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
Ok(None)
}
async fn update_id(&self) -> u32 {
self.channels_iter()
.map(|(_, channel)| channel.playlist().update_id())
.max()
.unwrap_or(0)
0
}
async fn last_change(&self) -> Option<SystemTime> {
let mut latest: Option<SystemTime> = None;
for (_, channel) in self.channels_iter() {
if let Some(change) = channel.playlist().last_change().await {
latest = Some(match latest {
Some(current) if change <= current => current,
_ => change,
});
}
}
latest
None
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
let mut all = Vec::new();
for descriptor in ALL_CHANNELS.iter() {
let mut items = self.channel_items(*descriptor, 0, None).await?;
all.append(&mut items);
}
if offset >= all.len() {
return Ok(Vec::new());
}
let end = if count == 0 {
all.len()
} else {
(offset + count).min(all.len())
};
Ok(all.into_iter().skip(offset).take(end - offset).collect())
}
async fn get_available_formats(&self, _object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
Ok(vec![pmosource::AudioFormat {
format_id: "flac".to_string(),
mime_type: "audio/flac".to_string(),
sample_rate: Some(44100),
bit_depth: Some(16),
bitrate: None,
channels: Some(2),
}])
}
async fn get_cache_status(&self, object_id: &str) -> Result<CacheStatus> {
let channel_id = parse_track_channel(object_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let channel = self
.channel(channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
channel
.cache_manager()
.get_cache_status(object_id)
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
}
async fn cache_item(&self, object_id: &str) -> Result<CacheStatus> {
let channel_id = parse_track_channel(object_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let channel = self
.channel(channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
channel
.cache_manager()
.get_cache_status(object_id)
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
}
async fn browse_paginated(
&self,
object_id: &str,
offset: usize,
limit: usize,
) -> Result<BrowseResult> {
match object_id {
"0" => {
if offset == 0 {
Ok(BrowseResult::Containers(vec![self.build_root_container()]))
} else {
Ok(BrowseResult::Containers(Vec::new()))
}
}
"radio-paradise" => {
let containers = self.build_channel_containers().await;
let total = containers.len();
if offset >= total {
return Ok(BrowseResult::Containers(Vec::new()));
}
let end = if limit == 0 {
total
} else {
(offset + limit).min(total)
};
Ok(BrowseResult::Containers(
containers
.into_iter()
.skip(offset)
.take(end - offset)
.collect(),
))
}
_ => {
if let Some(channel_id) = parse_channel_container_id(object_id) {
let descriptor = ALL_CHANNELS
.iter()
.find(|desc| desc.id == channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let items = self.channel_items(*descriptor, offset, Some(limit)).await?;
Ok(BrowseResult::Items(items))
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
}
}
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
match object_id {
"0" => Ok(1),
"radio-paradise" => Ok(ALL_CHANNELS.len()),
_ => {
if let Some(channel_id) = parse_channel_container_id(object_id) {
let channel = self
.channel(channel_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
Ok(channel.playlist().active_len().await)
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
}
}
async fn statistics(&self) -> Result<SourceStatistics> {
let mut total_tracks = 0usize;
let mut cached_tracks = 0usize;
for (_, channel) in self.channels_iter() {
total_tracks += channel.playlist().active_len().await;
let stats = channel.cache_manager().statistics().await;
cached_tracks += stats.cached_tracks;
}
Ok(SourceStatistics {
total_items: Some(total_tracks),
total_containers: Some(ALL_CHANNELS.len() + 1),
cached_items: Some(cached_tracks),
cache_size_bytes: None,
})
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
Ok(vec![])
}
}

View File

@@ -1,178 +0,0 @@
//! Block streaming functionality
use crate::error::{Error, Result};
use crate::models::Block;
use crate::RadioParadiseClient;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use std::pin::Pin;
use std::task::{Context, Poll};
use url::Url;
/// A stream of audio data from a Radio Paradise block
///
/// This wraps the HTTP response body and provides a `Stream<Item = Result<Bytes>>`
/// that can be consumed by audio players or written to a file.
pub struct BlockStream {
inner: Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>,
}
impl BlockStream {
/// Create a new block stream from a reqwest response
pub(crate) fn new(stream: impl Stream<Item = Result<Bytes>> + Send + 'static) -> Self {
Self {
inner: Box::pin(stream),
}
}
/// Extract the inner stream
///
/// Consumes the BlockStream and returns the underlying pinned stream.
/// Useful for advanced streaming scenarios like progressive decoding.
pub fn into_inner(self) -> Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>> {
self.inner
}
}
impl Stream for BlockStream {
type Item = Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
impl RadioParadiseClient {
/// Stream a block from its URL
///
/// Returns a `Stream` of audio bytes that can be consumed by an audio player.
/// The stream will continue until the entire block is downloaded or an error occurs.
///
/// # Arguments
///
/// * `block_url` - The URL of the block to stream
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
/// use futures::StreamExt;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut stream = client.stream_block(&block.url.parse()?).await?;
///
/// while let Some(chunk) = stream.next().await {
/// let bytes = chunk?;
/// // Write bytes to audio player or file
/// println!("Received {} bytes", bytes.len());
/// }
///
/// Ok(())
/// }
/// ```
pub async fn stream_block(&self, block_url: &Url) -> Result<BlockStream> {
#[cfg(feature = "logging")]
tracing::debug!("Starting block stream: {}", block_url);
let response = self
.client
.get(block_url.clone())
.timeout(self.block_timeout)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::other(format!(
"Failed to stream block: HTTP {}",
response.status()
)));
}
// Convert reqwest's byte stream to our Result type
let stream = response.bytes_stream();
let mapped = futures::stream::StreamExt::map(stream, |result| result.map_err(Error::from));
Ok(BlockStream::new(mapped))
}
/// Stream a block directly from a Block struct
///
/// Convenience method that parses the URL from the block.
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
/// use futures::StreamExt;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut stream = client.stream_block_from_metadata(&block).await?;
///
/// while let Some(chunk) = stream.next().await {
/// let bytes = chunk?;
/// // Process bytes...
/// }
///
/// Ok(())
/// }
/// ```
pub async fn stream_block_from_metadata(&self, block: &Block) -> Result<BlockStream> {
let url = Url::parse(&block.url)?;
self.stream_block(&url).await
}
/// Download an entire block as Bytes
///
/// This downloads the complete block file into memory. For streaming playback,
/// use `stream_block()` instead which is more memory efficient.
///
/// # Arguments
///
/// * `block_url` - The URL of the block to download
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
/// let url = block.url.parse()?;
/// let bytes = client.download_block(&url).await?;
/// println!("Downloaded {} bytes", bytes.len());
/// Ok(())
/// }
/// ```
pub async fn download_block(&self, block_url: &Url) -> Result<Bytes> {
let mut stream = self.stream_block(block_url).await?;
let mut data = Vec::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
data.extend_from_slice(&chunk);
}
Ok(Bytes::from(data))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_block_stream_creation() {
let stream = futures::stream::once(async { Ok(Bytes::from("test")) });
let _block_stream = BlockStream::new(stream);
}
}

View File

@@ -1,217 +0,0 @@
use anyhow::Result;
use bytes::Bytes;
use futures::stream::Stream;
use std::io::{self, Read};
use std::pin::Pin;
use std::sync::mpsc::{sync_channel, Receiver, RecvError, SyncSender};
use std::time::{Duration, Instant};
const CHANNEL_BUFFER_SIZE: usize = 64; // Augmenté de 16 à 64 pour réduire les warnings "buffer plein"
pub const CHUNK_SIZE_FRAMES: usize = 4096;
pub struct ChannelReader {
receiver: Receiver<Result<Bytes, String>>,
current_chunk: Option<Bytes>,
position: usize,
}
impl ChannelReader {
pub fn new(
stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
) -> Self {
let (tx, rx) = sync_channel(CHANNEL_BUFFER_SIZE);
tokio::spawn(Self::stream_feeder(stream, tx));
Self {
receiver: rx,
current_chunk: None,
position: 0,
}
}
async fn stream_feeder(
mut stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
tx: SyncSender<Result<Bytes, String>>,
) {
use futures::StreamExt;
while let Some(result) = stream.next().await {
let start = Instant::now();
let to_send = result.map_err(|e| e.to_string());
match tx.try_send(to_send) {
Ok(_) => { /* message envoyé sans attente */ }
Err(std::sync::mpsc::TrySendError::Full(value)) => {
tracing::warn!("stream_feeder: buffer plein");
// Revenir à lenvoi bloquant pour ne pas perdre le message
if tx.send(value).is_err() {
break;
}
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => break,
}
let waited = start.elapsed();
tracing::trace!("stream_feeder send {:?}", waited);
if waited > Duration::from_millis(200) {
tracing::warn!("stream_feeder wait {:?}", waited);
}
}
}
}
impl Read for ChannelReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let start = Instant::now();
loop {
if let Some(chunk) = &self.current_chunk {
if self.position < chunk.len() {
let available = chunk.len() - self.position;
let to_copy = available.min(buf.len());
buf[..to_copy].copy_from_slice(&chunk[self.position..self.position + to_copy]);
self.position += to_copy;
tracing::trace!(
"ChannelReader copied {} bytes (elapsed {:?})",
to_copy,
start.elapsed()
);
return Ok(to_copy);
}
}
match self.receiver.recv() {
Ok(Ok(bytes)) => {
tracing::trace!(
"ChannelReader received chunk of {} bytes after {:?}",
bytes.len(),
start.elapsed()
);
self.current_chunk = Some(bytes);
self.position = 0;
}
Ok(Err(e)) => {
tracing::warn!("ChannelReader received error chunk: {}", e);
return Err(io::Error::new(io::ErrorKind::Other, e));
}
Err(RecvError) => {
tracing::trace!("ChannelReader stream closed after {:?}", start.elapsed());
return Ok(0);
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct PCMChunk {
pub samples: Vec<i32>,
pub position_ms: u64,
pub sample_rate: u32,
pub channels: u32,
}
pub struct StreamingPCMDecoder<R: Read> {
reader: claxon::FlacReader<std::io::BufReader<R>>,
sample_rate: u32,
channels: u32,
bits_per_sample: u32,
total_samples_decoded: u64,
done: bool,
}
impl StreamingPCMDecoder<ChannelReader> {
/// Create a new decoder from an HTTP stream with default chunk size
pub fn new(http_stream: crate::stream::BlockStream) -> anyhow::Result<Self> {
Self::with_chunk_size(http_stream, CHUNK_SIZE_FRAMES)
}
pub fn with_chunk_size(
http_stream: crate::stream::BlockStream,
_chunk_size: usize,
) -> anyhow::Result<Self> {
let channel_reader = ChannelReader::new(http_stream.into_inner());
let buffered = std::io::BufReader::new(channel_reader);
let reader = claxon::FlacReader::new(buffered)
.map_err(|e| anyhow::anyhow!("FLAC reader error: {}", e))?;
let info = reader.streaminfo();
Ok(Self {
reader,
sample_rate: info.sample_rate,
channels: info.channels,
bits_per_sample: info.bits_per_sample,
total_samples_decoded: 0,
done: false,
})
}
/// Get the sample rate (e.g., 44100 Hz)
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
/// Get the number of channels (e.g., 2 for stereo)
pub fn channels(&self) -> u32 {
self.channels
}
/// Get bits per sample (e.g., 16)
pub fn bits_per_sample(&self) -> u32 {
self.bits_per_sample
}
pub fn decode_chunk(&mut self) -> anyhow::Result<Option<PCMChunk>> {
if self.done {
return Ok(None);
}
// Crée le FrameReader à la volée (emprunt de self.reader)
let mut frames = self.reader.blocks();
// API claxon 0.6.x : il FAUT fournir un Vec<i32> par valeur
let buf: Vec<i32> = Vec::new();
let frame = match frames.read_next_or_eof(buf) {
Ok(None) => {
self.done = true;
return Ok(None);
}
Ok(Some(f)) => f,
Err(e) => return Err(anyhow::anyhow!("FLAC decode error: {}", e)),
};
let planar_samples: Vec<i32> = frame.into_buffer();
if planar_samples.is_empty() {
self.done = true;
return Ok(None);
}
// IMPORTANT: Claxon retourne les samples en format PLANAR (tous les L, puis tous les R)
// Mais nous avons besoin du format INTERLEAVED (L, R, L, R, ...) pour l'encodage
let block_size = planar_samples.len() / self.channels as usize;
let mut samples = Vec::with_capacity(planar_samples.len());
for i in 0..block_size {
for ch in 0..self.channels as usize {
samples.push(planar_samples[ch * block_size + i]);
}
}
let position_ms = {
let frames = self.total_samples_decoded / self.channels as u64;
(frames * 1000) / self.sample_rate as u64
};
self.total_samples_decoded += samples.len() as u64;
Ok(Some(PCMChunk {
samples,
position_ms,
sample_rate: self.sample_rate,
channels: self.channels,
}))
}
}
pub fn ms_to_frames(ms: u64, sample_rate: u32) -> usize {
((ms as u128 * sample_rate as u128) / 1000) as usize
}
pub fn frames_to_ms(frames: usize, sample_rate: u32) -> u64 {
((frames as u128 * 1000) / sample_rate as u128) as u64
}

View File

@@ -1,397 +0,0 @@
//! Per-track extraction from FLAC blocks (optional feature)
//!
//! **Important Notes:**
//!
//! Radio Paradise publishes *blocks* containing multiple songs, not individual
//! per-track files. This module provides experimental functionality to extract
//! individual tracks from FLAC blocks, but comes with significant tradeoffs:
//!
//! - **Storage**: Requires downloading the entire block (50-100MB) to disk
//! - **Latency**: Must download and decode before playback can start
//! - **CPU**: FLAC decoding is CPU-intensive
//! - **Complexity**: Seeking in FLAC requires decoding from the beginning
//!
//! ## Recommended Alternative
//!
//! For most use cases, it's better to:
//! 1. Stream the entire block to your audio player
//! 2. Use the `song[i].elapsed` metadata to seek within the player
//! 3. Let the player handle gapless transitions between tracks
//!
//! Modern players (mpv, VLC, ffmpeg) can seek in FLAC streams efficiently.
//!
//! ## When to Use This Module
//!
//! Only use per-track extraction when you need:
//! - Individual WAV files for further processing
//! - PCM data for custom audio analysis
//! - Separate files for non-streaming scenarios
//!
//! ## Block URL Pattern
//!
//! Blocks follow this URL pattern:
//! ```text
//! https://apps.radioparadise.com/blocks/chan/0/4/<start_event>-<end_event>.flac
//! ```
//!
//! The `song[i].elapsed` field (in milliseconds) indicates when each track
//! starts within the block.
#[cfg(feature = "per-track")]
use crate::error::{Error, Result};
#[cfg(feature = "per-track")]
use crate::models::Block;
#[cfg(feature = "per-track")]
use crate::RadioParadiseClient;
#[cfg(feature = "per-track")]
use std::io::Write;
#[cfg(feature = "per-track")]
use std::path::PathBuf;
/// Metadata for a decoded track stream
#[cfg(feature = "per-track")]
#[derive(Debug, Clone)]
pub struct TrackMetadata {
/// Sample rate in Hz (e.g., 44100)
pub sample_rate: u32,
/// Number of audio channels (1 = mono, 2 = stereo)
pub channels: u16,
/// Bits per sample (typically 16 or 24)
pub bits_per_sample: u16,
/// Total number of samples in this track
pub total_samples: u64,
}
/// A stream of decoded PCM audio for a single track
///
/// Provides access to decoded FLAC audio data for one track within a block.
/// The audio is decoded to 16-bit PCM format.
#[cfg(feature = "per-track")]
pub struct TrackStream {
/// Audio format metadata
pub metadata: TrackMetadata,
/// Path to the temporary FLAC file
temp_path: PathBuf,
/// FLAC reader
reader: Option<claxon::FlacReader<std::io::BufReader<std::fs::File>>>,
/// Current sample position
current_sample: u64,
/// End sample position (where this track ends)
end_sample: u64,
}
#[cfg(feature = "per-track")]
impl TrackStream {
/// Create a new track stream from a block
///
/// This will:
/// 1. Download the entire block to a temporary file
/// 2. Open it with a FLAC decoder
/// 3. Seek to the track's start position
/// 4. Prepare to decode samples
///
/// **Warning**: This is an expensive operation. Consider caching blocks.
async fn from_block_internal(
client: &RadioParadiseClient,
block: &Block,
track_index: usize,
) -> Result<Self> {
// Validate track index
let song = block
.get_song(track_index)
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
// Download block to temporary file
let url = block
.url
.parse()
.map_err(|e| Error::other(format!("Invalid block URL: {}", e)))?;
let block_data = client.download_block(&url).await?;
// Write to temp file
let mut temp_file = tempfile::NamedTempFile::new()?;
temp_file.write_all(&block_data)?;
temp_file.flush()?;
let temp_path = temp_file.into_temp_path();
let path_buf = temp_path.to_path_buf();
#[cfg(feature = "logging")]
tracing::debug!("Wrote block to temp file: {:?}", path_buf);
// Open FLAC reader
let file = std::fs::File::open(&path_buf)?;
let buffered = std::io::BufReader::new(file);
let mut reader = claxon::FlacReader::new(buffered)?;
let streaminfo = reader.streaminfo();
let sample_rate = streaminfo.sample_rate;
let channels = streaminfo.channels as u16;
let bits_per_sample = streaminfo.bits_per_sample as u16;
// Calculate start and end sample positions
let start_sample = Self::ms_to_samples(song.elapsed, sample_rate);
let duration_samples = Self::ms_to_samples(song.duration, sample_rate);
let end_sample = start_sample + duration_samples;
#[cfg(feature = "logging")]
tracing::debug!(
"Track {} spans samples {} to {} ({} ms to {} ms)",
track_index,
start_sample,
end_sample,
song.elapsed,
song.elapsed + song.duration
);
// Seek to start position by reading and discarding samples
// Note: FLAC doesn't support random access, so we must decode from beginning
if start_sample > 0 {
#[cfg(feature = "logging")]
tracing::debug!("Seeking to sample {}", start_sample);
Self::skip_samples(&mut reader, start_sample)?;
}
let metadata = TrackMetadata {
sample_rate,
channels,
bits_per_sample,
total_samples: duration_samples,
};
Ok(Self {
metadata,
temp_path: path_buf,
reader: Some(reader),
current_sample: start_sample,
end_sample,
})
}
/// Convert milliseconds to sample count
fn ms_to_samples(ms: u64, sample_rate: u32) -> u64 {
(ms * sample_rate as u64) / 1000
}
/// Skip samples by reading and discarding
fn skip_samples(
reader: &mut claxon::FlacReader<std::io::BufReader<std::fs::File>>,
count: u64,
) -> Result<()> {
let mut samples = reader.samples();
for _ in 0..count {
if samples.next().is_none() {
return Err(Error::other("Unexpected end of FLAC stream while seeking"));
}
}
Ok(())
}
/// Read decoded PCM samples
///
/// Returns samples as 16-bit signed integers (i16), interleaved by channel.
/// For stereo: [L, R, L, R, ...]. Returns None when track ends.
pub fn read_samples(&mut self, buffer: &mut [i16]) -> Result<Option<usize>> {
let reader = self
.reader
.as_mut()
.ok_or(Error::other("TrackStream already consumed"))?;
let mut samples_iter = reader.samples();
let mut count = 0;
for chunk in buffer.chunks_mut(self.metadata.channels as usize) {
if self.current_sample >= self.end_sample {
break;
}
// Read one sample per channel
for sample_slot in chunk.iter_mut() {
match samples_iter.next() {
Some(Ok(sample)) => {
// Claxon returns i32, convert to i16
*sample_slot = (sample >> (self.metadata.bits_per_sample - 16)) as i16;
count += 1;
}
Some(Err(e)) => {
return Err(Error::FlacDecode(e.to_string()));
}
None => {
return Ok(if count > 0 { Some(count) } else { None });
}
}
}
self.current_sample += 1;
}
Ok(if count > 0 { Some(count) } else { None })
}
/// Export track to a WAV file
///
/// Decodes the entire track and writes it as a WAV file.
///
/// # Example
///
/// ```no_run
/// # #[cfg(feature = "per-track")]
/// # {
/// use pmoparadise::RadioParadiseClient;
/// use std::path::Path;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut track_stream = client.open_track_stream(&block, 0).await?;
/// track_stream.export_wav(Path::new("track.wav"))?;
/// # Ok(())
/// # }
/// # }
/// ```
pub fn export_wav(&mut self, output_path: &std::path::Path) -> Result<()> {
let spec = hound::WavSpec {
channels: self.metadata.channels,
sample_rate: self.metadata.sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(output_path, spec)?;
let mut buffer = vec![0i16; 8192 * self.metadata.channels as usize];
#[cfg(feature = "logging")]
tracing::info!("Exporting track to WAV: {:?}", output_path);
loop {
match self.read_samples(&mut buffer)? {
Some(count) => {
for &sample in &buffer[..count] {
writer.write_sample(sample)?;
}
}
None => break,
}
}
writer.finalize()?;
#[cfg(feature = "logging")]
tracing::info!("Successfully exported WAV file");
Ok(())
}
}
#[cfg(feature = "per-track")]
impl Drop for TrackStream {
fn drop(&mut self) {
// Close reader before removing temp file
self.reader.take();
// Clean up temporary file
if let Err(_e) = std::fs::remove_file(&self.temp_path) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to remove temp file {:?}: {}", self.temp_path, _e);
}
}
}
#[cfg(feature = "per-track")]
impl RadioParadiseClient {
/// Open a stream for a specific track within a block
///
/// **Warning**: This downloads the entire block to a temporary file
/// and performs FLAC decoding. See module documentation for alternatives.
///
/// # Arguments
///
/// * `block` - The block containing the track
/// * `track_index` - Index of the track (0-based)
///
/// # Example
///
/// ```no_run
/// # #[cfg(feature = "per-track")]
/// # {
/// use pmoparadise::RadioParadiseClient;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// // Extract first track
/// let mut track = client.open_track_stream(&block, 0).await?;
/// println!("Track: {} Hz, {} channels",
/// track.metadata.sample_rate,
/// track.metadata.channels);
///
/// // Read some samples
/// let mut buffer = vec![0i16; 4096];
/// if let Some(count) = track.read_samples(&mut buffer)? {
/// println!("Read {} samples", count);
/// }
/// # Ok(())
/// # }
/// # }
/// ```
pub async fn open_track_stream(
&self,
block: &Block,
track_index: usize,
) -> Result<TrackStream> {
TrackStream::from_block_internal(self, block, track_index).await
}
/// Helper: Get track position in seconds for player-based seeking
///
/// Instead of downloading and decoding, you can pass this information
/// to your audio player for efficient seeking.
///
/// Returns (start_seconds, duration_seconds)
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let (start, duration) = client.track_position_seconds(&block, 1)?;
/// println!("Track 1 starts at {}s, duration {}s", start, duration);
/// println!("Play with: mpv --start={} --length={} {}", start, duration, block.url);
/// # Ok(())
/// # }
/// ```
pub fn track_position_seconds(&self, block: &Block, track_index: usize) -> Result<(f64, f64)> {
let song = block
.get_song(track_index)
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
let start_secs = song.elapsed as f64 / 1000.0;
let duration_secs = song.duration as f64 / 1000.0;
Ok((start_secs, duration_secs))
}
}
#[cfg(test)]
#[cfg(feature = "per-track")]
mod tests {
use super::*;
#[test]
fn test_ms_to_samples() {
assert_eq!(TrackStream::ms_to_samples(1000, 44100), 44100);
assert_eq!(TrackStream::ms_to_samples(500, 44100), 22050);
assert_eq!(TrackStream::ms_to_samples(0, 44100), 0);
}
}

View File

@@ -4,7 +4,7 @@ use crate::playlist::Playlist;
use crate::track::PlaylistTrack;
use crate::Result;
use pmocache::cache_trait::FileCache;
use pmodidl::{Container, Item, Resource};
use pmodidl::{Container, Item};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

View File

@@ -1,6 +1,5 @@
//! WriteHandle : accès exclusif en écriture à une playlist
use crate::playlist::core::PlaylistConfig;
use crate::playlist::record::Record;
use crate::playlist::Playlist;
use crate::Result;
@@ -185,7 +184,7 @@ impl WriteHandle {
// Créer la nouvelle playlist persistante
let manager = crate::manager::PlaylistManager();
let mut new_handle = manager.create_persistent_playlist(new_id).await?;
let new_handle = manager.create_persistent_playlist(new_id).await?;
// Copier le titre et la config
new_handle.set_title(title).await?;

View File

@@ -4,7 +4,6 @@ pub mod core;
pub mod record;
use self::core::{PlaylistConfig, PlaylistCore};
use self::record::Record;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Weak};
use std::time::SystemTime;

View File

@@ -25,11 +25,10 @@ use tracing::Level;
use tracing_subscriber::{
Registry,
filter::LevelFilter,
layer::{Filter, SubscriberExt},
layer::SubscriberExt,
reload,
util::SubscriberInitExt,
};
use utoipa::OpenApi;
/// Représente une entrée de log
#[derive(Debug, Clone, Serialize)]
@@ -440,7 +439,7 @@ fn level_to_levelfilter(level: Level) -> LevelFilter {
/// Crée le router pour l'API de gestion des logs
pub fn create_logs_router(log_state: LogState) -> axum::Router {
use axum::routing::{get, post};
use axum::routing::get;
axum::Router::new()
.route("/log_setup", get(log_setup_get).post(log_setup_post))
.with_state(log_state)

View File

@@ -122,10 +122,10 @@ impl SourceCacheManager {
let cache = self.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
if let Some(ref pk) = metadata.cached_audio_pk {
if let Some(ref _pk) = metadata.cached_audio_pk {
#[cfg(feature = "server")]
{
let url = pmoupnp::cache_registry::build_audio_url(pk, Some("stream"))
let url = pmoupnp::cache_registry::build_audio_url(_pk, Some("stream"))
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
return Ok(url);
}
@@ -147,7 +147,7 @@ impl SourceCacheManager {
let cache = self.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
if let Some(ref pk) = metadata.cached_audio_pk {
if let Some(ref _pk) = metadata.cached_audio_pk {
// TODO: Ajouter get_info() à AudioCache
// Pour l'instant, on retourne juste Cached sans taille
return Ok(CacheStatus::Cached { size_bytes: 0 });
@@ -181,10 +181,10 @@ impl SourceCacheManager {
/// # Returns
///
/// L'URL complète de l'image
pub fn cover_url(&self, pk: &str, size: Option<usize>) -> Result<String> {
pub fn cover_url(&self, _pk: &str, _size: Option<usize>) -> Result<String> {
#[cfg(feature = "server")]
{
pmoupnp::cache_registry::build_cover_url(pk, size)
pmoupnp::cache_registry::build_cover_url(_pk, _size)
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
}
#[cfg(not(feature = "server"))]

View File

@@ -1,10 +1,8 @@
use std::{
collections::{HashMap, HashSet},
env::var,
sync::Arc,
};
use bevy_reflect::Reflect;
use xmltree::{Element, XMLNode};
use crate::actions::{Action, ActionData, ActionInstance, ArgInstanceSet};

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use tracing::{info, trace};
use tracing::info;
use xmltree::{Element, XMLNode};
use crate::actions::{Action, ActionHandler, ActionInstance, Argument, ArgumentSet};

View File

@@ -17,21 +17,3 @@ impl From<std::io::Error> for ActionError {
ActionError::GeneralError(format!("IO error: {}", err))
}
}
#[derive(Error, Debug)]
pub enum ArgumentError {
#[error("Argument error: {0}")]
GeneralError(String),
#[error("Argument error: {0}")]
ArgumentError(String),
#[error("Set operation error: {0}")]
SetError(String),
}
impl From<std::io::Error> for ArgumentError {
fn from(err: std::io::Error) -> Self {
ArgumentError::GeneralError(format!("IO error: {}", err))
}
}

View File

@@ -47,21 +47,21 @@ impl UpnpObject for Service {
elem.children.push(XMLNode::Element(service_id));
// SCPDURL
let mut SCPDURL = Element::new("SCPDURL");
SCPDURL.children.push(XMLNode::Text(self.scpd_route()));
elem.children.push(XMLNode::Element(SCPDURL));
let mut scpdurl = Element::new("SCPDURL");
scpdurl.children.push(XMLNode::Text(self.scpd_route()));
elem.children.push(XMLNode::Element(scpdurl));
// controlURL
let mut controlURL = Element::new("controlURL");
controlURL
let mut control_url = Element::new("controlURL");
control_url
.children
.push(XMLNode::Text(self.control_route()));
elem.children.push(XMLNode::Element(controlURL));
elem.children.push(XMLNode::Element(control_url));
// eventSubURL
let mut eventSubURL = Element::new("eventSubURL");
eventSubURL.children.push(XMLNode::Text(self.event_route()));
elem.children.push(XMLNode::Element(eventSubURL));
let mut event_sub_url = Element::new("eventSubURL");
event_sub_url.children.push(XMLNode::Text(self.event_route()));
elem.children.push(XMLNode::Element(event_sub_url));
elem
}