Showing posts with label sonos. Show all posts
Showing posts with label sonos. Show all posts

Monday, February 6, 2012

Java Program to Extract What’s in a Sonos Queue

Sonos Controller for Windows
This Java program takes what’s in the Sonos queue and outputs it to a list in a text file and an .m3u file. The origin of this Java program to extract the Sonos queue started with some work we did for the post A Simple Sonos JavaScript Application. A reader (see the comments) contributed a Java program to get the current queue and save its contents to a file. After that effort, we worked extensively on WPF Application to Save and Import Sonos Playlists and learned a few tricks. What we learned there, in particular, getting results recursively, we used to revise the initial Java queue extraction program. This post presents the revised program. It only gets what’s in the queue, so if you want to use this to save playlists you would first need to clear the queue, add a playlist to the queue, run this program (with the correct parameters), and repeat for as many playlists you want to save.

If you don’t know a lot about Java and running programs on your computer (in this case we show it for Windows), go see this post, Java, Apache Ant and Hello World.

You can run the code in several different ways, with Eclipse or just at the command line (just run “ant”) are probably the two most common ways. The program requires three inputs the IP address of a master device, the name of a folder to put the output files, and the name of the output file. Both a text version and an .m3u version of the queue are output.

To get the IP address you can use the Help menu in the Sonos Controller and it shows a summary of the devices it sees. The IP address can be found in the summary. Be warned though, the first item in the list is not necessarily the master and running this program without specifying the IP address of the master will not return any results. That said, if you are having trouble, just reduce a zone to one device and then it will be the master.

The Ant build task and the program are shown below.

Sonos Queue Extractor Running in Eclipse and at the Command Line with Ant
Sonos Queue Extractor - Java, EclipseSonos Queue Extractor - Java, ANT Task

How to Get the IP of a Sonos Device
1. Go to the Help Menu

Sonos Controller Windows
2. Get IP Address from Summary Information
Sonos Controller Windows

The Ant Task
<project name="SonosQueueExtractor " basedir="." default="main">
<property name="src.dir" value="src"/>
<property name="build.dir" value="build"/>
<property name="classes.dir" value="${build.dir}/classes"/>
<property name="jar.dir" value="${build.dir}/jar"/>
<property name="lib.dir" value="lib"/>
<property name="main-class" value="Travelmarx.SonosQueueExtractor"/>

<path id="classpath">
<fileset dir="${lib.dir}" includes="**/*.jar"/>
</path>
<target name="clean">
<delete dir="${build.dir}"/>
</target>
<target name="compile">
<mkdir dir="${classes.dir}"/>
<javac srcdir="${src.dir}" destdir="${classes.dir}" classpathref="classpath" includeantruntime="false"/>
</target>
<target name="jar" depends="compile">
<mkdir dir="${jar.dir}"/>
<jar destfile="${jar.dir}/${ant.project.name}.jar" basedir="${classes.dir}">
<manifest>
<attribute name="Main-Class" value="${main-class}"/>
</manifest>
</jar>
</target>
<target name="run" depends="jar">
<java classname="${main-class}">
<classpath>
<path refid="classpath"/>
<path location="${jar.dir}/${ant.project.name}.jar"/>
</classpath>
<arg value="192.168.2.155"/>
<arg value="c:\public"/>
<arg value="queue"/>
</java>
</target>
<target name="clean-build" depends="clean,jar"/>
<target name="main" depends="clean,run"/>
</project>
The Java Program: Sonos Queue Extractor
package Travelmarx;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.net.URLDecoder;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.commons.lang3.StringEscapeUtils;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;


/**
* Class responsible for extracting current Sonos queue to a playlist and text file.
*/

