(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.
<!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, "&"); string = string.replace(/"/g, """); string = string.replace(/'/g, "'"); string = string.replace(/</g, "<"); string = string.replace(/>/g, ">"); return string; }, xmlUnescape: function (string) { string = string.replace(/&/g, "&"); string = string.replace(/"/g, "\""); string = string.replace(/'/g, "'"); string = string.replace(/</g, "<"); string = string.replace(/>/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.
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("<item", j); while (i >= 0) { // Loop over all items, where each item is a track on the queue. j = response.indexOf("</item>", i); String track = response.substring(i + 8, j); String trackNo, artist, title, unc; trackNo = extract(track, " id="Q:", """); trackNo = trackNo.substring(trackNo.indexOf('/') + 1); unc = URLDecoder.decode(extract(track, ">x-file-cifs:", "<").replace('/', '\\').replaceAll("%20", " "), "UTF-8"); artist = extract(track, "<dc:creator>", "</dc:creator>"); title = extract(track, "<dc:title>", "</dc:title>"); txt.append(decode(trackNo + ". " + artist + ": " + title) + "\r\n"); m3u.append(decode(unc) + "\r\n"); i = response.indexOf("<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 & to &, then uses Apache * Commons Lang to decode the standard entities and then manually decodes a non- * standard entity ('). * @param s Text to be decoded. * @return Text with HTML character entities decoded. */ private String decode(String s) { // Convert & to &, ' to ' and let Apache Commons Lang about the rest. return StringEscapeUtils.unescapeHtml3(s.replaceAll("&", "&")).replaceAll("'", "'"); } /** * 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.