Skip to content

Commit

Permalink
spotify: refactor MercuryRequest code into sendProtobufRequest function
Browse files Browse the repository at this point in the history
Previously, all functions which required MecuryRequest for communications
(such as `metadata()`, `playlist()` and `rootlist()) ) had to do the
construction of requests and callback processing themselves.

This commit refactors the logic into a separate `sendProtobufRequest()` function
which handles these sorts of communication.

This commit also updates the MercuryRequest protobuf format with new fields and
field names as found in the official client, and it turns out that the reply
of a MercuryRequest is actually MercuryRequest protobuf and not a MercuryReply
as was it being parsed as previously, so it is used instead for parsing replies.

Fixes TooTallNate#11
  • Loading branch information
adammw committed Jun 18, 2013
1 parent ecb041b commit e2890ba
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 95 deletions.
240 changes: 154 additions & 86 deletions lib/spotify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
})
};

/**
Expand Down Expand Up @@ -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);
};

/**
Expand Down Expand Up @@ -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);
};

/**
Expand Down
20 changes: 13 additions & 7 deletions proto/mercury.desc
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@



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
cache_policy (2/.spotify.mercury.proto.MercuryReply.CachePolicy
ttl (
etag ( 
content_type ( 
body ( "@
body ( 5
user_fields ( 2 .spotify.mercury.proto.UserField"@
CachePolicy
CACHE_NO
CACHE_PRIVATE
CACHE_PUBLIC
CACHE_PUBLIC"(
UserField
name ( 
value (
Expand Down
10 changes: 8 additions & 2 deletions proto/mercury.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

0 comments on commit e2890ba

Please sign in to comment.