522 lines
20 KiB
Rust
522 lines
20 KiB
Rust
//! Backend-agnostic music renderer façade for PMOMusic.
|
||
//!
|
||
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
|
||
//! LinkPlay HTTP, Arylic TCP, Chromecast, 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, OnceLock, RwLock};
|
||
|
||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||
use crate::control_point::RendererRuntimeStateMut;
|
||
use crate::control_point::music_queue::MusicQueue;
|
||
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
||
use crate::media_server::ServerId;
|
||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||
use crate::openhome_client::parse_track_metadata_from_didl;
|
||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||
use crate::queue_backend::PlaybackItem;
|
||
use crate::{
|
||
ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer,
|
||
PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
|
||
};
|
||
use anyhow::{Result, anyhow};
|
||
use tracing::warn;
|
||
|
||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||
#[derive(Clone, Debug)]
|
||
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).
|
||
ArylicTcp(ArylicTcpRenderer),
|
||
/// Renderer controlled via the Google Cast protocol (Chromecast).
|
||
Chromecast(ChromecastRenderer),
|
||
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
|
||
/// to read detailed playback information as well as live volume/mute state.
|
||
HybridUpnpArylic {
|
||
upnp: UpnpRenderer,
|
||
arylic: ArylicTcpRenderer,
|
||
},
|
||
}
|
||
|
||
/// Build a standardized error when an operation is not supported by a backend.
|
||
pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error {
|
||
anyhow!(
|
||
"MusicRenderer operation '{}' is not supported by backend '{}'",
|
||
op,
|
||
backend
|
||
)
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct RendererRuntimeState {
|
||
pub queue: MusicQueue,
|
||
}
|
||
|
||
pub trait OpenHomeQueueProvider: Send + Sync + 'static {
|
||
fn renderer_state(&self, renderer_id: &RendererId) -> Result<RendererRuntimeState>;
|
||
fn renderer_state_mut<'a>(
|
||
&'a self,
|
||
renderer_id: &RendererId,
|
||
) -> Result<RendererRuntimeStateMut<'a>>;
|
||
fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>;
|
||
}
|
||
|
||
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
||
|
||
pub fn set_openhome_queue_provider(provider: Arc<dyn OpenHomeQueueProvider>) {
|
||
let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
||
}
|
||
|
||
impl MusicRenderer {
|
||
/// Renderer identifier (stable within the registry).
|
||
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(),
|
||
MusicRenderer::Chromecast(r) => r.id(),
|
||
}
|
||
}
|
||
|
||
/// Human-friendly name reported by the device.
|
||
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(),
|
||
MusicRenderer::Chromecast(r) => r.friendly_name(),
|
||
}
|
||
}
|
||
|
||
/// Protocol classification (UPnP AV only, OpenHome only, hybrid).
|
||
pub fn protocol(&self) -> &RendererProtocol {
|
||
&self.info().protocol
|
||
}
|
||
|
||
/// Full static info as stored in the registry.
|
||
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,
|
||
MusicRenderer::Chromecast(r) => &r.info,
|
||
}
|
||
}
|
||
|
||
/// Return a reference to the underlying UPnP backend, if any.
|
||
pub fn as_upnp(&self) -> Option<&UpnpRenderer> {
|
||
match self {
|
||
MusicRenderer::Upnp(r) => Some(r),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Construct a music renderer from a [`RendererInfo`] and the registry.
|
||
///
|
||
/// Returns `None` when no supported backend can be built for this renderer.
|
||
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
|
||
/// or [`MusicRenderer::Upnp`].
|
||
pub fn from_registry_info(
|
||
info: RendererInfo,
|
||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||
) -> Option<Self> {
|
||
if matches!(
|
||
info.protocol,
|
||
RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid
|
||
) {
|
||
if let Some(renderer) = {
|
||
let renderer = OpenHomeRenderer::new(info.clone());
|
||
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;
|
||
}
|
||
}
|
||
|
||
if matches!(info.protocol, RendererProtocol::ChromecastOnly) {
|
||
if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) {
|
||
return Some(MusicRenderer::Chromecast(renderer));
|
||
}
|
||
warn!(
|
||
renderer = info.friendly_name.as_str(),
|
||
"Failed to build Chromecast renderer"
|
||
);
|
||
return None;
|
||
}
|
||
|
||
match info.protocol {
|
||
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
|
||
let has_arylic = info.capabilities.has_arylic_tcp;
|
||
let has_avtransport = info.capabilities.has_avtransport;
|
||
|
||
if has_arylic && has_avtransport {
|
||
// Construire UpnpRenderer
|
||
let upnp = UpnpRenderer::from_registry(info.clone(), registry);
|
||
|
||
// Construire ArylicTcpRenderer
|
||
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
|
||
Ok(arylic) => {
|
||
return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic });
|
||
}
|
||
Err(err) => {
|
||
warn!(
|
||
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
|
||
info.friendly_name, err
|
||
);
|
||
return Some(MusicRenderer::Upnp(upnp));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pas d’Arylic : logique existante
|
||
if info.capabilities.has_linkplay_http {
|
||
if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) {
|
||
return Some(MusicRenderer::LinkPlay(lp));
|
||
}
|
||
}
|
||
|
||
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(
|
||
info, registry,
|
||
)))
|
||
}
|
||
RendererProtocol::OpenHomeOnly => None,
|
||
RendererProtocol::ChromecastOnly => None,
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||
self.fetch_openhome_playlist_snapshot()
|
||
}
|
||
|
||
pub(crate) fn fetch_openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_snapshot",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_len(&self) -> Result<usize> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_len",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_ids",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_clear(&self) -> Result<()> {
|
||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||
let result = {
|
||
let mut state = provider.renderer_state_mut(self.id())?;
|
||
match &mut *state.queue {
|
||
MusicQueue::OpenHome(queue) => queue.clear(),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_clear",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
};
|
||
if result.is_ok() {
|
||
provider.invalidate_openhome_cache(self.id())?;
|
||
}
|
||
result
|
||
} else {
|
||
self.fetch_openhome_playlist_clear()
|
||
}
|
||
}
|
||
|
||
pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_clear",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_add_track(
|
||
&self,
|
||
uri: &str,
|
||
metadata: &str,
|
||
after_id: Option<u32>,
|
||
play: bool,
|
||
) -> Result<u32> {
|
||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||
let result = {
|
||
let mut state = provider.renderer_state_mut(self.id())?;
|
||
match &mut *state.queue {
|
||
MusicQueue::OpenHome(queue) => {
|
||
let playback_item =
|
||
Self::playback_item_from_params(self.id(), uri, metadata)?;
|
||
queue.add_playback_item(playback_item, after_id, play)
|
||
}
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_add_track",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
};
|
||
if result.is_ok() {
|
||
provider.invalidate_openhome_cache(self.id())?;
|
||
}
|
||
result
|
||
} else {
|
||
self.fetch_openhome_playlist_add_track(uri, metadata, after_id, play)
|
||
}
|
||
}
|
||
|
||
pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||
let result = {
|
||
let mut state = provider.renderer_state_mut(self.id())?;
|
||
match &mut *state.queue {
|
||
MusicQueue::OpenHome(queue) => queue.select_track_id(id),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_play_id",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
};
|
||
if result.is_ok() {
|
||
provider.invalidate_openhome_cache(self.id())?;
|
||
}
|
||
result
|
||
} else {
|
||
self.fetch_openhome_playlist_play_id(id)
|
||
}
|
||
}
|
||
|
||
fn unsupported_backend_name(&self) -> &'static str {
|
||
match self {
|
||
MusicRenderer::Upnp(_) => "UPnP",
|
||
MusicRenderer::OpenHome(_) => "OpenHome",
|
||
MusicRenderer::LinkPlay(_) => "LinkPlay",
|
||
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
|
||
MusicRenderer::Chromecast(_) => "Chromecast",
|
||
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
||
}
|
||
}
|
||
|
||
fn fetch_openhome_playlist_add_track(
|
||
&self,
|
||
uri: &str,
|
||
metadata: &str,
|
||
after_id: Option<u32>,
|
||
play: bool,
|
||
) -> Result<u32> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => {
|
||
renderer.add_track_openhome(uri, metadata, after_id, play)
|
||
}
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_add_track",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
||
match self {
|
||
MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id),
|
||
_ => Err(op_not_supported(
|
||
"openhome_playlist_play_id",
|
||
self.unsupported_backend_name(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn playback_item_from_params(
|
||
renderer_id: &RendererId,
|
||
uri: &str,
|
||
metadata_xml: &str,
|
||
) -> Result<PlaybackItem> {
|
||
let metadata = parse_track_metadata_from_didl(metadata_xml);
|
||
let didl_id = didl_id_from_metadata(metadata_xml)
|
||
.unwrap_or_else(|| format!("openhome:{}", renderer_id.0));
|
||
Ok(PlaybackItem {
|
||
media_server_id: ServerId(format!("openhome:{}", renderer_id.0)),
|
||
didl_id,
|
||
uri: uri.to_string(),
|
||
// OpenHome tracks don't provide protocolInfo, use generic default
|
||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||
metadata,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Transport control façade that dispatches to whichever backend can fulfill
|
||
/// the request, returning a standardized error if the backend lacks support.
|
||
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::Chromecast(cc) => cc.play_uri(uri, meta),
|
||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
||
}
|
||
}
|
||
|
||
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::Chromecast(cc) => cc.play(),
|
||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||
}
|
||
}
|
||
|
||
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::Chromecast(cc) => cc.pause(),
|
||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||
}
|
||
}
|
||
|
||
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::Chromecast(cc) => cc.stop(),
|
||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||
}
|
||
}
|
||
|
||
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::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Volume and mute controls exposed via the façade.
|
||
///
|
||
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
||
/// always depend on a single [`VolumeControl`] entry point.
|
||
impl VolumeControl for MusicRenderer {
|
||
fn volume(&self) -> Result<u16> {
|
||
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(),
|
||
MusicRenderer::Chromecast(cc) => cc.volume(),
|
||
}
|
||
}
|
||
|
||
fn set_volume(&self, vol: u16) -> Result<()> {
|
||
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),
|
||
MusicRenderer::Chromecast(cc) => cc.set_volume(vol),
|
||
}
|
||
}
|
||
|
||
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(),
|
||
MusicRenderer::Chromecast(cc) => cc.mute(),
|
||
}
|
||
}
|
||
|
||
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),
|
||
MusicRenderer::Chromecast(cc) => cc.set_mute(m),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Playback-state queries sourced from the backend best suited for the job.
|
||
///
|
||
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
||
/// to reason about protocol-specific state machines.
|
||
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::Chromecast(cc) => cc.playback_state(),
|
||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
||
/// regardless of the backend providing the raw transport data.
|
||
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::Chromecast(cc) => cc.playback_position(),
|
||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||
}
|
||
}
|
||
}
|