From cce3a49bec1f92584c230f1e939ae5b79eddce78 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 20:05:43 +1100 Subject: [PATCH 001/108] Add Request / Response classes --- lib/request.js | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/response.js | 53 ++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lib/request.js create mode 100644 lib/response.js diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 0000000..e68d79b --- /dev/null +++ b/lib/request.js @@ -0,0 +1,131 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('./spotify'); +var Response = require('./response'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:request'); + +/** + * Module exports. + */ + +module.exports = Request; + +/** + * Request base class + * + * @api public + * + * @param {#Spotify} spotify + * @param {String} name + * @param {Array} (Optional) args Arguments to send with the request + */ + +function Request(spotify, name, args) { + debug('Request(%j, %j)', name, args); + if (!(this instanceof Request)) return new Request(spotify, name, args); + if ('object' != typeof spotify || !(spotify instanceof Spotify.constructor)) throw new Error('Spotify 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._spotify = spotify; + this.name = name || null; + this.args = args || null; + this.id = null; + this.response = null; + this.sent = false; + this._callback = null; + + if ('connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; +} +inherits(Request, EventEmitter); + +/** + * 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._callback = fn; + if (undefined !== args) this.args = args; + + // queue the request to be sent + this.sent = true; + this._spotify.queueRequest(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._spotify.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() { + return ('function' == typeof this._callback); +}; + +/** + * 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 + var fn = this._callback; + if ('function' == typeof fn && 2 == fn.length) return fn(err, this.response); + if (err) return this.emit('error', err); + if ('function' == typeof fn) fn(this.response); +}; diff --git a/lib/response.js b/lib/response.js new file mode 100644 index 0000000..001dd58 --- /dev/null +++ b/lib/response.js @@ -0,0 +1,53 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('./spotify'); +var debug = require('debug')('spotify-web: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; +}; From ac7c11e90fd594abd76091ec7f27a790d9aa7d36 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 20:09:24 +1100 Subject: [PATCH 002/108] Add HermesRequest / HermesResponse classes --- lib/hermes_request.js | 274 +++++++++++++++++++++++++++++++++++++++++ lib/hermes_response.js | 157 +++++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 lib/hermes_request.js create mode 100644 lib/hermes_response.js diff --git a/lib/hermes_request.js b/lib/hermes_request.js new file mode 100644 index 0000000..1cf7ca1 --- /dev/null +++ b/lib/hermes_request.js @@ -0,0 +1,274 @@ + +/** + * 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: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 {Spotify} spotify The spotify instance + * @param {String} (Optional) method The request method + * @param {Object|String} args The request arguments, or the Hermes URI + */ + +function HermesRequest(spotify, method, args) { + Request.call(this, spotify, 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 +} +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); +}; diff --git a/lib/hermes_response.js b/lib/hermes_response.js new file mode 100644 index 0000000..d781819 --- /dev/null +++ b/lib/hermes_response.js @@ -0,0 +1,157 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('./spotify'); +var schemas = require('./schemas'); +var http = require('http'); +var debug = require('debug')('spotify-web: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); + var self = this; + + // 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 { + var header = MercuryRequest.parse(new Buffer(data.result[0], 'base64')); + + this.uri = header.uri; + this.contentType = header.contentType; + this.statusCode = header.statusCode; + + if (header.userFields) { + if ('MC-Cache-Policy' in header.userFields) + this.cachePolicy = header.userFields['MC-Cache-Policy'].toString(); + if ('MC-ETag' in header.userFields) + this.etag = header.userFields['MC-ETag']; + if ('MC-TTL' in header.userFields) + this.ttl = Number(header.userFields['MC-TTL'].toString()); + + header.userFields.forEach(function(field) { + self.userFields[field.name] = field.value; + }); + } + + if (data.result.length > 1) + this.result = new Buffer(data.result[1], 'base64'); + } + + if (this.result && 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); +}; From 42f039226ef9f2d0e7d58bb5daa7a81473b6c90e Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 20:21:51 +1100 Subject: [PATCH 003/108] Move request / response classes to "connection" folder --- lib/{ => connection}/hermes_request.js | 2 +- lib/{ => connection}/hermes_response.js | 4 ++-- lib/{ => connection}/request.js | 2 +- lib/{ => connection}/response.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename lib/{ => connection}/hermes_request.js (99%) rename lib/{ => connection}/hermes_response.js (98%) rename lib/{ => connection}/request.js (98%) rename lib/{ => connection}/response.js (95%) diff --git a/lib/hermes_request.js b/lib/connection/hermes_request.js similarity index 99% rename from lib/hermes_request.js rename to lib/connection/hermes_request.js index 1cf7ca1..11cd949 100644 --- a/lib/hermes_request.js +++ b/lib/connection/hermes_request.js @@ -5,7 +5,7 @@ var Request = require('./request'); var HermesResponse = require('./hermes_response'); -var schemas = require('./schemas'); +var schemas = require('../schemas'); var http = require('http'); var inherits = require('util').inherits; var format = require('util').format; diff --git a/lib/hermes_response.js b/lib/connection/hermes_response.js similarity index 98% rename from lib/hermes_response.js rename to lib/connection/hermes_response.js index d781819..a2eb435 100644 --- a/lib/hermes_response.js +++ b/lib/connection/hermes_response.js @@ -3,8 +3,8 @@ * Module dependencies. */ -var Spotify = require('./spotify'); -var schemas = require('./schemas'); +var Spotify = require('../spotify'); +var schemas = require('../schemas'); var http = require('http'); var debug = require('debug')('spotify-web:response:hermes'); diff --git a/lib/request.js b/lib/connection/request.js similarity index 98% rename from lib/request.js rename to lib/connection/request.js index e68d79b..717f434 100644 --- a/lib/request.js +++ b/lib/connection/request.js @@ -3,7 +3,7 @@ * Module dependencies. */ -var Spotify = require('./spotify'); +var Spotify = require('../spotify'); var Response = require('./response'); var EventEmitter = require('events').EventEmitter; var inherits = require('util').inherits; diff --git a/lib/response.js b/lib/connection/response.js similarity index 95% rename from lib/response.js rename to lib/connection/response.js index 001dd58..0546b73 100644 --- a/lib/response.js +++ b/lib/connection/response.js @@ -3,7 +3,7 @@ * Module dependencies. */ -var Spotify = require('./spotify'); +var Spotify = require('../spotify'); var debug = require('debug')('spotify-web:response'); /** From 0584a3726297f8f9e4b846dbdab07cc8c2ac1e1d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 21:40:30 +1100 Subject: [PATCH 004/108] Create Connection class to handle the WebSocket connection --- lib/connection/connection.js | 282 +++++++++++++++++++++++++++++++ lib/connection/hermes_request.js | 6 +- lib/connection/index.js | 6 + lib/connection/request.js | 33 ++-- 4 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 lib/connection/connection.js create mode 100644 lib/connection/index.js diff --git a/lib/connection/connection.js b/lib/connection/connection.js new file mode 100644 index 0000000..7e36850 --- /dev/null +++ b/lib/connection/connection.js @@ -0,0 +1,282 @@ + +/** + * Module dependencies. + */ + +var Spotify = require('../spotify'); +var WebSocket = require('ws'); +var EventEmitter = require('events').EventEmitter; +var Request = require('./request'); +var Response = require('./response'); +var HermesRequest = require('./hermes_request'); +var HermesResponse = require('./hermes_response'); +var util = require('./util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:connection'); + +/** + * Module exports. + */ + +module.exports = SpotifyConnection; + +/** + * Re-export namespaces + */ + +var EXPORT_NAMESPACES = [Request, Response, HermesRequest, HermesResponse]; + +EXPORT_NAMESPACES.forEach(function (namespace) { + SpotifyConnection[namespace.name] = namespace; +}); + +/** + * 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._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('heartbeat', this._onheartbeat.bind(this)); + this.on('flush', this._onflush.bind(this)); + + // bind exported namespaces into object + EXPORT_NAMESPACES.forEach(util.bindNamespace.bind(null, this)); +} +inherits(SpotifyConnection, EventEmitter); + +/** + * 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(); + } +}; + +/** + * "connect" command callback function. + * + * @param {Object} res response Object + * @api private + */ + +SpotifyConnection.prototype._onconnect = function (err, 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) { + var request = this.requestQueue.shift(); + 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); + } + } + + this._requestQueueFlushId = null; +}; + +/** + * 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(fn, this.emit.bind(this, 'heartbeat')); +}; + +/** + * Stop the heartbeat interval + */ + +SpotifyConnection.prototype._stopHeartbeat = function () { + clearInterval(this._heartbeatId); + this._heartbeatId = null; +}; + +/** + * 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.bind(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(':') ]; + this.Request('connect').send(args, this._onconnect.bind(this)); +}; + +/** + * Sends an "sp/echo" command. + * + * @api private + */ + +SpotifyConnection.prototype.sendHeartbeat = function () { + debug('sendHeartbeat()'); + this.Request('sp/echo').send('h'); +}; diff --git a/lib/connection/hermes_request.js b/lib/connection/hermes_request.js index 11cd949..cb8bed6 100644 --- a/lib/connection/hermes_request.js +++ b/lib/connection/hermes_request.js @@ -36,13 +36,13 @@ var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); /** * HermesRequest class constructor. * - * @param {Spotify} spotify The spotify instance + * @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(spotify, method, args) { - Request.call(this, spotify, hermesRequestName, null); +function HermesRequest(connection, method, args) { + Request.call(this, connection, hermesRequestName, null); // argument surgery if ('string' == typeof args) { 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 index 717f434..869e9d3 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -3,10 +3,11 @@ * Module dependencies. */ -var Spotify = require('../spotify'); +var SpotifyConnection = require('./connection'); var Response = require('./response'); var EventEmitter = require('events').EventEmitter; var inherits = require('util').inherits; +var format = require('util').format; var debug = require('debug')('spotify-web:request'); /** @@ -20,19 +21,22 @@ module.exports = Request; * * @api public * - * @param {#Spotify} spotify + * @param {SpotifyConnection} connection * @param {String} name * @param {Array} (Optional) args Arguments to send with the request */ -function Request(spotify, name, args) { +function Request(connection, name, args) { debug('Request(%j, %j)', name, args); - if (!(this instanceof Request)) return new Request(spotify, name, args); - if ('object' != typeof spotify || !(spotify instanceof Spotify.constructor)) throw new Error('Spotify 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'); + 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._spotify = spotify; + this._connection = connection; this.name = name || null; this.args = args || null; this.id = null; @@ -40,10 +44,19 @@ function Request(spotify, name, args) { this.sent = false; this._callback = null; - if ('connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; + if (this.name && 'connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; } 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 * @@ -67,7 +80,7 @@ Request.prototype.send = function(args, fn) { // queue the request to be sent this.sent = true; - this._spotify.queueRequest(this); + this._connection.send(this); }; @@ -78,7 +91,7 @@ Request.prototype.send = function(args, fn) { */ Request.prototype.serialize = function() { // Generate id if not set - if (!this.id) this.id = String(this._spotify.seq++); + if (!this.id) this.id = String(this._connection.seq++); // Construct and return serialized message var msg = { From 069b533c7b7cf53efdc4331267b1d8cd2c986a2a Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 21:55:12 +1100 Subject: [PATCH 005/108] error: move to connection folder --- lib/connection/connection.js | 1 + lib/{ => connection}/error.js | 0 lib/spotify.js | 1 - lib/spotify.js.new-old | 1070 +++++++++++++++++++++++++++++++++ 4 files changed, 1071 insertions(+), 1 deletion(-) rename lib/{ => connection}/error.js (100%) create mode 100644 lib/spotify.js.new-old diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 7e36850..9229b54 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -6,6 +6,7 @@ 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'); 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/spotify.js b/lib/spotify.js index 2c7a584..c499dba 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -11,7 +11,6 @@ var cheerio = require('cheerio'); var schemas = require('./schemas'); var superagent = require('superagent'); var inherits = require('util').inherits; -var SpotifyError = require('./error'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var package = require('../package.json'); diff --git a/lib/spotify.js.new-old b/lib/spotify.js.new-old new file mode 100644 index 0000000..7f49594 --- /dev/null +++ b/lib/spotify.js.new-old @@ -0,0 +1,1070 @@ +/// REMEMBER TO JSLINT / JSHINT it before commiting! +/// TODO(adammw): reorganise into folders +/** + * Module dependencies. + */ + +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 SpotifyUri = require('./uri'); +var Request = require('./request'); +var HermesRequest = require('./hermes_request'); +var Playlist = require('./playlist'); +var Metadata = require('./metadata'); +var Image = require('./image'); +var User = require('./user'); +var EventEmitter = require('events').EventEmitter; +var debug = require('debug')('spotify-web'); +var package = require('../package.json'); + +/** + * Module exports. + */ + +module.exports = Spotify; + +/** + * Protocol Buffer types. + */ + +var MercuryMultiGetRequest = schemas.build('mercury','MercuryMultiGetRequest'); +var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); +var MercuryRequest = schemas.build('mercury','MercuryRequest'); + +require('./restriction'); + +var SelectedListContent = schemas.build('playlist4','SelectedListContent'); + +/** + * Re-export namespaces + */ + +var exportNamespaces = [Request, HermesRequest, Playlist, Metadata, Metadata.Track, Metadata.Artist, Metadata.Album, User]; + +exportNamespaces.forEach(function (namespace) { + Spotify[namespace.name] = namespace; +}); + +/** + * Declare which classes can handle which uri types + */ +var uriHandlers = { + 'track': Metadata.Track, + 'artist': Metadata.Artist, + 'album': Metadata.Album, + 'playlist': Playlist, + 'user': User +}; + +/** + * Re-export all the `SpotifyUri` functions for backwards compatiblity. + */ + +Object.keys(SpotifyUri).forEach(function (key) { + Spotify[key] = util[key]; +}); + +/** + * Create instance and login convenience function. + * + * @param {String} un username + * @param {String} pw password + * @param {Function} fn callback function + * @api public + */ + +Spotify.login = function (un, pw, fn) { + if (!fn) fn = function () {}; + var spotify = new Spotify(); + spotify.login(un, pw, function (err) { + if (err) return fn(err); + fn.call(spotify, null, spotify); + }); + return spotify; +}; + +/** + * Spotify Web base class. + * + * @api public + */ + +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.ws = null; + this.requestQueue = []; + this.requestQueueFlushImmediate = null; + this.currentPlaySession = null; + this.user = null; + this.username = null; + this.country = null; + this.accountType = null; + this.restrictToAvailable = true; + + this.authServer = 'play.spotify.com'; + this.authUrl = '/xhr/json/auth.php'; + this.landingUrl = '/'; + this.userAgent = 'Mozilla/5.0 (Chrome/13.37 compatible-ish) spotify-web/' + package.version; + + // base URLs for Image files like album artwork, artist prfiles, etc. + // these values taken from "spotify.web.client.js" + this.sourceUrl = 'https://d3rt1990lpmkn.cloudfront.net'; + this.sourceUrls = { + tiny: this.sourceUrl + '/60/', + small: this.sourceUrl + '/120/', + normal: this.sourceUrl + '/300/', + large: this.sourceUrl + '/640/', + avatar: this.sourceUrl + '/artist_image/', + original: this.sourceUrl + '/unbranded/' + }; + + // mappings for the protobuf `enum Size` + this.sourceUrls.DEFAULT = this.sourceUrls.normal; + this.sourceUrls.SMALL = this.sourceUrls.tiny; + 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); + + // needs to emulate Spotify's "CodeValidator" object + this._context = vm.createContext(); + this._context.reply = this._reply.bind(this); + + // binded callback for when user doesn't pass a callback function + this._defaultCallback = this._defaultCallback.bind(this); + + // bind exported namespaces into object + exportNamespaces.forEach(util.bindNamespace.bind(null, this)); + + // override Metadata.get with Spotify.get + this.Metadata.get = this.get; +} +inherits(Spotify, EventEmitter); + +/** + * 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 types = [ this.Track, this.Album, this.Artist ]; + for (var i = 0, l = types.length; i < l; i++) { + var type = types[0]; + + // 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 + for (var j = 0, l = type.acceptedSchemas.length; j < l; j++) { + var schema = type.acceptedSchemas[j]; + if (obj instanceof schema) { + 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; +}; + + + +/** + * Gets the "metadata" object for one or more URIs. + * + * @param {Array|String} uris A single URI, or an Array of URIs to get "metadata" for + * @param {Function} (Optional) fn callback function + * @retruns {Array|Object|Null} + * @api public + */ + +Spotify.prototype.get = +Spotify.prototype.metadata = function(uris, fn) { + debug('get(%j)', uris); + + var spotify = this; + + // 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]; + + var metadataObjs; + try { + metadataObjs = uris.map(function(uri) { + var type = util.uriType(uri); + if (!uriHandlers[type]) throw new Error('Unhandled URI type: ' + type); + return new uriHandlers[type](spotify, uri); + }); + } catch (e) { + debug('metadata error - %s', e); + if ('function' == typeof fn) return 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) return process.nextTick(fn.bind(null, null, ret)); + return ret; +}; + +/** + * Creates the connection to the Spotify Web websocket server and logs in using + * the given Spotify `username` and `password` credentials. + * + * @param {String} un username + * @param {String} pw password + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.login = function (un, pw, fn) { + debug('Spotify#login(%j, %j)', un, pw.replace(/./g, '*')); + + // save credentials for later... + this.creds = { username: un, password: pw, type: 'sp' }; + + this._setLoginCallbacks(fn); + this._makeLandingPageRequest(); +}; + +/** + * Creates the connection to the Spotify Web websocket server and logs in using + * an anonymous identity. + * + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.anonymousLogin = function (fn) { + debug('Spotify#anonymousLogin()'); + + // save credentials for later... + this.creds = { type: 'anonymous' }; + + this._setLoginCallbacks(fn); + this._makeLandingPageRequest(); +}; + +/** + * Creates the connection to the Spotify Web websocket server and logs in using + * the given Facebook App OAuth token and corresponding user ID. + * + * @param {String} fbuid facebook user Id + * @param {String} token oauth token + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.facebookLogin = function (fbuid, token, fn) { + debug('Spotify#facebookLogin(%j, %j)', fbuid, token); + + // save credentials for later... + this.creds = { fbuid: fbuid, token: token, type: 'fb' }; + + this._setLoginCallbacks(fn); + this._makeLandingPageRequest(); +}; + +/** + * Sets the login and error callbacks to invoke the specified callback function + * + * @param {Function} fn callback function + * @api private + */ + +Spotify.prototype._setLoginCallbacks = function(fn) { + var self = this; + function onLogin () { + cleanup(); + fn(); + } + function onError (err) { + cleanup(); + fn(err); + } + function cleanup () { + self.removeListener('login', onLogin); + self.removeListener('error', onError); + } + if ('function' == typeof fn) { + this.on('login', onLogin); + this.on('error', onError); + } +}; + +/** + * Makes a request for the landing page to get the CSRF token. + * + * @api private + */ + +Spotify.prototype._makeLandingPageRequest = function() { + var url = 'https://' + this.authServer + this.landingUrl; + debug('GET %j', url); + this.agent.get(url) + .set({ 'User-Agent': this.userAgent }) + .end(this._onsecret.bind(this)); +}; + +/** + * Called when the Facebook redirect URL GET (and any necessary redirects) has + * responded. + * + * @api private + */ + +Spotify.prototype._onsecret = function (err, res) { + if (err) return this.emit('error', err); + + debug('landing page: %d status code, %j content-type', res.statusCode, res.headers['content-type']); + var $ = cheerio.load(res.text); + + // need to grab the CSRF token and trackingId from the page. + // currently, it's inside an Object that gets passed to a + // `new Spotify.Web.Login()` call as the second parameter. + var args; + var scripts = $('script'); + function login (doc, data) { + debug('Spotify.Web.Login()'); + args = data; + return { init: function () { /* noop */ } }; + } + 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 } } }); + } + } + debug('login CSRF token: %j, tracking ID: %j', args.csrftoken, args.trackingId); + + // construct credentials object to send from stored credentials + var creds = this.creds; + delete this.creds; + creds.secret = args.csrftoken; + creds.trackingId = args.trackingId; + creds.landingURL = args.landingURL; + creds.referrer = args.referrer; + creds.cf = null; + + // now we have to "auth" in order to get Spotify Web "credentials" + var url = 'https://' + this.authServer + this.authUrl; + debug('POST %j', url); + this.agent.post(url) + .set({ 'User-Agent': this.userAgent }) + .type('form') + .send(creds) + .end(this._onauth.bind(this)); +}; + +/** + * Called upon the "auth" endpoint's HTTP response. + * + * @api private + */ + +Spotify.prototype._onauth = function (err, res) { + if (err) return this.emit('error', err); + + debug('auth %d status code, %j content-type', res.statusCode, res.headers['content-type']); + if ('ERROR' == res.body.status) { + // got an error... + var msg = res.body.error; + if (res.body.message) msg += ': ' + res.body.message; + this.emit('error', new Error(msg)); + } else { + this.settings = res.body.config; + this._resolveAP(); + } +}; + +/** + * Resolves the WebSocket AP to connect to + * Should be called after the _onauth() function + * + * @api private + */ + +Spotify.prototype._resolveAP = function () { + var query = { client: '24:0:0:' + this.settings.version }; + var resolver = this.settings.aps.resolver; + debug('ap resolver %j', resolver); + if (resolver.site) query.site = resolver.site; + + // connect to the AP resolver endpoint in order to determine + // the WebSocket server URL to connect to next + var url = 'http://' + resolver.hostname; + debug('GET %j', url); + this.agent.get(url) + .set({ 'User-Agent': this.userAgent }) + .query(query) + .end(this._openWebsocket.bind(this)); +}; + +/** + * Opens the WebSocket connection to the Spotify Web server. + * Should be called upon AP resolver's response. + * + * @api private. + */ + +Spotify.prototype._openWebsocket = function (err, res) { + if (err) return this.emit('error', err); + + debug('ap resolver %d status code, %j content-type', res.statusCode, res.headers['content-type']); + var ap_list = res.body.ap_list; + 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'); + if (!this.connected) { + // need to send "connect" message + this.connect(); + } +}; + +/** + * WebSocket "close" event. + * + * @api private + */ + +Spotify.prototype._onclose = function () { + debug('WebSocket "close" event'); + 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!'); + } +}; + +/** + * Handles a "message" command. Specifically, handles the "do_work" command and + * executes the specified JavaScript in the VM. + * + * @api private + */ + +Spotify.prototype._onmessagecommand = function (command, args) { + if ('do_work' == command) { + var js = args[0]; + debug('got "do_work" payload: %j', js); + try { + vm.runInContext(js, this._context); + } catch (e) { + this.emit('error', e); + } + } else if ('ping_flash2' == command) { + this.sendPong(args[0]); + } else if ('login_complete' == command) { + // ignore... + } else { + // unhandled message + console.error(command, args); + throw new Error('TODO: implement!'); + } +}; + +/** + * Called when the "sp/work_done" command is completed. + * + * @api private + */ + +Spotify.prototype._onworkdone = function (err, res) { + if (err) return this.emit('error', err); + debug('"sp/work_done" ACK'); +}; + +/** + * Responds to a sp/ping_flash2 request + * + * This request is usually handled by Flash, and in a perfect world we would + * execute the "original" Flash code directly with Shumway. + * + * Instead, this implementes a reverse-engineered algorithm that is estimated + * to produce the same output for 20-byte inputs, tested with + * Client version: 0.6.23.160, Hash: 3f1a035, Deployed at: 2014-02-13 15:06 UTC. + * + * @param {String} ping the argument sent from the request + */ +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 output = new Array(key.length); + for (var i = 0; i < key.length; i++) { + var idx = key[i][0]; + var xor = key[i][1]; + output[i] = input[idx] ^ xor; + } + pong = output.join(' '); + } + debug('received flash ping %j, sending pong: %j', ping, pong); + this.sendCommand('sp/pong_flash2', [pong]); +}; + +/** + * Sends a "message" across the WebSocket connection with the given "name" and + * optional Array of arguments. + * + * @param {String} name command name + * @param {Array} args optional Array or arguments to send + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.sendCommand = function (name, args, fn) { + if ('function' == typeof args) { + fn = args; + args = []; + } + debug('sendCommand(%j, %j)', name, args); + + var request = new this.Request(name, args); + request.send(fn); +}; + +/** + * Makes a Protobuf request over the WebSocket connection. + * Also known as a MercuryRequest or Hermes Call. + * + * @param {Object} req protobuf request object + * @param {Function} fn (optional) callback function + * @api public + */ + +Spotify.prototype.sendProtobufRequest = function(req, fn) { + debug('sendProtobufRequest(%j)', req); + + // extract request object + var isMultiGet = req.isMultiGet || false; + var payload = req.payload || []; + var header = { + uri: '', + method: '', + source: '', + contentType: isMultiGet ? 'vnd.spotify/mercury-mget-request' : '' + }; + if (req.header) { + header.uri = req.header.uri || ''; + header.method = req.header.method || ''; + header.source = req.header.source || ''; + } + + // load payload and response schemas + var loadSchema = function(schema, dontRecurse) { + if ('string' === typeof schema) { + var schemaName = schema.split("#"); + var schema = schemas.build(schemaName[0], schemaName[1]); + if (!schema) + throw new Error('Could not load schema: ' + schemaName.join('#')); + } else if (schema && !dontRecurse && (!schema.hasOwnProperty('parse') && !schema.hasOwnProperty('serialize'))) { + var keys = Object.keys(schema); + keys.forEach(function(key) { + schema[key] = loadSchema(schema[key], true); + }); + } + return schema; + }; + + var payloadSchema = isMultiGet ? MercuryMultiGetRequest : loadSchema(req.payloadSchema); + var responseSchema = loadSchema(req.responseSchema); + var isMultiResponseSchema = (!responseSchema.hasOwnProperty('parse')); + + var parseData = function(type, data, dontRecurse) { + var parser = responseSchema; + var ret; + if (!dontRecurse && 'vnd.spotify/mercury-mget-reply' == type) { + ret = []; + var response = self._parse(MercuryMultiGetReply, data); + response.reply.forEach(function(reply) { + var data = parseData(reply.contentType, new Buffer(reply.body, 'base64'), true); + ret.push(data); + }); + debug('parsed multi-get response - %d items', ret.length); + } else { + if (isMultiResponseSchema) { + if (responseSchema.hasOwnProperty(type)) { + parser = responseSchema[type]; + } else { + throw new Error('Unrecognised metadata type: ' + type); + } + } + ret = self._parse(parser, data); + debug('parsed response: [ %j ] %j', type, ret); + } + return ret; + }; + + var getNumber = function(method) { + switch(method) { + case "SUB": + return 1; + case "UNSUB": + return 2; + default: + return 0; + } + } + + // construct request + var args = [ getNumber(header.method) ]; + var data = MercuryRequest.serialize(header).toString('base64'); + args.push(data); + + if (isMultiGet) { + if (Array.isArray(req.payload)) { + req.payload = {request: req.payload}; + } else if (!req.payload.request) { + throw new Error('Invalid payload for Multi-Get Request.') + } + } + + if (payload && payloadSchema) { + data = payloadSchema.serialize(req.payload).toString('base64'); + args.push(data); + } + + // send request and parse response, pass data back to callback + var self = this; + this.sendCommand('sp/hm_b64', args, function (err, res) { + if ('function' !== typeof fn) return; // give up if no callback + if (err) return fn(err); + + var header = self._parse(MercuryRequest, new Buffer(res.result[0], 'base64')); + debug('response header: %j', header); + + // TODO: proper error handling, handle 300 errors + + if (header.statusCode >= 400 && header.statusCode < 500) { + var statusMessage = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; + return fn(new Error('Client Error: ' + statusMessage + ' (' + header.statusCode + ')')); + } + + if (header.statusCode >= 500 && header.statusCode < 600) { + var statusMessage = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; + return fn(new Error('Server Error: ' + statusMessage + ' (' + header.statusCode + ')')); + } + + if (isMultiGet && 'vnd.spotify/mercury-mget-reply' !== header.contentType) + return fn(new Error('Server Error: Server didn\'t send a multi-GET reply for a multi-GET request!')); + + var data = parseData(header.contentType, new Buffer(res.result[1], 'base64')); + fn(null, data); + }); +}; + +/** + * Sends the "connect" command. Should be called once the WebSocket connection is + * established. + * + * @param {Function} fn callback function + * @api public + */ + +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)); +}; + +/** + * Closes the WebSocket connection of present. This effectively ends your Spotify + * Web "session" (and derefs from the event-loop, so your program can exit). + * + * @api public + */ + +Spotify.prototype.disconnect = function () { + debug('disconnect()'); + this.connected = false; + clearInterval(this._heartbeatId); + this._heartbeatId = null; + if (this.ws) { + this.ws.close(); + this.ws = null; + } +}; + +/** + * 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); +}; + +/** + * Retrieve suggested similar tracks to the given track URI + * + * @param {String} uri track uri + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.similar = function(uri, fn) { + debug('similar(%j)', uri); + + if ('track' != util.uriType(uri)) + fn(new Error('uri must be a track uri')); + + var track = this.Track.get(uri); + track.similar(fn); +}; + +/** + * Executes a "search" against the Spotify music library. Note that the response + * is an XML data String, so you must parse it yourself. + * + * @param {String|Object} opts string search term, or options object with search + * @param {Function} fn callback function + * @api public + */ + +Spotify.prototype.search = function (opts, fn) { + if ('string' == typeof opts) { + opts = { query: opts }; + } + if (null == opts.maxResults || opts.maxResults > 50) { + opts.maxResults = 50; + } + if (null == opts.type) { + opts.type = 'all'; + } + if (null == opts.offset) { + opts.offset = 0; + } + if (null == opts.query) { + throw new Error('must pass a "query" option!'); + } + + var types = { + tracks: 1, + albums: 2, + artists: 4, + playlists: 8 + }; + var type; + if ('all' == opts.type) { + type = types.tracks | types.albums | types.artists | types.playlists; + } else if (Array.isArray(opts.type)) { + type = 0; + opts.type.forEach(function (t) { + if (!types.hasOwnProperty(t)) { + throw new Error('unknown search "type": ' + opts.type); + } + type |= types[t]; + }); + } else if (opts.type in types) { + type = types[opts.type]; + } else { + throw new Error('unknown search "type": ' + opts.type); + } + + var args = [ opts.query, type, opts.maxResults, opts.offset ]; + this.sendCommand('sp/search', args, function (err, res) { + if (err) return fn(err); + // XML-parsing is left up to the user, since they may want to use libxmljs, + // or node-sax, or node-xml2js, or whatever. So leave it up to them... + fn(null, res.result); + }); +}; + +/** + * "connect" command callback function. If the result was "ok", then get the + * logged in user's info. + * + * @param {Object} res response Object + * @api private + */ + +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 + } +}; + +/** + * "sp/user_info" command callback function. Once this is complete, the "login" + * event is emitted and control is passed back to the user for the first time. + * + * @param {Object} res response Object + * @api private + */ + +Spotify.prototype._onuserinfo = function (err, res) { + if (err) return this.emit('error', err); + + this.user_info = res.result; + this.username = res.result.user; + if (this.username) 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. + * + * @api private + */ + +Spotify.prototype._reply = function () { + var args = Array.prototype.slice.call(arguments); + debug('reply(%j)', args); + this.sendCommand('sp/work_done', args, this._onworkdone); +}; + +/** + * Default callback function for when the user does not pass a + * callback function of their own. + * + * @param {Error} err + * @api private + */ + +Spotify.prototype._defaultCallback = function (err) { + if (err) this.emit('error', err); +}; + +/** + * Wrapper around the Protobuf Schema's `parse()` function that also attaches this + * Spotify instance as `_spotify` to each entry in the parsed object. This is + * necessary so that instance methods (like `Track#play()`) have access to the + * Spotify instance in order to interact with it. + * + * @api private + */ + +Spotify.prototype._parse = function (parser, data) { + var obj = parser.parse(data); + tag(this, obj); + return obj; +}; + +/** + * XXX: move to `util`? + * Attaches the `_spotify` property to each "object" in the passed in `obj`. + * + * @api private + */ + +function tag(spotify, obj){ + if (obj === null || 'object' != typeof obj) return; + Object.keys(obj).forEach(function(key){ + var val = obj[key]; + var type = typeof val; + if ('object' == type) { + if (Array.isArray(val)) { + val.forEach(function (v) { + tag(spotify, v); + }); + } else { + tag(spotify, val); + } + } + }); + Object.defineProperty(obj, '_spotify', { + value: spotify, + enumerable: false, + writable: true, + configurable: true + }); +} From ed2d2451d10ac833749155a4cb383c65b4fe6075 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 21:55:36 +1100 Subject: [PATCH 006/108] connection: add missing event handlers --- lib/connection/connection.js | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 9229b54..2196258 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -67,6 +67,9 @@ function SpotifyConnection(spotify) { // handle events this.on('heartbeat', this._onheartbeat.bind(this)); 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)); // bind exported namespaces into object EXPORT_NAMESPACES.forEach(util.bindNamespace.bind(null, this)); @@ -102,6 +105,56 @@ SpotifyConnection.prototype._onclose = function () { } }; +/** + * WebSocket "message" event handler. + * + * @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('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. * From eb3e5b700e35356778c8da03d6b626815b467b1b Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 22:01:56 +1100 Subject: [PATCH 007/108] spotify: use SpotifyConnection --- lib/spotify.js | 170 ++++--------------------------------------------- 1 file changed, 11 insertions(+), 159 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index c499dba..884e546 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -5,12 +5,11 @@ 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 SpotifyConnection = require('./connection'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var package = require('../package.json'); @@ -77,11 +76,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 SpotifyConnection(this); this.authServer = 'play.spotify.com'; this.authUrl = '/xhr/json/auth.php'; @@ -105,16 +101,8 @@ 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)); // needs to emulate Spotify's "CodeValidator" object this._context = vm.createContext(); @@ -333,86 +321,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'); - if (!this.connected) { - // need to send "connect" message - this.connect(); - } -}; - -/** - * WebSocket "close" event. - * - * @api private - */ - -Spotify.prototype._onclose = function () { - debug('WebSocket "close" event'); - 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)); }; /** @@ -462,28 +371,8 @@ Spotify.prototype._onworkdone = function (err, res) { */ 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); - } + var request = new this.connection.Request(name, args) + request.send(fn); }; /** @@ -626,9 +515,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); }; /** @@ -640,13 +527,7 @@ 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(); }; /** @@ -1129,15 +1010,9 @@ 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'); + this.sendCommand('sp/user_info', this._onuserinfo.bind(this)); + this.sendCommand('sp/log', [41, 1, 0, 0, 0, 0]); // Spotify.Logging.Logger#logWindowSize }; /** @@ -1156,29 +1031,6 @@ Spotify.prototype._onuserinfo = function (err, res) { 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. * From 08312e9196d4c140c7537fadeecb2f54297f95c6 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 22:48:09 +1100 Subject: [PATCH 008/108] connection: fix typos --- lib/connection/connection.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 2196258..0cd49d9 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -11,7 +11,7 @@ var Request = require('./request'); var Response = require('./response'); var HermesRequest = require('./hermes_request'); var HermesResponse = require('./hermes_response'); -var util = require('./util'); +var util = require('../util'); var inherits = require('util').inherits; var debug = require('debug')('spotify-web:connection'); @@ -65,7 +65,6 @@ function SpotifyConnection(spotify) { this.once('connect', this._startHeartbeat.bind(this)); // handle events - this.on('heartbeat', this._onheartbeat.bind(this)); this.on('flush', this._onflush.bind(this)); this.on('open', this._onopen.bind(this)); this.on('close', this._onclose.bind(this)); @@ -73,6 +72,7 @@ function SpotifyConnection(spotify) { // bind exported namespaces into object EXPORT_NAMESPACES.forEach(util.bindNamespace.bind(null, this)); + this.on('heartbeat', this.sendHeartbeat.bind(this)); } inherits(SpotifyConnection, EventEmitter); @@ -112,7 +112,7 @@ SpotifyConnection.prototype._onclose = function () { * @api private */ -Spotify.prototype._onmessage = function (data) { +SpotifyConnection.prototype._onmessage = function (data) { debug('WebSocket "message" event: %s', data); var msg; try { @@ -267,7 +267,7 @@ SpotifyConnection.prototype.connect = function(url, fn) { this.ws = new WebSocket(url); ['open', 'close', 'message'].forEach(function(event) { - this.ws.on.bind(event, this.emit.bind(this, event)); + this.ws.on(event, this.emit.bind(this, event)); }, this); if ('function' == typeof fn) this.on('connect', fn); From 41610c9148b1130647421a563bd75ed0b22d4ce2 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 22:55:52 +1100 Subject: [PATCH 009/108] util: add export decorator --- lib/util.js | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/lib/util.js b/lib/util.js index 26611a3..e1fa73d 100644 --- a/lib/util.js +++ b/lib/util.js @@ -76,3 +76,108 @@ exports.uriType = function (uri) { throw new Error('could not determine "type" for URI: ' + uri); } }; + +/** + * Export namespaces decorator + * + * Inspired by AngularJS DI + * @api private + */ +exports.export = function(object, namespaces) { + var objectName = object.name; + + // support explict naming + if (Array.isArray(object)) { + objectName = object[0]; + object = object[1]; + } + + debug('exporting %d namespaces on %s', namespaces.length, objectName); + + var privateName = '_' + objectName; + + namespaces.forEach(function(namespace){ + var name = namespace.name; + + // support explict naming + if (Array.isArray(namespace)) { + name = namespace[0]; + namespace = namespace[1]; + } + + // static export + object[name] = namespace; + + // dynamic export + var argumentIndex; + Object.defineProperty(object.prototype, name, { + get: function() { + debug('get(%j)', name); + if (!this[privateName]) { + // bind the constructor + debug('attempting bind of %s.%s()', objectName, name); + this[privateName] = exports.bindFunction(namespace, this, objectName); + + // 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 + 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); + } + this[privateName][key] = fn; + }, this); + } + return this[privateName]; + }, + enumerable: true, + configurable: true + }); + }); +}; + +/** + * Bind a function to an object using the function's $inject array + * + * @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); + + // 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; + } + + // 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; +}; + From 417ec36baf58a71ecba31c22f8bc8206d6890e29 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 22:56:07 +1100 Subject: [PATCH 010/108] connection: use export decorator --- lib/connection/connection.js | 9 +-------- lib/connection/hermes_request.js | 1 + lib/connection/request.js | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 0cd49d9..1fe633b 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -25,11 +25,7 @@ module.exports = SpotifyConnection; * Re-export namespaces */ -var EXPORT_NAMESPACES = [Request, Response, HermesRequest, HermesResponse]; - -EXPORT_NAMESPACES.forEach(function (namespace) { - SpotifyConnection[namespace.name] = namespace; -}); +util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse]); /** * SpotifyConnection base class @@ -69,9 +65,6 @@ function SpotifyConnection(spotify) { this.on('open', this._onopen.bind(this)); this.on('close', this._onclose.bind(this)); this.on('message', this._onmessage.bind(this)); - - // bind exported namespaces into object - EXPORT_NAMESPACES.forEach(util.bindNamespace.bind(null, this)); this.on('heartbeat', this.sendHeartbeat.bind(this)); } inherits(SpotifyConnection, EventEmitter); diff --git a/lib/connection/hermes_request.js b/lib/connection/hermes_request.js index cb8bed6..24f3134 100644 --- a/lib/connection/hermes_request.js +++ b/lib/connection/hermes_request.js @@ -71,6 +71,7 @@ function HermesRequest(connection, method, args) { // TODO(adammw): support user fields } +HermesRequest['$inject'] = ['SpotifyConnection']; inherits(HermesRequest, Request); /** diff --git a/lib/connection/request.js b/lib/connection/request.js index 869e9d3..26bdd08 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -46,6 +46,7 @@ function Request(connection, name, args) { if (this.name && 'connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; } +Request['$inject'] = ['SpotifyConnection']; inherits(Request, EventEmitter); /** From 3bba62f0d5683870ab8fb227f5273b0fdffe4d90 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:12:49 +1100 Subject: [PATCH 011/108] request: handle multiple callbacks --- lib/connection/request.js | 11 ++++------- lib/util.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/connection/request.js b/lib/connection/request.js index 26bdd08..b067d3a 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -42,7 +42,6 @@ function Request(connection, name, args) { this.id = null; this.response = null; this.sent = false; - this._callback = null; if (this.name && 'connect' != this.name && !/^sp\//.test(this.name)) this.name = 'sp/' + this.name; } @@ -76,7 +75,7 @@ Request.prototype.send = function(args, fn) { if (this.sent) throw new Error('Request already sent'); // save the callback and arguments - this._callback = fn; + this.on('callback', util.wrapCallback(fn, this)); if (undefined !== args) this.args = args; // queue the request to be sent @@ -111,8 +110,9 @@ Request.prototype.serialize = function() { * * @return {Boolean} */ + Request.prototype.hasCallback = function() { - return ('function' == typeof this._callback); + return Boolean(EventEmitter.listenerCount('callback') || EventEmitter.listenerCount('response') || EventEmitter.listenerCount('error')); }; /** @@ -138,8 +138,5 @@ Request.prototype.callback = function(err, res){ if (this.response) this.emit('response', this.response); // invoke callback - var fn = this._callback; - if ('function' == typeof fn && 2 == fn.length) return fn(err, this.response); - if (err) return this.emit('error', err); - if ('function' == typeof fn) fn(this.response); + this.emit('callback', err, res); }; diff --git a/lib/util.js b/lib/util.js index e1fa73d..02dbde8 100644 --- a/lib/util.js +++ b/lib/util.js @@ -181,3 +181,20 @@ exports.bindFunction = function(fn, object, name) { return ret; }; +/** + * Wrap a callback and handle the arity check + * + * @param {Function} fn + * @param {Object} self (this context) + * @return {Function} + */ + +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); + }; +}; + From 3ede1a29dc6f6f3744705a5f2ee74c17791eac45 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:13:34 +1100 Subject: [PATCH 012/108] connection: separate sending request from flushing queue --- lib/connection/connection.js | 53 ++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 1fe633b..af5f100 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -201,26 +201,7 @@ SpotifyConnection.prototype._onflush = function() { // send each pending request in the queue while(this.requestQueue.length) { - var request = this.requestQueue.shift(); - 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); - } + this._sendRequest(this.requestQueue.shift()); } this._requestQueueFlushId = null; @@ -247,6 +228,38 @@ SpotifyConnection.prototype._stopHeartbeat = function () { 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 * From a95558b51ad1ca47854959a30a0d234c6b1d27af Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:16:06 +1100 Subject: [PATCH 013/108] connection: fix bugs preventing connection completing --- lib/connection/connection.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index af5f100..72c3c2c 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -21,12 +21,6 @@ var debug = require('debug')('spotify-web:connection'); module.exports = SpotifyConnection; -/** - * Re-export namespaces - */ - -util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse]); - /** * SpotifyConnection base class * @@ -69,6 +63,12 @@ function SpotifyConnection(spotify) { } inherits(SpotifyConnection, EventEmitter); +/** + * Re-export namespaces + */ + +util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse]); + /** * WebSocket "open" event handler * @@ -327,7 +327,11 @@ 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(':') ]; - this.Request('connect').send(args, this._onconnect.bind(this)); + 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); }; /** From 58cb89db1a844849241252d68e5292ef0354ff53 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:17:49 +1100 Subject: [PATCH 014/108] request: add missing require() --- lib/connection/request.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/connection/request.js b/lib/connection/request.js index b067d3a..9ebf754 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -8,6 +8,7 @@ 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:request'); /** From fc74beadbfe3fc1f9a5372471234d21d2cfb36cc Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:22:07 +1100 Subject: [PATCH 015/108] remove unintentionally committed file --- lib/spotify.js.new-old | 1070 ---------------------------------------- 1 file changed, 1070 deletions(-) delete mode 100644 lib/spotify.js.new-old diff --git a/lib/spotify.js.new-old b/lib/spotify.js.new-old deleted file mode 100644 index 7f49594..0000000 --- a/lib/spotify.js.new-old +++ /dev/null @@ -1,1070 +0,0 @@ -/// REMEMBER TO JSLINT / JSHINT it before commiting! -/// TODO(adammw): reorganise into folders -/** - * Module dependencies. - */ - -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 SpotifyUri = require('./uri'); -var Request = require('./request'); -var HermesRequest = require('./hermes_request'); -var Playlist = require('./playlist'); -var Metadata = require('./metadata'); -var Image = require('./image'); -var User = require('./user'); -var EventEmitter = require('events').EventEmitter; -var debug = require('debug')('spotify-web'); -var package = require('../package.json'); - -/** - * Module exports. - */ - -module.exports = Spotify; - -/** - * Protocol Buffer types. - */ - -var MercuryMultiGetRequest = schemas.build('mercury','MercuryMultiGetRequest'); -var MercuryMultiGetReply = schemas.build('mercury','MercuryMultiGetReply'); -var MercuryRequest = schemas.build('mercury','MercuryRequest'); - -require('./restriction'); - -var SelectedListContent = schemas.build('playlist4','SelectedListContent'); - -/** - * Re-export namespaces - */ - -var exportNamespaces = [Request, HermesRequest, Playlist, Metadata, Metadata.Track, Metadata.Artist, Metadata.Album, User]; - -exportNamespaces.forEach(function (namespace) { - Spotify[namespace.name] = namespace; -}); - -/** - * Declare which classes can handle which uri types - */ -var uriHandlers = { - 'track': Metadata.Track, - 'artist': Metadata.Artist, - 'album': Metadata.Album, - 'playlist': Playlist, - 'user': User -}; - -/** - * Re-export all the `SpotifyUri` functions for backwards compatiblity. - */ - -Object.keys(SpotifyUri).forEach(function (key) { - Spotify[key] = util[key]; -}); - -/** - * Create instance and login convenience function. - * - * @param {String} un username - * @param {String} pw password - * @param {Function} fn callback function - * @api public - */ - -Spotify.login = function (un, pw, fn) { - if (!fn) fn = function () {}; - var spotify = new Spotify(); - spotify.login(un, pw, function (err) { - if (err) return fn(err); - fn.call(spotify, null, spotify); - }); - return spotify; -}; - -/** - * Spotify Web base class. - * - * @api public - */ - -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.ws = null; - this.requestQueue = []; - this.requestQueueFlushImmediate = null; - this.currentPlaySession = null; - this.user = null; - this.username = null; - this.country = null; - this.accountType = null; - this.restrictToAvailable = true; - - this.authServer = 'play.spotify.com'; - this.authUrl = '/xhr/json/auth.php'; - this.landingUrl = '/'; - this.userAgent = 'Mozilla/5.0 (Chrome/13.37 compatible-ish) spotify-web/' + package.version; - - // base URLs for Image files like album artwork, artist prfiles, etc. - // these values taken from "spotify.web.client.js" - this.sourceUrl = 'https://d3rt1990lpmkn.cloudfront.net'; - this.sourceUrls = { - tiny: this.sourceUrl + '/60/', - small: this.sourceUrl + '/120/', - normal: this.sourceUrl + '/300/', - large: this.sourceUrl + '/640/', - avatar: this.sourceUrl + '/artist_image/', - original: this.sourceUrl + '/unbranded/' - }; - - // mappings for the protobuf `enum Size` - this.sourceUrls.DEFAULT = this.sourceUrls.normal; - this.sourceUrls.SMALL = this.sourceUrls.tiny; - 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); - - // needs to emulate Spotify's "CodeValidator" object - this._context = vm.createContext(); - this._context.reply = this._reply.bind(this); - - // binded callback for when user doesn't pass a callback function - this._defaultCallback = this._defaultCallback.bind(this); - - // bind exported namespaces into object - exportNamespaces.forEach(util.bindNamespace.bind(null, this)); - - // override Metadata.get with Spotify.get - this.Metadata.get = this.get; -} -inherits(Spotify, EventEmitter); - -/** - * 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 types = [ this.Track, this.Album, this.Artist ]; - for (var i = 0, l = types.length; i < l; i++) { - var type = types[0]; - - // 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 - for (var j = 0, l = type.acceptedSchemas.length; j < l; j++) { - var schema = type.acceptedSchemas[j]; - if (obj instanceof schema) { - 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; -}; - - - -/** - * Gets the "metadata" object for one or more URIs. - * - * @param {Array|String} uris A single URI, or an Array of URIs to get "metadata" for - * @param {Function} (Optional) fn callback function - * @retruns {Array|Object|Null} - * @api public - */ - -Spotify.prototype.get = -Spotify.prototype.metadata = function(uris, fn) { - debug('get(%j)', uris); - - var spotify = this; - - // 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]; - - var metadataObjs; - try { - metadataObjs = uris.map(function(uri) { - var type = util.uriType(uri); - if (!uriHandlers[type]) throw new Error('Unhandled URI type: ' + type); - return new uriHandlers[type](spotify, uri); - }); - } catch (e) { - debug('metadata error - %s', e); - if ('function' == typeof fn) return 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) return process.nextTick(fn.bind(null, null, ret)); - return ret; -}; - -/** - * Creates the connection to the Spotify Web websocket server and logs in using - * the given Spotify `username` and `password` credentials. - * - * @param {String} un username - * @param {String} pw password - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.login = function (un, pw, fn) { - debug('Spotify#login(%j, %j)', un, pw.replace(/./g, '*')); - - // save credentials for later... - this.creds = { username: un, password: pw, type: 'sp' }; - - this._setLoginCallbacks(fn); - this._makeLandingPageRequest(); -}; - -/** - * Creates the connection to the Spotify Web websocket server and logs in using - * an anonymous identity. - * - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.anonymousLogin = function (fn) { - debug('Spotify#anonymousLogin()'); - - // save credentials for later... - this.creds = { type: 'anonymous' }; - - this._setLoginCallbacks(fn); - this._makeLandingPageRequest(); -}; - -/** - * Creates the connection to the Spotify Web websocket server and logs in using - * the given Facebook App OAuth token and corresponding user ID. - * - * @param {String} fbuid facebook user Id - * @param {String} token oauth token - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.facebookLogin = function (fbuid, token, fn) { - debug('Spotify#facebookLogin(%j, %j)', fbuid, token); - - // save credentials for later... - this.creds = { fbuid: fbuid, token: token, type: 'fb' }; - - this._setLoginCallbacks(fn); - this._makeLandingPageRequest(); -}; - -/** - * Sets the login and error callbacks to invoke the specified callback function - * - * @param {Function} fn callback function - * @api private - */ - -Spotify.prototype._setLoginCallbacks = function(fn) { - var self = this; - function onLogin () { - cleanup(); - fn(); - } - function onError (err) { - cleanup(); - fn(err); - } - function cleanup () { - self.removeListener('login', onLogin); - self.removeListener('error', onError); - } - if ('function' == typeof fn) { - this.on('login', onLogin); - this.on('error', onError); - } -}; - -/** - * Makes a request for the landing page to get the CSRF token. - * - * @api private - */ - -Spotify.prototype._makeLandingPageRequest = function() { - var url = 'https://' + this.authServer + this.landingUrl; - debug('GET %j', url); - this.agent.get(url) - .set({ 'User-Agent': this.userAgent }) - .end(this._onsecret.bind(this)); -}; - -/** - * Called when the Facebook redirect URL GET (and any necessary redirects) has - * responded. - * - * @api private - */ - -Spotify.prototype._onsecret = function (err, res) { - if (err) return this.emit('error', err); - - debug('landing page: %d status code, %j content-type', res.statusCode, res.headers['content-type']); - var $ = cheerio.load(res.text); - - // need to grab the CSRF token and trackingId from the page. - // currently, it's inside an Object that gets passed to a - // `new Spotify.Web.Login()` call as the second parameter. - var args; - var scripts = $('script'); - function login (doc, data) { - debug('Spotify.Web.Login()'); - args = data; - return { init: function () { /* noop */ } }; - } - 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 } } }); - } - } - debug('login CSRF token: %j, tracking ID: %j', args.csrftoken, args.trackingId); - - // construct credentials object to send from stored credentials - var creds = this.creds; - delete this.creds; - creds.secret = args.csrftoken; - creds.trackingId = args.trackingId; - creds.landingURL = args.landingURL; - creds.referrer = args.referrer; - creds.cf = null; - - // now we have to "auth" in order to get Spotify Web "credentials" - var url = 'https://' + this.authServer + this.authUrl; - debug('POST %j', url); - this.agent.post(url) - .set({ 'User-Agent': this.userAgent }) - .type('form') - .send(creds) - .end(this._onauth.bind(this)); -}; - -/** - * Called upon the "auth" endpoint's HTTP response. - * - * @api private - */ - -Spotify.prototype._onauth = function (err, res) { - if (err) return this.emit('error', err); - - debug('auth %d status code, %j content-type', res.statusCode, res.headers['content-type']); - if ('ERROR' == res.body.status) { - // got an error... - var msg = res.body.error; - if (res.body.message) msg += ': ' + res.body.message; - this.emit('error', new Error(msg)); - } else { - this.settings = res.body.config; - this._resolveAP(); - } -}; - -/** - * Resolves the WebSocket AP to connect to - * Should be called after the _onauth() function - * - * @api private - */ - -Spotify.prototype._resolveAP = function () { - var query = { client: '24:0:0:' + this.settings.version }; - var resolver = this.settings.aps.resolver; - debug('ap resolver %j', resolver); - if (resolver.site) query.site = resolver.site; - - // connect to the AP resolver endpoint in order to determine - // the WebSocket server URL to connect to next - var url = 'http://' + resolver.hostname; - debug('GET %j', url); - this.agent.get(url) - .set({ 'User-Agent': this.userAgent }) - .query(query) - .end(this._openWebsocket.bind(this)); -}; - -/** - * Opens the WebSocket connection to the Spotify Web server. - * Should be called upon AP resolver's response. - * - * @api private. - */ - -Spotify.prototype._openWebsocket = function (err, res) { - if (err) return this.emit('error', err); - - debug('ap resolver %d status code, %j content-type', res.statusCode, res.headers['content-type']); - var ap_list = res.body.ap_list; - 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'); - if (!this.connected) { - // need to send "connect" message - this.connect(); - } -}; - -/** - * WebSocket "close" event. - * - * @api private - */ - -Spotify.prototype._onclose = function () { - debug('WebSocket "close" event'); - 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!'); - } -}; - -/** - * Handles a "message" command. Specifically, handles the "do_work" command and - * executes the specified JavaScript in the VM. - * - * @api private - */ - -Spotify.prototype._onmessagecommand = function (command, args) { - if ('do_work' == command) { - var js = args[0]; - debug('got "do_work" payload: %j', js); - try { - vm.runInContext(js, this._context); - } catch (e) { - this.emit('error', e); - } - } else if ('ping_flash2' == command) { - this.sendPong(args[0]); - } else if ('login_complete' == command) { - // ignore... - } else { - // unhandled message - console.error(command, args); - throw new Error('TODO: implement!'); - } -}; - -/** - * Called when the "sp/work_done" command is completed. - * - * @api private - */ - -Spotify.prototype._onworkdone = function (err, res) { - if (err) return this.emit('error', err); - debug('"sp/work_done" ACK'); -}; - -/** - * Responds to a sp/ping_flash2 request - * - * This request is usually handled by Flash, and in a perfect world we would - * execute the "original" Flash code directly with Shumway. - * - * Instead, this implementes a reverse-engineered algorithm that is estimated - * to produce the same output for 20-byte inputs, tested with - * Client version: 0.6.23.160, Hash: 3f1a035, Deployed at: 2014-02-13 15:06 UTC. - * - * @param {String} ping the argument sent from the request - */ -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 output = new Array(key.length); - for (var i = 0; i < key.length; i++) { - var idx = key[i][0]; - var xor = key[i][1]; - output[i] = input[idx] ^ xor; - } - pong = output.join(' '); - } - debug('received flash ping %j, sending pong: %j', ping, pong); - this.sendCommand('sp/pong_flash2', [pong]); -}; - -/** - * Sends a "message" across the WebSocket connection with the given "name" and - * optional Array of arguments. - * - * @param {String} name command name - * @param {Array} args optional Array or arguments to send - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.sendCommand = function (name, args, fn) { - if ('function' == typeof args) { - fn = args; - args = []; - } - debug('sendCommand(%j, %j)', name, args); - - var request = new this.Request(name, args); - request.send(fn); -}; - -/** - * Makes a Protobuf request over the WebSocket connection. - * Also known as a MercuryRequest or Hermes Call. - * - * @param {Object} req protobuf request object - * @param {Function} fn (optional) callback function - * @api public - */ - -Spotify.prototype.sendProtobufRequest = function(req, fn) { - debug('sendProtobufRequest(%j)', req); - - // extract request object - var isMultiGet = req.isMultiGet || false; - var payload = req.payload || []; - var header = { - uri: '', - method: '', - source: '', - contentType: isMultiGet ? 'vnd.spotify/mercury-mget-request' : '' - }; - if (req.header) { - header.uri = req.header.uri || ''; - header.method = req.header.method || ''; - header.source = req.header.source || ''; - } - - // load payload and response schemas - var loadSchema = function(schema, dontRecurse) { - if ('string' === typeof schema) { - var schemaName = schema.split("#"); - var schema = schemas.build(schemaName[0], schemaName[1]); - if (!schema) - throw new Error('Could not load schema: ' + schemaName.join('#')); - } else if (schema && !dontRecurse && (!schema.hasOwnProperty('parse') && !schema.hasOwnProperty('serialize'))) { - var keys = Object.keys(schema); - keys.forEach(function(key) { - schema[key] = loadSchema(schema[key], true); - }); - } - return schema; - }; - - var payloadSchema = isMultiGet ? MercuryMultiGetRequest : loadSchema(req.payloadSchema); - var responseSchema = loadSchema(req.responseSchema); - var isMultiResponseSchema = (!responseSchema.hasOwnProperty('parse')); - - var parseData = function(type, data, dontRecurse) { - var parser = responseSchema; - var ret; - if (!dontRecurse && 'vnd.spotify/mercury-mget-reply' == type) { - ret = []; - var response = self._parse(MercuryMultiGetReply, data); - response.reply.forEach(function(reply) { - var data = parseData(reply.contentType, new Buffer(reply.body, 'base64'), true); - ret.push(data); - }); - debug('parsed multi-get response - %d items', ret.length); - } else { - if (isMultiResponseSchema) { - if (responseSchema.hasOwnProperty(type)) { - parser = responseSchema[type]; - } else { - throw new Error('Unrecognised metadata type: ' + type); - } - } - ret = self._parse(parser, data); - debug('parsed response: [ %j ] %j', type, ret); - } - return ret; - }; - - var getNumber = function(method) { - switch(method) { - case "SUB": - return 1; - case "UNSUB": - return 2; - default: - return 0; - } - } - - // construct request - var args = [ getNumber(header.method) ]; - var data = MercuryRequest.serialize(header).toString('base64'); - args.push(data); - - if (isMultiGet) { - if (Array.isArray(req.payload)) { - req.payload = {request: req.payload}; - } else if (!req.payload.request) { - throw new Error('Invalid payload for Multi-Get Request.') - } - } - - if (payload && payloadSchema) { - data = payloadSchema.serialize(req.payload).toString('base64'); - args.push(data); - } - - // send request and parse response, pass data back to callback - var self = this; - this.sendCommand('sp/hm_b64', args, function (err, res) { - if ('function' !== typeof fn) return; // give up if no callback - if (err) return fn(err); - - var header = self._parse(MercuryRequest, new Buffer(res.result[0], 'base64')); - debug('response header: %j', header); - - // TODO: proper error handling, handle 300 errors - - if (header.statusCode >= 400 && header.statusCode < 500) { - var statusMessage = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; - return fn(new Error('Client Error: ' + statusMessage + ' (' + header.statusCode + ')')); - } - - if (header.statusCode >= 500 && header.statusCode < 600) { - var statusMessage = header.statusMessage || http.STATUS_CODES[header.statusCode] || 'Unknown Error'; - return fn(new Error('Server Error: ' + statusMessage + ' (' + header.statusCode + ')')); - } - - if (isMultiGet && 'vnd.spotify/mercury-mget-reply' !== header.contentType) - return fn(new Error('Server Error: Server didn\'t send a multi-GET reply for a multi-GET request!')); - - var data = parseData(header.contentType, new Buffer(res.result[1], 'base64')); - fn(null, data); - }); -}; - -/** - * Sends the "connect" command. Should be called once the WebSocket connection is - * established. - * - * @param {Function} fn callback function - * @api public - */ - -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)); -}; - -/** - * Closes the WebSocket connection of present. This effectively ends your Spotify - * Web "session" (and derefs from the event-loop, so your program can exit). - * - * @api public - */ - -Spotify.prototype.disconnect = function () { - debug('disconnect()'); - this.connected = false; - clearInterval(this._heartbeatId); - this._heartbeatId = null; - if (this.ws) { - this.ws.close(); - this.ws = null; - } -}; - -/** - * 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); -}; - -/** - * Retrieve suggested similar tracks to the given track URI - * - * @param {String} uri track uri - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.similar = function(uri, fn) { - debug('similar(%j)', uri); - - if ('track' != util.uriType(uri)) - fn(new Error('uri must be a track uri')); - - var track = this.Track.get(uri); - track.similar(fn); -}; - -/** - * Executes a "search" against the Spotify music library. Note that the response - * is an XML data String, so you must parse it yourself. - * - * @param {String|Object} opts string search term, or options object with search - * @param {Function} fn callback function - * @api public - */ - -Spotify.prototype.search = function (opts, fn) { - if ('string' == typeof opts) { - opts = { query: opts }; - } - if (null == opts.maxResults || opts.maxResults > 50) { - opts.maxResults = 50; - } - if (null == opts.type) { - opts.type = 'all'; - } - if (null == opts.offset) { - opts.offset = 0; - } - if (null == opts.query) { - throw new Error('must pass a "query" option!'); - } - - var types = { - tracks: 1, - albums: 2, - artists: 4, - playlists: 8 - }; - var type; - if ('all' == opts.type) { - type = types.tracks | types.albums | types.artists | types.playlists; - } else if (Array.isArray(opts.type)) { - type = 0; - opts.type.forEach(function (t) { - if (!types.hasOwnProperty(t)) { - throw new Error('unknown search "type": ' + opts.type); - } - type |= types[t]; - }); - } else if (opts.type in types) { - type = types[opts.type]; - } else { - throw new Error('unknown search "type": ' + opts.type); - } - - var args = [ opts.query, type, opts.maxResults, opts.offset ]; - this.sendCommand('sp/search', args, function (err, res) { - if (err) return fn(err); - // XML-parsing is left up to the user, since they may want to use libxmljs, - // or node-sax, or node-xml2js, or whatever. So leave it up to them... - fn(null, res.result); - }); -}; - -/** - * "connect" command callback function. If the result was "ok", then get the - * logged in user's info. - * - * @param {Object} res response Object - * @api private - */ - -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 - } -}; - -/** - * "sp/user_info" command callback function. Once this is complete, the "login" - * event is emitted and control is passed back to the user for the first time. - * - * @param {Object} res response Object - * @api private - */ - -Spotify.prototype._onuserinfo = function (err, res) { - if (err) return this.emit('error', err); - - this.user_info = res.result; - this.username = res.result.user; - if (this.username) 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. - * - * @api private - */ - -Spotify.prototype._reply = function () { - var args = Array.prototype.slice.call(arguments); - debug('reply(%j)', args); - this.sendCommand('sp/work_done', args, this._onworkdone); -}; - -/** - * Default callback function for when the user does not pass a - * callback function of their own. - * - * @param {Error} err - * @api private - */ - -Spotify.prototype._defaultCallback = function (err) { - if (err) this.emit('error', err); -}; - -/** - * Wrapper around the Protobuf Schema's `parse()` function that also attaches this - * Spotify instance as `_spotify` to each entry in the parsed object. This is - * necessary so that instance methods (like `Track#play()`) have access to the - * Spotify instance in order to interact with it. - * - * @api private - */ - -Spotify.prototype._parse = function (parser, data) { - var obj = parser.parse(data); - tag(this, obj); - return obj; -}; - -/** - * XXX: move to `util`? - * Attaches the `_spotify` property to each "object" in the passed in `obj`. - * - * @api private - */ - -function tag(spotify, obj){ - if (obj === null || 'object' != typeof obj) return; - Object.keys(obj).forEach(function(key){ - var val = obj[key]; - var type = typeof val; - if ('object' == type) { - if (Array.isArray(val)) { - val.forEach(function (v) { - tag(spotify, v); - }); - } else { - tag(spotify, val); - } - } - }); - Object.defineProperty(obj, '_spotify', { - value: spotify, - enumerable: false, - writable: true, - configurable: true - }); -} From 31dfafb459ff99bc66a86cf0cb51e4314d3167ac Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:29:42 +1100 Subject: [PATCH 016/108] request: add debugging call and fix hasCallback function --- lib/connection/request.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/connection/request.js b/lib/connection/request.js index 9ebf754..e70e546 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -113,7 +113,12 @@ Request.prototype.serialize = function() { */ Request.prototype.hasCallback = function() { - return Boolean(EventEmitter.listenerCount('callback') || EventEmitter.listenerCount('response') || EventEmitter.listenerCount('error')); + 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); }; /** From 48726771cc5e70922a9ee0611657e588520a2be5 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:30:59 +1100 Subject: [PATCH 017/108] connection: fix _startHeartbeat function --- lib/connection/connection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 72c3c2c..60f2e7e 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -216,7 +216,7 @@ SpotifyConnection.prototype._onflush = function() { SpotifyConnection.prototype._startHeartbeat = function () { debug('starting heartbeat every %s seconds', this.heartbeatInterval / 1000); - this._heartbeatId = setInterval(fn, this.emit.bind(this, 'heartbeat')); + this._heartbeatId = setInterval(this.emit.bind(this, 'heartbeat'), this.heartbeatInterval); }; /** From b1153ebccc401f0a6f140d1bad3a69267708edfd Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:31:17 +1100 Subject: [PATCH 018/108] connection: add debugging message for connect event --- lib/connection/connection.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 60f2e7e..ff57ebf 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -156,6 +156,7 @@ SpotifyConnection.prototype._onmessage = function (data) { */ 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'); From 8a2dc068a240d8ab679fa334a9af22466497c3a1 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:43:22 +1100 Subject: [PATCH 019/108] Add SpotifyUri class --- lib/uri.js | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 lib/uri.js diff --git a/lib/uri.js b/lib/uri.js new file mode 100644 index 0000000..d48d6e5 --- /dev/null +++ b/lib/uri.js @@ -0,0 +1,247 @@ + +/** + * Module dependencies. + */ + +var base62 = require('./base62'); +var debug = require('debug')('spotify-web:uri'); + +/** + * Module exports. + */ + +module.exports = SpotifyUri; + +/** + * 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(v, 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 && 'starred' == parts[3]) { + // e.g. spotify:user:tootallnate:starred + return 'playlist'; + } else if (len >= 3) { + // e.g. spotify:[track]:6tdp8sdXrXlPV6AZZN2PE8 + return parts[1]; + } else { + return null; + } + }, + enumerable: true, + configurable: true +}); + +/** + * SpotifyUri sid getter / setter + */ + +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 + */ + +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 + */ + +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 sid getter / setter + */ + +Object.defineProperty(SpotifyUri.prototype, 'user', { + get: function () { + var parts = this._uri_parts; + var len = parts.length; + + if (len >= 5 && 'user' == parts[1]) return parts[2]; + return null; + }, + set: function (user) { + var parts = this._uri_parts; + var len = parts.length; + + if (len >= 5 && '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 (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 (new SpotifyUri(uri)).type; +}; From 4304a679bdc2487394209fca96d7384d05d56a6d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:48:24 +1100 Subject: [PATCH 020/108] util: use uri conversion functions from SpotifyUri --- lib/util.js | 78 +++++------------------------------------------------ 1 file changed, 6 insertions(+), 72 deletions(-) diff --git a/lib/util.js b/lib/util.js index 02dbde8..8699ed6 100644 --- a/lib/util.js +++ b/lib/util.js @@ -3,79 +3,13 @@ * Module dependencies. */ -var base62 = require('./base62'); +var SpotifyUri = require('./uri'); +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. - */ - -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; -}; - -/** - * ID -> URI - */ - -exports.id2uri = function (uriType, v) { - var id = base62.fromHex(v, 22); - return 'spotify:' + uriType + ':' + id; -}; - -/** - * URI -> ID - * - * >>> SpotifyUtil.uri2id('spotify:track:6tdp8sdXrXlPV6AZZN2PE8') - * 'd49fcea60d1f450691669b67af3bda24' - * >>> SpotifyUtil.uri2id('spotify:user:tootallnate:playlist:0Lt5S4hGarhtZmtz7BNTeX') - * '192803a20370c0995f271891a32da6a3' - */ - -exports.uri2id = function (uri) { - var parts = uri.split(':'); - var s; - if (parts.length > 3 && 'playlist' == parts[3]) { - s = parts[4]; - } else { - s = parts[2]; - } - var v = base62.toHex(s); - return v; -}; - -/** - * GID -> URI - */ - -exports.gid2uri = function (uriType, gid) { - var id = exports.gid2id(gid); - return exports.id2uri(uriType, id); -}; - -/** - * Accepts a String URI, returns the "type" of URI. - * i.e. one of "local", "playlist", "track", etc. - */ - -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); - } -}; +// Export methods from SpotifyUri in this module for backwards compatibility +['gid2id', 'id2uri', 'uri2id', 'gid2uri', 'uriType'].forEach(function(key) { + exports[key] = SpotifyUri[key]; +}); /** * Export namespaces decorator From bae1392d7ed963b7aa013cd33f724aea8da25e09 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:54:58 +1100 Subject: [PATCH 021/108] util: export what is exported by export() --- lib/util.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/util.js b/lib/util.js index 8699ed6..bdd583e 100644 --- a/lib/util.js +++ b/lib/util.js @@ -30,6 +30,8 @@ exports.export = function(object, namespaces) { var privateName = '_' + objectName; + object['$exports'] = []; + namespaces.forEach(function(namespace){ var name = namespace.name; @@ -39,6 +41,9 @@ exports.export = function(object, namespaces) { namespace = namespace[1]; } + // append to exported types + object['$exports'].push(name); + // static export object[name] = namespace; From 6b4a547e3f4fdf2192045ea474ef49b90570e51c Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 14 Feb 2014 23:56:10 +1100 Subject: [PATCH 022/108] util: add caveat to export() function comment --- lib/util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/util.js b/lib/util.js index bdd583e..d814d14 100644 --- a/lib/util.js +++ b/lib/util.js @@ -14,7 +14,7 @@ var debug = require('debug')('spotify-web:util'); /** * Export namespaces decorator * - * Inspired by AngularJS DI + * Inspired by AngularJS DI, but is in no way compatible and does not use the same syntax * @api private */ exports.export = function(object, namespaces) { From acc006fa1de67a65ba59b83a0aebc139a16b1cf6 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:02:21 +1100 Subject: [PATCH 023/108] util: fix bug in export() --- lib/util.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/util.js b/lib/util.js index d814d14..44073dd 100644 --- a/lib/util.js +++ b/lib/util.js @@ -28,8 +28,6 @@ exports.export = function(object, namespaces) { debug('exporting %d namespaces on %s', namespaces.length, objectName); - var privateName = '_' + objectName; - object['$exports'] = []; namespaces.forEach(function(namespace){ @@ -41,6 +39,8 @@ exports.export = function(object, namespaces) { namespace = namespace[1]; } + var privateName = '_' + name; + // append to exported types object['$exports'].push(name); From fe3b313011b27ce44d5ca3216ed1a6809f484279 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:19:21 +1100 Subject: [PATCH 024/108] util: more export tweaks --- lib/util.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/util.js b/lib/util.js index 44073dd..6228502 100644 --- a/lib/util.js +++ b/lib/util.js @@ -15,6 +15,9 @@ var debug = require('debug')('spotify-web:util'); * Export namespaces decorator * * Inspired by AngularJS DI, but is in no way compatible and does not use the same syntax + * + * @param {Object} object Object to decorate + * @param {Array|Function} namespaces Namespace objects to export * @api private */ exports.export = function(object, namespaces) { @@ -26,9 +29,13 @@ exports.export = function(object, namespaces) { object = object[1]; } + if (!Array.isArray(namespaces)) + namespaces = [namespaces]; + debug('exporting %d namespaces on %s', namespaces.length, objectName); - object['$exports'] = []; + if (!Array.isArray(object['$exports'])) + object['$exports'] = []; namespaces.forEach(function(namespace){ var name = namespace.name; From 680794859315faa2c8bc08b7d37543bf50834478 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:19:38 +1100 Subject: [PATCH 025/108] connection: specify $inject --- lib/connection/connection.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index ff57ebf..48de742 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -61,6 +61,7 @@ function SpotifyConnection(spotify) { this.on('message', this._onmessage.bind(this)); this.on('heartbeat', this.sendHeartbeat.bind(this)); } +SpotifyConnection['$inject'] = ['Spotify']; inherits(SpotifyConnection, EventEmitter); /** From 2c6785c3fdb0c901169f8e929a74b213e4f8b1cc Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:31:59 +1100 Subject: [PATCH 026/108] spotify: re-export all classes off SpotifyConnection with util.recursiveExport --- lib/spotify.js | 8 +++++++- lib/util.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/spotify.js b/lib/spotify.js index 77fe0d7..fc4768e 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -77,7 +77,7 @@ function Spotify () { EventEmitter.call(this); this.agent = superagent.agent(); - this.connection = new SpotifyConnection(this); + this.connection = new this.SpotifyConnection; this.authServer = 'play.spotify.com'; this.authUrl = '/xhr/json/auth.php'; @@ -113,6 +113,12 @@ function Spotify () { } inherits(Spotify, EventEmitter); +/** + * Re-export all classess off SpotifyConnection + */ + +[SpotifyConnection].forEach(util.recursiveExport, Spotify); + /** * Creates the connection to the Spotify Web websocket server and logs in using * the given Spotify `username` and `password` credentials. diff --git a/lib/util.js b/lib/util.js index 6228502..835f7e9 100644 --- a/lib/util.js +++ b/lib/util.js @@ -11,6 +11,18 @@ var debug = require('debug')('spotify-web:util'); exports[key] = SpotifyUri[key]; }); +/** + * Export all the namespaces of the object passed in on to `this` + */ + +exports.recursiveExport = function(object) { + exports.export(this, object); + if (object['$exports']) + object['$exports'].forEach(function($export) { + exports.recursiveExport.call(this, object[$export]); + }, this) +}; + /** * Export namespaces decorator * From c43dc0d801115d44d4b51f90f3b1e06dad2c9ed1 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:32:24 +1100 Subject: [PATCH 027/108] util: add warning about the order of calls for export() --- lib/util.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/util.js b/lib/util.js index 835f7e9..469225c 100644 --- a/lib/util.js +++ b/lib/util.js @@ -27,6 +27,9 @@ exports.recursiveExport = function(object) { * 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 From 3710b998bb0867dce187d9170c95e6896d23d77c Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:38:28 +1100 Subject: [PATCH 028/108] Move track, artist and album to metadata folder --- lib/{ => metadata}/album.js | 0 lib/{ => metadata}/artist.js | 0 lib/{ => metadata}/track.js | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename lib/{ => metadata}/album.js (100%) rename lib/{ => metadata}/artist.js (100%) rename lib/{ => metadata}/track.js (100%) diff --git a/lib/album.js b/lib/metadata/album.js similarity index 100% rename from lib/album.js rename to lib/metadata/album.js diff --git a/lib/artist.js b/lib/metadata/artist.js similarity index 100% rename from lib/artist.js rename to lib/metadata/artist.js diff --git a/lib/track.js b/lib/metadata/track.js similarity index 100% rename from lib/track.js rename to lib/metadata/track.js From 7fcaf0c4c7c02868a284bc9bb8c65a03fabb4a19 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 00:46:01 +1100 Subject: [PATCH 029/108] metadata --- lib/metadata/album.js | 65 +++--- lib/metadata/artist.js | 64 +++--- lib/metadata/index.js | 6 + lib/metadata/metadata.js | 247 +++++++++++++++++++++ lib/metadata/play_session.js | 319 +++++++++++++++++++++++++++ lib/metadata/track.js | 414 +++++++++++++++++++++++++++-------- 6 files changed, 953 insertions(+), 162 deletions(-) create mode 100644 lib/metadata/index.js create mode 100644 lib/metadata/metadata.js create mode 100644 lib/metadata/play_session.js diff --git a/lib/metadata/album.js b/lib/metadata/album.js index 452407e..6db2000 100644 --- a/lib/metadata/album.js +++ b/lib/metadata/album.js @@ -3,9 +3,10 @@ * Module dependencies. */ -var util = require('./util'); -var Album = require('./schemas').build('metadata', 'Album'); -var debug = require('debug')('spotify-web:album'); +var Metadata = require('./metadata'); +var util = require('../util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:metadata:album'); /** * Module exports. @@ -14,42 +15,38 @@ var debug = require('debug')('spotify-web:album'); exports = module.exports = Album; /** - * Album URI getter. + * The list of schemas that the constructor will support */ -Object.defineProperty(Album.prototype, 'uri', { - get: function () { - return util.gid2uri('album', this.gid); - }, - enumerable: true, - configurable: true -}); +Album.acceptedSchemas = [ Metadata.schemas.album ]; /** - * 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. + * 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 = Metadata.get.bind(null, Album); +Album.get = util.bind(Metadata.get, null, Album); + +/** + * Album class. * - * @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); - }); -}; +function Album (spotify, uri, parent) { + if (!(this instanceof Album)) return new Album(spotify, uri, parent); + this.acceptedSchemas = Album.acceptedSchemas; + this.type = 'album'; + Metadata.call(this, spotify, uri, parent); +} +inherits(Album, Metadata); + diff --git a/lib/metadata/artist.js b/lib/metadata/artist.js index c7392fc..4e23de9 100644 --- a/lib/metadata/artist.js +++ b/lib/metadata/artist.js @@ -3,9 +3,10 @@ * Module dependencies. */ -var util = require('./util'); -var Artist = require('./schemas').build('metadata', 'Artist'); -var debug = require('debug')('spotify-web:artist'); +var Metadata = require('./metadata'); +var util = require('../util'); +var inherits = require('util').inherits; +var debug = require('debug')('spotify-web:metadata:artist'); /** * Module exports. @@ -14,42 +15,37 @@ var debug = require('debug')('spotify-web:artist'); exports = module.exports = Artist; /** - * Artist URI getter. + * The list of schemas that the constructor will support */ -Object.defineProperty(Artist.prototype, 'uri', { - get: function () { - return util.gid2uri('artist', this.gid); - }, - enumerable: true, - configurable: true -}); +Artist.acceptedSchemas = [ Metadata.schemas.artist ]; /** - * 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. + * 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 = Metadata.get.bind(null, Artist); +Artist.get = util.bind(Metadata.get, null, Artist); + +/** + * Artist class. * - * @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); - }); -}; +function Artist (spotify, uri, parent) { + if (!(this instanceof Artist)) return new Artist(spotify, uri, parent); + this.acceptedSchemas = Artist.acceptedSchemas; + this.type = 'artist'; + Metadata.call(this, spotify, uri, parent); +} +inherits(Artist, Metadata); 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..c4a0c66 --- /dev/null +++ b/lib/metadata/metadata.js @@ -0,0 +1,247 @@ + +/** + * 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; +}; + +/** + * 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: {} + }; + + // search for candidates for combination + for (var i = spotify.requestQueue.length - 1; i >= 0; i -= 1) { + var request = spotify.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); + spotify.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); + } + + spotify.requestQueue.push(request); + }); + }); +}; + +/** + * Re-export subtypes + */ + +var Album = require('./album'); +var Artist = require('./artist'); +var Track = require('./Track'); +[Album, Artist, Track].forEach(function (type) { + Metadata[type.name] = type; +}); + +/** + * 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) { + for (var i = 0, l = this.acceptedSchemas.length; i < l; i++) { + if (uri instanceof this.acceptedSchemas[i]) { + this._update(uri, true); + return this; // constructor + } + } + } + + throw new Error('ArgumentError: Invalid arguments'); +} + +/** + * 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 + + 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..dc10575 --- /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); + + +/** + * 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 index 11fa9ce..489a4d7 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -3,154 +3,380 @@ * Module dependencies. */ -var util = require('./util'); -var Track = require('./schemas').build('metadata','Track'); +var schemas = require('../schemas'); +var util = require('../util'); +var Metadata = require('./metadata'); +var PlaySession = require('./play_session'); var PassThrough = require('stream').PassThrough; -var debug = require('debug')('spotify-web:track'); +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'); /** - * Module exports. + * Protocol Buffer types. */ -exports = module.exports = Track; +var StoryRequest = schemas.build('bartender','StoryRequest'); +var StoryList = schemas.build('bartender','StoryList'); /** - * Track URI getter. + * Module exports. */ -Object.defineProperty(Track.prototype, 'uri', { - get: function () { - return util.gid2uri('track', this.gid); - }, - enumerable: true, - configurable: true +module.exports = Track; + +var exportNamespaces = [ PlaySession ]; + +/** + * Re-export namespaces + */ +exportNamespaces.forEach(function (namespace) { + Track[namespace.name] = namespace; }); /** - * Track Preview URL getter + * Constants + */ + +const previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/'; + +/** + * The list of schemas that the constructor will support */ -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 -}) + +Track.acceptedSchemas = [ Metadata.schemas.track ]; /** - * 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. + * Creates a new Track instance with the specified uri, or in the case of multiple uris, + * creates an array of new Track instances. * - * @param {Function} fn callback function + * 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.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)); +//Track.get = Metadata.get.bind(null, Track); +Track.get = util.bind(Metadata.get, null, Track); + +/** + * Track class. + * + * @api public + */ + +function Track (spotify, uri, parent) { + if (!(this instanceof Track)) return new Track(spotify, uri, parent); + this.acceptedSchemas = Track.acceptedSchemas; + this.playSession = null; + this.type = 'track'; + + // bind exported namespaces into object + exportNamespaces.forEach(util.bindNamespace.bind(null, this)); + + Metadata.call(this, spotify, uri, parent); +} +inherits(Track, Metadata); + +/** + * 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; } - var spotify = this._spotify; + 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; - spotify.get(this.uri, function (err, track) { + var spotify = this._spotify; + this.recurseAlternatives(spotify.user_info.country, 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]; - } + 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); }); - fn(null, self); }); }; /** - * Begins playing this track, returns a Readable stream that outputs MP3 data. + * 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.play = function () { - // TODO: add formatting options once we figure that out +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; - 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; + // 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)); } - // 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); - }); + // 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); - 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)); + 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; } } - - // return stream immediately so it can be .pipe()'d - return stream; + process.nextTick(fn.bind(null, null, available)); }; /** - * Begins playing a preview of the track, returns a Readable stream that outputs MP3 data. + * 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.playPreview = function () { +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 stream = new PassThrough(); - var previewUrl = this.previewUrl; - if (!previewUrl) { - process.nextTick(function() { - stream.emit('error', new Error('Track does not have preview available')); + var parts = this.uri.split(':'); + var id = parts[2]; + + var request = new spotify.HermesRequest('hm://similarity/suggest/' + id); + 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); + + var recommendations = res.result.stories.map(function(story) { + var data = Object.create(null); + (function objectify(recommendedItem) { + var type = util.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) { + track.artist = data.artist; + if (story.metadata && story.metadata.summary) track.artist.biography = {text: story.metadata.summary}; + if (story.heroImage) track.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 = track.artist; + } + return track; }); - return stream; + + 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; } - 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)); - } + // 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); - // return stream immediately so it can be .pipe()'d + 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); +}; From 9ec08a71ea83ff0a24de49372f04e02d7d573e5d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:04:38 +1100 Subject: [PATCH 030/108] metadata: fixes --- lib/metadata/album.js | 2 +- lib/metadata/artist.js | 2 +- lib/metadata/metadata.js | 24 +++++++++++++----------- lib/metadata/track.js | 19 ++++++------------- lib/spotify.js | 8 +++----- 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/lib/metadata/album.js b/lib/metadata/album.js index 6db2000..53a10a6 100644 --- a/lib/metadata/album.js +++ b/lib/metadata/album.js @@ -33,7 +33,6 @@ Album.acceptedSchemas = [ Metadata.schemas.album ]; * @api public */ -//Album.get = Metadata.get.bind(null, Album); Album.get = util.bind(Metadata.get, null, Album); /** @@ -49,4 +48,5 @@ function Album (spotify, uri, parent) { Metadata.call(this, spotify, uri, parent); } inherits(Album, Metadata); +Album['$inject'] = ['Spotify']; diff --git a/lib/metadata/artist.js b/lib/metadata/artist.js index 4e23de9..32a8eb8 100644 --- a/lib/metadata/artist.js +++ b/lib/metadata/artist.js @@ -33,7 +33,6 @@ Artist.acceptedSchemas = [ Metadata.schemas.artist ]; * @api public */ -//Artist.get = Metadata.get.bind(null, Artist); Artist.get = util.bind(Metadata.get, null, Artist); /** @@ -49,3 +48,4 @@ function Artist (spotify, uri, parent) { Metadata.call(this, spotify, uri, parent); } inherits(Artist, Metadata); +Artist['$inject'] = ['Spotify']; diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index c4a0c66..333c1ed 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -60,6 +60,7 @@ Metadata.get = function(type, spotify, uri, fn) { if ('function' == typeof fn) process.nextTick(fn.bind(null, null, ret)); return ret; }; +Metadata.get['$inject'] = [null, 'Spotify']; /** * Merge any pending metadata requests into a multi-GET request if possible @@ -119,17 +120,7 @@ Metadata.mergeMultiGetRequests = function(spotify) { }); }); }; - -/** - * Re-export subtypes - */ - -var Album = require('./album'); -var Artist = require('./artist'); -var Track = require('./Track'); -[Album, Artist, Track].forEach(function (type) { - Metadata[type.name] = type; -}); +Metadata.mergeMultiGetRequests['$inject'] = ['Spotify']; /** * Metadata class. @@ -165,6 +156,17 @@ function Metadata (spotify, uri, parent) { 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 diff --git a/lib/metadata/track.js b/lib/metadata/track.js index 489a4d7..ac29433 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -27,15 +27,6 @@ var StoryList = schemas.build('bartender','StoryList'); module.exports = Track; -var exportNamespaces = [ PlaySession ]; - -/** - * Re-export namespaces - */ -exportNamespaces.forEach(function (namespace) { - Track[namespace.name] = namespace; -}); - /** * Constants */ @@ -61,7 +52,6 @@ Track.acceptedSchemas = [ Metadata.schemas.track ]; * @api public */ -//Track.get = Metadata.get.bind(null, Track); Track.get = util.bind(Metadata.get, null, Track); /** @@ -76,12 +66,15 @@ function Track (spotify, uri, parent) { this.playSession = null; this.type = 'track'; - // bind exported namespaces into object - exportNamespaces.forEach(util.bindNamespace.bind(null, this)); - Metadata.call(this, spotify, uri, parent); } inherits(Track, Metadata); +Track['$inject'] = ['Spotify']; + +/** + * Re-export namespaces + */ +util.export(Track, [ PlaySession ]); /** * Creates a new play session for the given Track object, including the URL to access the audio data. diff --git a/lib/spotify.js b/lib/spotify.js index fc4768e..473daee 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -10,6 +10,7 @@ var schemas = require('./schemas'); var superagent = require('superagent'); var inherits = require('util').inherits; var SpotifyConnection = require('./connection'); +var Metadata = require('./metadata'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var pkg = require('../package.json'); @@ -28,9 +29,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'); @@ -114,10 +112,10 @@ function Spotify () { inherits(Spotify, EventEmitter); /** - * Re-export all classess off SpotifyConnection + * Re-export all sub-classes */ -[SpotifyConnection].forEach(util.recursiveExport, Spotify); +[SpotifyConnection, Metadata].forEach(util.recursiveExport, Spotify); /** * Creates the connection to the Spotify Web websocket server and logs in using From 597a78d028133e6950bde99255d4f6abf1fcbe13 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:05:01 +1100 Subject: [PATCH 031/108] util: add missing bind() helper --- lib/util.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/util.js b/lib/util.js index 469225c..195c884 100644 --- a/lib/util.js +++ b/lib/util.js @@ -159,3 +159,22 @@ exports.wrapCallback = function (fn, self) { }; }; + +/** + * 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; +}; + From c0b44f263b9b5fb4b1aca709e28d78d88feb41ef Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:12:00 +1100 Subject: [PATCH 032/108] spotify: export functions from SpotifyUri directly --- lib/spotify.js | 9 +++++---- lib/util.js | 5 ----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 473daee..a6d122e 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -10,6 +10,7 @@ var schemas = require('./schemas'); var superagent = require('superagent'); var inherits = require('util').inherits; var SpotifyConnection = require('./connection'); +var SpotifyUri = require('./uri'); var Metadata = require('./metadata'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); @@ -38,11 +39,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) { + exports[key] = SpotifyUri[key]; }); /** @@ -115,7 +116,7 @@ inherits(Spotify, EventEmitter); * Re-export all sub-classes */ -[SpotifyConnection, Metadata].forEach(util.recursiveExport, Spotify); +[SpotifyConnection, SpotifyUri, Metadata].forEach(util.recursiveExport, Spotify); /** * Creates the connection to the Spotify Web websocket server and logs in using diff --git a/lib/util.js b/lib/util.js index 195c884..2e8850b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -6,11 +6,6 @@ var SpotifyUri = require('./uri'); var debug = require('debug')('spotify-web:util'); -// Export methods from SpotifyUri in this module for backwards compatibility -['gid2id', 'id2uri', 'uri2id', 'gid2uri', 'uriType'].forEach(function(key) { - exports[key] = SpotifyUri[key]; -}); - /** * Export all the namespaces of the object passed in on to `this` */ From f09aaaa1e1d32aec083973fc80506a28e1e705b5 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:19:53 +1100 Subject: [PATCH 033/108] spotify: attach metadata multi get merger as request queue handler --- lib/spotify.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/spotify.js b/lib/spotify.js index a6d122e..d70542b 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -103,6 +103,9 @@ function Spotify () { // handle "message" commands... 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(); this._context.reply = this._reply.bind(this); From 3a9e71567eb421082756d46864eb1eec2f2df3a5 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:21:12 +1100 Subject: [PATCH 034/108] metadata: requestQueue is now on the connection --- lib/metadata/metadata.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 333c1ed..05a56f7 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -81,9 +81,11 @@ Metadata.mergeMultiGetRequests = function(spotify) { album: {} }; + var requestQueue = spotify.connection.requestQueue; + // search for candidates for combination - for (var i = spotify.requestQueue.length - 1; i >= 0; i -= 1) { - var request = spotify.requestQueue[i]; + 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))) { @@ -91,7 +93,7 @@ Metadata.mergeMultiGetRequests = function(spotify) { var qs = match[2] || ''; if (!multiGet[type][qs]) multiGet[type][qs] = []; multiGet[type][qs].push(request); - spotify.requestQueue.splice(i, 1); + requestQueue.splice(i, 1); } } } @@ -116,7 +118,7 @@ Metadata.mergeMultiGetRequests = function(spotify) { request.addSubrequests(candidates); } - spotify.requestQueue.push(request); + requestQueue.push(request); }); }); }; From 1b73d95bfca3528bd4acaa798a439ef6e5fc3af7 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 15 Feb 2014 01:23:53 +1100 Subject: [PATCH 035/108] spotify: add _objectify method --- lib/metadata/metadata.js | 4 ++-- lib/spotify.js | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 05a56f7..1dd0304 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -181,11 +181,11 @@ 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 + // TODO(adammw): update this._prerestricted on all the objects created by spotify._objectify() calls Object.keys(obj).forEach(function (key) { if (!self.hasOwnProperty(key)) { - self[key] = spotify.objectify(obj[key]); + self[key] = spotify._objectify(obj[key]); } }); diff --git a/lib/spotify.js b/lib/spotify.js index d70542b..783bd87 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -121,6 +121,49 @@ inherits(Spotify, EventEmitter); [SpotifyConnection, SpotifyUri, Metadata].forEach(util.recursiveExport, Spotify); +/** + * 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) + + for (var i = 0, l = Spotify['$exports'].length; i < l; i++) { + var type = this[Spotify['$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.acceptedSchemas && Array.isArray(type.acceptedSchemas) { + for (var j = 0, l = type.acceptedSchemas.length; j < l; j++) { + var schema = type.acceptedSchemas[j]; + if (obj instanceof schema) { + // TODO(adammw): make sure passing in a parent like this makes sense, + // perhaps we could better do it by looking at the $inject again + + 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 * the given Spotify `username` and `password` credentials. From 8076bbc855e6da5be837d0c6d73a04d23f802c0d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:04:14 +1100 Subject: [PATCH 036/108] spotify: export uri functions on Spotify object --- lib/spotify.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spotify.js b/lib/spotify.js index 783bd87..ad5bec3 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -43,7 +43,7 @@ var StoryList = schemas.build('bartender','StoryList'); */ ['gid2id', 'id2uri', 'uri2id', 'gid2uri', 'uriType'].forEach(function(key) { - exports[key] = SpotifyUri[key]; + Spotify[key] = SpotifyUri[key]; }); /** From 95814c111ae10d8511f037181fbabfc3145efade Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:04:51 +1100 Subject: [PATCH 037/108] spotify: make arguments in sendCommand optional --- lib/spotify.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/spotify.js b/lib/spotify.js index ad5bec3..bcdf1ea 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -453,6 +453,10 @@ Spotify.prototype.sendPong = function(ping) { */ Spotify.prototype.sendCommand = function (name, args, fn) { + if ('function' == typeof args) { + fn = args; + args = null; + } var request = new this.connection.Request(name, args) request.send(fn); }; From 5ee053833a9a2c6b2a7d82f85e354718e07dffa9 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:05:04 +1100 Subject: [PATCH 038/108] spotify: fix syntax error --- lib/spotify.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spotify.js b/lib/spotify.js index bcdf1ea..25cbb11 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -142,7 +142,7 @@ Spotify.prototype._objectify = function(obj, parent) { if (obj instanceof type) return obj; // check if the object is an instance of any of the types accepted schemas - if (type.acceptedSchemas && Array.isArray(type.acceptedSchemas) { + if (type.acceptedSchemas && Array.isArray(type.acceptedSchemas)) { for (var j = 0, l = type.acceptedSchemas.length; j < l; j++) { var schema = type.acceptedSchemas[j]; if (obj instanceof schema) { From 072d7cc372c31c8e66a51a2a12dd420b69afb00f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:25:14 +1100 Subject: [PATCH 039/108] util: add `$provides` mechanism to export() --- lib/util.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/util.js b/lib/util.js index 2e8850b..3bc7230 100644 --- a/lib/util.js +++ b/lib/util.js @@ -73,16 +73,33 @@ exports.export = function(object, namespaces) { // 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); From 54146171d96522b2c70b44fd0bfb2ed51325015b Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:25:33 +1100 Subject: [PATCH 040/108] spotify: declare `$provides` for SpotifyConnection --- lib/spotify.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/spotify.js b/lib/spotify.js index 25cbb11..63aca65 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -114,6 +114,7 @@ function Spotify () { this._defaultCallback = this._defaultCallback.bind(this); } inherits(Spotify, EventEmitter); +Spotify.$provides = {'SpotifyConnection': 'connection'}; /** * Re-export all sub-classes From 6878b1c2f683520a2331c05b29bade6956c3a508 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:33:36 +1100 Subject: [PATCH 041/108] request: fix response parameter in callback --- lib/connection/request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/request.js b/lib/connection/request.js index e70e546..93562ff 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -144,5 +144,5 @@ Request.prototype.callback = function(err, res){ if (this.response) this.emit('response', this.response); // invoke callback - this.emit('callback', err, res); + this.emit('callback', err, this.response); }; From 9c7261a11dacac93eef2f5bf924c55769d5a352b Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:37:14 +1100 Subject: [PATCH 042/108] spotify: save all user info in user_info object --- lib/spotify.js | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 63aca65..efbeb2c 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -122,6 +122,34 @@ Spotify.$provides = {'SpotifyConnection': 'connection'}; [SpotifyConnection, SpotifyUri, Metadata].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 * @@ -1113,9 +1141,7 @@ 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.emit('login'); }; From 42dfc61bdae30705194579e02d7071f17da2f176 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:38:21 +1100 Subject: [PATCH 043/108] track: default to 'US' if country not specified --- lib/metadata/track.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/metadata/track.js b/lib/metadata/track.js index ac29433..57d9194 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -119,7 +119,7 @@ Track.prototype.audioUrl = function(format, transport, fn) { var self = this; var spotify = this._spotify; - this.recurseAlternatives(spotify.user_info.country, function (err, track) { + 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); From cf013bd1c5270ae72910ed57de1ec50a394af865 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:51:39 +1100 Subject: [PATCH 044/108] spotify: fix objectify nested loops bug --- lib/spotify.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index efbeb2c..cf108a9 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -164,15 +164,18 @@ Spotify.prototype._objectify = function(obj, parent) { // TODO(adammw): convert bare uris to SpotifyUri types // (may not be needed if everything has it's own class that does that) - for (var i = 0, l = Spotify['$exports'].length; i < l; i++) { - var type = this[Spotify['$exports'][i]]; + 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.acceptedSchemas && Array.isArray(type.acceptedSchemas)) { - for (var j = 0, l = type.acceptedSchemas.length; j < l; j++) { + console.log(type.name, 'accepts:', type.acceptedSchemas); + for (var j = 0, jl = type.acceptedSchemas.length; j < jl; j++) { var schema = type.acceptedSchemas[j]; if (obj instanceof schema) { // TODO(adammw): make sure passing in a parent like this makes sense, From 2c31c4b71c08f7ad2d3553edc17cdaf8b243fa3f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:51:53 +1100 Subject: [PATCH 045/108] metadata: lowercase 'track' require() --- lib/metadata/metadata.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 1dd0304..7f27249 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -166,7 +166,7 @@ Metadata['$inject'] = ['Spotify']; var Album = require('./album'); // these require() statements MUST be after all static methods are defined var Artist = require('./artist'); -var Track = require('./Track'); +var Track = require('./track'); util.export(Metadata, [Album, Artist, Track]); From 8584b25074ba276c39c3a9f2f26dc2d36f6eb606 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 22:52:37 +1100 Subject: [PATCH 046/108] util: add deferCallback helper --- lib/util.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/util.js b/lib/util.js index 3bc7230..602227a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -190,3 +190,23 @@ exports.bind = function(fn) { return boundFn; }; + +/** + * 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); + } +}; From 10224bbe741c0681498221d7dbf90a67b16c0c8d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 23:06:55 +1100 Subject: [PATCH 047/108] uri: fix typo --- lib/uri.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/uri.js b/lib/uri.js index d48d6e5..6c6ca61 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -58,7 +58,7 @@ function SpotifyUri(type, id) { this.uri = type; } else { if (id instanceof Buffer) id = SpotifyUri.gid2id(id); - if (/^[0-9a-f]*$/.test(id)) id = base62.fromHex(v, 22); + if (/^[0-9a-f]*$/.test(id)) id = base62.fromHex(id, 22); this._uri_parts = ['spotify', type, id]; } } From fd7e3c112b2b011301f6502f4bbe8bef5bf993fb Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 23:07:35 +1100 Subject: [PATCH 048/108] metadata: convert gid to SpotifyUri objects --- lib/metadata/metadata.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 7f27249..8909ac3 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -183,6 +183,10 @@ Metadata.prototype._update = function(obj, partial) { // 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]); From b0afa784d284745d6df34e2eb0164793097c2b1e Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 23:08:15 +1100 Subject: [PATCH 049/108] uri: whitespace --- lib/uri.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/uri.js b/lib/uri.js index 6c6ca61..fbc22f1 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -18,6 +18,7 @@ module.exports = SpotifyUri; * @param {String} uriType * @param {Buffer} gid */ + SpotifyUri.fromGid = function(uriType, gid) { return new SpotifyUri(SpotifyUri.gid2uri(uriType, gid)); }; @@ -28,6 +29,7 @@ SpotifyUri.fromGid = function(uriType, gid) { * @param {String} uriType * @param {String} id (hexadecimal) */ + SpotifyUri.fromId = function(uriType, id) { return new SpotifyUri(SpotifyUri.id2uri(uriType, gid)); }; @@ -37,6 +39,7 @@ SpotifyUri.fromId = function(uriType, id) { * * @param {String} uri */ + SpotifyUri.fromUri = function(uri) { return new SpotifyUri(uri); }; From 06d11b36060a02a62dfd58ec8a7649b04a0c2b4d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 23:09:22 +1100 Subject: [PATCH 050/108] play_session: specify `$inject` --- lib/metadata/play_session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/metadata/play_session.js b/lib/metadata/play_session.js index dc10575..880a30c 100644 --- a/lib/metadata/play_session.js +++ b/lib/metadata/play_session.js @@ -40,7 +40,7 @@ function PlaySession(track, args) { this.uri = args.uri || null; } inherits(PlaySession, EventEmitter); - +PlaySession['$inject'] = ['Track']; /** * Default callback function for when the user does not pass a From 6b729a73954cd4b186fc99b8ae9e471d4299a1c1 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 17 Feb 2014 23:24:22 +1100 Subject: [PATCH 051/108] spotify: rewrite #metadata to use Metadata objects --- lib/spotify.js | 48 +++++++----------------------------------------- 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index cf108a9..f73d14b 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -663,49 +663,15 @@ Spotify.prototype.metadata = function (uris, fn) { if (!Array.isArray(uris)) { uris = [ uris ]; } - // array of "request" Objects that will be protobuf'd - var requests = []; - var mtype = ''; - uris.forEach(function (uri) { + var types = ['track', 'artist', 'album']; + var objects = uris.map(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' - }; - 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 - } - }, function(err, item) { - if (err) return fn(err); - item._loaded = true; - fn(null, item); + // TODO(adammw): error handling + if (-1 == types.indexOf(type)) return null; + var typeName = type.charAt(0).toUpperCase() + type.substring(1).toLowerCase(); + return new this[typeName](uri); }); + return objects; }; /** From 26a0841284596150c58804210f8d1b9965a2e04b Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 22:39:12 +1100 Subject: [PATCH 052/108] spotify: fix get function --- lib/spotify.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index f73d14b..9a6528f 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -665,12 +665,12 @@ Spotify.prototype.metadata = function (uris, fn) { } var types = ['track', 'artist', 'album']; var objects = uris.map(function (uri) { - var type = util.uriType(uri); + var type = SpotifyUri.uriType(uri); // TODO(adammw): error handling if (-1 == types.indexOf(type)) return null; var typeName = type.charAt(0).toUpperCase() + type.substring(1).toLowerCase(); return new this[typeName](uri); - }); + }, this); return objects; }; From 10575a1e13f2cb0aacbf74a1b5624492d625bf2b Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 22:39:29 +1100 Subject: [PATCH 053/108] Add User class --- lib/schemas.js | 2 + lib/spotify.js | 2 +- lib/uri.js | 6 +- lib/user.js | 195 +++++++++++++++++++++++++++++++++++++++++++ proto/presence.desc | Bin 0 -> 1943 bytes proto/presence.proto | 96 +++++++++++++++++++++ proto/social.desc | 12 +++ proto/social.proto | 11 +++ 8 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 lib/user.js create mode 100644 proto/presence.desc create mode 100644 proto/presence.proto create mode 100644 proto/social.desc create mode 100644 proto/social.proto diff --git a/lib/schemas.js b/lib/schemas.js index 9191dfa..ff21ebf 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', + social: 'social', toplist: 'toplist' }; var packageCache = module.exports = {}; diff --git a/lib/spotify.js b/lib/spotify.js index 9a6528f..fb0fbf6 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -120,7 +120,7 @@ Spotify.$provides = {'SpotifyConnection': 'connection'}; * Re-export all sub-classes */ -[SpotifyConnection, SpotifyUri, Metadata].forEach(util.recursiveExport, Spotify); +[SpotifyConnection, SpotifyUri, Metadata, User].forEach(util.recursiveExport, Spotify); /** * User info getters diff --git a/lib/uri.js b/lib/uri.js index fbc22f1..ead6910 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -167,7 +167,7 @@ Object.defineProperty(SpotifyUri.prototype, 'gid', { }); /** - * SpotifyUri sid getter / setter + * SpotifyUri user getter / setter */ Object.defineProperty(SpotifyUri.prototype, 'user', { @@ -175,14 +175,14 @@ Object.defineProperty(SpotifyUri.prototype, 'user', { var parts = this._uri_parts; var len = parts.length; - if (len >= 5 && 'user' == parts[1]) return parts[2]; + 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 >= 5 && 'user' == parts[1]) parts[2] = user; + if (len >= 3 && 'user' == parts[1]) parts[2] = user; }, enumerable: true, configurable: true diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 0000000..60fba74 --- /dev/null +++ b/lib/user.js @@ -0,0 +1,195 @@ + +/** + * Module dependencies. + */ + +var schemas = require('./schemas'); +var util = require('./util'); +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'); +}; + +User.prototype.rootlist = function() { + throw new Error('TODO: implement'); +}; + +User.prototype.starred = function() { + throw new Error('TODO: implement'); +}; diff --git a/proto/presence.desc b/proto/presence.desc new file mode 100644 index 0000000000000000000000000000000000000000..0f653a91f292eaada1af14674e3b0ad04e8f0347 GIT binary patch literal 1943 zcmcIk+iuf95GBp68MjHgO@dnq3HbqmprQB)74cx2q&0*l9@WfM;&18y&TbwfZ|Z4CG0&JnXGUybQB zZmq$VACX$C9I_B0$+1S8McNtcAzjCDQH*?t>4Wl#47-d+P zldslZLA_&NdCcL|@cmojD^Rq3e@*w=w^LA2q)^2;o&GP4$50+KXLw<=c(Q9tDTH_4G^ zwFB5`(JN;{O?T$8h0Pt$B>)><5V>dza{Stl__bdfP&=V6WzM7@nKImRTqdoTU;QMs z9;w2nSla@3gnDEVA}AjhOvIZJau#wxc`2K$S*C!X@JH+;Y%d~OhwT^*88+kM6*HCM z;w6e4fN%rKOE@G^5EmAU7q^8#8^{zVXE`^cQ@6!4MSs#vKHcV>S!=%wkPk<=a<6iZ zS#o%5_+?#7W5xdFNUSMNfN;WZfh=#@kCQ@`qgM208kszdglKsWwIvtFNU&vi;F1*m z`M=oK zcD#n-d3d<0G_;~WOY2U$Z|!FWa%ivykbc+r+%<rJUgjoiD2s(AA19H)V7R%KkCVmJ*bC0!;vu Date: Wed, 19 Feb 2014 22:59:27 +1100 Subject: [PATCH 054/108] spotify: support callback in Spotify#get --- lib/spotify.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index fb0fbf6..f53dac8 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -653,23 +653,35 @@ Spotify.prototype.disconnect = function () { * Gets the "metadata" object for one or more URIs. * * @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 + * @return {Array} * @api public */ Spotify.prototype.get = Spotify.prototype.metadata = function (uris, fn) { debug('metadata(%j)', uris); + if (!Array.isArray(uris)) { uris = [ uris ]; } + + fn = util.wrapCallback(fn, this); + var types = ['track', 'artist', 'album']; var objects = uris.map(function (uri) { var type = SpotifyUri.uriType(uri); - // TODO(adammw): error handling if (-1 == types.indexOf(type)) return null; var typeName = type.charAt(0).toUpperCase() + type.substring(1).toLowerCase(); - return new this[typeName](uri); + var object = null; + var err = null; + try { + object = new this[typeName](uri); + } catch (e) { + err = e; + } + process.nextTick(fn.bind(null, err, object)); + return object; }, this); return objects; }; From 74e153646fd6baf45c035ee4094e5eedb5e709a0 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 22:59:36 +1100 Subject: [PATCH 055/108] spotify: add missing require() --- lib/spotify.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/spotify.js b/lib/spotify.js index f53dac8..4810085 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -12,6 +12,7 @@ var inherits = require('util').inherits; var SpotifyConnection = require('./connection'); var SpotifyUri = require('./uri'); var Metadata = require('./metadata'); +var User = require('./user'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var pkg = require('../package.json'); From b0f4c12e319b09e3f4572c096f28f51984cf0966 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 23:10:36 +1100 Subject: [PATCH 056/108] Remove `acceptedSchemas` in favour of `_acceptsSchema()` --- lib/metadata/album.js | 6 ------ lib/metadata/artist.js | 6 ------ lib/metadata/metadata.js | 11 +++++++++++ lib/metadata/track.js | 6 ------ lib/spotify.js | 15 +++------------ 5 files changed, 14 insertions(+), 30 deletions(-) diff --git a/lib/metadata/album.js b/lib/metadata/album.js index 53a10a6..6a8b0a8 100644 --- a/lib/metadata/album.js +++ b/lib/metadata/album.js @@ -14,12 +14,6 @@ var debug = require('debug')('spotify-web:metadata:album'); exports = module.exports = Album; -/** - * The list of schemas that the constructor will support - */ - -Album.acceptedSchemas = [ Metadata.schemas.album ]; - /** * Creates a new Album instance with the specified uri, or in the case of multiple uris, * creates an array of new Album instances. diff --git a/lib/metadata/artist.js b/lib/metadata/artist.js index 32a8eb8..9a478d6 100644 --- a/lib/metadata/artist.js +++ b/lib/metadata/artist.js @@ -14,12 +14,6 @@ var debug = require('debug')('spotify-web:metadata:artist'); exports = module.exports = Artist; -/** - * The list of schemas that the constructor will support - */ - -Artist.acceptedSchemas = [ Metadata.schemas.artist ]; - /** * Creates a new Artist instance with the specified uri, or in the case of multiple uris, * creates an array of new Artist instances. diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 8909ac3..61b44f7 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -170,6 +170,17 @@ var Track = require('./track'); util.export(Metadata, [Album, Artist, Track]); +/** + * Check whether the class supports construction from a specific schema/object + * + * @param {Object} schema + * @return {Boolean} + * @api private + */ +Metadata.prototype._acceptsSchema = function(schema) { + return (schema instanceof Metadata.schemas[this.type]); +}; + /** * Update the Metadata instance with the properties of another object * diff --git a/lib/metadata/track.js b/lib/metadata/track.js index 57d9194..5af9066 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -33,12 +33,6 @@ module.exports = Track; const previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/'; -/** - * The list of schemas that the constructor will support - */ - -Track.acceptedSchemas = [ Metadata.schemas.track ]; - /** * Creates a new Track instance with the specified uri, or in the case of multiple uris, * creates an array of new Track instances. diff --git a/lib/spotify.js b/lib/spotify.js index 4810085..0db2c2d 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -174,18 +174,9 @@ Spotify.prototype._objectify = function(obj, parent) { if (obj instanceof type) return obj; // check if the object is an instance of any of the types accepted schemas - if (type.acceptedSchemas && Array.isArray(type.acceptedSchemas)) { - console.log(type.name, 'accepts:', type.acceptedSchemas); - for (var j = 0, jl = type.acceptedSchemas.length; j < jl; j++) { - var schema = type.acceptedSchemas[j]; - if (obj instanceof schema) { - // TODO(adammw): make sure passing in a parent like this makes sense, - // perhaps we could better do it by looking at the $inject again - - debug('objectifying object: %j', obj); - return new type(obj, parent); - } - } + if (type._acceptsSchema && type._acceptsSchema(obj)) { + debug('objectifying object: %j', obj); + return new type(obj, parent); } } From 40d8bab5e487c4bb4ec7839dec40f3c2d3922cbd Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 23:20:26 +1100 Subject: [PATCH 057/108] uri: add examples to getter/setter comments --- lib/uri.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/uri.js b/lib/uri.js index ead6910..0618f20 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -117,6 +117,8 @@ Object.defineProperty(SpotifyUri.prototype, 'type', { /** * SpotifyUri sid getter / setter + * + * e.g. '6tdp8sdXrXlPV6AZZN2PE8' for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 */ Object.defineProperty(SpotifyUri.prototype, 'sid', { @@ -138,6 +140,8 @@ Object.defineProperty(SpotifyUri.prototype, 'sid', { /** * SpotifyUri id getter / setter + * + * e.g. 'd49fcea60d1f450691669b67af3bda24' for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 */ Object.defineProperty(SpotifyUri.prototype, 'id', { @@ -153,6 +157,8 @@ Object.defineProperty(SpotifyUri.prototype, 'id', { /** * SpotifyUri gid getter / setter + * + * e.g. for spotify:track:6tdp8sdXrXlPV6AZZN2PE8 */ Object.defineProperty(SpotifyUri.prototype, 'gid', { From e342919faa40de407f4e111654c99e1415b9d01f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 23:38:55 +1100 Subject: [PATCH 058/108] uri: don't try to make a new object if an object was passed in --- lib/uri.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/uri.js b/lib/uri.js index 0618f20..1500a75 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -232,7 +232,7 @@ SpotifyUri.id2uri = function (uriType, id) { */ SpotifyUri.uri2id = function (uri) { - return (new SpotifyUri(uri)).id; + return (uri instanceof SpotifyUri) ? uri.id : (new SpotifyUri(uri)).id; }; /** @@ -252,5 +252,5 @@ SpotifyUri.gid2uri = function (uriType, gid) { */ SpotifyUri.uriType = function (uri) { - return (new SpotifyUri(uri)).type; + return (uri instanceof SpotifyUri) ? uri.type : (new SpotifyUri(uri)).type; }; From 1070a63178232a3ee9a2c4ca76bb80a0d43fc030 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Wed, 19 Feb 2014 23:39:15 +1100 Subject: [PATCH 059/108] track: fix Track#similar() --- lib/metadata/track.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/metadata/track.js b/lib/metadata/track.js index 5af9066..8436f7e 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -5,6 +5,7 @@ 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; @@ -258,10 +259,7 @@ Track.prototype.similar = function(fn) { var spotify = this._spotify; - var parts = this.uri.split(':'); - var id = parts[2]; - - var request = new spotify.HermesRequest('hm://similarity/suggest/' + id); + var request = new spotify.HermesRequest('hm://similarity/suggest/' + this.uri.sid); request.setRequestSchema(StoryRequest); request.setResponseSchema(StoryList); request.send({ @@ -271,10 +269,12 @@ Track.prototype.similar = function(fn) { }, 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 = util.uriType(recommendedItem.uri); + 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; @@ -293,16 +293,16 @@ Track.prototype.similar = function(fn) { if (data.album) track.album = data.album; if (data.artist) { - track.artist = data.artist; - if (story.metadata && story.metadata.summary) track.artist.biography = {text: story.metadata.summary}; - if (story.heroImage) track.artist.portrait = story.heroImage.map(function(image) { + 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 = track.artist; + if (track.album) track.album.artist = data.artist; + track.artist = [ data.artist ]; } return track; }); From 1fa8809e7cdfad20e793630ca8d2e43b4bde55a9 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Thu, 20 Feb 2014 00:34:28 +1100 Subject: [PATCH 060/108] connection: update debug namespaces --- lib/connection/hermes_request.js | 2 +- lib/connection/hermes_response.js | 2 +- lib/connection/request.js | 2 +- lib/connection/response.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/connection/hermes_request.js b/lib/connection/hermes_request.js index 24f3134..06443d4 100644 --- a/lib/connection/hermes_request.js +++ b/lib/connection/hermes_request.js @@ -9,7 +9,7 @@ var schemas = require('../schemas'); var http = require('http'); var inherits = require('util').inherits; var format = require('util').format; -var debug = require('debug')('spotify-web:request:hermes'); +var debug = require('debug')('spotify-web:connection:request:hermes'); /** * Module exports. diff --git a/lib/connection/hermes_response.js b/lib/connection/hermes_response.js index a2eb435..540d4fc 100644 --- a/lib/connection/hermes_response.js +++ b/lib/connection/hermes_response.js @@ -6,7 +6,7 @@ var Spotify = require('../spotify'); var schemas = require('../schemas'); var http = require('http'); -var debug = require('debug')('spotify-web:response:hermes'); +var debug = require('debug')('spotify-web:connection:response:hermes'); /** * Module exports. diff --git a/lib/connection/request.js b/lib/connection/request.js index 93562ff..1f2303f 100644 --- a/lib/connection/request.js +++ b/lib/connection/request.js @@ -9,7 +9,7 @@ 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:request'); +var debug = require('debug')('spotify-web:connection:request'); /** * Module exports. diff --git a/lib/connection/response.js b/lib/connection/response.js index 0546b73..43e2096 100644 --- a/lib/connection/response.js +++ b/lib/connection/response.js @@ -4,7 +4,7 @@ */ var Spotify = require('../spotify'); -var debug = require('debug')('spotify-web:response'); +var debug = require('debug')('spotify-web:connection:response'); /** * Module exports. From 911b768231a36e6f7e3acfa87322046c020732be Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Thu, 20 Feb 2014 01:19:13 +1100 Subject: [PATCH 061/108] hermes_response: don't require this.request --- lib/connection/hermes_response.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/hermes_response.js b/lib/connection/hermes_response.js index 540d4fc..4845e8f 100644 --- a/lib/connection/hermes_response.js +++ b/lib/connection/hermes_response.js @@ -150,7 +150,7 @@ HermesResponse.prototype.parse = function(data) { this.result = new Buffer(data.result[1], 'base64'); } - if (this.result && this.request.responseSchema) + 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); From 6b7a9c199c7e331e8ab558577722999037e25437 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Thu, 20 Feb 2014 01:20:35 +1100 Subject: [PATCH 062/108] connection: add subscription --- lib/connection/connection.js | 25 ++++++- lib/connection/subscription.js | 128 +++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 lib/connection/subscription.js diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 48de742..0e3861e 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -11,6 +11,7 @@ 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'); @@ -41,6 +42,7 @@ function SpotifyConnection(spotify) { this._spotify = spotify; this._heartbeatId = null; this._callbacks = Object.create(null); + this._subscriptions = Object.create(null); this._requestQueueFlushId = null; // initalise public instance variables @@ -60,6 +62,7 @@ function SpotifyConnection(spotify) { 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); @@ -68,7 +71,7 @@ inherits(SpotifyConnection, EventEmitter); * Re-export namespaces */ -util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse]); +util.export(SpotifyConnection, [Request, Response, HermesRequest, HermesResponse, Subscription]); /** * WebSocket "open" event handler @@ -209,6 +212,26 @@ SpotifyConnection.prototype._onflush = function() { this._requestQueueFlushId = null; }; +/** + * Handles a "message" command. + * + * @api private + */ + +Spotify.prototype._onmessagecommand = function (command, args) { + if ('hm_b64' == command) { + var header = new HermesResponse(); + header.parse(args[1]); + this._subscriptions.forEach(function(subscription) { + if (subscription.uri == header.uri) { + var response = new HermesResponse(subscription); + response.parse(args[1]); + subscription.emit('response', response); + } + }); + } +}; + /** * Start the interval that sends and "sp/echo" command to the Spotify server * every 180 seconds. diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js new file mode 100644 index 0000000..239e60c --- /dev/null +++ b/lib/connection/subscription.js @@ -0,0 +1,128 @@ +/** + * Module dependencies. + */ + +var SpotifyConnection = require('./connection'); +var HermesResponse = require('./hermes_response'); +var EventEmitter = require('events').EventEmitter; +var inherits = require('util').inherits; +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, %j)', name, args); + 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.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, subscription) { + +}; + +/** + * 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() { + if (!this.subscribeHandler) throw new Error("Subscribe Handler not set"); + var subscriptions = this._connection._subscriptions; + if (-1 === subscriptions.indexOf(this)) subscriptions.push(this); + for (var i = 0, l = subscriptions.length; i < l; i++) { + var subscription = subscriptions[i]; + if (subscription != this && subscriptions[i].uri == this.uri) return; + } + 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 first active subscription for this uri + */ +Subscription.prototype.unsubscribe = function() { + if (!this.unsubscribeHandler) throw new Error("Unsubscribe Handler not set"); + var subscriptions = this._connection._subscriptions; + var idx; + if (-1 !== (idx = subscriptions.indexOf(this))) + subscriptions.splice(idx, 1); + for (var i = 0, l = subscriptions.length; i < l; i++) { + if (subscriptions[i].uri == this.uri) return; + } + this.unsubscribeHandler(this._onunsubscribed.bind(this)); +}; From 1280e1154705b53fa345071eac51db94da1ae97c Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Thu, 20 Feb 2014 20:37:46 +1100 Subject: [PATCH 063/108] connection: fix typo --- lib/connection/connection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 0e3861e..9c08c6b 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -218,7 +218,7 @@ SpotifyConnection.prototype._onflush = function() { * @api private */ -Spotify.prototype._onmessagecommand = function (command, args) { +SpotifyConnection.prototype._onmessagecommand = function (command, args) { if ('hm_b64' == command) { var header = new HermesResponse(); header.parse(args[1]); From 2fc711ae4f7826035e06bf315e1a844d33eaef92 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 10:09:46 +1100 Subject: [PATCH 064/108] spotify: make Spotify#get backwards compatible, return single objects --- lib/spotify.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 0db2c2d..de006f3 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -644,9 +644,16 @@ Spotify.prototype.disconnect = function () { /** * 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 invoked once for each uri - * @return {Array} + * @return {Array|Object} * @api public */ @@ -654,9 +661,9 @@ Spotify.prototype.get = Spotify.prototype.metadata = function (uris, fn) { debug('metadata(%j)', uris); - if (!Array.isArray(uris)) { - uris = [ uris ]; - } + // 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]; fn = util.wrapCallback(fn, this); @@ -672,10 +679,17 @@ Spotify.prototype.metadata = function (uris, fn) { } catch (e) { err = e; } - process.nextTick(fn.bind(null, err, object)); + + if (err) { + process.nextTick(fn.bind(null, err)); + } else { + // for backwards compatibility we load in the metadata of the object before calling back + object.get(fn); + } + return object; }, this); - return objects; + return (returnArray) ? objects : objects[0]; }; /** From 46dcde08d6c307a80ea522a806df80dd41796d82 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 10:30:54 +1100 Subject: [PATCH 065/108] metadata: move _acceptsSchema method onto class --- lib/metadata/album.js | 12 +++++++++++- lib/metadata/artist.js | 13 ++++++++++++- lib/metadata/metadata.js | 32 ++++++++++++++++---------------- lib/metadata/track.js | 13 ++++++++++++- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/lib/metadata/album.js b/lib/metadata/album.js index 6a8b0a8..9f07006 100644 --- a/lib/metadata/album.js +++ b/lib/metadata/album.js @@ -29,6 +29,16 @@ exports = module.exports = Album; 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. * @@ -37,10 +47,10 @@ Album.get = util.bind(Metadata.get, null, Album); function Album (spotify, uri, parent) { if (!(this instanceof Album)) return new Album(spotify, uri, parent); - this.acceptedSchemas = Album.acceptedSchemas; 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 index 9a478d6..0826f3c 100644 --- a/lib/metadata/artist.js +++ b/lib/metadata/artist.js @@ -29,6 +29,16 @@ exports = module.exports = Artist; 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. * @@ -37,9 +47,10 @@ Artist.get = util.bind(Metadata.get, null, Artist); function Artist (spotify, uri, parent) { if (!(this instanceof Artist)) return new Artist(spotify, uri, parent); - this.acceptedSchemas = Artist.acceptedSchemas; 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/metadata.js b/lib/metadata/metadata.js index 61b44f7..4335bf3 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -62,6 +62,18 @@ Metadata.get = function(type, spotify, uri, fn) { }; 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 * @@ -148,11 +160,9 @@ function Metadata (spotify, uri, parent) { // 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) { - for (var i = 0, l = this.acceptedSchemas.length; i < l; i++) { - if (uri instanceof this.acceptedSchemas[i]) { - this._update(uri, true); - return this; // constructor - } + if (this._acceptsSchema(uri)) { + this._update(uri, true); + return this; // constructor } } @@ -170,17 +180,6 @@ var Track = require('./track'); util.export(Metadata, [Album, Artist, Track]); -/** - * Check whether the class supports construction from a specific schema/object - * - * @param {Object} schema - * @return {Boolean} - * @api private - */ -Metadata.prototype._acceptsSchema = function(schema) { - return (schema instanceof Metadata.schemas[this.type]); -}; - /** * Update the Metadata instance with the properties of another object * @@ -200,6 +199,7 @@ Metadata.prototype._update = function(obj, partial) { Object.keys(obj).forEach(function (key) { if (!self.hasOwnProperty(key)) { + console.log('objectifying %s', key); self[key] = spotify._objectify(obj[key]); } }); diff --git a/lib/metadata/track.js b/lib/metadata/track.js index 8436f7e..d8cdd4d 100644 --- a/lib/metadata/track.js +++ b/lib/metadata/track.js @@ -49,6 +49,16 @@ const previewUrlBase = 'http://d318706lgtcm8e.cloudfront.net/mp3-preview/'; 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. * @@ -57,7 +67,6 @@ Track.get = util.bind(Metadata.get, null, Track); function Track (spotify, uri, parent) { if (!(this instanceof Track)) return new Track(spotify, uri, parent); - this.acceptedSchemas = Track.acceptedSchemas; this.playSession = null; this.type = 'track'; @@ -71,6 +80,8 @@ Track['$inject'] = ['Spotify']; */ 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. * From 18c0d627dbb802ccc9deef981da45ae9cccd89cc Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 10:36:11 +1100 Subject: [PATCH 066/108] subscription: fix debug arguments --- lib/connection/subscription.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js index 239e60c..13b9bc5 100644 --- a/lib/connection/subscription.js +++ b/lib/connection/subscription.js @@ -23,7 +23,7 @@ module.exports = Subscription; */ function Subscription(connection, uri) { - debug('Subscription(%j, %j)', name, args); + debug('Subscription(uri)', uri); if (!(this instanceof Subscription)) return new Subscription(connection); if ('object' != typeof connection || !(connection instanceof SpotifyConnection.constructor)) From 49ef2290f016da565bd6ebefebde1ce5a20dd665 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 10:36:47 +1100 Subject: [PATCH 067/108] remove some logspew --- lib/schemas.js | 7 +------ lib/util.js | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/schemas.js b/lib/schemas.js index ff21ebf..ec99cb1 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -38,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]; @@ -74,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/util.js b/lib/util.js index 602227a..13e45dd 100644 --- a/lib/util.js +++ b/lib/util.js @@ -68,7 +68,6 @@ exports.export = function(object, namespaces) { var argumentIndex; Object.defineProperty(object.prototype, name, { get: function() { - debug('get(%j)', name); if (!this[privateName]) { // bind the constructor debug('attempting bind of %s.%s()', objectName, name); From 890072a24f92a6e2d0b3088f903e7e12e2f667f2 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 11:09:04 +1100 Subject: [PATCH 068/108] connection: make subscriptions an array --- lib/connection/connection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 9c08c6b..8eb25e6 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -42,7 +42,7 @@ function SpotifyConnection(spotify) { this._spotify = spotify; this._heartbeatId = null; this._callbacks = Object.create(null); - this._subscriptions = Object.create(null); + this._subscriptions = []; this._requestQueueFlushId = null; // initalise public instance variables From aa23a59ef0fb5a4a89d3c5715885e0b665af80e5 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 11:10:44 +1100 Subject: [PATCH 069/108] metadata: remove logging --- lib/metadata/metadata.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/metadata/metadata.js b/lib/metadata/metadata.js index 4335bf3..a9b62c5 100644 --- a/lib/metadata/metadata.js +++ b/lib/metadata/metadata.js @@ -199,7 +199,6 @@ Metadata.prototype._update = function(obj, partial) { Object.keys(obj).forEach(function (key) { if (!self.hasOwnProperty(key)) { - console.log('objectifying %s', key); self[key] = spotify._objectify(obj[key]); } }); From ca983f6312b4755c05a8b5041c3a1f6386344770 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Sat, 22 Feb 2014 11:11:27 +1100 Subject: [PATCH 070/108] Add initial prototype of Playlist classes --- lib/playlist/attributes.js | 35 ++++ lib/playlist/contents.js | 49 +++++ lib/playlist/index.js | 6 + lib/playlist/item.js | 21 +++ lib/playlist/playlist.js | 367 +++++++++++++++++++++++++++++++++++++ lib/playlist/revision.js | 52 ++++++ lib/spotify.js | 3 +- 7 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 lib/playlist/attributes.js create mode 100644 lib/playlist/contents.js create mode 100644 lib/playlist/index.js create mode 100644 lib/playlist/item.js create mode 100644 lib/playlist/playlist.js create mode 100644 lib/playlist/revision.js diff --git a/lib/playlist/attributes.js b/lib/playlist/attributes.js new file mode 100644 index 0000000..3229471 --- /dev/null +++ b/lib/playlist/attributes.js @@ -0,0 +1,35 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistAttributes; + +/** + * PlaylistAttributes class. + * + * @api public + */ + +function PlaylistAttributes(playlist) { + this.playlist = playlist; + this.revision = null; +} +PlaylistAttributes['$inject'] = ['Playlist']; + +/** + * 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]; + }); +}; diff --git a/lib/playlist/contents.js b/lib/playlist/contents.js new file mode 100644 index 0000000..6edd3fa --- /dev/null +++ b/lib/playlist/contents.js @@ -0,0 +1,49 @@ + +/** + * 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 contents = this; + 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 + data.contents.items.forEach(function(item) { + contents.push(new PlaylistItem(item.uri, item.attributes)); + }); +}; 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..894564b --- /dev/null +++ b/lib/playlist/item.js @@ -0,0 +1,21 @@ + +/** + * Module exports. + */ + +module.exports = PlaylistItem; + +/** + * PlaylistItem class. + * + * @api public + */ + +function PlaylistItem(playlist, uri, attributes) { + this.playlist = playlist; + this.attributes = attributes || {}; + + var spotify = this.playlist._spotify; + this.item = spotify.get(uri); +} +PlaylistItem['$inject'] = ['Playlist']; diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js new file mode 100644 index 0000000..2ea7b07 --- /dev/null +++ b/lib/playlist/playlist.js @@ -0,0 +1,367 @@ + +/** + * Module dependencies. + */ + +var schemas = require('../schemas'); +var util = require('../util'); +var SpotifyUri = require('../uri'); +var PlaylistAttributes = require('./attributes'); +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('pubsub', 'Subscription'); +var CreateListReply = schemas.build('playlist4', 'CreateListReply'); +var ModifyReply = schemas.build('playlist4', 'ModifyReply'); + +/** + * 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}; + } + debug('create(%j)', attributes); + + var requestArgs = { + ops: [{ + kind: 'UPDATE_LIST_ATTRIBUTES', + updateListAttributes: { + newAttributes: attributes + } + }] + }; + + var HermesRequest = this._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); + debug('playlist created - uri = %s , revision = %s', res.result.uri, new this.PlaylistRevision(res.result.revision)); + + // TODO(adammw): add item to the user's rootlist + + return new Playlist(spotify, res.result.uri); + }); +}; +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); + this.on('newListener', this._subscribeIfListeners.bind(this)); + this.on('removeListener', this._subscribeIfListeners.bind(this)); + this.contents.on('newListener', this._subscribeIfListeners.bind(this)); + this.contents.on('removeListener', this._subscribeIfListeners.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) + + // validate and parse uri + if (!uri) throw new Error('Invalid uri specified'); + if ('string' == typeof uri) uri = new SpotifyUri(uri); + if (!(uri instanceof SpotifyUri) || 'playlist' != uri.type) throw new Error('Invalid URI type'); + + this._hm_uri = 'hm://playlist/user/' + uri.user + '/playlist/' + uri.sid; + // TODO(adammw): support spotify:user:xxx:rootlist -> hm://playlist/user/xxx/rootlist + // and spotify:user:xxx:starred -> hm://playlist/user/xxx/starred + + 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.on('response', this._onsubscriptionresponse.bind(this)); + + this.uri = uri; +} +inherits(Playlist, EventEmitter); +Playlist['$inject'] = ['Spotify']; + +/** + * Re-export namespaces + */ +util.export(Playlist, [ PlaylistContents, PlaylistItem, PlaylistRevision, PlaylistAttributes ]); + +/** + * Count the number of listeners that would require a subscription + * + * @return {Number} + */ +Playlist.prototype._listenerCount = function() { + var count = 0; + this.contents._subscriptionEvents.forEach(function(event) { + count += this.contents.listeners(event).length; + }, this); + count += this.listeners('change').length; + return count; +}; + +/** + * Subscribe or unsubscribe depending on the number of listeners + */ +Playlist.prototype._subscribeIfListeners = function(event, listener) { + if ('newListener' == event || 'removeListener' == event) return; + if (this._listenerCount()) { + this.subscribe(); + } else { + this.unsubscribe(); + } +}; + +/** + * 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 + */ +Playlist.prototype._onsubscriptionresponse = function(response) { + // TODO(adammw) + debug('unhandled change: %j', response.result); +}; + +/** + * 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 + 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); + }); +}; + +/** + * 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) + + var PlaylistContents = this.PlaylistContents; + this._request(function(err, res) { + if (err) return fn(err); + var contents = new PlaylistContents(); + try { + contents.parse(res.result); + } catch(e) { + return fn(e); + } + // TODO(adammw): add to _contentsCache and ensure cache does not grow too big and stay around forever + fn(null, contents); + }); +}; + +/* + * Make `playlist.contents` an EventEmitter + */ +Playlist.prototype.contents.__proto__ = EventEmitter.prototype; +Playlist.prototype.contents._subscriptionEvents = ['add', 'mov', 'rem', 'mod', 'change']; + +/** + * Gets the latest revision from the server + * + * @param {Function} fn callback function + */ +Playlist.prototype.latestRevision = function(fn) { + var PlaylistRevision = this.PlaylistRevision; + this._request('HEAD', function(err, res) { + if (err) return fn(err); + var revision = new PlaylistRevision(res.result.revision); + fn(null, revision); + }); +}; + +/** + * 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/spotify.js b/lib/spotify.js index de006f3..ae77417 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -12,6 +12,7 @@ var inherits = require('util').inherits; 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'); @@ -121,7 +122,7 @@ Spotify.$provides = {'SpotifyConnection': 'connection'}; * Re-export all sub-classes */ -[SpotifyConnection, SpotifyUri, Metadata, User].forEach(util.recursiveExport, Spotify); +[SpotifyConnection, SpotifyUri, Metadata, Playlist, User].forEach(util.recursiveExport, Spotify); /** * User info getters From 08a877c6964d5f61d56b7a4292b6f99ea9f1d0e8 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 24 Feb 2014 19:25:43 +1100 Subject: [PATCH 071/108] connection: fix subscription header parsing --- lib/connection/connection.js | 6 +++--- lib/connection/hermes_response.js | 10 +++++++--- lib/util.js | 6 ++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/connection/connection.js b/lib/connection/connection.js index 8eb25e6..b7a9f5b 100644 --- a/lib/connection/connection.js +++ b/lib/connection/connection.js @@ -221,11 +221,11 @@ SpotifyConnection.prototype._onflush = function() { SpotifyConnection.prototype._onmessagecommand = function (command, args) { if ('hm_b64' == command) { var header = new HermesResponse(); - header.parse(args[1]); + header.parse(args.slice(1)); this._subscriptions.forEach(function(subscription) { - if (subscription.uri == header.uri) { + if (util.checkUri(subscription.uri, header.uri)) { var response = new HermesResponse(subscription); - response.parse(args[1]); + response.parse(args.slice(1)); subscription.emit('response', response); } }); diff --git a/lib/connection/hermes_response.js b/lib/connection/hermes_response.js index 4845e8f..ec4d6a0 100644 --- a/lib/connection/hermes_response.js +++ b/lib/connection/hermes_response.js @@ -127,7 +127,11 @@ HermesResponse.prototype.parse = function(data) { // general case } else { - var header = MercuryRequest.parse(new Buffer(data.result[0], 'base64')); + if (data.result) { + data = data.result; + } + + var header = MercuryRequest.parse(new Buffer(data[0], 'base64')); this.uri = header.uri; this.contentType = header.contentType; @@ -146,8 +150,8 @@ HermesResponse.prototype.parse = function(data) { }); } - if (data.result.length > 1) - this.result = new Buffer(data.result[1], 'base64'); + if (data.length > 1) + this.result = new Buffer(data[1], 'base64'); } if (this.result && this.request && this.request.responseSchema) diff --git a/lib/util.js b/lib/util.js index 13e45dd..dd34c42 100644 --- a/lib/util.js +++ b/lib/util.js @@ -189,6 +189,12 @@ exports.bind = function(fn) { 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; +}; /** * Helper function when callbacks need to be defered until something else can be completed. From 55ca9aa9769c5bbd39e7efb136c1443bef801a78 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Tue, 25 Feb 2014 20:23:20 +1100 Subject: [PATCH 072/108] subscription tweaks --- lib/connection/subscription.js | 23 ++++++++++++++++++----- lib/playlist/playlist.js | 2 +- lib/schemas.js | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js index 13b9bc5..edf43d2 100644 --- a/lib/connection/subscription.js +++ b/lib/connection/subscription.js @@ -6,6 +6,7 @@ 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'); /** @@ -23,7 +24,7 @@ module.exports = Subscription; */ function Subscription(connection, uri) { - debug('Subscription(uri)', uri); + debug('Subscription(%j)', uri); if (!(this instanceof Subscription)) return new Subscription(connection); if ('object' != typeof connection || !(connection instanceof SpotifyConnection.constructor)) @@ -31,6 +32,7 @@ function Subscription(connection, uri) { EventEmitter.call(this); this._connection = connection; + this._subscription = null; this.subscribeHandler = null; this.unsubscribeHandler = null; this.responseSchema = null; @@ -44,8 +46,10 @@ inherits(Subscription, EventEmitter); * * @api private */ -Subscription.prototype._onsubscribed = function(err, subscription) { - +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; }; /** @@ -101,6 +105,7 @@ Subscription.prototype.subscribed = function() { * 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)) subscriptions.push(this); @@ -113,16 +118,24 @@ Subscription.prototype.subscribe = function() { /** * Remove the subscription to the connection's list of active subscriptions - * and call the unsubscribe handler if it's the first active subscription for this uri + * 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))) + if (-1 === (idx = subscriptions.indexOf(this))) { + return; + } else { subscriptions.splice(idx, 1); + } for (var i = 0, l = subscriptions.length; i < l; i++) { if (subscriptions[i].uri == this.uri) return; } this.unsubscribeHandler(this._onunsubscribed.bind(this)); }; + +Subscription.prototype.toString = function() { + return format('', this.uri); +}; diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 2ea7b07..4ff69d7 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -29,7 +29,7 @@ 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('pubsub', 'Subscription'); +var Subscription = schemas.build('hermes.pubsub', 'Subscription'); var CreateListReply = schemas.build('playlist4', 'CreateListReply'); var ModifyReply = schemas.build('playlist4', 'ModifyReply'); diff --git a/lib/schemas.js b/lib/schemas.js index ec99cb1..7cd73e5 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -29,7 +29,7 @@ var packageMapping = { metadata: 'metadata', presence: 'presence', playlist4: "playlist4changes,playlist4content,playlist4issues,playlist4meta,playlist4ops,playlist4service".split(","), - pubsub: 'pubsub', + 'hermes.pubsub': 'pubsub', social: 'social', toplist: 'toplist' }; From 1f7e4c20ddfaeab5258c3bad4147e37c7bcc402f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Tue, 25 Feb 2014 20:23:49 +1100 Subject: [PATCH 073/108] hermes_request: add `toString` method to make debugging easier --- lib/connection/hermes_request.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/connection/hermes_request.js b/lib/connection/hermes_request.js index 06443d4..cdcd8fc 100644 --- a/lib/connection/hermes_request.js +++ b/lib/connection/hermes_request.js @@ -273,3 +273,12 @@ HermesRequest.prototype.callback = function(err, res){ 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); +}; From c19ed0f40f5fafeb74f8d7d1c8eb32be30caad70 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Tue, 25 Feb 2014 21:35:54 +1100 Subject: [PATCH 074/108] subscription: tweak subscribe/unsubscribe again --- lib/connection/subscription.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js index edf43d2..ca7b403 100644 --- a/lib/connection/subscription.js +++ b/lib/connection/subscription.js @@ -108,7 +108,8 @@ 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)) subscriptions.push(this); + if (-1 !== subscriptions.indexOf(this)) return; + subscriptions.push(this); for (var i = 0, l = subscriptions.length; i < l; i++) { var subscription = subscriptions[i]; if (subscription != this && subscriptions[i].uri == this.uri) return; @@ -125,11 +126,8 @@ Subscription.prototype.unsubscribe = function() { if (!this.unsubscribeHandler) throw new Error("Unsubscribe Handler not set"); var subscriptions = this._connection._subscriptions; var idx; - if (-1 === (idx = subscriptions.indexOf(this))) { - return; - } else { - subscriptions.splice(idx, 1); - } + if (-1 === (idx = subscriptions.indexOf(this))) return; + subscriptions.splice(idx, 1); for (var i = 0, l = subscriptions.length; i < l; i++) { if (subscriptions[i].uri == this.uri) return; } From 3260103570feb7535ea62c904f5a9fd14583182c Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Tue, 25 Feb 2014 22:56:25 +1100 Subject: [PATCH 075/108] subscription: add logging statements --- lib/connection/subscription.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/connection/subscription.js b/lib/connection/subscription.js index ca7b403..8eba6b1 100644 --- a/lib/connection/subscription.js +++ b/lib/connection/subscription.js @@ -108,12 +108,17 @@ 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)) return; + 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 && subscriptions[i].uri == this.uri) return; + 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)); }; @@ -126,11 +131,16 @@ Subscription.prototype.unsubscribe = function() { if (!this.unsubscribeHandler) throw new Error("Unsubscribe Handler not set"); var subscriptions = this._connection._subscriptions; var idx; - if (-1 === (idx = subscriptions.indexOf(this))) return; + 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)); }; From e84fae5e95c3ac9a44af267d23028ee95271e453 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Tue, 25 Feb 2014 22:58:04 +1100 Subject: [PATCH 076/108] playlist: subscription/event handling --- lib/playlist/playlist.js | 199 ++++++++++++++++++++++++++++++++++----- lib/util.js | 14 +++ 2 files changed, 188 insertions(+), 25 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 4ff69d7..301b6b3 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -32,6 +32,7 @@ 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, @@ -117,14 +118,18 @@ function Playlist (spotify, uri) { // initalise event emitters EventEmitter.call(this); EventEmitter.call(this.contents); - this.on('newListener', this._subscribeIfListeners.bind(this)); - this.on('removeListener', this._subscribeIfListeners.bind(this)); - this.contents.on('newListener', this._subscribeIfListeners.bind(this)); - this.contents.on('removeListener', this._subscribeIfListeners.bind(this)); + 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'); @@ -138,7 +143,10 @@ function Playlist (spotify, uri) { 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; } @@ -150,32 +158,69 @@ Playlist['$inject'] = ['Spotify']; */ util.export(Playlist, [ PlaylistContents, PlaylistItem, PlaylistRevision, PlaylistAttributes ]); -/** - * Count the number of listeners that would require a subscription - * - * @return {Number} - */ -Playlist.prototype._listenerCount = function() { +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; - this.contents._subscriptionEvents.forEach(function(event) { - count += this.contents.listeners(event).length; + Playlist._diffEvents.forEach(function(countEvent) { + count += this.contents.listeners(countEvent).length; }, this); - count += this.listeners('change').length; - return count; + + 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(); }; /** - * Subscribe or unsubscribe depending on the number of listeners + * Perform a DIFF */ -Playlist.prototype._subscribeIfListeners = function(event, listener) { - if ('newListener' == event || 'removeListener' == event) return; - if (this._listenerCount()) { - this.subscribe(); - } else { - this.unsubscribe(); +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) { + // TODO(adammw): fire relevant events + console.log('diff', diff); + }); +}; + +/** + * 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 * @@ -208,10 +253,16 @@ Playlist.prototype._request = function(method, args, fn) { /** * When a Playlist Subscription callback is invoked this function is called + * + * @param {HermesResponse} response */ Playlist.prototype._onsubscriptionresponse = function(response) { // TODO(adammw) - debug('unhandled change: %j', response.result); + 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); }; /** @@ -278,6 +329,39 @@ Playlist.prototype.attributes = function(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; + 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) { + if ('ADD' == op.kind || 'REM' == op.kind) { + op.items = op.items.map(function(item) { + return this.PlaylistItem(item.uri, item.attributes); + }, this); + } + }, this); + fn(null, diff); + }); +}; + /** * Get the playlist contents * @@ -316,23 +400,88 @@ Playlist.prototype.contents = function(offset, length, fn) { /* * Make `playlist.contents` an EventEmitter */ -Playlist.prototype.contents.__proto__ = EventEmitter.prototype; -Playlist.prototype.contents._subscriptionEvents = ['add', 'mov', 'rem', 'mod', 'change']; +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.latestRevision = function(fn) { +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(); +}; + /** * Deletes the Playlist represented by the Playlist instance on the server. * diff --git a/lib/util.js b/lib/util.js index dd34c42..c3afc9e 100644 --- a/lib/util.js +++ b/lib/util.js @@ -4,6 +4,7 @@ */ var SpotifyUri = require('./uri'); +var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web:util'); /** @@ -215,3 +216,16 @@ exports.deferCallback = function(fn, cb) { 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]; + }); +}; From 8705061af31ca62ed9528e0061aaf646b894f703 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 7 Mar 2014 19:31:57 +1100 Subject: [PATCH 077/108] Expose playlist changes --- lib/playlist/change.js | 25 +++++++++++++++++++++++++ lib/playlist/playlist.js | 35 ++++++++++++++++++++++------------- 2 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 lib/playlist/change.js 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/playlist.js b/lib/playlist/playlist.js index 301b6b3..8a75f5a 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -7,6 +7,7 @@ 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'); @@ -156,7 +157,7 @@ Playlist['$inject'] = ['Spotify']; /** * Re-export namespaces */ -util.export(Playlist, [ PlaylistContents, PlaylistItem, PlaylistRevision, PlaylistAttributes ]); +util.export(Playlist, [ PlaylistAttributes, PlaylistChange, PlaylistContents, PlaylistItem, PlaylistRevision ]); Playlist.prototype._onNewListener = function(event, listener) { if ('change' != event) return; @@ -192,10 +193,14 @@ Playlist.prototype._onRemoveListener = function(event, listener) { 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) { - // TODO(adammw): fire relevant events - console.log('diff', diff); - }); + this.diff(oldRevision, (function(err, diff) { + 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)); }; /** @@ -346,20 +351,24 @@ Playlist.prototype.diff = function(revision, fn) { // if ('string' != typeof revision && !(revision instanceof PlaylistRevision)) // return fn(new Error('Invalid revision')); - this._request('DIFF', { revision: revision.toString() }, function(err, res) { + 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) { - if ('ADD' == op.kind || 'REM' == op.kind) { - op.items = op.items.map(function(item) { - return this.PlaylistItem(item.uri, item.attributes); + 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) { + return this.PlaylistItem(item.uri, item.attributes); + }, this); }, this); - } - }, this); + }, this); + } fn(null, diff); - }); + }).bind(this)); }; /** From 358e8574e2276764706754da599e25c34847e487 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 7 Mar 2014 20:01:18 +1100 Subject: [PATCH 078/108] playlist: bugfix --- lib/playlist/item.js | 2 ++ lib/playlist/playlist.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/playlist/item.js b/lib/playlist/item.js index 894564b..8a7570d 100644 --- a/lib/playlist/item.js +++ b/lib/playlist/item.js @@ -12,6 +12,8 @@ module.exports = PlaylistItem; */ function PlaylistItem(playlist, uri, attributes) { + if (!(this instanceof PlaylistItem)) return new PlaylistItem(playlist, uri, attributes); + this.playlist = playlist; this.attributes = attributes || {}; diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 8a75f5a..ecf4959 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -362,7 +362,7 @@ Playlist.prototype.diff = function(revision, fn) { ['add', 'rem'].forEach(function(kind) { if (!op[kind] || !Array.isArray(op[kind].items)) return; op[kind].items = op[kind].items.map(function(item) { - return this.PlaylistItem(item.uri, item.attributes); + return new this.PlaylistItem(item.uri, item.attributes); }, this); }, this); }, this); From 8a77a7b639c254cab9978e46fe5d3f9245e1d752 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 10 Mar 2014 12:53:36 +1100 Subject: [PATCH 079/108] spotify: only load metadata when needed --- lib/spotify.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index ae77417..fa7be2a 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -654,18 +654,20 @@ Spotify.prototype.disconnect = function () { * * @param {Array|String} uris A single URI, or an Array of URIs to get "metadata" for * @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); // 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 types = ['track', 'artist', 'album']; @@ -683,7 +685,7 @@ Spotify.prototype.metadata = function (uris, fn) { if (err) { process.nextTick(fn.bind(null, err)); - } else { + } else if (loadMetadata) { // for backwards compatibility we load in the metadata of the object before calling back object.get(fn); } From 642026ddfbbc3ede6e77b84574146a95e1216cae Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 10 Mar 2014 12:54:18 +1100 Subject: [PATCH 080/108] spotify: don't throw on unhandled commands --- lib/spotify.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index fa7be2a..b0844ec 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -422,8 +422,7 @@ Spotify.prototype._onmessagecommand = function (command, args) { // ignore... } else { // unhandled message - console.error(command, args); - throw new Error('TODO: implement!'); + debug('unhandled %j command, args: %j', command, args); } }; From 62e1bae85586e8c9bddb333f530ccad89087f515 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 10 Mar 2014 12:55:47 +1100 Subject: [PATCH 081/108] playlist: fix bug in Playlist.create --- lib/playlist/playlist.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index ecf4959..5222732 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -101,8 +101,8 @@ Playlist.create = function(spotify, attributes, fn) { debug('playlist created - uri = %s , revision = %s', res.result.uri, new this.PlaylistRevision(res.result.revision)); // TODO(adammw): add item to the user's rootlist - - return new Playlist(spotify, res.result.uri); + var playlist = new Playlist(spotify, res.result.uri); + fn(null, playlist); }); }; Playlist.create['$inject'] = ['Spotify']; From 1b33cc5b639d3b4fa12c74e62157043b6856a3a6 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 10 Mar 2014 13:23:17 +1100 Subject: [PATCH 082/108] contents: handle empty playlists --- lib/playlist/contents.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/playlist/contents.js b/lib/playlist/contents.js index 6edd3fa..dafa3e4 100644 --- a/lib/playlist/contents.js +++ b/lib/playlist/contents.js @@ -43,7 +43,9 @@ PlaylistContents.prototype.parse = function(data) { this.truncated = data.contents.truncated; // convert items into PlaylistItem objects and add to our internal array - data.contents.items.forEach(function(item) { - contents.push(new PlaylistItem(item.uri, item.attributes)); - }); + if (data.contents.items && data.contents.items.length) { + data.contents.items.forEach(function(item) { + contents.push(new PlaylistItem(item.uri, item.attributes)); + }); + } }; From c2cc7ec238b9c31408b90af6d7d43dc03648a6fe Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Mon, 10 Mar 2014 13:23:30 +1100 Subject: [PATCH 083/108] playlist: more Playlist.create fixes --- lib/playlist/playlist.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 5222732..0a3ac4d 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -87,21 +87,22 @@ Playlist.create = function(spotify, attributes, fn) { ops: [{ kind: 'UPDATE_LIST_ATTRIBUTES', updateListAttributes: { - newAttributes: attributes + newAttributes: { values: attributes } } }] }; - var HermesRequest = this._spotify.HermesRequest; + 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); - debug('playlist created - uri = %s , revision = %s', res.result.uri, new this.PlaylistRevision(res.result.revision)); + debug('playlist created - uri = %s', res.result.uri); // TODO(adammw): add item to the user's rootlist - var playlist = new Playlist(spotify, res.result.uri); + var playlist = new Playlist(spotify, res.result.uri.toString()); + playlist._revision = new playlist.PlaylistRevision(res.result.revision); fn(null, playlist); }); }; From b65c445c0ac16e07619144757387394504028a72 Mon Sep 17 00:00:00 2001 From: John Chapman Date: Wed, 2 Apr 2014 19:51:02 -0400 Subject: [PATCH 084/108] Added Spotify.Web.App.initialize() noop Function Recently Spotify Web started calling the initialize() function in the authentication script which causes an exception to be thrown while connecting node-spotify-web. This NOOP operation allows authentication to complete successfully. Conflicts: lib/spotify.js --- lib/spotify.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index b0844ec..53bbd49 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -249,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 */ @@ -314,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); @@ -486,7 +486,7 @@ Spotify.prototype.sendCommand = function (name, args, 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 @@ -497,7 +497,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 = { @@ -774,14 +774,14 @@ Spotify.prototype.rootlist = function (user, from, length, fn) { /** * Retrieve suggested similar tracks to the given track URI - * + * * @param {String} uri track uri * @param {Function} fn callback function * @api public */ Spotify.prototype.similar = function(uri, fn) { - debug('similar(%j)', uri); + debug('similar(%j)', uri); var parts = uri.split(':'); var type = parts[1]; From b78e3d72efddbde27ceecadbcaff177b66c88da7 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 11:22:10 +1000 Subject: [PATCH 085/108] Add initial support for special playlists --- lib/playlist/playlist.js | 12 ++++++++---- lib/uri.js | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 0a3ac4d..fc44077 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -136,11 +136,15 @@ function Playlist (spotify, uri) { // validate and parse uri if (!uri) throw new Error('Invalid uri specified'); if ('string' == typeof uri) uri = new SpotifyUri(uri); - if (!(uri instanceof SpotifyUri) || 'playlist' != uri.type) throw new Error('Invalid URI type'); + 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 + '/playlist/' + uri.sid; - // TODO(adammw): support spotify:user:xxx:rootlist -> hm://playlist/user/xxx/rootlist - // and spotify:user:xxx:starred -> hm://playlist/user/xxx/starred + 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)); diff --git a/lib/uri.js b/lib/uri.js index 1500a75..d2d6369 100644 --- a/lib/uri.js +++ b/lib/uri.js @@ -12,6 +12,8 @@ var debug = require('debug')('spotify-web:uri'); module.exports = SpotifyUri; +SpotifyUri.PLAYLIST_TYPES = ['playlist', 'starred', 'rootlist', 'publishedrootlist']; + /** * Create a new SpotifyUri instance from a given uri type and gid * @@ -101,9 +103,9 @@ Object.defineProperty(SpotifyUri.prototype, 'type', { } else if (len >= 5) { // e.g. spotify:user:tootallnate:[playlist]:0Lt5S4hGarhtZmtz7BNTeX return parts[3]; - } else if (len >= 4 && 'starred' == parts[3]) { + } else if (len >= 4 && SpotifyUri.PLAYLIST_TYPES.indexOf(parts[3]) !== -1) { // e.g. spotify:user:tootallnate:starred - return 'playlist'; + return parts[3]; } else if (len >= 3) { // e.g. spotify:[track]:6tdp8sdXrXlPV6AZZN2PE8 return parts[1]; @@ -201,7 +203,7 @@ Object.defineProperty(SpotifyUri.prototype, 'user', { */ SpotifyUri.prototype.toString = function() { return this.uri; -} +}; /** * Converts a GID Buffer to an ID hex string. From d44c6a41b79c73e0aff4b37c9492ec667f6d17c4 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 11:22:52 +1000 Subject: [PATCH 086/108] Add more supported types to Spotify#get --- lib/spotify.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 53bbd49..80d8e22 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -669,15 +669,31 @@ Spotify.prototype.metadata = function (uris, fn, loadMetadata) { loadMetadata = ('undefined' !== typeof loadMetadata) ? loadMetadata : ('function' == typeof fn); fn = util.wrapCallback(fn, this); - var types = ['track', 'artist', 'album']; + 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 objects = uris.map(function (uri) { - var type = SpotifyUri.uriType(uri); - if (-1 == types.indexOf(type)) return null; - var typeName = type.charAt(0).toUpperCase() + type.substring(1).toLowerCase(); + uri = new SpotifyUri(uri); + + if (!(uri.type in typeMapping)) { + debug('unhandled uri type: %s', uri.type); + return { + uri: uri, + type: uri.type + }; + } + var object = null; var err = null; try { - object = new this[typeName](uri); + object = new typeMapping[uri.type](uri); } catch (e) { err = e; } From c618a7067769c510dd8ed53457f4cd220dfb794e Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 11:32:57 +1000 Subject: [PATCH 087/108] Remove Spotify#playlist, modify Spotify#rootlist --- lib/spotify.js | 76 +++++--------------------------------------------- 1 file changed, 7 insertions(+), 69 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 80d8e22..665d6e1 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -711,81 +711,19 @@ Spotify.prototype.metadata = function (uris, fn, loadMetadata) { }; /** - * Gets the metadata from a Spotify "playlist" URI. + * Gets a user's stored playlists * - * @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 + * @param {String} user (optional) the username to get the rootlist for. * @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; +Spotify.prototype.rootlist = function (user) { + if (!user) user = this.username; + debug('rootlist(%j)', user); - 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); -}; + var rootlistType = (user == this.username) ? 'rootlist' : 'publishedrootlist'; -/** - * 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); + return this.Playlist(['spotify', 'user', user, rootlistType].join(':')); }; /** From 943ab623518412110ee35da0b11a22c3e0ff628d Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 11:36:20 +1000 Subject: [PATCH 088/108] Emit errors from Playlist#_performDiff --- lib/playlist/playlist.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index fc44077..df059e5 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -199,6 +199,7 @@ 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) { From fe2cc336fb75a6407b0b220e32d5dba0c3e8b4de Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:06:32 +1000 Subject: [PATCH 089/108] Fix custom header field parsing --- lib/connection/hermes_response.js | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/connection/hermes_response.js b/lib/connection/hermes_response.js index ec4d6a0..ba3198a 100644 --- a/lib/connection/hermes_response.js +++ b/lib/connection/hermes_response.js @@ -112,8 +112,6 @@ Object.defineProperty(HermesResponse.prototype, 'statusMessage', { */ HermesResponse.prototype.parse = function(data) { debug('parse(%j)', data); - var self = this; - // special case where the callback is invoked from parent multi-get request if (data instanceof MercuryReply) { this.uri = this.request.uri; @@ -138,21 +136,21 @@ HermesResponse.prototype.parse = function(data) { this.statusCode = header.statusCode; if (header.userFields) { - if ('MC-Cache-Policy' in header.userFields) - this.cachePolicy = header.userFields['MC-Cache-Policy'].toString(); - if ('MC-ETag' in header.userFields) - this.etag = header.userFields['MC-ETag']; - if ('MC-TTL' in header.userFields) - this.ttl = Number(header.userFields['MC-TTL'].toString()); - header.userFields.forEach(function(field) { - self.userFields[field.name] = field.value; - }); + 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); From fedf3821ed4fe4f06e10b38633d2026b8cdd2831 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:07:18 +1000 Subject: [PATCH 090/108] Move rootlist method to User object --- lib/spotify.js | 16 ---------------- lib/user.js | 15 +++++++++++++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 665d6e1..48ba8ac 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -710,22 +710,6 @@ Spotify.prototype.metadata = function (uris, fn, loadMetadata) { return (returnArray) ? objects : objects[0]; }; -/** - * Gets a user's stored playlists - * - * @param {String} user (optional) the username to get the rootlist for. - * @api public - */ - -Spotify.prototype.rootlist = function (user) { - if (!user) user = this.username; - debug('rootlist(%j)', user); - - var rootlistType = (user == this.username) ? 'rootlist' : 'publishedrootlist'; - - return this.Playlist(['spotify', 'user', user, rootlistType].join(':')); -}; - /** * Retrieve suggested similar tracks to the given track URI * diff --git a/lib/user.js b/lib/user.js index 60fba74..05907e9 100644 --- a/lib/user.js +++ b/lib/user.js @@ -186,8 +186,19 @@ User.prototype.followers = function() { throw new Error('TODO: implement'); }; -User.prototype.rootlist = function() { - throw new Error('TODO: implement'); +/** + * Gets the user's stored playlists + * + * @param {String} type (optional) the rootlist type (either 'rootlist' or 'publishedrootlist') + * @api public + */ + +User.prototype.rootlist = function(type) { + if (!type) { + type = (this.isCurrentUser) ? 'rootlist' : 'publishedrootlist'; + } + + return this._spotify.Playlist(['spotify', 'user', this.username, rootlistType].join(':')); }; User.prototype.starred = function() { From e1951474c8f44790a3f72aa95f94e8efb2707a77 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:08:03 +1000 Subject: [PATCH 091/108] Parse and cache playlist attributes on contents --- lib/playlist/playlist.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index df059e5..f2ec33d 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -407,6 +407,13 @@ Playlist.prototype.contents = function(offset, length, fn) { } catch(e) { return fn(e); } + try { + var attributes = new PlaylistAttributes(); + attributes.parse(res.result); + self._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); }); From b9f5f39b91fd641371df85a7b5872bc39c47546f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:09:23 +1000 Subject: [PATCH 092/108] Remove Spotify#similar No longer needed now we have Track#similar --- lib/spotify.js | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index 48ba8ac..09669c1 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -710,39 +710,6 @@ Spotify.prototype.metadata = function (uris, fn, loadMetadata) { return (returnArray) ? objects : objects[0]; }; -/** - * Retrieve suggested similar tracks to the given track URI - * - * @param {String} uri track uri - * @param {Function} fn callback function - * @api public - */ - -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); -}; - /** * Gets the MP3 160k audio URL for the given "track" metadata object. * From 8d6f1ae8a51b5763a86e8c18965fd1d7bc0523c4 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:12:06 +1000 Subject: [PATCH 093/108] example: add playlistEvents This example script demonstrates how to listen for events on the playlist instance. --- example/playlistEvents.js | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 example/playlistEvents.js 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); + } +}); From c562504b7bdd8655caa531acd1104db0adfcaac7 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:16:49 +1000 Subject: [PATCH 094/108] user: add missing SpotifyUri require() --- lib/user.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/user.js b/lib/user.js index 05907e9..8448db6 100644 --- a/lib/user.js +++ b/lib/user.js @@ -5,6 +5,7 @@ var schemas = require('./schemas'); var util = require('./util'); +var SpotifyUri = require('./uri'); var debug = require('debug')('spotify-web:user'); /** From 03bcc3184aac65b73cddbaa97ca85f88257dd75f Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:17:12 +1000 Subject: [PATCH 095/108] spotify: create User object on login --- lib/spotify.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/spotify.js b/lib/spotify.js index 09669c1..af8d94d 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -1036,6 +1036,7 @@ Spotify.prototype._onconnect = function (err, res) { Spotify.prototype._onuserinfo = function (err, res) { if (err) return this.emit('error', err); this.user_info = res.result; + this.user = new this.User(this.username); this.emit('login'); }; From 3848063416caa5c24d961df18e5ed4f98b739e42 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:17:33 +1000 Subject: [PATCH 096/108] user: fix typo in User#rootlist --- lib/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/user.js b/lib/user.js index 8448db6..700127b 100644 --- a/lib/user.js +++ b/lib/user.js @@ -199,7 +199,7 @@ User.prototype.rootlist = function(type) { type = (this.isCurrentUser) ? 'rootlist' : 'publishedrootlist'; } - return this._spotify.Playlist(['spotify', 'user', this.username, rootlistType].join(':')); + return this._spotify.Playlist(['spotify', 'user', this.username, type].join(':')); }; User.prototype.starred = function() { From 1d69693905abbdc29c90643def563a3fead6cacf Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:29:52 +1000 Subject: [PATCH 097/108] playlist: fix scope in Playlist#contents callback --- lib/playlist/playlist.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index f2ec33d..f20f025 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -397,26 +397,24 @@ Playlist.prototype.contents = function(offset, length, fn) { debug('contents(%j, %j)', offset, length); // TODO(adammw): ensure this works with large playlists (ie >100 items) - - var PlaylistContents = this.PlaylistContents; this._request(function(err, res) { if (err) return fn(err); - var contents = new PlaylistContents(); + var contents = new this.PlaylistContents(); try { contents.parse(res.result); } catch(e) { return fn(e); } try { - var attributes = new PlaylistAttributes(); + var attributes = new this.PlaylistAttributes(); attributes.parse(res.result); - self._attributesCache = attributes; + 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)); }; /* From a9435588e3b98585a3dbdb25bbafb707f9769b3a Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:33:03 +1000 Subject: [PATCH 098/108] user: use Spotify#get in User#rootlist --- lib/user.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/user.js b/lib/user.js index 700127b..dfeb13e 100644 --- a/lib/user.js +++ b/lib/user.js @@ -191,15 +191,16 @@ User.prototype.followers = function() { * 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) { + if (type !== 'rootlist' && type !== 'publishedrootlist') { type = (this.isCurrentUser) ? 'rootlist' : 'publishedrootlist'; } - return this._spotify.Playlist(['spotify', 'user', this.username, type].join(':')); + return this._spotify.get(['spotify', 'user', this.username, type].join(':')); }; User.prototype.starred = function() { From 8868c3caa4db2287014d05a206d80f30a98b86c7 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:33:18 +1000 Subject: [PATCH 099/108] user: implement User#starred --- lib/user.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/user.js b/lib/user.js index dfeb13e..b170e29 100644 --- a/lib/user.js +++ b/lib/user.js @@ -203,6 +203,12 @@ User.prototype.rootlist = function(type) { 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() { - throw new Error('TODO: implement'); + return this._spotify.get(['spotify', 'user', this.username, 'starred'].join(':')); }; From 23dff23e389ad54a97f1ee714ed125372c7b0b89 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 12:36:38 +1000 Subject: [PATCH 100/108] example: Update rootlist for new API --- example/rootlist.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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(); }); From dde084dc5f71ff5a4081429bcb696ab12682fe75 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 13:44:08 +1000 Subject: [PATCH 101/108] playlist: Make attributes optional upon creation --- lib/playlist/playlist.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index f20f025..167a6a6 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -81,6 +81,10 @@ 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 = { From 84eb34b3d74e614190723125cb6eec9473b85a11 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 13:44:52 +1000 Subject: [PATCH 102/108] playlist: Add method to update playlist attributes --- lib/playlist/attributes.js | 23 +++++++++++++++++++++++ lib/playlist/playlist.js | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/playlist/attributes.js b/lib/playlist/attributes.js index 3229471..e57b711 100644 --- a/lib/playlist/attributes.js +++ b/lib/playlist/attributes.js @@ -17,6 +17,13 @@ function PlaylistAttributes(playlist) { } 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 * @@ -33,3 +40,19 @@ PlaylistAttributes.prototype.parse = function(data) { 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/playlist.js b/lib/playlist/playlist.js index 167a6a6..e12941a 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -344,6 +344,22 @@ Playlist.prototype.attributes = function(fn) { }); }; +/** + * 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 * From 0a76a3b73ce2932dde0bfcd3d0d61ee3d1588642 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 13:51:59 +1000 Subject: [PATCH 103/108] playlist-contents: set index and revision on items --- lib/playlist/contents.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/playlist/contents.js b/lib/playlist/contents.js index dafa3e4..b89d292 100644 --- a/lib/playlist/contents.js +++ b/lib/playlist/contents.js @@ -33,7 +33,6 @@ PlaylistContents['$inject'] = ['Playlist']; * @api private */ PlaylistContents.prototype.parse = function(data) { - var contents = this; var PlaylistItem = this.playlist.PlaylistItem; var PlaylistRevision = this.playlist.PlaylistRevision; @@ -44,8 +43,11 @@ PlaylistContents.prototype.parse = function(data) { // 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) { - contents.push(new PlaylistItem(item.uri, item.attributes)); - }); + 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); } }; From 8c1166a4da718b4ffe65418cd3943253b710b809 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 13:57:14 +1000 Subject: [PATCH 104/108] playlist: set index and revision on diff items --- lib/playlist/item.js | 3 +++ lib/playlist/playlist.js | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/playlist/item.js b/lib/playlist/item.js index 8a7570d..ccb0bf1 100644 --- a/lib/playlist/item.js +++ b/lib/playlist/item.js @@ -17,6 +17,9 @@ function PlaylistItem(playlist, uri, attributes) { this.playlist = playlist; this.attributes = attributes || {}; + this.index = null; + this.revision = null; + var spotify = this.playlist._spotify; this.item = spotify.get(uri); } diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index e12941a..e77818b 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -387,8 +387,11 @@ Playlist.prototype.diff = function(revision, fn) { 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) { - return new this.PlaylistItem(item.uri, item.attributes); + 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); From fa5bc9ab78e5f1df9e750df69430584b7ed58f89 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 14:40:07 +1000 Subject: [PATCH 105/108] playlist: add Playlist#add method --- lib/playlist/playlist.js | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index e77818b..8845edc 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -525,6 +525,54 @@ Playlist.prototype.revision._onRemoveListener = function(event, listener) { this.unsubscribe(); }; +/** + * Adds an item 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); +}; + /** * Deletes the Playlist represented by the Playlist instance on the server. * From df5fc9b3636d2ef2808f841d0c92a797e978f3ea Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 15:56:35 +1000 Subject: [PATCH 106/108] playlist: add remove methods --- lib/playlist/item.js | 23 +++++++++++++++++++++++ lib/playlist/playlist.js | 30 +++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/lib/playlist/item.js b/lib/playlist/item.js index ccb0bf1..c9739ef 100644 --- a/lib/playlist/item.js +++ b/lib/playlist/item.js @@ -19,8 +19,31 @@ function PlaylistItem(playlist, uri, 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 index 8845edc..4d7abd9 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -290,6 +290,7 @@ Playlist.prototype._onsubscriptionresponse = function(response) { 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); @@ -526,7 +527,7 @@ Playlist.prototype.revision._onRemoveListener = function(event, listener) { }; /** - * Adds an item to the playlist + * 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 @@ -573,6 +574,33 @@ Playlist.prototype.add = function(items, prepend, attributes, fn) { 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. * From 584a8cefdf7eae62314c771504a531cf59a8b375 Mon Sep 17 00:00:00 2001 From: Adam Malcontenti-Wilson Date: Fri, 18 Apr 2014 15:59:22 +1000 Subject: [PATCH 107/108] playlist: automatically add created to rootlist --- lib/playlist/playlist.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/playlist/playlist.js b/lib/playlist/playlist.js index 4d7abd9..4a4a3ba 100644 --- a/lib/playlist/playlist.js +++ b/lib/playlist/playlist.js @@ -102,10 +102,15 @@ Playlist.create = function(spotify, attributes, fn) { request.setResponseSchema(CreateListReply); request.send(requestArgs, function (err, res) { if (err) fn(err); - debug('playlist created - uri = %s', res.result.uri); + var uri = res.result.uri.toString(); + debug('playlist created - uri = %s', uri); - // TODO(adammw): add item to the user's rootlist - var playlist = new Playlist(spotify, res.result.uri.toString()); + 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); }); From 6a5e5e8c9cb593eba9b6ec3651d85f6f0d3cd5bc Mon Sep 17 00:00:00 2001 From: Brandt Abbott Date: Sat, 26 Apr 2014 14:38:45 -0700 Subject: [PATCH 108/108] spotify: new flash key, moved sp_log and sp_user_info commands after login_complete Squashed commit of the following: commit 0498c4563d29279e976b304fc74670352faaf201 Author: Brandt Abbott Date: Sat Apr 26 07:36:26 2014 -0600 Issue #81 forgot to remove calls from _onconnect, it was late at night commit 9268e2fd0322027781e3fbbf965c07770d271ac6 Author: Brandt Abbott Date: Fri Apr 25 23:25:48 2014 -0600 Issue #81 - New flash key. Moved sp_log and sp_user_info commands after login_complete Fixes #81. Closes #82. Conflicts: lib/spotify.js --- lib/spotify.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/spotify.js b/lib/spotify.js index af8d94d..36a9c8a 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -419,7 +419,8 @@ 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 debug('unhandled %j command, args: %j', command, args); @@ -453,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]; @@ -1021,8 +1022,6 @@ Spotify.prototype.sendTrackProgress = function (lid, ms, fn) { Spotify.prototype._onconnect = function (err, res) { 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 }; /**