Wednesday, June 30, 2010

A Simple Sonos JavaScript and Java Application


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

This is the second of several posts dealing with the Sonos system. (Click the "sonos" label keyword above to see all the posts.) The goal of this exercise was to get information about what Sonos is playing in a browser. The first post looked at simple ways of discovering your Sonos system. This post continues where the first post left off and creates a web page to display what Sonos is playing along with album artwork. I took inspiration for the code shown in the page below from this particular post on the Sonos Forums.

(A really good slick solution to use a browser (locally) to show what Sonos is playing and control it, is here: Node.js Sonos Web Controller. It requires slightly more setup (beyond just your browser), but not much more.)

You can run the page shown below on a web site hosted locally or just run the page as is (e.g., double-clicking it). We only tested this code on Windows 8 with Chrome (version 35) and Internet Explorer (version 11). In these browsers you may have to disable the same-origin policy. The image below shows how to disable same-origin policy in IE (or enable data access across domains). Go to Internet Options / Security / Miscellaneous "Access data sources across domains". Better yet you might want to set it to Prompt. When done experimenting, you put this settings back to Disable.



In Chrome, you can shut all instances of Chrome and then start it up with:

"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --disable-web-security


Or if you don't want to shut down current instances of Chrome, start as a new user:

"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --args --user-data-dir="C:/Chrome dev session" --disable-web-security

The web page her uses POST requests to the zone players which include a SOAP body. The response includes XML to parse with status or metadata. To understand more about this type of request, see the previous post Exploring Sonos via UPnP.

Some points to keep in mind:
  • Customize the code for your situation, in particular the _sonosTopology variable.
  • The more general solution to deal with same-origin policy is to use Cross-Origin Resource Sharing CORS. What wasn't clear to me is if the Sonos zone player (i.e. the server) could deal with CORS, let alone that you could set the proper headers.
  • All the code is in one page for demonstration purposes. The parsing of the XML returned for various queries is not optimal.
  • Encoding/decoding URLs and escaping/unescaping certain characters in XML can be tricky - I'm sure there are some scenarios where it is needed but were missed in this code.
This code is shown below and can also be found on Github here: https://github.com/travelmarx/travelmarx-blog/blob/master/WhatIsSonosPlaying/WhatSonosIsPlaying.htm.

