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 (
+
+
+
+
+ {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`] = `
+
+
+
+
+
+
+ 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())