Changement du mécanisme d'attention sur les channels Radio Paradise.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3280,6 +3280,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"once_cell",
|
||||||
"pmoaudio",
|
"pmoaudio",
|
||||||
"pmoaudio-ext",
|
"pmoaudio-ext",
|
||||||
"pmoaudiocache",
|
"pmoaudiocache",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use pmoapp::{WebAppExt, Webapp};
|
use pmoapp::{WebAppExt, Webapp};
|
||||||
use pmomediarenderer::MEDIA_RENDERER;
|
use pmomediarenderer::MEDIA_RENDERER;
|
||||||
use pmomediaserver::{MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt};
|
use pmomediaserver::{
|
||||||
|
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||||
|
};
|
||||||
use pmoserver::Server;
|
use pmoserver::Server;
|
||||||
use pmosource::MusicSourceExt;
|
use pmosource::MusicSourceExt;
|
||||||
use pmoupnp::UpnpServerExt;
|
use pmoupnp::UpnpServerExt;
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::{collections::VecDeque, pin::Pin, sync::Arc, task::{Context, Poll}};
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
use tokio::{io::{AsyncRead, ReadBuf}, sync::{RwLock, mpsc}};
|
use tokio::{
|
||||||
|
io::{AsyncRead, ReadBuf},
|
||||||
|
sync::{mpsc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use pmoaudio::{AudioChunk, AudioError};
|
use pmoaudio::{AudioChunk, AudioError};
|
||||||
|
|
||||||
/// Convert an AudioChunk to PCM bytes with specified bit depth.
|
/// Convert an AudioChunk to PCM bytes with specified bit depth.
|
||||||
pub(crate) fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result<Vec<u8>, AudioError> {
|
pub(crate) fn chunk_to_pcm_bytes(
|
||||||
|
chunk: &AudioChunk,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
) -> Result<Vec<u8>, AudioError> {
|
||||||
match chunk {
|
match chunk {
|
||||||
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
||||||
return Err(AudioError::ProcessingError(
|
return Err(AudioError::ProcessingError(
|
||||||
|
|||||||
@@ -298,28 +298,44 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
|
|
||||||
if let Some(sr) = transform.sample_rate {
|
if let Some(sr) = transform.sample_rate {
|
||||||
if let Err(e) = meta.set_sample_rate(Some(sr)).await {
|
if let Err(e) = meta.set_sample_rate(Some(sr)).await {
|
||||||
tracing::error!("FlacCacheSink: Failed to set sample_rate for pk {}: {:?}", pk, e);
|
tracing::error!(
|
||||||
|
"FlacCacheSink: Failed to set sample_rate for pk {}: {:?}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk);
|
tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(bps) = transform.bits_per_sample {
|
if let Some(bps) = transform.bits_per_sample {
|
||||||
if let Err(e) = meta.set_bits_per_sample(Some(bps)).await {
|
if let Err(e) = meta.set_bits_per_sample(Some(bps)).await {
|
||||||
tracing::error!("FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}", pk, e);
|
tracing::error!(
|
||||||
|
"FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk);
|
tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ch) = transform.channels {
|
if let Some(ch) = transform.channels {
|
||||||
if let Err(e) = meta.set_channels(Some(ch)).await {
|
if let Err(e) = meta.set_channels(Some(ch)).await {
|
||||||
tracing::error!("FlacCacheSink: Failed to set channels for pk {}: {:?}", pk, e);
|
tracing::error!(
|
||||||
|
"FlacCacheSink: Failed to set channels for pk {}: {:?}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk);
|
tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ts) = transform.total_samples {
|
if let Some(ts) = transform.total_samples {
|
||||||
if let Err(e) = meta.set_total_samples(Some(ts)).await {
|
if let Err(e) = meta.set_total_samples(Some(ts)).await {
|
||||||
tracing::error!("FlacCacheSink: Failed to set total_samples for pk {}: {:?}", pk, e);
|
tracing::error!(
|
||||||
|
"FlacCacheSink: Failed to set total_samples for pk {}: {:?}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk);
|
tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk);
|
||||||
}
|
}
|
||||||
@@ -329,10 +345,19 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
if sr > 0 {
|
if sr > 0 {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
let secs = (ts as f64 / sr as f64).round() as u64;
|
let secs = (ts as f64 / sr as f64).round() as u64;
|
||||||
if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await {
|
if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await
|
||||||
tracing::error!("FlacCacheSink: Failed to set duration for pk {}: {:?}", pk, e);
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"FlacCacheSink: Failed to set duration for pk {}: {:?}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("FlacCacheSink: Set duration={} secs for pk {}", secs, pk);
|
tracing::debug!(
|
||||||
|
"FlacCacheSink: Set duration={} secs for pk {}",
|
||||||
|
secs,
|
||||||
|
pk
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +365,10 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
|
|
||||||
drop(meta); // Libérer le lock explicitement
|
drop(meta); // Libérer le lock explicitement
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("FlacCacheSink: No transform metadata available for pk {}", pk);
|
tracing::warn!(
|
||||||
|
"FlacCacheSink: No transform metadata available for pk {}",
|
||||||
|
pk
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist
|
// Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ pub(crate) enum FlacStreamState {
|
|||||||
Streaming,
|
Streaming,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Validate and parse FLAC block size from frame header
|
/// Validate and parse FLAC block size from frame header
|
||||||
///
|
///
|
||||||
/// Returns the number of samples in the frame if the header is valid, or None if:
|
/// Returns the number of samples in the frame if the header is valid, or None if:
|
||||||
@@ -376,7 +375,6 @@ pub(crate) fn find_complete_frames_with_samples(data: &[u8]) -> (usize, u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Extract sample rate from STREAMINFO block in FLAC header
|
/// Extract sample rate from STREAMINFO block in FLAC header
|
||||||
pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioError> {
|
pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioError> {
|
||||||
// Verify we have at least "fLaC" magic + STREAMINFO block header
|
// Verify we have at least "fLaC" magic + STREAMINFO block header
|
||||||
@@ -424,7 +422,9 @@ pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read FLAC header (fLaC + all metadata blocks until first frame)
|
/// Read FLAC header (fLaC + all metadata blocks until first frame)
|
||||||
pub(crate) async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, AudioError> {
|
pub(crate) async fn read_flac_header(
|
||||||
|
stream: &mut FlacEncodedStream,
|
||||||
|
) -> Result<Vec<u8>, AudioError> {
|
||||||
let mut header = Vec::new();
|
let mut header = Vec::new();
|
||||||
let mut buffer = [0u8; 4];
|
let mut buffer = [0u8; 4];
|
||||||
|
|
||||||
@@ -472,8 +472,6 @@ pub(crate) async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<V
|
|||||||
Ok(header)
|
Ok(header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ use tokio::sync::{mpsc, RwLock};
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use crate::byte_stream_reader::{PcmChunk};
|
use crate::byte_stream_reader::PcmChunk;
|
||||||
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
||||||
use crate::sinks::streaming_sink_common::{
|
use crate::sinks::streaming_sink_common::{
|
||||||
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
||||||
@@ -143,7 +143,6 @@ impl StreamHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub struct FlacClientStream {
|
pub struct FlacClientStream {
|
||||||
inner: SharedClientStream,
|
inner: SharedClientStream,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
use std::{collections::VecDeque, pin::Pin, sync::Arc, task::{Context, Poll}};
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
use tokio::{io::{AsyncRead, ReadBuf}, sync::RwLock};
|
use tokio::{
|
||||||
|
io::{AsyncRead, ReadBuf},
|
||||||
|
sync::RwLock,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{MetadataSnapshot, sinks::{flac_frame_utils::FlacStreamState, streaming_sink_common::SharedStreamHandleInner, timed_broadcast::{self, TryRecvError}}};
|
use crate::{
|
||||||
|
sinks::{
|
||||||
|
flac_frame_utils::FlacStreamState,
|
||||||
|
streaming_sink_common::SharedStreamHandleInner,
|
||||||
|
timed_broadcast::{self, TryRecvError},
|
||||||
|
},
|
||||||
|
MetadataSnapshot,
|
||||||
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ use tokio::sync::{mpsc, RwLock};
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use crate::byte_stream_reader::{PcmChunk};
|
use crate::byte_stream_reader::PcmChunk;
|
||||||
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
||||||
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
|
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
|
||||||
use crate::sinks::streaming_sink_common::{
|
use crate::sinks::streaming_sink_common::{
|
||||||
@@ -421,7 +421,7 @@ impl StreamingOggFlacSink {
|
|||||||
"Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
|
"Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
|
||||||
broadcast_capacity, broadcast_max_lead_time
|
broadcast_capacity, broadcast_max_lead_time
|
||||||
);
|
);
|
||||||
let (broadcast, _) = timed_broadcast::channel("Ogg-Flac",broadcast_capacity);
|
let (broadcast, _) = timed_broadcast::channel("Ogg-Flac", broadcast_capacity);
|
||||||
|
|
||||||
// OGG-FLAC header cache
|
// OGG-FLAC header cache
|
||||||
let header = Arc::new(RwLock::new(None));
|
let header = Arc::new(RwLock::new(None));
|
||||||
|
|||||||
@@ -397,7 +397,9 @@ impl SharedSinkContext {
|
|||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}",
|
"Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}",
|
||||||
self.pending_track_duration.as_ref().map(|d| d.as_secs_f64()),
|
self.pending_track_duration
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.as_secs_f64()),
|
||||||
self.pending_total_samples
|
self.pending_total_samples
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ impl<T> State<T> {
|
|||||||
let entry = oentry.unwrap();
|
let entry = oentry.unwrap();
|
||||||
trace!(
|
trace!(
|
||||||
"TimedBroadcast[{}]: pruning played packet (@{} epoch={})",
|
"TimedBroadcast[{}]: pruning played packet (@{} epoch={})",
|
||||||
self.name, entry.seq, entry.epoch
|
self.name,
|
||||||
|
entry.seq,
|
||||||
|
entry.epoch
|
||||||
);
|
);
|
||||||
|
|
||||||
self.head_seq += 1;
|
self.head_seq += 1;
|
||||||
@@ -311,15 +313,13 @@ impl<T> Sender<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Vérifier si un slot est disponible et insérer
|
// 2. Vérifier si un slot est disponible et insérer
|
||||||
let is_top_zero =
|
let is_top_zero = audio_timestamp.abs() < TOP_ZERO_EPSILON
|
||||||
audio_timestamp.abs() < TOP_ZERO_EPSILON
|
|
||||||
&& segment_duration >= TOP_ZERO_EPSILON;
|
&& segment_duration >= TOP_ZERO_EPSILON;
|
||||||
let is_zero_header =
|
let is_zero_header =
|
||||||
audio_timestamp.abs() < TOP_ZERO_EPSILON
|
audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration < TOP_ZERO_EPSILON;
|
||||||
&& segment_duration < TOP_ZERO_EPSILON;
|
|
||||||
if state.buffer.len() < self.inner.capacity {
|
if state.buffer.len() < self.inner.capacity {
|
||||||
if !state.initialized {
|
if !state.initialized {
|
||||||
if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON {
|
if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON {
|
||||||
warn!(
|
warn!(
|
||||||
"TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway",
|
"TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway",
|
||||||
state.name,
|
state.name,
|
||||||
@@ -352,13 +352,12 @@ impl<T> Sender<T> {
|
|||||||
state.name,
|
state.name,
|
||||||
state.epoch,
|
state.epoch,
|
||||||
state.last_segment_end.is_some(),
|
state.last_segment_end.is_some(),
|
||||||
segment_duration*1000.0
|
segment_duration * 1000.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let expires_at = state.epoch_start
|
let expires_at = state.epoch_start
|
||||||
+ Duration::from_secs_f64(audio_timestamp
|
+ Duration::from_secs_f64(audio_timestamp + segment_duration);
|
||||||
+ segment_duration);
|
|
||||||
|
|
||||||
let is_first_packet = state.next_seq == 0;
|
let is_first_packet = state.next_seq == 0;
|
||||||
if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now {
|
if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now {
|
||||||
|
|||||||
@@ -322,9 +322,19 @@ impl NodeLogic for PlaylistSourceLogic {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!("PlaylistSource: finished track {} - {}", artist, title);
|
tracing::info!("PlaylistSource: finished track {} - {}", artist, title);
|
||||||
// Piste décodée avec succès, transférer vers l'historique si configuré
|
// Piste décodée avec succès, transférer vers l'historique si configuré
|
||||||
tracing::warn!("🔍 HISTORY DEBUG: history_playlist is {:?}", if self.history_playlist.is_some() { "Some" } else { "None" });
|
tracing::warn!(
|
||||||
|
"🔍 HISTORY DEBUG: history_playlist is {:?}",
|
||||||
|
if self.history_playlist.is_some() {
|
||||||
|
"Some"
|
||||||
|
} else {
|
||||||
|
"None"
|
||||||
|
}
|
||||||
|
);
|
||||||
if let Some(ref history) = self.history_playlist {
|
if let Some(ref history) = self.history_playlist {
|
||||||
tracing::warn!("🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history", cache_pk);
|
tracing::warn!(
|
||||||
|
"🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history",
|
||||||
|
cache_pk
|
||||||
|
);
|
||||||
if let Err(e) = history.push(cache_pk.to_string()).await {
|
if let Err(e) = history.push(cache_pk.to_string()).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"PlaylistSourceLogic: failed to add track to history: {}",
|
"PlaylistSourceLogic: failed to add track to history: {}",
|
||||||
|
|||||||
@@ -82,10 +82,7 @@ pub async fn get_cover_url(
|
|||||||
Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()),
|
Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()),
|
||||||
_ => match metadata_guard.get_cover_url().await {
|
_ => match metadata_guard.get_cover_url().await {
|
||||||
Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()),
|
Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()),
|
||||||
_ => (
|
_ => (pmometadata::get_default_cover_url(), "default".to_string()),
|
||||||
pmometadata::get_default_cover_url(),
|
|
||||||
"default".to_string(),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
//! spécifiques aux fichiers audio : conversion FLAC automatique et stockage
|
//! spécifiques aux fichiers audio : conversion FLAC automatique et stockage
|
||||||
//! des métadonnées en JSON dans la base de données.
|
//! des métadonnées en JSON dans la base de données.
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crate::metadata_ext::AudioTrackMetadataExt;
|
use crate::metadata_ext::AudioTrackMetadataExt;
|
||||||
use pmocache::CacheConfig;
|
use anyhow::Result;
|
||||||
use pmocache::download::TransformMetadata;
|
use pmocache::download::TransformMetadata;
|
||||||
|
use pmocache::CacheConfig;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -277,8 +277,7 @@ fn parse_flac_streaminfo(data: &[u8]) -> Option<(u32, u8, u64)> {
|
|||||||
let s = &data[8..8 + 34];
|
let s = &data[8..8 + 34];
|
||||||
|
|
||||||
// sample_rate: 20 bits: bytes 10..12
|
// sample_rate: 20 bits: bytes 10..12
|
||||||
let sample_rate =
|
let sample_rate = ((s[10] as u32) << 12) | ((s[11] as u32) << 4) | ((s[12] as u32 & 0xF0) >> 4);
|
||||||
((s[10] as u32) << 12) | ((s[11] as u32) << 4) | ((s[12] as u32 & 0xF0) >> 4);
|
|
||||||
|
|
||||||
// bits_per_sample: 5 bits spanning byte 12 (lsb) and byte 13 (msb)
|
// bits_per_sample: 5 bits spanning byte 12 (lsb) and byte 13 (msb)
|
||||||
let bps_raw = (((s[12] & 0x01) as u8) << 4) | ((s[13] & 0xF0) >> 4);
|
let bps_raw = (((s[12] & 0x01) as u8) << 4) | ((s[13] & 0xF0) >> 4);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ impl AudioCacheExt for pmoserver::Server {
|
|||||||
"/{pk}/cover-url",
|
"/{pk}/cover-url",
|
||||||
axum::routing::get(crate::api::get_cover_url),
|
axum::routing::get(crate::api::get_cover_url),
|
||||||
)
|
)
|
||||||
.with_state(cache.clone())
|
.with_state(cache.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let openapi = crate::ApiDoc::openapi();
|
let openapi = crate::ApiDoc::openapi();
|
||||||
|
|||||||
@@ -124,9 +124,24 @@ impl TrackMetadataDidlExt for dyn TrackMetadata {
|
|||||||
pmodidl::Resource {
|
pmodidl::Resource {
|
||||||
// Aligne sur Sink du renderer (audio/flac) avec PN explicite.
|
// Aligne sur Sink du renderer (audio/flac) avec PN explicite.
|
||||||
protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(),
|
protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(),
|
||||||
bits_per_sample: self.get_bits_per_sample().await.ok().flatten().map(|b| b.to_string()),
|
bits_per_sample: self
|
||||||
sample_frequency: self.get_sample_rate().await.ok().flatten().map(|sr| sr.to_string()),
|
.get_bits_per_sample()
|
||||||
nr_audio_channels: self.get_channels().await.ok().flatten().map(|ch| ch.to_string()),
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|b| b.to_string()),
|
||||||
|
sample_frequency: self
|
||||||
|
.get_sample_rate()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|sr| sr.to_string()),
|
||||||
|
nr_audio_channels: self
|
||||||
|
.get_channels()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|ch| ch.to_string()),
|
||||||
duration,
|
duration,
|
||||||
url,
|
url,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,22 +237,27 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(sr) = transform.sample_rate {
|
if let Some(sr) = transform.sample_rate {
|
||||||
self.db.set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?;
|
self.db
|
||||||
|
.set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?;
|
||||||
}
|
}
|
||||||
if let Some(bps) = transform.bits_per_sample {
|
if let Some(bps) = transform.bits_per_sample {
|
||||||
self.db.set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?;
|
self.db
|
||||||
|
.set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?;
|
||||||
}
|
}
|
||||||
if let Some(ch) = transform.channels {
|
if let Some(ch) = transform.channels {
|
||||||
self.db.set_a_metadata(pk, "channels", serde_json::json!(ch))?;
|
self.db
|
||||||
|
.set_a_metadata(pk, "channels", serde_json::json!(ch))?;
|
||||||
}
|
}
|
||||||
if let Some(ts) = transform.total_samples {
|
if let Some(ts) = transform.total_samples {
|
||||||
self.db.set_a_metadata(pk, "total_samples", serde_json::json!(ts))?;
|
self.db
|
||||||
|
.set_a_metadata(pk, "total_samples", serde_json::json!(ts))?;
|
||||||
|
|
||||||
// Calculer la durée à partir de total_samples et sample_rate
|
// Calculer la durée à partir de total_samples et sample_rate
|
||||||
if let Some(sr) = transform.sample_rate {
|
if let Some(sr) = transform.sample_rate {
|
||||||
if sr > 0 {
|
if sr > 0 {
|
||||||
let secs = (ts as f64 / sr as f64).round() as u64;
|
let secs = (ts as f64 / sr as f64).round() as u64;
|
||||||
self.db.set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?;
|
self.db
|
||||||
|
.set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,9 @@ fn test_get_pk_by_origin_url() {
|
|||||||
assert_eq!(found_pk, Some(pk.to_string()));
|
assert_eq!(found_pk, Some(pk.to_string()));
|
||||||
|
|
||||||
// Rechercher une URL qui n'existe pas
|
// Rechercher une URL qui n'existe pas
|
||||||
let not_found = db.get_pk_by_origin_url("https://example.com/notfound.jpg").unwrap();
|
let not_found = db
|
||||||
|
.get_pk_by_origin_url("https://example.com/notfound.jpg")
|
||||||
|
.unwrap();
|
||||||
assert_eq!(not_found, None);
|
assert_eq!(not_found, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod webp;
|
pub mod webp;
|
||||||
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
|
|
||||||
@@ -201,7 +200,10 @@ async fn serve_jpeg_internal(
|
|||||||
use image::ImageFormat;
|
use image::ImageFormat;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
let path = cache.get_file_path_with_qualifier(&pk, <CoversConfig as pmocache::cache::CacheConfig>::default_param());
|
let path = cache.get_file_path_with_qualifier(
|
||||||
|
&pk,
|
||||||
|
<CoversConfig as pmocache::cache::CacheConfig>::default_param(),
|
||||||
|
);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||||
}
|
}
|
||||||
@@ -218,12 +220,7 @@ async fn serve_jpeg_internal(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(Ok(data)) => (
|
Ok(Ok(data)) => (StatusCode::OK, [("content-type", "image/jpeg")], data).into_response(),
|
||||||
StatusCode::OK,
|
|
||||||
[("content-type", "image/jpeg")],
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::warn!("JPEG transcode error for {}: {}", pk, e);
|
tracing::warn!("JPEG transcode error for {}: {}", pk, e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response()
|
||||||
@@ -294,10 +291,7 @@ impl CoverCacheExt for pmoserver::Server {
|
|||||||
// Router JPEG (transcodage à la volée depuis le WebP stocké)
|
// Router JPEG (transcodage à la volée depuis le WebP stocké)
|
||||||
// Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size}
|
// Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size}
|
||||||
let jpeg_router = axum::Router::new()
|
let jpeg_router = axum::Router::new()
|
||||||
.route(
|
.route("/covers/jpeg/{pk}", axum::routing::get(serve_cover_jpeg))
|
||||||
"/covers/jpeg/{pk}",
|
|
||||||
axum::routing::get(serve_cover_jpeg),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/covers/jpeg/{pk}/{size}",
|
"/covers/jpeg/{pk}/{size}",
|
||||||
axum::routing::get(serve_cover_jpeg_with_size),
|
axum::routing::get(serve_cover_jpeg_with_size),
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ fn main() {
|
|||||||
creator: Some("Test Artist".to_string()),
|
creator: Some("Test Artist".to_string()),
|
||||||
class: "object.item.audioItem.musicTrack".to_string(),
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
artist: Some("Test Artist".to_string()),
|
artist: Some("Test Artist".to_string()),
|
||||||
album: None, // Pas d'album
|
album: None, // Pas d'album
|
||||||
genre: None,
|
genre: None,
|
||||||
album_art: None, // Pas d'albumArtURI
|
album_art: None, // Pas d'albumArtURI
|
||||||
album_art_pk: None,
|
album_art_pk: None,
|
||||||
date: None,
|
date: None,
|
||||||
original_track_number: None,
|
original_track_number: None,
|
||||||
@@ -42,7 +42,10 @@ fn main() {
|
|||||||
println!("=== Output from quick_xml::se::to_string() ===");
|
println!("=== Output from quick_xml::se::to_string() ===");
|
||||||
println!("{}", xml);
|
println!("{}", xml);
|
||||||
println!("\n=== Length: {} bytes ===", xml.len());
|
println!("\n=== Length: {} bytes ===", xml.len());
|
||||||
println!("\n=== Starts with '<?xml' ? {} ===", xml.starts_with("<?xml"));
|
println!(
|
||||||
|
"\n=== Starts with '<?xml' ? {} ===",
|
||||||
|
xml.starts_with("<?xml")
|
||||||
|
);
|
||||||
println!("\n=== With manual XML declaration ===");
|
println!("\n=== With manual XML declaration ===");
|
||||||
let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", xml);
|
let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", xml);
|
||||||
println!("{}", with_decl);
|
println!("{}", with_decl);
|
||||||
|
|||||||
@@ -554,7 +554,8 @@ impl ToXmlElement for Container {
|
|||||||
fn to_xml_element(&self) -> Element {
|
fn to_xml_element(&self) -> Element {
|
||||||
let mut elem = Element::new("container");
|
let mut elem = Element::new("container");
|
||||||
elem.attributes.insert("id".into(), self.id.clone());
|
elem.attributes.insert("id".into(), self.id.clone());
|
||||||
elem.attributes.insert("parentID".into(), self.parent_id.clone());
|
elem.attributes
|
||||||
|
.insert("parentID".into(), self.parent_id.clone());
|
||||||
if let Some(ref r) = self.restricted {
|
if let Some(ref r) = self.restricted {
|
||||||
elem.attributes.insert("restricted".into(), r.clone());
|
elem.attributes.insert("restricted".into(), r.clone());
|
||||||
}
|
}
|
||||||
@@ -562,11 +563,14 @@ impl ToXmlElement for Container {
|
|||||||
elem.attributes.insert("childCount".into(), cc.clone());
|
elem.attributes.insert("childCount".into(), cc.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref searchable) = self.searchable {
|
if let Some(ref searchable) = self.searchable {
|
||||||
elem.attributes.insert("searchable".into(), searchable.clone());
|
elem.attributes
|
||||||
|
.insert("searchable".into(), searchable.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.children.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
elem.children
|
||||||
elem.children.push(XMLNode::Element(text_element("upnp:class", &self.class)));
|
.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
||||||
|
elem.children
|
||||||
|
.push(XMLNode::Element(text_element("upnp:class", &self.class)));
|
||||||
|
|
||||||
for c in &self.containers {
|
for c in &self.containers {
|
||||||
elem.children.push(XMLNode::Element(c.to_xml_element()));
|
elem.children.push(XMLNode::Element(c.to_xml_element()));
|
||||||
@@ -583,15 +587,18 @@ impl ToXmlElement for Item {
|
|||||||
fn to_xml_element(&self) -> Element {
|
fn to_xml_element(&self) -> Element {
|
||||||
let mut elem = Element::new("item");
|
let mut elem = Element::new("item");
|
||||||
elem.attributes.insert("id".into(), self.id.clone());
|
elem.attributes.insert("id".into(), self.id.clone());
|
||||||
elem.attributes.insert("parentID".into(), self.parent_id.clone());
|
elem.attributes
|
||||||
|
.insert("parentID".into(), self.parent_id.clone());
|
||||||
if let Some(ref r) = self.restricted {
|
if let Some(ref r) = self.restricted {
|
||||||
elem.attributes.insert("restricted".into(), r.clone());
|
elem.attributes.insert("restricted".into(), r.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.children.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
elem.children
|
||||||
|
.push(XMLNode::Element(text_element("dc:title", &self.title)));
|
||||||
|
|
||||||
if let Some(ref c) = self.creator {
|
if let Some(ref c) = self.creator {
|
||||||
elem.children.push(XMLNode::Element(text_element("dc:creator", c)));
|
elem.children
|
||||||
|
.push(XMLNode::Element(text_element("dc:creator", c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.children
|
elem.children
|
||||||
@@ -644,7 +651,8 @@ impl ToXmlElement for Resource {
|
|||||||
elem.attributes.insert("bitsPerSample".into(), bps.clone());
|
elem.attributes.insert("bitsPerSample".into(), bps.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref freq) = self.sample_frequency {
|
if let Some(ref freq) = self.sample_frequency {
|
||||||
elem.attributes.insert("sampleFrequency".into(), freq.clone());
|
elem.attributes
|
||||||
|
.insert("sampleFrequency".into(), freq.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref ch) = self.nr_audio_channels {
|
if let Some(ref ch) = self.nr_audio_channels {
|
||||||
elem.attributes.insert("nrAudioChannels".into(), ch.clone());
|
elem.attributes.insert("nrAudioChannels".into(), ch.clone());
|
||||||
@@ -678,7 +686,6 @@ impl ToXmlElement for Description {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============= Itérateurs personnalisés =============
|
// ============= Itérateurs personnalisés =============
|
||||||
|
|
||||||
struct AllContainersIter<'a> {
|
struct AllContainersIter<'a> {
|
||||||
|
|||||||
@@ -554,7 +554,9 @@ fn run_encoder(
|
|||||||
"set_total_samples_estimate failed",
|
"set_total_samples_estimate failed",
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("FLAC encoder: total_samples is None, STREAMINFO will have total_samples=0");
|
tracing::warn!(
|
||||||
|
"FLAC encoder: total_samples is None, STREAMINFO will have total_samples=0"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(block_size) = options.block_size {
|
if let Some(block_size) = options.block_size {
|
||||||
ensure(
|
ensure(
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
//! - **Search** : Recherche dans les sources qui le supportent
|
//! - **Search** : Recherche dans les sources qui le supportent
|
||||||
//! - **Update ID** : Suivi des changements pour les notifications UPnP
|
//! - **Update ID** : Suivi des changements pour les notifications UPnP
|
||||||
|
|
||||||
use pmoutils::ToXmlElement;
|
|
||||||
use pmodidl::{Container, DIDLLite};
|
use pmodidl::{Container, DIDLLite};
|
||||||
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
|
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
|
||||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError};
|
use pmosource::{BrowseResult, MusicSource, MusicSourceError};
|
||||||
|
use pmoutils::ToXmlElement;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -123,20 +123,14 @@ impl ContentHandler {
|
|||||||
container.child_count = None; // compatibilité CP
|
container.child_count = None; // compatibilité CP
|
||||||
let didl = to_didl_lite(&[container], &[])?;
|
let didl = to_didl_lite(&[container], &[])?;
|
||||||
let update_id = source.update_id().await.max(1);
|
let update_id = source.update_id().await.max(1);
|
||||||
tracing::debug!(
|
tracing::debug!("BrowseMetadata root (flatten) didl_len={}B", didl.len());
|
||||||
"BrowseMetadata root (flatten) didl_len={}B",
|
|
||||||
didl.len()
|
|
||||||
);
|
|
||||||
return Ok((didl, 1, 1, update_id));
|
return Ok((didl, 1, 1, update_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon retourner le container racine agrégé
|
// Sinon retourner le container racine agrégé
|
||||||
let root = self.build_root_container().await;
|
let root = self.build_root_container().await;
|
||||||
let didl = to_didl_lite(&[root], &[])?;
|
let didl = to_didl_lite(&[root], &[])?;
|
||||||
tracing::debug!(
|
tracing::debug!("BrowseMetadata root (aggregate) didl_len={}B", didl.len());
|
||||||
"BrowseMetadata root (aggregate) didl_len={}B",
|
|
||||||
didl.len()
|
|
||||||
);
|
|
||||||
Ok((didl, 1, 1, 1))
|
Ok((didl, 1, 1, 1))
|
||||||
} else {
|
} else {
|
||||||
// Essayer de trouver l'objet dans les sources
|
// Essayer de trouver l'objet dans les sources
|
||||||
@@ -300,7 +294,13 @@ impl ContentHandler {
|
|||||||
match source.browse(object_id).await {
|
match source.browse(object_id).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
return self
|
return self
|
||||||
.browse_result_to_didl(object_id, result, source, starting_index, requested_count)
|
.browse_result_to_didl(
|
||||||
|
object_id,
|
||||||
|
result,
|
||||||
|
source,
|
||||||
|
starting_index,
|
||||||
|
requested_count,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(MusicSourceError::ObjectNotFound(_)) => continue,
|
Err(MusicSourceError::ObjectNotFound(_)) => continue,
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ use pmoupnp::define_service;
|
|||||||
|
|
||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod variables;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod variables;
|
||||||
|
|
||||||
use actions::{BROWSE, GETSEARCHCAPABILITIES, GETSORTCAPABILITIES, GETSYSTEMUPDATEID, SEARCH};
|
use actions::{BROWSE, GETSEARCHCAPABILITIES, GETSORTCAPABILITIES, GETSYSTEMUPDATEID, SEARCH};
|
||||||
use variables::{
|
use variables::{
|
||||||
A_ARG_TYPE_BROWSEFLAG, A_ARG_TYPE_COUNT, A_ARG_TYPE_FILTER, A_ARG_TYPE_INDEX,
|
A_ARG_TYPE_BROWSEFLAG, A_ARG_TYPE_COUNT, A_ARG_TYPE_FILTER, A_ARG_TYPE_INDEX,
|
||||||
A_ARG_TYPE_OBJECTID, A_ARG_TYPE_RESULT, A_ARG_TYPE_SEARCHCRITERIA, A_ARG_TYPE_SORTCRITERIA,
|
A_ARG_TYPE_OBJECTID, A_ARG_TYPE_RESULT, A_ARG_TYPE_SEARCHCRITERIA, A_ARG_TYPE_SORTCRITERIA,
|
||||||
A_ARG_TYPE_UPDATEID, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID, CONTAINERUPDATEIDS,
|
A_ARG_TYPE_UPDATEID, CONTAINERUPDATEIDS, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Service ContentDirectory:1 conforme à la spécification UPnP AV pour MediaServer
|
// Service ContentDirectory:1 conforme à la spécification UPnP AV pour MediaServer
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use pmoupnp::{services::ServiceInstance, variable_types::StateValue};
|
use pmoupnp::{services::ServiceInstance, variable_types::StateValue};
|
||||||
use std::sync::{atomic::{AtomicU32, Ordering}, Arc, Weak, Mutex};
|
use std::sync::{
|
||||||
|
Arc, Mutex, Weak,
|
||||||
|
atomic::{AtomicU32, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
static CONTENTDIR_INSTANCE: OnceCell<Weak<ServiceInstance>> = OnceCell::new();
|
static CONTENTDIR_INSTANCE: OnceCell<Weak<ServiceInstance>> = OnceCell::new();
|
||||||
static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1);
|
static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
@@ -17,7 +20,9 @@ pub fn register_instance(instance: &Arc<ServiceInstance>) {
|
|||||||
/// Notifie une mise à jour en incrémentant SystemUpdateID et ContainerUpdateIDs.
|
/// Notifie une mise à jour en incrémentant SystemUpdateID et ContainerUpdateIDs.
|
||||||
/// `container_ids` doit contenir les IDs des conteneurs impactés.
|
/// `container_ids` doit contenir les IDs des conteneurs impactés.
|
||||||
pub fn notify_containers_updated(container_ids: &[&str]) {
|
pub fn notify_containers_updated(container_ids: &[&str]) {
|
||||||
let new_id = SYSTEM_UPDATE_ID.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
let new_id = SYSTEM_UPDATE_ID
|
||||||
|
.fetch_add(1, Ordering::Relaxed)
|
||||||
|
.saturating_add(1);
|
||||||
set_system_update_id(new_id);
|
set_system_update_id(new_id);
|
||||||
|
|
||||||
if !container_ids.is_empty() {
|
if !container_ids.is_empty() {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
///! Extension trait pour initialiser le PMO Music MediaServer UPnP
|
///! Extension trait pour initialiser le PMO Music MediaServer UPnP
|
||||||
|
|
||||||
use pmoupnp::devices::DeviceInstance;
|
use pmoupnp::devices::DeviceInstance;
|
||||||
use pmoupnp::variable_types::StateValue;
|
use pmoupnp::variable_types::StateValue;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{
|
http::{
|
||||||
@@ -15,11 +14,17 @@ use axum::{
|
|||||||
},
|
},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use pmoaudiocache::{get_audio_cache, register_audio_cache, AudioCacheExt, Cache as AudioCache};
|
||||||
|
use pmocovers::{get_cover_cache, register_cover_cache, Cache as CoverCache, CoverCacheExt};
|
||||||
|
use pmoparadise::{
|
||||||
|
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||||
|
stream_channel::register_global_channel_manager,
|
||||||
|
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache};
|
|
||||||
use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache};
|
|
||||||
use pmoparadise::{ParadiseChannelManager, ParadiseHistoryBuilder, channels::ALL_CHANNELS};
|
|
||||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||||
|
use pmoplaylist::{self, PlaylistEventKind};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -64,7 +69,9 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
|||||||
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> {
|
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> {
|
||||||
info!("🎵 Initializing Radio Paradise streaming channels...");
|
info!("🎵 Initializing Radio Paradise streaming channels...");
|
||||||
// Sentinel log pour vérifier qu'on exécute bien cette version du binaire
|
// Sentinel log pour vérifier qu'on exécute bien cette version du binaire
|
||||||
tracing::warn!("🔍 Rien de neuf: entering init_paradise_streaming with caches+history setup");
|
tracing::warn!(
|
||||||
|
"🔍 Rien de neuf: entering init_paradise_streaming with caches+history setup"
|
||||||
|
);
|
||||||
|
|
||||||
// Récupérer ou initialiser les caches singletons
|
// Récupérer ou initialiser les caches singletons
|
||||||
info!("📦 Getting cache singletons...");
|
info!("📦 Getting cache singletons...");
|
||||||
@@ -143,6 +150,9 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
register_global_channel_manager(manager.clone());
|
||||||
|
spawn_playlist_event_handler(manager.clone());
|
||||||
|
|
||||||
let state = Arc::new(ParadiseStreamingState {
|
let state = Arc::new(ParadiseStreamingState {
|
||||||
manager: manager.clone(),
|
manager: manager.clone(),
|
||||||
});
|
});
|
||||||
@@ -317,3 +327,56 @@ async fn stream_history_ogg(
|
|||||||
.body(Body::from_stream(ReaderStream::new(stream)))
|
.body(Body::from_stream(ReaderStream::new(stream)))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut rx = pmoplaylist::subscribe_events();
|
||||||
|
while let Ok(envelope) = rx.recv().await {
|
||||||
|
if let PlaylistEventKind::TrackPlayed { cache_pk, .. } = envelope.event.kind {
|
||||||
|
if let Some(descriptor) = channel_from_live_playlist(&envelope.event.playlist_id) {
|
||||||
|
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to prefetch for channel {}: {}",
|
||||||
|
descriptor.display_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(e) = append_track_to_history(descriptor, &cache_pk).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to update history for channel {}: {}",
|
||||||
|
descriptor.display_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> {
|
||||||
|
const PREFIX: &str = "radio-paradise-live-";
|
||||||
|
let slug = playlist_id.strip_prefix(PREFIX)?;
|
||||||
|
ALL_CHANNELS
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.slug == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_track_to_history(descriptor: &ChannelDescriptor, cache_pk: &str) -> Result<()> {
|
||||||
|
let playlist_id = format!("radio-paradise-history-{}", descriptor.slug);
|
||||||
|
let manager = pmoplaylist::PlaylistManager();
|
||||||
|
let handle = manager
|
||||||
|
.get_persistent_write_handle(playlist_id.clone())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to get history playlist {}", playlist_id))?;
|
||||||
|
|
||||||
|
if handle.contains_pk(cache_pk).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
handle
|
||||||
|
.push(cache_pk.to_string())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to append {} to {}", cache_pk, playlist_id))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ impl SourcesExt for Server {
|
|||||||
|
|
||||||
#[cfg(feature = "paradise")]
|
#[cfg(feature = "paradise")]
|
||||||
async fn register_paradise(&mut self) -> Result<()> {
|
async fn register_paradise(&mut self) -> Result<()> {
|
||||||
use pmoparadise::{RadioParadiseExt, RadioParadiseSource};
|
|
||||||
use crate::contentdirectory::state;
|
use crate::contentdirectory::state;
|
||||||
|
use pmoparadise::{RadioParadiseExt, RadioParadiseSource};
|
||||||
|
|
||||||
tracing::info!("Initializing Radio Paradise source...");
|
tracing::info!("Initializing Radio Paradise source...");
|
||||||
|
|
||||||
|
|||||||
@@ -552,8 +552,23 @@ where
|
|||||||
let src_guard = src.read().await;
|
let src_guard = src.read().await;
|
||||||
|
|
||||||
copy_metadata!(
|
copy_metadata!(
|
||||||
src_guard, dest, title, artist, album, year, duration, sample_rate, total_samples,
|
src_guard,
|
||||||
bits_per_sample, track_id, channel_id, event, rating, cover_url, cover_pk, extra
|
dest,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
year,
|
||||||
|
duration,
|
||||||
|
sample_rate,
|
||||||
|
total_samples,
|
||||||
|
bits_per_sample,
|
||||||
|
track_id,
|
||||||
|
channel_id,
|
||||||
|
event,
|
||||||
|
rating,
|
||||||
|
cover_url,
|
||||||
|
cover_pk,
|
||||||
|
extra
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try to update the timestamp, but ignore transient errors
|
// Try to update the timestamp, but ignore transient errors
|
||||||
@@ -828,8 +843,6 @@ impl TrackMetadata for MemoryTrackMetadata {
|
|||||||
self.updated_at = Some(SystemTime::now());
|
self.updated_at = Some(SystemTime::now());
|
||||||
Ok(Some(()))
|
Ok(Some(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1248,10 +1261,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_cover_url_with_fallback_none() {
|
async fn test_get_cover_url_with_fallback_none() {
|
||||||
let metadata = MemoryTrackMetadata::new();
|
let metadata = MemoryTrackMetadata::new();
|
||||||
assert_eq!(
|
assert_eq!(metadata.get_cover_url_with_fallback().await.unwrap(), None);
|
||||||
metadata.get_cover_url_with_fallback().await.unwrap(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1278,10 +1288,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(metadata.get_cover_url_or_default().await.unwrap(), "abc123");
|
||||||
metadata.get_cover_url_or_default().await.unwrap(),
|
|
||||||
"abc123"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ hex = "0.4"
|
|||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
|
once_cell = "1.20"
|
||||||
|
|
||||||
# Gestion des erreurs
|
# Gestion des erreurs
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use pmoaudio_ext::StreamingSinkOptions;
|
||||||
use pmoaudiocache::{
|
use pmoaudiocache::{
|
||||||
new_cache_with_consolidation as new_audio_cache,
|
new_cache_with_consolidation as new_audio_cache,
|
||||||
register_audio_cache as register_global_audio_cache,
|
register_audio_cache as register_global_audio_cache,
|
||||||
};
|
};
|
||||||
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache};
|
use pmocovers::{
|
||||||
|
new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
|
||||||
|
};
|
||||||
use pmoparadise::{
|
use pmoparadise::{
|
||||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||||
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
||||||
};
|
};
|
||||||
use pmoaudio_ext::StreamingSinkOptions;
|
|
||||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||||
use std::{fs, net::SocketAddr, sync::Arc};
|
use std::{fs, net::SocketAddr, sync::Arc};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -44,9 +46,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt().with_env_filter(env_filter).init();
|
||||||
.with_env_filter(env_filter)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let descriptor = pick_descriptor(std::env::args().nth(1))?;
|
let descriptor = pick_descriptor(std::env::args().nth(1))?;
|
||||||
info!(
|
info!(
|
||||||
@@ -206,22 +206,16 @@ async fn get_cover(
|
|||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Récupérer le chemin de la cover depuis le cache
|
// Récupérer le chemin de la cover depuis le cache
|
||||||
// Le cache retourne un PathBuf pointant vers le fichier .webp
|
// Le cache retourne un PathBuf pointant vers le fichier .webp
|
||||||
let cover_path = state
|
let cover_path = state.cover_cache.get(&pk).await.map_err(|e| {
|
||||||
.cover_cache
|
tracing::error!("Failed to get cover path for {}: {}", pk, e);
|
||||||
.get(&pk)
|
StatusCode::NOT_FOUND
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Failed to get cover path for {}: {}", pk, e);
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Lire le fichier
|
// Lire le fichier
|
||||||
let cover_data = tokio::fs::read(&cover_path)
|
let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| {
|
||||||
.await
|
tracing::error!("Failed to read cover file {:?}: {}", cover_path, e);
|
||||||
.map_err(|e| {
|
StatusCode::NOT_FOUND
|
||||||
tracing::error!("Failed to read cover file {:?}: {}", cover_path, e);
|
})?;
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
|
|||||||
@@ -429,11 +429,7 @@ async fn get_cover_url(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let song = block.get_song(song_index).ok_or_else(|| {
|
let song = block.get_song(song_index).ok_or_else(|| {
|
||||||
tracing::warn!(
|
tracing::warn!("Song index {} not found in block {}", song_index, event_id);
|
||||||
"Song index {} not found in block {}",
|
|
||||||
song_index,
|
|
||||||
event_id
|
|
||||||
);
|
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,26 @@
|
|||||||
//! exposing live streams and historical playlists for all 4 channels.
|
//! exposing live streams and historical playlists for all 4 channels.
|
||||||
|
|
||||||
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
||||||
use std::fmt;
|
|
||||||
use pmosource::pmodidl::{Container, Item, Resource};
|
use pmosource::pmodidl::{Container, Item, Resource};
|
||||||
use pmosource::{
|
use pmosource::{
|
||||||
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
||||||
SourceCapabilities,
|
SourceCapabilities,
|
||||||
};
|
};
|
||||||
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
|
||||||
/// Default Radio Paradise image (embedded in binary)
|
/// Default Radio Paradise image (embedded in binary)
|
||||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5;
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
||||||
///
|
///
|
||||||
/// Provides access to:
|
/// Provides access to:
|
||||||
@@ -142,14 +148,21 @@ impl RadioParadiseSource {
|
|||||||
ALL_CHANNELS
|
ALL_CHANNELS
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ch| pid.ends_with(ch.slug))
|
.find(|ch| pid.ends_with(ch.slug))
|
||||||
.map(|ch| vec![format!("radio-paradise:channel:{}:history", ch.slug)])
|
.map(|ch| {
|
||||||
|
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
// live playlist -> container liveplaylist
|
// live playlist -> container liveplaylist
|
||||||
ALL_CHANNELS
|
ALL_CHANNELS
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ch| pid.ends_with(ch.slug))
|
.find(|ch| pid.ends_with(ch.slug))
|
||||||
.map(|ch| vec![format!("radio-paradise:channel:{}:liveplaylist", ch.slug)])
|
.map(|ch| {
|
||||||
|
vec![format!(
|
||||||
|
"radio-paradise:channel:{}:liveplaylist",
|
||||||
|
ch.slug
|
||||||
|
)]
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +194,10 @@ impl RadioParadiseSource {
|
|||||||
match response.json::<serde_json::Value>().await {
|
match response.json::<serde_json::Value>().await {
|
||||||
Ok(json) => {
|
Ok(json) => {
|
||||||
// Parse metadata from JSON and create an Item
|
// Parse metadata from JSON and create an Item
|
||||||
let title = json["title"].as_str().unwrap_or("Unknown Title").to_string();
|
let title = json["title"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("Unknown Title")
|
||||||
|
.to_string();
|
||||||
let artist = json["artist"].as_str().map(|s| s.to_string());
|
let artist = json["artist"].as_str().map(|s| s.to_string());
|
||||||
let album = json["album"].as_str().map(|s| s.to_string());
|
let album = json["album"].as_str().map(|s| s.to_string());
|
||||||
let year = json["year"].as_u64().map(|y| y as u32);
|
let year = json["year"].as_u64().map(|y| y as u32);
|
||||||
@@ -201,10 +217,12 @@ impl RadioParadiseSource {
|
|||||||
.or_else(|| json["duration"].as_f64())
|
.or_else(|| json["duration"].as_f64())
|
||||||
.map(|secs| {
|
.map(|secs| {
|
||||||
let total_secs = secs as u64;
|
let total_secs = secs as u64;
|
||||||
format!("{}:{:02}:{:02}",
|
format!(
|
||||||
|
"{}:{:02}:{:02}",
|
||||||
total_secs / 3600,
|
total_secs / 3600,
|
||||||
(total_secs % 3600) / 60,
|
(total_secs % 3600) / 60,
|
||||||
total_secs % 60)
|
total_secs % 60
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create the item with current metadata
|
// Create the item with current metadata
|
||||||
@@ -254,6 +272,42 @@ impl RadioParadiseSource {
|
|||||||
format!("radio-paradise-live-{}", slug)
|
format!("radio-paradise-live-{}", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> {
|
||||||
|
let playlist_id = Self::live_playlist_id(slug);
|
||||||
|
let manager = pmoplaylist::PlaylistManager();
|
||||||
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
||||||
|
MusicSourceError::BrowseError(format!(
|
||||||
|
"Failed to get live playlist {}: {}",
|
||||||
|
playlist_id, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
match reader.remaining().await {
|
||||||
|
Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(MusicSourceError::BrowseError(format!(
|
||||||
|
"Failed to inspect live playlist {}: {}",
|
||||||
|
playlist_id, e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT {
|
||||||
|
tracing::warn!(
|
||||||
|
"Timeout waiting for live playlist {} to reach {} items",
|
||||||
|
playlist_id,
|
||||||
|
LIVE_PLAYLIST_MIN_READY_ITEMS
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get channel descriptor by slug
|
/// Get channel descriptor by slug
|
||||||
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
||||||
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
||||||
@@ -380,7 +434,10 @@ impl RadioParadiseSource {
|
|||||||
|
|
||||||
/// Build a history container with accurate child count from playlist
|
/// Build a history container with accurate child count from playlist
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
async fn build_history_container_with_count(&self, descriptor: &ChannelDescriptor) -> Container {
|
async fn build_history_container_with_count(
|
||||||
|
&self,
|
||||||
|
descriptor: &ChannelDescriptor,
|
||||||
|
) -> Container {
|
||||||
let mut container = self.build_history_container(descriptor);
|
let mut container = self.build_history_container(descriptor);
|
||||||
|
|
||||||
// Try to get actual count from playlist
|
// Try to get actual count from playlist
|
||||||
@@ -466,32 +523,48 @@ impl RadioParadiseSource {
|
|||||||
_offset: usize,
|
_offset: usize,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Result<Vec<Item>> {
|
) -> Result<Vec<Item>> {
|
||||||
|
#[cfg(all(feature = "playlist", feature = "pmoaudio"))]
|
||||||
|
if let Some(descriptor) = Self::get_channel_by_slug(slug) {
|
||||||
|
if let Some(manager) = crate::stream_channel::get_global_channel_manager() {
|
||||||
|
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to prefetch live playlist for {}: {}",
|
||||||
|
descriptor.slug,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
if let Err(e) = self.wait_for_live_playlist_ready(slug).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to wait for live playlist readiness on {}: {}",
|
||||||
|
slug,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let playlist_id = Self::live_playlist_id(slug);
|
let playlist_id = Self::live_playlist_id(slug);
|
||||||
|
|
||||||
let manager = pmoplaylist::PlaylistManager();
|
let manager = pmoplaylist::PlaylistManager();
|
||||||
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
||||||
MusicSourceError::BrowseError(format!("Failed to get live playlist {}: {}", playlist_id, e))
|
MusicSourceError::BrowseError(format!(
|
||||||
|
"Failed to get live playlist {}: {}",
|
||||||
|
playlist_id, e
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut items = reader.to_items(count).await.map_err(|e| {
|
let mut items = reader.to_items(count).await.map_err(|e| {
|
||||||
MusicSourceError::BrowseError(format!(
|
MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e))
|
||||||
"Failed to read live playlist entries: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for item in items.iter_mut() {
|
for item in items.iter_mut() {
|
||||||
// Ajuster id/parent/url pour coller au schéma Radio Paradise
|
// Ajuster id/parent/url pour coller au schéma Radio Paradise
|
||||||
if let Some(resource) = item.resources.first_mut() {
|
if let Some(resource) = item.resources.first_mut() {
|
||||||
if let Some(pk) = resource.url.split('/').last() {
|
if let Some(pk) = resource.url.split('/').last() {
|
||||||
item.id = format!(
|
item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
||||||
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug);
|
||||||
slug, pk
|
|
||||||
);
|
|
||||||
item.parent_id = format!(
|
|
||||||
"radio-paradise:channel:{}:liveplaylist",
|
|
||||||
slug
|
|
||||||
);
|
|
||||||
|
|
||||||
if resource.url.starts_with('/') {
|
if resource.url.starts_with('/') {
|
||||||
resource.url = format!("{}{}", self.base_url, resource.url);
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
||||||
@@ -519,10 +592,7 @@ impl RadioParadiseSource {
|
|||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result<Item> {
|
async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result<Item> {
|
||||||
let items = self.get_live_playlist_items(slug, 0, 1000).await?;
|
let items = self.get_live_playlist_items(slug, 0, 1000).await?;
|
||||||
let expected_id = format!(
|
let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
||||||
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
|
||||||
slug, pk
|
|
||||||
);
|
|
||||||
for item in items {
|
for item in items {
|
||||||
if item.id == expected_id {
|
if item.id == expected_id {
|
||||||
return Ok(item);
|
return Ok(item);
|
||||||
@@ -533,7 +603,6 @@ impl RadioParadiseSource {
|
|||||||
pk
|
pk
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Types of object IDs in the Radio Paradise source
|
/// Types of object IDs in the Radio Paradise source
|
||||||
@@ -619,7 +688,8 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
|
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
{
|
{
|
||||||
let history_container = self.build_history_container_with_count(descriptor).await;
|
let history_container =
|
||||||
|
self.build_history_container_with_count(descriptor).await;
|
||||||
let items = self.get_history_items(&slug, 0, 100).await?;
|
let items = self.get_history_items(&slug, 0, 100).await?;
|
||||||
Ok(BrowseResult::Mixed {
|
Ok(BrowseResult::Mixed {
|
||||||
containers: vec![history_container],
|
containers: vec![history_container],
|
||||||
@@ -821,14 +891,14 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
for mut item in items {
|
for mut item in items {
|
||||||
if let Some(resource) = item.resources.first_mut() {
|
if let Some(resource) = item.resources.first_mut() {
|
||||||
if let Some(pk2) = resource.url.split('/').last() {
|
if let Some(pk2) = resource.url.split('/').last() {
|
||||||
item.id =
|
item.id = format!(
|
||||||
format!("radio-paradise:channel:{}:history:track:{}", slug, pk2);
|
"radio-paradise:channel:{}:history:track:{}",
|
||||||
item.parent_id =
|
slug, pk2
|
||||||
format!("radio-paradise:channel:{}:history", slug);
|
);
|
||||||
|
item.parent_id = format!("radio-paradise:channel:{}:history", slug);
|
||||||
|
|
||||||
if resource.url.starts_with('/') {
|
if resource.url.starts_with('/') {
|
||||||
resource.url =
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
||||||
format!("{}{}", self.base_url, resource.url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,7 +909,8 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the item matching this pk in the item ID
|
// Find the item matching this pk in the item ID
|
||||||
let expected_id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
let expected_id =
|
||||||
|
format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
||||||
for item in adjusted {
|
for item in adjusted {
|
||||||
if item.id == expected_id {
|
if item.id == expected_id {
|
||||||
return Ok(item);
|
return Ok(item);
|
||||||
@@ -887,10 +958,8 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
||||||
slug, pk2
|
slug, pk2
|
||||||
);
|
);
|
||||||
item.parent_id = format!(
|
item.parent_id =
|
||||||
"radio-paradise:channel:{}:liveplaylist",
|
format!("radio-paradise:channel:{}:liveplaylist", slug);
|
||||||
slug
|
|
||||||
);
|
|
||||||
|
|
||||||
if resource.url.starts_with('/') {
|
if resource.url.starts_with('/') {
|
||||||
resource.url = format!("{}{}", self.base_url, resource.url);
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
||||||
@@ -910,10 +979,8 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
item.album_art = Some(self.default_cover_url());
|
item.album_art = Some(self.default_cover_url());
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected_id = format!(
|
let expected_id =
|
||||||
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
||||||
slug, pk
|
|
||||||
);
|
|
||||||
if item.id == expected_id {
|
if item.id == expected_id {
|
||||||
return Ok(item);
|
return Ok(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ use crate::{
|
|||||||
models::{Block, EventId},
|
models::{Block, EventId},
|
||||||
playlist_feeder::RadioParadisePlaylistFeeder,
|
playlist_feeder::RadioParadisePlaylistFeeder,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Context as AnyhowContext, Result};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
use pmoaudio::{AudioError, AudioPipelineNode};
|
use pmoaudio::{AudioError, AudioPipelineNode};
|
||||||
use pmoaudio_ext::{
|
use pmoaudio_ext::{
|
||||||
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
||||||
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions,
|
||||||
StreamingSinkOptions,
|
TrackBoundaryCoverNode,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::{get_audio_cache, Cache as AudioCache};
|
use pmoaudiocache::{get_audio_cache, Cache as AudioCache};
|
||||||
use pmocovers::{get_cover_cache, Cache as CoverCache};
|
use pmocovers::{get_cover_cache, Cache as CoverCache};
|
||||||
@@ -203,9 +204,14 @@ impl ParadiseStreamChannel {
|
|||||||
// Propager server_base_url dans les options pour que les encoders injectent les covers du cache
|
// Propager server_base_url dans les options pour que les encoders injectent les covers du cache
|
||||||
let mut config = config;
|
let mut config = config;
|
||||||
if let Some(ref base) = config.server_base_url {
|
if let Some(ref base) = config.server_base_url {
|
||||||
config.flac_options =
|
config.flac_options = config
|
||||||
config.flac_options.clone().with_server_base_url(Some(base.clone()));
|
.flac_options
|
||||||
config.ogg_options = config.ogg_options.clone().with_server_base_url(Some(base.clone()));
|
.clone()
|
||||||
|
.with_server_base_url(Some(base.clone()));
|
||||||
|
config.ogg_options = config
|
||||||
|
.ogg_options
|
||||||
|
.clone()
|
||||||
|
.with_server_base_url(Some(base.clone()));
|
||||||
}
|
}
|
||||||
let cover_cache = cover_cache
|
let cover_cache = cover_cache
|
||||||
.or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone()))
|
.or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone()))
|
||||||
@@ -312,6 +318,7 @@ impl ParadiseStreamChannel {
|
|||||||
activity_notify: Notify::new(),
|
activity_notify: Notify::new(),
|
||||||
stop_token,
|
stop_token,
|
||||||
current_block: Mutex::new(None),
|
current_block: Mutex::new(None),
|
||||||
|
prefetch_lock: Mutex::new(()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let pipeline_state = state.clone();
|
let pipeline_state = state.clone();
|
||||||
@@ -496,6 +503,12 @@ impl Drop for ParadiseStreamChannel {
|
|||||||
|
|
||||||
const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600);
|
const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600);
|
||||||
const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300);
|
const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300);
|
||||||
|
const LIVE_PREFETCH_MIN_TRACKS: usize = 5;
|
||||||
|
const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||||
|
const LIVE_PREFETCH_MAX_BLOCKS: usize = 4;
|
||||||
|
|
||||||
|
static GLOBAL_CHANNEL_MANAGER: OnceCell<std::sync::Weak<ParadiseChannelManager>> = OnceCell::new();
|
||||||
|
|
||||||
struct ChannelState {
|
struct ChannelState {
|
||||||
descriptor: ChannelDescriptor,
|
descriptor: ChannelDescriptor,
|
||||||
@@ -510,6 +523,7 @@ struct ChannelState {
|
|||||||
activity_notify: Notify,
|
activity_notify: Notify,
|
||||||
stop_token: CancellationToken,
|
stop_token: CancellationToken,
|
||||||
current_block: Mutex<Option<EventId>>,
|
current_block: Mutex<Option<EventId>>,
|
||||||
|
prefetch_lock: Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelState {
|
impl ChannelState {
|
||||||
@@ -580,6 +594,66 @@ impl ChannelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn live_playlist_id(&self) -> String {
|
||||||
|
format!("radio-paradise-live-{}", self.descriptor.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prefetch_until_horizon(&self) -> Result<()> {
|
||||||
|
let _guard = self.prefetch_lock.lock().await;
|
||||||
|
let playlist_id = self.live_playlist_id();
|
||||||
|
let manager = PlaylistManager::get();
|
||||||
|
let reader = manager
|
||||||
|
.get_read_handle(&playlist_id)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to get live playlist {}", playlist_id))?;
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut next_event: Option<EventId> = None;
|
||||||
|
let mut attempts = 0usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let available = reader
|
||||||
|
.remaining()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to inspect playlist {}", playlist_id))?;
|
||||||
|
if available >= LIVE_PREFETCH_MIN_TRACKS {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if start.elapsed() >= LIVE_PREFETCH_TIMEOUT {
|
||||||
|
warn!(
|
||||||
|
"Prefetch timeout for channel {} ({} tracks available)",
|
||||||
|
self.descriptor.display_name, available
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempts >= LIVE_PREFETCH_MAX_BLOCKS {
|
||||||
|
warn!(
|
||||||
|
"Prefetch block limit reached for channel {} ({} tracks available)",
|
||||||
|
self.descriptor.display_name, available
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.client.get_block(next_event).await {
|
||||||
|
Ok(block) => {
|
||||||
|
attempts += 1;
|
||||||
|
next_event = Some(block.end_event);
|
||||||
|
self.feeder.push_block_id(block.event).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to fetch block during prefetch for channel {}: {}",
|
||||||
|
self.descriptor.display_name, e
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_current_block(&self, event_id: EventId) {
|
async fn set_current_block(&self, event_id: EventId) {
|
||||||
let mut guard = self.current_block.lock().await;
|
let mut guard = self.current_block.lock().await;
|
||||||
*guard = Some(event_id);
|
*guard = Some(event_id);
|
||||||
@@ -866,12 +940,7 @@ impl ParadiseChannelManager {
|
|||||||
);
|
);
|
||||||
let channel = match tokio::time::timeout(
|
let channel = match tokio::time::timeout(
|
||||||
Duration::from_secs(20),
|
Duration::from_secs(20),
|
||||||
ParadiseStreamChannel::new(
|
ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts),
|
||||||
descriptor,
|
|
||||||
config,
|
|
||||||
cover_cache.clone(),
|
|
||||||
history_opts,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -918,4 +987,25 @@ impl ParadiseChannelManager {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> {
|
pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> {
|
||||||
self.channels.values()
|
self.channels.values()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> {
|
||||||
|
let channel = self
|
||||||
|
.get(channel_id)
|
||||||
|
.ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?;
|
||||||
|
channel.prefetch_until_horizon().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_global_channel_manager(manager: Arc<ParadiseChannelManager>) {
|
||||||
|
let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_global_channel_manager() -> Option<Arc<ParadiseChannelManager>> {
|
||||||
|
GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParadiseStreamChannel {
|
||||||
|
pub async fn prefetch_until_horizon(&self) -> Result<()> {
|
||||||
|
self.state.prefetch_until_horizon().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,12 @@ impl ReadHandle {
|
|||||||
let resource = meta.to_didl_resource(url).await;
|
let resource = meta.to_didl_resource(url).await;
|
||||||
|
|
||||||
// Récupérer les métadonnées pour construire l'Item DIDL
|
// Récupérer les métadonnées pour construire l'Item DIDL
|
||||||
let title = meta.get_title().await.ok().flatten().unwrap_or_else(|| "Unknown".to_string());
|
let title = meta
|
||||||
|
.get_title()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
let artist = meta.get_artist().await.ok().flatten();
|
let artist = meta.get_artist().await.ok().flatten();
|
||||||
let album = meta.get_album().await.ok().flatten();
|
let album = meta.get_album().await.ok().flatten();
|
||||||
let genre = meta.get_genre().await.ok().flatten();
|
let genre = meta.get_genre().await.ok().flatten();
|
||||||
|
|||||||
@@ -222,6 +222,16 @@ impl WriteHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la playlist contient déjà un pk
|
||||||
|
pub async fn contains_pk(&self, cache_pk: &str) -> Result<bool> {
|
||||||
|
if !self.playlist.is_alive() {
|
||||||
|
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.playlist.core.read().await;
|
||||||
|
Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk))
|
||||||
|
}
|
||||||
|
|
||||||
/// Change le TTL par défaut
|
/// Change le TTL par défaut
|
||||||
pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> {
|
pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> {
|
||||||
if !self.playlist.is_alive() {
|
if !self.playlist.is_alive() {
|
||||||
|
|||||||
@@ -47,13 +47,13 @@
|
|||||||
mod error;
|
mod error;
|
||||||
mod handle;
|
mod handle;
|
||||||
mod manager;
|
mod manager;
|
||||||
mod persistence;
|
|
||||||
mod playlist;
|
|
||||||
mod track;
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
mod sse;
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
|
mod persistence;
|
||||||
|
mod playlist;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
mod sse;
|
||||||
|
mod track;
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
mod config_ext;
|
mod config_ext;
|
||||||
@@ -62,10 +62,10 @@ mod config_ext;
|
|||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use handle::{ReadHandle, WriteHandle};
|
pub use handle::{ReadHandle, WriteHandle};
|
||||||
pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager};
|
pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager};
|
||||||
pub use manager::{PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind, subscribe_events};
|
pub use manager::{subscribe_events, PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind};
|
||||||
pub use track::PlaylistTrack;
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use sse::playlist_events_router;
|
pub use sse::playlist_events_router;
|
||||||
|
pub use track::PlaylistTrack;
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub use config_ext::PlaylistConfigExt;
|
pub use config_ext::PlaylistConfigExt;
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ use crate::persistence::PersistenceManager;
|
|||||||
use crate::playlist::core::PlaylistConfig;
|
use crate::playlist::core::PlaylistConfig;
|
||||||
use crate::playlist::Playlist;
|
use crate::playlist::Playlist;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use pmocache::{CacheBroadcastEvent, CacheSubscription};
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
|
use pmocache::{CacheBroadcastEvent, CacheSubscription};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use std::sync::RwLock as StdRwLock;
|
use std::sync::RwLock as StdRwLock;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// Singleton PlaylistManager
|
/// Singleton PlaylistManager
|
||||||
static PLAYLIST_MANAGER: OnceCell<PlaylistManager> = OnceCell::new();
|
static PLAYLIST_MANAGER: OnceCell<PlaylistManager> = OnceCell::new();
|
||||||
@@ -181,14 +184,16 @@ impl PlaylistManager {
|
|||||||
|
|
||||||
/// Notifie tous les callbacks qu'une playlist a changé.
|
/// Notifie tous les callbacks qu'une playlist a changé.
|
||||||
pub(crate) fn notify_playlist_changed(&self, id: &str) {
|
pub(crate) fn notify_playlist_changed(&self, id: &str) {
|
||||||
self.notify_playlist_event(
|
self.notify_playlist_event(id, PlaylistEventKind::Updated);
|
||||||
id,
|
|
||||||
PlaylistEventKind::Updated,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée.
|
/// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée.
|
||||||
pub(crate) fn notify_playlist_track_played(&self, playlist_id: &str, cache_pk: &str, qualifier: &str) {
|
pub(crate) fn notify_playlist_track_played(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
cache_pk: &str,
|
||||||
|
qualifier: &str,
|
||||||
|
) {
|
||||||
self.notify_playlist_event(
|
self.notify_playlist_event(
|
||||||
playlist_id,
|
playlist_id,
|
||||||
PlaylistEventKind::TrackPlayed {
|
PlaylistEventKind::TrackPlayed {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
//! Route type : `GET /api/playlists/events?playlist_id=foo`
|
//! Route type : `GET /api/playlists/events?playlist_id=foo`
|
||||||
|
|
||||||
use crate::{subscribe_events, PlaylistEventKind};
|
use crate::{subscribe_events, PlaylistEventKind};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use async_stream::stream;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Query,
|
extract::Query,
|
||||||
response::sse::{Event, KeepAlive, Sse},
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
use async_stream::stream;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
|
Registry, filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload,
|
||||||
Registry,
|
util::SubscriberInitExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Représente une entrée de log
|
/// Représente une entrée de log
|
||||||
@@ -66,10 +66,7 @@ impl LogState {
|
|||||||
if let Err(e) = self.reload_handle.write().unwrap().reload(filter) {
|
if let Err(e) = self.reload_handle.write().unwrap().reload(filter) {
|
||||||
eprintln!("❌ Failed to reload log level filter: {}", e);
|
eprintln!("❌ Failed to reload log level filter: {}", e);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!("✅ Log level filter reloaded successfully to: {:?}", level);
|
||||||
"✅ Log level filter reloaded successfully to: {:?}",
|
|
||||||
level
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +279,10 @@ pub fn init_logging() -> LogState {
|
|||||||
} else {
|
} else {
|
||||||
match EnvFilter::try_new(trimmed) {
|
match EnvFilter::try_new(trimmed) {
|
||||||
Ok(filter) => {
|
Ok(filter) => {
|
||||||
let level_hint =
|
let level_hint = filter
|
||||||
filter.max_level_hint().and_then(levelfilter_to_level).unwrap_or(Level::TRACE);
|
.max_level_hint()
|
||||||
|
.and_then(levelfilter_to_level)
|
||||||
|
.unwrap_or(Level::TRACE);
|
||||||
(filter, level_hint, format!("RUST_LOG ({})", trimmed))
|
(filter, level_hint, format!("RUST_LOG ({})", trimmed))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -241,12 +241,8 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute un handler qui accepte tous les verbes HTTP (ANY) avec état
|
/// Ajoute un handler qui accepte tous les verbes HTTP (ANY) avec état
|
||||||
pub async fn add_any_handler_with_state<H, T, S>(
|
pub async fn add_any_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||||
&mut self,
|
where
|
||||||
path: &str,
|
|
||||||
handler: H,
|
|
||||||
state: S,
|
|
||||||
) where
|
|
||||||
H: Handler<T, S> + Clone + 'static,
|
H: Handler<T, S> + Clone + 'static,
|
||||||
T: 'static,
|
T: 'static,
|
||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
|
|||||||
@@ -955,7 +955,9 @@ async fn stream_source_item_metadata(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Sse::new(stream).keep_alive(KeepAlive::default()).into_response()
|
Sse::new(stream)
|
||||||
|
.keep_alive(KeepAlive::default())
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
None => (
|
None => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ impl UpnpTyped for Argument {
|
|||||||
impl UpnpObject for Argument {
|
impl UpnpObject for Argument {
|
||||||
fn to_xml_element(&self) -> Element {
|
fn to_xml_element(&self) -> Element {
|
||||||
// Compat: retourne le premier argument (utile si consommé isolément)
|
// Compat: retourne le premier argument (utile si consommé isolément)
|
||||||
self.to_xml_elements().into_iter().next().unwrap_or_else(|| Element::new("argument"))
|
self.to_xml_elements()
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| Element::new("argument"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -318,12 +318,14 @@ impl UpnpServerExt for Server {
|
|||||||
// API playlists (SSE + OpenAPI)
|
// API playlists (SSE + OpenAPI)
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
use pmoplaylist::{playlist_events_router, openapi::ApiDoc};
|
use pmoplaylist::{openapi::ApiDoc, playlist_events_router};
|
||||||
// SSE /api/playlists/events
|
// SSE /api/playlists/events
|
||||||
self.add_router("/api/playlists", playlist_events_router()).await;
|
self.add_router("/api/playlists", playlist_events_router())
|
||||||
|
.await;
|
||||||
// OpenAPI pour playlists
|
// OpenAPI pour playlists
|
||||||
let openapi = ApiDoc::openapi();
|
let openapi = ApiDoc::openapi();
|
||||||
self.add_openapi(axum::Router::new(), openapi, "playlists").await;
|
self.add_openapi(axum::Router::new(), openapi, "playlists")
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enregistrer le cache dans le registre global
|
// Enregistrer le cache dans le registre global
|
||||||
|
|||||||
Reference in New Issue
Block a user