<!DOCTYPE html>
<html>
<head>
<title>What's Our Sonos Playing?</title>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript">
    jQuery.noConflict();

    // Global variables that need to be customized to the environment.
    // Probably could get these automatically. To get them manually, use a tool like
    // Device Spy.exe to get these, http://opentools.homeip.net/dev-tools-for-upnp.
    var _sonosTopology = {
        "zones": [
        { "name": "office", "ip": "192.168.2.225", "id": "RINCON_000E58512EBC01400" },
        { "name": "kitchen", "ip": "192.168.2.155", "id": "RINCON_000E5825CEFC01400" },
        { "name": "living room", "ip": "192.168.2.10", "id": "RINCON_000E5833994801400" },
        { "name": "media room", "ip": "192.168.2.203", "id": "RINCON_000E5825B14201400" },
        { "name": "bedroom", "ip": "192.168.2.15", "id": "RINCON_000E5858A7CA01400" }
    ]
    };
    var _providers = [{ "name": "Rhapsody", "keyword": "rhapsody" },
                      { "name": "Pandora", "keyword": "pandora" },
                      { "name": "Local", "keyword": "x-file-cifs" },
                      { "name": "Radio", "keyword": "aac" },
                      { "name": "Radio", "keyword": "mms"}];

    // Global variables that don't need to be customized to the environment.
    var _soapRequestTemplate = '<?xml version="1.0" encoding="utf-8"?><s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body>{0}</s:Body></s:Envelope>';
    var _port = ':1400';
    var _currentArtist = "";
    var _currentComposer = "";
    var _currentAlbum = "";
    var _selectedZone = 0;  // zone serving up media
    var _refreshRate = 15000; // milliseconds
    var _debug = false;  // Browser security settings may prevent this from working.
    var _autoSetToMaster = true;
    var _masterFound = false;
    var _playlistsRetrieved = false;
    var _trackChange = true;
    var _debugWindow = null;
    var _debugConsole;
    var RequestType = { "metadata": 0, "transport": 1, "playlists": 2, "oneplaylist": 3 };

    // Some general functions for the page
    //

    // Logging functionality.
    function log(message) {
        if (!_debug) {
            return;
        }
        if (_debugWindow === null) {
            _debugWindow = window.open("", "", "width=350,height=250,menubar=0,toolbar=1,status=0,scrollbars=1,resizable=1");
            _debugWindow.document.writeln('<html><head><title>Console</title></head><body bgcolor=white></body></html>');
            _debugConsole = _debugWindow.document.body;
        }
        _debugConsole.innerHTML += message + "<hr/>";
    }
    // Once the DOM is loaded then we can work with HTML elements.
    jQuery(document).ready(function () {
        jQuery.support.cors = true;
        for (var i = 0; i < _sonosTopology.zones.length; i++) {  
            var zoneName = _sonosTopology.zones[i].name;          
            addOption(jQuery('#ZoneSelect')[0], zoneName, i);
        }
        jQuery('#ZoneSelect')[0].selectedIndex = 1;
        _selectedZone = jQuery('#ZoneSelect')[0].selectedIndex;
        refreshCurrentlyPlaying();
        log("Page initialized.");
        setInterval(refreshCurrentlyPlaying, _refreshRate);
        jQuery("#addthisdiv").click(function () {
        });
    });
    // Creates zone dropdown.
    function addOption(selectbox, text, value) {
        var optn = document.createElement("OPTION");
        optn.text = text;
        optn.value = value;
        selectbox.options.add(optn);
    }

    //
    // The following functions represent the functionality of making requests to the
    // UPnP devices and dealing with the response.
    //

    // Function to mute or unmote the selected zone. Action: 0 (unmute), 1 (mute)
    function muteOrUnMute(action) {
        var url, xml, soapBody, soapAction;
        var _activeZone = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_activeZone].ip + _port;
        url = '/MediaRenderer/RenderingControl/Control';
        soapAction = "urn:upnp-org:serviceId:RenderingControl#SetMute";
        soapBody = '<u:SetMute xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"><InstanceID>0</InstanceID><Channel>Master</Channel><DesiredMute>' + action + '</DesiredMute></u:SetMute>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.transport);
    }

    // Function to process Play, Stop, Pause, Previous and Next commands.
    function transport(cmd) {
        var url, xml, soapBody, soapAction;
        var _activeZone = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_activeZone].ip + _port;
        url = '/MediaRenderer/AVTransport/Control';
        soapAction = "urn:schemas-upnp-org:service:AVTransport:1#" + cmd;
        soapBody = '<u:' + cmd + ' xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><Speed>1</Speed></u:' + cmd + '>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.transport);
    }

    // Get playlists.
    function getPlaylists() {
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaServer/ContentDirectory/Control';
        soapAction = 'urn:schemas-upnp-org:service:ContentDirectory:1#Browse';
        soapBody = '<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID>SQ:</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter></Filter><StartingIndex>0</StartingIndex><RequestedCount>100</RequestedCount><SortCriteria></SortCriteria></u:Browse>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.playlists);
    }

    // Get one playlist based on its identifier.
    function getPlaylist(value) {
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaServer/ContentDirectory/Control';
        soapAction = 'urn:schemas-upnp-org:service:ContentDirectory:1#Browse';
        soapBody = '<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID>' + value + '</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter></Filter><StartingIndex>0</StartingIndex><RequestedCount>1000</RequestedCount><SortCriteria></SortCriteria></u:Browse>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.oneplaylist);
    }

    // Refresh metadata.
    function refreshCurrentlyPlaying() {
        // Set some globals to default.
        _currentAlbum = _currentArtist = _currentComposer = "";

        if (_trackChange) {
            jQuery.each(jQuery('div[id$=Metadata]'), function (i, item) {
                item.className = "ElementHidden";
            });
        }
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaRenderer/AVTransport/Control';
        soapAction = 'urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo';
        soapBody = '<u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><Channel>Master</Channel></u:GetPositionInfo>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.metadata);
        if (!_playlistsRetrieved) {
            getPlaylists();
            _playlistsRetrieved = true;
        }
    }

    // Main Ajax request function. uPnP requests go through here.
    // Here we use jQuery Ajax method because it does cross-domain without hassle.
    function sendSoapRequest(url, host, xml, soapAction, requestType) {
        url = 'http://' + host + url;
        jQuery.ajax({
            url: url,
            type: "POST",
            async: true,
            beforeSend: function (xhr) {
                xhr.setRequestHeader("SOAPAction", soapAction);
            },
            data: xml,
            success: function (data, status, xhr) {
                if (requestType == RequestType.metadata) {
                    processSuccessfulAjaxRequestNodes_Metadata(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.playlists) {
                    processSuccessfulAjaxRequestNodes_Playlist(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.oneplaylist) {
                    processSuccessfulAjaxRequestNodes_OnePlaylist(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.transport) {
                    // If this isn't a metadata request, then we should refresh the metadata to sync UI.
                    refreshCurrentlyPlaying();
                }
                var response = transport.responseText || "no response text";
                log("Success! \n\n" + data.xml);
            },
            complete: function (xhr, status) {
                var response = status || "no response text";
                log("Complete \n\n" + response);
            },
            ajaxError: function (data) {
                var response = data || "no response text";
                log("Failure! \n\n" + response);
            },
            error: function (xhr, status, err) { log('Exception: ' + err); }
        });
    }

    // Process data of one playlist.
    function processSuccessfulAjaxRequestNodes_OnePlaylist(responseNodes, host) {
        if (responseNodes.children("Result").length == 1) {
            jQuery('#PlaylistDump').html("");
            var sb = "";
            var x = jQuery(responseNodes.children("Result"));
            var y = jQuery.parseXML(x.text());
            var responseNodes2 = y.firstChild.childNodes;
            for (var i = 0; i < responseNodes2.length; i++) {
                var track = "UNK", album = "UNK", creator = "UNK";
                if (jQuery("dc\\:title", responseNodes2[i]).length == 1 || jQuery("title", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:title", responseNodes2[i]).text();
                    track = (testval != "") ? testval : jQuery(responseNodes2[i]).find("title").text();
                }
                if (jQuery("dc\\:creator", responseNodes2[i]).length == 1 || jQuery("creator", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:creator", responseNodes2[i]).text();
                    artist = (testval != "") ? testval : jQuery(responseNodes2[i]).find("creator").text();
                }
                if (jQuery("upnp\\:album", responseNodes2[i]).length == 1 || jQuery("album", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:album", responseNodes2[i]).text();
                    album = (testval != "") ? testval : jQuery(responseNodes2[i]).find("album").text();
                }
                if (track !== "UNK" | artist !== "UNK" | album !== "UNK") {
                    sb += "\"" + track + "\" from <i>" + album + "</i> by " + artist + "<br/>";
                }
            }
            jQuery('#PlaylistDump').html(sb.toString());
        }
    }

    // Process data from list of playlists.
    function processSuccessfulAjaxRequestNodes_Playlist(responseNodes, host) {
        if (responseNodes.children("Result").length == 1) {
            var x = jQuery(responseNodes.children("Result"));
            var y = jQuery.parseXML(x.text());
            var responseNodes2 = y.firstChild.childNodes;
            jQuery("select[id$=PlaylistSelect] > option").remove();
            for (var i = 0; i < responseNodes2.length; i++) {
                var testval = jQuery("dc\\:title", responseNodes2[i]).text();
                var playlistTitle = ( testval != "") ? testval : jQuery(responseNodes2[i]).find("title").text();
                var playlistId = responseNodes2[i].getAttribute("id");
                addOption(jQuery('#PlaylistSelect')[0], playlistTitle, playlistId);
            }
        }
        else {
            jQuery("select[id$=PlaylistSelect] > option").remove();
            addOption(jQuery('#PlaylistSelect')[0], "Cannot get playlists.", 0);
        }
    }

    // Refresh display on what's currently playing.
    function processSuccessfulAjaxRequestNodes_Metadata(responseNodes, host) {
        for (var i = 0; i < responseNodes.length; i++) {
            var currNodeName = responseNodes[i].nodeName;
            if (currNodeName == "TrackURI") {
                var result = responseNodes[i].firstChild.nodeValue;
                if (result.indexOf("x-rincon") > -1) {
                    var master = result.split(":")[1];
                    var indx = _selectedZone;
                    jQuery.each(_sonosTopology.zones, function (i) {
                        if (_sonosTopology.zones[i].id == master) {
                            indx = i;
                        }
                    });
                    if (!_autoSetToMaster) {
                        jQuery('#coordinatorName')[0].innerHTML = "slaved to " + _sonosTopology.zones[indx].name;
                        jQuery('#CoordinatorMetadata')[0].className = "ElementVisible";
                    }
                    else {
                        jQuery('#ZoneSelect')[0].selectedIndex = indx;
                        refreshCurrentlyPlaying();
                    }
                }
                else {
                    _masterFound = true;
                }
            }
            if (currNodeName == "TrackMetaData") {
                var responseNodes2 = jQuery(responseNodes[i].firstChild.nodeValue).find("*");
                var isStreaming = false;
                for (var j = 0; j < responseNodes2.length; j++) {
                    switch (responseNodes2[j].nodeName) {
                        case "DC:CREATOR":
                            _currentComposer = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentComposer !== jQuery('#composerName')[0].innerHTML) {
                                jQuery('#composerName')[0].innerHTML = _currentComposer;
                            }
                            jQuery('#ComposerMetadata')[0].className = "ElementVisible";
                            break;
                        case "R:ALBUMARTIST":
                            _currentArtist = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentArtist !== jQuery('#artistName')[0].innerHTML) {
                                jQuery('#artistName')[0].innerHTML = _currentArtist;
                            }
                            jQuery('#ArtistMetadata')[0].className = "ElementVisible";
                            break;
                        case "DC:TITLE":
                            if (!isStreaming) {
                                _currentTrack = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                if (_currentTrack !== jQuery('#trackName')[0].innerHTML) {
                                    jQuery('#trackName')[0].innerHTML = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                    _trackChange = true;
                                }
                                else {
                                    _trackChange = false;
                                }
                                jQuery('#TrackMetadata')[0].className = "ElementVisible";
                            }
                            break;

                        case "R:STREAMCONTENT":
                            if (responseNodes2[j].attributes.getNamedItem('protocolInfo') !== null) {
                                _currentTrack = responseNodes2[j].attributes.getNamedItem('protocolInfo').value;
                                if (_currentTrack.length > 1) {
                                    if (_currentTrack !== jQuery('#trackName')[0].innerHTML) {
                                        jQuery('#trackName')[0].innerHTML = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                        _trackChange = true;
                                    }
                                    else {
                                        _trackChange = false;
                                    }
                                    jQuery('#TrackMetadata')[0].className = "ElementVisible";
                                    isStreaming = true;
                                }
                            }
                            break;

                        case "UPNP:ALBUM":
                            _currentAlbum = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentAlbum !== jQuery('#albumName')[0].innerHTML) {
                                jQuery('#albumName')[0].innerHTML = _currentAlbum;
                                jQuery('#albumArt')[0].alt = _currentAlbum;
                            }
                            jQuery('#AlbumMetadata')[0].className = "ElementVisible";
                            break;
                        case "RES":
                            var protocolInfo = responseNodes2[j].attributes.getNamedItem('protocolInfo').value;
                            if (protocolInfo !== undefined) {
                                for (var k = 0; k < _providers.length; k++) {
                                    if (protocolInfo.toLowerCase().indexOf(_providers[k].keyword) > -1) {
                                        jQuery('#sourceName')[0].innerHTML = _providers[k].name;
                                        jQuery('#SourceMetadata')[0].className = "ElementVisible";
                                    }
                                }
                            }
                            break;
                        case "UPNP:ALBUMARTURI":
                            var newPath = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            newPath = (newPath.indexOf("http:") > -1) ? newPath : "http://" + host + newPath;
                            var currPath = jQuery('#albumArt')[0].src;
                            if (newPath !== currPath) {
                                jQuery('#albumArt')[0].src = newPath;
                            }
                            break;

                    }
                }
            }
        }
    }

    // Start dump of one playlist's items.
    function getPlaylistDump() {
        var selectedPlaylist = jQuery('#PlaylistSelect')[0].value;
        getPlaylist(selectedPlaylist);
    }

    // Do simple Google image search by opening a new window.
    function doGoogleImageSearch() {
        var query = (_currentArtist.length > 0) ? _currentArtist + " " + _currentAlbum : _currentComposer + " " + _currentAlbum;
        var url = "https://www.google.com/search?source=lnms&tbm=isch&sa=X&q=";
        url = url + query;
        window.open(url, "_blank");
    }

    // Do simple Bing image search by opening a new window.
    function doBingImageSearch() {
        var query = (_currentArtist.length > 0) ? _currentArtist + " " + _currentAlbum : _currentComposer + " " + _currentAlbum;
        var url = "http://www.bing.com/images/search?q=";
        url = url + query;
        window.open(url, "_blank");
    }

    // Utility functions.

    var XMLEscape = {
        escape: function (string) {
            return this.xmlEscape(string);
        },
        unescape: function (string) {
            return this.xmlUnescape(string);
        },
        xmlEscape: function (string) {
            string = string.replace(/&/g, "&amp;");
            string = string.replace(/"/g, "&quot;");
            string = string.replace(/'/g, "&apos;");
            string = string.replace(/</g, "&lt;");
            string = string.replace(/>/g, "&gt;");
            return string;
        },
        xmlUnescape: function (string) {
            string = string.replace(/&amp;/g, "&");
            string = string.replace(/&quot;/g, "\"");
            string = string.replace(/&apos;/g, "'");
            string = string.replace(/&lt;/g, "<");
            string = string.replace(/&gt;/g, ">");
            return string;
        }
    };
</script>
<style type="text/css">
body { font-family: Verdana; background-color: #BBCDDF; }
span.label { font-weight: bold;}
div.ElementHidden {display: none; }
div.ElementVisible {display: block; text-align: left;}
select.blend {background-color: white;}
img.AssetImg { height: 200px; border: none;}
a.AssetImg { }
div.TraceConsole {position:fixed; bottom:0px; left:0px; right:0px;}
#TableWrapper, #AssetsLostvibe, #AssetsSearch { text-align: center;}
#TableDiv { border: solid 1px black; width: 750px;}
table { }
table tr { vertical-align: top; }
table tr td { padding: 10px;}
</style>
</head>
<body>
    <div id="TableWrapper">
    <div id="TableDiv">
    <table>
        <tr>
            <td>
                <img alt="currently playing" id="albumArt" src="about:blank" 
                     width="300" height="300" style="border: solid 1px black"/>
            </td>
            <td >
                Zone:
                <select class="blend" id="ZoneSelect">
                </select>
                <input type="button" value="refresh" onclick="refreshCurrentlyPlaying()" /> <br />
                <a href="#" onclick="muteOrUnMute(1)">Mute Zone</a> | 
                <a href="#" onclick="muteOrUnMute(0)">UnMute Zone</a><br /><br />
                <div class="ElementHidden" id="TrackMetadata">
                <span class="label">Track:</span> <span id="trackName">
                </span></div>
                <div class="ElementHidden" id="ArtistMetadata">
                <span class="label">Artist:</span> <span id="artistName">
                </span> </div>
                <div class="ElementHidden" id="ComposerMetadata">
                <span class="label">Composer:</span> <span id="composerName">
                </span> </div>
                <div class="ElementHidden" id="AlbumMetadata">
                <span class="label">Album:</span> <span id="albumName">
                </span> </div>
                <div class="ElementHidden" id="CoordinatorMetadata">
                <span class="label">Status:</span> <span id="coordinatorName">
                </span></div>
                <div class="ElementHidden" id="SourceMetadata">
                <span class="label">Source:</span> <span id="sourceName">
                </span></div>
                <div class="ElementVisible" id="PlaylistDiv">
                <span class="label">Playlists:</span>
                <select class="blend" id="PlaylistSelect">
                <option>Getting playlists...please wait</option>
                </select> <input type="button" value="Print" onclick="getPlaylistDump()" />
                </div>
                <br />
                <a href="javascript:transport('Previous');">Previous
                </a> | <a href="javascript:transport('Play');">Play
                </a> | <a href="javascript:transport('Stop');">Stop
                </a> | <a href="javascript:transport('Pause');">Pause
                </a> | <a href="javascript:transport('Next');">Next
                </a> 
                <br /><br />
                <input type="button" value="Google Image Search" onclick="doGoogleImageSearch()" />
                <input type="button" value="Bing Image Search" onclick="doBingImageSearch()" />
            </td>
        </tr>
    </table>
    </div>
    </div>
    <div id="AssetsLostvibe"></div>
    <div id="AssetsSearch"></div>
    <div id="PlaylistDump"></div>
</body>
</html>
Next steps to improve the page:
  • Use auto-discovery of Sonos topology instead of a fixed array of the topology as done here.
  • Subscribe to UPnP events from Sonos to sync UI. In the page below a simple timer is used to poll for changes. It would be nice to go to a subscription/callback approach. From what I read it should be possible, but I never got event subscription to work with Sonos.
  • When choosing a slaved zone, the current page just informs you that it is a slave. The page should take next step go to the coordinator for metadata.
  • Use jQuery plugins to display additional images in a carousel or other interesting display.
  • Stop flicker when metadata update occurs. This is caused by the approach of hiding/showing data elements. It’s not elegant and can be improved on. If subscribing to Sonos events is figured out then this becomes a non-issue.
  • I think there is more resolution for album artwork returned in Sonos metadata. It would be nice to use it.
Update January 2011 A reader, Finn Ellebaek Nielsen, of this post (see comments below) contributed the following small Java application that saves the current queue as both a playlist (.m3u) and a text file with track number, artist and title. The source code is on GitHub at well: https://github.com/travelmarx/travelmarx-blog/tree/master/PlayListExtractor.

package travelmarx;

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

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

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

import org.apache.commons.lang3.StringEscapeUtils;

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

public class PlayListExtractor {
 public PlayListExtractor(String ipAddress, String filePath, String playListName)
   throws MalformedURLException, ProtocolException, IOException {
   // Build HTTP request with SOAP envelope asking for details about the
   // current queue.
   URL url = new URL("http://" + ipAddress + ":1400/MediaServer/ContentDirectory/Control");
   HttpURLConnection request = (HttpURLConnection)url.openConnection();
  
   request.setRequestMethod("POST");
   request.addRequestProperty("SOAPACTION", "\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"");
   request.setDoOutput(true);
   request.setReadTimeout(10000);
  
   request.connect();
  
   OutputStreamWriter input = new OutputStreamWriter(request.getOutputStream());
   input.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
   input.write("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\r\n");
   input.write("  <s:Body>\r\n");
   input.write("    <u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">\r\n");
   input.write("      <ObjectID>Q:0</ObjectID>\r\n");
   input.write("      <BrowseFlag>BrowseDirectChildren</BrowseFlag>\r\n");
   input.write("      <Filter>upnp:artist,dc:title</Filter>\r\n");
   input.write("     <StartingIndex>0</StartingIndex>\r\n");
   input.write("     <RequestedCount>100</RequestedCount>\r\n");
   input.write("     <SortCriteria></SortCriteria>\r\n");
   input.write("    </u:Browse>\r\n");
   input.write("  </s:Body>\r\n");
   input.write("</s:Envelope>\r\n");
   input.flush();

   // Read entire HTTP response, which is assumed to be in UTF-8.
  BufferedReader output = new BufferedReader(new InputStreamReader(request.getInputStream(), "UTF-8"));
   String response = new String();
   String line;
  
   while ((line = output.readLine()) != null) {
     response += line + "\r\n";
   }

   // Open output files, both in ISO8859-1 encoding.
   OutputStreamWriter txt = new OutputStreamWriter(new FileOutputStream(new File(filePath + "/" + playListName + ".txt")), "8859_1");
   OutputStreamWriter m3u = new OutputStreamWriter(new FileOutputStream(new File(filePath + "/" + playListName + ".m3u")), "8859_1");
  
   int i = 0, j = 0, k = 0, l = 0;
   i = response.indexOf("&lt;item", j);
   while (i >= 0) {
     // Loop over all items, where each item is a track on the queue.
     j = response.indexOf("&lt;/item&gt;", i);
     String track = response.substring(i + 8, j);
     String trackNo, artist, title, unc;

     trackNo = extract(track, " id=&quot;Q:", "&quot;");
     trackNo = trackNo.substring(trackNo.indexOf('/') + 1);
     unc = URLDecoder.decode(extract(track, "&gt;x-file-cifs:", "&lt;").replace('/', '\\').replaceAll("%20", " "), "UTF-8");
     artist = extract(track, "&lt;dc:creator&gt;", "&lt;/dc:creator&gt;");
     title = extract(track, "&lt;dc:title&gt;", "&lt;/dc:title&gt;");
    
     txt.append(decode(trackNo + ". " + artist + ": " + title) + "\r\n");
     m3u.append(decode(unc) + "\r\n");
    
     i = response.indexOf("&lt;item", j);
   }
   txt.close();
   m3u.close();
  
   request.disconnect();
 }

/**
* Extracts text surrounded by markers from given string.
* @param   s       String to extract text from.
* @param   start   Start marker.
* @param   stop    Stop marker.
* @return  Extracted text found between start and stop markers, markers
*          excluded.
*/

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

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

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

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

 public static void main(String[] args)
   throws MalformedURLException, ProtocolException, IOException {
   if (args.length < 3) {
     System.err.println("Usage: PlayListExtractor sonos_master_ip_address export_file_path playlist_name");
     System.exit(0);
   }
   PlayListExtractor pE = new PlayListExtractor(args[0], args[1], args[2]);
 }
}
The Java program above uses Apache Commons Lang libraries. The application command line is:

PlayListExtractor 192.168.1.13 c:\playlists queue

which takes the queue controlled by the Sonos master with IP address 192.168.1.13 and saves it to c:\playlists\queue.m3u and c:\playlists\queue.txt.

Here's a quick rundown on running the code:

In another post Java, Apache Ant and Hello World, we cover the basics of getting a simple Java program working. Here's a quick overview:

1. Create the following directory structure:

..\PlayListExtractor

..\PlayListExtractor\src\travelmarx\PlayListExtractor.java

..\PlayListExtractor\lib\commons-lang3-3.3.2.jar

..\PlayListExtractor\build\classes


2. Compile the code:
..\PlayListExtractor>javac -classpath lib\* -d build\classes src\travelmarx\PlayListExtractor.java


3. Run the code:
..\PlayListExtractor>java -classpath build\classes;lib\* travelmarx.PlayListExtractor 192.168.2.225 c:\playlists queue


Some ways to improve the program:
  • Figure out which of the players is the master one.
  • Programmatically clear the queue, find the given Sonos playlist to be exported and place this on the queue. With the current version you have to do this manually.
  • Improve error handling.
Thanks Finn.

18 comments:

  1. TravelMax, thank you for your post. I have found this by searching and it has been a great help.

    I am trying to control the sonos system from a home automation device ISY. I am not a programmer but your post has been helpful. Since I am a newbie, can you help me out to simply send a mute or stop to a sonos device using http, assuming that I already have a device ID, and ip address. Is there such thing as a one line http to do this?

    Thanks in advance, JOEFLY

    joefly

    ReplyDelete
  2. I don’t know of a simple, one line command you could issue say in the address bar of a browser to stop or mute a particular zone player (device). E.g. http://192.168.2.9?action=SetMute&InstanceID=0&Channel=Master&DesiredMute=1. If you find such a way please let me know.

    The example above details how to build an HTTP request, populate the body with the correct SOAP message, and send it to the device. Inside the HTTP body’s SOAP message is where the information about what you want to do is included. If you build a general page like suggested above you can have buttons and other UI controls on the page that are easily to manipulate while behind the scenes (in code) the correct HTTP request is built and sent.

    ReplyDelete
  3. HI TravelMarx, Thanks for your reply. I figured it out with the help of a programmer. My assumption was naive, But I figured it out, as There is a one line SOAP message but as you know this needs to be enclosed in a program. Sorry for the lack of clarity as I am not a programmer. But I am working with an ISY home automation hardware, and the hardware allows to issue SOAP commands. SO I was able to figure it out based on your explanation using device Spy. I have been able to do play, pause, next, volume up and down.

    However, I am having a hard time finding the SOAP command to switch to line-in and then a similar command to point to a pandora station. Can you point me to what command it is to switch to line in and then how do I chose a specific pandora station?

    Again, thank you for sharing your knowledge. Sharing/Mutually learning on the internet is an incredible thing.


    JOEFLY
    Joefly

    ReplyDelete
  4. No problem. In terms of the ability to switch line-in and then point to a different pandora radio station I did not have much luck. This is what I tried. 1) Make sure I already had two pandora stations defined. 2) Call up device spy. 3) While one pandora station is playing go to XXXX-Sonos Zone Player Media Renderer, urn:upnp-org:serviceId:AvTransport, GetMediaInfo and invoke. (XXXX would be whatever the name of your zone is). 3) Save the CurrentURI and CurrentURIMetadata field values - say to notepad. 4) Change the pandora station with the sonos controller. 4) Then go to SetAVTransportURI command and invoke with the same CurrentURI and CurrentURIMetadata values from the previous step for the first pandora station.

    Something happened, but not what I wanted, that is to change the station. Instead, the current station stopped and there was no music. So I got the system's attention but was not ultimately successful. I probably have the wrong info in the CurrentURI and CurrentURIMetadata fields.

    Please let me know if you get farther!

    ReplyDelete
  5. thanks for your suggestion. I have an email out to another programmer, if I hear something, i will post back. Joefly

    ReplyDelete
  6. Hi

    Very interesting articles, thank you very much. I'm trying to find a way to export a given Sonos playlist into a .m3u file (for backup in case I need to reset my players to factory defaults) and I'm struggling to find any SOAP message descriptions for this purpose. Do you have any pointers?

    Any help is greatly appreciated. Thanks in advance.

    Cheers

    Finn

    ReplyDelete
  7. I was just scratching my head over the same thing this week. Sometimes I want to remember my playlists outside of Sonos too. I was working with the Sonos Desktop Application and thought surely they would put some elementary cut and paste in that application but no. On a similar note the app for the iPhone is surprisingly limiting in that way too. You can't email the current song you are listing to or a playlist to anyone which seems isolating. Hello, Sonos, what about sharing don't your like? We are only talking about textual information here. [If the functionality exists, I'm too thick to discover it....]

    So the answer to your question is no I haven't figured anything out. It's on my list to try and tackle. I'm thinking if I take the above HTML/Javascript and that in combination with the previous post on "Exploring Sonos via UPnP" I might be able to figure out something.

    As a first step towards this goal: if you go to whichever controller is the master (using say DeviceSpy) and look at AVTransport, you see the following services:

    RemoveAllTracksFromQueue
    ReorderTracksInQueue
    SaveQueue
    AddURIToQueue

    but there's no "GetAllInQueue" type of service. The "SaveQueue" service doesn't do what you want.

    So, the brute force approach would be this:
    1. Clear queue (either programmatically or manually)
    2. Load queue with playlist (either programmatically or manually)
    3. Start "HTML page with new JavaScript function" (the page) yet to be programmed.
    4. The page goes through the playlist one by one and saves the list.
    5. When finished, print list to screen or save in some other way.

    I may try this. Let me know if you find a way.

    ReplyDelete
  8. Thanks a lot for your reply. I'll have a look at this over the coming weekend and let you know about my progress.

    Cheers

    Finn

    ReplyDelete
  9. Hi

    I've managed to write a small Java application that saves the current queue as both a playlist (.m3u) and a text file with track number, artist and title.

    Not sure how this will be formatted if posting here and I have to put it in a second comment as max. size for comments is 4KB. It has insufficient error handling and I've had to strip off comments and imports to reduce the size to less than 4KB.

    It uses Apache Commons Lang, which can be downloaded from one of the following:

    http://mirrors.rackhosting.com/apache//commons/lang/binaries/commons-lang-2.5-bin.tar.gz
    http://mirrors.rackhosting.com/apache//commons/lang/binaries/commons-lang-2.5-bin.zip

    The application command line is:

    Example:

    192.168.1.13 r:\ queue

    which takes the queue controlled by the Sonos master with IP address 192.168.1.13 and saves it to r:\queue.m3u and r:\queue.txt.

    This is great as I can now backup my Sonos playlists! It would be handy if it also could clear the queue, find the playlist and put it on the queue but I only have a few so this will do for now.

    Thanks so much for your help :-).

    Cheers

    ReplyDelete
  10. I get the following error when trying to post a new comment with my code. [Preview] is OK but it fails when I try to post it.

    Request-URI Too Large

    Any suggestions on how I can post my code?

    Cheers

    ReplyDelete
  11. Brilliant! I didn't even think to look at the device/service you used. I was able to read through your code and figure out it's:

    Device:Content directory (of the Master)
    - Service:ContentDirectory
    ObjectID = Q:0
    BrowseFlag = BrowseDirectChildren
    Filter = upnp:artist,dc:title
    StartingIndex = 0
    RequestedCount = 1000 (or whatever is needed)
    SortCriteria =

    And then hit send the send the command. I verified using DeviceSpy and it works. No brute force needed. Excellent.

    Sorry about the blogger limitations. If you'd like you can send the code and instructions for use here: seekies @ live . com - and I can attach it to the end of this post.

    Thanks, I learned something!

    Last night I took a simple .htm page built using the JavaScript above and put it on an iPhone on my home network and the page worked just fine! That in combination with what you found out the page functionality can be improved so that it can send an email or post somewhere the current song or a playlist...don't know yet.

    ReplyDelete
  12. Weird. I just loaded up the simple html page and changed the rincon and ip address of my zones and I getting an error(expected ;)on the below line

    var response = transport.responseText "no response text";

    tested under IE8

    ReplyDelete
  13. I've been using this html page heavily the last few days as I prepare to update it with new features (like showing your playlists) and it has worked well (IE 9). Seems like the failure is in the function sendSoapRequest. It isn't completing a full request. You can use the developer tools in IE (F12 in IE9) set a break point in that function and figure ou what is going on. Or you can do the same using Visual Studio. I'll assume you have set IE appropriately to "Access data sources acroos domains" already?

    I will admit my JavaScript programming skills are not that great so I could have programmed in such a way that I'm not including a scenario you are running into.

    ReplyDelete
  14. Think something was lost in translation when you put it up on the blog.......

    This works....

    var response = transport.responseText || "no response text";

    I got it up and running and working on putting in volume control which I believe comes out of the Render control service.

    ReplyDelete
  15. Duh! I was looking at the code on my computer and the "||" was there while wondering while you didn't use in the comment. Thanks for finding that. I was missing "||" three times total. The encoding program I used must have stripped them out. I fixed the code above.

    ReplyDelete
  16. Yeah saw that the "||" was missing on the other lines.

    I would like to ask your permission to use your transport code. I want to take that and embed it in a Lua Script I am putting together so I can tie it into my home automation system. It's for personal use and I would like to make it opensource after I finish it.

    ReplyDelete
  17. Yeah, no problem, use it. When you are finished send me a pointer.

    ReplyDelete
  18. I updated this program to get playlists. The post is here: http://travelmarx.blogspot.com/2011/01/extracting-sonos-playlist-simple-sonos.html

    ReplyDelete

All comments go through a moderation process. Even though it may not look like the comment was accepted, it probably was. Check back in a day if you asked a question. Thanks!