diff --git a/lib/error.js b/lib/error.js index a446741..914420b 100644 --- a/lib/error.js +++ b/lib/error.js @@ -24,6 +24,8 @@ var codes = { 0: 'Account subscription status not Spotify Premium', 1: 'Failed to send to backend', 8: 'Rate limited', + 11: 'Track Cap reached', + 12: 'Time Cap reached', 408: 'Timeout', 429: 'Too many requests' }; diff --git a/lib/spotify.js b/lib/spotify.js index ee8ad6e..817fdf2 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -15,6 +15,7 @@ var SpotifyError = require('./error'); var EventEmitter = require('events').EventEmitter; var debug = require('debug')('spotify-web'); var package = require('../package.json'); +var urlparse = require('url').parse; /** * Module exports. @@ -84,14 +85,24 @@ function Spotify () { this.connected = false; // true after the WebSocket "connect" message is sent this._callbacks = Object.create(null); + this.authAttempts = 0; + this.authAttemptLimit = 2; this.authServer = 'play.spotify.com'; this.authUrl = '/xhr/json/auth.php'; + this.redirectUrl = '/redirect/facebook/notification.php'; this.landingUrl = '/'; this.userAgent = 'Mozilla/5.0 (Chrome/13.37 compatible-ish) spotify-web/' + package.version; // the client version to "emulate" this.clientVersion = 62300120; // client version: 0.6.23.120, deployed 2014-01-08 14:28 UTC + // the query-string to send along to the "redirect url" + this.redirectPayload = { + album: 'http://open.spotify.com/album/2mCuMNdJkoyiXFhsQCLLqw', + song: 'http://open.spotify.com/track/6JEK0CvvjDjjMUBFoXShNZ' + }; + this.landingPayload = {}; + // 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'; @@ -161,7 +172,11 @@ Spotify.prototype.anonymousLogin = function (fn) { debug('Spotify#anonymousLogin()'); // save credentials for later... - this.creds = { type: 'anonymous' }; + this.creds = { type: 'anonymous', cf: 'facebook_play_to_webplayer', f: 'tosed', s: 'direct' }; + + // use redirect url as landing page + this.landingUrl = this.redirectUrl; + this.landingPayload = this.redirectPayload; this._setLoginCallbacks(fn); this._makeLandingPageRequest(); @@ -225,23 +240,35 @@ Spotify.prototype._makeLandingPageRequest = function() { debug('GET %j', url); this.agent.get(url) .set({ 'User-Agent': this.userAgent }) - .end(this._onsecret.bind(this)); + .query(this.landingPayload) + .end(this._onlandingpage.bind(this)); }; /** - * Called when the Facebook redirect URL GET (and any necessary redirects) has - * responded. + * Called when the Landing Page / Facebook redirect URL GET + * (and any necessary redirects) has responded. * * @api private */ -Spotify.prototype._onsecret = function (err, res) { +Spotify.prototype._onlandingpage = function (err, res) { if (err) return this.emit('error', err); + if (res.error) return this.emit('error', res.error); + if (res.redirects && res.redirects.length) { + var redirecturl = urlparse(res.redirects[res.redirects.length-1], true); + if (this.authServer == redirecturl.host) { + debug('saving redirected landing page: %s', redirecturl.path); + this.landingUrl = redirecturl.pathname; + this.landingPayload = redirecturl.query; + } else { + debug('redirected to a different host - not saving redirected url: %s', redirecturl.href); + } + } 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. + // need to grab the settings 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; @@ -257,24 +284,42 @@ Spotify.prototype._onsecret = function (err, res) { 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; + // save all settings, used later to authenticate + this.settings = args; + + // bypass the authentication endpoint if we already authenticated + // and we only wanted an anonymous session anyway + if ('anonymous' == this.creds.type && args.credentials && args.credentials.length && args.credentials[0]) { + debug('skipping authentication request as credentials are already available...'); + this._resolveAP(); + } else { + this._authenticate(); + } +}; +/** + * Called to send the POST to the "auth" endpoint after + * the initial settings have been gathered from the landing page request + * + * @api private + */ +Spotify.prototype._authenticate = function() { + debug('login CSRF token: %j, tracking ID: %j', this.settings.csrftoken, this.settings.trackingId); + + // amend credentials object with values from landing page + this.creds.secret = this.settings.csrftoken; + this.creds.trackingId = this.settings.trackingId; + this.creds.landingURL = this.settings.landingURL; + this.creds.referrer = this.settings.referrer; + // 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) + .send(this.creds) .end(this._onauth.bind(this)); }; @@ -285,18 +330,36 @@ Spotify.prototype._onsecret = function (err, res) { */ Spotify.prototype._onauth = function (err, res) { - if (err) return this.emit('error', err); + // count the number of auth attempts, and determine if we should keep trying + var attempt = ++this.authAttempts; + var retry = (attempt < this.authAttemptLimit); - debug('auth %d status code, %j content-type', res.statusCode, res.headers['content-type']); + // if an error occurs, try again from the landing page if we can, + // otherwise give up with an error event + var self = this; + var authError = function(err) { + if (!(err instanceof Error)) err = new Error(err); + debug('auth error: %s', err.message); + if (retry) return self._makeLandingPageRequest(); + return self.emit('error', err); + }; + + // check for errors + if (err) return authError(err); + if (res.error) return authError(res.error); 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(); + return authError(msg); + } + if (res.body.error) { + debug('auth status: %s', res.body.error); } + + // if no errors, save the config and delete the credentials + delete this.creds; + this.settings = res.body.config; + this._resolveAP(); }; /**