1 | #!/usr/local/bin/node
|
2 |
|
3 | var util = require('util'),
|
4 | cli = require('cli'),
|
5 | sonos = require('sonos'),
|
6 | Q = require('q'),
|
7 | http = require('http'),
|
8 | _ = require('underscore'),
|
9 | xml2js = require('xml2js'),
|
10 | readline = require('readline');
|
11 |
|
12 |
|
13 | var rl = readline.createInterface({
|
14 | input: process.stdin,
|
15 | output: process.stdout
|
16 | });
|
17 |
|
18 | var device;
|
19 |
|
20 | cli.parse({
|
21 | play: ['p', 'Play whichever track is currently queued'],
|
22 | pause: ['ps', 'Pause playback'],
|
23 | search: ['s', 'Search an artist in Spotify\'s collection', 'string' ],
|
24 | addandplay: ['ap', 'Add a track or an album by spotify URI and play it', 'string'],
|
25 | mute: ['m', 'Mute'],
|
26 | unmute: ['um', 'Unmute'],
|
27 | browse: ['b', 'Browse the current list of enqueued tracks'],
|
28 | next: ['n', 'Plays the next track in the queue'],
|
29 | previous: ['pr', 'Plays the previous track in the queue'],
|
30 | current: ['c', 'Shows the track currently playing']
|
31 | });
|
32 |
|
33 | sonos.Sonos.prototype.browse = function(){
|
34 |
|
35 | var RENDERING_ENDPOINT = '/MediaServer/ContentDirectory/Control';
|
36 | var action = '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"';
|
37 | var body = '<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID>Q:0</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter>dc:title,res,dc:creator,upnp:artist,upnp:album,upnp:albumArtURI</Filter><StartingIndex>0</StartingIndex><RequestedCount>100</RequestedCount><SortCriteria></SortCriteria></u:Browse>';
|
38 |
|
39 | var defer = Q.defer();
|
40 |
|
41 | this.request(RENDERING_ENDPOINT, action, body, 'u:BrowseResponse', function(err, data){
|
42 |
|
43 | new xml2js.Parser().parseString(data[0].Result, function(err, didl) {
|
44 |
|
45 | var items = [];
|
46 |
|
47 | _.each(didl['DIDL-Lite'].item, function(item, index){
|
48 | items.push({"title": item['dc:title'][0], "artist": item['dc:creator'][0], "index": index+1});
|
49 | });
|
50 |
|
51 | defer.resolve(items);
|
52 | });
|
53 | });
|
54 |
|
55 | return defer.promise;
|
56 | };
|
57 |
|
58 | sonos.Sonos.prototype.enqueueSpotify = function(uri){
|
59 |
|
60 | var encodedUri = encodeURIComponent(uri);
|
61 | var isAlbum = uri.indexOf('spotify:album') > -1;
|
62 |
|
63 | var audioClass = isAlbum ? 'object.container.album.musicAlbum' : 'object.item.audioItem.musicTrack';
|
64 | var enqueuedURI = isAlbum ? 'x-rincon-cpcontainer:0004006c' + encodedUri : 'x-sonos-spotify:' + encodedUri;
|
65 | var code = isAlbum ? '0004006c' : '00030000';
|
66 |
|
67 | var RENDERING_ENDPOINT = '/MediaRenderer/AVTransport/Control';
|
68 | var action = '"urn:schemas-upnp-org:service:AVTransport:1#AddURIToQueue"';
|
69 | var body = '<u:AddURIToQueue xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"> \
|
70 | <InstanceID>0</InstanceID> \
|
71 | <EnqueuedURI>' + enqueuedURI + '</EnqueuedURI> \
|
72 | <EnqueuedURIMetaData> \
|
73 | <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" \
|
74 | xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" \
|
75 | xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="' + code + encodedUri + '" \
|
76 | restricted="true"><dc:title>America</dc:title> \
|
77 | <upnp:class>' + audioClass + '</upnp:class><desc id="cdudn" \
|
78 | nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">SA_RINCON2311_X_#Svc2311-0-Token</desc></item></DIDL-Lite> \
|
79 | </EnqueuedURIMetaData> \
|
80 | <DesiredFirstTrackNumberEnqueued>0</DesiredFirstTrackNumberEnqueued> \
|
81 | <EnqueueAsNext>0</EnqueueAsNext> \
|
82 | </u:AddURIToQueue>';
|
83 |
|
84 | var defer = Q.defer();
|
85 |
|
86 | this.request(RENDERING_ENDPOINT, action, body, 'u:AddURIToQueueResponse', function(err, data){
|
87 | var newIndex = _.reduce(data[0].FirstTrackNumberEnqueued, function(it, num){
|
88 | return parseInt(num);
|
89 | }, 0);
|
90 | defer.resolve(newIndex);
|
91 | });
|
92 |
|
93 | return defer.promise;
|
94 | }
|
95 |
|
96 | sonos.Sonos.prototype.seekTrackNr = function(nr){
|
97 | var RENDERING_ENDPOINT = '/MediaRenderer/AVTransport/Control';
|
98 | var action = '"urn:schemas-upnp-org:service:AVTransport:1#Seek"';
|
99 | var body = '<s:Body><u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><Unit>TRACK_NR</Unit><Target>' + nr + '</Target></u:Seek>';
|
100 |
|
101 | var defer = Q.defer();
|
102 |
|
103 | this.request(RENDERING_ENDPOINT, action, body, 'u:AddURIToQueueResponse', function(err, data){
|
104 | defer.resolve();
|
105 | });
|
106 | return defer.promise;
|
107 | }
|
108 |
|
109 |
|
110 | cli.main(function(args, options){
|
111 |
|
112 | var deferred = Q.defer();
|
113 |
|
114 | sonos.search(function(device){
|
115 | deferred.resolve(device);
|
116 | });
|
117 |
|
118 |
|
119 | deferred.promise.then(function(_device){
|
120 |
|
121 | device = _device;
|
122 |
|
123 | if(options.addandplay){
|
124 | device.enqueueSpotify(options.addandplay).then(function(nr){
|
125 | device.seekTrackNr(nr);
|
126 | }).then(function(){
|
127 | device.play(function(){
|
128 | process.exit(0);
|
129 | });
|
130 | });
|
131 | }
|
132 |
|
133 | if(options.search){
|
134 |
|
135 | getArtists(options.search).then(selectArtist);
|
136 | }
|
137 |
|
138 | if(options.play){
|
139 | device.play(function(){
|
140 | process.exit(0);
|
141 | });
|
142 | }
|
143 |
|
144 | if(options.pause){
|
145 | device.pause(function(){
|
146 | process.exit(0);
|
147 | })
|
148 | }
|
149 |
|
150 | if(options.mute){
|
151 | device.setMuted(true, function(){
|
152 | process.exit(0);
|
153 | });
|
154 | }
|
155 |
|
156 | if(options.unmute){
|
157 | device.setMuted(false, function(){
|
158 | process.exit(0);
|
159 | })
|
160 | }
|
161 |
|
162 | if(options.browse){
|
163 | device.browse().then(showQueue);
|
164 | }
|
165 |
|
166 | if(options.next){
|
167 | device.next(function(){
|
168 | process.exit(0);
|
169 | });
|
170 | }
|
171 |
|
172 | if(options.previous){
|
173 | device.previous(function(){
|
174 | process.exit(0);
|
175 | });
|
176 | }
|
177 |
|
178 | if(options.current){
|
179 | device.currentTrack(function(err, result){
|
180 | console.log(result.artist + ' - ' + result.title);
|
181 | process.exit(0);
|
182 | });
|
183 | }
|
184 | });
|
185 | });
|
186 |
|
187 | function showQueue(browseResults){
|
188 | console.log('');
|
189 | _.each(browseResults, function(item, index){
|
190 | console.log(index + '. ', item.artist + ' - ' + item.title);
|
191 | });
|
192 |
|
193 | rl.question('\nSelect a track for playback: ', function(answer){
|
194 | var index = parseInt(answer);
|
195 | device.seekTrackNr(browseResults[index].index).then(function(){
|
196 | device.play(function(err, data){
|
197 | process.exit(0);
|
198 | });
|
199 | })
|
200 | })
|
201 | }
|
202 |
|
203 | function selectArtist(searchResults){
|
204 | console.log('');
|
205 | _.each(searchResults.artists, function(artist, i){
|
206 | console.log(i + '. ' + artist.name);
|
207 | });
|
208 |
|
209 | rl.question("\nSelect an artist: ", function(answer){
|
210 | var index = parseInt(answer);
|
211 | var artist = searchResults.artists[index];
|
212 |
|
213 | var result = getAlbumsForArtist(artist);
|
214 | result.then(function(albums){
|
215 | selectAlbum(albums.artist.albums);
|
216 | });
|
217 | });
|
218 | }
|
219 |
|
220 | function selectAlbum(albums){
|
221 | console.log('');
|
222 | _.each(albums, function(it, i){
|
223 | console.log(i + '. ' + it.album.artist + ' - ' + it.album.name);
|
224 | });
|
225 |
|
226 | rl.question('\nSelect an album: ', function(answer){
|
227 | var index = parseInt(answer);
|
228 | var it = albums[index];
|
229 | var promise = getTracksForAlbum(it.album);
|
230 | promise.then(function(tracks){
|
231 | selectTrack(tracks.album);
|
232 | });
|
233 | })
|
234 | }
|
235 |
|
236 | function playTrack(track){
|
237 | device.enqueueSpotify(track).then(function(nr){
|
238 | device.seekTrackNr(nr).then(function(){
|
239 | device.play(function(err, data){
|
240 | process.exit(0);
|
241 | });
|
242 | })
|
243 | });
|
244 | }
|
245 |
|
246 | function selectTrack(album){
|
247 | console.log('');
|
248 | console.log('0. Play entire album');
|
249 | _.each(album.tracks, function(track, i){
|
250 | console.log(parseInt(i) + 1 + '. ' + album.artist + ' - ' + track.name);
|
251 | });
|
252 |
|
253 | rl.question("\nSelect a track: ", function(answer){
|
254 | var index = parseInt(answer);
|
255 | var track = index > 0 ? album.tracks[index].href : album.href;
|
256 |
|
257 | playTrack(track);
|
258 | });
|
259 | }
|
260 |
|
261 | function getTracksForAlbum(album){
|
262 | var def = Q.defer();
|
263 | var url = "http://ws.spotify.com/lookup/1/.json?extras=track&uri=" + album.href;
|
264 |
|
265 | http.get(url, function(res){
|
266 | handleResponse(res, def);
|
267 | }).on('error', function(e) {
|
268 | console.log("Got error: ", e);
|
269 | });
|
270 | return def.promise;
|
271 | }
|
272 |
|
273 | function getAlbumsForArtist(artist){
|
274 | var def = Q.defer();
|
275 | var url = "http://ws.spotify.com/lookup/1/.json?extras=album&uri=" + artist.href;
|
276 |
|
277 | http.get(url, function(res){
|
278 | handleResponse(res, def);
|
279 | }).on('error', function(e) {
|
280 | console.log("Got error: ", e);
|
281 | });
|
282 | return def.promise;
|
283 | }
|
284 |
|
285 |
|
286 | function handleResponse(res, promise){
|
287 | var body = '';
|
288 |
|
289 | res.on('data', function(chunk) {
|
290 | body += chunk;
|
291 | });
|
292 |
|
293 | res.on('end', function() {
|
294 | var resp = JSON.parse(body)
|
295 | promise.resolve(resp);
|
296 | });
|
297 | }
|
298 |
|
299 | function getArtists(query){
|
300 | var def = Q.defer();
|
301 |
|
302 | var url = "http://ws.spotify.com/search/1/artist.json?q=" + query;
|
303 | http.get(url, function(res) {
|
304 | handleResponse(res, def);
|
305 | }).on('error', function(e) {
|
306 | console.log("Got error: ", e);
|
307 | });
|
308 |
|
309 | return def.promise;
|
310 | }
|