Refactoring de pmoflac -factorisation de code ogg et opus
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
//! Ce module utilise la macro `define_metadata_properties!` de pmocache
|
||||
//! pour générer automatiquement des méthodes d'accès typées aux métadonnées audio.
|
||||
|
||||
use pmocache::define_metadata_properties;
|
||||
use crate::AudioConfig;
|
||||
use pmocache::define_metadata_properties;
|
||||
|
||||
// Génération automatique du trait AudioMetadataExt avec toutes les propriétés audio
|
||||
define_metadata_properties! {
|
||||
|
||||
@@ -150,7 +150,10 @@ async fn stream_flac_to_flac(
|
||||
.await
|
||||
.map_err(|e| format!("Encoder/Decoder error: {}", e))?;
|
||||
|
||||
tracing::debug!("Streaming FLAC conversion complete: {} bytes", total_written);
|
||||
tracing::debug!(
|
||||
"Streaming FLAC conversion complete: {} bytes",
|
||||
total_written
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -182,9 +185,7 @@ async fn buffer_and_convert_to_flac(
|
||||
// 2. Si c'est déjà du FLAC, on l'écrit directement
|
||||
if buffer.len() >= 4 && &buffer[0..4] == b"fLaC" {
|
||||
tracing::debug!("Input is already FLAC, writing directly");
|
||||
file.write_all(&buffer)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
file.write_all(&buffer).await.map_err(|e| e.to_string())?;
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
progress(buffer.len() as u64);
|
||||
return Ok(());
|
||||
@@ -193,11 +194,10 @@ async fn buffer_and_convert_to_flac(
|
||||
tracing::debug!("Converting to FLAC with Symphonia + pmoflac");
|
||||
|
||||
// 3. Décoder l'audio avec Symphonia (dans un blocking task car c'est CPU-intensive)
|
||||
let (samples, channels, sample_rate, bits_per_sample) = tokio::task::spawn_blocking(move || {
|
||||
decode_with_symphonia_sync(buffer)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Decode task panicked: {}", e))??;
|
||||
let (samples, channels, sample_rate, bits_per_sample) =
|
||||
tokio::task::spawn_blocking(move || decode_with_symphonia_sync(buffer))
|
||||
.await
|
||||
.map_err(|e| format!("Decode task panicked: {}", e))??;
|
||||
|
||||
tracing::debug!(
|
||||
"Decoded {} samples, {} channels, {} Hz, {} bits",
|
||||
@@ -341,9 +341,7 @@ fn decode_with_symphonia_sync(buffer: Vec<u8>) -> Result<(Vec<i32>, usize, u32,
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -490,10 +488,7 @@ impl tokio::io::AsyncRead for StreamToAsyncRead {
|
||||
self.chunk_offset = 0;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e,
|
||||
)));
|
||||
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// Stream terminé
|
||||
|
||||
@@ -141,8 +141,7 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
///
|
||||
/// `true` si l'entrée existe en base de données et que le fichier est présent
|
||||
fn is_valid_pk(&self, pk: &str) -> bool {
|
||||
self.get_database().get(pk, false).is_ok()
|
||||
&& self.file_path(pk).exists()
|
||||
self.get_database().get(pk, false).is_ok() && self.file_path(pk).exists()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,4 +175,3 @@ pub fn pk_from_content_header(header: &[u8]) -> String {
|
||||
let result = hasher.finalize();
|
||||
hex::encode(&result[..16]) // 16 octets = 32 caractères hex
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ pub enum DecodeAudioError {
|
||||
#[error("MP3 decode error: {0}")]
|
||||
Mp3(#[from] Mp3Error),
|
||||
#[error("Ogg/Vorbis decode error: {0}")]
|
||||
Vorbis(#[from] OggError),
|
||||
Vorbis(OggError),
|
||||
#[error("Ogg/Opus decode error: {0}")]
|
||||
Opus(#[from] OggOpusError),
|
||||
Opus(OggOpusError),
|
||||
#[error("WAV decode error: {0}")]
|
||||
Wav(#[from] WavError),
|
||||
#[error("AIFF decode error: {0}")]
|
||||
@@ -67,11 +67,15 @@ where
|
||||
DecodedAudioStream::Mp3(stream)
|
||||
}
|
||||
DetectedFormat::OggVorbis => {
|
||||
let stream = decode_ogg_vorbis_stream(prefixed).await?;
|
||||
let stream = decode_ogg_vorbis_stream(prefixed)
|
||||
.await
|
||||
.map_err(DecodeAudioError::Vorbis)?;
|
||||
DecodedAudioStream::OggVorbis(stream)
|
||||
}
|
||||
DetectedFormat::OggOpus => {
|
||||
let stream = decode_ogg_opus_stream(prefixed).await?;
|
||||
let stream = decode_ogg_opus_stream(prefixed)
|
||||
.await
|
||||
.map_err(DecodeAudioError::Opus)?;
|
||||
DecodedAudioStream::OggOpus(stream)
|
||||
}
|
||||
DetectedFormat::Wav => {
|
||||
@@ -113,10 +117,10 @@ impl DecodedAudioStream {
|
||||
DecodedAudioStream::Flac(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedAudioStream::Mp3(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedAudioStream::OggVorbis(inner) => {
|
||||
inner.wait().await.map_err(DecodeAudioError::from)
|
||||
inner.wait().await.map_err(DecodeAudioError::Vorbis)
|
||||
}
|
||||
DecodedAudioStream::OggOpus(inner) => {
|
||||
inner.wait().await.map_err(DecodeAudioError::from)
|
||||
inner.wait().await.map_err(DecodeAudioError::Opus)
|
||||
}
|
||||
DecodedAudioStream::Wav(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedAudioStream::Aiff(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
@@ -184,8 +188,8 @@ impl DecodedReader {
|
||||
match self {
|
||||
DecodedReader::Flac(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedReader::Mp3(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedReader::OggVorbis(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedReader::OggOpus(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedReader::OggVorbis(inner) => inner.wait().await.map_err(DecodeAudioError::Vorbis),
|
||||
DecodedReader::OggOpus(inner) => inner.wait().await.map_err(DecodeAudioError::Opus),
|
||||
DecodedReader::Wav(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
DecodedReader::Aiff(inner) => inner.wait().await.map_err(DecodeAudioError::from),
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ pub mod encoder;
|
||||
pub mod error;
|
||||
pub mod mp3;
|
||||
pub mod ogg;
|
||||
mod ogg_common;
|
||||
pub mod opus;
|
||||
mod pcm;
|
||||
mod stream;
|
||||
|
||||
@@ -102,8 +102,7 @@
|
||||
//! ```
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Read},
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
@@ -121,56 +120,23 @@ use tokio::{
|
||||
use crate::{
|
||||
common::ChannelReader,
|
||||
decoder_common::{spawn_ingest_task, spawn_writer_task, CHANNEL_CAPACITY, DUPLEX_BUFFER_SIZE},
|
||||
ogg_common::{OggContainerError, OggPacketReader, OggReaderOptions},
|
||||
pcm::StreamInfo,
|
||||
stream::ManagedAsyncReader,
|
||||
};
|
||||
|
||||
/// Maximum number of bytes to scan when searching for Ogg sync pattern.
|
||||
///
|
||||
/// This prevents unbounded memory growth when processing streams with
|
||||
/// large amounts of garbage data before the first valid Ogg page.
|
||||
const MAX_SYNC_SEARCH: usize = 64 * 1024;
|
||||
/// Shared error alias for the Vorbis decoder.
|
||||
pub type OggError = OggContainerError;
|
||||
|
||||
/// Errors that can occur while decoding Ogg/Vorbis data.
|
||||
#[derive(thiserror::Error, Debug, Clone)]
|
||||
pub enum OggError {
|
||||
#[error("I/O error ({kind:?}): {message}")]
|
||||
Io {
|
||||
kind: io::ErrorKind,
|
||||
message: String,
|
||||
},
|
||||
#[error("Ogg/Vorbis decode error: {0}")]
|
||||
Decode(String),
|
||||
#[error("internal channel closed unexpectedly")]
|
||||
ChannelClosed,
|
||||
#[error("{role} task failed: {details}")]
|
||||
TaskJoin { role: &'static str, details: String },
|
||||
}
|
||||
|
||||
impl From<io::Error> for OggError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
OggError::Io {
|
||||
kind: err.kind(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<header::HeaderReadError> for OggError {
|
||||
impl From<header::HeaderReadError> for OggContainerError {
|
||||
fn from(err: header::HeaderReadError) -> Self {
|
||||
OggError::Decode(err.to_string())
|
||||
OggContainerError::Decode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<audio::AudioReadError> for OggError {
|
||||
impl From<audio::AudioReadError> for OggContainerError {
|
||||
fn from(err: audio::AudioReadError) -> Self {
|
||||
OggError::Decode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for OggError {
|
||||
fn from(value: String) -> Self {
|
||||
OggError::Decode(value)
|
||||
OggContainerError::Decode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,8 +228,8 @@ where
|
||||
let (info_tx, info_rx) = oneshot::channel::<Result<StreamInfo, OggError>>();
|
||||
|
||||
let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), OggError> {
|
||||
let channel_reader = ChannelReader::<OggError>::new(ingest_rx);
|
||||
let mut packet_reader = StreamingPacketReader::new(channel_reader);
|
||||
let channel_reader = ChannelReader::<OggContainerError>::new(ingest_rx);
|
||||
let mut packet_reader = OggPacketReader::new(channel_reader, OggReaderOptions::default());
|
||||
|
||||
// Read Vorbis headers (3 packets: identification, comment, setup)
|
||||
let ident_packet = packet_reader
|
||||
@@ -346,314 +312,3 @@ where
|
||||
|
||||
Ok(OggDecodedStream { info, reader })
|
||||
}
|
||||
|
||||
/// Streaming packet reader that assembles Vorbis packets from Ogg pages.
|
||||
///
|
||||
/// This reader parses Ogg pages manually without requiring seek operations,
|
||||
/// making it suitable for truly streaming scenarios. It handles:
|
||||
/// - Searching for Ogg sync pattern ("OggS")
|
||||
/// - Parsing page headers and segment tables
|
||||
/// - Validating CRC32 checksums
|
||||
/// - Assembling multi-page packets
|
||||
/// - Detecting end-of-stream
|
||||
struct StreamingPacketReader<E>
|
||||
where
|
||||
E: std::error::Error + std::fmt::Display,
|
||||
{
|
||||
reader: ChannelReader<E>,
|
||||
current_packet: Vec<u8>,
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
finished: bool,
|
||||
eos_seen: bool,
|
||||
stream_serial: Option<u32>,
|
||||
sync_buffer: Vec<u8>,
|
||||
synced: bool,
|
||||
}
|
||||
|
||||
impl<E> StreamingPacketReader<E>
|
||||
where
|
||||
E: std::error::Error + std::fmt::Display,
|
||||
{
|
||||
fn new(reader: ChannelReader<E>) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
current_packet: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
finished: false,
|
||||
eos_seen: false,
|
||||
stream_serial: None,
|
||||
sync_buffer: Vec::new(),
|
||||
synced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next complete Vorbis packet, or None if the stream has ended.
|
||||
fn next_packet(&mut self) -> Result<Option<Vec<u8>>, OggError> {
|
||||
loop {
|
||||
if let Some(packet) = self.queue.pop_front() {
|
||||
return Ok(Some(packet));
|
||||
}
|
||||
if self.finished {
|
||||
return Ok(None);
|
||||
}
|
||||
self.read_page()?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads bytes, first from sync_buffer then from the underlying reader.
|
||||
fn read_bytes(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
|
||||
// First, consume from sync_buffer
|
||||
if !self.sync_buffer.is_empty() {
|
||||
let to_copy = buf.len().min(self.sync_buffer.len());
|
||||
buf[..to_copy].copy_from_slice(&self.sync_buffer[..to_copy]);
|
||||
self.sync_buffer.drain(..to_copy);
|
||||
total += to_copy;
|
||||
if total == buf.len() {
|
||||
return Ok(total);
|
||||
}
|
||||
}
|
||||
|
||||
// Then read from underlying reader
|
||||
while total < buf.len() {
|
||||
match Read::read(&mut self.reader, &mut buf[total..])? {
|
||||
0 => break,
|
||||
n => total += n,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Reads exactly buf.len() bytes or returns error.
|
||||
fn read_exact_from_source(&mut self, buf: &mut [u8]) -> Result<bool, OggError> {
|
||||
let mut offset = 0;
|
||||
while offset < buf.len() {
|
||||
let n = self.read_bytes(&mut buf[offset..])?;
|
||||
if n == 0 {
|
||||
return if offset == 0 {
|
||||
Ok(false) // Clean EOF
|
||||
} else {
|
||||
Err(OggError::Decode("unexpected EOF while reading page".into()))
|
||||
};
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Searches for the Ogg sync pattern ("OggS") in the stream.
|
||||
///
|
||||
/// This is called before reading the first page to handle streams that
|
||||
/// have garbage bytes at the beginning (e.g., HTTP headers, ID3 tags).
|
||||
/// It buffers up to MAX_SYNC_SEARCH bytes while searching.
|
||||
fn find_sync(&mut self) -> Result<(), OggError> {
|
||||
if self.synced {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
while self.sync_buffer.len() < MAX_SYNC_SEARCH {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = Read::read(&mut self.reader, &mut chunk)?;
|
||||
if n == 0 {
|
||||
return Err(OggError::Decode(
|
||||
"EOF reached while searching for Ogg sync pattern".into(),
|
||||
));
|
||||
}
|
||||
self.sync_buffer.extend_from_slice(&chunk[..n]);
|
||||
|
||||
// Search for "OggS" pattern
|
||||
if let Some(pos) = self
|
||||
.sync_buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"OggS")
|
||||
{
|
||||
// Found sync! Remove garbage bytes before it
|
||||
self.sync_buffer.drain(..pos);
|
||||
self.synced = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If buffer is getting large and still no sync, keep only last 3 bytes
|
||||
// (in case "OggS" is split across chunk boundary)
|
||||
if self.sync_buffer.len() >= MAX_SYNC_SEARCH {
|
||||
let keep_len = 3.min(self.sync_buffer.len());
|
||||
self.sync_buffer.drain(..self.sync_buffer.len() - keep_len);
|
||||
}
|
||||
}
|
||||
|
||||
Err(OggError::Decode(format!(
|
||||
"No Ogg sync pattern found in first {} bytes",
|
||||
MAX_SYNC_SEARCH
|
||||
)))
|
||||
}
|
||||
|
||||
/// Reads a single Ogg page and processes its packets.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Ensures we're synced to "OggS" pattern
|
||||
/// 2. Reads the 27-byte page header
|
||||
/// 3. Validates the CRC32 checksum
|
||||
/// 4. Reads the segment table
|
||||
/// 5. Reads the page data
|
||||
/// 6. Assembles packets from segments
|
||||
fn read_page(&mut self) -> Result<(), OggError> {
|
||||
// Ensure we've found the sync pattern
|
||||
self.find_sync()?;
|
||||
|
||||
// Read 27-byte page header
|
||||
let mut header = [0u8; 27];
|
||||
if !self.read_exact_from_source(&mut header)? {
|
||||
self.finished = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate Ogg page header
|
||||
if &header[0..4] != b"OggS" {
|
||||
return Err(OggError::Decode("invalid Ogg capture pattern".into()));
|
||||
}
|
||||
if header[4] != 0 {
|
||||
return Err(OggError::Decode("unsupported Ogg version".into()));
|
||||
}
|
||||
|
||||
let header_type = header[5];
|
||||
let bitstream_serial = u32::from_le_bytes([header[14], header[15], header[16], header[17]]);
|
||||
|
||||
// Enforce single bitstream
|
||||
if let Some(serial) = self.stream_serial {
|
||||
if serial != bitstream_serial {
|
||||
return Err(OggError::Decode(
|
||||
"multiple logical streams are not supported".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.stream_serial = Some(bitstream_serial);
|
||||
}
|
||||
|
||||
// Read segment table
|
||||
let page_segments = header[26] as usize;
|
||||
let mut segment_table = vec![0u8; page_segments];
|
||||
self.read_exact_from_source(&mut segment_table)?;
|
||||
|
||||
// Calculate page data length
|
||||
let data_len: usize = segment_table.iter().map(|&v| v as usize).sum();
|
||||
let mut data = vec![0u8; data_len];
|
||||
self.read_exact_from_source(&mut data)?;
|
||||
|
||||
// Validate CRC32
|
||||
let expected_crc = u32::from_le_bytes([header[22], header[23], header[24], header[25]]);
|
||||
let mut crc_header = header;
|
||||
crc_header[22..26].copy_from_slice(&[0, 0, 0, 0]); // Zero out CRC field
|
||||
|
||||
let mut crc = crc::vorbis_crc32_update(0, &crc_header);
|
||||
crc = crc::vorbis_crc32_update(crc, &segment_table);
|
||||
crc = crc::vorbis_crc32_update(crc, &data);
|
||||
|
||||
if crc != expected_crc {
|
||||
return Err(OggError::Decode(format!(
|
||||
"CRC32 mismatch: expected 0x{:08x}, got 0x{:08x}",
|
||||
expected_crc, crc
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate continuation flags
|
||||
if header_type & 0x01 != 0 && self.current_packet.is_empty() {
|
||||
return Err(OggError::Decode(
|
||||
"unexpected continuation flag without existing packet".into(),
|
||||
));
|
||||
}
|
||||
if header_type & 0x01 == 0 && !self.current_packet.is_empty() {
|
||||
return Err(OggError::Decode(
|
||||
"dangling packet without continuation flag".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Assemble packets from segments
|
||||
let mut offset: usize = 0;
|
||||
for &seg_len in &segment_table {
|
||||
let len = seg_len as usize;
|
||||
let end = offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| OggError::Decode("segment length overflow".into()))?;
|
||||
if end > data.len() {
|
||||
return Err(OggError::Decode("segment exceeds page data".into()));
|
||||
}
|
||||
self.current_packet.extend_from_slice(&data[offset..end]);
|
||||
offset = end;
|
||||
|
||||
// Packet complete when segment is less than 255 bytes
|
||||
if seg_len < 255 {
|
||||
let packet = std::mem::take(&mut self.current_packet);
|
||||
self.queue.push_back(packet);
|
||||
}
|
||||
}
|
||||
|
||||
if offset != data.len() {
|
||||
return Err(OggError::Decode("page data not fully consumed".into()));
|
||||
}
|
||||
|
||||
// Check for end-of-stream
|
||||
if header_type & 0x04 != 0 {
|
||||
self.eos_seen = true;
|
||||
if self.current_packet.is_empty() {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC32 calculation for Ogg pages.
|
||||
///
|
||||
/// This module implements the CRC32 algorithm used by the Ogg container format.
|
||||
/// The polynomial is 0x04c11db7 with initial value 0 and no final XOR.
|
||||
mod crc {
|
||||
/// Precomputed CRC32 lookup table for Ogg.
|
||||
///
|
||||
/// Generated using the polynomial 0x04c11db7.
|
||||
const fn get_tbl_elem(idx: u32) -> u32 {
|
||||
let mut r: u32 = idx << 24;
|
||||
let mut i = 0;
|
||||
while i < 8 {
|
||||
r = (r << 1) ^ (-(((r >> 31) & 1) as i32) as u32 & 0x04c11db7);
|
||||
i += 1;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
const fn lookup_array() -> [u32; 0x100] {
|
||||
let mut lup_arr: [u32; 0x100] = [0; 0x100];
|
||||
let mut i = 0;
|
||||
while i < 0x100 {
|
||||
lup_arr[i] = get_tbl_elem(i as u32);
|
||||
i += 1;
|
||||
}
|
||||
lup_arr
|
||||
}
|
||||
|
||||
static CRC_LOOKUP_ARRAY: &[u32] = &lookup_array();
|
||||
|
||||
/// Updates the CRC32 value with new data.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cur` - Current CRC32 value (use 0 for initial call)
|
||||
/// * `array` - Data to include in CRC calculation
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Updated CRC32 value
|
||||
pub fn vorbis_crc32_update(cur: u32, array: &[u8]) -> u32 {
|
||||
let mut ret: u32 = cur;
|
||||
for av in array {
|
||||
ret = (ret << 8) ^ CRC_LOOKUP_ARRAY[(*av as u32 ^ (ret >> 24)) as usize];
|
||||
}
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
426
pmoflac/src/ogg_common.rs
Normal file
426
pmoflac/src/ogg_common.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! # Common Ogg Container Parsing
|
||||
//!
|
||||
//! This module provides shared functionality for parsing Ogg containers,
|
||||
//! used by both Ogg/Vorbis and Ogg/Opus decoders.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Streaming packet assembly**: Reads Ogg pages and assembles multi-page packets
|
||||
//! - **Optional CRC32 validation**: Can validate page integrity
|
||||
//! - **Optional sync search**: Can search for "OggS" pattern in streams with garbage
|
||||
//! - **Shared error type**: Uses `OggContainerError` for consistent error reporting
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! The `OggPacketReader` reads Ogg pages incrementally:
|
||||
//! 1. Optionally searches for "OggS" sync pattern
|
||||
//! 2. Reads 27-byte page headers
|
||||
//! 3. Optionally validates CRC32 checksums
|
||||
//! 4. Reads segment tables and page data
|
||||
//! 5. Assembles packets from segments (handling multi-page packets)
|
||||
//! 6. Returns complete packets to the decoder
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Read},
|
||||
};
|
||||
|
||||
use crate::common::ChannelReader;
|
||||
|
||||
/// Maximum number of bytes to scan when searching for Ogg sync pattern.
|
||||
///
|
||||
/// This prevents unbounded memory growth when processing streams with
|
||||
/// large amounts of garbage data before the first valid Ogg page.
|
||||
const MAX_SYNC_SEARCH: usize = 64 * 1024;
|
||||
|
||||
/// Configuration options for Ogg packet reader.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OggReaderOptions {
|
||||
/// Whether to validate CRC32 checksums of Ogg pages.
|
||||
///
|
||||
/// Vorbis typically validates CRC, Opus often doesn't.
|
||||
pub validate_crc: bool,
|
||||
|
||||
/// Whether to search for "OggS" sync pattern at start of stream.
|
||||
///
|
||||
/// Useful for streams that may have garbage bytes before valid data.
|
||||
pub find_sync: bool,
|
||||
|
||||
/// Maximum bytes to search for sync pattern (only used if find_sync is true).
|
||||
pub max_sync_search: usize,
|
||||
}
|
||||
|
||||
impl Default for OggReaderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
validate_crc: true,
|
||||
find_sync: true,
|
||||
max_sync_search: MAX_SYNC_SEARCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared error type for Ogg container parsing.
|
||||
#[derive(thiserror::Error, Debug, Clone)]
|
||||
pub enum OggContainerError {
|
||||
#[error("I/O error ({kind:?}): {message}")]
|
||||
Io {
|
||||
kind: io::ErrorKind,
|
||||
message: String,
|
||||
},
|
||||
#[error("ogg container error: {0}")]
|
||||
Decode(String),
|
||||
#[error("internal channel closed unexpectedly")]
|
||||
ChannelClosed,
|
||||
#[error("{role} task failed: {details}")]
|
||||
TaskJoin { role: &'static str, details: String },
|
||||
}
|
||||
|
||||
impl From<io::Error> for OggContainerError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
OggContainerError::Io {
|
||||
kind: err.kind(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for OggContainerError {
|
||||
fn from(value: String) -> Self {
|
||||
OggContainerError::Decode(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for OggContainerError {
|
||||
fn from(value: &str) -> Self {
|
||||
OggContainerError::Decode(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming Ogg packet reader that assembles packets from Ogg pages.
|
||||
///
|
||||
/// This reader parses Ogg pages manually without requiring seek operations,
|
||||
/// making it suitable for truly streaming scenarios.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - Searches for Ogg sync pattern ("OggS")
|
||||
/// - Parses page headers and segment tables
|
||||
/// - Validates CRC32 checksums (optional)
|
||||
/// - Assembles multi-page packets
|
||||
/// - Detects end-of-stream
|
||||
/// - Enforces single logical bitstream
|
||||
pub struct OggPacketReader {
|
||||
reader: ChannelReader<OggContainerError>,
|
||||
current_packet: Vec<u8>,
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
finished: bool,
|
||||
stream_serial: Option<u32>,
|
||||
sync_buffer: Vec<u8>,
|
||||
synced: bool,
|
||||
options: OggReaderOptions,
|
||||
}
|
||||
|
||||
impl OggPacketReader {
|
||||
/// Creates a new Ogg packet reader with the given options.
|
||||
pub fn new(reader: ChannelReader<OggContainerError>, options: OggReaderOptions) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
current_packet: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
finished: false,
|
||||
stream_serial: None,
|
||||
sync_buffer: Vec::new(),
|
||||
synced: !options.find_sync, // If we don't need to find sync, we're already synced
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next complete packet, or None if the stream has ended.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - I/O error occurs
|
||||
/// - Ogg page structure is invalid
|
||||
/// - CRC32 validation fails (if enabled)
|
||||
/// - Multiple logical bitstreams detected
|
||||
pub fn next_packet(&mut self) -> Result<Option<Vec<u8>>, OggContainerError> {
|
||||
loop {
|
||||
if let Some(packet) = self.queue.pop_front() {
|
||||
return Ok(Some(packet));
|
||||
}
|
||||
if self.finished {
|
||||
return Ok(None);
|
||||
}
|
||||
self.read_page()?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads bytes, first from sync_buffer then from the underlying reader.
|
||||
fn read_bytes(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
|
||||
// First, consume from sync_buffer
|
||||
if !self.sync_buffer.is_empty() {
|
||||
let to_copy = buf.len().min(self.sync_buffer.len());
|
||||
buf[..to_copy].copy_from_slice(&self.sync_buffer[..to_copy]);
|
||||
self.sync_buffer.drain(..to_copy);
|
||||
total += to_copy;
|
||||
if total == buf.len() {
|
||||
return Ok(total);
|
||||
}
|
||||
}
|
||||
|
||||
// Then read from underlying reader
|
||||
while total < buf.len() {
|
||||
match Read::read(&mut self.reader, &mut buf[total..])? {
|
||||
0 => break,
|
||||
n => total += n,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Reads exactly buf.len() bytes or returns error.
|
||||
fn read_exact_from_source(&mut self, buf: &mut [u8]) -> Result<bool, OggContainerError> {
|
||||
let mut offset = 0;
|
||||
while offset < buf.len() {
|
||||
let n = self
|
||||
.read_bytes(&mut buf[offset..])
|
||||
.map_err(OggContainerError::from)?;
|
||||
if n == 0 {
|
||||
return if offset == 0 {
|
||||
Ok(false) // Clean EOF
|
||||
} else {
|
||||
Err(OggContainerError::Decode(
|
||||
"unexpected EOF while reading page".into(),
|
||||
))
|
||||
};
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Searches for the Ogg sync pattern ("OggS") in the stream.
|
||||
///
|
||||
/// This is called before reading the first page to handle streams that
|
||||
/// have garbage bytes at the beginning (e.g., HTTP headers, ID3 tags).
|
||||
/// It buffers up to max_sync_search bytes while searching.
|
||||
fn find_sync(&mut self) -> Result<(), OggContainerError> {
|
||||
if self.synced {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
while self.sync_buffer.len() < self.options.max_sync_search {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = Read::read(&mut self.reader, &mut chunk).map_err(OggContainerError::from)?;
|
||||
if n == 0 {
|
||||
return Err(OggContainerError::Decode(
|
||||
"EOF reached while searching for Ogg sync pattern".into(),
|
||||
));
|
||||
}
|
||||
self.sync_buffer.extend_from_slice(&chunk[..n]);
|
||||
|
||||
// Search for "OggS" pattern
|
||||
if let Some(pos) = self
|
||||
.sync_buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"OggS")
|
||||
{
|
||||
// Found sync! Remove garbage bytes before it
|
||||
self.sync_buffer.drain(..pos);
|
||||
self.synced = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If buffer is getting large and still no sync, keep only last 3 bytes
|
||||
// (in case "OggS" is split across chunk boundary)
|
||||
if self.sync_buffer.len() >= self.options.max_sync_search {
|
||||
let keep_len = 3.min(self.sync_buffer.len());
|
||||
self.sync_buffer.drain(..self.sync_buffer.len() - keep_len);
|
||||
}
|
||||
}
|
||||
|
||||
Err(OggContainerError::Decode(format!(
|
||||
"No Ogg sync pattern found in first {} bytes",
|
||||
self.options.max_sync_search
|
||||
)))
|
||||
}
|
||||
|
||||
/// Reads a single Ogg page and processes its packets.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Ensures we're synced to "OggS" pattern (if enabled)
|
||||
/// 2. Reads the 27-byte page header
|
||||
/// 3. Validates the CRC32 checksum (if enabled)
|
||||
/// 4. Reads the segment table
|
||||
/// 5. Reads the page data
|
||||
/// 6. Assembles packets from segments
|
||||
fn read_page(&mut self) -> Result<(), OggContainerError> {
|
||||
// Ensure we've found the sync pattern (if required)
|
||||
if self.options.find_sync {
|
||||
self.find_sync()?;
|
||||
}
|
||||
|
||||
// Read 27-byte page header
|
||||
let mut header = [0u8; 27];
|
||||
if !self.read_exact_from_source(&mut header)? {
|
||||
self.finished = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Validate Ogg page header
|
||||
if &header[0..4] != b"OggS" {
|
||||
return Err(OggContainerError::Decode(
|
||||
"invalid Ogg capture pattern".into(),
|
||||
));
|
||||
}
|
||||
if header[4] != 0 {
|
||||
return Err(OggContainerError::Decode("unsupported Ogg version".into()));
|
||||
}
|
||||
|
||||
let header_type = header[5];
|
||||
let bitstream_serial = u32::from_le_bytes([header[14], header[15], header[16], header[17]]);
|
||||
|
||||
// Enforce single bitstream
|
||||
if let Some(serial) = self.stream_serial {
|
||||
if serial != bitstream_serial {
|
||||
return Err(OggContainerError::Decode(
|
||||
"multiple logical streams are not supported".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.stream_serial = Some(bitstream_serial);
|
||||
}
|
||||
|
||||
// Read segment table
|
||||
let page_segments = header[26] as usize;
|
||||
let mut segment_table = vec![0u8; page_segments];
|
||||
self.read_exact_from_source(&mut segment_table)?;
|
||||
|
||||
// Calculate page data length
|
||||
let data_len: usize = segment_table.iter().map(|&v| v as usize).sum();
|
||||
let mut data = vec![0u8; data_len];
|
||||
self.read_exact_from_source(&mut data)?;
|
||||
|
||||
// Validate CRC32 if enabled
|
||||
if self.options.validate_crc {
|
||||
let expected_crc = u32::from_le_bytes([header[22], header[23], header[24], header[25]]);
|
||||
let mut crc_header = header;
|
||||
crc_header[22..26].copy_from_slice(&[0, 0, 0, 0]); // Zero out CRC field
|
||||
|
||||
let mut crc = crc::vorbis_crc32_update(0, &crc_header);
|
||||
crc = crc::vorbis_crc32_update(crc, &segment_table);
|
||||
crc = crc::vorbis_crc32_update(crc, &data);
|
||||
|
||||
if crc != expected_crc {
|
||||
return Err(OggContainerError::Decode(format!(
|
||||
"CRC32 mismatch: expected 0x{expected_crc:08x}, got 0x{crc:08x}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate continuation flags
|
||||
if header_type & 0x01 != 0 && self.current_packet.is_empty() {
|
||||
return Err(OggContainerError::Decode(
|
||||
"unexpected continuation flag without existing packet".into(),
|
||||
));
|
||||
}
|
||||
if header_type & 0x01 == 0 && !self.current_packet.is_empty() {
|
||||
return Err(OggContainerError::Decode(
|
||||
"dangling packet without continuation flag".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Assemble packets from segments
|
||||
let mut offset: usize = 0;
|
||||
for &seg_len in &segment_table {
|
||||
let len = seg_len as usize;
|
||||
let end = offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| OggContainerError::Decode("segment length overflow".into()))?;
|
||||
if end > data.len() {
|
||||
return Err(OggContainerError::Decode(
|
||||
"segment exceeds page data".into(),
|
||||
));
|
||||
}
|
||||
self.current_packet.extend_from_slice(&data[offset..end]);
|
||||
offset = end;
|
||||
|
||||
// Packet complete when segment is less than 255 bytes
|
||||
if seg_len < 255 {
|
||||
let packet = std::mem::take(&mut self.current_packet);
|
||||
self.queue.push_back(packet);
|
||||
}
|
||||
}
|
||||
|
||||
if offset != data.len() {
|
||||
return Err(OggContainerError::Decode(
|
||||
"page data not fully consumed".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for end-of-stream
|
||||
if header_type & 0x04 != 0 && self.current_packet.is_empty() {
|
||||
self.finished = true;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC32 calculation for Ogg pages.
|
||||
///
|
||||
/// This module implements the CRC32 algorithm used by the Ogg container format.
|
||||
/// The polynomial is 0x04c11db7 with initial value 0 and no final XOR.
|
||||
pub(crate) mod crc {
|
||||
/// Precomputed CRC32 lookup table for Ogg.
|
||||
///
|
||||
/// Generated using the polynomial 0x04c11db7.
|
||||
const fn get_tbl_elem(idx: u32) -> u32 {
|
||||
let mut r: u32 = idx << 24;
|
||||
let mut i = 0;
|
||||
while i < 8 {
|
||||
r = (r << 1) ^ (-(((r >> 31) & 1) as i32) as u32 & 0x04c11db7);
|
||||
i += 1;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
const fn lookup_array() -> [u32; 0x100] {
|
||||
let mut lup_arr: [u32; 0x100] = [0; 0x100];
|
||||
let mut i = 0;
|
||||
while i < 0x100 {
|
||||
lup_arr[i] = get_tbl_elem(i as u32);
|
||||
i += 1;
|
||||
}
|
||||
lup_arr
|
||||
}
|
||||
|
||||
static CRC_LOOKUP_ARRAY: &[u32] = &lookup_array();
|
||||
|
||||
/// Updates the CRC32 value with new data.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cur` - Current CRC32 value (use 0 for initial call)
|
||||
/// * `array` - Data to include in CRC calculation
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Updated CRC32 value
|
||||
pub fn vorbis_crc32_update(cur: u32, array: &[u8]) -> u32 {
|
||||
let mut ret: u32 = cur;
|
||||
for av in array {
|
||||
ret = (ret << 8) ^ CRC_LOOKUP_ARRAY[(*av as u32 ^ (ret >> 24)) as usize];
|
||||
}
|
||||
ret
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 100% streaming (no seeking or buffering entire files).
|
||||
|
||||
use std::{
|
||||
io::{self, Read},
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
@@ -19,6 +19,7 @@ use tokio::{
|
||||
use crate::{
|
||||
common::ChannelReader,
|
||||
decoder_common::{spawn_ingest_task, spawn_writer_task, CHANNEL_CAPACITY, DUPLEX_BUFFER_SIZE},
|
||||
ogg_common::{OggContainerError, OggPacketReader, OggReaderOptions},
|
||||
pcm::StreamInfo,
|
||||
stream::ManagedAsyncReader,
|
||||
};
|
||||
@@ -26,38 +27,12 @@ use crate::{
|
||||
/// Maximum number of samples per Opus frame at 48 kHz (120 ms).
|
||||
const MAX_FRAME_SAMPLES: usize = 5760;
|
||||
|
||||
/// Errors that can occur while decoding Ogg/Opus data.
|
||||
#[derive(thiserror::Error, Debug, Clone)]
|
||||
pub enum OggOpusError {
|
||||
#[error("I/O error ({kind:?}): {message}")]
|
||||
Io {
|
||||
kind: io::ErrorKind,
|
||||
message: String,
|
||||
},
|
||||
#[error("Ogg/Opus decode error: {0}")]
|
||||
Decode(String),
|
||||
#[error("internal channel closed unexpectedly")]
|
||||
ChannelClosed,
|
||||
}
|
||||
/// Shared error alias for the Opus decoder.
|
||||
pub type OggOpusError = OggContainerError;
|
||||
|
||||
impl From<io::Error> for OggOpusError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
OggOpusError::Io {
|
||||
kind: err.kind(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpusError> for OggOpusError {
|
||||
impl From<OpusError> for OggContainerError {
|
||||
fn from(err: OpusError) -> Self {
|
||||
OggOpusError::Decode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for OggOpusError {
|
||||
fn from(value: String) -> Self {
|
||||
OggOpusError::Decode(value)
|
||||
OggContainerError::Decode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +82,15 @@ where
|
||||
let (info_tx, info_rx) = oneshot::channel::<Result<StreamInfo, OggOpusError>>();
|
||||
|
||||
let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), OggOpusError> {
|
||||
let channel_reader = ChannelReader::<OggOpusError>::new(ingest_rx);
|
||||
let mut packet_reader = StreamingPacketReader::new(channel_reader);
|
||||
let channel_reader = ChannelReader::<OggContainerError>::new(ingest_rx);
|
||||
let mut packet_reader = OggPacketReader::new(
|
||||
channel_reader,
|
||||
OggReaderOptions {
|
||||
validate_crc: false,
|
||||
find_sync: false,
|
||||
..OggReaderOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
let header_packet = packet_reader
|
||||
.next_packet()?
|
||||
@@ -275,141 +257,3 @@ impl OpusTags {
|
||||
Ok(OpusTags)
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming Ogg packet reader reused for Opus packets.
|
||||
struct StreamingPacketReader<E>
|
||||
where
|
||||
E: std::error::Error + std::fmt::Display,
|
||||
{
|
||||
reader: ChannelReader<E>,
|
||||
current_packet: Vec<u8>,
|
||||
pending_packets: std::collections::VecDeque<Vec<u8>>,
|
||||
finished: bool,
|
||||
stream_serial: Option<u32>,
|
||||
}
|
||||
|
||||
impl<E> StreamingPacketReader<E>
|
||||
where
|
||||
E: std::error::Error + std::fmt::Display,
|
||||
{
|
||||
fn new(reader: ChannelReader<E>) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
current_packet: Vec::new(),
|
||||
pending_packets: std::collections::VecDeque::new(),
|
||||
finished: false,
|
||||
stream_serial: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_packet(&mut self) -> Result<Option<Vec<u8>>, OggOpusError> {
|
||||
loop {
|
||||
if let Some(packet) = self.pending_packets.pop_front() {
|
||||
return Ok(Some(packet));
|
||||
}
|
||||
if self.finished {
|
||||
return Ok(None);
|
||||
}
|
||||
self.read_page()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_page(&mut self) -> Result<(), OggOpusError> {
|
||||
let mut header = [0u8; 27];
|
||||
if !read_exact_or_eof(&mut self.reader, &mut header)? {
|
||||
self.finished = true;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if &header[0..4] != b"OggS" {
|
||||
return Err(OggOpusError::Decode("invalid Ogg capture pattern".into()));
|
||||
}
|
||||
if header[4] != 0 {
|
||||
return Err(OggOpusError::Decode("unsupported Ogg version".into()));
|
||||
}
|
||||
|
||||
let header_type = header[5];
|
||||
let bitstream_serial = u32::from_le_bytes([header[14], header[15], header[16], header[17]]);
|
||||
|
||||
if let Some(serial) = self.stream_serial {
|
||||
if serial != bitstream_serial {
|
||||
return Err(OggOpusError::Decode(
|
||||
"multiple logical Ogg streams are unsupported".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.stream_serial = Some(bitstream_serial);
|
||||
}
|
||||
|
||||
let page_segments = header[26] as usize;
|
||||
let mut segment_table = vec![0u8; page_segments];
|
||||
read_exact_checked(&mut self.reader, &mut segment_table)?;
|
||||
|
||||
let data_len: usize = segment_table.iter().map(|&v| v as usize).sum();
|
||||
let mut data = vec![0u8; data_len];
|
||||
read_exact_checked(&mut self.reader, &mut data)?;
|
||||
|
||||
if header_type & 0x01 != 0 && self.current_packet.is_empty() {
|
||||
return Err(OggOpusError::Decode(
|
||||
"continuation flag set without existing packet".into(),
|
||||
));
|
||||
}
|
||||
if header_type & 0x01 == 0 && !self.current_packet.is_empty() {
|
||||
return Err(OggOpusError::Decode(
|
||||
"expected continuation flag for unfinished packet".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut offset = 0usize;
|
||||
for &seg_len in &segment_table {
|
||||
let len = seg_len as usize;
|
||||
let end = offset
|
||||
.checked_add(len)
|
||||
.ok_or_else(|| OggOpusError::Decode("segment length overflow".into()))?;
|
||||
if end > data.len() {
|
||||
return Err(OggOpusError::Decode("segment exceeds page data".into()));
|
||||
}
|
||||
self.current_packet.extend_from_slice(&data[offset..end]);
|
||||
offset = end;
|
||||
|
||||
if seg_len < 255 {
|
||||
let packet = std::mem::take(&mut self.current_packet);
|
||||
self.pending_packets.push_back(packet);
|
||||
}
|
||||
}
|
||||
|
||||
if offset != data.len() {
|
||||
return Err(OggOpusError::Decode("page data not fully consumed".into()));
|
||||
}
|
||||
|
||||
if header_type & 0x04 != 0 && self.current_packet.is_empty() {
|
||||
self.finished = true;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<bool> {
|
||||
let mut read = 0;
|
||||
while read < buf.len() {
|
||||
match reader.read(&mut buf[read..])? {
|
||||
0 if read == 0 => return Ok(false),
|
||||
0 => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"unexpected EOF while reading",
|
||||
))
|
||||
}
|
||||
n => read += n,
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn read_exact_checked<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), OggOpusError> {
|
||||
if !read_exact_or_eof(reader, buf)? {
|
||||
return Err(OggOpusError::Decode("unexpected EOF in Ogg stream".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<Str
|
||||
items: items.to_vec(),
|
||||
};
|
||||
|
||||
let body =
|
||||
quick_xml::se::to_string(&didl).map_err(|e| format!("Failed to serialize DIDL-Lite: {}", e))?;
|
||||
Ok(format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", body))
|
||||
let body = quick_xml::se::to_string(&didl)
|
||||
.map_err(|e| format!("Failed to serialize DIDL-Lite: {}", e))?;
|
||||
Ok(format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}",
|
||||
body
|
||||
))
|
||||
}
|
||||
|
||||
/// Handler pour le service ContentDirectory
|
||||
@@ -99,8 +102,8 @@ impl ContentHandler {
|
||||
if object_id == "0" {
|
||||
// Retourner le container racine
|
||||
let root = self.build_root_container().await;
|
||||
let didl = to_didl_lite(&[root], &[])?;
|
||||
Ok((didl, 1, 1, 1))
|
||||
let didl = to_didl_lite(&[root], &[])?;
|
||||
Ok((didl, 1, 1, 1))
|
||||
} else {
|
||||
// Essayer de trouver l'objet dans les sources
|
||||
// Vérifier si c'est un container racine d'une source
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn init() -> Result<()> {
|
||||
/// PCM chunk with decoded audio data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PCMChunk {
|
||||
pub samples: Vec<i16>, // Interleaved 16-bit samples
|
||||
pub samples: Vec<i16>, // Interleaved 16-bit samples
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub position_ms: u64,
|
||||
@@ -52,7 +52,7 @@ impl ProgressiveDecoder {
|
||||
let mut buffer = vec![0u8; 8192];
|
||||
loop {
|
||||
match stream.read(&mut buffer) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(0) => break, // EOF
|
||||
Ok(n) => {
|
||||
let chunk = Bytes::copy_from_slice(&buffer[..n]);
|
||||
if tx.send(Ok(chunk)).is_err() {
|
||||
|
||||
@@ -881,7 +881,7 @@ fn ms_to_frames(ms: u64, sample_rate: u32) -> usize {
|
||||
|
||||
fn decode_block_audio(data: Vec<u8>) -> anyhow::Result<DecodedBlock> {
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
|
||||
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! cargo run -p pmoplaylist --example basic_usage
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! cargo run -p pmoplaylist --example http_server_integration
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! cargo run -p pmoplaylist --example radio_streaming
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ impl ReadHandle {
|
||||
if let Some(persistence) = crate::manager::PlaylistManager().persistence() {
|
||||
let title = self.playlist.title().await;
|
||||
let core = self.playlist.core.read().await;
|
||||
let _ = persistence.save_playlist(&self.playlist.id, &title, &core.config, &core.tracks).await;
|
||||
let _ = persistence
|
||||
.save_playlist(&self.playlist.id, &title, &core.config, &core.tracks)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,7 @@ impl WriteHandle {
|
||||
}
|
||||
|
||||
// Créer tous les records
|
||||
let records: Vec<Record> = cache_pks.into_iter()
|
||||
.map(Record::new)
|
||||
.collect();
|
||||
let records: Vec<Record> = cache_pks.into_iter().map(Record::new).collect();
|
||||
|
||||
// Ajouter atomiquement
|
||||
let mut core = self.playlist.core.write().await;
|
||||
@@ -195,9 +193,7 @@ impl WriteHandle {
|
||||
new_handle.set_default_ttl(config.default_ttl).await?;
|
||||
|
||||
// Copier tous les morceaux
|
||||
let pks: Vec<String> = tracks.iter()
|
||||
.map(|r| r.cache_pk.clone())
|
||||
.collect();
|
||||
let pks: Vec<String> = tracks.iter().map(|r| r.cache_pk.clone()).collect();
|
||||
new_handle.push_set(pks).await?;
|
||||
|
||||
Ok(new_handle)
|
||||
@@ -245,7 +241,8 @@ impl WriteHandle {
|
||||
|
||||
async fn save_to_db(&self) -> Result<()> {
|
||||
let manager = crate::manager::PlaylistManager();
|
||||
let persistence = manager.persistence()
|
||||
let persistence = manager
|
||||
.persistence()
|
||||
.ok_or_else(|| crate::Error::PersistenceError("No persistence manager".into()))?;
|
||||
|
||||
let title = self.playlist.title().await;
|
||||
@@ -253,6 +250,8 @@ impl WriteHandle {
|
||||
let config = &core.config;
|
||||
let tracks = &core.tracks;
|
||||
|
||||
persistence.save_playlist(&self.playlist.id, &title, config, tracks).await
|
||||
persistence
|
||||
.save_playlist(&self.playlist.id, &title, config, tracks)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ impl PlaylistManager {
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
{
|
||||
PLAYLIST_MANAGER.get().expect("PlaylistManager not initialized. Call init() first.")
|
||||
PLAYLIST_MANAGER
|
||||
.get()
|
||||
.expect("PlaylistManager not initialized. Call init() first.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +104,8 @@ impl PlaylistManager {
|
||||
if let Some(persistence) = &self.inner.persistence {
|
||||
let title = playlist.title().await;
|
||||
let core = playlist.core.read().await;
|
||||
persistence.save_playlist(&playlist.id, &title, &core.config, &core.tracks)
|
||||
persistence
|
||||
.save_playlist(&playlist.id, &title, &core.config, &core.tracks)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -185,12 +188,7 @@ impl PlaylistManager {
|
||||
// Reconstruire la playlist
|
||||
let mut playlists = self.inner.playlists.write().await;
|
||||
|
||||
let playlist = Arc::new(Playlist::new(
|
||||
id.to_string(),
|
||||
title.clone(),
|
||||
config,
|
||||
true,
|
||||
));
|
||||
let playlist = Arc::new(Playlist::new(id.to_string(), title.clone(), config, true));
|
||||
|
||||
// Restaurer les tracks
|
||||
{
|
||||
@@ -265,7 +263,9 @@ impl PlaylistManager {
|
||||
if let Some(persistence) = &self.inner.persistence {
|
||||
let title = playlist.title().await;
|
||||
let core = playlist.core.read().await;
|
||||
let _ = persistence.save_playlist(&playlist.id, &title, &core.config, &core.tracks).await;
|
||||
let _ = persistence
|
||||
.save_playlist(&playlist.id, &title, &core.config, &core.tracks)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,8 +286,7 @@ pub(crate) async fn delete_playlist_internal(id: &str) -> Result<()> {
|
||||
|
||||
/// Helper pour acc<63>der au cache audio
|
||||
pub(crate) fn audio_cache() -> Result<Arc<pmoaudiocache::Cache>> {
|
||||
pmoupnp::get_audio_cache()
|
||||
.ok_or_else(|| crate::Error::ManagerNotInitialized)
|
||||
pmoupnp::get_audio_cache().ok_or_else(|| crate::Error::ManagerNotInitialized)
|
||||
}
|
||||
|
||||
/// Fonction raccourcie pour acc<63>der au singleton
|
||||
|
||||
@@ -19,12 +19,14 @@ impl PersistenceManager {
|
||||
pub fn new(db_path: &Path) -> Result<Self> {
|
||||
// Créer le répertoire parent si nécessaire
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create directory: {}", e)))?;
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to create directory: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to open database: {}", e)))?;
|
||||
let conn = Connection::open(db_path).map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to open database: {}", e))
|
||||
})?;
|
||||
|
||||
// Créer les tables
|
||||
conn.execute(
|
||||
@@ -37,7 +39,10 @@ impl PersistenceManager {
|
||||
last_modified INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to create playlists table: {}", e)))?;
|
||||
)
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to create playlists table: {}", e))
|
||||
})?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tracks (
|
||||
@@ -48,17 +53,22 @@ impl PersistenceManager {
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to create tracks table: {}", e)))?;
|
||||
)
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to create tracks table: {}", e))
|
||||
})?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tracks_playlist ON tracks(playlist_id, added_at)",
|
||||
[],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
||||
)
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tracks_cache_pk ON tracks(cache_pk)",
|
||||
[],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
||||
)
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
@@ -96,10 +106,10 @@ impl PersistenceManager {
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to save playlist: {}", e)))?;
|
||||
|
||||
// Supprimer les anciens tracks
|
||||
conn.execute(
|
||||
"DELETE FROM tracks WHERE playlist_id = ?1",
|
||||
params![id],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to delete old tracks: {}", e)))?;
|
||||
conn.execute("DELETE FROM tracks WHERE playlist_id = ?1", params![id])
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to delete old tracks: {}", e))
|
||||
})?;
|
||||
|
||||
// Insérer les nouveaux tracks
|
||||
for record in tracks {
|
||||
@@ -112,20 +122,28 @@ impl PersistenceManager {
|
||||
&record.cache_pk,
|
||||
record.ttl.map(|d| d.as_secs() as i64),
|
||||
],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to insert track: {}", e)))?;
|
||||
)
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to insert track: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge une playlist
|
||||
pub async fn load_playlist(&self, id: &str) -> Result<Option<(String, PlaylistConfig, VecDeque<Arc<Record>>)>> {
|
||||
pub async fn load_playlist(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Option<(String, PlaylistConfig, VecDeque<Arc<Record>>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// Charger les métadonnées
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT title, max_size, default_ttl_secs FROM playlists WHERE id = ?1"
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT title, max_size, default_ttl_secs FROM playlists WHERE id = ?1")
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
||||
})?;
|
||||
|
||||
let result = stmt.query_row(params![id], |row| {
|
||||
let title: String = row.get(0)?;
|
||||
@@ -144,7 +162,12 @@ impl PersistenceManager {
|
||||
let (title, config) = match result {
|
||||
Ok(data) => data,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(crate::Error::PersistenceError(format!("Failed to load playlist: {}", e))),
|
||||
Err(e) => {
|
||||
return Err(crate::Error::PersistenceError(format!(
|
||||
"Failed to load playlist: {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Charger les tracks
|
||||
@@ -152,24 +175,30 @@ impl PersistenceManager {
|
||||
"SELECT added_at, cache_pk, ttl_secs FROM tracks WHERE playlist_id = ?1 ORDER BY added_at ASC"
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?;
|
||||
|
||||
let rows = stmt.query_map(params![id], |row| {
|
||||
let added_at_nanos: i64 = row.get(0)?;
|
||||
let cache_pk: String = row.get(1)?;
|
||||
let ttl_secs: Option<i64> = row.get(2)?;
|
||||
let rows = stmt
|
||||
.query_map(params![id], |row| {
|
||||
let added_at_nanos: i64 = row.get(0)?;
|
||||
let cache_pk: String = row.get(1)?;
|
||||
let ttl_secs: Option<i64> = row.get(2)?;
|
||||
|
||||
let added_at = UNIX_EPOCH + Duration::from_nanos(added_at_nanos as u64);
|
||||
let ttl = ttl_secs.map(|s| Duration::from_secs(s as u64));
|
||||
let added_at = UNIX_EPOCH + Duration::from_nanos(added_at_nanos as u64);
|
||||
let ttl = ttl_secs.map(|s| Duration::from_secs(s as u64));
|
||||
|
||||
Ok(Record {
|
||||
cache_pk,
|
||||
added_at,
|
||||
ttl,
|
||||
Ok(Record {
|
||||
cache_pk,
|
||||
added_at,
|
||||
ttl,
|
||||
})
|
||||
})
|
||||
}).map_err(|e| crate::Error::PersistenceError(format!("Failed to query tracks: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to query tracks: {}", e))
|
||||
})?;
|
||||
|
||||
let mut tracks = VecDeque::new();
|
||||
for row in rows {
|
||||
let record = row.map_err(|e| crate::Error::PersistenceError(format!("Failed to read track: {}", e)))?;
|
||||
let record = row.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to read track: {}", e))
|
||||
})?;
|
||||
tracks.push_back(Arc::new(record));
|
||||
}
|
||||
|
||||
@@ -179,25 +208,29 @@ impl PersistenceManager {
|
||||
/// Supprime une playlist
|
||||
pub async fn delete_playlist(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM playlists WHERE id = ?1",
|
||||
params![id],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to delete playlist: {}", e)))?;
|
||||
conn.execute("DELETE FROM playlists WHERE id = ?1", params![id])
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to delete playlist: {}", e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liste toutes les playlists persistantes
|
||||
pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM playlists")
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?;
|
||||
let mut stmt = conn.prepare("SELECT id FROM playlists").map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
||||
})?;
|
||||
|
||||
let rows = stmt.query_map([], |row| row.get(0))
|
||||
.map_err(|e| crate::Error::PersistenceError(format!("Failed to query playlists: {}", e)))?;
|
||||
let rows = stmt.query_map([], |row| row.get(0)).map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to query playlists: {}", e))
|
||||
})?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for row in rows {
|
||||
ids.push(row.map_err(|e| crate::Error::PersistenceError(format!("Failed to read id: {}", e)))?);
|
||||
ids.push(row.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to read id: {}", e))
|
||||
})?);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
@@ -206,10 +239,10 @@ impl PersistenceManager {
|
||||
/// Supprime tous les tracks contenant un cache_pk donné
|
||||
pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM tracks WHERE cache_pk = ?1",
|
||||
params![cache_pk],
|
||||
).map_err(|e| crate::Error::PersistenceError(format!("Failed to remove tracks: {}", e)))?;
|
||||
conn.execute("DELETE FROM tracks WHERE cache_pk = ?1", params![cache_pk])
|
||||
.map_err(|e| {
|
||||
crate::Error::PersistenceError(format!("Failed to remove tracks: {}", e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,8 @@ impl PlaylistCore {
|
||||
/// Nettoie les morceaux expirés et applique la limite de taille
|
||||
pub fn evict(&mut self) {
|
||||
// 1. Supprimer les morceaux périmés par TTL
|
||||
self.tracks.retain(|record| {
|
||||
!record.is_expired(self.config.default_ttl)
|
||||
});
|
||||
self.tracks
|
||||
.retain(|record| !record.is_expired(self.config.default_ttl));
|
||||
|
||||
// 2. Appliquer la limite de taille (FIFO)
|
||||
if let Some(max) = self.config.max_size {
|
||||
|
||||
@@ -59,7 +59,8 @@ impl Playlist {
|
||||
|
||||
/// Marque la playlist comme supprimée
|
||||
pub fn mark_deleted(&self) {
|
||||
self.state.store(PlaylistState::Deleted as u8, Ordering::SeqCst);
|
||||
self.state
|
||||
.store(PlaylistState::Deleted as u8, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Met à jour le timestamp de dernière modification
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! PlaylistTrack : résultat d'un pop() avec helpers pour accéder au cache
|
||||
|
||||
use crate::Result;
|
||||
use pmocache::cache_trait::FileCache;
|
||||
use pmoaudiocache::AudioMetadataExt;
|
||||
use pmocache::cache_trait::FileCache;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Un morceau récupéré depuis une playlist
|
||||
|
||||
@@ -149,8 +149,7 @@ impl UpnpObject for DeviceInstance {
|
||||
|
||||
// UDN
|
||||
let mut udn = Element::new("UDN");
|
||||
udn.children
|
||||
.push(XMLNode::Text(self.udn_with_prefix()));
|
||||
udn.children.push(XMLNode::Text(self.udn_with_prefix()));
|
||||
elem.children.push(XMLNode::Element(udn));
|
||||
|
||||
// serviceList
|
||||
|
||||
Reference in New Issue
Block a user