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 (
- +
+ + {this.state.progressBar.show && progressBar} +
@@ -115,15 +135,19 @@ class PlaylistTable extends React.Component { {this.state.playlists.map((playlist, i) => { - return + return })}
Public? Collaborative? - +
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"