push-pqqsxyupswry #21

Merged
eric merged 76 commits from push-pqqsxyupswry into main 2025-12-06 12:49:47 +01:00
16 changed files with 2971 additions and 2877 deletions
Showing only changes of commit 2ffd3b5fb5 - Show all commits

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,7 @@ use pmocontrol::{
ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent,
RendererId, RendererInfo,
};
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
fn main() -> io::Result<()> {
// Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples)
@@ -53,6 +54,7 @@ fn main() -> io::Result<()> {
" [{}] {} | model={} | udn={} | location={} | online={}",
idx, info.friendly_name, info.model_name, info.udn, info.location, info.online
);
print_openhome_summary(" ", info, &cp);
}
// 3. Optionnel : sélection d'un renderer par index (filtrage des événements)
@@ -126,6 +128,7 @@ fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool {
RendererEvent::MuteChanged { id: eid, .. } => eid == id,
RendererEvent::MetadataChanged { id: eid, .. } => eid == id,
RendererEvent::QueueUpdated { id: eid, .. } => eid == id,
RendererEvent::BindingChanged { id: eid, .. } => eid == id,
}
}
@@ -165,6 +168,60 @@ fn format_position(pos: &PlaybackPositionInfo) -> String {
format!("track={} rel_time={} duration={}", track, rel, dur)
}
fn print_openhome_summary(prefix: &str, info: &RendererInfo, cp: &ControlPoint) {
if !info.capabilities.has_oh_playlist
&& !info.capabilities.has_oh_info
&& !info.capabilities.has_oh_time
{
return;
}
let registry = cp.registry();
let reg = registry.read().unwrap();
let playlist_client = reg.oh_playlist_client_for_renderer(&info.id);
let info_client = reg.oh_info_client_for_renderer(&info.id);
let time_client = reg.oh_time_client_for_renderer(&info.id);
drop(reg);
if playlist_client.is_none() && info_client.is_none() && time_client.is_none() {
return;
}
println!("{prefix}OpenHome:");
if let Some(client) = playlist_client {
match client.id_array() {
Ok(ids) => println!("{prefix} Playlist tracks : {}", ids.len()),
Err(err) => println!("{prefix} Playlist tracks : <error {err}>"),
}
}
if let Some(client) = info_client {
match client.transport_state() {
Ok(state) => {
let logical = map_openhome_state(&state);
println!(
"{prefix} Transport state : {} ({:?})",
state, logical
);
}
Err(err) => println!("{prefix} Transport state : <error {err}>"),
}
}
if let Some(client) = time_client {
match client.position() {
Ok(pos) => println!(
"{prefix} Position : {}/{} (tracks={})",
format_seconds(pos.elapsed_secs),
format_seconds(pos.duration_secs),
pos.track_count
),
Err(err) => println!("{prefix} Position : <error {err}>"),
}
}
}
/// Affiche un RendererEvent avec horodatage.
fn print_event(event: &RendererEvent) {
let ts = now_hms();

View File

@@ -329,13 +329,7 @@ impl CliConfig {
}
fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
let mut candidates: Vec<RendererInfo> = renderers
.into_iter()
.filter(|info| match info.protocol {
RendererProtocol::OpenHomeOnly => false,
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => true,
})
.collect();
let mut candidates: Vec<RendererInfo> = renderers;
if candidates.is_empty() {
return None;

View File

@@ -302,13 +302,7 @@ impl CliConfig {
}
fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
let mut candidates: Vec<RendererInfo> = renderers
.into_iter()
.filter(|info| match info.protocol {
RendererProtocol::OpenHomeOnly => false,
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => true,
})
.collect();
let mut candidates: Vec<RendererInfo> = renderers;
if candidates.is_empty() {
return None;

View File

@@ -18,6 +18,8 @@ use pmocontrol::{
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
RendererProtocol, TransportControl, VolumeControl,
};
use pmocontrol::model::RendererInfo;
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
use std::env;
use std::thread;
use std::time::Duration;
@@ -83,6 +85,7 @@ fn main() -> Result<()> {
);
print_backend(" ", r);
print_capabilities(" ", &info.capabilities, &info.protocol);
print_openhome_details(" ", info, &cp);
}
// 5. Select renderer
@@ -98,6 +101,7 @@ fn main() -> Result<()> {
println!(" Protocol : {:?}", info.protocol);
print_backend(" ", renderer);
print_capabilities(" ", &info.capabilities, &info.protocol);
print_openhome_details(" ", info, &cp);
if let Some(upnp) = renderer.as_upnp() {
println!(
@@ -224,6 +228,60 @@ fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &Rendere
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
}
fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint) {
if !info.capabilities.has_oh_playlist
&& !info.capabilities.has_oh_info
&& !info.capabilities.has_oh_time
{
return;
}
let registry = cp.registry();
let reg = registry.read().unwrap();
let playlist_client = reg.oh_playlist_client_for_renderer(&info.id);
let info_client = reg.oh_info_client_for_renderer(&info.id);
let time_client = reg.oh_time_client_for_renderer(&info.id);
drop(reg);
if playlist_client.is_none() && info_client.is_none() && time_client.is_none() {
return;
}
println!("{prefix}OpenHome:");
if let Some(client) = playlist_client {
match client.id_array() {
Ok(ids) => println!("{prefix} Playlist tracks : {}", ids.len()),
Err(err) => println!("{prefix} Playlist tracks : <error {err}>"),
}
}
if let Some(client) = info_client {
match client.transport_state() {
Ok(state) => {
let logical = map_openhome_state(&state);
println!(
"{prefix} Transport state : {} ({:?})",
state, logical
);
}
Err(err) => println!("{prefix} Transport state : <error {err}>"),
}
}
if let Some(client) = time_client {
match client.position() {
Ok(pos) => println!(
"{prefix} Position : {}/{} (tracks={})",
format_seconds(pos.elapsed_secs),
format_seconds(pos.duration_secs),
pos.track_count
),
Err(err) => println!("{prefix} Position : <error {err}>"),
}
}
}
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
let backend = match renderer {
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ pub mod linkplay;
pub mod media_server;
pub mod model;
pub mod music_renderer;
pub mod openhome_client;
pub mod openhome_renderer;
pub mod playback_queue;
pub mod provider;
pub mod registry;
@@ -43,6 +45,7 @@ pub use media_server::{
UpnpMediaServer,
};
pub use music_renderer::MusicRenderer;
pub use openhome_renderer::OpenHomeRenderer;
pub use playback_queue::{PlaybackItem, PlaybackQueue};
pub use rendering_control_client::RenderingControlClient;
pub use upnp_renderer::UpnpRenderer;

View File

@@ -72,6 +72,19 @@ pub struct RendererInfo {
pub rendering_control_control_url: Option<String>,
pub connection_manager_service_type: Option<String>,
pub connection_manager_control_url: Option<String>,
pub oh_playlist_service_type: Option<String>,
pub oh_playlist_control_url: Option<String>,
pub oh_playlist_event_sub_url: Option<String>,
pub oh_info_service_type: Option<String>,
pub oh_info_control_url: Option<String>,
pub oh_info_event_sub_url: Option<String>,
pub oh_time_service_type: Option<String>,
pub oh_time_control_url: Option<String>,
pub oh_time_event_sub_url: Option<String>,
pub oh_volume_service_type: Option<String>,
pub oh_volume_control_url: Option<String>,
pub oh_radio_service_type: Option<String>,
pub oh_radio_control_url: Option<String>,
}
#[derive(Clone, Debug)]

View File

@@ -1,18 +1,18 @@
//! Backend-agnostic music renderer façade for PMOMusic.
//!
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, LinkPlay HTTP,
//! Arylic TCP, and the hybrid UPnP + Arylic pairing) behind a single control
//! surface. Higher layers in PMOMusic must only interact with renderers through
//! this type so that transport, volume, and state queries stay backend-neutral.
//! OpenHome-only renderers are intentionally unsupported for now.
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
//! LinkPlay HTTP, Arylic TCP, and the hybrid UPnP + Arylic pairing) behind a
//! single control surface. Higher layers in PMOMusic must only interact with
//! renderers through this type so that transport, volume, and state queries
//! stay backend-neutral.
use std::sync::{Arc, RwLock};
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::{
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState,
TransportControl, UpnpRenderer, VolumeControl,
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition,
PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
};
use anyhow::{Result, anyhow};
use tracing::warn;
@@ -22,6 +22,8 @@ use tracing::warn;
pub enum MusicRenderer {
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
Upnp(UpnpRenderer),
/// Renderer powered by OpenHome services.
OpenHome(OpenHomeRenderer),
/// Renderer controlled via the LinkPlay HTTP API.
LinkPlay(LinkPlayRenderer),
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
@@ -48,6 +50,7 @@ impl MusicRenderer {
pub fn id(&self) -> &RendererId {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(),
MusicRenderer::OpenHome(r) => r.id(),
MusicRenderer::Upnp(r) => r.id(),
MusicRenderer::LinkPlay(r) => r.id(),
MusicRenderer::ArylicTcp(r) => r.id(),
@@ -58,6 +61,7 @@ impl MusicRenderer {
pub fn friendly_name(&self) -> &str {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(),
MusicRenderer::OpenHome(r) => r.friendly_name(),
MusicRenderer::Upnp(r) => r.friendly_name(),
MusicRenderer::LinkPlay(r) => r.friendly_name(),
MusicRenderer::ArylicTcp(r) => r.friendly_name(),
@@ -73,6 +77,7 @@ impl MusicRenderer {
pub fn info(&self) -> &RendererInfo {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info,
MusicRenderer::OpenHome(r) => &r.info,
MusicRenderer::Upnp(r) => &r.info,
MusicRenderer::LinkPlay(r) => &r.info,
MusicRenderer::ArylicTcp(r) => &r.info,
@@ -96,6 +101,24 @@ impl MusicRenderer {
info: RendererInfo,
registry: &Arc<RwLock<DeviceRegistry>>,
) -> Option<Self> {
if matches!(info.protocol, RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid) {
if let Some(renderer) = {
let reg = registry.read().unwrap();
let renderer = OpenHomeRenderer::new(info.clone(), &*reg);
renderer.has_any_openhome_service().then_some(renderer)
} {
return Some(MusicRenderer::OpenHome(renderer));
}
if matches!(info.protocol, RendererProtocol::OpenHomeOnly) {
warn!(
renderer = info.friendly_name.as_str(),
"Renderer advertises OpenHome only but exposes no usable services"
);
return None;
}
}
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
let has_arylic = info.capabilities.has_arylic_tcp;
@@ -131,10 +154,7 @@ impl MusicRenderer {
info, registry,
)))
}
RendererProtocol::OpenHomeOnly => {
// TODO: OH plus tard
None
}
RendererProtocol::OpenHomeOnly => None,
}
}
}
@@ -145,6 +165,7 @@ impl TransportControl for MusicRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta),
MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta),
MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta),
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
@@ -154,6 +175,7 @@ impl TransportControl for MusicRenderer {
fn play(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(upnp) => upnp.play(),
MusicRenderer::OpenHome(oh) => oh.play(),
MusicRenderer::LinkPlay(lp) => lp.play(),
MusicRenderer::ArylicTcp(ary) => ary.play(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
@@ -163,6 +185,7 @@ impl TransportControl for MusicRenderer {
fn pause(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(upnp) => upnp.pause(),
MusicRenderer::OpenHome(oh) => oh.pause(),
MusicRenderer::LinkPlay(lp) => lp.pause(),
MusicRenderer::ArylicTcp(ary) => ary.pause(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
@@ -172,6 +195,7 @@ impl TransportControl for MusicRenderer {
fn stop(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(upnp) => upnp.stop(),
MusicRenderer::OpenHome(oh) => oh.stop(),
MusicRenderer::LinkPlay(lp) => lp.stop(),
MusicRenderer::ArylicTcp(ary) => ary.stop(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
@@ -181,6 +205,7 @@ impl TransportControl for MusicRenderer {
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss),
MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
@@ -197,6 +222,7 @@ impl VolumeControl for MusicRenderer {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(),
MusicRenderer::ArylicTcp(ary) => ary.volume(),
MusicRenderer::OpenHome(oh) => oh.volume(),
MusicRenderer::Upnp(upnp) => upnp.volume(),
MusicRenderer::LinkPlay(lp) => lp.volume(),
}
@@ -206,6 +232,7 @@ impl VolumeControl for MusicRenderer {
match self {
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol),
MusicRenderer::OpenHome(oh) => oh.set_volume(vol),
MusicRenderer::Upnp(upnp) => upnp.set_volume(vol),
MusicRenderer::LinkPlay(lp) => lp.set_volume(vol),
}
@@ -214,6 +241,7 @@ impl VolumeControl for MusicRenderer {
fn mute(&self) -> Result<bool> {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(),
MusicRenderer::OpenHome(r) => r.mute(),
MusicRenderer::Upnp(r) => r.get_master_mute(),
MusicRenderer::LinkPlay(r) => r.mute(),
MusicRenderer::ArylicTcp(r) => r.mute(),
@@ -223,6 +251,7 @@ impl VolumeControl for MusicRenderer {
fn set_mute(&self, m: bool) -> Result<()> {
match self {
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
MusicRenderer::OpenHome(r) => r.set_mute(m),
MusicRenderer::Upnp(r) => r.set_master_mute(m),
MusicRenderer::LinkPlay(r) => r.set_mute(m),
MusicRenderer::ArylicTcp(r) => r.set_mute(m),
@@ -238,6 +267,7 @@ impl PlaybackStatus for MusicRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
match self {
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r),
MusicRenderer::LinkPlay(r) => r.playback_state(),
MusicRenderer::ArylicTcp(r) => r.playback_state(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
@@ -251,6 +281,7 @@ impl PlaybackPosition for MusicRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
match self {
MusicRenderer::Upnp(r) => r.playback_position(),
MusicRenderer::OpenHome(r) => r.playback_position(),
MusicRenderer::LinkPlay(r) => r.playback_position(),
MusicRenderer::ArylicTcp(r) => r.playback_position(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),

View File

@@ -22,12 +22,41 @@ pub struct RendererSummary {
pub friendly_name: String,
/// Modèle du renderer
pub model_name: String,
/// Protocole (Upnp, Hybrid, etc.)
pub protocol: String,
/// Protocole (UPnP pur, OpenHome pur, hybride)
pub protocol: RendererProtocolSummary,
/// Capacités détectées
pub capabilities: RendererCapabilitiesSummary,
/// Renderer en ligne
pub online: bool,
}
/// Protocole exposé par le renderer
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RendererProtocolSummary {
Upnp,
Openhome,
Hybrid,
}
/// Drapeaux de capacités renderer (transport, volume, services OpenHome, etc.)
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RendererCapabilitiesSummary {
pub has_avtransport: bool,
pub has_avtransport_set_next: bool,
pub has_rendering_control: bool,
pub has_connection_manager: bool,
pub has_linkplay_http: bool,
pub has_arylic_tcp: bool,
pub has_oh_playlist: bool,
pub has_oh_volume: bool,
pub has_oh_info: bool,
pub has_oh_time: bool,
pub has_oh_radio: bool,
}
/// État détaillé d'un renderer
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -100,6 +129,55 @@ pub struct QueueSnapshot {
pub current_index: Option<usize>,
}
// ============================================================================
// OPENHOME PLAYLIST
// ============================================================================
/// Snapshot de la playlist native OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OpenHomePlaylistSnapshot {
/// ID du renderer concerné
pub renderer_id: String,
/// ID courant dans la playlist (si connu)
pub current_id: Option<u32>,
/// Tracks présents dans la playlist native
pub tracks: Vec<OpenHomePlaylistTrack>,
}
/// Track issue de la playlist native OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OpenHomePlaylistTrack {
/// ID interne OpenHome
pub id: u32,
/// URI du flux
pub uri: String,
/// Titre
pub title: Option<String>,
/// Artiste
pub artist: Option<String>,
/// Album
pub album: Option<String>,
/// Pochette (si disponible)
pub album_art_uri: Option<String>,
}
/// Requête pour ajouter un track à la playlist OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct OpenHomePlaylistAddRequest {
/// URI du flux à insérer
pub uri: String,
/// Métadonnées DIDL-Lite complètes
pub metadata: String,
/// ID devant lequel insérer (None => fin de playlist)
pub after_id: Option<u32>,
/// Si true, démarre immédiatement la lecture du track inséré
#[serde(default)]
pub play: bool,
}
// ============================================================================
// MEDIA SERVERS
// ============================================================================
@@ -280,6 +358,10 @@ GET /control/servers/{server_id}/containers/{container_id}
crate::pmoserver_ext::get_renderer_state,
crate::pmoserver_ext::get_renderer_queue,
crate::pmoserver_ext::get_renderer_binding,
crate::pmoserver_ext::get_openhome_playlist,
crate::pmoserver_ext::clear_openhome_playlist,
crate::pmoserver_ext::add_openhome_playlist_item,
crate::pmoserver_ext::play_openhome_track,
crate::pmoserver_ext::play_renderer,
crate::pmoserver_ext::pause_renderer,
crate::pmoserver_ext::stop_renderer,
@@ -300,10 +382,15 @@ GET /control/servers/{server_id}/containers/{container_id}
),
components(schemas(
RendererSummary,
RendererProtocolSummary,
RendererCapabilitiesSummary,
RendererState,
AttachedPlaylistInfo,
QueueItem,
QueueSnapshot,
OpenHomePlaylistSnapshot,
OpenHomePlaylistTrack,
OpenHomePlaylistAddRequest,
MediaServerSummary,
ContainerEntry,
BrowseResponse,

View File

@@ -0,0 +1,693 @@
use anyhow::{Result, anyhow};
use crate::model::TrackMetadata;
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
#[derive(Debug, Clone)]
pub struct OhTrackEntry {
pub id: u32,
pub uri: String,
pub metadata_xml: String,
}
#[derive(Debug, Clone)]
pub struct OhInfoTrack {
pub uri: String,
pub metadata_xml: Option<String>,
}
impl OhInfoTrack {
pub fn metadata(&self) -> Option<TrackMetadata> {
self.metadata_xml
.as_deref()
.and_then(parse_track_metadata_from_didl)
}
}
#[derive(Debug, Clone)]
pub struct OhTimePosition {
pub track_count: u32,
pub duration_secs: u32,
pub elapsed_secs: u32,
}
#[derive(Debug, Clone)]
pub struct OhRadioChannel {
pub uri: String,
pub metadata_xml: Option<String>,
}
#[derive(Debug, Clone)]
pub struct OhPlaylistClient {
pub control_url: String,
pub service_type: String,
}
impl OhPlaylistClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>> {
if id_list.is_empty() {
return Ok(Vec::new());
}
let id_list_csv = id_list
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let args = [("aIdList", id_list_csv.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"ReadList",
&args,
)?;
let envelope = ensure_success("ReadList", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
.ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?;
let track_list_xml = extract_child_text(response, "aTrackList")?;
parse_track_list(&track_list_xml)
}
pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result<u32> {
let after_id_str = after_id.to_string();
let args = [
("aAfterId", after_id_str.as_str()),
("aUri", uri),
("aMetadata", metadata),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Insert",
&args,
)?;
let envelope = ensure_success("Insert", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
.ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?;
let new_id_text = extract_child_text(response, "aNewId")?;
let new_id = new_id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid aNewId value: {}", new_id_text))?;
Ok(new_id)
}
pub fn play_id(&self, id: u32) -> Result<()> {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"PlayId",
&args,
)?;
handle_action_response("PlayId", &call_result)
}
pub fn play(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
handle_action_response("Play", &call_result)
}
pub fn pause(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
handle_action_response("Pause", &call_result)
}
pub fn stop(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
handle_action_response("Stop", &call_result)
}
pub fn next(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
handle_action_response("Next", &call_result)
}
pub fn previous(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?;
handle_action_response("Previous", &call_result)
}
pub fn seek_second_absolute(&self, second: u32) -> Result<()> {
let second_str = second.to_string();
let args = [("aSecond", second_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SeekSecondAbsolute",
&args,
)?;
handle_action_response("SeekSecondAbsolute", &call_result)
}
pub fn delete_id(&self, id: u32) -> Result<()> {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"DeleteId",
&args,
)?;
handle_action_response("DeleteId", &call_result)
}
pub fn delete_all(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
handle_action_response("DeleteAll", &call_result)
}
pub fn tracks_max(&self) -> Result<u32> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"TracksMax",
&[],
)?;
let envelope = ensure_success("TracksMax", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
.ok_or_else(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?;
let value_text = extract_child_text(response, "aValue")?;
let value = value_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?;
Ok(value)
}
pub fn id_array(&self) -> Result<Vec<u32>> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"IdArray",
&[],
)?;
let envelope = ensure_success("IdArray", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
let array_text = extract_child_text_any(response, &["aArray", "aIdArray"])?;
let bytes = decode_base64(&array_text)?;
if bytes.len() % 4 != 0 {
return Err(anyhow!(
"Invalid IdArray payload length {} (expected multiple of 4)",
bytes.len()
));
}
let mut ids = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
ids.push(u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Ok(ids)
}
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>> {
let ids = self.id_array()?;
if ids.is_empty() {
return Ok(Vec::new());
}
const MAX_BATCH: usize = 64;
let mut entries = Vec::with_capacity(ids.len());
for chunk in ids.chunks(MAX_BATCH) {
let mut batch = self.read_list(chunk)?;
entries.append(&mut batch);
}
Ok(entries)
}
}
#[derive(Debug, Clone)]
pub struct OhInfoClient {
pub control_url: String,
pub service_type: String,
}
impl OhInfoClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
pub fn track(&self) -> Result<OhInfoTrack> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
let envelope = ensure_success("Track", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
.ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?;
let uri = extract_child_text(response, "aUri")?;
let metadata_xml = extract_child_text_optional(response, "aMetadata")
.unwrap_or(None)
.filter(|s| !s.is_empty());
Ok(OhInfoTrack { uri, metadata_xml })
}
pub fn next(&self) -> Result<OhInfoTrack> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
let envelope = ensure_success("Next", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
.ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?;
let uri = extract_child_text(response, "aUri")?;
let metadata_xml = extract_child_text_optional(response, "aMetadata")
.unwrap_or(None)
.filter(|s| !s.is_empty());
Ok(OhInfoTrack { uri, metadata_xml })
}
pub fn id(&self) -> Result<u32> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
let envelope = ensure_success("Id", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
let id_text = extract_child_text(response, "aId")?;
let id = id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid Info.Id value: {}", id_text))?;
Ok(id)
}
pub fn transport_state(&self) -> Result<String> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"TransportState",
&[],
)?;
let envelope = ensure_success("TransportState", &call_result)?;
let response =
find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
let state = extract_child_text(response, "aState")?;
Ok(state)
}
pub fn read_current_metadata(&self) -> Result<Option<TrackMetadata>> {
let track = self.track()?;
Ok(track.metadata())
}
}
#[derive(Debug, Clone)]
pub struct OhTimeClient {
pub control_url: String,
pub service_type: String,
}
impl OhTimeClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
pub fn position(&self) -> Result<OhTimePosition> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
let envelope = ensure_success("Time", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
.ok_or_else(|| anyhow!("Missing TimeResponse element in SOAP body"))?;
let track_count = extract_child_text(response, "aTrackCount")?
.parse::<u32>()
.map_err(|_| anyhow!("Invalid aTrackCount value in Time response"))?;
let duration_secs = extract_child_text(response, "aDuration")?
.parse::<u32>()
.map_err(|_| anyhow!("Invalid aDuration value in Time response"))?;
let elapsed_secs = extract_child_text(response, "aSeconds")?
.parse::<u32>()
.map_err(|_| anyhow!("Invalid aSeconds value in Time response"))?;
Ok(OhTimePosition {
track_count,
duration_secs,
elapsed_secs,
})
}
}
#[derive(Debug, Clone)]
pub struct OhVolumeClient {
pub control_url: String,
pub service_type: String,
}
impl OhVolumeClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
pub fn volume(&self) -> Result<u16> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
let envelope = ensure_success("Volume", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
let value = extract_child_text(response, "aVolume")?;
let parsed = value
.parse::<u32>()
.map_err(|_| anyhow!("Invalid volume value: {}", value))?;
Ok(parsed.min(u16::MAX as u32) as u16)
}
pub fn set_volume(&self, vol: u16) -> Result<()> {
let vol_str = vol.to_string();
let args = [("aVolume", vol_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetVolume",
&args,
)?;
handle_action_response("SetVolume", &call_result)
}
pub fn mute(&self) -> Result<bool> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
let envelope = ensure_success("Mute", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
let value = extract_child_text(response, "aMute")?;
parse_bool(&value)
}
pub fn set_mute(&self, mute: bool) -> Result<()> {
let mute_str = if mute { "1" } else { "0" };
let args = [("aMute", mute_str)];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetMute",
&args,
)?;
handle_action_response("SetMute", &call_result)
}
}
#[derive(Debug, Clone)]
pub struct OhRadioClient {
pub control_url: String,
pub service_type: String,
}
impl OhRadioClient {
pub fn new(control_url: String, service_type: String) -> Self {
Self {
control_url,
service_type,
}
}
pub fn play_channel(&self, id: u32) -> Result<()> {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"PlayChannel",
&args,
)?;
handle_action_response("PlayChannel", &call_result)
}
pub fn channel(&self, id: u32) -> Result<OhRadioChannel> {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Channel", &args)?;
let envelope = ensure_success("Channel", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse")
.ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?;
let uri = extract_child_text(response, "aUri")?;
let metadata_xml = extract_child_text_optional(response, "aMetadata")
.unwrap_or(None)
.filter(|s| !s.is_empty());
Ok(OhRadioChannel { uri, metadata_xml })
}
}
pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
if xml.trim().is_empty() {
return None;
}
let parsed = pmodidl::parse_metadata::<pmodidl::DIDLLite>(xml).ok()?;
let item = parsed.data.items.first()?;
Some(TrackMetadata {
title: Some(item.title.clone()),
artist: item.artist.clone(),
album: item.album.clone(),
genre: item.genre.clone(),
album_art_uri: item.album_art.clone(),
date: item.date.clone(),
track_number: item.original_track_number.clone(),
creator: item.creator.clone(),
})
}
fn parse_track_list(xml: &str) -> Result<Vec<OhTrackEntry>> {
if xml.trim().is_empty() {
return Ok(Vec::new());
}
let mut reader = std::io::Cursor::new(xml.as_bytes());
let root = Element::parse(&mut reader)
.map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?;
let mut entries = Vec::new();
for node in &root.children {
if let XMLNode::Element(elem) = node {
if elem.name.ends_with("Entry") {
entries.push(parse_track_entry(elem)?);
}
}
}
Ok(entries)
}
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
let id_text = extract_child_text(elem, "Id")?;
let id = id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?;
let uri = extract_child_text(elem, "Uri")?;
let metadata_xml = extract_child_text_optional(elem, "Metadata")?.unwrap_or_default();
Ok(OhTrackEntry {
id,
uri,
metadata_xml,
})
}
fn ensure_success<'a>(
action: &str,
call_result: &'a SoapCallResult,
) -> Result<&'a SoapEnvelope> {
if !call_result.status.is_success() {
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(anyhow!(
"{action} failed with UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
}
return Err(anyhow!(
"{action} failed with HTTP status {} and body: {}",
call_result.status,
call_result.raw_body
));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in {action} response"))?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(anyhow!(
"{action} returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
));
}
Ok(envelope)
}
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
ensure_success(action, call_result)?;
Ok(())
}
#[derive(Debug, Clone)]
struct UpnpError {
pub error_code: u32,
pub error_description: String,
}
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
let detail = find_child_with_suffix(fault, "detail")?;
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
_ => None,
})?;
let error_code_text = error_code_elem.get_text()?.trim().to_string();
let error_code = error_code_text.parse::<u32>().ok()?;
let error_description = upnp_error
.children
.iter()
.find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
elem.get_text().map(|t| t.trim().to_string())
}
_ => None,
})
.unwrap_or_default();
Some(UpnpError {
error_code,
error_description,
})
}
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
_ => None,
})
}
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
Ok(text)
}
fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result<Option<String>> {
if let Some(child) = find_child_with_suffix(parent, suffix) {
let text = child.get_text().map(|t| t.trim().to_string()).unwrap_or_default();
Ok(Some(text))
} else {
Ok(None)
}
}
fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result<String> {
for suffix in suffixes {
if let Ok(text) = extract_child_text(parent, suffix) {
return Ok(text);
}
}
Err(anyhow!("Missing {} element in response", suffixes.join(" or ")))
}
fn parse_bool(value: &str) -> Result<bool> {
match value.trim() {
"0" => Ok(false),
"1" => Ok(true),
other => Err(anyhow!("Invalid boolean value '{}'", other)),
}
}
pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
fn value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut output = Vec::new();
let mut buffer: u32 = 0;
let mut bits_collected: u8 = 0;
for byte in input.bytes() {
if byte == b'=' {
break;
}
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
continue;
}
let val = value(byte)
.ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
buffer = (buffer << 6) | (val as u32);
bits_collected += 6;
if bits_collected >= 8 {
bits_collected -= 8;
let out = (buffer >> bits_collected) & 0xFF;
output.push(out as u8);
}
}
Ok(output)
}

