Skip to content

Commit

Permalink
Add rate limiting and export progress tracking [#12]
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
watsonbox authored Nov 16, 2020
1 parent b4c171e commit 4aa4255
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 202 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@types/react-bootstrap": "^0.32.24",
"@types/react-dom": "^16.9.8",
"bootstrap": "3.3.7",
"bottleneck": "^2.19.5",
"file-saver": "^2.0.2",
"jquery": "2.1.4",
"jszip": "^3.5.0",
Expand Down
26 changes: 22 additions & 4 deletions src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@
h1 a { color: black; }
h1 a:hover { color: black; text-decoration: none; }

nav.paginator:nth-child(1) {
margin-top: -74px;
}

table {
float: left;
}
Expand All @@ -27,6 +23,28 @@ table {
display: none;
}

#playlistsHeader {
display: flex;
flex-direction: row-reverse;

.progress {
flex-grow: 1;
margin: 20px 20px 20px 0;
height: 30px;

.progress-bar {
white-space: nowrap;
padding: 4px 10px;
text-align: left;

// Transitioning when resetting looks weird
&[aria-valuenow="1"] {
transition: none;
}
}
}
}

@keyframes spinner {
to {transform: rotate(360deg);}
}
Expand Down
2 changes: 1 addition & 1 deletion src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("authentication request", () => {

describe("authentication return", () => {
beforeAll(() => {
window.location = { hash: "#access_token=TEST_TOKEN" }
window.location = { hash: "#access_token=TEST_ACCESS_TOKEN" }
})

test("renders playlist component on return from Spotify with auth token", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function App() {
<p style={{ marginTop: "50px" }}>It should still be possible to export individual playlists, particularly when using your own Spotify application.</p>
</div>
} else if (key.has('access_token')) {
view = <PlaylistTable access_token={key.get('access_token')} />
view = <PlaylistTable accessToken={key.get('access_token')} />
} else {
view = <Login />
}
Expand Down
102 changes: 44 additions & 58 deletions src/components/PlaylistExporter.jsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,69 @@
import $ from "jquery" // TODO: Remove jQuery dependency
import { saveAs } from "file-saver"

import { apiCall } from "helpers"

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

csvData: function(access_token, playlist) {
csvData: async function(accessToken, playlist) {
var requests = [];
var limit = playlist.tracks.limit || 100;

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

return $.when.apply($, requests).then(function() {
var responses = [];

// Handle either single or multiple responses
if (typeof arguments[0] != 'undefined') {
if (typeof arguments[0].href == 'undefined') {
responses = Array.prototype.slice.call(arguments).map(function(a) { return a[0] });
} else {
responses = [arguments[0]];
}
}

var tracks = responses.map(function(response) {
return response.items.map(function(item) {
return item.track && [
item.track.uri,
item.track.name,
item.track.artists.map(function(artist) { return artist.uri }).join(', '),
item.track.artists.map(function(artist) { return String(artist.name).replace(/,/g, "\\,") }).join(', '),
item.track.album.uri,
item.track.album.name,
item.track.disc_number,
item.track.track_number,
item.track.duration_ms,
item.added_by == null ? '' : item.added_by.uri,
item.added_at
];
}).filter(e => e);
});

// Flatten the array of pages
tracks = $.map(tracks, function(n) { return n })
let promises = requests.map((request) => {
return apiCall(request, accessToken)
})

tracks.unshift([
"Track URI",
"Track Name",
"Artist URI",
"Artist Name",
"Album URI",
"Album Name",
"Disc Number",
"Track Number",
"Track Duration (ms)",
"Added By",
"Added At"
]);
let tracks = (await Promise.all(promises)).flatMap(response => {
return response.items.map(item => {
return item.track && [
item.track.uri,
item.track.name,
item.track.artists.map(function(artist) { return artist.uri }).join(', '),
item.track.artists.map(function(artist) { return String(artist.name).replace(/,/g, "\\,") }).join(', '),
item.track.album.uri,
item.track.album.name,
item.track.disc_number,
item.track.track_number,
item.track.duration_ms,
item.added_by == null ? '' : item.added_by.uri,
item.added_at
];
}).filter(e => e)
})

let csvContent = '';
tracks.unshift([
"Track URI",
"Track Name",
"Artist URI",
"Artist Name",
"Album URI",
"Album Name",
"Disc Number",
"Track Number",
"Track Duration (ms)",
"Added By",
"Added At"
]);

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

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

return csvContent;
},

fileName: function(playlist) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/PlaylistRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PlaylistExporter from "./PlaylistExporter"

class PlaylistRow extends React.Component {
exportPlaylist = () => {
PlaylistExporter.export(this.props.access_token, this.props.playlist);
PlaylistExporter.export(this.props.accessToken, this.props.playlist);
}

renderTickCross(condition) {
Expand Down
100 changes: 62 additions & 38 deletions src/components/PlaylistTable.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react"
import $ from "jquery" // TODO: Remove jQuery dependency
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { ProgressBar } from "react-bootstrap"

import PlaylistRow from "./PlaylistRow"
import Paginator from "./Paginator"
Expand All @@ -11,45 +11,43 @@ class PlaylistTable extends React.Component {
state = {
playlists: [],
playlistCount: 0,
likedSongsLimit: 0,
likedSongsCount: 0,
likedSongs: {
limit: 0,
count: 0
},
nextURL: null,
prevURL: null
prevURL: null,
progressBar: {
show: false,
label: "",
value: 0
}
}

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

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

// Show liked tracks playlist if viewing first page
if (firstPage) {
return $.when.apply($, [
return Promise.all([
apiCall(
"https://api.spotify.com/v1/users/" + userId + "/tracks",
this.props.access_token
"https://api.spotify.com/v1/users/" + userId + "/playlists",
this.props.accessToken
),
apiCall(
"https://api.spotify.com/v1/users/" + userId + "/playlists",
this.props.access_token
"https://api.spotify.com/v1/users/" + userId + "/tracks",
this.props.accessToken
)
])
} else {
return apiCall(url, this.props.access_token);
}
}).done((...args) => {
var response;
var playlists = [];

if (args[1] === 'success') {
response = args[0];
playlists = args[0].items;
} else {
response = args[1][0];
playlists = args[1][0].items;
return Promise.all([apiCall(url, this.props.accessToken)])
}
}).then(([playlistsResponse, likedTracksResponse]) => {
let playlists = playlistsResponse.items;

// Show library of saved tracks if viewing first page
if (firstPage) {
Expand All @@ -65,46 +63,68 @@ class PlaylistTable extends React.Component {
},
"tracks": {
"href": "https://api.spotify.com/v1/me/tracks",
"limit": args[0][0].limit,
"total": args[0][0].total
"limit": likedTracksResponse.limit,
"total": likedTracksResponse.total
},
"uri": "spotify:user:" + userId + ":saved"
});

// FIXME: Handle unmounting
this.setState({
likedSongsLimit: args[0][0].limit,
likedSongsCount: args[0][0].total
likedSongs: {
limit: likedTracksResponse.limit,
count: likedTracksResponse.total
}
})
}

// FIXME: Handle unmounting
this.setState({
playlists: playlists,
playlistCount: response.total,
nextURL: response.next,
prevURL: response.previous
playlistCount: playlistsResponse.total,
nextURL: playlistsResponse.next,
prevURL: playlistsResponse.previous
});

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

handleLoadedPlaylistsCountChanged = (count) => {
this.setState({
progressBar: {
show: true,
label: "Loading playlists...",
value: count
}
})
}

exportPlaylists = () => {
PlaylistsExporter.export(this.props.access_token, this.state.playlistCount, this.state.likedSongsLimit, this.state.likedSongsCount);
handleExportedPlaylistsCountChanged = (count) => {
this.setState({
progressBar: {
show: true,
label: count >= this.state.playlistCount ? "Done!" : "Exporting tracks...",
value: count
}
})
}

componentDidMount() {
this.loadPlaylists(this.props.url);
}

render() {
if (this.state.playlists.length > 0) {
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} />

if (this.state.playlistCount > 0) {
return (
<div id="playlists">
<Paginator nextURL={this.state.nextURL} prevURL={this.state.prevURL} loadPlaylists={this.loadPlaylists}/>
<div id="playlistsHeader">
<Paginator nextURL={this.state.nextURL} prevURL={this.state.prevURL} loadPlaylists={this.loadPlaylists}/>
{this.state.progressBar.show && progressBar}
</div>
<table className="table table-hover">
<thead>
<tr>
Expand All @@ -115,15 +135,19 @@ class PlaylistTable extends React.Component {
<th style={{width: "120px"}}>Public?</th>
<th style={{width: "120px"}}>Collaborative?</th>
<th style={{width: "100px"}} className="text-right">
<button className="btn btn-default btn-xs" type="submit" onClick={this.exportPlaylists}>
<span className="fa fa-file-archive"></span><FontAwesomeIcon icon={['far', 'file-archive']}/> Export All
</button>
<PlaylistsExporter
accessToken={this.props.accessToken}
onLoadedPlaylistsCountChanged={this.handleLoadedPlaylistsCountChanged}
onExportedPlaylistsCountChanged={this.handleExportedPlaylistsCountChanged}
playlistCount={this.state.playlistCount}
likedSongs={this.state.likedSongs}
/>
</th>
</tr>
</thead>
<tbody>
{this.state.playlists.map((playlist, i) => {
return <PlaylistRow playlist={playlist} key={playlist.id} access_token={this.props.access_token}/>
return <PlaylistRow playlist={playlist} key={playlist.id} accessToken={this.props.accessToken}/>
})}
</tbody>
</table>
Expand Down
Loading

0 comments on commit 4aa4255

Please sign in to comment.