From c551b0cffd43de7ec8f4a2a1510a1348407ae013 Mon Sep 17 00:00:00 2001 From: Rylie Nelson Date: Tue, 25 Nov 2025 12:09:57 -0800 Subject: [PATCH 1/4] first pass, working. still need progress bar --- src/App.scss | 16 ++--- src/App.tsx | 6 +- src/components/PlaylistTable.tsx | 2 +- src/components/SavedAlbumExporter.tsx | 41 +++++++++++++ src/components/SavedAlbumRow.scss | 19 ++++++ src/components/SavedAlbumRow.tsx | 87 +++++++++++++++++++++++++++ src/components/data/SavedAlbumData.ts | 86 ++++++++++++++++++++++++++ src/i18n/locales/en/translation.json | 11 ++++ src/icons.ts | 5 +- 9 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 src/components/SavedAlbumExporter.tsx create mode 100644 src/components/SavedAlbumRow.scss create mode 100644 src/components/SavedAlbumRow.tsx create mode 100644 src/components/data/SavedAlbumData.ts diff --git a/src/App.scss b/src/App.scss index 769968d..0ed9558 100644 --- a/src/App.scss +++ b/src/App.scss @@ -51,6 +51,10 @@ } } +#saved-albums { + animation: fadeIn 1s; +} + #playlists { animation: fadeIn 1s; @@ -140,19 +144,17 @@ } .spinner { - min-width: 24px; - min-height: 24px; + display: flex; + justify-content: center; + align-items: center; + min-height: 100px; + margin: 12px; } .spinner:before { content: 'Loading…'; - position: absolute; - top: 240px; - left: 50%; width: 100px; height: 100px; - margin-top: -50px; - margin-left: -50px; } .spinner:not(:required):before { diff --git a/src/App.tsx b/src/App.tsx index 70c5ba8..511fd2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import "url-search-params-polyfill" import Login from 'components/Login' import PlaylistTable from "components/PlaylistTable" +import SavedAlbumRow from "components/SavedAlbumRow" import TopMenu from "components/TopMenu" import { loadAccessToken, exchangeCodeForToken } from "auth" @@ -50,7 +51,10 @@ function App() {

