Saturday, May 28, 2011

Cabazon Again – Tilting at Windmills

Cabazon T. Rex

Here we go again, another stop at the Cabazon dinosaurs. Maybe the silliness of the story told here is simply a relief from the billboards of musical artists that you thought might be in retirement but no, they are playing at Fantasy Springs or the Morongo. In the entry for the last visit, we discussed some of the freaky facts presented at Cabazon.

In this visit, it was just flat out windy and we walked around for a few minutes or so and made a dash back for the car. Speaking of wind, it was ripping but many of the windmills (turbine generators to be more precise) on the way to Palm Springs were motionless. Why is that the case? From what we’ve read, it was too windy. The turbines operate automatically and algorithms in the electronics determine whether the turbine turns or not. The San Gorgonio Pass is one of the windiest places on earth but the turbines only run when the wind is blowing between 10 and 55 mph.

The wind farm that you pass through as you travel east (on Interstate 10) to Palm Springs is called the San Gorgonio Wind Farm. The San Gorgonio Pass is the mountain pass between the Greater San Bernardino area to west, and Palm Spring and the Coachella Valley to the east.

San Gorgonio Wind Farm on a Very Windy Day – Mt San Jacinto in the Background – View from North Indian Canyon Drive

San Gorgonia Wind Farm

Saturday, May 21, 2011

Centaurea montana and Bombus

centaurea montana

Every spring, Centaurea montana (Perennial Cornflower, Bachelor’s Button, Knapweed, Bluebottle) shows up in a corner of our front yard. In the moist, gentle days of April and May it grows lushly, often with stems travelling a foot or so stretching toward the most light. Then as it gets drier and hotter, the plants die off and disappear. By August, we pull out the dry dead stems. But, they come back again in the fall for one last show.

The genus name Centaur'ea comes from the Latin and refers to the Centaur Chiron known for his knowledge of medicinal plants. The species name, monta’na, because Centaurea montana is typically found in meadows and open woodland in upper and sub-alpine zones.

As we went out to snap a photo the other day, a large Bombus (bumblebee) came in for some fuel. Likely it is a female that overwintered and is starting out her own colony. The markings are too hard for us to decipher. Even after consulting BubbleBee.org we couldn’t match the markings. Is it Bombus pratorum (early bumblebee) because it is early in the season?
centaurea montana

Sunday, May 8, 2011

Chief (Noah) Seattle

Chief Seattle Grave - Suquamish
On a recent Sunday, some friends took pity on the boat-less Travelmarx and took us across the Puget Sound (start point: Shilshole) for brunch in Suquamish on the Kitsap Peninsula just north of Bainbridge Island. We ate at the delicious Agate Pass Café. Afterwards, we took the short walk up the hill to Chief Seattle’s tombstone. Chief Seattle (also spelled Sealth, See-alth, and Seathl) (c. 1780 – 1866) is famous as an important Northwest leader and namesake of Seattle. The chief’s baptismal name was Noah.

Suquamish, which means “place of clear salt water” in Chief Seattle’s Lushootsee language, is located in the Port Madison Indian Reservation, home to the Suquamish tribe.
Chief Seattle - Suquamsh

Saturday, May 7, 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
<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="S://servername/music/album/artist/song.flac" parentID="A:ALBUMARTIST/Association,%20the/" restricted="true"><dc:title>Song Title</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>
One Local Album x-rincon-playlist:RINCON_000E58512EBC01400#
A:ALBUMARTIST/
Artist/
AlbumTitle
<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="A:ALBUMARTIST/Artist/AlbumTitle" parentID="A:ALBUMARTIST/Aritst" restricted="true"><dc:title>Keepin' Me Up Nights</dc:title><upnp:class>object.container.album.musicAlbum</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>
One Rhapsody Track radea:Tra.6542458.mp3 <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="RDCPI:GLBTRACK:Tra.6542458" parentID="RDCPA:GLBALBUM:Alb.6538091" restricted="true"><dc:title>Farther On</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON1_someone@email.com</desc></item></DIDL-Lite>
One Rhapsody Album x-rincon-cpcontainer:RDCPA:
GLBALBUM:Alb.6538091
<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="RDCPA:GLBALBUM:Alb.6538091" parentID="RDCPA:ARTALBUM:Art.5195118" restricted="true"><dc:title>Vetiver</dc:title><upnp:class>object.container.album.musicAlbum</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON1_someone@email.com </desc></item></DIDL-Lite>
One Radio Station x-rincon-mp3radio://109.123.116.202:8020 <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="-1" parentID="-1" restricted="true" ><res protocolInfo="x-rincon-mp3radio:*:*:* " >x-rincon-mp3radio://109.123.116.202:8020</res><r:streamContent>Dario Castello (1590-1658) - Sonata seconda</r:streamContent><dc:title>109.123.116.202:8020</dc:title><upnp:class>object.item</upnp:class></item></DIDL-Lite>


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();
        }
    }
}