Enorme refactoring de PMO control step 2
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -20,7 +20,7 @@
|
|||||||
"rust-analyzer.numThreads": 4,
|
"rust-analyzer.numThreads": 4,
|
||||||
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
||||||
"rust-analyzer.checkOnSave.enable": true,
|
"rust-analyzer.checkOnSave.enable": true,
|
||||||
"rust-analyzer.checkOnSave.command": "clippy",
|
"rust-analyzer.checkOnSave.command": "check",
|
||||||
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
||||||
"rust-analyzer.exclude": [
|
"rust-analyzer.exclude": [
|
||||||
"target",
|
"target",
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ mdns = "3.0"
|
|||||||
async-std = "1.12"
|
async-std = "1.12"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
smol = "2.0"
|
smol = "2.0"
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
# pmoserver extension support (optional)
|
# pmoserver extension support (optional)
|
||||||
pmoserver = { path = "../pmoserver", optional = true }
|
pmoserver = { path = "../pmoserver", optional = true }
|
||||||
utoipa = { version = "5.4.0", optional = true }
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
axum = { version = "0.8.4", optional = true }
|
axum = { version = "0.8.4", optional = true }
|
||||||
serde = { workspace = true, optional = true }
|
|
||||||
serde_json = { workspace = true, optional = true }
|
|
||||||
tokio = { workspace = true, features = ["sync", "rt"], optional = true }
|
tokio = { workspace = true, features = ["sync", "rt"], optional = true }
|
||||||
tokio-util = { version = "0.7", optional = true }
|
tokio-util = { version = "0.7", optional = true }
|
||||||
async-trait = { version = "0.1", optional = true }
|
async-trait = { version = "0.1", optional = true }
|
||||||
@@ -38,11 +38,9 @@ async-stream = { version = "0.3", optional = true }
|
|||||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
percent-encoding = "2.3"
|
percent-encoding = "2.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# Active l'API REST pmoserver
|
# Active l'API REST pmoserver
|
||||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||||
|
|||||||
288
pmocontrol/src/arylic_client/mod.rs
Normal file
288
pmocontrol/src/arylic_client/mod.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}};
|
||||||
|
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
|
||||||
|
pub const ARYLIC_TCP_PORT: u16 = 8899;
|
||||||
|
pub const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
|
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
||||||
|
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
||||||
|
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
||||||
|
|
||||||
|
// Garde global pour respecter le délai de 200ms entre commandes
|
||||||
|
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn last_command_time() -> &'static Mutex<Instant> {
|
||||||
|
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||||
|
enum ResponseMode<'a> {
|
||||||
|
/// On n’attend aucune réponse (fire-and-forget).
|
||||||
|
None,
|
||||||
|
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
||||||
|
Optional(&'a [&'a str]),
|
||||||
|
/// On attend une réponse, et l’absence de réponse est une erreur.
|
||||||
|
Required(&'a [&'a str]),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_command_with_mode(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
timeout: Duration,
|
||||||
|
payload: &str,
|
||||||
|
mode: ResponseMode<'_>,
|
||||||
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
|
let mut stream = connect(host, port, timeout)?;
|
||||||
|
let packet = encode_packet(payload);
|
||||||
|
|
||||||
|
stream.write_all(&packet).map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(
|
||||||
|
format!(
|
||||||
|
"Failed to write Arylic TCP packet for {}: {}",
|
||||||
|
host, payload
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
stream.flush().map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(
|
||||||
|
format!(
|
||||||
|
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||||
|
host, payload
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
ResponseMode::None => {
|
||||||
|
debug!(
|
||||||
|
"Arylic TCP fire-and-forget command sent to {}: {}",
|
||||||
|
host, payload
|
||||||
|
);
|
||||||
|
let _ = stream.shutdown(Shutdown::Write);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
ResponseMode::Required(expected) => {
|
||||||
|
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
||||||
|
}
|
||||||
|
ResponseMode::Optional(expected) => {
|
||||||
|
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||||
|
match read_packet(&mut stream) {
|
||||||
|
Ok(response) => {
|
||||||
|
if expected.iter().any(|p| response.starts_with(p)) {
|
||||||
|
return Ok(Some(response));
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||||
|
host, response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
||||||
|
payload, host, err
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"No expected response for optional command {} on {}",
|
||||||
|
payload,
|
||||||
|
host
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_expected_response(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
host: &str,
|
||||||
|
payload: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
) -> Result<String, ControlPointError> {
|
||||||
|
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||||
|
let response = match read_packet(stream) {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||||
|
host,
|
||||||
|
payload,
|
||||||
|
err
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||||
|
host, response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"No expected response for command {} on {}",
|
||||||
|
payload,
|
||||||
|
host
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_command_required(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
timeout: Duration,
|
||||||
|
payload: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
) -> Result<String, ControlPointError> {
|
||||||
|
match send_command_with_mode(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout,
|
||||||
|
payload,
|
||||||
|
ResponseMode::Required(expected),
|
||||||
|
)? {
|
||||||
|
Some(s) => Ok(s),
|
||||||
|
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Arylic TCP: no response payload for required command {}",
|
||||||
|
payload
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_command_optional(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
timeout: Duration,
|
||||||
|
payload: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
|
send_command_with_mode(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout,
|
||||||
|
payload,
|
||||||
|
ResponseMode::Optional(expected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> {
|
||||||
|
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
||||||
|
if let Ok(mut last_time) = last_command_time().lock() {
|
||||||
|
let elapsed = last_time.elapsed();
|
||||||
|
if elapsed < Duration::from_millis(200) {
|
||||||
|
let wait = Duration::from_millis(200) - elapsed;
|
||||||
|
debug!(
|
||||||
|
"Waiting {:?} before sending command to respect 200ms interval",
|
||||||
|
wait
|
||||||
|
);
|
||||||
|
thread::sleep(wait);
|
||||||
|
}
|
||||||
|
*last_time = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let address = if host.contains(':') {
|
||||||
|
format!("[{}]:{}", host, port)
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut last_err = None;
|
||||||
|
for addr in address
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
||||||
|
})?
|
||||||
|
{
|
||||||
|
match TcpStream::connect_timeout(&addr, timeout) {
|
||||||
|
Ok(stream) => {
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(timeout))
|
||||||
|
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||||
|
.map_err(|err| {
|
||||||
|
ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Failed to set socket timeouts for {}",
|
||||||
|
address
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
return Ok(stream);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_err = Some((addr, err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match last_err {
|
||||||
|
Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Failed to connect to {} via {}: {}",
|
||||||
|
host, addr, err
|
||||||
|
))),
|
||||||
|
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"No socket addresses resolved for {}",
|
||||||
|
address
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_packet(payload: &str) -> Vec<u8> {
|
||||||
|
let bytes = payload.as_bytes();
|
||||||
|
let len = bytes.len() as u32;
|
||||||
|
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
||||||
|
out.extend_from_slice(&PACKET_HEADER);
|
||||||
|
out.extend_from_slice(&len.to_le_bytes());
|
||||||
|
out.extend_from_slice(&checksum.to_le_bytes());
|
||||||
|
out.extend_from_slice(&RESERVED_BYTES);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
||||||
|
let mut header = [0u8; 4];
|
||||||
|
stream.read_exact(&mut header)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
if header != PACKET_HEADER {
|
||||||
|
return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
stream.read_exact(&mut len_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
let len = u32::from_le_bytes(len_buf) as usize;
|
||||||
|
|
||||||
|
let mut checksum_buf = [0u8; 4];
|
||||||
|
stream.read_exact(&mut checksum_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||||
|
|
||||||
|
let mut reserved = [0u8; 8];
|
||||||
|
stream.read_exact(&mut reserved)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
|
||||||
|
let mut payload = vec![0u8; len];
|
||||||
|
stream.read_exact(&mut payload)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
||||||
|
|
||||||
|
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||||
|
if actual_checksum != expected_checksum {
|
||||||
|
warn!(
|
||||||
|
"Arylic payload checksum mismatch: expected={} actual={}",
|
||||||
|
expected_checksum, actual_checksum
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8(payload)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?)
|
||||||
|
}
|
||||||
@@ -1,671 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::{Shutdown, TcpStream, ToSocketAddrs};
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
|
||||||
use crate::capabilities::{
|
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
|
||||||
VolumeControl,
|
|
||||||
};
|
|
||||||
use crate::errors::ControlPointError;
|
|
||||||
use crate::linkplay_renderer::{extract_linkplay_host, parse_flat_json};
|
|
||||||
use crate::model::RendererInfo;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// Garde global pour respecter le délai de 200ms entre commandes
|
|
||||||
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn last_command_time() -> &'static Mutex<Instant> {
|
|
||||||
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const ARYLIC_TCP_PORT: u16 = 8899;
|
|
||||||
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
|
||||||
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
|
||||||
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
|
||||||
|
|
||||||
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
|
||||||
enum ResponseMode<'a> {
|
|
||||||
/// On n’attend aucune réponse (fire-and-forget).
|
|
||||||
None,
|
|
||||||
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
|
||||||
Optional(&'a [&'a str]),
|
|
||||||
/// On attend une réponse, et l’absence de réponse est une erreur.
|
|
||||||
Required(&'a [&'a str]),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
|
|
||||||
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
|
|
||||||
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
|
||||||
let Some(host) = extract_linkplay_host(location) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(cache) = detection_cache().lock() {
|
|
||||||
if let Some(result) = cache.get(&host) {
|
|
||||||
return *result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let detected = match try_detect_tcp(&host, timeout) {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"Arylic TCP detection failed for {} (host={}): {}",
|
|
||||||
location, host, err
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(mut cache) = detection_cache().lock() {
|
|
||||||
cache.insert(host, detected);
|
|
||||||
}
|
|
||||||
|
|
||||||
detected
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> {
|
|
||||||
let payload = send_command_required(
|
|
||||||
host,
|
|
||||||
ARYLIC_TCP_PORT,
|
|
||||||
timeout,
|
|
||||||
"MCU+INF+GET",
|
|
||||||
&["AXX+INF+", "AXX+DEV+"],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Unexpected INF response from {}: {}",
|
|
||||||
host,
|
|
||||||
payload
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ArylicTcpRenderer {
|
|
||||||
host: String,
|
|
||||||
port: u16,
|
|
||||||
timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ArylicTcpRenderer {
|
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self, ControlPointError> {
|
|
||||||
let host = extract_linkplay_host(info.location())
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
host,
|
|
||||||
port: ARYLIC_TCP_PORT,
|
|
||||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String, ControlPointError> {
|
|
||||||
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>, ControlPointError> {
|
|
||||||
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> {
|
|
||||||
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
|
||||||
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
|
||||||
match parse_playback_info(&payload) {
|
|
||||||
Ok(info) => Ok(info),
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"Failed to parse Arylic playback info for {}: {}",
|
|
||||||
self.host, err
|
|
||||||
);
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_volume_command(value: u16) -> String {
|
|
||||||
format!("MCU+VOL+{:03}", value.min(100))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_volume_payload(payload: &str) -> Result<u16, ControlPointError> {
|
|
||||||
let data = payload
|
|
||||||
.strip_prefix("AXX+VOL+")
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload)))?;
|
|
||||||
let value: u16 = data
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data)))?;
|
|
||||||
Ok(value.min(100))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_mute_payload(payload: &str) -> Result<bool, ControlPointError> {
|
|
||||||
let data = payload
|
|
||||||
.strip_prefix("AXX+MUT+")
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload)))?;
|
|
||||||
match data.trim() {
|
|
||||||
"000" | "0" => Ok(false),
|
|
||||||
"001" | "1" => Ok(true),
|
|
||||||
other => Err(ControlPointError::ArilycTcpError(format!("Invalid mute value: {}", other))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransportControl for ArylicTcpRenderer {
|
|
||||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
|
||||||
Err(ControlPointError::upnp_operation_not_supported(
|
|
||||||
"Arylic TCP backend does not support direct URL loading.",
|
|
||||||
"ArylicTcpRenderer",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play(&self) -> Result<(), ControlPointError> {
|
|
||||||
self.send_no_response("MCU+PLY-PLA")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pause(&self) -> Result<(), ControlPointError> {
|
|
||||||
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) -> Result<(), ControlPointError> {
|
|
||||||
self.send_no_response("MCU+PLY-STP")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
||||||
let _ = parse_hhmmss(hhmmss)?;
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Arylic TCP seek_rel_time is not implemented yet for this device."
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VolumeControl for ArylicTcpRenderer {
|
|
||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
||||||
if let Ok(info) = self.fetch_playback_info() {
|
|
||||||
if let Some(vol) = info.volume {
|
|
||||||
return Ok(vol);
|
|
||||||
}
|
|
||||||
debug!(
|
|
||||||
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
|
||||||
self.host
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
|
||||||
Self::parse_volume_payload(&payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
|
||||||
let command = Self::format_volume_command(v);
|
|
||||||
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
||||||
if let Ok(info) = self.fetch_playback_info() {
|
|
||||||
if let Some(mute) = info.mute {
|
|
||||||
return Ok(mute);
|
|
||||||
}
|
|
||||||
debug!(
|
|
||||||
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
|
||||||
self.host
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
|
||||||
Self::parse_mute_payload(&payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
||||||
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
|
||||||
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
|
||||||
let _ = Self::parse_mute_payload(&payload)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackStatus for ArylicTcpRenderer {
|
|
||||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
||||||
let info = self.fetch_playback_info()?;
|
|
||||||
Ok(info.playback_state())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackPosition for ArylicTcpRenderer {
|
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
||||||
let info = self.fetch_playback_info()?;
|
|
||||||
Ok(info.position_info())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ArylicPlaybackInfo {
|
|
||||||
status_raw: String,
|
|
||||||
curpos_ms: u64,
|
|
||||||
totlen_ms: u64,
|
|
||||||
volume: Option<u16>,
|
|
||||||
mute: Option<bool>,
|
|
||||||
playlist_size: Option<u32>,
|
|
||||||
track_index: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ArylicPlaybackInfo {
|
|
||||||
fn playback_state(&self) -> PlaybackState {
|
|
||||||
match self.status_raw.as_str() {
|
|
||||||
"play" => PlaybackState::Playing,
|
|
||||||
"pause" => PlaybackState::Paused,
|
|
||||||
"stop" => PlaybackState::Stopped,
|
|
||||||
other => PlaybackState::Unknown(other.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn position_info(&self) -> PlaybackPositionInfo {
|
|
||||||
let track = match (self.track_index, self.playlist_size) {
|
|
||||||
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
|
||||||
(Some(idx), _) => Some(idx),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
PlaybackPositionInfo {
|
|
||||||
track,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
track_metadata: None,
|
|
||||||
track_uri: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
|
||||||
let json_blob = payload
|
|
||||||
.strip_prefix("AXX+PLY+INF")
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload)))?;
|
|
||||||
|
|
||||||
let json_blob = json_blob.trim_end_matches('&').trim();
|
|
||||||
let map = parse_flat_json(json_blob)?;
|
|
||||||
|
|
||||||
let status_raw = map
|
|
||||||
.get("status")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `status` field")))?;
|
|
||||||
|
|
||||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
|
||||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
|
||||||
|
|
||||||
let volume = match map.get("vol") {
|
|
||||||
Some(raw) => match raw.parse::<u16>() {
|
|
||||||
Ok(value) => Some(value.min(100)),
|
|
||||||
Err(err) => {
|
|
||||||
debug!("Invalid Arylic `vol` value {}: {}", raw, err);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mute = match map.get("mute") {
|
|
||||||
Some(value) if value == "1" => Some(true),
|
|
||||||
Some(value) if value == "0" => Some(false),
|
|
||||||
Some(other) => {
|
|
||||||
debug!("Invalid Arylic `mute` value {}", other);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let playlist_size = map
|
|
||||||
.get("plicount")
|
|
||||||
.and_then(|raw| match raw.parse::<u32>() {
|
|
||||||
Ok(count) if count > 0 => Some(count),
|
|
||||||
Ok(_) => None,
|
|
||||||
Err(err) => {
|
|
||||||
debug!("Invalid Arylic `plicount` value {}: {}", raw, err);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let track_index = map.get("plicurr").and_then(|raw| match raw.parse::<u32>() {
|
|
||||||
Ok(idx) if idx > 0 => Some(idx),
|
|
||||||
Ok(_) => None,
|
|
||||||
Err(err) => {
|
|
||||||
debug!("Invalid Arylic `plicurr` value {}: {}", raw, err);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ArylicPlaybackInfo {
|
|
||||||
status_raw,
|
|
||||||
curpos_ms,
|
|
||||||
totlen_ms,
|
|
||||||
volume,
|
|
||||||
mute,
|
|
||||||
playlist_size,
|
|
||||||
track_index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64, ControlPointError> {
|
|
||||||
let raw = map
|
|
||||||
.get(key)
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("Playback info missing `{}` field", key)))?;
|
|
||||||
raw.parse::<u64>()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid `{}` value: {}", key, raw)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
|
||||||
if let Ok(mut last_time) = last_command_time().lock() {
|
|
||||||
let elapsed = last_time.elapsed();
|
|
||||||
if elapsed < Duration::from_millis(200) {
|
|
||||||
let wait = Duration::from_millis(200) - elapsed;
|
|
||||||
debug!(
|
|
||||||
"Waiting {:?} before sending command to respect 200ms interval",
|
|
||||||
wait
|
|
||||||
);
|
|
||||||
thread::sleep(wait);
|
|
||||||
}
|
|
||||||
*last_time = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = if host.contains(':') {
|
|
||||||
format!("[{}]:{}", host, port)
|
|
||||||
} else {
|
|
||||||
format!("{host}:{port}")
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut last_err = None;
|
|
||||||
for addr in address
|
|
||||||
.to_socket_addrs()
|
|
||||||
.with_context(|| format!("Failed to resolve {}:{}", host, port))
|
|
||||||
.map_err(|_| {
|
|
||||||
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
|
||||||
})?
|
|
||||||
{
|
|
||||||
match TcpStream::connect_timeout(&addr, timeout) {
|
|
||||||
Ok(stream) => {
|
|
||||||
stream
|
|
||||||
.set_read_timeout(Some(timeout))
|
|
||||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
|
||||||
.with_context(|| format!("Failed to set socket timeouts for {}", address))
|
|
||||||
.map_err(|err| {
|
|
||||||
ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Failed to set socket timeouts for {}",
|
|
||||||
address
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
return Ok(stream);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
last_err = Some((addr, err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match last_err {
|
|
||||||
Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Failed to connect to {} via {}: {}",
|
|
||||||
host, addr, err
|
|
||||||
))),
|
|
||||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"No socket addresses resolved for {}",
|
|
||||||
address
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_packet(payload: &str) -> Vec<u8> {
|
|
||||||
let bytes = payload.as_bytes();
|
|
||||||
let len = bytes.len() as u32;
|
|
||||||
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
|
||||||
out.extend_from_slice(&PACKET_HEADER);
|
|
||||||
out.extend_from_slice(&len.to_le_bytes());
|
|
||||||
out.extend_from_slice(&checksum.to_le_bytes());
|
|
||||||
out.extend_from_slice(&RESERVED_BYTES);
|
|
||||||
out.extend_from_slice(bytes);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
|
||||||
let mut header = [0u8; 4];
|
|
||||||
stream.read_exact(&mut header)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
|
||||||
if header != PACKET_HEADER {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut len_buf = [0u8; 4];
|
|
||||||
stream.read_exact(&mut len_buf)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
|
||||||
let len = u32::from_le_bytes(len_buf) as usize;
|
|
||||||
|
|
||||||
let mut checksum_buf = [0u8; 4];
|
|
||||||
stream.read_exact(&mut checksum_buf)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
|
||||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
|
||||||
|
|
||||||
let mut reserved = [0u8; 8];
|
|
||||||
stream.read_exact(&mut reserved)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
|
||||||
|
|
||||||
let mut payload = vec![0u8; len];
|
|
||||||
stream.read_exact(&mut payload)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
|
||||||
|
|
||||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
|
||||||
if actual_checksum != expected_checksum {
|
|
||||||
warn!(
|
|
||||||
"Arylic payload checksum mismatch: expected={} actual={}",
|
|
||||||
expected_checksum, actual_checksum
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(String::from_utf8(payload)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_hhmmss(value: &str) -> Result<u64, ControlPointError> {
|
|
||||||
let parts: Vec<_> = value.split(':').collect();
|
|
||||||
if parts.len() != 3 {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Invalid time format `{}`. Expected HH:MM:SS.",
|
|
||||||
value
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let hours: u64 = parts[0]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid hour component in {}", value)))?;
|
|
||||||
let minutes: u64 = parts[1]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid minute component in {}", value)))?;
|
|
||||||
let seconds: u64 = parts[2]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid second component in {}", value)))?;
|
|
||||||
|
|
||||||
if minutes > 59 || seconds > 59 {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
|
|
||||||
value
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_command_with_mode(
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
timeout: Duration,
|
|
||||||
payload: &str,
|
|
||||||
mode: ResponseMode<'_>,
|
|
||||||
) -> Result<Option<String>, ControlPointError> {
|
|
||||||
let mut stream = connect(host, port, timeout)?;
|
|
||||||
let packet = encode_packet(payload);
|
|
||||||
|
|
||||||
stream.write_all(&packet).map_err(|_| {
|
|
||||||
ControlPointError::ArilycTcpError(
|
|
||||||
format!(
|
|
||||||
"Failed to write Arylic TCP packet for {}: {}",
|
|
||||||
host, payload
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
stream.flush().map_err(|_| {
|
|
||||||
ControlPointError::ArilycTcpError(
|
|
||||||
format!(
|
|
||||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
|
||||||
host, payload
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
ResponseMode::None => {
|
|
||||||
debug!(
|
|
||||||
"Arylic TCP fire-and-forget command sent to {}: {}",
|
|
||||||
host, payload
|
|
||||||
);
|
|
||||||
let _ = stream.shutdown(Shutdown::Write);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
ResponseMode::Required(expected) => {
|
|
||||||
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
|
||||||
}
|
|
||||||
ResponseMode::Optional(expected) => {
|
|
||||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
|
||||||
match read_packet(&mut stream) {
|
|
||||||
Ok(response) => {
|
|
||||||
if expected.iter().any(|p| response.starts_with(p)) {
|
|
||||||
return Ok(Some(response));
|
|
||||||
}
|
|
||||||
debug!(
|
|
||||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
|
||||||
host, response
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
|
||||||
payload, host, err
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"No expected response for optional command {} on {}",
|
|
||||||
payload,
|
|
||||||
host
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_expected_response(
|
|
||||||
stream: &mut TcpStream,
|
|
||||||
host: &str,
|
|
||||||
payload: &str,
|
|
||||||
expected: &[&str],
|
|
||||||
) -> Result<String, ControlPointError> {
|
|
||||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
|
||||||
let response = match read_packet(stream) {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
|
||||||
host,
|
|
||||||
payload,
|
|
||||||
err
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
|
||||||
host, response
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"No expected response for command {} on {}",
|
|
||||||
payload,
|
|
||||||
host
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_command_required(
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
timeout: Duration,
|
|
||||||
payload: &str,
|
|
||||||
expected: &[&str],
|
|
||||||
) -> Result<String, ControlPointError> {
|
|
||||||
match send_command_with_mode(
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
timeout,
|
|
||||||
payload,
|
|
||||||
ResponseMode::Required(expected),
|
|
||||||
)? {
|
|
||||||
Some(s) => Ok(s),
|
|
||||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Arylic TCP: no response payload for required command {}",
|
|
||||||
payload
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_command_optional(
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
timeout: Duration,
|
|
||||||
payload: &str,
|
|
||||||
expected: &[&str],
|
|
||||||
) -> Result<Option<String>, ControlPointError> {
|
|
||||||
send_command_with_mode(
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
timeout,
|
|
||||||
payload,
|
|
||||||
ResponseMode::Optional(expected),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> {
|
|
||||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
66
pmocontrol/src/discovery/arylic.rs
Normal file
66
pmocontrol/src/discovery/arylic.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Mutex, OnceLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host
|
||||||
|
};
|
||||||
|
|
||||||
|
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
|
||||||
|
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
|
||||||
|
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
||||||
|
let Some(host) = extract_linkplay_host(location) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(cache) = detection_cache().lock() {
|
||||||
|
if let Some(result) = cache.get(&host) {
|
||||||
|
return *result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let detected = match try_detect_tcp(&host, timeout) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"Arylic TCP detection failed for {} (host={}): {}",
|
||||||
|
location, host, err
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut cache) = detection_cache().lock() {
|
||||||
|
cache.insert(host.to_string(), detected);
|
||||||
|
}
|
||||||
|
|
||||||
|
detected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> {
|
||||||
|
let payload = send_command_required(
|
||||||
|
host,
|
||||||
|
ARYLIC_TCP_PORT,
|
||||||
|
timeout,
|
||||||
|
"MCU+INF+GET",
|
||||||
|
&["AXX+INF+", "AXX+DEV+"],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Unexpected INF response from {}: {}",
|
||||||
|
host, payload
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,176 +2,181 @@
|
|||||||
//!
|
//!
|
||||||
//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the
|
//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the
|
||||||
//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP.
|
//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP.
|
||||||
//! This module handles the discovery of Chromecast devices and converts them
|
//! This module handles the discovery of Chromecast devices and registers them
|
||||||
//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`.
|
//! directly into the `DeviceRegistry`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::time::SystemTime;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::DeviceId;
|
use crate::DeviceId;
|
||||||
use crate::registry::DeviceUpdate;
|
use crate::DeviceRegistry;
|
||||||
|
use crate::discovery::manager::UDNRegistry;
|
||||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
/// Information about a discovered Chromecast device from mDNS.
|
/// Gestionnaire des événements mDNS pour Chromecast.
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct DiscoveredChromecast {
|
|
||||||
pub friendly_name: String,
|
|
||||||
pub host: String,
|
|
||||||
pub port: u16,
|
|
||||||
pub model: Option<String>,
|
|
||||||
pub uuid: String,
|
|
||||||
pub manufacturer: Option<String>,
|
|
||||||
pub last_seen: SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages the discovery and tracking of Chromecast devices via mDNS.
|
|
||||||
pub struct ChromecastDiscoveryManager {
|
pub struct ChromecastDiscoveryManager {
|
||||||
discovered_devices: HashMap<String, DiscoveredChromecast>,
|
device_registry: Arc<Mutex<DeviceRegistry>>,
|
||||||
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChromecastDiscoveryManager {
|
impl ChromecastDiscoveryManager {
|
||||||
pub fn new() -> Self {
|
pub fn new(
|
||||||
|
device_registry: Arc<Mutex<DeviceRegistry>>,
|
||||||
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
discovered_devices: HashMap::new(),
|
device_registry,
|
||||||
|
udn_cache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds or updates a discovered Chromecast device.
|
/// Traite une réponse mDNS pour un appareil Chromecast.
|
||||||
pub fn update_device(&mut self, device: DiscoveredChromecast) {
|
///
|
||||||
let uuid = device.uuid.clone();
|
/// Cette fonction parse les réponses de service discovery mDNS pour les appareils
|
||||||
self.discovered_devices.insert(uuid, device);
|
/// Chromecast et les enregistre directement dans le registre.
|
||||||
}
|
pub fn handle_mdns_response(&mut self, response: mdns::Response) {
|
||||||
|
// Extract basic information from the mDNS response
|
||||||
|
let service_name = match response.records().find_map(|r| {
|
||||||
|
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
||||||
|
Some(name.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Some(name) => name,
|
||||||
|
None => {
|
||||||
|
warn!("No PTR record found in mDNS response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// Retrieves a discovered device by UUID.
|
debug!("Processing mDNS response for service: {}", service_name);
|
||||||
pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> {
|
|
||||||
self.discovered_devices.get(uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists all discovered devices.
|
// Extract IP addresses
|
||||||
pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> {
|
let addresses: Vec<IpAddr> = response
|
||||||
self.discovered_devices.values().collect()
|
.records()
|
||||||
}
|
.filter_map(|r| match r.kind {
|
||||||
}
|
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
||||||
|
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
/// Processes an mDNS response and converts it into a `DeviceUpdate` event.
|
if addresses.is_empty() {
|
||||||
///
|
warn!("No IP address found for Chromecast device: {}", service_name);
|
||||||
/// This function parses mDNS service discovery responses for Chromecast
|
return;
|
||||||
/// devices and creates the appropriate update event for the device registry.
|
|
||||||
pub fn process_mdns_response(response: mdns::Response) -> Option<DeviceUpdate> {
|
|
||||||
// Extract basic information from the mDNS response
|
|
||||||
let service_name = response.records().filter_map(|r| {
|
|
||||||
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
|
||||||
Some(name.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}).next()?;
|
|
||||||
|
|
||||||
debug!("Processing mDNS response for service: {}", service_name);
|
// Prefer IPv4 addresses
|
||||||
|
let host = match addresses
|
||||||
// Extract IP addresses
|
.iter()
|
||||||
let addresses: Vec<IpAddr> = response
|
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
||||||
.records()
|
.or_else(|| addresses.first())
|
||||||
.filter_map(|r| match r.kind {
|
{
|
||||||
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
Some(addr) => addr.to_string(),
|
||||||
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
None => {
|
||||||
_ => None,
|
warn!("Could not extract host from addresses");
|
||||||
})
|
return;
|
||||||
.collect();
|
|
||||||
|
|
||||||
if addresses.is_empty() {
|
|
||||||
warn!("No IP address found for Chromecast device: {}", service_name);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer IPv4 addresses
|
|
||||||
let host = addresses
|
|
||||||
.iter()
|
|
||||||
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
|
||||||
.or_else(|| addresses.first())
|
|
||||||
.map(|addr| addr.to_string())?;
|
|
||||||
|
|
||||||
// Extract port from SRV record
|
|
||||||
let port = response
|
|
||||||
.records()
|
|
||||||
.filter_map(|r| {
|
|
||||||
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
|
||||||
Some(port)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.next()
|
|
||||||
.unwrap_or(8009); // Default Chromecast port
|
|
||||||
|
|
||||||
// Extract TXT records for additional metadata
|
// Extract port from SRV record
|
||||||
let txt_records: HashMap<String, String> = response
|
let port = response
|
||||||
.records()
|
.records()
|
||||||
.filter_map(|r| {
|
.find_map(|r| {
|
||||||
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
||||||
Some(data.clone())
|
Some(port)
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.flat_map(|data| {
|
|
||||||
// data is Vec<String>, each string is "key=value"
|
|
||||||
data.into_iter().filter_map(|s| {
|
|
||||||
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
Some((parts[0].to_string(), parts[1].to_string()))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
.unwrap_or(8009); // Default Chromecast port
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Extract metadata from TXT records
|
// Extract TXT records for additional metadata
|
||||||
let model = txt_records.get("md").cloned();
|
let txt_records: HashMap<String, String> = response
|
||||||
let uuid = txt_records
|
.records()
|
||||||
.get("id")
|
.filter_map(|r| {
|
||||||
.cloned()
|
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
||||||
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
Some(data.clone())
|
||||||
let manufacturer = Some("Google Inc.".to_string());
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flat_map(|data| {
|
||||||
|
// data is Vec<String>, each string is "key=value"
|
||||||
|
data.into_iter().filter_map(|s| {
|
||||||
|
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
Some((parts[0].to_string(), parts[1].to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Extract friendly name from TXT record "fn" if available
|
// Extract metadata from TXT records
|
||||||
// Otherwise, extract from service instance name (PTR record)
|
let model = txt_records.get("md").cloned();
|
||||||
let friendly_name = txt_records
|
let uuid = txt_records
|
||||||
.get("fn")
|
.get("id")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
||||||
// Fallback: extract from service name, removing the UUID suffix if present
|
let manufacturer = Some("Google Inc.".to_string());
|
||||||
service_name
|
|
||||||
.split("._googlecast._tcp.local")
|
|
||||||
.next()
|
|
||||||
.unwrap_or("Unknown Chromecast")
|
|
||||||
.split('-')
|
|
||||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-")
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(
|
// Extract friendly name from TXT record "fn" if available
|
||||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
// Otherwise, extract from service instance name (PTR record)
|
||||||
friendly_name, host, port, uuid, model
|
let friendly_name = txt_records
|
||||||
);
|
.get("fn")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// Fallback: extract from service name, removing the UUID suffix if present
|
||||||
|
service_name
|
||||||
|
.split("._googlecast._tcp.local")
|
||||||
|
.next()
|
||||||
|
.unwrap_or("Unknown Chromecast")
|
||||||
|
.split('-')
|
||||||
|
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
// Create RendererInfo for the registry
|
debug!(
|
||||||
let renderer_info = build_renderer_info(
|
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||||
&uuid,
|
friendly_name, host, port, uuid, model
|
||||||
&friendly_name,
|
);
|
||||||
&host,
|
|
||||||
port,
|
|
||||||
model.as_deref(),
|
|
||||||
manufacturer.as_deref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(DeviceUpdate::RendererOnline(renderer_info))
|
// Build UDN and check cache
|
||||||
|
let udn = format!("uuid:{}", uuid);
|
||||||
|
|
||||||
|
// Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept
|
||||||
|
let default_max_age = 1800u64; // 30 minutes
|
||||||
|
|
||||||
|
// Check cache to avoid redundant updates
|
||||||
|
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||||
|
debug!("Chromecast {} recently seen, skipping", udn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create RendererInfo for the registry
|
||||||
|
let renderer_info = build_renderer_info(
|
||||||
|
&uuid,
|
||||||
|
&friendly_name,
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
model.as_deref(),
|
||||||
|
manufacturer.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register the renderer
|
||||||
|
self.device_registry
|
||||||
|
.lock()
|
||||||
|
.expect("DeviceRegistry mutex lock failed")
|
||||||
|
.push_renderer(&renderer_info, default_max_age as u32);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a `RendererInfo` structure for a Chromecast device.
|
/// Builds a `RendererInfo` structure for a Chromecast device.
|
||||||
|
|||||||
@@ -1,40 +1,45 @@
|
|||||||
use std::{collections::HashMap, sync::{Arc, Mutex, OnceLock}, time::{Instant, SystemTime}};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
use tracing_subscriber::registry;
|
sync::{Arc, Mutex},
|
||||||
|
time::{Instant, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
struct UDNSeen {
|
struct UDNSeen {
|
||||||
max_age: u64,
|
max_age: u64,
|
||||||
last_seen: Instant,
|
last_seen: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UDNRegistry {
|
pub struct UDNRegistry {
|
||||||
seen: HashMap<String, UDNSeen>,
|
seen: HashMap<String, UDNSeen>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UDNRegistry {
|
impl UDNRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
UDNRegistry { seen: HashMap::new() }
|
UDNRegistry {
|
||||||
|
seen: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the UDN has been seen for at least half of its lifetime
|
/// Returns `true` if the UDN has been seen for at least half of its lifetime
|
||||||
pub fn should_fetch(registry : Arc<Mutex<UDNRegistry>>,udn: &str, max_age: u64 ) -> bool {
|
pub fn should_fetch(registry: Arc<Mutex<UDNRegistry>>, udn: &str, max_age: u64) -> bool {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut r = registry
|
let mut r = registry.lock().expect("UDNRegistry mutex lock failed");
|
||||||
.lock()
|
|
||||||
.expect("UDNRegistry mutex lock failed");
|
|
||||||
if let Some(seen) = r.seen.get_mut(udn) {
|
if let Some(seen) = r.seen.get_mut(udn) {
|
||||||
if now.duration_since(seen.last_seen).as_secs() > max_age/2 {
|
if now.duration_since(seen.last_seen).as_secs() > max_age / 2 {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
seen.last_seen = now;
|
seen.last_seen = now;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
r.seen.insert(udn.to_string(), UDNSeen { max_age, last_seen: now });
|
r.seen.insert(
|
||||||
|
udn.to_string(),
|
||||||
|
UDNSeen {
|
||||||
|
max_age,
|
||||||
|
last_seen: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
|
use crate::{RendererInfo, UpnpMediaServer};
|
||||||
|
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod upnp_discovery;
|
pub mod upnp_discovery;
|
||||||
pub mod upnp_provider;
|
pub mod upnp_provider;
|
||||||
pub mod chromecast_discovery;
|
pub mod chromecast_discovery;
|
||||||
|
pub mod arylic;
|
||||||
|
|
||||||
|
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
||||||
|
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
||||||
|
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
||||||
|
pub trait DeviceDescriptionProvider: Send + Sync {
|
||||||
|
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
||||||
|
/// ne correspond pas à un renderer audio intéressant.
|
||||||
|
fn build_renderer_info(
|
||||||
|
&self,
|
||||||
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
|
) -> Option<RendererInfo>;
|
||||||
|
|
||||||
|
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
||||||
|
/// ne correspond pas à un media server (ou pas intéressant).
|
||||||
|
fn build_server_info(
|
||||||
|
&self,
|
||||||
|
udn: &str,
|
||||||
|
location: &str,
|
||||||
|
server_header: &str,
|
||||||
|
) -> Option<UpnpMediaServer>;
|
||||||
|
}
|
||||||
@@ -1,49 +1,14 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
|
||||||
|
|
||||||
use pmoupnp::ssdp::SsdpEvent;
|
use pmoupnp::ssdp::SsdpEvent;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::discovery::manager::UDNRegistry;
|
use crate::discovery::manager::UDNRegistry;
|
||||||
use crate::media_server::UpnpMediaServer;
|
|
||||||
use crate::model::RendererInfo;
|
|
||||||
use crate::registry::DeviceUpdate;
|
|
||||||
|
|
||||||
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
|
||||||
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
|
||||||
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
|
||||||
pub trait DeviceDescriptionProvider: Send + Sync {
|
|
||||||
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
|
||||||
/// ne correspond pas à un renderer audio intéressant.
|
|
||||||
fn build_renderer_info(
|
|
||||||
&self,
|
|
||||||
udn: &str,
|
|
||||||
location: &str,
|
|
||||||
server_header: &str,
|
|
||||||
) -> Option<RendererInfo>;
|
|
||||||
|
|
||||||
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
|
||||||
/// ne correspond pas à un media server (ou pas intéressant).
|
|
||||||
fn build_server_info(
|
|
||||||
&self,
|
|
||||||
udn: &str,
|
|
||||||
location: &str,
|
|
||||||
server_header: &str,
|
|
||||||
) -> Option<UpnpMediaServer>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||||
pub struct DiscoveryManager<P>
|
|
||||||
where
|
|
||||||
P: DeviceDescriptionProvider,
|
|
||||||
{
|
|
||||||
provider: P,
|
|
||||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
|
||||||
device_registry: Arc<DeviceRegistry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UpnpDiscoveryManager {
|
pub struct UpnpDiscoveryManager {
|
||||||
device_registry: Arc<Mutex<DeviceRegistry>>,
|
device_registry: Arc<Mutex<DeviceRegistry>>,
|
||||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
http_client: Agent, // ← intégré
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpDiscoveryManager {
|
impl UpnpDiscoveryManager {
|
||||||
@@ -70,428 +35,35 @@ impl UpnpDiscoveryManager {
|
|||||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||||
if alive {
|
if alive {
|
||||||
// ✅ Check cache
|
// ✅ Check cache
|
||||||
if UDNRegistry::should_fetch(self.udn_cache, &udn, max_age as u64) {
|
if UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, max_age as u64) {
|
||||||
// ✅ Fetch + parse
|
// ✅ Fetch + parse
|
||||||
let info = self.provider.build_renderer_info(&location)?;
|
if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) {
|
||||||
}
|
if let Some(renderer_info) = info.build_renderer() {
|
||||||
|
self.device_registry
|
||||||
|
.lock()
|
||||||
|
.expect("UDNRegistry mutex lock failed")
|
||||||
|
.push_renderer(&renderer_info,max_age);
|
||||||
|
} else {
|
||||||
|
if let Some(server_info) = info.build_server() {
|
||||||
|
self.device_registry
|
||||||
|
.lock()
|
||||||
|
.expect("UDNRegistry mutex lock failed")
|
||||||
|
.push_server(&server_info,max_age);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
} else {
|
} else {
|
||||||
self.device_registry.lock()
|
self.device_registry
|
||||||
|
.lock()
|
||||||
.expect("UDNRegistry mutex lock failed")
|
.expect("UDNRegistry mutex lock failed")
|
||||||
.device_says_byebye(&udn);
|
.device_says_byebye(&udn);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch and parse the device description.xml at endpoint.location.
|
|
||||||
fn fetch_and_parse(
|
|
||||||
&self,
|
|
||||||
udn: &str,
|
|
||||||
location: &str,
|
|
||||||
server_header: &str,
|
|
||||||
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
|
||||||
debug!("Fetching description for {} at {}", udn, location);
|
|
||||||
|
|
||||||
let config = Agent::config_builder()
|
|
||||||
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let agent: Agent = config.into();
|
|
||||||
|
|
||||||
let response = agent.get(location).call()?;
|
|
||||||
|
|
||||||
// response: http::Response<ureq::Body>
|
|
||||||
let (_parts, body) = response.into_parts();
|
|
||||||
|
|
||||||
// body.into_reader() -> impl Read + 'static
|
|
||||||
let body_reader = body.into_reader();
|
|
||||||
|
|
||||||
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
|
||||||
reader.config_mut().trim_text(true);
|
|
||||||
debug!("Parsing description XML for {} at {}", udn, location);
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut parsed = ParsedDeviceDescription::default();
|
|
||||||
|
|
||||||
let mut in_device = false;
|
|
||||||
let mut in_service = false;
|
|
||||||
let mut current_tag: Option<String> = None;
|
|
||||||
|
|
||||||
// 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)? {
|
|
||||||
Event::Start(e) => {
|
|
||||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
||||||
match name.as_str() {
|
|
||||||
"device" => {
|
|
||||||
in_device = true;
|
|
||||||
current_tag = None;
|
|
||||||
}
|
|
||||||
"service" => {
|
|
||||||
if in_device {
|
|
||||||
in_service = true;
|
|
||||||
current_tag = None;
|
|
||||||
current_service_type = None;
|
|
||||||
current_control_url = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if in_device {
|
|
||||||
current_tag = Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::End(e) => {
|
|
||||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
||||||
match name.as_str() {
|
|
||||||
"device" => {
|
|
||||||
in_device = false;
|
|
||||||
}
|
|
||||||
"service" => {
|
|
||||||
if in_device && in_service {
|
|
||||||
// We just finished a <service> block: if this is AVTransport,
|
|
||||||
// store its endpoint in parsed.*
|
|
||||||
if let (Some(st), Some(ctrl)) =
|
|
||||||
(¤t_service_type, ¤t_control_url)
|
|
||||||
{
|
|
||||||
let lower = st.to_ascii_lowercase();
|
|
||||||
if lower.contains("urn:schemas-upnp-org:service:avtransport:") {
|
|
||||||
// Only set once; if multiple AVTransport services exist,
|
|
||||||
// we keep the first one.
|
|
||||||
if parsed.avtransport_service_type.is_none() {
|
|
||||||
parsed.avtransport_service_type = Some(st.clone());
|
|
||||||
parsed.avtransport_control_url = Some(ctrl.clone());
|
|
||||||
debug!(
|
|
||||||
"Found AVTransport service for {}: type={} controlURL={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lower
|
|
||||||
.contains("urn:schemas-upnp-org:service:renderingcontrol:")
|
|
||||||
{
|
|
||||||
if parsed.rendering_control_service_type.is_none() {
|
|
||||||
parsed.rendering_control_service_type =
|
|
||||||
Some(st.clone());
|
|
||||||
parsed.rendering_control_control_url =
|
|
||||||
Some(ctrl.clone());
|
|
||||||
debug!(
|
|
||||||
"Found RenderingControl service for {}: type={} controlURL={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lower
|
|
||||||
.contains("urn:schemas-upnp-org:service:connectionmanager:")
|
|
||||||
{
|
|
||||||
if parsed.connection_manager_service_type.is_none() {
|
|
||||||
parsed.connection_manager_service_type =
|
|
||||||
Some(st.clone());
|
|
||||||
parsed.connection_manager_control_url =
|
|
||||||
Some(ctrl.clone());
|
|
||||||
debug!(
|
|
||||||
"Found ConnectionManager service for {}: type={} controlURL={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lower
|
|
||||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
|
||||||
{
|
|
||||||
if parsed.content_directory_service_type.is_none() {
|
|
||||||
parsed.content_directory_service_type =
|
|
||||||
Some(st.clone());
|
|
||||||
parsed.content_directory_control_url =
|
|
||||||
Some(ctrl.clone());
|
|
||||||
debug!(
|
|
||||||
"Found ContentDirectory service for {}: type={} controlURL={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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={}",
|
|
||||||
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={}",
|
|
||||||
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={}",
|
|
||||||
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={}",
|
|
||||||
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={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lower.contains("urn:av-openhome-org:service:product:") {
|
|
||||||
if parsed.oh_product_service_type.is_none() {
|
|
||||||
parsed.oh_product_service_type = Some(st.clone());
|
|
||||||
parsed.oh_product_control_url = Some(ctrl.clone());
|
|
||||||
debug!(
|
|
||||||
"Found OpenHome Product for {}: type={} controlURL={}",
|
|
||||||
udn, st, ctrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
in_service = false;
|
|
||||||
current_service_type = None;
|
|
||||||
current_control_url = None;
|
|
||||||
current_event_sub_url = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
current_tag = None;
|
|
||||||
}
|
|
||||||
Event::Text(e) => {
|
|
||||||
if in_device {
|
|
||||||
if let Some(tag) = ¤t_tag {
|
|
||||||
// quick-xml ≥ 0.37 : unescape() → decode()
|
|
||||||
let text = e.decode().map_err(XmlError::Encoding)?.into_owned();
|
|
||||||
|
|
||||||
match tag.as_str() {
|
|
||||||
"UDN" => {
|
|
||||||
parsed.udn = Some(text);
|
|
||||||
}
|
|
||||||
"deviceType" => {
|
|
||||||
parsed.device_type = Some(text);
|
|
||||||
}
|
|
||||||
"friendlyName" => {
|
|
||||||
parsed.friendly_name = Some(text);
|
|
||||||
}
|
|
||||||
"manufacturer" => {
|
|
||||||
parsed.manufacturer = Some(text);
|
|
||||||
}
|
|
||||||
"modelName" => {
|
|
||||||
parsed.model_name = Some(text);
|
|
||||||
}
|
|
||||||
"serviceType" if in_service => {
|
|
||||||
parsed.service_types.push(text.clone());
|
|
||||||
current_service_type = Some(text);
|
|
||||||
}
|
|
||||||
"controlURL" if in_service => {
|
|
||||||
current_control_url = Some(text);
|
|
||||||
}
|
|
||||||
"eventSubURL" if in_service => {
|
|
||||||
current_event_sub_url = Some(text);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Eof => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed.require_fields()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P> DiscoveryManager<P>
|
|
||||||
where
|
|
||||||
P: DeviceDescriptionProvider,
|
|
||||||
{
|
|
||||||
pub fn new(provider: P) -> Self {
|
|
||||||
Self { provider }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dans handle_ssdp_event (upnp_discovery.rs)
|
|
||||||
fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
|
|
||||||
let (alive, usn, location, max_age, server_header) = match event {
|
|
||||||
SsdpEvent::Alive {
|
|
||||||
usn,
|
|
||||||
location,
|
|
||||||
max_age,
|
|
||||||
server,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| SsdpEvent::SearchResponse {
|
|
||||||
usn,
|
|
||||||
location,
|
|
||||||
max_age,
|
|
||||||
server,
|
|
||||||
..
|
|
||||||
} => (true, usn, location, max_age, server),
|
|
||||||
SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
|
||||||
if alive {
|
|
||||||
// ✅ Check cache
|
|
||||||
if !UDNRegistry::should_fetch(&udn, max_age) {
|
|
||||||
return vec![]; // Skip, vu récemment
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Fetch + parse
|
|
||||||
let info = self.provider.build_renderer_info(&location)?;
|
|
||||||
vec![DeviceUpdate::RendererOnline(info)]
|
|
||||||
} else {
|
|
||||||
self.handle_byebye(udn, nt, &mut updates);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_alive(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
nt: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_search_response(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
st: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
self.update_endpoint(udn, st, location, server_header, max_age, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
|
|
||||||
if let Some(endpoint) = self.endpoints.get(&udn) {
|
|
||||||
if endpoint.seen_as_renderer {
|
|
||||||
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
|
|
||||||
}
|
|
||||||
if endpoint.seen_as_server {
|
|
||||||
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_endpoint(
|
|
||||||
&mut self,
|
|
||||||
udn: String,
|
|
||||||
device_type: String,
|
|
||||||
location: String,
|
|
||||||
server_header: String,
|
|
||||||
max_age: u32,
|
|
||||||
updates: &mut Vec<DeviceUpdate>,
|
|
||||||
) {
|
|
||||||
tracing::debug!(
|
|
||||||
"SSDP update: udn={} type={} location={} max_age={}",
|
|
||||||
udn,
|
|
||||||
device_type,
|
|
||||||
location,
|
|
||||||
max_age
|
|
||||||
);
|
|
||||||
|
|
||||||
let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({
|
|
||||||
let udn = udn.clone();
|
|
||||||
let location = location.clone();
|
|
||||||
let server_header = server_header.clone();
|
|
||||||
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
|
||||||
});
|
|
||||||
|
|
||||||
endpoint.touch(location, server_header, max_age);
|
|
||||||
endpoint.types_seen.insert(device_type);
|
|
||||||
|
|
||||||
// Toujours tenter de classifier et envoyer un événement Online pour maintenir
|
|
||||||
// l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent)
|
|
||||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
|
||||||
if !endpoint.seen_as_renderer {
|
|
||||||
tracing::debug!(
|
|
||||||
"Renderer classified: udn={} friendly_name={} model={}",
|
|
||||||
info.udn(),
|
|
||||||
info.friendly_name(),
|
|
||||||
info.model_name()
|
|
||||||
);
|
|
||||||
endpoint.seen_as_renderer = true;
|
|
||||||
}
|
|
||||||
updates.push(DeviceUpdate::RendererOnline(info));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
|
||||||
if !endpoint.seen_as_server {
|
|
||||||
tracing::debug!(
|
|
||||||
"Server classified: udn={} friendly_name={} model={}",
|
|
||||||
info.udn,
|
|
||||||
info.friendly_name,
|
|
||||||
info.model_name
|
|
||||||
);
|
|
||||||
endpoint.seen_as_server = true;
|
|
||||||
}
|
|
||||||
updates.push(DeviceUpdate::ServerOnline(info));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||||
let lower = usn.trim().to_ascii_lowercase();
|
let lower = usn.trim().to_ascii_lowercase();
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::time::Duration;
|
||||||
|
|
||||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug};
|
||||||
|
|
||||||
use crate::DeviceId;
|
use crate::DeviceId;
|
||||||
use crate::arylic_tcp::detect_arylic_tcp;
|
use crate::discovery::arylic::detect_arylic_tcp;
|
||||||
use crate::avtransport_client::AvTransportClient;
|
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
|
||||||
use crate::discovery::upnp_discovery::DeviceDescriptionProvider;
|
|
||||||
use crate::linkplay_renderer::detect_linkplay_http;
|
|
||||||
use crate::media_server::UpnpMediaServer;
|
use crate::media_server::UpnpMediaServer;
|
||||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||||
|
use crate::upnp_clients::{AvTransportClient,resolve_control_url};
|
||||||
|
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
@@ -32,8 +31,11 @@ pub enum DescriptionError {
|
|||||||
|
|
||||||
/// Parsed device description, plus (optionally) AVTransport endpoint.
|
/// Parsed device description, plus (optionally) AVTransport endpoint.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct ParsedDeviceDescription {
|
pub struct ParsedDeviceDescription {
|
||||||
udn: Option<String>,
|
timeout_secs: u64,
|
||||||
|
udn: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
device_type: Option<String>,
|
device_type: Option<String>,
|
||||||
friendly_name: Option<String>,
|
friendly_name: Option<String>,
|
||||||
manufacturer: Option<String>,
|
manufacturer: Option<String>,
|
||||||
@@ -75,41 +77,18 @@ struct ParsedDeviceDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ParsedDeviceDescription {
|
impl ParsedDeviceDescription {
|
||||||
fn require_fields(self) -> Result<Self, DescriptionError> {
|
|
||||||
if self.device_type.is_none() {
|
|
||||||
return Err(DescriptionError::MissingField("deviceType"));
|
|
||||||
}
|
|
||||||
if self.friendly_name.is_none() {
|
|
||||||
return Err(DescriptionError::MissingField("friendlyName"));
|
|
||||||
}
|
|
||||||
if self.model_name.is_none() {
|
|
||||||
return Err(DescriptionError::MissingField("modelName"));
|
|
||||||
}
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HTTP-based XML description provider (UPnP device description.xml)
|
|
||||||
pub struct HttpXmlDescriptionProvider {
|
|
||||||
timeout_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HttpXmlDescriptionProvider {
|
|
||||||
pub fn new(timeout_secs: u64) -> Self {
|
|
||||||
Self { timeout_secs }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch and parse the device description.xml at endpoint.location.
|
/// Fetch and parse the device description.xml at endpoint.location.
|
||||||
fn fetch_and_parse(
|
pub fn new(
|
||||||
&self,
|
|
||||||
udn: &str,
|
udn: &str,
|
||||||
location: &str,
|
location: &str,
|
||||||
server_header: &str,
|
server_header: &str,
|
||||||
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
timeout_secs: u64,
|
||||||
|
) -> Result<Self, DescriptionError> {
|
||||||
debug!("Fetching description for {} at {}", udn, location);
|
debug!("Fetching description for {} at {}", udn, location);
|
||||||
|
|
||||||
let config = Agent::config_builder()
|
let config = Agent::config_builder()
|
||||||
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
.timeout_global(Some(std::time::Duration::from_secs(timeout_secs)))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let agent: Agent = config.into();
|
let agent: Agent = config.into();
|
||||||
@@ -129,6 +108,11 @@ impl HttpXmlDescriptionProvider {
|
|||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut parsed = ParsedDeviceDescription::default();
|
let mut parsed = ParsedDeviceDescription::default();
|
||||||
|
|
||||||
|
parsed.timeout_secs=timeout_secs;
|
||||||
|
parsed.location = location.to_string();
|
||||||
|
parsed.udn = udn.to_string();
|
||||||
|
parsed.server_header = server_header.to_string();
|
||||||
|
|
||||||
let mut in_device = false;
|
let mut in_device = false;
|
||||||
let mut in_service = false;
|
let mut in_service = false;
|
||||||
let mut current_tag: Option<String> = None;
|
let mut current_tag: Option<String> = None;
|
||||||
@@ -331,7 +315,7 @@ impl HttpXmlDescriptionProvider {
|
|||||||
|
|
||||||
match tag.as_str() {
|
match tag.as_str() {
|
||||||
"UDN" => {
|
"UDN" => {
|
||||||
parsed.udn = Some(text);
|
parsed.udn = text;
|
||||||
}
|
}
|
||||||
"deviceType" => {
|
"deviceType" => {
|
||||||
parsed.device_type = Some(text);
|
parsed.device_type = Some(text);
|
||||||
@@ -370,181 +354,274 @@ impl HttpXmlDescriptionProvider {
|
|||||||
parsed.require_fields()
|
parsed.require_fields()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_renderer(
|
pub fn build_renderer(
|
||||||
&self,
|
&self,
|
||||||
udn: &str,
|
|
||||||
location: &str,
|
|
||||||
server_header: &str,
|
|
||||||
parsed: &ParsedDeviceDescription,
|
|
||||||
) -> Option<RendererInfo> {
|
) -> Option<RendererInfo> {
|
||||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||||
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
|
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
|
||||||
&& !device_type.contains("urn:av-openhome-org:device:source:")
|
&& !device_type.contains("urn:av-openhome-org:device:source:")
|
||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
"build_renderer: ignoring deviceType for {}: {}",
|
"build_renderer: ignoring deviceType for {}: {}",
|
||||||
udn, device_type
|
self.udn, device_type
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw_udn = parsed.udn.as_deref().unwrap_or_else(|| udn);
|
let udn = self.udn.to_ascii_lowercase();
|
||||||
let udn = raw_udn.to_ascii_lowercase();
|
let mut caps = detect_renderer_capabilities(&self.service_types);
|
||||||
let mut caps = detect_renderer_capabilities(&parsed.service_types);
|
if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||||
if detect_linkplay_http(location, Duration::from_secs(self.timeout_secs.max(1))) {
|
|
||||||
caps.has_linkplay_http = true;
|
caps.has_linkplay_http = true;
|
||||||
}
|
}
|
||||||
if detect_arylic_tcp(location, Duration::from_secs(self.timeout_secs.max(1))) {
|
if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||||
caps.has_arylic_tcp = true;
|
caps.has_arylic_tcp = true;
|
||||||
}
|
}
|
||||||
let protocol = detect_renderer_protocol(&caps);
|
let protocol = detect_renderer_protocol(&caps);
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
Some(RendererInfo::make(
|
Some(RendererInfo::make(
|
||||||
DeviceId(udn.clone()),
|
DeviceId(udn.clone()),
|
||||||
udn,
|
udn,
|
||||||
parsed.friendly_name.clone().unwrap_or_default(),
|
self.friendly_name.clone().unwrap_or_default(),
|
||||||
parsed.model_name.clone().unwrap_or_default(),
|
self.model_name.clone().unwrap_or_default(),
|
||||||
parsed.manufacturer.clone().unwrap_or_default(),
|
self.manufacturer.clone().unwrap_or_default(),
|
||||||
protocol,
|
protocol,
|
||||||
caps,
|
caps,
|
||||||
location.to_string(),
|
self.location.clone(),
|
||||||
endpoint.server_header.clone(),
|
self.server_header.clone(),
|
||||||
// online: true,
|
self.avtransport_service_type.clone(),
|
||||||
// last_seen: now,
|
self
|
||||||
// max_age: endpoint.max_age,
|
|
||||||
parsed.avtransport_service_type.clone(),
|
|
||||||
parsed
|
|
||||||
.avtransport_control_url
|
.avtransport_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed.rendering_control_service_type.clone(),
|
self.rendering_control_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.rendering_control_control_url
|
.rendering_control_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed.connection_manager_service_type.clone(),
|
self.connection_manager_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.connection_manager_control_url
|
.connection_manager_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed.oh_playlist_service_type.clone(),
|
self.oh_playlist_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_playlist_control_url
|
.oh_playlist_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed
|
self
|
||||||
.oh_playlist_event_sub_url
|
.oh_playlist_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
parsed.oh_info_service_type.clone(),
|
self.oh_info_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_info_control_url
|
.oh_info_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed
|
self
|
||||||
.oh_info_event_sub_url
|
.oh_info_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
parsed.oh_time_service_type.clone(),
|
self.oh_time_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_time_control_url
|
.oh_time_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed
|
self
|
||||||
.oh_time_event_sub_url
|
.oh_time_event_sub_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
parsed.oh_volume_service_type.clone(),
|
self.oh_volume_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_volume_control_url
|
.oh_volume_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed.oh_radio_service_type.clone(),
|
self.oh_radio_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_radio_control_url
|
.oh_radio_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
parsed.oh_product_service_type.clone(),
|
self.oh_product_service_type.clone(),
|
||||||
parsed
|
self
|
||||||
.oh_product_control_url
|
.oh_product_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_server(
|
pub fn build_server(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
|
||||||
parsed: &ParsedDeviceDescription,
|
|
||||||
) -> Option<UpnpMediaServer> {
|
) -> Option<UpnpMediaServer> {
|
||||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw_udn = parsed
|
let udn = self.udn.to_ascii_lowercase();
|
||||||
.udn
|
let has_content_directory = self.service_types.iter().any(|st| {
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
|
||||||
let udn = raw_udn.to_ascii_lowercase();
|
|
||||||
let has_content_directory = parsed.service_types.iter().any(|st| {
|
|
||||||
st.to_ascii_lowercase()
|
st.to_ascii_lowercase()
|
||||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||||
});
|
});
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
let content_directory_control_url = parsed
|
let content_directory_control_url = self
|
||||||
.content_directory_control_url
|
.content_directory_control_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl));
|
.map(|ctrl| resolve_control_url(&self.location, ctrl));
|
||||||
|
|
||||||
Some(UpnpMediaServer {
|
Some(UpnpMediaServer::new(
|
||||||
id: DeviceId(udn.clone()),
|
DeviceId(udn.clone()),
|
||||||
udn,
|
udn,
|
||||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
self.friendly_name.clone().unwrap_or_default(),
|
||||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
self.model_name.clone().unwrap_or_default(),
|
||||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
self.manufacturer.clone().unwrap_or_default(),
|
||||||
location: endpoint.location.clone(),
|
self.location.clone(),
|
||||||
server_header: endpoint.server_header.clone(),
|
self.server_header.clone(),
|
||||||
online: true,
|
|
||||||
last_seen: now,
|
|
||||||
max_age: endpoint.max_age,
|
|
||||||
has_content_directory,
|
has_content_directory,
|
||||||
content_directory_service_type: parsed.content_directory_service_type.clone(),
|
self.content_directory_service_type.clone(),
|
||||||
content_directory_control_url,
|
content_directory_control_url,
|
||||||
})
|
))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New helper: build an AvTransportClient directly from a discovered endpoint.
|
|
||||||
///
|
|
||||||
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
||||||
/// Ok(None) if no AVTransport service was found.
|
/// Ok(None) if no AVTransport service was found.
|
||||||
pub fn build_avtransport_client(
|
pub fn build_avtransport_client(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &DiscoveredEndpoint,
|
|
||||||
) -> Result<Option<AvTransportClient>, DescriptionError> {
|
) -> Result<Option<AvTransportClient>, DescriptionError> {
|
||||||
let parsed = self.fetch_and_parse(endpoint)?;
|
let service_type = match &self.avtransport_service_type {
|
||||||
|
|
||||||
let service_type = match &parsed.avtransport_service_type {
|
|
||||||
Some(st) => st.clone(),
|
Some(st) => st.clone(),
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw_control = match &parsed.avtransport_control_url {
|
let raw_control = match &self.avtransport_control_url {
|
||||||
Some(ctrl) => ctrl.clone(),
|
Some(ctrl) => ctrl.clone(),
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let control_url = resolve_control_url(&endpoint.location, &raw_control);
|
let control_url = resolve_control_url(&self.location, &raw_control);
|
||||||
debug!(
|
debug!(
|
||||||
"AVTransport client for {}: service_type={} control_url={}",
|
"AVTransport client for {}: service_type={} control_url={}",
|
||||||
endpoint.udn, service_type, control_url
|
&self.udn, service_type, control_url
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Some(AvTransportClient::new(control_url, service_type)))
|
Ok(Some(AvTransportClient::new(control_url, service_type)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_fields(self) -> Result<Self, DescriptionError> {
|
||||||
|
if self.device_type.is_none() {
|
||||||
|
return Err(DescriptionError::MissingField("deviceType"));
|
||||||
|
}
|
||||||
|
if self.friendly_name.is_none() {
|
||||||
|
return Err(DescriptionError::MissingField("friendlyName"));
|
||||||
|
}
|
||||||
|
if self.model_name.is_none() {
|
||||||
|
return Err(DescriptionError::MissingField("modelName"));
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn udn(&self) -> Option<String> {
|
||||||
|
Some(self.udn.clone())
|
||||||
|
}
|
||||||
|
pub fn device_type(&self) -> Option<String> {
|
||||||
|
self.device_type.clone()
|
||||||
|
}
|
||||||
|
pub fn friendly_name(&self) -> Option<String> {
|
||||||
|
self.friendly_name.clone()
|
||||||
|
}
|
||||||
|
pub fn manufacturer(&self) -> Option<String> {
|
||||||
|
self.manufacturer.clone()
|
||||||
|
}
|
||||||
|
pub fn model_name(&self) -> Option<String> {
|
||||||
|
self.model_name.clone()
|
||||||
|
}
|
||||||
|
pub fn service_types(&self) -> Vec<String> {
|
||||||
|
self.service_types.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New: AVTransport endpoint (if present in serviceList)
|
||||||
|
pub fn avtransport_service_type(&self) -> Option<String> {
|
||||||
|
self.avtransport_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn avtransport_control_url(&self) -> Option<String> {
|
||||||
|
self.avtransport_control_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderingControl endpoint (if present in serviceList)
|
||||||
|
pub fn rendering_control_service_type(&self) -> Option<String> {
|
||||||
|
self.rendering_control_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn rendering_control_control_url(&self) -> Option<String> {
|
||||||
|
self.rendering_control_control_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionManager endpoint (if present in serviceList)
|
||||||
|
pub fn connection_manager_service_type(&self) -> Option<String> {
|
||||||
|
self.connection_manager_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn connection_manager_control_url(&self) -> Option<String> {
|
||||||
|
self.connection_manager_control_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentDirectory endpoint (if present in serviceList)
|
||||||
|
pub fn content_directory_service_type(&self) -> Option<String> {
|
||||||
|
self.content_directory_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn content_directory_control_url(&self) -> Option<String> {
|
||||||
|
self.content_directory_control_url.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenHome endpoints (if present in serviceList)
|
||||||
|
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_playlist_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_info_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_info_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_info_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_time_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_time_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
||||||
|
self.oh_time_event_sub_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_volume_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_volume_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_volume_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_volume_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_radio_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_radio_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_radio_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_radio_control_url.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_product_service_type(&self) -> Option<String> {
|
||||||
|
self.oh_product_service_type.clone()
|
||||||
|
}
|
||||||
|
pub fn oh_product_control_url(&self) -> Option<String> {
|
||||||
|
self.oh_product_control_url.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-based XML description provider (UPnP device description.xml)
|
||||||
|
pub struct HttpXmlDescriptionProvider {
|
||||||
|
timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- capabilities detection unchanged ---
|
// --- capabilities detection unchanged ---
|
||||||
@@ -601,91 +678,23 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a possibly relative controlURL against the description URL.
|
|
||||||
///
|
|
||||||
/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is.
|
|
||||||
/// - Otherwise, it is resolved against the scheme://host:port of `description_url`.
|
|
||||||
pub(crate) fn resolve_control_url(description_url: &str, control_url: &str) -> String {
|
|
||||||
if control_url.starts_with("http://") || control_url.starts_with("https://") {
|
|
||||||
return control_url.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract "scheme://host[:port]" from description_url
|
|
||||||
if let Some((scheme, rest)) = description_url.split_once("://") {
|
|
||||||
if let Some(pos) = rest.find('/') {
|
|
||||||
let authority = &rest[..pos];
|
|
||||||
let base = format!("{}://{}", scheme, authority);
|
|
||||||
|
|
||||||
if control_url.starts_with('/') {
|
|
||||||
return format!("{}{}", base, control_url);
|
|
||||||
} else {
|
|
||||||
return format!("{}/{}", base, control_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: just return the raw control_url if we cannot parse
|
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||||
control_url.to_string()
|
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||||
}
|
let Some(host) = extract_linkplay_host(location) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
match fetch_status_for_host(&host, timeout) {
|
||||||
mod tests {
|
Ok(_) => true,
|
||||||
use super::resolve_control_url;
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
#[test]
|
"LinkPlay detection failed for {} (host={}): {}",
|
||||||
fn resolves_relative_path_against_description() {
|
location, host, err
|
||||||
let base = "http://192.0.2.10:49152/device.xml";
|
);
|
||||||
let control = "/upnp/control/playlist";
|
false
|
||||||
let resolved = resolve_control_url(base, control);
|
|
||||||
assert_eq!(resolved, "http://192.0.2.10:49152/upnp/control/playlist");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leaves_absolute_url_untouched() {
|
|
||||||
let url = "http://renderer.local:1400/MediaRenderer/Control";
|
|
||||||
let resolved = resolve_control_url("http://example.invalid/device.xml", url);
|
|
||||||
assert_eq!(resolved, url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
|
||||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo> {
|
|
||||||
match self.fetch_and_parse(endpoint) {
|
|
||||||
Ok(parsed) => {
|
|
||||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
|
||||||
debug!(
|
|
||||||
"Renderer description OK for {} at {} (deviceType={})",
|
|
||||||
endpoint.udn, endpoint.location, device_type
|
|
||||||
);
|
|
||||||
self.build_renderer(endpoint, &parsed)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to fetch/parse renderer description for {} at {}: {}",
|
|
||||||
endpoint.udn, endpoint.location, err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<UpnpMediaServer> {
|
|
||||||
match self.fetch_and_parse(endpoint) {
|
|
||||||
Ok(parsed) => {
|
|
||||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
|
||||||
debug!(
|
|
||||||
"Server description OK for {} at {} (deviceType={})",
|
|
||||||
endpoint.udn, endpoint.location, device_type
|
|
||||||
);
|
|
||||||
self.build_server(endpoint, &parsed)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to fetch/parse server description for {} at {}: {}",
|
|
||||||
endpoint.udn, endpoint.location, err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,8 @@ pub enum ControlPointError {
|
|||||||
MediaServerError(String),
|
MediaServerError(String),
|
||||||
#[error("Queue Error: {0}")]
|
#[error("Queue Error: {0}")]
|
||||||
QueueError(String),
|
QueueError(String),
|
||||||
|
#[error("Invalid time format: {0}")]
|
||||||
|
InvalidTimeFormat(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlPointError {
|
impl ControlPointError {
|
||||||
|
|||||||
@@ -3,28 +3,20 @@ mod media_server_events;
|
|||||||
|
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod arylic_tcp;
|
pub mod upnp_clients;
|
||||||
pub mod avtransport_client;
|
pub mod arylic_client;
|
||||||
pub mod capabilities;
|
pub mod linkplay_client;
|
||||||
pub mod chromecast_renderer;
|
|
||||||
pub mod connection_manager_client;
|
|
||||||
pub mod control_point;
|
pub mod control_point;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod linkplay_renderer;
|
|
||||||
pub mod media_server;
|
pub mod media_server;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod music_renderer;
|
pub mod music_renderer;
|
||||||
pub mod openhome;
|
|
||||||
pub mod openhome_client;
|
|
||||||
pub mod openhome_playlist;
|
|
||||||
pub mod openhome_renderer;
|
|
||||||
pub mod provider;
|
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod rendering_control_client;
|
|
||||||
pub mod soap_client;
|
pub mod soap_client;
|
||||||
pub mod upnp_renderer;
|
|
||||||
pub mod online;
|
pub mod online;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
pub mod linkplay_utils;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// pmoserver extension (optional)
|
// pmoserver extension (optional)
|
||||||
@@ -40,30 +32,16 @@ use std::time::Duration;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use pmoserver_ext::ControlPointExt;
|
pub use pmoserver_ext::ControlPointExt;
|
||||||
|
|
||||||
pub use arylic_tcp::ArylicTcpRenderer;
|
|
||||||
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
|
||||||
pub use capabilities::{
|
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
|
||||||
VolumeControl,
|
|
||||||
};
|
|
||||||
pub use chromecast_renderer::ChromecastRenderer;
|
|
||||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
|
||||||
pub use control_point::{ControlPoint, PlaylistBinding};
|
pub use control_point::{ControlPoint, PlaylistBinding};
|
||||||
pub use linkplay_renderer::LinkPlayRenderer;
|
|
||||||
pub use media_server::{
|
pub use media_server::{
|
||||||
MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer,
|
MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer,
|
||||||
};
|
};
|
||||||
pub use music_renderer::MusicRendererBackend;
|
|
||||||
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
|
||||||
pub use openhome_renderer::OpenHomeRenderer;
|
|
||||||
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||||
pub use rendering_control_client::RenderingControlClient;
|
|
||||||
pub use upnp_renderer::UpnpRenderer;
|
|
||||||
|
|
||||||
pub use model::{
|
pub use model::{
|
||||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol,
|
MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol,
|
||||||
};
|
};
|
||||||
pub use provider::HttpXmlDescriptionProvider;
|
|
||||||
pub use registry::{DeviceRegistry, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceUpdate};
|
||||||
|
|
||||||
pub use soap_client::invoke_upnp_action;
|
pub use soap_client::invoke_upnp_action;
|
||||||
|
|||||||
156
pmocontrol/src/linkplay_client/mod.rs
Normal file
156
pmocontrol/src/linkplay_client/mod.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::debug;
|
||||||
|
use ureq::Agent;
|
||||||
|
|
||||||
|
use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}};
|
||||||
|
|
||||||
|
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||||
|
|
||||||
|
/// Raw response from LinkPlay getPlayerStatus API
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LinkPlayStatusRaw {
|
||||||
|
status: String,
|
||||||
|
curpos: String,
|
||||||
|
totlen: String,
|
||||||
|
vol: String,
|
||||||
|
mute: String,
|
||||||
|
#[serde(default)]
|
||||||
|
plicurr: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LinkPlayStatus {
|
||||||
|
state_raw: String,
|
||||||
|
pub curpos_ms: u64,
|
||||||
|
pub totlen_ms: u64,
|
||||||
|
pub track_index: Option<u32>,
|
||||||
|
pub volume: u16,
|
||||||
|
pub mute: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkPlayStatus {
|
||||||
|
pub 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position_info(&self) -> PlaybackPositionInfo {
|
||||||
|
PlaybackPositionInfo {
|
||||||
|
track: self.track_index,
|
||||||
|
rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))),
|
||||||
|
abs_time: None,
|
||||||
|
track_duration: if self.totlen_ms > 0 {
|
||||||
|
Some(format_hhmmss(ms_to_seconds(self.totlen_ms)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
track_metadata: None,
|
||||||
|
track_uri: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_agent(timeout: Duration) -> Agent {
|
||||||
|
Agent::config_builder()
|
||||||
|
.timeout_global(Some(timeout))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
|
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||||
|
let mut response = build_agent(timeout)
|
||||||
|
.get(&url)
|
||||||
|
.call()
|
||||||
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?;
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?;
|
||||||
|
|
||||||
|
parse_linkplay_status(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
|
let raw: LinkPlayStatusRaw = serde_json::from_str(body)
|
||||||
|
.map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?;
|
||||||
|
|
||||||
|
let curpos_ms = raw.curpos.parse::<u64>()
|
||||||
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?;
|
||||||
|
|
||||||
|
let totlen_ms = raw.totlen.parse::<u64>()
|
||||||
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?;
|
||||||
|
|
||||||
|
let volume = raw.vol.parse::<u16>()
|
||||||
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))?
|
||||||
|
.min(100);
|
||||||
|
|
||||||
|
let mute = match raw.mute.as_str() {
|
||||||
|
"1" => true,
|
||||||
|
"0" => false,
|
||||||
|
other => {
|
||||||
|
return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_index = raw.plicurr
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.filter(|idx| *idx > 0);
|
||||||
|
|
||||||
|
Ok(LinkPlayStatus {
|
||||||
|
state_raw: raw.status,
|
||||||
|
curpos_ms,
|
||||||
|
totlen_ms,
|
||||||
|
track_index,
|
||||||
|
volume,
|
||||||
|
mute,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
use std::char;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
|
||||||
use tracing::debug;
|
|
||||||
use ureq::Agent;
|
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
|
||||||
use crate::capabilities::{
|
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
|
||||||
VolumeControl,
|
|
||||||
};
|
|
||||||
use crate::errors::ControlPointError;
|
|
||||||
use crate::model::{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 {
|
|
||||||
host: String,
|
|
||||||
timeout: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for LinkPlayRenderer {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("LinkPlayRenderer")
|
|
||||||
.field("host", &self.host)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinkPlayRenderer {
|
|
||||||
/// Build a LinkPlay backend from a registry snapshot.
|
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self, ControlPointError> {
|
|
||||||
let host = extract_linkplay_host(&info.location())
|
|
||||||
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
host,
|
|
||||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent(&self) -> Agent {
|
|
||||||
build_agent(self.timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> {
|
|
||||||
let url = format!(
|
|
||||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
|
||||||
self.host, command
|
|
||||||
);
|
|
||||||
self.agent()
|
|
||||||
.get(&url)
|
|
||||||
.call()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_status(&self) -> Result<LinkPlayStatus, ControlPointError> {
|
|
||||||
fetch_status_for_host(&self.host, self.timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransportControl for LinkPlayRenderer {
|
|
||||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
|
||||||
let encoded = percent_encode(uri);
|
|
||||||
self.send_player_command(&format!("play:{}", encoded))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play(&self) -> Result<(), ControlPointError> {
|
|
||||||
self.send_player_command("resume")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pause(&self) -> Result<(), ControlPointError> {
|
|
||||||
self.send_player_command("pause")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) -> Result<(), ControlPointError> {
|
|
||||||
self.send_player_command("stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
||||||
let secs = parse_hhmmss_to_secs(hhmmss)
|
|
||||||
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Invalid seek position format: {}", hhmmss)))?;
|
|
||||||
self.send_player_command(&format!("seek:{}", secs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VolumeControl for LinkPlayRenderer {
|
|
||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
||||||
Ok(self.fetch_status()?.volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
|
||||||
let value = v.min(100);
|
|
||||||
self.send_player_command(&format!("vol:{}", value))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
||||||
Ok(self.fetch_status()?.mute)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
||||||
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackStatus for LinkPlayRenderer {
|
|
||||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
||||||
Ok(self.fetch_status()?.playback_state())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackPosition for LinkPlayRenderer {
|
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
||||||
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, ControlPointError> {
|
|
||||||
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
|
||||||
let mut response = build_agent(timeout)
|
|
||||||
.get(&url)
|
|
||||||
.call()
|
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?;
|
|
||||||
|
|
||||||
let body = response
|
|
||||||
.body_mut()
|
|
||||||
.read_to_string()
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?;
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
track_metadata: None,
|
|
||||||
track_uri: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
|
||||||
let mut map = parse_flat_json(body)?;
|
|
||||||
|
|
||||||
let state_raw = map
|
|
||||||
.remove("status")
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("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(ControlPointError::ArilycTcpError(format!("Invalid LinkPlay mute value: {}", other)));
|
|
||||||
}
|
|
||||||
None => return Err(ControlPointError::arilyc_tcp_error("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, ControlPointError> {
|
|
||||||
let raw = map
|
|
||||||
.get(key)
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?;
|
|
||||||
raw.parse::<u64>()
|
|
||||||
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16, ControlPointError> {
|
|
||||||
let raw = map
|
|
||||||
.get(key)
|
|
||||||
.ok_or_else(|| ControlPointError::ArilycTcpError(format!("LinkPlay status missing `{}` field", key)))?;
|
|
||||||
let value = raw
|
|
||||||
.parse::<u16>()
|
|
||||||
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid `{}` value: {}", key, raw)))?;
|
|
||||||
Ok(value.min(100))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>, ControlPointError> {
|
|
||||||
let mut chars = input.chars().peekable();
|
|
||||||
skip_ws(&mut chars);
|
|
||||||
if chars.next() != Some('{') {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!("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(ControlPointError::ArilycTcpError(format!("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(ControlPointError::ArilycTcpError(format!(
|
|
||||||
"Unexpected character '{}' while parsing JSON",
|
|
||||||
other
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
None => return Err(ControlPointError::ArilycTcpError(format!("Unexpected end of JSON while parsing fields"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
|
||||||
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(ControlPointError::ArilycTcpError(format!("Unsupported JSON value in LinkPlay status"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
|
||||||
if chars.next() != Some('"') {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!("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(|| ControlPointError::ArilycTcpError(format!("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(|| ControlPointError::ArilycTcpError(format!("Invalid \\u escape")))?;
|
|
||||||
hex.push(h);
|
|
||||||
}
|
|
||||||
let code = u16::from_str_radix(&hex, 16)
|
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("Invalid unicode escape: {}", hex)))?;
|
|
||||||
if let Some(c) = char::from_u32(code as u32) {
|
|
||||||
out.push(c);
|
|
||||||
} else {
|
|
||||||
return Err(ControlPointError::ArilycTcpError(format!("Invalid unicode code point: {}", code)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => return Err(ControlPointError::ArilycTcpError(format!("Unsupported escape: {}", other))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => out.push(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!("Unterminated JSON string")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String, ControlPointError> {
|
|
||||||
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(ControlPointError::ArilycTcpError(format!("Invalid number")));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expect_literal(
|
|
||||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
|
||||||
literal: &str,
|
|
||||||
) -> Result<(), ControlPointError> {
|
|
||||||
for expected in literal.chars() {
|
|
||||||
match chars.next() {
|
|
||||||
Some(ch) if ch == expected => {}
|
|
||||||
_ => return Err(ControlPointError::ArilycTcpError(format!("Invalid literal while parsing JSON"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<(), ControlPointError> {
|
|
||||||
match chars.next() {
|
|
||||||
Some(ch) if ch == expected => Ok(()),
|
|
||||||
_ => Err(ControlPointError::ArilycTcpError(format!("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)
|
|
||||||
}
|
|
||||||
0
pmocontrol/src/linkplay_utils.rs
Normal file
0
pmocontrol/src/linkplay_utils.rs
Normal file
@@ -10,9 +10,9 @@ use xmltree::{Element, XMLNode};
|
|||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||||
use crate::queue::backend::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||||
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity, RendererInfo};
|
||||||
|
|
||||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -31,6 +31,33 @@ pub struct UpnpMediaServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpMediaServer {
|
impl UpnpMediaServer {
|
||||||
|
pub fn new(
|
||||||
|
id: DeviceId,
|
||||||
|
udn: String,
|
||||||
|
friendly_name: String,
|
||||||
|
model_name: String,
|
||||||
|
manufacturer: String,
|
||||||
|
location: String,
|
||||||
|
server_header: String,
|
||||||
|
has_content_directory: bool,
|
||||||
|
content_directory_service_type: Option<String>,
|
||||||
|
content_directory_control_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
udn,
|
||||||
|
friendly_name,
|
||||||
|
model_name,
|
||||||
|
manufacturer,
|
||||||
|
location,
|
||||||
|
server_header,
|
||||||
|
has_content_directory,
|
||||||
|
content_directory_service_type,
|
||||||
|
content_directory_control_url,
|
||||||
|
connection: DeviceConnectionState::make(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn make(
|
pub fn make(
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
udn: String,
|
udn: String,
|
||||||
@@ -43,7 +70,7 @@ impl UpnpMediaServer {
|
|||||||
content_directory_service_type: Option<String>,
|
content_directory_service_type: Option<String>,
|
||||||
content_directory_control_url: Option<String>,
|
content_directory_control_url: Option<String>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self::new(
|
||||||
id,
|
id,
|
||||||
udn,
|
udn,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
@@ -54,8 +81,7 @@ impl UpnpMediaServer {
|
|||||||
has_content_directory,
|
has_content_directory,
|
||||||
content_directory_service_type,
|
content_directory_service_type,
|
||||||
content_directory_control_url,
|
content_directory_control_url,
|
||||||
connection: DeviceConnectionState::make(),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_with_flag(
|
fn browse_with_flag(
|
||||||
@@ -357,6 +383,12 @@ pub enum MusicServer {
|
|||||||
Upnp(UpnpMediaServer),
|
Upnp(UpnpMediaServer),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MusicServer {
|
||||||
|
pub fn from_server_info(info: &UpnpMediaServer) -> Result<MusicServer, ControlPointError> {
|
||||||
|
Ok(MusicServer::Upnp(info.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DeviceOnline for MusicServer {
|
impl DeviceOnline for MusicServer {
|
||||||
fn is_online(&self) -> bool {
|
fn is_online(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ use tracing::{debug, info, warn};
|
|||||||
use ureq::{Agent, http};
|
use ureq::{Agent, http};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
|
use crate::DeviceId;
|
||||||
use crate::events::MediaServerEventBus;
|
use crate::events::MediaServerEventBus;
|
||||||
use crate::media_server::{UpnpMediaServer, ServerId};
|
use crate::media_server::{UpnpMediaServer};
|
||||||
use crate::model::MediaServerEvent;
|
use crate::model::MediaServerEvent;
|
||||||
use crate::provider::resolve_control_url;
|
use crate::upnp_clients::resolve_control_url;
|
||||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead};
|
use crate::registry::DeviceRegistry;
|
||||||
|
use crate::{DeviceOnline,DeviceIdentity};
|
||||||
|
|
||||||
const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
|
const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
|
||||||
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
|
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
|
||||||
@@ -179,8 +181,8 @@ struct MediaServerEventWorker {
|
|||||||
http_timeout: Duration,
|
http_timeout: Duration,
|
||||||
notify_rx: Receiver<IncomingNotify>,
|
notify_rx: Receiver<IncomingNotify>,
|
||||||
listener_port: u16,
|
listener_port: u16,
|
||||||
subscriptions: HashMap<ServerId, SubscriptionState>,
|
subscriptions: HashMap<DeviceId, SubscriptionState>,
|
||||||
path_index: HashMap<String, ServerId>,
|
path_index: HashMap<String, DeviceId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaServerEventWorker {
|
impl MediaServerEventWorker {
|
||||||
@@ -223,10 +225,10 @@ impl MediaServerEventWorker {
|
|||||||
reg.list_servers()
|
reg.list_servers()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut active: HashSet<ServerId> = HashSet::new();
|
let mut active: HashSet<DeviceId> = HashSet::new();
|
||||||
|
|
||||||
for info in server_infos {
|
for info in server_infos {
|
||||||
if !info.online || !info.has_content_directory {
|
if !info.is_online() || !info.has_content_directory {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use crate::{DeviceId,DeviceIdentity};
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
|
||||||
use crate::control_point::PlaylistBinding;
|
use crate::control_point::PlaylistBinding;
|
||||||
use crate::media_server::UpnpMediaServer;
|
use crate::media_server::UpnpMediaServer;
|
||||||
|
use crate::music_renderer::PlaybackPositionInfo;
|
||||||
|
use crate::{DeviceId, DeviceIdentity};
|
||||||
|
|
||||||
|
/// High-level playback state across backends.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum PlaybackState {
|
||||||
|
Stopped,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Transitioning,
|
||||||
|
NoMedia,
|
||||||
|
/// Backend-specific or unknown state string.
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackState {
|
||||||
|
/// Map a raw UPnP AVTransport CurrentTransportState string
|
||||||
|
/// to a logical PlaybackState.
|
||||||
|
pub fn from_upnp_state(raw: &str) -> Self {
|
||||||
|
let s = raw.trim().to_ascii_uppercase();
|
||||||
|
match s.as_str() {
|
||||||
|
"STOPPED" => PlaybackState::Stopped,
|
||||||
|
"PLAYING" => PlaybackState::Playing,
|
||||||
|
"PAUSED_PLAYBACK" => PlaybackState::Paused,
|
||||||
|
// States from the AVTransport spec that we normalize:
|
||||||
|
"PAUSED_RECORDING" => PlaybackState::Paused,
|
||||||
|
"RECORDING" => PlaybackState::Playing,
|
||||||
|
// Common vendor-specific states:
|
||||||
|
"TRANSITIONING" => PlaybackState::Transitioning,
|
||||||
|
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
|
||||||
|
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
|
||||||
|
_ => PlaybackState::Unknown(raw.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a human-readable label for the playback state.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
PlaybackState::Stopped => "STOPPED",
|
||||||
|
PlaybackState::Playing => "PLAYING",
|
||||||
|
PlaybackState::Paused => "PAUSED",
|
||||||
|
PlaybackState::Transitioning => "TRANSITIONING",
|
||||||
|
PlaybackState::NoMedia => "NO_MEDIA",
|
||||||
|
PlaybackState::Unknown(s) => s.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TrackMetadata {
|
pub struct TrackMetadata {
|
||||||
@@ -233,71 +275,70 @@ impl RendererInfo {
|
|||||||
&self.capabilities
|
&self.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn avtransport_service_type(&self) -> Option<String> {
|
pub fn avtransport_service_type(&self) -> Option<String> {
|
||||||
self.avtransport_service_type.clone()
|
self.avtransport_service_type.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn avtransport_control_url(&self) -> Option<String> {
|
pub fn avtransport_control_url(&self) -> Option<String> {
|
||||||
self.avtransport_control_url.clone()
|
self.avtransport_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn rendering_control_service_type(&self) -> Option<String> {
|
pub fn rendering_control_service_type(&self) -> Option<String> {
|
||||||
self.rendering_control_service_type.clone()
|
self.rendering_control_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn rendering_control_control_url(&self) -> Option<String> {
|
pub fn rendering_control_control_url(&self) -> Option<String> {
|
||||||
self.rendering_control_control_url.clone()
|
self.rendering_control_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn connection_manager_service_type(&self) -> Option<String> {
|
pub fn connection_manager_service_type(&self) -> Option<String> {
|
||||||
self.connection_manager_service_type.clone()
|
self.connection_manager_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn connection_manager_control_url(&self) -> Option<String> {
|
pub fn connection_manager_control_url(&self) -> Option<String> {
|
||||||
self.connection_manager_control_url.clone()
|
self.connection_manager_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
||||||
self.oh_playlist_service_type.clone()
|
self.oh_playlist_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
||||||
self.oh_playlist_control_url.clone()
|
self.oh_playlist_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
||||||
self.oh_playlist_event_sub_url.clone()
|
self.oh_playlist_event_sub_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_info_service_type(&self) -> Option<String> {
|
pub fn oh_info_service_type(&self) -> Option<String> {
|
||||||
self.oh_info_service_type.clone()
|
self.oh_info_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_info_control_url(&self) -> Option<String> {
|
pub fn oh_info_control_url(&self) -> Option<String> {
|
||||||
self.oh_info_control_url.clone()
|
self.oh_info_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
||||||
self.oh_info_event_sub_url.clone()
|
self.oh_info_event_sub_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_time_service_type(&self) -> Option<String> {
|
pub fn oh_time_service_type(&self) -> Option<String> {
|
||||||
self.oh_time_service_type.clone()
|
self.oh_time_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_time_control_url(&self) -> Option<String> {
|
pub fn oh_time_control_url(&self) -> Option<String> {
|
||||||
self.oh_time_control_url.clone()
|
self.oh_time_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
||||||
self.oh_time_event_sub_url.clone()
|
self.oh_time_event_sub_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_volume_service_type(&self) -> Option<String> {
|
pub fn oh_volume_service_type(&self) -> Option<String> {
|
||||||
self.oh_volume_service_type.clone()
|
self.oh_volume_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_volume_control_url(&self) -> Option<String> {
|
pub fn oh_volume_control_url(&self) -> Option<String> {
|
||||||
self.oh_volume_control_url.clone()
|
self.oh_volume_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_radio_service_type(&self) -> Option<String> {
|
pub fn oh_radio_service_type(&self) -> Option<String> {
|
||||||
self.oh_radio_service_type.clone()
|
self.oh_radio_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_radio_control_url(&self) -> Option<String> {
|
pub fn oh_radio_control_url(&self) -> Option<String> {
|
||||||
self.oh_radio_control_url.clone()
|
self.oh_radio_control_url.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_product_service_type(&self) -> Option<String> {
|
pub fn oh_product_service_type(&self) -> Option<String> {
|
||||||
self.oh_product_service_type.clone()
|
self.oh_product_service_type.clone()
|
||||||
}
|
}
|
||||||
pub fn oh_product_control_url(&self) -> Option<String> {
|
pub fn oh_product_control_url(&self) -> Option<String> {
|
||||||
self.oh_product_control_url.clone()
|
self.oh_product_control_url.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceIdentity for RendererInfo {
|
impl DeviceIdentity for RendererInfo {
|
||||||
@@ -323,7 +364,9 @@ impl DeviceIdentity for RendererInfo {
|
|||||||
&self.server_header
|
&self.server_header
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_a_music_renderer(&self) -> bool {true }
|
fn is_a_music_renderer(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -1,961 +0,0 @@
|
|||||||
//! 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, Mutex, RwLock};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
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::errors::ControlPointError;
|
|
||||||
use crate::media_server::ServerId;
|
|
||||||
use crate::model::{
|
|
||||||
RendererConnectionState, ServiceId, RendererInfo, RendererProtocol, TrackMetadata,
|
|
||||||
};
|
|
||||||
use crate::openhome_client::parse_track_metadata_from_didl;
|
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
|
||||||
use crate::queue_backend::{PlaybackItem, QueueSnapshot};
|
|
||||||
use crate::{
|
|
||||||
ArylicTcpRenderer, ChromecastRenderer, DeviceIdentity, DeviceOnline, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl
|
|
||||||
};
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum MusicRendererBackend {
|
|
||||||
/// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MusicRenderer {
|
|
||||||
info: RendererInfo,
|
|
||||||
connection: Arc<Mutex<RendererConnectionState>>,
|
|
||||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MusicRenderer {
|
|
||||||
pub fn new(
|
|
||||||
info: RendererInfo,
|
|
||||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
|
||||||
) -> Arc<MusicRenderer> {
|
|
||||||
let connection = RendererConnectionState::new();
|
|
||||||
|
|
||||||
let renderer = MusicRenderer {
|
|
||||||
info,
|
|
||||||
connection,
|
|
||||||
backend,
|
|
||||||
queue,
|
|
||||||
};
|
|
||||||
|
|
||||||
Arc::new(renderer)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Arc<MusicRenderer>,ControlPointError> {
|
|
||||||
let connection = RendererConnectionState::new();
|
|
||||||
let backend = MusicRendererBackend::from_renderer_info(info)?;
|
|
||||||
let queue = Mutex::new(MusicQueue::new());
|
|
||||||
|
|
||||||
let renderer = Arc::new(MusicRenderer { info, connection, backend, queue });
|
|
||||||
Ok(renderer)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(&self) -> &RendererInfo {
|
|
||||||
&self.info
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the protocol.
|
|
||||||
fn protocol(&self) -> RendererProtocol {
|
|
||||||
self.info.protocol()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_upnp(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::Upnp(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_openhome(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::OpenHome(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_linkplay(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::LinkPlay(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_arylictcp(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::ArylicTcp(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_chromecast(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::Chromecast(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_hybridupnparylic(&self) -> bool {
|
|
||||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
|
||||||
pub fn supports_set_next(&self) -> bool {
|
|
||||||
self.info.capabilities().supports_set_next()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceIdentity for MusicRenderer {
|
|
||||||
fn id(&self) -> ServiceId {
|
|
||||||
self.info.id()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn udn(&self) -> &str {
|
|
||||||
&*self.info.udn()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn friendly_name(&self) -> &str {
|
|
||||||
&self.info.friendly_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_name(&self) -> &str {
|
|
||||||
&self.info.model_name()
|
|
||||||
}
|
|
||||||
fn manufacturer(&self) -> &str {
|
|
||||||
&self.info.manufacturer()
|
|
||||||
}
|
|
||||||
fn location(&self) -> &str {
|
|
||||||
&self.info.location()
|
|
||||||
}
|
|
||||||
fn server_header(&self) -> &str {
|
|
||||||
&self.info.server_header()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeviceOnline for MusicRenderer {
|
|
||||||
fn is_online(&self) -> bool {
|
|
||||||
self.connection
|
|
||||||
.lock()
|
|
||||||
.expect("Connection mutex poisoned")
|
|
||||||
.is_online()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn last_seen(&self) -> SystemTime {
|
|
||||||
self.connection
|
|
||||||
.lock()
|
|
||||||
.expect("Connection mutex poisoned")
|
|
||||||
.last_seen()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_been_seen_now(&self, max_age: u32) {
|
|
||||||
self.connection
|
|
||||||
.lock()
|
|
||||||
.expect("Connection mutex poisoned")
|
|
||||||
.has_been_seen_now(max_age)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_as_offline(&self) {
|
|
||||||
self.connection
|
|
||||||
.lock()
|
|
||||||
.expect("Connection mutex poisoned")
|
|
||||||
.mark_as_offline()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_age(&self) -> u32 {
|
|
||||||
self.connection
|
|
||||||
.lock()
|
|
||||||
.expect("Connection mutex poisoned")
|
|
||||||
.max_age()
|
|
||||||
}}
|
|
||||||
// #[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 MusicRendererBackend {
|
|
||||||
// fn as_backend(&self) -> &dyn MusicRendererBackend {
|
|
||||||
// match self {
|
|
||||||
// MusicRendererBackend::OpenHome(r) => r,
|
|
||||||
// MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic,
|
|
||||||
// MusicRendererBackend::Upnp(r) => r,
|
|
||||||
// MusicRendererBackend::LinkPlay(r) => r,
|
|
||||||
// MusicRendererBackend::ArylicTcp(r) => r,
|
|
||||||
// MusicRendererBackend::Chromecast(r) => r,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
/// 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_renderer_info(
|
|
||||||
info: RendererInfo,
|
|
||||||
) -> Result<Arc<Mutex<Self>>, ControlPointError> {
|
|
||||||
|
|
||||||
match info.protocol() {
|
|
||||||
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
|
||||||
OpenHomeRenderer::from_renderer_info(info.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Err(ControlPointError::MusicRendererBackendBuild(format!("{:#?}", info.id()))),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Le backend est de type openhome
|
|
||||||
|
|
||||||
if matches!(
|
|
||||||
info.protocol(),
|
|
||||||
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid
|
|
||||||
) {
|
|
||||||
if let Some(renderer) = {
|
|
||||||
let renderer = OpenHomeRenderer::new(info.clone());
|
|
||||||
renderer.has_any_openhome_service().then_some(renderer)
|
|
||||||
} {
|
|
||||||
return Some(MusicRendererBackend::OpenHome(renderer));
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
|
||||||
warn!(
|
|
||||||
renderer = info.friendly_name(),
|
|
||||||
"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(MusicRendererBackend::Chromecast(renderer));
|
|
||||||
}
|
|
||||||
warn!(
|
|
||||||
renderer = info.friendly_name(),
|
|
||||||
"Failed to build Chromecast renderer"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match info.protocol() {
|
|
||||||
RendererProtocol::UpnpAvOnly | RendererProtocol::OpenHomeHybrid => {
|
|
||||||
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_info(&info);
|
|
||||||
|
|
||||||
// Construire ArylicTcpRenderer
|
|
||||||
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
|
|
||||||
Ok(arylic) => {
|
|
||||||
return Some(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
|
|
||||||
info.friendly_name(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
return Some(MusicRendererBackend::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(MusicRendererBackend::LinkPlay(lp));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(MusicRendererBackend::Upnp(UpnpRenderer::from_info(
|
|
||||||
&info,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
RendererProtocol::OpenHomeOnly => None,
|
|
||||||
RendererProtocol::ChromecastOnly => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::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 {
|
|
||||||
MusicRendererBackend::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 {
|
|
||||||
MusicRendererBackend::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 {
|
|
||||||
MusicRendererBackend::OpenHome(renderer) => renderer.clear_openhome_playlist(),
|
|
||||||
_ => Err(op_not_supported(
|
|
||||||
"openhome_playlist_clear",
|
|
||||||
self.unsupported_backend_name(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level method to prepare the renderer for attaching a new playlist.
|
|
||||||
///
|
|
||||||
/// This method handles backend-specific clearing logic:
|
|
||||||
/// - For OpenHome: clears the OpenHome playlist
|
|
||||||
/// - For AVTransport/Chromecast/etc.: stops the renderer (since they don't have a persistent queue)
|
|
||||||
///
|
|
||||||
/// This should be called by ControlPoint when attaching a new playlist, ensuring that:
|
|
||||||
/// - Any currently playing content is stopped
|
|
||||||
/// - The renderer is in a clean state ready to receive new content
|
|
||||||
pub fn clear_for_playlist_attach(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
// For OpenHome: clear the playlist (DeleteAll also stops playback automatically)
|
|
||||||
// then explicitly stop to ensure clean state
|
|
||||||
self.openhome_playlist_clear()?;
|
|
||||||
self.stop().or_else(|err| -> Result<()> {
|
|
||||||
// If stop fails (e.g., already stopped), that's fine
|
|
||||||
warn!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Stop failed after clearing OpenHome playlist (continuing anyway)"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
|
||||||
// For AVTransport and other single-track renderers: stop playback
|
|
||||||
// This ensures we're not in the middle of playing when we start the new playlist
|
|
||||||
self.stop().or_else(|err| {
|
|
||||||
// If stop fails (e.g., already stopped), that's fine - we just want to ensure it's not playing
|
|
||||||
warn!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Stop failed when preparing for playlist attach (continuing anyway)"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Synchronize the local queue state with the backend's actual state.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: fetches the playlist from the renderer and updates local cache
|
|
||||||
/// - For Internal queue/AVTransport: no-op (queue is already local)
|
|
||||||
pub fn sync_queue_state(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
|
||||||
// Fetch fresh playlist snapshot and update cache
|
|
||||||
provider.invalidate_openhome_cache(self.id())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
|
||||||
// No sync needed - queue is local only
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the queue on both the backend and in local state.
|
|
||||||
///
|
|
||||||
/// This ensures the backend renderer and local cache are consistent.
|
|
||||||
/// Should be called before queue mutations to ensure clean state.
|
|
||||||
pub fn clear_queue(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
// For OpenHome: clear the playlist on the renderer itself
|
|
||||||
self.openhome_playlist_clear()
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
|
||||||
// For other renderers: no persistent queue to clear
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a track to the backend's queue, returning backend-specific track ID if applicable.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: adds to OpenHome playlist and returns track ID
|
|
||||||
/// - For others: returns error (not supported for single-track renderers)
|
|
||||||
pub fn add_track_to_queue(
|
|
||||||
&self,
|
|
||||||
uri: &str,
|
|
||||||
metadata: &str,
|
|
||||||
after_id: Option<u32>,
|
|
||||||
play: bool,
|
|
||||||
) -> Result<Option<u32>> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
let track_id = self.openhome_playlist_add_track(uri, metadata, after_id, play)?;
|
|
||||||
Ok(Some(track_id))
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
|
||||||
"add_track_to_queue is not supported for {} backend",
|
|
||||||
self.unsupported_backend_name()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select and play a specific track from the backend's queue.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: uses track ID to select from OpenHome playlist
|
|
||||||
/// - For others: returns error (use play_uri instead)
|
|
||||||
pub fn select_queue_track(&self, track_id: u32) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => self.openhome_playlist_play_id(track_id),
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
|
||||||
"select_queue_track is not supported for {} backend",
|
|
||||||
self.unsupported_backend_name()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current queue state from the backend.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: fetches current playlist snapshot
|
|
||||||
/// - For others: returns None (no persistent queue on backend)
|
|
||||||
pub fn queue_snapshot(&self) -> Result<Option<QueueSnapshot>> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
let oh_snapshot = self.openhome_playlist_snapshot()?;
|
|
||||||
|
|
||||||
// Convert OpenHome tracks to PlaybackItems
|
|
||||||
let items: Vec<PlaybackItem> = oh_snapshot
|
|
||||||
.tracks
|
|
||||||
.iter()
|
|
||||||
.map(|track| Self::playback_item_from_openhome_track(self.id(), track))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let snapshot = QueueSnapshot {
|
|
||||||
items,
|
|
||||||
current_index: oh_snapshot.current_index,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(snapshot))
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => {
|
|
||||||
// No backend queue for these renderers
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play the current item from the backend queue.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: Uses the native playlist to play the current track
|
|
||||||
/// - For others: Returns error (no backend queue)
|
|
||||||
pub fn play_current_from_backend_queue(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
// Get the current OpenHome playlist snapshot
|
|
||||||
let snapshot = self.openhome_playlist_snapshot()?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
tracks_count = snapshot.tracks.len(),
|
|
||||||
current_id = ?snapshot.current_id,
|
|
||||||
current_index = ?snapshot.current_index,
|
|
||||||
"play_current_from_backend_queue: OpenHome snapshot fetched"
|
|
||||||
);
|
|
||||||
|
|
||||||
if snapshot.tracks.is_empty() {
|
|
||||||
return Err(anyhow!("OpenHome playlist is empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the track_id to play (prefer current_id, then current_index, then first)
|
|
||||||
let target_track_id = if let Some(current_id) = snapshot.current_id {
|
|
||||||
debug!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
track_id = current_id,
|
|
||||||
"Using current_id for playback"
|
|
||||||
);
|
|
||||||
Some(current_id)
|
|
||||||
} else if let Some(current_idx) = snapshot.current_index {
|
|
||||||
let track_id = snapshot.tracks.get(current_idx).map(|track| track.id);
|
|
||||||
debug!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
current_idx,
|
|
||||||
track_id = ?track_id,
|
|
||||||
"Using current_index for playback"
|
|
||||||
);
|
|
||||||
track_id
|
|
||||||
} else {
|
|
||||||
let track_id = snapshot.tracks.first().map(|track| track.id);
|
|
||||||
debug!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
track_id = ?track_id,
|
|
||||||
"Using first track for playback"
|
|
||||||
);
|
|
||||||
track_id
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(track_id) = target_track_id {
|
|
||||||
info!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
track_id, "Calling openhome_playlist_play_id to start playback"
|
|
||||||
);
|
|
||||||
self.openhome_playlist_play_id(track_id)?;
|
|
||||||
info!(
|
|
||||||
renderer = self.id().0.as_str(),
|
|
||||||
track_id, "Successfully called openhome_playlist_play_id"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("No track to play in OpenHome playlist"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
|
||||||
"play_current_from_backend_queue is not supported for {} backend (no persistent queue)",
|
|
||||||
self.unsupported_backend_name()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play the next item from the backend queue.
|
|
||||||
///
|
|
||||||
/// - For OpenHome: Advances to the next track in the playlist
|
|
||||||
/// - For others: Returns error (no backend queue)
|
|
||||||
pub fn play_next_from_backend_queue(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::OpenHome(_) => {
|
|
||||||
// Get the current OpenHome playlist snapshot
|
|
||||||
let snapshot = self.openhome_playlist_snapshot()?;
|
|
||||||
|
|
||||||
if snapshot.tracks.is_empty() {
|
|
||||||
return Err(anyhow!("OpenHome playlist is empty, cannot play next"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the next track_id
|
|
||||||
let next_track_id = match snapshot.current_index {
|
|
||||||
Some(idx) => {
|
|
||||||
// Take the next track if it exists, otherwise loop to first
|
|
||||||
snapshot
|
|
||||||
.tracks
|
|
||||||
.get(idx + 1)
|
|
||||||
.map(|track| track.id)
|
|
||||||
.or_else(|| snapshot.tracks.first().map(|track| track.id))
|
|
||||||
}
|
|
||||||
None => snapshot.tracks.first().map(|track| track.id),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(track_id) = next_track_id {
|
|
||||||
self.openhome_playlist_play_id(track_id)?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(anyhow!(
|
|
||||||
"No track available to advance to in OpenHome playlist"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Upnp(_)
|
|
||||||
| MusicRendererBackend::Chromecast(_)
|
|
||||||
| MusicRendererBackend::LinkPlay(_)
|
|
||||||
| MusicRendererBackend::ArylicTcp(_)
|
|
||||||
| MusicRendererBackend::HybridUpnpArylic { .. } => Err(anyhow!(
|
|
||||||
"play_next_from_backend_queue is not supported for {} backend (no persistent queue)",
|
|
||||||
self.unsupported_backend_name()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert an OpenHome playlist track to a PlaybackItem.
|
|
||||||
fn playback_item_from_openhome_track(
|
|
||||||
renderer_id: &ServiceId,
|
|
||||||
track: &OpenHomePlaylistTrack,
|
|
||||||
) -> PlaybackItem {
|
|
||||||
let metadata = TrackMetadata {
|
|
||||||
title: track.title.clone(),
|
|
||||||
artist: track.artist.clone(),
|
|
||||||
album: track.album.clone(),
|
|
||||||
genre: None,
|
|
||||||
album_art_uri: track.album_art_uri.clone(),
|
|
||||||
date: None,
|
|
||||||
track_number: None,
|
|
||||||
creator: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
PlaybackItem {
|
|
||||||
media_server_id: ServerId(format!("openhome:{}", renderer_id.0)),
|
|
||||||
didl_id: format!("openhome:{}", track.id),
|
|
||||||
uri: track.uri.clone(),
|
|
||||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
|
||||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
|
||||||
metadata: Some(metadata),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
MusicRendererBackend::Upnp(_) => "UPnP",
|
|
||||||
MusicRendererBackend::OpenHome(_) => "OpenHome",
|
|
||||||
MusicRendererBackend::LinkPlay(_) => "LinkPlay",
|
|
||||||
MusicRendererBackend::ArylicTcp(_) => "ArylicTcp",
|
|
||||||
MusicRendererBackend::Chromecast(_) => "Chromecast",
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_openhome_playlist_add_track(
|
|
||||||
&self,
|
|
||||||
uri: &str,
|
|
||||||
metadata: &str,
|
|
||||||
after_id: Option<u32>,
|
|
||||||
play: bool,
|
|
||||||
) -> Result<u32> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::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 {
|
|
||||||
MusicRendererBackend::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: &ServiceId,
|
|
||||||
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 MusicRendererBackend {
|
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
|
||||||
MusicRendererBackend::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
|
||||||
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pause(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
|
||||||
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
|
||||||
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
|
||||||
MusicRendererBackend::ArylicTcp(_) => {
|
|
||||||
Err(op_not_supported("seek_rel_time", "ArylicTcp"))
|
|
||||||
}
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
|
||||||
MusicRendererBackend::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 MusicRendererBackend {
|
|
||||||
fn volume(&self) -> Result<u16> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
|
||||||
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_volume(&self, vol: u16) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
|
||||||
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
|
||||||
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
|
||||||
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
|
||||||
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
|
||||||
MusicRendererBackend::OpenHome(r) => r.mute(),
|
|
||||||
MusicRendererBackend::Upnp(r) => r.get_master_mute(),
|
|
||||||
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
|
||||||
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
|
||||||
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
|
||||||
MusicRendererBackend::Upnp(r) => r.set_master_mute(m),
|
|
||||||
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
|
||||||
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
|
||||||
MusicRendererBackend::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 MusicRendererBackend {
|
|
||||||
fn playback_state(&self) -> Result<PlaybackState> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
|
||||||
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
|
||||||
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
|
||||||
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
|
||||||
MusicRendererBackend::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 MusicRendererBackend {
|
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
|
||||||
match self {
|
|
||||||
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
|
||||||
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
|
||||||
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
|
||||||
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
|
||||||
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
|
||||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
321
pmocontrol/src/music_renderer/arylic_tcp.rs
Normal file
321
pmocontrol/src/music_renderer/arylic_tcp.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::DeviceIdentity;
|
||||||
|
use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required};
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::linkplay_client::extract_linkplay_host;
|
||||||
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
|
VolumeControl,
|
||||||
|
};
|
||||||
|
use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict};
|
||||||
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
|
||||||
|
/// Raw response from Arylic MCU+PINFGET command
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ArylicPlaybackInfoRaw {
|
||||||
|
status: String,
|
||||||
|
curpos: String,
|
||||||
|
totlen: String,
|
||||||
|
#[serde(default)]
|
||||||
|
vol: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
mute: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
plicount: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
plicurr: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ArylicTcpRenderer {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArylicTcpRenderer {
|
||||||
|
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String, ControlPointError> {
|
||||||
|
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_optional(
|
||||||
|
&self,
|
||||||
|
cmd: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
) -> Result<Option<String>, ControlPointError> {
|
||||||
|
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> {
|
||||||
|
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||||
|
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
||||||
|
match parse_playback_info(&payload) {
|
||||||
|
Ok(info) => Ok(info),
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
"Failed to parse Arylic playback info for {}: {}",
|
||||||
|
self.host, err
|
||||||
|
);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_volume_command(value: u16) -> String {
|
||||||
|
format!("MCU+VOL+{:03}", value.min(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_volume_payload(payload: &str) -> Result<u16, ControlPointError> {
|
||||||
|
let data = payload.strip_prefix("AXX+VOL+").ok_or_else(|| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload))
|
||||||
|
})?;
|
||||||
|
let value: u16 = data.trim().parse().map_err(|_| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data))
|
||||||
|
})?;
|
||||||
|
Ok(value.min(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mute_payload(payload: &str) -> Result<bool, ControlPointError> {
|
||||||
|
let data = payload.strip_prefix("AXX+MUT+").ok_or_else(|| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload))
|
||||||
|
})?;
|
||||||
|
match data.trim() {
|
||||||
|
"000" | "0" => Ok(false),
|
||||||
|
"001" | "1" => Ok(true),
|
||||||
|
other => Err(ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Invalid mute value: {}",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||||
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let host = extract_linkplay_host(info.location()).ok_or_else(|| {
|
||||||
|
ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Renderer {} has no valid LOCATION host",
|
||||||
|
info.udn()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port: ARYLIC_TCP_PORT,
|
||||||
|
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
MusicRendererBackend::ArylicTcp(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportControl for ArylicTcpRenderer {
|
||||||
|
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||||
|
Err(ControlPointError::upnp_operation_not_supported(
|
||||||
|
"Arylic TCP backend does not support direct URL loading.",
|
||||||
|
"ArylicTcpRenderer",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.send_no_response("MCU+PLY-PLA")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
|
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.send_no_response("MCU+PLY-STP")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
|
let _ = parse_hhmmss_strict(hhmmss)?;
|
||||||
|
Err(ControlPointError::ArilycTcpError(
|
||||||
|
"Arylic TCP seek_rel_time is not implemented yet for this device.".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeControl for ArylicTcpRenderer {
|
||||||
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
|
if let Ok(info) = self.fetch_playback_info() {
|
||||||
|
if let Some(vol) = info.volume {
|
||||||
|
return Ok(vol);
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
||||||
|
self.host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
||||||
|
Self::parse_volume_payload(&payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
|
let command = Self::format_volume_command(v);
|
||||||
|
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
|
if let Ok(info) = self.fetch_playback_info() {
|
||||||
|
if let Some(mute) = info.mute {
|
||||||
|
return Ok(mute);
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
||||||
|
self.host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
||||||
|
Self::parse_mute_payload(&payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
|
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
||||||
|
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
||||||
|
let _ = Self::parse_mute_payload(&payload)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackStatus for ArylicTcpRenderer {
|
||||||
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
|
let info = self.fetch_playback_info()?;
|
||||||
|
Ok(info.playback_state())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackPosition for ArylicTcpRenderer {
|
||||||
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
|
let info = self.fetch_playback_info()?;
|
||||||
|
Ok(info.position_info())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ArylicPlaybackInfo {
|
||||||
|
status_raw: String,
|
||||||
|
curpos_ms: u64,
|
||||||
|
totlen_ms: u64,
|
||||||
|
volume: Option<u16>,
|
||||||
|
mute: Option<bool>,
|
||||||
|
playlist_size: Option<u32>,
|
||||||
|
track_index: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArylicPlaybackInfo {
|
||||||
|
fn playback_state(&self) -> PlaybackState {
|
||||||
|
match self.status_raw.as_str() {
|
||||||
|
"play" => PlaybackState::Playing,
|
||||||
|
"pause" => PlaybackState::Paused,
|
||||||
|
"stop" => PlaybackState::Stopped,
|
||||||
|
other => PlaybackState::Unknown(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_info(&self) -> PlaybackPositionInfo {
|
||||||
|
let track = match (self.track_index, self.playlist_size) {
|
||||||
|
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
||||||
|
(Some(idx), _) => Some(idx),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
PlaybackPositionInfo {
|
||||||
|
track,
|
||||||
|
rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))),
|
||||||
|
abs_time: None,
|
||||||
|
track_duration: if self.totlen_ms > 0 {
|
||||||
|
Some(format_hhmmss(ms_to_seconds(self.totlen_ms)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
track_metadata: None,
|
||||||
|
track_uri: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||||
|
let json_blob = payload.strip_prefix("AXX+PLY+INF").ok_or_else(|| {
|
||||||
|
ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let json_blob = json_blob.trim_end_matches('&').trim();
|
||||||
|
|
||||||
|
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to parse Arylic playback info JSON: {}", e)))?;
|
||||||
|
|
||||||
|
let curpos_ms = raw.curpos.parse::<u64>()
|
||||||
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?;
|
||||||
|
|
||||||
|
let totlen_ms = raw.totlen.parse::<u64>()
|
||||||
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?;
|
||||||
|
|
||||||
|
let volume = raw.vol.and_then(|raw_vol| {
|
||||||
|
match raw_vol.parse::<u16>() {
|
||||||
|
Ok(value) => Some(value.min(100)),
|
||||||
|
Err(err) => {
|
||||||
|
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mute = raw.mute.and_then(|raw_mute| {
|
||||||
|
match raw_mute.as_str() {
|
||||||
|
"1" => Some(true),
|
||||||
|
"0" => Some(false),
|
||||||
|
other => {
|
||||||
|
debug!("Invalid Arylic `mute` value {}", other);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let playlist_size = raw.plicount.and_then(|raw_count| {
|
||||||
|
match raw_count.parse::<u32>() {
|
||||||
|
Ok(count) if count > 0 => Some(count),
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(err) => {
|
||||||
|
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let track_index = raw.plicurr.and_then(|raw_idx| {
|
||||||
|
match raw_idx.parse::<u32>() {
|
||||||
|
Ok(idx) if idx > 0 => Some(idx),
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(err) => {
|
||||||
|
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ArylicPlaybackInfo {
|
||||||
|
status_raw: raw.status,
|
||||||
|
curpos_ms,
|
||||||
|
totlen_ms,
|
||||||
|
volume,
|
||||||
|
mute,
|
||||||
|
playlist_size,
|
||||||
|
track_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// pmocontrol/src/capabilities.rs
|
// pmocontrol/src/capabilities.rs
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::{errors::ControlPointError, model::PlaybackState};
|
||||||
|
|
||||||
/// Logical playback position across backends.
|
/// Logical playback position across backends.
|
||||||
///
|
///
|
||||||
@@ -21,51 +21,6 @@ pub trait PlaybackPosition {
|
|||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level playback state across backends.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum PlaybackState {
|
|
||||||
Stopped,
|
|
||||||
Playing,
|
|
||||||
Paused,
|
|
||||||
Transitioning,
|
|
||||||
NoMedia,
|
|
||||||
/// Backend-specific or unknown state string.
|
|
||||||
Unknown(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackState {
|
|
||||||
/// Map a raw UPnP AVTransport CurrentTransportState string
|
|
||||||
/// to a logical PlaybackState.
|
|
||||||
pub fn from_upnp_state(raw: &str) -> Self {
|
|
||||||
let s = raw.trim().to_ascii_uppercase();
|
|
||||||
match s.as_str() {
|
|
||||||
"STOPPED" => PlaybackState::Stopped,
|
|
||||||
"PLAYING" => PlaybackState::Playing,
|
|
||||||
"PAUSED_PLAYBACK" => PlaybackState::Paused,
|
|
||||||
// States from the AVTransport spec that we normalize:
|
|
||||||
"PAUSED_RECORDING" => PlaybackState::Paused,
|
|
||||||
"RECORDING" => PlaybackState::Playing,
|
|
||||||
// Common vendor-specific states:
|
|
||||||
"TRANSITIONING" => PlaybackState::Transitioning,
|
|
||||||
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
|
|
||||||
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
|
|
||||||
_ => PlaybackState::Unknown(raw.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a human-readable label for the playback state.
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
PlaybackState::Stopped => "STOPPED",
|
|
||||||
PlaybackState::Playing => "PLAYING",
|
|
||||||
PlaybackState::Paused => "PAUSED",
|
|
||||||
PlaybackState::Transitioning => "TRANSITIONING",
|
|
||||||
PlaybackState::NoMedia => "NO_MEDIA",
|
|
||||||
PlaybackState::Unknown(s) => s.as_str(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generic abstraction for playback status (transport state).
|
/// Generic abstraction for playback status (transport state).
|
||||||
///
|
///
|
||||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||||
@@ -14,18 +14,19 @@
|
|||||||
use std::sync::{Arc, Mutex, Once};
|
use std::sync::{Arc, Mutex, Once};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
use crate::DeviceIdentity;
|
||||||
use crate::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||||
|
use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::{RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
|
||||||
use rust_cast::{
|
use rust_cast::{
|
||||||
CastDevice, ChannelMessage,
|
CastDevice, ChannelMessage,
|
||||||
@@ -116,9 +117,8 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl ChromecastRenderer {
|
impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||||
/// Creates a new ChromecastRenderer from RendererInfo.
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ChromecastRenderer::from_renderer_info location={} for {}",
|
"ChromecastRenderer::from_renderer_info location={} for {}",
|
||||||
info.location(),
|
info.location(),
|
||||||
@@ -147,6 +147,11 @@ impl ChromecastRenderer {
|
|||||||
thread_handle,
|
thread_handle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
MusicRendererBackend::Chromecast(self)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for ChromecastRenderer {
|
impl TransportControl for ChromecastRenderer {
|
||||||
@@ -428,26 +433,7 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
||||||
|
|
||||||
// Parse HH:MM:SS to seconds
|
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
|
||||||
let parts: Vec<&str> = hhmmss.split(':').collect();
|
|
||||||
if parts.len() != 3 {
|
|
||||||
return Err(ControlPointError::ChromecastError(format!(
|
|
||||||
"Invalid time format, expected HH:MM:SS: {}",
|
|
||||||
hhmmss
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let hours: u32 = parts[0]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid hours in time: {}", hhmmss)))?;
|
|
||||||
let minutes: u32 = parts[1]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid minutes in time: {}", hhmmss)))?;
|
|
||||||
let seconds: u32 = parts[2]
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ControlPointError::ChromecastError(format!("Invalid seconds in time: {}", hhmmss)))?;
|
|
||||||
|
|
||||||
let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32;
|
|
||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
@@ -563,13 +549,13 @@ impl PlaybackPosition for ChromecastRenderer {
|
|||||||
// Extract position information
|
// Extract position information
|
||||||
let rel_time = media_entry
|
let rel_time = media_entry
|
||||||
.current_time
|
.current_time
|
||||||
.map(|time| format_time_hhmmss(time as f64));
|
.map(|time| format_hhmmss_f64(time as f64));
|
||||||
|
|
||||||
let track_duration = media_entry
|
let track_duration = media_entry
|
||||||
.media
|
.media
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.duration)
|
.and_then(|m| m.duration)
|
||||||
.map(|dur| format_time_hhmmss(dur as f64));
|
.map(|dur| format_hhmmss_f64(dur as f64));
|
||||||
|
|
||||||
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
||||||
|
|
||||||
@@ -645,15 +631,6 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String {
|
|||||||
content_type.to_string()
|
content_type.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts seconds to HH:MM:SS format.
|
|
||||||
fn format_time_hhmmss(seconds: f64) -> String {
|
|
||||||
let total_secs = seconds as u64;
|
|
||||||
let hours = total_secs / 3600;
|
|
||||||
let minutes = (total_secs % 3600) / 60;
|
|
||||||
let secs = total_secs % 60;
|
|
||||||
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VolumeControl for ChromecastRenderer {
|
impl VolumeControl for ChromecastRenderer {
|
||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
134
pmocontrol/src/music_renderer/linkplay_renderer.rs
Normal file
134
pmocontrol/src/music_renderer/linkplay_renderer.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ureq::Agent;
|
||||||
|
|
||||||
|
use crate::DeviceIdentity;
|
||||||
|
use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode};
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
|
VolumeControl,
|
||||||
|
};
|
||||||
|
use crate::music_renderer::time_utils::{parse_hhmmss_strict};
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::{RendererInfo, PlaybackState};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
|
||||||
|
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
|
|
||||||
|
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LinkPlayRenderer {
|
||||||
|
host: String,
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for LinkPlayRenderer {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("LinkPlayRenderer")
|
||||||
|
.field("host", &self.host)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkPlayRenderer {
|
||||||
|
|
||||||
|
fn agent(&self) -> Agent {
|
||||||
|
build_agent(self.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> {
|
||||||
|
let url = format!(
|
||||||
|
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||||
|
self.host, command
|
||||||
|
);
|
||||||
|
self.agent()
|
||||||
|
.get(&url)
|
||||||
|
.call()
|
||||||
|
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_status(&self) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
|
fetch_status_for_host(&self.host, self.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||||
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
let host = extract_linkplay_host(&info.location())
|
||||||
|
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
MusicRendererBackend::LinkPlay(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportControl for LinkPlayRenderer {
|
||||||
|
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||||
|
let encoded = percent_encode(uri);
|
||||||
|
self.send_player_command(&format!("play:{}", encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.send_player_command("resume")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.send_player_command("pause")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.send_player_command("stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
|
let secs = parse_hhmmss_strict(hhmmss)?;
|
||||||
|
self.send_player_command(&format!("seek:{}", secs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeControl for LinkPlayRenderer {
|
||||||
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
|
Ok(self.fetch_status()?.volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
|
let value = v.min(100);
|
||||||
|
self.send_player_command(&format!("vol:{}", value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
|
Ok(self.fetch_status()?.mute)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
|
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackStatus for LinkPlayRenderer {
|
||||||
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
|
Ok(self.fetch_status()?.playback_state())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackPosition for LinkPlayRenderer {
|
||||||
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
|
Ok(self.fetch_status()?.position_info())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
53
pmocontrol/src/music_renderer/mod.rs
Normal file
53
pmocontrol/src/music_renderer/mod.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
mod arylic_tcp;
|
||||||
|
mod linkplay_renderer;
|
||||||
|
|
||||||
|
mod upnp_renderer;
|
||||||
|
|
||||||
|
mod openhome;
|
||||||
|
mod openhome_renderer;
|
||||||
|
|
||||||
|
mod capabilities;
|
||||||
|
mod chromecast_renderer;
|
||||||
|
|
||||||
|
mod musicrenderer;
|
||||||
|
pub mod time_utils;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||||
|
};
|
||||||
|
pub use crate::music_renderer::musicrenderer::MusicRenderer;
|
||||||
|
use crate::{
|
||||||
|
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait RendererFromMediaRendererInfo {
|
||||||
|
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
fn build_from_renderer_info(
|
||||||
|
renderer: &RendererInfo,
|
||||||
|
) -> Result<MusicRendererBackend, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let instance = Self::from_renderer_info(renderer)?;
|
||||||
|
Ok(instance.to_backend())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_from_renderer_info(
|
||||||
|
renderer: &RendererInfo,
|
||||||
|
) -> Result<Arc<Mutex<MusicRendererBackend>>, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let backend = Self::build_from_renderer_info(renderer)?;
|
||||||
|
Ok(Arc::new(Mutex::new(backend)))
|
||||||
|
}
|
||||||
|
}
|
||||||
737
pmocontrol/src/music_renderer/musicrenderer.rs
Normal file
737
pmocontrol/src/music_renderer/musicrenderer.rs
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
//! 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, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::control_point::PlaylistBinding;
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState};
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
|
VolumeControl,
|
||||||
|
};
|
||||||
|
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
||||||
|
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||||
|
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||||
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||||
|
use crate::online::DeviceConnectionState;
|
||||||
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot};
|
||||||
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||||
|
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum MusicRendererBackend {
|
||||||
|
/// 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MusicRenderer {
|
||||||
|
info: RendererInfo,
|
||||||
|
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||||
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
|
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicRenderer {
|
||||||
|
pub fn new(
|
||||||
|
info: RendererInfo,
|
||||||
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
|
) -> Arc<MusicRenderer> {
|
||||||
|
let connection = DeviceConnectionState::new();
|
||||||
|
|
||||||
|
let renderer = MusicRenderer {
|
||||||
|
info,
|
||||||
|
connection: Arc::new(Mutex::new(connection)),
|
||||||
|
backend,
|
||||||
|
queue,
|
||||||
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Arc::new(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
||||||
|
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
||||||
|
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
||||||
|
let queue = MusicQueue::make_from_renderer_info(info)?;
|
||||||
|
|
||||||
|
let renderer = MusicRenderer {
|
||||||
|
info: info.clone(),
|
||||||
|
connection,
|
||||||
|
backend,
|
||||||
|
queue,
|
||||||
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
|
};
|
||||||
|
Ok(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> &RendererInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the protocol.
|
||||||
|
fn protocol(&self) -> RendererProtocol {
|
||||||
|
self.info.protocol()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_upnp(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::Upnp(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_openhome(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::OpenHome(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_linkplay(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::LinkPlay(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_arylictcp(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::ArylicTcp(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_chromecast(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::Chromecast(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hybridupnparylic(&self) -> bool {
|
||||||
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
||||||
|
pub fn supports_set_next(&self) -> bool {
|
||||||
|
self.info.capabilities().supports_set_next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
||||||
|
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
||||||
|
// Clear the queue first
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.expect("Queue mutex poisoned")
|
||||||
|
.clear_queue()?;
|
||||||
|
|
||||||
|
// Then stop playback (ignore errors if already stopped)
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.stop()
|
||||||
|
.or_else(|err| {
|
||||||
|
warn!(
|
||||||
|
renderer = self.id().0.as_str(),
|
||||||
|
error = %err,
|
||||||
|
"Stop failed when preparing for playlist attach (continuing anyway)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current queue snapshot.
|
||||||
|
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.expect("Queue mutex poisoned")
|
||||||
|
.queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play the current item from the queue.
|
||||||
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
|
||||||
|
// Get the current item from the queue
|
||||||
|
let (item, _remaining) = queue.peek_current()?.ok_or_else(|| {
|
||||||
|
ControlPointError::QueueError("Queue is empty, cannot play current".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Build DIDL metadata if available
|
||||||
|
let metadata_xml = item
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Play the URI using the backend
|
||||||
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance to and play the next item from the queue.
|
||||||
|
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
|
||||||
|
// Dequeue the next item
|
||||||
|
let (item, _remaining) = queue
|
||||||
|
.dequeue_next()?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?;
|
||||||
|
|
||||||
|
// Build DIDL metadata if available
|
||||||
|
let metadata_xml = item
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Play the URI using the backend
|
||||||
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport control: play URI with metadata
|
||||||
|
pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.play_uri(uri, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport control: play
|
||||||
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.backend.lock().expect("Backend mutex poisoned").play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport control: pause
|
||||||
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.backend.lock().expect("Backend mutex poisoned").pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport control: stop
|
||||||
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
|
self.backend.lock().expect("Backend mutex poisoned").stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport control: seek to relative time
|
||||||
|
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.seek_rel_time(hhmmss)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume control: get current volume
|
||||||
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.volume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume control: set volume
|
||||||
|
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.set_volume(vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume control: get mute state
|
||||||
|
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
|
self.backend.lock().expect("Backend mutex poisoned").mute()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume control: set mute state
|
||||||
|
pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.set_mute(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playback state
|
||||||
|
pub fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.playback_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playback position
|
||||||
|
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.playback_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the playlist binding for this renderer.
|
||||||
|
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
||||||
|
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the current playlist binding, if any.
|
||||||
|
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
|
||||||
|
self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the playlist binding.
|
||||||
|
pub fn clear_playlist_binding(&self) {
|
||||||
|
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the renderer's queue using the generic QueueBackend trait.
|
||||||
|
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
queue.clear_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a track to the queue.
|
||||||
|
///
|
||||||
|
/// This is primarily for backends with persistent queues (OpenHome).
|
||||||
|
/// For backends without this capability, it returns an error.
|
||||||
|
///
|
||||||
|
/// Returns the backend-specific track ID if applicable.
|
||||||
|
pub fn add_track_to_queue(
|
||||||
|
&self,
|
||||||
|
_uri: &str,
|
||||||
|
_metadata: &str,
|
||||||
|
_after_id: Option<u32>,
|
||||||
|
_play: bool,
|
||||||
|
) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
// This operation requires backend-specific APIs (especially for OpenHome)
|
||||||
|
// that aren't exposed through the generic QueueBackend trait.
|
||||||
|
// The generic implementation cannot support this without more context
|
||||||
|
// (media_server_id, didl_id, protocol_info, etc.).
|
||||||
|
Err(ControlPointError::QueueError(
|
||||||
|
"add_track_to_queue requires backend-specific implementation".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects and plays a specific track from the queue by ID.
|
||||||
|
///
|
||||||
|
/// Converts the track ID to a position using the generic QueueBackend trait,
|
||||||
|
/// then plays the track.
|
||||||
|
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
||||||
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
|
||||||
|
// Convert track ID to position using generic QueueBackend trait
|
||||||
|
let position = queue.id_to_position(track_id)?;
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
// Set the index
|
||||||
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
queue.set_index(Some(position))?;
|
||||||
|
|
||||||
|
// Get the item to play
|
||||||
|
let item = queue.get_item(position)?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
// Build metadata XML
|
||||||
|
let metadata_xml = item
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Play the item using TransportControl
|
||||||
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronizes the queue state with the backend.
|
||||||
|
///
|
||||||
|
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
||||||
|
/// For others, this is essentially a no-op (just reads the current state).
|
||||||
|
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
||||||
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
// Calling queue_snapshot() triggers a refresh for backends that need it
|
||||||
|
let _ = queue.queue_snapshot()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays the current item from the backend queue.
|
||||||
|
///
|
||||||
|
/// This is primarily for backends with persistent queues (OpenHome).
|
||||||
|
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
|
||||||
|
// Get current track ID using generic QueueBackend trait
|
||||||
|
let track_id = queue.current_track()?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
||||||
|
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
// Play it using select_queue_track
|
||||||
|
self.select_queue_track(track_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the queue (read-only access via lock).
|
||||||
|
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
||||||
|
pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
||||||
|
self.queue.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the queue (write access via lock).
|
||||||
|
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
||||||
|
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
||||||
|
self.queue.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
||||||
|
fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||||
|
<item id="0" parentID="-1" restricted="1">
|
||||||
|
<dc:title>{}</dc:title>
|
||||||
|
<dc:creator>{}</dc:creator>
|
||||||
|
<upnp:artist>{}</upnp:artist>
|
||||||
|
<upnp:album>{}</upnp:album>
|
||||||
|
{}
|
||||||
|
<res protocolInfo="{}">{}</res>
|
||||||
|
</item>
|
||||||
|
</DIDL-Lite>"#,
|
||||||
|
metadata.title.as_deref().unwrap_or("Unknown Title"),
|
||||||
|
metadata
|
||||||
|
.creator
|
||||||
|
.as_deref()
|
||||||
|
.or(metadata.artist.as_deref())
|
||||||
|
.unwrap_or("Unknown Artist"),
|
||||||
|
metadata.artist.as_deref().unwrap_or("Unknown Artist"),
|
||||||
|
metadata.album.as_deref().unwrap_or("Unknown Album"),
|
||||||
|
metadata
|
||||||
|
.album_art_uri
|
||||||
|
.as_ref()
|
||||||
|
.map(|art_uri| format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", art_uri))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
protocol_info,
|
||||||
|
uri
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceIdentity for MusicRenderer {
|
||||||
|
fn id(&self) -> DeviceId {
|
||||||
|
self.info.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn udn(&self) -> &str {
|
||||||
|
&*self.info.udn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn friendly_name(&self) -> &str {
|
||||||
|
&self.info.friendly_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.info.model_name()
|
||||||
|
}
|
||||||
|
fn manufacturer(&self) -> &str {
|
||||||
|
&self.info.manufacturer()
|
||||||
|
}
|
||||||
|
fn location(&self) -> &str {
|
||||||
|
&self.info.location()
|
||||||
|
}
|
||||||
|
fn server_header(&self) -> &str {
|
||||||
|
&self.info.server_header()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceOnline for MusicRenderer {
|
||||||
|
fn is_online(&self) -> bool {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.is_online()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_seen(&self) -> SystemTime {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.last_seen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.has_been_seen_now(max_age)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_as_offline(&self) {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.mark_as_offline()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_age(&self) -> u32 {
|
||||||
|
self.connection
|
||||||
|
.lock()
|
||||||
|
.expect("Connection mutex poisoned")
|
||||||
|
.max_age()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererFromMediaRendererInfo for MusicRendererBackend {
|
||||||
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
// Try OpenHome first for OpenHome-capable renderers
|
||||||
|
match info.protocol() {
|
||||||
|
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
||||||
|
if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) {
|
||||||
|
return Ok(backend);
|
||||||
|
}
|
||||||
|
// If OpenHomeOnly failed, it's an error
|
||||||
|
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
||||||
|
return Err(ControlPointError::MusicRendererBackendBuild(format!(
|
||||||
|
"OpenHomeOnly renderer {} has no usable OpenHome services",
|
||||||
|
info.friendly_name()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// OpenHomeHybrid: fall through to try UPnP-based backends
|
||||||
|
}
|
||||||
|
RendererProtocol::ChromecastOnly => {
|
||||||
|
return ChromecastRenderer::build_from_renderer_info(info);
|
||||||
|
}
|
||||||
|
RendererProtocol::UpnpAvOnly => {
|
||||||
|
// Will be handled below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback)
|
||||||
|
let has_arylic = info.capabilities().has_arylic_tcp;
|
||||||
|
let has_avtransport = info.capabilities().has_avtransport();
|
||||||
|
|
||||||
|
// Try Hybrid UPnP + Arylic (special case: combines two backends)
|
||||||
|
if has_arylic && has_avtransport {
|
||||||
|
let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?;
|
||||||
|
if let MusicRendererBackend::Upnp(upnp) = upnp_backend {
|
||||||
|
match ArylicTcpRenderer::build_from_renderer_info(info) {
|
||||||
|
Ok(MusicRendererBackend::ArylicTcp(arylic)) => {
|
||||||
|
return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
renderer = info.friendly_name(),
|
||||||
|
error = %err,
|
||||||
|
"Failed to build Arylic TCP backend, falling back to UPnP only"
|
||||||
|
);
|
||||||
|
return Ok(MusicRendererBackend::Upnp(upnp));
|
||||||
|
}
|
||||||
|
_ => unreachable!(
|
||||||
|
"ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LinkPlay
|
||||||
|
if info.capabilities().has_linkplay_http() {
|
||||||
|
if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) {
|
||||||
|
return Ok(backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UPnP
|
||||||
|
if has_avtransport {
|
||||||
|
return UpnpRenderer::build_from_renderer_info(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No suitable backend found
|
||||||
|
Err(ControlPointError::MusicRendererBackendBuild(format!(
|
||||||
|
"No suitable backend for renderer {}",
|
||||||
|
info.friendly_name()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 MusicRendererBackend {
|
||||||
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
||||||
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
||||||
|
ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"),
|
||||||
|
),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
||||||
|
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
||||||
|
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
||||||
|
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
||||||
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
||||||
|
ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"),
|
||||||
|
),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
||||||
|
MusicRendererBackend::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 MusicRendererBackend {
|
||||||
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
||||||
|
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
||||||
|
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
||||||
|
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
||||||
|
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
||||||
|
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.mute(),
|
||||||
|
MusicRendererBackend::Upnp(r) => r.mute(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
||||||
|
MusicRendererBackend::Upnp(r) => r.set_mute(m),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
||||||
|
MusicRendererBackend::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 MusicRendererBackend {
|
||||||
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
||||||
|
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
||||||
|
MusicRendererBackend::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 MusicRendererBackend {
|
||||||
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::model::RendererInfo;
|
use crate::{
|
||||||
use crate::openhome_client::{
|
model::RendererInfo,
|
||||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
upnp_clients::{
|
||||||
|
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient,
|
||||||
|
OhVolumeClient,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
@@ -35,14 +38,8 @@ fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option<OhServiceEnd
|
|||||||
info.oh_playlist_control_url()?,
|
info.oh_playlist_control_url()?,
|
||||||
info.oh_playlist_service_type()?,
|
info.oh_playlist_service_type()?,
|
||||||
),
|
),
|
||||||
OhServiceKind::Info => (
|
OhServiceKind::Info => (info.oh_info_control_url()?, info.oh_info_service_type()?),
|
||||||
info.oh_info_control_url()?,
|
OhServiceKind::Time => (info.oh_time_control_url()?, info.oh_time_service_type()?),
|
||||||
info.oh_info_service_type()?,
|
|
||||||
),
|
|
||||||
OhServiceKind::Time => (
|
|
||||||
info.oh_time_control_url()?,
|
|
||||||
info.oh_time_service_type()?,
|
|
||||||
),
|
|
||||||
OhServiceKind::Volume => (
|
OhServiceKind::Volume => (
|
||||||
info.oh_volume_control_url()?,
|
info.oh_volume_control_url()?,
|
||||||
info.oh_volume_service_type()?,
|
info.oh_volume_service_type()?,
|
||||||
@@ -110,16 +107,16 @@ pub fn build_product_client(info: &RendererInfo) -> Option<OhProductClient> {
|
|||||||
pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
||||||
let control_url = info.oh_radio_control_url()?;
|
let control_url = info.oh_radio_control_url()?;
|
||||||
let service_type = info.oh_radio_service_type()?;
|
let service_type = info.oh_radio_service_type()?;
|
||||||
Some(OhRadioClient::new(
|
Some(OhRadioClient::new(control_url, service_type))
|
||||||
control_url,
|
|
||||||
service_type,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{DeviceId, model::{RendererCapabilities, RendererInfo, RendererProtocol}};
|
use crate::{
|
||||||
|
DeviceId,
|
||||||
|
model::{RendererCapabilities, RendererInfo, RendererProtocol},
|
||||||
|
};
|
||||||
|
|
||||||
fn sample_renderer_info() -> RendererInfo {
|
fn sample_renderer_info() -> RendererInfo {
|
||||||
RendererInfo::make(
|
RendererInfo::make(
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use crate::DeviceIdentity;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
use crate::{DeviceIdentity, MusicRendererBackend};
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
use crate::capabilities::{
|
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
|
use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32};
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::RendererInfo;
|
use crate::model::{RendererInfo, PlaybackState};
|
||||||
use crate::openhome::{
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
use crate::music_renderer::openhome::{
|
||||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||||
build_time_client, build_volume_client,
|
build_time_client, build_volume_client,
|
||||||
};
|
};
|
||||||
use crate::openhome_client::{
|
use crate::upnp_clients::{
|
||||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||||
OhTimeClient, OhTrackEntry, OhVolumeClient, parse_track_metadata_from_didl,
|
OhTimeClient, OhVolumeClient,
|
||||||
};
|
};
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -50,29 +49,6 @@ impl OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(
|
|
||||||
info: RendererInfo,
|
|
||||||
) -> Result<Arc<Mutex<MusicRendererBackend>>, ControlPointError> {
|
|
||||||
let renderer = OpenHomeRenderer::new(
|
|
||||||
build_playlist_client(&info),
|
|
||||||
build_info_client(&info),
|
|
||||||
build_time_client(&info),
|
|
||||||
build_volume_client(&info),
|
|
||||||
build_product_client(&info),
|
|
||||||
build_radio_client(&info),
|
|
||||||
);
|
|
||||||
|
|
||||||
if renderer.has_any_openhome_service() {
|
|
||||||
Ok(Arc::new(Mutex::new(MusicRendererBackend::OpenHome(
|
|
||||||
renderer,
|
|
||||||
))))
|
|
||||||
} else {
|
|
||||||
Err(ControlPointError::OpenHomeNotAValidDevice(format!(
|
|
||||||
"{:?}",
|
|
||||||
info.id()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_playlist(&self) -> bool {
|
pub fn has_playlist(&self) -> bool {
|
||||||
self.playlist.is_some()
|
self.playlist.is_some()
|
||||||
@@ -159,7 +135,7 @@ impl OpenHomeRenderer {
|
|||||||
|
|
||||||
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
||||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize> {
|
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("openhome_playlist_len")?;
|
let playlist = self.playlist_client_for("openhome_playlist_len")?;
|
||||||
let ids = playlist.id_array()?;
|
let ids = playlist.id_array()?;
|
||||||
Ok(ids.len())
|
Ok(ids.len())
|
||||||
@@ -196,17 +172,47 @@ impl OpenHomeRenderer {
|
|||||||
|
|
||||||
let new_id = playlist.insert(insert_after, uri, metadata)?;
|
let new_id = playlist.insert(insert_after, uri, metadata)?;
|
||||||
if play {
|
if play {
|
||||||
playlist.play_id(new_id)?;
|
playlist.seek_id(new_id)?;
|
||||||
}
|
}
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> {
|
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
||||||
playlist.play_id(id)
|
playlist.seek_id(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||||
|
fn from_renderer_info(
|
||||||
|
info: &RendererInfo,
|
||||||
|
) -> Result<Self, ControlPointError> {
|
||||||
|
let renderer = OpenHomeRenderer::new(
|
||||||
|
build_playlist_client(&info),
|
||||||
|
build_info_client(&info),
|
||||||
|
build_time_client(&info),
|
||||||
|
build_volume_client(&info),
|
||||||
|
build_product_client(&info),
|
||||||
|
build_radio_client(&info),
|
||||||
|
);
|
||||||
|
|
||||||
|
if renderer.has_any_openhome_service() {
|
||||||
|
Ok(renderer)
|
||||||
|
} else {
|
||||||
|
Err(ControlPointError::OpenHomeNotAValidDevice(format!(
|
||||||
|
"{:?}",
|
||||||
|
info.id()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
MusicRendererBackend::OpenHome(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
impl TransportControl for OpenHomeRenderer {
|
impl TransportControl for OpenHomeRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play_uri")?;
|
let playlist = self.playlist_client_for("play_uri")?;
|
||||||
@@ -241,9 +247,7 @@ impl TransportControl for OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let seconds = parse_hms(hhmmss).ok_or_else(|| {
|
let seconds = parse_time_flexible(hhmmss)?;
|
||||||
ControlPointError::upnp_bad_return_value("HH:MM:SS", &hhmmss)
|
|
||||||
})?;
|
|
||||||
let playlist = self.playlist_client_for("seek_rel_time")?;
|
let playlist = self.playlist_client_for("seek_rel_time")?;
|
||||||
playlist.seek_second_absolute(seconds)
|
playlist.seek_second_absolute(seconds)
|
||||||
}
|
}
|
||||||
@@ -314,29 +318,15 @@ impl PlaybackPosition for OpenHomeRenderer {
|
|||||||
|
|
||||||
Ok(PlaybackPositionInfo {
|
Ok(PlaybackPositionInfo {
|
||||||
track: track_id,
|
track: track_id,
|
||||||
rel_time: Some(format_seconds(time_info.elapsed_secs)),
|
rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)),
|
||||||
abs_time: None,
|
abs_time: None,
|
||||||
track_duration: Some(format_seconds(time_info.duration_secs)),
|
track_duration: Some(format_hhmmss_u32(time_info.duration_secs)),
|
||||||
track_metadata: track_metadata_xml,
|
track_metadata: track_metadata_xml,
|
||||||
track_uri,
|
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 {
|
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||||
match raw.trim().to_ascii_uppercase().as_str() {
|
match raw.trim().to_ascii_uppercase().as_str() {
|
||||||
"PLAYING" => PlaybackState::Playing,
|
"PLAYING" => PlaybackState::Playing,
|
||||||
@@ -347,21 +337,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_oh_track_entry(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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
218
pmocontrol/src/music_renderer/time_utils.rs
Normal file
218
pmocontrol/src/music_renderer/time_utils.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//! Time formatting and parsing utilities for music renderers.
|
||||||
|
//!
|
||||||
|
//! This module provides utilities to convert between different time representations:
|
||||||
|
//! - HH:MM:SS format (UPnP standard)
|
||||||
|
//! - Seconds (u32/u64)
|
||||||
|
//! - Milliseconds
|
||||||
|
//!
|
||||||
|
//! All functions are designed to be robust and provide clear error messages.
|
||||||
|
|
||||||
|
use crate::errors::ControlPointError;
|
||||||
|
|
||||||
|
/// Formats a duration in seconds as HH:MM:SS.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// # use pmocontrol::music_renderer::time_utils::format_hhmmss;
|
||||||
|
/// assert_eq!(format_hhmmss(0), "00:00:00");
|
||||||
|
/// assert_eq!(format_hhmmss(61), "00:01:01");
|
||||||
|
/// assert_eq!(format_hhmmss(3661), "01:01:01");
|
||||||
|
/// ```
|
||||||
|
pub fn format_hhmmss(seconds: u64) -> String {
|
||||||
|
let hours = seconds / 3600;
|
||||||
|
let minutes = (seconds % 3600) / 60;
|
||||||
|
let secs = seconds % 60;
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a duration in seconds as HH:MM:SS (u32 variant).
|
||||||
|
///
|
||||||
|
/// Convenience wrapper for u32 values.
|
||||||
|
pub fn format_hhmmss_u32(seconds: u32) -> String {
|
||||||
|
format_hhmmss(seconds as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a duration in seconds as HH:MM:SS (f64 variant).
|
||||||
|
///
|
||||||
|
/// Used by Chromecast which returns floating point durations.
|
||||||
|
/// Rounds to nearest second.
|
||||||
|
pub fn format_hhmmss_f64(seconds: f64) -> String {
|
||||||
|
format_hhmmss(seconds.round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a time string in HH:MM:SS, MM:SS, or SS format to seconds.
|
||||||
|
///
|
||||||
|
/// This is the most flexible parser, supporting multiple formats:
|
||||||
|
/// - "HH:MM:SS" → hours * 3600 + minutes * 60 + seconds
|
||||||
|
/// - "MM:SS" → minutes * 60 + seconds
|
||||||
|
/// - "SS" → seconds
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// # use pmocontrol::music_renderer::time_utils::parse_time_flexible;
|
||||||
|
/// assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723);
|
||||||
|
/// assert_eq!(parse_time_flexible("02:03").unwrap(), 123);
|
||||||
|
/// assert_eq!(parse_time_flexible("42").unwrap(), 42);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The input has more than 3 parts
|
||||||
|
/// - Any part is not a valid u32
|
||||||
|
pub fn parse_time_flexible(input: &str) -> Result<u32, ControlPointError> {
|
||||||
|
let parts: Vec<&str> = input.split(':').collect();
|
||||||
|
|
||||||
|
if parts.is_empty() || parts.len() > 3 {
|
||||||
|
return Err(ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total = 0u32;
|
||||||
|
for part in parts {
|
||||||
|
let value = part.parse::<u32>().map_err(|_| {
|
||||||
|
ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid numeric value '{}' in time string '{}'", part, input)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
total = total * 60 + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a strict HH:MM:SS format to seconds.
|
||||||
|
///
|
||||||
|
/// This parser requires exactly 3 components separated by colons,
|
||||||
|
/// and validates that minutes and seconds are < 60.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// # use pmocontrol::music_renderer::time_utils::parse_hhmmss_strict;
|
||||||
|
/// assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723);
|
||||||
|
/// assert!(parse_hhmmss_strict("02:03").is_err()); // requires HH:MM:SS
|
||||||
|
/// assert!(parse_hhmmss_strict("00:61:00").is_err()); // minutes > 59
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - The format is not exactly HH:MM:SS
|
||||||
|
/// - Minutes or seconds are >= 60
|
||||||
|
/// - Any component is not a valid u64
|
||||||
|
pub fn parse_hhmmss_strict(input: &str) -> Result<u64, ControlPointError> {
|
||||||
|
let parts: Vec<&str> = input.split(':').collect();
|
||||||
|
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid time format '{}': expected exactly HH:MM:SS", input)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours: u64 = parts[0].parse().map_err(|_| {
|
||||||
|
ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid hour component in '{}'", input)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let minutes: u64 = parts[1].parse().map_err(|_| {
|
||||||
|
ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid minute component in '{}'", input)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let seconds: u64 = parts[2].parse().map_err(|_| {
|
||||||
|
ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid second component in '{}'", input)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if minutes >= 60 || seconds >= 60 {
|
||||||
|
return Err(ControlPointError::InvalidTimeFormat(
|
||||||
|
format!("Invalid time '{}': minutes and seconds must be < 60", input)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts milliseconds to seconds (rounding down).
|
||||||
|
#[inline]
|
||||||
|
pub fn ms_to_seconds(milliseconds: u64) -> u64 {
|
||||||
|
milliseconds / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts seconds to milliseconds.
|
||||||
|
#[inline]
|
||||||
|
pub fn seconds_to_ms(seconds: u64) -> u64 {
|
||||||
|
seconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_hhmmss() {
|
||||||
|
assert_eq!(format_hhmmss(0), "00:00:00");
|
||||||
|
assert_eq!(format_hhmmss(1), "00:00:01");
|
||||||
|
assert_eq!(format_hhmmss(60), "00:01:00");
|
||||||
|
assert_eq!(format_hhmmss(61), "00:01:01");
|
||||||
|
assert_eq!(format_hhmmss(3600), "01:00:00");
|
||||||
|
assert_eq!(format_hhmmss(3661), "01:01:01");
|
||||||
|
assert_eq!(format_hhmmss(86399), "23:59:59");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_hhmmss_f64() {
|
||||||
|
assert_eq!(format_hhmmss_f64(123.4), "00:02:03");
|
||||||
|
assert_eq!(format_hhmmss_f64(123.6), "00:02:04");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_time_flexible() {
|
||||||
|
// HH:MM:SS format
|
||||||
|
assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723);
|
||||||
|
assert_eq!(parse_time_flexible("00:00:00").unwrap(), 0);
|
||||||
|
assert_eq!(parse_time_flexible("23:59:59").unwrap(), 86399);
|
||||||
|
|
||||||
|
// MM:SS format
|
||||||
|
assert_eq!(parse_time_flexible("02:03").unwrap(), 123);
|
||||||
|
assert_eq!(parse_time_flexible("00:00").unwrap(), 0);
|
||||||
|
|
||||||
|
// SS format
|
||||||
|
assert_eq!(parse_time_flexible("42").unwrap(), 42);
|
||||||
|
assert_eq!(parse_time_flexible("0").unwrap(), 0);
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
assert!(parse_time_flexible("").is_err());
|
||||||
|
assert!(parse_time_flexible("1:2:3:4").is_err());
|
||||||
|
assert!(parse_time_flexible("abc").is_err());
|
||||||
|
assert!(parse_time_flexible("1:abc").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_hhmmss_strict() {
|
||||||
|
assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723);
|
||||||
|
assert_eq!(parse_hhmmss_strict("00:00:00").unwrap(), 0);
|
||||||
|
assert_eq!(parse_hhmmss_strict("23:59:59").unwrap(), 86399);
|
||||||
|
|
||||||
|
// Errors - wrong format
|
||||||
|
assert!(parse_hhmmss_strict("02:03").is_err());
|
||||||
|
assert!(parse_hhmmss_strict("42").is_err());
|
||||||
|
|
||||||
|
// Errors - invalid values
|
||||||
|
assert!(parse_hhmmss_strict("00:60:00").is_err());
|
||||||
|
assert!(parse_hhmmss_strict("00:00:60").is_err());
|
||||||
|
assert!(parse_hhmmss_strict("abc:00:00").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ms_conversions() {
|
||||||
|
assert_eq!(ms_to_seconds(1000), 1);
|
||||||
|
assert_eq!(ms_to_seconds(1500), 1); // rounds down
|
||||||
|
assert_eq!(ms_to_seconds(999), 0);
|
||||||
|
|
||||||
|
assert_eq!(seconds_to_ms(1), 1000);
|
||||||
|
assert_eq!(seconds_to_ms(0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
221
pmocontrol/src/music_renderer/upnp_renderer.rs
Normal file
221
pmocontrol/src/music_renderer/upnp_renderer.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||||
|
VolumeControl,
|
||||||
|
};
|
||||||
|
use crate::model::PlaybackState;
|
||||||
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
use crate::upnp_clients::{
|
||||||
|
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||||
|
RenderingControlClient,
|
||||||
|
};
|
||||||
|
use crate::{DeviceIdentity, RendererInfo};
|
||||||
|
|
||||||
|
/// High-level handle representing a renderer and its optional AVTransport client.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UpnpRenderer {
|
||||||
|
avtransport: Option<AvTransportClient>,
|
||||||
|
rendering_control: Option<RenderingControlClient>,
|
||||||
|
connection_manager: Option<ConnectionManagerClient>,
|
||||||
|
has_avtransport_set_next: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpnpRenderer {
|
||||||
|
/// Retourne true si le renderer à un service de type AVTransport.
|
||||||
|
pub fn has_avtransport(&self) -> bool {
|
||||||
|
self.avtransport.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne true si le renderer à un service de type RenderingControl.
|
||||||
|
pub fn has_rendering_control(&self) -> bool {
|
||||||
|
self.rendering_control.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne true si le renderer à un service de type ConnectionManager.
|
||||||
|
pub fn has_connection_manager(&self) -> bool {
|
||||||
|
self.connection_manager.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement.
|
||||||
|
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
|
||||||
|
self.avtransport.as_ref().ok_or_else(|| {
|
||||||
|
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement.
|
||||||
|
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
|
||||||
|
self.rendering_control.as_ref().ok_or_else(|| {
|
||||||
|
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement.
|
||||||
|
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
|
||||||
|
self.connection_manager.as_ref().ok_or_else(|| {
|
||||||
|
ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
||||||
|
let cm = self.connection_manager()?;
|
||||||
|
cm.get_protocol_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
||||||
|
let cm = self.connection_manager()?;
|
||||||
|
cm.get_current_connection_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
||||||
|
let cm = self.connection_manager()?;
|
||||||
|
cm.get_current_connection_info(connection_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
|
if !self.has_avtransport_set_next {
|
||||||
|
return Err(ControlPointError::upnp_operation_not_supported(
|
||||||
|
"SetNextAVTransportURI",
|
||||||
|
"Renderer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.set_next_av_transport_uri(uri, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||||
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
// Prepare le service AVTTransport
|
||||||
|
let avtransport = match (
|
||||||
|
info.avtransport_control_url(),
|
||||||
|
info.avtransport_service_type(),
|
||||||
|
) {
|
||||||
|
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare le service RenderingControl
|
||||||
|
let rendering_control = match (
|
||||||
|
info.rendering_control_control_url(),
|
||||||
|
info.rendering_control_service_type(),
|
||||||
|
) {
|
||||||
|
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare le service ConnectionManager
|
||||||
|
let connection_manager = match (
|
||||||
|
info.connection_manager_control_url(),
|
||||||
|
info.connection_manager_service_type(),
|
||||||
|
) {
|
||||||
|
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exige AVTTransport et RenderingControl au minimum
|
||||||
|
if avtransport.is_none() || rendering_control.is_none() {
|
||||||
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
|
"Some mandatory services are missing on {:?}",
|
||||||
|
info.id(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
avtransport,
|
||||||
|
rendering_control,
|
||||||
|
connection_manager,
|
||||||
|
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
|
MusicRendererBackend::Upnp(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||||
|
///
|
||||||
|
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||||
|
impl TransportControl for UpnpRenderer {
|
||||||
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.set_av_transport_uri(uri, meta)?;
|
||||||
|
avt.play(0, "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play(&self) -> Result<(), ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.play(0, "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&self) -> Result<(), ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.pause(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> Result<(), ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.stop(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.seek(0, "REL_TIME", hhmmss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
|
||||||
|
///
|
||||||
|
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
||||||
|
impl VolumeControl for UpnpRenderer {
|
||||||
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
|
let rc = self.rendering_control()?;
|
||||||
|
rc.get_volume(0, "Master")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||||
|
let rc = self.rendering_control()?;
|
||||||
|
rc.set_volume(0, "Master", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
|
let rc = self.rendering_control()?;
|
||||||
|
rc.get_mute(0, "Master")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||||
|
let rc = self.rendering_control()?;
|
||||||
|
rc.set_mute(0, "Master", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
|
||||||
|
///
|
||||||
|
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
||||||
|
impl PlaybackStatus for UpnpRenderer {
|
||||||
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
let info = avt.get_transport_info(0)?;
|
||||||
|
Ok(PlaybackState::from_upnp_state(
|
||||||
|
&info.current_transport_state,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackPosition for UpnpRenderer {
|
||||||
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
let raw: PositionInfo = avt.get_position_info(0)?;
|
||||||
|
|
||||||
|
Ok(PlaybackPositionInfo {
|
||||||
|
track: Some(raw.track),
|
||||||
|
rel_time: raw.rel_time,
|
||||||
|
abs_time: raw.abs_time,
|
||||||
|
track_duration: raw.track_duration,
|
||||||
|
track_metadata: raw.track_metadata,
|
||||||
|
track_uri: raw.track_uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Instant, SystemTime},
|
time::{SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone,Debug)]
|
#[derive(Clone,Debug)]
|
||||||
pub struct DeviceConnectionState {
|
pub struct DeviceConnectionState {
|
||||||
online: bool,
|
online: bool,
|
||||||
last_seen: Instant,
|
last_seen: SystemTime,
|
||||||
max_age: u32,
|
max_age: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,13 +19,15 @@ pub trait DeviceOnline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceConnectionState {
|
impl DeviceConnectionState {
|
||||||
pub fn make() -> Arc<Mutex<Self>> {
|
pub fn new() -> Self {
|
||||||
Arc::new(Mutex::new(DeviceConnectionState {
|
DeviceConnectionState {
|
||||||
online: false,
|
online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 }
|
||||||
last_seen: std::time::UNIX_EPOCH,
|
|
||||||
max_age: 1800,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn make() -> Arc<Mutex<Self>> {
|
||||||
|
Arc::new(Mutex::new(DeviceConnectionState::new()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_online(&self) -> bool {
|
pub fn is_online(&self) -> bool {
|
||||||
self.online
|
self.online
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ impl DeviceConnectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
||||||
self.last_seen = Instant::now();
|
self.last_seen = SystemTime::now();
|
||||||
self.max_age = max_age;
|
self.max_age = max_age;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
/// Snapshot de la playlist native OpenHome pour un renderer donné.
|
|
||||||
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OpenHomePlaylistSnapshot {
|
|
||||||
/// ID du renderer concerné.
|
|
||||||
pub renderer_id: String,
|
|
||||||
/// ID courant dans la playlist (si connu).
|
|
||||||
pub current_id: Option<u32>,
|
|
||||||
/// Position courante dans la playlist (si connue).
|
|
||||||
pub current_index: Option<usize>,
|
|
||||||
/// Tracks présents dans la playlist native.
|
|
||||||
pub tracks: Vec<OpenHomePlaylistTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Représentation d'un track OpenHome tel qu'exposé par la playlist native.
|
|
||||||
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OpenHomePlaylistTrack {
|
|
||||||
/// ID interne OpenHome du track.
|
|
||||||
pub id: u32,
|
|
||||||
/// URI de lecture.
|
|
||||||
pub uri: String,
|
|
||||||
/// Titre (optionnel si non fourni par le renderer).
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// Artiste (optionnel).
|
|
||||||
pub artist: Option<String>,
|
|
||||||
/// Album (optionnel).
|
|
||||||
pub album: Option<String>,
|
|
||||||
/// URI de pochette (optionnelle).
|
|
||||||
pub album_art_uri: Option<String>,
|
|
||||||
}
|
|
||||||
@@ -28,103 +28,7 @@
|
|||||||
//! - This identity is used by the sync helpers to preserve the current
|
//! - This identity is used by the sync helpers to preserve the current
|
||||||
//! track across queue rebuilds when the MediaServer content changes.
|
//! track across queue rebuilds when the MediaServer content changes.
|
||||||
|
|
||||||
use crate::DeviceId;
|
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
||||||
use crate::errors::ControlPointError;
|
|
||||||
// ADAPTE ces imports aux modules existants dans pmocontrol.
|
|
||||||
// Exemple probable :
|
|
||||||
// use crate::model::MediaServerId;
|
|
||||||
// use crate::model::TrackMetadata;
|
|
||||||
use crate::model::TrackMetadata;
|
|
||||||
|
|
||||||
/// Canonical representation of a track in a renderer queue.
|
|
||||||
///
|
|
||||||
/// This type is the bridge between:
|
|
||||||
/// - the UPnP MediaServer (DIDL-Lite items),
|
|
||||||
/// - the ControlPoint runtime,
|
|
||||||
/// - and the different queue backends (internal / OpenHome).
|
|
||||||
///
|
|
||||||
/// It is intentionally DIDL-centric: every item in a queue comes from
|
|
||||||
/// a UPnP ContentDirectory and carries its MediaServer identity.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct PlaybackItem {
|
|
||||||
/// Identifier of the UPnP MediaServer that owns this content.
|
|
||||||
///
|
|
||||||
/// Typically this is the UDN of the MediaServer device, or an
|
|
||||||
/// equivalent logical identifier.
|
|
||||||
pub media_server_id: DeviceId,
|
|
||||||
|
|
||||||
/// L'ID interne de l'item dans la queue.
|
|
||||||
/// Cet ID n'a de sens que lors du retour d'un snapshot.
|
|
||||||
/// Dans une queue interne, il peut avoir n'importe quelle valeur,
|
|
||||||
/// l'ID qui compte et la position dans le vecteur.
|
|
||||||
/// Par principe, on la peut la mettre égale à usize::MAX.
|
|
||||||
pub backend_id: usize,
|
|
||||||
|
|
||||||
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
|
||||||
///
|
|
||||||
/// This, combined with `media_server_id`, is the logical identity
|
|
||||||
/// of the track across refreshes of the MediaServer state.
|
|
||||||
pub didl_id: String,
|
|
||||||
|
|
||||||
/// Main resource URI to be used for playback.
|
|
||||||
///
|
|
||||||
/// This is usually the first `<res>` element (or a selected one)
|
|
||||||
/// from the DIDL-Lite item.
|
|
||||||
pub uri: String,
|
|
||||||
|
|
||||||
/// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*").
|
|
||||||
///
|
|
||||||
/// This string describes the protocol, network, MIME type, and additional
|
|
||||||
/// info about the media resource. It's required for proper UPnP/OpenHome
|
|
||||||
/// renderer compatibility.
|
|
||||||
pub protocol_info: String,
|
|
||||||
|
|
||||||
/// Optional rich metadata for the track (title, artist, album, cover,
|
|
||||||
/// duration, …).
|
|
||||||
///
|
|
||||||
/// The exact structure is defined in `TrackMetadata` and may
|
|
||||||
/// aggregate information from DIDL, tags, or additional sources.
|
|
||||||
pub metadata: Option<TrackMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackItem {
|
|
||||||
/// Returns a stable, backend-agnostic logical identifier for this item.
|
|
||||||
///
|
|
||||||
/// By default this is the concatenation of the MediaServer identifier
|
|
||||||
/// and the DIDL `id`. Backends and higher-level logic should use this
|
|
||||||
/// when they need to match items across queue rebuilds.
|
|
||||||
pub fn unique_id(&self) -> String {
|
|
||||||
// ADAPTE si MediaServerId n'implémente pas Display : utilise
|
|
||||||
// un champ string interne ou une méthode as_str().
|
|
||||||
format!("{}::{}", self.media_server_id.0, self.didl_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Logical snapshot of a renderer queue.
|
|
||||||
///
|
|
||||||
/// This is the canonical view used by the ControlPoint and the REST/API
|
|
||||||
/// layer. It is independent of how the queue is actually stored (local
|
|
||||||
/// in-memory queue, OpenHome playlist, …).
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct QueueSnapshot {
|
|
||||||
/// All items currently in the queue, in play order.
|
|
||||||
pub items: Vec<PlaybackItem>,
|
|
||||||
/// Index (0-based) of the current item in `items`, or `None` if
|
|
||||||
/// no item is currently selected.
|
|
||||||
pub current_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl QueueSnapshot {
|
|
||||||
/// Returns the number of items in the snapshot.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.items.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the snapshot contains no items.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.items.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level enqueue mode.
|
/// High-level enqueue mode.
|
||||||
///
|
///
|
||||||
@@ -164,6 +68,24 @@ pub trait QueueBackend {
|
|||||||
// BACKEND PRIMITIVES (must be implemented)
|
// BACKEND PRIMITIVES (must be implemented)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Returns the length of this queue.
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError>;
|
||||||
|
|
||||||
|
/// Lists all the items in this queue.
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError>;
|
||||||
|
|
||||||
|
/// Converts a track ID to its position in the queue.
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError>;
|
||||||
|
|
||||||
|
/// Converts a track ID to its position in the queue.
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError>;
|
||||||
|
|
||||||
|
/// Return the current playing track identifier
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError>;
|
||||||
|
|
||||||
|
/// Returns the current playing track index in the queue
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError>;
|
||||||
|
|
||||||
/// Returns the full snapshot (items + current index) of this queue.
|
/// Returns the full snapshot (items + current index) of this queue.
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
|
||||||
|
|
||||||
@@ -181,12 +103,28 @@ pub trait QueueBackend {
|
|||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<(), ControlPointError>;
|
) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
/// Updates the queue while adjusting so that the new current_index
|
||||||
|
/// corresponds to the old current index track.
|
||||||
|
/// If the old current index track is absent from the new queue,
|
||||||
|
/// it is kept as the first item and the new items are appended after it.
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
/// Returns the item at `index`, if it exists.
|
/// Returns the item at `index`, if it exists.
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||||
|
|
||||||
/// Replaces the item at `index` with `item`.
|
/// Replaces the item at `index` with `item`.
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>;
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
/// Enqueues items according to the selected `EnqueueMode`.
|
||||||
|
///
|
||||||
|
/// This method only manipulates the queue structure; it does not
|
||||||
|
/// start playback.
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// DEFAULT HELPERS (backend-agnostic logic)
|
// DEFAULT HELPERS (backend-agnostic logic)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -196,41 +134,11 @@ pub trait QueueBackend {
|
|||||||
self.replace_queue(Vec::new(), None)
|
self.replace_queue(Vec::new(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `clear_queue`, semantic name for “empty before rebuild”.
|
|
||||||
fn empty_queue(&mut self) -> Result<(), ControlPointError> {
|
|
||||||
self.clear_queue()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current index, if any.
|
|
||||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
|
||||||
Ok(self.queue_snapshot()?.current_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of items in the queue.
|
|
||||||
fn len(&self) -> Result<usize, ControlPointError> {
|
|
||||||
Ok(self.queue_snapshot()?.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the queue is empty.
|
/// Returns `true` if the queue is empty.
|
||||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||||
Ok(self.queue_snapshot()?.is_empty())
|
Ok(self.queue_snapshot()?.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a full snapshot of the queue.
|
|
||||||
fn full_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
||||||
self.queue_snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an iterator over all items in the queue.
|
|
||||||
///
|
|
||||||
/// The default implementation:
|
|
||||||
/// - takes a snapshot,
|
|
||||||
/// - returns a boxed iterator owning the underlying `Vec`.
|
|
||||||
fn iter_items(&self) -> Result<Box<dyn Iterator<Item = PlaybackItem>>, ControlPointError> {
|
|
||||||
let snapshot = self.queue_snapshot()?;
|
|
||||||
Ok(Box::new(snapshot.items.into_iter()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the list of items that come strictly after the current index.
|
/// Returns the list of items that come strictly after the current index.
|
||||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
@@ -243,7 +151,12 @@ pub trait QueueBackend {
|
|||||||
|
|
||||||
/// Returns how many items remain in the queue after the current index.
|
/// Returns how many items remain in the queue after the current index.
|
||||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
Ok(self.upcoming_items()?.len())
|
let snapshot = self.queue_snapshot()?;
|
||||||
|
let len = snapshot.items.len();
|
||||||
|
match snapshot.current_index {
|
||||||
|
None => Ok(len),
|
||||||
|
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current item (or the first pending item if no index is set)
|
/// Returns the current item (or the first pending item if no index is set)
|
||||||
@@ -312,36 +225,6 @@ pub trait QueueBackend {
|
|||||||
Ok(Some((item, remaining)))
|
Ok(Some((item, remaining)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues items according to the selected `EnqueueMode`.
|
|
||||||
///
|
|
||||||
/// This method only manipulates the queue structure; it does not
|
|
||||||
/// start playback.
|
|
||||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<(), ControlPointError> {
|
|
||||||
let mut snapshot = self.queue_snapshot()?;
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
EnqueueMode::AppendToEnd => {
|
|
||||||
snapshot.items.extend(items);
|
|
||||||
}
|
|
||||||
EnqueueMode::InsertAfterCurrent => {
|
|
||||||
let insert_pos = snapshot
|
|
||||||
.current_index
|
|
||||||
.map(|i| (i + 1).min(snapshot.items.len()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
for (offset, it) in items.into_iter().enumerate() {
|
|
||||||
snapshot.items.insert(insert_pos + offset, it);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EnqueueMode::ReplaceAll => {
|
|
||||||
snapshot.items = items;
|
|
||||||
snapshot.current_index = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.replace_queue(snapshot.items, snapshot.current_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces the queue with `items` and sets a default index.
|
/// Replaces the queue with `items` and sets a default index.
|
||||||
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
@@ -410,7 +293,7 @@ pub trait QueueBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience helper to update an item “in place” at the given index.
|
/// Convenience helper to update an item "in place" at the given index.
|
||||||
fn update_item(
|
fn update_item(
|
||||||
&mut self,
|
&mut self,
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -420,34 +303,10 @@ pub trait QueueBackend {
|
|||||||
let new_item = update(item);
|
let new_item = update(item);
|
||||||
self.replace_item(index, new_item)
|
self.replace_item(index, new_item)
|
||||||
} else {
|
} else {
|
||||||
Err(ControlPointError::QueueError(format!("Queue index {} out of range", index)))
|
Err(ControlPointError::QueueError(format!(
|
||||||
}
|
"Queue index {} out of range",
|
||||||
}
|
index
|
||||||
|
)))
|
||||||
/// Synchronizes the queue with a new list of items coming from an
|
|
||||||
/// external MediaServer, trying to preserve the current track.
|
|
||||||
fn sync_from_external_preserve_current(&mut self, new_items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
|
||||||
let snapshot = self.queue_snapshot()?;
|
|
||||||
let current = snapshot
|
|
||||||
.current_index
|
|
||||||
.and_then(|i| snapshot.items.get(i).cloned());
|
|
||||||
|
|
||||||
let Some(current) = current else {
|
|
||||||
return self.replace_all(new_items);
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_uid = current.unique_id();
|
|
||||||
|
|
||||||
if let Some(new_idx) = new_items
|
|
||||||
.iter()
|
|
||||||
.position(|it| it.unique_id() == current_uid)
|
|
||||||
{
|
|
||||||
self.replace_queue(new_items, Some(new_idx))
|
|
||||||
} else {
|
|
||||||
let mut items = Vec::with_capacity(new_items.len() + 1);
|
|
||||||
items.push(current);
|
|
||||||
items.extend(new_items);
|
|
||||||
self.replace_queue(items, Some(0))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,11 @@
|
|||||||
//! - maintains a `current_index`,
|
//! - maintains a `current_index`,
|
||||||
//! - never starts playback (transport control is handled elsewhere).
|
//! - never starts playback (transport control is handled elsewhere).
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DeviceId, RendererInfo,
|
DeviceId, DeviceIdentity, RendererInfo,
|
||||||
DeviceIdentity,
|
|
||||||
errors::ControlPointError,
|
errors::ControlPointError,
|
||||||
queue::{
|
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||||
MusicQueue,
|
|
||||||
backend::{PlaybackItem, QueueBackend, QueueSnapshot},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Internal/local queue implementation.
|
/// Internal/local queue implementation.
|
||||||
@@ -54,27 +47,59 @@ impl InternalQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<InternalQueue, ControlPointError> {
|
||||||
info: &RendererInfo,
|
Ok(InternalQueue::new(info.id()))
|
||||||
) -> Result<InternalQueue, ControlPointError> {
|
|
||||||
Ok(InternalQueue::new(
|
|
||||||
info.id(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes a read-only view of the underlying items.
|
/// Exposes a read-only view of the underlying items.
|
||||||
pub fn items(&self) -> &[PlaybackItem] {
|
pub fn items(&self) -> &[PlaybackItem] {
|
||||||
&self.items
|
&self.items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exposes the current index (read-only).
|
|
||||||
pub fn current_index(&self) -> Option<usize> {
|
|
||||||
self.current_index
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueueBackend for InternalQueue {
|
impl QueueBackend for InternalQueue {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
Ok(self.items.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
let ids: Vec<u32> = (0..self.len()?).map(|i| i as u32).collect();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
Ok(id as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
u32::try_from(id).map_err(|_| {
|
||||||
|
ControlPointError::QueueError(format!(
|
||||||
|
"Position {} exceeds u32::MAX",
|
||||||
|
id
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
match self.current_index {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(i) => {
|
||||||
|
u32::try_from(i)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| {
|
||||||
|
ControlPointError::QueueError(format!(
|
||||||
|
"Current index {} exceeds u32::MAX",
|
||||||
|
i
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
Ok(self.current_index)
|
||||||
|
}
|
||||||
|
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
let mut items = self.items.clone();
|
let mut items = self.items.clone();
|
||||||
for (i, item) in items.iter_mut().enumerate() {
|
for (i, item) in items.iter_mut().enumerate() {
|
||||||
@@ -96,7 +121,11 @@ impl QueueBackend for InternalQueue {
|
|||||||
if i < self.items.len() {
|
if i < self.items.len() {
|
||||||
self.current_index = Some(i);
|
self.current_index = Some(i);
|
||||||
} else {
|
} else {
|
||||||
self.current_index = None;
|
return Err(ControlPointError::QueueError(format!(
|
||||||
|
"Index out of bound {} >= {}",
|
||||||
|
i,
|
||||||
|
self.items.len()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,14 +142,187 @@ impl QueueBackend for InternalQueue {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return self.replace_queue(Vec::new(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer l'item actuel
|
||||||
|
let current = self.current_index
|
||||||
|
.and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone())));
|
||||||
|
|
||||||
|
if let Some((_current_idx, current_uri)) = current {
|
||||||
|
// Chercher l'item actuel dans la nouvelle liste (par URI)
|
||||||
|
let new_idx = items.iter().position(|item| item.uri == current_uri);
|
||||||
|
|
||||||
|
if let Some(new_idx) = new_idx {
|
||||||
|
// Item trouvé dans la nouvelle liste
|
||||||
|
self.replace_queue(items, Some(new_idx))
|
||||||
|
} else {
|
||||||
|
// Item pas trouvé, le garder comme premier
|
||||||
|
let current_item = self.items[self.current_index.unwrap()].clone();
|
||||||
|
let mut new_items = Vec::with_capacity(items.len() + 1);
|
||||||
|
new_items.push(current_item);
|
||||||
|
new_items.extend(items);
|
||||||
|
self.replace_queue(new_items, Some(0))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pas d'item actuel
|
||||||
|
self.replace_queue(items, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: crate::queue::EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
use crate::queue::EnqueueMode;
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
EnqueueMode::AppendToEnd => {
|
||||||
|
self.items.extend(items);
|
||||||
|
}
|
||||||
|
EnqueueMode::InsertAfterCurrent => {
|
||||||
|
let insert_pos = self.current_index
|
||||||
|
.map(|i| (i + 1).min(self.items.len()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
for (offset, item) in items.into_iter().enumerate() {
|
||||||
|
self.items.insert(insert_pos + offset, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnqueueMode::ReplaceAll => {
|
||||||
|
self.items = items;
|
||||||
|
self.current_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
Ok(self.items.get(index).cloned())
|
if index < self.items.len() {
|
||||||
|
Ok(self.items.get(index).cloned())
|
||||||
|
} else {
|
||||||
|
Err(ControlPointError::QueueError(format!(
|
||||||
|
"get_item index out of bound {} >= {}",
|
||||||
|
index,
|
||||||
|
self.items.len()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized helpers for InternalQueue
|
||||||
|
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||||
|
self.items.clear();
|
||||||
|
self.current_index = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||||
|
Ok(self.items.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
let len = self.items.len();
|
||||||
|
match self.current_index {
|
||||||
|
None => Ok(len),
|
||||||
|
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
|
let items = match self.current_index {
|
||||||
|
None => self.items.clone(),
|
||||||
|
Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(),
|
||||||
|
};
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
|
if self.items.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = self.items.len();
|
||||||
|
let (item, resolved_index) = match self.current_index {
|
||||||
|
Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)),
|
||||||
|
_ => (self.items.first().cloned(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = match item {
|
||||||
|
Some(item) => item,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = match resolved_index {
|
||||||
|
Some(idx) => len.saturating_sub(idx + 1),
|
||||||
|
None => len,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some((item, remaining)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
|
if self.items.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = self.items.len();
|
||||||
|
let next_index = match self.current_index {
|
||||||
|
None => 0,
|
||||||
|
Some(idx) => {
|
||||||
|
let candidate = idx + 1;
|
||||||
|
if candidate >= len {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(item) = self.items.get(next_index).cloned() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = len.saturating_sub(next_index + 1);
|
||||||
|
self.current_index = Some(next_index);
|
||||||
|
Ok(Some((item, remaining)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
let was_empty = self.items.is_empty();
|
||||||
|
self.items.extend(items);
|
||||||
|
|
||||||
|
if was_empty && !self.items.is_empty() {
|
||||||
|
self.current_index = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
if index < self.items.len() {
|
if index < self.items.len() {
|
||||||
self.items[index] = item;
|
self.items[index] = item;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ControlPointError::QueueError(format!(
|
||||||
|
"Index out of bound {} >= {}",
|
||||||
|
index,
|
||||||
|
self.items.len()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
Ok(())
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueFromRendererInfo for InternalQueue {
|
||||||
|
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
InternalQueue::from_renderer_info(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicQueue {
|
||||||
|
MusicQueue::Internal(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
mod music_queue;
|
mod music_queue;
|
||||||
mod openhome;
|
|
||||||
mod backend;
|
mod backend;
|
||||||
|
mod snapshot;
|
||||||
|
mod openhome;
|
||||||
mod interne;
|
mod interne;
|
||||||
|
|
||||||
pub use music_queue::MusicQueue;
|
use std::sync::{Arc, Mutex};
|
||||||
pub use backend::{PlaybackItem, QueueBackend, QueueSnapshot, EnqueueMode};
|
|
||||||
|
pub use music_queue::MusicQueue;
|
||||||
|
pub use backend::{QueueBackend, EnqueueMode};
|
||||||
|
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||||
|
|
||||||
|
// Internal queue implementations - not part of the public API
|
||||||
|
pub(crate) use openhome::OpenHomeQueue;
|
||||||
|
pub(crate) use interne::InternalQueue;
|
||||||
|
|
||||||
|
use crate::{RendererInfo, errors::ControlPointError};
|
||||||
|
|
||||||
|
pub trait QueueFromRendererInfo {
|
||||||
|
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicQueue;
|
||||||
|
|
||||||
|
fn build_from_renderer_info(
|
||||||
|
renderer: &RendererInfo,
|
||||||
|
) -> Result<MusicQueue, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let instance = Self::from_renderer_info(renderer)?;
|
||||||
|
Ok(instance.to_backend())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_from_renderer_info(
|
||||||
|
renderer: &RendererInfo,
|
||||||
|
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let backend = Self::build_from_renderer_info(renderer)?;
|
||||||
|
Ok(Arc::new(Mutex::new(backend)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::RendererInfo;
|
|
||||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
use crate::queue::{
|
||||||
use crate::queue::backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
||||||
use crate::queue::interne::InternalQueue;
|
};
|
||||||
|
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MusicQueue {
|
pub enum MusicQueue {
|
||||||
@@ -12,39 +11,65 @@ pub enum MusicQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MusicQueue {
|
impl MusicQueue {
|
||||||
|
/// Creates a queue appropriate for the given renderer.
|
||||||
|
/// This is the factory method used by QueueFromRendererInfo trait.
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||||
if info.capabilities().has_oh_playlist() {
|
if info.capabilities().has_oh_playlist() {
|
||||||
|
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
|
||||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(info)?))
|
info,
|
||||||
|
)?))
|
||||||
} else {
|
} else {
|
||||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(info)?))
|
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
|
||||||
}
|
info,
|
||||||
}
|
)?))
|
||||||
|
|
||||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot, ControlPointError> {
|
|
||||||
match self {
|
|
||||||
MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(),
|
|
||||||
_ => Err(ControlPointError::QueueError(format!(
|
|
||||||
"OpenHome playlist snapshot is only available for OpenHome queues"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_with_attached_playlist(
|
|
||||||
&mut self,
|
|
||||||
items: Vec<PlaybackItem>,
|
|
||||||
current_index: Option<usize>,
|
|
||||||
) -> Result<(), ControlPointError> {
|
|
||||||
match self {
|
|
||||||
MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index),
|
|
||||||
MusicQueue::Internal(queue) => queue.replace_queue(items, current_index),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl QueueBackend for MusicQueue {
|
impl QueueBackend for MusicQueue {
|
||||||
|
// Primitives
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.len(),
|
||||||
|
MusicQueue::OpenHome(q) => q.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.track_ids(),
|
||||||
|
MusicQueue::OpenHome(q) => q.track_ids(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.id_to_position(id),
|
||||||
|
MusicQueue::OpenHome(q) => q.id_to_position(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.position_to_id(id),
|
||||||
|
MusicQueue::OpenHome(q) => q.position_to_id(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.current_track(),
|
||||||
|
MusicQueue::OpenHome(q) => q.current_track(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.current_index(),
|
||||||
|
MusicQueue::OpenHome(q) => q.current_index(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||||
@@ -70,6 +95,13 @@ impl QueueBackend for MusicQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.sync_queue(items),
|
||||||
|
MusicQueue::OpenHome(q) => q.sync_queue(items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
match self {
|
match self {
|
||||||
MusicQueue::Internal(q) => q.get_item(index),
|
MusicQueue::Internal(q) => q.get_item(index),
|
||||||
@@ -83,4 +115,75 @@ impl QueueBackend for MusicQueue {
|
|||||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
|
||||||
|
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized helpers
|
||||||
|
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.clear_queue(),
|
||||||
|
MusicQueue::OpenHome(q) => q.clear_queue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.is_empty(),
|
||||||
|
MusicQueue::OpenHome(q) => q.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.upcoming_len(),
|
||||||
|
MusicQueue::OpenHome(q) => q.upcoming_len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.upcoming_items(),
|
||||||
|
MusicQueue::OpenHome(q) => q.upcoming_items(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.peek_current(),
|
||||||
|
MusicQueue::OpenHome(q) => q.peek_current(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.dequeue_next(),
|
||||||
|
MusicQueue::OpenHome(q) => q.dequeue_next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicQueue::Internal(q) => q.append_or_init_index(items),
|
||||||
|
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueFromRendererInfo for MusicQueue {
|
||||||
|
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
MusicQueue::from_renderer_info(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_backend(self) -> MusicQueue {
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
91
pmocontrol/src/queue/snapshot.rs
Normal file
91
pmocontrol/src/queue/snapshot.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::{DeviceId, model::TrackMetadata};
|
||||||
|
|
||||||
|
/// Canonical representation of a track in a renderer queue.
|
||||||
|
///
|
||||||
|
/// This type is the bridge between:
|
||||||
|
/// - the UPnP MediaServer (DIDL-Lite items),
|
||||||
|
/// - the ControlPoint runtime,
|
||||||
|
/// - and the different queue backends (internal / OpenHome).
|
||||||
|
///
|
||||||
|
/// It is intentionally DIDL-centric: every item in a queue comes from
|
||||||
|
/// a UPnP ContentDirectory and carries its MediaServer identity.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PlaybackItem {
|
||||||
|
/// Identifier of the UPnP MediaServer that owns this content.
|
||||||
|
///
|
||||||
|
/// Typically this is the UDN of the MediaServer device, or an
|
||||||
|
/// equivalent logical identifier.
|
||||||
|
pub media_server_id: DeviceId,
|
||||||
|
|
||||||
|
/// Internal ID of the item in the queue.
|
||||||
|
/// This ID only has meaning when returning a snapshot.
|
||||||
|
/// In an internal queue, it can have any value; the position in the vector is what matters.
|
||||||
|
/// In principle, it can be set to usize::MAX.
|
||||||
|
pub backend_id: usize,
|
||||||
|
|
||||||
|
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
||||||
|
///
|
||||||
|
/// This, combined with `media_server_id`, is the logical identity
|
||||||
|
/// of the track across refreshes of the MediaServer state.
|
||||||
|
pub didl_id: String,
|
||||||
|
|
||||||
|
/// Main resource URI to be used for playback.
|
||||||
|
///
|
||||||
|
/// This is usually the first `<res>` element (or a selected one)
|
||||||
|
/// from the DIDL-Lite item.
|
||||||
|
pub uri: String,
|
||||||
|
|
||||||
|
/// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*").
|
||||||
|
///
|
||||||
|
/// This string describes the protocol, network, MIME type, and additional
|
||||||
|
/// info about the media resource. It's required for proper UPnP/OpenHome
|
||||||
|
/// renderer compatibility.
|
||||||
|
pub protocol_info: String,
|
||||||
|
|
||||||
|
/// Optional rich metadata for the track (title, artist, album, cover,
|
||||||
|
/// duration, …).
|
||||||
|
///
|
||||||
|
/// The exact structure is defined in `TrackMetadata` and may
|
||||||
|
/// aggregate information from DIDL, tags, or additional sources.
|
||||||
|
pub metadata: Option<TrackMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackItem {
|
||||||
|
/// Returns a stable, backend-agnostic logical identifier for this item.
|
||||||
|
///
|
||||||
|
/// By default this is the concatenation of the MediaServer identifier
|
||||||
|
/// and the DIDL `id`. Backends and higher-level logic should use this
|
||||||
|
/// when they need to match items across queue rebuilds.
|
||||||
|
pub fn unique_id(&self) -> String {
|
||||||
|
// ADAPTE si MediaServerId n'implémente pas Display : utilise
|
||||||
|
// un champ string interne ou une méthode as_str().
|
||||||
|
format!("{}::{}", self.media_server_id.0, self.didl_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Logical snapshot of a renderer queue.
|
||||||
|
///
|
||||||
|
/// This is the canonical view used by the ControlPoint and the REST/API
|
||||||
|
/// layer. It is independent of how the queue is actually stored (local
|
||||||
|
/// in-memory queue, OpenHome playlist, …).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct QueueSnapshot {
|
||||||
|
/// All items currently in the queue, in play order.
|
||||||
|
pub items: Vec<PlaybackItem>,
|
||||||
|
/// Index (0-based) of the current item in `items`, or `None` if
|
||||||
|
/// no item is currently selected.
|
||||||
|
pub current_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueSnapshot {
|
||||||
|
/// Returns the number of items in the snapshot.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.items.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the snapshot contains no items.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.items.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,28 @@ use std::collections::HashMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||||
use crate::media_server::MusicServer;
|
use crate::media_server::MusicServer;
|
||||||
use crate::model::RendererInfo;
|
use crate::model::RendererInfo;
|
||||||
use crate::music_renderer::MusicRenderer;
|
use crate::music_renderer::MusicRenderer;
|
||||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline, UpnpMediaServer};
|
use crate::{
|
||||||
|
DeviceId, DeviceIdentity, DeviceOnline, MediaServerEvent, RendererEvent, UpnpMediaServer,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_AGE: u32 = 1800;
|
const DEFAULT_MAX_AGE: u32 = 1800;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DeviceItem {
|
pub struct DeviceItem {
|
||||||
music_rendrer : Option<Arc<MusicRenderer>>,
|
music_renderer: Option<Arc<MusicRenderer>>,
|
||||||
music_server: Option<Arc<MusicServer>>,
|
music_server: Option<Arc<MusicServer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
devices: HashMap<DeviceId, DeviceItem>,
|
devices: HashMap<DeviceId, DeviceItem>,
|
||||||
udn_index: HashMap<String, DeviceId>,
|
udn_index: HashMap<String, DeviceId>,
|
||||||
|
renderer_bus: RendererEventBus,
|
||||||
|
server_bus: MediaServerEventBus,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -32,74 +34,85 @@ pub enum DeviceUpdate {
|
|||||||
|
|
||||||
impl DeviceItem {
|
impl DeviceItem {
|
||||||
pub fn as_music_renderer(&self) -> Result<Arc<MusicRenderer>, ControlPointError> {
|
pub fn as_music_renderer(&self) -> Result<Arc<MusicRenderer>, ControlPointError> {
|
||||||
match self {
|
self.music_renderer
|
||||||
DeviceItem::MusicRenderer(renderer) => Ok(Arc::clone(renderer)),
|
.clone() // Clone l'Option<Arc<...>>
|
||||||
_ => Err(ControlPointError::IsNotAMediaRender(format!("{:#?}", self))),
|
.ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_a_music_renderer(&self) -> bool {
|
pub fn is_a_music_renderer(&self) -> bool {
|
||||||
match self {
|
self.music_renderer.is_some()
|
||||||
DeviceItem::MusicRenderer(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_music_server(&self) -> Result<Arc<MusicServer>, ControlPointError> {
|
pub fn as_music_server(&self) -> Result<Arc<MusicServer>, ControlPointError> {
|
||||||
match self {
|
self.music_server
|
||||||
DeviceItem::MusicServer(server) => Ok(Arc::clone(server)),
|
.clone() // Clone l'Option<Arc<...>>
|
||||||
_ => Err(ControlPointError::IsNotAMediaServer(format!("{:#?}", self))),
|
.ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self)))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_a_music_server(&self) -> bool {
|
pub fn is_a_music_server(&self) -> bool {
|
||||||
match self {
|
self.music_server.is_some()
|
||||||
DeviceItem::MusicServer(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceOnline for DeviceItem {
|
impl DeviceOnline for DeviceItem {
|
||||||
fn is_online(&self) -> bool {
|
fn is_online(&self) -> bool {
|
||||||
match self {
|
self.music_renderer
|
||||||
DeviceItem::MusicRenderer(r) => r.is_online(),
|
.as_ref()
|
||||||
DeviceItem::MusicServer(s) => s.is_online(),
|
.map_or(false, |r| r.is_online())
|
||||||
}
|
|| self.music_server.as_ref().map_or(false, |s| s.is_online())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn last_seen(&self) -> SystemTime {
|
fn last_seen(&self) -> SystemTime {
|
||||||
match self {
|
let renderer_time = self.music_renderer.as_ref().map(|r| r.last_seen());
|
||||||
DeviceItem::MusicRenderer(r) => r.last_seen(),
|
let server_time = self.music_server.as_ref().map(|s| s.last_seen());
|
||||||
DeviceItem::MusicServer(s) => s.last_seen(),
|
|
||||||
|
match (renderer_time, server_time) {
|
||||||
|
(Some(r), Some(s)) => r.max(s), // Le plus récent
|
||||||
|
(Some(r), None) => r,
|
||||||
|
(None, Some(s)) => s,
|
||||||
|
(None, None) => unreachable!("DeviceEntry must have at least one component"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_been_seen_now(&self, max_age: u32) {
|
fn has_been_seen_now(&self, max_age: u32) {
|
||||||
match self {
|
if let Some(r) = &self.music_renderer {
|
||||||
DeviceItem::MusicRenderer(r) => r.has_been_seen_now(max_age),
|
r.has_been_seen_now(max_age);
|
||||||
DeviceItem::MusicServer(s) => s.has_been_seen_now(max_age),
|
}
|
||||||
|
if let Some(s) = &self.music_server {
|
||||||
|
s.has_been_seen_now(max_age);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_as_offline(&self) {
|
fn mark_as_offline(&self) {
|
||||||
match self {
|
if let Some(r) = &self.music_renderer {
|
||||||
DeviceItem::MusicRenderer(r) => r.mark_as_offline(),
|
r.mark_as_offline();
|
||||||
DeviceItem::MusicServer(s) => s.mark_as_offline(),
|
}
|
||||||
|
if let Some(s) = &self.music_server {
|
||||||
|
s.mark_as_offline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn max_age(&self) -> u32 {
|
fn max_age(&self) -> u32 {
|
||||||
match self {
|
let renderer_max_age = self.music_renderer.as_ref().map(|r| r.max_age());
|
||||||
DeviceItem::MusicRenderer(r) => r.max_age(),
|
let server_max_age = self.music_server.as_ref().map(|s| s.max_age());
|
||||||
DeviceItem::MusicServer(s) => s.max_age(),
|
|
||||||
|
match (renderer_max_age, server_max_age) {
|
||||||
|
(Some(r), Some(s)) => r.max(s), // Le plus récent
|
||||||
|
(Some(r), None) => r,
|
||||||
|
(None, Some(s)) => s,
|
||||||
|
(None, None) => unreachable!("DeviceEntry must have at least one component"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
devices: HashMap::new(),
|
||||||
|
udn_index: HashMap::new(),
|
||||||
|
renderer_bus,
|
||||||
|
server_bus,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_renderers(&self) -> Result<Vec<Arc<MusicRenderer>>, ControlPointError> {
|
pub fn list_renderers(&self) -> Result<Vec<Arc<MusicRenderer>>, ControlPointError> {
|
||||||
@@ -126,59 +139,6 @@ impl DeviceRegistry {
|
|||||||
self.devices.get(id)?.as_music_server().ok()
|
self.devices.get(id)?.as_music_server().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_renderer(&mut self, info: RendererInfo, max_age: u32) {
|
|
||||||
if let Some(existing) = self.devices.get(&info.id()) {
|
|
||||||
existing.has_been_seen_now(max_age);
|
|
||||||
} else {
|
|
||||||
// let renderer = MusicRenderer::from_renderer_info(info);
|
|
||||||
match MusicRenderer::from_renderer_info(info.clone()) {
|
|
||||||
Ok(renderer) => {
|
|
||||||
self.devices
|
|
||||||
.insert(info.id(), DeviceItem::MusicRenderer(renderer));
|
|
||||||
self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id());
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!("Failed to create renderer: {:#?}\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_server(&mut self, info: UpnpMediaServer, max_age: u32) {
|
|
||||||
if let Some(existing) = self.devices.get(&info.id()) {
|
|
||||||
existing.has_been_seen_now(max_age);
|
|
||||||
} else {
|
|
||||||
let server = MusicServer::Upnp(info.clone());
|
|
||||||
self.devices
|
|
||||||
.insert(info.id(), DeviceItem::MusicServer(Arc::new(server)));
|
|
||||||
self.udn_index.insert(info.udn().to_ascii_lowercase(), info.id());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn device_says_byebye(&mut self, udn: &str) {
|
|
||||||
if let Some(device) = self.get_device_by_udn(udn) {
|
|
||||||
device.mark_as_offline();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_update(&mut self, update: DeviceUpdate) {
|
|
||||||
match update {
|
|
||||||
DeviceUpdate::OfflineById(id) => {
|
|
||||||
if let Some(renderer) = self.devices.get(&id) {
|
|
||||||
renderer.mark_as_offline();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeviceUpdate::OfflineByUdn(udn) => {
|
|
||||||
let lookup = udn.to_ascii_lowercase();
|
|
||||||
if let Some(id) = self.udn_index.get(&lookup) {
|
|
||||||
if let Some(renderer) = self.devices.get(id) {
|
|
||||||
renderer.mark_as_offline();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_device_by_udn(&self, udn: &str) -> Option<DeviceItem> {
|
pub fn get_device_by_udn(&self, udn: &str) -> Option<DeviceItem> {
|
||||||
let lookup = udn.to_ascii_lowercase();
|
let lookup = udn.to_ascii_lowercase();
|
||||||
self.udn_index
|
self.udn_index
|
||||||
@@ -197,4 +157,147 @@ impl DeviceRegistry {
|
|||||||
self.get_device_by_udn(udn)
|
self.get_device_by_udn(udn)
|
||||||
.and_then(|item| item.as_music_server().ok())
|
.and_then(|item| item.as_music_server().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn push_renderer(&mut self, info: &RendererInfo, max_age: u32) {
|
||||||
|
let device_id = info.id();
|
||||||
|
|
||||||
|
if let Some(entry) = self.devices.get_mut(&device_id) {
|
||||||
|
if let Some(renderer) = &entry.music_renderer {
|
||||||
|
let was_online = renderer.is_online();
|
||||||
|
renderer.has_been_seen_now(max_age);
|
||||||
|
|
||||||
|
if !was_online {
|
||||||
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
|
id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Entry existe mais pas de renderer -> on l'ajoute
|
||||||
|
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||||
|
entry.music_renderer = Some(Arc::new(new_renderer));
|
||||||
|
self.udn_index
|
||||||
|
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
|
id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Entry n'existe pas -> on crée
|
||||||
|
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||||
|
let new_entry = DeviceItem {
|
||||||
|
music_renderer: Some(Arc::new(new_renderer)),
|
||||||
|
music_server: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.devices.insert(device_id.clone(), new_entry);
|
||||||
|
self.udn_index
|
||||||
|
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
|
id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_server(&mut self, info: &UpnpMediaServer, max_age: u32) {
|
||||||
|
let device_id = info.id();
|
||||||
|
|
||||||
|
if let Some(entry) = self.devices.get_mut(&device_id) {
|
||||||
|
if let Some(server) = &entry.music_server {
|
||||||
|
let was_online = server.is_online();
|
||||||
|
server.has_been_seen_now(max_age);
|
||||||
|
|
||||||
|
if !was_online {
|
||||||
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
|
server_id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Entry existe mais pas de renderer -> on l'ajoute
|
||||||
|
if let Ok(new_server) = MusicServer::from_server_info(info) {
|
||||||
|
entry.music_server = Some(Arc::new(new_server));
|
||||||
|
self.udn_index
|
||||||
|
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
|
server_id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Entry n'existe pas -> on crée
|
||||||
|
if let Ok(new_server) = MusicServer::from_server_info(info) {
|
||||||
|
let new_entry = DeviceItem {
|
||||||
|
music_renderer: None,
|
||||||
|
music_server: Some(Arc::new(new_server)),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.devices.insert(device_id.clone(), new_entry);
|
||||||
|
self.udn_index
|
||||||
|
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
|
server_id: device_id,
|
||||||
|
info: info.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_says_byebye(&mut self, udn: &str) {
|
||||||
|
let lookup = udn.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if let Some(id) = self.udn_index.get(&lookup) {
|
||||||
|
if let Some(device) = self.devices.get(id) {
|
||||||
|
device.mark_as_offline();
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
if device.is_a_music_renderer() {
|
||||||
|
self.renderer_bus
|
||||||
|
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||||
|
}
|
||||||
|
if device.is_a_music_server() {
|
||||||
|
self.server_bus.broadcast(MediaServerEvent::Offline {
|
||||||
|
server_id: id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_timeouts(&mut self) {
|
||||||
|
let now = SystemTime::now();
|
||||||
|
|
||||||
|
for (id, device) in &self.devices {
|
||||||
|
if let Ok(elapsed) = now.duration_since(device.last_seen()) {
|
||||||
|
if elapsed.as_secs() > device.max_age() as u64 {
|
||||||
|
device.mark_as_offline();
|
||||||
|
|
||||||
|
// Broadcast sur le bon bus
|
||||||
|
if device.is_a_music_renderer() {
|
||||||
|
self.renderer_bus
|
||||||
|
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||||
|
}
|
||||||
|
if device.is_a_music_server() {
|
||||||
|
self.server_bus.broadcast(MediaServerEvent::Offline {
|
||||||
|
server_id: id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
pmocontrol/src/upnp_clients/mod.rs
Normal file
41
pmocontrol/src/upnp_clients/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
mod openhome_client;
|
||||||
|
mod avtransport_client;
|
||||||
|
mod rendering_control_client;
|
||||||
|
mod connection_manager_client;
|
||||||
|
|
||||||
|
|
||||||
|
pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo};
|
||||||
|
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
|
||||||
|
pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||||
|
pub use crate::upnp_clients::openhome_client::{
|
||||||
|
OPENHOME_PLAYLIST_HEAD_ID,
|
||||||
|
OhTrackEntry,OhTrack,
|
||||||
|
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve a possibly relative controlURL against the description URL.
|
||||||
|
///
|
||||||
|
/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is.
|
||||||
|
/// - Otherwise, it is resolved against the scheme://host:port of `description_url`.
|
||||||
|
pub fn resolve_control_url(description_url: &str, control_url: &str) -> String {
|
||||||
|
if control_url.starts_with("http://") || control_url.starts_with("https://") {
|
||||||
|
return control_url.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract "scheme://host[:port]" from description_url
|
||||||
|
if let Some((scheme, rest)) = description_url.split_once("://") {
|
||||||
|
if let Some(pos) = rest.find('/') {
|
||||||
|
let authority = &rest[..pos];
|
||||||
|
let base = format!("{}://{}", scheme, authority);
|
||||||
|
|
||||||
|
if control_url.starts_with('/') {
|
||||||
|
return format!("{}{}", base, control_url);
|
||||||
|
} else {
|
||||||
|
return format!("{}/{}", base, control_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just return the raw control_url if we cannot parse
|
||||||
|
control_url.to_string()
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use crate::soap_client::{
|
|||||||
invoke_upnp_action, parse_bool, parse_visible_flag,
|
invoke_upnp_action, parse_bool, parse_visible_flag,
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
use pmodidl::DIDLLite;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
@@ -17,11 +18,25 @@ use crate::model::RendererInfo;
|
|||||||
/// historical 0xFFFFFFFF sentinel.
|
/// historical 0xFFFFFFFF sentinel.
|
||||||
pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0;
|
pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub trait OhTrack {
|
||||||
pub struct OhTrackEntry {
|
fn uri(&self) -> &str;
|
||||||
pub id: u32,
|
fn metadata_xml(&self) -> Option<&str>;
|
||||||
pub uri: String,
|
|
||||||
pub metadata_xml: String,
|
fn metadata(&self) -> Option<TrackMetadata> {
|
||||||
|
self.metadata_xml()
|
||||||
|
.as_deref()
|
||||||
|
.and_then(parse_track_metadata_from_didl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn didl_id(&self) -> Option<String> {
|
||||||
|
let trimmed = self.metadata_xml()?.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = pmodidl::parse_metadata::<DIDLLite>(trimmed).ok()?;
|
||||||
|
parsed.data.items.first().map(|item| item.id.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -31,10 +46,20 @@ pub struct OhInfoTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OhInfoTrack {
|
impl OhInfoTrack {
|
||||||
pub fn metadata(&self) -> Option<TrackMetadata> {
|
pub fn new(uri: &str, metadata_xml: Option<&str>) -> Self {
|
||||||
self.metadata_xml
|
OhInfoTrack {
|
||||||
.as_deref()
|
uri: uri.to_string(),
|
||||||
.and_then(parse_track_metadata_from_didl)
|
metadata_xml: metadata_xml.map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OhTrack for OhInfoTrack {
|
||||||
|
fn metadata_xml(&self) -> Option<&str> {
|
||||||
|
self.metadata_xml.as_deref()
|
||||||
|
}
|
||||||
|
fn uri(&self) -> &str {
|
||||||
|
&self.uri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +83,52 @@ pub struct OhProductSource {
|
|||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OhInfoClient {
|
||||||
|
pub control_url: String,
|
||||||
|
pub service_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OhPlaylistClient {
|
pub struct OhPlaylistClient {
|
||||||
pub control_url: String,
|
pub control_url: String,
|
||||||
pub service_type: String,
|
pub service_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OhTrackEntry {
|
||||||
|
pub id: u32,
|
||||||
|
uri: String,
|
||||||
|
metadata_xml: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OhTrackEntry {
|
||||||
|
pub fn new(id: u32, uri: &str, metadata_xml: &str) -> Self {
|
||||||
|
OhTrackEntry {
|
||||||
|
id,
|
||||||
|
uri: uri.to_string(),
|
||||||
|
metadata_xml: metadata_xml.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> u32 {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OhTrack for OhTrackEntry {
|
||||||
|
fn metadata_xml(&self) -> Option<&str> {
|
||||||
|
if self.metadata_xml.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&self.metadata_xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn uri(&self) -> &str {
|
||||||
|
&self.uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl OhPlaylistClient {
|
impl OhPlaylistClient {
|
||||||
pub fn new(control_url: String, service_type: String) -> Self {
|
pub fn new(control_url: String, service_type: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -73,14 +138,46 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_playlist_control_url()
|
let control_url = info.oh_playlist_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create playlist control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_playlist_service_type()
|
let service_type = info.oh_playlist_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create playlist service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create playlist service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhPlaylistClient::new(control_url, service_type))
|
Ok(OhPlaylistClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report the uri and metadata for a given track id.
|
||||||
|
/// Returns a 800 fault code if the given id is not in the playlist.
|
||||||
|
pub fn read(&self, id: u32) -> Result<OhTrackEntry, ControlPointError> {
|
||||||
|
let id_str = id.to_string();
|
||||||
|
let args = [("IdList", id_str.as_str())];
|
||||||
|
|
||||||
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Read", &args)?;
|
||||||
|
|
||||||
|
if call_result.status == 800 {
|
||||||
|
return Err(ControlPointError::OpenHomeError(format!(
|
||||||
|
"Id {} not found in OpenHome playlist",
|
||||||
|
id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("Read", &call_result)?;
|
||||||
|
|
||||||
|
let response =
|
||||||
|
find_child_with_suffix(&envelope.body.content, "ReadResponse").ok_or_else(|| {
|
||||||
|
ControlPointError::OpenHomeError(format!(
|
||||||
|
"Missing ReadResponse element in SOAP body"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let track_uri = extract_child_text(response, "Uri")?;
|
||||||
|
let metadata = extract_child_text(response, "Metadata")?;
|
||||||
|
|
||||||
|
Ok(OhTrackEntry::new(id, &track_uri, &metadata))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||||
if id_list.is_empty() {
|
if id_list.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -96,9 +193,13 @@ impl OhPlaylistClient {
|
|||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
|
||||||
|
|
||||||
let envelope = ensure_success("ReadList", &call_result)?;
|
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError(format!("Missing ReadListResponse element in SOAP body")))?;
|
.ok_or_else(|| {
|
||||||
|
ControlPointError::OpenHomeError(format!(
|
||||||
|
"Missing ReadListResponse element in SOAP body"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?;
|
let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?;
|
||||||
let track_list_sample: String = track_list_b64.chars().take(256).collect();
|
let track_list_sample: String = track_list_b64.chars().take(256).collect();
|
||||||
@@ -143,7 +244,9 @@ impl OhPlaylistClient {
|
|||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_id(&self, id: u32) -> Result<(), ControlPointError> {
|
/// The id of the current track (the track currently playing or that would be played
|
||||||
|
/// if the Play action was invoked). Or 0 if the playlist is empty.
|
||||||
|
pub fn seek_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("Value", id_str.as_str())];
|
let args = [("Value", id_str.as_str())];
|
||||||
|
|
||||||
@@ -171,11 +274,13 @@ impl OhPlaylistClient {
|
|||||||
pub fn id(&self) -> Result<u32, ControlPointError> {
|
pub fn id(&self) -> Result<u32, ControlPointError> {
|
||||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
||||||
|
|
||||||
let envelope = ensure_success("Id", &call_result)
|
let envelope = ensure_success("Id", &call_result)?;
|
||||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("IdResponse".to_string()))?;
|
ControlPointError::OpenHomeError(format!(
|
||||||
|
"Missing ReadResponse element in SOAP body"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let id_text = extract_child_text(response, "Value")?;
|
let id_text = extract_child_text(response, "Value")?;
|
||||||
let id = id_text.parse::<u32>().map_err(|_| {
|
let id = id_text.parse::<u32>().map_err(|_| {
|
||||||
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text)
|
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text)
|
||||||
@@ -276,15 +381,6 @@ impl OhPlaylistClient {
|
|||||||
handle_action_response("DeleteAll", &call_result)
|
handle_action_response("DeleteAll", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_id(&self) -> Result<String> {
|
|
||||||
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 value: String = extract_child_text_any(response, &["Value"])?;
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
|
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
||||||
@@ -387,12 +483,6 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OhInfoClient {
|
|
||||||
pub control_url: String,
|
|
||||||
pub service_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OhInfoClient {
|
impl OhInfoClient {
|
||||||
pub fn new(control_url: String, service_type: String) -> Self {
|
pub fn new(control_url: String, service_type: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -402,11 +492,13 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_info_control_url()
|
let control_url = info.oh_info_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create info control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_info_service_type()
|
let service_type = info.oh_info_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create info service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create info service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhInfoClient::new(control_url, service_type))
|
Ok(OhInfoClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,11 +564,13 @@ impl OhTimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_time_control_url()
|
let control_url = info.oh_time_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create time control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_time_service_type()
|
let service_type = info.oh_time_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create time service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create time service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhTimeClient::new(control_url, service_type))
|
Ok(OhTimeClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,15 +622,16 @@ impl OhVolumeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_volume_control_url()
|
let control_url = info.oh_volume_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create volume control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_volume_service_type()
|
let service_type = info.oh_volume_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create volume service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create volume service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhVolumeClient::new(control_url, service_type))
|
Ok(OhVolumeClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
||||||
let envelope = ensure_success("Volume", &call_result)?;
|
let envelope = ensure_success("Volume", &call_result)?;
|
||||||
@@ -596,11 +691,13 @@ impl OhRadioClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_radio_control_url()
|
let control_url = info.oh_radio_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create radio control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_radio_service_type()
|
let service_type = info.oh_radio_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create radio service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create radio service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhRadioClient::new(control_url, service_type))
|
Ok(OhRadioClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,15 +743,16 @@ impl OhProductClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let control_url = info.oh_product_control_url()
|
let control_url = info.oh_product_control_url().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product control client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create product control client".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let service_type = info.oh_product_service_type()
|
let service_type = info.oh_product_service_type().ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::OpenHomeError("Cannot create product service client".to_string()))?;
|
ControlPointError::OpenHomeError("Cannot create product service client".to_string())
|
||||||
|
})?;
|
||||||
Ok(OhProductClient::new(control_url, service_type))
|
Ok(OhProductClient::new(control_url, service_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
||||||
let call_result =
|
let call_result =
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
||||||
@@ -799,6 +897,15 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
||||||
|
if xml.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
||||||
|
parsed.data.items.first().map(|item| item.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||||
let trimmed = payload.trim();
|
let trimmed = payload.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -810,7 +917,9 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointErro
|
|||||||
} else {
|
} else {
|
||||||
let bytes = decode_base64(trimmed)?;
|
let bytes = decode_base64(trimmed)?;
|
||||||
let decoded = String::from_utf8(bytes).map_err(|err| {
|
let decoded = String::from_utf8(bytes).map_err(|err| {
|
||||||
ControlPointError::OpenHomeError(format!("TrackList payload not valid UTF-8 after base64 decode: {err}"))
|
ControlPointError::OpenHomeError(format!(
|
||||||
|
"TrackList payload not valid UTF-8 after base64 decode: {err}"
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
(decoded, true)
|
(decoded, true)
|
||||||
};
|
};
|
||||||
@@ -823,8 +932,9 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointErro
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
||||||
let root = Element::parse(&mut reader)
|
let root = Element::parse(&mut reader).map_err(|err| {
|
||||||
.map_err(|err| ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err)))?;
|
ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err))
|
||||||
|
})?;
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
for node in &root.children {
|
for node in &root.children {
|
||||||
@@ -856,9 +966,9 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError>
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = id_text
|
let id = id_text.parse::<u32>().map_err(|_| {
|
||||||
.parse::<u32>()
|
ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text))
|
||||||
.map_err(|_| ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text)))?;
|
})?;
|
||||||
|
|
||||||
let uri = extract_child_text_local(elem, "Uri")?;
|
let uri = extract_child_text_local(elem, "Uri")?;
|
||||||
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
|
||||||
use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
|
||||||
use crate::errors::ControlPointError;
|
|
||||||
use crate::rendering_control_client::RenderingControlClient;
|
|
||||||
use crate::{
|
|
||||||
AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererInfo,
|
|
||||||
TransportControl, VolumeControl,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// High-level handle representing a renderer and its optional AVTransport client.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UpnpRenderer {
|
|
||||||
// registry: Arc<RwLock<DeviceRegistry>>,
|
|
||||||
avtransport: Option<AvTransportClient>,
|
|
||||||
rendering_control: Option<RenderingControlClient>,
|
|
||||||
connection_manager: Option<ConnectionManagerClient>,
|
|
||||||
has_avtransport_set_next: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpnpRenderer {
|
|
||||||
pub fn has_avtransport(&self) -> bool {
|
|
||||||
self.avtransport.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_rendering_control(&self) -> bool {
|
|
||||||
self.rendering_control.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_connection_manager(&self) -> bool {
|
|
||||||
self.connection_manager.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
|
|
||||||
self.avtransport.as_ref().ok_or_else(|| {
|
|
||||||
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
|
|
||||||
self.rendering_control.as_ref().ok_or_else(|| {
|
|
||||||
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
|
|
||||||
self.connection_manager
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.set_av_transport_uri(uri, meta)?;
|
|
||||||
avt.play(0, "1")
|
|
||||||
}
|
|
||||||
|
|
||||||
// /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI.
|
|
||||||
// pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
|
||||||
// if !self.has_avtransport_set_next {
|
|
||||||
// return Err(op_not_supported("SetNextAVTransportURI", "AVTransport"));
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let client = self.avtransport()?;
|
|
||||||
// let result = client.set_next_av_transport_uri(next_uri, next_meta);
|
|
||||||
|
|
||||||
// if result.is_ok() {
|
|
||||||
// let mut reg = self.registry.write().unwrap();
|
|
||||||
// reg.mark_renderer_supports_set_next(&self.info.id);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// result
|
|
||||||
// }
|
|
||||||
|
|
||||||
pub fn pause(&self) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.pause(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.stop(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.seek(0, "REL_TIME", hhmmss)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_master_volume(&self) -> Result<u16, ControlPointError> {
|
|
||||||
let rc = self.rendering_control()?;
|
|
||||||
rc.get_volume(0, "Master")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_master_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
|
||||||
let rc = self.rendering_control()?;
|
|
||||||
rc.set_volume(0, "Master", volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_master_mute(&self) -> Result<bool, ControlPointError> {
|
|
||||||
let rc = self.rendering_control()?;
|
|
||||||
rc.get_mute(0, "Master")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_master_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
|
||||||
let rc = self.rendering_control()?;
|
|
||||||
rc.set_mute(0, "Master", mute)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
|
||||||
let cm = self.connection_manager()?;
|
|
||||||
cm.get_protocol_info()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
|
||||||
let cm = self.connection_manager()?;
|
|
||||||
cm.get_current_connection_ids()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
|
||||||
let cm = self.connection_manager()?;
|
|
||||||
cm.get_current_connection_info(connection_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_info(info: &RendererInfo) -> Self {
|
|
||||||
let avtransport = match (
|
|
||||||
info.avtransport_control_url(),
|
|
||||||
info.avtransport_service_type(),
|
|
||||||
) {
|
|
||||||
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rendering_control = match (
|
|
||||||
info.rendering_control_control_url(),
|
|
||||||
info.rendering_control_service_type(),
|
|
||||||
) {
|
|
||||||
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let connection_manager = match (
|
|
||||||
info.connection_manager_control_url(),
|
|
||||||
info.connection_manager_service_type(),
|
|
||||||
) {
|
|
||||||
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
// registry: Arc::clone(registry),
|
|
||||||
avtransport,
|
|
||||||
rendering_control,
|
|
||||||
connection_manager,
|
|
||||||
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
|
||||||
///
|
|
||||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
|
||||||
impl TransportControl for UpnpRenderer {
|
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.set_av_transport_uri(uri, meta)?;
|
|
||||||
avt.play(0, "1")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play(&self) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.play(0, "1")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pause(&self) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.pause(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.stop(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
avt.seek(0, "REL_TIME", hhmmss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
|
|
||||||
///
|
|
||||||
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
|
||||||
impl VolumeControl for UpnpRenderer {
|
|
||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
||||||
self.get_master_volume()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
|
||||||
self.set_master_volume(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
||||||
self.get_master_mute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
||||||
self.set_master_mute(m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
|
|
||||||
///
|
|
||||||
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
|
||||||
impl PlaybackStatus for UpnpRenderer {
|
|
||||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
let info = avt.get_transport_info(0)?;
|
|
||||||
Ok(PlaybackState::from_upnp_state(
|
|
||||||
&info.current_transport_state,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaybackPosition for UpnpRenderer {
|
|
||||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
||||||
let avt = self.avtransport()?;
|
|
||||||
let raw: PositionInfo = avt.get_position_info(0)?;
|
|
||||||
|
|
||||||
Ok(PlaybackPositionInfo {
|
|
||||||
track: Some(raw.track),
|
|
||||||
rel_time: raw.rel_time,
|
|
||||||
abs_time: raw.abs_time,
|
|
||||||
track_duration: raw.track_duration,
|
|
||||||
track_metadata: raw.track_metadata,
|
|
||||||
track_uri: raw.track_uri,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #[cfg(test)]
|
|
||||||
// mod tests {
|
|
||||||
// use super::*;
|
|
||||||
// use crate::model::{RendererCapabilities, RendererProtocol};
|
|
||||||
// use crate::registry::{DeviceRegistry, DeviceUpdate};
|
|
||||||
// use std::sync::{Arc, RwLock};
|
|
||||||
// use std::time::SystemTime;
|
|
||||||
|
|
||||||
// // fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo {
|
|
||||||
// // RendererInfo {
|
|
||||||
// // id: RendererId(format!("renderer-{id_suffix}")),
|
|
||||||
// // udn: format!("uuid:renderer-{id_suffix}"),
|
|
||||||
// // friendly_name: format!("Renderer {id_suffix}"),
|
|
||||||
// // model_name: "Model".into(),
|
|
||||||
// // manufacturer: "Manufacturer".into(),
|
|
||||||
// // protocol: RendererProtocol::UpnpAvOnly,
|
|
||||||
// // capabilities: RendererCapabilities {
|
|
||||||
// // has_avtransport: with_avtransport,
|
|
||||||
// // ..RendererCapabilities::default()
|
|
||||||
// // },
|
|
||||||
// // location: "http://127.0.0.1/device.xml".into(),
|
|
||||||
// // server_header: "TestServer/1.0".into(),
|
|
||||||
// // avtransport_service_type: with_avtransport
|
|
||||||
// // .then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()),
|
|
||||||
// // avtransport_control_url: with_avtransport
|
|
||||||
// // .then(|| "http://127.0.0.1/avtransport".into()),
|
|
||||||
// // rendering_control_service_type: None,
|
|
||||||
// // 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,
|
|
||||||
// // oh_product_service_type: None,
|
|
||||||
// // oh_product_control_url: None,
|
|
||||||
// // }
|
|
||||||
// // }
|
|
||||||
|
|
||||||
// fn registry_with_renderer(info: RendererInfo) -> Arc<RwLock<DeviceRegistry>> {
|
|
||||||
// let mut registry = DeviceRegistry::new();
|
|
||||||
// registry.apply_update(DeviceUpdate::RendererOnline(info));
|
|
||||||
// Arc::new(RwLock::new(registry))
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[test]
|
|
||||||
// fn renderer_without_avtransport() {
|
|
||||||
// let info = renderer_info("no-avt", false);
|
|
||||||
// let registry = registry_with_renderer(info.clone());
|
|
||||||
// let renderer = UpnpRenderer::from_registry(info, ®istry);
|
|
||||||
|
|
||||||
// assert_eq!(renderer.has_avtransport(), false);
|
|
||||||
// assert_eq!(renderer.id().0, "renderer-no-avt");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #[test]
|
|
||||||
// fn renderer_with_avtransport() {
|
|
||||||
// let info = renderer_info("with-avt", true);
|
|
||||||
// let registry = registry_with_renderer(info.clone());
|
|
||||||
// let renderer = UpnpRenderer::from_registry(info, ®istry);
|
|
||||||
|
|
||||||
// assert!(renderer.has_avtransport());
|
|
||||||
// assert_eq!(renderer.friendly_name(), "Renderer with-avt");
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
Reference in New Issue
Block a user