Keep an eye on the Spotify Web API Status page to see if there are any known problems right now, and then retry.

} else if (accessToken) { - view = + view = <> + + + } else { view = } diff --git a/src/components/PlaylistTable.tsx b/src/components/PlaylistTable.tsx index 0fe3fd5..59eae86 100644 --- a/src/components/PlaylistTable.tsx +++ b/src/components/PlaylistTable.tsx @@ -14,7 +14,7 @@ import { apiCall, apiCallErrorHandler } from "helpers" interface PlaylistTableProps extends WithTranslation { accessToken: string, config?: any, - onSetSubtitle: (subtitile: React.JSX.Element) => void + onSetSubtitle: (subtitile: React.JSX.Element) => void, } class PlaylistTable extends React.Component { diff --git a/src/components/SavedAlbumExporter.tsx b/src/components/SavedAlbumExporter.tsx new file mode 100644 index 0000000..6784fb0 --- /dev/null +++ b/src/components/SavedAlbumExporter.tsx @@ -0,0 +1,41 @@ +import { saveAs } from "file-saver"; +import SavedAlbumData from "./data/SavedAlbumData"; + +// Handles exporting all of a user's saved albums as a CSV file +class SavedAlbumExporter { + FILE_NAME = "saved_albums.csv" + + accessToken: string + savedAlbumCount: number + + constructor(accessToken: string, savedAlbumCount: number) { + this.accessToken = accessToken + this.savedAlbumCount = savedAlbumCount + } + + async export() { + return this.csvData().then((data) => { + const blob = new Blob([data], { type: "text/csv;charset=utf-8" }) + saveAs(blob, this.FILE_NAME, { autoBom: false }) + }) + } + + async csvData() { + const savedAlbumData = new SavedAlbumData(this.accessToken, this.savedAlbumCount) + const items = await savedAlbumData.data() + + let csvContent = "" + csvContent += savedAlbumData.dataLabels().map(this.sanitize).join() + "\n" + items.forEach((albumData) => { + csvContent += albumData.map(this.sanitize).join(",") + "\n" + }) + + return csvContent + } + + sanitize(string: string): string { + return '"' + String(string).replace(/"/g, '""') + '"' + } +} + +export default SavedAlbumExporter \ No newline at end of file diff --git a/src/components/SavedAlbumRow.scss b/src/components/SavedAlbumRow.scss new file mode 100644 index 0000000..6236de6 --- /dev/null +++ b/src/components/SavedAlbumRow.scss @@ -0,0 +1,19 @@ +@import "../index.scss"; + +#saved-album-row { + display: flex; + align-items: center; + padding: 8px; + gap: 12px; + + margin-top: 16px; + margin-bottom: 32px; + + &:hover { + background-color: $table-hover-bg; + } + + button { + margin-left: auto; + } +} \ No newline at end of file diff --git a/src/components/SavedAlbumRow.tsx b/src/components/SavedAlbumRow.tsx new file mode 100644 index 0000000..5ec9fc6 --- /dev/null +++ b/src/components/SavedAlbumRow.tsx @@ -0,0 +1,87 @@ +import "./SavedAlbumRow.scss"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { apiCall, apiCallErrorHandler } from "helpers"; +import React from "react"; +import { Button, ProgressBar } from "react-bootstrap"; +import { withTranslation, WithTranslation } from "react-i18next"; +import SavedAlbumExporter from "./SavedAlbumExporter"; + +interface SavedAlbumRowProps extends WithTranslation { + accessToken: string +} + +class SavedAlbumRow extends React.Component { + state = { + initialized: false, + exporting: false, + savedAlbumCount: 0, + progressBar: { + show: false, + label: "", + value: 0 + }, + } + + exportAlbums = () => { + this.setState({ exporting: true }, () => { + new SavedAlbumExporter(this.props.accessToken, this.state.savedAlbumCount) + .export() + .catch(apiCallErrorHandler) + .then(() => { + this.setState({ exporting: false }); + }) + }) + } + + // We make one 'dummy' call to the user's saved album API to get the count + async componentDidMount() { + try { + const res = await apiCall( + `https://api.spotify.com/v1/me/albums?limit=1`, + this.props.accessToken + ).then((response) => response.data) + + this.setState({ + savedAlbumCount: res.total, + initialized: true, + }) + } catch (error) { + apiCallErrorHandler(error) + } + } + + render() { + const icon = ["fas", this.state.exporting ? "sync" : "download"] + const progressBar = ( + + ); + + if (this.state.initialized) { + return ( +
+

Saved albums

+
+ + {this.state.savedAlbumCount} saved albums + {/* @ts-ignore */} + +
+
+ ) + } else { + return
+ } + } +} + +export default withTranslation()(SavedAlbumRow) diff --git a/src/components/data/SavedAlbumData.ts b/src/components/data/SavedAlbumData.ts new file mode 100644 index 0000000..4479b7c --- /dev/null +++ b/src/components/data/SavedAlbumData.ts @@ -0,0 +1,86 @@ +import { apiCall } from "helpers"; +import i18n from "i18n/config"; + +class SavedAlbumData { + private accessToken: string + + /** + * we fetch this count on initial load of App.tsx. We need it here to calculate how many + * pages of requests we need to make + */ + private savedAlbumCount: number + + constructor(accessToken: string, savedAlbumCount: number) { + this.accessToken = accessToken + this.savedAlbumCount = savedAlbumCount + } + + dataLabels() { + return [ + i18n.t("album.album_uri"), + i18n.t("album.album_name"), + i18n.t("album.album_type"), + i18n.t("album.album_artist_uris"), + i18n.t("album.album_artist_names"), + i18n.t("album.album_release_date"), + i18n.t("album.album_release_date_precision"), + i18n.t("album.album_track_count"), + i18n.t("album.saved_at"), + ] + } + + private savedAlbumItems: any[] = []; + async fetchSavedAlbumItems() { + if (this.savedAlbumItems.length > 0) { + return this.savedAlbumItems + } + const requests = [] + const limit = 50 // max allowed by spotify API + + for (let offset = 0; offset < this.savedAlbumCount; offset = offset + limit) { + requests.push(`https://api.spotify.com/v1/me/albums?limit=${limit}&offset=${offset}`) + } + + const albumPromises = requests.map((request) => { + return apiCall(request, this.accessToken) + }) + const albumResponses = await Promise.all(albumPromises) + this.savedAlbumItems = albumResponses.flatMap((response) => { + return response.data.items.filter((i: any) => i.album) + }) + return this.savedAlbumItems + } + + async data() { + await this.fetchSavedAlbumItems() + + return new Map( + this.savedAlbumItems.map((item) => { + return [ + item.album.uri, + [ + item.album.uri, + item.album.name, + item.album.album_type, + item.album.artists + .map((a: any) => { + return a.uri; + }) + .join(", "), + item.album.artists + .map((a: any) => { + return String(a.name).replace(/,/g, "\\,"); + }) + .join(", "), + item.album.release_date, + item.album.release_date_precision, + item.album.tracks.total, + item.added_at + ], + ] + }) + ) + } +} + +export default SavedAlbumData diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 0214958..074b326 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -87,5 +87,16 @@ "tempo": "Tempo", "time_signature": "Time Signature" } + }, + "album": { + "album_uri": "Album URI", + "album_name": "Album Name", + "album_type": "Album Type", + "album_artist_uris": "Album Artist URI(s)", + "album_artist_names": "Album Artist Name(s)", + "album_release_date": "Album Release Date", + "album_release_date_precision": "Release Date Precision", + "album_track_count": "Track Count", + "saved_at": "Saved At" } } diff --git a/src/icons.ts b/src/icons.ts index d748de1..cdaf41c 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { fab } from '@fortawesome/free-brands-svg-icons' import { faCheckCircle, faTimesCircle, faFileArchive, faHeart } from '@fortawesome/free-regular-svg-icons' -import { faBolt, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck } from '@fortawesome/free-solid-svg-icons' +import { faBolt, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck, faRecordVinyl } from '@fortawesome/free-solid-svg-icons' library.add( fab, @@ -20,5 +20,6 @@ library.add( faLightbulb, faCircleInfo, faGlobe, - faCheck + faCheck, + faRecordVinyl ) From b450b7818c80f9dc3d0578a1482aa4c8f85bc946 Mon Sep 17 00:00:00 2001 From: Rylie Nelson Date: Tue, 25 Nov 2025 12:34:32 -0800 Subject: [PATCH 2/4] progress bar --- src/components/SavedAlbumExporter.tsx | 6 ++- src/components/SavedAlbumRow.scss | 15 +++++++ src/components/SavedAlbumRow.tsx | 63 ++++++++++++++++++++++----- src/components/data/SavedAlbumData.ts | 16 +++++-- src/i18n/locales/en/translation.json | 1 + 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/components/SavedAlbumExporter.tsx b/src/components/SavedAlbumExporter.tsx index 6784fb0..c80fc90 100644 --- a/src/components/SavedAlbumExporter.tsx +++ b/src/components/SavedAlbumExporter.tsx @@ -7,10 +7,12 @@ class SavedAlbumExporter { accessToken: string savedAlbumCount: number + onPageFetched: (albumsFetched: number) => void - constructor(accessToken: string, savedAlbumCount: number) { + constructor(accessToken: string, savedAlbumCount: number, onPageFetched: (albumsFetched: number) => void) { this.accessToken = accessToken this.savedAlbumCount = savedAlbumCount + this.onPageFetched = onPageFetched } async export() { @@ -21,7 +23,7 @@ class SavedAlbumExporter { } async csvData() { - const savedAlbumData = new SavedAlbumData(this.accessToken, this.savedAlbumCount) + const savedAlbumData = new SavedAlbumData(this.accessToken, this.savedAlbumCount, this.onPageFetched) const items = await savedAlbumData.data() let csvContent = "" diff --git a/src/components/SavedAlbumRow.scss b/src/components/SavedAlbumRow.scss index 6236de6..23742da 100644 --- a/src/components/SavedAlbumRow.scss +++ b/src/components/SavedAlbumRow.scss @@ -16,4 +16,19 @@ button { margin-left: auto; } +} + +#saved-album-header { + display: flex; + align-items: center; + gap: 12px; + + h4 { + margin: 0; + white-space: nowrap; + } + + .progress { + flex: 1; + } } \ No newline at end of file diff --git a/src/components/SavedAlbumRow.tsx b/src/components/SavedAlbumRow.tsx index 5ec9fc6..56bcee5 100644 --- a/src/components/SavedAlbumRow.tsx +++ b/src/components/SavedAlbumRow.tsx @@ -6,6 +6,7 @@ import React from "react"; import { Button, ProgressBar } from "react-bootstrap"; import { withTranslation, WithTranslation } from "react-i18next"; import SavedAlbumExporter from "./SavedAlbumExporter"; +import Bugsnag from "@bugsnag/js" interface SavedAlbumRowProps extends WithTranslation { accessToken: string @@ -23,15 +24,54 @@ class SavedAlbumRow extends React.Component { }, } + handlePageFetched = (albumsFetched: number) => { + this.setState({ + progressBar: { + show: true, + label: this.props.i18n.t("exporting_saved_albums", { + fetched: albumsFetched, + total: this.state.savedAlbumCount, + }), + value: albumsFetched, + }, + }); + } + exportAlbums = () => { - this.setState({ exporting: true }, () => { - new SavedAlbumExporter(this.props.accessToken, this.state.savedAlbumCount) - .export() - .catch(apiCallErrorHandler) - .then(() => { - this.setState({ exporting: false }); - }) - }) + Bugsnag.leaveBreadcrumb("Started exporting all saved albums"); + this.setState( + { + exporting: true, + progressBar: { + show: true, + label: this.props.i18n.t("exporting_saved_albums", { + fetched: 0, + total: this.state.savedAlbumCount, + }), + value: 0, + }, + }, + () => { + new SavedAlbumExporter( + this.props.accessToken, + this.state.savedAlbumCount, + this.handlePageFetched + ) + .export() + .catch(apiCallErrorHandler) + .then(() => { + Bugsnag.leaveBreadcrumb("Finished exporting all saved albums"); + this.setState({ + exporting: false, + progressBar: { + show: false, + label: "", + value: 0, + }, + }); + }); + } + ); } // We make one 'dummy' call to the user's saved album API to get the count @@ -57,16 +97,19 @@ class SavedAlbumRow extends React.Component { ); if (this.state.initialized) { return (
-

Saved albums

+
+

Saved albums

{this.state.progressBar.show && progressBar} +
{this.state.savedAlbumCount} saved albums diff --git a/src/components/data/SavedAlbumData.ts b/src/components/data/SavedAlbumData.ts index 4479b7c..5bced2c 100644 --- a/src/components/data/SavedAlbumData.ts +++ b/src/components/data/SavedAlbumData.ts @@ -5,14 +5,16 @@ class SavedAlbumData { private accessToken: string /** - * we fetch this count on initial load of App.tsx. We need it here to calculate how many + * we fetch this count on initial load of App.tsx. We need it here to calculate how many * pages of requests we need to make */ private savedAlbumCount: number + private onPageFetched: (albumsFetched: number) => void - constructor(accessToken: string, savedAlbumCount: number) { + constructor(accessToken: string, savedAlbumCount: number, onPageFetched: (albumsFetched: number) => void) { this.accessToken = accessToken this.savedAlbumCount = savedAlbumCount + this.onPageFetched = onPageFetched } dataLabels() { @@ -41,8 +43,16 @@ class SavedAlbumData { requests.push(`https://api.spotify.com/v1/me/albums?limit=${limit}&offset=${offset}`) } + let albumsFetchedSoFar = 0 const albumPromises = requests.map((request) => { - return apiCall(request, this.accessToken) + return apiCall(request, this.accessToken).then((response) => { + const albumsInPage = response.data.items.filter((i: any) => i.album).length + albumsFetchedSoFar += albumsInPage + if (this.onPageFetched) { + this.onPageFetched(albumsFetchedSoFar) + } + return response + }) }) const albumResponses = await Promise.all(albumPromises) this.savedAlbumItems = albumResponses.flatMap((response) => { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 074b326..5bc24bf 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -8,6 +8,7 @@ "export_all": "Export All", "exporting_done": "Done!", "exporting_playlist": "Exporting {{playlistName}}...", + "exporting_saved_albums": "Exporting saved albums... ({{fetched}}/{{total}})", "export_search_results": "Export Results", "top_menu": { "help": "Help", From 78404023e258976d8a28436ee8efc272ca3b5c38 Mon Sep 17 00:00:00 2001 From: Rylie Nelson Date: Tue, 25 Nov 2025 13:14:35 -0800 Subject: [PATCH 3/4] add basic tests --- src/components/SavedAlbumRow.test.tsx | 124 ++++++++++ src/components/SavedAlbumRow.tsx | 2 +- .../__snapshots__/SavedAlbumRow.test.tsx.snap | 61 +++++ src/mocks/handlers.ts | 211 ++++++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/components/SavedAlbumRow.test.tsx create mode 100644 src/components/__snapshots__/SavedAlbumRow.test.tsx.snap diff --git a/src/components/SavedAlbumRow.test.tsx b/src/components/SavedAlbumRow.test.tsx new file mode 100644 index 0000000..2800809 --- /dev/null +++ b/src/components/SavedAlbumRow.test.tsx @@ -0,0 +1,124 @@ +import React from "react" +import "i18n/config" +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { setupServer } from "msw/node" +import FileSaver from "file-saver" + +import SavedAlbumRow from "./SavedAlbumRow" + +import "../icons" +import { handlerCalled, handlers } from "../mocks/handlers" + +const server = setupServer(...handlers) + +// Mock out Bugsnag calls +jest.mock('@bugsnag/js') + +server.listen({ + onUnhandledRequest: 'warn' +}) + +beforeAll(() => { + // @ts-ignore + global.Blob = function (content, options) { return ({ content, options }) } + + // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}) + +const { location } = window + +beforeAll(() => { + // @ts-ignore + delete window.location +}) + +afterAll(() => { + window.location = location +}) + +afterEach(() => { + jest.restoreAllMocks() + server.resetHandlers() +}) + +const baseAlbumHeaders = '"Album URI","Album Name","Album Type","Album Artist URI(s)","Album Artist Name(s)","Album Release Date","Release Date Precision","Track Count","Saved At"' + +// Use a snapshot test to ensure exact component rendering +test("saved album row loading", async () => { + const { asFragment } = render() + + expect(await screen.findByText(/Saved albums/)).toBeInTheDocument() + + expect(asFragment()).toMatchSnapshot(); +}) + +test("redirecting when access token is invalid", async () => { + // @ts-ignore + window.location = { pathname: "/exportify", href: "http://www.example.com/exportify" } + + render() + + await waitFor(() => { + expect(window.location.href).toBe("/exportify") + }) +}) + +test("standard case exports successfully", async () => { + const saveAsMock = jest.spyOn(FileSaver, "saveAs") + saveAsMock.mockImplementation(jest.fn()) + + render(); + + expect(await screen.findByText(/Saved albums/)).toBeInTheDocument() + + const buttonElement = screen.getByRole("button", { name: /export/i }) + + expect(buttonElement).toBeInTheDocument() + + await userEvent.click(buttonElement) + + await waitFor(() => { + expect(buttonElement).toHaveAttribute("disabled") + }) + + await waitFor(() => { + expect(handlerCalled.mock.calls).toContainEqual( + ['https://api.spotify.com/v1/me/albums?limit=50&offset=0'] + ) + }) + + await waitFor(() => { + expect(saveAsMock).toHaveBeenCalledTimes(1) + }) + + expect(saveAsMock).toHaveBeenCalledWith( + { + content: [ + `${baseAlbumHeaders}\n` + + `"spotify:album:4iwv7b8gDPKztLkKCbWyhi","Best of Six By Seven","album","spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz","Six by Seven","2017-02-17","day","14","2020-07-19T09:24:39Z"\n` + + `"spotify:album:4MxbRuLNbxf0RERbT8OHsU","Cinder","album","spotify:artist:6H9oDpJUDuw3nkogwhd21s","Lux Terminus","2025-04-18","day","10","2025-04-23T14:51:45Z"\n` + + `"spotify:album:7aIEHWiuOkDywdjyQyt8CL","program music II","album","spotify:artist:5sGsy5o8hBSMmDUFTC5Q2P","KASHIWA Daisuke","2016-04-30","day","8","2025-11-25T18:26:03Z"\n` + + `"spotify:album:3xOcExpIWzroZldcdc212q","The Overview","album","spotify:artist:4X42BfuhWCAZ2swiVze9O0","Steven Wilson","2025-03-14","day","12","2025-03-20T21:24:38Z"\n` + + `"spotify:album:6azzagF3oeYffG22gIiLWz","Nocturne","album","spotify:artist:2SDGIFzEh9xmE5zDKcMRkj","The Human Abstract","2006-08-22","day","12","2025-11-25T00:41:40Z"\n` + + `"spotify:album:4abjNrXQcMRQlm0O4iyUSZ","Digital Veil","album","spotify:artist:2SDGIFzEh9xmE5zDKcMRkj","The Human Abstract","2011-03-08","day","8","2025-11-24T23:54:26Z"\n` + ], + options: { type: 'text/csv;charset=utf-8' } + }, + 'saved_albums.csv', + { "autoBom": false } + ) +}) diff --git a/src/components/SavedAlbumRow.tsx b/src/components/SavedAlbumRow.tsx index 56bcee5..a551e20 100644 --- a/src/components/SavedAlbumRow.tsx +++ b/src/components/SavedAlbumRow.tsx @@ -112,7 +112,7 @@ class SavedAlbumRow extends React.Component {
- {this.state.savedAlbumCount} saved albums + {this.state.savedAlbumCount} saved album(s) {/* @ts-ignore */} +
+
+ +`; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index d770805..3fc76aa 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -546,6 +546,217 @@ export const handlers = [ )) }), + rest.get('https://api.spotify.com/v1/me/albums', (req, res, ctx) => { + handlerCalled(req.url.toString()) + + if (req.headers.get("Authorization") !== "Bearer TEST_ACCESS_TOKEN") { + return res(ctx.status(401), ctx.json({ message: 'Not authorized' })) + } + + // Initial count request + if (req.url.searchParams.get('limit') === "1") { + return res(ctx.json( + { + "href": "https://api.spotify.com/v1/me/albums?limit=1", + "items": [{ + "added_at": "2020-07-19T09:24:39Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/4TXdHyuAOl3rAOFmZ6MeKz" + }, + "href": "https://api.spotify.com/v1/artists/4TXdHyuAOl3rAOFmZ6MeKz", + "id": "4TXdHyuAOl3rAOFmZ6MeKz", + "name": "Six by Seven", + "type": "artist", + "uri": "spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz" + }], + "href": "https://api.spotify.com/v1/albums/4iwv7b8gDPKztLkKCbWyhi", + "id": "4iwv7b8gDPKztLkKCbWyhi", + "name": "Best of Six By Seven", + "release_date": "2017-02-17", + "release_date_precision": "day", + "tracks": { + "total": 14 + }, + "type": "album", + "uri": "spotify:album:4iwv7b8gDPKztLkKCbWyhi" + } + }], + "limit": 1, + "next": null, + "offset": 0, + "previous": null, + "total": 1 + } + )) + } + + // Paginated album fetch + return res(ctx.json( + { + "href": "https://api.spotify.com/v1/me/albums?limit=50&offset=0", + "items": [{ + "added_at": "2020-07-19T09:24:39Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/4TXdHyuAOl3rAOFmZ6MeKz" + }, + "href": "https://api.spotify.com/v1/artists/4TXdHyuAOl3rAOFmZ6MeKz", + "id": "4TXdHyuAOl3rAOFmZ6MeKz", + "name": "Six by Seven", + "type": "artist", + "uri": "spotify:artist:4TXdHyuAOl3rAOFmZ6MeKz" + }], + "href": "https://api.spotify.com/v1/albums/4iwv7b8gDPKztLkKCbWyhi", + "id": "4iwv7b8gDPKztLkKCbWyhi", + "name": "Best of Six By Seven", + "release_date": "2017-02-17", + "release_date_precision": "day", + "tracks": { + "total": 14 + }, + "type": "album", + "uri": "spotify:album:4iwv7b8gDPKztLkKCbWyhi" + } + }, { + "added_at": "2025-04-23T14:51:45Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/6H9oDpJUDuw3nkogwhd21s" + }, + "href": "https://api.spotify.com/v1/artists/6H9oDpJUDuw3nkogwhd21s", + "id": "6H9oDpJUDuw3nkogwhd21s", + "name": "Lux Terminus", + "type": "artist", + "uri": "spotify:artist:6H9oDpJUDuw3nkogwhd21s" + }], + "href": "https://api.spotify.com/v1/albums/4MxbRuLNbxf0RERbT8OHsU", + "id": "4MxbRuLNbxf0RERbT8OHsU", + "name": "Cinder", + "release_date": "2025-04-18", + "release_date_precision": "day", + "tracks": { + "total": 10 + }, + "type": "album", + "uri": "spotify:album:4MxbRuLNbxf0RERbT8OHsU" + } + }, { + "added_at": "2025-11-25T18:26:03Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/5sGsy5o8hBSMmDUFTC5Q2P" + }, + "href": "https://api.spotify.com/v1/artists/5sGsy5o8hBSMmDUFTC5Q2P", + "id": "5sGsy5o8hBSMmDUFTC5Q2P", + "name": "KASHIWA Daisuke", + "type": "artist", + "uri": "spotify:artist:5sGsy5o8hBSMmDUFTC5Q2P" + }], + "href": "https://api.spotify.com/v1/albums/7aIEHWiuOkDywdjyQyt8CL", + "id": "7aIEHWiuOkDywdjyQyt8CL", + "name": "program music II", + "release_date": "2016-04-30", + "release_date_precision": "day", + "tracks": { + "total": 8 + }, + "type": "album", + "uri": "spotify:album:7aIEHWiuOkDywdjyQyt8CL" + } + }, { + "added_at": "2025-03-20T21:24:38Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/4X42BfuhWCAZ2swiVze9O0" + }, + "href": "https://api.spotify.com/v1/artists/4X42BfuhWCAZ2swiVze9O0", + "id": "4X42BfuhWCAZ2swiVze9O0", + "name": "Steven Wilson", + "type": "artist", + "uri": "spotify:artist:4X42BfuhWCAZ2swiVze9O0" + }], + "href": "https://api.spotify.com/v1/albums/3xOcExpIWzroZldcdc212q", + "id": "3xOcExpIWzroZldcdc212q", + "name": "The Overview", + "release_date": "2025-03-14", + "release_date_precision": "day", + "tracks": { + "total": 12 + }, + "type": "album", + "uri": "spotify:album:3xOcExpIWzroZldcdc212q" + } + }, { + "added_at": "2025-11-25T00:41:40Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/2SDGIFzEh9xmE5zDKcMRkj" + }, + "href": "https://api.spotify.com/v1/artists/2SDGIFzEh9xmE5zDKcMRkj", + "id": "2SDGIFzEh9xmE5zDKcMRkj", + "name": "The Human Abstract", + "type": "artist", + "uri": "spotify:artist:2SDGIFzEh9xmE5zDKcMRkj" + }], + "href": "https://api.spotify.com/v1/albums/6azzagF3oeYffG22gIiLWz", + "id": "6azzagF3oeYffG22gIiLWz", + "name": "Nocturne", + "release_date": "2006-08-22", + "release_date_precision": "day", + "tracks": { + "total": 12 + }, + "type": "album", + "uri": "spotify:album:6azzagF3oeYffG22gIiLWz" + } + }, { + "added_at": "2025-11-24T23:54:26Z", + "album": { + "album_type": "album", + "artists": [{ + "external_urls": { + "spotify": "https://open.spotify.com/artist/2SDGIFzEh9xmE5zDKcMRkj" + }, + "href": "https://api.spotify.com/v1/artists/2SDGIFzEh9xmE5zDKcMRkj", + "id": "2SDGIFzEh9xmE5zDKcMRkj", + "name": "The Human Abstract", + "type": "artist", + "uri": "spotify:artist:2SDGIFzEh9xmE5zDKcMRkj" + }], + "href": "https://api.spotify.com/v1/albums/4abjNrXQcMRQlm0O4iyUSZ", + "id": "4abjNrXQcMRQlm0O4iyUSZ", + "name": "Digital Veil", + "release_date": "2011-03-08", + "release_date_precision": "day", + "tracks": { + "total": 8 + }, + "type": "album", + "uri": "spotify:album:4abjNrXQcMRQlm0O4iyUSZ" + } + }], + "limit": 50, + "next": null, + "offset": 0, + "previous": null, + "total": 6 + } + )) + }), + rest.get('https://api.spotify.com/v1/albums', (req, res, ctx) => { handlerCalled(req.url.toString()) From a4b6409502b9b82e3205ce73bc6fca9f82ac1bab Mon Sep 17 00:00:00 2001 From: Rylie Nelson Date: Tue, 25 Nov 2025 13:17:29 -0800 Subject: [PATCH 4/4] removing semicolons --- src/components/PlaylistTable.tsx | 2 +- src/components/SavedAlbumRow.tsx | 12 ++++++------ src/components/data/SavedAlbumData.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/PlaylistTable.tsx b/src/components/PlaylistTable.tsx index 59eae86..0fe3fd5 100644 --- a/src/components/PlaylistTable.tsx +++ b/src/components/PlaylistTable.tsx @@ -14,7 +14,7 @@ import { apiCall, apiCallErrorHandler } from "helpers" interface PlaylistTableProps extends WithTranslation { accessToken: string, config?: any, - onSetSubtitle: (subtitile: React.JSX.Element) => void, + onSetSubtitle: (subtitile: React.JSX.Element) => void } class PlaylistTable extends React.Component { diff --git a/src/components/SavedAlbumRow.tsx b/src/components/SavedAlbumRow.tsx index a551e20..e5ec272 100644 --- a/src/components/SavedAlbumRow.tsx +++ b/src/components/SavedAlbumRow.tsx @@ -34,11 +34,11 @@ class SavedAlbumRow extends React.Component { }), value: albumsFetched, }, - }); + }) } exportAlbums = () => { - Bugsnag.leaveBreadcrumb("Started exporting all saved albums"); + Bugsnag.leaveBreadcrumb("Started exporting all saved albums") this.setState( { exporting: true, @@ -68,10 +68,10 @@ class SavedAlbumRow extends React.Component { label: "", value: 0, }, - }); - }); + }) + }) } - ); + ) } // We make one 'dummy' call to the user's saved album API to get the count @@ -102,7 +102,7 @@ class SavedAlbumRow extends React.Component { max={this.state.savedAlbumCount} label={this.state.progressBar.label} /> - ); + ) if (this.state.initialized) { return ( diff --git a/src/components/data/SavedAlbumData.ts b/src/components/data/SavedAlbumData.ts index 5bced2c..fe49971 100644 --- a/src/components/data/SavedAlbumData.ts +++ b/src/components/data/SavedAlbumData.ts @@ -31,7 +31,7 @@ class SavedAlbumData { ] } - private savedAlbumItems: any[] = []; + private savedAlbumItems: any[] = [] async fetchSavedAlbumItems() { if (this.savedAlbumItems.length > 0) { return this.savedAlbumItems