public class SonosQueueExtractor {

public static Integer maxPageResults = 50;
public static String queueName = "Q:0"; // the main queue, can't be used currently with playlist like SQ:14
public static OutputStreamWriter txtFile;
public static OutputStreamWriter m3uFile;
public static String ipAddress;

public SonosQueueExtractor()
throws MalformedURLException, ProtocolException, IOException, SAXException, ParserConfigurationException {

queryQueue(0);
}

private void queryQueue(Integer pageIndex) throws IOException, ParserConfigurationException, SAXException {
// Build HTTP request with SOAP envelope asking for details about the
// current queue.
URL url = new URL("http://" + ipAddress + ":1400/MediaServer/ContentDirectory/Control");
HttpURLConnection request = (HttpURLConnection)url.openConnection();

request.setRequestMethod("POST");
request.addRequestProperty("SOAPACTION", "\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"");
request.setDoOutput(true);
request.setReadTimeout(2000);

Integer startPageResults = maxPageResults*pageIndex;
Integer numTotalMatches = 0;

request.connect();

OutputStreamWriter input = new OutputStreamWriter(request.getOutputStream());
input.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
input.write("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\r\n");
input.write(" <s:Body>\r\n");
input.write(" <u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">\r\n");
input.write(" <ObjectID>" + queueName + "</ObjectID>\r\n");
input.write(" <BrowseFlag>BrowseDirectChildren</BrowseFlag>\r\n");
input.write(" <Filter>upnp:artist,dc:title</Filter>\r\n");
input.write(" <StartingIndex>" + startPageResults.toString() + "</StartingIndex>\r\n");
input.write(" <RequestedCount>" + maxPageResults.toString() + "</RequestedCount>\r\n");
input.write(" <SortCriteria></SortCriteria>\r\n");
input.write(" </u:Browse>\r\n");
input.write(" </s:Body>\r\n");
input.write("</s:Envelope>\r\n");
input.flush();

// Read entire HTTP response, which is assumed to be in UTF-8.
BufferedReader output = new BufferedReader(new InputStreamReader(request.getInputStream(), "UTF-8"));
String oneResponse = new String();
String line;

while ((line = output.readLine()) != null) {
oneResponse += line + "\r\n";
}

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder db = dbf.newDocumentBuilder();
String root = "<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>" + xmlDecode(oneResponse);
Document doc = db.parse(new ByteArrayInputStream(root.getBytes()));
NodeList nodeList = doc.getElementsByTagName("TotalMatches");
numTotalMatches = Integer.parseInt(nodeList.item(0).getTextContent());
Integer nextPageResult = ((startPageResults + maxPageResults) < numTotalMatches) ? startPageResults + maxPageResults : numTotalMatches;
System.out.println("Writing " + startPageResults.toString() + " to " + nextPageResult + " out of "+ numTotalMatches + " matches.");

if (numTotalMatches > maxPageResults*(pageIndex + 1)) {
// Get more items, recursively
pageIndex += 1;
writeToFiles(oneResponse);
queryQueue(pageIndex);
}
else {
writeToFiles(oneResponse);
}

request.disconnect();

}

private void writeToFiles(String response) throws IOException {

int i = 0, j = 0;
i = response.indexOf("&lt;item", j);
while (i >= 0) {
// Loop over all items, where each item is a track on the queue.
j = response.indexOf("&lt;/item&gt;", i);
String track = response.substring(i + 8, j);
String trackNo, artist, title, unc;

trackNo = extract(track, " id=&quot;Q:", "&quot;");
trackNo = trackNo.substring(trackNo.indexOf('/') + 1);
unc = URLDecoder.decode(extract(track, "&gt;x-file-cifs:", "&lt;").replace('/', '\\').replaceAll("%20", " "), "UTF-8");
artist = extract(track, "&lt;dc:creator&gt;", "&lt;/dc:creator&gt;");
title = extract(track, "&lt;dc:title&gt;", "&lt;/dc:title&gt;");

txtFile.append(decode(trackNo + ". " + artist + ": " + title) + "\r\n");
m3uFile.append(decode(unc) + "\r\n");

i = response.indexOf("&lt;item", j);
}
}
/**
* Extracts text surrounded by markers from given string.
* @param s String to extract text from.
* @param start Start marker.
* @param stop Stop marker.
* @return Extracted text found between start and stop markers, markers
* excluded.
*/

private String extract(String s, String start, String stop) {
int i = s.indexOf(start) + start.length();

return s.substring(i, s.indexOf(stop, i));
}

/**
* Decodes HTML character entities. First changes &amp; to &, then uses Apache
* Commons Lang to decode the standard entities and then manually decodes a non-
* standard entity (&apos;).
* @param s Text to be decoded.
* @return Text with HTML character entities decoded.
*/

private String decode(String s) {
// Convert &amp; to &, &apos; to ' and let Apache Commons Lang about the rest.
return StringEscapeUtils.unescapeHtml3(s.replaceAll("&amp;", "&")).replaceAll("&apos;", "'");
}

/**
* Decodes XML character entities. Uses the Apache
* Commons Lang to decode the standard entity.
* @param s Text to be decoded.
* @return Text with XML character entities decoded.
*/
private String xmlDecode(String s) {
String out = s.replaceAll("&amp;", "&");
out = out.replaceAll("&apos;", "'");
out = out.replaceAll("&quot;", "\"");
out = out.replaceAll("&lt;", "<");
out = out.replaceAll("&gt;", ">");
out = out.replaceAll("&nbsp;", " ");
return out;
}

/**
* Extracts current Sonos queue and saves track information to a playlist file
* and a text file. The playlist file is saved in .m3u format and the text file
* is a plain text file with each line in the format
* <track_no>. <artist>: <title>
* Both files are in ISO8859-1 format.
* @param args 0: Sonos master IP address.
* 1: Export file path.
* 2: Playlist name.
* @throws MalformedURLException
* @throws ProtocolException
* @throws IOException
* @throws ParserConfigurationException
* @throws SAXException
*/

public static void main(String[] args)
throws MalformedURLException, ProtocolException, IOException, SAXException, ParserConfigurationException {
if (args.length < 3) {
System.err.println("Usage: SonosQueueExtractor sonos_master_ip_address export_file_path playlist_name");
System.exit(0);
}

ipAddress = args[0];
// Open output files, both in ISO8859-1 encoding.
txtFile = new OutputStreamWriter(new FileOutputStream(new File(args[1] + "/" + args[2] + ".txt")), "8859_1");
m3uFile = new OutputStreamWriter(new FileOutputStream(new File(args[1] + "/" + args[2] + ".m3u")), "8859_1");
new SonosQueueExtractor ();

txtFile.close();
m3uFile.close();
System.out.println("Check " + args[1] + " for output files.");

}
}

Sunday, May 8, 2011

WPF Application to Save and Import Sonos Playlists


Overview

(The code on this page was last checked and verified in June 2014.)

In a past post, we showed how to code up an HTML page that contained JavaScript and queried the Sonos Music System and presented what was playing and showed playlist information. One of the configuration parameters of the HTML page is an array of the network addresses of the Sonos devices. You had to enter that information in the JavaScript code before using the page. In this post we show how to create a Windows WPF/C# application that can discover Sonos devices on your network and display them as well as show, import, and export playlist information. When you combine the WPF application it produces an executable file (.exe). We developed and tested the WPF application on Windows 7, against the Sonos version 3.4, and using Visual Studio 2010.

Disclaimers: This is proof of concept WPF application for managing one aspect of your Sonos system: playlists. It doesn’t work flawlessly and you should use care when sending broadcast packets on your network and importing playlists into your Sonos system, both which this application does. Furthermore, we are providing the source code, but not the executable.

Working With Playlists

In the previous post about extracting playlists we only investigated one half of the story of playlists, the easy part, which is exporting. Importing is a bit more complicated. It’s more complicated because it requires a sequence of several SOAP requests. First, let’s start with what happens when you work with one the existing Sonos controllers. Here’s a familiar sequence of steps that you might do:
  1. Clear the queue.
  2. Add music (either local or from a non-local source like Rhapsody) to the queue. Local = music library in the Sonos documentation.
  3. Save the queue as a playlist (also referred to as a saved queue reflecting what it really is).
  4. Clear the queue.
  5. Load a different playlist.
  6. Add some music to it.
  7. Save the queue back reusing the same name.
The basic playlist creation and management is through the queue. Not obvious on first glance is that you only add one track at a time, or one album at a time, or one playlist. You can’t add multiple songs, or multiple albums, or multiple playlists at once. The reason why this is interesting is that when importing from a previously exported playlist (e.g. a file on disk), we can’t just stick the XML that represents the playlist in the queue and be done with it. We will have to go through the XML and add items one by one to a queue. This simulates what you do by hand. (Note: maybe there is a way to write a saved queue directly? That would simplify matters in one part of the code shown here.)

The trick to importing a previously exported playlist is to find the SOAP operations that support the actions described above. That’s where Microsoft Network Monitor (NetMon) helps out so you can watch traffic and look at what’s going back and forth between a controller and the UPnP devices (zone players). So, if you use the Sonos Desktop Controller then you can capture the basic SOAP requests shown below – with their device path, SOAP actions, and service parameters. You can then use a UPnP exploration tool like DeviceSpy (discussed in the first of the series of Sonos-related posts) to test these actions. Note, the following queue-based actions are performed on a master zone player.

Task: Remove All Tracks from the QueuePath: /MediaRenderer/AVTransport/Control
Action: urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue
-> InstanceID = 0

Task: Add Music Item to the QueuePath: /MediaRenderer/AVTransport/Control
Action: urn:schemas-upnp-org:service:AVTransport:1#AddURIToQueue
-> InstanceID = 0
-> EnqueuedURI = [see below]
-> EnqueuedURIMetaData = [see below, must be HTML encoded]
-> DesiredFirstTrackNumberEnqueued = 0
-> EnqueueAsNext = 0

Task: Save the Queue as a PlaylistPath: /MediaRenderer/AVTransport/Control
Action: urn:schemas-upnp-org:service:AVTransport:1 #SaveQueue
-> InstanceID = 0
-> Title = [Enter your playlist title. Limit of 20 characters.]
-> ObjectId = [Leave blank]

When adding music, the EnqueuedURI parameter depends on what you are importing. The URI must match the type of item being added to the queue. The EnqueuedURIMetaData parameter contains a blob of DIDL which stands for Digital Item Declaration Language and is an XML dialect for MPEG-21. Five examples are shown below for adding different types of items to add to a queue. What is shown below is more for someone who might construct the DIDL XML by hand. In the WPF application developed in this post you don't need to know the exact formats of what goes into the EnqueueURI and EnqueuedURIMetadata parameters when importing and exporting.

Type of Item Added Example of EnqueuedURI Example of EnqueuedURIMetaData
One Local Track x-file-cifs://servername/
music/album/artist/
song.flac
&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;S://servername/music/album/artist/song.flac&quot; parentID=&quot;A:ALBUMARTIST/Association,%20the/&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Song Title&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;RINCON_AssociatedZPUDN&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;
One Local Album x-rincon-playlist:RINCON_000E58512EBC01400#
A:ALBUMARTIST/
Artist/
AlbumTitle
&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;A:ALBUMARTIST/Artist/AlbumTitle&quot; parentID=&quot;A:ALBUMARTIST/Aritst&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Keepin&amp;apos; Me Up Nights&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.album.musicAlbum&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;RINCON_AssociatedZPUDN&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;
One Rhapsody Track radea:Tra.6542458.mp3 &lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;RDCPI:GLBTRACK:Tra.6542458&quot; parentID=&quot;RDCPA:GLBALBUM:Alb.6538091&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Farther On&lt;/dc:title&gt;&lt;upnp:class&gt;object.item.audioItem.musicTrack&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;SA_RINCON1_someone@email.com&lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;
One Rhapsody Album x-rincon-cpcontainer:RDCPA:
GLBALBUM:Alb.6538091
&lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt;&lt;item id=&quot;RDCPA:GLBALBUM:Alb.6538091&quot; parentID=&quot;RDCPA:ARTALBUM:Art.5195118&quot; restricted=&quot;true&quot;&gt;&lt;dc:title&gt;Vetiver&lt;/dc:title&gt;&lt;upnp:class&gt;object.container.album.musicAlbum&lt;/upnp:class&gt;&lt;desc id=&quot;cdudn&quot; nameSpace=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot;&gt;SA_RINCON1_someone@email.com &lt;/desc&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;
One Radio Station x-rincon-mp3radio://109.123.116.202:8020 &lt;DIDL-Lite xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot; xmlns:r=&quot;urn:schemas-rinconnetworks-com:metadata-1-0/&quot; xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot;&gt; &lt;item id=&quot;-1&quot; parentID=&quot;-1&quot; restricted=&quot;true&quot; &gt;&lt;res protocolInfo=&quot;x-rincon-mp3radio:*:*:* &quot; &gt;x-rincon-mp3radio://109.123.116.202:8020&lt;/res&gt;&lt;r:streamContent&gt;Dario Castello (1590-1658) - Sonata seconda&lt;/r:streamContent&gt;&lt;dc:title&gt;109.123.116.202:8020&lt;/dc:title&gt;&lt;upnp:class&gt;object.item&lt;/upnp:class&gt;&lt;/item&gt;&lt;/DIDL-Lite&gt;


Who’s a Master?

Determining which zones are master zones was something that was also needed for this application because the importing of playlists is done through a queue which is associated with a master zone. It’s not obvious when working with a Sonos controller that the queue(s) you work with are associated with one zone in a group (assuming you have a group of more than one zone). Here’s what we figured out for determining master status:


Task: Get Current Media QueryPath: /MediaRenderer/AVTransport/Control
Action: urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo
-> InstanceID = 0

If the GetPositionInfo value returned is something like this: “x-rincon:RINCON_000E5825CEFC01400” then the zone is a slave to the zone referenced. If the value is track data or empty then it is a master zone. (Note: Is there was a better way to figure out master status?)

Update 2011-06-27:: a reader (see comments for this post) discovered that this approach does not work for a ZoneBridge. We did not test for that case so in the code below you should exclude ZoneBridges from the check. Specifically, you could check for BR100 in the Discover_Click method of MainWindow.xaml.cs and exclude that device.

The Discovery Process

Sonos Discovery - Response

Running DeviceSpy many times and studying the output with Net Monitor was the key to figuring out what was going on in the discovery of zones. We started from a post on Code Project which provided the nucleus of the discovery code, but for a different problem. Slowly we evolved the code shown here that behaves well enough for this proof of concept. But, there are times when the discovery process doesn't find anything. It would definitely make sense to have more logic to deal failure of the UDP broadcast. Future work.
The request headers are for the UDP broadcast are:

M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
ST:upnp:rootdevice
MAN:"ssdp:discover"
MX:2

HeaderEnd: CRLF


Responses received are checked for header content that contains the words text “upnp:rootdevice” and “sonos”. A response back might look something like this:

http/1.1 200 ok
cache-control: max-age = 1800
ext:
location:
http://192.168.2.5:1400/xml/zone_player.xml
server: linux upnp/1.0 sonos/14.5-40190c (zp90)
st: upnp:rootdevice
usn: uuid:rincon_000e5825b14201400::upnp:rootdevice
x-rincon-bootseq: 19
x-rincon-household: hhid_############


And the location header is all you need to start working with the zone. Once you have responses from all zones you have the start of a map of the Sonos topology.

Summary of POSTs and GETs to the Devices

The Sonos WPF application queries zones with a SOAP message in the body of an HTTP request (POST). This includes sending requests to the following Services.

Description Service Action
Get zone attributes /DeviceProperties GetZoneAttributes
Get transport information
-or-
Figure out if zone is a master
/MediaRenderer/AVTransport GetPositionInfo
Get playlist information /MediaServer/ContentDirectory Browse
Remove tracks from queue /MediaRenderer/AVTransport RemoveAllTracksFromQueue
Insert a track into a queue /MediaRenderer/AVTransport AddURIToQueue
Save queue /MediaRenderer/AVTransport SaveQueue

There is one place in this Sonos WPF application where there is an HTTP request (GET) for a page (/xml/device_description.xml). The device_description.xml is used to return the model number (ZP90, ZP120, etc.) and the unique UUID (RINCON_000#####) of the zone.

Update 2011-11-15:: a reader/tester (see comments for this post) discovered that large playlists were not correctly saved. After a few hundred items, the playlist items were not fetched. It turns out that a recursive fetch of all the playlist items is needed.  See the UPnP.QueryDevice.GetPlaylist method in UPnP.cs.

Update 2011-12-20:: After recent release of Sonos, some things worked differently and had to adapt the program. Instead of zone_player.xml to get device info, we now use device_description.xml. Part of rebranding changes? When you use Device Spy, you now see “PLAY:5” and “CONNECT:AMP”.  Also added logic for dealing with Zone Bridges. Basically, you can’t query them the same way as you can with other devices.  Also added minimal support of m3u playlist generation. Clicking a button next to the playlist name will create a “m3u_playlistname.m3u” file. You may still have to do post-processing on the file. For example, if you home for music files is “//homeserver/music” but you will be using the m3u playlist where the music is homed elsewhere, then you have to change the playlist to reflect this. The release notes for the various versions of this effort are here.

Update 2012-01-10:: Make ZoneBridge check less restrictive based on user feedback. Search for just "bridge" instead of "zonebridge".

Update 2012-02-05:: V5 Revised the discovery method (UPnP.Discovery.Discover()) by separating the collecting of the broadcast responses from the checking of them. Basically, we made two loops. Seems to perform better and require less overall time to discover. Also, tweaked MX header in discovery to 2 instead of 5. Finally, added time stamp after "Discovered devices..." method to make it easier to see when the last discovery happened. The release notes are here. The V5 version is here.

Update 2014-06-22:: Just tried this code on Windows 8 with Visual Studio 2013 and it is working. I moved the project to GitHub.

Update 2015-01-18:: Made a code fix to put a try/catch in the FindMaster() method. It was reported that some devices failed in this SOAPrequest. While working on the fix, tried the code in Visual Studio Community 2013 (free development environment). I updated the code at GitHub.

Update 2015-08-28:: Tried the code on Windows 10 and Visual Studio 2013 Community Edition. It worked. In Visual Studio, you can load the project directly by
  1. File / Open from Source Code
  2. In the Team Explorer pane, clone and paste in the HTTPS clone URL https://github.com/travelmarx/travelmarx-blog.git
  3. Open SonosWpfApplication solution.

Code Comments

  1. In the code for broadcasting a UDP message over a socket (see the UPnP.Discovery.Discover() method) it was tricky to get the “while” loop code to work for the specified timeout and to process responses to the UDP broadcast. There is remove for improvement. Experiment with the best period of time to broadcast.  There is a variable called _timeout you can set.
  2. In the general SOAP request method (see the UPnP.QueryDevice.SOAPRequest() method) we didn’t realize at first that a response could be spread out over several TCP frames and that you have to keep reading until you reach the end of the data.
  3. We got stuck on an error regarding invalid characters in the XML. This post helped set us straight that just because it looks like good XML you still have to check for invalid characters.
  4. In the import process (see the UPnP.QueryDevice.ImportPlaylist() method) some shortcuts have been taken. There are replies back from the AddURIToQueue action including FirstTrackNumberEnqueue, NumTracksAdded, and NewQueueLength. In the application developed here, they are not used.
  5. Querying for XML nodes in the DIDL-Lite XML took a little time to figure out. You have to define namespaces to find the elements you want. That includes the default namespace. For an example, see the UPnP.QueryDevice.ImportPlaylist() method.
  6. The WPF updates to the UI seem to be very slow. Ironically, the UI refreshes quicker when running in debug. There is a message control at the bottom left in the UI that was intended to be updated in a timely manner to let you know what is going on, but it can take some time. Be patient.
  7. The export of Sonos playlists to XML or as m3u playlists is done to the same directory where the executable file for the application is located and prefixes XML-exported playlists with “sp_” and m3u playlists with “m3u_”. So if you have a playlist in your Sonos system called “Dance” then the export of the playlist creates sp_Dance.xml or m3u_Dance.m3u.
  8. The import of Sonos playlists uses the exact file name (minus the extension) as the playlist name. So if your file on disk is Jazz1.xml then the playlist after imported (and hopefully successful) becomes just “Jazz1”. There is not import from m3u.
  9. You can have two Sonos playlists with the same name. They will have different queue names, e.g. SQ:15 and SQ:20.  The application developed here assumes unique playlist names so exporting will overwrite another exported file with the same name. Additional code to deal with this case could be easily added, be we haven’t done it here.
  10. This application does not provide sorting of table columns in the UI.
  11. When exporting you must select a master zone to send the clear/add/save queue commands. There is a dropdown box in the lower right of the UI that allows you to select a master.

Suggested First Test Procedure

  1. Load the project that includes the WPF application and build the release executable.
  2. Run the application.
  3. Click the “Discover” button to start the discovery process. (If discovery fails, try again.)
  4. Wait for all the zone and playlist information to come back.
  5. Export one playlist.
  6. Change the name of the file to something like “test.xml”.
  7. Select a master zone that you want to work with.
  8. Import the playlist.
  9. Check that the playlist got imported into your Sonos Music system.

Key Files Included in the Application

The Visual Studio project is zipped and located here. The project includes the following files:

MainWindow.xaml (shown below) The XAML markup code that describes the main window UI. There is only one window in this WPF application.

ManWindow.xaml.cs (shown below) The code-behind for the main window that handles events from the UI.

SonosWpfApplication.ico The icon for the WPF application. The MainWindow.xaml file references it.

UPnP.cs (shown below) The code that supports discovery and all other device queries.

SonosWpfApplication.csproj The project file.

App.xaml / .xaml.cs The application XAML and code-behindthat calls the main window. It was not modified from the default Visual Studio version when the project was created.

app.config The application configuration. Ensure that sku=”.NETFramework, Version=v4.0”.

Properties\AssemblyInfo.cs Modified to reflect the details of this application. This can be changed as needed.

Code Listings

MainWindow.xaml
<Window x:Class="SonosWpfApplication.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="My Sonos" Height="450" Width="700" Icon="SonosWpfApplication.ico">
    <StackPanel Height="450" Width="700"  Background="#FF9CAAC1">
        <ListView Name="ZoneList" Height="150" HorizontalAlignment="Left" Margin="12,12,0,0" 
                    VerticalAlignment="Top" Width="660" ItemsSource="{Binding ZoneCollection}">
            <ListView.View>
                <GridView >
                    <GridViewColumn Width="200" Header="Zone Name" DisplayMemberBinding="{Binding ZoneName}" />
                    <GridViewColumn Width="50" Header="Master" DisplayMemberBinding="{Binding ZoneMaster}" />
                    <GridViewColumn Width="150" Header="Address" DisplayMemberBinding="{Binding ZoneAddress}" />
                    <GridViewColumn Width="70" Header="Type" DisplayMemberBinding="{Binding ZoneType}" />
                    <GridViewColumn Width="190" Header="UUID" DisplayMemberBinding="{Binding ZoneID}"/>
                </GridView>
            </ListView.View>
        </ListView>
        <ListView Name="PlaylistList" Height="160" HorizontalAlignment="Left" Margin="12,12,0,0" 
                  VerticalAlignment="Top" Width="660" ItemsSource="{Binding PlaylistCollection}">
            <ListView.View>
                <GridView>
                    <GridViewColumn Width="350" Header="Playlist Name" DisplayMemberBinding="{Binding PlaylistName}" />
                    <GridViewColumn Width="75" Header="Queue" DisplayMemberBinding="{Binding PlaylistSQ}" />
                    <GridViewColumn Width="75" Header="Items" DisplayMemberBinding="{Binding NumItems}" />
                    <GridViewColumn Width="80">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Button Content="Save" Height="23" Name="PlaylistButton" Click="playlistButton_Click" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="80">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Button Content="M3U" Height="23" Name="M3UButton" Click="M3UButton_Click" />
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
        <Grid Name="ControlsGrid" HorizontalAlignment="Left" Margin="12,12,0,0" 
                  VerticalAlignment="Top" Width="660" Height="25">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*"/> 
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="120*"/>
                <ColumnDefinition Width="200*"/>
            </Grid.ColumnDefinitions>
            <Button Grid.Column="0" Content="Discover" Height="23" Name="DiscoverButton" Width="100" Click="Discover_Click" />
            <Button Grid.Column="1" Content="Save All Playlists" Height="23" Name="SaveButton" Width="100" Click="SaveAll_Click"  />
            <Button Grid.Column="2" Content="Import Playlist" Height="23" Name="ImportButton" Width="100" Click="Import_Click" />
            <TextBlock Grid.Column="3" HorizontalAlignment="Right" VerticalAlignment="Center">Master Zone To Use:</TextBlock>
            <ComboBox Name="MasterZoneDropDown" Grid.Column="4" Height="23" HorizontalAlignment="Left" 
                      VerticalAlignment="Top" Width="150" Margin="5 0 0 0" ItemsSource="{Binding MasterZones}" SelectedIndex="0">
                <ComboBox.ItemTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding Path=ZoneName}"></TextBlock>
                    </DataTemplate>
                </ComboBox.ItemTemplate>
            </ComboBox>
        </Grid>
        <TextBlock Name="Message" Margin="12,12,0,0" FontStyle="Italic">
            Click "Discover" to start.
        </TextBlock>
    </StackPanel>
</Window>





MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Collections.ObjectModel;
using Microsoft.Win32;
using System.IO;
using System.Text.RegularExpressions;

namespace SonosWpfApplication
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// UI setup and databinding inspired by this http://www.switchonthecode.com/tutorials/wpf-tutorial-using-the-listview-part-1
    /// </summary>
    public partial class MainWindow : Window
    {
        ObservableCollection<ZoneData> _ZoneCollection = new ObservableCollection<ZoneData>();
        ObservableCollection<PlaylistData> _PlaylistCollection = new ObservableCollection<PlaylistData>();
        ObservableCollection<ZoneData> _MasterZones = new ObservableCollection<ZoneData>();

        public MainWindow()
        {
            InitializeComponent();
        }
        public ObservableCollection<ZoneData> ZoneCollection
        { get { return _ZoneCollection; } }
        public ObservableCollection<PlaylistData> PlaylistCollection
        { get { return _PlaylistCollection; } }
        public ObservableCollection<ZoneData> MasterZones
        {  get { return _MasterZones; }}

        /// <summary>
        /// Processes the button click event when saving one playlist.
        /// </summary>
        private void playlistButton_Click(object sender, RoutedEventArgs e)
        {
            Button b = new Button();
            b = (Button)sender;
            string playlistQueue = ((PlaylistData)b.DataContext).PlaylistSQ;
            string playlistName = ((PlaylistData)b.DataContext).PlaylistName;

            string fileName = "sp_" + playlistName + ".xml";
            string content = UPnP.QueryDevice.GetPlaylist(playlistQueue, UPnP.QueryDevice.PlaylistAction.Save, 0);
            ExportPlaylist(fileName, content, "xml" /* type */);
        }

        private void M3UButton_Click(object sender, RoutedEventArgs e)
        {
            Button b = new Button();
            b = (Button)sender;
            string playlistQueue = ((PlaylistData)b.DataContext).PlaylistSQ;
            string playlistName = ((PlaylistData)b.DataContext).PlaylistName;

            string fileName = "m3u_" + playlistName + ".m3u";
            string content = UPnP.QueryDevice.GetPlaylist(playlistQueue, UPnP.QueryDevice.PlaylistAction.Save, 0);
            ExportPlaylist(fileName, content, "m3u" /* type */);
        }


        /// <summary>
        /// Processes the button click event when saving all playlists.
        /// </summary>
        private void SaveAll_Click(object sender, RoutedEventArgs e)
        {
            // Iterate through playlists.
            if (PlaylistCollection.Count > 0)
            {
                foreach (PlaylistData pd in PlaylistCollection)
                {
                    string fileName = "sp_" + pd.PlaylistName + ".xml";
                    string content = UPnP.QueryDevice.GetPlaylist(pd.PlaylistSQ, UPnP.QueryDevice.PlaylistAction.Save, 0 /* start at zero */);
                    ExportPlaylist(fileName, content, "xml" /* type */);
                }
                RefreshMessage("Saved all playlists.");
            }
        }

        /// <summary>
        /// Processes the import button click.
        /// Open dialog code inspired by http://www.kirupa.com/net/using_open_file_dialog_pg2.htm
        /// </summary>
        private void Import_Click(object sender, RoutedEventArgs e)
        {
            string playlistToImport = null;
            string playlistToImportSafe = null;
            OpenFileDialog ofd = new OpenFileDialog();
            ofd.Multiselect = false;
            if (ofd.ShowDialog() == true)
            {
                playlistToImport = ofd.FileName; // includes path
                playlistToImportSafe = ofd.SafeFileName; // no path
                playlistToImportSafe = System.IO.Path.GetFileNameWithoutExtension(playlistToImportSafe); // without extension
            }
            try
            {
                StringBuilder sb = new StringBuilder();
                using (var stream = new StreamReader(playlistToImport))
                {
                    RefreshMessage("Reading " + playlistToImport);
                    String line;
                    while ((line = stream.ReadLine()) != null)
                    {
                        sb.Append(line);
                    }
                }
                string DIDLString = sb.ToString();
                UPnP.QueryDevice.ImportPlaylist(DIDLString, ((ZoneData)MasterZoneDropDown.SelectedItem).ZoneAddress, playlistToImportSafe);
                RefreshMessage("Finished importing.");
            }
            catch (Exception ex)
            {
                RefreshMessage("Failed to read file " + playlistToImportSafe + " for import. " + ex.Message);
            }
        }

        /// <summary>
        /// Processes the discover button click.
        /// </summary>
        private void Discover_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                RefreshMessage("Discovering...");
                UPnP.Discovery.Discover();
                UPnP.QueryDevice.QueryZoneAttributes();
                UPnP.QueryDevice.FindMasters();
                UPnP.QueryDevice.QueryZonePlayerXml();
                UPnP.QueryDevice.GetPlaylists();
                RefreshMessage((UPnP.Discovery.Zones.Count != 0) ? 
                    "Discovered " + UPnP.Discovery.Zones.Count.ToString() + 
                    " devices. (" + DateTime.Now.ToString() + ")" : "No devices discovered. Try discovery again.");
                foreach (string zone in UPnP.Discovery.Zones)
                {
                    Uri uri = new Uri(zone);
                    ZoneData zd = new ZoneData
                    {
                        ZoneName = UPnP.Discovery.ZoneTable[zone],
                        ZoneAddress = uri.Host,
                        ZoneType = UPnP.Discovery.ZoneTypes[zone],
                        ZoneID = UPnP.Discovery.ZoneIDs[zone],
                        ZoneMaster = UPnP.Discovery.ZoneMasters[zone].ToString()
                    };
                    if (!_ZoneCollection.Contains(zd, new ZoneComparer()))
                    {
                        _ZoneCollection.Add(zd);
                    }
                    if (UPnP.Discovery.ZoneMasters[zone] && !_MasterZones.Contains(zd, new ZoneComparer()))
                    {
                        _MasterZones.Add(zd);
                    }
                }
                foreach (KeyValuePair<string, string> kvp in UPnP.QueryDevice.Playlists)
                {
                    PlaylistData pd = new PlaylistData
                    {
                        PlaylistName = kvp.Value,
                        PlaylistSQ = kvp.Key,
                        NumItems = UPnP.QueryDevice.GetPlaylist(kvp.Key, UPnP.QueryDevice.PlaylistAction.Count, 0  /* not used for count */)
                    };
                    if (!_PlaylistCollection.Contains(pd, new PlaylistComparer()))
                    {
                        _PlaylistCollection.Add(pd);
                    }
                }
            }
            catch(Exception ex)
            {
                RefreshMessage( ex.Message + " Try discovery again.");
            }

        }
        /// <summary>
        /// Gets a valid file name, making sure not bad characters get in the name.
        /// Code from: http://stackoverflow.com/questions/309485/c-sanitize-file-name
        /// </summary>
        private static string GetValidFileName(string name)
        {
            string invalidChars = Regex.Escape(new string(System.IO.Path.GetInvalidFileNameChars()));
            string invalidReStr = string.Format(@"[{0}]+", invalidChars);
            return Regex.Replace(name, invalidReStr, "_");
        }

