refactor: Remove obsolete streaming API (stream.rs, track.rs, per-track feature)
The old streaming API has been completely replaced by RadioParadiseStreamSource which integrates directly with the pmoaudio pipeline. Removed: - src/stream.rs (179 lines) - BlockStream, stream_block(), download_block() - src/track.rs - Per-track extraction functionality - examples/stream_block.rs - Obsolete streaming example - examples/extract_track.rs - Per-track extraction example - Feature "per-track" and dependencies (hound, tempfile) Updated: - Cargo.toml: Removed per-track feature and obsolete examples - lib.rs: Removed module declarations and re-exports The new RadioParadiseStreamSource provides: - Direct integration with pmoaudio pipeline - FLAC decoding via pmoflac - Automatic TrackBoundary insertion - Better performance and lower latency
This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -1472,12 +1472,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "htmlescape"
|
||||
version = "0.3.1"
|
||||
@@ -2884,7 +2878,6 @@ dependencies = [
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hound",
|
||||
"pmoaudio",
|
||||
"pmoaudiocache",
|
||||
"pmoconfig",
|
||||
@@ -2901,7 +2894,6 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
|
||||
@@ -51,10 +51,6 @@ symphonia = { version = "0.5", features = ["all"] }
|
||||
# Audio decoding - claxon for FLAC streaming
|
||||
claxon = "0.4"
|
||||
|
||||
# Per-track feature dependencies
|
||||
hound = { version = "3.5", optional = true }
|
||||
tempfile = { version = "3.8", optional = true }
|
||||
|
||||
# Common music source traits
|
||||
pmosource = { path = "../pmosource" }
|
||||
|
||||
@@ -81,8 +77,6 @@ futures-util = { version = "0.3", optional = true }
|
||||
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)
|
||||
@@ -107,17 +101,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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¤t_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(¤t_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(())
|
||||
}
|
||||
@@ -215,10 +215,6 @@ pub mod client;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod source;
|
||||
pub mod stream;
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
pub mod track;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
@@ -234,14 +230,10 @@ 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 = "pmoaudio")]
|
||||
pub use radio_paradise_stream_source::RadioParadiseStreamSource;
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
pub use track::{TrackMetadata, TrackStream};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use pmoserver_ext::{
|
||||
create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user