diff --git a/README.md b/README.md index 78eb5d0c..09763eb0 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ The actions supported as of today: * favorite * favorites (with optional "detailed" parameter) * playlist +* playlistcreate +* playlistdelete +* playlistexport +* playlistimport * lockvolumes / unlockvolumes (experimental, will enforce the volume that was selected when locking!) * repeat (on/off) * shuffle (on/off) diff --git a/lib/actions/playlist.js b/lib/actions/playlist.js index 8e6cc81b..a55179e8 100644 --- a/lib/actions/playlist.js +++ b/lib/actions/playlist.js @@ -1,4 +1,59 @@ 'use strict'; +const sleep = require('system-sleep'); + +function createPlaylist(player, values) { + const playlistName = decodeURIComponent(values[0]); + return player.coordinator + .createPlaylist(playlistName) + .then((res) => { + return res; + } + ); +} + +function deletePlaylist(player, values) { + const sqid = decodeURIComponent(values[0]); + return player.coordinator + .deletePlaylist(sqid) + .then((res) => { + return res; + } + ); +} + +function importPlaylist(player, values) { + const sqid = decodeURIComponent(values[0]); + if (values.body !== undefined) { + // multi uri + const items = values.body.export.items; + return Promise.resolve().then(_ => { + items.map(item => { + const title = item.title; + const uri = item.uri; + // there is no way to batch import in Controller UI, we must import one by one + // since adduri requires a synchronous browse for index updateID, we pause. + // pause should be low enough for the HTTP roundtrips, tested on WiFi + play:5 + sleep(600); + return player.coordinator.importPlaylist(sqid, uri, title); + }); + }).then((res) => { + return res; + }).catch((err) => { + throw new Error(err); + }); + } + else { + // single uri or internal rsq jffs to jffs appending + const title = decodeURIComponent(values[1]); + const uri = values.slice(2).map(x => "/" + x).join().replace(/,/g,'').replace(/\//,''); + return player.coordinator + .importPlaylist(sqid, uri, title) + .then((res) => { + return res; + } + ); + } +} function playlist(player, values) { const playlistName = decodeURIComponent(values[0]); @@ -7,6 +62,21 @@ function playlist(player, values) { .then(() => player.coordinator.play()); } +function exportPlaylist(player, values) { + var id = decodeURIComponent(values[0]); + return player.coordinator + .exportPlaylist(id) + .then((res) => { + return { + export : res + }; + }); +} + module.exports = function (api) { api.registerAction('playlist', playlist); + api.registerAction('playlistexport', exportPlaylist); + api.registerAction('playlistcreate', createPlaylist); + api.registerAction('playlistdelete', deletePlaylist); + api.registerAction('playlistimport', importPlaylist); }; diff --git a/lib/sonos-http-api.js b/lib/sonos-http-api.js index 6f776965..dce33f13 100644 --- a/lib/sonos-http-api.js +++ b/lib/sonos-http-api.js @@ -47,6 +47,7 @@ function HttpAPI(discovery, settings) { }); this.requestHandler = function (req, res) { + if (req.url === '/favicon.ico') { res.end(); return; @@ -73,6 +74,7 @@ function HttpAPI(discovery, settings) { opt.action = (params[0] || '').toLowerCase(); opt.values = params.splice(1); } + opt.values.body = req.body; function sendResponse(code, body) { var jsonResponse = JSON.stringify(body); diff --git a/package.json b/package.json index 19907b34..6ab8e2c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sonos-http-api", - "version": "1.4.1", + "version": "1.4.2", "description": "A simple node app for controlling a Sonos system with basic HTTP requests", "scripts": { "start": "node server.js" @@ -18,7 +18,8 @@ "json5": "^0.5.1", "node-static": "~0.7.0", "request-promise": "~1.0.2", - "sonos-discovery": "https://github.com/jishi/node-sonos-discovery/archive/v1.4.1.tar.gz" + "sonos-discovery": "file:../node-sonos-discovery", + "system-sleep": "^1.3.5" }, "engines": { "node": ">=4.0.0", diff --git a/server.js b/server.js index a5700431..77acd53b 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,19 @@ const discovery = new SonosSystem(settings); const api = new SonosHttpAPI(discovery, settings); var requestHandler = function (req, res) { + let body = ''; + + if (req.method === 'POST') { + req.on('data', function (data) { + body += data; + + // Too much POST data, kill the connection! + // 1e6 === 1 * Math.pow(10, 6) === 1 * 1000000 ~~~ 1MB + if (body.length > 1e6) { + req.connection.destroy(); + } + }); + } req.addListener('end', function () { fileServer.serve(req, res, function (err) { @@ -45,8 +58,15 @@ var requestHandler = function (req, res) { res.end(); return; } - - if (req.method === 'GET') { + if (req.method === 'POST') { + req.body = body; + try { + req.body = JSON.parse(body); + } catch(e) { + logger.error("Invalid JSON body", e); + } + } + if (req.method === 'GET' || req.method === 'POST') { api.requestHandler(req, res); } }); diff --git a/static/index.html b/static/index.html index e2ef1a1b..d8a1c019 100644 --- a/static/index.html +++ b/static/index.html @@ -62,7 +62,13 @@

Actions

  • previous
  • state (will return a json-representation of the current state of player)
  • favorite
  • -
  • playlist
  • +
  • playlist (parameter is playlist name, will play)
  • +
  • playlistcreate (parameter is playlist name, will return a suitable name used for export, import and delete, see below)
  • +
  • playlistdelete (parameter is name of the playlist)
  • +
  • playlistexport for backup purposes in case of factory reset or hardware failure (will return a json-representation of all playlists (optional parameter is name, will return the desired playlist contents)
  • +
  • playlistimport (parameters : name of the playlist / optional: track or playlist name / optional: track or playlist uri obtained by playlistexport. Example : /target playlist name/track title/x-file-cifs://MacBook-Air-de-laurent-2/Music/iTunes/iTunes Media/Music/artist/album/track file.mp3 or for appending a playlist to another one : /target playlist name/source playlist name/file:///jffs/settings/savedqueues.rsq#2. Note : invalid local uri track paths will not trigger an error, the device currently does not return an UPnP error code for unmatched Controller files. +
    + to import a whole playlist : save the response generated by playlistexport/name as a file, and POST the file contents to playlistimport/name. Example: curl -v POST http://localhost:5005/playlistimport/playlist name/places.json -d @export.json --header "Content-Type: application/json". Be patient, large playlists can take time.
  • lockvolumes / unlockvolumes (experimental, will enforce the volume that was selected when locking!)
  • repeat (on/off)
  • shuffle (on/off)