diff --git a/example/playlistEvents.js b/example/playlistEvents.js new file mode 100644 index 0000000..d57024f --- /dev/null +++ b/example/playlistEvents.js @@ -0,0 +1,121 @@ + +/** + * Example script that plays a playlist, with live updating of the internal queue + * when the contents of the playlist changes + */ + +var Spotify = require('../'); +var login = require('../login'); +var lame = require('lame'); +var Speaker = require('speaker'); +var uri = process.argv[2]; + +var onPlaylist = function(err, playlist) { + if (err) throw err; + + var playing = false; + var itemsToPlay = []; + var queueOffset = 0; + + playlist.contents(function(err, contents) { + if (err) throw err; + + // add existing playlist contents to queue + contents.forEach(function(playlistItem) { + itemsToPlay.push(playlistItem.item); + }); + + // handle playlist modifications + playlist.contents.on('add', function(change) { + change.add.items.forEach(function(playlistItem, index) { + var item = playlistItem.item; + var position = change.add.fromIndex + index; + if (position < queueOffset) { + console.log('Tracks added before current track, will not be played.'); + return; + } + console.log('Track added to queue: %s at position %d', item.uri, position); + itemsToPlay.splice(position - queueOffset, 0, item); + if (!playing) next(); + }); + }); + playlist.contents.on('rem', function(change) { + itemsToPlay.splice(change.rem.fromIndex - queueOffset, change.rem.length); + console.log('items:', change.rem.items); + console.log('Tracks removed from queue: %s', change.rem.items.map(function(i) { return i.item.uri; }).join(', ')); + // TODO: handle removing current track + }); + playlist.contents.on('mov', function(change) { + console.log('tracks moved', change); + var itemsToMove = itemsToPlay.splice(change.mov.fromIndex - queueOffset, change.mov.toIndex); + if (change.mov.toIndex < queueOffset) { + console.log('Tracks moved to before current track, will not be played.'); + return; + } + var args = [change.mov.toIndex - queueOffset, 0].concat(itemsToMove); + itemsToPlay.splice.apply(itemsToPlay, args); + console.log('Tracks moved in queue'); + // TODO: handle moving current track + }); + + // start playing or wait for tracks + if (itemsToPlay.length) { + console.log('Playing songs from %s.', playlist.uri); + next(); + } else { + console.log('Ready... Add songs to the playlist %s to start playing.', playlist.uri); + } + }); + + var next = function() { + var track = itemsToPlay.shift(); + if (!track) { + console.log('End of queue'); + playing = false; + return; + } + queueOffset++; + if ('track' != track.type) { + console.log('Skipping non-track item:', track); + return next(); + } + + playing = true; + + console.log('Fetching: %s', track.uri); + + track.get(function(err, track) { + if (err) { + console.error(err.stack || err); + return next(); + } + + console.log('Playing: %s - %s', track.artist[0].name, track.name); + + track.play() + .on('error', function (err) { + console.error(err.stack || err); + next(); + }) + .pipe(new lame.Decoder()) + .pipe(new Speaker()) + .on('finish', next); + }); + }; +}; + +// initiate the Spotify session +Spotify.login(login.username, login.password, function (err, spotify) { + if (err) throw err; + + // Load an existing playlist if specified, otherwise create a new one + if (uri && uri.length) { + var type = Spotify.uriType(uri); + if ('playlist' != type) { + throw new Error('Must pass a "playlist" URI, got ' + JSON.stringify(type)); + } + spotify.Playlist.get(uri, onPlaylist); + } else { + spotify.Playlist.create('Test Playlist ' + (new Date().toDateString()), onPlaylist); + } +}); diff --git a/example/rootlist.js b/example/rootlist.js index 2aaddde..aa393c3 100644 --- a/example/rootlist.js +++ b/example/rootlist.js @@ -10,11 +10,16 @@ var login = require('../login'); Spotify.login(login.username, login.password, function (err, spotify) { if (err) throw err; + console.log('Rootlist for %s\n============', spotify.user.username); + // get the currently logged in user's rootlist (playlist names) - spotify.rootlist(function (err, rootlist) { - if (err) throw err; + var rootlist = spotify.user.rootlist(); - console.log(rootlist.contents); + rootlist.contents(function(err, contents) { + if (err) throw err; + contents.forEach(function(item, i) { + console.log('%d. %s', i+1, item.item.uri); + }); spotify.disconnect(); }); diff --git a/lib/album.js b/lib/album.js deleted file mode 100644 index 452407e..0000000 --- a/lib/album.js +++ /dev/null @@ -1,55 +0,0 @@ - -/** - * Module dependencies. - */ - -var util = require('./util'); -var Album = require('./schemas').build('metadata', 'Album'); -var debug = require('debug')('spotify-web:album'); - -/** - * Module exports. - */ - -exports = module.exports = Album; - -/** - * Album URI getter. - */ - -Object.defineProperty(Album.prototype, 'uri', { - get: function () { - return util.gid2uri('album', this.gid); - }, - enumerable: true, - configurable: true -}); - -/** - * Loads all the metadata for this Album instance. Useful for when you get an only - * partially filled Album instance from an Album instance for example. - * - * @param {Function} fn callback function - * @api public - */ - -Album.prototype.get = -Album.prototype.metadata = function (fn) { - if (this._loaded) { - // already been loaded... - debug('album already loaded'); - return process.nextTick(fn.bind(null, null, this)); - } - var spotify = this._spotify; - var self = this; - spotify.get(this.uri, function (err, album) { - if (err) return fn(err); - // extend this Album instance with the new one's properties - Object.keys(album).forEach(function (key) { - if (!self.hasOwnProperty(key)) { - self[key] = album[key]; - } - }); - fn(null, self); - }); -}; diff --git a/lib/artist.js b/lib/artist.js deleted file mode 100644 index c7392fc..0000000 --- a/lib/artist.js +++ /dev/null @@ -1,55 +0,0 @@ - -/** - * Module dependencies. - */ - -var util = require('./util'); -var Artist = require('./schemas').build('metadata', 'Artist'); -var debug = require('debug')('spotify-web:artist'); - -/** - * Module exports. - */ - -exports = module.exports = Artist; - -/** - * Artist URI getter. - */ - -Object.defineProperty(Artist.prototype, 'uri', { - get: function () { - return util.gid2uri('artist', this.gid); - }, - enumerable: true, - configurable: true -}); - -/** - * Loads all the metadata for this Artist instance. Useful for when you get an only - * partially filled Artist instance from an Album instance for example. - * - * @param {Function} fn callback function - * @api public - */ - -Artist.prototype.get = -Artist.prototype.metadata = function (fn) { - if (this._loaded) { - // already been loaded... - debug('artist already loaded'); - return process.nextTick(fn.bind(null, null, this)); - } - var spotify = this._spotify; - var self = this; - spotify.get(this.uri, function (err, artist) { - if (err) return fn(err); - // extend this Artist instance with the new one's properties - Object.keys(artist).forEach(function (key) { - if (!self.hasOwnProperty(key)) { - self[key] = artist[key]; - } - }); - fn(null, self); - }); -}; diff --git a/lib/connection/connection.js b/lib/connection/connection.js new file mode 100644 index 0000000..b7a9f5b --- /dev/null +++ b/lib/connection/connection.js @@ -0,0 +1,371 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('../spotify'); +var WebSocket = require('ws'); +var EventEmitter = require('events').EventEmitter; +var SpotifyError = require('./error'); +var Request = require('./request'); +var Response = require('./response'); +var HermesRequest = require('./hermes_request'); +var HermesResponse = require('./hermes_response'); +var Subscription = require('./subscription'); +var util = require('../util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:connection'); + +/** + * Module exports. + */ + +module.exports = SpotifyConnection; + +/** + * SpotifyConnection base class + * + * @param {Spotify} spotify + * @api public + */ + +function SpotifyConnection(spotify) { + if (!(this instanceof SpotifyConnection)) + return new SpotifyConnection(spotify); + + if ('object' != typeof spotify || !(spotify instanceof Spotify.constructor)) + throw new Error('Spotify instance must be supplied as the first argument to the constructor'); + + EventEmitter.call(this); + + // initalise private instance variables + this._spotify = spotify; + this._heartbeatId = null; + this._callbacks = Object.create(null); + this._subscriptions = []; + this._requestQueueFlushId = null; + + // initalise public instance variables + this.requestQueue = []; + this.requestQueueFlushHandlers = []; + this.seq = 0; + this.heartbeatInterval = 18E4; // 180s, from "spotify.web.client.js" + this.connected = false; // true after the WebSocket "connect" message is sent + this.ws = null; + + // start the "heartbeat" once the WebSocket connection is established + this.once('connect', this._startHeartbeat.bind(this)); + + // handle events + this.on('flush', this._onflush.bind(this)); + this.on('open', this._onopen.bind(this)); + this.on('close', this._onclose.bind(this)); + this.on('message', this._onmessage.bind(this)); + this.on('heartbeat', this.sendHeartbeat.bind(this)); + this.on('command', this._onmessagecommand.bind(this)); +} +SpotifyConnection['$inject'] = ['Spotify']; +inherits(SpotifyConnection, EventEmitter); + +/** + * Re-export namespaces + */ + +util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse, Subscription]); + +/** + * WebSocket "open" event handler + * + * @api private + */ + +SpotifyConnection.prototype._onopen = function () { + debug('WebSocket "open" event'); + + if (!this.connected) { + // need to send "connect" message + this.sendConnect(); + } +}; + +/** + * WebSocket "close" event handler + * + * @api private + */ + +SpotifyConnection.prototype._onclose = function () { + debug('WebSocket "close" event'); + + if (this.connected) { + this.disconnect(); + } +}; + +/** + * WebSocket "message" event handler. + * + * @param {String} + * @api private + */ + +SpotifyConnection.prototype._onmessage = function (data) { + debug('WebSocket "message" event: %s', data); + var msg; + try { + msg = JSON.parse(data); + } catch (e) { + return this.emit('error', e); + } + + var self = this; + var id = msg.id; + var callbacks = this._callbacks; + + function fn (err, res) { + var cb = callbacks[id]; + if (cb) { + // got a callback function! + delete callbacks[id]; + cb.call(self, err, res, msg); + } + } + + if ('error' in msg) { + var err = new SpotifyError(msg.error); + if (null == id) { + this.emit('error', err); + } else { + fn(err); + } + } else if ('message' in msg) { + var command = msg.message[0]; + var args = msg.message.slice(1); + this.emit('command', command, args); + } else if ('id' in msg) { + fn(null, msg); + } else { + // unhandled command + var err = new Error("Unhandled WebSocket message"); + this.emit('error', err); + console.error(err, msg); + } +}; + +/** + * "connect" command callback function. + * + * @param {Object} res response Object + * @api private + */ + +SpotifyConnection.prototype._onconnect = function (err, res) { + debug('SpotifyConnection "connect" event: %s', res); + if (err) return this.emit('error', err); + if ('ok' == res.result) { + debug('connected'); + + this.connected = true; + this.emit('connect'); + + // flush the queue if a flush isn't already queued and there are requests in the queue + if (this.requestQueue.length && !this._requestQueueFlushId) { + this._requestQueueFlushId = setImmediate(this.emit.bind(this, 'flush')); + } + } else { + // TODO: handle possible error case + debug('unhandled error case'); + } +}; + +/** + * Request Queue Flush callback function. + * + * Flushes the request queue by sending out requests + * + * @api private + */ + +SpotifyConnection.prototype._onflush = function() { + if (!this.connected) { + debug('defering queue flush until connection'); + return; + } + + debug('request queue flush, %d request(s) before merge', this.requestQueue.length); + + // call request queue flush handlers + this.requestQueueFlushHandlers.forEach(function(fn) { + fn.call(this, this.requestQueue); + }); + + // combine multiget requests + //this.Metadata.mergeMultiGetRequests(); + + debug('request queue flush, %d request(s) after merge', this.requestQueue.length); + + // send each pending request in the queue + while(this.requestQueue.length) { + this._sendRequest(this.requestQueue.shift()); + } + + this._requestQueueFlushId = null; +}; + +/** + * Handles a "message" command. + * + * @api private + */ + +SpotifyConnection.prototype._onmessagecommand = function (command, args) { + if ('hm_b64' == command) { + var header = new HermesResponse(); + header.parse(args.slice(1)); + this._subscriptions.forEach(function(subscription) { + if (util.checkUri(subscription.uri, header.uri)) { + var response = new HermesResponse(subscription); + response.parse(args.slice(1)); + subscription.emit('response', response); + } + }); + } +}; + +/** + * Start the interval that sends and "sp/echo" command to the Spotify server + * every 180 seconds. + * + * @api private + */ + +SpotifyConnection.prototype._startHeartbeat = function () { + debug('starting heartbeat every %s seconds', this.heartbeatInterval / 1000); + this._heartbeatId = setInterval(this.emit.bind(this, 'heartbeat'), this.heartbeatInterval); +}; + +/** + * Stop the heartbeat interval + */ + +SpotifyConnection.prototype._stopHeartbeat = function () { + clearInterval(this._heartbeatId); + this._heartbeatId = null; +}; + +/** + * Actually send a request + * + * This method should only be called as part of flushing the queue + * + * @param {Request} request + * @api private + */ + +SpotifyConnection.prototype._sendRequest = function (request) { + debug('sendRequest(%s)', request); + var data = request.serialize(); + + // store callback function for later + var callback; + if (request.hasCallback()) { + debug('storing callback function for message id %s', request.id); + callback = this._callbacks[request.id] = request.callback.bind(request); + } else { + debug('no callbacks for message id %s', request.id); + callback = this.emit.bind(this, 'error'); + } + + debug('sending: %s', data); + + try { + this.ws.send(data); + } catch (e) { + callback.call(null, e); + } +}; + +/** + * Connect to the Spotify WebSocket server + * + * @param {String} url WebSocket url + * @param {Function} fn Callback + */ + +SpotifyConnection.prototype.connect = function(url, fn) { + debug('connect(%j)', url); + + this.ws = new WebSocket(url); + + ['open', 'close', 'message'].forEach(function(event) { + this.ws.on(event, this.emit.bind(this, event)); + }, this); + + if ('function' == typeof fn) this.on('connect', fn); +}; + +/** + * Close the WebSocket connection. + * + * This effectively ends your Spotify Web "session" + * (and derefs from the event-loop, so your program can exit). + * + * @api public + */ + +SpotifyConnection.prototype.disconnect = function () { + debug('disconnect()'); + this.connected = false; + this._stopHeartbeat(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.emit('disconnect'); +}; + +/** + * Queue a request to be sent + * + * @param {Request} request + * @api private + */ +SpotifyConnection.prototype.send = function(request) { + if (!(request instanceof Request)) throw new Error('Request must be a SpotifyConnection.Request instance'); + + debug('send(%s)', request); + this.requestQueue.push(request); + + if (!this._requestQueueFlushId) + this._requestQueueFlushId = setImmediate(this.emit.bind(this, 'flush')); +}; + +/** + * Sends the "connect" command. + * Should be called once the WebSocket connection is established. + * + * @param {Function} fn callback function + * @api public + */ + +SpotifyConnection.prototype.sendConnect = function (fn) { + debug('sendConnect()'); + var creds = this._spotify.settings.credentials[0].split(':'); + var args = [ creds[0], creds[1], creds.slice(2).join(':') ]; + var request = this.Request('connect', args); + request.on('callback', this._onconnect.bind(this)); + + // we can't use the queue here as the queue waits until we are connected + this._sendRequest(request); +}; + +/** + * Sends an "sp/echo" command. + * + * @api private + */ + +SpotifyConnection.prototype.sendHeartbeat = function () { + debug('sendHeartbeat()'); + this.Request('sp/echo').send('h'); +}; diff --git a/lib/error.js b/lib/connection/error.js similarity index 100% rename from lib/error.js rename to lib/connection/error.js diff --git a/lib/connection/hermes_request.js b/lib/connection/hermes_request.js new file mode 100644 index 0000000..cdcd8fc --- /dev/null +++ b/lib/connection/hermes_request.js @@ -0,0 +1,284 @@ + +/** + * Module dependencies. + */ + +var Request = require('./request'); +var HermesResponse = require('./hermes_response'); +var schemas = require('../schemas'); +var http = require('http'); +var inherits = require('util').inherits; +var format = require('util').format; +var debug = require('debug')('spotify-web:connection:request:hermes'); + +/** + * Module exports. + */ + +module.exports = HermesRequest; + +/** + * Constants + */ + +const hermesRequestName = 'sp/hm_b64'; +const multiGetRequestType = 'vnd.spotify/mercury-mget-request'; +const multiGetResponseType = 'vnd.spotify/mercury-mget-reply'; + +/** + * Protocol Buffer types. + */ + +var MercuryRequest = schemas.build('mercury','MercuryRequest'); +var MercuryMultiGetRequest = schemas.build('mercury','MercuryMultiGetRequest'); +var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); + +/** + * HermesRequest class constructor. + * + * @param {SpotifyConnection} connection The SpotifyConnection instance + * @param {String} (Optional) method The request method + * @param {Object|String} args The request arguments, or the Hermes URI + */ + +function HermesRequest(connection, method, args) { + Request.call(this, connection, hermesRequestName, null); + + // argument surgery + if ('string' == typeof args) { + args = { uri: args }; + } + if ('string' == typeof method) { + if (!args) args = { uri: method }; + else args.method = method; + } else if (method && !args) { + args = method; + } + args = args || {}; + + debug('HermesRequest(%j)', args); + + this.subrequests = []; + this.response = null; + + this.uri = args.uri || ''; + this.method = args.method || 'GET'; + this.source = args.source || ''; + this.contentType = args.contentType || ''; + this.requestSchema = args.requestSchema || args.payloadSchema || null; // payloadSchema for backwards compat + this.responseSchema = args.responseSchema || null; + this.payload = args.payload || null; + + // TODO(adammw): support user fields +} +HermesRequest['$inject'] = ['SpotifyConnection']; +inherits(HermesRequest, Request); + +/** + * Return the method ID number used in the arguments + * + * @api private + */ +HermesRequest.prototype._methodId = function() { + switch(this.method) { + case "SUB": + return 1; + case "UNSUB": + return 2; + default: + return 0; + } +}; + +/** + * Add a subrequest to the request instance to perform a "multi-get" request + * + * @param {HermesRequest|Object} request The subrequest to add to the parent request instance + */ +HermesRequest.prototype.addSubrequest = function(request) { + debug('addSubrequest()'); + if (!(request instanceof HermesRequest)) request = new HermesRequest(this._spotify, request); + if (request.hasSubrequests()) throw new Error('Cannot add a request with subrequests to another request'); + this.subrequests.push(request); +}; + +/** + * Add multiple subrequests to the request instance to perform a "multi-get" request + * + * @param {Array} requests An array of subrequests + */ +HermesRequest.prototype.addSubrequests = function(requests) { + debug('addSubrequests() : %d requests', requests.length); + if (!Array.isArray(requests)) throw new Error('Argument must be an array'); + requests.forEach(this.addSubrequest.bind(this)); +}; + +/** + * Returns whether or not the request instance has any subrequests added to it + * + * @return {Boolean} + */ +HermesRequest.prototype.hasSubrequests = function() { + debug('hasSubrequests()'); + return Boolean(this.subrequests.length); +}; + +/** + * Return whether or not the request or any subrequests have a callback assigned + * + * @return {Boolean} + */ +HermesRequest.prototype.hasCallback = function() { + debug('hasCallback()'); + if (this.hasSubrequests()) { + for (var i = 0, l = this.subrequests.length; i < l; i++) { + if (this.subrequests[i].hasCallback()) return true; + } + } + return Request.prototype.hasCallback.call(this); +}; + + +/** + * Sets the schema to be used to serialise the payload when sending the request + * + * @param {Schema} schema + */ +HermesRequest.prototype.setRequestSchema = function(schema) { + debug('setRequestSchema()'); + + // TODO(adammw): check that schema is a valid schema + this.requestSchema = schema; +}; + +/** + * Sets the schema to be used to parse the response payload when recieving the payload + * + * @param {Schema} schema + */ +HermesRequest.prototype.setResponseSchema = function(schema) { + debug('setResponseSchema()'); + + // TODO(adammw): check that schema is a valid schema + this.responseSchema = schema; +}; + +/** + * Send the request with the specified payload + * + * @param {Object} (Optional) data Data payload to send with the request + * @param {Function} fn Callback, with signature `function(err, res)` where res is an instance of HermesResponse + */ +HermesRequest.prototype.send = function(data, fn) { + // argument surgery + if ('function' == typeof data) { + fn = data; + data = null; + } + + debug('send(%j)', data); + if (this.sent) throw new Error('Request already sent'); + + // save the data payload + this.payload = data; + + // defer to Request class + // (we cheat a little by setting arguments to null, and overriding them at serialization time) + return Request.prototype.send.call(this, null, fn); +}; + +/** + * Serialise the request to be sent over the wire + * + * @return {String} + */ +HermesRequest.prototype.serialize = function() { + debug('serialize()'); + + if (this.hasSubrequests()) { + this.contentType = multiGetRequestType; + this.method = 'GET'; + this.requestSchema = MercuryMultiGetRequest; + this.responseSchema = MercuryMultiGetReply; + this.payload = { request: this.subrequests }; + } + + // serialise header + var header = MercuryRequest.serialize(this).toString('base64'); + + // construct arguments for request + this.args = [ this._methodId(), header ]; + + // serialize payload + if (this.payload) { + var data = this.payload; + if (this.requestSchema) data = this.requestSchema.serialize(data).toString('base64'); + this.args.push(data); + } + + // defer to Request class + return Request.prototype.serialize.call(this); +}; + +/** + * Invoke the callback and any callbacks of the subrequests. + * + * @param {Error} err + * @param {Response} res + * @api private + */ + +HermesRequest.prototype.callback = function(err, res){ + debug('callback()'); + + if (err && !this.hasCallback()) { + debug('no callback - emitting error event'); + return this.emit('error', err); + } + + if (!err) { + this.response = new HermesResponse(this); + this.response.parse(res); + + // make unsuccessful responses an error + if (!this.response.isSuccess) { + var type = ''; + if (this.response.isClientError) type = 'Client '; + if (this.response.isServerError) type = 'Server '; + if (this.response.isRedirect) type = 'Redirect '; + err = new Error(format('%sError: %s (%d)', type, this.response.statusMessage, this.response.statusCode)); + } + + // call the callbacks of each subrequest + if (this.hasSubrequests()) { + debug('calling %d subrequest callbacks', this.subrequests.length); + + if (multiGetResponseType != this.response.contentType) + err = new Error('Server Error: Server didn\'t send a multi-GET reply for a multi-GET request!'); + + if (err) { // with an error... + this.subrequests.forEach(function(req) { + req.callback(err); + }); + } else { // or with their response data... + var replies = this.response.result.reply; + if (replies.length != this.subrequests.length) + debug("warn: number of replies does not match number of requests"); + for (var i = 0, l = Math.min(replies.length, this.subrequests.length); i < l; i++) { + this.subrequests[i].callback(null, replies[i]); + } + } + } + } + + Request.prototype.callback.call(this, err, null); +}; + +/** + * Return a string representing the Request object + * + * @return {String} + */ +HermesRequest.prototype.toString = function() { + return format('', this.id, this.method, this.uri, this.payload); +}; diff --git a/lib/connection/hermes_response.js b/lib/connection/hermes_response.js new file mode 100644 index 0000000..ba3198a --- /dev/null +++ b/lib/connection/hermes_response.js @@ -0,0 +1,159 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('../spotify'); +var schemas = require('../schemas'); +var http = require('http'); +var debug = require('debug')('spotify-web:connection:response:hermes'); + +/** + * Module exports. + */ + +module.exports = HermesResponse; + +/** + * Protocol Buffer types. + */ + +var MercuryRequest = schemas.build('mercury','MercuryRequest'); +var MercuryReply = schemas.build('mercury','MercuryReply'); + +/** + * HermesResponse base class + * + * @api public + * + * @param {HermesRequest} request + */ + +function HermesResponse(request) { + if (!(this instanceof HermesResponse)) return new HermesResponse(request); + + this._statusMessage = null; + + this.request = request || null; + this.uri = null; + this.contentType = null; + this.statusCode = null; + this.cachePolicy = null; + this.ttl = null; + this.etag = null; + this.userFields = Object.create(null); + this.result = null; +} + +/** + * isSuccess getter. + */ + +Object.defineProperty(HermesResponse.prototype, 'isSuccess', { + get: function () { + return (200 == this.statusCode); + }, + enumerable: true, + configurable: true +}); + +/** + * isRedirect getter. + */ + +Object.defineProperty(HermesResponse.prototype, 'isRedirect', { + get: function () { + return (this.statusCode >= 300 && this.statusCode < 400); + }, + enumerable: true, + configurable: true +}); + +/** + * isClientError getter. + */ + +Object.defineProperty(HermesResponse.prototype, 'isClientError', { + get: function () { + return (this.statusCode >= 400 && this.statusCode < 500); + }, + enumerable: true, + configurable: true +}); + +/** + * isServerError getter. + */ + +Object.defineProperty(HermesResponse.prototype, 'isServerError', { + get: function () { + return (this.statusCode >= 500 && this.statusCode < 600); + }, + enumerable: true, + configurable: true +}); + +/** + * statusMessage getter. + */ +Object.defineProperty(HermesResponse.prototype, 'statusMessage', { + get: function () { + if (this._statusMessage) return this._statusMessage; + return http.STATUS_CODES[this.statusCode] || 'Unknown Status Code'; + }, + enumerable: true, + configurable: true +}); + +/** + * HermesResponse parser + * + * @param {Array} data + */ +HermesResponse.prototype.parse = function(data) { + debug('parse(%j)', data); + // special case where the callback is invoked from parent multi-get request + if (data instanceof MercuryReply) { + this.uri = this.request.uri; + this.contentType = data.contentType.toString(); + this.statusCode = data.statusCode; + this._statusMessage = data.statusMessage; + this.cachePolicy = data.cachePolicy.replace('CACHE_','').toLowerCase(); + this.ttl = data.ttl; + this.etag = data.etag; + this.result = data.body; + + // general case + } else { + if (data.result) { + data = data.result; + } + + var header = MercuryRequest.parse(new Buffer(data[0], 'base64')); + + this.uri = header.uri; + this.contentType = header.contentType; + this.statusCode = header.statusCode; + + if (header.userFields) { + header.userFields.forEach(function(field) { + this.userFields[field.name] = field.value; + }, this); + + if ('MC-Cache-Policy' in this.userFields) + this.cachePolicy = this.userFields['MC-Cache-Policy'].toString(); + if ('MC-ETag' in this.userFields) + this.etag = this.userFields['MC-ETag']; + if ('MC-TTL' in this.userFields) + this.ttl = Number(this.userFields['MC-TTL'].toString()); + } + + if (data.length > 1) + this.result = new Buffer(data[1], 'base64'); + } + + if (this.result && this.request && this.request.responseSchema) + this.result = this.request.responseSchema.parse(this.result); + + debug('%s response [%d / %s] - %j', this.uri, this.statusCode, this.contentType, this.result); +}; diff --git a/lib/connection/index.js b/lib/connection/index.js new file mode 100644 index 0000000..104cf23 --- /dev/null +++ b/lib/connection/index.js @@ -0,0 +1,6 @@ + +/** + * Module exports. + */ + +module.exports = require('./connection'); \ No newline at end of file diff --git a/lib/connection/request.js b/lib/connection/request.js new file mode 100644 index 0000000..1f2303f --- /dev/null +++ b/lib/connection/request.js @@ -0,0 +1,148 @@ + +/** + * Module dependencies. + */ + +var SpotifyConnection = require('./connection'); +var Response = require('./response'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +var format = require('util').format; +var util = require('../util'); +var debug = require('debug')('spotify-web:connection:request'); + +/** + * Module exports. + */ + +module.exports = Request; + +/** + * Request base class + * + * @api public + * + * @param {SpotifyConnection} connection + * @param {String} name + * @param {Array} (Optional) args Arguments to send with the request + */ + +function Request(connection, name, args) { + debug('Request(%j, %j)', name, args); + if (!(this instanceof Request)) + return new Request(connection, name, args); + if ('object' != typeof connection || !(connection instanceof SpotifyConnection.constructor)) + throw new Error('SpotifyConnection instance must be supplied as the first argument to the constructor'); + if (name && 'string' != typeof name) + throw new Error('Name arguments must be a String'); + EventEmitter.call(this); + + this._connection = connection; + this.name = name || null; + this.args = args || null; + this.id = null; + this.response = null; + this.sent = false; + + if (this.name && 'connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; +} +Request['$inject'] = ['SpotifyConnection']; +inherits(Request, EventEmitter); + +/** + * Return a string representing the Request object + * + * @return {String} + */ +Request.prototype.toString = function() { + return format('', this.id, this.name, this.args); +}; + +/** + * Send the request with the specified payload + * + * @param {Array} (Optional) args Arguments to send with the request + * @param {Function} fn Callback, with signature `function(err, res)` where res is an instance of Response + */ +Request.prototype.send = function(args, fn) { + // argument surgery + if ('function' == typeof args) { + fn = args; + args = undefined; + } + + debug('send(%j)', args); + + if (this.sent) throw new Error('Request already sent'); + + // save the callback and arguments + this.on('callback', util.wrapCallback(fn, this)); + if (undefined !== args) this.args = args; + + // queue the request to be sent + this.sent = true; + this._connection.send(this); +}; + + +/** + * Serialise the request to be sent over the wire + * + * @return {String} + */ +Request.prototype.serialize = function() { + // Generate id if not set + if (!this.id) this.id = String(this._connection.seq++); + + // Construct and return serialized message + var msg = { + name: this.name, + id: this.id, + args: this.args + }; + + var data = JSON.stringify(msg); + debug('serialise() : %s', data); + return data; +}; + +/** + * Return whether or not the request has a callback assigned + * + * @return {Boolean} + */ + +Request.prototype.hasCallback = function() { + var numCallbackListeners = EventEmitter.listenerCount(this, 'callback'); + var numResponseListeners = EventEmitter.listenerCount(this, 'response'); + var numErrorListeners = EventEmitter.listenerCount(this, 'error'); + + debug('callback count - "callback": %d, "response": %d, "error": %d', numCallbackListeners, numResponseListeners, numErrorListeners); + return Boolean(numCallbackListeners || numResponseListeners || numErrorListeners); +}; + +/** + * Invokes the callback with `err` and `res` and handle arity check. + * + * Called when a message comes back with the same ID number as what we sent. + * + * @param {Error} err + * @param {Object} res + * @api private + */ + +Request.prototype.callback = function(err, res){ + debug('callback()'); + + // create the response object and parse the result + if (!err && !this.response) { + this.response = new Response(this); + this.response.parse(res); + } + + // emit response event + if (this.response) this.emit('response', this.response); + + // invoke callback + this.emit('callback', err, this.response); +}; diff --git a/lib/connection/response.js b/lib/connection/response.js new file mode 100644 index 0000000..43e2096 --- /dev/null +++ b/lib/connection/response.js @@ -0,0 +1,53 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('../spotify'); +var debug = require('debug')('spotify-web:connection:response'); + +/** + * Module exports. + */ + +module.exports = Response; + +/** + * Response base class + * + * @api public + * + * @param {Request} request + */ + +function Response(request) { + if (!(this instanceof Response)) return new Response(request); + + this.request = request || null; + this.result = null; + this.error = null; +} + +/** + * isSuccess getter. + */ + +Object.defineProperty(Response.prototype, 'isSuccess', { + get: function () { + return (null === this.error); + }, + enumerable: true, + configurable: true +}); + + +/** + * Response parser + * + * @param {Array} result + */ +Response.prototype.parse = function(data) { + debug('parse(%j)', data); + this.result = data.result || null; + this.error = data.error || null; +}; diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js new file mode 100644 index 0000000..8eba6b1 --- /dev/null +++ b/lib/connection/subscription.js @@ -0,0 +1,149 @@ +/** + * Module dependencies. + */ + +var SpotifyConnection = require('./connection'); +var HermesResponse = require('./hermes_response'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +var format = require('util').format; +var debug = require('debug')('spotify-web:connection:subscription'); + +/** + * Module exports. + */ + +module.exports = Subscription; + +/** + * Subscription base class + * + * @api public + * + * @param {SpotifyConnection} connection + */ + +function Subscription(connection, uri) { + debug('Subscription(%j)', uri); + if (!(this instanceof Subscription)) + return new Subscription(connection); + if ('object' != typeof connection || !(connection instanceof SpotifyConnection.constructor)) + throw new Error('SpotifyConnection instance must be supplied as the first argument to the constructor'); + EventEmitter.call(this); + + this._connection = connection; + this._subscription = null; + this.subscribeHandler = null; + this.unsubscribeHandler = null; + this.responseSchema = null; + this.uri = uri; +} +Subscription['$inject'] = ['SpotifyConnection']; +inherits(Subscription, EventEmitter); + +/** + * Handle subscription + * + * @api private + */ +Subscription.prototype._onsubscribed = function(err, response) { + debug('%s#_onsubscribed() : %j', this, response.result); + this._subscription = response.result; + if (this._subscription.uri) this.uri = this._subscription.uri; +}; + +/** + * Handle unsubscription + * + * @api private + */ +Subscription.prototype._onunsubscribed = function(err) { + +}; + +/** + * Set the subscribe handler which is used to make the Subscribe requests + * + * @param {Function} fn Function with signature `function(fn)` where fn is a callback with signature `function(err, subscription)` where subscription is the returned subscription + */ +Subscription.prototype.setSubscribeHandler = function(fn) { + this.subscribeHandler = fn; +}; + +/** + * Set the unsubscribe handler which is used to make the Unsubscribe requests + * + * @param {Function} fn Function with signature `function(fn)` where fn is a callback with signature `function(err)` + */ +Subscription.prototype.setUnsubscribeHandler = function(fn) { + this.unsubscribeHandler = fn; +}; + +/** + * Sets the schema to be used to parse the response payload when recieving the payload + * + * @param {Schema} schema + */ +Subscription.prototype.setResponseSchema = function(schema) { + debug('setResponseSchema()'); + + // TODO(adammw): check that schema is a valid schema + this.responseSchema = schema; +}; + +/** + * Returns if the subscription is subscribed + * + * @return {Boolean} + */ +Subscription.prototype.subscribed = function() { + return (-1 !== this._connection._subscriptions.indexOf(this)); +}; + +/** + * Add the subscription to the connection's list of active subscriptions + * and call the subscribe handler if it's the first active subscription for this uri + */ +Subscription.prototype.subscribe = function() { + debug('%s#subscribe()', this); + if (!this.subscribeHandler) throw new Error("Subscribe Handler not set"); + var subscriptions = this._connection._subscriptions; + if (-1 !== subscriptions.indexOf(this)) { + debug('already subscribed - ignoring subscribe()'); + return; + } + subscriptions.push(this); + debug('added subscription'); + for (var i = 0, l = subscriptions.length; i < l; i++) { + var subscription = subscriptions[i]; + if (subscription != this && subscription.uri == this.uri) return; + } + debug('we are the only subscription for this url, calling subscribeHandler'); + this.subscribeHandler(this._onsubscribed.bind(this)); +}; + +/** + * Remove the subscription to the connection's list of active subscriptions + * and call the unsubscribe handler if it's the last active subscription for this uri + */ +Subscription.prototype.unsubscribe = function() { + debug('%s#unsubscribe()', this); + if (!this.unsubscribeHandler) throw new Error("Unsubscribe Handler not set"); + var subscriptions = this._connection._subscriptions; + var idx; + if (-1 === (idx = subscriptions.indexOf(this))) { + debug('not subscribed - ignoring unsubscribe()'); + return; + } + debug('removing subscription'); + subscriptions.splice(idx, 1); + for (var i = 0, l = subscriptions.length; i < l; i++) { + if (subscriptions[i].uri == this.uri) return; + } + debug('we are the last subscription for this url, calling unsubscribeHandler'); + this.unsubscribeHandler(this._onunsubscribed.bind(this)); +}; + +Subscription.prototype.toString = function() { + return format('', this.uri); +}; diff --git a/lib/metadata/album.js b/lib/metadata/album.js new file mode 100644 index 0000000..9f07006 --- /dev/null +++ b/lib/metadata/album.js @@ -0,0 +1,56 @@ + +/** + * Module dependencies. + */ + +var Metadata = require('./metadata'); +var util = require('../util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:metadata:album'); + +/** + * Module exports. + */ + +exports = module.exports = Album; + +/** + * Creates a new Album instance with the specified uri, or in the case of multiple uris, + * creates an array of new Album instances. + * + * Instances will only contain a URI and will not have metadata populated + * + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single URI, or an Array of URIs to get Album instances for + * @param {Function} (Optional) fn callback function + * @return {Array|Album} + * @api public + */ + +Album.get = util.bind(Metadata.get, null, Album); + +/** + * Check whether the class supports construction from a specific schema/object + * + * @param {Object} schema + * @return {Boolean} + * @api private + */ + +Album._acceptsSchema = util.bind(Metadata._acceptsSchema, null, 'album'); + +/** + * Album class. + * + * @api public + */ + +function Album (spotify, uri, parent) { + if (!(this instanceof Album)) return new Album(spotify, uri, parent); + this.type = 'album'; + Metadata.call(this, spotify, uri, parent); +} +inherits(Album, Metadata); +Album['$inject'] = ['Spotify']; + +Album.prototype._acceptsSchema = Album._acceptsSchema; diff --git a/lib/metadata/artist.js b/lib/metadata/artist.js new file mode 100644 index 0000000..0826f3c --- /dev/null +++ b/lib/metadata/artist.js @@ -0,0 +1,56 @@ + +/** + * Module dependencies. + */ + +var Metadata = require('./metadata'); +var util = require('../util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:metadata:artist'); + +/** + * Module exports. + */ + +exports = module.exports = Artist; + +/** + * Creates a new Artist instance with the specified uri, or in the case of multiple uris, + * creates an array of new Artist instances. + * + * Instances will only contain a URI and will not have metadata populated + * + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single URI, or an Array of URIs to get Artist instances for + * @param {Function} (Optional) fn callback function + * @return {Array|Artist} + * @api public + */ + +Artist.get = util.bind(Metadata.get, null, Artist); + +/** + * Check whether the class supports construction from a specific schema/object + * + * @param {Object} schema + * @return {Boolean} + * @api private + */ + +Artist._acceptsSchema = util.bind(Metadata._acceptsSchema, null, 'artist'); + +/** + * Artist class. + * + * @api public + */ + +function Artist (spotify, uri, parent) { + if (!(this instanceof Artist)) return new Artist(spotify, uri, parent); + this.type = 'artist'; + Metadata.call(this, spotify, uri, parent); +} +inherits(Artist, Metadata); +Artist['$inject'] = ['Spotify']; + +Artist.prototype._acceptsSchema = Artist._acceptsSchema; diff --git a/lib/metadata/index.js b/lib/metadata/index.js new file mode 100644 index 0000000..6f88aea --- /dev/null +++ b/lib/metadata/index.js @@ -0,0 +1,6 @@ + +/** + * Module exports. + */ + +module.exports = require('./metadata'); diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js new file mode 100644 index 0000000..a9b62c5 --- /dev/null +++ b/lib/metadata/metadata.js @@ -0,0 +1,265 @@ + +/** + * Module dependencies. + */ + +var util = require('../util'); +var schemas = require('../schemas'); +var SpotifyUri = require('../uri'); +var querystring = require('querystring'); +var debug = require('debug')('spotify-web:metadata'); + +/** + * Module exports. + */ + +exports = module.exports = Metadata; + +/** + * Protocol Buffer types. + */ + +Metadata.schemas = { + album: schemas.build('metadata', 'Album'), + artist: schemas.build('metadata', 'Artist'), + track: schemas.build('metadata', 'Track'), +}; + +/** + * Creates a new Metadata instance with the specified uri, or in the case of multiple uris, + * creates an array of new Metadata instances. + * + * Instances will only contain a URI and will not have metadata populated + * + * @param {Metadata} type + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single URI, or an Array of URIs to get Metadata instances for + * @param {Function} (Optional) fn callback function + * @return {Array|Metadata} + * @api public + */ + +Metadata.get = function(type, spotify, uri, fn) { + debug('get(%j)', uri); + + // convert input uris to array but save if we should return an array or a bare object + var returnArray = Array.isArray(uri); + if (!returnArray) uri = [uri]; + + // call the Metadata constructor for each uri, and call the callback if we have an error + var metadataObjs; + try { + metadataObjs = uri.map(type.bind(null, spotify)); + } catch (e) { + if ('function' == typeof fn) process.nextTick(fn.bind(null, e)); + return null; + } + + // return the array of metadataObjs or a single metadataObj and call callbacks if applicable + var ret = (returnArray) ? metadataObjs : metadataObjs[0]; + if ('function' == typeof fn) process.nextTick(fn.bind(null, null, ret)); + return ret; +}; +Metadata.get['$inject'] = [null, 'Spotify']; + +/** + * Check whether the class supports construction from a specific schema/object + * + * @param {String} type + * @param {Object} schema + * @return {Boolean} + * @api private + */ +Metadata._acceptsSchema = function(type, schema) { + return (type && Metadata.schemas[type] && schema instanceof Metadata.schemas[type]); +}; + +/** + * Merge any pending metadata requests into a multi-GET request if possible + * + * @param {Spotify} spotify + * @api private + */ +Metadata.mergeMultiGetRequests = function(spotify) { + debug('mergeMultiGetRequests()'); + // TODO(adammw): we should be sending 100 subrequests at most, + // and if over this limit they should be split up into batches + + // TODO(adammw): try harder to retain the original ordering of requests + + var multiGet = { + track: {}, + artist: {}, + album: {} + }; + + var requestQueue = spotify.connection.requestQueue; + + // search for candidates for combination + for (var i = requestQueue.length - 1; i >= 0; i -= 1) { + var request = requestQueue[i]; + if (request instanceof spotify.HermesRequest && !request.hasSubrequests()) { + var match; + if (request.uri && 'GET' == request.method && (match = /^hm:\/\/metadata\/(track|artist|album)\/[0-9a-f]+(?:\?(.+))?$/.exec(request.uri))) { + var type = match[1]; + var qs = match[2] || ''; + if (!multiGet[type][qs]) multiGet[type][qs] = []; + multiGet[type][qs].push(request); + requestQueue.splice(i, 1); + } + } + } + + // combine requests on type and querystring + Object.keys(multiGet).forEach(function(type) { + Object.keys(multiGet[type]).forEach(function(qs) { + debug('%d candidates for multiget combination for type: %s and querystring "%s"', multiGet[type][qs].length, type, qs); + + var candidates = multiGet[type][qs]; + candidates.reverse(); // requests were extracted from going backwards, so reverse it again to compensate + + // leave single requests unchanged + var request; + if (candidates.length == 1) { + request = candidates[0]; + } else { + debug('creating new multi-get request for %s with querystring "%s"', type, qs); + var hm_uri = 'hm://metadata/' + type + 's'; + if (qs) hm_uri += '?' + qs; + request = new spotify.HermesRequest(hm_uri); + request.addSubrequests(candidates); + } + + requestQueue.push(request); + }); + }); +}; +Metadata.mergeMultiGetRequests['$inject'] = ['Spotify']; + +/** + * Metadata class. + * + * @api public + */ + +function Metadata (spotify, uri, parent) { + if (!(this instanceof Metadata) || !this.type) throw new Error('Invalid use of Metadata object'); + + this._spotify = spotify; + this._parent = parent || null; + this._loaded = false; + this._prerestricted = (this._parent instanceof Metadata) ? this._parent._prerestricted : false; + + // if a uri was passed in, ensure it is of the correct type + if ('string' == typeof uri) uri = new SpotifyUri(uri); + if (uri instanceof SpotifyUri) { + if (this.type != uri.type) throw new Error('Invalid URI Type: ' + uri.type); + this.uri = uri; + return this; // constructor + + // if an object was passed in, update the object with the properties + // of the passed in object only if it is of one of the accepted schemas + } else if ('object' == typeof uri) { + if (this._acceptsSchema(uri)) { + this._update(uri, true); + return this; // constructor + } + } + + throw new Error('ArgumentError: Invalid arguments'); +} +Metadata['$inject'] = ['Spotify']; + +/** + * Re-export subtypes + */ + +var Album = require('./album'); // these require() statements MUST be after all static methods are defined +var Artist = require('./artist'); +var Track = require('./track'); + +util.export(Metadata, [Album, Artist, Track]); + +/** + * Update the Metadata instance with the properties of another object + * + * @param {Object} obj + * @param {Boolean} (Optional) partial set to true if the object is non-authorative to ensure the _loaded flag is not set + * @api private + */ +Metadata.prototype._update = function(obj, partial) { + var self = this; + var spotify = this._spotify; + + // TODO(adammw): update this._prerestricted on all the objects created by spotify._objectify() calls + + if (obj.gid) { + this.uri = SpotifyUri.fromGid(this.type, obj.gid); + } + + Object.keys(obj).forEach(function (key) { + if (!self.hasOwnProperty(key)) { + self[key] = spotify._objectify(obj[key]); + } + }); + + if (!partial) this._loaded = true; +}; + +/** + * Loads all the metadata for this Metadata instance. + * + * @param {Boolean} (Optional) restrictToAvailable restrict the data loaded to only that which is available to the current user, defaults to true + * @param {Boolean} (Optional) refresh + * @param {Function} fn callback function + * @api public + */ + +Metadata.prototype.get = +Metadata.prototype.metadata = function (restrictToAvailable, refresh, fn) { + // argument surgery + if ('function' == typeof refresh) { + fn = refresh; + refresh = null; + } + if ('function' == typeof restrictToAvailable) { + fn = restrictToAvailable; + restrictToAvailable = refresh = null; + } + if (null === refresh) refresh = false; + if (null === restrictToAvailable) restrictToAvailable = true; + + debug('metadata(%j)', refresh); + + var self = this; + var spotify = this._spotify; + + // TODO(adammw): don't send request twice if eg. there are two callbacks, ie set 'requestSent' after first request sent + + if (!refresh && this._loaded) { + // already been loaded... + debug('metadata object already loaded'); + return process.nextTick(fn.bind(null, null, this)); + } + + var hm_uri = 'hm://metadata/' + this.type + '/' + this.uri.id; + // adding the query parameter filters the metadata on the server side to only those + // that are available for your country / account type, and does not return any restriction + // information for alternatives (as they are already filtered) + if (restrictToAvailable && spotify.user_info) { + hm_uri += '?' + querystring.stringify({ + country: spotify.user_info.country, + catalogue: spotify.user_info.catalogue, + locale: spotify.user_info.preferred_locale + }); + this._prerestricted = true; + } + + var request = new spotify.HermesRequest(hm_uri); + request.setResponseSchema(Metadata.schemas[this.type]); + request.send(function(err, res) { + if (err) return fn(err); + self._update(res.result); + fn(null, self); + }); +}; diff --git a/lib/metadata/play_session.js b/lib/metadata/play_session.js new file mode 100644 index 0000000..880a30c --- /dev/null +++ b/lib/metadata/play_session.js @@ -0,0 +1,319 @@ + +/** + * Module dependencies. + */ + +var util = require('../util'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +var PassThrough = require('stream').PassThrough; +var debug = require('debug')('spotify-web:metadata:track:playsession'); + +// node v0.8.x compat +if (!PassThrough) PassThrough = require('readable-stream/passthrough'); + +/** + * Module exports. + */ + +module.exports = PlaySession; + +/** + * PlaySession class. + * + * @api public + */ + +function PlaySession(track, args) { + EventEmitter.call(this); + + this._defaultCallback = this._defaultCallback.bind(this); + this._req = null; + this._started = null; + this._track = track; + this.aborted = false; + this.ended = false; + this.stream = new PassThrough(); + this.lid = args.lid || null; + this.tid = args.tid || null; + this.type = args.type || null; + this.uri = args.uri || null; +} +inherits(PlaySession, EventEmitter); +PlaySession['$inject'] = ['Track']; + +/** + * Default callback function for when the user does not pass a + * callback function of their own. + * + * @param {Error} err + * @api private + */ + +PlaySession.prototype._defaultCallback = function (err) { + if (err) this.emit('error', err); +}; + +/** + * Abort downloading a playing track + */ +PlaySession.prototype.abort = function() { + debug('abort()'); + // TODO(adammw): check the download hasn't already finished + if (this.aborted === false && this._started) { + this.aborted = true; + this._req.abort(); + this._req.res.unpipe(this.stream); + process.nextTick(this.emit.bind(this, 'abort')); + } +}; + +/** + * Begins playing this track, returns a Readable stream that outputs MP3 data. + * + * @param {Function} (Optional) fn callback with signature `function(err, stream)` or `function(stream)` + * @return {Stream} + * @api public + */ + +PlaySession.prototype.play = function(fn) { + debug('play()'); + + var self = this; + var spotify = this._track._spotify; + var stream = this.stream; + + var callback = function(err, data) { + if (err && ('function' != typeof fn || ('function' == typeof fn && fn.length == 1))) { + process.nextTick(self.emit.bind(self, 'error', err)); + process.nextTick(stream.emit.bind(stream, 'error', err)); + } else if ('function' == typeof fn) { + return fn(err, data); + } + if ('function' == typeof fn) fn(data); + }; + + // we only play once... + if (this._started) { + return callback(new Error('PlaySession already started')); + } + + // TODO(adammw): implement rtmp handling + if (/^rtmp(t|e|s){0,2}:\/\//.test(this.uri)) { + return callback(new Error('TODO: implement rtmp transport!')); + } + + this._started = true; + + // if a song was playing before this, the "track_end" command needs to be sent + var session = spotify.currentPlaySession; + if (session && !session.ended) session.end(); + + // set this PlaySession instance as the "currentPlaySession" + spotify.currentPlaySession = this; + + // make the GET request to the uri + debug('GET %s', this.uri); + this._req = spotify.agent.get(this.uri) + .set({ 'User-Agent': spotify.userAgent }) + .end() + .request(); + this._req.on('response', function(res) { + debug('HTTP/%s %s', res.httpVersion, res.statusCode); + if (res.statusCode == 200) { + self._started = Date.now(); + res.pipe(stream); + process.nextTick(self.emit.bind(self, 'response', res)); + process.nextTick(self.emit.bind(self, 'stream', stream)); + callback(null, stream); + } else { + callback(new Error('HTTP Status Code ' + res.statusCode)); + } + }); + + // return stream immediately so it can be .pipe()'d + return stream; +}; + +/** + * Sends the "sp/track_end" event. This is required after each track is played, + * otherwise Spotify limits you to 3 track URL fetches per session. + * + * @param {Number} (Optional) ms number of milliseconds played, defaults to track duration or time existed, whichever is lesser + * @param {Function} (Optional) fn callback function + * @api public + */ + +PlaySession.prototype.end = function (ms, fn) { + // argument surgery + if ('function' == typeof ms) { + fn = ms; + ms = null; + } + if (null === ms) { + ms = Math.min(this._track.duration, Date.now() - this._started); + } + + if (!fn) fn = this._defaultCallback; + + if (this.ended) return process.nextTick(fn.bind(null, new Error('PlaySession ended'))); + + debug('sendTrackEnd(%j, %j, %j)', this.lid, this._track.uri, ms); + + this.ended = true; + + var ms_played = Number(ms); + var ms_played_union = ms_played; + var n_seeks_forward = 0; + var n_seeks_backward = 0; + var ms_seeks_forward = 0; + var ms_seeks_backward = 0; + var ms_latency = 100; + var display_track = null; + var play_context = 'unknown'; + var source_start = 'unknown'; + var source_end = 'unknown'; + var reason_start = 'unknown'; + var reason_end = 'unknown'; + var referrer = 'unknown'; + var referrer_version = '0.1.0'; + var referrer_vendor = 'com.spotify'; + var max_continuous = ms_played; + var args = [ + this.lid, + ms_played, + ms_played_union, + n_seeks_forward, + n_seeks_backward, + ms_seeks_forward, + ms_seeks_backward, + ms_latency, + display_track, + play_context, + source_start, + source_end, + reason_start, + reason_end, + referrer, + referrer_version, + referrer_vendor, + max_continuous + ]; + + var spotify = this._track._spotify; + var request = new spotify.Request('sp/track_end', args); + request.send(function (err, res) { + if (err) return fn(err); + if (null === res.data) { + // apparently no result means "ok" + fn(); + } else { + // TODO: handle error case + debug('non-null sp/track_end result: %j', res.data); + } + }); +}; + +/** + * Sends the "sp/track_event" event. These are pause and play events (possibly + * others). + * + * @param {String} event + * @param {Number} (Optional) ms number of milliseconds played so far + * @param {Function} (Optional) fn callback function + * @api public + */ + +PlaySession.prototype.event = function (event, ms, fn) { + // argument surgery + if ('function' == typeof ms) { + fn = ms; + ms = null; + } + if (null === ms) { + ms = Math.min(this._track.duration, Date.now() - this._started); + } + + if (!fn) fn = this._defaultCallback; + + if (this.ended) return process.nextTick(fn.bind(null, new Error('PlaySession ended'))); + + debug('sendTrackEvent(%j, %j, %j)', this.lid, event, ms); + + var num = event; + var args = [ this.lid, num, ms ]; + + var spotify = this._track._spotify; + var request = new spotify.Request('sp/track_event', args); + request.send(function (err, res) { + if (err) return fn(err); + if (null === res.data) { + // apparently no result means "ok" + fn(); + } else { + // TODO: handle error case + debug('non-null sp/track_event result: %j', res.data); + } + }); +}; + +/** + * Sends the "sp/track_progress" event. Should be called periodically while + * playing a Track. + * + * @param {Number} (Optional) ms number of milliseconds played so far + * @param {Function} (Optional) fn callback function + * @api public + */ + +PlaySession.prototype.progress = function (lid, ms, fn) { + // argument surgery + if ('function' == typeof ms) { + fn = ms; + ms = null; + } + if (null === ms) { + ms = Math.min(this._track.duration, Date.now() - this._started); + } + + if (!fn) fn = this._defaultCallback; + + if (this.ended) return process.nextTick(fn.bind(null, new Error('PlaySession ended'))); + + debug('sendTrackProgress(%j, %j)', this.lid, ms); + + var ms_played = Number(ms); + var source_start = 'unknown'; + var reason_start = 'unknown'; + var ms_latency = 100; + var play_context = 'unknown'; + var display_track = ''; + var referrer = 'unknown'; + var referrer_version = '0.1.0'; + var referrer_vendor = 'com.spotify'; + var args = [ + lid, + source_start, + reason_start, + ms_played, + ms_latency, + play_context, + display_track, + referrer, + referrer_version, + referrer_vendor + ]; + + var spotify = this._track._spotify; + var request = new spotify.Request('sp/track_progress', args); + request.send(function (err, res) { + if (err) return fn(err); + if (null === res.data) { + // apparently no result means "ok" + fn(); + } else { + // TODO: handle error case + debug('non-null sp/track_progress result: %j', res.data); + } + }); +}; diff --git a/lib/metadata/track.js b/lib/metadata/track.js new file mode 100644 index 0000000..d8cdd4d --- /dev/null +++ b/lib/metadata/track.js @@ -0,0 +1,380 @@ + +/** + * Module dependencies. + */ + +var schemas = require('../schemas'); +var util = require('../util'); +var SpotifyUri = require('../uri'); +var Metadata = require('./metadata'); +var PlaySession = require('./play_session'); +var PassThrough = require('stream').PassThrough; +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:metadata:track'); + +// node v0.8.x compat +if (!PassThrough) PassThrough = require('readable-stream/passthrough'); + +/** + * Protocol Buffer types. + */ + +var StoryRequest = schemas.build('bartender','StoryRequest'); +var StoryList = schemas.build('bartender','StoryList'); + +/** + * Module exports. + */ + +module.exports = Track; + +/** + * Constants + */ + +const previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/'; + +/** + * Creates a new Track instance with the specified uri, or in the case of multiple uris, + * creates an array of new Track instances. + * + * Instances will only contain a URI and will not have metadata populated + * + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single URI, or an Array of URIs to get Track instances for + * @param {Function} (Optional) fn callback function + * @return {Array|Track} + * @api public + */ + +Track.get = util.bind(Metadata.get, null, Track); + +/** + * Check whether the class supports construction from a specific schema/object + * + * @param {Object} schema + * @return {Boolean} + * @api private + */ + +Track._acceptsSchema = util.bind(Metadata._acceptsSchema, null, 'track'); + +/** + * Track class. + * + * @api public + */ + +function Track (spotify, uri, parent) { + if (!(this instanceof Track)) return new Track(spotify, uri, parent); + this.playSession = null; + this.type = 'track'; + + Metadata.call(this, spotify, uri, parent); +} +inherits(Track, Metadata); +Track['$inject'] = ['Spotify']; + +/** + * Re-export namespaces + */ +util.export(Track, [ PlaySession ]); + +Track.prototype._acceptsSchema = Track._acceptsSchema; + +/** + * Creates a new play session for the given Track object, including the URL to access the audio data. + * + * @param {String} (Optional) format One of 'MP3_96' (30 second preview) or 'MP3_160' (default)' + * @param {String} (Optional) transport One of 'http' (default) or 'rtmp' + * @param {Function} fn callback + */ +Track.prototype.audioUrl = function(format, transport, fn) { + // argument surgery + if ('function' == typeof transport) { + fn = transport; + transport = null; + } + if ('function' == typeof format) { + fn = format; + format = transport = null; + } + if (null === format) format = 'MP3_160'; + if (null === transport) transport = 'http'; + + debug('audioUrl(%j, %j)', format, transport); + + // we can't do anything if we're not loaded... + if (!this._loaded) return this.get(util.deferCallback(this.audioUrl.bind(this, format, transport), fn)); + + //if (!this._loaded) return this.get(this.audioUrl.bind(this, format, transport, fn)); + + // handle 30 second preview format separately + if ('MP3_96' == format) { + var preview = this.preview.filter(function(preview) { + return (preview.format == format); + }); + // TODO(adammw): recurse alternatives + if (!preview.length) { + return process.nextTick(fn.bind(null, new Error('No preview available'))); + } + var url = previewUrlBase + preview[0].fileId.toString('hex'); + this.playSession = new this.PlaySession({ uri: url }); + return process.nextTick(fn.bind(null, null, this.playSession)); + } + + var self = this; + var spotify = this._spotify; + this.recurseAlternatives(spotify.user_info.country || 'US', function (err, track) { + if (err) return fn(err); + var args = [ 'mp3160', track.uri.gid.toString('hex'), ('rtmp' == transport) ? 'rtmp' : '' ]; + debug('sp/track_uri args: %j', args); + (new spotify.Request('sp/track_uri', args)).send(function (err, res) { + if (err) return fn(err); + self.playSession = new self.PlaySession(res.result); + fn(null, self.playSession); + }); + }); +}; + +/** + * Checks if the given track "metadata" object is "available" for playback, taking + * account for the allowed/forbidden countries, the user's current country, the + * user's account type (free/paid), etc. + * + * @param {String} (Optional) country 2 letter country code to check if the track is playable for + * @param {Function} fn callback with signature `function(err, result){}` where result is true if track is playable, false otherwise + * @api public + */ + +Track.prototype.available = function (country, fn) { + // argument surgery + if ('function' == typeof country) { + fn = country; + country = null; + } + debug('available(%j)', country); + + var self = this; + var spotify = this._spotify; + + // make sure we are loaded before trying to read the track's restrictions + if (!this._loaded) return this.get(util.deferCallback(this.available.bind(this, country), fn)); + + // if the track was checked for restrictions on the server side then + // it should be available as long as there are no restrictions + if (this._prerestricted) { + debug('track was loaded with restrictions applied from server, available = %j', !this.restriction); + return process.nextTick(fn.bind(null, null, !this.restriction)); + } + + // default to the user's country + if (!country) country = spotify.user_info.country; + + var allowed = []; + var forbidden = []; + var available = false; + var restriction; + + if (Array.isArray(this.restriction)) { + debug('checking track restrictions...'); + for (var i = 0; i < this.restriction.length; i++) { + restriction = this.restriction[i]; + allowed.push.apply(allowed, restriction.allowed); + forbidden.push.apply(forbidden, restriction.forbidden); + + var isAllowed = !restriction.hasOwnProperty('countriesAllowed') || util.has(allowed, country); + var isForbidden = util.has(forbidden, country) && forbidden.length > 0; + + // TODO(adammw): fix names, ensure code is correct + // guessing at names here, corrections welcome... + var accountTypeMap = { + premium: 'SUBSCRIPTION', + unlimited: 'SUBSCRIPTION', + free: 'AD' + }; + + if (util.has(allowed, country) && util.has(forbidden, country)) { + isAllowed = true; + isForbidden = false; + } + + var type = accountTypeMap[spotify.user_info.catalogue] || 'AD'; + var applicable = util.has(restriction.catalogue, type); + + available = isAllowed && !isForbidden && applicable; + + //debug('restriction: %j', restriction); + debug('type: %j', type); + debug('allowed: %j', allowed); + debug('forbidden: %j', forbidden); + debug('isAllowed: %j', isAllowed); + debug('isForbidden: %j', isForbidden); + debug('applicable: %j', applicable); + debug('available: %j', available); + + if (available) break; + } + } + process.nextTick(fn.bind(null, null, available)); +}; + +/** + * Checks if the given "track" is "available". If yes, returns the "track" + * untouched. If no, then the "alternative" tracks array on the "track" instance + * is searched until one of them is "available", and then returns that "track". + * If none of the alternative tracks are "available", returns `null`. + * + * @param {String} country 2 letter country code to attempt to find a playable "track" for + * @param {Function} fn callback function + * @api public + */ + +Track.prototype.recurseAlternatives = function (country, fn) { + var self = this; + debug('recurseAlternatives(%j)', country); + + // check if the current track is available + this.available(country, function(err, available) { + if (err) return fn(err); + if (available) return fn(null, self); + if (!Array.isArray(self.alternative)) return fn(new Error('[no alternatives]Track is not playable in country "' + country + '"')); + + // check if any alternatives are available + var tracks = self.alternative.slice(0); + (function next() { + var track = tracks.shift(); + if (!track) { + // not playable + return fn(new Error('[none left]Track is not playable in country "' + country + '"')); + } + debug('checking alternative track %j', track.uri); + track.available(country, function(err, available) { + if (available) return fn(null, track); + next(); + }); + })(); + }); +}; + +/** + * Retrieve suggested similar tracks to the current track instance + * + * @param {Function} fn callback function + * @return {Array} an array of Track instances, that are semi-populated with name and artist images + * @api public + */ + +Track.prototype.similar = function(fn) { + debug('similar()'); + + var spotify = this._spotify; + + var request = new spotify.HermesRequest('hm://similarity/suggest/' + this.uri.sid); + request.setRequestSchema(StoryRequest); + request.setResponseSchema(StoryList); + request.send({ + country: spotify.user_info.country || 'US', + language: spotify.user_info.preferred_locale || spotify.settings.locale.current || 'en', + device: 'web' + }, function(err, res) { + if (err) return fn(err); + + // normalise response into Metadata objects + var recommendations = res.result.stories.map(function(story) { + var data = Object.create(null); + + (function objectify(recommendedItem) { + var type = SpotifyUri.uriType(recommendedItem.uri); + var className = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase(); + data[type] = new spotify[className](recommendedItem.uri); + data[type].name = recommendedItem.displayName; + if (recommendedItem.parent) objectify(recommendedItem.parent); + })(story.recommendedItem); + + var track = data.track; + if (story.preview) { + track.preview = story.preview.map(function(preview) { + return { + fileId: new Buffer(preview.fileId, 'hex'), + format: 'MP3_96' + }; + }); + } + + if (data.album) track.album = data.album; + if (data.artist) { + if (story.metadata && story.metadata.summary) data.artist.biography = {text: story.metadata.summary}; + if (story.heroImage) data.artist.portrait = story.heroImage.map(function(image) { + return { + fileId: new Buffer(image.fileId, 'hex'), + width: image.width, + height: image.height + }; + }); + if (track.album) track.album.artist = data.artist; + track.artist = [ data.artist ]; + } + return track; + }); + + fn(null, recommendations); + }); +}; + +/** + * Begins playing this track, returns a Readable stream that outputs audio data. + * + * @param {String} (Optional) format One of 'MP3_96' (30 second preview) or 'MP3_160' (default)' + * @param {Function} (Optional) fn callback with signature `function(err, stream)` or `function(stream)` + * @return {Stream|Null} + * @api public + */ + +Track.prototype.play = function (format, fn) { + // argument surgery + if ('function' == typeof format) { + fn = format; + format = null; + } + + // ugly hacks for backwards compat + var stream = null; + if ('function' != typeof fn) { + stream = new PassThrough(); + } + var callback = function(err, data) { + if (err && ('function' != typeof fn || ('function' == typeof fn && fn.length == 1))) { + process.nextTick(stream.emit.bind(stream, 'error', err)); + } else if ('function' == typeof fn) { + return fn(err, data); + } + if ('function' == typeof fn) fn(data); + }; + + // request a play session + this.audioUrl(format, function (err, session) { + if (err) return callback(err); + + if ('function' != typeof fn) { + session.stream.pipe(stream); + } + + session.play(fn); + }); + + // return stream immediately so it can be .pipe()'d or null if we are using callbacks + return stream; +}; + +/** + * Begins playing a preview of the track, returns a Readable stream that outputs MP3 data. + * + * @param {Function} (Optional) fn callback with signature `function(err, stream)` or `function(stream)` + * @return {Stream|Null} + * @api public + */ + +Track.prototype.playPreview = function (fn) { + return this.play('MP3_96', fn); +}; diff --git a/lib/playlist/attributes.js b/lib/playlist/attributes.js new file mode 100644 index 0000000..e57b711 --- /dev/null +++ b/lib/playlist/attributes.js @@ -0,0 +1,58 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistAttributes; + +/** + * PlaylistAttributes class. + * + * @api public + */ + +function PlaylistAttributes(playlist) { + this.playlist = playlist; + this.revision = null; +} +PlaylistAttributes['$inject'] = ['Playlist']; + +/** + * List of valid attributes + * Extracted from playlist4meta.proto + */ +PlaylistAttributes.VALID_ATTRIBUTES = ['name', 'description', 'picture', 'collaborative', + 'pl3_version', 'deleted_by_owner', 'restricted_collaborative']; + +/** + * Parse the response from the Playlist request + * + * @param {SelectedListContent} data + * @api private + */ +PlaylistAttributes.prototype.parse = function(data) { + var self = this; + var PlaylistRevision = this.playlist.PlaylistRevision; + + this.revision = new PlaylistRevision(data.revision); + + Object.keys(data.attributes).forEach(function(key) { + self[key] = data.attributes[key]; + }); +}; + +/** + * Save modifications to the PlaylistAttributes back to the server + * + * @param {Function} fn callback function + * @api private + */ +PlaylistAttributes.prototype.save = function(fn) { + var attributes = {}; + Object.keys(this).filter(function(key) { + return PlaylistAttributes.VALID_ATTRIBUTES.indexOf(key) !== -1; + }).forEach(function(key) { + attributes[key] = this[key]; + }, this); + this.playlist.updateAttributes(attributes, fn); +}; diff --git a/lib/playlist/change.js b/lib/playlist/change.js new file mode 100644 index 0000000..d370ca4 --- /dev/null +++ b/lib/playlist/change.js @@ -0,0 +1,25 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistChange; + +/** + * PlaylistChange class. + * + * @api public + */ + +function PlaylistChange(playlist, diff, op) { + this.playlist = playlist; + this.kind = op.kind; + if (this.kind != 'KIND_UNKNOWN') { + var lowercaseKind = this.kind.toLowerCase(); + this[lowercaseKind] = op[lowercaseKind]; + } + + this.fromRevision = diff.fromRevision; + this.toRevision = diff.toRevision; +} +PlaylistChange['$inject'] = ['Playlist']; diff --git a/lib/playlist/contents.js b/lib/playlist/contents.js new file mode 100644 index 0000000..b89d292 --- /dev/null +++ b/lib/playlist/contents.js @@ -0,0 +1,53 @@ + +/** + * Module dependencies. + */ + +var inherits = require('util').inherits; + +/** + * Module exports. + */ + +module.exports = PlaylistContents; + +/** + * PlaylistContents class. + * + * @api public + */ + +function PlaylistContents(playlist) { + this.playlist = playlist; + this.revision = null; + this.offset = null; + this.truncated = null; +} +inherits(PlaylistContents, Array); +PlaylistContents['$inject'] = ['Playlist']; + +/** + * Parse the response from the Playlist contents request + * + * @param {SelectedListContent} data + * @api private + */ +PlaylistContents.prototype.parse = function(data) { + var PlaylistItem = this.playlist.PlaylistItem; + var PlaylistRevision = this.playlist.PlaylistRevision; + + // copy over some data + this.revision = new PlaylistRevision(data.revision); + this.offset = data.contents.pos; + this.truncated = data.contents.truncated; + + // convert items into PlaylistItem objects and add to our internal array + if (data.contents.items && data.contents.items.length) { + data.contents.items.forEach(function(item, i) { + var playlistItem = new PlaylistItem(item.uri, item.attributes); + playlistItem.index = this.offset + i; + playlistItem.revision = this.revision; + this.push(playlistItem); + }, this); + } +}; diff --git a/lib/playlist/index.js b/lib/playlist/index.js new file mode 100644 index 0000000..1eca836 --- /dev/null +++ b/lib/playlist/index.js @@ -0,0 +1,6 @@ + +/** + * Module exports. + */ + +module.exports = require('./playlist'); diff --git a/lib/playlist/item.js b/lib/playlist/item.js new file mode 100644 index 0000000..c9739ef --- /dev/null +++ b/lib/playlist/item.js @@ -0,0 +1,49 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistItem; + +/** + * PlaylistItem class. + * + * @api public + */ + +function PlaylistItem(playlist, uri, attributes) { + if (!(this instanceof PlaylistItem)) return new PlaylistItem(playlist, uri, attributes); + + this.playlist = playlist; + this.attributes = attributes || {}; + + this.index = null; + this.revision = null; + this.removed = false; + + var spotify = this.playlist._spotify; + this.item = spotify.get(uri); +} +PlaylistItem['$inject'] = ['Playlist']; + +/** + * Remove the item from it's playlist + * This method relies on the playlist item's internal index being correct + * + * @param {Function} fn callback function + */ +PlaylistItem.prototype.remove = function(fn) { + if (this.removed) return fn(new Error('PlaylistItem already removed')); + + fn = util.wrapCallback(fn, this.playlist); + this.playlist._sendOps([{ + kind: "REM", + rem: { + fromIndex: this.index, + length: 1 + } + }], function(err) { + this.removed = true; + fn(err); + }.bind(this)); +}; diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js new file mode 100644 index 0000000..4a4a3ba --- /dev/null +++ b/lib/playlist/playlist.js @@ -0,0 +1,640 @@ + +/** + * Module dependencies. + */ + +var schemas = require('../schemas'); +var util = require('../util'); +var SpotifyUri = require('../uri'); +var PlaylistAttributes = require('./attributes'); +var PlaylistChange = require('./change'); +var PlaylistContents = require('./contents'); +var PlaylistItem = require('./item'); +var PlaylistRevision = require('./revision'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +var querystring = require('querystring'); +var debug = require('debug')('spotify-web:playlist'); + +/** + * Module exports. + */ + +module.exports = Playlist; + +/** + * Protocol Buffer types. + */ + +var SelectedListContent = schemas.build('playlist4', 'SelectedListContent'); +var OpList = schemas.build('playlist4', 'OpList'); +var SubscribeRequest = schemas.build('playlist4', 'SubscribeRequest'); +var UnsubscribeRequest = schemas.build('playlist4', 'UnsubscribeRequest'); +var Subscription = schemas.build('hermes.pubsub', 'Subscription'); +var CreateListReply = schemas.build('playlist4', 'CreateListReply'); +var ModifyReply = schemas.build('playlist4', 'ModifyReply'); +var PlaylistModificationInfo = schemas.build('playlist4', 'PlaylistModificationInfo'); + +/** + * Creates a new Playlist instance with the specified uri, or in the case of multiple uris, + * creates an array of new Playlist instances. + * + * Instances will only contain a URI and will not have metadata populated + * + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single URI, or an Array of URIs to get Playlist instances for + * @param {Function} (Optional) fn callback function + * @return {Array|Metadata} + * @api public + */ + +Playlist.get = function(spotify, uri, fn) { + debug('get(%j)', uri); + + // convert input uris to array but save if we should return an array or a bare object + var returnArray = Array.isArray(uri); + if (!returnArray) uri = [uri]; + + // call the Playlist constructor for each uri, and call the callback if we have an error + var playlists; + try { + playlists = uri.map(Playlist.bind(null, spotify)); + } catch (e) { + if ('function' == typeof fn) process.nextTick(fn.bind(null, e)); + return null; + } + + // return the array of playlists or a single playlist and call callbacks if applicable + var ret = (returnArray) ? playlists : playlists[0]; + if ('function' == typeof fn) process.nextTick(fn.bind(null, null, ret)); + return ret; +}; +Playlist.get['$inject'] = ['Spotify']; + +/** + * Create a new Playlist on the server, optionally with the specified attributes. + * + * @param {String|Object} (Optional) attributes object or playlist name + * @param {Function} fn callback function + */ +Playlist.create = function(spotify, attributes, fn) { + if ('string' == typeof attributes) { + attributes = {name: attributes}; + } + if ('function' == typeof attributes) { + fn = attributes; + attributes = {}; + } + debug('create(%j)', attributes); + + var requestArgs = { + ops: [{ + kind: 'UPDATE_LIST_ATTRIBUTES', + updateListAttributes: { + newAttributes: { values: attributes } + } + }] + }; + + var HermesRequest = spotify.HermesRequest; + var request = new HermesRequest('PUT', 'hm://playlist/user/' + spotify.username); + request.setRequestSchema(OpList); + request.setResponseSchema(CreateListReply); + request.send(requestArgs, function (err, res) { + if (err) fn(err); + var uri = res.result.uri.toString(); + debug('playlist created - uri = %s', uri); + + spotify.user.rootlist().add(uri, function(err, resp) { + if (err) return debug('playlist failed to be added to rootlist', err); + debug('playlist added to rootlist'); + }); + + var playlist = new Playlist(spotify, uri); + playlist._revision = new playlist.PlaylistRevision(res.result.revision); + fn(null, playlist); + }); +}; +Playlist.create['$inject'] = ['Spotify']; + +/** + * Playlist class. + * + * @api public + */ + +function Playlist (spotify, uri) { + if (!(this instanceof Playlist)) return new Playlist(spotify, uri); + + // initalise event emitters + EventEmitter.call(this); + EventEmitter.call(this.contents); + EventEmitter.call(this.revision); + this.on('newListener', this._onNewListener.bind(this)); + this.on('removeListener', this._onRemoveListener.bind(this)); + this.contents.on('newListener', this.contents._onNewListener.bind(this)); + this.contents.on('removeListener', this.contents._onRemoveListener.bind(this)); + this.revision.on('newListener', this.revision._onNewListener.bind(this)); + this.revision.on('removeListener', this.revision._onRemoveListener.bind(this)); + + this._spotify = spotify; + this._attributesCache = null; + this._contentsCache = []; // TODO(adammw): an opt-in caching policy (and also for playlist to be a singleton per uri so caches are shared) + this._revision = null; + + // validate and parse uri + if (!uri) throw new Error('Invalid uri specified'); + if ('string' == typeof uri) uri = new SpotifyUri(uri); + if (!(uri instanceof SpotifyUri) || SpotifyUri.PLAYLIST_TYPES.indexOf(uri.type) === -1) { + throw new Error('Invalid URI type: ' + uri.type); + } + + this._hm_uri = 'hm://playlist/user/' + uri.user + '/' + uri.type; + if (uri.sid != uri.type) { + this._hm_uri += '/' + uri.sid; + } + this.type = uri.type; // e.g. playlist, starred, rootlist + + this._subscription = new spotify.connection.Subscription(this._hm_uri); + this._subscription.setSubscribeHandler(this._sendSubscribe.bind(this)); + this._subscription.setUnsubscribeHandler(this._sendUnsubscribe.bind(this)); + this._subscription.setResponseSchema(PlaylistModificationInfo); + this._subscription.on('response', this._onsubscriptionresponse.bind(this)); + this._onrevisionchange = this._performDiff.bind(this); + this.once('needsRevision', this.revision.bind(this)); + + this.uri = uri; +} +inherits(Playlist, EventEmitter); +Playlist['$inject'] = ['Spotify']; + +/** + * Re-export namespaces + */ +util.export(Playlist, [ PlaylistAttributes, PlaylistChange, PlaylistContents, PlaylistItem, PlaylistRevision ]); + +Playlist.prototype._onNewListener = function(event, listener) { + if ('change' != event) return; + + this._addDiffChangeHandler(); +}; + +Playlist.prototype._onRemoveListener = function(event, listener) { + if ('change' != event) return; + + // count the remaining listeners + var count = 0; + Playlist._diffEvents.forEach(function(countEvent) { + count += this.contents.listeners(countEvent).length; + }, this); + + var listeners = this.listeners('change'); + var idx; + if (-1 !== (idx = listeners.indexOf(listener))) { + listeners.splice(idx, 1); + } + count += listeners.length; + + // abort if listeners remain + if (0 != count) return; + + this._removeDiffChangeHandler(); +}; + +/** + * Perform a DIFF + */ +Playlist.prototype._performDiff = function(newRevision, oldRevision) { + if (!oldRevision || newRevision.toString() == oldRevision.toString()) return; + debug('performDiff(%j, %j)', newRevision.toString(), oldRevision.toString()); + this.diff(oldRevision, (function(err, diff) { + if (err) return this.contents.emit('error', err); + diff.ops.forEach(function(op) { + var kind = op.kind.toLowerCase(); + if (['add', 'mov', 'rem'].indexOf(kind) !== -1) { + this.contents.emit(kind, new this.PlaylistChange(diff, op)); + } + }, this); + }).bind(this)); +}; + +/** + * Add the handler for the revision 'change' event to perform a DIFF + * + * @api private + */ +Playlist.prototype._addDiffChangeHandler = function() { + debug('_addDiffChangeHandler()'); + if (-1 === this.revision.listeners('change').indexOf(this._onrevisionchange)) { + this.revision.addListener('change', this._onrevisionchange); + if (!this._revison) this.emit('needsRevision'); + } +}; + +/** + * Remove the handler for the revision 'change' event to perform a DIFF + * + * @api private + */ +Playlist.prototype._removeDiffChangeHandler = function() { + debug('_removeDiffChangeHandler()'); + this.revision.removeListener('change', this._onrevisionchange); +}; + +/** + * Perform a "SelectedListContent" request for playlist data + * + * @param {String} (Optional) method + * @param {Object} (Optional) args + * @param {Function} fn callback + * @api private + */ +Playlist.prototype._request = function(method, args, fn) { + // argument surgery + if ('function' == typeof args) { + fn = args; + args = null; + } + if ('function' == typeof method) { + fn = method; + args = method = null; + } + + // construct url + var hm_uri = this._hm_uri; + if (args) hm_uri += '?' + querystring.stringify(args); + + // perform request + var HermesRequest = this._spotify.HermesRequest; + var request = new HermesRequest(method, hm_uri); + request.setResponseSchema(SelectedListContent); + request.send(fn); +}; + +/** + * When a Playlist Subscription callback is invoked this function is called + * + * @param {HermesResponse} response + */ +Playlist.prototype._onsubscriptionresponse = function(response) { + // TODO(adammw) + var oldRevision = this._revision || null; + var newRevision = (response.result.newRevision) ? new this.PlaylistRevision(response.result.newRevision) : null; + debug('_onsubscriptionresponse %s -> %s', oldRevision, newRevision); + if (newRevision) this._revision = newRevision; + this.revision.emit('change', newRevision, oldRevision); +}; + +/** + * Send a MODIFY request + * + * @param {Array} ops Array of operations to apply + * @param {Function} fn request callback + * @api private + */ +Playlist.prototype._sendOps = function (ops, fn) { + var HermesRequest = this._spotify.HermesRequest; + // TODO(adammw): work out which query string arguments are needed + // TODO(adammw): handle revision + var request = new HermesRequest('MODIFY', this._hm_uri + '?syncpublished=true'); + request.setRequestSchema(OpList); + request.setResponseSchema(ModifyReply); + request.send({ops: ops}, fn); +}; + +/** + * Send a Subscribe request + * + * @param {Function} fn request callback + * @api private + */ +Playlist.prototype._sendSubscribe = function(fn) { + var HermesRequest = this._spotify.HermesRequest; + var request = new HermesRequest('SUB', 'hm://playlist/'); + request.setRequestSchema(SubscribeRequest); + request.setResponseSchema(Subscription); + request.send({ uris: [ this._hm_uri ]}, fn); +}; + +/** + * Send an Unsubscribe request + * + * @param {Function} fn request callback + * @api private + */ +Playlist.prototype._sendUnsubscribe = function(fn) { + var HermesRequest = this._spotify.HermesRequest; + var request = new HermesRequest('UNSUB', 'hm://playlist/'); + request.setRequestSchema(UnsubscribeRequest); + request.send({ uris: [ this._hm_uri ]}, fn); +}; + +/** + * Gets the playlist attributes + * + * @param {Function} fn callback function + */ +Playlist.prototype.attributes = function(fn) { + var self = this; + var PlaylistAttributes = this.PlaylistAttributes; + this._request('HEAD', function(err, res) { + if (err) return fn(err); + var attributes = new PlaylistAttributes(); + try { + attributes.parse(res.result); + } catch(e) { + return fn(e); + } + self._attributesCache = attributes; + fn(null, attributes); + }); +}; + +/** + * Update the playlist attributes + * + * @param {Object|PlaylistAttributes} attributes + * @param {Function} fn callback function + */ +Playlist.prototype.updateAttributes = function(attributes, fn) { + fn = util.wrapCallback(fn, this); + this._sendOps([{ + kind: 'UPDATE_LIST_ATTRIBUTES', + updateListAttributes: { + newAttributes: { values: attributes } + } + }], fn); +}; + +/** + * Retrieves changes to the playlist since the specified revision + * + * @param {PlaylistRevision} revision (optional) + * @param {Function} fn callback function + */ +Playlist.prototype.diff = function(revision, fn) { + // argument surgery + if ('function' == revision) { + fn = revision; + revision = null; + } + fn = util.wrapCallback(fn, this); + if (null === revision) revision = this._revision; + // if ('string' != typeof revision && !(revision instanceof PlaylistRevision)) + // return fn(new Error('Invalid revision')); + + this._request('DIFF', { revision: revision.toString() }, (function(err, res) { + if (err) return fn(err); + var diff = res.result.diff; + debug('processing diff: %j', diff); + if (diff.fromRevision) diff.fromRevision = new this.PlaylistRevision(diff.fromRevision); + if (diff.toRevision) diff.toRevision = new this.PlaylistRevision(diff.toRevision); + if (Array.isArray(diff.ops)) { + diff.ops.forEach(function(op) { + ['add', 'rem'].forEach(function(kind) { + if (!op[kind] || !Array.isArray(op[kind].items)) return; + op[kind].items = op[kind].items.map(function(item, i) { + var playlistItem = new this.PlaylistItem(item.uri, item.attributes); + playlistItem.index = op[kind].fromIndex + i; + playlistItem.revision = (kind == 'add') ? diff.toRevision : diff.fromRevision; + return playlistItem; + }, this); + }, this); + }, this); + } + fn(null, diff); + }).bind(this)); +}; + +/** + * Get the playlist contents + * + * @param {Number} offset (Optional) + * @param {Number} length (Optional) + * @param {Function} fn callback function + */ +Playlist.prototype.contents = function(offset, length, fn) { + if ('function' == typeof length) { + fn = length; + length = null; + } + if ('function' == typeof offset) { + fn = offset; + offset = length = null; + } + + debug('contents(%j, %j)', offset, length); + + // TODO(adammw): ensure this works with large playlists (ie >100 items) + this._request(function(err, res) { + if (err) return fn(err); + var contents = new this.PlaylistContents(); + try { + contents.parse(res.result); + } catch(e) { + return fn(e); + } + try { + var attributes = new this.PlaylistAttributes(); + attributes.parse(res.result); + this._attributesCache = attributes; + } catch(e) { + debug("failed to parse attributes", e); + } + // TODO(adammw): add to _contentsCache and ensure cache does not grow too big and stay around forever + fn(null, contents); + }.bind(this)); +}; + +/* + * Make `playlist.contents` an EventEmitter + */ +util.makeEmitter(Playlist.prototype.contents); + +Playlist._diffEvents = ['add', 'mov', 'rem', 'mod', 'change']; + +Playlist.prototype.contents._onNewListener = function(event, listener) { + if (-1 === Playlist._diffEvents.indexOf(event)) return; + + this._addDiffChangeHandler(); +}; + +Playlist.prototype.contents._onRemoveListener = function(event, listener) { + if (-1 === Playlist._diffEvents.indexOf(event)) return; + + // count the remaining listeners + var count = 0; + Playlist._diffEvents.forEach(function(countEvent) { + var listeners = this.contents.listeners(countEvent); + + // don't count the listener being removed + if (event == countEvent) { + var idx; + if (-1 !== (idx = listeners.indexOf(listener))) { + listeners.splice(idx, 1); + } + } + + count += listeners.length; + }, this); + + count += this.listeners('change').length; + + // abort if listeners remain + if (0 != count) return; + + this._removeDiffChangeHandler(); +}; + +/** + * Gets the latest revision from the server + * + * @param {Function} fn callback function + */ +Playlist.prototype.revision = function(fn) { + debug('revision(%j)', arguments) + var PlaylistRevision = this.PlaylistRevision; + var self = this; + fn = util.wrapCallback(fn); + this._request('HEAD', function(err, res) { + if (err) return fn(err); + var revision = new PlaylistRevision(res.result.revision); + fn(null, revision); + process.nextTick(self.revision.emit.bind(self.revision, 'change', revision, self._revision)); + self._revision = revision; + }); +}; + +/* + * Make `playlist.revision` an EventEmitter + */ +util.makeEmitter(Playlist.prototype.revision); + +Playlist.prototype.revision._onNewListener = function(event, listener) { + if ('change' != event) return; + this.subscribe(); +}; + +Playlist.prototype.revision._onRemoveListener = function(event, listener) { + if ('change' != event) return; + + // count the remaining listeners + var listeners = this.revision.listeners('change'); + var idx; + if (-1 !== (idx = listeners.indexOf(listener))) { + listeners.splice(idx, 1); + } + + // abort if listeners remain + if (listeners.length) return; + + this.unsubscribe(); +}; + +/** + * Adds items to the playlist + * + * @param {Object|Array|String} item + * @param {Number} prepend (optional) set to true to add the item to the start of the playlist rather than the end + * @param {Object} attributes (optional) + * @param {Function} fn callback function + * @api public + */ +Playlist.prototype.add = function(items, prepend, attributes, fn) { + // argument surgery + if (!Array.isArray(items)) { + items = [ items ]; + } + if ('function' === typeof prepend) { + fn = prepend; + prepend = attributes = null; + } + if ('function' === typeof attributes) { + fn = attributes; + attributes = null; + } + if (null === prepend || 'undefined' == typeof prepend) { prepend = false; } + fn = util.wrapCallback(fn, this); + + var spotify = this._spotify; + + items = items.map(function(item) { + return { + uri: (item.uri) ? item.uri : item, + attributes: (item.attributes) ? item.attributes : (attributes || { + added_by: spotify.username + }) + }; + }); + + var op = { + kind: 'ADD', + add: { + items: items, + addLast: !prepend, + addFirst: !!prepend + } + }; + + this._sendOps([op], fn); +}; + +/** + * Removes items from the playlist + * + * @param {Object|Array|String} item + * @param {Function} fn callback function + * @api public + */ +Playlist.prototype.remove = function(items, fn) { + // argument surgery + if (!Array.isArray(items)) { + items = [ items ]; + } + fn = util.wrapCallback(fn, this); + + if ('playlist' == this.type) { + throw new Error('TODO: Not implemented!'); + } + + // perform request + var HermesRequest = this._spotify.HermesRequest; + var request = new HermesRequest("REMOVE", this._hm_uri); + var payload = items.map(function(item) { + return (item.uri) ? item.uri.toString() : item; + }).join(','); + request.send(new Buffer(payload).toString('base64'), fn); +}; + +/** + * Deletes the Playlist represented by the Playlist instance on the server. + * + * Note that Spotify playlists are never actually deleted, they are just removed from the user's rootlist + * + * @param {Function} fn callback function + */ +Playlist.prototype.delete = function(fn) { + throw new Error('TODO: Not implemented!'); +}; + +Playlist.prototype.publish = +Playlist.prototype.follow = function(fn) { + throw new Error('TODO: Not implemented!'); +}; + +Playlist.prototype.unpublish = +Playlist.prototype.unfollow = function(fn) { + throw new Error('TODO: Not implemented!'); +}; + +Playlist.prototype.subscribed = function() { + return this._subscription.subscribed(); +}; + +Playlist.prototype.subscribe = function() { + this._subscription.subscribe(); +}; + +Playlist.prototype.unsubscribe = function() { + this._subscription.unsubscribe(); +}; diff --git a/lib/playlist/revision.js b/lib/playlist/revision.js new file mode 100644 index 0000000..63d421c --- /dev/null +++ b/lib/playlist/revision.js @@ -0,0 +1,52 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistRevision; + +/** + * PlaylistRevision class. + * + * @api public + */ + +function PlaylistRevision(playlist, revision) { + this.playlist = playlist; + this.revision = revision; +} +PlaylistRevision['$inject'] = ['Playlist']; + +/** + * Revision number getter + */ + +Object.defineProperty(PlaylistRevision.prototype, 'version', { + get: function () { + return this.revision.readUInt32BE(0); + }, + enumerable: true, + configurable: true +}); + +/** + * Revision sha1 getter + */ + +Object.defineProperty(PlaylistRevision.prototype, 'sha1', { + get: function () { + return this.revision.slice(4).toString('hex'); + }, + enumerable: true, + configurable: true +}); + +/** + * Returns the string representation of the revision by + * concatenating the revision number and hash, separated by a comma + * + * @return {String} + */ +PlaylistRevision.prototype.toString = function() { + return [this.version, this.sha1].join(','); +}; diff --git a/lib/schemas.js b/lib/schemas.js index 9191dfa..7cd73e5 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -27,8 +27,10 @@ var packageMapping = { bartender: 'bartender', mercury: 'mercury', metadata: 'metadata', + presence: 'presence', playlist4: "playlist4changes,playlist4content,playlist4issues,playlist4meta,playlist4ops,playlist4service".split(","), - pubsub: 'pubsub', + 'hermes.pubsub': 'pubsub', + social: 'social', toplist: 'toplist' }; var packageCache = module.exports = {}; @@ -36,11 +38,8 @@ var packageCache = module.exports = {}; var loadPackage = function(id) { // Use cached packages if (packageCache.hasOwnProperty(id)) { - debug('loadPackage(%j) [%s, cached]', id, library); return packageCache[id]; - } else { - debug('loadPackage(%j) [%s]', id, library); - } + } // Load the mapping of packages to proto files var mapping = packageMapping[id]; @@ -72,8 +71,6 @@ var loadPackage = function(id) { }; var loadMessage = module.exports.build = function(packageId, messageId) { - debug('loadMessage(%j, %j) [%s]', packageId, messageId, library); - var packageObj = loadPackage(packageId); var messageObj = null; diff --git a/lib/spotify.js b/lib/spotify.js index b5b130f..36a9c8a 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -5,13 +5,15 @@ var vm = require('vm'); var util = require('./util'); -var http = require('http'); -var WebSocket = require('ws'); var cheerio = require('cheerio'); var schemas = require('./schemas'); var superagent = require('superagent'); var inherits = require('util').inherits; -var SpotifyError = require('./error'); +var SpotifyConnection = require('./connection'); +var SpotifyUri = require('./uri'); +var Metadata = require('./metadata'); +var Playlist = require('./playlist'); +var User = require('./user'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var pkg = require('../package.json'); @@ -30,9 +32,6 @@ var MercuryMultiGetRequest = schemas.build('mercury','MercuryMultiGetRequest'); var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); var MercuryRequest = schemas.build('mercury','MercuryRequest'); -var Artist = require('./artist'); -var Album = require('./album'); -var Track = require('./track'); var Image = require('./image'); require('./restriction'); @@ -42,11 +41,11 @@ var StoryRequest = schemas.build('bartender','StoryRequest'); var StoryList = schemas.build('bartender','StoryList'); /** - * Re-export all the `util` functions. + * Re-export all the `SpotifyUri` functions. */ -Object.keys(util).forEach(function (key) { - Spotify[key] = util[key]; +['gid2id', 'id2uri', 'uri2id', 'gid2uri', 'uriType'].forEach(function(key) { + Spotify[key] = SpotifyUri[key]; }); /** @@ -78,11 +77,8 @@ function Spotify () { if (!(this instanceof Spotify)) return new Spotify(); EventEmitter.call(this); - this.seq = 0; - this.heartbeatInterval = 18E4; // 180s, from "spotify.web.client.js" this.agent = superagent.agent(); - this.connected = false; // true after the WebSocket "connect" message is sent - this._callbacks = Object.create(null); + this.connection = new this.SpotifyConnection; this.authServer = 'play.spotify.com'; this.authUrl = '/xhr/json/auth.php'; @@ -106,16 +102,11 @@ function Spotify () { this.sourceUrls.LARGE = this.sourceUrls.large; this.sourceUrls.XLARGE = this.sourceUrls.avatar; - // WebSocket callbacks - this._onopen = this._onopen.bind(this); - this._onclose = this._onclose.bind(this); - this._onmessage = this._onmessage.bind(this); - - // start the "heartbeat" once the WebSocket connection is established - this.once('connect', this._startHeartbeat); - // handle "message" commands... - this.on('message', this._onmessagecommand); + this.connection.on('command', this._onmessagecommand.bind(this)); + + // handle Multi-Get automatically + this.connection.requestQueueFlushHandlers.push(this.Metadata.mergeMultiGetRequests); // needs to emulate Spotify's "CodeValidator" object this._context = vm.createContext(); @@ -125,6 +116,78 @@ function Spotify () { this._defaultCallback = this._defaultCallback.bind(this); } inherits(Spotify, EventEmitter); +Spotify.$provides = {'SpotifyConnection': 'connection'}; + +/** + * Re-export all sub-classes + */ + +[SpotifyConnection, SpotifyUri, Metadata, Playlist, User].forEach(util.recursiveExport, Spotify); + +/** + * User info getters + */ + +Object.defineProperty(Spotify.prototype, 'username', { + get: function() { + return this.user_info.user; + }, + enumerable: true, + configurable: true +}); + +Object.defineProperty(Spotify.prototype, 'country', { + get: function() { + return this.user_info.country; + }, + enumerable: true, + configurable: true +}); + +Object.defineProperty(Spotify.prototype, 'catalogue', { + get: function() { + return this.user_info.catalogue; + }, + enumerable: true, + configurable: true +}); + +/** + * Convert Schemas to objects + * + * @param {Object} obj + * @param {Object} (Optional) parent + * @return {Object} + * @api private + */ +Spotify.prototype._objectify = function(obj, parent) { + if ('object' != typeof obj) return obj; + + // TODO(adammw): convert bare uris to SpotifyUri types + // (may not be needed if everything has it's own class that does that) + + var exports = Spotify['$exports']; + + for (var i = 0, il = exports.length; i < il; i++) { + var type = this[exports[i]]; + + // skip if it is already a type + if (obj instanceof type) return obj; + + // check if the object is an instance of any of the types accepted schemas + if (type._acceptsSchema && type._acceptsSchema(obj)) { + debug('objectifying object: %j', obj); + return new type(obj, parent); + } + } + + // otherwise, recurse the object + var self = this; + Object.keys(obj).forEach(function(key) { + obj[key] = self._objectify(obj[key], parent); + }); + return obj; +}; /** * Creates the connection to the Spotify Web websocket server and logs in using @@ -186,7 +249,7 @@ Spotify.prototype.facebookLogin = function (fbuid, token, fn) { /** * Sets the login and error callbacks to invoke the specified callback function - * + * * @param {Function} fn callback function * @api private */ @@ -251,7 +314,7 @@ Spotify.prototype._onsecret = function (err, res) { for (var i = 0; i < scripts.length; i++) { var code = scripts.eq(i).text(); if (~code.indexOf('Spotify.Web.Login')) { - vm.runInNewContext(code, { document: null, Spotify: { Web: { Login: login } } }); + vm.runInNewContext(code, { document: null, Spotify: { Web: { Login: login, App: { initialize: function() { } } } } }); } } debug('login CSRF token: %j, tracking ID: %j', args.csrftoken, args.trackingId); @@ -334,88 +397,7 @@ Spotify.prototype._openWebsocket = function (err, res) { var url = 'wss://' + ap_list[0] + '/'; debug('WS %j', url); - this.ws = new WebSocket(url); - this.ws.on('open', this._onopen); - this.ws.on('close', this._onclose); - this.ws.on('message', this._onmessage); -}; - -/** - * WebSocket "open" event. - * - * @api private - */ - -Spotify.prototype._onopen = function () { - debug('WebSocket "open" event'); - this.emit('open'); - if (!this.connected) { - // need to send "connect" message - this.connect(); - } -}; - -/** - * WebSocket "close" event. - * - * @api private - */ - -Spotify.prototype._onclose = function () { - debug('WebSocket "close" event'); - this.emit('close'); - if (this.connected) { - this.disconnect(); - } -}; - -/** - * WebSocket "message" event. - * - * @param {String} - * @api private - */ - -Spotify.prototype._onmessage = function (data) { - debug('WebSocket "message" event: %s', data); - var msg; - try { - msg = JSON.parse(data); - } catch (e) { - return this.emit('error', e); - } - - var self = this; - var id = msg.id; - var callbacks = this._callbacks; - - function fn (err, res) { - var cb = callbacks[id]; - if (cb) { - // got a callback function! - delete callbacks[id]; - cb.call(self, err, res, msg); - } - } - - if ('error' in msg) { - var err = new SpotifyError(msg.error); - if (null == id) { - this.emit('error', err); - } else { - fn(err); - } - } else if ('message' in msg) { - var command = msg.message[0]; - var args = msg.message.slice(1); - this.emit('message', command, args); - } else if ('id' in msg) { - fn(null, msg); - } else { - // unhandled command - console.error(msg); - throw new Error('TODO: implement!'); - } + this.connection.connect(url, this._onconnect.bind(this)); }; /** @@ -437,11 +419,11 @@ Spotify.prototype._onmessagecommand = function (command, args) { } else if ('ping_flash2' == command) { this.sendPong(args[0]); } else if ('login_complete' == command) { - // ignore... + this.sendCommand('sp/log', [41, 1, 0, 0, 0, 0]); // Spotify.Logging.Logger#logWindowSize + this.sendCommand('sp/user_info', this._onuserinfo.bind(this)); } else { // unhandled message - console.error(command, args); - throw new Error('TODO: implement!'); + debug('unhandled %j command, args: %j', command, args); } }; @@ -472,7 +454,7 @@ Spotify.prototype.sendPong = function(ping) { var pong = "undefined 0"; var input = ping.split(' '); if (input.length >= 20) { - var key = [[19,104],[16,19],[0,41],[3,133],[10,175],[1,240],[5,150],[17,116],[7,240],[13,0]]; + var key = [[7, 203], [15, 15], [1, 96], [19, 93], [3, 165], [14, 130], [12, 16], [4, 6], [6, 225], [13, 37]]; var output = new Array(key.length); for (var i = 0; i < key.length; i++) { var idx = key[i][0]; @@ -498,30 +480,14 @@ Spotify.prototype.sendPong = function(ping) { Spotify.prototype.sendCommand = function (name, args, fn) { if ('function' == typeof args) { fn = args; - args = []; - } - debug('sendCommand(%j, %j)', name, args); - var msg = { - name: name, - id: String(this.seq++), - args: args || [] - }; - if ('function' == typeof fn) { - // store callback function for later - debug('storing callback function for message id %s', msg.id); - this._callbacks[msg.id] = fn; - } - var data = JSON.stringify(msg); - debug('sending command: %s', data); - try { - this.ws.send(data); - } catch (e) { - this.emit('error', e); + args = null; } + var request = new this.connection.Request(name, args) + request.send(fn); }; /** - * Makes a Protobuf request over the WebSocket connection. + * Makes a Protobuf request over the WebSocket connection. * Also known as a MercuryRequest or Hermes Call. * * @param {Object} req protobuf request object @@ -532,7 +498,7 @@ Spotify.prototype.sendCommand = function (name, args, fn) { Spotify.prototype.sendProtobufRequest = function(req, fn) { debug('sendProtobufRequest(%j)', req); - // extract request object + // extract request object var isMultiGet = req.isMultiGet || false; var payload = req.payload || []; var header = { @@ -661,9 +627,7 @@ Spotify.prototype.sendProtobufRequest = function(req, fn) { Spotify.prototype.connect = function (fn) { debug('connect()'); - var creds = this.settings.credentials[0].split(':'); - var args = [ creds[0], creds[1], creds.slice(2).join(':') ]; - this.sendCommand('connect', args, this._onconnect.bind(this)); + this.connection.sendConnect(fn); }; /** @@ -675,183 +639,76 @@ Spotify.prototype.connect = function (fn) { Spotify.prototype.disconnect = function () { debug('disconnect()'); - this.connected = false; - clearInterval(this._heartbeatId); - this._heartbeatId = null; - if (this.ws) { - this.ws.close(); - this.ws = null; - } + this.connection.disconnect(); }; /** * Gets the "metadata" object for one or more URIs. * + * This function returns a single object or array of objects (depending on if the input is a String or Array) + * with each object only populated with the URI. To get the metadata for the object, use the .get() or .metadata() + * methods on the object. + * + * For backwards compatiblity, a callback function is also supported which automatically grabs the metadata before + * calling back with each new metadata object + * * @param {Array|String} uris A single URI, or an Array of URIs to get "metadata" for - * @param {Function} fn callback function + * @param {Function} fn callback function invoked once for each uri + * @param {Boolean} loadMetadata + * @return {Array|Object} * @api public */ Spotify.prototype.get = -Spotify.prototype.metadata = function (uris, fn) { +Spotify.prototype.metadata = function (uris, fn, loadMetadata) { debug('metadata(%j)', uris); - if (!Array.isArray(uris)) { - uris = [ uris ]; - } - // array of "request" Objects that will be protobuf'd - var requests = []; - var mtype = ''; - uris.forEach(function (uri) { - var type = util.uriType(uri); - if ('local' == type) { - debug('ignoring "local" track URI: %j', uri); - return; - } - var id = util.uri2id(uri); - mtype = type; - requests.push({ - method: 'GET', - uri: 'hm://metadata/' + type + '/' + id - }); - }); - - - var header = { - method: 'GET', - uri: 'hm://metadata/' + mtype + 's' + + // convert input uris to array but save if we should return an array or a bare object + var returnArray = Array.isArray(uris); + if (!returnArray) uris = [uris]; + + loadMetadata = ('undefined' !== typeof loadMetadata) ? loadMetadata : ('function' == typeof fn); + fn = util.wrapCallback(fn, this); + + var typeMapping = { + 'track': this.Track, + 'artist': this.Artist, + 'album': this.Album, + 'playlist': this.Playlist, + 'starred': this.Playlist, + 'rootlist': this.Playlist, + 'publishedrootlist': this.Playlist, + 'user': this.User }; - var multiGet = true; - if (requests.length == 1) { - header = requests[0]; - requests = null; - multiGet = false; - } - - this.sendProtobufRequest({ - header: header, - payload: requests, - isMultiGet: multiGet, - responseSchema: { - 'vnd.spotify/metadata-artist': Artist, - 'vnd.spotify/metadata-album': Album, - 'vnd.spotify/metadata-track': Track + var objects = uris.map(function (uri) { + uri = new SpotifyUri(uri); + + if (!(uri.type in typeMapping)) { + debug('unhandled uri type: %s', uri.type); + return { + uri: uri, + type: uri.type + }; } - }, function(err, item) { - if (err) return fn(err); - item._loaded = true; - fn(null, item); - }); -}; - -/** - * Gets the metadata from a Spotify "playlist" URI. - * - * @param {String} uri playlist uri - * @param {Number} from (optional) the start index. defaults to 0. - * @param {Number} length (optional) number of tracks to get. defaults to 100. - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.playlist = function (uri, from, length, fn) { - // argument surgery - if ('function' == typeof from) { - fn = from; - from = length = null; - } else if ('function' == typeof length) { - fn = length; - length = null; - } - if (null == from) from = 0; - if (null == length) length = 100; - - debug('playlist(%j, %j, %j)', uri, from, length); - var self = this; - var parts = uri.split(':'); - var user = parts[2]; - var id = parts[4]; - var hm = 'hm://playlist/user/' + user + '/playlist/' + id + - '?from=' + from + '&length=' + length; - - this.sendProtobufRequest({ - header: { - method: 'GET', - uri: hm - }, - responseSchema: SelectedListContent - }, fn); -}; - -/** - * Gets the user's stored playlists - * - * @param {Number} from (optional) the start index. defaults to 0. - * @param {Number} length (optional) number of tracks to get. defaults to 100. - * @param {Function} fn callback function - * @api public - */ -Spotify.prototype.rootlist = function (user, from, length, fn) { - // argument surgery - if ('function' == typeof user) { - fn = user; - from = length = user = null; - } else if ('function' == typeof from) { - fn = from; - from = length = null; - } else if ('function' == typeof length) { - fn = length; - length = null; - } - if (null == user) user = this.username; - if (null == from) from = 0; - if (null == length) length = 100; - - debug('rootlist(%j, %j, %j)', user, from, length); - - var self = this; - var hm = 'hm://playlist/user/' + user + '/rootlist?from=' + from + '&length=' + length; - - this.sendProtobufRequest({ - header: { - method: 'GET', - uri: hm - }, - responseSchema: SelectedListContent - }, fn); -}; + var object = null; + var err = null; + try { + object = new typeMapping[uri.type](uri); + } catch (e) { + err = e; + } -/** - * Retrieve suggested similar tracks to the given track URI - * - * @param {String} uri track uri - * @param {Function} fn callback function - * @api public - */ + if (err) { + process.nextTick(fn.bind(null, err)); + } else if (loadMetadata) { + // for backwards compatibility we load in the metadata of the object before calling back + object.get(fn); + } -Spotify.prototype.similar = function(uri, fn) { - debug('similar(%j)', uri); - - var parts = uri.split(':'); - var type = parts[1]; - var id = parts[2]; - - if (!type || !id || 'track' != type) - throw new Error('uri must be a track uri'); - - this.sendProtobufRequest({ - header: { - method: 'GET', - uri: 'hm://similarity/suggest/' + id - }, - payload: { - country: this.country || 'US', - language: this.settings.locale.current || 'en', - device: 'web' - }, - payloadSchema: StoryRequest, - responseSchema: StoryList - }, fn); + return object; + }, this); + return (returnArray) ? objects : objects[0]; }; /** @@ -1164,15 +1021,7 @@ Spotify.prototype.sendTrackProgress = function (lid, ms, fn) { */ Spotify.prototype._onconnect = function (err, res) { - if (err) return this.emit('error', err); - if ('ok' == res.result) { - this.connected = true; - this.emit('connect'); - this.sendCommand('sp/user_info', this._onuserinfo.bind(this)); - this.sendCommand('sp/log', [41, 1, 0, 0, 0, 0]); // Spotify.Logging.Logger#logWindowSize - } else { - // TODO: handle possible error case - } + this.emit('connect'); }; /** @@ -1185,35 +1034,11 @@ Spotify.prototype._onconnect = function (err, res) { Spotify.prototype._onuserinfo = function (err, res) { if (err) return this.emit('error', err); - this.username = res.result.user; - this.country = res.result.country; - this.accountType = res.result.catalogue; + this.user_info = res.result; + this.user = new this.User(this.username); this.emit('login'); }; -/** - * Starts the interval that sends and "sp/echo" command to the Spotify server - * every 18 seconds. - * - * @api private - */ - -Spotify.prototype._startHeartbeat = function () { - debug('starting heartbeat every %s seconds', this.heartbeatInterval / 1000); - var fn = this._onheartbeat.bind(this); - this._heartbeatId = setInterval(fn, this.heartbeatInterval); -}; - -/** - * Sends an "sp/echo" command. - * - * @api private - */ - -Spotify.prototype._onheartbeat = function () { - this.sendCommand('sp/echo', 'h'); -}; - /** * Called when `this.reply()` is called in the "do_work" payload. * diff --git a/lib/track.js b/lib/track.js deleted file mode 100644 index 11fa9ce..0000000 --- a/lib/track.js +++ /dev/null @@ -1,156 +0,0 @@ - -/** - * Module dependencies. - */ - -var util = require('./util'); -var Track = require('./schemas').build('metadata','Track'); -var PassThrough = require('stream').PassThrough; -var debug = require('debug')('spotify-web:track'); - -// node v0.8.x compat -if (!PassThrough) PassThrough = require('readable-stream/passthrough'); - -/** - * Module exports. - */ - -exports = module.exports = Track; - -/** - * Track URI getter. - */ - -Object.defineProperty(Track.prototype, 'uri', { - get: function () { - return util.gid2uri('track', this.gid); - }, - enumerable: true, - configurable: true -}); - -/** - * Track Preview URL getter - */ -Object.defineProperty(Track.prototype, 'previewUrl', { - get: function () { - var previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/' - return this.preview.length && (previewUrlBase + util.gid2id(this.preview[0].fileId)); - }, - enumerable: true, - configurable: true -}) - -/** - * Loads all the metadata for this Track instance. Useful for when you get an only - * partially filled Track instance from an Album instance for example. - * - * @param {Function} fn callback function - * @api public - */ - -Track.prototype.get = -Track.prototype.metadata = function (fn) { - if (this._loaded) { - // already been loaded... - debug('track already loaded'); - return process.nextTick(fn.bind(null, null, this)); - } - var spotify = this._spotify; - var self = this; - spotify.get(this.uri, function (err, track) { - if (err) return fn(err); - // extend this Track instance with the new one's properties - Object.keys(track).forEach(function (key) { - if (!self.hasOwnProperty(key)) { - self[key] = track[key]; - } - }); - fn(null, self); - }); -}; - -/** - * Begins playing this track, returns a Readable stream that outputs MP3 data. - * - * @api public - */ - -Track.prototype.play = function () { - // TODO: add formatting options once we figure that out - var spotify = this._spotify; - var stream = new PassThrough(); - - // if a song was playing before this, the "track_end" command needs to be sent - var track = spotify.currentTrack; - if (track && track._playSession) { - spotify.sendTrackEnd(track._playSession.lid, track.uri, track.duration); - track._playSession = null; - } - - // set this Track instance as the "currentTrack" - spotify.currentTrack = track = this; - - // initiate a "play session" for this Track - spotify.trackUri(track, function (err, res) { - if (err) return stream.emit('error', err); - if (!res.uri) return stream.emit('error', new Error('response contained no "uri"')); - debug('GET %s', res.uri); - track._playSession = res; - var req = spotify.agent.get(res.uri) - .set({ 'User-Agent': spotify.userAgent }) - .end() - .request(); - req.on('response', response); - }); - - function response (res) { - debug('HTTP/%s %s', res.httpVersion, res.statusCode); - if (res.statusCode == 200) { - res.pipe(stream); - } else { - stream.emit('error', new Error('HTTP Status Code ' + res.statusCode)); - } - } - - // return stream immediately so it can be .pipe()'d - return stream; -}; - -/** - * Begins playing a preview of the track, returns a Readable stream that outputs MP3 data. - * - * @api public - */ - -Track.prototype.playPreview = function () { - var spotify = this._spotify; - var stream = new PassThrough(); - var previewUrl = this.previewUrl; - - if (!previewUrl) { - process.nextTick(function() { - stream.emit('error', new Error('Track does not have preview available')); - }); - return stream; - } - - debug('GET %s', previewUrl); - var req = spotify.agent.get(previewUrl) - .set({ 'User-Agent': spotify.userAgent }) - .end() - .request(); - req.on('response', response); - - function response (res) { - debug('HTTP/%s %s', res.httpVersion, res.statusCode); - if (res.statusCode == 200) { - res.pipe(stream); - } else { - stream.emit('error', new Error('HTTP Status Code ' + res.statusCode)); - } - } - - // return stream immediately so it can be .pipe()'d - return stream; -}; diff --git a/lib/uri.js b/lib/uri.js new file mode 100644 index 0000000..d2d6369 --- /dev/null +++ b/lib/uri.js @@ -0,0 +1,258 @@ + +/** + * Module dependencies. + */ + +var base62 = require('./base62'); +var debug = require('debug')('spotify-web:uri'); + +/** + * Module exports. + */ + +module.exports = SpotifyUri; + +SpotifyUri.PLAYLIST_TYPES = ['playlist', 'starred', 'rootlist', 'publishedrootlist']; + +/** + * Create a new SpotifyUri instance from a given uri type and gid + * + * @param {String} uriType + * @param {Buffer} gid + */ + +SpotifyUri.fromGid = function(uriType, gid) { + return new SpotifyUri(SpotifyUri.gid2uri(uriType, gid)); +}; + +/** + * Create a new SpotifyUri instance from a given uri type and id + * + * @param {String} uriType + * @param {String} id (hexadecimal) + */ + +SpotifyUri.fromId = function(uriType, id) { + return new SpotifyUri(SpotifyUri.id2uri(uriType, gid)); +}; + +/** + * Create a new SpotifyUri instance from a given uri + * + * @param {String} uri + */ + +SpotifyUri.fromUri = function(uri) { + return new SpotifyUri(uri); +}; + +/** + * SpotifyUri class. + * + * @api public + */ + +function SpotifyUri(type, id) { + if ('string' != typeof type) throw new Error('Invalid URI type'); + + this._uri_parts = []; + + // TODO(adammw): support playlists in constructor + + if (!id) { + this.uri = type; + } else { + if (id instanceof Buffer) id = SpotifyUri.gid2id(id); + if (/^[0-9a-f]*$/.test(id)) id = base62.fromHex(id, 22); + this._uri_parts = ['spotify', type, id]; + } +} + +/** + * SpotifyUri uri getter / setter + */ + +Object.defineProperty(SpotifyUri.prototype, 'uri', { + get: function () { + var uri = this._uri_parts.join(':'); + debug('get uri() : %s', uri) + return uri; + }, + set: function (uri) { + debug('set uri() : %s', uri); + var uri_parts = uri.split(':'); + if ('spotify' != uri_parts[0]) throw new Error('Invalid Spotify Uri'); + this._uri_parts = uri_parts; + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri type getter + */ + +Object.defineProperty(SpotifyUri.prototype, 'type', { + get: function () { + var parts = this._uri_parts; + var len = parts.length; + + if (len >= 3 && 'local' == parts[1]) { + // e.g. spotify:local:AC%2FDC:Highway+to+Hell:Highway+to+Hell:209 + return 'local'; + } else if (len >= 5) { + // e.g. spotify:user:tootallnate:[playlist]:0Lt5S4hGarhtZmtz7BNTeX + return parts[3]; + } else if (len >= 4 && SpotifyUri.PLAYLIST_TYPES.indexOf(parts[3]) !== -1) { + // e.g. spotify:user:tootallnate:starred + return parts[3]; + } else if (len >= 3) { + // e.g. spotify:[track]:6tdp8sdXrXlPV6AZZN2PE8 + return parts[1]; + } else { + return null; + } + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri sid getter / setter + * + * e.g. '6tdp8sdXrXlPV6AZZN2PE8' for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 + */ + +Object.defineProperty(SpotifyUri.prototype, 'sid', { + get: function () { + var parts = this._uri_parts; + var len = parts.length; + + return parts[len - 1]; + }, + set: function (sid) { + var parts = this._uri_parts; + var len = parts.length; + + parts[len - 1] = sid; + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri id getter / setter + * + * e.g. 'd49fcea60d1f450691669b67af3bda24' for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 + */ + +Object.defineProperty(SpotifyUri.prototype, 'id', { + get: function () { + return base62.toHex(this.sid); + }, + set: function (id) { + this.sid = base62.fromHex(id, 22); + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri gid getter / setter + * + * e.g. for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 + */ + +Object.defineProperty(SpotifyUri.prototype, 'gid', { + get: function () { + return new Buffer(this.id, 'hex'); + }, + set: function (gid) { + this.id = gid.toString('hex'); + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri user getter / setter + */ + +Object.defineProperty(SpotifyUri.prototype, 'user', { + get: function () { + var parts = this._uri_parts; + var len = parts.length; + + if (len >= 3 && 'user' == parts[1]) return parts[2]; + return null; + }, + set: function (user) { + var parts = this._uri_parts; + var len = parts.length; + + if (len >= 3 && 'user' == parts[1]) parts[2] = user; + }, + enumerable: true, + configurable: true +}); + +/** + * Returns the underlying uri string + * + * @return {String} + */ +SpotifyUri.prototype.toString = function() { + return this.uri; +}; + +/** + * Converts a GID Buffer to an ID hex string. + * Provided for backwards compatibility. + */ + +SpotifyUri.gid2id = function (gid) { + return gid.toString('hex'); +}; + +/** + * ID -> URI + * Provided for backwards compatibility. + */ + +SpotifyUri.id2uri = function (uriType, id) { + return (new SpotifyUri(uriType, id)).uri; +}; + +/** + * URI -> ID + * Provided for backwards compatibility. + * + * >>> SpotifyUtil.uri2id('spotify:track:6tdp8sdXrXlPV6AZZN2PE8') + * 'd49fcea60d1f450691669b67af3bda24' + * >>> SpotifyUtil.uri2id('spotify:user:tootallnate:playlist:0Lt5S4hGarhtZmtz7BNTeX') + * '192803a20370c0995f271891a32da6a3' + */ + +SpotifyUri.uri2id = function (uri) { + return (uri instanceof SpotifyUri) ? uri.id : (new SpotifyUri(uri)).id; +}; + +/** + * GID -> URI + * Provided for backwards compatibility. + */ + +SpotifyUri.gid2uri = function (uriType, gid) { + return (new SpotifyUri(uriType, gid)).uri; +}; + +/** + * Accepts a String URI, returns the "type" of URI. + * i.e. one of "local", "playlist", "track", etc. + * + * Provided for backwards compatibility. + */ + +SpotifyUri.uriType = function (uri) { + return (uri instanceof SpotifyUri) ? uri.type : (new SpotifyUri(uri)).type; +}; diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 0000000..b170e29 --- /dev/null +++ b/lib/user.js @@ -0,0 +1,214 @@ + +/** + * Module dependencies. + */ + +var schemas = require('./schemas'); +var util = require('./util'); +var SpotifyUri = require('./uri'); +var debug = require('debug')('spotify-web:user'); + +/** + * Protocol Buffer types. + */ + +var SocialDecorationData = schemas.build('social','DecorationData'); +var PresenceState = schemas.build('presence','State'); + +/** + * Module exports. + */ + +exports = module.exports = User; + +/** + * Creates a new User instance with the specified username or uri, + * or in the case of multiple usernames or uris, creates an array of new User instances. + * + * Instances will only contain a username and will not have any metadata populated + * + * @param {Object} spotify Spotify object instance + * @param {Array|String} uris A single username or user URI, or an Array + * @param {Function} (Optional) fn callback function + * @return {Array|Album} + * @api public + */ + +User.get = function(spotify, uri, fn) { + debug('get(%j)', uri); + + // convert input uris to array but save if we should return an array or a bare object + var returnArray = Array.isArray(uri); + if (!returnArray) uri = [uri]; + + // call the Album constructor for each uri, and call the callback if we have an error + var users; + try { + users = uri.map(User.bind(null, spotify)); + } catch (e) { + return process.nextTick(fn.bind(null, e)); + } + + // return the array of albums or a single album and call callbacks if applicable + var ret = (returnArray) ? users : users[0]; + if ('function' == typeof fn) process.nextTick(fn.bind(null, null, ret)); + return ret; +}; +User.get['$inject'] = ['Spotify']; + +/** + * User class. + * + * @api public + */ + +function User (spotify, username) { + if (!(this instanceof User)) return new User(spotify, username); + this._spotify = spotify; + if ('string' == typeof username) { + if (/:/.test(username)) { + this.uri = new SpotifyUri(username); + if ('user' != this.uri.type) throw new Error('Invalid URI Type: ' + type); + } else { + this.uri = new SpotifyUri('user', username); + } + } else { + throw new Error('ArgumentError: Invalid arguments'); + } + + this._loaded = false; +} +User['$inject'] = ['Spotify']; + +/** + * Username getter / setter + */ + +Object.defineProperty(User.prototype, 'username', { + get: function () { + return this.uri.user; + }, + set: function (username) { + this.uri.user = username; + }, + enumerable: true, + configurable: true +}); + +/** + * isCurrentUser getter + */ + +Object.defineProperty(User.prototype, 'isCurrentUser', { + get: function () { + return this._spotify.username == this.username; + }, + enumerable: true, + configurable: true +}); + +/** + * Update the User instance with the properties of another object + * + * @param {SocialDecorationData} user + * @api private + */ +User.prototype._update = function(user) { + var self = this; + var spotify = this._spotify; + + Object.keys(user).forEach(function (key) { + if (!self.hasOwnProperty(key)) { + self[key] = spotify._objectify(user[key]); + } + }); + + this._loaded = true; +}; + +/** + * Loads all the metadata for this User instance. + * + * @param {Boolean} (Optional) refresh + * @param {Function} fn callback function + * @api public + */ + +User.prototype.get = +User.prototype.metadata = function (refresh, fn) { + // argument surgery + if ('function' == typeof refresh) { + fn = refresh; + refresh = false; + } + + debug('metadata(%j)', refresh); + + var self = this; + var spotify = this._spotify; + + if (!refresh && this._loaded) { + // already been loaded... + debug('user already loaded'); + return process.nextTick(fn.bind(null, null, this)); + } + + var request = new spotify.HermesRequest('hm://social/decoration/user/' + encodeURIComponent(this.username)); + request.setResponseSchema(SocialDecorationData); + request.send(function(err, res) { + if (err) return fn(err); + self._update(res.result); + fn(null, self); + }); +}; + +/** + * Get the user's recent activity + * + * @param {Function} fn callback + */ +User.prototype.activity = function(fn) { + debug('activity()'); + var spotify = this._spotify; + var request = new spotify.HermesRequest('hm://presence/user/'); + request.setResponseSchema(PresenceState); + request.send((new Buffer(this.username)).toString('base64'), function(err, res) { + if (err) return fn(err); + //TODO + fn(null, res.result); + }); +}; + +User.prototype.following = function() { + throw new Error('TODO: implement'); +}; + +User.prototype.followers = function() { + throw new Error('TODO: implement'); +}; + +/** + * Gets the user's stored playlists + * + * @param {String} type (optional) the rootlist type (either 'rootlist' or 'publishedrootlist') + * @return {Spotify.Playlist} + * @api public + */ + +User.prototype.rootlist = function(type) { + if (type !== 'rootlist' && type !== 'publishedrootlist') { + type = (this.isCurrentUser) ? 'rootlist' : 'publishedrootlist'; + } + + return this._spotify.get(['spotify', 'user', this.username, type].join(':')); +}; + +/** + * Gets a user's starred playlist + * + * @return {Spotify.Playlist} + * @api public + */ +User.prototype.starred = function() { + return this._spotify.get(['spotify', 'user', this.username, 'starred'].join(':')); +}; diff --git a/lib/util.js b/lib/util.js index 26611a3..c3afc9e 100644 --- a/lib/util.js +++ b/lib/util.js @@ -3,76 +3,229 @@ * Module dependencies. */ -var base62 = require('./base62'); +var SpotifyUri = require('./uri'); +var EventEmitter = require('events').EventEmitter; +var debug = require('debug')('spotify-web:util'); /** - * Converts a GID Buffer to an ID hex string. - * Based off of Spotify.Utils.str2hex(), modified to work with Buffers. + * Export all the namespaces of the object passed in on to `this` */ -exports.gid2id = function (gid) { - for (var b = '', c = 0, a = gid.length; c < a; ++c) { - b += (gid[c] + 256).toString(16).slice(-2); - } - return b; +exports.recursiveExport = function(object) { + exports.export(this, object); + if (object['$exports']) + object['$exports'].forEach(function($export) { + exports.recursiveExport.call(this, object[$export]); + }, this) }; /** - * ID -> URI + * Export namespaces decorator + * + * Inspired by AngularJS DI, but is in no way compatible and does not use the same syntax + * + * As this method adds properties to the object's prototype, it must be called *after* any + * modifications to the prototype object, such as is done by util.inherits + * + * @param {Object} object Object to decorate + * @param {Array|Function} namespaces Namespace objects to export + * @api private */ +exports.export = function(object, namespaces) { + var objectName = object.name; + + // support explict naming + if (Array.isArray(object)) { + objectName = object[0]; + object = object[1]; + } + + if (!Array.isArray(namespaces)) + namespaces = [namespaces]; + + debug('exporting %d namespaces on %s', namespaces.length, objectName); + + if (!Array.isArray(object['$exports'])) + object['$exports'] = []; + + namespaces.forEach(function(namespace){ + var name = namespace.name; -exports.id2uri = function (uriType, v) { - var id = base62.fromHex(v, 22); - return 'spotify:' + uriType + ':' + id; + // support explict naming + if (Array.isArray(namespace)) { + name = namespace[0]; + namespace = namespace[1]; + } + + var privateName = '_' + name; + + // append to exported types + object['$exports'].push(name); + + // static export + object[name] = namespace; + + // dynamic export + var argumentIndex; + Object.defineProperty(object.prototype, name, { + get: function() { + if (!this[privateName]) { + // bind the constructor + debug('attempting bind of %s.%s()', objectName, name); + this[privateName] = exports.bindFunction(namespace, this, objectName); + + // TODO(adammw): rewrite this so the call stack doesn't go crazy + if (object['$provides']) { + Object.keys(object['$provides']).forEach(function(providerName) { + var providerKey = object['$provides'][providerName]; + debug('%j provides %j at %s.%s', objectName, providerName, objectName, providerKey); + this[privateName] = exports.bindFunction(this[privateName], this[providerKey], providerName); + }, this); + } + + // bind any static methods that we need to bind, + // otherwise just copy the value to the bound function + // TODO(adammw): make this function recursive if there are objects + // TODO(adammw): rewrite this so the call stack doesn't go crazy + Object.keys(namespace).forEach(function(key) { + var fn = namespace[key], match, split; + if ('$inject' == key) return; + if ('function' == typeof fn) { + debug('attempting bind of %s.%s.%s()', objectName, name, key); + fn = exports.bindFunction(fn, this, objectName); + if (object['$provides']) { + Object.keys(object['$provides']).forEach(function(providerName) { + var providerKey = object['$provides'][providerName]; + debug('%j provides %j at %s.%s', objectName, providerName, objectName, providerKey); + fn = exports.bindFunction(fn, this[providerKey], providerName); + }, this); + } + } + this[privateName][key] = fn; + }, this); + } + return this[privateName]; + }, + enumerable: true, + configurable: true + }); + }); }; /** - * URI -> ID + * Bind a function to an object using the function's $inject array * - * >>> SpotifyUtil.uri2id('spotify:track:6tdp8sdXrXlPV6AZZN2PE8') - * 'd49fcea60d1f450691669b67af3bda24' - * >>> SpotifyUtil.uri2id('spotify:user:tootallnate:playlist:0Lt5S4hGarhtZmtz7BNTeX') - * '192803a20370c0995f271891a32da6a3' + * @param {Function} fn Function to be bound + * @param {Object} object Object that the function will be bound to + * @param {String} name The Object's name, used to search $inject + * @return {Function} + * @api private */ +exports.bindFunction = function(fn, object, name) { + debug('injection arguments are: %j', fn.$inject); -exports.uri2id = function (uri) { - var parts = uri.split(':'); - var s; - if (parts.length > 3 && 'playlist' == parts[3]) { - s = parts[4]; - } else { - s = parts[2]; + // no arguments - abort + if (!fn.$inject || !Array.isArray(fn.$inject) || !fn.$inject.length) return fn; + + // search for the argument we can provide + argumentIndex = fn.$inject.indexOf(name); + + // if it isn't there - abort + if (-1 === argumentIndex) return fn; + + // if it's the first argument, use Function.prototype.bind + // and modify the arguments on the bound fn + debug('binding instance to first argument of fn using Function.prototype.bind'); + if (0 === argumentIndex) { + var ret = fn.bind(null, object); + ret.$inject = fn.$inject.slice(1); + return ret; } - var v = base62.toHex(s); - return v; + + // otherwise, it's not the first argument, and we need to do bind ourselves + debug('binding instance to argument %d of fn using bound_fn', argumentIndex); + var ret = function bound_fn(){ + var args = Array.prototype.slice.call(arguments, 0); + args.splice(argumentIndex, 0, object); + fn.apply(null, args); + }; + ret.$inject = fn.$inject.slice(0); + ret.$inject.splice(argumentIndex, 1); + return ret; }; /** - * GID -> URI + * Wrap a callback and handle the arity check + * + * @param {Function} fn + * @param {Object} self (this context) + * @return {Function} */ -exports.gid2uri = function (uriType, gid) { - var id = exports.gid2id(gid); - return exports.id2uri(uriType, id); +exports.wrapCallback = function (fn, self) { + if (!self) self = this; + if ('function' == typeof fn && 2 == fn.length) return fn; + return function(err, res) { + if (err) return self.emit('error', err); + if ('function' == typeof fn) fn(res); + }; }; -/** - * Accepts a String URI, returns the "type" of URI. - * i.e. one of "local", "playlist", "track", etc. + +/** + * Bind helper that sets the $inject property correctly + * + * @param {Function} fn the function to bind + * @param ... arguments to bind with + * @return {Function} */ +exports.bind = function(fn) { + var bindArgs = Array.prototype.slice.call(arguments, 1); + var boundFn = Function.prototype.bind.apply(fn, bindArgs); + if (fn.$inject && Array.isArray(fn.$inject)) { + boundFn.$inject = fn.$inject.slice(bindArgs.length - 1); + } + + debug('binding %s - bind args: %j, old $inject: %j, new $inject: %j', fn.name, bindArgs, fn.$inject, boundFn.$inject); + return boundFn; +}; + +exports.checkUri = function(uriA, uriB) { + if ('/' == uriA[uriA.length - 1]) uriA = uriA.substring(0, uriA.length - 1); + if ('/' == uriB[uriB.length - 1]) uriB = uriB.substring(0, uriB.length - 1); + debug('checkUri %s == %s', uriA, uriB); + return uriA == uriB; +}; -exports.uriType = function (uri) { - var parts = uri.split(':'); - var len = parts.length; - if (len >= 3 && 'local' == parts[1]) { - return 'local'; - } else if (len >= 5) { - return parts[3]; - } else if (len >= 4 && 'starred' == parts[3]) { - return 'playlist'; - } else if (len >= 3) { - return parts[1]; - } else { - throw new Error('could not determine "type" for URI: ' + uri); +/** + * Helper function when callbacks need to be defered until something else can be completed. + * + * Returns a function that accepts an error argument, if the error is defined, + * calls back the callback with the argument. + * + * If there is no error, calls back the original function with the callback as the argument. + * + * @param {Function} fn the function to callback with arguments on success + * @param {Function} cb the function to callback with error argument on error + * @returns {Function} + */ +exports.deferCallback = function(fn, cb) { + debug('deferring callback...'); + return function(err) { + if (err) return cb(err); + fn(cb); } }; + +/** + * Coerce a function into an EventEmitter-like object + * + * @param {Function} fn + * @return {Function} + */ +exports.makeEmitter = function(fn) { + fn.__proto__ = Object.create(fn.__proto__); + Object.keys(EventEmitter.prototype).forEach(function(key) { + fn.__proto__[key] = EventEmitter.prototype[key]; + }); +}; diff --git a/proto/presence.desc b/proto/presence.desc new file mode 100644 index 0000000..0f653a9 Binary files /dev/null and b/proto/presence.desc differ diff --git a/proto/presence.proto b/proto/presence.proto new file mode 100644 index 0000000..106444b --- /dev/null +++ b/proto/presence.proto @@ -0,0 +1,96 @@ +package spotify.presence.proto; + +message PlaylistPublishedState { + optional string uri = 1; + optional int64 timestamp = 2; +} + +message PlaylistTrackAddedState { + optional string playlist_uri = 1; + optional string track_uri = 2; + optional int64 timestamp = 3; +} + +message TrackFinishedPlayingState { + optional string uri = 1; + optional string context_uri = 2; + optional int64 timestamp = 3; + optional string referrer_uri = 4; + +} + +message FavoriteAppAddedState { + optional string app_uri = 1; + optional int64 timestamp = 2; +} + +message TrackStartedPlayingState { + optional string uri = 1; + optional string context_uri = 2; + optional int64 timestamp = 3; + optional string referrer_uri = 4; +} + +message UriSharedState { + optional string uri = 1; + optional string message = 2 [default=""]; + optional int64 timestamp = 3; +} + +message ArtistFollowedState { + optional string uri = 1; + optional string artist_name = 2 [default=""]; + optional string artist_cover_uri = 3 [default=""]; + optional int64 timestamp = 4; +} + +message DeviceInformation { + optional string os = 1; + optional string type = 2; +} + +message GenericPresenceState { + optional int32 type = 1; + optional int64 timestamp = 2; + optional string item_uri = 3; + optional string item_name = 4; + optional string item_image = 5; + optional string context_uri = 6; + optional string context_name = 7; + optional string context_image = 8; + optional string referrer_uri = 9; + optional string referrer_name = 10; + optional string referrer_image = 11; + optional string message = 12; + optional DeviceInformation device_information = 13; +} + +message State { + optional int64 timestamp = 1; + + enum Type { + PLAYLIST_PUBLISHED = 1; + PLAYLIST_TRACK_ADDED = 2; + TRACK_FINISHED_PLAYING = 3; + FAVORITE_APP_ADDED = 4; + TRACK_STARTED_PLAYING = 5; + URI_SHARED = 6; + ARTIST_FOLLOWED = 7; + GENERIC = 11; + } + + optional Type type = 2; + optional string uri = 3; + optional PlaylistPublishedState playlist_published = 4; + optional PlaylistTrackAddedState playlist_track_added = 5; + optional TrackFinishedPlayingState track_finished_playing = 6; + optional FavoriteAppAddedState favorite_app_added = 7; + optional TrackStartedPlayingState track_started_playing = 8; + optional UriSharedState uri_shared = 9; + optional ArtistFollowedState artist_followed = 10; + optional GenericPresenceState generic = 11; +} + +message StateList { + repeated State states = 1; +} diff --git a/proto/social.desc b/proto/social.desc new file mode 100644 index 0000000..63e05cc --- /dev/null +++ b/proto/social.desc @@ -0,0 +1,12 @@ + +Å + social.protospotify.social.proto"ž +DecorationData +username (  + full_name (  + image_url (  +large_image_url (  + +first_name (  + last_name (  + facebook_uid ( \ No newline at end of file diff --git a/proto/social.proto b/proto/social.proto new file mode 100644 index 0000000..08f9746 --- /dev/null +++ b/proto/social.proto @@ -0,0 +1,11 @@ +package spotify.social.proto; + +message DecorationData { + optional string username = 1; + optional string full_name = 2; + optional string image_url = 3; + optional string large_image_url = 5; + optional string first_name = 6; + optional string last_name = 7; + optional string facebook_uid = 8; +}