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

49 comments:

  1. Hi,

    Thanks for sharing your code and efforts.

    I found that the code will discover my zone bridge along with the players. Then I get an error "Method Not Allowed" when attempting to call the method to determine if the zone bridge is a master.

    * The remote server returned an error: (405) Method Not Allowed

    If I skip the zone bridge in the loops, the discovery process works. So I will try adding code to skip the Zone Bridge.

    Question: Do you know if would be possible to add a URI to queue that is a HTTP URL to an mp3 to stream from the web? I would love to have a queue built from my ex.fm or tumblr accounts.

    Thanks,
    Chris

    ReplyDelete
  2. Error Issue: FindMasters function doesn't work for you? "Method not allowed" as an error suggests that either the endpoint (IP address) that is being queried isn't a Sonos device or it's a device that doesn't support the GetPositionInfo method. The Sonos topology I tried is just one configuration and so my testing was limited. Our layout includes: ZP120, two ZP90s, and two S5s. What to do? Go into the debugger, try to find out if it is one particular device that is the problem or all of them. If you find it's one device, then go into Device Spy (or similar tool) and try to see if you can use the methods by hand. For more info see: http://travelmarx.blogspot.com/2010/06/exploring-sonos-via-upnp.html

    Question: Adding a URI to a queue? I would guess that it would not work if the source isn't a service that Sonos supports. However, you can try. Again, I would use Device Spy and try to do it manually first - insert URL - and see. I like the idea of it!

    ReplyDelete
  3. Hi, Thanks for the reply.

    Yes, the ZoneBridge apparently does not accept the GetPositionInfo method. So I will update the code to ignore ZoneBridges. ZoneBridge is the device that you can hardwire to the router so that we don't need to have any ZP or S5 wired.

    I think your hunch is probably correct about Sonos not allowing to stream from just any MP3 URI (other than supported services such as Rhapsody). More on the idea - I would like to be able to have a Bookmarklet and right click an mp3 link in browser or iphone to run a "Listen Later" command. It would add the URI to Sonos Queue to "listen later" - similar to "read later" for Instapaper. Or I would like to be able to stream from my ex.fm queue.

    Thanks,
    Chris

    ReplyDelete
  4. It make sense now. I didn't test with a ZoneBridge. (I'll make a note above for readers to see these comments.) Thanks for digging into this

    In regard to the Bookmarklet idea. I like it. I hear a lot of music when I'm not at home with the Sonos system, but when I am with Sonos I want to listen in more detail and to have those references handy would be nice. So for the Bookmarklet idea, when you flag music, you basically save off artist, title, or whatever metadata is available and save it into a simple list say as separate XML files locally or in the cloud like in some service like Dropbox. Then when you are within range of your Sonos system, you can "walk down the list", formulate the request and send the request to the service of your choice. You will have to deal with non-exact matches and such. Not optimal, but it's a start. Haven't thought through it too much.

    ReplyDelete
  5. If you've happy to run VLC you can play a list of URIs on Sonos. The app I'm developing reads a playlist of URIs and plays them on Sonos via VLC. If you're interested search for Sonospy on GitHub.

    ReplyDelete
  6. Thanks for writing this program. I have one problem though. In my larger playlists the number of items is not correct. I have for instance a playlist with 2040 items but in the program it shows 348 items for that playlist. Also another one with 291 items shows 288 items in the program. I have also exported and imported them and then only the number of items mentioned in the program is shown in the Sonos.

    My playlists with up to 69 items seem to be ok. I don't know if it is a coincidence that the larger ones are not complete. I am not a programmer so unfortunately I am not able to check the code for indications.

    What I did notice is that the exported files are never larger than 320 KB. So after exporting all my playlists, the larger ones are all the same size (320 KB) but the original lists contain different number of items.

    ReplyDelete
  7. A small addition to the previous post that might be of relevance. All my 3 zones are marked as Master in the program and they are not joined

    ReplyDelete
  8. Looks like I hardwired 1,000 items to return for a playlist in the GetPlaylist method above. So that's a limit and bad on my part. Should have made that a variable. That doesn't explain why playlists with less than 1,000 items are still not correctly importing.

    I'm wondering if it is this: the UPnP commands used to return playlist information only return so many items at a time, in chunks so to speak, and the program above only asks for data one time whereas it needs to keep asking until all the data is retrieved. I will have to try it myself by creating a large playlist and trying an export.

    Meanwhile, a question for you. When you import an exported playlist (that has been truncated), what items are imported, the first few hundred in order or is it random items from the original playlist?

    ReplyDelete
  9. Thanks for your quick answer. As it seems the truncated playlists are truncated in order, so the first few hundred items from the original are transfered.

    ReplyDelete
  10. Okay, I worked on the problem last night and addressed it. Will try to update code above tonight with corrected code. Stay tuned....

    The problem was as I guessed. You can't ask for all results at once - or, rather you can't for large amounts of data. I don't know what the cut off is, but somewhere greater than 400 items in playlist I started to see problems. So I created a variable that can be set to the number to bring back in each request. The program then just keeps calling until all are fetched.

    ReplyDelete
  11. That is great news. Thanks for all your hard work.
    As soon as the new code is available I will test it.

    ReplyDelete
  12. Updated the code. What changed: UPnP.cs and MainWindow.xaml.cs.

    - made fetch of playlists recursive and number of items fetched is configurable, see _maxPlaylistPagingResults

    - added _maxPlaylistsReturned, instead of hardcoding number of playlists returned

    - for single playlists save, added "sp_" prefix in file name

    - updated the link to the zipped code (v2) (http://cid-3faa5c9b69a80c67.office.live.com/self.aspx/Public/SonosWpfApplicationV2.zip)

    Let me know how it goes.

    ReplyDelete
  13. Thanks very much TravelMax. I have tested it and as far as I can see it works perfectly. I also checked a playlist exactly on the spot where it was cut off previously and it just continues as it should.

    There is another thing I am also trying to do and that is to convert the Sonos playlists to m3u files. I tried your "What Sonos Is playing" program but the output is just text. On the page regarding this project I also saw the program from Finn Ellebaek Nielsen for extracting the current playlist as m3u file. I copied the code and saved it as HTM / HTML file. But this did not work. Obviously I don't have a clue what I am doing, so maybe you can give me some directions how to use it... If this takes too much of you time I do understand.

    My goal with the m3u list is to create a quick backup of my lists in the "Imported Playlists" on the Sonos (that nobody in the house can edit by accident from the Sonos).

    If I do succeed my next goal would be to find a program that would be able to copy the files from a playlist to a certain directory, so I can put them on an SD card and play them on the car radio. I have not looked at it yet but there are probably regular programs that can do that.

    Thanks a lot.

    Regards,
    Walter

    ReplyDelete
  14. I found a (freeware) program that can copy the audio files from various playlist types to a certain location (with or without the original folderstructure). Maybe other visitors might like to use it also.
    It is called "AmoK Playlist Copy". It can be found here:

    http://www.amok.am/en/freeware/amok_playlist_copy/

    Be aware: I installed the "full version without setup" and after starting it, the program was in German. This can be changed by copying the correct "language.lng" file from the "language" folder to the start folder.

    ReplyDelete
  15. re: converting playlist to m3u.
    Yes the "What Sonos is Playing" post only outputs the title. The current post "WPF Application" outputs a blob of XML that follows the DIDL schema. An addition to this program could be to output to m3u format. Or, you can consider a post transform, e.g. an XSL transform of the DIDL XML output into an m3u format. So, there is a couple of ways to tackle this particular aspect of the problem.

    The minor wrinkle in the larger plan is this: when you get information form Sonos you don't get the exact location to the music tracks. (Maybe I don't understand m3u playlist format - so take what I have to say with a grain of salt.) If you look inside the exported playlist file you will see references to the album and title. So if your music is at \\homeserver\music then you will have to build the exact path to each file using the album and title from the DIDL markup and use that built path in the m3u playlist. Then, where your music is homed for Sonos is where you would run the Amok program. At Travelmarx we keep our Sonos music share directory (e.g. \\homeserver\music) as the source and we typically don't want anything mucking around with it. Also we have various file formats (.mp3, .flac, .wma) we are dealing with so creating something for a SD card could be tricky because you might not be able to play the format in the car without some transcode.

    re: Nielson program. Back when he proposed it, I wasn't set up to play with Java. Now I am. Just tried it and got it to work with some minor modifications. It seemed to truncate (not get all the items in the queue). It only gets the item in the queue. Perhaps I'll repost the program I got to work with more instructions and a simple build file. (I followed the strategy here: http://travelmarx.blogspot.com/2011/10/java-apache-ant-and-hello-world.html) for getting it to run. The output form the Java program is like this:

    1. The Dandy Warhols: We Used To Be Friends
    2. Gabin: Lies
    ....

    re: amok playlist copy program - i haven't tried it but sounds like it would work.

    ReplyDelete
  16. re: converting playlist to m3u
    An addition to the "WPF Application" with m3u output would be great but of course it is not a custom made program for me ;-)
    So any method that would work is fine with me.

    If I look into the XML export file I can also see the exact folderpath including file name in it, for example:

    "S://READYNAS01/Sonos_FLAC/Downtempo/Pop/Pop/Select/Ane%20Brun%20-%20Discography/Ane%20Brun%20-%202003%20-%20Spending%20Time%20With%20Morgan/01%20-%20Humming%20One%20of%20Your%20Songs.flac"

    So it should be possible to convert it to a regular playlist format (like m3u). Unfortuntely there does not seems to be an existing convertion tool current exported format.

    re: Nielson program.
    The fact that it only would export an m3u file from the current cue would not be a dealbreaker for me. But when it trancates the files it would be a problem.
    By the way your "What Sonos Is playing" program also truncates the playlists at exact the same place as the previous version of "WPF Application".

    re: amok playlist copy program
    I would only use the program to copy the files from the playlist, from the NAS to a local disk station. So I don't think I can do much harm. To be save I could also use a Read Only account on the NAS share during the copy action.
    I have a FLAC only library and they don't play on the car radio. But I use AudioConverter (which supports lots of fle formats) to do a batch conversion to mp3, on the files on the local drive.

    ReplyDelete
  17. Yes, you are absolutely right, the path is there in the export. I overlooked the obvious. So that's good.

    The Nielsen program could be easily modified. But, I may write the m3u part of the WPF, I'll see :-).

    Sounds like you have your process with Amok all squared away so that's good.

    ReplyDelete
  18. Thanks TravelMarx. I would be graful for either solution. And yes, the rest of the process is covered :-) Have a nice weekend.

    ReplyDelete
  19. Cager,

    Did you find the solution (in code) how to skip the zonebridges in the discovery process? I would like to know since i have the same problem but cannot debug because that problem is on a remote server.

    Kind regards,
    Frank

    ReplyDelete
  20. I did suggest a solution above where the Discover_Click method in the MainWindow.xaml.cs file should have an additional logic check. Right after the start of the foreach loop where you look for the string "BR100" as part of the zone type and then don't complete the rest of the loop for that type, i.e. don't include the bridge in the collection that is displayed.

    I want to add this check and add support for m3u playlist generation, hopefully this weekend.

    ReplyDelete
  21. Added support for creating m3u playlists. Addressed (hopefully) ZoneBridge issue. Made some other minor updates to sync with latest Sonos update. Note previous versions and release notes are here: http://cid-3faa5c9b69a80c67.office.live.com/self.aspx/Public/.

    ReplyDelete
  22. Thank you so much for adding the m3u export function. You have not idear how happy you made me. This is my best Christmas present ever :-)
    I am wishing you and your family a very merry Christmas and the best wishes for the New Year.
    Best Regards,
    Walter

    ReplyDelete
  23. Thanks for the sentiment. If there are problems let me know. I don't do nearly enough testing due to time constraints. That goes for the m3u support and the support for dealing with (really ignoring) Zone Bridges. I was visiting friends who have zone bridges but kept eating and talking and didn't do much testing. Let me know how it works. Thanks.

    ReplyDelete
  24. Zonebridge detection is working fine for me, as did the m3u export feature. Thanks!

    ReplyDelete
  25. I don't have a zonebridge but m3u export works fine for me as well.

    Regards,

    Walter

    ReplyDelete
  26. I got a bridge and play 3 (Christmas) and I had to change the "zonebridge" check to just bridge as my bridge was reporting itself as "ZoneName:SONOS-BRIDGE".

    I posted in your first blog on this but thanks for all the details, was able to code up a little app that turns down the volume when I get a cell call (need to get a modem in to finish the landline part).

    Thanks again
    Bill.

    ReplyDelete
  27. Thanks. I just made an update. Probably better to be less restrictive. We don't have a ZoneBridge, but friends who have one invited us over for Christmas. I felt it was a bit awkward to spend the whole time on the computer! Thanks for testing. Again, cool work on the volume app.

    ReplyDelete
  28. Thanks for the WPFApps.

    I ran across few problems lately. Is it in relation with the latest Sonos updates?

    At one of my customer 2 weeks ago, nothing was found with the "Discover" button when I was connected on his home network.

    Today here at home:
    "The underlying connection was closed: An unexpected error occured on a service. Try discovery again."

    I run Sonos 3.6 on my laptop.

    ReplyDelete
  29. I just made an update to the program. V5 now (http://cid-3faa5c9b69a80c67.office.live.com/self.aspx/Public/SonosWpfApplicationV5.zip) and release notes (http://cid-3faa5c9b69a80c67.office.live.com/self.aspx/Public/releasenotes_SonosWpfApplication.txt). I've been wanted to revise discovery and that's what I did. I separated the collecting of the broadcast responses from the checking of them. Basically, I 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. I tested on Sonos version 3.6.1 (build 16748310). Hope it works better.

    ReplyDelete
  30. Also, just added this post: http://travelmarx.blogspot.com/2012/02/java-program-to-extract-whats-in-sonos.html which is a simple Java program to get what's in the queue and output to a file (simple list or .m3u).

    ReplyDelete
  31. I will give it try. But allready my compliments! Thanks.

    ReplyDelete
  32. This looks great. The link to the .zip file for version 5 doesn't seem to work anymore. Any chance you could fix it?

    ReplyDelete
  33. I just tried it the program/code on Windows 8 and it worked. I moved the code to GitHub: https://github.com/travelmarx/travelmarx-blog/tree/master/SonosWpfApplicationV5

    ReplyDelete
  34. Hi TravelMarx,

    Ran version 5 on my network in Visual Studio 2010. It looks like the Playbar (S9), Sub (Sub) and Play 1 (S1) devices thrown an exception UPnP.FindMasters method when the SOAPRequest is executed. These devices return a 500 error.

    As a quick hack, I put a try/catch inside the if statement. If there is an exception, in the catch, I set: UPnP.Discovery.ZoneMasters[zone] = false;

    This allows the app to continue processing and I could access the playlists.

    Michael

    ReplyDelete
  35. Interesting. I don't have those devices so I can't test. Would be interesting to probe these devices following the post "Exploring Sonos via UPnP" (http://blog.travelmarx.com/2010/06/exploring-sonos-via-upnp.html) and look to see that there is a /MediaRender/AVTransport/Control, etc.

    I changed the code at https://github.com/travelmarx/travelmarx-blog/tree/master/SonosWpfApplicationV5 and I'll update the version on Onedrive. Also, will add update note in the post.

    Thanks for reporting thi.

    ReplyDelete
    Replies
    1. FYI, Checked with spy: When a PLAY:1 and SUB is connected to a Playbar the PLAY:1 and SUB don't have a /MediaRender/... service anymore in the list.

      Delete
  36. I am unable to get the code to discover my Zones. I'm running Windows 8 + Visual Studio Express 2012 for Windows Desktop. My laptop is connected via wifi to my router with a default gateway address of 192.168.0.1. Do I need to change the _broadcastIP. I've tried changing it to various different values but no luck. Please can you assist?

    ReplyDelete
    Replies
    1. I've managed to get it working (_broadcastIP = 192.168.0.255)

      Delete
    2. Bingo. My network is 172.30.123.xxx, so I set _broadcastIP = 172.30.123.255

      Delete
  37. Fantastic posts on this subject. May I trouble you post an EXE if you wouldn't mind? I get an error on the 365 link. Visual Studio is a 9 GB download and wants to impregnate itself into explorer and context menu. Thanks very much.

    ReplyDelete
  38. Sorry, I'm without a Sonos system to play with for a little bit. You can also access the code on Github at https://github.com/travelmarx/travelmarx-blog/tree/master/SonosWpfApplicationV5. In terms of Visual Studio, I understand downloading and dealing with it. The only thing I can suggest is to try Visual Studio Community version. I used it recently and it worked well. Will try to see about making an EXE.

    ReplyDelete
  39. This comment has been removed by a blog administrator.

    ReplyDelete
  40. Like others here I had trouble getting Discovery to work, so I refactored the code using techniques from OpenSource DeviceSpy. Now works over all local interfaces and supports IPV6 too!

    Affects MainWindow.xaml.cs and UPnp.cs, adds three utility files. TravelMarx - what's the best way to get these to you? I do have a GitHub account

    ReplyDelete
  41. I could get it from there. I haven't worked on the code in a while and would be good to try it out again with your fixes.

    ReplyDelete
  42. The updated code (full VS2010 solution) is at https://github.com/tyddynonn/SonosWPFApplication

    ReplyDelete
  43. Sorry, it took so long for me to take a look. I tried the code out for a little bit. My a bit on my new (very small) Sonos setup and have some comments/questions:

    1. First, it looks like changes you did to discovery are much improved over what I had. Thanks! Also, other changes that I could see with a diff helped.

    2. I noticed that in MainWindow.xaml.cs you commented out ExportPlaylist for save to .xml. Any reason why?

    3. Along with Q2, you open a new DataWindow when saving to .xml, but there is nothing do with the data window after searching for dups. Is that just intended to be helpful information?

    Going forward, I think your version (after resolving Q2 and Q3) should be definitely replace what I have. Maybe we should just retire this post and point to what you have in Github?

    ReplyDelete