diff --git a/.dockerignore b/.dockerignore index d992b0f..2eac0dc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,3 @@ node_modules -.git \ No newline at end of file +.git +.env \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..7793c77 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "env": { + "node": true, + "es6": true + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd4f2b0..9bdf355 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +.env \ No newline at end of file diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 6cf513e..0000000 --- a/.jshintrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "node": true -} \ No newline at end of file diff --git a/lib/playmusic.js b/lib/playmusic.js deleted file mode 100644 index b0048f4..0000000 --- a/lib/playmusic.js +++ /dev/null @@ -1,496 +0,0 @@ -/* Node-JS Google Play Music API -* -* Written by Jamon Terrell -* -* This Source Code Form is subject to the terms of the Mozilla Public -* License, v. 2.0. If a copy of the MPL was not distributed with this -* file, You can obtain one at http://mozilla.org/MPL/2.0/. -* -* Based partially on the work of the Google Play Music resolver for Tomahawk (https://github.com/tomahawk-player/tomahawk-resolvers/blob/master/gmusic/content/contents/code/gmusic.js) -* and the gmusicapi project by Simon Weber (https://github.com/simon-weber/Unofficial-Google-Music-API/blob/develop/gmusicapi/protocol/mobileclient.py). -*/ -var https = require('https'); -var querystring = require('querystring'); -var url = require('url'); -var CryptoJS = require("crypto-js"); -var uuid = require('node-uuid'); -var util = require('util'); - -var pmUtil = {}; -pmUtil.parseKeyValues = function(body) { - var obj = {}; - body.split("\n").forEach(function(line) { - var pos = line.indexOf("="); - if(pos > 0) obj[line.substr(0, pos)] = line.substr(pos+1); - }); - return obj; -}; -pmUtil.Base64 = { - _map: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", - stringify: CryptoJS.enc.Base64.stringify, - parse: CryptoJS.enc.Base64.parse -}; -pmUtil.salt = function(len) { - return Array.apply(0, Array(len)).map(function() { - return (function(charset){ - return charset.charAt(Math.floor(Math.random() * charset.length)); - }('abcdefghijklmnopqrstuvwxyz0123456789')); - }).join(''); -}; - - -var PlayMusic = function() {}; - -PlayMusic.prototype._baseURL = 'https://www.googleapis.com/sj/v1.5/'; -PlayMusic.prototype._webURL = 'https://play.google.com/music/'; -PlayMusic.prototype._mobileURL = 'https://android.clients.google.com/music/'; -PlayMusic.prototype._accountURL = 'https://www.google.com/accounts/'; - -PlayMusic.prototype.request = function(options) { - var opt = url.parse(options.url); - opt.headers = {}; - opt.method = options.method || "GET"; - if(typeof options.options === "object") { - Object.keys(options.options).forEach(function(k) { - opt[k] = options.options[k]; - }); - } - if(typeof this._token !== "undefined") opt.headers.Authorization = "GoogleLogin auth=" + this._token; - opt.headers["Content-type"] = options.contentType || "application/x-www-form-urlencoded"; - - var req = https.request(opt, function(res) { - res.setEncoding('utf8'); - var body = ""; - res.on('data', function(chunk) { - body += chunk; - }); - res.on('end', function() { - if(res.statusCode === 200) { - options.success(body, res); - } else { - options.error(body, null, res); - } - }); - res.on('error', function() { - options.error(null, Array.prototype.slice.apply(arguments), res); - }); - }); - if(typeof options.data !== "undefined") req.write(options.data); - req.end(); -}; - - -PlayMusic.prototype.init = function(config, next) { - var that = this; - - this._email = config.email; - this._password = config.password; - - // load signing key - var s1 = CryptoJS.enc.Base64.parse('VzeC4H4h+T2f0VI180nVX8x+Mb5HiTtGnKgH52Otj8ZCGDz9jRWyHb6QXK0JskSiOgzQfwTY5xgLLSdUSreaLVMsVVWfxfa8Rw=='); - var s2 = CryptoJS.enc.Base64.parse('ZAPnhUkYwQ6y5DdQxWThbvhJHN8msQ1rqJw0ggKdufQjelrKuiGGJI30aswkgCWTDyHkTGK9ynlqTkJ5L4CiGGUabGeo8M6JTQ=='); - - for(var idx = 0; idx < s1.words.length; idx++) { - s1.words[idx] ^= s2.words[idx]; - } - - this._key = s1; - - this._login(function(err, response) { - if (err) { - return next(err); - } - that._token = response.Auth; - that._getXt(function(err, xt) { - if (err) { - return next(err); - } - that._xt = xt; - that.getSettings(function(err, response) { - if (err) { - return next(err); - } - that._allAccess = response.settings.isSubscription; - - var devices = response.settings.devices.filter(function(d) { - return d.type === "PHONE" || d.type === "IOS"; - }); - - if(devices.length > 0) { - that._deviceId = devices[0].id.slice(2); - next(null, response); - } else { - next(new Error("Unable to find a usable device on your account, access from a mobile device and try again")); - } - }); - }); - }); -}; - -PlayMusic.prototype._login = function (next) { - var that = this; - var data = { - accountType: "HOSTED_OR_GOOGLE", - Email: that._email.trim(), - Passwd: that._password.trim(), - service: "sj", - source: "node-gmusic" - }; - this.request({ - method: "POST", - url: this._accountURL + "ClientLogin", - contentType: "application/x-www-form-urlencoded", - data: querystring.stringify(data), // @TODO make this.request auto serialize based on contentType - success: function(data, res) { - var obj = pmUtil.parseKeyValues(data); - next(null, obj); - }, - error: function(data, err, res) { - next(new Error("login failed!")); - } - }); -}; - -PlayMusic.prototype._getXt = function (next) { - var that = this; - this.request({ - method: "HEAD", - url: this._webURL + "listen", - success: function(data, res) { - // @TODO replace with real cookie handling - var cookies = {}; - res.headers['set-cookie'].forEach(function(c) { - var pos = c.indexOf("="); - if(pos > 0) cookies[c.substr(0, pos)] = c.substr(pos+1, c.indexOf(";")-(pos+1)); - }); - - if (typeof cookies.xt !== "undefined") { - next(null, cookies.xt); - } else { - next(new Error("xt cookie missing")); - } - }, - error: function(data, err, res) { - next(new Error("request for xt cookie failed")); - } - }); -}; - -/** -* Returns settings / device ids authorized for account. -* -* @param success function(settings) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getSettings = function(next) { - var that = this; - - this.request({ - method: "POST", - url: this._webURL + "services/loadsettings?" + querystring.stringify({u: 0, xt: this._xt}), - contentType: "application/json", - data: JSON.stringify({"sessionId": ""}), - success: function(body, res) { - var response = JSON.parse(body); - next(null, response); - }, - error: function(body, err, res) { - next(new Error("error loading settings")); - } - }); -}; - -/** -* Returns list of all tracks -* -* @param success function(trackList) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getLibrary = PlayMusic.prototype.getAllTracks = function(next) { - var that = this; - this.request({ - method: "POST", - url: this._baseURL + "trackfeed", - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error retrieving all tracks")); - } - }); -}; - -/** -* Returns stream URL for a track. -* -* @param id string - track id, hyphenated is preferred, but "nid" will work for all access tracks (not uploaded ones) -* @param success function(streamUrl) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getStreamUrl = function (id, next) { - var that = this; - var salt = pmUtil.salt(13); - var sig = CryptoJS.HmacSHA1(id + salt, this._key).toString(pmUtil.Base64); - var qp = { - u: "0", - net: "wifi", - pt: "e", - targetkbps: "8310", - slt: salt, - sig: sig - }; - if(id.charAt(0) === "T") { - qp.mjck = id; - } else { - qp.songid = id; - } - - var qstring = querystring.stringify(qp); - this.request({ - method: "GET", - url: this._mobileURL + 'mplay?' + qstring, - options: { headers: { "X-Device-ID": this._deviceId } }, - success: function(data, res) { - next(new Error("successfully retrieved stream urls, but wasn't expecting that...")); - }, - error: function(data, err, res) { - if(res.statusCode === 302) { - next(null, res.headers.location); - } else { - next(new Error("error getting stream urls")); - } - } - }); -}; - -/** -* Searches for All Access tracks. -* -* @param text string - search text -* @param maxResults int - max number of results to return -* @param success function(searchResults) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.search = function (text, maxResults, next) { - var that = this; - var qp = { - q: text, - "max-results": maxResults - }; - var qstring = querystring.stringify(qp); - this.request({ - method: "GET", - url: this._baseURL + 'query?' + qstring, - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting search results")); - } - }); -}; - -/** -* Returns list of all playlists. -* -* @param success function(playlists) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getPlayLists = function (next) { - var that = this; - this.request({ - method: "POST", - url: this._baseURL + 'playlistfeed', - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting playlist results")); - } - }); -}; - -/** -* Creates a new playlist -* -* @param playlistName string - the playlist name -* @param success function(mutationStatus) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.addPlayList = function (playlistName, next) { - var that = this; - var mutations = [ - { - "create": { - "creationTimestamp": -1, - "deleted": false, - "lastModifiedTimestamp": 0, - "name": playlistName, - "type": "USER_GENERATED" - } - } - ]; - this.request({ - method: "POST", - contentType: "application/json", - url: this._baseURL + 'playlistbatch?' + querystring.stringify({alt: "json"}), - data: JSON.stringify({"mutations": mutations}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error adding a playlist")); - } - }); -}; - -/** -* Adds a track to end of a playlist. -* -* @param songId int - the song id -* @param playlistId int - the playlist id -* @param success function(mutationStatus) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.addTrackToPlayList = function (songId, playlistId, next) { - var that = this; - var mutations = [ - { - "create": { - "clientId": uuid.v1(), - "creationTimestamp": "-1", - "deleted": "false", - "lastModifiedTimestamp": "0", - "playlistId": playlistId, - "source": (songId.indexOf("T") == 0 ? "2" : "1"), - "trackId": songId - } - } - ]; - this.request({ - method: "POST", - contentType: "application/json", - url: this._baseURL + 'plentriesbatch?' + querystring.stringify({alt: "json"}), - data: JSON.stringify({"mutations": mutations}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error adding a track into a playlist")); - } - }); -}; - -/** -* Removes given entry id from playlist entries -* -* @param entryId int - the entry id. You can get this from getPlayListEntries -* @param success function(mutationStatus) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.removePlayListEntry = function (entryId, next) { - var that = this; - var mutations = [ { "delete": entryId } ]; - - this.request({ - method: "POST", - contentType: "application/json", - url: this._baseURL + 'plentriesbatch?' + querystring.stringify({alt: "json"}), - data: JSON.stringify({"mutations": mutations}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error removing a playlist entry")); - } - }); -}; - -/** -* Returns tracks on all playlists. -* -* @param success function(playlistEntries) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getPlayListEntries = function (next) { - var that = this; - this.request({ - method: "POST", - url: this._baseURL + 'plentryfeed', - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting playlist results")); - } - }); -}; - -/** -* Returns info about an All Access album. Does not work for uploaded songs. -* -* @param albumId string All Access album "nid" -- WILL NOT ACCEPT album "id" (requires "T" id, not hyphenated id) -* @param includeTracks boolean -- include track list -* @param success function(albumList) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getAlbum = function (albumId, includeTracks, next) { - var that = this; - this.request({ - method: "GET", - url: this._baseURL + "fetchalbum?" + querystring.stringify({nid: albumId, "include-tracks": includeTracks, alt: "json"}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting album tracks")); - } - }); -}; - -/** -* Returns info about an All Access track. Does not work for uploaded songs. -* -* @param trackId string All Access track "nid" -- WILL NOT ACCEPT track "id" (requires "T" id, not hyphenated id) -* @param success function(trackInfo) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getTrack = function (trackId, next) { - var that = this; - this.request({ - method: "GET", - url: this._baseURL + "fetchtrack?" + querystring.stringify({nid: trackId, alt: "json"}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting album tracks")); - } - }); -}; - -/** -* Returns Artist Info, top tracks, albums, related artists -* -* @param artistId string - not sure which id this is -* @param includeAlbums boolean - should album list be included in result -* @param topTrackCount int - number of top tracks to return -* @param relatedArtistCount int - number of related artists to return -* @param success function(artistInfo) - success callback -* @param error function(data, err, res) - error callback -*/ -PlayMusic.prototype.getArtist = function (artistId, includeAlbums, topTrackCount, relatedArtistCount, success, error) { - var that = this; - this.request({ - method: "GET", - url: this._baseURL + "fetchartist?" + querystring.stringify({nid: artistId, "include-albums": includeAlbums, "num-top-tracks": topTrackCount, "num-related-artists": relatedArtistCount, alt: "json"}), - success: function(data, res) { - next(null, JSON.parse(data)); - }, - error: function(data, err, res) { - next(new Error("error getting album tracks")); - } - }); -}; - -module.exports = exports = PlayMusic; diff --git a/lib/services/google/index.js b/lib/services/google/index.js index cd146d6..97e9203 100644 --- a/lib/services/google/index.js +++ b/lib/services/google/index.js @@ -1,21 +1,21 @@ "use strict"; + var parse = require("url").parse; -var Promise = require('bluebird'); -var PlayMusic = require('../../playmusic'); +var Promise = require("bluebird"); +var PlayMusic = require("playmusic"); var pm = Promise.promisifyAll(new PlayMusic()); module.exports.id = "google"; if (!process.env.GOOGLE_EMAIL || !process.env.GOOGLE_PASSWORD) { console.warn("GOOGLE_EMAIL or GOOGLE_PASSWORD environment variables not found, deactivating Google Play Music."); - return; } var ready = pm.initAsync({email: process.env.GOOGLE_EMAIL, password: process.env.GOOGLE_PASSWORD}).catch(function(err) { - console.log(err) + console.log(err); }); -module.exports.match = require('./url').match; +module.exports.match = require("./url").match; module.exports.parseUrl = function(url) { return ready.then(function() { @@ -28,28 +28,28 @@ module.exports.parseUrl = function(url) { var id = parts[2]; var artist = decodeURIComponent(parts[3]); var album = decodeURIComponent(parts[4]); - - if (type != "album" && type != "track") { + + if (type !== "album" && type !== "track") { return false; } - + if (id.length > 0) { return {id: id, type: type}; } else { - return module.exports.search({type: type, name:album, artist: {name: artist}}); + return module.exports.search({type: type, name: album, artist: {name: artist}}); } } else if(path) { var matches = path.match(/\/music\/m\/([\w]+)/); - var type = matches[1][0] == "T" ? "track" : "album"; + type = matches[1][0] === "T" ? "track" : "album"; return module.exports.lookupId(matches[1], type); } return false; - }) -} + }); +}; module.exports.lookupId = function(id, type) { return ready.then(function() { - if (type == "album") { + if (type === "album") { return pm.getAlbumAsync(id, false).then(function(album) { return { service: "google", @@ -69,8 +69,8 @@ module.exports.lookupId = function(id, type) { }, function(error) { throw error; }); - } else if (type == "track") { - return pm.getTrackAsync(id).then(function(track) { + } else if (type === "track") { + return pm.getAllAccessTrackAsync(id).then(function(track) { return { service: "google", type: "track", @@ -94,30 +94,30 @@ module.exports.lookupId = function(id, type) { }); } }); -} +}; module.exports.search = function(data) { return ready.then(function() { var query, album; var type = data.type; - if (type == "album") { + if (type === "album") { query = data.artist.name + " " + data.name; album = data.name; - } else if (type == "track") { + } else if (type === "track") { query = data.artist.name + " " + data.album.name + " " + data.name; album = data.album.name; } return pm.searchAsync(query, 5).then(function(result) { - + if (!result.entries) { var matches = album.match(/^[^\(\[]+/); - if (matches && matches[0] && matches[0] != album) { + if (matches && matches[0] && matches[0] !== album) { var cleanedData = JSON.parse(JSON.stringify(data)); - if (type == "album") { + if (type === "album") { cleanedData.name = matches[0].trim(); - } else if (type == "track") { + } else if (type === "track") { cleanedData.album.name = matches[0].trim(); } return module.exports.search(cleanedData); @@ -125,8 +125,8 @@ module.exports.search = function(data) { return {service: "google"}; } } - var result = result.entries.filter(function(result) { - return result[type]; + result = result.entries.filter(function(entry) { + return entry[type]; }).sort(function(a, b) { // sort by match score return a.score < b.score; }).shift(); @@ -135,9 +135,9 @@ module.exports.search = function(data) { return {service: "google"}; } else { var id; - if (type == "album") { + if (type === "album") { id = result.album.albumId; - } else if (type == "track") { + } else if (type === "track") { id = result.track.nid; } @@ -145,4 +145,4 @@ module.exports.search = function(data) { } }); }); -} +}; diff --git a/package.json b/package.json index aea8340..55689b6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "clean": "rm -f ./public/javascript/bundle.js" }, "engines": { - "node": "0.10.x" + "node": "0.12.x" }, "browserify": { "transform": [ @@ -39,6 +39,7 @@ "morgan": "~1.5.0", "node-jsx": "^0.12.4", "node-uuid": "^1.4.2", + "playmusic": "^2.0.0", "promised-mongo": "^0.11.1", "rdio": "^1.5.2", "react": "^0.12.1", diff --git a/test/services/deezer.js b/test/services/deezer.js index 6e27fe0..23b4f84 100644 --- a/test/services/deezer.js +++ b/test/services/deezer.js @@ -24,7 +24,7 @@ describe('Deezer', function(){ describe('search', function(){ it('should find album by search', function(done){ deezer.search({type: "album", artist: {name: "David Guetta"}, name: "Listen (Deluxe)"}).then(function(result) { - result.name.should.equal("Listen (Deluxe)"); + result.name.should.equal("Listen"); done(); }); });