├── .gitignore ├── History.md ├── LICENSE ├── README.md ├── example ├── albumArt.js ├── genre.js ├── play.js ├── playAlbum.js ├── playPreview.js ├── playlist.js ├── rootlist.js ├── search.js ├── similarTracks.js └── starredPlaylist.js ├── index.js ├── lib ├── album.js ├── artist.js ├── base62.js ├── error.js ├── image.js ├── restriction.js ├── schemas.js ├── spotify.js ├── track.js └── util.js ├── package.json └── proto ├── bartender.desc ├── bartender.proto ├── mercury.desc ├── mercury.proto ├── metadata.desc ├── metadata.proto ├── playlist4changes.desc ├── playlist4changes.proto ├── playlist4content.desc ├── playlist4content.proto ├── playlist4issues.desc ├── playlist4issues.proto ├── playlist4meta.desc ├── playlist4meta.proto ├── playlist4ops.desc ├── playlist4ops.proto ├── playlist4service.desc ├── playlist4service.proto ├── pubsub.desc ├── pubsub.proto ├── toplist.desc └── toplist.proto /.gitignore: -------------------------------------------------------------------------------- 1 | login.js 2 | node_modules 3 | ?.js 4 | *.mp3 5 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 1.3.0 / 2014-12-11 3 | ================== 4 | 5 | * spotify: update comment to explain the web service 6 | * spotify: use `ping-pong.spotify.nodestuff.net` web service for sendPong (#98, @denysvitali) 7 | * spotify: set "User-Agent" and "Origin" on WebSocket connection (#96, @denysvitali) 8 | * add LICENSE file 9 | * README: add License section 10 | * s/NodeJS/Node.js/ 11 | * example: add an example of getting Artist "genre" 12 | 13 | 1.2.0 / 2014-04-26 14 | ================== 15 | 16 | * spotify: new flash key (#81, @brandtabbott) 17 | * spotify: added functionality to get a users starred playlist with example 18 | 19 | 1.1.1 / 2014-04-02 20 | ================== 21 | 22 | * example: switch 23 | * spotify: added `Spotify.Web.App.initialize()` noop Function (#76) 24 | 25 | 1.1.0 / 2014-02-24 26 | ================== 27 | 28 | * spotify: handle `sp/ping_flash2` commands 29 | * spotify: emit "open" and "close" events 30 | * spotify: fix lint 31 | * spotify: use client version from auth response 32 | 33 | 1.0.0 / 2014-01-08 34 | ================== 35 | 36 | * spotify: update user agent and send window size log event on connection (#60) 37 | * spotify: tag function bugfix 38 | * proto: update schemas module api, add protobufjs support 39 | * proto: add pubsub and playlist4service 40 | * proto: Update metadata protobuf with added Catalogue SHUFFLE enum 41 | * bugfix for multiGet 42 | * spotify: use http status message in error when none is defined 43 | * spotify: add similar(uri, fn) method 44 | * proto: add bartender schemas 45 | * track: cache previewUrl from getter in playPreview method and emit error on stream in nextTick 46 | * track: emit error if preview stream does not respond with 200 status code 47 | * spotify: fix bug which prevented facebook and anonymous login from working 48 | * spotify: add facebook and anonymous login 49 | * spotify: improve authentication and include trackingId 50 | * spotify: add XXX comment... 51 | * spotify: more robust `has()` function 52 | * track: emit error if stream does not respond with 200 status code 53 | * spotify: use correct number argument for sending SUB/UNSUB MercuryRequests 54 | * spotify: make callback of sendProtobufRequest optional 55 | * track: add previewUrl getter and playPreview method 56 | * example: add preview playing example 57 | * proto: update metadata fields 58 | * spotify: use SelectedListContent instead of ListDump for playlist and rootlist responseSchemas 59 | * spotify: refactor MercuryRequest code into sendProtobufRequest function 60 | 61 | 0.1.3 / 2013-06-16 62 | ================== 63 | 64 | * proto: change contentType to bytes format 65 | * spotify: support MercuryMultiGetRequest in `get()` function 66 | * util: fix comment 67 | * spotify: fix lint 68 | 69 | 0.1.2 / 2013-05-18 70 | ================== 71 | 72 | * spotify: use AP resolver to connect to websocket server (GH-13) @adammw 73 | 74 | 0.1.1 / 2013-03-22 75 | ================== 76 | 77 | * error: add error code for non-premium accounts 78 | 79 | 0.1.0 / 2013-03-09 80 | ================== 81 | 82 | * spotify: implement real error handling 83 | * spotify: ignore "login_complete" message commands 84 | * spotify: throw an error on unhandled "message" commands 85 | * error: add SpotifyError class 86 | * spotify: make rootlist() user default to yourself 87 | * track: send the User-Agent in .play() 88 | * Added `rootlist()` function to get a user's stored playlists 89 | 90 | 0.0.2 / 2013-02-07 91 | ================== 92 | 93 | * Fix CSRF token retrieval 94 | * A whole lot of API changes... too much to list... 95 | 96 | 0.0.1 / 2013-01-12 97 | ================== 98 | 99 | * Initial release: 100 | * getting Artist/Album/Track metadata works 101 | * getting MP3 playback URL for a Track works 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014 Nathan Rajlich 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-spotify-web 2 | ================ 3 | ### Node.js implementation of the Spotify Web protocol 4 | 5 | This module implements the "Spotify Web" WebSocket protocol that is used on 6 | Spotify's [Web UI](http://play.spotify.com). 7 | 8 | This module is heavily inspired by the original open-source Python implementation: 9 | [Hexxeh/spotify-websocket-api](https://github.com/Hexxeh/spotify-websocket-api). 10 | 11 | Installation 12 | ------------ 13 | 14 | ``` bash 15 | $ npm install spotify-web 16 | ``` 17 | 18 | 19 | Example 20 | ------- 21 | 22 | Here's an example of logging in to the Spotify server and creating a session. Then 23 | requesting the metadata for a given track URI, and playing the track audio file 24 | through the speakers: 25 | 26 | ``` javascript 27 | var lame = require('lame'); 28 | var Speaker = require('speaker'); 29 | var Spotify = require('spotify-web'); 30 | var uri = process.argv[2] || 'spotify:track:6tdp8sdXrXlPV6AZZN2PE8'; 31 | 32 | // Spotify credentials... 33 | var username = process.env.USERNAME; 34 | var password = process.env.PASSWORD; 35 | 36 | Spotify.login(username, password, function (err, spotify) { 37 | if (err) throw err; 38 | 39 | // first get a "Track" instance from the track URI 40 | spotify.get(uri, function (err, track) { 41 | if (err) throw err; 42 | console.log('Playing: %s - %s', track.artist[0].name, track.name); 43 | 44 | // play() returns a readable stream of MP3 audio data 45 | track.play() 46 | .pipe(new lame.Decoder()) 47 | .pipe(new Speaker()) 48 | .on('finish', function () { 49 | spotify.disconnect(); 50 | }); 51 | 52 | }); 53 | }); 54 | ``` 55 | 56 | See the `example` directory for some more example code. 57 | 58 | 59 | API 60 | --- 61 | 62 | TODO: document! 63 | 64 | 65 | License 66 | ------- 67 | 68 | (The MIT License) 69 | 70 | Copyright (c) 2013-2014 Nathan Rajlich <nathan@tootallnate.net> 71 | 72 | Permission is hereby granted, free of charge, to any person obtaining 73 | a copy of this software and associated documentation files (the 74 | 'Software'), to deal in the Software without restriction, including 75 | without limitation the rights to use, copy, modify, merge, publish, 76 | distribute, sublicense, and/or sell copies of the Software, and to 77 | permit persons to whom the Software is furnished to do so, subject to 78 | the following conditions: 79 | 80 | The above copyright notice and this permission notice shall be 81 | included in all copies or substantial portions of the Software. 82 | 83 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 84 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 85 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 86 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 87 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 88 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 89 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 90 | -------------------------------------------------------------------------------- /example/albumArt.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Prints out the HTTP uris for the different album cover sizes for the specified 4 | * Album. 5 | */ 6 | 7 | var Spotify = require('../'); 8 | var login = require('../login'); 9 | 10 | // determine the URI to play, ensure it's an "album" URI 11 | var uri = process.argv[2] || 'spotify:album:7u6zL7kqpgLPISZYXNTgYk'; 12 | var type = Spotify.uriType(uri); 13 | if ('album' != type) { 14 | throw new Error('Must pass a "album" URI, got ' + JSON.stringify(type)); 15 | } 16 | 17 | Spotify.login(login.username, login.password, function (err, spotify) { 18 | if (err) throw err; 19 | 20 | // first get a "Album" instance from the album URI 21 | spotify.get(uri, function (err, album) { 22 | if (err) throw err; 23 | console.log('Album Art URIs for "%s - %s"', album.artist[0].name, album.name); 24 | 25 | // print out the HTTP uris for each image size of the album covers 26 | album.cover.forEach(function (image) { 27 | console.log('%s: %s', image.size, image.uri); 28 | }); 29 | 30 | spotify.disconnect(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /example/genre.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Print out the "genre" of a Track. 4 | * 5 | * This is a good example because it shows how to "load" all the metadata 6 | * for the `artist` field of the Track instance by calling `track.artist.get()`. 7 | */ 8 | 9 | var Spotify = require('../'); 10 | var login = require('../login'); 11 | 12 | var uri = process.argv[2] || 'spotify:track:1MjeP8lwmaNGqGbmS9IrEc'; 13 | 14 | // initiate the Spotify session 15 | Spotify.login(login.username, login.password, function (err, spotify) { 16 | if (err) throw err; 17 | 18 | // first get a "Track" instance from the track URI 19 | spotify.get(uri, function (err, track) { 20 | if (err) throw err; 21 | track.artist[0].get(function (err, artist) { 22 | console.log(artist.genre); 23 | spotify.disconnect(); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /example/play.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Example script that retrieves the specified Track through Spotify, then decodes 4 | * the MP3 data through node-lame, and fianally plays the decoded PCM data through 5 | * the speakers using node-speaker. 6 | */ 7 | 8 | var Spotify = require('../'); 9 | var login = require('../login'); 10 | var lame = require('lame'); 11 | var Speaker = require('speaker'); 12 | 13 | // determine the URI to play, ensure it's a "track" URI 14 | var uri = process.argv[2] || 'spotify:track:1ZBAee0xUblF4zhfefY0W1'; 15 | var type = Spotify.uriType(uri); 16 | if ('track' != type) { 17 | throw new Error('Must pass a "track" URI, got ' + JSON.stringify(type)); 18 | } 19 | 20 | // initiate the Spotify session 21 | Spotify.login(login.username, login.password, function (err, spotify) { 22 | if (err) throw err; 23 | 24 | // first get a "Track" instance from the track URI 25 | spotify.get(uri, function (err, track) { 26 | if (err) throw err; 27 | console.log('Playing: %s - %s', track.artist[0].name, track.name); 28 | 29 | track.play() 30 | .pipe(new lame.Decoder()) 31 | .pipe(new Speaker()) 32 | .on('finish', function () { 33 | spotify.disconnect(); 34 | }); 35 | 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /example/playAlbum.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Example script that retrieves the specified Album through Spotify, then decodes 4 | * the MP3 data through node-lame, and finally plays the decoded PCM data through 5 | * the speakers using node-speaker. 6 | */ 7 | 8 | var Spotify = require('../'); 9 | var login = require('../login'); 10 | var lame = require('lame'); 11 | var Speaker = require('speaker'); 12 | 13 | // determine the URI to play, ensure it's an "album" URI 14 | var uri = process.argv[2] || 'spotify:album:7u6zL7kqpgLPISZYXNTgYk'; 15 | var type = Spotify.uriType(uri); 16 | if ('album' != type) { 17 | throw new Error('Must pass a "album" URI, got ' + JSON.stringify(type)); 18 | } 19 | 20 | Spotify.login(login.username, login.password, function (err, spotify) { 21 | if (err) throw err; 22 | 23 | // first get a "Album" instance from the album URI 24 | spotify.get(uri, function (err, album) { 25 | if (err) throw err; 26 | 27 | // first get the Track instances for each disc 28 | var tracks = []; 29 | album.disc.forEach(function (disc) { 30 | if (!Array.isArray(disc.track)) return; 31 | tracks.push.apply(tracks, disc.track); 32 | }); 33 | console.log(tracks.map(function(t){ return t.uri; })); 34 | 35 | function next () { 36 | var track = tracks.shift(); 37 | if (!track) return spotify.disconnect(); 38 | 39 | track.get(function (err) { 40 | if (err) throw err; 41 | console.log('Playing: %s - %s', track.artist[0].name, track.name); 42 | 43 | track.play() 44 | .on('error', function (err) { 45 | console.error(err.stack || err); 46 | next(); 47 | }) 48 | .pipe(new lame.Decoder()) 49 | .pipe(new Speaker()) 50 | .on('finish', next); 51 | }); 52 | } 53 | next(); 54 | 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /example/playPreview.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Example script that retrieves a preview of the specified Track through Spotify, 4 | * then decodes the MP3 data through node-lame, and fianally plays the decoded PCM 5 | * data through the speakers using node-speaker. 6 | */ 7 | 8 | var Spotify = require('../'); 9 | var login = require('../login'); 10 | var lame = require('lame'); 11 | var Speaker = require('speaker'); 12 | 13 | // determine the URI to play, ensure it's a "track" URI 14 | var uri = process.argv[2] || 'spotify:track:6tdp8sdXrXlPV6AZZN2PE8'; 15 | var type = Spotify.uriType(uri); 16 | if ('track' != type) { 17 | throw new Error('Must pass a "track" URI, got ' + JSON.stringify(type)); 18 | } 19 | 20 | // initiate the Spotify session 21 | Spotify.login(login.username, login.password, function (err, spotify) { 22 | if (err) throw err; 23 | 24 | // first get a "Track" instance from the track URI 25 | spotify.get(uri, function (err, track) { 26 | if (err) throw err; 27 | console.log('Playing 30 second preview of: %s - %s', track.artist[0].name, track.name); 28 | 29 | track.playPreview() 30 | .pipe(new lame.Decoder()) 31 | .pipe(new Speaker()) 32 | .on('finish', function () { 33 | spotify.disconnect(); 34 | }); 35 | 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /example/playlist.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Gets a `Playlist` instance based off of the given Spotify playlist URI. 4 | */ 5 | 6 | var Spotify = require('../'); 7 | var login = require('../login'); 8 | 9 | // determine the playlist URI to use, ensure it's a "playlist" URI 10 | var uri = process.argv[2] || 'spotify:user:123156851:playlist:6OvyUNc4ELX0b6OOhxfjRt'; 11 | var type = Spotify.uriType(uri); 12 | if ('playlist' != type) { 13 | throw new Error('Must pass a "playlist" URI, got ' + JSON.stringify(type)); 14 | } 15 | 16 | // initiate the Spotify session 17 | Spotify.login(login.username, login.password, function (err, spotify) { 18 | if (err) throw err; 19 | 20 | spotify.playlist(uri, function (err, playlist) { 21 | if (err) throw err; 22 | 23 | console.log(playlist.contents); 24 | 25 | spotify.disconnect(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /example/rootlist.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Gets a user's "rootlist" (array of playlist IDs). 4 | */ 5 | 6 | var Spotify = require('../'); 7 | var login = require('../login'); 8 | 9 | // determine which Users rootlist to select 10 | var user = process.argv[2] || login.username; 11 | 12 | // initiate the Spotify session 13 | Spotify.login(login.username, login.password, function (err, spotify) { 14 | if (err) throw err; 15 | 16 | // get the selected user's rootlist (playlist names) 17 | spotify.rootlist( user, function (err, rootlist) { 18 | if (err) throw err; 19 | 20 | console.log(rootlist.contents); 21 | 22 | spotify.disconnect(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /example/search.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Example that executes a Spotify "Search" and parses the XML results using 4 | * node-xml2js. 5 | */ 6 | 7 | var xml2js = require('xml2js'); 8 | var Spotify = require('../'); 9 | var login = require('../login'); 10 | var superagent = require('superagent'); 11 | var query = process.argv[2] || 'guitar gently weeps'; 12 | 13 | Spotify.login(login.username, login.password, function (err, spotify) { 14 | if (err) throw err; 15 | 16 | spotify.search(query, function (err, xml) { 17 | if (err) throw err; 18 | spotify.disconnect(); 19 | 20 | var parser = new xml2js.Parser(); 21 | parser.on('end', function (data) { 22 | console.log(JSON.stringify(data, null, 2)); 23 | }); 24 | parser.parseString(xml); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /example/similarTracks.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Example script that retrieves similar tracks 4 | */ 5 | 6 | var Spotify = require('../'); 7 | var login = require('../login'); 8 | 9 | // determine the URI to play, ensure it's a "track" URI 10 | var uri = process.argv[2] || 'spotify:track:6tdp8sdXrXlPV6AZZN2PE8'; 11 | var type = Spotify.uriType(uri); 12 | if ('track' != type) { 13 | throw new Error('Must pass a "track" URI, got ' + JSON.stringify(type)); 14 | } 15 | 16 | // initiate the Spotify session 17 | Spotify.login(login.username, login.password, function (err, spotify) { 18 | if (err) throw err; 19 | 20 | // first get a "Track" instance from the track URI 21 | spotify.get(uri, function (err, track) { 22 | if (err) throw err; 23 | console.log('Similar Tracks to %s - %s', track.artist[0].name, track.name); 24 | 25 | // request similar tracks - and display the artist and track name for each suggested track 26 | spotify.similar(uri, function (err, similar) { 27 | if (err) throw err; 28 | 29 | similar.stories.forEach(function(story) { 30 | var track = story.recommendedItem; 31 | var album = track.parent; 32 | var artist = album.parent; 33 | console.log(' * %s - %s [%s]', artist.displayName, track.displayName, track.uri); 34 | }); 35 | 36 | spotify.disconnect(); 37 | }); 38 | }); 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /example/starredPlaylist.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Gets a user's starred playlist (array of track URI's). 4 | */ 5 | 6 | var Spotify = require('../'); 7 | var login = require('../login'); 8 | 9 | // determine which Users starred Playlist to select 10 | var user = process.argv[2] || login.username; 11 | 12 | // initiate the Spotify session 13 | Spotify.login(login.username, login.password, function (err, spotify) { 14 | if (err) throw err; 15 | 16 | // get the selected user's rootlist (playlist names) 17 | spotify.starred( user , function (err, starred) { 18 | if (err) throw err; 19 | 20 | console.log(starred.contents); 21 | 22 | spotify.disconnect(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/spotify'); 2 | -------------------------------------------------------------------------------- /lib/album.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var util = require('./util'); 7 | var Album = require('./schemas').build('metadata', 'Album'); 8 | var debug = require('debug')('spotify-web:album'); 9 | 10 | /** 11 | * Module exports. 12 | */ 13 | 14 | exports = module.exports = Album; 15 | 16 | /** 17 | * Album URI getter. 18 | */ 19 | 20 | Object.defineProperty(Album.prototype, 'uri', { 21 | get: function () { 22 | return util.gid2uri('album', this.gid); 23 | }, 24 | enumerable: true, 25 | configurable: true 26 | }); 27 | 28 | /** 29 | * Loads all the metadata for this Album instance. Useful for when you get an only 30 | * partially filled Album instance from an Album instance for example. 31 | * 32 | * @param {Function} fn callback function 33 | * @api public 34 | */ 35 | 36 | Album.prototype.get = 37 | Album.prototype.metadata = function (fn) { 38 | if (this._loaded) { 39 | // already been loaded... 40 | debug('album already loaded'); 41 | return process.nextTick(fn.bind(null, null, this)); 42 | } 43 | var spotify = this._spotify; 44 | var self = this; 45 | spotify.get(this.uri, function (err, album) { 46 | if (err) return fn(err); 47 | // extend this Album instance with the new one's properties 48 | Object.keys(album).forEach(function (key) { 49 | if (!self.hasOwnProperty(key)) { 50 | self[key] = album[key]; 51 | } 52 | }); 53 | fn(null, self); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /lib/artist.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var util = require('./util'); 7 | var Artist = require('./schemas').build('metadata', 'Artist'); 8 | var debug = require('debug')('spotify-web:artist'); 9 | 10 | /** 11 | * Module exports. 12 | */ 13 | 14 | exports = module.exports = Artist; 15 | 16 | /** 17 | * Artist URI getter. 18 | */ 19 | 20 | Object.defineProperty(Artist.prototype, 'uri', { 21 | get: function () { 22 | return util.gid2uri('artist', this.gid); 23 | }, 24 | enumerable: true, 25 | configurable: true 26 | }); 27 | 28 | /** 29 | * Loads all the metadata for this Artist instance. Useful for when you get an only 30 | * partially filled Artist instance from an Album instance for example. 31 | * 32 | * @param {Function} fn callback function 33 | * @api public 34 | */ 35 | 36 | Artist.prototype.get = 37 | Artist.prototype.metadata = function (fn) { 38 | if (this._loaded) { 39 | // already been loaded... 40 | debug('artist already loaded'); 41 | return process.nextTick(fn.bind(null, null, this)); 42 | } 43 | var spotify = this._spotify; 44 | var self = this; 45 | spotify.get(this.uri, function (err, artist) { 46 | if (err) return fn(err); 47 | // extend this Artist instance with the new one's properties 48 | Object.keys(artist).forEach(function (key) { 49 | if (!self.hasOwnProperty(key)) { 50 | self[key] = artist[key]; 51 | } 52 | }); 53 | fn(null, self); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /lib/base62.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * From Spotify Web client code. 4 | * See: https://gist.github.com/4463921#file-spotify-web-client-js-L3503-L3546 5 | */ 6 | 7 | module.exports = function () { 8 | function g(a, b, c) { 9 | for (var d = [0], f = [1], g = 0; g < a.length; ++g) { 10 | for (var k = d, p = f, q = a[g], s = c, t = 0, y = 0; y < p.length; ++y) t = ~~k[y] + p[y] * q + t, k[y] = t % s, t = ~~ (t / s); 11 | for (; t;) t = ~~k[y] + t, k[y] = t % s, t = ~~ (t / s), ++y; 12 | k = f; 13 | p = b; 14 | q = c; 15 | for (s = y = 0; s < k.length; ++s) y = k[s] * p + y, k[s] = y % q, y = ~~ (y / q); 16 | for (; y;) k.push(y % q), y = ~~ (y / q) 17 | } 18 | return d 19 | } 20 | function f(a, b) { 21 | for (var c = 0, d = []; c < a.length; ++c) d.push(b[a[c]]); 22 | return d.reverse() 23 | } 24 | function d(a, b) { 25 | for (; a.length < b;) a.push(0); 26 | return a 27 | } 28 | for (var b = {}, c = {}, a = 0; a < 62; ++a) c["0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" [a]] = a; 29 | for (a = 0; a < 16; ++a) b["0123456789abcdef" [a]] = a; 30 | for (a = 0; a < 16; ++a) b["0123456789ABCDEF" [a]] = a; 31 | return { 32 | fromBytes: function (a, b) { 33 | var c = g(a.slice(0).reverse(), 256, 62); 34 | return f(d(c, b), "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ").join("") 35 | }, 36 | toBytes: function (a, b) { 37 | var l = g(f(a, c), 62, 256); 38 | return d(l, b).reverse() 39 | }, 40 | toHex: function (a, b) { 41 | var l = g(f(a, c), 62, 16); 42 | return f(d(l, b), "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ").join("") 43 | }, 44 | fromHex: function (a, c) { 45 | var l = g(f(a, b), 16, 62); 46 | return f(d(l, 47 | c), "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ").join("") 48 | } 49 | } 50 | }(); 51 | -------------------------------------------------------------------------------- /lib/error.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var inherits = require('util').inherits; 7 | 8 | /** 9 | * Error "domains". 10 | */ 11 | 12 | var domains = { 13 | 11: 'AuthorizationError', 14 | 12: 'TrackError', 15 | 13: 'HermesError', 16 | 14: 'HermesServiceError' 17 | }; 18 | 19 | /** 20 | * Error "codes". 21 | */ 22 | 23 | var codes = { 24 | 0: 'Account subscription status not Spotify Premium', 25 | 1: 'Failed to send to backend', 26 | 8: 'Rate limited', 27 | 408: 'Timeout', 28 | 429: 'Too many requests' 29 | }; 30 | 31 | /** 32 | * Module exports. 33 | */ 34 | 35 | module.exports = SpotifyError; 36 | 37 | /** 38 | * Spotify error class. 39 | * 40 | * Sample `err` objects: 41 | * 42 | * [ 11, 1, 'Invalid user' ] 43 | * [ 12, 8, '' ] 44 | * [ 14, 408, '' ] 45 | * [ 14, 429, '' ] 46 | */ 47 | 48 | function SpotifyError (err) { 49 | this.domain = err[0] || 0; 50 | this.code = err[1] || 0; 51 | this.description = err[2] || ''; 52 | this.data = err[3] || null; 53 | 54 | // Error impl 55 | this.name = domains[this.domain]; 56 | var msg = codes[this.code]; 57 | if (this.description) msg += ' (' + this.description + ')'; 58 | this.message = msg; 59 | Error.captureStackTrace(this, SpotifyError); 60 | } 61 | inherits(SpotifyError, Error); 62 | -------------------------------------------------------------------------------- /lib/image.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var gid2id = require('./util').gid2id; 7 | var Image = require('./schemas').build('metadata','Image'); 8 | 9 | /** 10 | * Module exports. 11 | */ 12 | 13 | exports = module.exports = Image; 14 | 15 | /** 16 | * Image HTTP link getter. 17 | */ 18 | 19 | Object.defineProperty(Image.prototype, 'uri', { 20 | get: function () { 21 | var spotify = this._spotify; 22 | var base = spotify.sourceUrls[this.size]; 23 | return base + gid2id(this.fileId); 24 | }, 25 | enumerable: true, 26 | configurable: true 27 | }); 28 | -------------------------------------------------------------------------------- /lib/restriction.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var util = require('./util'); 7 | var Restriction = require('./schemas').build('metadata','Restriction'); 8 | 9 | /** 10 | * Module exports. 11 | */ 12 | 13 | exports = module.exports = Restriction; 14 | 15 | /** 16 | * Allowed countries 2-letter code Array getter. 17 | */ 18 | 19 | Object.defineProperty(Restriction.prototype, 'allowed', { 20 | get: function () { 21 | if (!this.countriesAllowed) return []; 22 | return this.countriesAllowed.match(/[A-Z]{2}/g); 23 | }, 24 | enumerable: true, 25 | configurable: true 26 | }); 27 | 28 | /** 29 | * Forbidden countries 2-letter code Array getter. 30 | */ 31 | 32 | Object.defineProperty(Restriction.prototype, 'forbidden', { 33 | get: function () { 34 | if (!this.countriesForbidden) return []; 35 | return this.countriesForbidden.match(/[A-Z]{2}/g); 36 | }, 37 | enumerable: true, 38 | configurable: true 39 | }); 40 | -------------------------------------------------------------------------------- /lib/schemas.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var fs = require('fs'); 7 | var path = require('path'); 8 | var debug = require('debug')('spotify-web:schemas'); 9 | 10 | try { 11 | var protobuf = require('protobuf'); 12 | var protobufjs = null; 13 | } catch(e) { 14 | var protobuf = null; 15 | var protobufjs = require('protobufjs'); 16 | } 17 | 18 | /** 19 | * Protocol Buffer schemas. 20 | */ 21 | 22 | var library = (protobuf) ? 'protobuf' : 'protobufjs'; 23 | 24 | var protoPath = path.resolve(__dirname, '..', 'proto'); 25 | 26 | var packageMapping = { 27 | bartender: 'bartender', 28 | mercury: 'mercury', 29 | metadata: 'metadata', 30 | playlist4: "playlist4changes,playlist4content,playlist4issues,playlist4meta,playlist4ops,playlist4service".split(","), 31 | pubsub: 'pubsub', 32 | toplist: 'toplist' 33 | }; 34 | var packageCache = module.exports = {}; 35 | 36 | var loadPackage = function(id) { 37 | // Use cached packages 38 | if (packageCache.hasOwnProperty(id)) { 39 | debug('loadPackage(%j) [%s, cached]', id, library); 40 | return packageCache[id]; 41 | } else { 42 | debug('loadPackage(%j) [%s]', id, library); 43 | } 44 | 45 | // Load the mapping of packages to proto files 46 | var mapping = packageMapping[id]; 47 | if (!mapping) { 48 | debug('No mapping for %s, assuming single proto file', id) 49 | mapping = id; 50 | } 51 | if (!Array.isArray(mapping)) mapping = [mapping]; 52 | 53 | if (protobuf) { 54 | // Protobuf works with compiled .desc files rather than .proto files and doesn't support imports 55 | // Therefore, we load in each schema into an array and check each of them when looking for a message object 56 | packageCache[id]= mapping.map(function(schema) { 57 | return new protobuf.Schema(fs.readFileSync(path.resolve(protoPath, schema + '.desc'))); 58 | }); 59 | return packageCache[id]; 60 | 61 | } else { // protobufjs 62 | // Generate a proto string with import statements 63 | var proto = mapping.map(function(schema) { 64 | return 'import "' + schema + '.proto";'; 65 | }).join('\n'); 66 | 67 | // Load the generated import file, and return the built package 68 | var builder = protobufjs.protoFromString(proto, new protobufjs.Builder(), {root: protoPath, file: id+'_generated_import.proto'}); 69 | packageCache[id] = builder.build("spotify." + id + ".proto"); 70 | return packageCache[id]; 71 | } 72 | }; 73 | 74 | var loadMessage = module.exports.build = function(packageId, messageId) { 75 | debug('loadMessage(%j, %j) [%s]', packageId, messageId, library); 76 | 77 | var packageObj = loadPackage(packageId); 78 | var messageObj = null; 79 | 80 | if (protobuf) { 81 | var identifier = "spotify." + packageId + ".proto." + messageId; 82 | 83 | // Loop though each loaded schema looking for the message 84 | for (var i = 0; i < packageObj.length; i++) { 85 | messageObj = packageObj[i][identifier]; 86 | if (messageObj) break; 87 | } 88 | 89 | } else { // protobufjs 90 | // Load the message directly 91 | messageObj = packageObj[messageId]; 92 | 93 | // Add wrapper functions 94 | messageObj.parse = function protobufjs_parse_wrapper() { 95 | debug('protobufjs_parse_wrapper(%j)', arguments); 96 | 97 | // Call the message object decode function with the arguments 98 | var message = messageObj.decode.apply(null, arguments); 99 | // Convert the object keys to camel case, ByteBuffers to Node Buffers and then return the parsed object 100 | return convertByteBuffersToNodeBuffers(reCamelCase(message)); 101 | } 102 | messageObj.serialize = function protobufjs_serialize_wrapper() { 103 | debug('protobufjs_serialize_wrapper(%j)', arguments); 104 | 105 | // Convert any camel cased properties in the arguments to underscored properties 106 | Array.prototype.map.call(arguments, function (argument) { 107 | return deCamelCase(argument); 108 | }); 109 | 110 | // Call the message object constructor with the modified arguments 111 | var message = Object.create(messageObj.prototype); 112 | message = messageObj.apply(message, arguments) || message; 113 | 114 | // Return the node Buffer object containing the serialised data 115 | return message.encodeNB(); 116 | }; 117 | } 118 | 119 | return messageObj; 120 | }; 121 | 122 | var deCamelCase = function(obj) { 123 | if (obj === null || 'object' != typeof obj) return obj; 124 | Object.keys(obj).forEach(function(old_key) { 125 | var new_key = old_key.replace(/([A-Z])/g, function($1){return "_"+$1.toLowerCase();}); 126 | obj[new_key] = deCamelCase(obj[old_key]); 127 | if (new_key != old_key) delete obj[old_key]; 128 | }); 129 | return obj; 130 | }; 131 | 132 | var reCamelCase = function(obj) { 133 | if (obj === null || 'object' != typeof obj) return obj; 134 | Object.keys(obj).forEach(function(old_key) { 135 | var new_key = old_key.replace(/(\_[a-z])/g, function($1){return $1.toUpperCase().replace('_','');}); 136 | obj[new_key] = reCamelCase(obj[old_key]); 137 | if (new_key != old_key) delete obj[old_key]; 138 | }); 139 | return obj; 140 | }; 141 | 142 | var convertByteBuffersToNodeBuffers = function(obj) { 143 | if (obj === null || 'object' != typeof obj) return obj; 144 | Object.keys(obj).forEach(function(key) { 145 | // attempt to detect a bytebuffer object 146 | if (obj[key] && obj[key].hasOwnProperty('array') && obj[key].hasOwnProperty('view')) { 147 | obj[key] = obj[key].toBuffer(); 148 | } else { 149 | obj[key] = convertByteBuffersToNodeBuffers(obj[key]); 150 | } 151 | }); 152 | return obj; 153 | }; 154 | -------------------------------------------------------------------------------- /lib/spotify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | var vm = require('vm'); 6 | var util = require('./util'); 7 | var http = require('http'); 8 | var WebSocket = require('ws'); 9 | var cheerio = require('cheerio'); 10 | var schemas = require('./schemas'); 11 | var superagent = require('superagent'); 12 | var inherits = require('util').inherits; 13 | var SpotifyError = require('./error'); 14 | var EventEmitter = require('events').EventEmitter; 15 | var debug = require('debug')('spotify-web'); 16 | var pkg = require('../package.json'); 17 | 18 | /** 19 | * Module exports. 20 | */ 21 | 22 | module.exports = Spotify; 23 | 24 | /** 25 | * Protocol Buffer types. 26 | */ 27 | 28 | var MercuryMultiGetRequest = schemas.build('mercury','MercuryMultiGetRequest'); 29 | var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); 30 | var MercuryRequest = schemas.build('mercury','MercuryRequest'); 31 | 32 | var Artist = require('./artist'); 33 | var Album = require('./album'); 34 | var Track = require('./track'); 35 | var Image = require('./image'); 36 | require('./restriction'); 37 | 38 | var SelectedListContent = schemas.build('playlist4','SelectedListContent'); 39 | 40 | var StoryRequest = schemas.build('bartender','StoryRequest'); 41 | var StoryList = schemas.build('bartender','StoryList'); 42 | 43 | /** 44 | * Re-export all the `util` functions. 45 | */ 46 | 47 | Object.keys(util).forEach(function (key) { 48 | Spotify[key] = util[key]; 49 | }); 50 | 51 | /** 52 | * Create instance and login convenience function. 53 | * 54 | * @param {String} un username 55 | * @param {String} pw password 56 | * @param {Function} fn callback function 57 | * @api public 58 | */ 59 | 60 | Spotify.login = function (un, pw, fn) { 61 | if (!fn) fn = function () {}; 62 | var spotify = new Spotify(); 63 | spotify.login(un, pw, function (err) { 64 | if (err) return fn(err); 65 | fn.call(spotify, null, spotify); 66 | }); 67 | return spotify; 68 | }; 69 | 70 | /** 71 | * Spotify Web base class. 72 | * 73 | * @api public 74 | */ 75 | 76 | function Spotify () { 77 | if (!(this instanceof Spotify)) return new Spotify(); 78 | EventEmitter.call(this); 79 | 80 | this.seq = 0; 81 | this.heartbeatInterval = 18E4; // 180s, from "spotify.web.client.js" 82 | this.agent = superagent.agent(); 83 | this.connected = false; // true after the WebSocket "connect" message is sent 84 | this._callbacks = Object.create(null); 85 | 86 | this.authServer = 'play.spotify.com'; 87 | this.authUrl = '/xhr/json/auth.php'; 88 | this.landingUrl = '/'; 89 | this.userAgent = 'Mozilla/5.0 (Chrome/13.37 compatible-ish) spotify-web/' + pkg.version; 90 | 91 | // base URLs for Image files like album artwork, artist prfiles, etc. 92 | // these values taken from "spotify.web.client.js" 93 | this.sourceUrl = 'https://d3rt1990lpmkn.cloudfront.net'; 94 | this.sourceUrls = { 95 | tiny: this.sourceUrl + '/60/', 96 | small: this.sourceUrl + '/120/', 97 | normal: this.sourceUrl + '/300/', 98 | large: this.sourceUrl + '/640/', 99 | avatar: this.sourceUrl + '/artist_image/' 100 | }; 101 | 102 | // mappings for the protobuf `enum Size` 103 | this.sourceUrls.DEFAULT = this.sourceUrls.normal; 104 | this.sourceUrls.SMALL = this.sourceUrls.tiny; 105 | this.sourceUrls.LARGE = this.sourceUrls.large; 106 | this.sourceUrls.XLARGE = this.sourceUrls.avatar; 107 | 108 | // WebSocket callbacks 109 | this._onopen = this._onopen.bind(this); 110 | this._onclose = this._onclose.bind(this); 111 | this._onmessage = this._onmessage.bind(this); 112 | 113 | // start the "heartbeat" once the WebSocket connection is established 114 | this.once('connect', this._startHeartbeat); 115 | 116 | // handle "message" commands... 117 | this.on('message', this._onmessagecommand); 118 | 119 | // needs to emulate Spotify's "CodeValidator" object 120 | this._context = vm.createContext(); 121 | this._context.reply = this._reply.bind(this); 122 | 123 | // binded callback for when user doesn't pass a callback function 124 | this._defaultCallback = this._defaultCallback.bind(this); 125 | } 126 | inherits(Spotify, EventEmitter); 127 | 128 | /** 129 | * Creates the connection to the Spotify Web websocket server and logs in using 130 | * the given Spotify `username` and `password` credentials. 131 | * 132 | * @param {String} un username 133 | * @param {String} pw password 134 | * @param {Function} fn callback function 135 | * @api public 136 | */ 137 | 138 | Spotify.prototype.login = function (un, pw, fn) { 139 | debug('Spotify#login(%j, %j)', un, pw.replace(/./g, '*')); 140 | 141 | // save credentials for later... 142 | this.creds = { username: un, password: pw, type: 'sp' }; 143 | 144 | this._setLoginCallbacks(fn); 145 | this._makeLandingPageRequest(); 146 | }; 147 | 148 | /** 149 | * Creates the connection to the Spotify Web websocket server and logs in using 150 | * an anonymous identity. 151 | * 152 | * @param {Function} fn callback function 153 | * @api public 154 | */ 155 | 156 | Spotify.prototype.anonymousLogin = function (fn) { 157 | debug('Spotify#anonymousLogin()'); 158 | 159 | // save credentials for later... 160 | this.creds = { type: 'anonymous' }; 161 | 162 | this._setLoginCallbacks(fn); 163 | this._makeLandingPageRequest(); 164 | }; 165 | 166 | /** 167 | * Creates the connection to the Spotify Web websocket server and logs in using 168 | * the given Facebook App OAuth token and corresponding user ID. 169 | * 170 | * @param {String} fbuid facebook user Id 171 | * @param {String} token oauth token 172 | * @param {Function} fn callback function 173 | * @api public 174 | */ 175 | 176 | Spotify.prototype.facebookLogin = function (fbuid, token, fn) { 177 | debug('Spotify#facebookLogin(%j, %j)', fbuid, token); 178 | 179 | // save credentials for later... 180 | this.creds = { fbuid: fbuid, token: token, type: 'fb' }; 181 | 182 | this._setLoginCallbacks(fn); 183 | this._makeLandingPageRequest(); 184 | }; 185 | 186 | /** 187 | * Sets the login and error callbacks to invoke the specified callback function 188 | * 189 | * @param {Function} fn callback function 190 | * @api private 191 | */ 192 | 193 | Spotify.prototype._setLoginCallbacks = function(fn) { 194 | var self = this; 195 | function onLogin () { 196 | cleanup(); 197 | fn(); 198 | } 199 | function onError (err) { 200 | cleanup(); 201 | fn(err); 202 | } 203 | function cleanup () { 204 | self.removeListener('login', onLogin); 205 | self.removeListener('error', onError); 206 | } 207 | if ('function' == typeof fn) { 208 | this.on('login', onLogin); 209 | this.on('error', onError); 210 | } 211 | }; 212 | 213 | /** 214 | * Makes a request for the landing page to get the CSRF token. 215 | * 216 | * @api private 217 | */ 218 | 219 | Spotify.prototype._makeLandingPageRequest = function() { 220 | var url = 'https://' + this.authServer + this.landingUrl; 221 | debug('GET %j', url); 222 | this.agent.get(url) 223 | .set({ 'User-Agent': this.userAgent }) 224 | .end(this._onsecret.bind(this)); 225 | }; 226 | 227 | /** 228 | * Called when the Facebook redirect URL GET (and any necessary redirects) has 229 | * responded. 230 | * 231 | * @api private 232 | */ 233 | 234 | Spotify.prototype._onsecret = function (err, res) { 235 | if (err) return this.emit('error', err); 236 | 237 | debug('landing page: %d status code, %j content-type', res.statusCode, res.headers['content-type']); 238 | var $ = cheerio.load(res.text); 239 | 240 | // need to grab the CSRF token and trackingId from the page. 241 | // currently, it's inside an Object that gets passed to a 242 | // `new Spotify.Web.Login()` call as the second parameter. 243 | var args; 244 | var scripts = $('script'); 245 | function login (doc, data) { 246 | debug('Spotify.Web.Login()'); 247 | args = data; 248 | return { init: function () { /* noop */ } }; 249 | } 250 | for (var i = 0; i < scripts.length; i++) { 251 | var code = scripts.eq(i).text(); 252 | if (~code.indexOf('Spotify.Web.Login')) { 253 | vm.runInNewContext(code, { document: null, Spotify: { Web: { Login: login, App: { initialize: function() { } } } } }); 254 | } 255 | } 256 | debug('login CSRF token: %j, tracking ID: %j', args.csrftoken, args.trackingId); 257 | 258 | // construct credentials object to send from stored credentials 259 | var creds = this.creds; 260 | delete this.creds; 261 | creds.secret = args.csrftoken; 262 | creds.trackingId = args.trackingId; 263 | creds.landingURL = args.landingURL; 264 | creds.referrer = args.referrer; 265 | creds.cf = null; 266 | 267 | // now we have to "auth" in order to get Spotify Web "credentials" 268 | var url = 'https://' + this.authServer + this.authUrl; 269 | debug('POST %j', url); 270 | this.agent.post(url) 271 | .set({ 'User-Agent': this.userAgent }) 272 | .type('form') 273 | .send(creds) 274 | .end(this._onauth.bind(this)); 275 | }; 276 | 277 | /** 278 | * Called upon the "auth" endpoint's HTTP response. 279 | * 280 | * @api private 281 | */ 282 | 283 | Spotify.prototype._onauth = function (err, res) { 284 | if (err) return this.emit('error', err); 285 | 286 | debug('auth %d status code, %j content-type', res.statusCode, res.headers['content-type']); 287 | if ('ERROR' == res.body.status) { 288 | // got an error... 289 | var msg = res.body.error; 290 | if (res.body.message) msg += ': ' + res.body.message; 291 | this.emit('error', new Error(msg)); 292 | } else { 293 | this.settings = res.body.config; 294 | this._resolveAP(); 295 | } 296 | }; 297 | 298 | /** 299 | * Resolves the WebSocket AP to connect to 300 | * Should be called after the _onauth() function 301 | * 302 | * @api private 303 | */ 304 | 305 | Spotify.prototype._resolveAP = function () { 306 | var query = { client: '24:0:0:' + this.settings.version }; 307 | var resolver = this.settings.aps.resolver; 308 | debug('ap resolver %j', resolver); 309 | if (resolver.site) query.site = resolver.site; 310 | 311 | // connect to the AP resolver endpoint in order to determine 312 | // the WebSocket server URL to connect to next 313 | var url = 'http://' + resolver.hostname; 314 | debug('GET %j', url); 315 | this.agent.get(url) 316 | .set({ 'User-Agent': this.userAgent }) 317 | .query(query) 318 | .end(this._openWebsocket.bind(this)); 319 | }; 320 | 321 | /** 322 | * Opens the WebSocket connection to the Spotify Web server. 323 | * Should be called upon AP resolver's response. 324 | * 325 | * @api private. 326 | */ 327 | 328 | Spotify.prototype._openWebsocket = function (err, res) { 329 | if (err) return this.emit('error', err); 330 | 331 | debug('ap resolver %d status code, %j content-type', res.statusCode, res.headers['content-type']); 332 | var ap_list = res.body.ap_list; 333 | var url = 'wss://' + ap_list[0] + '/'; 334 | 335 | debug('WS %j', url); 336 | this.ws = new WebSocket(url, null, {"origin": "https://play.spotify.com", "headers":{"User-Agent": this.userAgent}}); 337 | this.ws.on('open', this._onopen); 338 | this.ws.on('close', this._onclose); 339 | this.ws.on('message', this._onmessage); 340 | }; 341 | 342 | /** 343 | * WebSocket "open" event. 344 | * 345 | * @api private 346 | */ 347 | 348 | Spotify.prototype._onopen = function () { 349 | debug('WebSocket "open" event'); 350 | this.emit('open'); 351 | if (!this.connected) { 352 | // need to send "connect" message 353 | this.connect(); 354 | } 355 | }; 356 | 357 | /** 358 | * WebSocket "close" event. 359 | * 360 | * @api private 361 | */ 362 | 363 | Spotify.prototype._onclose = function () { 364 | debug('WebSocket "close" event'); 365 | this.emit('close'); 366 | if (this.connected) { 367 | this.disconnect(); 368 | } 369 | }; 370 | 371 | /** 372 | * WebSocket "message" event. 373 | * 374 | * @param {String} 375 | * @api private 376 | */ 377 | 378 | Spotify.prototype._onmessage = function (data) { 379 | debug('WebSocket "message" event: %s', data); 380 | var msg; 381 | try { 382 | msg = JSON.parse(data); 383 | } catch (e) { 384 | return this.emit('error', e); 385 | } 386 | 387 | var self = this; 388 | var id = msg.id; 389 | var callbacks = this._callbacks; 390 | 391 | function fn (err, res) { 392 | var cb = callbacks[id]; 393 | if (cb) { 394 | // got a callback function! 395 | delete callbacks[id]; 396 | cb.call(self, err, res, msg); 397 | } 398 | } 399 | 400 | if ('error' in msg) { 401 | var err = new SpotifyError(msg.error); 402 | if (null == id) { 403 | this.emit('error', err); 404 | } else { 405 | fn(err); 406 | } 407 | } else if ('message' in msg) { 408 | var command = msg.message[0]; 409 | var args = msg.message.slice(1); 410 | this.emit('message', command, args); 411 | } else if ('id' in msg) { 412 | fn(null, msg); 413 | } else { 414 | // unhandled command 415 | console.error(msg); 416 | throw new Error('TODO: implement!'); 417 | } 418 | }; 419 | 420 | /** 421 | * Handles a "message" command. Specifically, handles the "do_work" command and 422 | * executes the specified JavaScript in the VM. 423 | * 424 | * @api private 425 | */ 426 | 427 | Spotify.prototype._onmessagecommand = function (command, args) { 428 | if ('do_work' == command) { 429 | var js = args[0]; 430 | debug('got "do_work" payload: %j', js); 431 | try { 432 | vm.runInContext(js, this._context); 433 | } catch (e) { 434 | this.emit('error', e); 435 | } 436 | } else if ('ping_flash2' == command) { 437 | this.sendPong(args[0]); 438 | } else if ('login_complete' == command) { 439 | this.sendCommand('sp/log', [41, 1, 0, 0, 0, 0]); // Spotify.Logging.Logger#logWindowSize 440 | this.sendCommand('sp/user_info', this._onuserinfo.bind(this)); 441 | } else { 442 | // unhandled message 443 | console.error(command, args); 444 | throw new Error('TODO: implement!'); 445 | } 446 | }; 447 | 448 | /** 449 | * Called when the "sp/work_done" command is completed. 450 | * 451 | * @api private 452 | */ 453 | 454 | Spotify.prototype._onworkdone = function (err, res) { 455 | if (err) return this.emit('error', err); 456 | debug('"sp/work_done" ACK'); 457 | }; 458 | 459 | /** 460 | * Responds to a `sp/ping_flash2` request. 461 | * 462 | * This request is usually handled by Flash, and in a perfect world we would 463 | * execute the "original" Flash code directly with Shumway or equivalent. 464 | * Unfortunately the player.swf file is compiled with C code via Adobe Alchemy, 465 | * and therefore doesn't work with Shumway at this time. 466 | * See: http://git.io/qCWplA 467 | * 468 | * Instead, this function calls out to a web service that is running the 469 | * `player.swf` file in a web browser, and proxies the ping request to the 470 | * web browser and sends us back the response. Not ideal, but it works… 471 | * See: http://git.io/WyCx0Q 472 | * 473 | * @param {String} ping the argument sent from the request 474 | */ 475 | 476 | Spotify.prototype.sendPong = function(ping) { 477 | this.agent 478 | .get('http://ping-pong.spotify.nodestuff.net/' + ping.split(" ").join("-")) 479 | .set({ 'User-Agent': this.userAgent }) 480 | .end(function (err, res) { 481 | if (err) return this.emit('error', err); 482 | 483 | if (res.body.status == 100) { 484 | pong = res.body.pong.split("-").join(" "); 485 | this.sendCommand('sp/pong_flash2', [ pong ]); 486 | } 487 | }.bind(this)); 488 | }; 489 | 490 | /** 491 | * Sends a "message" across the WebSocket connection with the given "name" and 492 | * optional Array of arguments. 493 | * 494 | * @param {String} name command name 495 | * @param {Array} args optional Array or arguments to send 496 | * @param {Function} fn callback function 497 | * @api public 498 | */ 499 | 500 | Spotify.prototype.sendCommand = function (name, args, fn) { 501 | if ('function' == typeof args) { 502 | fn = args; 503 | args = []; 504 | } 505 | debug('sendCommand(%j, %j)', name, args); 506 | var msg = { 507 | name: name, 508 | id: String(this.seq++), 509 | args: args || [] 510 | }; 511 | if ('function' == typeof fn) { 512 | // store callback function for later 513 | debug('storing callback function for message id %s', msg.id); 514 | this._callbacks[msg.id] = fn; 515 | } 516 | var data = JSON.stringify(msg); 517 | debug('sending command: %s', data); 518 | try { 519 | this.ws.send(data); 520 | } catch (e) { 521 | this.emit('error', e); 522 | } 523 | }; 524 | 525 | /** 526 | * Makes a Protobuf request over the WebSocket connection. 527 | * Also known as a MercuryRequest or Hermes Call. 528 | * 529 | * @param {Object} req protobuf request object 530 | * @param {Function} fn (optional) callback function 531 | * @api public 532 | */ 533 | 534 | Spotify.prototype.sendProtobufRequest = function(req, fn) { 535 | debug('sendProtobufRequest(%j)', req); 536 | 537 | // extract request object 538 | var isMultiGet = req.isMultiGet || false; 539 | var payload = req.payload || []; 540 | var header = { 541 | uri: '', 542 | method: '', 543 | source: '', 544 | contentType: isMultiGet ? 'vnd.spotify/mercury-mget-request' : '' 545 | }; 546 | if (req.header) { 547 | header.uri = req.header.uri || ''; 548 | header.method = req.header.method || ''; 549 | header.source = req.header.source || ''; 550 | } 551 | 552 | // load payload and response schemas 553 | var loadSchema = function(schema, dontRecurse) { 554 | if ('string' === typeof schema) { 555 | var schemaName = schema.split("#"); 556 | schema = schemas.build(schemaName[0], schemaName[1]); 557 | if (!schema) 558 | throw new Error('Could not load schema: ' + schemaName.join('#')); 559 | } else if (schema && !dontRecurse && (!schema.hasOwnProperty('parse') && !schema.hasOwnProperty('serialize'))) { 560 | var keys = Object.keys(schema); 561 | keys.forEach(function(key) { 562 | schema[key] = loadSchema(schema[key], true); 563 | }); 564 | } 565 | return schema; 566 | }; 567 | 568 | var payloadSchema = isMultiGet ? MercuryMultiGetRequest : loadSchema(req.payloadSchema); 569 | var responseSchema = loadSchema(req.responseSchema); 570 | var isMultiResponseSchema = (!responseSchema.hasOwnProperty('parse')); 571 | 572 | var parseData = function(type, data, dontRecurse) { 573 | var parser = responseSchema; 574 | var ret; 575 | if (!dontRecurse && 'vnd.spotify/mercury-mget-reply' == type) { 576 | ret = []; 577 | var response = self._parse(MercuryMultiGetReply, data); 578 | response.reply.forEach(function(reply) { 579 | var data = parseData(reply.contentType, new Buffer(reply.body, 'base64'), true); 580 | ret.push(data); 581 | }); 582 | debug('parsed multi-get response - %d items', ret.length); 583 | } else { 584 | if (isMultiResponseSchema) { 585 | if (responseSchema.hasOwnProperty(type)) { 586 | parser = responseSchema[type]; 587 | } else { 588 | throw new Error('Unrecognised metadata type: ' + type); 589 | } 590 | } 591 | ret = self._parse(parser, data); 592 | debug('parsed response: [ %j ] %j', type, ret); 593 | } 594 | return ret; 595 | }; 596 | 597 | function getNumber (method) { 598 | switch(method) { 599 | case "SUB": 600 | return 1; 601 | case "UNSUB": 602 | return 2; 603 | default: 604 | return 0; 605 | } 606 | } 607 | 608 | // construct request 609 | var args = [ getNumber(header.method) ]; 610 | var data = MercuryRequest.serialize(header).toString('base64'); 611 | args.push(data); 612 | 613 | if (isMultiGet) { 614 | if (Array.isArray(req.payload)) { 615 | req.payload = {request: req.payload}; 616 | } else if (!req.payload.request) { 617 | throw new Error('Invalid payload for Multi-Get Request.'); 618 | } 619 | } 620 | 621 | if (payload && payloadSchema) { 622 | data = payloadSchema.serialize(req.payload).toString('base64'); 623 | args.push(data); 624 | } 625 | 626 | // send request and parse response, pass data back to callback 627 | var self = this; 628 | this.sendCommand('sp/hm_b64', args, function (err, res) { 629 | if ('function' !== typeof fn) return; // give up if no callback 630 | if (err) return fn(err); 631 | 632 | var header = self._parse(MercuryRequest, new Buffer(res.result[0], 'base64')); 633 | debug('response header: %j', header); 634 | 635 | // TODO: proper error handling, handle 300 errors 636 | 637 | var message; 638 | if (header.statusCode >= 400 && header.statusCode < 500) { 639 | message = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; 640 | return fn(new Error('Client Error: ' + message + ' (' + header.statusCode + ')')); 641 | } 642 | 643 | if (header.statusCode >= 500 && header.statusCode < 600) { 644 | message = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; 645 | return fn(new Error('Server Error: ' + message + ' (' + header.statusCode + ')')); 646 | } 647 | 648 | if (isMultiGet && 'vnd.spotify/mercury-mget-reply' !== header.contentType) 649 | return fn(new Error('Server Error: Server didn\'t send a multi-GET reply for a multi-GET request!')); 650 | 651 | var data = parseData(header.contentType, new Buffer(res.result[1], 'base64')); 652 | fn(null, data); 653 | }); 654 | }; 655 | 656 | /** 657 | * Sends the "connect" command. Should be called once the WebSocket connection is 658 | * established. 659 | * 660 | * @param {Function} fn callback function 661 | * @api public 662 | */ 663 | 664 | Spotify.prototype.connect = function (fn) { 665 | debug('connect()'); 666 | var creds = this.settings.credentials[0].split(':'); 667 | var args = [ creds[0], creds[1], creds.slice(2).join(':') ]; 668 | this.sendCommand('connect', args, this._onconnect.bind(this)); 669 | }; 670 | 671 | /** 672 | * Closes the WebSocket connection of present. This effectively ends your Spotify 673 | * Web "session" (and derefs from the event-loop, so your program can exit). 674 | * 675 | * @api public 676 | */ 677 | 678 | Spotify.prototype.disconnect = function () { 679 | debug('disconnect()'); 680 | this.connected = false; 681 | clearInterval(this._heartbeatId); 682 | this._heartbeatId = null; 683 | if (this.ws) { 684 | this.ws.close(); 685 | this.ws = null; 686 | } 687 | }; 688 | 689 | /** 690 | * Gets the "metadata" object for one or more URIs. 691 | * 692 | * @param {Array|String} uris A single URI, or an Array of URIs to get "metadata" for 693 | * @param {Function} fn callback function 694 | * @api public 695 | */ 696 | 697 | Spotify.prototype.get = 698 | Spotify.prototype.metadata = function (uris, fn) { 699 | debug('metadata(%j)', uris); 700 | if (!Array.isArray(uris)) { 701 | uris = [ uris ]; 702 | } 703 | // array of "request" Objects that will be protobuf'd 704 | var requests = []; 705 | var mtype = ''; 706 | uris.forEach(function (uri) { 707 | var type = util.uriType(uri); 708 | if ('local' == type) { 709 | debug('ignoring "local" track URI: %j', uri); 710 | return; 711 | } 712 | var id = util.uri2id(uri); 713 | mtype = type; 714 | requests.push({ 715 | method: 'GET', 716 | uri: 'hm://metadata/' + type + '/' + id 717 | }); 718 | }); 719 | 720 | 721 | var header = { 722 | method: 'GET', 723 | uri: 'hm://metadata/' + mtype + 's' 724 | }; 725 | var multiGet = true; 726 | if (requests.length == 1) { 727 | header = requests[0]; 728 | requests = null; 729 | multiGet = false; 730 | } 731 | 732 | this.sendProtobufRequest({ 733 | header: header, 734 | payload: requests, 735 | isMultiGet: multiGet, 736 | responseSchema: { 737 | 'vnd.spotify/metadata-artist': Artist, 738 | 'vnd.spotify/metadata-album': Album, 739 | 'vnd.spotify/metadata-track': Track 740 | } 741 | }, function(err, item) { 742 | if (err) return fn(err); 743 | item._loaded = true; 744 | fn(null, item); 745 | }); 746 | }; 747 | 748 | /** 749 | * Gets the metadata from a Spotify "playlist" URI. 750 | * 751 | * @param {String} uri playlist uri 752 | * @param {Number} from (optional) the start index. defaults to 0. 753 | * @param {Number} length (optional) number of tracks to get. defaults to 100. 754 | * @param {Function} fn callback function 755 | * @api public 756 | */ 757 | 758 | Spotify.prototype.playlist = function (uri, from, length, fn) { 759 | // argument surgery 760 | if ('function' == typeof from) { 761 | fn = from; 762 | from = length = null; 763 | } else if ('function' == typeof length) { 764 | fn = length; 765 | length = null; 766 | } 767 | if (null == from) from = 0; 768 | if (null == length) length = 100; 769 | 770 | debug('playlist(%j, %j, %j)', uri, from, length); 771 | var self = this; 772 | var parts = uri.split(':'); 773 | var user = parts[2]; 774 | var id = parts[4]; 775 | var hm = 'hm://playlist/user/' + user + '/playlist/' + id + 776 | '?from=' + from + '&length=' + length; 777 | 778 | this.sendProtobufRequest({ 779 | header: { 780 | method: 'GET', 781 | uri: hm 782 | }, 783 | responseSchema: SelectedListContent 784 | }, fn); 785 | }; 786 | 787 | /** 788 | * Gets a user's starred playlist 789 | * 790 | * @param {Number} from (optional) the start index. defaults to 0. 791 | * @param {Number} length (optional) number of tracks to get. defaults to 100. 792 | * @param {Function} fn callback function 793 | * @api public 794 | */ 795 | 796 | Spotify.prototype.starred = function (user, from, length, fn) { 797 | // argument surgery 798 | if ('function' == typeof from) { 799 | fn = from; 800 | from = length = null; 801 | } else if ('function' == typeof length) { 802 | fn = length; 803 | length = null; 804 | } 805 | if (null == from) from = 0; 806 | if (null == length) length = 100; 807 | 808 | debug('starred(%j, %j, %j)', user, from, length); 809 | 810 | var self = this; 811 | var hm = 'hm://playlist/user/' + user + '/starred?from=' + from + '&length=' + length; 812 | 813 | this.sendProtobufRequest({ 814 | header: { 815 | method: 'GET', 816 | uri: hm 817 | }, 818 | responseSchema: SelectedListContent 819 | }, fn); 820 | }; 821 | 822 | /** 823 | * Gets the user's stored playlists 824 | * 825 | * @param {String} user (optional) the username for the rootlist you want to retrieve. defaults to current user. 826 | * @param {Number} from (optional) the start index. defaults to 0. 827 | * @param {Number} length (optional) number of tracks to get. defaults to 100. 828 | * @param {Function} fn callback function 829 | * @api public 830 | */ 831 | 832 | Spotify.prototype.rootlist = function (user, from, length, fn) { 833 | // argument surgery 834 | if ('function' == typeof user) { 835 | fn = user; 836 | from = length = user = null; 837 | } else if ('function' == typeof from) { 838 | fn = from; 839 | from = length = null; 840 | } else if ('function' == typeof length) { 841 | fn = length; 842 | length = null; 843 | } 844 | if (null == user) user = this.username; 845 | if (null == from) from = 0; 846 | if (null == length) length = 100; 847 | 848 | debug('rootlist(%j, %j, %j)', user, from, length); 849 | 850 | var self = this; 851 | var hm = 'hm://playlist/user/' + user + '/publishedrootlist?from=' + from + '&length=' + length; 852 | 853 | this.sendProtobufRequest({ 854 | header: { 855 | method: 'GET', 856 | uri: hm 857 | }, 858 | responseSchema: SelectedListContent 859 | }, fn); 860 | }; 861 | 862 | /** 863 | * Retrieve suggested similar tracks to the given track URI 864 | * 865 | * @param {String} uri track uri 866 | * @param {Function} fn callback function 867 | * @api public 868 | */ 869 | 870 | Spotify.prototype.similar = function(uri, fn) { 871 | debug('similar(%j)', uri); 872 | 873 | var parts = uri.split(':'); 874 | var type = parts[1]; 875 | var id = parts[2]; 876 | 877 | if (!type || !id || 'track' != type) 878 | throw new Error('uri must be a track uri'); 879 | 880 | this.sendProtobufRequest({ 881 | header: { 882 | method: 'GET', 883 | uri: 'hm://similarity/suggest/' + id 884 | }, 885 | payload: { 886 | country: this.country || 'US', 887 | language: this.settings.locale.current || 'en', 888 | device: 'web' 889 | }, 890 | payloadSchema: StoryRequest, 891 | responseSchema: StoryList 892 | }, fn); 893 | }; 894 | 895 | /** 896 | * Gets the MP3 160k audio URL for the given "track" metadata object. 897 | * 898 | * @param {Object} track Track "metadata" instance 899 | * @param {Function} fn callback function 900 | * @api public 901 | */ 902 | 903 | Spotify.prototype.trackUri = function (track, fn) { 904 | debug('trackUri()'); 905 | // TODO: make "format" configurable here 906 | this.recurseAlternatives(track, this.country, function (err, track) { 907 | if (err) return fn(err); 908 | var args = [ 'mp3160', util.gid2id(track.gid) ]; 909 | debug('sp/track_uri args: %j', args); 910 | this.sendCommand('sp/track_uri', args, function (err, res) { 911 | if (err) return fn(err); 912 | fn(null, res.result); 913 | }); 914 | }.bind(this)); 915 | }; 916 | 917 | /** 918 | * Checks if the given track "metadata" object is "available" for playback, taking 919 | * account for the allowed/forbidden countries, the user's current country, the 920 | * user's account type (free/paid), etc. 921 | * 922 | * @param {Object} track Track "metadata" instance 923 | * @param {String} country 2 letter country code to check if the track is playable for 924 | * @return {Boolean} true if track is playable, false otherwise 925 | * @api public 926 | */ 927 | 928 | Spotify.prototype.isTrackAvailable = function (track, country) { 929 | if (!country) country = this.country; 930 | debug('isTrackAvailable()'); 931 | 932 | var allowed = []; 933 | var forbidden = []; 934 | var available = false; 935 | var restriction; 936 | 937 | if (Array.isArray(track.restriction)) { 938 | for (var i = 0; i < track.restriction.length; i++) { 939 | restriction = track.restriction[i]; 940 | allowed.push.apply(allowed, restriction.allowed); 941 | forbidden.push.apply(forbidden, restriction.forbidden); 942 | 943 | var isAllowed = !restriction.hasOwnProperty('countriesAllowed') || has(allowed, country); 944 | var isForbidden = has(forbidden, country) && forbidden.length > 0; 945 | 946 | // guessing at names here, corrections welcome... 947 | var accountTypeMap = { 948 | premium: 'SUBSCRIPTION', 949 | unlimited: 'SUBSCRIPTION', 950 | free: 'AD' 951 | }; 952 | 953 | if (has(allowed, country) && has(forbidden, country)) { 954 | isAllowed = true; 955 | isForbidden = false; 956 | } 957 | 958 | var type = accountTypeMap[this.accountType] || 'AD'; 959 | var applicable = has(restriction.catalogue, type); 960 | 961 | available = isAllowed && !isForbidden && applicable; 962 | 963 | //debug('restriction: %j', restriction); 964 | debug('type: %j', type); 965 | debug('allowed: %j', allowed); 966 | debug('forbidden: %j', forbidden); 967 | debug('isAllowed: %j', isAllowed); 968 | debug('isForbidden: %j', isForbidden); 969 | debug('applicable: %j', applicable); 970 | debug('available: %j', available); 971 | 972 | if (available) break; 973 | } 974 | } 975 | return available; 976 | }; 977 | 978 | /** 979 | * Checks if the given "track" is "available". If yes, returns the "track" 980 | * untouched. If no, then the "alternative" tracks array on the "track" instance 981 | * is searched until one of them is "available", and then returns that "track". 982 | * If none of the alternative tracks are "available", returns `null`. 983 | * 984 | * @param {Object} track Track "metadata" instance 985 | * @param {String} country 2 letter country code to attempt to find a playable "track" for 986 | * @param {Function} fn callback function 987 | * @api public 988 | */ 989 | 990 | Spotify.prototype.recurseAlternatives = function (track, country, fn) { 991 | debug('recurseAlternatives()'); 992 | function done () { 993 | process.nextTick(function () { 994 | fn(null, track); 995 | }); 996 | } 997 | if (this.isTrackAvailable(track, country)) { 998 | return done(); 999 | } else if (Array.isArray(track.alternative)) { 1000 | var tracks = track.alternative; 1001 | for (var i = 0; i < tracks.length; i++) { 1002 | debug('checking alternative track %j', track.uri); 1003 | track = tracks[i]; 1004 | if (this.isTrackAvailable(track, country)) { 1005 | return done(); 1006 | } 1007 | } 1008 | } 1009 | // not playable 1010 | process.nextTick(function () { 1011 | fn(new Error('Track is not playable in country "' + country + '"')); 1012 | }); 1013 | }; 1014 | 1015 | /** 1016 | * Executes a "search" against the Spotify music library. Note that the response 1017 | * is an XML data String, so you must parse it yourself. 1018 | * 1019 | * @param {String|Object} opts string search term, or options object with search 1020 | * @param {Function} fn callback function 1021 | * @api public 1022 | */ 1023 | 1024 | Spotify.prototype.search = function (opts, fn) { 1025 | if ('string' == typeof opts) { 1026 | opts = { query: opts }; 1027 | } 1028 | if (null == opts.maxResults || opts.maxResults > 50) { 1029 | opts.maxResults = 50; 1030 | } 1031 | if (null == opts.type) { 1032 | opts.type = 'all'; 1033 | } 1034 | if (null == opts.offset) { 1035 | opts.offset = 0; 1036 | } 1037 | if (null == opts.query) { 1038 | throw new Error('must pass a "query" option!'); 1039 | } 1040 | 1041 | var types = { 1042 | tracks: 1, 1043 | albums: 2, 1044 | artists: 4, 1045 | playlists: 8 1046 | }; 1047 | var type; 1048 | if ('all' == opts.type) { 1049 | type = types.tracks | types.albums | types.artists | types.playlists; 1050 | } else if (Array.isArray(opts.type)) { 1051 | type = 0; 1052 | opts.type.forEach(function (t) { 1053 | if (!types.hasOwnProperty(t)) { 1054 | throw new Error('unknown search "type": ' + opts.type); 1055 | } 1056 | type |= types[t]; 1057 | }); 1058 | } else if (opts.type in types) { 1059 | type = types[opts.type]; 1060 | } else { 1061 | throw new Error('unknown search "type": ' + opts.type); 1062 | } 1063 | 1064 | var args = [ opts.query, type, opts.maxResults, opts.offset ]; 1065 | this.sendCommand('sp/search', args, function (err, res) { 1066 | if (err) return fn(err); 1067 | // XML-parsing is left up to the user, since they may want to use libxmljs, 1068 | // or node-sax, or node-xml2js, or whatever. So leave it up to them... 1069 | fn(null, res.result); 1070 | }); 1071 | }; 1072 | 1073 | /** 1074 | * Sends the "sp/track_end" event. This is required after each track is played, 1075 | * otherwise Spotify limits you to 3 track URL fetches per session. 1076 | * 1077 | * @param {String} lid the track "lid" 1078 | * @param {String} uri track spotify uri (not playback uri) 1079 | * @param {Number} ms number of milliseconds played 1080 | * @param {Function} fn callback function 1081 | * @api public 1082 | */ 1083 | 1084 | Spotify.prototype.sendTrackEnd = function (lid, uri, ms, fn) { 1085 | debug('sendTrackEnd(%j, %j, %j)', lid, uri, ms); 1086 | if (!fn) fn = this._defaultCallback; 1087 | 1088 | var ms_played = Number(ms); 1089 | var ms_played_union = ms_played; 1090 | var n_seeks_forward = 0; 1091 | var n_seeks_backward = 0; 1092 | var ms_seeks_forward = 0; 1093 | var ms_seeks_backward = 0; 1094 | var ms_latency = 100; 1095 | var display_track = null; 1096 | var play_context = 'unknown'; 1097 | var source_start = 'unknown'; 1098 | var source_end = 'unknown'; 1099 | var reason_start = 'unknown'; 1100 | var reason_end = 'unknown'; 1101 | var referrer = 'unknown'; 1102 | var referrer_version = '0.1.0'; 1103 | var referrer_vendor = 'com.spotify'; 1104 | var max_continuous = ms_played; 1105 | var args = [ 1106 | lid, 1107 | ms_played, 1108 | ms_played_union, 1109 | n_seeks_forward, 1110 | n_seeks_backward, 1111 | ms_seeks_forward, 1112 | ms_seeks_backward, 1113 | ms_latency, 1114 | display_track, 1115 | play_context, 1116 | source_start, 1117 | source_end, 1118 | reason_start, 1119 | reason_end, 1120 | referrer, 1121 | referrer_version, 1122 | referrer_vendor, 1123 | max_continuous 1124 | ]; 1125 | this.sendCommand('sp/track_end', args, function (err, res) { 1126 | if (err) return fn(err); 1127 | if (null == res.result) { 1128 | // apparently no result means "ok" 1129 | fn(); 1130 | } else { 1131 | // TODO: handle error case 1132 | } 1133 | }); 1134 | }; 1135 | 1136 | /** 1137 | * Sends the "sp/track_event" event. These are pause and play events (possibly 1138 | * others). 1139 | * 1140 | * @param {String} lid the track "lid" 1141 | * @param {String} event 1142 | * @param {Number} ms number of milliseconds played so far 1143 | * @param {Function} fn callback function 1144 | * @api public 1145 | */ 1146 | 1147 | Spotify.prototype.sendTrackEvent = function (lid, event, ms, fn) { 1148 | debug('sendTrackEvent(%j, %j, %j)', lid, event, ms); 1149 | var num = event; 1150 | var args = [ lid, num, ms ]; 1151 | this.sendCommand('sp/track_event', args, function (err, res) { 1152 | if (err) return fn(err); 1153 | console.log(res); 1154 | }); 1155 | }; 1156 | 1157 | /** 1158 | * Sends the "sp/track_progress" event. Should be called periodically while 1159 | * playing a Track. 1160 | * 1161 | * @param {String} lid the track "lid" 1162 | * @param {Number} ms number of milliseconds played so far 1163 | * @param {Function} fn callback function 1164 | * @api public 1165 | */ 1166 | 1167 | Spotify.prototype.sendTrackProgress = function (lid, ms, fn) { 1168 | debug('sendTrackProgress(%j, %j)', lid, ms); 1169 | var ms_played = Number(ms); 1170 | var source_start = 'unknown'; 1171 | var reason_start = 'unknown'; 1172 | var ms_latency = 100; 1173 | var play_context = 'unknown'; 1174 | var display_track = ''; 1175 | var referrer = 'unknown'; 1176 | var referrer_version = '0.1.0'; 1177 | var referrer_vendor = 'com.spotify'; 1178 | var args = [ 1179 | lid, 1180 | source_start, 1181 | reason_start, 1182 | ms_played, 1183 | ms_latency, 1184 | play_context, 1185 | display_track, 1186 | referrer, 1187 | referrer_version, 1188 | referrer_vendor 1189 | ]; 1190 | this.sendCommand('sp/track_progress', args, function (err, res) { 1191 | if (err) return fn(err); 1192 | console.log(res); 1193 | }); 1194 | }; 1195 | 1196 | /** 1197 | * "connect" command callback function. If the result was "ok", then get the 1198 | * logged in user's info. 1199 | * 1200 | * @param {Object} res response Object 1201 | * @api private 1202 | */ 1203 | 1204 | Spotify.prototype._onconnect = function (err, res) { 1205 | if (err) return this.emit('error', err); 1206 | if ('ok' == res.result) { 1207 | this.connected = true; 1208 | this.emit('connect'); 1209 | } else { 1210 | // TODO: handle possible error case 1211 | } 1212 | }; 1213 | 1214 | /** 1215 | * "sp/user_info" command callback function. Once this is complete, the "login" 1216 | * event is emitted and control is passed back to the user for the first time. 1217 | * 1218 | * @param {Object} res response Object 1219 | * @api private 1220 | */ 1221 | 1222 | Spotify.prototype._onuserinfo = function (err, res) { 1223 | if (err) return this.emit('error', err); 1224 | this.username = res.result.user; 1225 | this.country = res.result.country; 1226 | this.accountType = res.result.catalogue; 1227 | this.emit('login'); 1228 | }; 1229 | 1230 | /** 1231 | * Starts the interval that sends and "sp/echo" command to the Spotify server 1232 | * every 18 seconds. 1233 | * 1234 | * @api private 1235 | */ 1236 | 1237 | Spotify.prototype._startHeartbeat = function () { 1238 | debug('starting heartbeat every %s seconds', this.heartbeatInterval / 1000); 1239 | var fn = this._onheartbeat.bind(this); 1240 | this._heartbeatId = setInterval(fn, this.heartbeatInterval); 1241 | }; 1242 | 1243 | /** 1244 | * Sends an "sp/echo" command. 1245 | * 1246 | * @api private 1247 | */ 1248 | 1249 | Spotify.prototype._onheartbeat = function () { 1250 | this.sendCommand('sp/echo', 'h'); 1251 | }; 1252 | 1253 | /** 1254 | * Called when `this.reply()` is called in the "do_work" payload. 1255 | * 1256 | * @api private 1257 | */ 1258 | 1259 | Spotify.prototype._reply = function () { 1260 | var args = Array.prototype.slice.call(arguments); 1261 | debug('reply(%j)', args); 1262 | this.sendCommand('sp/work_done', args, this._onworkdone); 1263 | }; 1264 | 1265 | /** 1266 | * Default callback function for when the user does not pass a 1267 | * callback function of their own. 1268 | * 1269 | * @param {Error} err 1270 | * @api private 1271 | */ 1272 | 1273 | Spotify.prototype._defaultCallback = function (err) { 1274 | if (err) this.emit('error', err); 1275 | }; 1276 | 1277 | /** 1278 | * Wrapper around the Protobuf Schema's `parse()` function that also attaches this 1279 | * Spotify instance as `_spotify` to each entry in the parsed object. This is 1280 | * necessary so that instance methods (like `Track#play()`) have access to the 1281 | * Spotify instance in order to interact with it. 1282 | * 1283 | * @api private 1284 | */ 1285 | 1286 | Spotify.prototype._parse = function (parser, data) { 1287 | var obj = parser.parse(data); 1288 | tag(this, obj); 1289 | return obj; 1290 | }; 1291 | 1292 | /** 1293 | * XXX: move to `util`? 1294 | * Attaches the `_spotify` property to each "object" in the passed in `obj`. 1295 | * 1296 | * @api private 1297 | */ 1298 | 1299 | function tag(spotify, obj){ 1300 | if (obj === null || 'object' != typeof obj) return; 1301 | Object.keys(obj).forEach(function(key){ 1302 | var val = obj[key]; 1303 | var type = typeof val; 1304 | if ('object' == type) { 1305 | if (Array.isArray(val)) { 1306 | val.forEach(function (v) { 1307 | tag(spotify, v); 1308 | }); 1309 | } else { 1310 | tag(spotify, val); 1311 | } 1312 | } 1313 | }); 1314 | Object.defineProperty(obj, '_spotify', { 1315 | value: spotify, 1316 | enumerable: false, 1317 | writable: true, 1318 | configurable: true 1319 | }); 1320 | } 1321 | 1322 | /** 1323 | * XXX: move to `util`? 1324 | * Returns `true` if `val` is present in the `array`. Returns `false` otherwise. 1325 | * 1326 | * @api private 1327 | */ 1328 | 1329 | function has (array, val) { 1330 | var rtn = false; 1331 | if (Array.isArray(array)) { 1332 | rtn = !!~array.indexOf(val); 1333 | } 1334 | return rtn; 1335 | } 1336 | -------------------------------------------------------------------------------- /lib/track.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var util = require('./util'); 7 | var Track = require('./schemas').build('metadata','Track'); 8 | var PassThrough = require('stream').PassThrough; 9 | var debug = require('debug')('spotify-web:track'); 10 | 11 | // node v0.8.x compat 12 | if (!PassThrough) PassThrough = require('readable-stream/passthrough'); 13 | 14 | /** 15 | * Module exports. 16 | */ 17 | 18 | exports = module.exports = Track; 19 | 20 | /** 21 | * Track URI getter. 22 | */ 23 | 24 | Object.defineProperty(Track.prototype, 'uri', { 25 | get: function () { 26 | return util.gid2uri('track', this.gid); 27 | }, 28 | enumerable: true, 29 | configurable: true 30 | }); 31 | 32 | /** 33 | * Track Preview URL getter 34 | */ 35 | Object.defineProperty(Track.prototype, 'previewUrl', { 36 | get: function () { 37 | var previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/' 38 | return this.preview.length && (previewUrlBase + util.gid2id(this.preview[0].fileId)); 39 | }, 40 | enumerable: true, 41 | configurable: true 42 | }) 43 | 44 | /** 45 | * Loads all the metadata for this Track instance. Useful for when you get an only 46 | * partially filled Track instance from an Album instance for example. 47 | * 48 | * @param {Function} fn callback function 49 | * @api public 50 | */ 51 | 52 | Track.prototype.get = 53 | Track.prototype.metadata = function (fn) { 54 | if (this._loaded) { 55 | // already been loaded... 56 | debug('track already loaded'); 57 | return process.nextTick(fn.bind(null, null, this)); 58 | } 59 | var spotify = this._spotify; 60 | var self = this; 61 | spotify.get(this.uri, function (err, track) { 62 | if (err) return fn(err); 63 | // extend this Track instance with the new one's properties 64 | Object.keys(track).forEach(function (key) { 65 | if (!self.hasOwnProperty(key)) { 66 | self[key] = track[key]; 67 | } 68 | }); 69 | fn(null, self); 70 | }); 71 | }; 72 | 73 | /** 74 | * Begins playing this track, returns a Readable stream that outputs MP3 data. 75 | * 76 | * @api public 77 | */ 78 | 79 | Track.prototype.play = function () { 80 | // TODO: add formatting options once we figure that out 81 | var spotify = this._spotify; 82 | var stream = new PassThrough(); 83 | 84 | // if a song was playing before this, the "track_end" command needs to be sent 85 | var track = spotify.currentTrack; 86 | if (track && track._playSession) { 87 | spotify.sendTrackEnd(track._playSession.lid, track.uri, track.duration); 88 | track._playSession = null; 89 | } 90 | 91 | // set this Track instance as the "currentTrack" 92 | spotify.currentTrack = track = this; 93 | 94 | // initiate a "play session" for this Track 95 | spotify.trackUri(track, function (err, res) { 96 | if (err) return stream.emit('error', err); 97 | if (!res.uri) return stream.emit('error', new Error('response contained no "uri"')); 98 | debug('GET %s', res.uri); 99 | track._playSession = res; 100 | var req = spotify.agent.get(res.uri) 101 | .set({ 'User-Agent': spotify.userAgent }) 102 | .end() 103 | .request(); 104 | req.on('response', response); 105 | }); 106 | 107 | function response (res) { 108 | debug('HTTP/%s %s', res.httpVersion, res.statusCode); 109 | if (res.statusCode == 200) { 110 | res.pipe(stream); 111 | } else { 112 | stream.emit('error', new Error('HTTP Status Code ' + res.statusCode)); 113 | } 114 | } 115 | 116 | // return stream immediately so it can be .pipe()'d 117 | return stream; 118 | }; 119 | 120 | /** 121 | * Begins playing a preview of the track, returns a Readable stream that outputs MP3 data. 122 | * 123 | * @api public 124 | */ 125 | 126 | Track.prototype.playPreview = function () { 127 | var spotify = this._spotify; 128 | var stream = new PassThrough(); 129 | var previewUrl = this.previewUrl; 130 | 131 | if (!previewUrl) { 132 | process.nextTick(function() { 133 | stream.emit('error', new Error('Track does not have preview available')); 134 | }); 135 | return stream; 136 | } 137 | 138 | debug('GET %s', previewUrl); 139 | var req = spotify.agent.get(previewUrl) 140 | .set({ 'User-Agent': spotify.userAgent }) 141 | .end() 142 | .request(); 143 | req.on('response', response); 144 | 145 | function response (res) { 146 | debug('HTTP/%s %s', res.httpVersion, res.statusCode); 147 | if (res.statusCode == 200) { 148 | res.pipe(stream); 149 | } else { 150 | stream.emit('error', new Error('HTTP Status Code ' + res.statusCode)); 151 | } 152 | } 153 | 154 | // return stream immediately so it can be .pipe()'d 155 | return stream; 156 | }; 157 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var base62 = require('./base62'); 7 | 8 | /** 9 | * Converts a GID Buffer to an ID hex string. 10 | * Based off of Spotify.Utils.str2hex(), modified to work with Buffers. 11 | */ 12 | 13 | exports.gid2id = function (gid) { 14 | for (var b = '', c = 0, a = gid.length; c < a; ++c) { 15 | b += (gid[c] + 256).toString(16).slice(-2); 16 | } 17 | return b; 18 | }; 19 | 20 | /** 21 | * ID -> URI 22 | */ 23 | 24 | exports.id2uri = function (uriType, v) { 25 | var id = base62.fromHex(v, 22); 26 | return 'spotify:' + uriType + ':' + id; 27 | }; 28 | 29 | /** 30 | * URI -> ID 31 | * 32 | * >>> SpotifyUtil.uri2id('spotify:track:6tdp8sdXrXlPV6AZZN2PE8') 33 | * 'd49fcea60d1f450691669b67af3bda24' 34 | * >>> SpotifyUtil.uri2id('spotify:user:tootallnate:playlist:0Lt5S4hGarhtZmtz7BNTeX') 35 | * '192803a20370c0995f271891a32da6a3' 36 | */ 37 | 38 | exports.uri2id = function (uri) { 39 | var parts = uri.split(':'); 40 | var s; 41 | if (parts.length > 3 && 'playlist' == parts[3]) { 42 | s = parts[4]; 43 | } else { 44 | s = parts[2]; 45 | } 46 | var v = base62.toHex(s); 47 | return v; 48 | }; 49 | 50 | /** 51 | * GID -> URI 52 | */ 53 | 54 | exports.gid2uri = function (uriType, gid) { 55 | var id = exports.gid2id(gid); 56 | return exports.id2uri(uriType, id); 57 | }; 58 | 59 | /** 60 | * Accepts a String URI, returns the "type" of URI. 61 | * i.e. one of "local", "playlist", "track", etc. 62 | */ 63 | 64 | exports.uriType = function (uri) { 65 | var parts = uri.split(':'); 66 | var len = parts.length; 67 | if (len >= 3 && 'local' == parts[1]) { 68 | return 'local'; 69 | } else if (len >= 5) { 70 | return parts[3]; 71 | } else if (len >= 4 && 'starred' == parts[3]) { 72 | return 'playlist'; 73 | } else if (len >= 3) { 74 | return parts[1]; 75 | } else { 76 | throw new Error('could not determine "type" for URI: ' + uri); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-web", 3 | "version": "1.3.0", 4 | "description": "Node.js implementation of the Spotify Web protocol", 5 | "main": "index.js", 6 | "dependencies": { 7 | "cheerio": "~0.18.0", 8 | "debug": "~2.1.1", 9 | "protobufjs": "~2.2.1", 10 | "superagent": "~0.21.0", 11 | "ws": "~0.6.4", 12 | "readable-stream": "~1.0.33" 13 | }, 14 | "optionalDependencies": { 15 | "protobuf": "~0.11.0" 16 | }, 17 | "devDependencies": { 18 | "lame": "~1.2.2", 19 | "speaker": "~0.2.5", 20 | "xml2js": "~0.4.9" 21 | }, 22 | "scripts": { 23 | "test": "echo \"Error: no test specified\" && exit 1" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git://github.com/TooTallNate/node-spotify-web.git" 28 | }, 29 | "author": "Nathan Rajlich (http://tootallnate.net)", 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /proto/bartender.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/bartender.desc -------------------------------------------------------------------------------- /proto/bartender.proto: -------------------------------------------------------------------------------- 1 | package spotify.bartender.proto; 2 | 3 | message StoryRequest { 4 | optional string country = 1; // Two-letter ISO 3166-1 country code. 5 | optional string language = 2; // Two-letter ISO 639-1 language code. 6 | optional string device = 3; // "unknown", "mobile", "tablet" or "desktop". 7 | // More device types could be added in the future. 8 | } 9 | 10 | message StoryList { 11 | repeated Story stories = 1; 12 | 13 | // Whether this is a page of generic fallback stories (as opposed to a personalized ones). 14 | optional bool has_fallback = 12; 15 | } 16 | 17 | enum StoryType { 18 | TYPE_UNKNOWN_STORY = 0; 19 | TYPE_RECOMMENDATION = 1; 20 | TYPE_NEW_RELEASE = 2; 21 | TYPE_SHARED_ITEM = 3; 22 | TYPE_CREATED_ITEM = 4; 23 | TYPE_SUBSCRIBED_TO_ITEM = 5; 24 | TYPE_FOLLOWED_PROFILE = 6; 25 | TYPE_SOCIAL_LISTEN = 7; 26 | TYPE_RECENT_STREAM = 8; 27 | } 28 | 29 | message Story { 30 | 31 | optional int32 version = 1; 32 | 33 | optional string story_id = 2; 34 | 35 | optional StoryType type = 3; 36 | 37 | // The actual music item that is being recommended (track, album, artist, etc.). 38 | optional SpotifyLink recommended_item = 5; 39 | 40 | // The "parent" of the media item, if applicable. For a track, this is the album. For an album, 41 | // this should be the artist. 42 | optional SpotifyLink recommended_item_parent = 6; // DEPRECATED 43 | 44 | // An additional image that may come from 3rd-party metadata, like a concert or news photo. 45 | repeated SpotifyImage hero_image = 8; 46 | 47 | optional Metadata metadata = 9; 48 | 49 | optional RichText reason_text = 10; 50 | 51 | repeated SpotifyImage reason_image = 11; 52 | } 53 | 54 | message RichText { 55 | optional string text = 1; 56 | repeated RichTextField fields = 2; 57 | } 58 | 59 | message RichTextField { 60 | optional string text = 1; 61 | optional string uri = 2; 62 | optional string url = 3; 63 | optional bool bold = 4; 64 | optional bool italic = 5; 65 | optional bool underline = 6; 66 | } 67 | 68 | enum ReasonType { 69 | TYPE_UNKNOWN_REASON = 0; 70 | TYPE_LISTENED_TO = 1; 71 | TYPE_LISTENED_TO2 = 2; // DEPRECATED 72 | TYPE_FOLLOW_USER = 3; 73 | TYPE_FOLLOW_ARTIST = 4; 74 | TYPE_POPULAR = 5; 75 | } 76 | 77 | message Reason { // DEPRECATED 78 | 79 | optional ReasonType type = 1; // DEPRECATED 80 | 81 | repeated SpotifyLink sample_criteria = 2; 82 | 83 | optional int32 criteria_count = 3; 84 | 85 | repeated ReasonType reason_type = 4; 86 | } 87 | 88 | message SpotifyLink { // Can be any spotify object: track, album, artist, playlist or even user. 89 | optional string uri = 1; 90 | optional string display_name = 2; 91 | optional SpotifyLink parent = 3; 92 | repeated SpotifyAudioPreview preview = 6; 93 | } 94 | 95 | message SpotifyAudioPreview { 96 | optional string uri = 1; 97 | optional string file_id = 2; 98 | } 99 | 100 | message SpotifyImage { 101 | optional string uri = 1; 102 | optional string file_id = 2; 103 | optional int32 width = 3; 104 | optional int32 height = 4; 105 | } 106 | 107 | enum MetadataType { 108 | TYPE_UNKNOWN_METADATA = 0; 109 | TYPE_SPOTIFY_DATA = 1; 110 | TYPE_REVIEW = 2; 111 | TYPE_NEWS = 3; 112 | TYPE_CONCERT = 4; 113 | TYPE_PLAYLIST = 5; 114 | } 115 | 116 | enum ScoreType { 117 | TYPE_UNKNOWN_SCORE = 0; 118 | TYPE_FOLLOWER_COUNT = 1; 119 | TYPE_STAR_RATING = 2; 120 | } 121 | 122 | message Metadata { 123 | 124 | // Common data 125 | optional string id = 1; 126 | 127 | optional string app = 2; // May be spotify 128 | 129 | optional MetadataType type = 3; 130 | 131 | optional string title = 4; 132 | 133 | optional string summary = 5; 134 | 135 | optional string favicon_url = 6; 136 | 137 | optional string external_url = 7; 138 | 139 | optional string internal_uri = 8; 140 | 141 | optional int32 dtpublished = 9; 142 | 143 | optional int32 dtexpiry = 10; 144 | 145 | optional SpotifyLink author = 11; 146 | 147 | repeated int32 score = 12; 148 | 149 | repeated ScoreType score_type = 13; 150 | 151 | optional ConcertData concert_data = 14; 152 | 153 | repeated string item_uri = 15; 154 | 155 | repeated SpotifyImage image = 16; // never returned, only used internally 156 | } 157 | 158 | message ConcertData { 159 | optional int32 dtstart = 1; 160 | optional int32 dtend = 2; 161 | optional Location location = 3; 162 | } 163 | 164 | message Location { 165 | optional string name = 1; 166 | optional string city = 2; 167 | optional double lat = 3; 168 | optional double lng = 4; 169 | } 170 | 171 | message DiscoveredPlaylist { 172 | optional string uri = 1; 173 | } 174 | 175 | message DiscoverNux { 176 | optional int32 seen = 1; 177 | } 178 | -------------------------------------------------------------------------------- /proto/mercury.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/mercury.desc -------------------------------------------------------------------------------- /proto/mercury.proto: -------------------------------------------------------------------------------- 1 | package spotify.mercury.proto; 2 | 3 | message MercuryMultiGetRequest { 4 | repeated MercuryRequest request = 1; 5 | } 6 | message MercuryMultiGetReply { 7 | repeated MercuryReply reply = 1; 8 | } 9 | message MercuryRequest { 10 | optional string uri = 1; 11 | optional string content_type = 2; 12 | optional string method = 3; 13 | optional sint32 status_code = 4; 14 | optional string source = 5; 15 | repeated UserField user_fields = 6; 16 | } 17 | message MercuryReply { 18 | enum CachePolicy { 19 | CACHE_NO = 1; 20 | CACHE_PRIVATE = 2; 21 | CACHE_PUBLIC = 3; 22 | } 23 | optional sint32 status_code = 1; 24 | optional string status_message = 2; 25 | optional CachePolicy cache_policy = 3; 26 | optional sint32 ttl = 4; 27 | optional bytes etag = 5; 28 | optional bytes content_type = 6; 29 | optional bytes body = 7; 30 | } 31 | message UserField { 32 | optional string name = 1; 33 | optional bytes value = 2; 34 | } 35 | -------------------------------------------------------------------------------- /proto/metadata.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/metadata.desc -------------------------------------------------------------------------------- /proto/metadata.proto: -------------------------------------------------------------------------------- 1 | package spotify.metadata.proto; 2 | 3 | option optimize_for = SPEED; 4 | option java_package = "com.spotify.metadata.proto"; 5 | option java_outer_classname = "Metadata"; 6 | 7 | message TopTracks { 8 | optional string country = 1; 9 | repeated Track track = 2; 10 | } 11 | message ActivityPeriod { 12 | optional sint32 start_year = 1; 13 | optional sint32 end_year = 2; 14 | optional sint32 decade = 3; 15 | } 16 | message Artist { 17 | optional bytes gid = 1; 18 | optional string name = 2; 19 | optional sint32 popularity = 3; 20 | repeated TopTracks top_track = 4; 21 | repeated AlbumGroup album_group = 5; 22 | repeated AlbumGroup single_group = 6; 23 | repeated AlbumGroup compilation_group = 7; 24 | repeated AlbumGroup appears_on_group = 8; 25 | repeated string genre = 9; 26 | repeated ExternalId external_id = 10; 27 | repeated Image portrait = 11; 28 | repeated Biography biography = 12; 29 | repeated ActivityPeriod activity_period = 13; 30 | repeated Restriction restriction = 14; 31 | repeated Artist related = 15; 32 | optional bool is_portrait_album_cover = 16; 33 | optional ImageGroup portrait_group = 17; 34 | } 35 | message AlbumGroup { 36 | repeated Album album = 1; 37 | } 38 | message Date { 39 | optional sint32 year = 1; 40 | optional sint32 month = 2; 41 | optional sint32 day = 3; 42 | } 43 | message Album { 44 | enum Type { 45 | ALBUM = 1; 46 | SINGLE = 2; 47 | COMPILATION = 3; 48 | } 49 | optional bytes gid = 1; 50 | optional string name = 2; 51 | repeated Artist artist = 3; 52 | optional Type type = 4; 53 | optional string label = 5; 54 | optional Date date = 6; 55 | optional sint32 popularity = 7; 56 | repeated string genre = 8; 57 | repeated Image cover = 9; 58 | repeated ExternalId external_id = 10; 59 | repeated Disc disc = 11; 60 | repeated string review = 12; 61 | repeated Copyright copyright = 13; 62 | repeated Restriction restriction = 14; 63 | repeated Album related = 15; 64 | repeated SalePeriod sale_period = 16; 65 | optional ImageGroup cover_group = 17; 66 | } 67 | 68 | message Track { 69 | optional bytes gid = 1; 70 | optional string name = 2; 71 | optional Album album = 3; 72 | repeated Artist artist = 4; 73 | optional sint32 number = 5; 74 | optional sint32 disc_number = 6; 75 | optional sint32 duration = 7; 76 | optional sint32 popularity = 8; 77 | optional bool explicit = 9; 78 | repeated ExternalId external_id = 10; 79 | repeated Restriction restriction = 11; 80 | repeated AudioFile file = 12; 81 | repeated Track alternative = 13; 82 | repeated SalePeriod sale_period = 14; 83 | repeated AudioFile preview = 15; 84 | } 85 | message Image { 86 | enum Size { 87 | DEFAULT = 0; 88 | SMALL = 1; 89 | LARGE = 2; 90 | XLARGE = 3; 91 | } 92 | optional bytes file_id = 1; 93 | optional Size size = 2; 94 | optional sint32 width = 3; 95 | optional sint32 height = 4; 96 | } 97 | message ImageGroup { 98 | repeated Image image = 1; 99 | } 100 | message Biography { 101 | optional string text = 1; 102 | repeated Image portrait = 2; 103 | repeated ImageGroup portrait_group = 3; 104 | } 105 | message Disc { 106 | optional sint32 number = 1; 107 | optional string name = 2; 108 | repeated Track track = 3; 109 | } 110 | message Copyright { 111 | enum Type { 112 | P = 0; 113 | C = 1; 114 | } 115 | optional Type type = 1; 116 | optional string text = 2; 117 | } 118 | message Restriction { 119 | enum Catalogue { 120 | AD = 0; 121 | SUBSCRIPTION = 1; 122 | SHUFFLE = 3; 123 | } 124 | enum Type { 125 | STREAMING = 0; 126 | } 127 | repeated Catalogue catalogue = 1; 128 | optional string countries_allowed = 2; 129 | optional string countries_forbidden = 3; 130 | optional Type type = 4; 131 | } 132 | 133 | message SalePeriod { 134 | repeated Restriction restriction = 1; 135 | optional Date start = 2; 136 | optional Date end = 3; 137 | } 138 | 139 | message ExternalId { 140 | optional string type = 1; 141 | optional string id = 2; 142 | } 143 | 144 | message AudioFile { 145 | enum Format { 146 | OGG_VORBIS_96 = 0; 147 | OGG_VORBIS_160 = 1; 148 | OGG_VORBIS_320 = 2; 149 | MP3_256 = 3; 150 | MP3_320 = 4; 151 | MP3_160 = 5; 152 | MP3_96 = 6; 153 | } 154 | optional bytes file_id = 1; 155 | optional Format format = 2; 156 | } 157 | -------------------------------------------------------------------------------- /proto/playlist4changes.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4changes.desc -------------------------------------------------------------------------------- /proto/playlist4changes.proto: -------------------------------------------------------------------------------- 1 | import "playlist4content.proto"; 2 | import "playlist4issues.proto"; 3 | import "playlist4meta.proto"; 4 | import "playlist4ops.proto"; 5 | 6 | package spotify.playlist4.proto; 7 | 8 | option optimize_for = SPEED; 9 | option java_package = "com.spotify.playlist4.proto"; 10 | 11 | message ChangeInfo { 12 | optional string user = 1; 13 | optional int32 timestamp = 2; 14 | optional bool admin = 3; 15 | optional bool undo = 4; 16 | optional bool redo = 5; 17 | optional bool merge = 6; 18 | optional bool compressed = 7; 19 | optional bool migration = 8; 20 | } 21 | message Delta { 22 | optional bytes base_version = 1; 23 | repeated Op ops = 2; 24 | optional ChangeInfo info = 4; 25 | } 26 | message Merge { 27 | optional bytes base_version = 1; 28 | optional bytes merge_version = 2; 29 | optional ChangeInfo info = 4; 30 | } 31 | message ChangeSet { 32 | enum Kind { 33 | KIND_UNKNOWN = 0; 34 | DELTA = 2; 35 | MERGE = 3; 36 | }; 37 | required Kind kind = 1; 38 | optional Delta delta = 2; 39 | optional Merge merge = 3; 40 | } 41 | message RevisionTaggedChangeSet { 42 | required bytes revision = 1; 43 | required ChangeSet change_set = 2; 44 | } 45 | message Diff { 46 | required bytes from_revision = 1; 47 | repeated Op ops = 2; 48 | required bytes to_revision = 3; 49 | } 50 | message ListDump { 51 | optional bytes latestRevision = 1; 52 | optional int32 length = 2; 53 | optional ListAttributes attributes = 3; 54 | optional ListChecksum checksum = 4; 55 | optional ListItems contents = 5; 56 | repeated Delta pendingDeltas = 7; 57 | } 58 | message ListChanges { 59 | optional bytes baseRevision = 1; 60 | repeated Delta deltas = 2; 61 | optional bool wantResultingRevisions = 3; 62 | optional bool wantSyncResult = 4; 63 | optional ListDump dump = 5; 64 | repeated int32 nonces = 6; 65 | } 66 | message SelectedListContent { 67 | optional bytes revision = 1; 68 | optional int32 length = 2; 69 | optional ListAttributes attributes = 3; 70 | optional ListChecksum checksum = 4; 71 | optional ListItems contents = 5; 72 | optional Diff diff = 6; 73 | 74 | optional Diff syncResult = 7; 75 | repeated bytes resultingRevisions = 8; 76 | 77 | optional bool multipleHeads = 9; 78 | 79 | optional bool upToDate = 10; 80 | 81 | repeated ClientResolveAction resolveAction = 12; 82 | repeated ClientIssue issues = 13; 83 | 84 | repeated int32 nonces = 14; 85 | } 86 | -------------------------------------------------------------------------------- /proto/playlist4content.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4content.desc -------------------------------------------------------------------------------- /proto/playlist4content.proto: -------------------------------------------------------------------------------- 1 | import "playlist4meta.proto"; 2 | import "playlist4issues.proto"; 3 | 4 | package spotify.playlist4.proto; 5 | 6 | option optimize_for = SPEED; 7 | option java_package = "com.spotify.playlist4.proto"; 8 | 9 | message Item { 10 | required string uri = 1; 11 | optional ItemAttributes attributes = 2; 12 | } 13 | message ListItems { 14 | required int32 pos = 1; 15 | required bool truncated = 2; 16 | repeated Item items = 3; 17 | } 18 | message ContentRange { 19 | required int32 pos = 1; 20 | optional int32 length = 2; 21 | } 22 | message ListContentSelection { 23 | optional bool wantRevision = 1; 24 | optional bool wantLength = 2; 25 | optional bool wantAttributes = 3; 26 | optional bool wantChecksum = 4; 27 | optional bool wantContent = 5; 28 | optional ContentRange contentRange = 6; 29 | optional bool wantDiff = 7; 30 | optional bytes baseRevision = 8; 31 | optional bytes hintRevision = 9; 32 | optional bool wantNothingIfUpToDate = 10; 33 | optional bool wantResolveAction = 12; 34 | repeated ClientIssue issues = 13; 35 | repeated ClientResolveAction resolveAction = 14; 36 | } 37 | -------------------------------------------------------------------------------- /proto/playlist4issues.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4issues.desc -------------------------------------------------------------------------------- /proto/playlist4issues.proto: -------------------------------------------------------------------------------- 1 | package spotify.playlist4.proto; 2 | 3 | option optimize_for = SPEED; 4 | option java_package = "com.spotify.playlist4.proto"; 5 | 6 | message ClientIssue { 7 | enum Level { 8 | LEVEL_UNKNOWN = 0; 9 | LEVEL_DEBUG = 1; 10 | LEVEL_INFO = 2; 11 | LEVEL_NOTICE = 3; 12 | LEVEL_WARNING = 4; 13 | LEVEL_ERROR = 5; 14 | } 15 | enum Code { 16 | CODE_UNKNOWN = 0; 17 | CODE_INDEX_OUT_OF_BOUNDS = 1; 18 | CODE_VERSION_MISMATCH = 2; 19 | CODE_CACHED_CHANGE = 3; 20 | CODE_OFFLINE_CHANGE = 4; 21 | CODE_CONCURRENT_CHANGE = 5; 22 | } 23 | optional Level level = 1; 24 | optional Code code = 2; 25 | optional int32 repeatCount = 3; 26 | } 27 | 28 | message ClientResolveAction { 29 | enum Code { 30 | CODE_UNKNOWN = 0; 31 | CODE_NO_ACTION = 1; 32 | CODE_RETRY = 2; 33 | CODE_RELOAD = 3; 34 | CODE_DISCARD_LOCAL_CHANGES = 4; 35 | CODE_SEND_DUMP = 5; 36 | CODE_DISPLAY_ERROR_MESSAGE = 6; 37 | } 38 | enum Initiator { 39 | INITIATOR_UNKNOWN = 0; 40 | INITIATOR_SERVER = 1; 41 | INITIATOR_CLIENT = 2; 42 | } 43 | optional Code code = 1; 44 | optional Initiator initiator = 2; 45 | } 46 | -------------------------------------------------------------------------------- /proto/playlist4meta.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4meta.desc -------------------------------------------------------------------------------- /proto/playlist4meta.proto: -------------------------------------------------------------------------------- 1 | package spotify.playlist4.proto; 2 | 3 | option optimize_for = SPEED; 4 | option java_package = "com.spotify.playlist4.proto"; 5 | 6 | message ListChecksum { 7 | required int32 version = 1; 8 | optional bytes sha1 = 4; 9 | } 10 | message DownloadFormat { 11 | enum Codec { 12 | CODEC_UNKNOWN = 0; 13 | OGG_VORBIS = 1; 14 | FLAC = 2; 15 | MPEG_1_LAYER_3 = 3; 16 | } 17 | required Codec codec = 1; 18 | } 19 | enum ListAttributeKind { 20 | LIST_UNKNOWN = 0; 21 | LIST_NAME = 1; 22 | LIST_DESCRIPTION = 2; 23 | LIST_PICTURE = 3; 24 | LIST_COLLABORATIVE = 4; 25 | LIST_PL3_VERSION = 5; 26 | LIST_DELETED_BY_OWNER = 6; 27 | LIST_RESTRICTED_COLLABORATIVE = 7; 28 | } 29 | message ListAttributes { 30 | optional string name = 1; 31 | optional string description = 2; 32 | optional bytes picture = 3; 33 | optional bool collaborative = 4; 34 | optional string pl3_version = 5; 35 | optional bool deleted_by_owner = 6; 36 | optional bool restricted_collaborative = 7; 37 | } 38 | enum ItemAttributeKind { 39 | ITEM_UNKNOWN = 0; 40 | ITEM_ADDED_BY = 1; 41 | ITEM_TIMESTAMP = 2; 42 | ITEM_MESSAGE = 3; 43 | ITEM_SEEN = 4; 44 | ITEM_DOWNLOAD_COUNT = 5; 45 | ITEM_DOWNLOAD_FORMAT = 6; 46 | ITEM_SEVENDIGITAL_ID = 7; 47 | ITEM_SEVENDIGITAL_LEFT = 8; 48 | ITEM_SEEN_AT = 9; 49 | } 50 | message ItemAttributes { 51 | optional string added_by = 1; 52 | optional string message = 3; 53 | optional bool seen = 4; 54 | optional DownloadFormat download_format = 6; 55 | optional string sevendigital_id = 7; 56 | } 57 | message StringAttribute { 58 | required string key = 1; 59 | required string value = 2; 60 | } 61 | message StringAttributes { 62 | repeated StringAttribute attribute = 1; 63 | } 64 | -------------------------------------------------------------------------------- /proto/playlist4ops.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4ops.desc -------------------------------------------------------------------------------- /proto/playlist4ops.proto: -------------------------------------------------------------------------------- 1 | import "playlist4content.proto"; 2 | import "playlist4meta.proto"; 3 | 4 | package spotify.playlist4.proto; 5 | 6 | option optimize_for = SPEED; 7 | option java_package = "com.spotify.playlist4.proto"; 8 | 9 | message Add { 10 | optional int32 fromIndex = 1; 11 | repeated Item items = 2; 12 | optional ListChecksum list_checksum = 3; 13 | optional bool addLast = 4; 14 | optional bool addFirst = 5; 15 | } 16 | message Rem { 17 | optional int32 fromIndex = 1; 18 | optional int32 length = 2; 19 | repeated Item items = 3; 20 | optional ListChecksum list_checksum = 4; 21 | optional ListChecksum items_checksum = 5; 22 | optional ListChecksum uris_checksum = 6; 23 | optional bool itemsAsKey = 7; 24 | } 25 | message Mov { 26 | required int32 fromIndex = 1; 27 | required int32 length = 2; 28 | required int32 toIndex = 3; 29 | optional ListChecksum list_checksum = 4; 30 | optional ListChecksum items_checksum = 5; 31 | optional ListChecksum uris_checksum = 6; 32 | } 33 | message ItemAttributesPartialState { 34 | required ItemAttributes values = 1; 35 | repeated ItemAttributeKind no_value = 2; 36 | } 37 | message ListAttributesPartialState { 38 | required ListAttributes values = 1; 39 | repeated ListAttributeKind no_value = 2; 40 | } 41 | message UpdateItemAttributes { 42 | required int32 index = 1; 43 | required ItemAttributesPartialState new_attributes = 2; 44 | optional ItemAttributesPartialState old_attributes = 3; 45 | optional ListChecksum list_checksum = 4; 46 | optional ListChecksum old_attributes_checksum = 5; 47 | } 48 | message UpdateListAttributes { 49 | required ListAttributesPartialState new_attributes = 1; 50 | optional ListAttributesPartialState old_attributes = 2; 51 | optional ListChecksum list_checksum = 3; 52 | optional ListChecksum old_attributes_checksum = 4; 53 | } 54 | message Op { 55 | enum Kind { 56 | KIND_UNKNOWN = 0; 57 | ADD = 2; 58 | REM = 3; 59 | MOV = 4; 60 | UPDATE_ITEM_ATTRIBUTES = 5; 61 | UPDATE_LIST_ATTRIBUTES = 6; 62 | }; 63 | required Kind kind = 1; 64 | optional Add add = 2; 65 | optional Rem rem = 3; 66 | optional Mov mov = 4; 67 | optional UpdateItemAttributes update_item_attributes = 5; 68 | optional UpdateListAttributes update_list_attributes = 6; 69 | } 70 | 71 | message OpList { 72 | repeated Op ops = 1; 73 | } 74 | -------------------------------------------------------------------------------- /proto/playlist4service.desc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TooTallNate/node-spotify-web/9ace59c486d32f194bb01fb578b2f0ddfabbe9a9/proto/playlist4service.desc -------------------------------------------------------------------------------- /proto/playlist4service.proto: -------------------------------------------------------------------------------- 1 | import "playlist4changes.proto"; 2 | import "playlist4content.proto"; 3 | 4 | package spotify.playlist4.proto; 5 | option optimize_for = SPEED; 6 | 7 | message RequestContext { 8 | optional bool administrative = 2; 9 | optional bool migration = 4; 10 | optional string tag = 7; 11 | optional bool useStarredView = 8; 12 | optional bool syncWithPublished = 9; 13 | } 14 | message GetCurrentRevisionArgs { 15 | optional bytes uri = 1; 16 | optional RequestContext context = 2; 17 | } 18 | message GetChangesInSequenceRangeArgs { 19 | optional bytes uri = 1; 20 | optional RequestContext context = 2; 21 | optional int32 fromSequenceNumber = 3; 22 | optional int32 toSequenceNumber = 4; 23 | } 24 | message GetChangesInSequenceRangeMatchingPl3VersionArgs { 25 | optional bytes uri = 1; 26 | optional RequestContext context = 2; 27 | optional int32 fromSequenceNumber = 3; 28 | optional int32 toSequenceNumber = 4; 29 | optional string pl3Version = 5; 30 | } 31 | message GetChangesInSequenceRangeReturn { 32 | repeated RevisionTaggedChangeSet result = 1; 33 | } 34 | message ObliterateListArgs { 35 | optional bytes uri = 1; 36 | optional RequestContext context = 2; 37 | } 38 | message UpdatePublishedArgs { 39 | optional bytes publishedUri = 1; 40 | optional RequestContext context = 2; 41 | optional bytes uri = 3; 42 | optional bool isPublished = 4; 43 | } 44 | message SynchronizeArgs { 45 | optional bytes uri = 1; 46 | optional RequestContext context = 2; 47 | optional ListContentSelection selection = 3; 48 | optional ListChanges changes = 4; 49 | } 50 | message GetSnapshotAtRevisionArgs { 51 | optional bytes uri = 1; 52 | optional RequestContext context = 2; 53 | optional bytes revision = 3; 54 | } 55 | message SubscribeRequest { 56 | repeated bytes uris = 1; 57 | } 58 | message UnsubscribeRequest { 59 | repeated bytes uris = 1; 60 | } 61 | enum Playlist4InboxErrorKind { 62 | INBOX_NOT_ALLOWED = 2; 63 | INBOX_INVALID_USER = 3; 64 | INBOX_INVALID_URI = 4; 65 | INBOX_LIST_TOO_LONG = 5; 66 | } 67 | message Playlist4ServiceException { 68 | optional string why = 1; 69 | optional string symbol = 2; 70 | optional bool permanent = 3; 71 | optional string serviceErrorClass = 4; 72 | optional Playlist4InboxErrorKind inboxErrorKind = 5; 73 | } 74 | message SynchronizeReturn { 75 | optional SelectedListContent result = 1; 76 | optional Playlist4ServiceException exception = 4; 77 | } 78 | enum Playlist4ServiceMethodKind { 79 | METHOD_UNKNOWN = 0; 80 | METHOD_GET_CURRENT_REVISION = 2; 81 | METHOD_GET_CHANGES_IN_SEQUENCE_RANGE = 3; 82 | METHOD_OBLITERATE_LIST = 4; 83 | METHOD_SYNCHRONIZE = 5; 84 | METHOD_UPDATE_PUBLISHED = 6; 85 | METHOD_GET_CHANGES_IN_SEQUENCE_RANGE_MATCHING_PL3_VERSION = 7; 86 | METHOD_GET_SNAPSHOT_AT_REVISION = 8; 87 | } 88 | message Playlist4ServiceCall { 89 | optional Playlist4ServiceMethodKind kind = 1; 90 | optional GetCurrentRevisionArgs getCurrentRevisionArgs = 2; 91 | optional GetChangesInSequenceRangeArgs getChangesInSequenceRangeArgs = 3; 92 | optional ObliterateListArgs obliterateListArgs = 4; 93 | optional SynchronizeArgs synchronizeArgs = 5; 94 | optional UpdatePublishedArgs updatePublishedArgs = 6; 95 | optional GetChangesInSequenceRangeMatchingPl3VersionArgs getChangesInSequenceRangeMatchingPl3VersionArgs = 7; 96 | optional GetSnapshotAtRevisionArgs getSnapshotAtRevisionArgs = 8; 97 | } 98 | message Playlist4ServiceReturn { 99 | optional Playlist4ServiceMethodKind kind = 1; 100 | optional Playlist4ServiceException exception = 2; 101 | optional bytes getCurrentRevisionReturn = 3; 102 | optional GetChangesInSequenceRangeReturn getChangesInSequenceRangeReturn = 4; 103 | optional bool obliterateListReturn = 5; 104 | optional SynchronizeReturn synchronizeReturn = 6; 105 | optional bool updatePublishedReturn = 7; 106 | optional GetChangesInSequenceRangeReturn getChangesInSequenceRangeMatchingPl3VersionReturn = 8; 107 | //optional RevisionTaggedListSnapshot getSnapshotAtRevisionReturn = 9; 108 | optional bytes getSnapshotAtRevisionReturn = 9; 109 | } 110 | message CreateListReply { 111 | required bytes uri = 1; 112 | optional bytes revision = 2; 113 | } 114 | message ModifyReply { 115 | required bytes uri = 1; 116 | optional bytes revision = 2; 117 | } 118 | message PlaylistModificationInfo { 119 | optional bytes uri = 1; 120 | optional bytes new_revision = 2; 121 | } -------------------------------------------------------------------------------- /proto/pubsub.desc: -------------------------------------------------------------------------------- 1 | 2 | m 3 | pubsub.protospotify.hermes.pubsub.proto"@ 4 | Subscription 5 | uri (  6 | expiry ( 7 | status_code ( -------------------------------------------------------------------------------- /proto/pubsub.proto: -------------------------------------------------------------------------------- 1 | package spotify.hermes.pubsub.proto; 2 | 3 | message Subscription { 4 | optional string uri = 1; 5 | optional int32 expiry = 2; 6 | optional int32 status_code = 3; 7 | } -------------------------------------------------------------------------------- /proto/toplist.desc: -------------------------------------------------------------------------------- 1 | 2 | ) 3 | toplist.proto" 4 | Toplist 5 | items ( -------------------------------------------------------------------------------- /proto/toplist.proto: -------------------------------------------------------------------------------- 1 | message Toplist { 2 | repeated string items = 1; 3 | } 4 | --------------------------------------------------------------------------------