Corrections mineurs sur pmoflac

This commit is contained in:
2025-11-02 08:18:50 +01:00
parent 0cd2b6a64a
commit e743c8affe
8 changed files with 388 additions and 90 deletions

3
Cargo.lock generated
View File

@@ -2734,11 +2734,14 @@ dependencies = [
"lewton",
"libc",
"libflac-sys",
"lofty",
"minimp3",
"opus",
"pmometadata",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]

View File

@@ -13,13 +13,16 @@ path = "src/lib.rs"
[dependencies]
bytes = "1.6"
claxon = "0.4"
lewton = "0.10"
libc = "0.2"
libflac-sys = { version = "0.3.3", default-features = false, features = ["build-flac"] }
lofty = "0.22"
minimp3 = "0.5"
opus = "0.3"
pmometadata = { path = "../pmometadata" }
thiserror = "1.0"
tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros", "sync", "io-util", "fs"] }
minimp3 = "0.5"
lewton = "0.10"
opus = "0.3"
tracing = "0.1"
[dev-dependencies]
tempfile = "3.10"

View File

@@ -1,5 +1,5 @@
use std::{
cmp, io,
io,
pin::Pin,
task::{Context, Poll},
};
@@ -8,9 +8,9 @@ use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
use crate::{
decode_aiff_stream, decode_flac_stream, decode_mp3_stream, decode_ogg_opus_stream,
decode_ogg_vorbis_stream, decode_wav_stream, pcm::StreamInfo, AiffDecodedStream, AiffError,
FlacDecodedStream, FlacError, Mp3DecodedStream, Mp3Error, OggDecodedStream, OggError,
OggOpusDecodedStream, OggOpusError, WavDecodedStream, WavError,
decode_ogg_vorbis_stream, decode_wav_stream, pcm::StreamInfo, prefixed_reader::PrefixedReader,
AiffDecodedStream, AiffError, FlacDecodedStream, FlacError, Mp3DecodedStream, Mp3Error,
OggDecodedStream, OggError, OggOpusDecodedStream, OggOpusError, WavDecodedStream, WavError,
};
const MAX_SNIFF_BYTES: usize = 64 * 1024;
@@ -290,39 +290,3 @@ enum DetectedFormat {
Wav,
Aiff,
}
struct PrefixedReader<R> {
prefix: Vec<u8>,
position: usize,
reader: R,
}
impl<R> PrefixedReader<R> {
fn new(prefix: Vec<u8>, reader: R) -> Self {
Self {
prefix,
position: 0,
reader,
}
}
}
impl<R: AsyncRead + Unpin> AsyncRead for PrefixedReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if self.position < self.prefix.len() && buf.remaining() > 0 {
let remaining = self.prefix.len() - self.position;
let to_copy = cmp::min(remaining, buf.remaining());
buf.put_slice(&self.prefix[self.position..self.position + to_copy]);
self.position += to_copy;
return Poll::Ready(Ok(()));
}
Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
impl<R: Unpin> Unpin for PrefixedReader<R> {}
unsafe impl<R: Send> Send for PrefixedReader<R> {}

View File

@@ -102,11 +102,13 @@ pub mod decoder;
mod decoder_common;
pub mod encoder;
pub mod error;
pub mod metadata;
pub mod mp3;
pub mod ogg;
mod ogg_common;
pub mod opus;
mod pcm;
mod prefixed_reader;
mod stream;
pub mod transcode;
mod util;
@@ -117,6 +119,7 @@ pub use autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream,
pub use decoder::{decode_flac_stream, FlacDecodedStream};
pub use encoder::{encode_flac_stream, EncoderOptions, FlacEncodedStream};
pub use error::FlacError;
pub use metadata::AudioFileMetadata;
pub use mp3::{decode_mp3_stream, Mp3DecodedStream, Mp3Error};
pub use ogg::{decode_ogg_vorbis_stream, OggDecodedStream, OggError};
pub use opus::{decode_ogg_opus_stream, OggOpusDecodedStream, OggOpusError};

297
pmoflac/src/metadata.rs Normal file
View File

