Files
pmomusic/pmocontrol/examples/openhome_tester.rs
Eric Coissac 5c5e1d534b feat: Implémentation complète de la source Radio France avec intégration UPnP et serveur HTTP
Ajout de la source Radio France avec :
- Implémentation du trait MusicSource pour l'intégration UPnP
- Routes HTTP API REST pour l'accès aux stations et flux AAC
- Proxy streaming avec tracking des connexions
- Génération dynamique de l'arborescence UPnP
- Cache multi-niveaux (stations, métadonnées, covers)
- Support des ~70 stations (standalone, groupes, radios ICI)

Fichiers créés :
- pmoradiofrance/src/source.rs (implémentation MusicSource)
- pmoradiofrance/src/server_ext.rs (routes HTTP et proxy streaming)
- pmoradiofrance/assets/radiofrance-logo.webp (logo placeholder)

Fichiers modifiés :
- pmoradiofrance/src/lib.rs (re-exports et modules)
- pmoradiofrance/Cargo.toml (dépendances server)
- Cargo.toml (workspace)
- pmomediaserver/Cargo.toml (feature radiofrance)
- PMOMusic/Cargo.toml (feature radiofrance)
- PMOMusic/src/main.rs (enregistrement automatique)

Tests et validation : compilation OK, pattern respecté, feature-gating cohérent
2026-01-23 12:20:43 +01:00

