diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs
index 388dc525..1d22a4d1 100644
--- a/pmocontrol/examples/full_control_point_demo.rs
+++ b/pmocontrol/examples/full_control_point_demo.rs
@@ -1271,6 +1271,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
+ protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs
index 127870ae..73df75fc 100644
--- a/pmocontrol/examples/live_pmomusic_demo.rs
+++ b/pmocontrol/examples/live_pmomusic_demo.rs
@@ -552,6 +552,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
+ protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs
index 0beb5df1..276cb059 100644
--- a/pmocontrol/examples/queue_pmomusic_demo.rs
+++ b/pmocontrol/examples/queue_pmomusic_demo.rs
@@ -483,6 +483,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
+ protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
diff --git a/pmocontrol/examples/test_attach_playlist.rs b/pmocontrol/examples/test_attach_playlist.rs
new file mode 100644
index 00000000..e4cef051
--- /dev/null
+++ b/pmocontrol/examples/test_attach_playlist.rs
@@ -0,0 +1,179 @@
+/// Test example to debug playlist attachment issues
+///
+/// This example tests each step of attaching a playlist to an OpenHome renderer:
+/// 1. Browse the MediaServer for playlist items
+/// 2. Insert each item into the OpenHome renderer's playlist
+/// 3. Verify the operation succeeded
+///
+/// Usage:
+/// cargo run --example test_attach_playlist
+
+use anyhow::{Context, Result};
+use pmocontrol::{
+ media_server::{MediaBrowser, MediaEntry, MediaServer, ServerId},
+ openhome_client::OhPlaylistClient,
+};
+use tracing::{debug, error, info, warn};
+use tracing_subscriber::{fmt, prelude::*, EnvFilter};
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ // Initialize logging
+ tracing_subscriber::registry()
+ .with(fmt::layer())
+ .with(
+ EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new("info,pmocontrol=debug")),
+ )
+ .init();
+
+ info!("๐งช Starting playlist attachment test");
+
+ // Configuration - adjust these for your setup
+ let media_server_url = "http://192.168.0.138:8080";
+ let media_server_id = "uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4";
+ let playlist_container_id = "radio-paradise:channel:mellow:liveplaylist";
+
+ let renderer_playlist_url = "http://192.168.0.200:49152/Playlist";
+ let renderer_service_type = "urn:av-openhome-org:service:Playlist:1";
+
+ info!("Configuration:");
+ info!(" MediaServer: {}", media_server_url);
+ info!(" Playlist: {}", playlist_container_id);
+ info!(" Renderer: {}", renderer_playlist_url);
+ info!("");
+
+ // Step 1: Create MediaServer client
+ info!("๐ก Step 1: Connecting to MediaServer");
+ let content_directory_url = format!(
+ "{}/device/{}/service/ContentDirectory/control",
+ media_server_url, media_server_id.replace("uuid:", "")
+ );
+
+ let server = MediaServer::new(
+ ServerId(media_server_id.to_string()),
+ "PMOMusic Test".to_string(),
+ content_directory_url,
+ );
+
+ debug!("MediaServer client created");
+
+ // Step 2: Browse the playlist container
+ info!("๐ Step 2: Browsing playlist container");
+ info!(" Container ID: {}", playlist_container_id);
+
+ let entries = match server.browse_children(playlist_container_id, 0, 10) {
+ Ok(entries) => {
+ info!("โ
Browse succeeded: {} items found", entries.len());
+ entries
+ }
+ Err(e) => {
+ error!("โ Browse failed: {}", e);
+ error!(" This is where the error occurs!");
+ return Err(e);
+ }
+ };
+
+ if entries.is_empty() {
+ warn!("โ ๏ธ Playlist is empty, nothing to insert");
+ return Ok(());
+ }
+
+ // Display the first few items
+ info!("๐ First items in playlist:");
+ for (idx, entry) in entries.iter().take(3).enumerate() {
+ info!(" [{}] {} - {}",
+ idx,
+ entry.title,
+ entry.artist.as_deref().unwrap_or("Unknown")
+ );
+ if let Some(res) = entry.resources.first() {
+ debug!(" URI: {}", res.uri);
+ debug!(" protocolInfo: {}", res.protocol_info);
+ }
+ }
+ info!("");
+
+ // Step 3: Connect to OpenHome renderer
+ info!("๐ต Step 3: Connecting to OpenHome renderer");
+ let oh_client = OhPlaylistClient::new(
+ renderer_playlist_url.to_string(),
+ renderer_service_type.to_string(),
+ );
+ debug!("OpenHome client created");
+
+ // Step 4: Clear existing playlist
+ info!("๐๏ธ Step 4: Clearing existing playlist");
+ match oh_client.delete_all() {
+ Ok(_) => info!("โ
Playlist cleared"),
+ Err(e) => {
+ warn!("โ ๏ธ Could not clear playlist: {}", e);
+ }
+ }
+ info!("");
+
+ // Step 5: Insert items one by one
+ info!("โ Step 5: Inserting items into renderer");
+ let mut after_id = 0u32;
+ let mut inserted_count = 0;
+
+ for (idx, entry) in entries.iter().enumerate() {
+ if entry.is_container {
+ debug!("Skipping container: {}", entry.title);
+ continue;
+ }
+
+ let resource = match entry.resources.iter().find(|r| r.is_audio()) {
+ Some(r) => r,
+ None => {
+ warn!("No audio resource found for: {}", entry.title);
+ continue;
+ }
+ };
+
+ info!(" [{}] Inserting: {}", idx, entry.title);
+ debug!(" URI: {}", resource.uri);
+ debug!(" protocolInfo: {}", resource.protocol_info);
+
+ // Build simple DIDL-Lite metadata
+ let didl_metadata = format!(
+ r#"- {}object.item.audioItem.musicTrack{}
"#,
+ entry.id,
+ xmlescape(&entry.title),
+ resource.protocol_info,
+ xmlescape(&resource.uri)
+ );
+
+ trace!("DIDL metadata: {}", didl_metadata);
+
+ match oh_client.insert(after_id, &resource.uri, &didl_metadata) {
+ Ok(new_id) => {
+ debug!(" โ
Inserted with ID: {}", new_id);
+ after_id = new_id;
+ inserted_count += 1;
+ }
+ Err(e) => {
+ error!(" โ Insert failed: {}", e);
+ error!(" This is the UPnP 501 error location!");
+
+ // Continue with next item instead of failing
+ warn!(" Continuing with next item...");
+ }
+ }
+ }
+
+ info!("");
+ info!("โ
Test completed: {}/{} items inserted successfully",
+ inserted_count, entries.len());
+
+ Ok(())
+}
+
+/// Simple XML escaping
+fn xmlescape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs
index baa532b5..11d8077d 100644
--- a/pmocontrol/src/control_point.rs
+++ b/pmocontrol/src/control_point.rs
@@ -14,7 +14,7 @@ use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource};
use pmoupnp::ssdp::SsdpClient;
use quick_xml::se::to_string as to_didl_string;
use thiserror::Error;
-use tracing::{debug, error, info, warn};
+use tracing::{debug, error, info, trace, warn};
use ureq::{Agent, http};
use xmltree::{Element, XMLNode};
@@ -1424,6 +1424,13 @@ impl ControlPoint {
for item in items.iter() {
let metadata = playback_item_to_didl(item);
+ debug!(
+ uri = item.uri.as_str(),
+ protocol_info = item.protocol_info.as_str(),
+ metadata_len = metadata.len(),
+ "Inserting track to OpenHome playlist"
+ );
+ trace!(metadata = metadata.as_str(), "DIDL-Lite metadata");
after_id =
Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?);
}
@@ -2119,6 +2126,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
+ protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
@@ -2144,6 +2152,8 @@ fn playback_item_from_openhome_track(
media_server_id: ServerId(format!("openhome:{}", renderer_id.0)),
didl_id: format!("{}{}", OPENHOME_TRACK_PREFIX, 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),
}
}
@@ -2178,7 +2188,7 @@ fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
date: metadata.and_then(|m| m.date.clone()),
original_track_number: metadata.and_then(|m| m.track_number.clone()),
resources: vec![DidlResource {
- protocol_info: "http-get:*:audio/*:*".to_string(),
+ protocol_info: item.protocol_info.clone(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
diff --git a/pmocontrol/src/control_point/openhome_queue.rs b/pmocontrol/src/control_point/openhome_queue.rs
index c410f807..167a12d5 100644
--- a/pmocontrol/src/control_point/openhome_queue.rs
+++ b/pmocontrol/src/control_point/openhome_queue.rs
@@ -181,6 +181,8 @@ impl OpenHomeQueue {
media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)),
didl_id,
uri: entry.uri.clone(),
+ // OpenHome tracks don't provide protocolInfo, use generic default
+ protocol_info: "http-get:*:audio/*:*".to_string(),
metadata,
}
}
@@ -261,9 +263,10 @@ fn build_metadata_xml(item: &PlaybackItem) -> String {
}
}
+ let escaped_protocol_info = escape(item.protocol_info.as_str());
xml.push_str(&format!(
- r#"{}"#,
- escaped_uri
+ r#"{}"#,
+ escaped_protocol_info, escaped_uri
));
xml.push_str(r#"object.item.audioItem.musicTrack"#);
xml
diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs
index 140320d3..6c3a4bcb 100644
--- a/pmocontrol/src/music_renderer.rs
+++ b/pmocontrol/src/music_renderer.rs
@@ -356,6 +356,8 @@ impl MusicRenderer {
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,
})
}
diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs
index a2abb583..310763c3 100644
--- a/pmocontrol/src/pmoserver_ext.rs
+++ b/pmocontrol/src/pmoserver_ext.rs
@@ -1875,6 +1875,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
+ protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
diff --git a/pmocontrol/src/queue_backend.rs b/pmocontrol/src/queue_backend.rs
index cc5d34da..70c1cdca 100644
--- a/pmocontrol/src/queue_backend.rs
+++ b/pmocontrol/src/queue_backend.rs
@@ -66,6 +66,13 @@ pub struct PlaybackItem {
/// 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, โฆ).
///
diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs
index 77c0a5a3..e20d0003 100644
--- a/pmocontrol/src/soap_client.rs
+++ b/pmocontrol/src/soap_client.rs
@@ -2,6 +2,7 @@ use std::time::Duration;
use anyhow::{Context, Result};
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
+use tracing::{debug, trace};
use ureq::Agent;
/// Result of a SOAP call:
@@ -39,6 +40,15 @@ pub fn invoke_upnp_action_with_timeout(
let body_xml = build_soap_request(service_type, action, args)
.context("Failed to build SOAP request body")?;
+ debug!(
+ url = control_url,
+ action = action,
+ service_type = service_type,
+ "Sending SOAP request"
+ );
+
+ trace!(body = body_xml.as_str(), "SOAP request body");
+
let mut builder = Agent::config_builder();
builder = builder.http_status_as_error(false);
if let Some(duration) = timeout {
@@ -60,6 +70,7 @@ pub fn invoke_upnp_action_with_timeout(
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?;
let status = response.status();
+ debug!(status = status.as_u16(), "SOAP response received");
// 5. Read full body
//