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/SavedAlbumExporter.tsx b/src/components/SavedAlbumExporter.tsx new file mode 100644 index 0000000..c80fc90 --- /dev/null +++ b/src/components/SavedAlbumExporter.tsx @@ -0,0 +1,43 @@ +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 + onPageFetched: (albumsFetched: number) => void + + constructor(accessToken: string, savedAlbumCount: number, onPageFetched: (albumsFetched: number) => void) { + this.accessToken = accessToken + this.savedAlbumCount = savedAlbumCount + this.onPageFetched = onPageFetched + } + + 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, this.onPageFetched) + 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..23742da --- /dev/null +++ b/src/components/SavedAlbumRow.scss @@ -0,0 +1,34 @@ +@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; + } +} + +#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.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 new file mode 100644 index 0000000..e5ec272 --- /dev/null +++ b/src/components/SavedAlbumRow.tsx @@ -0,0 +1,130 @@ +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"; +import Bugsnag from "@bugsnag/js" + +interface SavedAlbumRowProps extends WithTranslation { + accessToken: string +} + +class SavedAlbumRow extends React.Component { + state = { + initialized: false, + exporting: false, + savedAlbumCount: 0, + progressBar: { + show: false, + label: "", + value: 0 + }, + } + + 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 = () => { + 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 + 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.progressBar.show && progressBar} +
+
+ + {this.state.savedAlbumCount} saved album(s) + {/* @ts-ignore */} + +
+
+ ) + } else { + return
+ } + } +} + +export default withTranslation()(SavedAlbumRow) diff --git a/src/components/__snapshots__/SavedAlbumRow.test.tsx.snap b/src/components/__snapshots__/SavedAlbumRow.test.tsx.snap new file mode 100644 index 0000000..40f18e1 --- /dev/null +++ b/src/components/__snapshots__/SavedAlbumRow.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`saved album row loading 1`] = ` + +
+
+

+ Saved albums +

+ +
+
+ + + 1 saved album(s) + + +
+
+
+`; diff --git a/src/components/data/SavedAlbumData.ts b/src/components/data/SavedAlbumData.ts new file mode 100644 index 0000000..fe49971 --- /dev/null +++ b/src/components/data/SavedAlbumData.ts @@ -0,0 +1,96 @@ +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 + private onPageFetched: (albumsFetched: number) => void + + constructor(accessToken: string, savedAlbumCount: number, onPageFetched: (albumsFetched: number) => void) { + this.accessToken = accessToken + this.savedAlbumCount = savedAlbumCount + this.onPageFetched = onPageFetched + } + + 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}`) + } + + let albumsFetchedSoFar = 0 + const albumPromises = requests.map((request) => { + 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) => { + 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..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", @@ -87,5 +88,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 ) 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())