-
+
-
+
@@ -79,7 +79,8 @@ myTags
-
+
+
{{tag.name}}
{{tag.artist}}
@@ -92,7 +93,7 @@
myTags
artistField
-
searchTrackButton cancel
+
searchTrackButton cancel
{{newSearch.error}}
diff --git a/popup/popup.html b/popup/popup.html
index 4cf27d4..9ef72c7 100755
--- a/popup/popup.html
+++ b/popup/popup.html
@@ -2,11 +2,14 @@
-
+
+
+
+
diff --git a/src/background/CanvasIcon.js b/src/background/CanvasIcon.js
new file mode 100644
index 0000000..3c950b8
--- /dev/null
+++ b/src/background/CanvasIcon.js
@@ -0,0 +1,81 @@
+(function(){
+ var CanvasIcon = {
+ // Create new canvas, load icon and set browser action's icon
+ load: function() {
+ var icon = this;
+
+ this.loaded = false;
+
+ this.interval = null;
+
+ this.canvas = document.createElement('canvas');
+ document.body.appendChild(this.canvas);
+ this.ctx = this.canvas.getContext('2d');
+
+ this.img = new Image();
+ this.img.src = '../icons/icon19.png';
+
+ this.ang = 0; //angle
+ this.fps = 1000 / 10; //number of frames per sec
+
+ this.img.onload = function () {
+ icon.loaded = true;
+
+ icon.canvas.width = this.width;
+ icon.canvas.height = this.height;
+ icon.cache = this; //cache the local copy of image element for future reference
+
+ icon.ctx.clearRect(0, 0, icon.canvas.width, icon.canvas.height); //clear the canvas
+ icon.ctx.drawImage(icon.img, 0, 0);
+
+ var imageData = icon.ctx.getImageData(0, 0, 19, 19);
+ chrome.browserAction.setIcon({ imageData: imageData });
+ };
+ },
+
+ // Start rotation of icon
+ startRotation: function() {
+ if(!this.loaded) {
+ if(!this.img) {
+ CanvasIcon.load();
+ }
+
+ setTimeout(function() {
+ CanvasIcon.startRotation();
+ }, 2000);
+
+ return;
+ }
+
+ var icon = this;
+
+ this.ang = 0;
+
+ this.interval = setInterval(function () {
+ icon.ctx.save();
+ icon.ctx.clearRect(0, 0, icon.canvas.width, icon.canvas.height); //clear the canvas
+ icon.ctx.translate(icon.cache.width/2, icon.cache.height/2);
+ icon.ctx.rotate(Math.PI / 180 * (icon.ang += 10)); //increment the angle and rotate the image
+ icon.ctx.drawImage(icon.img, -icon.cache.width / 2, -icon.cache.height / 2, icon.cache.width, icon.cache.height); //draw the image ;)
+ icon.ctx.restore();
+
+ var imageData = icon.ctx.getImageData(0, 0, 19, 19);
+ chrome.browserAction.setIcon({ imageData: imageData });
+ }, this.fps);
+ },
+
+ // Stop rotation of icon
+ stopRotation: function() {
+ clearInterval(this.interval);
+ this.interval = null;
+
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); //clear the canvas
+ this.ctx.drawImage(this.img, 0, 0);
+
+ var imageData = this.ctx.getImageData(0, 0, 19, 19);
+ chrome.browserAction.setIcon({ imageData: imageData });
+ }
+ };
+
+ window.s2s.CanvasIcon = CanvasIcon;
+})();
\ No newline at end of file
diff --git a/src/background/ChromeHelper.js b/src/background/ChromeHelper.js
new file mode 100644
index 0000000..26477fe
--- /dev/null
+++ b/src/background/ChromeHelper.js
@@ -0,0 +1,51 @@
+(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) {
+ 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() {
+ chrome.storage.local.clear();
+ chrome.storage.sync.clear();
+ }
+ };
+
+ window.s2s.ChromeHelper = ChromeHelper;
+})();
\ No newline at end of file
diff --git a/src/background/Helper.js b/src/background/Helper.js
new file mode 100644
index 0000000..bad87c7
--- /dev/null
+++ b/src/background/Helper.js
@@ -0,0 +1,38 @@
+(function(){
+ var Helper = {
+ // Thanks : http://stackoverflow.com/a/1714899/1160800
+ serializeUrlVars: function(obj) {
+ var str = [];
+ for(var p in obj)
+ if (obj.hasOwnProperty(p)) {
+ str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
+ }
+ return str.join("&");
+ },
+
+ getUrlVars: function(url) {
+ var re = /[\\?&]([^=]*)=([^]*)/g;
+ var params = {};
+ var match;
+
+ while ((match = re.exec(url)) !== null) {
+ params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
+ }
+
+ return params;
+ },
+
+ // Thanks : https://github.com/spotify/web-api-auth-examples/blob/master/authorization_code/app.js#L24
+ generateRandomString: function(length) {
+ var text = '';
+ var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+ for (var i = 0; i < length; i++) {
+ text += possible.charAt(Math.floor(Math.random() * possible.length));
+ }
+ return text;
+ }
+ };
+
+ window.s2s.Helper = Helper;
+})();
\ No newline at end of file
diff --git a/src/background/LoggerService.js b/src/background/LoggerService.js
new file mode 100644
index 0000000..e22cae6
--- /dev/null
+++ b/src/background/LoggerService.js
@@ -0,0 +1,50 @@
+(function(){
+ var Logger = {
+ logs: [],
+
+ info: function(data) {
+ console.log(data);
+ Logger.add('info', data);
+ },
+
+ error: function(data) {
+ console.error(data);
+ Logger.add('error', data);
+ },
+
+ add: function(type, data) {
+ var log = {
+ type: type,
+ date: new Date(),
+ message: ''
+ };
+
+ if(data instanceof Error) {
+ log.message = data.toString();
+ log.stack = data.stack;
+ } else {
+ log.message = data;
+ }
+
+ Logger.logs.push(log);
+ },
+
+ exportLogs: function() {
+ var data = '';
+
+ Logger.logs.forEach(function(log) {
+ data += '['+ log.type +'] '+ log.date +' - '+ log.message;
+
+ if(log.stack) {
+ data += log.stack;
+ }
+
+ data += '\n';
+ });
+
+ return data;
+ }
+ };
+
+ window.s2s.Logger = Logger;
+})();
\ No newline at end of file
diff --git a/src/background/ShazamService.js b/src/background/ShazamService.js
new file mode 100644
index 0000000..bb4ee54
--- /dev/null
+++ b/src/background/ShazamService.js
@@ -0,0 +1,93 @@
+(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() {
+ Logger.info('[Shazam] login status : logged.');
+ callback(true);
+ })
+ .fail(function(jqXHR, textStatus) {
+ if(jqXHR.status === 401) {
+ Logger.info('[Shazam] login status : not logged (401).');
+ Logger.error(textStatus);
+ callback(false);
+ } else {
+ Logger.info('[Shazam] login status : logged.');
+ callback(true);
+ }
+ });
+ },
+
+ // 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')
+ .done(function(data) {
+ if(data) {
+ Shazam.parseTags(lastUpdate, data, callback);
+ } else {
+ Logger.error('[Shazam] Cannot download Shazam history.');
+ Logger.error('[Shazam] Data returned : "'+ data +'"');
+
+ return callback(new Error('Cannot download Shazam history.'));
+ }
+ })
+ .fail(function(jqXHR, textStatus) {
+ Logger.info('[Shazam] Tags fetch error : '+textStatus+'.');
+
+ return callback(new Error('Tags fetch error : '+textStatus));
+ });
+ },
+
+ // 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...');
+
+ for(var i = 0; i < tagsEl.length && stopParsing === false; i++) {
+ if($('td', tagsEl[i]).length === 0) {
+ continue;
+ }
+
+ 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', tagsEl[i]).attr('href'));
+ if(!idMatch) {
+ continue;
+ }
+
+ var tag = {
+ shazamId: idMatch[1],
+ 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);
+ }
+
+ };
+
+ window.s2s.Shazam = Shazam;
+})(window.s2s.ChromeHelper, window.s2s.Logger);
\ No newline at end of file
diff --git a/src/popup/js/services/SpotifyService.js b/src/background/SpotifyService.js
similarity index 55%
rename from src/popup/js/services/SpotifyService.js
rename to src/background/SpotifyService.js
index f822534..cb574a9 100644
--- a/src/popup/js/services/SpotifyService.js
+++ b/src/background/SpotifyService.js
@@ -1,16 +1,18 @@
-angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper, StorageHelper, Helper, TagsService, $timeout, $http) {
+(function(Helper, StorageHelper, Logger){
var Spotify = {
api: {
clientId: 'b0b7b50eac4642f482825c535bae2708',
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;
@@ -19,17 +21,19 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
async.waterfall([
function checkUserId(cb) {
if(!userId) {
- console.log('No userId stored, need to get one.');
+ Logger.info('[Spotify] No userId stored, need to fetch it.');
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')); }
+ if(err) { Logger.error(err); return cb(err); }
+ if(data && data.id) { Logger.error(data); return cb(new Error('Cannot get user ID')); }
userId = data.id;
+ Logger.info('[Spotify] User ID : '+ userId);
+
cb();
});
} else {
@@ -39,12 +43,14 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
function checkPlaylistId(cb) {
if(!playlistId) {
- console.log('No playlistId stored, need to getOrCreate.');
+ Logger.info('[Spotify] No playlistId stored, need to get or create one.');
Spotify.playlist.getOrCreate(function(err, data) {
if(data && data.id && !err) {
playlistId = data.id;
+ Logger.info('[Spotify] PlaylistId: '+ playlistId);
cb();
} else {
+ Logger.error(err);
cb(new Error('Error creating/getting playlist'));
}
});
@@ -59,51 +65,10 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
},
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;
+ name: chrome.i18n.getMessage('myTags'), // The playlist's name
- 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) {
+ // Get id of existing playlist on Spotify or error if not found
+ getExistingId: function(callback) {
var playlistName = Spotify.playlist.name;
Spotify.data.get(['userId', 'playlistId'], function(items) {
@@ -111,7 +76,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
var playlistId = items.playlistId;
if(playlistId) {
- return Spotify.playlist.get(callback);
+ return callback(null, playlistId);
}
Spotify.findInPagedResult({
@@ -130,71 +95,83 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
}, function(playlistId) {
if(playlistId) {
Spotify.data.set({playlistId: playlistId}, function() {
- Spotify.playlist.get(callback);
+ callback(null, playlistId);
});
} else {
- Spotify.playlist.create(callback);
+ callback(new Error('Playlist not found in Spotify.'));
}
});
});
},
- searchAndAddTags: function(callback) {
- var tracksAdded = [];
-
- async.eachSeries(TagsService.list, function(tag, cbi) {
- if(tag.status > 1) { return cbi(); }
+ // Get playlist details
+ get: function(callback) {
+ Spotify.getUserAndPlaylist(function(err, userId, playlistId) {
+ if(err) { return callback(err); }
- tag.query = tag.query || 'track:'+ tag.name.replace(' ', '+') +' artist:'+ tag.artist.replace(' ', '+');
+ Spotify.call({
+ method: 'GET',
+ endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId
+ }, function(err, data) {
+ if(err) { Logger.error(err); }
- 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);
+ callback(err, data);
});
});
},
- 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')); }
+ // Create playlist on Spotify
+ create: function(callback) {
+ Spotify.data.get(['userId', 'playlistId'], function(items) {
+ var userId = items.userId;
+ var playlistId = items.playlistId;
- var track = data.tracks.items[0];
+ if(playlistId) {
+ Logger.info('[Spotify] PlaylistId exists in storage: '+ playlistId);
+ return Spotify.playlist.get(callback);
+ }
- tag.spotifyId = track.id;
- tag.status = 3;
+ Spotify.call({
+ method: 'POST',
+ endpoint: '/v1/users/'+ userId +'/playlists',
+ data: JSON.stringify({
+ 'name': Spotify.playlist.name,
+ 'public': false
+ })
+ }, function(err, data) {
+ if(err) { Logger.error(err); return callback(err); }
- if(shouldSave) {
- TagsService.save();
- Spotify.playlist.addTracks([tag.spotifyId], function(err) {
- callback(err);
+ Spotify.data.set({playlistId: data.id}, function() {
+ callback(null, data);
});
+ });
+ });
+ },
+
+ // Get playlist if it exists, or create a new one
+ getOrCreate: function(callback) {
+ Spotify.playlist.getExistingId(function(err, playlistId) {
+ if(err) {
+ Spotify.playlist.create(callback);
} else {
- callback();
+ Spotify.playlist.get(callback);
}
});
},
+ // Add an array of trackIds to playlist
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); }
- 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({
@@ -203,41 +180,115 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
}, 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);
+ 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) {
- if(alreadyInPlaylist.indexOf(id) == -1) {
- tracksPaths.push('spotify:track:'+ id);
- } else {
- console.log('Track '+ id +' already in playlist.');
- }
+ tracksPaths.push('spotify:track:'+ id);
});
- // We don't have any tracks to add anymore
- if(tracksPaths.length === 0) {
- return callback();
+ Spotify.playlist._addTracksPaths(tracksPaths, callback);
+ });
+ });
+ },
+
+ // 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) {
+ Logger.info('[Spotify] No tracks to add to playlist.');
+ return callback();
+ }
+
+ Spotify.getUserAndPlaylist(function(err, userId, playlistId) {
+ if(err) { return callback(err); }
+
+ Logger.info('[Spotify] Going to add tracks to playlist '+ playlistId +'...');
+
+ // Spotify API allow only to add 100 tracks per requests, so we need to split it
+ var remainingTracks = tracksPaths.slice(99);
+ if(remainingTracks.length > 0) {
+ Logger.info('[Spotify] Due to Spotify limitation (max 100 tracks addition/request), we will split this request.');
+ Logger.info('[Spotify] Starting to add the first 100 tracks.');
+ Logger.info('[Spotify] Then, we will have '+ remainingTracks.length +' tracks remaining to add.');
+
+ tracksPaths = tracksPaths.slice(0, 99);
+ }
+
+ Logger.info('[Spotify] Saving tracks to playlist '+playlistId+' :');
+ Logger.info(tracksPaths);
+
+ Spotify.call({
+ method: 'POST',
+ endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks',
+ data: JSON.stringify({ uris: tracksPaths })
+ }, function(err, data) {
+ if(err) {
+ Logger.info('[Spotify] Error saving tracks to playlist.');
+ Logger.error(err);
+
+ return callback(err);
}
- Spotify.call({
- method: 'POST',
- endpoint: '/v1/users/'+ userId +'/playlists/'+ playlistId +'/tracks',
- data: tracksPaths
- }, function(err, data) {
- 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 {
+ // All tracks added, finished !
+ Logger.info('[Spotify] All done !');
+ callback();
+ }
});
});
}
},
+ // Find a track on Spotify
+ 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);
+ });
+ },
+
+ // 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';
@@ -251,7 +302,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
scope: 'playlist-read-private playlist-modify-private'
};
- return 'https://accounts.spotify.com/authorize/?'+ Helper.serializeUrlVars(params);
+ return 'https://accounts.spotify.com/authorize/?'+ $.param(params);
},
token: function() {
@@ -259,9 +310,10 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
}
},
+ // 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) { console.error(err); }
+ if(err) { Logger.error(err); }
checkFind(data.items, function(found) {
if(found) {
@@ -281,6 +333,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
});
},
+ // Call an API endpoint
call: function(options, callback) {
Spotify.loginStatus(function(status) {
if(!status) {
@@ -290,18 +343,23 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
Spotify.data.get('accessToken', function(items) {
var accessToken = items.accessToken;
- $http({
- url: (options.endpoint && !options.url) ? 'https://api.spotify.com'+ options.endpoint : options.url,
+ var url = (options.endpoint && !options.url) ? 'https://api.spotify.com'+ options.endpoint : options.url;
+
+ if(options.params) {
+ url += '?'+ $.param(options.params);
+ }
+
+ $.ajax({
+ url: url,
method: options.method,
data: (options.data) ? options.data : null,
- params: (options.params) ? options.params : null,
headers: { 'Authorization': 'Bearer '+ accessToken }
})
- .success(function(data) {
+ .done(function(data) {
callback(null, data);
})
- .error(function(data, status) {
- if(status === 401) {
+ .fail(function(jqXHR, textStatus) {
+ if(jqXHR.status === 401) {
Spotify.refreshToken(function(status) {
if(status === true) {
// Refresh/login successfull, retry call
@@ -313,13 +371,14 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
});
} else {
callback(new Error('Error calling API'));
- console.error('Error calling API : ', options, data, status);
+ Logger.error('[Spotify] Error calling API : '+textStatus+'.');
}
});
});
});
},
+ // 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...
@@ -337,7 +396,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
// 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.');
+ Logger.info('[Spotify] Token expired, we need to refresh it.');
Spotify.refreshToken(callback);
} else {
callback(true);
@@ -345,64 +404,73 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
});
},
+ // Refresh access token
refreshToken: function(callback) {
+ Logger.info('[Spotify] Refreshing token...');
+
Spotify.data.get('refreshToken', function(items) {
if(items.refreshToken) {
- $http({
+ $.ajax({
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({
+ data: {
grant_type: 'refresh_token',
refresh_token: items.refreshToken
- })
+ }
})
- .success(function(data) {
+ .done(function(data) {
Spotify.saveAccessToken(data, function(status) {
if(status === true) {
+ Logger.info('[Spotify] Token refresh successful.');
callback(true);
} else {
- console.error('Error while refreshing token... open login.');
+ Logger.error('[Spotify] Error while refreshing token... open login.');
Spotify.openLogin(true, callback);
}
});
})
- .error(function(data, status) {
- console.error('Error getting token : ', data, status);
+ .fail(function(jqXHR, textStatus) {
+ Logger.error('[Spotify] Error getting token : '+textStatus+'.');
callback(false);
});
} else {
- console.log('No refresh token stored... open login.');
+ Logger.info('[Spotify] No refresh token stored... open login.');
Spotify.openLogin(true, callback);
}
});
},
+ // Get an access token from an auth code
getAccessToken: function(authCode, callback) {
- $http({
+ Logger.info('[Spotify] Getting access token...');
+
+ $.ajax({
url: Spotify.getUrl.token(),
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
- data: $.param({
+ data: {
grant_type: 'authorization_code',
code: authCode,
redirect_uri: Spotify.getUrl.redirect(),
client_id: Spotify.api.clientId,
client_secret: Spotify.api.clientSecret
- })
+ }
})
- .success(function(data) {
+ .done(function(data) {
+ Logger.info('[Spotify] Access token successfuly fetched.');
Spotify.saveAccessToken(data, callback);
})
- .error(function(data, status) {
- console.error('Error getting token : ', data, status);
+ .fail(function(jqXHR, textStatus) {
+ Logger.error('[Spotify] Error getting access token : '+ textStatus +'.');
callback(false);
});
},
+ // Save access token
saveAccessToken: function(data, callback) {
if(data.access_token && data.expires_in) {
Spotify.data.set({
@@ -422,11 +490,13 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
});
});
} else {
- console.error('Error getting token : ', data);
+ Logger.error(data);
+ Logger.error('[Spotify] Error getting access token.');
callback(false);
}
},
+ // Open Spotify login (with chrome identity)
openLogin: function(interactive, callback) {
if(typeof interactive === 'function') {
callback = interactive;
@@ -436,21 +506,28 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
callback = function(){};
}
+ Logger.info('[Spotify] Starting login process...');
+
chrome.identity.launchWebAuthFlow({'url': Spotify.getUrl.authorize(), 'interactive': interactive}, function(redirectUrl) {
- console.log('redirectUrl: ', redirectUrl);
+ if(chrome.runtime.lastError) {
+ Logger.error('[Spotify] Authorization has failed : '+ chrome.runtime.lastError.message);
+
+ return callback(false);
+ }
if(!redirectUrl) {
- callback(false);
- return console.error('Authorization failed : redirect URL empty ('+ redirectUrl +')');
+ Logger.error('[Spotify] Authorization failed : redirect URL empty.');
+
+ return callback(false);
}
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);
+ Logger.error('[Spotify] Authorization has failed.');
+ Logger.error(params.error);
+
+ return callback(false);
}
Spotify.data.set({'authCode': params.code}, function() {
@@ -464,7 +541,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
endpoint: '/v1/me',
method: 'GET'
}, function(err, data) {
- if(err) { console.err('Error getting user infos', err); }
+ if(err) { Logger.error(err); Logger.error('[Spotify] Error getting user infos.'); }
Spotify.data.set({'userId': data.id}, function() {
callback(true);
@@ -475,6 +552,7 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
});
},
+ // Disconnect from Spotify API
disconnect: function(callback) {
callback = callback || function(){};
@@ -490,5 +568,5 @@ angular.module('Shazam2Spotify').factory('SpotifyService', function(ChromeHelper
}
};
- return Spotify;
-});
\ No newline at end of file
+ window.s2s.Spotify = Spotify;
+})(window.s2s.Helper, window.s2s.StorageHelper, window.s2s.Logger);
\ No newline at end of file
diff --git a/src/background/StorageHelper.js b/src/background/StorageHelper.js
new file mode 100644
index 0000000..33dea48
--- /dev/null
+++ b/src/background/StorageHelper.js
@@ -0,0 +1,84 @@
+(function(Logger){
+ var StorageHelper = function(prefix, type) {
+ type = type || 'local';
+
+ this.type = (['local', 'sync'].indexOf(type) != -1) ? type : 'local';
+ this.prefix = prefix;
+ this.cache = {};
+ };
+
+ // Get values either from cache or from storage
+ 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];
+ }
+
+ try {
+ callback(data);
+ } catch(e) {
+ Logger.error('An error occured in storage.get callback:');
+ Logger.error(e);
+ }
+ });
+ };
+
+ // Set values to cache & storage
+ StorageHelper.prototype.set = function(objects, callback) {
+ callback = callback || function(){};
+
+ 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);
+ }
+
+ try {
+ callback(error);
+ } catch(e) {
+ Logger.error('An error occured in storage.set callback:');
+ Logger.error(e);
+ }
+ });
+ };
+
+ // Clear cache
+ StorageHelper.prototype.clearCache = function() {
+ this.cache = {};
+ };
+
+ window.s2s.StorageHelper = StorageHelper;
+})(window.s2s.Logger);
\ No newline at end of file
diff --git a/src/background/TagsService.js b/src/background/TagsService.js
new file mode 100644
index 0000000..293eba5
--- /dev/null
+++ b/src/background/TagsService.js
@@ -0,0 +1,148 @@
+(function(StorageHelper, Logger, Spotify, Shazam){
+ var Tags = {
+ list: [],
+ lastUpdate: new Date(0),
+
+ // Add/update a tag in the tags list
+ add: function(tag, callback) {
+ callback = callback || function(){};
+
+ 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
+
+ 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);
+ }
+ },
+
+ // 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;
+ for(var i in Tags.list) {
+ if(Tags.list[i].shazamId == tag.shazamId) {
+ found = true;
+ $.extend(Tags.list[i], tag); // Update existing tag
+ break;
+ }
+ }
+
+ if(!found) {
+ Tags.list.push(tag);
+ }
+
+ Tags.list.sort(function (a, b) {
+ if (a.date > b.date) { return -1; }
+ if (a.date < b.date) { return 1; }
+ return 0;
+ });
+
+ callback();
+ },
+
+ // Update tags list from MyShazam and then update Spotify playlist
+ update: function(callback) {
+ Logger.info('[Tags] Updating since '+ Tags.lastUpdate +'.');
+
+ Shazam.getTags(Tags.lastUpdate, function(err, tags) {
+ 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) {
+ Tags.add(tag, function() {
+ cbe();
+ });
+ }, function() {
+ Tags.updatePlaylist(callback);
+ });
+ } else {
+ callback(err);
+ }
+ });
+ },
+
+ // Update Spotify playlist with tags found but not already added (status=3)
+ 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 tags data (list & lastUpdate)
+ save: function(callback) {
+ callback = callback || function(){};
+
+ Logger.info('[Tags] Saving tags data.');
+
+ Tags.data.set({'tagsList': Tags.list, 'lastUpdate': Tags.lastUpdate.getTime()}, function() {
+ callback();
+ });
+ },
+
+ // Load tags data (list & lastUpdate)
+ load: function(callback) {
+ callback = callback || function(){};
+
+ 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();
+ });
+ },
+
+ data: new StorageHelper('Tags')
+ };
+
+ window.s2s.Tags = Tags;
+})(window.s2s.StorageHelper, window.s2s.Logger, window.s2s.Spotify, window.s2s.Shazam);
\ No newline at end of file
diff --git a/src/background/UpdateService.js b/src/background/UpdateService.js
new file mode 100644
index 0000000..827f8f6
--- /dev/null
+++ b/src/background/UpdateService.js
@@ -0,0 +1,84 @@
+(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;
+
+ var updatesToApply = [];
+
+ // 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;
+ }
+
+ if(startIndex !== null && UpdateService._updates[i].version <= finalVersion) {
+ endIndex = i;
+
+ updatesToApply.push(UpdateService._updates[i]);
+ }
+
+ shouldStop = (startIndex !== null && endIndex !== null && UpdateService._updates[i].version >= finalVersion);
+ }
+
+ if(startIndex !== null && endIndex !== null) {
+ 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 +'.');
+ }
+ },
+
+ 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': 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();
+
+ // Reload tags, will reset list & lastUpdate
+ s2s.Tags.load();
+
+ UpdateService.openUpdatePage('0.2.0');
+
+ callback();
+ }}
+ ]
+ };
+
+ window.s2s.UpdateService = UpdateService;
+})(window.s2s.Logger, window.s2s.ChromeHelper, window.s2s.Spotify, window.s2s.Shazam, window.s2s.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..39f2639
--- /dev/null
+++ b/src/background/background.js
@@ -0,0 +1,103 @@
+window.s2s = {};
+
+$(document).ready(function() {
+ s2s.Logger.info('[init] Welcome ! '+ (new Date()));
+ s2s.Logger.info('[init] Loading tags from storage...');
+
+ s2s.Tags.load(function() {
+ s2s.Logger.info('[init] '+ s2s.Tags.list.length +' tags loaded.');
+ });
+
+ s2s.CanvasIcon.load();
+
+ s2s.updating = false;
+
+ s2s.updateTags = function(callback) {
+ if(s2s.updating) {
+ s2s.Logger.info('[core] Tags update already in progress.');
+ return callback('already_in_progress');
+ }
+
+ s2s.Logger.info('[core] Starting tags update...');
+
+ s2s.updating = true;
+ s2s.CanvasIcon.startRotation();
+
+ s2s.Spotify.playlist.get(function(err) {
+ if(err) {
+ s2s.Logger.info('[core] Error getting playlist. Tags update aborted.');
+ s2s.updating = false;
+ s2s.CanvasIcon.stopRotation();
+ return callback(err);
+ }
+
+ s2s.Logger.info('[core] Spotify playlist got.');
+ s2s.Logger.info('[core] Updating tags...');
+
+ s2s.Tags.update(function(err) {
+ if(err) {
+ s2s.Logger.info('[core] Error updating tags.');
+ } else {
+ s2s.Logger.info('[core] All done ! Tags updated.');
+ }
+
+ s2s.updating = false;
+ s2s.CanvasIcon.stopRotation();
+
+ callback(err);
+ });
+ });
+ };
+
+ s2s.searchTag = function(trackName, artist, tag, callback) {
+ tag.name = trackName;
+ tag.artist = artist;
+ tag.query = null; // Will be regenerated by Tags.add
+
+ s2s.Tags.add(tag, function() {
+ if(tag.status > 2) {
+ s2s.Tags.updatePlaylist(callback);
+ } else {
+ callback(new Error('Not found'));
+ }
+ });
+ };
+
+ // When we receive a "clearStorage" message, we need to close popup and then clear storage
+ chrome.extension.onMessage.addListener(function(request,sender,sendResponse)
+ {
+ if(request.greeting === 'clearStorage')
+ {
+ // Close popup before cleaning storage if we don't want Chrome to crash on Windows
+ var popup = chrome.extension.getViews({type: 'popup'})[0];
+ popup.window.close();
+
+ s2s.Logger.info('[core] Cleaning extension\'s background data.');
+
+ s2s.ChromeHelper.clearStorage();
+
+ // Reload background script - disabled for now, it crash Chrome on Windows
+ //window.location.reload();
+
+ // Clear cached data from background script
+ s2s.Tags.data.clearCache();
+ s2s.Spotify.data.clearCache();
+ s2s.CanvasIcon.stopRotation();
+
+ // Reload tags, will reset list & lastUpdate
+ s2s.Tags.load();
+ }
+ });
+
+ // 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 +'.');
+
+ s2s.UpdateService.update(details.previousVersion, thisVersion);
+ }
+ });
+});
\ No newline at end of file
diff --git a/src/background/lib/async.js b/src/background/lib/async.js
new file mode 100644
index 0000000..2aa9ba4
--- /dev/null
+++ b/src/background/lib/async.js
@@ -0,0 +1,1127 @@
+/*!
+ * 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