From de82213ddbae302e755128b01e6fdc4a9e8c7b6d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 20 Nov 2014 11:40:27 +0100 Subject: [PATCH 01/60] Start refactor for v0.2.0 - Move all logic to background - Start logging service logic --- README.md | 10 +- background/background.js | 0 manifest.json | 1 + src/background/ChromeHelper.js | 22 ++ src/background/LoggerService.js | 53 +++ src/background/ShazamService.js | 100 +++++ src/background/SpotifyService.js | 490 +++++++++++++++++++++++ src/background/StorageHelper.js | 61 +++ src/background/TagsService.js | 53 +++ src/background/background.js | 4 + src/popup/js/services/ShazamService.js | 101 +---- src/popup/js/services/SpotifyService.js | 491 +----------------------- src/popup/js/services/TagsService.js | 54 +-- 13 files changed, 795 insertions(+), 645 deletions(-) create mode 100644 background/background.js create mode 100644 src/background/ChromeHelper.js create mode 100644 src/background/LoggerService.js create mode 100644 src/background/ShazamService.js create mode 100644 src/background/SpotifyService.js create mode 100644 src/background/StorageHelper.js create mode 100644 src/background/TagsService.js create mode 100644 src/background/background.js diff --git a/README.md b/README.md index c458725..4e1e92b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ Shazam2Spotify Chrome extension used to export your Shazam tags to a Spotify playlist. [![ScreenShot](https://raw.githubusercontent.com/leeroybrun/chrome-shazam2spotify/master/promo_1400x560.png)](http://youtu.be/Zi1VRJqEI0Q) +``` + +### Roadmap for 0.2.0 + +- Add all the processing part to the background script. The popup will only remain here for displaying tags and calling background functions. +- Add logging to background script, and the possibility to export/view logs +- Add and advanced section (hidden at start, need to click it to display) for settings with : extension cache clear, spotify playlist removal, all clear (= the two) -> always close the extension when done ## How it works @@ -44,5 +51,4 @@ grunt build ### How to bundle ``` -grunt bundle -``` +grunt bundle \ No newline at end of file diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..e69de29 diff --git a/manifest.json b/manifest.json index d38c3df..c389135 100755 --- a/manifest.json +++ b/manifest.json @@ -19,6 +19,7 @@ "tabs", "identity", "storage", + "background", "http://www.shazam.com/*", "https://www.shazam.com/*", "https://api.spotify.com/*", diff --git a/src/background/ChromeHelper.js b/src/background/ChromeHelper.js new file mode 100644 index 0000000..1f2f9a1 --- /dev/null +++ b/src/background/ChromeHelper.js @@ -0,0 +1,22 @@ +var ChromeHelper = { + focusOrCreateTab: function(url) { + chrome.windows.getAll({"populate":true}, function(windows) { + var existing_tab = null; + for (var i in windows) { + var tabs = windows[i].tabs; + for (var j in tabs) { + var tab = tabs[j]; + if (tab.url == url) { + existing_tab = tab; + break; + } + } + } + if (existing_tab) { + chrome.tabs.update(existing_tab.id, {"selected":true}); + } else { + chrome.tabs.create({"url":url, "selected":true}); + } + }); + } +}; \ No newline at end of file diff --git a/src/background/LoggerService.js b/src/background/LoggerService.js new file mode 100644 index 0000000..00dcf3c --- /dev/null +++ b/src/background/LoggerService.js @@ -0,0 +1,53 @@ +var Logger = { + logs: [], + + add: function(newTag, callback) { + callback = callback || function(){}; + + newTag.spotifyId = newTag.spotifyId || null; + newTag.status = newTag.status || 1; // Status : 1 = just added, 2 = not found in spotify, 3 = found & added to playlist + + newTag.query = newTag.query || ''; + + var found = false; + for(var i in Tags.list) { + if(Tags.list[i].id == newTag.id) { + found = true; + $.extend(Tags.list[i], newTag); // Update existing tag + break; + } + } + + if(!found) { + Tags.list.push(newTag); + } + + Tags.list.sort(function (a, b) { + if (a.date > b.date) { return -1; } + if (a.date < b.date) { return 1; } + return 0; + }); + + callback(); + }, + + save: function(callback) { + callback = callback || function(){}; + + Logger.data.set({'logsList': Logger.logs}, function() { + callback(); + }); + }, + + load: function(callback) { + callback = callback || function(){}; + + Logger.data.get('logsList', function(items) { + Logger.logs = items.logsList || []; + + callback(); + }); + }, + + data: new StorageHelper('Logs') +}; \ No newline at end of file diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js new file mode 100644 index 0000000..353d4c5 --- /dev/null +++ b/src/background/ShazamService.js @@ -0,0 +1,100 @@ +var Shazam = { + newTags: [], + + data: new StorageHelper('Shazam'), + + openLogin: function() { + ChromeHelper.focusOrCreateTab('https://www.shazam.com/myshazam'); + }, + + loginStatus: function(callback) { + $http.get('https://www.shazam.com/fragment/myshazam') + .success(function() { + callback(true); + }) + .error(function(data, status) { + if(status === 401) { + callback(false); + } else { + callback(true); + } + }); + }, + + updateTags: function(path, callback) { + if(typeof callback === 'undefined' && typeof path === 'function') { + callback = path; + path = null; + } + + callback = callback || function(){}; + path = path || '/fragment/myshazam'; + + function saveTags(error) { + if(error) { + TagsService.save(function() { + callback(error); + }); + } else { + Shazam.data.set({'lastTagsUpdate': (new Date()).toString()}, function() { + TagsService.save(function() { + callback(); + }); + }); + } + } + + Shazam.data.get('lastTagsUpdate', function(items) { + var lastTagsUpdate = new Date(items.lastTagsUpdate) || new Date(0); + lastTagsUpdate = (!isNaN(lastTagsUpdate.valueOf())) ? lastTagsUpdate : new Date(0); + + $http.get('https://www.shazam.com'+ path) + .success(function(data) { + if(data && typeof data === 'object' && data.feed.indexOf('ms-no-tags') == -1) { + var lastTagDate = new Date(parseInt($('
').append(data.feed).find('article').last().find('.tl-date').attr('data-time'))); + + Shazam.parseTags(lastTagsUpdate, data.feed, function() { + if(data.previous && data.feed.indexOf('ms-no-tags') == -1 && lastTagDate > lastTagsUpdate) { + $timeout(function() { + Shazam.updateTags(data.previous, callback); + }, 2000); + } else { + saveTags(false); + } + }); + } else { + saveTags(false); + } + }) + .error(function(data, status) { + if(status === 401) { + saveTags(true); + } else { + saveTags(false); + } + }); + }); + }, + + parseTags: function(lastTagsUpdate, data, callback) { + $('
').append(data).find('article').each(function() { + var date = parseInt($('.tl-date', this).attr('data-time')); + + if(new Date(date) > lastTagsUpdate) { + var tag = { + id: $(this).attr('data-track-id'), + name: $('[data-track-title]', this).text().trim(), + artist: $('[data-track-artist]', this).text().trim().replace(/^by /, ''), + date: date, + image: $('img[itemprop="image"]', this).attr('src') + }; + + tag.query = SpotifyService.genQuery(tag.name, tag.artist); + + TagsService.add(tag); + } + }); + + callback(); + } +}; \ No newline at end of file diff --git a/src/background/SpotifyService.js b/src/background/SpotifyService.js new file mode 100644 index 0000000..780c783 --- /dev/null +++ b/src/background/SpotifyService.js @@ -0,0 +1,490 @@ +var Spotify = { + api: { + clientId: 'b0b7b50eac4642f482825c535bae2708', + clientSecret: 'b3bc17ef4d964fccb63b1f37af9101f8' + }, + + genQuery: function(track, artist) { + var reSpaces = new RegExp(' ', 'g'); + + return 'track:'+ track.replace(reSpaces, '+') +' artist:'+ artist.replace('Feat. ', '').replace(reSpaces, '+'); + }, + + getUserAndPlaylist: function(callback) { + Spotify.data.get(['userId', 'playlistId'], function(items) { + var userId = items.userId; + var playlistId = items.playlistId; + + async.waterfall([ + function checkUserId(cb) { + if(!userId) { + console.log('No userId stored, need to get one.'); + + Spotify.call({ + endpoint: '/v1/me', + method: 'GET' + }, function(err, data) { + if(err) { console.error(err); return cb(err); } + if(data && data.id) { console.error(data); return cb(new Error('Cannot get user ID')); } + + userId = data.id; + + cb(); + }); + } else { + cb(); + } + }, + + function checkPlaylistId(cb) { + if(!playlistId) { + console.log('No playlistId stored, need to getOrCreate.'); + Spotify.playlist.getOrCreate(function(err, data) { + if(data && data.id && !err) { + playlistId = data.id; + cb(); + } else { + cb(new Error('Error creating/getting playlist')); + } + }); + } else { + cb(); + } + } + ], function(err) { + callback(err, userId, playlistId); + }); + }); + }, + + playlist: { + name: chrome.i18n.getMessage('myTags'), + + get: function(callback) { + Spotify.getUserAndPlaylist(function(err, userId, playlistId) { + if(err) { return callback(err); } + + Spotify.call({ + method: 'GET', + endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId + }, function(err, data) { + if(err) { console.error(err); } + + callback(err, data); + }); + }); + }, + + create: function(callback) { + Spotify.data.get(['userId', 'playlistId'], function(items) { + var userId = items.userId; + var playlistId = items.playlistId; + + if(playlistId) { + console.log('PlaylistId exists in storage: '+ playlistId); + return Spotify.playlist.get(callback); + } + + Spotify.call({ + method: 'POST', + endpoint: '/v1/users/'+ userId +'/playlists', + data: { + 'name': Spotify.playlist.name, + 'public': false + } + }, function(err, data) { + if(err) { console.error(err); return callback(err); } + + Spotify.data.set({playlistId: data.id}, function() { + callback(null, data); + }); + }); + }); + }, + + getOrCreate: function(callback) { + var playlistName = Spotify.playlist.name; + + Spotify.data.get(['userId', 'playlistId'], function(items) { + var userId = items.userId; + var playlistId = items.playlistId; + + if(playlistId) { + return Spotify.playlist.get(callback); + } + + Spotify.findInPagedResult({ + method: 'GET', + endpoint: '/v1/users/'+ userId +'/playlists' + }, function(data, cbFind) { + var found = false; + + data.forEach(function(playlist) { + if(playlist.name == playlistName) { + found = playlist.id; + } + }); + + cbFind(found); + }, function(playlistId) { + if(playlistId) { + Spotify.data.set({playlistId: playlistId}, function() { + Spotify.playlist.get(callback); + }); + } else { + Spotify.playlist.create(callback); + } + }); + }); + }, + + searchAndAddTags: function(callback) { + var tracksAdded = []; + + async.eachSeries(TagsService.list, function(tag, cbi) { + if(tag.status > 1) { return cbi(); } + + tag.query = tag.query || 'track:'+ tag.name.replace(' ', '+') +' artist:'+ tag.artist.replace(' ', '+'); + + Spotify.playlist.searchAndAddTag(tag, tag.query, false, function(err) { + if(!err) { + tracksAdded.push(tag.spotifyId); + } + cbi(); + }); + }, function(err) { + TagsService.save(); + Spotify.playlist.addTracks(tracksAdded, function(err) { + callback(err); + }); + }); + }, + + searchAndAddTag: function(tag, query, shouldSave, callback) { + Spotify.call({ + endpoint: '/v1/search', + method: 'GET', + params: { + q: query, + type: 'track', + limit: 1 + } + }, function(err, data) { + if(err) { console.error(err); return callback(err); } + if(data.tracks.total === 0) { tag.status = 2; return callback(new Error('Not found')); } + + var track = data.tracks.items[0]; + + tag.spotifyId = track.id; + tag.status = 3; + + if(shouldSave) { + TagsService.save(); + Spotify.playlist.addTracks([tag.spotifyId], function(err) { + callback(err); + }); + } else { + callback(); + } + }); + }, + + addTracks: function(tracksIds, callback) { + Spotify.getUserAndPlaylist(function(err, userId, playlistId) { + if(err) { return callback(err); } + + var alreadyInPlaylist = []; + + // Check for already existing tracks in playlist + Spotify.findInPagedResult({ + method: 'GET', + endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks' + }, function(data, cbFind) { + // Check if tracks are already in playlist + data.forEach(function(track) { + if(tracksIds.indexOf(track.track.id) != -1) { + alreadyInPlaylist.push(track.track.id); + } + }); + + cbFind(false); + }, function() { + var tracksPaths = []; + tracksIds.forEach(function(id) { + if(alreadyInPlaylist.indexOf(id) == -1) { + tracksPaths.push('spotify:track:'+ id); + } else { + console.log('Track '+ id +' already in playlist.'); + } + }); + + // We don't have any tracks to add anymore + if(tracksPaths.length === 0) { + return callback(); + } + + Spotify.call({ + method: 'POST', + endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks', + data: tracksPaths + }, function(err, data) { + callback(err); + }); + }); + }); + } + }, + + data: new StorageHelper('Spotify', 'sync'), // New storage, synced with other Chrome installs + + getUrl: { + redirect: function() { + return 'https://'+ chrome.runtime.id +'.chromiumapp.org/spotify_cb'; + }, + + authorize: function() { + var params = { + client_id: Spotify.api.clientId, + response_type: 'code', + redirect_uri: Spotify.getUrl.redirect(), + scope: 'playlist-read-private playlist-modify-private' + }; + + return 'https://accounts.spotify.com/authorize/?'+ Helper.serializeUrlVars(params); + }, + + token: function() { + return 'https://accounts.spotify.com/api/token'; + } + }, + + findInPagedResult: function(callOptions, checkFind, callback) { + Spotify.call(callOptions, function(err, data) { + if(err) { console.error(err); } + + checkFind(data.items, function(found) { + if(found) { + return callback(found); + } + + // Not found, and no next page + if(!data.next) { + return callback(false); + } + + // Not found, but next page exists, load it + callOptions.endpoint = null; + callOptions.url = data.next; + Spotify.findInPagedResult(callOptions, checkFind, callback); + }); + }); + }, + + call: function(options, callback) { + Spotify.loginStatus(function(status) { + if(!status) { + return callback(new Error('You must login on Spotify.')); + } + + Spotify.data.get('accessToken', function(items) { + var accessToken = items.accessToken; + + $http({ + url: (options.endpoint && !options.url) ? 'https://api.spotify.com'+ options.endpoint : options.url, + method: options.method, + data: (options.data) ? options.data : null, + params: (options.params) ? options.params : null, + headers: { 'Authorization': 'Bearer '+ accessToken } + }) + .success(function(data) { + callback(null, data); + }) + .error(function(data, status) { + if(status === 401) { + Spotify.refreshToken(function(status) { + if(status === true) { + // Refresh/login successfull, retry call + Spotify.call(options, callback); + } else { + // Error... + callback(new Error('Please authorize Shazam2Spotify to access your Spotify account.')); + } + }); + } else { + callback(new Error('Error calling API')); + console.error('Error calling API : ', options, data, status); + } + }); + }); + }); + }, + + loginStatus: function(callback) { + Spotify.data.get(['accessToken', 'tokenTime', 'expiresIn'], function(items) { + // Don't have an access token ? We are not logged in... + if(!items.accessToken) { + return callback(false); + } + + if(!items.tokenTime) { + return callback(false); + } + + if(!items.expiresIn) { + return callback(false); + } + + // Token expired, we need to get one new with the refreshToken + if(new Date(new Date(items.tokenTime).getTime()+(items.expiresIn*1000)) <= new Date()) { + console.log('Token expired, we need to refresh it.'); + Spotify.refreshToken(callback); + } else { + callback(true); + } + }); + }, + + refreshToken: function(callback) { + Spotify.data.get('refreshToken', function(items) { + if(items.refreshToken) { + $http({ + url: Spotify.getUrl.token(), + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Authorization': 'Basic '+ window.btoa(Spotify.api.clientId+':'+Spotify.api.clientSecret) + }, + data: $.param({ + grant_type: 'refresh_token', + refresh_token: items.refreshToken + }) + }) + .success(function(data) { + Spotify.saveAccessToken(data, function(status) { + if(status === true) { + callback(true); + } else { + console.error('Error while refreshing token... open login.'); + Spotify.openLogin(true, callback); + } + }); + }) + .error(function(data, status) { + console.error('Error getting token : ', data, status); + callback(false); + }); + } else { + console.log('No refresh token stored... open login.'); + Spotify.openLogin(true, callback); + } + }); + }, + + getAccessToken: function(authCode, callback) { + $http({ + url: Spotify.getUrl.token(), + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, + data: $.param({ + grant_type: 'authorization_code', + code: authCode, + redirect_uri: Spotify.getUrl.redirect(), + client_id: Spotify.api.clientId, + client_secret: Spotify.api.clientSecret + }) + }) + .success(function(data) { + Spotify.saveAccessToken(data, callback); + }) + .error(function(data, status) { + console.error('Error getting token : ', data, status); + callback(false); + }); + }, + + saveAccessToken: function(data, callback) { + if(data.access_token && data.expires_in) { + Spotify.data.set({ + 'accessToken': data.access_token, + 'expiresIn': data.expires_in, + 'tokenTime': new Date().toString() + }, function() { + // In case of a refresh, the API will not return a new refreshToken + if(!data.refresh_token) { + return callback(true); + } + + Spotify.data.set({ + 'refreshToken': data.refresh_token + }, function() { + callback(true); + }); + }); + } else { + console.error('Error getting token : ', data); + callback(false); + } + }, + + openLogin: function(interactive, callback) { + if(typeof interactive === 'function') { + callback = interactive; + interactive = true; + } else if(typeof interactive === 'undefined') { + interactive = true; + callback = function(){}; + } + + chrome.identity.launchWebAuthFlow({'url': Spotify.getUrl.authorize(), 'interactive': interactive}, function(redirectUrl) { + console.log('redirectUrl: ', redirectUrl); + + if(!redirectUrl) { + callback(false); + return console.error('Authorization failed : redirect URL empty ('+ redirectUrl +')'); + } + + var params = Helper.getUrlVars(redirectUrl); + + console.log('AuthWebFlow returned params: ', params); + + if(params.error || !params.code) { + callback(false); + return console.error('Authorization has failed.', params.error); + } + + Spotify.data.set({'authCode': params.code}, function() { + Spotify.getAccessToken(params.code, function(status) { + if(!status) { + return callback(status); + } + + // We get the user ID + Spotify.call({ + endpoint: '/v1/me', + method: 'GET' + }, function(err, data) { + if(err) { console.err('Error getting user infos', err); } + + Spotify.data.set({'userId': data.id}, function() { + callback(true); + }); + }); + }); + }); + }); + }, + + disconnect: function(callback) { + callback = callback || function(){}; + + Spotify.data.set({ + 'authCode': null, + 'userId': null, + 'playlistId': null, + 'accessToken': null, + 'expiresIn': null, + 'refreshToken': null, + 'tokenTime': null + }, callback); + } +}; \ No newline at end of file diff --git a/src/background/StorageHelper.js b/src/background/StorageHelper.js new file mode 100644 index 0000000..fe978f5 --- /dev/null +++ b/src/background/StorageHelper.js @@ -0,0 +1,61 @@ +var StorageHelper = function(prefix, type) { + type = type || 'local'; + + this.type = (['local', 'sync'].indexOf(type) != -1) ? type : 'local'; + this.prefix = prefix; + this.cache = {}; +}; + +StorageHelper.prototype.get = function(names, callback) { + var storage = this; + + if(!Array.isArray(names)) { + names = [names]; + } + + // Check for each data we want if it's cached or not + var toGetFromStorage = []; + var data = {}; + names.forEach(function(name) { + if(name in storage.cache) { + data[name] = storage.cache[name]; + } else { + toGetFromStorage.push(storage.prefix+'_'+name); + } + }); + + // We've got all from cache, yay ! + if(toGetFromStorage.length === 0) { + return callback(data); + } + + // Get additional values from storage + chrome.storage[this.type].get(toGetFromStorage, function(items) { + for(var key in items) { + var name = key.replace(storage.prefix+'_', ''); // Retrive original name + + data[name] = JSON.parse(items[key]); + storage.cache[name] = data[name]; + } + + callback(data); + }); +}; + +StorageHelper.prototype.set = function(objects, callback) { + var data = {}; + for(var key in objects) { + this.cache[key] = objects[key]; + data[this.prefix+'_'+key] = JSON.stringify(objects[key]); + } + + chrome.storage[this.type].set(data, function() { + var error = null; + if(chrome.runtime.lastError) { + error = chrome.runtime.lastError; + console.error('An error occured during storage set: ', error); + } + + callback(error); + }); +}; \ No newline at end of file diff --git a/src/background/TagsService.js b/src/background/TagsService.js new file mode 100644 index 0000000..3422460 --- /dev/null +++ b/src/background/TagsService.js @@ -0,0 +1,53 @@ +var Tags = { + list: [], + + add: function(newTag, callback) { + callback = callback || function(){}; + + newTag.spotifyId = newTag.spotifyId || null; + newTag.status = newTag.status || 1; // Status : 1 = just added, 2 = not found in spotify, 3 = found & added to playlist + + newTag.query = newTag.query || ''; + + var found = false; + for(var i in Tags.list) { + if(Tags.list[i].id == newTag.id) { + found = true; + $.extend(Tags.list[i], newTag); // Update existing tag + break; + } + } + + if(!found) { + Tags.list.push(newTag); + } + + Tags.list.sort(function (a, b) { + if (a.date > b.date) { return -1; } + if (a.date < b.date) { return 1; } + return 0; + }); + + callback(); + }, + + save: function(callback) { + callback = callback || function(){}; + + Tags.data.set({'tagsList': Tags.list}, function() { + callback(); + }); + }, + + load: function(callback) { + callback = callback || function(){}; + + Tags.data.get('tagsList', function(items) { + Tags.list = items.tagsList || []; + + callback(); + }); + }, + + data: new StorageHelper('Tags') +}; \ No newline at end of file diff --git a/src/background/background.js b/src/background/background.js new file mode 100644 index 0000000..0691ff1 --- /dev/null +++ b/src/background/background.js @@ -0,0 +1,4 @@ +Tags.load(function() { + +}); + diff --git a/src/popup/js/services/ShazamService.js b/src/popup/js/services/ShazamService.js index 33639b8..486ae41 100644 --- a/src/popup/js/services/ShazamService.js +++ b/src/popup/js/services/ShazamService.js @@ -1,104 +1,5 @@ angular.module('Shazam2Spotify').factory('ShazamService', function(ChromeHelper, StorageHelper, TagsService, SpotifyService, $timeout, $http) { - var Shazam = { - newTags: [], - - data: new StorageHelper('Shazam'), - - openLogin: function() { - ChromeHelper.focusOrCreateTab('https://www.shazam.com/myshazam'); - }, - - loginStatus: function(callback) { - $http.get('https://www.shazam.com/fragment/myshazam') - .success(function() { - callback(true); - }) - .error(function(data, status) { - if(status === 401) { - callback(false); - } else { - callback(true); - } - }); - }, - - updateTags: function(path, callback) { - if(typeof callback === 'undefined' && typeof path === 'function') { - callback = path; - path = null; - } - - callback = callback || function(){}; - path = path || '/fragment/myshazam'; - - function saveTags(error) { - if(error) { - TagsService.save(function() { - callback(error); - }); - } else { - Shazam.data.set({'lastTagsUpdate': (new Date()).toString()}, function() { - TagsService.save(function() { - callback(); - }); - }); - } - } - - Shazam.data.get('lastTagsUpdate', function(items) { - var lastTagsUpdate = new Date(items.lastTagsUpdate) || new Date(0); - lastTagsUpdate = (!isNaN(lastTagsUpdate.valueOf())) ? lastTagsUpdate : new Date(0); - - $http.get('https://www.shazam.com'+ path) - .success(function(data) { - if(data && typeof data === 'object' && data.feed.indexOf('ms-no-tags') == -1) { - var lastTagDate = new Date(parseInt($('
').append(data.feed).find('article').last().find('.tl-date').attr('data-time'))); - - Shazam.parseTags(lastTagsUpdate, data.feed, function() { - if(data.previous && data.feed.indexOf('ms-no-tags') == -1 && lastTagDate > lastTagsUpdate) { - $timeout(function() { - Shazam.updateTags(data.previous, callback); - }, 2000); - } else { - saveTags(false); - } - }); - } else { - saveTags(false); - } - }) - .error(function(data, status) { - if(status === 401) { - saveTags(true); - } else { - saveTags(false); - } - }); - }); - }, - - parseTags: function(lastTagsUpdate, data, callback) { - $('
').append(data).find('article').each(function() { - var date = parseInt($('.tl-date', this).attr('data-time')); - - if(new Date(date) > lastTagsUpdate) { - var tag = { - id: $(this).attr('data-track-id'), - name: $('[data-track-title]', this).text().trim(), - artist: $('[data-track-artist]', this).text().trim().replace(/^by /, ''), - date: date, - image: $('img[itemprop="image"]', this).attr('src') - }; - - tag.query = SpotifyService.genQuery(tag.name, tag.artist); - - TagsService.add(tag); - } - }); - - callback(); - } - }; + return Shazam; }); \ No newline at end of file diff --git a/src/popup/js/services/SpotifyService.js b/src/popup/js/services/SpotifyService.js index f822534..eff6383 100644 --- a/src/popup/js/services/SpotifyService.js +++ b/src/popup/js/services/SpotifyService.js @@ -1,494 +1,5 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper, StorageHelper, Helper, TagsService, $timeout, $http) { - var Spotify = { - api: { - clientId: 'b0b7b50eac4642f482825c535bae2708', - clientSecret: 'b3bc17ef4d964fccb63b1f37af9101f8' - }, - - genQuery: function(track, artist) { - var reSpaces = new RegExp(' ', 'g'); - - return 'track:'+ track.replace(reSpaces, '+') +' artist:'+ artist.replace('Feat. ', '').replace(reSpaces, '+'); - }, - - getUserAndPlaylist: function(callback) { - Spotify.data.get(['userId', 'playlistId'], function(items) { - var userId = items.userId; - var playlistId = items.playlistId; - - async.waterfall([ - function checkUserId(cb) { - if(!userId) { - console.log('No userId stored, need to get one.'); - - Spotify.call({ - endpoint: '/v1/me', - method: 'GET' - }, function(err, data) { - if(err) { console.error(err); return cb(err); } - if(data && data.id) { console.error(data); return cb(new Error('Cannot get user ID')); } - - userId = data.id; - - cb(); - }); - } else { - cb(); - } - }, - - function checkPlaylistId(cb) { - if(!playlistId) { - console.log('No playlistId stored, need to getOrCreate.'); - Spotify.playlist.getOrCreate(function(err, data) { - if(data && data.id && !err) { - playlistId = data.id; - cb(); - } else { - cb(new Error('Error creating/getting playlist')); - } - }); - } else { - cb(); - } - } - ], function(err) { - callback(err, userId, playlistId); - }); - }); - }, - - playlist: { - name: chrome.i18n.getMessage('myTags'), - - get: function(callback) { - Spotify.getUserAndPlaylist(function(err, userId, playlistId) { - if(err) { return callback(err); } - - Spotify.call({ - method: 'GET', - endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId - }, function(err, data) { - if(err) { console.error(err); } - - callback(err, data); - }); - }); - }, - - create: function(callback) { - Spotify.data.get(['userId', 'playlistId'], function(items) { - var userId = items.userId; - var playlistId = items.playlistId; - - if(playlistId) { - console.log('PlaylistId exists in storage: '+ playlistId); - return Spotify.playlist.get(callback); - } - - Spotify.call({ - method: 'POST', - endpoint: '/v1/users/'+ userId +'/playlists', - data: { - 'name': Spotify.playlist.name, - 'public': false - } - }, function(err, data) { - if(err) { console.error(err); return callback(err); } - - Spotify.data.set({playlistId: data.id}, function() { - callback(null, data); - }); - }); - }); - }, - - getOrCreate: function(callback) { - var playlistName = Spotify.playlist.name; - - Spotify.data.get(['userId', 'playlistId'], function(items) { - var userId = items.userId; - var playlistId = items.playlistId; - - if(playlistId) { - return Spotify.playlist.get(callback); - } - - Spotify.findInPagedResult({ - method: 'GET', - endpoint: '/v1/users/'+ userId +'/playlists' - }, function(data, cbFind) { - var found = false; - - data.forEach(function(playlist) { - if(playlist.name == playlistName) { - found = playlist.id; - } - }); - - cbFind(found); - }, function(playlistId) { - if(playlistId) { - Spotify.data.set({playlistId: playlistId}, function() { - Spotify.playlist.get(callback); - }); - } else { - Spotify.playlist.create(callback); - } - }); - }); - }, - - searchAndAddTags: function(callback) { - var tracksAdded = []; - - async.eachSeries(TagsService.list, function(tag, cbi) { - if(tag.status > 1) { return cbi(); } - - tag.query = tag.query || 'track:'+ tag.name.replace(' ', '+') +' artist:'+ tag.artist.replace(' ', '+'); - - Spotify.playlist.searchAndAddTag(tag, tag.query, false, function(err) { - if(!err) { - tracksAdded.push(tag.spotifyId); - } - cbi(); - }); - }, function(err) { - TagsService.save(); - Spotify.playlist.addTracks(tracksAdded, function(err) { - callback(err); - }); - }); - }, - - searchAndAddTag: function(tag, query, shouldSave, callback) { - Spotify.call({ - endpoint: '/v1/search', - method: 'GET', - params: { - q: query, - type: 'track', - limit: 1 - } - }, function(err, data) { - if(err) { console.error(err); return callback(err); } - if(data.tracks.total === 0) { tag.status = 2; return callback(new Error('Not found')); } - - var track = data.tracks.items[0]; - - tag.spotifyId = track.id; - tag.status = 3; - - if(shouldSave) { - TagsService.save(); - Spotify.playlist.addTracks([tag.spotifyId], function(err) { - callback(err); - }); - } else { - callback(); - } - }); - }, - - addTracks: function(tracksIds, callback) { - Spotify.getUserAndPlaylist(function(err, userId, playlistId) { - if(err) { return callback(err); } - - var alreadyInPlaylist = []; - - // Check for already existing tracks in playlist - Spotify.findInPagedResult({ - method: 'GET', - endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks' - }, function(data, cbFind) { - // Check if tracks are already in playlist - data.forEach(function(track) { - if(tracksIds.indexOf(track.track.id) != -1) { - alreadyInPlaylist.push(track.track.id); - } - }); - - cbFind(false); - }, function() { - var tracksPaths = []; - tracksIds.forEach(function(id) { - if(alreadyInPlaylist.indexOf(id) == -1) { - tracksPaths.push('spotify:track:'+ id); - } else { - console.log('Track '+ id +' already in playlist.'); - } - }); - - // We don't have any tracks to add anymore - if(tracksPaths.length === 0) { - return callback(); - } - - Spotify.call({ - method: 'POST', - endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks', - data: tracksPaths - }, function(err, data) { - callback(err); - }); - }); - }); - } - }, - - data: new StorageHelper('Spotify', 'sync'), // New storage, synced with other Chrome installs - - getUrl: { - redirect: function() { - return 'https://'+ chrome.runtime.id +'.chromiumapp.org/spotify_cb'; - }, - - authorize: function() { - var params = { - client_id: Spotify.api.clientId, - response_type: 'code', - redirect_uri: Spotify.getUrl.redirect(), - scope: 'playlist-read-private playlist-modify-private' - }; - - return 'https://accounts.spotify.com/authorize/?'+ Helper.serializeUrlVars(params); - }, - - token: function() { - return 'https://accounts.spotify.com/api/token'; - } - }, - - findInPagedResult: function(callOptions, checkFind, callback) { - Spotify.call(callOptions, function(err, data) { - if(err) { console.error(err); } - - checkFind(data.items, function(found) { - if(found) { - return callback(found); - } - - // Not found, and no next page - if(!data.next) { - return callback(false); - } - - // Not found, but next page exists, load it - callOptions.endpoint = null; - callOptions.url = data.next; - Spotify.findInPagedResult(callOptions, checkFind, callback); - }); - }); - }, - - call: function(options, callback) { - Spotify.loginStatus(function(status) { - if(!status) { - return callback(new Error('You must login on Spotify.')); - } - - Spotify.data.get('accessToken', function(items) { - var accessToken = items.accessToken; - - $http({ - url: (options.endpoint && !options.url) ? 'https://api.spotify.com'+ options.endpoint : options.url, - method: options.method, - data: (options.data) ? options.data : null, - params: (options.params) ? options.params : null, - headers: { 'Authorization': 'Bearer '+ accessToken } - }) - .success(function(data) { - callback(null, data); - }) - .error(function(data, status) { - if(status === 401) { - Spotify.refreshToken(function(status) { - if(status === true) { - // Refresh/login successfull, retry call - Spotify.call(options, callback); - } else { - // Error... - callback(new Error('Please authorize Shazam2Spotify to access your Spotify account.')); - } - }); - } else { - callback(new Error('Error calling API')); - console.error('Error calling API : ', options, data, status); - } - }); - }); - }); - }, - - loginStatus: function(callback) { - Spotify.data.get(['accessToken', 'tokenTime', 'expiresIn'], function(items) { - // Don't have an access token ? We are not logged in... - if(!items.accessToken) { - return callback(false); - } - - if(!items.tokenTime) { - return callback(false); - } - - if(!items.expiresIn) { - return callback(false); - } - - // Token expired, we need to get one new with the refreshToken - if(new Date(new Date(items.tokenTime).getTime()+(items.expiresIn*1000)) <= new Date()) { - console.log('Token expired, we need to refresh it.'); - Spotify.refreshToken(callback); - } else { - callback(true); - } - }); - }, - - refreshToken: function(callback) { - Spotify.data.get('refreshToken', function(items) { - if(items.refreshToken) { - $http({ - url: Spotify.getUrl.token(), - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Authorization': 'Basic '+ window.btoa(Spotify.api.clientId+':'+Spotify.api.clientSecret) - }, - data: $.param({ - grant_type: 'refresh_token', - refresh_token: items.refreshToken - }) - }) - .success(function(data) { - Spotify.saveAccessToken(data, function(status) { - if(status === true) { - callback(true); - } else { - console.error('Error while refreshing token... open login.'); - Spotify.openLogin(true, callback); - } - }); - }) - .error(function(data, status) { - console.error('Error getting token : ', data, status); - callback(false); - }); - } else { - console.log('No refresh token stored... open login.'); - Spotify.openLogin(true, callback); - } - }); - }, - - getAccessToken: function(authCode, callback) { - $http({ - url: Spotify.getUrl.token(), - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, - data: $.param({ - grant_type: 'authorization_code', - code: authCode, - redirect_uri: Spotify.getUrl.redirect(), - client_id: Spotify.api.clientId, - client_secret: Spotify.api.clientSecret - }) - }) - .success(function(data) { - Spotify.saveAccessToken(data, callback); - }) - .error(function(data, status) { - console.error('Error getting token : ', data, status); - callback(false); - }); - }, - - saveAccessToken: function(data, callback) { - if(data.access_token && data.expires_in) { - Spotify.data.set({ - 'accessToken': data.access_token, - 'expiresIn': data.expires_in, - 'tokenTime': new Date().toString() - }, function() { - // In case of a refresh, the API will not return a new refreshToken - if(!data.refresh_token) { - return callback(true); - } - - Spotify.data.set({ - 'refreshToken': data.refresh_token - }, function() { - callback(true); - }); - }); - } else { - console.error('Error getting token : ', data); - callback(false); - } - }, - - openLogin: function(interactive, callback) { - if(typeof interactive === 'function') { - callback = interactive; - interactive = true; - } else if(typeof interactive === 'undefined') { - interactive = true; - callback = function(){}; - } - - chrome.identity.launchWebAuthFlow({'url': Spotify.getUrl.authorize(), 'interactive': interactive}, function(redirectUrl) { - console.log('redirectUrl: ', redirectUrl); - - if(!redirectUrl) { - callback(false); - return console.error('Authorization failed : redirect URL empty ('+ redirectUrl +')'); - } - - var params = Helper.getUrlVars(redirectUrl); - - console.log('AuthWebFlow returned params: ', params); - - if(params.error || !params.code) { - callback(false); - return console.error('Authorization has failed.', params.error); - } - - Spotify.data.set({'authCode': params.code}, function() { - Spotify.getAccessToken(params.code, function(status) { - if(!status) { - return callback(status); - } - - // We get the user ID - Spotify.call({ - endpoint: '/v1/me', - method: 'GET' - }, function(err, data) { - if(err) { console.err('Error getting user infos', err); } - - Spotify.data.set({'userId': data.id}, function() { - callback(true); - }); - }); - }); - }); - }); - }, - - disconnect: function(callback) { - callback = callback || function(){}; - - Spotify.data.set({ - 'authCode': null, - 'userId': null, - 'playlistId': null, - 'accessToken': null, - 'expiresIn': null, - 'refreshToken': null, - 'tokenTime': null - }, callback); - } - }; + return Spotify; }); \ No newline at end of file diff --git a/src/popup/js/services/TagsService.js b/src/popup/js/services/TagsService.js index 9fec940..473973c 100644 --- a/src/popup/js/services/TagsService.js +++ b/src/popup/js/services/TagsService.js @@ -1,57 +1,5 @@ angular.module('Shazam2Spotify').factory('TagsService', function(StorageHelper) { - var Tags = { - list: [], - - add: function(newTag, callback) { - callback = callback || function(){}; - - newTag.spotifyId = newTag.spotifyId || null; - newTag.status = newTag.status || 1; // Status : 1 = just added, 2 = not found in spotify, 3 = found & added to playlist - - newTag.query = newTag.query || ''; - - var found = false; - for(var i in Tags.list) { - if(Tags.list[i].id == newTag.id) { - found = true; - $.extend(Tags.list[i], newTag); // Update existing tag - break; - } - } - - if(!found) { - Tags.list.push(newTag); - } - - Tags.list.sort(function (a, b) { - if (a.date > b.date) { return -1; } - if (a.date < b.date) { return 1; } - return 0; - }); - - callback(); - }, - - save: function(callback) { - callback = callback || function(){}; - - Tags.data.set({'tagsList': Tags.list}, function() { - callback(); - }); - }, - - load: function(callback) { - callback = callback || function(){}; - - Tags.data.get('tagsList', function(items) { - Tags.list = items.tagsList || []; - - callback(); - }); - }, - - data: new StorageHelper('Tags') - }; + return Tags; }); \ No newline at end of file From 767875aec89500bdd4c1dea86042f38bee9ef99e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 21 Nov 2014 16:50:41 +0100 Subject: [PATCH 02/60] Continue --- background/background.js | 1980 ++++++++++++++++++++ gruntfile.js | 18 +- manifest.json | 5 +- package.json | 2 +- src/background/Helper.js | 34 + src/background/LoggerService.js | 65 +- src/background/ShazamService.js | 36 +- src/background/SpotifyService.js | 121 +- src/background/TagsService.js | 2 +- src/background/background.js | 3 +- src/background/header.js | 1 + src/background/lib/async.js | 1127 +++++++++++ src/background/lib/jquery.js | 5 + src/popup/js/controllers/SettingsCtrl.js | 14 +- src/popup/js/controllers/TagsCtrl.js | 28 +- src/popup/js/services/BackgroundService.js | 3 + 16 files changed, 3318 insertions(+), 126 deletions(-) create mode 100644 src/background/Helper.js create mode 100644 src/background/header.js create mode 100644 src/background/lib/async.js create mode 100644 src/background/lib/jquery.js create mode 100644 src/popup/js/services/BackgroundService.js diff --git a/background/background.js b/background/background.js index e69de29..dbb8453 100644 --- a/background/background.js +++ b/background/background.js @@ -0,0 +1,1980 @@ +/*! + * async + * https://github.com/caolan/async + * + * Copyright 2010-2014 Caolan McMahon + * Released under the MIT license + */ +// jshint ignore: start +/*jshint onevar: false, indent:4 */ +/*global setImmediate: false, setTimeout: false, console: false */ +(function () { + + var async = {}; + + // global on the server, window in the browser + var root, previous_async; + + root = this; + if (root != null) { + previous_async = root.async; + } + + async.noConflict = function () { + root.async = previous_async; + return async; + }; + + function only_once(fn) { + var called = false; + return function() { + if (called) throw new Error("Callback was already called."); + called = true; + fn.apply(root, arguments); + } + } + + //// cross-browser compatiblity functions //// + + var _toString = Object.prototype.toString; + + var _isArray = Array.isArray || function (obj) { + return _toString.call(obj) === '[object Array]'; + }; + + var _each = function (arr, iterator) { + if (arr.forEach) { + return arr.forEach(iterator); + } + for (var i = 0; i < arr.length; i += 1) { + iterator(arr[i], i, arr); + } + }; + + var _map = function (arr, iterator) { + if (arr.map) { + return arr.map(iterator); + } + var results = []; + _each(arr, function (x, i, a) { + results.push(iterator(x, i, a)); + }); + return results; + }; + + var _reduce = function (arr, iterator, memo) { + if (arr.reduce) { + return arr.reduce(iterator, memo); + } + _each(arr, function (x, i, a) { + memo = iterator(memo, x, i, a); + }); + return memo; + }; + + var _keys = function (obj) { + if (Object.keys) { + return Object.keys(obj); + } + var keys = []; + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + keys.push(k); + } + } + return keys; + }; + + //// exported async module functions //// + + //// nextTick implementation with browser-compatible fallback //// + if (typeof process === 'undefined' || !(process.nextTick)) { + if (typeof setImmediate === 'function') { + async.nextTick = function (fn) { + // not a direct alias for IE10 compatibility + setImmediate(fn); + }; + async.setImmediate = async.nextTick; + } + else { + async.nextTick = function (fn) { + setTimeout(fn, 0); + }; + async.setImmediate = async.nextTick; + } + } + else { + async.nextTick = process.nextTick; + if (typeof setImmediate !== 'undefined') { + async.setImmediate = function (fn) { + // not a direct alias for IE10 compatibility + setImmediate(fn); + }; + } + else { + async.setImmediate = async.nextTick; + } + } + + async.each = function (arr, iterator, callback) { + callback = callback || function () {}; + if (!arr.length) { + return callback(); + } + var completed = 0; + _each(arr, function (x) { + iterator(x, only_once(done) ); + }); + function done(err) { + if (err) { + callback(err); + callback = function () {}; + } + else { + completed += 1; + if (completed >= arr.length) { + callback(); + } + } + } + }; + async.forEach = async.each; + + async.eachSeries = function (arr, iterator, callback) { + callback = callback || function () {}; + if (!arr.length) { + return callback(); + } + var completed = 0; + var iterate = function () { + iterator(arr[completed], function (err) { + if (err) { + callback(err); + callback = function () {}; + } + else { + completed += 1; + if (completed >= arr.length) { + callback(); + } + else { + iterate(); + } + } + }); + }; + iterate(); + }; + async.forEachSeries = async.eachSeries; + + async.eachLimit = function (arr, limit, iterator, callback) { + var fn = _eachLimit(limit); + fn.apply(null, [arr, iterator, callback]); + }; + async.forEachLimit = async.eachLimit; + + var _eachLimit = function (limit) { + + return function (arr, iterator, callback) { + callback = callback || function () {}; + if (!arr.length || limit <= 0) { + return callback(); + } + var completed = 0; + var started = 0; + var running = 0; + + (function replenish () { + if (completed >= arr.length) { + return callback(); + } + + while (running < limit && started < arr.length) { + started += 1; + running += 1; + iterator(arr[started - 1], function (err) { + if (err) { + callback(err); + callback = function () {}; + } + else { + completed += 1; + running -= 1; + if (completed >= arr.length) { + callback(); + } + else { + replenish(); + } + } + }); + } + })(); + }; + }; + + + var doParallel = function (fn) { + return function () { + var args = Array.prototype.slice.call(arguments); + return fn.apply(null, [async.each].concat(args)); + }; + }; + var doParallelLimit = function(limit, fn) { + return function () { + var args = Array.prototype.slice.call(arguments); + return fn.apply(null, [_eachLimit(limit)].concat(args)); + }; + }; + var doSeries = function (fn) { + return function () { + var args = Array.prototype.slice.call(arguments); + return fn.apply(null, [async.eachSeries].concat(args)); + }; + }; + + + var _asyncMap = function (eachfn, arr, iterator, callback) { + arr = _map(arr, function (x, i) { + return {index: i, value: x}; + }); + if (!callback) { + eachfn(arr, function (x, callback) { + iterator(x.value, function (err) { + callback(err); + }); + }); + } else { + var results = []; + eachfn(arr, function (x, callback) { + iterator(x.value, function (err, v) { + results[x.index] = v; + callback(err); + }); + }, function (err) { + callback(err, results); + }); + } + }; + async.map = doParallel(_asyncMap); + async.mapSeries = doSeries(_asyncMap); + async.mapLimit = function (arr, limit, iterator, callback) { + return _mapLimit(limit)(arr, iterator, callback); + }; + + var _mapLimit = function(limit) { + return doParallelLimit(limit, _asyncMap); + }; + + // reduce only has a series version, as doing reduce in parallel won't + // work in many situations. + async.reduce = function (arr, memo, iterator, callback) { + async.eachSeries(arr, function (x, callback) { + iterator(memo, x, function (err, v) { + memo = v; + callback(err); + }); + }, function (err) { + callback(err, memo); + }); + }; + // inject alias + async.inject = async.reduce; + // foldl alias + async.foldl = async.reduce; + + async.reduceRight = function (arr, memo, iterator, callback) { + var reversed = _map(arr, function (x) { + return x; + }).reverse(); + async.reduce(reversed, memo, iterator, callback); + }; + // foldr alias + async.foldr = async.reduceRight; + + var _filter = function (eachfn, arr, iterator, callback) { + var results = []; + arr = _map(arr, function (x, i) { + return {index: i, value: x}; + }); + eachfn(arr, function (x, callback) { + iterator(x.value, function (v) { + if (v) { + results.push(x); + } + callback(); + }); + }, function (err) { + callback(_map(results.sort(function (a, b) { + return a.index - b.index; + }), function (x) { + return x.value; + })); + }); + }; + async.filter = doParallel(_filter); + async.filterSeries = doSeries(_filter); + // select alias + async.select = async.filter; + async.selectSeries = async.filterSeries; + + var _reject = function (eachfn, arr, iterator, callback) { + var results = []; + arr = _map(arr, function (x, i) { + return {index: i, value: x}; + }); + eachfn(arr, function (x, callback) { + iterator(x.value, function (v) { + if (!v) { + results.push(x); + } + callback(); + }); + }, function (err) { + callback(_map(results.sort(function (a, b) { + return a.index - b.index; + }), function (x) { + return x.value; + })); + }); + }; + async.reject = doParallel(_reject); + async.rejectSeries = doSeries(_reject); + + var _detect = function (eachfn, arr, iterator, main_callback) { + eachfn(arr, function (x, callback) { + iterator(x, function (result) { + if (result) { + main_callback(x); + main_callback = function () {}; + } + else { + callback(); + } + }); + }, function (err) { + main_callback(); + }); + }; + async.detect = doParallel(_detect); + async.detectSeries = doSeries(_detect); + + async.some = function (arr, iterator, main_callback) { + async.each(arr, function (x, callback) { + iterator(x, function (v) { + if (v) { + main_callback(true); + main_callback = function () {}; + } + callback(); + }); + }, function (err) { + main_callback(false); + }); + }; + // any alias + async.any = async.some; + + async.every = function (arr, iterator, main_callback) { + async.each(arr, function (x, callback) { + iterator(x, function (v) { + if (!v) { + main_callback(false); + main_callback = function () {}; + } + callback(); + }); + }, function (err) { + main_callback(true); + }); + }; + // all alias + async.all = async.every; + + async.sortBy = function (arr, iterator, callback) { + async.map(arr, function (x, callback) { + iterator(x, function (err, criteria) { + if (err) { + callback(err); + } + else { + callback(null, {value: x, criteria: criteria}); + } + }); + }, function (err, results) { + if (err) { + return callback(err); + } + else { + var fn = function (left, right) { + var a = left.criteria, b = right.criteria; + return a < b ? -1 : a > b ? 1 : 0; + }; + callback(null, _map(results.sort(fn), function (x) { + return x.value; + })); + } + }); + }; + + async.auto = function (tasks, callback) { + callback = callback || function () {}; + var keys = _keys(tasks); + var remainingTasks = keys.length + if (!remainingTasks) { + return callback(); + } + + var results = {}; + + var listeners = []; + var addListener = function (fn) { + listeners.unshift(fn); + }; + var removeListener = function (fn) { + for (var i = 0; i < listeners.length; i += 1) { + if (listeners[i] === fn) { + listeners.splice(i, 1); + return; + } + } + }; + var taskComplete = function () { + remainingTasks-- + _each(listeners.slice(0), function (fn) { + fn(); + }); + }; + + addListener(function () { + if (!remainingTasks) { + var theCallback = callback; + // prevent final callback from calling itself if it errors + callback = function () {}; + + theCallback(null, results); + } + }); + + _each(keys, function (k) { + var task = _isArray(tasks[k]) ? tasks[k]: [tasks[k]]; + var taskCallback = function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (args.length <= 1) { + args = args[0]; + } + if (err) { + var safeResults = {}; + _each(_keys(results), function(rkey) { + safeResults[rkey] = results[rkey]; + }); + safeResults[k] = args; + callback(err, safeResults); + // stop subsequent errors hitting callback multiple times + callback = function () {}; + } + else { + results[k] = args; + async.setImmediate(taskComplete); + } + }; + var requires = task.slice(0, Math.abs(task.length - 1)) || []; + var ready = function () { + return _reduce(requires, function (a, x) { + return (a && results.hasOwnProperty(x)); + }, true) && !results.hasOwnProperty(k); + }; + if (ready()) { + task[task.length - 1](taskCallback, results); + } + else { + var listener = function () { + if (ready()) { + removeListener(listener); + task[task.length - 1](taskCallback, results); + } + }; + addListener(listener); + } + }); + }; + + async.retry = function(times, task, callback) { + var DEFAULT_TIMES = 5; + var attempts = []; + // Use defaults if times not passed + if (typeof times === 'function') { + callback = task; + task = times; + times = DEFAULT_TIMES; + } + // Make sure times is a number + times = parseInt(times, 10) || DEFAULT_TIMES; + var wrappedTask = function(wrappedCallback, wrappedResults) { + var retryAttempt = function(task, finalAttempt) { + return function(seriesCallback) { + task(function(err, result){ + seriesCallback(!err || finalAttempt, {err: err, result: result}); + }, wrappedResults); + }; + }; + while (times) { + attempts.push(retryAttempt(task, !(times-=1))); + } + async.series(attempts, function(done, data){ + data = data[data.length - 1]; + (wrappedCallback || callback)(data.err, data.result); + }); + } + // If a callback is passed, run this as a controll flow + return callback ? wrappedTask() : wrappedTask + }; + + async.waterfall = function (tasks, callback) { + callback = callback || function () {}; + if (!_isArray(tasks)) { + var err = new Error('First argument to waterfall must be an array of functions'); + return callback(err); + } + if (!tasks.length) { + return callback(); + } + var wrapIterator = function (iterator) { + return function (err) { + if (err) { + callback.apply(null, arguments); + callback = function () {}; + } + else { + var args = Array.prototype.slice.call(arguments, 1); + var next = iterator.next(); + if (next) { + args.push(wrapIterator(next)); + } + else { + args.push(callback); + } + async.setImmediate(function () { + iterator.apply(null, args); + }); + } + }; + }; + wrapIterator(async.iterator(tasks))(); + }; + + var _parallel = function(eachfn, tasks, callback) { + callback = callback || function () {}; + if (_isArray(tasks)) { + eachfn.map(tasks, function (fn, callback) { + if (fn) { + fn(function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (args.length <= 1) { + args = args[0]; + } + callback.call(null, err, args); + }); + } + }, callback); + } + else { + var results = {}; + eachfn.each(_keys(tasks), function (k, callback) { + tasks[k](function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (args.length <= 1) { + args = args[0]; + } + results[k] = args; + callback(err); + }); + }, function (err) { + callback(err, results); + }); + } + }; + + async.parallel = function (tasks, callback) { + _parallel({ map: async.map, each: async.each }, tasks, callback); + }; + + async.parallelLimit = function(tasks, limit, callback) { + _parallel({ map: _mapLimit(limit), each: _eachLimit(limit) }, tasks, callback); + }; + + async.series = function (tasks, callback) { + callback = callback || function () {}; + if (_isArray(tasks)) { + async.mapSeries(tasks, function (fn, callback) { + if (fn) { + fn(function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (args.length <= 1) { + args = args[0]; + } + callback.call(null, err, args); + }); + } + }, callback); + } + else { + var results = {}; + async.eachSeries(_keys(tasks), function (k, callback) { + tasks[k](function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (args.length <= 1) { + args = args[0]; + } + results[k] = args; + callback(err); + }); + }, function (err) { + callback(err, results); + }); + } + }; + + async.iterator = function (tasks) { + var makeCallback = function (index) { + var fn = function () { + if (tasks.length) { + tasks[index].apply(null, arguments); + } + return fn.next(); + }; + fn.next = function () { + return (index < tasks.length - 1) ? makeCallback(index + 1): null; + }; + return fn; + }; + return makeCallback(0); + }; + + async.apply = function (fn) { + var args = Array.prototype.slice.call(arguments, 1); + return function () { + return fn.apply( + null, args.concat(Array.prototype.slice.call(arguments)) + ); + }; + }; + + var _concat = function (eachfn, arr, fn, callback) { + var r = []; + eachfn(arr, function (x, cb) { + fn(x, function (err, y) { + r = r.concat(y || []); + cb(err); + }); + }, function (err) { + callback(err, r); + }); + }; + async.concat = doParallel(_concat); + async.concatSeries = doSeries(_concat); + + async.whilst = function (test, iterator, callback) { + if (test()) { + iterator(function (err) { + if (err) { + return callback(err); + } + async.whilst(test, iterator, callback); + }); + } + else { + callback(); + } + }; + + async.doWhilst = function (iterator, test, callback) { + iterator(function (err) { + if (err) { + return callback(err); + } + var args = Array.prototype.slice.call(arguments, 1); + if (test.apply(null, args)) { + async.doWhilst(iterator, test, callback); + } + else { + callback(); + } + }); + }; + + async.until = function (test, iterator, callback) { + if (!test()) { + iterator(function (err) { + if (err) { + return callback(err); + } + async.until(test, iterator, callback); + }); + } + else { + callback(); + } + }; + + async.doUntil = function (iterator, test, callback) { + iterator(function (err) { + if (err) { + return callback(err); + } + var args = Array.prototype.slice.call(arguments, 1); + if (!test.apply(null, args)) { + async.doUntil(iterator, test, callback); + } + else { + callback(); + } + }); + }; + + async.queue = function (worker, concurrency) { + if (concurrency === undefined) { + concurrency = 1; + } + function _insert(q, data, pos, callback) { + if (!q.started){ + q.started = true; + } + if (!_isArray(data)) { + data = [data]; + } + if(data.length == 0) { + // call drain immediately if there are no tasks + return async.setImmediate(function() { + if (q.drain) { + q.drain(); + } + }); + } + _each(data, function(task) { + var item = { + data: task, + callback: typeof callback === 'function' ? callback : null + }; + + if (pos) { + q.tasks.unshift(item); + } else { + q.tasks.push(item); + } + + if (q.saturated && q.tasks.length === q.concurrency) { + q.saturated(); + } + async.setImmediate(q.process); + }); + } + + var workers = 0; + var q = { + tasks: [], + concurrency: concurrency, + saturated: null, + empty: null, + drain: null, + started: false, + paused: false, + push: function (data, callback) { + _insert(q, data, false, callback); + }, + kill: function () { + q.drain = null; + q.tasks = []; + }, + unshift: function (data, callback) { + _insert(q, data, true, callback); + }, + process: function () { + if (!q.paused && workers < q.concurrency && q.tasks.length) { + var task = q.tasks.shift(); + if (q.empty && q.tasks.length === 0) { + q.empty(); + } + workers += 1; + var next = function () { + workers -= 1; + if (task.callback) { + task.callback.apply(task, arguments); + } + if (q.drain && q.tasks.length + workers === 0) { + q.drain(); + } + q.process(); + }; + var cb = only_once(next); + worker(task.data, cb); + } + }, + length: function () { + return q.tasks.length; + }, + running: function () { + return workers; + }, + idle: function() { + return q.tasks.length + workers === 0; + }, + pause: function () { + if (q.paused === true) { return; } + q.paused = true; + }, + resume: function () { + if (q.paused === false) { return; } + q.paused = false; + // Need to call q.process once per concurrent + // worker to preserve full concurrency after pause + for (var w = 1; w <= q.concurrency; w++) { + async.setImmediate(q.process); + } + } + }; + return q; + }; + + async.priorityQueue = function (worker, concurrency) { + + function _compareTasks(a, b){ + return a.priority - b.priority; + }; + + function _binarySearch(sequence, item, compare) { + var beg = -1, + end = sequence.length - 1; + while (beg < end) { + var mid = beg + ((end - beg + 1) >>> 1); + if (compare(item, sequence[mid]) >= 0) { + beg = mid; + } else { + end = mid - 1; + } + } + return beg; + } + + function _insert(q, data, priority, callback) { + if (!q.started){ + q.started = true; + } + if (!_isArray(data)) { + data = [data]; + } + if(data.length == 0) { + // call drain immediately if there are no tasks + return async.setImmediate(function() { + if (q.drain) { + q.drain(); + } + }); + } + _each(data, function(task) { + var item = { + data: task, + priority: priority, + callback: typeof callback === 'function' ? callback : null + }; + + q.tasks.splice(_binarySearch(q.tasks, item, _compareTasks) + 1, 0, item); + + if (q.saturated && q.tasks.length === q.concurrency) { + q.saturated(); + } + async.setImmediate(q.process); + }); + } + + // Start with a normal queue + var q = async.queue(worker, concurrency); + + // Override push to accept second parameter representing priority + q.push = function (data, priority, callback) { + _insert(q, data, priority, callback); + }; + + // Remove unshift function + delete q.unshift; + + return q; + }; + + async.cargo = function (worker, payload) { + var working = false, + tasks = []; + + var cargo = { + tasks: tasks, + payload: payload, + saturated: null, + empty: null, + drain: null, + drained: true, + push: function (data, callback) { + if (!_isArray(data)) { + data = [data]; + } + _each(data, function(task) { + tasks.push({ + data: task, + callback: typeof callback === 'function' ? callback : null + }); + cargo.drained = false; + if (cargo.saturated && tasks.length === payload) { + cargo.saturated(); + } + }); + async.setImmediate(cargo.process); + }, + process: function process() { + if (working) return; + if (tasks.length === 0) { + if(cargo.drain && !cargo.drained) cargo.drain(); + cargo.drained = true; + return; + } + + var ts = typeof payload === 'number' + ? tasks.splice(0, payload) + : tasks.splice(0, tasks.length); + + var ds = _map(ts, function (task) { + return task.data; + }); + + if(cargo.empty) cargo.empty(); + working = true; + worker(ds, function () { + working = false; + + var args = arguments; + _each(ts, function (data) { + if (data.callback) { + data.callback.apply(null, args); + } + }); + + process(); + }); + }, + length: function () { + return tasks.length; + }, + running: function () { + return working; + } + }; + return cargo; + }; + + var _console_fn = function (name) { + return function (fn) { + var args = Array.prototype.slice.call(arguments, 1); + fn.apply(null, args.concat([function (err) { + var args = Array.prototype.slice.call(arguments, 1); + if (typeof console !== 'undefined') { + if (err) { + if (console.error) { + console.error(err); + } + } + else if (console[name]) { + _each(args, function (x) { + console[name](x); + }); + } + } + }])); + }; + }; + async.log = _console_fn('log'); + async.dir = _console_fn('dir'); + /*async.info = _console_fn('info'); + async.warn = _console_fn('warn'); + async.error = _console_fn('error');*/ + + async.memoize = function (fn, hasher) { + var memo = {}; + var queues = {}; + hasher = hasher || function (x) { + return x; + }; + var memoized = function () { + var args = Array.prototype.slice.call(arguments); + var callback = args.pop(); + var key = hasher.apply(null, args); + if (key in memo) { + async.nextTick(function () { + callback.apply(null, memo[key]); + }); + } + else if (key in queues) { + queues[key].push(callback); + } + else { + queues[key] = [callback]; + fn.apply(null, args.concat([function () { + memo[key] = arguments; + var q = queues[key]; + delete queues[key]; + for (var i = 0, l = q.length; i < l; i++) { + q[i].apply(null, arguments); + } + }])); + } + }; + memoized.memo = memo; + memoized.unmemoized = fn; + return memoized; + }; + + async.unmemoize = function (fn) { + return function () { + return (fn.unmemoized || fn).apply(null, arguments); + }; + }; + + async.times = function (count, iterator, callback) { + var counter = []; + for (var i = 0; i < count; i++) { + counter.push(i); + } + return async.map(counter, iterator, callback); + }; + + async.timesSeries = function (count, iterator, callback) { + var counter = []; + for (var i = 0; i < count; i++) { + counter.push(i); + } + return async.mapSeries(counter, iterator, callback); + }; + + async.seq = function (/* functions... */) { + var fns = arguments; + return function () { + var that = this; + var args = Array.prototype.slice.call(arguments); + var callback = args.pop(); + async.reduce(fns, args, function (newargs, fn, cb) { + fn.apply(that, newargs.concat([function () { + var err = arguments[0]; + var nextargs = Array.prototype.slice.call(arguments, 1); + cb(err, nextargs); + }])) + }, + function (err, results) { + callback.apply(that, [err].concat(results)); + }); + }; + }; + + async.compose = function (/* functions... */) { + return async.seq.apply(null, Array.prototype.reverse.call(arguments)); + }; + + var _applyEach = function (eachfn, fns /*args...*/) { + var go = function () { + var that = this; + var args = Array.prototype.slice.call(arguments); + var callback = args.pop(); + return eachfn(fns, function (fn, cb) { + fn.apply(that, args.concat([cb])); + }, + callback); + }; + if (arguments.length > 2) { + var args = Array.prototype.slice.call(arguments, 2); + return go.apply(this, args); + } + else { + return go; + } + }; + async.applyEach = doParallel(_applyEach); + async.applyEachSeries = doSeries(_applyEach); + + async.forever = function (fn, callback) { + function next(err) { + if (err) { + if (callback) { + return callback(err); + } + throw err; + } + fn(next); + } + next(); + }; + + // Node.js + if (typeof module !== 'undefined' && module.exports) { + module.exports = async; + } + // AMD / RequireJS + else if (typeof define !== 'undefined' && define.amd) { + define([], function () { + return async; + }); + } + // included directly via + + diff --git a/src/popup/js/app.js b/src/popup/js/app.js index bb68ad6..2b912c3 100644 --- a/src/popup/js/app.js +++ b/src/popup/js/app.js @@ -1,4 +1,4 @@ -angular.module('Shazam2Spotify', ['ngRoute', 'ngAnimate']) +angular.module('Shazam2Spotify', ['ngRoute', 'ngAnimate', 'angulartics', 'angulartics.google.analytics']) .config(['$routeProvider', function($routeProvider) { $routeProvider .when('/', { From eee24a0cdd1f0b8454ee9698fdb3d54564b96c2a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:15:20 +0100 Subject: [PATCH 22/60] Log message when popup closed --- src/popup/js/app.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/popup/js/app.js b/src/popup/js/app.js index 2b912c3..c99186b 100644 --- a/src/popup/js/app.js +++ b/src/popup/js/app.js @@ -24,4 +24,8 @@ angular.module('Shazam2Spotify', ['ngRoute', 'ngAnimate', 'angulartics', 'angula $("body").prepend(data); } }); + + addEventListener('unload', function (event) { + chrome.extension.getBackgroundPage().s2s.Logger.info('[core] Popup closed.'); + }, true); }]); \ No newline at end of file From f65095b44e06976f0b35c3cf0dc5ebd232e77306 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:26:34 +0100 Subject: [PATCH 23/60] Exported logs always open in new tab --- src/popup/js/helpers/ChromeHelper.js | 58 +++++++++++++++++++--------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/popup/js/helpers/ChromeHelper.js b/src/popup/js/helpers/ChromeHelper.js index 7560467..a92446f 100644 --- a/src/popup/js/helpers/ChromeHelper.js +++ b/src/popup/js/helpers/ChromeHelper.js @@ -1,24 +1,44 @@ angular.module('Shazam2Spotify').factory('ChromeHelper', function() { var ChromeHelper = { + findExistingTab: function(url, callback) { + chrome.windows.getAll({'populate': true}, function(windows) { + var existing_tab = null; + for (var i in windows) { + var tabs = windows[i].tabs; + for (var j in tabs) { + var tab = tabs[j]; + if (tab.url == url) { + existing_tab = tab; + break; + } + } + } + + callback(existing_tab); + }); + }, + + // Remove existing tab, and recreate it + removeAndCreateTab: function(url) { + ChromeHelper.findExistingTab(url, function(existing_tab) { + if (existing_tab) { + chrome.tabs.remove(existing_tab.id); + } + + chrome.tabs.create({'url': url, 'selected': true}); + }); + }, + + // Focus existing tab or create it focusOrCreateTab: function(url) { - chrome.windows.getAll({"populate":true}, function(windows) { - var existing_tab = null; - for (var i in windows) { - var tabs = windows[i].tabs; - for (var j in tabs) { - var tab = tabs[j]; - if (tab.url == url) { - existing_tab = tab; - break; - } - } - } - if (existing_tab) { - chrome.tabs.update(existing_tab.id, {"selected":true}); - } else { - chrome.tabs.create({"url":url, "selected":true}); - } - }); + ChromeHelper.findExistingTab(url, function(existing_tab) { + if (existing_tab) { + chrome.tabs.reload(existing_tab.id, {'bypassCache': true}); + chrome.tabs.update(existing_tab.id, {'selected': true}); + } else { + chrome.tabs.create({'url': url, 'selected': true}); + } + }); }, exportData: function(fileName, data) { @@ -36,7 +56,7 @@ angular.module('Shazam2Spotify').factory('ChromeHelper', function() { return; } - ChromeHelper.focusOrCreateTab(fileEntry.toURL()); + ChromeHelper.removeAndCreateTab(fileEntry.toURL()); }; fileWriter.onerror = function(error) { From bb7c6544c8cd64fff090eeaa523416c7b711d6ba Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:31:31 +0100 Subject: [PATCH 24/60] Update TODO --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9a72f61..14a0b0d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Create TagService in Angular side - Try tags updating refresh (interval) on Windows - Add analytics -- When "Export logs", close perviously opened log tab or reload ? - Show info about new version and how to report bugs, when the ext has been updated ### Roadmap for 0.3.0 From 0cc5e4906cc333a2231272ae6862574938d14056 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:35:35 +0100 Subject: [PATCH 25/60] Move routes and cnfig to separate files --- src/popup/js/app.js | 32 +------------------------------ src/popup/js/config/closeEvent.js | 6 ++++++ src/popup/js/config/routes.js | 17 ++++++++++++++++ src/popup/js/config/svgIcons.js | 12 ++++++++++++ 4 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 src/popup/js/config/closeEvent.js create mode 100644 src/popup/js/config/routes.js create mode 100644 src/popup/js/config/svgIcons.js diff --git a/src/popup/js/app.js b/src/popup/js/app.js index c99186b..4cd62d1 100644 --- a/src/popup/js/app.js +++ b/src/popup/js/app.js @@ -1,31 +1 @@ -angular.module('Shazam2Spotify', ['ngRoute', 'ngAnimate', 'angulartics', 'angulartics.google.analytics']) - .config(['$routeProvider', function($routeProvider) { - $routeProvider - .when('/', { - templateUrl: 'partials/tags.html', - controller: 'TagsCtrl' - }) - .when('/settings', { - templateUrl: 'partials/settings.html', - controller: 'SettingsCtrl' - }) - .when('/intro', { - templateUrl: 'partials/intro.html', - controller: 'IntroCtrl' - }) - .otherwise({redirectTo: '/'}); - - // Load SVG icons (dirty, should not use jQuery...) - $.ajax({ - url: 'img/icons.svg', - method: 'GET', - dataType: 'html', - success: function(data) { - $("body").prepend(data); - } - }); - - addEventListener('unload', function (event) { - chrome.extension.getBackgroundPage().s2s.Logger.info('[core] Popup closed.'); - }, true); - }]); \ No newline at end of file +angular.module('Shazam2Spotify', ['ngRoute', 'ngAnimate', 'angulartics', 'angulartics.google.analytics']); \ No newline at end of file diff --git a/src/popup/js/config/closeEvent.js b/src/popup/js/config/closeEvent.js new file mode 100644 index 0000000..97dd30d --- /dev/null +++ b/src/popup/js/config/closeEvent.js @@ -0,0 +1,6 @@ +angular.module('Shazam2Spotify') + .config(function() { + addEventListener('unload', function (event) { + chrome.extension.getBackgroundPage().s2s.Logger.info('[core] Popup closed.'); + }, true); + }); \ No newline at end of file diff --git a/src/popup/js/config/routes.js b/src/popup/js/config/routes.js new file mode 100644 index 0000000..2cb6720 --- /dev/null +++ b/src/popup/js/config/routes.js @@ -0,0 +1,17 @@ +angular.module('Shazam2Spotify') + .config(['$routeProvider', function($routeProvider) { + $routeProvider + .when('/', { + templateUrl: 'partials/tags.html', + controller: 'TagsCtrl' + }) + .when('/settings', { + templateUrl: 'partials/settings.html', + controller: 'SettingsCtrl' + }) + .when('/intro', { + templateUrl: 'partials/intro.html', + controller: 'IntroCtrl' + }) + .otherwise({redirectTo: '/'}); + }]); \ No newline at end of file diff --git a/src/popup/js/config/svgIcons.js b/src/popup/js/config/svgIcons.js new file mode 100644 index 0000000..13432a8 --- /dev/null +++ b/src/popup/js/config/svgIcons.js @@ -0,0 +1,12 @@ +angular.module('Shazam2Spotify') + .config(function() { + // Load SVG icons (dirty, should not use jQuery...) + $.ajax({ + url: 'img/icons.svg', + method: 'GET', + dataType: 'html', + success: function(data) { + $("body").prepend(data); + } + }); + }); \ No newline at end of file From 56d4be2025728e60000383b026b759c719bf2853 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:37:46 +0100 Subject: [PATCH 26/60] Update TODO --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 14a0b0d..e7667c9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Create TagService in Angular side - Try tags updating refresh (interval) on Windows - Add analytics + - Add custom events + - Intro + - Refresh + - Export logs + - Clean data + - Logout/login in settings +- Intro + - When clicking on Spotify auth button, show loader - Show info about new version and how to report bugs, when the ext has been updated ### Roadmap for 0.3.0 From 0c1e5c901b312cfa9716bc1eaff9597b1773cbf8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:39:39 +0100 Subject: [PATCH 27/60] Update TODO --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e7667c9..8d2189f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Logout/login in settings - Intro - When clicking on Spotify auth button, show loader +- Find better alternative to Helvetica Neue (for Windows) - Show info about new version and how to report bugs, when the ext has been updated ### Roadmap for 0.3.0 From 3ccc1c05caee48ca3c57067427226d27a8b92ff1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 19:55:46 +0100 Subject: [PATCH 28/60] Update TODO --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d2189f..7f2094f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Create TagService in Angular side - Try tags updating refresh (interval) on Windows + - Move individual tag search to TagsService - Add analytics - Add custom events - Intro From 03b4434be4ab6b98b01fdef15f67a0d881512f78 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:14:15 +0100 Subject: [PATCH 29/60] Move search specific tag logic to background & service --- src/background/background.js | 12 ++++++++++++ src/popup/js/controllers/TagsCtrl.js | 8 ++------ src/popup/js/services/TagsService.js | 8 ++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/background/background.js b/src/background/background.js index ce3fce7..4f74694 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -57,6 +57,18 @@ $(document).ready(function() { }); }; + s2s.searchTag = function(trackName, artist, tag, callback) { + var query = s2s.Spotify.genQuery(trackName, artist); + + s2s.Spotify.playlist.searchAndAddTag(tag, query, true, function(error) { + if(error) { + callback(true); + } else { + callback(); + } + }); + }; + // When we receive a "clearStorage" message, we need to close popup and then clear storage chrome.extension.onMessage.addListener(function(request,sender,sendResponse) { diff --git a/src/popup/js/controllers/TagsCtrl.js b/src/popup/js/controllers/TagsCtrl.js index c05cf22..19153c8 100644 --- a/src/popup/js/controllers/TagsCtrl.js +++ b/src/popup/js/controllers/TagsCtrl.js @@ -13,18 +13,14 @@ angular.module('Shazam2Spotify').controller('TagsCtrl', function($scope, $locati track: '' }, send: function() { - var query = BackgroundService.Spotify.genQuery($scope.newSearch.query.track, $scope.newSearch.query.artist); - - BackgroundService.Spotify.playlist.searchAndAddTag($scope.newSearch.tag, query, true, function(error) { - if(error) { + TagsService.searchTag($scope.newSearch.query.track, $scope.newSearch.query.artist, $scope.newSearch.tag, function(err) { + if(err) { $scope.newSearch.error = chrome.i18n.getMessage('noTrackFoundQuery'); } else { $scope.newSearch.error = null; $scope.newSearch.tag = null; $scope.newSearch.show = false; } - - $scope.$apply(); }); }, cancel: function() { diff --git a/src/popup/js/services/TagsService.js b/src/popup/js/services/TagsService.js index d03350a..76e918d 100644 --- a/src/popup/js/services/TagsService.js +++ b/src/popup/js/services/TagsService.js @@ -38,6 +38,14 @@ angular.module('Shazam2Spotify').factory('TagsService', function($timeout, $inte }, 0); }); }); + }, + + searchTag: function(trackName, artist, tag, callback) { + BackgroundService.searchTag(trackName, artist, tag, function(error) { + $timeout(function() { + callback(error); + }, 0); + }); } }; From 6666e2f1059d6ca520fdc232918f78b23e465722 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:14:33 +0100 Subject: [PATCH 30/60] Update TODO --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f2094f..878c504 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Create TagService in Angular side - Try tags updating refresh (interval) on Windows - - Move individual tag search to TagsService - Add analytics - Add custom events - Intro @@ -65,6 +64,7 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Logout/login in settings - Intro - When clicking on Spotify auth button, show loader + - Error in response to storage.set: TypeError: undefined is not a function - Find better alternative to Helvetica Neue (for Windows) - Show info about new version and how to report bugs, when the ext has been updated From eba5c60fe09fee2b39f1955c6865063861e51907 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:32:36 +0100 Subject: [PATCH 31/60] Disable Spotify login button when clicked --- popup/partials/intro.html | 2 +- src/popup/css/popup.css | 1 + src/popup/js/services/LoginService.js | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/popup/partials/intro.html b/popup/partials/intro.html index a402840..946187c 100644 --- a/popup/partials/intro.html +++ b/popup/partials/intro.html @@ -45,7 +45,7 @@

introSpotifyLoggedOutText
introSpotifyLoggedInText
-

introOpenSpotifyLogin

+

diff --git a/src/popup/css/popup.css b/src/popup/css/popup.css index 647d265..b01c8b7 100644 --- a/src/popup/css/popup.css +++ b/src/popup/css/popup.css @@ -364,6 +364,7 @@ https://github.com/yui/pure/blob/master/LICENSE.md .intro-steps .step .pure-button { font-size:16px; + font-weight:300; } .intro-steps .step h1 .iconmelon { diff --git a/src/popup/js/services/LoginService.js b/src/popup/js/services/LoginService.js index d8c9d85..a7fccbd 100644 --- a/src/popup/js/services/LoginService.js +++ b/src/popup/js/services/LoginService.js @@ -42,10 +42,14 @@ angular.module('Shazam2Spotify').factory('LoginService', function(BackgroundServ spotify: { status: false, + loginOpened: false, openLogin: function() { + LoginService.spotify.loginOpened = true; + BackgroundService.Spotify.openLogin(function(status) { $timeout(function() { + LoginService.spotify.loginOpened = false; LoginService.spotify.status = status; }, 0); }); From 91ef62f270b1fdc13a2a16d343b3ae9eae8fcf14 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:39:50 +0100 Subject: [PATCH 32/60] Add default callback for StorageHelper.set --- src/background/StorageHelper.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/background/StorageHelper.js b/src/background/StorageHelper.js index a18dda4..78df4c6 100644 --- a/src/background/StorageHelper.js +++ b/src/background/StorageHelper.js @@ -42,12 +42,15 @@ try { callback(data); } catch(e) { + Logger.error('An error occured in storage.get callback:'); Logger.error(e); } }); }; StorageHelper.prototype.set = function(objects, callback) { + callback = callback || function(){}; + var data = {}; for(var key in objects) { this.cache[key] = objects[key]; @@ -61,7 +64,12 @@ console.error('An error occured during storage set: ', error); } - callback(error); + try { + callback(error); + } catch(e) { + Logger.error('An error occured in storage.set callback:'); + Logger.error(e); + } }); }; From 5c9aadbfcae02dfdfcacd584d7914338577f0965 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:40:13 +0100 Subject: [PATCH 33/60] Update TODO --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 878c504..8d2189f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Logout/login in settings - Intro - When clicking on Spotify auth button, show loader - - Error in response to storage.set: TypeError: undefined is not a function - Find better alternative to Helvetica Neue (for Windows) - Show info about new version and how to report bugs, when the ext has been updated From 54181a9a43eddee7a19e8fd7ffb29a37ee8fd4d9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 20:50:26 +0100 Subject: [PATCH 34/60] Load SVG icons with $http instead of jQuery --- src/popup/js/config/svgIcons.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/popup/js/config/svgIcons.js b/src/popup/js/config/svgIcons.js index 13432a8..fd923d0 100644 --- a/src/popup/js/config/svgIcons.js +++ b/src/popup/js/config/svgIcons.js @@ -1,12 +1,8 @@ angular.module('Shazam2Spotify') - .config(function() { + .run(function($http) { // Load SVG icons (dirty, should not use jQuery...) - $.ajax({ - url: 'img/icons.svg', - method: 'GET', - dataType: 'html', - success: function(data) { - $("body").prepend(data); - } - }); + $http.get('img/icons.svg', {responseType: 'html'}). + success(function(data) { + angular.element('body').prepend(data); + }); }); \ No newline at end of file From 04144f5152401c524a0b9135cbc65f3b5cb5a5e7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 21:00:40 +0100 Subject: [PATCH 35/60] Remove jQuery dependency in popup --- popup/popup.html | 1 - src/popup/js/config/svgIcons.js | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/popup/popup.html b/popup/popup.html index 489a40c..f191be5 100755 --- a/popup/popup.html +++ b/popup/popup.html @@ -3,7 +3,6 @@ - diff --git a/src/popup/js/config/svgIcons.js b/src/popup/js/config/svgIcons.js index fd923d0..aab3115 100644 --- a/src/popup/js/config/svgIcons.js +++ b/src/popup/js/config/svgIcons.js @@ -1,8 +1,8 @@ angular.module('Shazam2Spotify') - .run(function($http) { + .run(function($http, $document) { // Load SVG icons (dirty, should not use jQuery...) $http.get('img/icons.svg', {responseType: 'html'}). success(function(data) { - angular.element('body').prepend(data); + angular.element($document[0]).find('body').prepend(data); }); }); \ No newline at end of file From 00df3ded569f9d39894241bf29991ce6b638406f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 9 Dec 2014 21:02:07 +0100 Subject: [PATCH 36/60] Stop icon rotation when we clear storage --- src/background/background.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/background/background.js b/src/background/background.js index 4f74694..d412955 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -90,6 +90,7 @@ $(document).ready(function() { s2s.Tags.data.clearCache(); s2s.Shazam.data.clearCache(); s2s.Spotify.data.clearCache(); + s2s.CanvasIcon.stopRotation(); } }); }); \ No newline at end of file From b9bda179811a9bdbd829eae59d9138529d713edd Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 10:31:23 +0100 Subject: [PATCH 37/60] Roboto to replace Helvetica Neue --- manifest.json | 2 +- popup/popup.html | 1 + src/popup/css/popup.css | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 4b5e5b7..56e0d3d 100755 --- a/manifest.json +++ b/manifest.json @@ -28,5 +28,5 @@ "https://api.spotify.com/*", "https://accounts.spotify.com/api/*" ], - "content_security_policy": "script-src 'self' https://ssl.google-analytics.com; object-src 'self'" + "content_security_policy": "script-src 'self' https://ssl.google-analytics.com https://fonts.googleapis.com; object-src 'self'" } \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html index f191be5..9ef72c7 100755 --- a/popup/popup.html +++ b/popup/popup.html @@ -9,6 +9,7 @@ +
diff --git a/src/popup/css/popup.css b/src/popup/css/popup.css index b01c8b7..cbfee86 100644 --- a/src/popup/css/popup.css +++ b/src/popup/css/popup.css @@ -36,7 +36,7 @@ https://github.com/yui/pure/blob/master/LICENSE.md /* General */ body { - font-family:'Helvetica Neue', Helvetica, sans-serif; + font-family:Roboto, "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; font-size:12px; line-height:1.5; margin:12px 15px; From 9da05767978c9b5b0fc337cba08f1e7171401a90 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 10:31:47 +0100 Subject: [PATCH 38/60] Update TODO --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 8d2189f..3d04069 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,6 @@ Some parts of the code are really messy. This should be cleaned in the next vers ### Roadmap for 0.2.0 -- Create TagService in Angular side - - Try tags updating refresh (interval) on Windows - Add analytics - Add custom events - Intro @@ -64,7 +62,6 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Logout/login in settings - Intro - When clicking on Spotify auth button, show loader -- Find better alternative to Helvetica Neue (for Windows) - Show info about new version and how to report bugs, when the ext has been updated ### Roadmap for 0.3.0 From 0e1cea044b39826a32f29c1e53c1ee587301fb85 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 11:39:02 +0100 Subject: [PATCH 39/60] Better font weights & sizes --- src/popup/css/popup.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/popup/css/popup.css b/src/popup/css/popup.css index cbfee86..f993440 100644 --- a/src/popup/css/popup.css +++ b/src/popup/css/popup.css @@ -258,8 +258,8 @@ https://github.com/yui/pure/blob/master/LICENSE.md .topbar h2 { margin-top:0; margin-bottom:0; - font-weight:300; - font-size:20px; + font-weight:400; + font-size:18px; } .topbar .right { @@ -500,6 +500,10 @@ https://github.com/yui/pure/blob/master/LICENSE.md /* Settings */ + .settings h3 { + font-weight:500; + } + .login-status { overflow:hidden; } From 0e3fe79f657bc871b20529cc67116731a987c7b7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 11:39:14 +0100 Subject: [PATCH 40/60] Update TODO --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d04069..fd6526b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Clean data - Logout/login in settings - Intro - - When clicking on Spotify auth button, show loader + - When clicking on Spotify auth button, show loader (http://spiffygif.com/) - Show info about new version and how to report bugs, when the ext has been updated ### Roadmap for 0.3.0 From 7974df919bd1e992db71aacdffbd4e99e7881ec7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 17:45:57 +0100 Subject: [PATCH 41/60] Update TODO --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fd6526b..d9b627e 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,15 @@ Some parts of the code are really messy. This should be cleaned in the next vers - Intro - When clicking on Spotify auth button, show loader (http://spiffygif.com/) - Show info about new version and how to report bugs, when the ext has been updated +- Replace "export" by "sync" in all extention texts ### Roadmap for 0.3.0 -- Clear code - - Spotify service - - $scope.$apply everywhere because of data comming from background page. Should create Angular services for each background service and put $apply calls in it ? +- Tags should have more states : + 1 = just added + 2 = not found in spotify + 3 = found + 4 = added to playlist + + Tags addition to playlist should be a separate step from searching for it on Spotify. +- Move all tags logic to TagsService (background) ? Spotify & Shazam services should only handle parsing/searching/adding From 6424973b250888bd46c3046fd2902d30f36f318d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 17:55:09 +0100 Subject: [PATCH 42/60] Add custom events for GA --- popup/partials/intro.html | 10 +++++----- popup/partials/settings.html | 14 +++++++------- popup/partials/tags.html | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/popup/partials/intro.html b/popup/partials/intro.html index 946187c..7ee84de 100644 --- a/popup/partials/intro.html +++ b/popup/partials/intro.html @@ -4,7 +4,7 @@

introWelcome

introWelcomeText
@@ -24,8 +24,8 @@

introShazamLoggedOutText
introShazamLoggedInText

@@ -45,8 +45,8 @@

introSpotifyLoggedOutText
introSpotifyLoggedInText
-

-

+

+

diff --git a/popup/partials/settings.html b/popup/partials/settings.html index 09362b7..a3c2797 100644 --- a/popup/partials/settings.html +++ b/popup/partials/settings.html @@ -1,5 +1,5 @@
- advancedSettings + advancedSettings

clearData

clearDataText

-

clearData

+

clearData

exportLogs

exportLogsText

-

exportLogs

+

exportLogs

\ No newline at end of file diff --git a/popup/partials/tags.html b/popup/partials/tags.html index 502d896..0aac731 100644 --- a/popup/partials/tags.html +++ b/popup/partials/tags.html @@ -1,6 +1,6 @@
-
+ From 67bc04283dddc25a0002869b5320722731f50979 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 18:22:15 +0100 Subject: [PATCH 43/60] Add loader to Spotify auth button --- popup/img/spiffygif_22x22.gif | Bin 0 -> 19932 bytes popup/partials/intro.html | 2 +- src/popup/css/popup.css | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 popup/img/spiffygif_22x22.gif diff --git a/popup/img/spiffygif_22x22.gif b/popup/img/spiffygif_22x22.gif new file mode 100644 index 0000000000000000000000000000000000000000..2cee883991d307aa4d6ad7f4a0b295785da5339b GIT binary patch literal 19932 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 literal 0 HcmV?d00001 diff --git a/popup/partials/intro.html b/popup/partials/intro.html index 7ee84de..df4bf26 100644 --- a/popup/partials/intro.html +++ b/popup/partials/intro.html @@ -45,7 +45,7 @@

introSpotifyLoggedOutText
introSpotifyLoggedInText
-

+

introOpenSpotifyLogin

diff --git a/src/popup/css/popup.css b/src/popup/css/popup.css index f993440..b07b769 100644 --- a/src/popup/css/popup.css +++ b/src/popup/css/popup.css @@ -123,6 +123,41 @@ https://github.com/yui/pure/blob/master/LICENSE.md } } +/* Centered loader */ + + .centered-loader-container.centered-loader-active { + pointer-events:none; + } + + .centered-loader-container.centered-loader-active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: .4; + background-color:white; + z-index:20; + pointer-events:none; + } + + .centered-loader-container { + position:relative; + } + + .centered-loader { + width:22px; + height:22px; + background-image:url('img/spiffygif_22x22.gif'); + position:absolute; + top:50%; + left:50%; + margin-left:-11px; + margin-top:-12px; + z-index:40; + } + /* Modal */ .modal-overlay { From 9d954849fcfa7728b267720b509583dc33307589 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 18:22:40 +0100 Subject: [PATCH 44/60] Update TODO --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index d9b627e..a3d2cfb 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,7 @@ Some parts of the code are really messy. This should be cleaned in the next vers ### Roadmap for 0.2.0 - Add analytics - - Add custom events - - Intro - - Refresh - - Export logs - - Clean data - - Logout/login in settings -- Intro - - When clicking on Spotify auth button, show loader (http://spiffygif.com/) + - Try - Show info about new version and how to report bugs, when the ext has been updated - Replace "export" by "sync" in all extention texts From 61d6ac8810a105687bfe9e4a2ccc57898ec082a1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 18:24:14 +0100 Subject: [PATCH 45/60] Small description changes --- _locales/en/messages.json | 2 +- _locales/fr/messages.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 02cc5ee..d175b5b 100755 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,6 +1,6 @@ { "appDesc": { - "message":"Export your Shazam tags to a new Spotify playlist", + "message":"Sync your Shazam tags to a Spotify playlist", "description":"The description of the application, displayed in the web store." }, diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index edc69ac..84f893e 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -1,6 +1,6 @@ { "appDesc": { - "message":"Exportez vos tags Shazam dans une playlist Spotify", + "message":"Synchronisez vos tags Shazam dans une playlist Spotify", "description":"The description of the application, displayed in the web store." }, From 13c64a0b7c6ea24fa9d648b079cc409928b4303d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 18:48:24 +0100 Subject: [PATCH 46/60] Update README --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index a3d2cfb..35e18b2 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,9 @@ grunt build grunt bundle ``` -### Disclaimer - -Some parts of the code are really messy. This should be cleaned in the next version (0.3.0). - ### Roadmap for 0.2.0 -- Add analytics - - Try - Show info about new version and how to report bugs, when the ext has been updated -- Replace "export" by "sync" in all extention texts ### Roadmap for 0.3.0 From 9e05d8fa79b09c00b676d9fa47733f316bd252c2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 20:28:51 +0100 Subject: [PATCH 47/60] Open page when extension is updated --- src/background/ChromeHelper.js | 56 ++++++++++++++++++++++----------- src/background/background.js | 22 ++++++++++++- static/img/promo_600.jpg | Bin 0 -> 63469 bytes static/style.css | 36 +++++++++++++++++++++ static/update-0.2.0-en.html | 17 ++++++++++ static/update-0.2.0-fr.html | 17 ++++++++++ 6 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 static/img/promo_600.jpg create mode 100644 static/style.css create mode 100644 static/update-0.2.0-en.html create mode 100644 static/update-0.2.0-fr.html diff --git a/src/background/ChromeHelper.js b/src/background/ChromeHelper.js index 015df35..26477fe 100644 --- a/src/background/ChromeHelper.js +++ b/src/background/ChromeHelper.js @@ -1,24 +1,44 @@ (function(){ var ChromeHelper = { + findExistingTab: function(url, callback) { + chrome.windows.getAll({'populate': true}, function(windows) { + var existing_tab = null; + for (var i in windows) { + var tabs = windows[i].tabs; + for (var j in tabs) { + var tab = tabs[j]; + if (tab.url == url) { + existing_tab = tab; + break; + } + } + } + + callback(existing_tab); + }); + }, + + // Remove existing tab, and recreate it + removeAndCreateTab: function(url) { + ChromeHelper.findExistingTab(url, function(existing_tab) { + if (existing_tab) { + chrome.tabs.remove(existing_tab.id); + } + + chrome.tabs.create({'url': url, 'selected': true}); + }); + }, + + // Focus existing tab or create it focusOrCreateTab: function(url) { - chrome.windows.getAll({"populate":true}, function(windows) { - var existing_tab = null; - for (var i in windows) { - var tabs = windows[i].tabs; - for (var j in tabs) { - var tab = tabs[j]; - if (tab.url == url) { - existing_tab = tab; - break; - } - } - } - if (existing_tab) { - chrome.tabs.update(existing_tab.id, {"selected":true}); - } else { - chrome.tabs.create({"url":url, "selected":true}); - } - }); + ChromeHelper.findExistingTab(url, function(existing_tab) { + if (existing_tab) { + chrome.tabs.reload(existing_tab.id, {'bypassCache': true}); + chrome.tabs.update(existing_tab.id, {'selected': true}); + } else { + chrome.tabs.create({'url': url, 'selected': true}); + } + }); }, clearStorage: function() { diff --git a/src/background/background.js b/src/background/background.js index d412955..4161573 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -92,5 +92,25 @@ $(document).ready(function() { s2s.Spotify.data.clearCache(); s2s.CanvasIcon.stopRotation(); } - }); + }); + + // Check for install/update + chrome.runtime.onInstalled.addListener(function(details){ + if(details.reason == 'install') { + s2s.Logger.info('[core] Extension installed.'); + } else if(details.reason == 'update') { + var thisVersion = chrome.runtime.getManifest().version; + s2s.Logger.info('[core] Extension updated from '+ details.previousVersion +' to '+ thisVersion +'.'); + + // Only open update page for major versions + var majorUpdates = ['0.2.0']; + if(majorUpdates.indexOf(thisVersion) != -1) { + var supportedLocales = ['en', 'fr']; + var locale = chrome.i18n.getMessage('@@ui_locale'); + locale = (supportedLocales.indexOf(locale) != -1) ? locale : supportedLocales[0]; + + chrome.tabs.create({'url': chrome.extension.getURL('static/update-'+ thisVersion +'-'+ locale +'.html'), 'selected': true}); + } + } + }); }); \ No newline at end of file diff --git a/static/img/promo_600.jpg b/static/img/promo_600.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f37e4861685a8ca2bfe97e95babeb7a1f38466f8 GIT binary patch literal 63469 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 literal 0 HcmV?d00001 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..410c91e --- /dev/null +++ b/static/style.css @@ -0,0 +1,36 @@ + body { + font-family:Roboto, "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + font-size:12px; + line-height:1.5; + margin:12px 15px; + } + + .container { + width:600px; + margin:60px auto; + font-size:18px; + font-weight:300; + line-height:1.3; + } + + h1 { + color:#3c763d; + font-size:30px; + font-weight:300; + margin-top:0; + } + + h2 { + font-size:22px; + font-weight:300; + margin-top:0; + } + + p { + margin:22px 0; + } + + .signature { + text-align:right; + font-style:italic; + } \ No newline at end of file diff --git a/static/update-0.2.0-en.html b/static/update-0.2.0-en.html new file mode 100644 index 0000000..25b034b --- /dev/null +++ b/static/update-0.2.0-en.html @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/static/update-0.2.0-fr.html b/static/update-0.2.0-fr.html new file mode 100644 index 0000000..d394d9d --- /dev/null +++ b/static/update-0.2.0-fr.html @@ -0,0 +1,17 @@ + + + + + + + +
+

Shazam2Spotify a été mis à jour - v0.2.0

+

Cette nouvelle version devrait être bien plus fiable que la précédente.
Si vous aviez des problèmes pour synchroniser vos tags vers Spotify, cela doit maintenant être résolu.

+

Si vous rencontrez des problèmes ou souhaiteriez de nouvelles fonctions, n'hésitez pas à ouvrir un nouveau ticket sur notre page Github.

+

Merci d'utiliser Shazam2Spotify !

+

Leeroy

+

+
+ + \ No newline at end of file From 9f7f65ca99f1edb7ebb03d2d57416b4b91dad0d4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 20:43:46 +0100 Subject: [PATCH 48/60] Open page when extension is updated --- src/background/background.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/background/background.js b/src/background/background.js index 4161573..306b9cd 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -95,7 +95,7 @@ $(document).ready(function() { }); // Check for install/update - chrome.runtime.onInstalled.addListener(function(details){ + chrome.runtime.onInstalled.addListener(function(details) { if(details.reason == 'install') { s2s.Logger.info('[core] Extension installed.'); } else if(details.reason == 'update') { From bf65b3937a234cc84ee4627d5cc2eb997971fc91 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 20:45:14 +0100 Subject: [PATCH 49/60] Update README --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 35e18b2..a647110 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Shazam2Spotify ===================== -Chrome extension used to export your Shazam tags to a Spotify playlist. +Chrome extension used to sync your Shazam tags to a Spotify playlist. [![ScreenShot](https://raw.githubusercontent.com/leeroybrun/chrome-shazam2spotify/master/promo_1400x560.png)](http://youtu.be/Zi1VRJqEI0Q) @@ -47,10 +47,6 @@ grunt build grunt bundle ``` -### Roadmap for 0.2.0 - -- Show info about new version and how to report bugs, when the ext has been updated - ### Roadmap for 0.3.0 - Tags should have more states : From ea39a551f41633ac07c3073bf90d929a7e53d108 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 10 Dec 2014 21:04:41 +0100 Subject: [PATCH 50/60] Update TODO --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a647110..aab7764 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ grunt build grunt bundle ``` +### Roadmap for 0.2.0 + +- New MyShazam version ? Cannot get tags list + - Download tags history : http://www.shazam.com/myshazam/download-history + ### Roadmap for 0.3.0 - Tags should have more states : From 1416c8154a4e0de8d78ba16e6c271c3ffe682072 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 09:39:19 +0100 Subject: [PATCH 51/60] ShazamService rewritten - Now use Download History from MyShazam -> one download -> all tags - No tags logic in ShazamService, will be consumed by TagsService instead --- src/background/ShazamService.js | 110 ++++++++++---------------------- 1 file changed, 34 insertions(+), 76 deletions(-) diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js index d02431f..058ec54 100644 --- a/src/background/ShazamService.js +++ b/src/background/ShazamService.js @@ -1,9 +1,5 @@ -(function(StorageHelper, ChromeHelper, Tags, Logger, Spotify){ +(function(ChromeHelper, Logger){ var Shazam = { - newTags: [], - - data: new StorageHelper('Shazam'), - openLogin: function() { Logger.info('[Shazam] Opening login page...'); ChromeHelper.focusOrCreateTab('https://www.shazam.com/myshazam'); @@ -27,91 +23,53 @@ }); }, - updateTags: function(path, callback) { - if(typeof callback === 'undefined' && typeof path === 'function') { - callback = path; - path = null; - } - - callback = callback || function(){}; - path = path || '/fragment/myshazam'; - - function saveTags(error) { - if(error) { - Tags.save(function() { - callback(error); - }); - } else { - Shazam.data.set({'lastTagsUpdate': (new Date()).toString()}, function() { - Tags.save(function() { - callback(); - }); - }); - } - } + getTags: function(lastUpdate, callback) { + Logger.info('[Shazam] Downloading tags history...'); + $.get('https://www.shazam.com/myshazam/download-history') + .done(function(data) { + if(data) { + Logger.info('[Shazam] Parsing tags...'); - Shazam.data.get('lastTagsUpdate', function(items) { - var lastTagsUpdate = new Date(items.lastTagsUpdate) || new Date(0); - lastTagsUpdate = (!isNaN(lastTagsUpdate.valueOf())) ? lastTagsUpdate : new Date(0); + Shazam.parseTags(lastUpdate, data, callback); + } else { + Logger.error('[Shazam] Cannot download Shazam history.'); + Logger.error('[Shazam] Data returned : "'+ data +'"'); - Logger.info('[Shazam] Updating tags since '+ lastTagsUpdate +'.'); + return callback(new Error('Cannot download Shazam history.')); + } + }) + .fail(function(jqXHR, textStatus) { + Logger.info('[Shazam] Tags fetch error : '+textStatus+'.'); - $.get('https://www.shazam.com'+ path) - .done(function(data) { - if(data && typeof data === 'object' && data.feed.indexOf('ms-no-tags') == -1) { - var lastTagDate = new Date(parseInt($('
').append(data.feed).find('article').last().find('.tl-date').attr('data-time'))); + return callback(new Error('Tags fetch error : '+textStatus)); + }); + }, - Logger.info('[Shazam] Parsing tags for '+ path +'.'); + parseTags: function(lastUpdate, data, callback) { + var tags = []; - Shazam.parseTags(lastTagsUpdate, data.feed, function() { - if(data.previous && data.feed.indexOf('ms-no-tags') == -1 && lastTagDate > lastTagsUpdate) { - Logger.info('[Shazam] Tags parsed. Waiting 2s before next page...'); - setTimeout(function() { - Shazam.updateTags(data.previous, callback); - }, 2000); - } else { - Logger.info('[Shazam] All tags fetched.'); - saveTags(false); - } - }); - } else { - Logger.info('[Shazam] All tags fetched.'); - saveTags(false); - } - }) - .fail(function(jqXHR, textStatus) { - Logger.info('[Shazam] Tags fetch error : '+textStatus+'.'); - if(jqXHR.status === 401) { - saveTags(true); - } else { - saveTags(false); - } - }); - }); - }, + $(data).find('tr').each(function() { + if($('td', this).length == 0) { + return; + } - parseTags: function(lastTagsUpdate, data, callback) { - $('
').append(data).find('article').each(function() { - var date = parseInt($('.tl-date', this).attr('data-time')); + var date = new Date($('td.time', this).text()); - if(new Date(date) > lastTagsUpdate) { + if(date > lastUpdate) { var tag = { - id: $(this).attr('data-track-id'), - name: $('[data-track-title]', this).text().trim(), - artist: $('[data-track-artist]', this).text().trim().replace(/^by /, ''), - date: date, - image: $('img[itemprop="image"]', this).attr('src') + name: $('td:nth-child(1) a', this).text().trim(), + artist: $('td:nth-child(2)', this).text().trim(), + date: date }; - tag.query = Spotify.genQuery(tag.name, tag.artist); - - Tags.add(tag); + tags.push(tag); } }); - callback(); + callback(null, tags); } + }; window.s2s.Shazam = Shazam; -})(window.s2s.StorageHelper, window.s2s.ChromeHelper, window.s2s.Tags, window.s2s.Logger, window.s2s.Spotify); \ No newline at end of file +})(window.s2s.ChromeHelper, window.s2s.Logger); \ No newline at end of file From 1ba5b64be7a46c34e6e0286fb7d7eaa1bd9911a0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 11:44:04 +0100 Subject: [PATCH 52/60] Remove Tags logic from SpotifyService --- src/background/SpotifyService.js | 137 ++++++++++++------------------- 1 file changed, 53 insertions(+), 84 deletions(-) diff --git a/src/background/SpotifyService.js b/src/background/SpotifyService.js index 62c4392..9a0d740 100644 --- a/src/background/SpotifyService.js +++ b/src/background/SpotifyService.js @@ -1,4 +1,4 @@ -(function(Helper, StorageHelper, Tags, Logger){ +(function(Helper, StorageHelper, Logger){ var Spotify = { api: { clientId: 'b0b7b50eac4642f482825c535bae2708', @@ -143,8 +143,6 @@ }); }, - - getOrCreate: function(callback) { Spotify.playlist.getExistingId(function(err, playlistId) { if(err) { @@ -155,64 +153,12 @@ }); }, - searchAndAddTags: function(callback) { - var tracksAdded = []; - - Logger.info('[Spotify] Searching tags on Spotify.'); - - async.eachSeries(Tags.list, function(tag, cbi) { - if(tag.status > 1) { return cbi(); } - - tag.query = tag.query || 'track:'+ tag.name.replace(' ', '+') +' artist:'+ tag.artist.replace(' ', '+'); - - Spotify.playlist.searchAndAddTag(tag, tag.query, false, function(err) { - if(!err) { - tracksAdded.push(tag.spotifyId); - } - cbi(); - }); - }, function(err) { - Tags.save(); - Spotify.playlist.addTracks(tracksAdded, function(err) { - callback(err); - }); - }); - }, - - searchAndAddTag: function(tag, query, shouldSave, callback) { - Logger.info('[Spotify] Searching for tag "'+ query +'"...'); - - Spotify.call({ - endpoint: '/v1/search', - method: 'GET', - params: { - q: query, - type: 'track', - limit: 1 - } - }, function(err, data) { - if(err) { Logger.info('[Spotify] Error searching tag "'+ query +'".'); Logger.error(err); return callback(err); } - if(data.tracks.total === 0) { tag.status = 2; Logger.info('[Spotify] Tag "'+ query +'" not found.'); return callback(new Error('Not found')); } - - var track = data.tracks.items[0]; - - tag.spotifyId = track.id; - tag.status = 3; - - Logger.info('[Spotify] Tag found "'+ track.id +'".'); - - if(shouldSave) { - Tags.save(); - Spotify.playlist.addTracks([tag.spotifyId], function(err) { - callback(err); - }); - } else { - callback(); - } - }); - }, - addTracks: function(tracksIds, callback) { + if(tracksIds.length == 0) { + Logger.info('[Spotify] No tracks to add to playlist.'); + return callback(); + } + Spotify.getUserAndPlaylist(function(err, userId, playlistId) { if(err) { return callback(err); } @@ -239,12 +185,18 @@ tracksPaths.push('spotify:track:'+ id); }); - Spotify.playlist.addTracksPaths(tracksPaths, callback); + Spotify.playlist._addTracksPaths(tracksPaths, callback); }); }); }, - addTracksPaths: function(tracksPaths, callback) { + _addTracksPaths: function(tracksPaths, callback) { + // We don't have any tracks to add anymore + if(tracksPaths.length === 0) { + Logger.info('[Spotify] No tracks to add to playlist.'); + return callback(); + } + Spotify.getUserAndPlaylist(function(err, userId, playlistId) { if(err) { return callback(err); } @@ -259,12 +211,6 @@ tracksPaths = tracksPaths.slice(0, 99); } - - // We don't have any tracks to add anymore - if(tracksPaths.length === 0) { - Logger.info('[Spotify] No tags to add to playlist.'); - return callback(); - } Logger.info('[Spotify] Saving tracks to playlist '+playlistId+' :'); Logger.info(tracksPaths); @@ -279,27 +225,50 @@ Logger.error(err); return callback(err); + } + + Logger.info('[Spotify] Tracks saved to playlist.'); + + // If we have remaining tracks to add, call the method again + if(remainingTracks.length > 0) { + Logger.info('[Spotify] Waiting 2s before processing the next batch of tracks.'); + + setTimeout(function() { + Spotify.playlist._addTracksPaths(remainingTracks, callback); + }, 2000); } else { - Logger.info('[Spotify] Tracks saved to playlist.'); - - // If we have remaining tracks to add, call the method again - if(remainingTracks.length > 0) { - Logger.info('[Spotify] Waiting 2s before processing the next batch of tracks.'); - - setTimeout(function() { - Spotify.playlist.addTracksPaths(remainingTracks, callback); - }, 2000); - } else { - // All tracks added, finished ! - Logger.info('[Spotify] All done !'); - callback(); - } - } + // All tracks added, finished ! + Logger.info('[Spotify] All done !'); + callback(); + } }); }); } }, + findTrack: function(query, callback) { + Logger.info('[Spotify] Searching for track "'+ query +'"...'); + + Spotify.call({ + endpoint: '/v1/search', + method: 'GET', + params: { + q: query, + type: 'track', + limit: 1 + } + }, function(err, data) { + if(err) { Logger.info('[Spotify] Error searching track "'+ query +'".'); Logger.error(err); return callback(err); } + if(data.tracks.total === 0) { Logger.info('[Spotify] Track "'+ query +'" not found.'); return callback(new Error('Not found')); } + + var track = data.tracks.items[0]; + + Logger.info('[Spotify] Track found "'+ track.id +'".'); + + callback(null, track); + }); + }, + data: new StorageHelper('Spotify', 'sync'), // New storage, synced with other Chrome installs getUrl: { @@ -574,4 +543,4 @@ }; window.s2s.Spotify = Spotify; -})(window.s2s.Helper, window.s2s.StorageHelper, window.s2s.Tags, window.s2s.Logger); \ No newline at end of file +})(window.s2s.Helper, window.s2s.StorageHelper, window.s2s.Logger); \ No newline at end of file From a5f717302d6e541fc2d9bcadc385aed29e7dfdfa Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 12:00:44 +0100 Subject: [PATCH 53/60] Get Shazam ID from link --- src/background/ShazamService.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js index 058ec54..0375afb 100644 --- a/src/background/ShazamService.js +++ b/src/background/ShazamService.js @@ -56,7 +56,13 @@ var date = new Date($('td.time', this).text()); if(date > lastUpdate) { + var idMatch = (new RegExp('t([0-9]+)', 'g')).exec($('td:nth-child(1) a', this).attr('href')); + if(!idMatch) { + return; + } + var tag = { + shazamId: idMatch[1], name: $('td:nth-child(1) a', this).text().trim(), artist: $('td:nth-child(2)', this).text().trim(), date: date From 96a2d749e000e636cb6c509e852f3532c26ac6e0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 16:01:50 +0100 Subject: [PATCH 54/60] Moved all tags logic to TagsService --- gruntfile.js | 2 +- popup/img/placeholder.png | Bin 0 -> 717 bytes popup/partials/tags.html | 5 +- src/background/ShazamService.js | 2 +- src/background/SpotifyService.js | 10 ++- src/background/TagsService.js | 99 ++++++++++++++++++++++++--- src/background/background.js | 43 +++++------- src/popup/css/popup.css | 4 ++ src/popup/js/services/TagsService.js | 4 +- 9 files changed, 126 insertions(+), 43 deletions(-) create mode 100644 popup/img/placeholder.png diff --git a/gruntfile.js b/gruntfile.js index 4dbdfca..ee1ebe2 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -31,9 +31,9 @@ module.exports = function(grunt) { 'src/background/CanvasIcon.js', 'src/background/LoggerService.js', 'src/background/StorageHelper.js', - 'src/background/TagsService.js', 'src/background/SpotifyService.js', 'src/background/ShazamService.js', + 'src/background/TagsService.js', ] } } diff --git a/popup/img/placeholder.png b/popup/img/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc829823f748ea16e32f68d2a2640b6464410c0 GIT binary patch literal 717 zcmV;;0y6!HP)00009a7bBm000XU z000XU0RWnu7ytkO8FWQhbW?9;ba!ELWdK2BZ(?O2No`?gWm08fWO;GPWjp`?0$NE# zK~#9!?3izJqA(D~Edd-r1$%dW-@dr-|GBPz6p*Awjr&il z_4$s+Cw zp!!@5@4hqD!6a@uiZcM5=a`)XmP5E@DLzI$@P!ex=h9M9tFMP3;@QWjJjT@t8{fT7<|DcCRxN?Phvz8)U~~bF z`~EN>O9)dGCocI)a(7t@fH4MDIq&WvSd9j7Y>wN!-~}KH63+pxO~ug^`I{zbLvFFq z53=nxhugXD9wLysz)1{4`g8x_$bjV#ISvp1{}Nc2##Y<5+8Cu&7z8xSd3@Tq?0)9n znFE6{&xqgiFtl&ZA?9pQ^c1CITW-eH+|_Shh-Bfj=I*)8@HTfj)5Gd*XH#vjpXVB7 zQBJrXn0!Ga00JNY0w4eaAOHd&00Lk;UIiEcKzj;insQPr00000NkvXXu0mjfE~h~p literal 0 HcmV?d00001 diff --git a/popup/partials/tags.html b/popup/partials/tags.html index 0aac731..9ac78b8 100644 --- a/popup/partials/tags.html +++ b/popup/partials/tags.html @@ -51,7 +51,7 @@

myTags

-
+
@@ -79,7 +79,8 @@

myTags

- + +
{{tag.name}}
{{tag.artist}}
diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js index 0375afb..f09cbae 100644 --- a/src/background/ShazamService.js +++ b/src/background/ShazamService.js @@ -49,7 +49,7 @@ var tags = []; $(data).find('tr').each(function() { - if($('td', this).length == 0) { + if($('td', this).length === 0) { return; } diff --git a/src/background/SpotifyService.js b/src/background/SpotifyService.js index 9a0d740..5c0082e 100644 --- a/src/background/SpotifyService.js +++ b/src/background/SpotifyService.js @@ -154,7 +154,7 @@ }, addTracks: function(tracksIds, callback) { - if(tracksIds.length == 0) { + if(tracksIds.length === 0) { Logger.info('[Spotify] No tracks to add to playlist.'); return callback(); } @@ -162,7 +162,9 @@ Spotify.getUserAndPlaylist(function(err, userId, playlistId) { if(err) { return callback(err); } - var alreadyInPlaylist = []; + Logger.info('[Spotify] '+ tracksIds.length +' tracks to check if they already exist in playlist.'); + + var alreadyInPlaylist = 0; // Check for already existing tracks in playlist Spotify.findInPagedResult({ @@ -174,12 +176,16 @@ var index = tracksIds.indexOf(track.track.id); if(index != -1) { tracksIds.splice(index, 1); + alreadyInPlaylist++; Logger.info('[Spotify] Track '+ track.track.id +' already in playlist.'); } }); cbFind(false); }, function() { + Logger.info('[Spotify] '+ alreadyInPlaylist +' tracks already in playlist.'); + Logger.info('[Spotify] '+ tracksIds.length +' tracks remaining to add.'); + var tracksPaths = []; tracksIds.forEach(function(id) { tracksPaths.push('spotify:track:'+ id); diff --git a/src/background/TagsService.js b/src/background/TagsService.js index 176536e..2cabccd 100644 --- a/src/background/TagsService.js +++ b/src/background/TagsService.js @@ -1,26 +1,49 @@ -(function(StorageHelper){ +(function(StorageHelper, Logger, Spotify, Shazam){ var Tags = { list: [], + lastUpdate: new Date(0), - add: function(newTag, callback) { + add: function(tag, callback) { callback = callback || function(){}; - newTag.spotifyId = newTag.spotifyId || null; - newTag.status = newTag.status || 1; // Status : 1 = just added, 2 = not found in spotify, 3 = found & added to playlist + tag.spotifyId = tag.spotifyId || null; + tag.status = tag.status || 1; // Status : 1 = just added, 2 = not found in spotify, 3 = found, 4 = added to playlist - newTag.query = newTag.query || ''; + tag.query = tag.query || Spotify.genQuery(tag.name, tag.artist); + tag.date = (tag.date instanceof Date) ? tag.date.getTime() : tag.date; + + // Search track on Spotify, if not already found + if(tag.status < 3) { + Spotify.findTrack(tag.query, function(err, track) { + if(err || !track) { + tag.status = 2; + } else { + tag.status = 3; + tag.image = track.album.images[track.album.images.length-1].url; + tag.spotifyId = track.id; + } + + Tags._addToList(tag, callback); + }); + } else { + Tags._addToList(tag, callback); + } + }, + + _addToList: function(tag, callback) { + // TODO: use Array.prototype.find when available on Chrome var found = false; for(var i in Tags.list) { - if(Tags.list[i].id == newTag.id) { + if(Tags.list[i].shazamId == tag.shazamId) { found = true; - $.extend(Tags.list[i], newTag); // Update existing tag + $.extend(Tags.list[i], tag); // Update existing tag break; } } if(!found) { - Tags.list.push(newTag); + Tags.list.push(tag); } Tags.list.sort(function (a, b) { @@ -32,10 +55,59 @@ callback(); }, + update: function(callback) { + Logger.info('[Tags] Updating since '+ Tags.lastUpdate +'.'); + + Shazam.getTags(Tags.lastUpdate, function(err, tags) { + if(!err) { + Tags.lastUpdate = new Date(); + + async.eachSeries(tags, function(tag, cbe) { + Tags.add(tag, function() { + cbe(); + }); + }, function() { + Tags.updatePlaylist(callback); + }); + } + }); + }, + + updatePlaylist: function(callback) { + Logger.info('[Tags] Updating playlist on Spotify.'); + + var tracksToAdd = []; // Used to revert "status" if an error occurs + var tracksIdsToAdd = []; // Used to add tracks to playlist + + for(var i in Tags.list) { + var tag = Tags.list[i]; + + if(tag.status == 3) { + tracksToAdd.push(tag); + tracksIdsToAdd.push(tag.spotifyId); + tag.status = 4; + } + } + + Spotify.playlist.addTracks(tracksIdsToAdd, function(err) { + if(err) { + Logger.info('[Tags] Cannot add tags to playlist, reverting tags status.'); + // If an error occurs, revert tag status to 3. This will let the system retry addition later. + for(var i in tracksToAdd) { + tracksToAdd[i].status = 3; + } + } + + Tags.save(callback); + }); + }, + save: function(callback) { callback = callback || function(){}; - Tags.data.set({'tagsList': Tags.list}, function() { + Logger.info('[Tags] Saving tags data.'); + + Tags.data.set({'tagsList': Tags.list, 'lastUpdate': Tags.lastUpdate.getTime()}, function() { callback(); }); }, @@ -43,8 +115,13 @@ load: function(callback) { callback = callback || function(){}; - Tags.data.get('tagsList', function(items) { + Tags.data.get(['tagsList', 'lastUpdate'], function(items) { Tags.list = items.tagsList || []; + Tags.lastUpdate = new Date(items.lastUpdate) || new Date(0); + Tags.lastUpdate = (!isNaN(Tags.lastUpdate.valueOf())) ? Tags.lastUpdate : new Date(0); + + Logger.info('[Tags] Got from storage -> tagsList: '+ Tags.list.length +' items.'); + Logger.info('[Tags] Got from storage -> lastUpdate: '+ Tags.lastUpdate +'.'); callback(); }); @@ -54,4 +131,4 @@ }; window.s2s.Tags = Tags; -})(window.s2s.StorageHelper); \ No newline at end of file +})(window.s2s.StorageHelper, window.s2s.Logger, window.s2s.Spotify, window.s2s.Shazam); \ No newline at end of file diff --git a/src/background/background.js b/src/background/background.js index 306b9cd..1a07e13 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -18,7 +18,7 @@ $(document).ready(function() { return callback('already_in_progress'); } - s2s.Logger.info('[core] Updating tags...'); + s2s.Logger.info('[core] Starting tags update...'); s2s.updating = true; s2s.CanvasIcon.startRotation(); @@ -32,39 +32,33 @@ $(document).ready(function() { } s2s.Logger.info('[core] Spotify playlist got.'); - s2s.Logger.info('[core] Fetching last tags from Shazam.'); + s2s.Logger.info('[core] Updating tags...'); - s2s.Shazam.updateTags(function(err) { + s2s.Tags.update(function(err) { if(err) { - s2s.Logger.info('[core] Error fetching Shazam tags. Tags update aborted.'); - s2s.updating = false; - s2s.CanvasIcon.stopRotation(); - return callback(err); - } - - s2s.Logger.info('[core] Tags updated from Shazam.'); - s2s.Logger.info('[core] Now updating Spotify playlist.'); - - s2s.Spotify.playlist.searchAndAddTags(function() { + s2s.Logger.info('[core] Error updating tags.'); + } else { s2s.Logger.info('[core] All done ! Tags updated.'); + } - s2s.updating = false; - s2s.CanvasIcon.stopRotation(); + s2s.updating = false; + s2s.CanvasIcon.stopRotation(); - callback(); - }); + callback(err); }); }); }; s2s.searchTag = function(trackName, artist, tag, callback) { - var query = s2s.Spotify.genQuery(trackName, artist); + tag.name = trackName; + tag.artist = artist; + tag.query = null; // Will be regenerated by Tags.add - s2s.Spotify.playlist.searchAndAddTag(tag, query, true, function(error) { - if(error) { - callback(true); + s2s.Tags.add(tag, function() { + if(tag.status > 2) { + s2s.Tags.updatePlaylist(callback); } else { - callback(); + callback(new Error('Not found')); } }); }; @@ -86,11 +80,12 @@ $(document).ready(function() { //window.location.reload(); // Clear cached data from background script - s2s.Tags.list = []; s2s.Tags.data.clearCache(); - s2s.Shazam.data.clearCache(); s2s.Spotify.data.clearCache(); s2s.CanvasIcon.stopRotation(); + + // Reload tags, will reset list & lastUpdate + s2s.Tags.load(); } }); diff --git a/src/popup/css/popup.css b/src/popup/css/popup.css index b07b769..9508c61 100644 --- a/src/popup/css/popup.css +++ b/src/popup/css/popup.css @@ -493,6 +493,10 @@ https://github.com/yui/pure/blob/master/LICENSE.md fill:#2ECC71; } + .tag-status .success.pending .iconmelon { + opacity:0.6; + } + .tag-status .error .iconmelon { fill:#F9690E; } diff --git a/src/popup/js/services/TagsService.js b/src/popup/js/services/TagsService.js index 76e918d..676654a 100644 --- a/src/popup/js/services/TagsService.js +++ b/src/popup/js/services/TagsService.js @@ -41,9 +41,9 @@ angular.module('Shazam2Spotify').factory('TagsService', function($timeout, $inte }, searchTag: function(trackName, artist, tag, callback) { - BackgroundService.searchTag(trackName, artist, tag, function(error) { + BackgroundService.searchTag(trackName, artist, tag, function(err) { $timeout(function() { - callback(error); + callback(err); }, 0); }); } From f1820f77836409b7e02896a5f91ec1ac5214b577 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 16:14:50 +0100 Subject: [PATCH 55/60] Add comments --- src/background/CanvasIcon.js | 3 +++ src/background/ShazamService.js | 4 ++++ src/background/SpotifyService.js | 22 +++++++++++++++++++++- src/background/StorageHelper.js | 3 +++ src/background/TagsService.js | 6 ++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/background/CanvasIcon.js b/src/background/CanvasIcon.js index b0daec6..3c950b8 100644 --- a/src/background/CanvasIcon.js +++ b/src/background/CanvasIcon.js @@ -1,5 +1,6 @@ (function(){ var CanvasIcon = { + // Create new canvas, load icon and set browser action's icon load: function() { var icon = this; @@ -32,6 +33,7 @@ }; }, + // Start rotation of icon startRotation: function() { if(!this.loaded) { if(!this.img) { @@ -62,6 +64,7 @@ }, this.fps); }, + // Stop rotation of icon stopRotation: function() { clearInterval(this.interval); this.interval = null; diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js index f09cbae..8ebff78 100644 --- a/src/background/ShazamService.js +++ b/src/background/ShazamService.js @@ -1,10 +1,12 @@ (function(ChromeHelper, Logger){ var Shazam = { + // Open the MyShazam login page openLogin: function() { Logger.info('[Shazam] Opening login page...'); ChromeHelper.focusOrCreateTab('https://www.shazam.com/myshazam'); }, + // Check current login status on MyShazam loginStatus: function(callback) { $.get('https://www.shazam.com/fragment/myshazam') .done(function() { @@ -23,6 +25,7 @@ }); }, + // Download tags history, parse it and return a tags array getTags: function(lastUpdate, callback) { Logger.info('[Shazam] Downloading tags history...'); $.get('https://www.shazam.com/myshazam/download-history') @@ -45,6 +48,7 @@ }); }, + // Parse tags from tags history parseTags: function(lastUpdate, data, callback) { var tags = []; diff --git a/src/background/SpotifyService.js b/src/background/SpotifyService.js index 5c0082e..cb574a9 100644 --- a/src/background/SpotifyService.js +++ b/src/background/SpotifyService.js @@ -5,12 +5,14 @@ clientSecret: 'b3bc17ef4d964fccb63b1f37af9101f8' }, + // From a track name and artist, generate a query for Spotify search genQuery: function(track, artist) { var reSpaces = new RegExp(' ', 'g'); return 'track:'+ track.replace(reSpaces, '+') +' artist:'+ artist.replace('Feat. ', '').replace(reSpaces, '+'); }, + // Get current user and playlist ir cache or on Spotify getUserAndPlaylist: function(callback) { Spotify.data.get(['userId', 'playlistId'], function(items) { var userId = items.userId; @@ -63,8 +65,9 @@ }, playlist: { - name: chrome.i18n.getMessage('myTags'), + name: chrome.i18n.getMessage('myTags'), // The playlist's name + // Get id of existing playlist on Spotify or error if not found getExistingId: function(callback) { var playlistName = Spotify.playlist.name; @@ -101,6 +104,7 @@ }); }, + // Get playlist details get: function(callback) { Spotify.getUserAndPlaylist(function(err, userId, playlistId) { if(err) { return callback(err); } @@ -116,6 +120,7 @@ }); }, + // Create playlist on Spotify create: function(callback) { Spotify.data.get(['userId', 'playlistId'], function(items) { var userId = items.userId; @@ -143,6 +148,7 @@ }); }, + // Get playlist if it exists, or create a new one getOrCreate: function(callback) { Spotify.playlist.getExistingId(function(err, playlistId) { if(err) { @@ -153,6 +159,7 @@ }); }, + // Add an array of trackIds to playlist addTracks: function(tracksIds, callback) { if(tracksIds.length === 0) { Logger.info('[Spotify] No tracks to add to playlist.'); @@ -196,6 +203,8 @@ }); }, + // Private : called from addTracks, add an array of trackPaths to playlist. + // Handle arrays bigger than 100 items (should be splitted in multiple requests for Spotify API) _addTracksPaths: function(tracksPaths, callback) { // We don't have any tracks to add anymore if(tracksPaths.length === 0) { @@ -252,6 +261,7 @@ } }, + // Find a track on Spotify findTrack: function(query, callback) { Logger.info('[Spotify] Searching for track "'+ query +'"...'); @@ -275,8 +285,10 @@ }); }, + // Storage for Spotify data (auth token, etc) data: new StorageHelper('Spotify', 'sync'), // New storage, synced with other Chrome installs + // Helpers to get URLs for API calls getUrl: { redirect: function() { return 'https://'+ chrome.runtime.id +'.chromiumapp.org/spotify_cb'; @@ -298,6 +310,7 @@ } }, + // Call the Spotify API and search in paged result with checkFind, call callback when found or when last page is reached findInPagedResult: function(callOptions, checkFind, callback) { Spotify.call(callOptions, function(err, data) { if(err) { Logger.error(err); } @@ -320,6 +333,7 @@ }); }, + // Call an API endpoint call: function(options, callback) { Spotify.loginStatus(function(status) { if(!status) { @@ -364,6 +378,7 @@ }); }, + // Check login status on Spotify loginStatus: function(callback) { Spotify.data.get(['accessToken', 'tokenTime', 'expiresIn'], function(items) { // Don't have an access token ? We are not logged in... @@ -389,6 +404,7 @@ }); }, + // Refresh access token refreshToken: function(callback) { Logger.info('[Spotify] Refreshing token...'); @@ -428,6 +444,7 @@ }); }, + // Get an access token from an auth code getAccessToken: function(authCode, callback) { Logger.info('[Spotify] Getting access token...'); @@ -453,6 +470,7 @@ }); }, + // Save access token saveAccessToken: function(data, callback) { if(data.access_token && data.expires_in) { Spotify.data.set({ @@ -478,6 +496,7 @@ } }, + // Open Spotify login (with chrome identity) openLogin: function(interactive, callback) { if(typeof interactive === 'function') { callback = interactive; @@ -533,6 +552,7 @@ }); }, + // Disconnect from Spotify API disconnect: function(callback) { callback = callback || function(){}; diff --git a/src/background/StorageHelper.js b/src/background/StorageHelper.js index 78df4c6..33dea48 100644 --- a/src/background/StorageHelper.js +++ b/src/background/StorageHelper.js @@ -7,6 +7,7 @@ this.cache = {}; }; + // Get values either from cache or from storage StorageHelper.prototype.get = function(names, callback) { var storage = this; @@ -48,6 +49,7 @@ }); }; + // Set values to cache & storage StorageHelper.prototype.set = function(objects, callback) { callback = callback || function(){}; @@ -73,6 +75,7 @@ }); }; + // Clear cache StorageHelper.prototype.clearCache = function() { this.cache = {}; }; diff --git a/src/background/TagsService.js b/src/background/TagsService.js index 2cabccd..ee05b9c 100644 --- a/src/background/TagsService.js +++ b/src/background/TagsService.js @@ -3,6 +3,7 @@ list: [], lastUpdate: new Date(0), + // Add/update a tag in the tags list add: function(tag, callback) { callback = callback || function(){}; @@ -31,6 +32,7 @@ } }, + // Private : called from Tags.add, add the specified tag to list or update it _addToList: function(tag, callback) { // TODO: use Array.prototype.find when available on Chrome var found = false; @@ -55,6 +57,7 @@ callback(); }, + // Update tags list from MyShazam and then update Spotify playlist update: function(callback) { Logger.info('[Tags] Updating since '+ Tags.lastUpdate +'.'); @@ -73,6 +76,7 @@ }); }, + // Update Spotify playlist with tags found but not already added (status=3) updatePlaylist: function(callback) { Logger.info('[Tags] Updating playlist on Spotify.'); @@ -102,6 +106,7 @@ }); }, + // Save tags data (list & lastUpdate) save: function(callback) { callback = callback || function(){}; @@ -112,6 +117,7 @@ }); }, + // Load tags data (list & lastUpdate) load: function(callback) { callback = callback || function(){}; From db817ca85813848b68b5a919dc00bde91eb84bf1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 16:15:44 +0100 Subject: [PATCH 56/60] Update TODO --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index aab7764..507bce4 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,3 @@ grunt build ``` grunt bundle ``` - -### Roadmap for 0.2.0 - -- New MyShazam version ? Cannot get tags list - - Download tags history : http://www.shazam.com/myshazam/download-history - -### Roadmap for 0.3.0 - -- Tags should have more states : - 1 = just added - 2 = not found in spotify - 3 = found - 4 = added to playlist - - Tags addition to playlist should be a separate step from searching for it on Spotify. -- Move all tags logic to TagsService (background) ? Spotify & Shazam services should only handle parsing/searching/adding From f2e0167a9055d44ea7d82a01195ee60cd5797e24 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 17:02:48 +0100 Subject: [PATCH 57/60] Do not parse tags when date < lastUpdate --- src/background/ShazamService.js | 30 +++++++++++++++++++----------- src/background/TagsService.js | 10 +++++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js index 8ebff78..bb4ee54 100644 --- a/src/background/ShazamService.js +++ b/src/background/ShazamService.js @@ -31,8 +31,6 @@ $.get('https://www.shazam.com/myshazam/download-history') .done(function(data) { if(data) { - Logger.info('[Shazam] Parsing tags...'); - Shazam.parseTags(lastUpdate, data, callback); } else { Logger.error('[Shazam] Cannot download Shazam history.'); @@ -50,31 +48,41 @@ // Parse tags from tags history parseTags: function(lastUpdate, data, callback) { + Logger.info('[Shazam] Parsing tags...'); + var tags = []; + var stopParsing = false; + var tagsEl = $(data).find('tr'); + + Logger.info('[Shazam] Start parsing of '+ tagsEl.length +' elements...'); - $(data).find('tr').each(function() { - if($('td', this).length === 0) { - return; + for(var i = 0; i < tagsEl.length && stopParsing === false; i++) { + if($('td', tagsEl[i]).length === 0) { + continue; } - var date = new Date($('td.time', this).text()); + var date = new Date($('td.time', tagsEl[i]).text()); if(date > lastUpdate) { - var idMatch = (new RegExp('t([0-9]+)', 'g')).exec($('td:nth-child(1) a', this).attr('href')); + var idMatch = (new RegExp('t([0-9]+)', 'g')).exec($('td:nth-child(1) a', tagsEl[i]).attr('href')); if(!idMatch) { - return; + continue; } var tag = { shazamId: idMatch[1], - name: $('td:nth-child(1) a', this).text().trim(), - artist: $('td:nth-child(2)', this).text().trim(), + name: $('td:nth-child(1) a', tagsEl[i]).text().trim(), + artist: $('td:nth-child(2)', tagsEl[i]).text().trim(), date: date }; tags.push(tag); + } else { + // Tag's date is lower than last update date = the following tags were already fetched in previous updates + Logger.info('[Shazam] Stop parsing, we reached the last tag not already fetched.'); + stopParsing = true; } - }); + } callback(null, tags); } diff --git a/src/background/TagsService.js b/src/background/TagsService.js index ee05b9c..293eba5 100644 --- a/src/background/TagsService.js +++ b/src/background/TagsService.js @@ -62,7 +62,13 @@ Logger.info('[Tags] Updating since '+ Tags.lastUpdate +'.'); Shazam.getTags(Tags.lastUpdate, function(err, tags) { - if(!err) { + if(!err && Array.isArray(tags)) { + Logger.info('[Tags] Got '+ tags.length +' tags from Shazam.'); + + if(tags.length === 0) { + return callback(); + } + Tags.lastUpdate = new Date(); async.eachSeries(tags, function(tag, cbe) { @@ -72,6 +78,8 @@ }, function() { Tags.updatePlaylist(callback); }); + } else { + callback(err); } }); }, From d65973d5e6ad91234e6fbea67de69f8aa455ba4b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 17:04:54 +0100 Subject: [PATCH 58/60] Update TODO --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 507bce4..e924ca1 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,9 @@ grunt build ``` grunt bundle ``` + +## Roadmap for v0.2.0 + +- Add custom update scripts, this let us manage how to handle updates per version + - Should call all update scripts from current version to new version + - Update script for v0.2.0 : clear data \ No newline at end of file From 7a50bf358bcf22b4557f501c8a6ad80462401a28 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 18:29:15 +0100 Subject: [PATCH 59/60] Add UpdateService to manage versions updates --- gruntfile.js | 1 + src/background/UpdateService.js | 59 +++++++++++++++++++++++++++++++++ src/background/background.js | 10 +----- 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 src/background/UpdateService.js diff --git a/gruntfile.js b/gruntfile.js index ee1ebe2..6e2f95c 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -34,6 +34,7 @@ module.exports = function(grunt) { 'src/background/SpotifyService.js', 'src/background/ShazamService.js', 'src/background/TagsService.js', + 'src/background/UpdateService.js', ] } } diff --git a/src/background/UpdateService.js b/src/background/UpdateService.js new file mode 100644 index 0000000..8a0ac09 --- /dev/null +++ b/src/background/UpdateService.js @@ -0,0 +1,59 @@ +(function(Logger){ + var UpdateService = { + update: function(initVersionTxt, finalVersionTxt) { + var rePoint = new RegExp('\\.', 'g'); + + var initVersion = parseInt(initVersionTxt.replace(rePoint, '')); + var finalVersion = parseInt(finalVersionTxt.replace(rePoint, '')); + + var startIndex = null; + var endIndex = null; + + console.log(UpdateService._updates); + + for(var i = 0; i < UpdateService._updates.length && (startIndex === null || endIndex === null); i++) { + console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); + + if(startIndex === null && UpdateService._updates[i].version > initVersion) { + startIndex = i; + } + + console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); + + if(startIndex !== null && UpdateService._updates[i].version <= finalVersion) { + endIndex = i; + } + + console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); + } + + if(startIndex !== null && endIndex !== null) { + Logger.info('[Updater] '+ (endIndex-startIndex+1) +' update scripts to call to go from v'+ initVersionTxt +' to v'+ finalVersionTxt +'.'); + } else { + Logger.info('[Updater] No update script defined to go from v'+ initVersionTxt +' to v'+ finalVersionTxt +'.'); + } + }, + + openUpdatePage: function(version) { + var supportedLocales = ['en', 'fr']; + var locale = chrome.i18n.getMessage('@@ui_locale'); + locale = (supportedLocales.indexOf(locale) != -1) ? locale : supportedLocales[0]; + + chrome.tabs.create({'url': chrome.extension.getURL('static/update-'+ version +'-'+ locale +'.html'), 'selected': true}); + }, + + _updates: [ + {'version': 10, 'update': function(callback) { + + }}, + {'version': 20, 'update': function(callback) { + + }}, + {'version': 30, 'update': function(callback) { + + }} + ] + }; + + window.s2s.UpdateService = UpdateService; +})(window.s2s.Logger); \ No newline at end of file diff --git a/src/background/background.js b/src/background/background.js index 1a07e13..39f2639 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -97,15 +97,7 @@ $(document).ready(function() { var thisVersion = chrome.runtime.getManifest().version; s2s.Logger.info('[core] Extension updated from '+ details.previousVersion +' to '+ thisVersion +'.'); - // Only open update page for major versions - var majorUpdates = ['0.2.0']; - if(majorUpdates.indexOf(thisVersion) != -1) { - var supportedLocales = ['en', 'fr']; - var locale = chrome.i18n.getMessage('@@ui_locale'); - locale = (supportedLocales.indexOf(locale) != -1) ? locale : supportedLocales[0]; - - chrome.tabs.create({'url': chrome.extension.getURL('static/update-'+ thisVersion +'-'+ locale +'.html'), 'selected': true}); - } + s2s.UpdateService.update(details.previousVersion, thisVersion); } }); }); \ No newline at end of file From 720579595e16bf7b55f76fd07d82280c38c495b2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 11 Dec 2014 21:18:59 +0100 Subject: [PATCH 60/60] Working UpdateService to apply update scripts --- src/background/UpdateService.js | 53 ++++++++++++++++++++++++--------- static/update-0.2.0-en.html | 2 +- static/update-0.2.0-fr.html | 2 +- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/background/UpdateService.js b/src/background/UpdateService.js index 8a0ac09..827f8f6 100644 --- a/src/background/UpdateService.js +++ b/src/background/UpdateService.js @@ -1,34 +1,47 @@ -(function(Logger){ +(function(Logger, ChromeHelper, Spotify, Shazam, Tags){ var UpdateService = { update: function(initVersionTxt, finalVersionTxt) { var rePoint = new RegExp('\\.', 'g'); + // TODO: handle versions 0.2.10 -> 210 -> bigger than 0.3.1 -> 31 ! var initVersion = parseInt(initVersionTxt.replace(rePoint, '')); var finalVersion = parseInt(finalVersionTxt.replace(rePoint, '')); var startIndex = null; var endIndex = null; - console.log(UpdateService._updates); + var updatesToApply = []; - for(var i = 0; i < UpdateService._updates.length && (startIndex === null || endIndex === null); i++) { - console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); + // If initVersion is the same or bigger than final, nothing to do... + var shouldStop = initVersion >= finalVersion; + for(var i = 0; i < UpdateService._updates.length && shouldStop === false; i++) { if(startIndex === null && UpdateService._updates[i].version > initVersion) { startIndex = i; } - console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); - if(startIndex !== null && UpdateService._updates[i].version <= finalVersion) { endIndex = i; + + updatesToApply.push(UpdateService._updates[i]); } - console.log(i, UpdateService._updates[i].version, initVersion, finalVersion, startIndex, endIndex); + shouldStop = (startIndex !== null && endIndex !== null && UpdateService._updates[i].version >= finalVersion); } if(startIndex !== null && endIndex !== null) { - Logger.info('[Updater] '+ (endIndex-startIndex+1) +' update scripts to call to go from v'+ initVersionTxt +' to v'+ finalVersionTxt +'.'); + Logger.info('[Updater] '+ (endIndex-startIndex+1) +' update scripts ('+startIndex+'->'+endIndex+') to call to go from v'+ initVersionTxt +' to v'+ finalVersionTxt +'.'); + + async.eachSeries(updatesToApply, function(update, cbe) { + Logger.info('[Updater] Calling update script for v'+update.version); + update.perform(function(err) { + if(err) { Logger.error(err); } + + cbe(); + }); + }, function() { + Logger.info('[Updater] All update scripts applied !'); + }); } else { Logger.info('[Updater] No update script defined to go from v'+ initVersionTxt +' to v'+ finalVersionTxt +'.'); } @@ -43,17 +56,29 @@ }, _updates: [ - {'version': 10, 'update': function(callback) { + {'version': 20, 'perform': function(callback) { + s2s.Logger.info('[Update] Cleaning extension\'s background data.'); + + var popups = chrome.extension.getViews({type: 'popup'}); + if(popups && popups.length) { + popups[0].window.close(); + } + + ChromeHelper.clearStorage(); + + // Clear cached data from background script + Tags.data.clearCache(); + Spotify.data.clearCache(); - }}, - {'version': 20, 'update': function(callback) { + // Reload tags, will reset list & lastUpdate + s2s.Tags.load(); - }}, - {'version': 30, 'update': function(callback) { + UpdateService.openUpdatePage('0.2.0'); + callback(); }} ] }; window.s2s.UpdateService = UpdateService; -})(window.s2s.Logger); \ No newline at end of file +})(window.s2s.Logger, window.s2s.ChromeHelper, window.s2s.Spotify, window.s2s.Shazam, window.s2s.Tags); \ No newline at end of file diff --git a/static/update-0.2.0-en.html b/static/update-0.2.0-en.html index 25b034b..3f621e4 100644 --- a/static/update-0.2.0-en.html +++ b/static/update-0.2.0-en.html @@ -8,7 +8,7 @@

Shazam2Spotify has been updated - v0.2.0

This new version should be a lot more reliable than the previous one.
If you had troubles syncing tags to Spotify, this should now be fixed.

-

If you experience any bug or want to request a new feature, please open a new issue on our Github repository.

+

If you experience any bug or want to request a new feature, please don't hesitate to open a new issue on our Github repository.

Thanks for using Shazam2Spotify !

Leeroy

diff --git a/static/update-0.2.0-fr.html b/static/update-0.2.0-fr.html index d394d9d..5aee7f0 100644 --- a/static/update-0.2.0-fr.html +++ b/static/update-0.2.0-fr.html @@ -8,7 +8,7 @@

Shazam2Spotify a été mis à jour - v0.2.0

Cette nouvelle version devrait être bien plus fiable que la précédente.
Si vous aviez des problèmes pour synchroniser vos tags vers Spotify, cela doit maintenant être résolu.

-

Si vous rencontrez des problèmes ou souhaiteriez de nouvelles fonctions, n'hésitez pas à ouvrir un nouveau ticket sur notre page Github.

+

Si vous rencontrez d'autres difficultés ou souhaiteriez de nouvelles fonctions, n'hésitez pas à ouvrir un nouveau ticket sur notre page Github.

Merci d'utiliser Shazam2Spotify !

Leeroy