diff --git a/lib/helpers/soap.js b/lib/helpers/soap.js index d83c5ac7..371a6d32 100644 --- a/lib/helpers/soap.js +++ b/lib/helpers/soap.js @@ -31,6 +31,9 @@ const TYPE = Object.freeze({ BecomeCoordinatorOfStandaloneGroup: 'urn:schemas-upnp-org:service:AVTransport:1#BecomeCoordinatorOfStandaloneGroup', RefreshShareIndex: 'urn:schemas-upnp-org:service:ContentDirectory:1#RefreshShareIndex', AddURIToQueue: 'urn:schemas-upnp-org:service:AVTransport:1#AddURIToQueue', + AddURIToSavedQueue: 'urn:schemas-upnp-org:service:AVTransport:1#AddURIToSavedQueue', + CreateSavedQueue: 'urn:schemas-upnp-org:service:AVTransport:1#CreateSavedQueue', + DestroyObject: 'urn:schemas-upnp-org:service:ContentDirectory:1#DestroyObject', AddMultipleURIsToQueue: 'urn:schemas-upnp-org:service:AVTransport:1#AddMultipleURIsToQueue', ListAvailableServices: 'urn:schemas-upnp-org:service:MusicServices:1#ListAvailableServices', }); @@ -60,6 +63,9 @@ const TEMPLATES = Object.freeze({ [TYPE.BecomeCoordinatorOfStandaloneGroup]: '0', [TYPE.RefreshShareIndex]: '', [TYPE.AddURIToQueue]: '0{uri}{metadata}{desiredFirstTrackNumberEnqueued}{enqueueAsNext}', + [TYPE.AddURIToSavedQueue]: '0SQ:{sqid}{updateID}{uri}<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="" restricted="true"><dc:title>{title}</dc:title><upnp:class>{upnpClass}</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>4294967295', + [TYPE.CreateSavedQueue]: '0{title}', + [TYPE.DestroyObject]: '{id}', [TYPE.AddMultipleURIsToQueue]: '00{amount}{uris}{metadatas}{containerURI}{containerMetadata}{desiredFirstTrackNumberEnqueued}{enqueueAsNext}', [TYPE.ListAvailableServices]: '' }); diff --git a/lib/models/Player.js b/lib/models/Player.js index cf3fd216..39e9465e 100644 --- a/lib/models/Player.js +++ b/lib/models/Player.js @@ -578,6 +578,108 @@ Player.prototype.saveQueue = function saveQueue(title) { }); }; +Player.prototype.createSavedQueue = function createSavedQueue(title) { + return soap.invoke( + `${this.baseUrl}/MediaRenderer/AVTransport/Control`, + TYPE.CreateSavedQueue, + { + title + }).then(soap.parse); +}; + +Player.prototype.destroyObject = function destroyObject(id) { + return soap.invoke( + `${this.baseUrl}/MediaServer/ContentDirectory/Control`, + TYPE.DestroyObject, + { + id + }).then(soap.parse); +}; + +Player.prototype.destroyByTitle = function destroyByTitle(name) { + return this.browseAll('SQ:') + .then((playlists) => { + let match; + for (var i = playlists.length - 1; i >= 0; i--) { + const playlist = playlists[i]; + if (playlist.title.toLowerCase() === name.toLowerCase()) { + match = playlist; + break; + } + } + + return 'SQ:' + match.sqid; + }). + then((id) => { + return this.destroyObject(id); + }); +}; + +Player.prototype.addURIToSavedQueue = function addURIToSavedQueue(name, uri, title) { + let upnpClass = 'object.item.audioItem.musicTrack'; + let itemId; + if (uri.indexOf('savedqueues.rsq') !== -1) { + // append a playlist into another + upnpClass = 'object.container.playlistContainer'; + itemId = 'SQ:' + uri.match('#(\\d+)$')[1]; + } else { + // TODO test multi controller setup + if (uri.indexOf('x-file-cifs') !== -1) { + // for DIDL CDATA, per wireshark, remove protocols like x-file-cifs:// + // I don't have a windows or android setup so I don't know if S:// is assumed by device as a shared volume (on macOS, yes) + itemId = 'S://' + uri.replace(/(.*:\/\/)/, ''); + } + } + + return this.browseAll('SQ:') + .then((playlists) => { + let match; + for (var i = playlists.length - 1; i >= 0; i--) { + const playlist = playlists[i]; + if (playlist.title.toLowerCase() === name.toLowerCase()) { + match = playlist; + break; + } + } + + return match; + }).then((match) => { + const sqid = match.sqid; + if (uri.indexOf('savedqueues.rsq') !== -1) { + + // the title is not the rsq destination name but the origin name + title = match.title; + } + + return this.browse('SQ:' + sqid, 0, 100).then((res) => { + const updateID = res.updateID; + logger.info(`will import to sqid ${sqid} at index ${updateID} with : ${uri}, ${title}, DIDL metadata : itemId ${itemId}, title ${title}`); + return soap.invoke( + `${this.baseUrl}/MediaRenderer/AVTransport/Control`, + TYPE.AddURIToSavedQueue, + { + sqid, + uri, + itemId, + title, + updateID, + upnpClass + }).catch((err) => { + const path = err.path; + const statusCode = err.statusCode; + const statusMessage = err.statusMessage; + const body = err.body; + const msg = `import error for ${uri}@SQ:${sqid}:${path}: ${statusCode}, ${statusMessage}, ${body}`; + logger.error(msg, err); + return Promise.reject(new Error(msg)); + }); + } + ); + } + ) +.then(soap.parse); +}; + Player.prototype.addURIToQueue = function addURIToQueue(uri, metadata, enqueueAsNext, desiredFirstTrackNumberEnqueued) { desiredFirstTrackNumberEnqueued = desiredFirstTrackNumberEnqueued === undefined @@ -740,9 +842,9 @@ Player.prototype.browse = function browse(objectId, startIndex, limit) { startIndex, items: [] }; - returnResult.numberReturned = parseInt(res.numberreturned, 10); returnResult.totalMatches = parseInt(res.totalmatches, 10); + returnResult.updateID = parseInt(res.updateid, 10); let stream = streamer(res.result); let sax = flow(stream, { preserveMarkup: flow.NEVER }); @@ -762,12 +864,12 @@ Player.prototype.browse = function browse(objectId, startIndex, limit) { }); sax.on('tag:container', (item) => { - returnResult.items.push({ uri: item.res.$text, title: item['dc:title'], artist: item['dc:creator'], - albumArtUri: item['upnp:albumarturi'] + albumArtUri: item['upnp:albumarturi'], + sqid: item.$attrs.id.replace(/SQ:/, '') }); }); @@ -805,6 +907,7 @@ Player.prototype.browseAll = function browseAll(objectId) { } // Recursive promise chain + return this.browse(objectId, chunk.startIndex + chunk.numberReturned, 0) .then(getChunk); }; diff --git a/lib/prototypes/Player/createPlaylist.js b/lib/prototypes/Player/createPlaylist.js new file mode 100644 index 00000000..93078434 --- /dev/null +++ b/lib/prototypes/Player/createPlaylist.js @@ -0,0 +1,22 @@ +'use strict'; +const logger = require('../../helpers/logger'); + +function createPlaylist(title) { + logger.debug(`creating playlist with name ${title}`); + + if (!title) { + throw new Error('No playlist name provided'); + } + + return this.createSavedQueue(title) + .then((res) => { + return { + result: 'success', + title: title, + sqid: res.assignedobjectid.replace(/SQ:/, '') + }; + }); +} + +module.exports = createPlaylist; + diff --git a/lib/prototypes/Player/deletePlaylist.js b/lib/prototypes/Player/deletePlaylist.js new file mode 100644 index 00000000..8f828611 --- /dev/null +++ b/lib/prototypes/Player/deletePlaylist.js @@ -0,0 +1,20 @@ +'use strict'; +const logger = require('../../helpers/logger'); + +function deletePlaylist(name) { + logger.debug(`deleting playlist with name ${name}`); + + if (!name) { + throw new Error('No playlist name provided'); + } + + return this.destroyByTitle(name) + .then((res) => { + return { + result: 'success' + }; + }); + +} + +module.exports = deletePlaylist; diff --git a/lib/prototypes/Player/exportPlaylist.js b/lib/prototypes/Player/exportPlaylist.js new file mode 100644 index 00000000..3ac25bcb --- /dev/null +++ b/lib/prototypes/Player/exportPlaylist.js @@ -0,0 +1,47 @@ +'use strict'; +const logger = require('../../helpers/logger'); + +function exportPlaylist(id) { + logger.debug(`exporting playlist using id ${id}`); + if (id) { + return this.system.getPlaylists() + .then((playlists) => { + let match; + for (var i = playlists.length - 1; i >= 0; i--) { + const playlist = playlists[i]; + if (playlist.title.toLowerCase() === id.toLowerCase()) { + match = playlist; + break; + } + }; + + return match || {}; + }) + .then((playlist) => { + const ptitle = playlist.title; + if (ptitle === undefined) { + return {}; + } + + const psqid = playlist.sqid; + return this.system.getPlaylists(psqid) + .then((playlist) => { + playlist.title = ptitle; + playlist.sqid = psqid; + return { + title: ptitle, + sqid: psqid, + items: playlist + }; + }); + }); + } + + return this.system.getPlaylists(id) + .then((playlist) => { + return playlist; + }); +} + +module.exports = exportPlaylist; + diff --git a/lib/prototypes/Player/importPlaylist.js b/lib/prototypes/Player/importPlaylist.js new file mode 100644 index 00000000..dc2626f7 --- /dev/null +++ b/lib/prototypes/Player/importPlaylist.js @@ -0,0 +1,18 @@ +'use strict'; +const logger = require('../../helpers/logger'); + +function importPlaylist(sqid, uri, title) { + if (!sqid || !uri || !title) { + throw new Error('No playlist id or title provided or no URI provided'); + }; + + return this.addURIToSavedQueue(sqid, uri, title) + .then((res) => { + return { result: 'success', import: res }; + }).catch((err) => { + throw new Error(err); + }); +} + +module.exports = importPlaylist; + diff --git a/lib/prototypes/SonosSystem/getPlaylists.js b/lib/prototypes/SonosSystem/getPlaylists.js index d4ebbe4c..365e0b75 100644 --- a/lib/prototypes/SonosSystem/getPlaylists.js +++ b/lib/prototypes/SonosSystem/getPlaylists.js @@ -1,7 +1,7 @@ 'use strict'; -function getPlaylists() { - return this.getAnyPlayer().browseAll('SQ:'); +function getPlaylists(id) { + return this.getAnyPlayer().browseAll(id ? 'SQ:' + id : 'SQ:'); } module.exports = getPlaylists; diff --git a/package.json b/package.json index d05ea13d..ef4b9bd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sonos-discovery", - "version": "1.4.1", + "version": "1.4.2", "description": "A simple node library for managing your Sonos system", "author": "Jimmy Shimizu ", "repository": { diff --git a/test/data/addURIToSavedQueue.xml b/test/data/addURIToSavedQueue.xml new file mode 100644 index 00000000..cb67e262 --- /dev/null +++ b/test/data/addURIToSavedQueue.xml @@ -0,0 +1,9 @@ + + + + 1 + 1 + 1 + + + diff --git a/test/data/createSavedQueue.xml b/test/data/createSavedQueue.xml new file mode 100644 index 00000000..66f7694d --- /dev/null +++ b/test/data/createSavedQueue.xml @@ -0,0 +1,10 @@ + + + + 0 + 0 + 0 + SQ:1 + + + diff --git a/test/data/destroyObject.xml b/test/data/destroyObject.xml new file mode 100644 index 00000000..d7f69a41 --- /dev/null +++ b/test/data/destroyObject.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/test/unit/models/Player.js b/test/unit/models/Player.js index 5096da43..2915b6c7 100644 --- a/test/unit/models/Player.js +++ b/test/unit/models/Player.js @@ -668,6 +668,86 @@ describe('Player', () => { }); }); + it('createSavedQueue', () => { + soap.parse.restore(); + let createSavedQueueXml = fs.createReadStream(`${__dirname}/../../data/createSavedQueue.xml`); + createSavedQueueXml.statusCode = 200; + soap.invoke.resolves(createSavedQueueXml); + + expect(TYPE.CreateSavedQueue).not.undefined; + return player.createSavedQueue('myplaylist') + .then((result) => { + expect(result).eql({ + assignedobjectid: 'SQ:1', + newqueuelength: '0', + newupdateid: '0', + numtracksadded: '0' + }); + expect(soap.invoke).calledOnce; + expect(soap.invoke.firstCall.args).eql([ + 'http://192.168.1.151:1400/MediaRenderer/AVTransport/Control', + TYPE.CreateSavedQueue, + { + title: 'myplaylist' + } + ]); + }); + }); + + it('destroyObject', () => { + soap.parse.restore(); + let destroyObjectXml = fs.createReadStream(`${__dirname}/../../data/destroyObject.xml`); + destroyObjectXml.statusCode = 200; + soap.invoke.resolves(destroyObjectXml); + + expect(TYPE.DestroyObject).not.undefined; + return player.destroyObject('1') + .then(() => { + expect(soap.invoke).calledOnce; + expect(soap.invoke.firstCall.args).eql([ + 'http://192.168.1.151:1400/MediaServer/ContentDirectory/Control', + TYPE.DestroyObject, + { + id: '1' + } + ]); + }); + }); + + it('Should have invoked browse to find the playlist to add URI to', () => { + let addURIToSavedQueueXml = fs.createReadStream(`${__dirname}/../../data/addURIToSavedQueue.xml`); + addURIToSavedQueueXml.statusCode = 200; + soap.invoke.resolves(addURIToSavedQueueXml); + + expect(TYPE.AddURIToSavedQueue).not.undefined; + + return player.addURIToSavedQueue('1', 'x-file-cifs://MacBook-Air-de-laurent-2/Musique/iTunes/iTunes%20Media/Music/The%20xx/I%20See%20You/06%20Replica.mp3', 'track title') + .then((result) => { + expect(soap.invoke).calledThrice; + expect(soap.invoke.secondCall.args).eql([ + 'http://192.168.1.151:1400/MediaServer/ContentDirectory/Control', + TYPE.Browse, + { + limit: 100, + objectId: 'SQ:1', + startIndex: 0 + } + ]); + expect(soap.invoke.thirdCall.args).eql([ + 'http://192.168.1.151:1400/MediaRenderer/AVTransport/Control', + TYPE.AddURIToSavedQueue, + { + sqid: '1', + uri: 'x-file-cifs://MacBook-Air-de-laurent-2/Musique/iTunes/iTunes%20Media/Music/The%20xx/I%20See%20You/06%20Replica.mp3', + itemId: 'S://MacBook-Air-de-laurent-2/Musique/iTunes/iTunes%20Media/Music/The%20xx/I%20See%20You/06%20Replica.mp3', + title: 'track title', + updateID: NaN, /* I did not find a way to stub the updateID from browser */ + upnpClass: 'object.item.audioItem.musicTrack' + } + ]); + }); + }); + it('addURIToQueue', () => { soap.parse.restore(); let addURIToQueueXml = fs.createReadStream(`${__dirname}/../../data/addURIToQueue.xml`); @@ -828,6 +908,7 @@ describe('Player', () => { uri: 'file:///jffs/settings/savedqueues.rsq#2', title: 'Morgon', artist: undefined, + sqid: '2', albumArtUri: [ '/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a35N1AduT1LDo3deLfYniTY%3fsid%3d9%26flags%3d0', '/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a1MQYow43CGLYMECVSjTpCM%3fsid%3d9%26flags%3d0',