        /// <summary>
        /// Exports a playlist.
        /// </summary>
        /// <param name="fileName">The name of the exported playlist file.</param>
        /// <param name="content">The content to go inside the file.</param>
        private void ExportPlaylist(string fileName, string content, string type)
        {
            string currDirectory = AppDomain.CurrentDomain.BaseDirectory;
            string filePath = currDirectory + GetValidFileName(fileName);
            try
            {
                using (var fileStream = new FileStream(filePath, FileMode.Create)) // Synchronous mode
                {
                    if (type.Equals("xml"))
                    {
                        byte[] info = new UTF8Encoding(true).GetBytes(content);
                        fileStream.Write(info, 0, info.Length);
                        RefreshMessage("Saved " + fileName);
                    }
                    else
                    {
                        String playlist = UPnP.QueryDevice.CreateM3UPlaylist(content);
                        byte[] info = new UTF8Encoding(true).GetBytes(playlist);
                        fileStream.Write(info, 0, info.Length);
                        RefreshMessage("Saved " + fileName);
                    }
                }
            }
            catch (Exception ex)
            {
                RefreshMessage("Failed to save " + fileName + ". " + ex.Message);
            }
        }

        /// <summary>
        /// Helper method to avoid typing the same two lines many times.
        /// </summary>
        /// <param name="message">Message to assign to UI.</param>
        public void RefreshMessage(string message)
        {
            Message.Text = message;
            Message.Refresh();
        }

    }

    /// <summary>
    /// Compares two zones to determine if they are equal.
    /// </summary>
    public class ZoneComparer : IEqualityComparer<ZoneData>
    {
        public bool Equals(ZoneData zd1, ZoneData zd2)
        {
            return zd1.ZoneID == zd2.ZoneID;
        }
        public int GetHashCode(ZoneData zd)
        {
            return zd.ZoneID.GetHashCode();
        }
    }

    /// <summary>
    /// Compares two playlists to determine if they are equal.
    /// </summary>
    public class PlaylistComparer : IEqualityComparer<PlaylistData>
    {
        public bool Equals(PlaylistData pd1, PlaylistData pd2)
        {
            return pd1.PlaylistSQ == pd2.PlaylistSQ;
        }
        public int GetHashCode(PlaylistData pd)
        {
            return (pd.PlaylistName + pd.PlaylistSQ).GetHashCode();
        }
    }

    /// <summary>
    /// Extension method to deal with refreshing UI element in WPF xaml.
    /// Idea came from here: http://social.msdn.microsoft.com/forums/en-US/wpf/thread/878ea631-c76e-49e8-9e25-81e76a3c4fe3
    /// </summary>
    public static class ExtensionMethods
    {
        private static Action EmptyDelegate = delegate() { };
        public static void Refresh(this UIElement uiElement)
        {
            uiElement.Dispatcher.Invoke(EmptyDelegate, System.Windows.Threading.DispatcherPriority.Render);
        }
    }

    /// <summary>
    /// Defines one zone.
    /// </summary>
    public class ZoneData
    {
        public string ZoneName { get; set; }
        public string ZoneAddress { get; set; }
        public string ZoneType { get; set; }
        public string ZoneID { get; set; }
        public string ZoneMaster { get; set; }
    }

    /// <summary>
    /// Defines one playlist.
    /// </summary>
    public class PlaylistData
    {
        public string PlaylistName { get; set; }
        public string PlaylistSQ { get; set; }
        public string NumItems { get; set; }
    }
}





