Gestion des renderes openhome
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
693
pmocontrol/src/openhome_client.rs
Normal file
693
pmocontrol/src/openhome_client.rs
Normal 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)
|
||||
}
|
||||
239
pmocontrol/src/openhome_renderer.rs
Normal file
239
pmocontrol/src/openhome_renderer.rs
Normal 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}")
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user