diff --git a/lib/spotify.js b/lib/spotify.js index f92d1c0..76ab73c 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -28,7 +28,6 @@ var mercury = schemas.mercury; var MercuryMultiGetRequest = mercury['spotify.mercury.proto.MercuryMultiGetRequest']; var MercuryMultiGetReply = mercury['spotify.mercury.proto.MercuryMultiGetReply']; var MercuryRequest = mercury['spotify.mercury.proto.MercuryRequest']; -var MercuryReply = mercury['spotify.mercury.proto.MercuryReply']; var Artist = require('./artist'); var Album = require('./album'); @@ -436,6 +435,122 @@ Spotify.prototype.sendCommand = function (name, args, 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 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 = null; + if (schema.hasOwnProperty(schemaName[0])) + schema = schemas[schemaName[0]]['spotify.' + schemaName[0] + '.proto.' + 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; + }; + + // construct request + var args = [ 0 ]; + 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 (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) + return fn(new Error('Client Error: ' + header.statusMessage)); + + if (header.statusCode >= 500 && header.statusCode < 600) + return fn(new Error('Server Error: ' + header.statusMessage)); + + if (isMultiGet && 'vnd.spotify/mercury-mget-reply' !== type) + 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. @@ -495,68 +610,37 @@ Spotify.prototype.metadata = function (uris, fn) { var id = util.uri2id(uri); mtype = type; requests.push({ - body: 'GET', + method: 'GET', uri: 'hm://metadata/' + type + '/' + id }); }); - var data; - var self = this; - var args = [ 0 ]; - if (requests.length == 1) { - data = MercuryRequest.serialize(requests[0]).toString('base64'); - args.push(data); - } else { - data = MercuryMultiGetRequest.serialize({ - request: requests - }).toString('base64'); - var header = MercuryRequest.serialize({ - body: 'GET', - contentType: 'vnd.spotify/mercury-mget-request', - uri: 'hm://metadata/' + mtype + 's' - }).toString('base64'); - - args.push(header); - args.push(data); - } - this.sendCommand('sp/hm_b64', args, function (err, res) { - if (err) return fn(err); - - var ret; - var data = res.result; - var header = self._parse(MercuryReply, new Buffer(data[0], 'base64')); - debug('response header: %j', header); - if ('vnd.spotify/mercury-mget-reply' == header.statusMessage) { - ret = []; - var response = self._parse(MercuryMultiGetReply, new Buffer(data[1], 'base64')); - for (var i = 0; i < response.reply.length; i++) { - var type = response.reply[i].contentType.toString(); - ret.push(parseItem(type, response.reply[i].body)); - } - debug('parsed response: %d items', ret.length); - } else { - // single entry response - ret = parseItem(header.statusMessage, data[1]); - debug('parsed response: %j', ret); - } - fn(null, ret); - }); - function parseItem (type, body) { - var parser; - if ('vnd.spotify/metadata-artist' == type) { - parser = Artist; - } else if ('vnd.spotify/metadata-album' == type) { - parser = Album; - } else if ('vnd.spotify/metadata-track' == type) { - parser = Track; - } else { - throw new Error('Unrecognised metadata type: ' + type); + 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 } - var item = self._parse(parser, new Buffer(body, 'base64')); + }, function(err, item) { + if (err) return fn(err); item._loaded = true; - return item; - } + fn(null, item); + }) }; /** @@ -589,18 +673,13 @@ Spotify.prototype.playlist = function (uri, from, length, fn) { var hm = 'hm://playlist/user/' + user + '/playlist/' + id + '?from=' + from + '&length=' + length; - var req = { body: 'GET', uri: hm }; - var data = MercuryRequest.serialize(req).toString('base64'); - var args = [ 0, data ]; - - this.sendCommand('sp/hm_b64', args, function (err, res) { - if (err) return fn(err); - // TODO: error handling - //var header = MercuryReply.parse(new Buffer(res.result[0], 'base64')); - //console.log(header); - var obj = self._parse(ListDump, new Buffer(res.result[1], 'base64')); - fn(null, obj); - }); + this.sendProtobufRequest({ + header: { + method: 'GET', + uri: hm + }, + responseSchema: ListDump + }, fn); }; /** @@ -633,24 +712,13 @@ Spotify.prototype.rootlist = function (user, from, length, fn) { var self = this; var hm = 'hm://playlist/user/' + user + '/rootlist?from=' + from + '&length=' + length; - var req = { body: 'GET', uri: hm }; - var data = MercuryRequest.serialize(req).toString('base64'); - var args = [ 0, data ]; - - this.sendCommand('sp/hm_b64', args, function (err, res) { - if (err) return fn(err); - - var obj; - var data = res.result; - if (data.length >= 2) { - // success! - obj = self._parse(ListDump, new Buffer(res.result[1], 'base64')); - } else { - // TODO: real error handling - var header = MercuryReply.parse(new Buffer(res.result[0], 'base64')); - } - fn(err, obj); - }); + this.sendProtobufRequest({ + header: { + method: 'GET', + uri: hm + }, + responseSchema: ListDump + }, fn); }; /** diff --git a/proto/mercury.desc b/proto/mercury.desc index 1ed37b7..b22f46d 100644 --- a/proto/mercury.desc +++ b/proto/mercury.desc @@ -1,15 +1,17 @@ -› +Í mercury.protospotify.mercury.proto"P MercuryMultiGetRequest6 request ( 2%.spotify.mercury.proto.MercuryRequest"J MercuryMultiGetReply2 -reply ( 2#.spotify.mercury.proto.MercuryReply"O +reply ( 2#.spotify.mercury.proto.MercuryReply"Ÿ MercuryRequest uri (  - content_type (  -body (  -etag ( "ƒ + content_type (  +method (  + status_code ( +source ( 5 + user_fields ( 2 .spotify.mercury.proto.UserField"º MercuryReply status_code ( status_message ( E @@ -17,8 +19,12 @@ ttl ( etag (  content_type (  -body ( "@ +body ( 5 + user_fields ( 2 .spotify.mercury.proto.UserField"@ CachePolicy CACHE_NO CACHE_PRIVATE - CACHE_PUBLIC \ No newline at end of file + CACHE_PUBLIC"( + UserField +name (  +value ( \ No newline at end of file diff --git a/proto/mercury.proto b/proto/mercury.proto index 35c6b41..9e20ffa 100644 --- a/proto/mercury.proto +++ b/proto/mercury.proto @@ -9,8 +9,10 @@ message MercuryMultiGetReply { message MercuryRequest { optional string uri = 1; optional string content_type = 2; - optional bytes body = 3; - optional bytes etag = 4; + optional string method = 3; + optional sint32 status_code = 4; + optional string source = 5; + repeated UserField user_fields = 6; } message MercuryReply { enum CachePolicy { @@ -26,3 +28,7 @@ message MercuryReply { optional bytes content_type = 6; optional bytes body = 7; } +message UserField { + optional string name = 1; + optional bytes value = 2; +}