Skip to content

Commit 4aa4255

Browse files
authored
Add rate limiting and export progress tracking [#12]
* Implement rate limiting using Retry-After header - Remove jQuery in exporter objects - Use Bottleneck library for rate limiting https://github.com/SGrondin/bottleneck - Simplify code * Add progress bar for Export All progress * Update tests to ensure access token is correctly passed
1 parent b4c171e commit 4aa4255

13 files changed

+274
-202
lines changed

package.json

Lines changed: 1 addition & 0 deletions
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.scss

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@
1515
h1 a { color: black; }
1616
h1 a:hover { color: black; text-decoration: none; }
1717

18-
nav.paginator:nth-child(1) {
19-
margin-top: -74px;
20-
}
21-
2218
table {
2319
float: left;
2420
}
@@ -27,6 +23,28 @@ table {
2723
display: none;
2824
}
2925

26+
#playlistsHeader {
27+
display: flex;
28+
flex-direction: row-reverse;
29+
30+
.progress {
31+
flex-grow: 1;
32+
margin: 20px 20px 20px 0;
33+
height: 30px;
34+
35+
.progress-bar {
36+
white-space: nowrap;
37+
padding: 4px 10px;
38+
text-align: left;
39+
40+
// Transitioning when resetting looks weird
41+
&[aria-valuenow="1"] {
42+
transition: none;
43+
}
44+
}
45+
}
46+
}
47+
3048
@keyframes spinner {
3149
to {transform: rotate(360deg);}
3250
}