321 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashSet;
use std::env;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use pmocontrol::{
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider,
MusicRendererBackend, RendererInfo,
control_point::ControlPoint,
openhome_client::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
OhTimeClient, OhVolumeClient, parse_track_metadata_from_didl,
},
};
fn main() -> Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init();
let args: Vec<String> = env::args().collect();
let renderer = match args.len() {
1 => auto_discover_renderer()
.context("Unable to auto-discover the OpenHome renderer; rerun with explicit UDN and description URL")?,
3 => {
let udn = args[1].to_ascii_lowercase();
let description_url = args[2].clone();
renderer_from_description(&udn, &description_url)?
}
_ => {
eprintln!(
"Usage:\n {0} # auto-discover the single OpenHome renderer\n {0} <renderer_udn> <description_url>",
args[0]
);
std::process::exit(1);
}
};
println!(
"Renderer: {} ({})",
renderer.friendly_name, renderer.model_name
);
println!("UDN: {}", renderer.udn);
println!(
"OpenHome services -> playlist:{} info:{} time:{} volume:{} radio:{} product:{}",
renderer.oh_playlist_control_url.is_some(),
renderer.oh_info_control_url.is_some(),
renderer.oh_time_control_url.is_some(),
renderer.oh_volume_control_url.is_some(),
renderer.oh_radio_control_url.is_some(),
renderer.oh_product_control_url.is_some(),
);
if let Some(result) = test_product(&renderer) {
result?;
}
if let Some(result) = test_playlist(&renderer) {
result?;
}
if let Some(result) = test_info(&renderer) {
result?;
}
if let Some(result) = test_time(&renderer) {
result?;
}
if let Some(result) = test_volume(&renderer) {
result?;
}
if let Some(result) = test_radio(&renderer) {
result?;
}
Ok(())
}
fn auto_discover_renderer() -> Result<RendererInfo> {
println!("Auto-discovering OpenHome renderer via SSDP…");
let control_point = ControlPoint::spawn(5).context("Failed to start control point")?;
let deadline = Instant::now() + Duration::from_secs(12);
while Instant::now() < deadline {
let renderers = control_point.list_music_renderers();
let mut seen = HashSet::new();
let mut openhome_infos = Vec::new();
for renderer in renderers {
if let MusicRendererBackend::OpenHome(oh) = renderer {
if seen.insert(oh.id().0.clone()) {
openhome_infos.push(oh.info.clone());
}
}
}
match openhome_infos.len() {
0 => {
thread::sleep(Duration::from_millis(500));
}
1 => {
let info = openhome_infos.remove(0);
println!(
"Discovered OpenHome renderer '{}' ({})",
info.friendly_name, info.udn
);
return Ok(info);
}
_ => {
println!("Detected multiple OpenHome renderers:");
for info in &openhome_infos {
println!(" - {} ({})", info.friendly_name, info.udn);
}
return Err(anyhow!(
"Multiple OpenHome renderers present; rerun with explicit UDN + description URL."
));
}
}
}
Err(anyhow!("No OpenHome renderer discovered on the network."))
}
fn renderer_from_description(udn: &str, description_url: &str) -> Result<RendererInfo> {
let endpoint = DiscoveredEndpoint::new(
udn.to_ascii_lowercase(),
description_url.to_string(),
"openhome-tester/1.0".into(),
1800,
);
let provider = HttpXmlDescriptionProvider::new(5);
provider
.build_renderer_info(&endpoint)
.with_context(|| format!("Device {} is not a usable renderer", description_url))
}
fn test_product(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_product_control_url.as_ref()?;
let service_type = info.oh_product_service_type.as_ref()?;
let client = OhProductClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Product] Listing sources…");
let sources = client.source_xml()?;
for (idx, source) in sources.iter().enumerate() {
println!(
" #{idx} {} (type={}, visible={})",
source.name, source.source_type, source.visible
);
}
let current = client.source_index()?;
println!("[Product] Current source index: {current}");
client.ensure_playlist_source_selected()?;
println!("[Product] Playlist source is now selected");
Ok(())
})())
}
fn test_playlist(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_playlist_control_url.as_ref()?;
let service_type = info.oh_playlist_service_type.as_ref()?;
let client = OhPlaylistClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Playlist] TracksMax={}", client.tracks_max()?);
let ids = client.id_array()?;
println!(
"[Playlist] IdArray contains {} entries (showing up to 5)",
ids.len()
);
if ids.is_empty() {
println!("[Playlist] Playlist is empty");
} else {
let preview = &ids[..ids.len().min(5)];
let entries = client.read_list(preview)?;
for entry in entries {
println!(
" - id={} uri={} title={}",
entry.id,
entry.uri,
parse_track_metadata_from_didl(&entry.metadata_xml)
.and_then(|m| m.title)
.unwrap_or_else(|| "<unknown>".into())
);
}
}
exercise_playlist_mutations(&client)?;
Ok(())
})())
}
fn exercise_playlist_mutations(client: &OhPlaylistClient) -> Result<()> {
println!("\n[Playlist] Exercising DeleteAll → Insert → DeleteAll");
client.delete_all()?;
println!(" DeleteAll succeeded");
std::thread::sleep(Duration::from_millis(200));
let _ = client.id_array()?;
let test_uri = env::var("OPENHOME_TEST_URI")
.unwrap_or_else(|_| "http://ice1.somafm.com/groovesalad-128-mp3".into());
let metadata = build_sample_metadata_xml(&test_uri);
println!(
" Inserting sample track at head (after_id = {:#x})…",
OPENHOME_PLAYLIST_HEAD_ID
);
let new_id = match client.insert(OPENHOME_PLAYLIST_HEAD_ID, &test_uri, &metadata) {
Ok(id) => {
println!(" Insert succeeded with id {}", id);
id
}
Err(err) => {
println!(
" Insert with sentinel failed: {}. Retrying with aAfterId=0…",
err
);
let id = client.insert(0, &test_uri, &metadata)?;
println!(" Insert with aAfterId=0 succeeded with id {}", id);
id
}
};
let ids = client.id_array()?;
println!(" Playlist now reports {} id(s): {:?}", ids.len(), ids);
let entries = client.read_list(&[new_id])?;
if let Some(entry) = entries.first() {
println!(" ReadList confirms id={} uri={}", entry.id, entry.uri);
}
client.delete_all()?;
println!(" Playlist restored to empty state");
Ok(())
}
fn build_sample_metadata_xml(uri: &str) -> String {
format!(
concat!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" "#,
r#"xmlns:dc="http://purl.org/dc/elements/1.1/" "#,
r#"xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
r#"<item id="pmo:test:track" parentID="0" restricted="0">"#,
r#"<dc:title>PMO Test Track</dc:title>"#,
r#"<res protocolInfo="http-get:*:audio/mpeg:*">"#,
"{uri}",
r#"</res>"#,
r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"#,
r#"</item></DIDL-Lite>"#
),
uri = uri
)
}
fn test_info(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_info_control_url.as_ref()?;
let service_type = info.oh_info_service_type.as_ref()?;
let client = OhInfoClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Info] Reading current track…");
let track = client.track()?;
println!(" URI: {}", track.uri);
if let Some(metadata) = track.metadata() {
println!(
" Metadata: {} {}",
metadata.artist.as_deref().unwrap_or("<unknown artist>"),
metadata.title.as_deref().unwrap_or("<unknown title>")
);
}
match client.transport_state() {
Ok(state) => println!(" Transport state: {}", state),
Err(err) => println!(" TransportState call failed: {err}"),
}
Ok(())
})())
}
fn test_time(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_time_control_url.as_ref()?;
let service_type = info.oh_time_service_type.as_ref()?;
let client = OhTimeClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Time] Querying position…");
let pos = client.position()?;
println!(
" Tracks={} duration={}s elapsed={}s",
pos.track_count, pos.duration_secs, pos.elapsed_secs
);
Ok(())
})())
}
fn test_volume(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_volume_control_url.as_ref()?;
let service_type = info.oh_volume_service_type.as_ref()?;
let client = OhVolumeClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Volume] Current volume: {}", client.volume()?);
println!("[Volume] Muted: {}", client.mute()?);
Ok(())
})())
}
fn test_radio(info: &RendererInfo) -> Option<Result<()>> {
let control_url = info.oh_radio_control_url.as_ref()?;
let service_type = info.oh_radio_service_type.as_ref()?;
let client = OhRadioClient::new(control_url.clone(), service_type.clone());
Some((|| {
println!("\n[Radio] Fetching channel #0 metadata…");
let channel = client.channel(0)?;
println!(" URI: {}", channel.uri);
if let Some(meta) = channel.metadata_xml {
println!(" Metadata XML: {}", meta);
}
Ok(())
})())
}