Skip to content
Merged
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
2 changes: 0 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export default tseslint.config(
tseslint.configs.recommended,
{
rules: {
// Spotify responses are untyped for now: TASK-24.1 re-enables this
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }],
"@typescript-eslint/no-empty-object-type": ["error", { allowInterfaces: "with-single-extends" }],
},
Expand Down
19 changes: 12 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import "./icons"

import React, { useEffect, useState } from 'react'
import Bugsnag from "@bugsnag/js"
import { AxiosResponse, isAxiosError } from "axios"
import { useTranslation, Translation } from "react-i18next"
import { Alert } from "react-bootstrap"
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'

import Login from 'components/Login'
import PlaylistTable from "components/PlaylistTable"
import TopMenu from "components/TopMenu"
import { loadAccessToken, exchangeCodeForToken, MissingCodeVerifierError } from "auth"
import { loadAccessToken, exchangeCodeForToken, MissingCodeVerifierError, OAuthErrorResponse } from "auth"

const baseUrl = import.meta.env.BASE_URL

Expand All @@ -24,8 +25,12 @@ function loginErrorFrom(params: URLSearchParams): LoginError | null {
return error === "access_denied" ? { kind: "cancelled" } : { kind: "failed", detail: `Spotify: ${error}` }
}

function describeExchangeFailure(error: any): string {
const spotifyError = error.response?.data
function oauthResponse(error: unknown) {
return isAxiosError<Partial<OAuthErrorResponse>>(error) ? error.response : undefined
}

function describeExchangeFailure(error: Error): string {
const spotifyError = oauthResponse(error)?.data
if (!spotifyError?.error) { return error.message }

return spotifyError.error_description
Expand All @@ -34,7 +39,7 @@ function describeExchangeFailure(error: any): string {
}

// Sends Spotify's reason but never the request, whose body holds the code and verifier
function reportLoginFailure(error: Error, detail: string, response?: any) {
function reportLoginFailure(error: Error, detail: string, response?: AxiosResponse<Partial<OAuthErrorResponse>>) {
// Google's Read Aloud fetcher replays callback URLs with empty storage, so its failures are noise
if (navigator.userAgent.includes("Google-Read-Aloud")) { return }

Expand Down Expand Up @@ -70,7 +75,7 @@ function App() {

let view

const onSetSubtitle = (subtitle: any) => {
const onSetSubtitle = (subtitle: React.JSX.Element) => {
setSubtitle(subtitle)
}

Expand All @@ -84,9 +89,9 @@ function App() {
if (callbackError?.detail) {
reportLoginFailure(new Error(`Spotify authorization error: ${params.get("error")}`), callbackError.detail)
} else if (code && !loadAccessToken()) {
exchangeCodeForToken(code).then(setAccessToken, (error) => {
exchangeCodeForToken(code).then(setAccessToken, (error: Error) => {
const detail = describeExchangeFailure(error)
reportLoginFailure(error, detail, error.response)
reportLoginFailure(error, detail, oauthResponse(error))
setLoginError({ kind: error instanceof MissingCodeVerifierError ? "wrong_browser" : "failed", detail })
}).finally(() => setExchangingCode(false))
}
Expand Down
16 changes: 8 additions & 8 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// - https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow

import Bugsnag from "@bugsnag/js"
import axios from "axios"
import axios, { AxiosResponse } from "axios"
import { redirectTo } from "./navigation"

const SPOTIFY_CLIENT_ID = "9950ac751e34487dbbe027c4fd7f8e99"
Expand All @@ -13,6 +13,7 @@ const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-l
const EXPIRY_MARGIN = 60 * 1000

type TokenResponse = { access_token: string, expires_in: number, refresh_token?: string }
export type OAuthErrorResponse = { error: string, error_description?: string }

// Access token management
export function loadAccessToken(): string | null {
Expand Down Expand Up @@ -55,7 +56,7 @@ async function requestRefresh(): Promise<string> {
if (!refreshToken) { throw new SessionExpiredError() }

try {
const response = await axios.post(
const response = await axios.post<TokenResponse>(
SPOTIFY_TOKEN_URL,
new URLSearchParams({
grant_type: "refresh_token",
Expand All @@ -66,18 +67,17 @@ async function requestRefresh(): Promise<string> {
)
saveTokens(response.data)
return response.data.access_token
} catch (error: any) {
} catch (error) {
// With no response it's the connection, which the request's own retries can ride out
if (!error.response) { throw error }
if (!axios.isAxiosError<Partial<OAuthErrorResponse>>(error) || !error.response) { throw error }

reportRefreshFailure(error)
reportRefreshFailure(error.response)
throw new SessionExpiredError()
}
}

// Sends Spotify's reason but never the request, whose body holds the refresh token
function reportRefreshFailure(error: any) {
const { status, data } = error.response
function reportRefreshFailure({ status, data }: AxiosResponse<Partial<OAuthErrorResponse>>) {
const reason = data?.error_description ? `${data.error} (${data.error_description})` : data?.error ?? status

Bugsnag.notify(new Error(`Token refresh failed: ${reason}`), (event) => {
Expand Down Expand Up @@ -173,7 +173,7 @@ export async function exchangeCodeForToken(code: string): Promise<string> {
throw new MissingCodeVerifierError()
}

const response = await axios.post(
const response = await axios.post<TokenResponse>(
SPOTIFY_TOKEN_URL,
new URLSearchParams({
client_id: clientId,
Expand Down
8 changes: 7 additions & 1 deletion src/components/ConfigDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import { withTranslation, WithTranslation } from "react-i18next"
import { Dropdown, Form } from "react-bootstrap"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

export interface ExportConfig {
includeArtistsData: boolean
includeAudioFeaturesData: boolean
includeAlbumData: boolean
}

interface ConfigDropdownProps extends WithTranslation {
onConfigChanged: (config: any) => void
onConfigChanged: (config: ExportConfig) => void
}

class ConfigDropdown extends React.Component<ConfigDropdownProps> {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Paginator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ interface PaginatorProps {
}

class Paginator extends React.Component<PaginatorProps> {
nextClick = (e: any) => {
nextClick = (e: React.MouseEvent) => {
e.preventDefault()

this.props.onPageChanged(this.props.currentPage + 1)
}

prevClick = (e: any) => {
prevClick = (e: React.MouseEvent) => {
e.preventDefault()

this.props.onPageChanged(this.props.currentPage - 1)
Expand Down
11 changes: 7 additions & 4 deletions src/components/PlaylistExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pagedGet, spotifyGet } from "../mocks/handlers"
import { csvHeaders, csvRecords } from "../test/csv"
import { API, buildArtist, buildPlaylist, buildPlaylistItem, buildTrack, buildUser } from "../test/fixtures/spotify/builders"
import PlaylistExporter from "./PlaylistExporter"
import { Track } from "../spotify"

const baseHeaders = [
"Track URI", "Track Name", "Artist URI(s)", "Artist Name(s)", "Album URI", "Album Name", "Album Artist URI(s)",
Expand All @@ -13,7 +14,9 @@ const baseHeaders = [

const playlist = buildPlaylist({ id: 'p1', name: 'Mixtape', tracks: { total: 1 } })

const serveTracks = (...tracks: object[]) =>
const noExtraData = { includeArtistsData: false, includeAudioFeaturesData: false, includeAlbumData: false }

const serveTracks = (...tracks: Track[]) =>
server.use(pagedGet(`${API}/playlists/p1/tracks`, tracks.map(track => buildPlaylistItem({ track, added_by: buildUser({ id: 'dj' }), added_at: '2024-01-02T03:04:05Z' }))))

beforeEach(() => {
Expand All @@ -24,7 +27,7 @@ describe("PlaylistExporter", () => {
test("puts base track columns before the item columns", async () => {
serveTracks(buildTrack())

const csv = await new PlaylistExporter(playlist, {}).csvData()
const csv = await new PlaylistExporter(playlist, noExtraData).csvData()

expect(csvHeaders(csv)).toEqual(baseHeaders)
})
Expand All @@ -51,7 +54,7 @@ describe("PlaylistExporter", () => {
test("quotes every field so commas, quotes and newlines survive", async () => {
serveTracks(buildTrack({ id: 't1', name: 'Say "Hi",\nthen leave', artists: [buildArtist({ name: 'A, B' })] }))

const csv = await new PlaylistExporter(playlist, {}).csvData()
const csv = await new PlaylistExporter(playlist, noExtraData).csvData()

expect(csvRecords(csv)).toEqual([expect.objectContaining({
"Track URI": "spotify:track:t1",
Expand All @@ -68,7 +71,7 @@ describe("PlaylistExporter", () => {
['tabs\tand "quotes"', 'tabs_and_quotes_'],
['Déjà Vu', 'déjà_vu'],
])("names the file for %j as %j", (name, fileName) => {
const exporter = new PlaylistExporter({ ...playlist, name }, {})
const exporter = new PlaylistExporter({ ...playlist, name }, noExtraData)

expect(exporter.fileName(false)).toBe(fileName)
expect(exporter.fileName()).toBe(`${fileName}.csv`)
Expand Down
32 changes: 17 additions & 15 deletions src/components/PlaylistExporter.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { saveAs } from "file-saver"
import i18n from "../i18n/config"

import TracksData from "components/data/TracksData"
import TracksBaseData from "components/data/TracksBaseData"
import TracksData, { CsvValue } from "components/data/TracksData"
import TracksBaseData, { TrackItem } from "components/data/TracksBaseData"
import TracksArtistsData from "components/data/TracksArtistsData"
import TracksAudioFeaturesData from "components/data/TracksAudioFeaturesData"
import TracksAlbumData from "components/data/TracksAlbumData"
import { ExportablePlaylist } from "components/data/PlaylistsData"
import { ExportConfig } from "components/ConfigDropdown"

class TracksCsvFile {
playlist: any
trackItems: any
playlist: ExportablePlaylist
trackItems: TrackItem[]
columnNames: string[]
lineData: Map<string, string[]>
lineData: Map<string, CsvValue[]>

lineTrackUris: string[]
lineTrackData: string[][]
lineTrackData: CsvValue[][]

constructor(playlist: any, trackItems: any) {
constructor(playlist: ExportablePlaylist, trackItems: TrackItem[]) {
this.playlist = playlist
this.trackItems = trackItems
this.columnNames = [
Expand All @@ -25,8 +27,8 @@ class TracksCsvFile {
]

this.lineData = new Map()
this.lineTrackUris = trackItems.map((i: any) => i.track.uri)
this.lineTrackData = trackItems.map((i: any) => [
this.lineTrackUris = trackItems.map((i) => i.track.uri)
this.lineTrackData = trackItems.map((i) => [
i.added_by == null ? '' : i.added_by.uri,
i.added_at
])
Expand All @@ -39,7 +41,7 @@ class TracksCsvFile {
this.columnNames.push(...tracksData.dataLabels())
}

const data: Map<string, string[]> = await tracksData.data()
const data = await tracksData.data()

this.lineTrackUris.forEach((uri: string, index: number) => {
if (data.has(uri)) {
Expand All @@ -64,17 +66,17 @@ class TracksCsvFile {
return csvContent
}

sanitize(string: string): string {
return '"' + String(string).replace(/"/g, '""') + '"'
sanitize(value: CsvValue): string {
return '"' + String(value).replace(/"/g, '""') + '"'
}
}

// Handles exporting a single playlist as a CSV file
class PlaylistExporter {
playlist: any
config: any
playlist: ExportablePlaylist
config: ExportConfig

constructor(playlist: any, config: any) {
constructor(playlist: ExportablePlaylist, config: ExportConfig) {
this.playlist = playlist
this.config = config
}
Expand Down
21 changes: 10 additions & 11 deletions src/components/PlaylistRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { Button } from "react-bootstrap"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

import PlaylistExporter from "./PlaylistExporter"
import { ExportablePlaylist, PlaylistPlaceholder } from "./data/PlaylistsData"
import { ExportConfig } from "./ConfigDropdown"

interface PlaylistRowProps extends WithTranslation {
playlist: any,
config: any
playlist: ExportablePlaylist | PlaylistPlaceholder,
config: ExportConfig
onApiError: (error: unknown) => void
}

Expand All @@ -16,12 +18,12 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {
exporting: false
}

exportPlaylist = () => {
exportPlaylist = (playlist: ExportablePlaylist) => {
this.setState(
{ exporting: true },
() => {
(new PlaylistExporter(
this.props.playlist,
playlist,
this.props.config
)).export().catch(this.props.onApiError).finally(() => {
this.setState({ exporting: false })
Expand All @@ -30,15 +32,15 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {
)
}

renderTickCross(condition: boolean) {
renderTickCross(condition?: boolean | null) {
if (condition) {
return <FontAwesomeIcon icon={['far', 'check-circle']} size="sm" />
} else {
return <FontAwesomeIcon icon={['far', 'times-circle']} size="sm" className="opacity-25" />
}
}

renderIcon(playlist: any) {
renderIcon(playlist: ExportablePlaylist | PlaylistPlaceholder) {
if (playlist.id === 'liked') {
return <FontAwesomeIcon icon={['far', 'heart']} style={{ color: 'red' }} />;
} else {
Expand All @@ -48,7 +50,6 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {

render() {
const playlist = this.props.playlist
const icon = ['fas', (this.state.exporting ? 'sync' : 'download')]

if (playlist.uri == null) return (
<tr>
Expand All @@ -70,10 +71,8 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {
<td className="d-none d-sm-table-cell">{this.renderTickCross(playlist.public)}</td>
<td className="d-none d-md-table-cell">{this.renderTickCross(playlist.collaborative)}</td>
<td className="text-end">
{/* @ts-expect-error - custom xs size from index.scss */}
<Button type="submit" variant="primary" size="xs" onClick={this.exportPlaylist} disabled={this.state.exporting} className="text-nowrap">
{/* @ts-expect-error - icon is a plain string array */}
<FontAwesomeIcon icon={icon} size="sm" spin={this.state.exporting} /> {this.props.i18n.t("playlist.export")}
<Button type="submit" variant="primary" onClick={() => this.exportPlaylist(playlist)} disabled={this.state.exporting} className="text-nowrap btn-xs">
<FontAwesomeIcon icon={['fas', this.state.exporting ? 'sync' : 'download']} size="sm" spin={this.state.exporting} /> {this.props.i18n.t("playlist.export")}
</Button>
</td>
</tr>
Expand Down
2 changes: 1 addition & 1 deletion src/components/PlaylistSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

interface PlaylistSearchProps extends WithTranslation {
onPlaylistSearch: (query: string) => void
onPlaylistSearchCancel: () => Promise<any>
onPlaylistSearchCancel: () => Promise<void>
}

class PlaylistSearch extends React.Component<PlaylistSearchProps> {
Expand Down
7 changes: 4 additions & 3 deletions src/components/PlaylistTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import FileSaver from "file-saver"
import JSZip from "jszip"

import PlaylistTable from "./PlaylistTable"
import { ExportConfig } from "./ConfigDropdown"

import "../icons"
import { server } from "../mocks/server"
Expand Down Expand Up @@ -112,8 +113,8 @@ const localTrack = (name: string, artist: string, durationMs: number) => ({

const requestedUrls = () => handlerCalled.mock.calls.map(([url]) => url)

const renderTable = (config = {}) =>
render(<PlaylistTable config={config} onSetSubtitle={vi.fn()} />)
const renderTable = (config: Partial<ExportConfig> = {}) =>
render(<PlaylistTable config={{ includeArtistsData: false, includeAudioFeaturesData: false, includeAlbumData: false, ...config }} onSetSubtitle={vi.fn()} />)

async function savedFile() {
const saveAs = vi.mocked(FileSaver.saveAs)
Expand Down Expand Up @@ -524,7 +525,7 @@ describe("when an export fails unexpectedly", () => {

test("Export All stays busy until the zip is saved", async () => {
let finishZip: (zip: Blob) => void = () => {}
generateAsync().mockReturnValue(new Promise(resolve => { finishZip = resolve }) as any)
generateAsync().mockReturnValue(new Promise<Blob>(resolve => { finishZip = resolve }))
renderTable()
const button = await screen.findByRole('button', { name: /Export All/ })

Expand Down
Loading
Loading