UPnP.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using System.Net.Sockets;
using System.Net;
using System.Xml;
using System.IO;
using System.Linq;
using System.Web;
using System.Xml.Linq; // Be sure to set "Target framework:"  to non-client, e.g. framework 4.0.

namespace UPnP
{
    /// <summary>
    /// The Discovery class does the UDP broadcast and holds the Sonos topology info in various dictionaries.
    /// </summary>
    public class Discovery
    {
        // Discovery parameter that may need to be changed.
        static TimeSpan _timeout = new TimeSpan(0, 0, 0, 10); // fourth parameter is seconds
        static string _broadcastIP = "239.255.255.250"; 
        static int _broadcastPort = 1900;

        // Define properties and backing objects.
        public TimeSpan TimeOut 
        { get { return _timeout; } set { _timeout = value; }}
        static public Dictionary<string, string> ZoneTable 
        { get { return _zoneTable; } set { _zoneTable = value; }}
        static public Dictionary<string, string> ZoneTypes
        { get { return _zoneTypes; } set { _zoneTypes = value; }}
        static public Dictionary<string, string> ZoneIDs
        { get { return _zoneIDs; } set { _zoneIDs = value; }}
        static public Dictionary<string, bool> ZoneMasters
        { get { return _zoneMasters; } set { _zoneMasters = value; }}
        static public ArrayList Zones
        { get { return _zones; } set { _zones = value; }}

