Add a LinkPlayRenderer
This commit is contained in:
101
pmocontrol/examples/linkplay_demo.rs
Normal file
101
pmocontrol/examples/linkplay_demo.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::io;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
||||
PlaybackState, PlaybackStatus, VolumeControl,
|
||||
};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
println!("Starting PMOMusic LinkPlay demo...");
|
||||
|
||||
let cp = ControlPoint::spawn(5)?;
|
||||
println!("Waiting 5 seconds for SSDP discovery...");
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
let registry = cp.registry();
|
||||
let renderers = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.list_renderers()
|
||||
};
|
||||
|
||||
if renderers.is_empty() {
|
||||
println!("No renderers discovered.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Discovered renderers:");
|
||||
for (idx, info) in renderers.iter().enumerate() {
|
||||
println!(
|
||||
" [{}] {} | model={} | LinkPlay HTTP={}",
|
||||
idx, info.friendly_name, info.model_name, info.capabilities.has_linkplay_http
|
||||
);
|
||||
}
|
||||
|
||||
let linkplay_renderers: Vec<MusicRenderer> = {
|
||||
let reg = registry.read().unwrap();
|
||||
renderers
|
||||
.iter()
|
||||
.filter(|info| info.capabilities.has_linkplay_http)
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®))
|
||||
.collect()
|
||||
};
|
||||
|
||||
if linkplay_renderers.is_empty() {
|
||||
println!("\nNo LinkPlay-capable renderers detected.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\nLinkPlay renderer status:");
|
||||
for renderer in linkplay_renderers {
|
||||
println!("- {} ({})", renderer.friendly_name(), renderer.id().0);
|
||||
|
||||
match renderer.playback_state() {
|
||||
Ok(state) => println!(" state: {}", format_playback_state(&state)),
|
||||
Err(err) => println!(" state: error: {}", err),
|
||||
}
|
||||
|
||||
match renderer.playback_position() {
|
||||
Ok(pos) => println!(" position: {}", format_position(&pos)),
|
||||
Err(err) => println!(" position: error: {}", err),
|
||||
}
|
||||
|
||||
match renderer.volume() {
|
||||
Ok(vol) => println!(" volume: {}", vol),
|
||||
Err(err) => println!(" volume: error: {}", err),
|
||||
}
|
||||
|
||||
match renderer.mute() {
|
||||
Ok(mute) => println!(" mute: {}", mute),
|
||||
Err(err) => println!(" mute: error: {}", err),
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_playback_state(state: &PlaybackState) -> String {
|
||||
match state {
|
||||
PlaybackState::Stopped => "Stopped".to_string(),
|
||||
PlaybackState::Playing => "Playing".to_string(),
|
||||
PlaybackState::Paused => "Paused".to_string(),
|
||||
PlaybackState::Transitioning => "Transitioning".to_string(),
|
||||
PlaybackState::NoMedia => "NoMedia".to_string(),
|
||||
PlaybackState::Unknown(raw) => format!("Unknown({})", raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_position(pos: &PlaybackPositionInfo) -> String {
|
||||
let rel = pos.rel_time.as_deref().unwrap_or("-");
|
||||
let dur = pos.track_duration.as_deref().unwrap_or("-");
|
||||
let track = pos
|
||||
.track
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
format!("track={} rel_time={} duration={}", track, rel, dur)
|
||||
}
|
||||
@@ -82,6 +82,7 @@ fn main() -> Result<()> {
|
||||
" [{}] {} | model={} | udn={} | location={}",
|
||||
idx, info.friendly_name, info.model_name, info.udn, info.location
|
||||
);
|
||||
print_backend(" ", r);
|
||||
print_capabilities(" ", &info.capabilities, &info.protocol);
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ fn main() -> Result<()> {
|
||||
println!(" UDN : {}", info.udn);
|
||||
println!(" Location : {}", info.location);
|
||||
println!(" Protocol : {:?}", info.protocol);
|
||||
print_backend(" ", renderer);
|
||||
print_capabilities(" ", &info.capabilities, &info.protocol);
|
||||
|
||||
if let Some(upnp) = renderer.as_upnp() {
|
||||
@@ -216,15 +218,24 @@ fn main() -> Result<()> {
|
||||
|
||||
fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &RendererProtocol) {
|
||||
println!("{prefix}Capabilities:");
|
||||
println!("{prefix} Protocol : {:?}", proto);
|
||||
println!("{prefix} AVTransport : {}", caps.has_avtransport);
|
||||
println!("{prefix} RendControl : {}", caps.has_rendering_control);
|
||||
println!("{prefix} ConnManager : {}", caps.has_connection_manager);
|
||||
println!("{prefix} OH Playlist : {}", caps.has_oh_playlist);
|
||||
println!("{prefix} OH Volume : {}", caps.has_oh_volume);
|
||||
println!("{prefix} OH Info : {}", caps.has_oh_info);
|
||||
println!("{prefix} OH Time : {}", caps.has_oh_time);
|
||||
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
|
||||
println!("{prefix} Protocol : {:?}", proto);
|
||||
println!("{prefix} AVTransport : {}", caps.has_avtransport);
|
||||
println!("{prefix} RendControl : {}", caps.has_rendering_control);
|
||||
println!("{prefix} ConnManager : {}", caps.has_connection_manager);
|
||||
println!("{prefix} LinkPlay HTTP : {}", caps.has_linkplay_http);
|
||||
println!("{prefix} OH Playlist : {}", caps.has_oh_playlist);
|
||||
println!("{prefix} OH Volume : {}", caps.has_oh_volume);
|
||||
println!("{prefix} OH Info : {}", caps.has_oh_info);
|
||||
println!("{prefix} OH Time : {}", caps.has_oh_time);
|
||||
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
|
||||
}
|
||||
|
||||
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
|
||||
let backend = match renderer {
|
||||
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||
};
|
||||
println!("{prefix}Backend : {backend}");
|
||||
}
|
||||
|
||||
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::avtransport_client::PositionInfo;
|
||||
|
||||
/// Logical playback position across backends.
|
||||
///
|
||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||
|
||||
@@ -91,14 +91,14 @@ impl ControlPoint {
|
||||
let reg = runtime_cp.registry.read().unwrap();
|
||||
reg.list_renderers()
|
||||
.into_iter()
|
||||
.map(|info| UpnpRenderer::from_registry(info, ®))
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info, ®))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut seen_ids = HashSet::new();
|
||||
|
||||
for renderer in renderers {
|
||||
let info = &renderer.info;
|
||||
let info = renderer.info();
|
||||
|
||||
// Ne pas poller les renderers offline
|
||||
if !info.online {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod capabilities;
|
||||
pub mod connection_manager_client;
|
||||
pub mod control_point;
|
||||
pub mod discovery;
|
||||
pub mod linkplay;
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod upnp_renderer;
|
||||
@@ -18,6 +19,7 @@ pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackS
|
||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
pub use control_point::ControlPoint;
|
||||
pub use rendering_control_client::RenderingControlClient;
|
||||
pub use linkplay::LinkPlayRenderer;
|
||||
pub use upnp_renderer::UpnpRenderer;
|
||||
pub use music_renderer::MusicRenderer;
|
||||
|
||||
|
||||
478
pmocontrol/src/linkplay.rs
Normal file
478
pmocontrol/src/linkplay.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
use std::char;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tracing::debug;
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::{RendererId, RendererInfo};
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||
|
||||
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||
#[derive(Clone)]
|
||||
pub struct LinkPlayRenderer {
|
||||
pub info: RendererInfo,
|
||||
host: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LinkPlayRenderer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("LinkPlayRenderer")
|
||||
.field("id", &self.info.id)
|
||||
.field("friendly_name", &self.info.friendly_name)
|
||||
.field("host", &self.host)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkPlayRenderer {
|
||||
/// Build a LinkPlay backend from a registry snapshot.
|
||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||
let host = extract_linkplay_host(&info.location)
|
||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
host,
|
||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent(&self) -> Agent {
|
||||
build_agent(self.timeout)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
fn send_player_command(&self, command: &str) -> Result<()> {
|
||||
let url = format!(
|
||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||
self.host, command
|
||||
);
|
||||
self.agent()
|
||||
.get(&url)
|
||||
.call()
|
||||
.with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_status(&self) -> Result<LinkPlayStatus> {
|
||||
fetch_status_for_host(&self.host, self.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> {
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
self.send_player_command("resume")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
self.send_player_command("pause")
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
self.send_player_command("stop")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let secs = parse_hhmmss_to_secs(hhmmss)
|
||||
.ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?;
|
||||
self.send_player_command(&format!("seek:{}", secs))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for LinkPlayRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
Ok(self.fetch_status()?.volume)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
let value = v.min(100);
|
||||
self.send_player_command(&format!("vol:{}", value))
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
Ok(self.fetch_status()?.mute)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for LinkPlayRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
Ok(self.fetch_status()?.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for LinkPlayRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
Ok(self.fetch_status()?.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match fetch_status_for_host(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"LinkPlay detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the IP/host component from a LOCATION URL.
|
||||
pub fn extract_linkplay_host(location: &str) -> Option<String> {
|
||||
let (_, rest) = location.split_once("://")?;
|
||||
let authority = rest.split('/').next().unwrap_or(rest);
|
||||
let without_auth = authority.split('@').last().unwrap_or(authority);
|
||||
|
||||
if without_auth.starts_with('[') {
|
||||
let end = without_auth.find(']')?;
|
||||
let host = &without_auth[1..end];
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
} else {
|
||||
let host = without_auth.split(':').next().unwrap_or("");
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus> {
|
||||
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||
let mut response = build_agent(timeout)
|
||||
.get(&url)
|
||||
.call()
|
||||
.with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?;
|
||||
|
||||
let body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.context("Failed to read LinkPlay status body")?;
|
||||
|
||||
parse_linkplay_status(&body)
|
||||
}
|
||||
|
||||
fn build_agent(timeout: Duration) -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(timeout))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct LinkPlayStatus {
|
||||
state_raw: String,
|
||||
curpos_ms: u64,
|
||||
totlen_ms: u64,
|
||||
track_index: Option<u32>,
|
||||
volume: u16,
|
||||
mute: bool,
|
||||
}
|
||||
|
||||
impl LinkPlayStatus {
|
||||
fn playback_state(&self) -> PlaybackState {
|
||||
match self.state_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
"load" => PlaybackState::Transitioning,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_info(&self) -> PlaybackPositionInfo {
|
||||
PlaybackPositionInfo {
|
||||
track: self.track_index,
|
||||
rel_time: Some(format_hms(self.curpos_ms / 1000)),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hms(self.totlen_ms / 1000))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
|
||||
let mut map = parse_flat_json(body)?;
|
||||
|
||||
let state_raw = map
|
||||
.remove("status")
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?;
|
||||
|
||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||
let volume = parse_u16_field(&map, "vol")?;
|
||||
let mute = match map.get("mute").map(|s| s.as_str()) {
|
||||
Some("1") => true,
|
||||
Some("0") => false,
|
||||
Some(other) => {
|
||||
return Err(anyhow!("Invalid LinkPlay mute value: {}", other));
|
||||
}
|
||||
None => return Err(anyhow!("LinkPlay status missing `mute` field")),
|
||||
};
|
||||
|
||||
let track_index = map
|
||||
.get("plicurr")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|idx| *idx > 0);
|
||||
|
||||
Ok(LinkPlayStatus {
|
||||
state_raw,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
track_index,
|
||||
volume,
|
||||
mute,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
||||
raw.parse::<u64>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
||||
}
|
||||
|
||||
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
||||
let value = raw
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))?;
|
||||
Ok(value.min(100))
|
||||
}
|
||||
|
||||
fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
|
||||
let mut chars = input.chars().peekable();
|
||||
skip_ws(&mut chars);
|
||||
if chars.next() != Some('{') {
|
||||
return Err(anyhow!("LinkPlay status is not a JSON object"));
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
loop {
|
||||
skip_ws(&mut chars);
|
||||
match chars.peek() {
|
||||
Some('}') => {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => return Err(anyhow!("Unexpected end of JSON object")),
|
||||
}
|
||||
|
||||
let key = parse_json_string(&mut chars)?;
|
||||
skip_ws(&mut chars);
|
||||
expect_char(&mut chars, ':')?;
|
||||
skip_ws(&mut chars);
|
||||
let value = parse_json_value(&mut chars)?;
|
||||
map.insert(key, value);
|
||||
skip_ws(&mut chars);
|
||||
|
||||
match chars.peek() {
|
||||
Some(',') => {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
Some('}') => {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(anyhow!(
|
||||
"Unexpected character '{}' while parsing JSON",
|
||||
other
|
||||
));
|
||||
}
|
||||
None => return Err(anyhow!("Unexpected end of JSON while parsing fields")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
||||
match chars.peek() {
|
||||
Some('"') => parse_json_string(chars),
|
||||
Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars),
|
||||
Some('t') => {
|
||||
expect_literal(chars, "true")?;
|
||||
Ok("true".to_string())
|
||||
}
|
||||
Some('f') => {
|
||||
expect_literal(chars, "false")?;
|
||||
Ok("false".to_string())
|
||||
}
|
||||
_ => Err(anyhow!("Unsupported JSON value in LinkPlay status")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_string(
|
||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
||||
) -> Result<String> {
|
||||
if chars.next() != Some('"') {
|
||||
return Err(anyhow!("Expected string"));
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'"' => return Ok(out),
|
||||
'\\' => {
|
||||
let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?;
|
||||
match escaped {
|
||||
'"' => out.push('"'),
|
||||
'\\' => out.push('\\'),
|
||||
'/' => out.push('/'),
|
||||
'b' => out.push('\u{0008}'),
|
||||
'f' => out.push('\u{000C}'),
|
||||
'n' => out.push('\n'),
|
||||
'r' => out.push('\r'),
|
||||
't' => out.push('\t'),
|
||||
'u' => {
|
||||
let mut hex = String::with_capacity(4);
|
||||
for _ in 0..4 {
|
||||
let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?;
|
||||
hex.push(h);
|
||||
}
|
||||
let code = u16::from_str_radix(&hex, 16)
|
||||
.with_context(|| format!("Invalid unicode escape: {}", hex))?;
|
||||
if let Some(c) = char::from_u32(code as u32) {
|
||||
out.push(c);
|
||||
} else {
|
||||
return Err(anyhow!("Invalid unicode code point: {}", code));
|
||||
}
|
||||
}
|
||||
other => return Err(anyhow!("Unsupported escape: {}", other)),
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Unterminated JSON string"))
|
||||
}
|
||||
|
||||
fn parse_json_number(
|
||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
||||
) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
if matches!(chars.peek(), Some('-')) {
|
||||
out.push('-');
|
||||
chars.next();
|
||||
}
|
||||
|
||||
while let Some(ch) = chars.peek() {
|
||||
if ch.is_ascii_digit() || *ch == '.' {
|
||||
out.push(*ch);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() || out == "-" {
|
||||
return Err(anyhow!("Invalid number"));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn expect_literal(
|
||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
||||
literal: &str,
|
||||
) -> Result<()> {
|
||||
for expected in literal.chars() {
|
||||
match chars.next() {
|
||||
Some(ch) if ch == expected => {}
|
||||
_ => return Err(anyhow!("Invalid literal while parsing JSON")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<()> {
|
||||
match chars.next() {
|
||||
Some(ch) if ch == expected => Ok(()),
|
||||
_ => Err(anyhow!("Missing '{}' while parsing JSON", expected)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
|
||||
while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for b in input.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_hhmmss_to_secs(s: &str) -> Option<u64> {
|
||||
let parts: Vec<_> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let h: u64 = parts[0].parse().ok()?;
|
||||
let m: u64 = parts[1].parse().ok()?;
|
||||
let sec: u64 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + sec)
|
||||
}
|
||||
|
||||
fn format_hms(secs: u64) -> String {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub struct RendererCapabilities {
|
||||
pub has_avtransport: bool,
|
||||
pub has_rendering_control: bool,
|
||||
pub has_connection_manager: bool,
|
||||
pub has_linkplay_http: bool,
|
||||
|
||||
pub has_oh_playlist: bool,
|
||||
pub has_oh_volume: bool,
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
|
||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||
use crate::{DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, TransportControl, UpnpRenderer, VolumeControl};
|
||||
use crate::{
|
||||
DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState, TransportControl,
|
||||
UpnpRenderer, VolumeControl,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use tracing::warn;
|
||||
|
||||
/// Music view of a renderer, independent of the underlying protocol/backend.
|
||||
///
|
||||
/// For now, only UPnP AV renderers are supported via [`UpnpRenderer`],
|
||||
/// but this type is designed to host additional backends (e.g. OpenHome)
|
||||
/// later on.
|
||||
/// Currently supported backends:
|
||||
/// - [`UpnpRenderer`] (AVTransport + RenderingControl)
|
||||
/// - [`LinkPlayRenderer`] (LinkPlay HTTP API)
|
||||
/// Additional backends (e.g. OpenHome) can be integrated later.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MusicRenderer {
|
||||
/// UPnP AV / DLNA backend.
|
||||
Upnp(UpnpRenderer),
|
||||
// Future backends could be added here, e.g.:
|
||||
// OpenHome(OpenHomeRenderer),
|
||||
/// LinkPlay HTTP backend (Arylic and similar devices).
|
||||
LinkPlay(LinkPlayRenderer),
|
||||
}
|
||||
|
||||
impl MusicRenderer {
|
||||
@@ -23,6 +28,7 @@ impl MusicRenderer {
|
||||
pub fn id(&self) -> &RendererId {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.id(),
|
||||
MusicRenderer::LinkPlay(r) => r.id(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,20 +36,20 @@ impl MusicRenderer {
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.friendly_name(),
|
||||
MusicRenderer::LinkPlay(r) => r.friendly_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol classification (UPnP AV only, OpenHome only, hybrid).
|
||||
pub fn protocol(&self) -> &RendererProtocol {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => &r.info.protocol,
|
||||
}
|
||||
&self.info().protocol
|
||||
}
|
||||
|
||||
/// Full static info as stored in the registry.
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => &r.info,
|
||||
MusicRenderer::LinkPlay(r) => &r.info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,19 +57,30 @@ impl MusicRenderer {
|
||||
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.
|
||||
/// Currently, only UPnP AV / hybrid renderers are mapped to [`MusicRenderer::Upnp`].
|
||||
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
|
||||
/// or [`MusicRenderer::Upnp`].
|
||||
pub fn from_registry_info(
|
||||
info: RendererInfo,
|
||||
registry: &DeviceRegistry,
|
||||
) -> Option<MusicRenderer> {
|
||||
match info.protocol {
|
||||
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
|
||||
if info.capabilities.has_linkplay_http {
|
||||
match LinkPlayRenderer::from_renderer_info(info.clone()) {
|
||||
Ok(renderer) => return Some(MusicRenderer::LinkPlay(renderer)),
|
||||
Err(err) => warn!(
|
||||
"Failed to build LinkPlay backend for {}: {}. Falling back to UPnP.",
|
||||
info.friendly_name, err
|
||||
),
|
||||
}
|
||||
}
|
||||
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry)))
|
||||
}
|
||||
RendererProtocol::OpenHomeOnly => {
|
||||
@@ -76,78 +93,85 @@ impl MusicRenderer {
|
||||
|
||||
/// Implémentation générique de `TransportControl` pour [`MusicRenderer`].
|
||||
///
|
||||
/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée,
|
||||
/// et la délégation se fait vers l'implémentation UPnP AV.
|
||||
/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants.
|
||||
impl TransportControl for MusicRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.play_uri(uri, meta),
|
||||
MusicRenderer::LinkPlay(r) => r.play_uri(uri, meta),
|
||||
}
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => TransportControl::play(r),
|
||||
MusicRenderer::LinkPlay(r) => r.play(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.pause(),
|
||||
MusicRenderer::LinkPlay(r) => r.pause(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.stop(),
|
||||
MusicRenderer::LinkPlay(r) => r.stop(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.seek_rel_time(hhmmss),
|
||||
MusicRenderer::LinkPlay(r) => r.seek_rel_time(hhmmss),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation générique de `VolumeControl` pour [`MusicRenderer`].
|
||||
///
|
||||
/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée,
|
||||
/// et la délégation se fait vers l'implémentation UPnP RenderingControl.
|
||||
/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants.
|
||||
impl VolumeControl for MusicRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.get_master_volume(),
|
||||
MusicRenderer::LinkPlay(r) => r.volume(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.set_master_volume(v),
|
||||
MusicRenderer::LinkPlay(r) => r.set_volume(v),
|
||||
}
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.get_master_mute(),
|
||||
MusicRenderer::LinkPlay(r) => r.mute(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.set_master_mute(m),
|
||||
MusicRenderer::LinkPlay(r) => r.set_mute(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation générique de `PlaybackStatus` pour [`MusicRenderer`].
|
||||
///
|
||||
/// La variante UPnP délègue à [`UpnpRenderer`]. Les backends OpenHome
|
||||
/// futurs mapperont l'état OH vers `PlaybackState`.
|
||||
/// Chaque backend fournit sa propre source d'état (UPnP AVTransport ou LinkPlay HTTP).
|
||||
impl PlaybackStatus for MusicRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
|
||||
MusicRenderer::LinkPlay(r) => r.playback_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +180,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(), plus tard
|
||||
MusicRenderer::LinkPlay(r) => r.playback_position(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::io::BufReader;
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||
use thiserror::Error;
|
||||
@@ -7,6 +7,7 @@ use tracing::{debug, warn};
|
||||
|
||||
use crate::avtransport_client::AvTransportClient;
|
||||
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
|
||||
use crate::linkplay::detect_linkplay_http;
|
||||
use crate::model::{
|
||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||
RendererInfo, RendererProtocol,
|
||||
@@ -273,7 +274,13 @@ impl HttpXmlDescriptionProvider {
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
||||
let udn = raw_udn.to_ascii_lowercase();
|
||||
let caps = detect_renderer_capabilities(&parsed.service_types);
|
||||
let mut caps = detect_renderer_capabilities(&parsed.service_types);
|
||||
if detect_linkplay_http(
|
||||
&endpoint.location,
|
||||
Duration::from_secs(self.timeout_secs.max(1)),
|
||||
) {
|
||||
caps.has_linkplay_http = true;
|
||||
}
|
||||
let protocol = detect_renderer_protocol(&caps);
|
||||
let now = SystemTime::now();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user