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',