        static ArrayList _zones = new ArrayList();
        static Dictionary<string, string> _zoneTable = new Dictionary<string, string>();
        static Dictionary<string, string> _zoneTypes = new Dictionary<string, string>();
        static Dictionary<string, string> _zoneIDs = new Dictionary<string, string>();
        static Dictionary<string, bool> _zoneMasters = new Dictionary<string, bool>();

        /// <summary>
        /// Sends a UDP package over the specified broadcast IP and port and waits for responses.
        /// Some background: http://www.upnp-hacks.org/upnp.html, http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf
        /// </summary>
        static public void Discover()
        {
            Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
            s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1);
            string req = "M-SEARCH * HTTP/1.1\r\n" +
            "HOST: " + _broadcastIP + ":" + _broadcastPort.ToString() + "\r\n" +
            "ST:upnp:rootdevice\r\n" +
            "MAN:\"ssdp:discover\"\r\n" +
            "MX:2\r\n\r\n" +
            "HeaderEnd: CRLF";
            byte[] data = Encoding.ASCII.GetBytes(req);
            IPEndPoint ipe = new IPEndPoint(IPAddress.Parse(_broadcastIP), _broadcastPort);
            byte[] buffer = new byte[0x8000];
            ArrayList responses = new ArrayList();

            DateTime start = DateTime.Now;

            // Are multiple sends needed? Is sending one broadcast sufficient?
            s.SendTo(data, ipe);

            int length = 0;
            do
            {
                if (s.Available > 0)
                {
                    length = s.Receive(buffer);
                    string resp = Encoding.ASCII.GetString(buffer, 0, length).ToLower();
                    responses.Add(resp);
                }
            } while (DateTime.Now.Subtract(start) < _timeout);
            s.Shutdown(SocketShutdown.Both);
            s.Close();

            // Go through results.