@@ -0,0 +1,297 @@
//! Audio file metadata extraction using lofty.
//!
//! This module provides utilities to extract both technical audio properties
//! (sample rate, channels, bit depth) and artistic tags (title, artist, album)
//! from audio files in various formats.
//!
//! # Examples
//!
//! ```no_run
//! use pmoflac::AudioFileMetadata;
//! use std::path::Path;
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let metadata = AudioFileMetadata::from_file(Path::new("audio.flac"))?;
//!
//! println!("Title: {:?}", metadata.title);
//! println!("Artist: {:?}", metadata.artist);
//! println!("Sample rate: {:?} Hz", metadata.sample_rate);
//! println!("Duration: {:?} seconds", metadata.duration_secs);
//! # Ok(())
//! # }
//! ```
use lofty::{config::ParseOptions, prelude::*, probe::Probe};
use std::{io::Cursor, path::Path};
/// Comprehensive audio file metadata including both technical properties and artistic tags.
///
/// This struct is populated from audio file tags using the lofty library,
/// which supports FLAC, MP3, Ogg Vorbis, Opus, WAV, AIFF, and other formats.
#[derive(Debug, Clone, Default)]
pub struct AudioFileMetadata {
// Artistic tags
/// Track title
pub title: Option<String>,
/// Artist name
pub artist: Option<String>,
/// Album name
pub album: Option<String>,
/// Year of release
pub year: Option<u32>,
/// Music genre
pub genre: Option<String>,
/// Track number in the album
pub track_number: Option<u32>,
/// Total number of tracks in the album
pub track_total: Option<u32>,
/// Disc number (for multi-disc albums)
pub disc_number: Option<u32>,
/// Total number of discs
pub disc_total: Option<u32>,
// Technical audio properties
/// Duration in seconds
pub duration_secs: Option<u64>,
/// Sample rate in Hz (e.g., 44100, 48000)
pub sample_rate: Option<u32>,
/// Number of audio channels (1 = mono, 2 = stereo, etc.)
pub channels: Option<u8>,
/// Bitrate in bits per second
pub bitrate: Option<u32>,
}
impl AudioFileMetadata {
/// Extracts metadata from an audio file on disk.
///
/// # Arguments
///
/// * `path` - Path to the audio file
///
/// # Returns
///
/// Returns `AudioFileMetadata` on success, or a `lofty::error::LoftyError` if
/// the file cannot be read or parsed.
///
/// # Examples
///
/// ```no_run
/// use pmoflac::AudioFileMetadata;
/// use std::path::Path;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let metadata = AudioFileMetadata::from_file(Path::new("song.flac"))?;
/// println!("Duration: {:?}s", metadata.duration_secs);
/// # Ok(())
/// # }
/// ```
pub fn from_file(path: &Path) -> Result<Self, lofty::error::LoftyError> {
tracing::debug!(path = %path.display(), "Extracting metadata from file");
let tagged_file = Probe::open(path)?.options(ParseOptions::new()).read()?;
let metadata = Self::from_tagged_file(tagged_file);
tracing::debug!(
path = %path.display(),
title = ?metadata.title,
artist = ?metadata.artist,
duration_secs = ?metadata.duration_secs,
"Metadata extracted successfully"
);
Ok(metadata)
}
/// Extracts metadata from audio file bytes in memory.
///
/// # Arguments
///
/// * `data` - Audio file data as a byte slice
///
/// # Returns
///
/// Returns `AudioFileMetadata` on success, or a `lofty::error::LoftyError` if
/// the data cannot be parsed.
///
/// # Examples
///
/// ```no_run
/// use pmoflac::AudioFileMetadata;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let audio_bytes: &[u8] = &[/* ... */];
/// let metadata = AudioFileMetadata::from_bytes(audio_bytes)?;
/// # Ok(())
/// # }
/// ```
pub fn from_bytes(data: &[u8]) -> Result<Self, lofty::error::LoftyError> {
tracing::debug!(size = data.len(), "Extracting metadata from bytes");
let cursor = Cursor::new(data);
let tagged_file = Probe::new(cursor)
.guess_file_type()?
.options(ParseOptions::new())
.read()?;
Ok(Self::from_tagged_file(tagged_file))
}
/// Internal helper to extract metadata from a lofty `TaggedFile`.
fn from_tagged_file(tagged_file: lofty::file::TaggedFile) -> Self {
let properties = tagged_file.properties();
// Try to get the primary tag, or fall back to the first available tag
let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag());
// Initialize with technical properties
let mut metadata = Self {
title: None,
artist: None,
album: None,
year: None,
genre: None,
track_number: None,
track_total: None,
disc_number: None,
disc_total: None,
duration_secs: Some(properties.duration().as_secs()),
sample_rate: properties.sample_rate(),
channels: properties.channels(),
bitrate: properties.audio_bitrate(),
};
// Extract artistic tags if available
if let Some(tag) = tag {
metadata.title = tag.title().map(|s| s.to_string());
metadata.artist = tag.artist().map(|s| s.to_string());
metadata.album = tag.album().map(|s| s.to_string());
metadata.year = tag.year();
metadata.genre = tag.genre().map(|s| s.to_string());
metadata.track_number = tag.track();
metadata.track_total = tag.track_total();
metadata.disc_number = tag.disk();
metadata.disc_total = tag.disk_total();
} else {
tracing::warn!("No tags found in audio file");
}
metadata
}
/// Returns a formatted duration string in H:MM:SS format (DIDL-Lite compatible).
///
/// # Examples
///
/// ```
/// use pmoflac::AudioFileMetadata;
///
/// let mut metadata = AudioFileMetadata {
/// duration_secs: Some(3665), // 1 hour, 1 minute, 5 seconds
/// title: None,
/// artist: None,
/// album: None,
/// year: None,
/// genre: None,
/// track_number: None,
/// track_total: None,
/// disc_number: None,
/// disc_total: None,
/// sample_rate: None,
/// channels: None,
/// bitrate: None,
/// };
///
/// assert_eq!(metadata.duration_formatted(), "1:01:05");
/// ```
pub fn duration_formatted(&self) -> String {
match self.duration_secs {
Some(secs) => {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
format!("{}:{:02}:{:02}", hours, minutes, seconds)
}
None => "0:00:00".to_string(),
}
}
/// Returns a collection key in the format "artist:album" for grouping tracks.
///
/// This is useful for organizing tracks into albums in a music library.
///
/// # Examples
///
/// ```
/// use pmoflac::AudioFileMetadata;
///
/// let metadata = AudioFileMetadata {
/// artist: Some("Pink Floyd".to_string()),
/// album: Some("The Dark Side of the Moon".to_string()),
/// title: None,
/// year: None,
/// genre: None,
/// track_number: None,
/// track_total: None,
/// disc_number: None,
/// disc_total: None,
/// duration_secs: None,
/// sample_rate: None,
/// channels: None,
/// bitrate: None,
/// };
///
/// assert_eq!(metadata.collection_key(), "Pink Floyd:The Dark Side of the Moon");
/// ```
pub fn collection_key(&self) -> String {
match (&self.artist, &self.album) {
(Some(artist), Some(album)) => format!("{}:{}", artist, album),
(Some(artist), None) => artist.clone(),
(None, Some(album)) => album.clone(),
(None, None) => "Unknown".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duration_formatted() {
let mut meta = AudioFileMetadata::default();
meta.duration_secs = Some(65);
assert_eq!(meta.duration_formatted(), "0:01:05");
meta.duration_secs = Some(3665);
assert_eq!(meta.duration_formatted(), "1:01:05");
meta.duration_secs = Some(0);
assert_eq!(meta.duration_formatted(), "0:00:00");
meta.duration_secs = None;
assert_eq!(meta.duration_formatted(), "0:00:00");
}
#[test]
fn test_collection_key() {
let mut meta = AudioFileMetadata::default();
meta.artist = Some("Artist".to_string());
meta.album = Some("Album".to_string());
assert_eq!(meta.collection_key(), "Artist:Album");
meta.album = None;
assert_eq!(meta.collection_key(), "Artist");
meta.artist = None;
meta.album = Some("Album".to_string());
assert_eq!(meta.collection_key(), "Album");
meta.album = None;
assert_eq!(meta.collection_key(), "Unknown");
}
}

View File

@@ -66,9 +66,9 @@ impl PcmFormat {
384000,
];
if !STANDARD_RATES.contains(&self.sample_rate) {
eprintln!(
"Warning: non-standard sample rate {} Hz (valid but unusual)",
self.sample_rate
tracing::warn!(
sample_rate = %self.sample_rate,
"non-standard sample rate (valid but unusual)"
);
}
@@ -79,18 +79,18 @@ impl PcmFormat {
// FLAC officially supports 4-32 bits/sample
if self.bits_per_sample < 4 {
eprintln!(
"Warning: bits_per_sample={} is less than 4 (unusual for FLAC)",
self.bits_per_sample
tracing::warn!(
bits_per_sample = %self.bits_per_sample,
"bits_per_sample is less than 4 (unusual for FLAC)"
);
}
// Warn about common bit depths
const COMMON_BIT_DEPTHS: &[u8] = &[8, 16, 24, 32];
if !COMMON_BIT_DEPTHS.contains(&self.bits_per_sample) {
eprintln!(
"Warning: non-standard bit depth {} (valid but unusual)",
self.bits_per_sample
tracing::warn!(
bits_per_sample = %self.bits_per_sample,
"non-standard bit depth (valid but unusual)"
);
}

View File

@@ -0,0 +1,64 @@
//! Utility for reading from a prefix buffer followed by an underlying reader.
//!
//! This module provides `PrefixedReader`, which allows reading from an in-memory
//! buffer first, then seamlessly continuing with an underlying async reader.
//!
//! This is particularly useful for format detection where we need to peek at the
//! beginning of a stream before processing it.
use std::{
cmp, io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, ReadBuf};
/// An async reader that reads from a prefix buffer before delegating to an underlying reader.
///
/// This is useful when you've already consumed some bytes from a stream (e.g., for format
/// detection) and need to replay them before continuing with the rest of the stream.
pub(crate) struct PrefixedReader<R> {
prefix: Vec<u8>,
position: usize,
reader: R,
}
impl<R> PrefixedReader<R> {
/// Creates a new `PrefixedReader` with the given prefix and underlying reader.
///
/// # Arguments
///
/// * `prefix` - Bytes to read first before delegating to the reader
/// * `reader` - The underlying async reader to use after the prefix is exhausted
pub fn new(prefix: Vec<u8>, reader: R) -> Self {
Self {
prefix,
position: 0,
reader,
}
}
}
impl<R: AsyncRead + Unpin> AsyncRead for PrefixedReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
// If we still have bytes in the prefix, read from there first
if self.position < self.prefix.len() && buf.remaining() > 0 {
let remaining = self.prefix.len() - self.position;
let to_copy = cmp::min(remaining, buf.remaining());
buf.put_slice(&self.prefix[self.position..self.position + to_copy]);
self.position += to_copy;
return Poll::Ready(Ok(()));
}
// Prefix exhausted, delegate to underlying reader
Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
impl<R: Unpin> Unpin for PrefixedReader<R> {}
unsafe impl<R: Send> Send for PrefixedReader<R> {}

View File

@@ -15,7 +15,8 @@ use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
use crate::{
autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream},
encode_flac_stream, EncoderOptions, FlacEncodedStream, FlacError, PcmFormat, StreamInfo,
encode_flac_stream, prefixed_reader::PrefixedReader, EncoderOptions, FlacEncodedStream,
FlacError, PcmFormat, StreamInfo,
};
const READ_CHUNK: usize = 4096;
@@ -306,43 +307,6 @@ fn parse_flac_stream_info(block: &[u8]) -> Option<StreamInfo> {
})
}
struct PrefixedReader<R> {
prefix: Vec<u8>,
position: usize,
reader: R,
}
impl<R> PrefixedReader<R> {
fn new(prefix: Vec<u8>, reader: R) -> Self {
Self {
prefix,
position: 0,
reader,
}
}
}
unsafe impl<R: Send> Send for PrefixedReader<R> {}
impl<R: Unpin> Unpin for PrefixedReader<R> {}
impl<R: AsyncRead + Unpin> AsyncRead for PrefixedReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if self.position < self.prefix.len() && buf.remaining() > 0 {
let remaining = self.prefix.len() - self.position;
let to_copy = remaining.min(buf.remaining());
buf.put_slice(&self.prefix[self.position..self.position + to_copy]);
self.position += to_copy;
return Poll::Ready(Ok(()));
}
Pin::new(&mut self.reader).poll_read(cx, buf)
}
}
#[cfg(test)]
mod tests {
use super::*;