Ajout d'un nœud de cache des images dans les trackboundary

This commit is contained in:
2025-11-15 15:03:08 +01:00
parent d9ad056933
commit 58c4383023
4 changed files with 208 additions and 8 deletions

View File

@@ -25,6 +25,9 @@
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub mod sinks;
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub mod nodes;
#[cfg(feature = "playlist")]
pub mod sources;
@@ -32,5 +35,8 @@ pub mod sources;
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub use sinks::*;
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub use nodes::*;
#[cfg(feature = "playlist")]
pub use sources::*;

View File

@@ -0,0 +1,5 @@
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub mod track_boundary_cover_node;
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
pub use track_boundary_cover_node::TrackBoundaryCoverNode;

View File

@@ -0,0 +1,170 @@
//! Node de conversion qui s'assure que chaque `TrackBoundary` possède un `cover_pk`.
//!
//! Il laisse passer tous les segments audio de manière transparente. Lorsqu'un
//! `TrackBoundary` est détecté, il vérifie si ses métadonnées contiennent déjà
//! un `cover_pk`. Si ce n'est pas le cas mais qu'une `cover_url` est disponible,
//! l'image est sauvegardée dans le cache de couvertures puis la clé primaire est
//! écrite dans les métadonnées avant de poursuivre la propagation.
use std::sync::Arc;
use pmoaudio::{
nodes::{AudioError, DEFAULT_CHANNEL_SIZE},
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle},
AudioSegment, TypeRequirement, TypedAudioNode,
};
use pmocovers::Cache as CoverCache;
use pmometadata::TrackMetadata;
use tokio::select;
use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
/// Node enveloppe qui applique [`TrackBoundaryCoverLogic`].
pub struct TrackBoundaryCoverNode {
inner: Node<TrackBoundaryCoverLogic>,
}
impl TrackBoundaryCoverNode {
/// Crée un nouveau node.
pub fn new(cover_cache: Arc<CoverCache>) -> Self {
let logic = TrackBoundaryCoverLogic::new(cover_cache);
Self {
inner: Node::new_with_input(logic, DEFAULT_CHANNEL_SIZE),
}
}
}
impl AudioPipelineNode for TrackBoundaryCoverNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
fn start(self: Box<Self>) -> PipelineHandle {
Box::new(self.inner).start()
}
}
impl TypedAudioNode for TrackBoundaryCoverNode {
fn input_type(&self) -> Option<TypeRequirement> {
Some(TypeRequirement::any())
}
fn output_type(&self) -> Option<TypeRequirement> {
Some(TypeRequirement::any())
}
}
struct TrackBoundaryCoverLogic {
cover_cache: Arc<CoverCache>,
}
impl TrackBoundaryCoverLogic {
fn new(cover_cache: Arc<CoverCache>) -> Self {
Self { cover_cache }
}
async fn ensure_cover_pk(&self, metadata: Arc<RwLock<dyn TrackMetadata>>) {
let cover_url = {
let guard = metadata.read().await;
match guard.get_cover_pk().await {
Ok(Some(pk)) => {
debug!("TrackBoundaryCoverNode: cover_pk already set ({})", pk);
return;
}
Ok(None) => {}
Err(err) => warn!("TrackBoundaryCoverNode: cannot read cover_pk: {}", err),
}
match guard.get_cover_url().await {
Ok(url) => url,
Err(err) => {
warn!("TrackBoundaryCoverNode: cannot read cover_url: {}", err);
return;
}
}
};
let cover_url = match cover_url {
Some(url) => url,
None => {
debug!("TrackBoundaryCoverNode: no cover_url present, skipping cache");
return;
}
};
match self.cover_cache.add_from_url(&cover_url, None).await {
Ok(pk) => {
debug!(
"TrackBoundaryCoverNode: cached cover for url={}, pk={}",
cover_url, pk
);
let mut guard = metadata.write().await;
if let Err(err) = guard.set_cover_pk(Some(pk.clone())).await {
warn!(
"TrackBoundaryCoverNode: failed to set cover_pk {}: {}",
pk, err
);
}
}
Err(err) => {
warn!(
"TrackBoundaryCoverNode: failed to cache cover from {}: {}",
cover_url, err
);
}
}
}
}
#[async_trait::async_trait]
impl NodeLogic for TrackBoundaryCoverLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut input = input.ok_or_else(|| {
AudioError::ProcessingError(
"TrackBoundaryCoverNode requires an upstream input channel".into(),
)
})?;
loop {
let segment = select! {
_ = stop_token.cancelled() => {
debug!("TrackBoundaryCoverNode: stop requested");
break;
}
segment = input.recv() => segment,
};
let Some(segment) = segment else {
debug!("TrackBoundaryCoverNode: upstream closed");
break;
};
if let Some(metadata) = segment.as_track_metadata() {
self.ensure_cover_pk(Arc::clone(metadata)).await;
}
for tx in &output {
if tx.send(segment.clone()).await.is_err() {
return Err(AudioError::ChildDied);
}
}
}
Ok(())
}
}

View File

@@ -18,8 +18,9 @@ use anyhow::Result;
use pmoaudio::AudioPipelineNode;
use pmoaudio_ext::{
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
StreamHandle, StreamingFlacSink, StreamingOggFlacSink,
StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
};
use pmocovers::Cache as CoverCache;
use pmoflac::EncoderOptions;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::Notify;
@@ -101,6 +102,7 @@ impl ParadiseStreamChannel {
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: ParadiseStreamChannelConfig,
cover_cache: Option<Arc<CoverCache>>,
) -> Self {
let mut source = RadioParadiseStreamSource::new(client.clone());
let block_handle = source.block_handle();
@@ -116,8 +118,15 @@ impl ParadiseStreamChannel {
config.max_lead_seconds,
);
if let Some(cache) = cover_cache {
let mut cover_node = TrackBoundaryCoverNode::new(cache);
cover_node.register(Box::new(flac_sink));
cover_node.register(Box::new(ogg_sink));
source.register(Box::new(cover_node));
} else {
source.register(Box::new(flac_sink));
source.register(Box::new(ogg_sink));
}
stream_handle.set_auto_stop(false);
ogg_handle.set_auto_stop(false);
@@ -165,12 +174,13 @@ impl ParadiseStreamChannel {
pub async fn new(
descriptor: ChannelDescriptor,
config: ParadiseStreamChannelConfig,
cover_cache: Option<Arc<CoverCache>>,
) -> Result<Self> {
let client = RadioParadiseClient::builder()
.channel(descriptor.id)
.build()
.await?;
Ok(Self::with_client(descriptor, client, config))
Ok(Self::with_client(descriptor, client, config, cover_cache))
}
/// S'abonne au flux FLAC pur.
@@ -360,17 +370,26 @@ impl ParadiseChannelManager {
Self { channels }
}
pub async fn with_defaults() -> Result<Self> {
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
) -> Result<Self> {
let mut map = HashMap::new();
for descriptor in ALL_CHANNELS.iter().copied() {
let channel =
ParadiseStreamChannel::new(descriptor, ParadiseStreamChannelConfig::default())
let channel = ParadiseStreamChannel::new(
descriptor,
ParadiseStreamChannelConfig::default(),
cover_cache.clone(),
)
.await?;
map.insert(descriptor.id, Arc::new(channel));
}
Ok(Self { channels: map })
}
pub async fn with_defaults() -> Result<Self> {
Self::with_defaults_with_cover_cache(None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
self.channels.get(&id).cloned()
}