            for (int i = 0; i < responses.Count; i++)
            {
                String resp = responses[i].ToString();
                if (resp.ToLower().Contains("upnp:rootdevice") && resp.ToLower().Contains("sonos"))
                {
                    string loc = resp.Substring(resp.ToLower().IndexOf("location:") + 9);
                    loc = loc.Substring(0, loc.IndexOf("\r")).Trim();
                    if (!_zones.Contains(loc))
                    {
                        _zones.Add(loc);
                        _zoneTable.Add(loc, ""); // Will fill in zone friendly name later.
                    }
                }
            }

        }
    }
    /// <summary>
    /// The QueryDevice class does the following:
    /// 1. Queries (SOAP) zone attributes to get zone friendly name.
    /// 2. Queries (HTTP) zone player xml to get zone type and ID. 
    /// 3. Queries (SOAP) zone transports to see if zone is a master.
    /// 4. Queries (SOAP) zone content directory to get playlist list.
    /// 5. Queries (SOAP) zone content directory to get one playlist's content or a count of items.
    /// 6. 
    /// </summary>
    public static class QueryDevice
    {
        // Properties and backing objects.
        public static Dictionary<string, string> Playlists
        { get { return _playlists; } set { _playlists = value; } }
        public enum PlaylistAction { Count, Save};
        static public Dictionary<string, string> _playlists = new Dictionary<string, string>();
        static int _maxPlaylistPagingResults = 100;
        static int _maxPlaylistsReturned = 100;

        /// <summary>
        /// Get attributes for each zone in the Sonos topology.
        /// </summary>
        public static void QueryZoneAttributes()
        {
            if (UPnP.Discovery.ZoneTable.Count > 0)
            {
                foreach (string zone in UPnP.Discovery.Zones)
                {
                    // Query to get zone attributes.
                    string path = "/DeviceProperties/Control";
                    Uri uri = new Uri(zone);
                    string host = uri.Scheme + "://" + uri.Host + ":" + uri.Port.ToString();
                    string soapAction = "urn:upnp-org:serviceId:DeviceProperties#GetZoneAttributes";
                    string soapBody = "<u:GetZoneAttributes xmlns:u=\"urn:schemas-upnp-org:service:DeviceProperties:1\">"+
                                      "</u:GetZoneAttributes>";
                    XmlDocument resp = SOAPRequest(path, host, soapBody, soapAction);
                    string zoneName = resp.SelectSingleNode("//CurrentZoneName").InnerText;
                    UPnP.Discovery.ZoneTable[zone] = zoneName;
                }
            }
        }

        /// <summary>
        /// Gets the zoneplayer xml file to read two pieces of information, the zone ID and type. We
        /// could use this xml to figure out the friendly name of the zone but we do that elsewhere.
        /// </summary>
        public static void QueryZonePlayerXml()
        {
            if (UPnP.Discovery.ZoneTable.Count > 0)
            {
                foreach (string zone in UPnP.Discovery.Zones)
                {
                    if (!UPnP.Discovery.ZoneTable[zone].ToLower().Contains("bridge"))
                    {
                        string path = "/xml/device_description.xml"; //  "/xml/zone_player.xml";
                        Uri uri = new Uri(zone);
                        string host = uri.Scheme + "://" + uri.Host + ":" + uri.Port.ToString();
                        XmlDocument resp = GetWebResponse(path, host);

                        XmlNamespaceManager nsm = new XmlNamespaceManager(resp.NameTable);
                        nsm.AddNamespace("zp", "urn:schemas-upnp-org:device-1-0");
                        string zoneType = resp.SelectSingleNode("//zp:modelNumber", nsm).InnerText;
                        UPnP.Discovery.ZoneTypes[zone] = zoneType;
                        string uuid = resp.SelectSingleNode("//zp:UDN", nsm).InnerText;
                        UPnP.Discovery.ZoneIDs[zone] = uuid.Substring(5, uuid.Length - 5); // need to remove uuid:
                    }
                    else
                    {
                        UPnP.Discovery.ZoneIDs[zone] = "....";
                        UPnP.Discovery.ZoneTypes[zone] = "ZoneBridge";
                    }
                }
            }
        }

        /// <summary>
        /// Iterates through zones and figures out if the zone is a master.
        /// </summary>
        public static void FindMasters()
        {
            if (UPnP.Discovery.ZoneTable.Count > 0)
            {
                foreach (string zone in UPnP.Discovery.Zones)
                {
                    if (!UPnP.Discovery.ZoneTable[zone].ToLower().Contains("bridge"))
                    {
                        try
                        {
                            string path = "/MediaRenderer/AVTransport/Control";
                            Uri uri = new Uri(zone);
                            string host = uri.Scheme + "://" + uri.Host + ":" + uri.Port.ToString();
                            string soapAction = "uurn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo";
                            string soapBody = "" +
                                                  "0" +
                                                  "Master" +
                                              "";
                            XmlDocument resp = SOAPRequest(path, host, soapBody, soapAction);

                            string trackURI = resp.SelectSingleNode("//TrackURI").InnerText;
                            // check SOAP for TrackURI for x-rincon:RINCON
                            UPnP.Discovery.ZoneMasters[zone] = (!trackURI.StartsWith("x-rincon:RINCON") | trackURI == String.Empty);
                        }
                        catch (Exception e)
                        {
                            // It was reported that some devices throw an error as reported by a reader, adding try to catch this.
                            // Currently set message but don't use it. Consider surfacing in .xaml
                            string err = e.Message;
                            UPnP.Discovery.ZoneMasters[zone] = false;
                        }
                    }
                    else
                    {
                        UPnP.Discovery.ZoneMasters[zone] = false;
                    }
                }
            }

        }

        /// <summary>
        /// Gets all playlists defined for the Sonos topology.
        /// </summary>
        public static void GetPlaylists()
        {
            if (UPnP.Discovery.Zones.Count > 0)
            {
                string zone = UPnP.Discovery.Zones[0].ToString(); // use first in list if it isn't a bridge
                if (UPnP.Discovery.ZoneTable[zone].ToLower().Contains("bridge"))
                {
                    zone = UPnP.Discovery.Zones[1].ToString(); // use second
                }
                string path = "/MediaServer/ContentDirectory/Control";
                Uri uri = new Uri(zone);
                string host = uri.Scheme + "://" + uri.Host + ":" + uri.Port.ToString();
                string soapAction = "urn:schemas-upnp-org:service:ContentDirectory:1#Browse";
                string soapBody = "<u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">" +
                                      "<ObjectID>SQ:</ObjectID>" + 
                                      "<BrowseFlag>BrowseDirectChildren</BrowseFlag>" + 
                                      "<Filter></Filter>" + 
                                      "<StartingIndex>0</StartingIndex>" +
                                      "<RequestedCount>" + _maxPlaylistsReturned.ToString() + "</RequestedCount>" + 
                                      "<SortCriteria></SortCriteria>" +
                                   "</u:Browse>";
                XmlDocument resp = SOAPRequest(path, host, soapBody, soapAction);

                XmlNamespaceManager nsm = new XmlNamespaceManager(resp.NameTable);
                nsm.AddNamespace("dc", "http://purl.org/dc/elements/1.1/");
                XmlDocument resultNode = new XmlDocument();
                resultNode.LoadXml(resp.SelectSingleNode("*//Result").InnerText);
                XmlNodeList playlists = resultNode.SelectNodes("//dc:title", nsm);
                foreach (XmlNode xn in playlists)
                {
                    string id = xn.ParentNode.Attributes["id"].Value;
                    if (!_playlists.ContainsKey(id))
                    {
                        _playlists.Add(id, xn.InnerText);
                    }
                }
            }

        }

        /// <summary>
        /// Gets a playlist.
        /// </summary>
        /// <param name="playlistID">The playlist ID in the system, like "SQ:14" - for Saved Queue.</param>
        /// <param name="action">What to find out about the playlist, either return the items in the playlist or a count of the items.</param>
        /// <returns></returns>
        public static string GetPlaylist(string playlistID, PlaylistAction action, int index)
        {
            string zone = UPnP.Discovery.Zones[0].ToString(); // use first in list
            string path = "/MediaServer/ContentDirectory/Control";
            string numResults = String.Empty;
            string retVal = String.Empty;

            Uri uri = new Uri(zone);
            string host = uri.Scheme + "://" + uri.Host + ":" + uri.Port.ToString();
            string soapAction = "urn:schemas-upnp-org:service:ContentDirectory:1#Browse";
            string soapBody = "<u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">" +
                                   "<ObjectID>" + playlistID + "</ObjectID>" +
                                   "<BrowseFlag>BrowseDirectChildren</BrowseFlag>" +
                                   "<Filter></Filter>" +
                                   "<StartingIndex>" + (_maxPlaylistPagingResults * (index)).ToString() + "</StartingIndex>" + 
                                   "<RequestedCount>" + (_maxPlaylistPagingResults).ToString() + "</RequestedCount>" + 
                                   "<SortCriteria></SortCriteria>" + 
                               "</u:Browse>";
            XmlDocument resp = SOAPRequest(path, host, soapBody, soapAction);

            XmlNamespaceManager nsm = new XmlNamespaceManager(resp.NameTable);
            nsm.AddNamespace("dc", "http://purl.org/dc/elements/1.1/");
            XmlDocument resultNode = new XmlDocument();
            numResults = resp.SelectSingleNode("*//TotalMatches").InnerText;
            switch (action)
            {
                default:
                case PlaylistAction.Count:
                    retVal = numResults;
                    break;
                case PlaylistAction.Save:
                    if (Int32.Parse(numResults) > _maxPlaylistPagingResults*(index+1) /* zero-based index */)
                    {
                        index += 1;
                        // Get playlist items recursively.
                        XDocument recurVal = XDocument.Parse(GetPlaylist(playlistID, action, index));
                        XDocument currVal = XDocument.Parse(resp.SelectSingleNode("*//Result").InnerText);
                        currVal.Root.Add(recurVal.Root.Elements());
                        retVal = currVal.ToString();
                    }
                    else
                    {
                        retVal = resp.SelectSingleNode("*//Result").InnerText;
                    }
                    break;
            }
            return retVal;
        }

        public static string CreateM3UPlaylist(string content)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("#EXTM3U");
            sb.Append(System.Environment.NewLine);
            sb.Append(System.Environment.NewLine);

            // Then we iterate through the DIDL xml item by item
            string DIDLTemplate = "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" " +
                                    "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" " +
                                    "xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" " +
                                    "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">{0}" +
                                   "</DIDL-Lite>";
            int musicTracksSent = 0;
            XmlDocument DIDLXml = new XmlDocument();
            DIDLXml.LoadXml(SanitizeXmlString(content));
            XmlNamespaceManager nsm = new XmlNamespaceManager(DIDLXml.NameTable);
            nsm.AddNamespace("didl", "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/");
            nsm.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); // not required for below currently
            nsm.AddNamespace("upnp", "urn:schemas-upnp-org:metadata-1-0/upnp/"); // not required for below currently
            XmlNodeList nl = DIDLXml.SelectNodes("//didl:item", nsm);
            foreach (XmlNode node in nl)
            {
                string location = node.SelectSingleNode("didl:res", nsm).InnerText;
                location = location.Remove(0, 12); // Remove x-file-cifs:
                location = HttpUtility.UrlDecode(location);
                string name = node.SelectSingleNode("dc:title", nsm).InnerText;
                string creator = "";
                if (node.SelectSingleNode("dc:creator", nsm) != null)
                {
                    creator = node.SelectSingleNode("dc:creator", nsm).InnerText;
                }
                string oneItemDIDL = HttpUtility.HtmlEncode(String.Format(DIDLTemplate, node.OuterXml));
                //oneItemDIDL = System.Security.SecurityElement.Escape(oneItemDIDL);
                sb.Append("#EXTINF:0," + creator + " - " + name);
                sb.Append(System.Environment.NewLine);
                sb.Append(location.Replace("/","\\"));
                sb.Append(System.Environment.NewLine);
                sb.Append(System.Environment.NewLine);

                musicTracksSent += 1;
                // Report on items written?
            }
            return sb.ToString();
        }

        /// <summary>
        /// Imports playlist content from a string into a master controller queue.
        /// </summary>
        /// <param name="content">The XML string that represents the playlist.</param>
        /// <param name="masterZoneAddress">The master zone address, like "192.168.2.5"</param>
        /// <param name="name">The name to give the playlist.</param>
        public static void ImportPlaylist(string content, string masterZoneAddress, string name)
        {
            // First we clear items in the queue
            string path = "/MediaRenderer/AVTransport/Control";
            string host = "http://" + masterZoneAddress + ":1400";
            string soapAction = "urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue";
            string soapBody = "<u:RemoveAllTracksFromQueue xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\">" + 
                                   "<InstanceID>0</InstanceID>" +
                               "</u:RemoveAllTracksFromQueue>";
            XmlDocument resp = SOAPRequest(path, host, soapBody, soapAction);

            // Then we iterate through the DIDL xml item by item
            string DIDLTemplate = "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" " + 
                                    "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" " +
                                    "xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" " + 
                                    "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">{0}"+
                                   "</DIDL-Lite>";
            int musicTracksSent = 0;
            XmlDocument DIDLXml = new XmlDocument();
            DIDLXml.LoadXml(SanitizeXmlString(content));
            XmlNamespaceManager nsm = new XmlNamespaceManager(DIDLXml.NameTable);
            nsm.AddNamespace("didl", "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/");
            nsm.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); // not required for below currently
            nsm.AddNamespace("upnp", "urn:schemas-upnp-org:metadata-1-0/upnp/"); // not required for below currently
            XmlNodeList nl = DIDLXml.SelectNodes("//didl:item", nsm);
            foreach (XmlNode node in nl)
            {
                // Create a command
                string protocol = node.SelectSingleNode("didl:res", nsm).InnerText;
                string oneItemDIDL = HttpUtility.HtmlEncode(String.Format(DIDLTemplate, node.OuterXml));
                //oneItemDIDL = System.Security.SecurityElement.Escape(oneItemDIDL);
                string title = name;

                // Now put the element in the queue
                soapAction = "urn:schemas-upnp-org:service:AVTransport:1#AddURIToQueue";
                soapBody = "<u:AddURIToQueue xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\">" + 
                               "<InstanceID>0</InstanceID>" + 
                               "<EnqueuedURI>" + protocol + "</EnqueuedURI>" + 
                               "<EnqueuedURIMetaData>" + oneItemDIDL + "</EnqueuedURIMetaData>" + 
                               "<DesiredFirstTrackNumberEnqueued>0</DesiredFirstTrackNumberEnqueued>" + 
                               "<EnqueueAsNext>0</EnqueueAsNext>" + 
                            "</u:AddURIToQueue>";
                resp = SOAPRequest(path, host, soapBody, soapAction);
                musicTracksSent += 1;
                // Process return parameters to provide info about has been put into queue?
            }
            if (musicTracksSent > 0)  
            {
                // At least one thing was written so try to save. If program exits before this point then items
                // are in queue in master zone and can be manually saved.
                name = name.Substring(0, Math.Min(20, name.Length)); // Limit on playlist length.
                soapAction = "urn:schemas-upnp-org:service:AVTransport:1#SaveQueue";
                soapBody = "<u:SaveQueue xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\">" + 
                               "<InstanceID>0</InstanceID>" + 
                               "<Title>" + name + "</Title>" + 
                               "<ObjectID></ObjectID>" + 
                           "</u:SaveQueue>";
                resp = SOAPRequest(path, host, soapBody, soapAction);
            }

        }

        /// <summary>
        /// Gets the response to an HTTP request.
        /// </summary>
        /// <param name="path">URL path, like "/xml/zoneplayer.xml"</param>
        /// <param name="host">Host, like "192.168.2.5"</param>
        /// <returns></returns>
        private static XmlDocument GetWebResponse(string path, string host)
        {
            HttpWebRequest webReq = (HttpWebRequest)WebRequest.Create(host + path);
            webReq.Method = "GET";

            //Get the response handle, no response yet.
            HttpWebResponse webResp = (HttpWebResponse)webReq.GetResponse();

            //Get response
            Stream strm = webResp.GetResponseStream();
            StreamReader strmReader = new StreamReader(strm);
            string data = strmReader.ReadToEnd();
            XmlDocument resp = new XmlDocument();
            resp.LoadXml(SanitizeXmlString(data));
            strmReader.Close();
            strm.Close();
            webResp.Close();
            return resp;
        }

        /// <summary>
        /// Gets the response to a HTTP request with SOAP body.
        /// </summary>
        /// <param name="path">URL path, like "/MediaRenderer/AVTransport/Control"</param>
        /// <param name="host">Host, like "192.168.2.5"</param>
        /// <param name="soapBody">The SOAP envelope containing the parameters of the request.</param>
        /// <param name="soapAction">The SOAP action, like "Browse" or "SaveQueue".</param>
        /// <returns></returns>
        private static XmlDocument SOAPRequest(string path, string host, string soapBody, string soapAction)
        {
            string reqBody = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
            "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">" +
            "<s:Body>" +
            soapBody +
            "</s:Body>" +
            "</s:Envelope>";
            HttpWebRequest webReq = (HttpWebRequest)WebRequest.Create(host + path);
            webReq.Method = "POST";
            byte[] buffer = Encoding.UTF8.GetBytes(reqBody);
            webReq.Headers.Add("SOAPACTION", "\"" + soapAction + "\"");
            webReq.ContentType = "text/xml; charset=\"utf-8\"";
            webReq.ContentLength = reqBody.Length;

            // Open  a stream for writing. Close.
            Stream postData = webReq.GetRequestStream();
            postData.Write(buffer, 0, buffer.Length);
            postData.Close();

            //Get the response handle, no response yet.
            HttpWebResponse webResp = (HttpWebResponse)webReq.GetResponse();

            //Get response
            int contentLength = (int)webResp.ContentLength;
            char[] data = new char[contentLength];
            Stream strm = webResp.GetResponseStream();
            StreamReader strmReader = new StreamReader(strm);
            int chunkSize = 256;
            if (contentLength <= chunkSize)
            {
                strmReader.Read(data, 0, contentLength);
            }
            else
            {
                int count = strmReader.Read(data, 0, chunkSize);
                int indx = count;
                while (count > 0)
                {
                    int inc = (contentLength - chunkSize) >= indx ? chunkSize : contentLength - indx;
                    count = strmReader.Read(data, indx, inc);
                    indx += count;
                }
            }
            XmlDocument resp = new XmlDocument();
            resp.LoadXml("<root>" + SanitizeXmlString(new String(data)) + "</root>");
            strmReader.Close();
            strm.Close();
            webResp.Close();
            return resp;
        }

        /// <summary>
        /// Removes junk from an XML string that doesn't belong there.
        /// Source: http://seattlesoftware.wordpress.com/2008/09/11/hexadecimal-value-0-is-an-invalid-character/
        /// Would be best to follow advice in link and build functionality into StreamReader.
        /// </summary>
        /// <param name="xml">XML to check.</param>
        /// <returns></returns>
        public static string SanitizeXmlString(string xml)
        {
            if (xml == null)
            {
                throw new ArgumentNullException("xml");
            }
            StringBuilder buffer = new StringBuilder(xml.Length);

            foreach (char c in xml)
            {
                if (XmlConvert.IsXmlChar(c))
                {
                    buffer.Append(c);
                }
            }
            return buffer.ToString();
        }
    }
}