View File

@@ -0,0 +1,239 @@
use anyhow::{Result, anyhow};
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::music_renderer::op_not_supported;
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
};
use crate::registry::DeviceRegistry;
use tracing::debug;
#[derive(Clone, Debug)]
pub struct OpenHomeRenderer {
pub info: RendererInfo,
playlist: Option<OhPlaylistClient>,
info_client: Option<OhInfoClient>,
time_client: Option<OhTimeClient>,
volume_client: Option<OhVolumeClient>,
#[allow(dead_code)]
radio_client: Option<OhRadioClient>,
}
impl OpenHomeRenderer {
pub fn new(info: RendererInfo, registry: &DeviceRegistry) -> Self {
let id = info.id.clone();
Self {
playlist: registry.oh_playlist_client_for_renderer(&id),
info_client: registry.oh_info_client_for_renderer(&id),
time_client: registry.oh_time_client_for_renderer(&id),
volume_client: registry.oh_volume_client_for_renderer(&id),
radio_client: registry.oh_radio_client_for_renderer(&id),
info,
}
}
pub fn id(&self) -> &RendererId {
&self.info.id
}
pub fn friendly_name(&self) -> &str {
&self.info.friendly_name
}
pub fn protocol(&self) -> &RendererProtocol {
&self.info.protocol
}
pub fn has_playlist(&self) -> bool {
self.playlist.is_some()
}
pub fn has_info(&self) -> bool {
self.info_client.is_some()
}
pub fn has_time(&self) -> bool {
self.time_client.is_some()
}
pub fn has_volume(&self) -> bool {
self.volume_client.is_some()
}
pub fn has_any_openhome_service(&self) -> bool {
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
}
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient> {
self.playlist
.as_ref()
.ok_or_else(|| op_not_supported(op, "OpenHome Playlist"))
}
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient> {
self.info_client
.as_ref()
.ok_or_else(|| op_not_supported(op, "OpenHome Info"))
}
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient> {
self.time_client
.as_ref()
.ok_or_else(|| op_not_supported(op, "OpenHome Time"))
}
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient> {
self.volume_client
.as_ref()
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
}
}
impl TransportControl for OpenHomeRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
let playlist = self.playlist_client_for("play_uri")?;
if let Err(err) = playlist.delete_all() {
debug!(
renderer = self.info.id.0.as_str(),
error = %err,
"Failed to clear OpenHome playlist before insert"
);
}
let new_id = playlist.insert(0, uri, meta)?;
playlist.play_id(new_id)
}
fn play(&self) -> Result<()> {
let playlist = self.playlist_client_for("play")?;
playlist.play()
}
fn pause(&self) -> Result<()> {
let playlist = self.playlist_client_for("pause")?;
playlist.pause()
}
fn stop(&self) -> Result<()> {
let playlist = self.playlist_client_for("stop")?;
playlist.stop()
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
let seconds = parse_hms(hhmmss).ok_or_else(|| {
anyhow!(
"Invalid HH:MM:SS format for OpenHome SeekSecondAbsolute: {}",
hhmmss
)
})?;
let playlist = self.playlist_client_for("seek_rel_time")?;
playlist.seek_second_absolute(seconds)
}
}
impl VolumeControl for OpenHomeRenderer {
fn volume(&self) -> Result<u16> {
let client = self.volume_client_for("volume")?;
client.volume()
}
fn set_volume(&self, v: u16) -> Result<()> {
let client = self.volume_client_for("set_volume")?;
client.set_volume(v)
}
fn mute(&self) -> Result<bool> {
let client = self.volume_client_for("mute")?;
client.mute()
}
fn set_mute(&self, m: bool) -> Result<()> {
let client = self.volume_client_for("set_mute")?;
client.set_mute(m)
}
}
impl PlaybackStatus for OpenHomeRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
let client = self.info_client_for("playback_state")?;
let state = client.transport_state()?;
Ok(map_openhome_state(&state))
}
}
impl PlaybackPosition for OpenHomeRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
let time_info = self.time_client_for("playback_position")?.position()?;
let mut track_id = None;
let mut track_uri = None;
let mut track_metadata_xml = None;
if let Some(info_client) = &self.info_client {
match info_client.id() {
Ok(id) => track_id = Some(id),
Err(err) => debug!(
renderer = self.info.id.0.as_str(),
error = %err,
"Failed to read OpenHome track id"
),
}
match info_client.track() {
Ok(track) => {
track_uri = Some(track.uri);
track_metadata_xml = track.metadata_xml;
}
Err(err) => debug!(
renderer = self.info.id.0.as_str(),
error = %err,
"Failed to read OpenHome track metadata"
),
}
}
Ok(PlaybackPositionInfo {
track: track_id,
rel_time: Some(format_seconds(time_info.elapsed_secs)),
abs_time: None,
track_duration: Some(format_seconds(time_info.duration_secs)),
track_metadata: track_metadata_xml,
track_uri,
})
}
}
fn parse_hms(input: &str) -> Option<u32> {
let parts: Vec<&str> = input.split(':').collect();
if parts.is_empty() || parts.len() > 3 {
return None;
}
let mut total = 0u32;
for part in parts {
let value = part.parse::<u32>().ok()?;
total = total * 60 + value;
}
Some(total)
}
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
match raw.trim().to_ascii_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
"BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning,
other => PlaybackState::Unknown(other.to_string()),
}
}
pub(crate) fn format_seconds(seconds: u32) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
format!("{hours:02}:{minutes:02}:{secs:02}")
}

