diff --git a/lib/spotify.js b/lib/spotify.js index 792cab7..3a48eb1 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -5,6 +5,8 @@ var vm = require('vm'); var util = require('./util'); var http = require('http'); +var https = require('https'); +var tls = require('tls'); var WebSocket = require('ws'); var cheerio = require('cheerio'); var schemas = require('./schemas'); @@ -67,6 +69,35 @@ Spotify.login = function (un, pw, fn) { return spotify; }; +/** + * Patched version of `https.Agent.createConnection` that disables SNI on websocket connections. + */ + +function createHttpsConnection(port, host, options) { + if (typeof port === 'object') { + options = port; + } else if (typeof host === 'object') { + options = host; + } else if (typeof options === 'object') { + options = options; + } else { + options = {}; + } + + if (typeof port === 'number') { + options.port = port; + } + + if (typeof host === 'string') { + options.host = host; + } + + // Disable SNI + options.servername = null; + + return tls.connect(options); +} + /** * Spotify Web base class. * @@ -86,7 +117,7 @@ function Spotify () { 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/' + pkg.version; + this.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.46 Safari/537.36'; // base URLs for Image files like album artwork, artist prfiles, etc. // these values taken from "spotify.web.client.js" @@ -105,6 +136,10 @@ function Spotify () { this.sourceUrls.LARGE = this.sourceUrls.large; this.sourceUrls.XLARGE = this.sourceUrls.avatar; + // WebSocket agent + this.wsAgent = new https.Agent(); + this.wsAgent.createConnection = createHttpsConnection; + // WebSocket callbacks this._onopen = this._onopen.bind(this); this._onclose = this._onclose.bind(this); @@ -333,7 +368,11 @@ Spotify.prototype._openWebsocket = function (err, res) { var url = 'wss://' + ap_list[0] + '/'; debug('WS %j', url); - this.ws = new WebSocket(url, null, {"origin": "https://play.spotify.com", "headers":{"User-Agent": this.userAgent}}); + this.ws = new WebSocket(url, null, { + "agent": this.wsAgent, + "origin": "https://play.spotify.com", + "headers":{"User-Agent": this.userAgent} + }); this.ws.on('open', this._onopen); this.ws.on('close', this._onclose); this.ws.on('message', this._onmessage); @@ -713,14 +752,14 @@ Spotify.prototype.metadata = function (uris, fn) { mtype = type; requests.push({ method: 'GET', - uri: 'hm://metadata/' + type + '/' + id + uri: 'hm://metadata/3/' + type + '/' + id }); }); var header = { method: 'GET', - uri: 'hm://metadata/' + mtype + 's' + uri: 'hm://metadata/3/' + mtype + 's' }; var multiGet = true; if (requests.length == 1) { @@ -929,50 +968,107 @@ Spotify.prototype.isTrackAvailable = function (track, country) { if (!country) country = this.country; debug('isTrackAvailable()'); - var allowed = []; - var forbidden = []; - var available = false; - var restriction; - - if (Array.isArray(track.restriction)) { - for (var i = 0; i < track.restriction.length; i++) { - restriction = track.restriction[i]; - allowed.push.apply(allowed, restriction.allowed); - forbidden.push.apply(forbidden, restriction.forbidden); - - var isAllowed = !restriction.hasOwnProperty('countriesAllowed') || has(allowed, country); - var isForbidden = has(forbidden, country) && forbidden.length > 0; - - // guessing at names here, corrections welcome... - var accountTypeMap = { - premium: 'SUBSCRIPTION', - unlimited: 'SUBSCRIPTION', - free: 'AD' - }; - - if (has(allowed, country) && has(forbidden, country)) { - isAllowed = true; - isForbidden = false; + var account = { + catalogue: this.accountType, + country: country + }; + + return this.isPlayable( + this.parseRestrictions(track.restriction, account), + account + ); +}; + +/** + * @param {String} availability Track availability + * @param {Object} account Account details {catalogue, country} + * @api public + */ + +Spotify.prototype.isPlayable = function(availability, account) { + if(availability === "premium" && account.catalogue === "premium") { + return true; + } + + return availability === "available"; +}; + +/** + * @param {Array} restrictions Track restrictions + * @param {Object} account Account details {catalogue, country} + * @api public + */ + +Spotify.prototype.parseRestrictions = function(restrictions, account) { + debug('parseRestrictions() account: %j', account); + + var catalogues = {}, + available = false; + + if ("undefined" === typeof restrictions || 0 === restrictions.length) { + // Track has no restrictions + return "available"; + } + + for (var ri = 0; ri < restrictions.length; ++ri) { + var restriction = restrictions[ri], + valid = true, + allowed; + + if(restriction.countriesAllowed != void 0) { + // Check if account region is allowed + valid = restriction.countriesAllowed.length !== 0; + allowed = has(restriction.allowed, account.country); + } else { + // Check if account region is forbidden + if(restriction.countriesForbidden != void 0) { + allowed = !has(restriction.forbidden, account.country); + } else { + allowed = true; + } + } + + if (allowed && restriction.catalogueStr != void 0) { + // Update track catalogues + for (var ci = 0; ci < restriction.catalogueStr.length; ++ci) { + var key = restriction.catalogueStr[ci]; + + catalogues[key] = true; } + } - var type = accountTypeMap[this.accountType] || 'AD'; - var applicable = has(restriction.catalogue, type); + if (restriction.type == void 0 || "streaming" == restriction.type.toLowerCase()) { + available |= valid; + } + } - available = isAllowed && !isForbidden && applicable; + debug('parseRestrictions() catalogues: %j', catalogues); - //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 && account.catalogue === "all") { + // Account can stream anything + return "available"; + } - if (available) break; + if(catalogues[account.catalogue]) { + // Track can be streamed by this account + if(account.catalogue === "premium") { + return "premium"; + } else { + return "available"; } } - return available; + + if(catalogues.premium) { + // Premium account required + return "premium"; + } + + if(available) { + // Track not available in the account region + return "regional"; + } + + return "unavailable"; }; /** diff --git a/lib/track.js b/lib/track.js index 11fa9ce..3d5d355 100644 --- a/lib/track.js +++ b/lib/track.js @@ -62,7 +62,7 @@ Track.prototype.metadata = function (fn) { 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)) { + if (track.hasOwnProperty(key)) { self[key] = track[key]; } }); diff --git a/proto/metadata.desc b/proto/metadata.desc index df61c1e..fc0c6a3 100644 Binary files a/proto/metadata.desc and b/proto/metadata.desc differ diff --git a/proto/metadata.proto b/proto/metadata.proto index 2b0d9f9..ce29b25 100644 --- a/proto/metadata.proto +++ b/proto/metadata.proto @@ -116,18 +116,13 @@ message Copyright { optional string text = 2; } message Restriction { - enum Catalogue { - AD = 0; - SUBSCRIPTION = 1; - SHUFFLE = 3; - } enum Type { STREAMING = 0; } - repeated Catalogue catalogue = 1; optional string countries_allowed = 2; optional string countries_forbidden = 3; optional Type type = 4; + repeated string catalogue_str = 5; } message SalePeriod {