Thursday, February 3, 2011

A Do-It-Yourself (DIY) Sonos Controller (Web Page) on iPad

iPad Web Page Displaying Sonos Content


(The code on this page was last checked and verified in June 2014.)

Warning this is not an elegant solution, however, it may interest those curious about Sonos and ways to get more interesting information displays about what Sonos is playing. Start from the post, A Simple Sonos JavaScript and Java Application. Use the HTML page (with JavaScript code) that is given in that post or here on GitHub, exactly as is.

The trick to this whole process is to host the page (with the JavaScript functionality that does all the magic) locally on the iPad. The way I did that is put the page in Dropbox and then using the Atomic Web Browser I could save the page locally. When the page is rendered locally the URL looks like this (in Atomic) “file:///var/mobile/Applications....”.

The following images are taken from an iPad (hold the Home button and power button simultaneously).

1. Open up the Atomic Browser.
Navigate to www.dropbox.com and sign in. Make sure you have uploaded the file (WhatSonosIsPlaying.html) to DropBox.



2. Select file in Dropbox and Get the File Locally


3. Select the Local File in Atomic Web Browser

The page should show metadata about what's playing. Clicking the Google or Bing search buttons should open into a new window.

Saturday, January 15, 2011

Extracting Sonos Playlists - A Simple Sonos JavaScript Application, Take II

Overview

(The code on this page was last checked and verified in June 2014.)

