- Replace `.lock().unwrap()` with explicit error messages for poisoned MutexGuard - Improve robustness against mutex poisoning in renderer, queue and event subsystems
1160 lines
41 KiB
Rust
1160 lines
41 KiB
Rust
use crate::errors::ControlPointError;
|
|
use crate::model::TrackMetadata;
|
|
use crate::soap_client::{
|
|
decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text,
|
|
extract_child_text_any, extract_child_text_local, extract_child_text_optional,
|
|
extract_child_text_optional_local, find_child_with_suffix, handle_action_response,
|
|
invoke_upnp_action, parse_bool, parse_visible_flag,
|
|
};
|
|
use anyhow::{anyhow, Result};
|
|
use pmodidl::DIDLLite;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, SystemTime};
|
|
use tracing::{debug, info, trace, warn};
|
|
use xmltree::{Element, XMLNode};
|
|
|
|
use crate::model::RendererInfo;
|
|
|
|
/// Value used by OpenHome renderers to indicate "insert at the head".
|
|
/// Several implementations, including upmpdcli, expect zero rather than the
|
|
/// historical 0xFFFFFFFF sentinel.
|
|
pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0;
|
|
|
|
pub trait OhTrack {
|
|
fn uri(&self) -> &str;
|
|
fn metadata_xml(&self) -> Option<&str>;
|
|
|
|
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)]
|
|
pub struct OhInfoTrack {
|
|
pub uri: String,
|
|
pub metadata_xml: Option<String>,
|
|
}
|
|
|
|
impl OhInfoTrack {
|
|
pub fn new(uri: &str, metadata_xml: Option<&str>) -> Self {
|
|
OhInfoTrack {
|
|
uri: uri.to_string(),
|
|
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
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhTimePosition {
|
|
pub track_count: u32,
|
|
pub duration_secs: u32,
|
|
pub elapsed_secs: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhRadioChannel {
|
|
pub uri: String,
|
|
pub metadata_xml: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhProductSource {
|
|
pub name: String,
|
|
pub source_type: String,
|
|
pub visible: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhInfoClient {
|
|
pub control_url: String,
|
|
pub service_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhPlaylistClient {
|
|
pub control_url: 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 {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_playlist_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create playlist control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_playlist_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create playlist service client".to_string())
|
|
})?;
|
|
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> {
|
|
if id_list.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// OpenHome spec: IdList is space-delimited (not comma-delimited)
|
|
let id_list_csv = id_list
|
|
.iter()
|
|
.map(|id| id.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let args = [("IdList", id_list_csv.as_str())];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
|
|
|
|
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
|
.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_sample: String = track_list_b64.chars().take(256).collect();
|
|
debug!(
|
|
control_url = self.control_url.as_str(),
|
|
track_list_len = track_list_b64.len(),
|
|
track_list_sample = %track_list_sample,
|
|
"OpenHome ReadList returned raw TrackList content"
|
|
);
|
|
parse_track_list(&track_list_b64)
|
|
}
|
|
|
|
pub fn insert(
|
|
&self,
|
|
after_id: u32,
|
|
uri: &str,
|
|
metadata: &str,
|
|
) -> Result<u32, ControlPointError> {
|
|
let after_id_str = after_id.to_string();
|
|
let args = [
|
|
("AfterId", after_id_str.as_str()),
|
|
("Uri", uri),
|
|
("Metadata", metadata),
|
|
];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
|
|
|
|
let envelope = ensure_success("Insert", &call_result)
|
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
|
|
|
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpMissingReturnValue("InsertResponse".to_string())
|
|
})?;
|
|
|
|
let new_id_text = extract_child_text_any(response, &["NewId", "Value"])?;
|
|
let new_id = new_id_text
|
|
.parse::<u32>()
|
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("NewId".to_string(), new_id_text))?;
|
|
|
|
Ok(new_id)
|
|
}
|
|
|
|
/// 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 args = [("Value", id_str.as_str())];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SeekId", &args)?;
|
|
|
|
handle_action_response("SeekId", &call_result)
|
|
}
|
|
|
|
pub fn transport_state(&self) -> Result<String, ControlPointError> {
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
|
|
|
|
let envelope = ensure_success("TransportState", &call_result)?;
|
|
|
|
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
|
|
})?;
|
|
|
|
// upmpdcli returns <Value>, other implementations may use <State>
|
|
let state = extract_child_text_any(response, &["Value", "State"])?;
|
|
Ok(state)
|
|
}
|
|
|
|
pub fn id(&self) -> Result<u32, ControlPointError> {
|
|
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(|| {
|
|
ControlPointError::OpenHomeError(format!(
|
|
"Missing ReadResponse element in SOAP body"
|
|
))
|
|
})?;
|
|
let id_text = extract_child_text(response, "Value")?;
|
|
let id = id_text.parse::<u32>().map_err(|_| {
|
|
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text)
|
|
})?;
|
|
Ok(id)
|
|
}
|
|
|
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
|
|
handle_action_response("Play", &call_result)
|
|
}
|
|
|
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
|
|
handle_action_response("Pause", &call_result)
|
|
}
|
|
|
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
|
|
handle_action_response("Stop", &call_result)
|
|
}
|
|
|
|
pub fn next(&self) -> Result<(), ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
|
handle_action_response("Next", &call_result)
|
|
}
|
|
|
|
pub fn previous(&self) -> Result<(), ControlPointError> {
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?;
|
|
handle_action_response("Previous", &call_result)
|
|
}
|
|
|
|
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
|
|
let second_str = second.to_string();
|
|
let args = [("Value", second_str.as_str())];
|
|
let call_result = invoke_upnp_action(
|
|
&self.control_url,
|
|
&self.service_type,
|
|
"SeekSecondAbsolute",
|
|
&args,
|
|
)?;
|
|
|
|
handle_action_response("SeekSecondAbsolute", &call_result)
|
|
}
|
|
|
|
#[track_caller]
|
|
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
|
|
let caller = std::panic::Location::caller();
|
|
tracing::trace!(control_url = self.control_url.as_str(), id, caller = %caller, "OpenHome DeleteId");
|
|
let id_str = id.to_string();
|
|
let args = [("Value", id_str.as_str())];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteId", &args)?;
|
|
|
|
handle_action_response("DeleteId", &call_result)
|
|
}
|
|
|
|
/// Attempts to delete an OpenHome playlist entry by ID.
|
|
/// Unlike delete_id(), this function silently ignores errors related to invalid/missing IDs,
|
|
/// which is useful in multi-control-point scenarios where playlist state may have changed.
|
|
///
|
|
/// Returns:
|
|
/// - Ok(true) if the ID was successfully deleted
|
|
/// - Ok(false) if the ID didn't exist (logged as warning)
|
|
/// - Err(_) for other errors (network issues, etc.)
|
|
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool, ControlPointError> {
|
|
match self.delete_id(id) {
|
|
Ok(()) => Ok(true),
|
|
Err(err) => {
|
|
// Check if this is an error about an invalid/missing ID
|
|
// OpenHome servers may return different error messages/codes for this case
|
|
let err_msg = format!("{err}");
|
|
if err_msg.contains("Invalid")
|
|
|| err_msg.contains("invalid")
|
|
|| err_msg.contains("not found")
|
|
|| err_msg.contains("does not exist")
|
|
|| err_msg.contains("unknown")
|
|
|| err_msg.contains("500") // HTTP 500 = renderer in inconsistent state
|
|
|| err_msg.contains("Action Failed")
|
|
// UPnP error 501
|
|
{
|
|
warn!(
|
|
control_url = self.control_url.as_str(),
|
|
id,
|
|
"DeleteId silently ignored - ID does not exist or renderer in inconsistent state (likely modified by events)"
|
|
);
|
|
Ok(false)
|
|
} else {
|
|
// Re-throw other errors (network issues, etc.)
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[track_caller]
|
|
pub fn delete_all(&self) -> Result<(), ControlPointError> {
|
|
let caller = std::panic::Location::caller();
|
|
tracing::trace!(control_url = self.control_url.as_str(), caller = %caller, "OpenHome DeleteAll");
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
|
handle_action_response("DeleteAll", &call_result)
|
|
}
|
|
|
|
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
|
|
|
let envelope = ensure_success("TracksMax", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpMissingReturnValue("TracksMaxResponse".to_string())
|
|
})?;
|
|
let value_text: String = extract_child_text_any(response, &["Value"])?;
|
|
let value = value_text.parse::<u32>().map_err(|_| {
|
|
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), value_text)
|
|
})?;
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
pub fn id_array(&self) -> Result<Vec<u32>, ControlPointError> {
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
|
|
let envelope = ensure_success("IdArray", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
|
.ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?;
|
|
|
|
// Log the raw IdArrayResponse XML for debugging
|
|
{
|
|
let raw_children: Vec<String> = response
|
|
.children
|
|
.iter()
|
|
.map(|n: &xmltree::XMLNode| match n {
|
|
xmltree::XMLNode::Element(e) => format!("{}={:?}", e.name, e.get_text()),
|
|
_ => String::new(),
|
|
})
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
tracing::trace!(
|
|
children = ?raw_children,
|
|
"id_array: IdArrayResponse children"
|
|
);
|
|
}
|
|
|
|
// Try to extract the array element. If missing, assume empty playlist.
|
|
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
|
|
Ok(text) => text,
|
|
Err(_) => {
|
|
// Element not found - playlist is likely empty
|
|
return Ok(Vec::new());
|
|
}
|
|
};
|
|
|
|
// Handle empty string (another way renderers indicate empty playlist)
|
|
if array_text.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let bytes = decode_base64(&array_text)?;
|
|
if bytes.len() % 4 != 0 {
|
|
return Err(ControlPointError::SoapAction(format!(
|
|
"Invalid IdArray payload length {} (expected multiple of 4)",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
|
|
let mut ids = Vec::with_capacity(bytes.len() / 4);
|
|
for chunk in bytes.chunks_exact(4) {
|
|
ids.push(u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
|
}
|
|
Ok(ids)
|
|
}
|
|
}
|
|
|
|
impl OhInfoClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_info_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create info control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_info_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create info service client".to_string())
|
|
})?;
|
|
Ok(OhInfoClient::new(control_url, service_type))
|
|
}
|
|
|
|
pub fn track(&self) -> Result<OhInfoTrack> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
|
|
|
|
let envelope = ensure_success("Track", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
|
.ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?;
|
|
|
|
let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"]).unwrap_or_default();
|
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
|
.unwrap_or(None)
|
|
.filter(|s| !s.is_empty());
|
|
|
|
debug!(
|
|
uri = uri.as_str(),
|
|
has_metadata = metadata_xml.is_some(),
|
|
metadata_length = metadata_xml.as_ref().map(|s| s.len()),
|
|
"OpenHome Info.Track() returned"
|
|
);
|
|
|
|
if let Some(ref xml) = metadata_xml {
|
|
debug!(metadata_xml = xml.as_str(), "OpenHome metadata XML content");
|
|
}
|
|
|
|
Ok(OhInfoTrack { uri, metadata_xml })
|
|
}
|
|
|
|
pub fn next(&self) -> Result<OhInfoTrack> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
|
|
|
let envelope = ensure_success("Next", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
|
.ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?;
|
|
|
|
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
|
.unwrap_or(None)
|
|
.filter(|s| !s.is_empty());
|
|
|
|
Ok(OhInfoTrack { uri, metadata_xml })
|
|
}
|
|
|
|
pub fn read_current_metadata(&self) -> Result<Option<TrackMetadata>> {
|
|
let track = self.track()?;
|
|
Ok(track.metadata())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhTimeClient {
|
|
pub control_url: String,
|
|
pub service_type: String,
|
|
}
|
|
|
|
impl OhTimeClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_time_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create time control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_time_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create time service client".to_string())
|
|
})?;
|
|
Ok(OhTimeClient::new(control_url, service_type))
|
|
}
|
|
|
|
pub fn position(&self) -> Result<OhTimePosition, ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
|
|
|
|
let envelope = ensure_success("Time", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("TimeResponse".to_string()))?;
|
|
|
|
let track_count = extract_child_text_any(response, &["TrackCount", "Value"])?
|
|
.parse::<u32>()
|
|
.map_err(|_| {
|
|
ControlPointError::UpnpBadReturnValue("TrackCount".to_string(), "".to_string())
|
|
})?;
|
|
|
|
let duration_secs = extract_child_text_any(response, &["Duration", "Value"])?
|
|
.parse::<u32>()
|
|
.map_err(|_| {
|
|
ControlPointError::UpnpBadReturnValue("Duration".to_string(), "".to_string())
|
|
})?;
|
|
|
|
let elapsed_secs = extract_child_text_any(response, &["Seconds", "Value"])?
|
|
.parse::<u32>()
|
|
.map_err(|_| {
|
|
ControlPointError::UpnpBadReturnValue("Second".to_string(), "".to_string())
|
|
})?;
|
|
Ok(OhTimePosition {
|
|
track_count,
|
|
duration_secs,
|
|
elapsed_secs,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhVolumeClient {
|
|
pub control_url: String,
|
|
pub service_type: String,
|
|
}
|
|
|
|
impl OhVolumeClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_volume_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create volume control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_volume_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create volume service client".to_string())
|
|
})?;
|
|
Ok(OhVolumeClient::new(control_url, service_type))
|
|
}
|
|
|
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
|
let envelope = ensure_success("Volume", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpMissingReturnValue("VolumeResponse".to_string())
|
|
})?;
|
|
|
|
let value = extract_child_text_any(response, &["Value"])?;
|
|
let parsed = value
|
|
.parse::<u32>()
|
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?;
|
|
|
|
Ok(parsed.min(u16::MAX as u32) as u16)
|
|
}
|
|
|
|
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
|
let vol_str = vol.to_string();
|
|
let args = [("Value", vol_str.as_str())];
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
|
|
handle_action_response("SetVolume", &call_result)
|
|
}
|
|
|
|
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
|
|
let envelope = ensure_success("Mute", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
|
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("MuteResponse".to_string()))?;
|
|
|
|
let value = extract_child_text_any(response, &["Value"])?;
|
|
|
|
Ok(parse_bool(&value))
|
|
}
|
|
|
|
pub fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
|
let mute_str = if mute { "1" } else { "0" };
|
|
let args = [("Value", mute_str)];
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
|
handle_action_response("SetMute", &call_result)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhRadioClient {
|
|
pub control_url: String,
|
|
pub service_type: String,
|
|
}
|
|
|
|
impl OhRadioClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_radio_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create radio control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_radio_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create radio service client".to_string())
|
|
})?;
|
|
Ok(OhRadioClient::new(control_url, service_type))
|
|
}
|
|
|
|
pub fn play_channel(&self, id: u32) -> Result<(), ControlPointError> {
|
|
let id_str = id.to_string();
|
|
let args = [("Id", id_str.as_str())];
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "PlayChannel", &args)?;
|
|
handle_action_response("PlayChannel", &call_result)
|
|
}
|
|
|
|
pub fn channel(&self, id: u32) -> Result<OhRadioChannel> {
|
|
let id_str = id.to_string();
|
|
let args = [("Id", id_str.as_str())];
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "Channel", &args)?;
|
|
|
|
let envelope = ensure_success("Channel", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse")
|
|
.ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?;
|
|
|
|
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
|
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
|
.unwrap_or(None)
|
|
.filter(|s| !s.is_empty());
|
|
|
|
Ok(OhRadioChannel { uri, metadata_xml })
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SourceXmlCache {
|
|
sources: Option<Vec<OhProductSource>>,
|
|
last_update: Option<SystemTime>,
|
|
}
|
|
|
|
impl SourceXmlCache {
|
|
fn new() -> Self {
|
|
Self {
|
|
sources: None,
|
|
last_update: None,
|
|
}
|
|
}
|
|
|
|
fn is_valid(&self) -> bool {
|
|
if let (Some(_), Some(last_update)) = (&self.sources, self.last_update) {
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
|
|
return elapsed < Duration::from_secs(600); // 10 minutes TTL
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn get(&self) -> Option<Vec<OhProductSource>> {
|
|
if self.is_valid() {
|
|
self.sources.clone()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn set(&mut self, sources: Vec<OhProductSource>) {
|
|
self.sources = Some(sources);
|
|
self.last_update = Some(SystemTime::now());
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct SourceIndexCache {
|
|
index: Option<u32>,
|
|
last_update: Option<SystemTime>,
|
|
}
|
|
|
|
impl SourceIndexCache {
|
|
fn new() -> Self {
|
|
Self {
|
|
index: None,
|
|
last_update: None,
|
|
}
|
|
}
|
|
|
|
fn is_valid(&self) -> bool {
|
|
if let (Some(_), Some(last_update)) = (self.index, self.last_update) {
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
|
|
return elapsed < Duration::from_secs(1); // 1 second TTL
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn get(&self) -> Option<u32> {
|
|
if self.is_valid() {
|
|
self.index
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn set(&mut self, index: u32) {
|
|
self.index = Some(index);
|
|
self.last_update = Some(SystemTime::now());
|
|
}
|
|
|
|
fn invalidate(&mut self) {
|
|
self.index = None;
|
|
self.last_update = None;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OhProductClient {
|
|
pub control_url: String,
|
|
pub service_type: String,
|
|
source_xml_cache: Arc<Mutex<SourceXmlCache>>,
|
|
source_index_cache: Arc<Mutex<SourceIndexCache>>,
|
|
}
|
|
|
|
impl OhProductClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
source_xml_cache: Arc::new(Mutex::new(SourceXmlCache::new())),
|
|
source_index_cache: Arc::new(Mutex::new(SourceIndexCache::new())),
|
|
}
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let control_url = info.oh_product_control_url().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create product control client".to_string())
|
|
})?;
|
|
|
|
let service_type = info.oh_product_service_type().ok_or_else(|| {
|
|
ControlPointError::OpenHomeError("Cannot create product service client".to_string())
|
|
})?;
|
|
Ok(OhProductClient::new(control_url, service_type))
|
|
}
|
|
|
|
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
|
// Lock the cache for the entire operation to prevent race conditions
|
|
let mut cache = self.source_xml_cache.lock().expect("source_xml_cache mutex poisoned");
|
|
|
|
// Check if cache is valid
|
|
if let Some(cached_sources) = cache.get() {
|
|
return Ok(cached_sources);
|
|
}
|
|
|
|
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
|
let envelope = ensure_success("SourceXml", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "SourceXmlResponse")
|
|
.ok_or_else(|| anyhow!("Missing SourceXmlResponse element in SOAP body"))?;
|
|
let xml = extract_child_text_any(response, &["SourceXml", "Xml", "Value"])?;
|
|
let sources = parse_product_source_list(&xml)?;
|
|
|
|
// Update cache before releasing lock
|
|
cache.set(sources.clone());
|
|
|
|
Ok(sources)
|
|
}
|
|
|
|
pub fn source_index(&self) -> Result<u32, ControlPointError> {
|
|
// Lock the cache for the entire operation to prevent race conditions
|
|
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
|
|
|
// Check if cache is valid
|
|
if let Some(cached_index) = cache.get() {
|
|
return Ok(cached_index);
|
|
}
|
|
|
|
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SourceIndex", &[])?;
|
|
let envelope = ensure_success("SourceIndex", &call_result)?;
|
|
let response = find_child_with_suffix(&envelope.body.content, "SourceIndexResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpMissingReturnValue("SourceIndexResponse".to_string())
|
|
})?;
|
|
|
|
let value = extract_child_text_any(response, &["Index", "Value"])?;
|
|
let index = value
|
|
.parse::<u32>()
|
|
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?;
|
|
|
|
// Update cache before releasing lock
|
|
cache.set(index);
|
|
|
|
Ok(index)
|
|
}
|
|
|
|
pub fn set_source_index(&self, index: u32) -> Result<(), ControlPointError> {
|
|
let value = index.to_string();
|
|
let args = [("Index", value.as_str())];
|
|
let call_result = invoke_upnp_action(
|
|
&self.control_url,
|
|
&self.service_type,
|
|
"SetSourceIndex",
|
|
&args,
|
|
)?;
|
|
let result = handle_action_response("SetSourceIndex", &call_result);
|
|
|
|
// Invalidate cache after write operation
|
|
if result.is_ok() {
|
|
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
|
cache.invalidate();
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
pub fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
|
let sources = self
|
|
.source_xml()
|
|
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
|
|
|
// Log all available sources for diagnostics
|
|
debug!(
|
|
control_url = self.control_url.as_str(),
|
|
source_count = sources.len(),
|
|
"OpenHome Product sources available"
|
|
);
|
|
|
|
for (idx, source) in sources.iter().enumerate() {
|
|
trace!(
|
|
control_url = self.control_url.as_str(),
|
|
index = idx,
|
|
name = source.name.as_str(),
|
|
source_type = source.source_type.as_str(),
|
|
visible = source.visible,
|
|
"OpenHome source"
|
|
);
|
|
}
|
|
|
|
let playlist_index = sources
|
|
.iter()
|
|
.position(|source| source.source_type.eq_ignore_ascii_case("playlist"))
|
|
.ok_or_else(|| {
|
|
warn!(
|
|
control_url = self.control_url.as_str(),
|
|
available_types = ?sources.iter().map(|s| s.source_type.as_str()).collect::<Vec<_>>(),
|
|
"OpenHome Product source list does not expose a Playlist entry"
|
|
);
|
|
ControlPointError::OpenHomeNoPlaylistEntry()
|
|
})?;
|
|
let playlist_index = playlist_index as u32;
|
|
let current_index = self.source_index()?;
|
|
|
|
// Log current source state
|
|
let current_source = sources.get(current_index as usize);
|
|
debug!(
|
|
control_url = self.control_url.as_str(),
|
|
current_index,
|
|
current_source_name = current_source.map(|s| s.name.as_str()).unwrap_or("unknown"),
|
|
current_source_type = current_source
|
|
.map(|s| s.source_type.as_str())
|
|
.unwrap_or("unknown"),
|
|
playlist_index,
|
|
needs_switch = current_index != playlist_index,
|
|
"OpenHome source state"
|
|
);
|
|
|
|
if current_index != playlist_index {
|
|
info!(
|
|
control_url = self.control_url.as_str(),
|
|
from_index = current_index,
|
|
to_index = playlist_index,
|
|
"Switching OpenHome Product source to Playlist"
|
|
);
|
|
self.set_source_index(playlist_index)?;
|
|
|
|
// Verify the switch was successful
|
|
let new_index = self.source_index()?;
|
|
if new_index == playlist_index {
|
|
info!(
|
|
control_url = self.control_url.as_str(),
|
|
"Successfully switched to Playlist source"
|
|
);
|
|
} else {
|
|
warn!(
|
|
control_url = self.control_url.as_str(),
|
|
expected = playlist_index,
|
|
actual = new_index,
|
|
"Source switch may have failed - index mismatch"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
|
|
if xml.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let parsed = pmodidl::parse_metadata::<pmodidl::DIDLLite>(xml).ok()?;
|
|
let item = parsed.data.items.first()?;
|
|
|
|
debug!(
|
|
title = item.title.as_str(),
|
|
has_album_art = item.album_art.is_some(),
|
|
album_art_uri = item.album_art.as_deref(),
|
|
"Parsed DIDL metadata for track"
|
|
);
|
|
|
|
// Déterminer si c'est un stream continu à partir de l'URI
|
|
let uri = item.resources.first().map(|r| r.url.as_str()).unwrap_or("");
|
|
let is_continuous_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
|
|
|
Some(TrackMetadata {
|
|
title: Some(item.title.clone()),
|
|
artist: item.artist.clone(),
|
|
album: item.album.clone(),
|
|
genre: item.genre.clone(),
|
|
album_art_uri: item.album_art.clone(),
|
|
date: item.date.clone(),
|
|
track_number: item.original_track_number.clone(),
|
|
creator: item.creator.clone(),
|
|
duration: item.resources.first().and_then(|r| r.duration.clone()),
|
|
is_continuous_stream,
|
|
})
|
|
}
|
|
|
|
/// Extracts the ID from DIDL-Lite XML metadata.
|
|
#[allow(dead_code)]
|
|
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> {
|
|
let trimmed = payload.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let (xml, was_base64) = if trimmed.starts_with('<') {
|
|
(trimmed.to_string(), false)
|
|
} else {
|
|
let bytes = decode_base64(trimmed)?;
|
|
let decoded = String::from_utf8(bytes).map_err(|err| {
|
|
ControlPointError::OpenHomeError(format!(
|
|
"TrackList payload not valid UTF-8 after base64 decode: {err}"
|
|
))
|
|
})?;
|
|
(decoded, true)
|
|
};
|
|
let xml_sample: String = xml.chars().take(256).collect();
|
|
debug!(
|
|
raw_base64 = was_base64,
|
|
decoded_len = xml.len(),
|
|
decoded_sample = %xml_sample,
|
|
"Decoded OpenHome TrackList payload"
|
|
);
|
|
|
|
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
|
let root = Element::parse(&mut reader).map_err(|err| {
|
|
ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err))
|
|
})?;
|
|
let mut entries = Vec::new();
|
|
|
|
for node in &root.children {
|
|
if let XMLNode::Element(elem) = node {
|
|
if elem.name.ends_with("Entry") {
|
|
entries.push(parse_track_entry(elem)?);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError> {
|
|
let id_text = extract_child_text_local(elem, "Id")?;
|
|
|
|
// Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element
|
|
// when using ReadList. This is a non-standard compact format that we cannot parse properly
|
|
// because we need to fetch each track individually to get its Uri and Metadata.
|
|
// Return an error to force the fallback to individual Read() calls.
|
|
if id_text.contains(',') {
|
|
debug!(
|
|
raw_entry = %elem.name,
|
|
raw_id = id_text.as_str(),
|
|
"Multi-value Id element detected - forcing fallback to individual reads"
|
|
);
|
|
return Err(ControlPointError::OpenHomeError(format!(
|
|
"Renderer returned comma-separated IDs in single Entry - need individual reads"
|
|
)));
|
|
}
|
|
|
|
let id = id_text.parse::<u32>().map_err(|_| {
|
|
ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text))
|
|
})?;
|
|
|
|
let uri = extract_child_text_local(elem, "Uri")?;
|
|
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
|
|
|
Ok(OhTrackEntry {
|
|
id,
|
|
uri,
|
|
metadata_xml,
|
|
})
|
|
}
|
|
|
|
fn parse_product_source_list(xml: &str) -> Result<Vec<OhProductSource>> {
|
|
if xml.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
|
let root = Element::parse(&mut reader)
|
|
.map_err(|err| anyhow!("Failed to parse OpenHome SourceXml payload: {}", err))?;
|
|
|
|
let mut sources = Vec::new();
|
|
for node in &root.children {
|
|
if let XMLNode::Element(elem) = node {
|
|
if elem.name.ends_with("Source") {
|
|
let name = extract_child_text(elem, "Name")?;
|
|
let source_type = extract_child_text(elem, "Type")?;
|
|
let visible = extract_child_text_optional(elem, "Visible")?
|
|
.map(|v| parse_visible_flag(&v))
|
|
.unwrap_or(true);
|
|
|
|
sources.push(OhProductSource {
|
|
name,
|
|
source_type,
|
|
visible,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(sources)
|
|
}
|
|
|
|
/// Check if error is an invalid OpenHome entry ID error.
|
|
#[allow(dead_code)]
|
|
fn is_invalid_entry_id_error(err: &ControlPointError) -> bool {
|
|
let msg = format!("{err}");
|
|
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Cursor;
|
|
|
|
#[test]
|
|
fn parse_insert_response_accepts_newid_without_prefix() {
|
|
let xml = r#"<u:InsertResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><NewId>1337</NewId></u:InsertResponse>"#;
|
|
let mut cursor = Cursor::new(xml.as_bytes());
|
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
|
let value = extract_child_text_any(&response, &["NewId", "Value"]).unwrap();
|
|
assert_eq!(value, "1337");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_readlist_response_accepts_tracklist_without_prefix() {
|
|
let xml = r#"<u:ReadListResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><TrackList>PGVudHJ5PjwvZW50cnk+</TrackList></u:ReadListResponse>"#;
|
|
let mut cursor = Cursor::new(xml.as_bytes());
|
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
|
let value = extract_child_text_any(&response, &["TrackList", "Value"]).expect("tracklist");
|
|
assert_eq!(value, "PGVudHJ5PjwvZW50cnk+");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_idarray_response_accepts_array_without_prefix() {
|
|
let xml = r#"<u:IdArrayResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><Token>1</Token><Array>AAAAAQAAAAI=</Array></u:IdArrayResponse>"#;
|
|
let mut cursor = Cursor::new(xml.as_bytes());
|
|
let response = Element::parse(&mut cursor).expect("valid xml");
|
|
let value = extract_child_text_any(&response, &["Array", "IdArray", "Value"])
|
|
.expect("array content");
|
|
assert_eq!(value, "AAAAAQAAAAI=");
|
|
}
|
|
}
|