src/App.test.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe("authentication request", () => {
3434

3535
describe("authentication return", () => {
3636
beforeAll(() => {
37-
window.location = { hash: "#access_token=TEST_TOKEN" }
37+
window.location = { hash: "#access_token=TEST_ACCESS_TOKEN" }
3838
})
3939

4040
test("renders playlist component on return from Spotify with auth token", () => {

src/App.tsx

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

src/components/PlaylistExporter.jsx

Lines changed: 44 additions & 58 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react"
22
import $ from "jquery" // TODO: Remove jQuery dependency
3-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
3+
import { ProgressBar } from "react-bootstrap"
44

55
import PlaylistRow from "./PlaylistRow"
66
import Paginator from "./Paginator"
@@ -11,45 +11,43 @@ class PlaylistTable extends React.Component {
1111
state = {
1212
playlists: [],
1313
playlistCount: 0,
14-
likedSongsLimit: 0,
15-
likedSongsCount: 0,
14+
likedSongs: {
15+
limit: 0,
16+
count: 0
17+
},
1618
nextURL: null,
17-
prevURL: null
19+
prevURL: null,
20+
progressBar: {
21+
show: false,
22+
label: "",
23+
value: 0
24+
}
1825
}
1926

2027
loadPlaylists = (url) => {
2128
var userId = '';
2229
var firstPage = typeof url === 'undefined' || url.indexOf('offset=0') > -1;
2330

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

2734
// Show liked tracks playlist if viewing first page
2835
if (firstPage) {
29-
return $.when.apply($, [
36+
return Promise.all([
3037
apiCall(
31-
"https://api.spotify.com/v1/users/" + userId + "/tracks",
32-
this.props.access_token
38+
"https://api.spotify.com/v1/users/" + userId + "/playlists",
39+
this.props.accessToken
3340
),
3441
apiCall(
35-
"https://api.spotify.com/v1/users/" + userId + "/playlists",
36-
this.props.access_token
42+
"https://api.spotify.com/v1/users/" + userId + "/tracks",
43+
this.props.accessToken
3744
)
3845
])
3946
} 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;
47+
return Promise.all([apiCall(url, this.props.accessToken)])
5248
}
49+
}).then(([playlistsResponse, likedTracksResponse]) => {
50+
let playlists = playlistsResponse.items;
5351

5452
// Show library of saved tracks if viewing first page
5553
if (firstPage) {
@@ -65,46 +63,68 @@ class PlaylistTable extends React.Component {
6563
},
6664
"tracks": {
6765
"href": "https://api.spotify.com/v1/me/tracks",
68-
"limit": args[0][0].limit,
69-
"total": args[0][0].total
66+
"limit": likedTracksResponse.limit,
67+
"total": likedTracksResponse.total
7068
},
7169
"uri": "spotify:user:" + userId + ":saved"
7270
});
7371

7472
// FIXME: Handle unmounting
7573
this.setState({
76-
likedSongsLimit: args[0][0].limit,
77-
likedSongsCount: args[0][0].total
74+
likedSongs: {
75+
limit: likedTracksResponse.limit,
76+
count: likedTracksResponse.total
77+
}
7878
})
7979
}
8080

8181
// FIXME: Handle unmounting
8282
this.setState({
8383
playlists: playlists,
84-
playlistCount: response.total,
85-
nextURL: response.next,
86-
prevURL: response.previous
84+
playlistCount: playlistsResponse.total,
85+
nextURL: playlistsResponse.next,
86+
prevURL: playlistsResponse.previous
8787
});
8888

8989
$('#playlists').fadeIn();
90-
$('#subtitle').text((response.offset + 1) + '-' + (response.offset + response.items.length) + ' of ' + response.total + ' playlists for ' + userId)
90+
$('#subtitle').text((playlistsResponse.offset + 1) + '-' + (playlistsResponse.offset + playlistsResponse.items.length) + ' of ' + playlistsResponse.total + ' playlists for ' + userId)
91+
})
92+
}
9193

94+
handleLoadedPlaylistsCountChanged = (count) => {
95+
this.setState({
96+
progressBar: {
97+
show: true,
98+
label: "Loading playlists...",
99+
value: count
100+
}
92101
})
93102
}
94103

95-
exportPlaylists = () => {
96-
PlaylistsExporter.export(this.props.access_token, this.state.playlistCount, this.state.likedSongsLimit, this.state.likedSongsCount);
104+
handleExportedPlaylistsCountChanged = (count) => {
105+
this.setState({
106+
progressBar: {
107+
show: true,
108+
label: count >= this.state.playlistCount ? "Done!" : "Exporting tracks...",
109+
value: count
110+
}
111+
})
97112
}
98113

99114
componentDidMount() {
100115
this.loadPlaylists(this.props.url);
101116
}
102117

103118
render() {
104-
if (this.state.playlists.length > 0) {
119+
const progressBar = <ProgressBar striped active={this.state.progressBar.value < this.state.playlistCount} now={this.state.progressBar.value} max={this.state.playlistCount} label={this.state.progressBar.label} />
120+
121+
if (this.state.playlistCount > 0) {
105122
return (
106123
<div id="playlists">
107-
<Paginator nextURL={this.state.nextURL} prevURL={this.state.prevURL} loadPlaylists={this.loadPlaylists}/>
124+
<div id="playlistsHeader">
125+
<Paginator nextURL={this.state.nextURL} prevURL={this.state.prevURL} loadPlaylists={this.loadPlaylists}/>
126+
{this.state.progressBar.show && progressBar}
127+
</div>
108128
<table className="table table-hover">
109129
<thead>
110130
<tr>
@@ -115,15 +135,19 @@ class PlaylistTable extends React.Component {
115135
<th style={{width: "120px"}}>Public?</th>
116136
<th style={{width: "120px"}}>Collaborative?</th>
117137
<th style={{width: "100px"}} className="text-right">
118-
<button className="btn btn-default btn-xs" type="submit" onClick={this.exportPlaylists}>
119-
<span className="fa fa-file-archive"></span><FontAwesomeIcon icon={['far', 'file-archive']}/> Export All
120-
</button>
138+
<PlaylistsExporter
139+
accessToken={this.props.accessToken}
140+
onLoadedPlaylistsCountChanged={this.handleLoadedPlaylistsCountChanged}
141+
onExportedPlaylistsCountChanged={this.handleExportedPlaylistsCountChanged}
142+
playlistCount={this.state.playlistCount}
143+
likedSongs={this.state.likedSongs}
144+
/>
121145
</th>
122146
</tr>
123147
</thead>
124148
<tbody>
125149
{this.state.playlists.map((playlist, i) => {
126-
return <PlaylistRow playlist={playlist} key={playlist.id} access_token={this.props.access_token}/>
150+
return <PlaylistRow playlist={playlist} key={playlist.id} accessToken={this.props.accessToken}/>
127151
})}
128152
</tbody>
129153
</table>

0 commit comments

Comments
 (0)