Did you ever want to extract a Sonos playlist so you could share it or keep it for your records outside of Sonos? Well you can. In this post we’ll talk about some simple ways to do it. First, let’s start with a little history. We’ve written about Sonos a couple of other times in the blog. First we showed how to explore your Sonos UPnP topology and then we wrote a simple program using JavaScript to query Sonos (via SOAP requests) to display basic information about what was playing (on GitHub). A reader of the latter blog entry asked the question about generating playlists, and then contributed a Java program which does it (on GitHub). Still later (a year), we developed a WPF application (see WPF Application to Save and Import Sonos Playlists) to extract lists. This post summarizes all the approaches:


  • First Approach - Manual
    The approach requires minimal programming – you work with DeviceSpy (or something equivalent), issue a command, get the returned XML, and transform it. (If you need info on DeviceSpy see the Exploring Sonos via UPnP.) This approach is discussed in detail below.
  • Second Approach - Javascript in the Browser
    The second approach extracts playlists using JavaScript in a browser page. It requires you to configure your browser so that it can communicate to the Sonos devices (disable same origin policy). This approach is discussed below, but only in comparison to the first approach. See A Simple Sonos JavaScript and Java Application for details on how to use this program. (Code is on GitHub.) 
  • Third Approach - Java Program
    The third approach is a simple Java program that takes the IP address of a Sonos device and then spits out what's in its queue. You have to specifically load a playlist in a queue to download it.  (This may be useful for just capturing what's in a queue in general.) This approach requires some knowledge of how to run a basic Java program. See A Simple Sonos JavaScript and Java Application for details on how to do this. (Code is on GitHub.)
  • Fourth Approach - WPF Program
    This is a C# program running on Windows 7/8 which can extract one or many playlists. It can also import into a playlist. This approach is discussed here: WPF Application to Save and Import Sonos Playlists.


First Approach – Manual

Step 1: Open DeviceSpy and go to a zone player that is a master. If all your zones are in one group and you look at a Sonos controller (e.g. the handheld or the iPhone application) the master is the first item in the zone list*. To start with just put all the zone players in the same zone to make it easy.**

*update: Not true. The master is the zone you started the zone group with. You can
**update: Maybe easier is to just create a group of one zone and work with that then you are assured it is the master.

Step 2: In DeviceSpy, go to a zone master expand the Media Server Device, and expand the Content Directory Service.
Browse Action Dialog


Step 3: Open (double click or right Invoke Action) the Browse Action.

Invoke Action Dialog

Step 4: Fill in the parameters in the Browse Action as shown in each query below. The queries end with Query 6 which is what we really want, displaying what’s in a playlist. In each query, you fill in the fields as shown and hit Invoke and the results will be in the Result field as XML. For now you can post the XML in Notepad to get a sense of what it contains. To get the XML from the Result field to the clipboard place your cursor in the field and CTRL + A to select all.

Query 1: Get a Count of What’s in the Queue
- ObjectID = Q
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank]
- StartingIndex = 0
- RequestedCount = 1
- SortCriteria = [leave blank]
- Result = [look for the childCount attribute in the returned XML]

Query 2: Get What’s in the Queue
- ObjectID = Q:0
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank]
- StartingIndex = 0
- RequestedCount = 100 (or something high enough to get what you want; if the queue is very big consider doing this in several operations)
- SortCriteria = [leave blank]
- Result = [lots of XML returned about what’s in the queue]

Query 3: See What’s Available to Query In Terms of Searching for Music
- ObjectID = A:
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank, sure wish I knew the format for using it though…]
- StartingIndex = 0
- RequestedCount = 10
- SortCriteria = [leave blank]
- Result = [XML which shows A:ALBUMARTIST, A:ALBUM, …, A:PLAYLISTS as valid ObjectIDs]

Query 4: See What’s Available to Query System-wide
- ObjectID = 0 [zero]
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank]
- StartingIndex = 0
- RequestedCount = 10
- SortCriteria = [leave blank]
- Result = [XML which shows A:, S:, Q:, SQ:, R:, AI:, and EN: ]

A: Atribute
S: Music Shares
Q: Queues [we’ve seen an example above]
SQ: Saved Queues
R: Internet Radio
AI: Audio Inputs
EN: Entire Network

Query 5: List Saved Queues (Playlist)
- ObjectID = SQ:
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank]
- StartingIndex = 0
- RequestedCount = 100
- SortCriteria = [leave blank]
- Result = [XML which shows SQ:1 to however many playlists you have. ]

Query 6: List Songs in a Particular Saved Queue (Playlist)
- ObjectID = SQ:1 [choose a # you know exists using the previous query]
- BrowseFlag = BrowseDirectChildren
- Filter = [leave blank]
- StartingIndex = 0
- RequestedCount = 100
- SortCriteria = [leave blank]
- Result = [XML which shows contents of saved queue or playlist]

The following example below shows some XML from the Results field. The XML shows one track coming from Rhapsody and one from a local server called mediaserver.

<?xml version="1.0" encoding="utf-8"?>
<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
 <item id="SQ:5/radea%3aTra.16238560.mp3:AFreely,Devendra%20Banhart,Smokey%20Rolls%20Down%20Thunder%20Canyon,298" parentID="SQ:5" restricted="true">
   <res protocolInfo="real.com-rhapsody-direct:*:audio/mp3:*" duration="0:04:58">radea:Tra.16238560.mp3</res>
   <upnp:albumArtURI>/getaa?r=1&amp;u=radea%3aTra.16238560.mp3</upnp:albumArtURI>
   <dc:title>Freely</dc:title>
   <upnp:class>object.item.audioItem.musicTrack</upnp:class>
   <dc:creator>Devendra Banhart</dc:creator>
   <upnp:album>Smokey Rolls Down Thunder Canyon</upnp:album>
 </item>
 <item id="S://mediaserver/music/Master/Supertramp/Even%20in%20the%20Quietest%20Moments/Supertramp%20-%2004%20-%20Downstream.flac" parentID="SQ:5" restricted="true">
   <res protocolInfo="x-file-cifs:*:audio/flac:*">x-file-cifs://mediamarx/music/Master/Supertramp/Even%20in%20the%20Quietest%20Moments/Supertramp%20-%2004%20-%20Downstream.flac</res>
   <upnp:albumArtURI>/getaa?u=x-file-cifs%3a%2f%2fmediamarx%2fmusic%2fMaster%2fSupertramp%2fEven%2520in%2520the%2520Quietest%2520Moments%2fSupertramp%2520-%252004%2520-%2520Downstream.flac&amp;v=167</upnp:albumArtURI>
   <dc:title>Downstream</dc:title>
   <upnp:class>object.item.audioItem.musicTrack</upnp:class>
   <dc:creator>Supertramp</dc:creator>
   <upnp:album>Even in the Quietest Moments...</upnp:album>
   <upnp:originalTrackNumber>4</upnp:originalTrackNumber>
 </item>
</DIDL-Lite>


Step 6: Take the XML from the last request (a particular saved queue) and put in a file called playlist.xml.

Step 7: Create a XSLT file in the same location as the XML file. Call it transform.xslt. You can create a text document and change the extension to .xslt.

Step 8: In the XSLT file put the following:
<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:didl="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
    xmlns:msxsl="urn:schemas-microsoft-com:xslt" exclude-result-prefixes="msxsl"
    xmlns:dc="http://purl.org/dc/elements/1.1/"
    xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
    xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/"
>
    <xsl:output method="html" indent="yes"/>
      <xsl:template match="didl:DIDL-Lite">
        <xsl:for-each select="didl:item">
            <xsl:value-of select="dc:creator"/>,
            <b>
            <xsl:value-of select="upnp:album"/>,
            </b>
            "<xsl:value-of select="dc:title"/>"
            <br></br>
        </xsl:for-each>
      </xsl:template>
</xsl:stylesheet>


Step 9: Open the playlist.xml file and put the following line after the version (?xml) line so that it’s the second line in the file:

<?xml-stylesheet type="text/xsl" href="transform.xslt"?>

Step 10: Close and save both files and double-click on the playlist.xml file. It should open in a browser and show the transformed list. For example for the XML shown above the transform produces:

Output of transforming Sonos XML

The XML the query returned is DIDL which stands for Digital Item Declaration Language and is an XML dialect for MPEG-21. In the XLST file you have to be sure and use the namespace declarations carefully. The XML file has xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" so we have to use that in XSLT as well. In the XLST we define it as xmlns:didl="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" using a prefix of “didl”.

In this example we use the browser (verified in Internet Explorer 9 and Chrome) to do the transform.

Second Approach - Javascript in the Browser


The HTML page (using JavaScript and jQuery) that was given in a previous post is approach shown (or really repeated here). Note that the page (HTML and JavaScript) depends on the ability of the browser to “Allow data access across domains” (a setting). See the post for details on doing that at least for IE and Chrome.

1. Populate a drop down list with defined playlists. Uses Query 5 above.
2. Create two buttons next to playlist drop down list. Uses Query 6 above. One button shows the playlist items in the page and the other to copy the playlist to the clipboard. (Since we are stuck with Internet Explorer, we went ahead and used the clipboardData object.
3. Automatically change the zone drop down to the master zone - was a manual process in the previous version.

An example of the page output is shown below. Get the code from GitHub.