Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
}
}

#saved-albums {
animation: fadeIn 1s;
}

#playlists {
animation: fadeIn 1s;

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -50,7 +51,10 @@ function App() {
<p style={{ marginTop: "50px" }}>Keep an eye on the <a target="_blank" rel="noreferrer" href="https://status.spotify.dev/">Spotify Web API Status page</a> to see if there are any known problems right now, and then <a rel="noreferrer" href="?">retry</a>.</p>
</div>
} else if (accessToken) {
view = <PlaylistTable accessToken={accessToken!} onSetSubtitle={onSetSubtitle} />
view = <>
<PlaylistTable accessToken={accessToken!} onSetSubtitle={onSetSubtitle} />
<SavedAlbumRow accessToken={accessToken!} />
</>
} else {
view = <Login />
}
Expand Down
43 changes: 43 additions & 0 deletions src/components/SavedAlbumExporter.tsx
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions src/components/SavedAlbumRow.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
124 changes: 124 additions & 0 deletions src/components/SavedAlbumRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SavedAlbumRow accessToken="TEST_ACCESS_TOKEN" />)

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(<SavedAlbumRow accessToken="INVALID_ACCESS_TOKEN" />)

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(<SavedAlbumRow accessToken="TEST_ACCESS_TOKEN" />);

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 }
)
})
130 changes: 130 additions & 0 deletions src/components/SavedAlbumRow.tsx
Original file line number Diff line number Diff line change
@@ -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<SavedAlbumRowProps> {
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 = (
<ProgressBar
striped
variant="primary"
animated={true}
now={this.state.progressBar.value}
max={this.state.savedAlbumCount}
label={this.state.progressBar.label}
/>
)

if (this.state.initialized) {
return (
<div id="saved-albums">
<div id="saved-album-header">
<h4>Saved albums</h4> {this.state.progressBar.show && progressBar}
</div>
<div id="saved-album-row">
<FontAwesomeIcon icon={["fas", "record-vinyl"]} size="lg" />
<span>{this.state.savedAlbumCount} saved album(s)</span>
{/* @ts-ignore */}
<Button type="submit" variant="primary" size="xs" onClick={this.exportAlbums} disabled={this.state.exporting} className="text-nowrap">
{/* @ts-ignore */}
<FontAwesomeIcon icon={icon} size="sm" spin={this.state.exporting} /> {this.props.i18n.t("playlist.export")}
</Button>
</div>
</div>
)
} else {
return <div className="spinner" data-testid="savedAlbumRowSpinner"></div>
}
}
}

export default withTranslation()(SavedAlbumRow)
Loading