View File

@@ -8,14 +8,20 @@ use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
#[cfg(feature = "pmoserver")]
use crate::model::{RendererId, RendererProtocol};
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::playback_queue::PlaybackItem;
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
MediaServerSummary, PlayContentRequest, QueueItem, QueueSnapshot, RendererState,
RendererSummary, SuccessResponse, VolumeSetRequest,
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
OpenHomePlaylistTrack, PlayContentRequest, QueueItem, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl,
};
#[cfg(feature = "pmoserver")]
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
@@ -106,7 +112,8 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
protocol: protocol_to_string(&info.protocol),
protocol: protocol_summary(&info.protocol),
capabilities: capability_summary(&info.capabilities),
online: info.online,
}
})
@@ -1165,6 +1172,347 @@ async fn detach_playlist_binding(
})
}
// ============================================================================
// HANDLERS - OPENHOME PLAYLIST
// ============================================================================
/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/renderers/{renderer_id}/oh/playlist",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn get_openhome_playlist(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, info_client) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let fetch_task = tokio::task::spawn_blocking(move || -> anyhow::Result<OpenHomePlaylistSnapshot> {
let entries = playlist_client.read_all_tracks()?;
let current_id = info_client
.and_then(|client| client.id().ok());
let tracks: Vec<OpenHomePlaylistTrack> =
entries.iter().map(convert_openhome_track).collect();
Ok(OpenHomePlaylistSnapshot {
renderer_id: rid.0,
current_id,
tracks,
})
});
let snapshot = fetch_task
.await
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while fetching OpenHome playlist"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to read OpenHome playlist"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to read OpenHome playlist: {}", e),
}),
)
})?;
Ok(Json(snapshot))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/clear",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist vidée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn clear_openhome_playlist(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let clear_task =
tokio::task::spawn_blocking(move || playlist_client.delete_all());
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"Clearing OpenHome playlist timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Clear playlist timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while clearing OpenHome playlist"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to clear OpenHome playlist"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to clear OpenHome playlist: {}", e),
}),
)
})?;
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
warn!(
renderer = renderer_id.as_str(),
error = %err,
"Failed to refresh queue after OpenHome clear"
);
}
Ok(Json(SuccessResponse {
message: "OpenHome playlist cleared".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/add",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = OpenHomePlaylistAddRequest,
responses(
(status = 200, description = "Track ajouté", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn add_openhome_playlist_item(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<OpenHomePlaylistAddRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let add_task = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let mut after_id = if let Some(id) = req.after_id {
id
} else {
playlist_client
.id_array()?
.last()
.copied()
.unwrap_or(0)
};
let new_id =
playlist_client.insert(after_id, &req.uri, &req.metadata)?;
after_id = new_id;
if req.play {
playlist_client.play_id(after_id)?;
}
Ok(())
});
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"Adding OpenHome track timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Add track timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while adding OpenHome track"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to add OpenHome track"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to add OpenHome track: {}", e),
}),
)
})?;
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
warn!(
renderer = renderer_id.as_str(),
error = %err,
"Failed to refresh queue after OpenHome add"
);
}
Ok(Json(SuccessResponse {
message: "Track added to OpenHome playlist".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}",
params(
("renderer_id" = String, Path, description = "ID unique du renderer"),
("track_id" = String, Path, description = "ID OpenHome du morceau")
),
responses(
(status = 200, description = "Lecture démarrée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn play_openhome_track(
State(state): State<ControlPointState>,
Path((renderer_id, track_id)): Path<(String, String)>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let parsed_id = track_id.parse::<u32>().map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid track id '{}': {}", track_id, e),
}),
)
})?;
let play_task =
tokio::task::spawn_blocking(move || playlist_client.play_id(parsed_id));
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"PlayId command timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Play track timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while playing OpenHome track"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
track_id = parsed_id,
"Failed to start OpenHome track"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to play OpenHome track: {}", e),
}),
)
})?;
Ok(Json(SuccessResponse {
message: format!("Playing OpenHome track {}", parsed_id),
}))
}
// ============================================================================
// HANDLERS - QUEUE CONTENT
// ============================================================================
@@ -1644,11 +1992,64 @@ fn is_audio_resource(res: &MediaResource) -> bool {
}
#[cfg(feature = "pmoserver")]
fn protocol_to_string(protocol: &RendererProtocol) -> String {
fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
match protocol {
RendererProtocol::UpnpAvOnly => "UpnpAvOnly".to_string(),
RendererProtocol::OpenHomeOnly => "OpenHomeOnly".to_string(),
RendererProtocol::Hybrid => "Hybrid".to_string(),
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid,
}
}
#[cfg(feature = "pmoserver")]
fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummary {
RendererCapabilitiesSummary {
has_avtransport: caps.has_avtransport,
has_avtransport_set_next: caps.has_avtransport_set_next,
has_rendering_control: caps.has_rendering_control,
has_connection_manager: caps.has_connection_manager,
has_linkplay_http: caps.has_linkplay_http,
has_arylic_tcp: caps.has_arylic_tcp,
has_oh_playlist: caps.has_oh_playlist,
has_oh_volume: caps.has_oh_volume,
has_oh_info: caps.has_oh_info,
has_oh_time: caps.has_oh_time,
has_oh_radio: caps.has_oh_radio,
}
}
#[cfg(feature = "pmoserver")]
fn openhome_clients_for_renderer(
control_point: &ControlPoint,
renderer_id: &RendererId,
) -> Result<(OhPlaylistClient, Option<OhInfoClient>), (StatusCode, Json<ErrorResponse>)> {
let registry = control_point.registry();
let reg = registry.read().unwrap();
let Some(client) = reg.oh_playlist_client_for_renderer(renderer_id) else {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!(
"Renderer {} has no OpenHome playlist service",
renderer_id.0
),
}),
));
};
let info_client = reg.oh_info_client_for_renderer(renderer_id);
Ok((client, info_client))
}
#[cfg(feature = "pmoserver")]
fn convert_openhome_track(entry: &OhTrackEntry) -> OpenHomePlaylistTrack {
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
OpenHomePlaylistTrack {
id: entry.id,
uri: entry.uri.clone(),
title: metadata.as_ref().and_then(|m| m.title.clone()),
artist: metadata.as_ref().and_then(|m| m.artist.clone()),
album: metadata.as_ref().and_then(|m| m.album.clone()),
album_art_uri: metadata
.and_then(|m| m.album_art_uri),
}
}
@@ -1728,6 +2129,23 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/binding/detach",
post(detach_playlist_binding),
)
// OpenHome playlist
.route(
"/renderers/{renderer_id}/oh/playlist",
get(get_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/clear",
post(clear_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/add",
post(add_openhome_playlist_item),
)
.route(
"/renderers/{renderer_id}/oh/playlist/play/{track_id}",
post(play_openhome_track),
)
// Queue content
.route(
"/renderers/{renderer_id}/queue/play",

View File

@@ -54,6 +54,21 @@ struct ParsedDeviceDescription {
// ContentDirectory endpoint (if present in serviceList)
content_directory_service_type: Option<String>,
content_directory_control_url: Option<String>,
// OpenHome endpoints (if present in serviceList)
oh_playlist_service_type: Option<String>,
oh_playlist_control_url: Option<String>,
oh_playlist_event_sub_url: Option<String>,
oh_info_service_type: Option<String>,
oh_info_control_url: Option<String>,
oh_info_event_sub_url: Option<String>,
oh_time_service_type: Option<String>,
oh_time_control_url: Option<String>,
oh_time_event_sub_url: Option<String>,
oh_volume_service_type: Option<String>,
oh_volume_control_url: Option<String>,
oh_radio_service_type: Option<String>,
oh_radio_control_url: Option<String>,
}
impl ParsedDeviceDescription {
@@ -122,6 +137,7 @@ impl HttpXmlDescriptionProvider {
// New: track current serviceType + controlURL while inside <service>...</service>
let mut current_service_type: Option<String> = None;
let mut current_control_url: Option<String> = None;
let mut current_event_sub_url: Option<String> = None;
loop {
match reader.read_event_into(&mut buf)? {
@@ -218,11 +234,79 @@ impl HttpXmlDescriptionProvider {
);
}
}
if lower.contains("urn:av-openhome-org:service:playlist:") {
if parsed.oh_playlist_service_type.is_none() {
parsed.oh_playlist_service_type = Some(st.clone());
parsed.oh_playlist_control_url = Some(ctrl.clone());
if parsed.oh_playlist_event_sub_url.is_none() {
parsed.oh_playlist_event_sub_url =
current_event_sub_url.clone();
}
debug!(
"Found OpenHome Playlist for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
if lower.contains("urn:av-openhome-org:service:info:") {
if parsed.oh_info_service_type.is_none() {
parsed.oh_info_service_type = Some(st.clone());
parsed.oh_info_control_url = Some(ctrl.clone());
if parsed.oh_info_event_sub_url.is_none() {
parsed.oh_info_event_sub_url =
current_event_sub_url.clone();
}
debug!(
"Found OpenHome Info for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
if lower.contains("urn:av-openhome-org:service:time:") {
if parsed.oh_time_service_type.is_none() {
parsed.oh_time_service_type = Some(st.clone());
parsed.oh_time_control_url = Some(ctrl.clone());
if parsed.oh_time_event_sub_url.is_none() {
parsed.oh_time_event_sub_url =
current_event_sub_url.clone();
}
debug!(
"Found OpenHome Time for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
if lower.contains("urn:av-openhome-org:service:volume:") {
if parsed.oh_volume_service_type.is_none() {
parsed.oh_volume_service_type = Some(st.clone());
parsed.oh_volume_control_url = Some(ctrl.clone());
debug!(
"Found OpenHome Volume for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
if lower.contains("urn:av-openhome-org:service:radio:") {
if parsed.oh_radio_service_type.is_none() {
parsed.oh_radio_service_type = Some(st.clone());
parsed.oh_radio_control_url = Some(ctrl.clone());
debug!(
"Found OpenHome Radio for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
);
}
}
}
in_service = false;
current_service_type = None;
current_control_url = None;
current_event_sub_url = None;
}
}
_ => {}
@@ -258,6 +342,9 @@ impl HttpXmlDescriptionProvider {
"controlURL" if in_service => {
current_control_url = Some(text);
}
"eventSubURL" if in_service => {
current_event_sub_url = Some(text);
}
_ => {}
}
}
@@ -339,6 +426,43 @@ impl HttpXmlDescriptionProvider {
.connection_manager_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_playlist_service_type: parsed.oh_playlist_service_type.clone(),
oh_playlist_control_url: parsed
.oh_playlist_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_playlist_event_sub_url: parsed
.oh_playlist_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_info_service_type: parsed.oh_info_service_type.clone(),
oh_info_control_url: parsed
.oh_info_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_info_event_sub_url: parsed
.oh_info_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_time_service_type: parsed.oh_time_service_type.clone(),
oh_time_control_url: parsed
.oh_time_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_time_event_sub_url: parsed
.oh_time_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_volume_service_type: parsed.oh_volume_service_type.clone(),
oh_volume_control_url: parsed
.oh_volume_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_radio_service_type: parsed.oh_radio_service_type.clone(),
oh_radio_control_url: parsed
.oh_radio_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
})
}

View File

@@ -5,6 +5,9 @@ use crate::avtransport_client::AvTransportClient;
use crate::connection_manager_client::ConnectionManagerClient;
use crate::media_server::{MediaServerInfo, ServerId};
use crate::model::{RendererId, RendererInfo};
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
};
use crate::rendering_control_client::RenderingControlClient;
use tracing::debug;
@@ -203,4 +206,54 @@ impl DeviceRegistry {
}
}
}
pub fn oh_playlist_client_for_renderer(&self, id: &RendererId) -> Option<OhPlaylistClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_playlist_service_type.as_ref()?;
let control_url = info.oh_playlist_control_url.as_ref()?;
Some(OhPlaylistClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_info_client_for_renderer(&self, id: &RendererId) -> Option<OhInfoClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_info_service_type.as_ref()?;
let control_url = info.oh_info_control_url.as_ref()?;
Some(OhInfoClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_time_client_for_renderer(&self, id: &RendererId) -> Option<OhTimeClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_time_service_type.as_ref()?;
let control_url = info.oh_time_control_url.as_ref()?;
Some(OhTimeClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_volume_client_for_renderer(&self, id: &RendererId) -> Option<OhVolumeClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_volume_service_type.as_ref()?;
let control_url = info.oh_volume_control_url.as_ref()?;
Some(OhVolumeClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_radio_client_for_renderer(&self, id: &RendererId) -> Option<OhRadioClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_radio_service_type.as_ref()?;
let control_url = info.oh_radio_control_url.as_ref()?;
Some(OhRadioClient::new(
control_url.clone(),
service_type.clone(),
))
}
}

View File

@@ -190,6 +190,19 @@ mod tests {
rendering_control_control_url: None,
connection_manager_service_type: None,
connection_manager_control_url: None,
oh_playlist_service_type: None,
oh_playlist_control_url: None,
oh_playlist_event_sub_url: None,
oh_info_service_type: None,
oh_info_control_url: None,
oh_info_event_sub_url: None,
oh_time_service_type: None,
oh_time_control_url: None,
oh_time_event_sub_url: None,
oh_volume_service_type: None,
oh_volume_control_url: None,
oh_radio_service_type: None,
oh_radio_control_url: None,
}
}