UNPKG

sonos

Version:
352 lines (330 loc) 14.4 kB
/** * Sonos library to control (almost) everything from your sonos devices * @module sonos * @requires 'debug' * @requires 'xml2js */ const debug = require('debug')('sonos:helpers') const parseString = require('xml2js').parseString /** * Helper class * @class Helpers */ const Helpers = {} /** * Wrap in UPnP Envelope * @param {String} body The SOAP body. * @return {String} */ Helpers.CreateSoapEnvelop = function (body) { return [// '<?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>' + body + '</s:Body>', '</s:Envelope>'].join('') } /** * Encodes characters not allowed within html/xml tags, for use with nester xml. * @param {String} input * @return {String} */ Helpers.EncodeXml = function (input) { return String(input).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') } /** * Converts parentID to upnp cass * @param {String} parentID The id of the parent * @return {String} object.item.audioItem.musicTrack */ Helpers.GetUpnpClass = function (parentID) { switch (parentID) { case 'A:ALBUMS': return 'object.item.audioItem.musicAlbum' case 'A:TRACKS': return 'object.item.audioItem.musicTrack' case 'A:ALBUMARTIST': return 'object.item.audioItem.musicArtist' case 'A:GENRE': return 'object.container.genre.musicGenre' case 'A:COMPOSER': return 'object.container.person.composer' default: return '' } } /** * Generate custom metadata, to be used with the play and/or setAVTransportURI * @param {String} streamUri The playback uri * @param {String} itemId * @param {String} duration The duration of the song, as 'hh:mm:ss' * @param {String} title The title of the song * @param {String} artist The artist of the sons * @param {String} album the album of the song * @param {String} coverUrl the coverUrl of the song * @param {String} parentId the parentId of the song */ Helpers.GenerateCustomMetadata = function (streamUrl, itemId, duration = '00:00:00', title, artist, album, coverUrl, parentId) { let metadata = '<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/">' metadata += `<item id="${itemId}"` + (parentId) ? ` parentID="${parentId}">` : '>' metadata += `<res protocolInfo="http-get:*:audio/mpeg:*" duration="${duration}">${streamUrl}</res>` if (coverUrl) metadata += `<upnp:albumArtURI>${coverUrl}</upnp:albumArtURI>` if (title) metadata += `<dc:title>${title}</dc:title>` if (artist) metadata += `<dc:creator>${artist}</dc:creator>` if (album) metadata += `<upnp:album>${album}</upnp:album>` metadata += '</item></DIDL-Lite>' return metadata } /** * Creates object with uri and metadata from playback uri * @param {String} uri The playback uri * @param {String} artUri Uri for art image * @return {Object} { uri: uri, metadata: metadata } */ Helpers.GenerateLocalMetadata = function (uri, artUri = '') { let title = '' const match = /.*\/(.*)$/g.exec(uri.replace(/\.[a-zA-Z0-9]{3}$/, '')) if (match) { title = match[1] } const meta = '<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="##ITEMID##" parentID="##PARENTID##" restricted="true"><dc:title>##RESOURCETITLE##</dc:title><upnp:class>##UPNPCLASS##</upnp:class><upnp:albumArtURI>##ARTURI##</upnp:albumArtURI><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">##REGION##</desc></item></DIDL-Lite>' if (uri.startsWith('x-file-cifs')) { return { uri, metadata: meta.replace('##ITEMID##', uri.replace('x-file-cifs', 'S').replace(/\s/g, '%20')) .replace('##RESOURCETITLE##', title.replace('%20', ' ')) .replace('##UPNPCLASS##', Helpers.GetUpnpClass('A:TRACKS')) .replace('##PARENTID##', 'A:TRACKS') .replace('##ARTURI##', artUri.replace(/\s/g, '%20')) .replace('##REGION##', 'RINCON_AssociatedZPUDN') } } if (uri.startsWith('x-rincon-playlist')) { const parentMatch = /.*#(.*)\/.*/g.exec(uri) const parentID = parentMatch[1] return { uri, metadata: meta.replace('##ITEMID##', `${parentID}/${title.replace(/\s/g, '%20')}`) .replace('##RESOURCETITLE##', title.replace('%20', ' ')) .replace('##UPNPCLASS##', Helpers.GetUpnpClass(parentID)) .replace('##PARENTID##', parentID) .replace('##ARTURI##', artUri.replace(/\s/g, '%20')) .replace('##REGION##', 'RINCON_AssociatedZPUDN') } } return { uri, metadata: '' } } /** * Creates object with uri and metadata from playback uri * @param {String} uri The playback uri (currently supports spotify, tunein) * @param {String} title Sometimes the title is required. * @param {String} region Spotify region is required for all spotify tracks, see `sonos.SpotifyRegion` * @return {Object} options {uri: Spotify uri, metadata: metadata} */ Helpers.GenerateMetadata = function (uri, title = '', region = '3079') { const parts = uri.split(':') if (!((parts.length === 2 && (parts[0] === 'radio' || parts[0] === 'x-sonosapi-stream' || parts[0] === 'x-rincon-cpcontainer')) || (parts.length >= 3 && parts[0] === 'spotify'))) { debug('Returning string because it isn\'t recognized') return Helpers.GenerateLocalMetadata(uri) } let meta = '<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="##SPOTIFYURI##" ##PARENTID##restricted="true"><dc:title>##RESOURCETITLE##</dc:title><upnp:class>##SPOTIFYTYPE##</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">##REGION##</desc></item></DIDL-Lite>' if (parts[0] === 'radio' || parts[0] === 'x-sonosapi-stream') { const radioTitle = title || 'TuneIn Radio' if (parts[0] === 'radio') { return { uri: 'x-sonosapi-stream:' + parts[1] + '?sid=254&flags=8224&sn=0', metadata: meta.replace('##SPOTIFYURI##', 'F00092020' + parts[1]) .replace('##RESOURCETITLE##', Helpers.EncodeXml(radioTitle)) .replace('##SPOTIFYTYPE##', 'object.item.audioItem.audioBroadcast') .replace('##PARENTID##', 'parentID="L" ') .replace('##REGION##', 'SA_RINCON65031_') } } else { const itemId = parts[1].split('?')[0] return { uri, metadata: meta.replace('##SPOTIFYURI##', 'F00092020' + itemId) .replace('##RESOURCETITLE##', radioTitle) .replace('##SPOTIFYTYPE##', 'object.item.audioItem.audioBroadcast') .replace('##PARENTID##', 'parentID="R:0/0" ') .replace('##REGION##', 'SA_RINCON65031_') } } } else { meta = meta.replace('##REGION##', 'SA_RINCON' + region + '_X_#Svc' + region + '-0-Token') } const spotifyUri = uri.replace(/:/g, '%3a') if (uri.startsWith('spotify:track:')) { // Just one track return { uri: 'x-sonos-spotify:' + spotifyUri, metadata: meta.replace('##SPOTIFYURI##', '00032020' + spotifyUri) .replace('##RESOURCETITLE##', '') .replace('##SPOTIFYTYPE##', 'object.item.audioItem.musicTrack') .replace('##PARENTID##', '') } } else if (uri.startsWith('spotify:album:')) { // Album return { uri: 'x-rincon-cpcontainer:0004206c' + spotifyUri, metadata: meta.replace('##SPOTIFYURI##', '0004206c' + spotifyUri) .replace('##RESOURCETITLE##', '') .replace('##SPOTIFYTYPE##', 'object.container.album.musicAlbum') .replace('##PARENTID##', '') } } else if (uri.startsWith('spotify:artistTopTracks:')) { // Artist top tracks return { uri: 'x-rincon-cpcontainer:000e206c' + spotifyUri, metadata: meta.replace('##SPOTIFYURI##', '000e206c' + spotifyUri) .replace('##RESOURCETITLE##', '') .replace('##SPOTIFYTYPE##', 'object.container.playlistContainer') .replace('##PARENTID##', '') } } else if (uri.startsWith('spotify:playlist:')) { // Playlist return { uri: 'x-rincon-cpcontainer:1006206c' + spotifyUri, metadata: meta.replace('##SPOTIFYURI##', '1006206c' + spotifyUri) .replace('##RESOURCETITLE##', '') .replace('##SPOTIFYTYPE##', 'object.container.album.playlistContainer') .replace('##PARENTID##', '') } } else if (uri.startsWith('spotify:user:')) { return { uri: 'x-rincon-cpcontainer:10062a6c' + spotifyUri + '?sid=9&flags=10860&sn=7', metadata: meta.replace('##SPOTIFYURI##', '10062a6c' + spotifyUri) .replace('##RESOURCETITLE##', 'User playlist') .replace('##SPOTIFYTYPE##', 'object.container.playlistContainer') .replace('##PARENTID##', 'parentID="10082664playlists" ') } } else if (uri.startsWith('spotify:artistRadio:')) { // Artist radio const spotifyTitle = title || 'Artist Radio' const parentId = spotifyUri.replace('artistRadio', 'artist') return { uri: 'x-sonosapi-radio:' + spotifyUri + '?sid=12&flags=8300&sn=5', metadata: meta.replace('##SPOTIFYURI##', '100c206c' + spotifyUri) .replace('##RESOURCETITLE##', spotifyTitle) .replace('##SPOTIFYTYPE##', 'object.item.audioItem.audioBroadcast.#artistRadio') .replace('##PARENTID##', 'parentID="10052064' + parentId + '" ') } } else if (uri.startsWith('x-rincon-cpcontainer:1006206ccatalog')) { // Amazon prime container const [id] = uri.match(/^x-rincon-cpcontainer:(.*)\?.*/).splice(1) return { uri: String(uri).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"'), metadata: meta.replace('##SPOTIFYURI##', id) .replace('##RESOURCETITLE##', title) .replace('##SPOTIFYTYPE##', 'object.container.playlistContainer') .replace('##PARENTID##', '') } } else if (uri.startsWith('x-rincon-cpcontainer:100d206cuser-fav')) { // Sound Cloud likes const id = uri.replace('x-rincon-cpcontainer:', '') return { uri, metadata: meta.replace('##SPOTIFYURI##', id) .replace('##RESOURCETITLE##', title || 'Sound Cloud Likes') .replace('##SPOTIFYTYPE##', 'object.container.albumList') .replace('##PARENTID##', '') .replace(`SA_RINCON${region}_X_#Svc${region}-0-Token`, 'SA_RINCON40967_X_#Svc40967-0-Token') } } else if (uri.startsWith('x-rincon-cpcontainer:1006206cplaylist')) { // Sound Cloud playlists const [id] = uri.replace('x-rincon-cpcontainer:', '').split('?') return { uri, metadata: meta.replace('##SPOTIFYURI##', id) .replace('##RESOURCETITLE##', title || 'Sound Cloud Playlist') .replace('##SPOTIFYTYPE##', 'object.container.playlistContainer') .replace('##PARENTID##', '') .replace(`SA_RINCON${region}_X_#Svc${region}-0-Token`, 'SA_RINCON40967_X_#Svc40967-0-Token') } } else { return { uri, metadata: '' } } } /** * Parse DIDL into track structure * @param {String} didl * @param {String} host host ip * @param {Number} port port numer * @return {object} */ Helpers.ParseDIDL = function (didl, host, port, trackUri) { debug('ParseDIDL %j %s %d', didl, host, port) if ((!didl) || (!didl['DIDL-Lite'])) return {} const item = didl['DIDL-Lite'].item return Helpers.ParseDIDLItem(item, host, port, trackUri) } Helpers.dropIDNamespace = function (value) { if (!value) { return null } const ret = value.split(':', 2)[1] if (!ret || ret.length === 0) { return null } return ret } Helpers.ParseDIDLItem = function (item, host, port, trackUri) { let albumArtURI = item['upnp:albumArtURI'] || null if (albumArtURI && Array.isArray(albumArtURI)) { albumArtURI = albumArtURI.length > 0 ? albumArtURI[0] : null } const track = { id: Helpers.dropIDNamespace(item.id), parentID: Helpers.dropIDNamespace(item.parentID), title: item['r:streamContent'] || item['dc:title'] || null, artist: item['dc:creator'] || null, album: item['upnp:album'] || null, albumArtist: item['r:albumArtist'] || null, albumArtURI } if (trackUri) track.uri = trackUri if (host && port && track.albumArtURI && !/^\bhttp[s]*:\/\//.test(track.albumArtURI)) { track.albumArtURI = 'http://' + host + ':' + port + track.albumArtURI } return track } /** * Convert a time string to seconds * @param {String} time like `00:03:34` * @returns {Number} number of seconds like 214 */ Helpers.TimeToSeconds = function (time) { const timeSplit = time.split(':') return (parseInt(timeSplit[0], 10) * 60 * 60) + (parseInt(timeSplit[1], 10) * 60) + parseInt(timeSplit[2], 10) } Helpers._ParseXml = function (input, callback) { return parseString(input, { mergeAttrs: true, explicitArray: false }, callback) } const playbackStates = { STOPPED: 'stopped', PLAYING: 'playing', PAUSED_PLAYBACK: 'paused', TRANSITIONING: 'transitioning', NO_MEDIA_PRESENT: 'no_media' } /** * Convert the playbackstate to a bit more readable * @param {String} state Sonos playback state */ Helpers.TranslateState = function (state) { return playbackStates[state] || state } /** * Parse Xml to an object async * @param {String} input The XML to be parsed * @return {Promise} */ Helpers.ParseXml = async function (input) { debug('Helpers.ParseXml %j', input) return new Promise((resolve, reject) => { Helpers._ParseXml(input, (err, data) => { if (err) { reject(err) } else { resolve(data) } }) }) } /** * Sanitizes Device Description XML async * @param {String} input The XML to be sanitized * @return {Promise} */ Helpers.SanitizeDeviceDescriptionXml = async function (input) { // This is to fix malformed xml in description see https://github.com/bencevans/node-sonos/issues/465 debug('Helpers.SanitizeDeviceDescriptionXml %j', input) return input.replace(/#([\w]+)#/gm, '<!-- #$1# -->') } module.exports = Helpers