Skip to content

Commit 75f6048

Browse files
committed
Implement rate limiting using Retry-After header [#12]
- Remove jQuery in exporter objects - Use Bottleneck library for rate limiting https://github.com/SGrondin/bottleneck - Simplify code
1 parent 032ec7f commit 75f6048

8 files changed

+131
-152
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@types/react-bootstrap": "^0.32.24",
2121
"@types/react-dom": "^16.9.8",
2222
"bootstrap": "3.3.7",
23+
"bottleneck": "^2.19.5",
2324
"file-saver": "^2.0.2",
2425
"jquery": "2.1.4",
2526
"jszip": "^3.5.0",

src/App.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function App() {
1919
<p style={{ marginTop: "50px" }}>It should still be possible to export individual playlists, particularly when using your own Spotify application.</p>
2020
</div>
2121
} else if (key.has('access_token')) {
22-
view = <PlaylistTable access_token={key.get('access_token')} />
22+
view = <PlaylistTable accessToken={key.get('access_token')} />
2323
} else {
2424
view = <Login />
2525
}

src/components/PlaylistExporter.jsx

+44-58
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,69 @@
1-
import $ from "jquery" // TODO: Remove jQuery dependency
21
import { saveAs } from "file-saver"
32

43
import { apiCall } from "helpers"
54

65
// Handles exporting a single playlist as a CSV file
76
var PlaylistExporter = {
8-
export: function(access_token, playlist) {
9-
this.csvData(access_token, playlist).then((data) => {
7+
export: function(accessToken, playlist) {
8+
this.csvData(accessToken, playlist).then((data) => {
109
var blob = new Blob([ data ], { type: "text/csv;charset=utf-8" });
1110
saveAs(blob, this.fileName(playlist), true);
1211
})
1312
},
1413

15-
csvData: function(access_token, playlist) {
14+
csvData: async function(accessToken, playlist) {
1615
var requests = [];
1716
var limit = playlist.tracks.limit || 100;
1817

18+
// Add tracks
1919
for (var offset = 0; offset < playlist.tracks.total; offset = offset + limit) {
20-
requests.push(
21-
apiCall(`${playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`, access_token)
22-
)
20+
requests.push(`${playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`)
2321
}
2422

25-
return $.when.apply($, requests).then(function() {
26-
var responses = [];
27-
28-
// Handle either single or multiple responses
29-
if (typeof arguments[0] != 'undefined') {
30-
if (typeof arguments[0].href == 'undefined') {
31-
responses = Array.prototype.slice.call(arguments).map(function(a) { return a[0] });
32-
} else {
33-
responses = [arguments[0]];
34-
}
35-
}
36-
37-
var tracks = responses.map(function(response) {
38-
return response.items.map(function(item) {
39-
return item.track && [
40-
item.track.uri,
41-
item.track.name,
42-
item.track.artists.map(function(artist) { return artist.uri }).join(', '),
43-
item.track.artists.map(function(artist) { return String(artist.name).replace(/,/g, "\\,") }).join(', '),
44-
item.track.album.uri,
45-
item.track.album.name,
46-
item.track.disc_number,
47-
item.track.track_number,
48-
item.track.duration_ms,
49-
item.added_by == null ? '' : item.added_by.uri,
50-
item.added_at
51-
];
52-
}).filter(e => e);
53-
});
54-
55-
// Flatten the array of pages
56-
tracks = $.map(tracks, function(n) { return n })
23+
let promises = requests.map((request) => {
24+
return apiCall(request, accessToken)
25+
})
5726

58-
tracks.unshift([
59-
"Track URI",
60-
"Track Name",
61-
"Artist URI",
62-
"Artist Name",
63-
"Album URI",
64-
"Album Name",
65-
"Disc Number",
66-
"Track Number",
67-
"Track Duration (ms)",
68-
"Added By",
69-
"Added At"
70-
]);
27+
let tracks = (await Promise.all(promises)).flatMap(response => {
28+
return response.items.map(item => {
29+
return item.track && [
30+
item.track.uri,
31+
item.track.name,
32+
item.track.artists.map(function(artist) { return artist.uri }).join(', '),
33+
item.track.artists.map(function(artist) { return String(artist.name).replace(/,/g, "\\,") }).join(', '),
34+
item.track.album.uri,
35+
item.track.album.name,
36+
item.track.disc_number,
37+
item.track.track_number,
38+
item.track.duration_ms,
39+
item.added_by == null ? '' : item.added_by.uri,
40+
item.added_at
41+
];
42+
}).filter(e => e)
43+
})
7144

72-
let csvContent = '';
45+
tracks.unshift([
46+
"Track URI",
47+
"Track Name",
48+
"Artist URI",
49+
"Artist Name",
50+
"Album URI",
51+
"Album Name",
52+
"Disc Number",
53+
"Track Number",
54+
"Track Duration (ms)",
55+
"Added By",
56+
"Added At"
57+
]);
7358

74-
tracks.forEach(function(row, index){
75-
let dataString = row.map(function (cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(",");
76-
csvContent += dataString + "\n";
77-
});
59+
let csvContent = '';
7860

79-
return csvContent;
61+
tracks.forEach(function(row, index){
62+
let dataString = row.map(function (cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(",");
63+
csvContent += dataString + "\n";
8064
});
65+
66+
return csvContent;
8167
},
8268

8369
fileName: function(playlist) {

src/components/PlaylistRow.jsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import PlaylistExporter from "./PlaylistExporter"
55

66
class PlaylistRow extends React.Component {
77
exportPlaylist = () => {
8-
PlaylistExporter.export(this.props.access_token, this.props.playlist);
8+
PlaylistExporter.export(this.props.accessToken, this.props.playlist);
99
}
1010

1111
renderTickCross(condition) {

src/components/PlaylistTable.jsx

+19-29
Original file line numberDiff line numberDiff line change
@@ -21,35 +21,26 @@ class PlaylistTable extends React.Component {
2121
var userId = '';
2222
var firstPage = typeof url === 'undefined' || url.indexOf('offset=0') > -1;
2323

24-
apiCall("https://api.spotify.com/v1/me", this.props.access_token).then((response) => {
24+
apiCall("https://api.spotify.com/v1/me", this.props.accessToken).then((response) => {
2525
userId = response.id;
2626

2727
// Show liked tracks playlist if viewing first page
2828
if (firstPage) {
29-
return $.when.apply($, [
29+
return Promise.all([
3030
apiCall(
31-
"https://api.spotify.com/v1/users/" + userId + "/tracks",
32-
this.props.access_token
31+
"https://api.spotify.com/v1/users/" + userId + "/playlists",
32+
this.props.accessToken
3333
),
3434
apiCall(
35-
"https://api.spotify.com/v1/users/" + userId + "/playlists",
36-
this.props.access_token
35+
"https://api.spotify.com/v1/users/" + userId + "/tracks",
36+
this.props.accessToken
3737
)
3838
])
3939
} else {
40-
return apiCall(url, this.props.access_token);
41-
}
42-
}).done((...args) => {
43-
var response;
44-
var playlists = [];
45-
46-
if (args[1] === 'success') {
47-
response = args[0];
48-
playlists = args[0].items;
49-
} else {
50-
response = args[1][0];
51-
playlists = args[1][0].items;
40+
return Promise.all([apiCall(url, this.props.accessToken)])
5241
}
42+
}).then(([playlistsResponse, likedTracksResponse]) => {
43+
let playlists = playlistsResponse.items;
5344

5445
// Show library of saved tracks if viewing first page
5546
if (firstPage) {
@@ -65,35 +56,34 @@ class PlaylistTable extends React.Component {
6556
},
6657
"tracks": {
6758
"href": "https://api.spotify.com/v1/me/tracks",
68-
"limit": args[0][0].limit,
69-
"total": args[0][0].total
59+
"limit": likedTracksResponse.limit,
60+
"total": likedTracksResponse.total
7061
},
7162
"uri": "spotify:user:" + userId + ":saved"
7263
});
7364

7465
// FIXME: Handle unmounting
7566
this.setState({
76-
likedSongsLimit: args[0][0].limit,
77-
likedSongsCount: args[0][0].total
67+
likedSongsLimit: likedTracksResponse.limit,
68+
likedSongsCount: likedTracksResponse.total
7869
})
7970
}
8071

8172
// FIXME: Handle unmounting
8273
this.setState({
8374
playlists: playlists,
84-
playlistCount: response.total,
85-
nextURL: response.next,
86-
prevURL: response.previous
75+
playlistCount: playlistsResponse.total,
76+
nextURL: playlistsResponse.next,
77+
prevURL: playlistsResponse.previous
8778
});
8879

8980
$('#playlists').fadeIn();
90-
$('#subtitle').text((response.offset + 1) + '-' + (response.offset + response.items.length) + ' of ' + response.total + ' playlists for ' + userId)
91-
81+
$('#subtitle').text((playlistsResponse.offset + 1) + '-' + (playlistsResponse.offset + playlistsResponse.items.length) + ' of ' + playlistsResponse.total + ' playlists for ' + userId)
9282
})
9383
}
9484

9585
exportPlaylists = () => {
96-
PlaylistsExporter.export(this.props.access_token, this.state.playlistCount, this.state.likedSongsLimit, this.state.likedSongsCount);
86+
PlaylistsExporter.export(this.props.accessToken, this.state.playlistCount, this.state.likedSongsLimit, this.state.likedSongsCount);
9787
}
9888

9989
componentDidMount() {
@@ -123,7 +113,7 @@ class PlaylistTable extends React.Component {
123113
</thead>
124114
<tbody>
125115
{this.state.playlists.map((playlist, i) => {
126-
return <PlaylistRow playlist={playlist} key={playlist.id} access_token={this.props.access_token}/>
116+
return <PlaylistRow playlist={playlist} key={playlist.id} accessToken={this.props.accessToken}/>
127117
})}
128118
</tbody>
129119
</table>

src/components/PlaylistsExporter.jsx

+38-49
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import $ from "jquery" // TODO: Remove jQuery dependency
21
import { saveAs } from "file-saver"
32
import JSZip from "jszip"
43

@@ -7,70 +6,60 @@ import { apiCall } from "helpers"
76

87
// Handles exporting all playlist data as a zip file
98
let PlaylistsExporter = {
10-
export: function(access_token, playlistCount, likedSongsLimit, likedSongsCount) {
11-
var playlistFileNames = [];
9+
export: async function(accessToken, playlistCount, likedSongsLimit, likedSongsCount) {
1210

13-
apiCall("https://api.spotify.com/v1/me", access_token).then(function(response) {
11+
var playlistFileNames = []
12+
var playlistCsvExports = []
13+
14+
apiCall("https://api.spotify.com/v1/me", accessToken).then(async (response) => {
1415
var limit = 20;
1516
var userId = response.id;
1617
var requests = [];
1718

1819
// Add playlists
1920
for (var offset = 0; offset < playlistCount; offset = offset + limit) {
2021
var url = "https://api.spotify.com/v1/users/" + userId + "/playlists";
21-
requests.push(
22-
apiCall(`${url}?offset=${offset}&limit=${limit}`, access_token)
23-
)
22+
requests.push(`${url}?offset=${offset}&limit=${limit}`)
2423
}
2524

26-
$.when.apply($, requests).then(function() {
27-
var playlists = [];
28-
var playlistExports = [];
25+
let playlistPromises = requests.map((request, index) => {
26+
return apiCall(request, accessToken).then((response) => {
27+
return response
28+
})
29+
})
2930

30-
// Handle either single or multiple responses
31-
if (typeof arguments[0].href == 'undefined') {
32-
$(arguments).each(function(i, response) {
33-
if (typeof response[0].items === 'undefined') {
34-
// Single playlist
35-
playlists.push(response[0]);
36-
} else {
37-
// Page of playlists
38-
$.merge(playlists, response[0].items);
39-
}
40-
})
41-
} else {
42-
playlists = arguments[0].items
43-
}
31+
let playlists = (await Promise.all(playlistPromises)).flatMap(response => response.items)
4432

45-
// Add library of saved tracks
46-
playlists.unshift({
47-
"id": "liked",
48-
"name": "Liked",
49-
"tracks": {
50-
"href": "https://api.spotify.com/v1/me/tracks",
51-
"limit": likedSongsLimit,
52-
"total": likedSongsCount
53-
},
54-
});
33+
// Add library of saved tracks
34+
playlists.unshift({
35+
"id": "liked",
36+
"name": "Liked",
37+
"tracks": {
38+
"href": "https://api.spotify.com/v1/me/tracks",
39+
"limit": likedSongsLimit,
40+
"total": likedSongsCount
41+
},
42+
})
5543

56-
$(playlists).each(function(i, playlist) {
57-
playlistFileNames.push(PlaylistExporter.fileName(playlist));
58-
playlistExports.push(PlaylistExporter.csvData(access_token, playlist));
59-
});
44+
let trackPromises = playlists.map((playlist, index) => {
45+
return PlaylistExporter.csvData(accessToken, playlist).then((csvData) => {
46+
playlistFileNames.push(PlaylistExporter.fileName(playlist))
47+
playlistCsvExports.push(csvData)
48+
})
49+
})
6050

61-
return $.when.apply($, playlistExports);
62-
}).then(function() {
63-
var zip = new JSZip();
51+
await Promise.all(trackPromises)
6452

65-
$(arguments).each(function(i, response) {
66-
zip.file(playlistFileNames[i], response)
67-
});
53+
var zip = new JSZip()
6854

69-
zip.generateAsync({ type: "blob" }).then(function(content) {
70-
saveAs(content, "spotify_playlists.zip");
71-
})
72-
});
73-
});
55+
playlistCsvExports.forEach(function(csv, i) {
56+
zip.file(playlistFileNames[i], csv)
57+
})
58+
59+
zip.generateAsync({ type: "blob" }).then(function(content) {
60+
saveAs(content, "spotify_playlists.zip");
61+
})
62+
})
7463
}
7564
}
7665

0 commit comments

Comments
 (0)