Files
pmomusic/pmoradiofrance/src/source.rs
Eric Coissac 4b3e8a961c Implémentation du streaming audio côté serveur pour WebRenderer
Cette mise à jour implémente le streaming audio côté serveur pour WebRenderer, en remplaçant l'ancienne architecture basée sur WebSocket par une nouvelle approche utilisant des endpoints HTTP. Les modifications incluent l'ajout de dépendances audio, la création de nouveaux modules pour le pipeline audio, la gestion des registres et des flux, ainsi que la modification des handlers UPnP pour utiliser le nouveau pipeline. Le frontend a également été mis à jour pour utiliser un seul élément <audio> pointant vers le nouveau endpoint de streaming.
2026-02-23 20:00:28 +01:00

384 lines
13 KiB
Rust

//! MusicSource implementation for Radio France
//!
//! This module implements the `MusicSource` trait from `pmosource` for Radio France,
//! providing UPnP/DLNA integration with dynamic container generation.
use crate::client::RadioFranceClient;
use crate::error::Result;
use crate::metadata_cache::MetadataCache;
use crate::playlist::StationGroups;
use pmoconfig::Config;
use pmodidl::{Container, Item};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, SourceCapabilities};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
#[cfg(feature = "server")]
use pmoupnp;
/// Default image for Radio France source (embedded in binary)
pub const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofrance-logo.webp");
/// Radio France music source
///
/// Provides access to ~70 Radio France stations via UPnP/DLNA with:
/// - Cache de métadonnées avec TTL et système d'événements
/// - Construction dynamique de containers DIDL
/// - Notifications GENA pour les playlists
pub struct RadioFranceSource {
/// Cache de métadonnées centralisé
metadata_cache: Arc<MetadataCache>,
/// Server base URL
server_base_url: String,
/// Update counter for change tracking
update_id: Arc<RwLock<u32>>,
/// Last change timestamp
last_change: Arc<RwLock<Option<SystemTime>>>,
/// Callback for notifying container updates (UPnP GENA)
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync + 'static>>,
}
impl RadioFranceSource {
/// Create a new Radio France source
///
/// # Arguments
///
/// * `config` - Configuration for cache and discovery
///
/// # Example
///
/// ```no_run
/// use pmoradiofrance::RadioFranceSource;
/// use pmoconfig::get_config;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let config = get_config();
/// let source = RadioFranceSource::new(config).await?;
/// Ok(())
/// }
/// ```
#[cfg(feature = "cache")]
pub async fn new(config: Arc<Config>) -> Result<Self> {
let client = RadioFranceClient::new().await?;
let cover_cache = pmoupnp::cache_registry::get_cover_cache()
.ok_or_else(|| crate::error::Error::Other("Cover cache not initialized".to_string()))?;
let server_base_url = config.get_base_url();
let metadata_cache = Arc::new(MetadataCache::new(
client,
cover_cache,
server_base_url.clone(),
config,
));
let update_id = Arc::new(RwLock::new(0));
let last_change = Arc::new(RwLock::new(None));
let source = Self {
metadata_cache,
server_base_url,
update_id,
last_change,
container_notifier: None,
};
Ok(source)
}
/// Set the container notifier for UPnP GENA events
pub fn with_container_notifier(
mut self,
notifier: Arc<dyn Fn(&[String]) + Send + Sync + 'static>,
) -> Self {
// S'abonner aux événements du cache de métadonnées
let container_notifier = Arc::new(notifier.clone());
let update_id = self.update_id.clone();
let last_change = self.last_change.clone();
self.metadata_cache.subscribe(Arc::new(move |slug: &str| {
let slug = slug.to_string();
let update_id = update_id.clone();
let last_change = last_change.clone();
let container_notifier = container_notifier.clone();
tokio::spawn(async move {
*update_id.write().await += 1;
*last_change.write().await = Some(SystemTime::now());
// Notifier le container de playlist (pas l'item)
container_notifier(&[format!("radiofrance:{}", slug)]);
});
}));
self.container_notifier = Some(notifier);
self
}
/// Create a new Radio France source from the cache registry
///
/// This is the recommended way to create a source when using the UPnP server.
///
/// # Arguments
///
/// * `config` - Configuration
/// * `base_url` - Base URL for streaming server
#[cfg(feature = "server")]
pub async fn from_registry(config: Arc<Config>, base_url: impl Into<String>) -> Result<Self> {
let client = RadioFranceClient::new().await?;
let cover_cache = pmoupnp::cache_registry::get_cover_cache()
.ok_or_else(|| crate::error::Error::Other("Cover cache not initialized".to_string()))?;
let server_base_url = base_url.into();
let metadata_cache = Arc::new(MetadataCache::new(
client,
cover_cache,
server_base_url.clone(),
config,
));
let update_id = Arc::new(RwLock::new(0));
let last_change = Arc::new(RwLock::new(None));
Ok(Self {
metadata_cache,
server_base_url,
update_id,
last_change,
container_notifier: None,
})
}
}
impl RadioFranceSource {
/// Get the list of all Radio France stations
pub async fn get_stations(&self) -> Result<Vec<crate::models::Station>> {
self.metadata_cache.get_stations().await
}
/// Get live metadata for a station
pub async fn get_live_metadata(&self, slug: &str) -> Result<crate::models::LiveResponse> {
self.metadata_cache.get_live_metadata(slug).await
}
/// Get the HiFi stream URL for a station
pub async fn get_stream_url(&self, slug: &str) -> Result<String> {
self.metadata_cache.get_stream_url(slug).await
}
}
impl std::fmt::Debug for RadioFranceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioFranceSource")
.field("server_base_url", &self.server_base_url)
.finish()
}
}
#[async_trait]
impl MusicSource for RadioFranceSource {
fn name(&self) -> &str {
"Radio France"
}
fn id(&self) -> &str {
"radiofrance"
}
fn default_image(&self) -> &[u8] {
RADIOFRANCE_DEFAULT_IMAGE
}
fn capabilities(&self) -> SourceCapabilities {
SourceCapabilities {
supports_fifo: false,
supports_search: false,
supports_favorites: false,
supports_playlists: false,
supports_user_content: false,
supports_high_res_audio: false,
max_sample_rate: Some(48000), // AAC 48kHz
supports_multiple_formats: false,
supports_advanced_search: false,
supports_pagination: false,
}
}
async fn root_container(&self) -> pmosource::Result<Container> {
Ok(Container {
id: "radiofrance".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: None,
searchable: Some("0".to_string()),
title: "Radio France".to_string(),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers: vec![],
items: vec![],
})
}
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
match object_id {
"radiofrance" => {
// Niveau 0: retourne le Container avec les groupes
let stations = self
.metadata_cache
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
let container = groups
.to_didl(&self.metadata_cache, &self.server_base_url)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
Ok(BrowseResult::Containers(container.containers))
}
id if id.starts_with("radiofrance:group:") || id == "radiofrance:ici" => {
// Niveau 1: browse d'un groupe
let slug = id
.strip_prefix("radiofrance:group:")
.or_else(|| {
if id == "radiofrance:ici" {
Some("ici")
} else {
None
}
})
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
let stations = self
.metadata_cache
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
let group = groups
.groups
.iter()
.find(|g| g.slug() == slug)
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
let container = group
.to_didl(&self.metadata_cache, &self.server_base_url)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Si c'est une playlist (1 station), retourner le container lui-même
// Sinon retourner ses sous-containers
if container.class == "object.container.playlistContainer" {
Ok(BrowseResult::Containers(vec![container]))
} else {
Ok(BrowseResult::Containers(container.containers))
}
}
id if id.starts_with("radiofrance:") && !id.contains(":stream") => {
// Niveau 2: browse d'une station (playlist)
let slug = id
.strip_prefix("radiofrance:")
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
let stations = self
.metadata_cache
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let station = stations
.iter()
.find(|s| s.slug == slug)
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
let container = station
.to_didl(&self.metadata_cache, &self.server_base_url)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Retourner les items de la playlist
Ok(BrowseResult::Items(container.items))
}
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
}
}
async fn get_item(&self, object_id: &str) -> pmosource::Result<Item> {
// Format: radiofrance:{slug}:stream
let slug = object_id
.strip_prefix("radiofrance:")
.and_then(|s| s.strip_suffix(":stream"))
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let stations = self
.metadata_cache
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let station = stations
.iter()
.find(|s| s.slug == slug)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let container = station
.to_didl(&self.metadata_cache, &self.server_base_url)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
container
.items
.into_iter()
.next()
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))
}
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
// Extract station slug from object_id (format: radiofrance:{slug}:stream)
let _slug = object_id
.strip_prefix("radiofrance:")
.and_then(|s| s.strip_suffix(":stream"))
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
// Get the item to extract the stream URL
let item = self.get_item(object_id).await?;
item.resources
.first()
.map(|r| r.url.clone())
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))
}
fn supports_fifo(&self) -> bool {
false
}
async fn append_track(&self, _item: Item) -> pmosource::Result<()> {
Err(MusicSourceError::FifoNotSupported)
}
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
*self.update_id.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
*self.last_change.read().await
}
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
Ok(vec![])
}
}