diff --git a/package.json b/package.json
index a40adc1..ee182d3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.scss b/src/App.scss
index 07622e4..2a121a7 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -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;
}
@@ -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);}
}
diff --git a/src/App.test.jsx b/src/App.test.jsx
index a3afe02..35740ca 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -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", () => {
diff --git a/src/App.tsx b/src/App.tsx
index 6e40dde..1c4116e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -19,7 +19,7 @@ function App() {
It should still be possible to export individual playlists, particularly when using your own Spotify application.
} else if (key.has('access_token')) {
- view =
+ view =
} else {
view =
}
diff --git a/src/components/PlaylistExporter.jsx b/src/components/PlaylistExporter.jsx
index 7ef709d..47fc971 100644
--- a/src/components/PlaylistExporter.jsx
+++ b/src/components/PlaylistExporter.jsx
@@ -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) {
diff --git a/src/components/PlaylistRow.jsx b/src/components/PlaylistRow.jsx
index 16f95ff..b9b7770 100644
--- a/src/components/PlaylistRow.jsx
+++ b/src/components/PlaylistRow.jsx
@@ -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) {
diff --git a/src/components/PlaylistTable.jsx b/src/components/PlaylistTable.jsx
index 84d5e1c..2b766cb 100644
--- a/src/components/PlaylistTable.jsx
+++ b/src/components/PlaylistTable.jsx
@@ -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"
@@ -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) {
@@ -65,35 +63,52 @@ 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() {
@@ -101,10 +116,15 @@ class PlaylistTable extends React.Component {
}
render() {
- if (this.state.playlists.length > 0) {
+ const progressBar =
+
+ if (this.state.playlistCount > 0) {
return (
-
+
@@ -115,15 +135,19 @@ class PlaylistTable extends React.Component {
Public? |
Collaborative? |
-
+
|
{this.state.playlists.map((playlist, i) => {
- return
+ return
})}
diff --git a/src/components/PlaylistTable.test.jsx b/src/components/PlaylistTable.test.jsx
index 566bde0..f219dbe 100644
--- a/src/components/PlaylistTable.test.jsx
+++ b/src/components/PlaylistTable.test.jsx
@@ -27,7 +27,7 @@ afterEach(() => {
// Use a snapshot test to ensure exact component rendering
test("playlist loading", async () => {
- const component = renderer.create()
+ const component = renderer.create()
const instance = component.getInstance()
await waitFor(() => {
@@ -42,7 +42,7 @@ describe("single playlist exporting", () => {
const saveAsMock = jest.spyOn(FileSaver, "saveAs")
saveAsMock.mockImplementation(jest.fn())
- render();
+ render();
await waitFor(() => {
expect(screen.getByText(/Export All/)).toBeInTheDocument()
@@ -76,7 +76,7 @@ describe("single playlist exporting", () => {
const saveAsMock = jest.spyOn(FileSaver, "saveAs")
saveAsMock.mockImplementation(jest.fn())
- render();
+ render();
await waitFor(() => {
expect(screen.getByText(/Export All/)).toBeInTheDocument()
@@ -112,7 +112,7 @@ test("exporting of all playlist", async () => {
const jsZipGenerateAsync = jest.spyOn(JSZip.prototype, 'generateAsync')
jsZipGenerateAsync.mockResolvedValue("zip_content")
- render();
+ render();
await waitFor(() => {
expect(screen.getByText(/Export All/)).toBeInTheDocument()
diff --git a/src/components/PlaylistsExporter.jsx b/src/components/PlaylistsExporter.jsx
index a63fd85..b4253c8 100644
--- a/src/components/PlaylistsExporter.jsx
+++ b/src/components/PlaylistsExporter.jsx
@@ -1,4 +1,5 @@
-import $ from "jquery" // TODO: Remove jQuery dependency
+import React from "react"
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { saveAs } from "file-saver"
import JSZip from "jszip"
@@ -6,11 +7,12 @@ import PlaylistExporter from "./PlaylistExporter"
import { apiCall } from "helpers"
// Handles exporting all playlist data as a zip file
-let PlaylistsExporter = {
- export: function(access_token, playlistCount, likedSongsLimit, likedSongsCount) {
- var playlistFileNames = [];
+class PlaylistsExporter extends React.Component {
+ async export(accessToken, playlistCount, likedSongsLimit, likedSongsCount) {
+ var playlistFileNames = []
+ var playlistCsvExports = []
- apiCall("https://api.spotify.com/v1/me", access_token).then(function(response) {
+ apiCall("https://api.spotify.com/v1/me", accessToken).then(async (response) => {
var limit = 20;
var userId = response.id;
var requests = [];
@@ -18,59 +20,59 @@ let PlaylistsExporter = {
// Add playlists
for (var offset = 0; offset < playlistCount; offset = offset + limit) {
var url = "https://api.spotify.com/v1/users/" + userId + "/playlists";
- requests.push(
- apiCall(`${url}?offset=${offset}&limit=${limit}`, access_token)
- )
+ requests.push(`${url}?offset=${offset}&limit=${limit}`)
}
- $.when.apply($, requests).then(function() {
- var playlists = [];
- var playlistExports = [];
+ let playlistPromises = requests.map((request, index) => {
+ return apiCall(request, accessToken).then((response) => {
+ this.props.onLoadedPlaylistsCountChanged((index + 1) * limit)
+ return response
+ })
+ })
+
+ let playlists = (await Promise.all(playlistPromises)).flatMap(response => response.items)
- // Handle either single or multiple responses
- if (typeof arguments[0].href == 'undefined') {
- $(arguments).each(function(i, response) {
- if (typeof response[0].items === 'undefined') {
- // Single playlist
- playlists.push(response[0]);
- } else {
- // Page of playlists
- $.merge(playlists, response[0].items);
- }
- })
- } else {
- playlists = arguments[0].items
- }
+ // Add library of saved tracks
+ playlists.unshift({
+ "id": "liked",
+ "name": "Liked",
+ "tracks": {
+ "href": "https://api.spotify.com/v1/me/tracks",
+ "limit": likedSongsLimit,
+ "total": likedSongsCount
+ },
+ })
- // Add library of saved tracks
- playlists.unshift({
- "id": "liked",
- "name": "Liked",
- "tracks": {
- "href": "https://api.spotify.com/v1/me/tracks",
- "limit": likedSongsLimit,
- "total": likedSongsCount
- },
- });
+ let trackPromises = playlists.map((playlist, index) => {
+ return PlaylistExporter.csvData(accessToken, playlist).then((csvData) => {
+ playlistFileNames.push(PlaylistExporter.fileName(playlist))
+ playlistCsvExports.push(csvData)
+ this.props.onExportedPlaylistsCountChanged(index + 1)
+ })
+ })
- $(playlists).each(function(i, playlist) {
- playlistFileNames.push(PlaylistExporter.fileName(playlist));
- playlistExports.push(PlaylistExporter.csvData(access_token, playlist));
- });
+ await Promise.all(trackPromises)
- return $.when.apply($, playlistExports);
- }).then(function() {
- var zip = new JSZip();
+ var zip = new JSZip()
- $(arguments).each(function(i, response) {
- zip.file(playlistFileNames[i], response)
- });
+ playlistCsvExports.forEach(function(csv, i) {
+ zip.file(playlistFileNames[i], csv)
+ })
- zip.generateAsync({ type: "blob" }).then(function(content) {
- saveAs(content, "spotify_playlists.zip");
- })
- });
- });
+ zip.generateAsync({ type: "blob" }).then(function(content) {
+ saveAs(content, "spotify_playlists.zip");
+ })
+ })
+ }
+
+ exportPlaylists = () => {
+ this.export(this.props.accessToken, this.props.playlistCount, this.props.likedSongs.limit, this.props.likedSongs.count)
+ }
+
+ render() {
+ return
}
}
diff --git a/src/components/__snapshots__/PlaylistTable.test.jsx.snap b/src/components/__snapshots__/PlaylistTable.test.jsx.snap
index 0196c11..21118b0 100644
--- a/src/components/__snapshots__/PlaylistTable.test.jsx.snap
+++ b/src/components/__snapshots__/PlaylistTable.test.jsx.snap
@@ -4,44 +4,48 @@ exports[`playlist loading 1`] = `
-
+
+ »
+
+
+
+
+
+
diff --git a/src/helpers.jsx b/src/helpers.jsx
index 61cd084..7fb1cd7 100644
--- a/src/helpers.jsx
+++ b/src/helpers.jsx
@@ -1,4 +1,5 @@
import $ from "jquery" // TODO: Remove jQuery dependency
+import Bottleneck from "bottleneck"
export function authorize() {
var client_id = getQueryParam('app_client_id');
@@ -23,22 +24,29 @@ export function getQueryParam(name) {
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
-export function apiCall(url, access_token) {
+const limiter = new Bottleneck({
+ maxConcurrent: 1,
+ minTime: 0
+})
+
+limiter.on("failed", async (error, jobInfo) => {
+ if (error.status === 401) {
+ // Return to home page after auth token expiry
+ window.location.href = window.location.href.split('#')[0]
+ } else if (error.status === 429 && jobInfo.retryCount === 0) {
+ // Retry according to the indication from the server with a small buffer
+ return ((error.getResponseHeader("Retry-After") || 1) * 1000) + 1000
+ } else {
+ // TODO: Improve
+ alert(error.responseText)
+ }
+})
+
+export const apiCall = limiter.wrap(function(url, accessToken) {
return $.ajax({
url: url,
headers: {
- 'Authorization': 'Bearer ' + access_token
- }
- }).fail(function (jqXHR, textStatus) {
- if (jqXHR.status === 401) {
- // Return to home page after auth token expiry
- window.location.href = window.location.href.split('#')[0]
- } else if (jqXHR.status === 429) {
- // API Rate-limiting encountered
- window.location.href = window.location.href.split('#')[0] + '?rate_limit_message=true'
- } else {
- // Otherwise report the error so user can raise an issue
- alert(jqXHR.responseText);
+ 'Authorization': 'Bearer ' + accessToken
}
})
-}
+})
diff --git a/src/mocks/handlers.jsx b/src/mocks/handlers.jsx
index e81ec01..589504b 100644
--- a/src/mocks/handlers.jsx
+++ b/src/mocks/handlers.jsx
@@ -2,6 +2,10 @@ import { rest } from 'msw'
export const handlers = [
rest.get('https://api.spotify.com/v1/me', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"display_name" : "watsonbox",
@@ -22,6 +26,10 @@ export const handlers = [
}),
rest.get('https://api.spotify.com/v1/users/watsonbox/tracks', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"href" : "https://api.spotify.com/v1/me/tracks?offset=0&limit=20",
@@ -108,6 +116,10 @@ export const handlers = [
// FIXME: Duplication of data
rest.get('https://api.spotify.com/v1/me/tracks', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"href" : "https://api.spotify.com/v1/me/tracks?offset=0&limit=20",
@@ -193,6 +205,10 @@ export const handlers = [
}),
rest.get('https://api.spotify.com/v1/users/watsonbox/playlists', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"href" : "https://api.spotify.com/v1/users/watsonbox/playlists?offset=0&limit=20",
@@ -240,6 +256,10 @@ export const handlers = [
}),
rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=10', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"href" : "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=100",
@@ -430,6 +450,10 @@ export const handlers = [
export const nullTrackHandlers = [
rest.get('https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=10', (req, res, ctx) => {
+ if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") {
+ return res(ctx.status(401), ctx.json({ message: 'Not authorized' }))
+ }
+
return res(ctx.json(
{
"href" : "https://api.spotify.com/v1/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks?offset=0&limit=100",
diff --git a/yarn.lock b/yarn.lock
index 5107ca0..df6de7b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2837,6 +2837,11 @@ bootstrap@3.3.7:
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.3.7.tgz#5a389394549f23330875a3b150656574f8a9eb71"
integrity sha1-WjiTlFSfIzMIdaOxUGVldPip63E=
+bottleneck@^2.19.5:
+ version "2.19.5"
+ resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91"
+ integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==
+
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"