From 7bf2b358e8f1856e55bbba6cfdb3dda93a2bcb53 Mon Sep 17 00:00:00 2001 From: Howard Wilson Date: Tue, 20 Oct 2026 20:23:10 +0200 Subject: [PATCH 1/3] Export saved albums Add a Saved Albums row under Liked Songs, hidden when there are none, whose export downloads saved_albums.csv with one line per album. Export All includes it in the zip. Strings are English only for now. Builds on #216. Co-authored-by: Rylie Nelson <5449165+ryliemn@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- src/components/PlaylistExporter.tsx | 10 +- src/components/PlaylistRow.tsx | 7 +- src/components/PlaylistTable.test.tsx | 136 ++++++++++++++++++++- src/components/PlaylistsExporter.tsx | 3 +- src/components/SavedAlbumsExporter.ts | 83 +++++++++++++ src/components/data/PlaylistsData.test.ts | 1 + src/components/data/PlaylistsData.ts | 65 ++++++++-- src/components/data/TracksBaseData.ts | 6 +- src/i18n/locales.test.ts | 2 +- src/i18n/locales/en/translation.json | 8 ++ src/icons.ts | 5 +- src/mocks/handlers.ts | 2 + src/spotify.ts | 5 + src/test/fixtures/spotify/README.md | 2 +- src/test/fixtures/spotify/builders.test.ts | 12 +- src/test/fixtures/spotify/builders.ts | 7 +- 16 files changed, 329 insertions(+), 25 deletions(-) create mode 100644 src/components/SavedAlbumsExporter.ts diff --git a/src/components/PlaylistExporter.tsx b/src/components/PlaylistExporter.tsx index 7b608e8..27e4687 100644 --- a/src/components/PlaylistExporter.tsx +++ b/src/components/PlaylistExporter.tsx @@ -6,11 +6,11 @@ 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 { TracksPlaylist } from "components/data/PlaylistsData" import { ExportConfig } from "components/ConfigDropdown" class TracksCsvFile { - playlist: ExportablePlaylist + playlist: TracksPlaylist trackItems: TrackItem[] columnNames: string[] lineData: Map @@ -18,7 +18,7 @@ class TracksCsvFile { lineTrackUris: string[] lineTrackData: CsvValue[][] - constructor(playlist: ExportablePlaylist, trackItems: TrackItem[]) { + constructor(playlist: TracksPlaylist, trackItems: TrackItem[]) { this.playlist = playlist this.trackItems = trackItems this.columnNames = [ @@ -73,10 +73,10 @@ class TracksCsvFile { // Handles exporting a single playlist as a CSV file class PlaylistExporter { - playlist: ExportablePlaylist + playlist: TracksPlaylist config: ExportConfig - constructor(playlist: ExportablePlaylist, config: ExportConfig) { + constructor(playlist: TracksPlaylist, config: ExportConfig) { this.playlist = playlist this.config = config } diff --git a/src/components/PlaylistRow.tsx b/src/components/PlaylistRow.tsx index 10efd3f..6ec1a54 100644 --- a/src/components/PlaylistRow.tsx +++ b/src/components/PlaylistRow.tsx @@ -4,6 +4,7 @@ import { Button } from "react-bootstrap" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import PlaylistExporter from "./PlaylistExporter" +import SavedAlbumsExporter from "./SavedAlbumsExporter" import { ExportablePlaylist, PlaylistPlaceholder } from "./data/PlaylistsData" import { ExportConfig } from "./ConfigDropdown" @@ -22,7 +23,7 @@ class PlaylistRow extends React.Component { this.setState( { exporting: true }, () => { - (new PlaylistExporter( + ("albums" in playlist ? new SavedAlbumsExporter(playlist) : new PlaylistExporter( playlist, this.props.config )).export().catch(this.props.onApiError).finally(() => { @@ -43,6 +44,8 @@ class PlaylistRow extends React.Component { renderIcon(playlist: ExportablePlaylist | PlaylistPlaceholder) { if (playlist.id === 'liked') { return ; + } else if ("albums" in playlist) { + return ; } else { return ; } @@ -67,7 +70,7 @@ class PlaylistRow extends React.Component { {this.renderIcon(playlist)} {playlist.name} {playlist.owner.display_name} - {playlist.tracks.total} + {"albums" in playlist ? <>{playlist.albums.total} {this.props.i18n.t("saved_albums.album", { count: playlist.albums.total })} : playlist.tracks.total} {this.renderTickCross(playlist.public)} {this.renderTickCross(playlist.collaborative)} diff --git a/src/components/PlaylistTable.test.tsx b/src/components/PlaylistTable.test.tsx index 65e425a..0b63b47 100644 --- a/src/components/PlaylistTable.test.tsx +++ b/src/components/PlaylistTable.test.tsx @@ -14,9 +14,10 @@ import { server } from "../mocks/server" import { redirectTo } from "../navigation" import { csvRecords } from "../test/csv" import { captureBugsnagReports } from "../test/bugsnag" -import { API, buildPlaylist, buildUser } from "../test/fixtures/spotify/builders" +import { API, buildArtist, buildFullAlbum, buildPlaylist, buildSavedAlbum, buildUser } from "../test/fixtures/spotify/builders" import { errorResponse, ghostpoetTracksUrl, handlerCalled, pagedGet, nullAlbumHandlers, nullTrackHandlers, localTrackHandlers, duplicateTrackHandlers, missingPlaylistsHandlers } from "../mocks/handlers" import grantedToken from "../test/fixtures/spotify/documented/token.json" +import liveSavedAlbums from "../test/fixtures/spotify/live/me-albums-limit-50.json" vi.mock('@bugsnag/js') @@ -27,6 +28,7 @@ const likedRequests = [ 'https://api.spotify.com/v1/me', 'https://api.spotify.com/v1/me/playlists?offset=0&limit=20', 'https://api.spotify.com/v1/me/tracks', + 'https://api.spotify.com/v1/me/albums?limit=1', 'https://api.spotify.com/v1/me/tracks?offset=0&limit=50', ] @@ -187,6 +189,138 @@ test("only the Liked Songs row has the heart icon", async () => { expect(icons).toEqual(['heart', 'music']) }) +describe("saved albums", () => { + const serveSavedAlbums = (items: object[]) => server.use(pagedGet(`${API}/me/albums`, items)) + + const twoStar = { + "Album URI": "spotify:album:62xd4emBJXINuYl0RzfvO8", + "Album Name": "Two Star & The Dream Police", + "Album Artist URI(s)": "spotify:artist:7tr9pbgNEKtG0GQTKe08Tz", + "Album Artist Name(s)": "Mk.gee", + "Album Type": "album", + "Album Release Date": "2024-02-09", + "Total Tracks": "12", + "Label": "R&R Digital", + "UPC": "193436368681", + "Popularity": "71", + "Album Image URL": "https://i.scdn.co/image/ab67616d0000b2738b048b6486231cca4766a076", + "Added At": "2026-10-17T14:37:28Z", + } + + const liveAlbumNames = liveSavedAlbums.body.items.map(item => item.album.name) + + test("a Saved Albums row under Liked Songs shows the album count", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + renderTable() + + const row = await screen.findByRole('row', { name: name => name.includes('Saved Albums') }) + + expect(screen.getAllByRole('row')[2]).toBe(row) + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Liked') + expect(row).toHaveTextContent('7 albums') + expect(within(row).getAllByRole('img', { hidden: true })[0]).toHaveAttribute('data-icon', 'record-vinyl') + }) + + test("there's no Saved Albums row without saved albums", async () => { + renderTable() + + expect(await screen.findByText(ghostpoetPlaylist)).toBeInTheDocument() + expect(screen.queryByText('Saved Albums')).not.toBeInTheDocument() + }) + + test("Saved Albums isn't in search results", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + renderTable() + + await userEvent.type(await screen.findByRole('searchbox'), 'Albums{enter}') + + await waitFor(() => expect(screen.queryAllByRole('row')).toHaveLength(1)) + }) + + test("the row exports one line per album in Spotify's order, ignoring the export settings", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + + const { fileName, records } = await exportPlaylist("Saved Albums", { includeArtistsData: true, includeAudioFeaturesData: true, includeAlbumData: true }) + + expect(fileName).toBe('saved_albums.csv') + expect(records[0]).toEqual(twoStar) + expect(records.map(record => record["Album Name"])).toEqual(liveAlbumNames) + expect(requestedUrls()).toEqual([...likedRequests.slice(0, 4), `${API}/me/albums?offset=0&limit=50`]) + }) + + test("artist names are escaped and missing values left blank", async () => { + serveSavedAlbums([buildSavedAlbum({ + added_at: '2026-10-18T10:00:00Z', + album: buildFullAlbum({ + id: 'compilation1', + name: 'Hits, Vol. 1', + album_type: 'compilation', + artists: [buildArtist({ id: 'artist1', name: 'Earth, Wind & Fire' }), buildArtist({ id: 'artist2', name: 'Chic' })], + release_date: '1999', + total_tracks: 20, + label: 'Some Label', + external_ids: {}, + popularity: 10, + images: [], + }), + })]) + + const { records } = await exportPlaylist("Saved Albums") + + expect(records).toEqual([{ + "Album URI": "spotify:album:compilation1", + "Album Name": "Hits, Vol. 1", + "Album Artist URI(s)": "spotify:artist:artist1, spotify:artist:artist2", + "Album Artist Name(s)": "Earth\\, Wind & Fire, Chic", + "Album Type": "compilation", + "Album Release Date": "1999", + "Total Tracks": "20", + "Label": "Some Label", + "UPC": "", + "Popularity": "10", + "Album Image URL": "", + "Added At": "2026-10-18T10:00:00Z", + }]) + }) + + test("the export pages through albums 50 at a time", async () => { + serveSavedAlbums(Array.from({ length: 60 }, (_, i) => buildSavedAlbum({ album: buildFullAlbum({ id: `album${i}` }) }))) + + const { records } = await exportPlaylist("Saved Albums") + + expect(records).toHaveLength(60) + expect(requestedUrls().filter(url => url.includes('/me/albums'))).toEqual([ + `${API}/me/albums?limit=1`, + `${API}/me/albums?offset=0&limit=50`, + `${API}/me/albums?offset=50&limit=50`, + ]) + }) + + test("Export All includes saved_albums.csv", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + renderTable() + + const files = await exportZip(/Export All/) + + expect(Object.keys(files)).toEqual(['liked.csv', 'saved_albums.csv', ghostpoetFileName]) + expect(files['saved_albums.csv'][0]).toEqual(twoStar) + }) + + test("a failed export shows a message and can be tried again", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + renderTable() + const row = await screen.findByRole('row', { name: name => name.includes('Saved Albums') }) + server.use(http.get(`${API}/me/albums`, errorResponse(403, "Spotify is unavailable in this country"))) + + const button = within(row).getByRole('button', { name: 'Export' }) + await userEvent.click(button) + + expect(await screen.findByRole('alert')).toHaveTextContent("Spotify refused the request (error 403).") + expect(button).toBeEnabled() + expect(FileSaver.saveAs).not.toHaveBeenCalled() + }) +}) + describe("single playlist exporting", () => { test("standard case exports successfully", async () => { const { fileName, records } = await exportPlaylist("Liked") diff --git a/src/components/PlaylistsExporter.tsx b/src/components/PlaylistsExporter.tsx index 856fe1f..bfb0bf2 100644 --- a/src/components/PlaylistsExporter.tsx +++ b/src/components/PlaylistsExporter.tsx @@ -6,6 +6,7 @@ import { saveAs } from "file-saver" import JSZip from "jszip" import PlaylistExporter from "./PlaylistExporter" +import SavedAlbumsExporter from "./SavedAlbumsExporter" import PlaylistsData from "./data/PlaylistsData" import { ExportConfig } from "./ConfigDropdown" @@ -35,7 +36,7 @@ class PlaylistsExporter extends React.Component { for (const playlist of playlists) { this.props.onPlaylistExportStarted(playlist.name, doneCount) - const exporter = new PlaylistExporter(playlist, config) + const exporter = "albums" in playlist ? new SavedAlbumsExporter(playlist) : new PlaylistExporter(playlist, config) const csvData = await exporter.csvData() let fileName = exporter.fileName(false) diff --git a/src/components/SavedAlbumsExporter.ts b/src/components/SavedAlbumsExporter.ts new file mode 100644 index 0000000..5819a5c --- /dev/null +++ b/src/components/SavedAlbumsExporter.ts @@ -0,0 +1,83 @@ +import { saveAs } from "file-saver" +import i18n from "../i18n/config" + +import { apiCall } from "helpers" +import { Paging, SavedAlbum, SimplifiedArtist } from "spotify" +import { CsvValue } from "components/data/TracksData" +import { SavedAlbumsPlaylist } from "components/data/PlaylistsData" + +const LIMIT = 50 // The most /me/albums allows + +const artistUris = (artists: SimplifiedArtist[]) => artists.map(a => a.uri).join(', ') +const artistNames = (artists: SimplifiedArtist[]) => artists.map(a => (a.name ?? "").replace(/,/g, "\\,")).join(', ') +const sanitize = (value: CsvValue) => '"' + String(value).replace(/"/g, '""') + '"' + +// Handles exporting saved albums as a CSV file, one line per album +class SavedAlbumsExporter { + playlist: SavedAlbumsPlaylist + + constructor(playlist: SavedAlbumsPlaylist) { + this.playlist = playlist + } + + async export() { + const blob = new Blob([await this.csvData()], { type: "text/csv;charset=utf-8" }) + saveAs(blob, this.fileName(), { autoBom: false }) + } + + async csvData() { + const columnNames = [ + i18n.t("track.album_uri"), + i18n.t("track.album_name"), + i18n.t("track.album_artist_uris"), + i18n.t("track.album_artist_names"), + i18n.t("saved_albums.album_type"), + i18n.t("track.album_release_date"), + i18n.t("saved_albums.total_tracks"), + i18n.t("track.album.label"), + i18n.t("saved_albums.upc"), + i18n.t("track.popularity"), + i18n.t("track.album_image_url"), + i18n.t("track.added_at") + ] + + const lines = (await this.savedAlbums()).map(({ album, added_at }) => [ + album.uri, + album.name, + artistUris(album.artists), + artistNames(album.artists), + album.album_type ?? '', + album.release_date ?? '', + album.total_tracks ?? '', + album.label, + album.external_ids.upc ?? '', + album.popularity, + album.images[0]?.url ?? '', + added_at + ]) + + return [columnNames, ...lines].map(line => line.map(sanitize).join(",") + "\n").join("") + } + + fileName(withExtension = true): string { + return "saved_albums" + (withExtension ? this.fileExtension() : "") + } + + fileExtension(): string { + return ".csv" + } + + private async savedAlbums() { + const requests = [] + + for (let offset = 0; offset < this.playlist.albums.total; offset = offset + LIMIT) { + requests.push(apiCall>(`${this.playlist.albums.href}?offset=${offset}&limit=${LIMIT}`)) + } + + const responses = await Promise.all(requests) + + return responses.flatMap(response => response.data.items) + } +} + +export default SavedAlbumsExporter diff --git a/src/components/data/PlaylistsData.test.ts b/src/components/data/PlaylistsData.test.ts index 6c72aee..66da378 100644 --- a/src/components/data/PlaylistsData.test.ts +++ b/src/components/data/PlaylistsData.test.ts @@ -51,6 +51,7 @@ describe("PlaylistsData", () => { `${API}/me/playlists?offset=50&limit=50`, `${API}/me/playlists?offset=100&limit=50`, `${API}/me/tracks`, + `${API}/me/albums?limit=1`, ]) expect(onStarted).toHaveBeenCalledOnce() expect(onDone).toHaveBeenCalledOnce() diff --git a/src/components/data/PlaylistsData.ts b/src/components/data/PlaylistsData.ts index 41eec7c..4742804 100644 --- a/src/components/data/PlaylistsData.ts +++ b/src/components/data/PlaylistsData.ts @@ -1,5 +1,6 @@ +import i18n from "../../i18n/config" import { apiCall } from "helpers" -import { Paging, SavedTrack, SimplifiedPlaylist } from "spotify" +import { Paging, SavedAlbum, SavedTrack, SimplifiedPlaylist } from "spotify" // Liked Songs isn't a playlist on Spotify, so it's built from /me/tracks and exported alongside them export interface LikedSongsPlaylist { @@ -12,7 +13,19 @@ export interface LikedSongsPlaylist { uri: string } -export type ExportablePlaylist = SimplifiedPlaylist | LikedSongsPlaylist +// Saved albums are listed and exported like a playlist, with albums in place of tracks +export interface SavedAlbumsPlaylist { + id: "albums" + name: string + public: false + collaborative: false + owner: { id: string; display_name: string; uri: string } + albums: { href: string; total: number } + uri: string +} + +export type TracksPlaylist = SimplifiedPlaylist | LikedSongsPlaylist +export type ExportablePlaylist = TracksPlaylist | SavedAlbumsPlaylist // Stands in for a playlist that hasn't been loaded yet export type PlaylistPlaceholder = { [K in keyof SimplifiedPlaylist]?: undefined } @@ -30,6 +43,7 @@ class PlaylistsData { private onPlaylistsLoadingDone?: () => void private data: (SimplifiedPlaylist | PlaylistPlaceholder | null)[] private likedTracksPlaylist: LikedSongsPlaylist | null + private savedAlbumsPlaylist?: SavedAlbumsPlaylist | null private dataInitialized = false constructor(userId: string, onPlaylistsLoadingStarted?: () => void, onPlaylistsLoadingDone?: () => void) { @@ -50,12 +64,12 @@ class PlaylistsData { async slice(start: number, end: number): Promise<(ExportablePlaylist | PlaylistPlaceholder)[]> { await this.loadSlice(start, end) - const likedTracksPlaylist = await this.loadLikedTracksPlaylist() + const libraryPlaylists = await this.loadLibraryPlaylists() const playlists = this.data.slice(start, end).filter(p => p != null) - // It's a little ugly, but we slip in liked tracks with the first slice + // It's a little ugly, but we slip in liked tracks and saved albums with the first slice if (start === 0) { - return [likedTracksPlaylist, ...playlists] + return [...libraryPlaylists, ...playlists] } else { return playlists } @@ -63,10 +77,10 @@ class PlaylistsData { async all(): Promise { await this.loadAll() - const likedTracksPlaylist = await this.loadLikedTracksPlaylist() + const libraryPlaylists = await this.loadLibraryPlaylists() // Remove any uninitialized playlists when exporting - return [likedTracksPlaylist, ...this.data.filter(isLoaded)] + return [...libraryPlaylists, ...this.data.filter(isLoaded)] } async search(query: string): Promise { @@ -131,6 +145,13 @@ class PlaylistsData { this.data.splice(start, playlistsData.items.length, ...playlistsData.items) } + private async loadLibraryPlaylists(): Promise { + const likedTracksPlaylist = await this.loadLikedTracksPlaylist() + const savedAlbumsPlaylist = await this.loadSavedAlbumsPlaylist() + + return savedAlbumsPlaylist ? [likedTracksPlaylist, savedAlbumsPlaylist] : [likedTracksPlaylist] + } + private async loadLikedTracksPlaylist(): Promise { if (this.likedTracksPlaylist !== null) { return this.likedTracksPlaylist @@ -160,6 +181,36 @@ class PlaylistsData { return this.likedTracksPlaylist } + + // Null when there are no saved albums, so the row is left out + private async loadSavedAlbumsPlaylist(): Promise { + if (this.savedAlbumsPlaylist !== undefined) { + return this.savedAlbumsPlaylist + } + + const savedAlbumsUrl = "https://api.spotify.com/v1/me/albums" + const savedAlbumsResponse = await apiCall>(`${savedAlbumsUrl}?limit=1`) + const total = savedAlbumsResponse.data.total + + this.savedAlbumsPlaylist = total === 0 ? null : { + "id": "albums", + "name": i18n.t("saved_albums.name"), + "public": false, + "collaborative": false, + "owner": { + "id": this.userId, + "display_name": this.userId, + "uri": "spotify:user:" + this.userId + }, + "albums": { + "href": savedAlbumsUrl, + "total": total + }, + "uri": "spotify:user:" + this.userId + ":collection:albums" + } + + return this.savedAlbumsPlaylist + } } export default PlaylistsData diff --git a/src/components/data/TracksBaseData.ts b/src/components/data/TracksBaseData.ts index 916442a..7310b24 100644 --- a/src/components/data/TracksBaseData.ts +++ b/src/components/data/TracksBaseData.ts @@ -2,7 +2,7 @@ import i18n from "../../i18n/config" import TracksData from "./TracksData" import { apiCall } from "helpers" import { Paging, PlaylistItem, SavedTrack, Track, UserReference } from "spotify" -import { ExportablePlaylist } from "./PlaylistsData" +import { TracksPlaylist } from "./PlaylistsData" // What playlist items and saved tracks have in common, once items whose track is gone are left out export interface TrackItem { @@ -14,9 +14,9 @@ export interface TrackItem { const hasTrack = (item: PlaylistItem | SavedTrack): item is TrackItem => item.track != null class TracksBaseData extends TracksData { - playlist: ExportablePlaylist + playlist: TracksPlaylist - constructor(playlist: ExportablePlaylist) { + constructor(playlist: TracksPlaylist) { super() this.playlist = playlist } diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts index 442e4ac..5123357 100644 --- a/src/i18n/locales.test.ts +++ b/src/i18n/locales.test.ts @@ -12,7 +12,7 @@ function flatten(translations: Translations, prefix = ""): Record (text.match(/{{\s*\w+\s*}}/g) ?? []).map(placeholder => placeholder.replace(/[{}\s]/g, "")).sort() // Keys under these prefixes fall back to English until someone translates them -const englishOnly = ["api_error."] +const englishOnly = ["api_error.", "saved_albums."] const translatable = (key: string) => !englishOnly.some(prefix => key.startsWith(prefix)) const en = flatten(locales.en) diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index dcff4b7..f6d6a14 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -58,6 +58,14 @@ "not_supported": "This playlist is not supported", "export": "Export" }, + "saved_albums": { + "name": "Saved Albums", + "album_one": "album", + "album_other": "albums", + "album_type": "Album Type", + "total_tracks": "Total Tracks", + "upc": "UPC" + }, "track": { "track_uri": "Track URI", "track_name": "Track Name", diff --git a/src/icons.ts b/src/icons.ts index 4ac02c5..d47ccc6 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { faSpotify } from '@fortawesome/free-brands-svg-icons' import { faCheckCircle, faTimesCircle, faFileArchive, faHeart } from '@fortawesome/free-regular-svg-icons' -import { faBolt, faServer, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck } from '@fortawesome/free-solid-svg-icons' +import { faBolt, faServer, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck, faRecordVinyl } from '@fortawesome/free-solid-svg-icons' library.add( faSpotify, @@ -21,5 +21,6 @@ library.add( faLightbulb, faCircleInfo, faGlobe, - faCheck + faCheck, + faRecordVinyl ) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0ce3b71..df49ace 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -107,6 +107,8 @@ export const handlers = [ pagedGet(`${API}/me/tracks`, [buildSavedTrack({ added_at: '2020-07-19T09:24:39Z', track: crying })]), + pagedGet(`${API}/me/albums`, []), + // Ensure that a null playlist doesn't break anything spotifyGet(playlistsUrl, () => buildPage([ghostpoetPlaylist, null], { url: playlistsUrl, total: 1 })), diff --git a/src/spotify.ts b/src/spotify.ts index 04959a9..3f92ff8 100644 --- a/src/spotify.ts +++ b/src/spotify.ts @@ -111,6 +111,11 @@ export interface SavedTrack { track: Track } +export interface SavedAlbum { + added_at: string + album: Album +} + export interface SimplifiedPlaylist { collaborative: boolean description: string | null diff --git a/src/test/fixtures/spotify/README.md b/src/test/fixtures/spotify/README.md index 13af9f9..e82eedf 100644 --- a/src/test/fixtures/spotify/README.md +++ b/src/test/fixtures/spotify/README.md @@ -49,4 +49,4 @@ The script rewrites `live/` and merges the new private value hashes into `privat ## Not covered -No playlist is `collaborative: true`, the playlist list and Liked Songs each fit in a single page, and there are no local files. Build those with fixture builders. +No playlist is `collaborative: true`, the playlist list, Liked Songs and saved albums each fit in a single page, and there are no local files. The saved albums are all `album_type: "album"` with a single artist and a UPC. Build those with fixture builders. diff --git a/src/test/fixtures/spotify/builders.test.ts b/src/test/fixtures/spotify/builders.test.ts index 6a22689..6be8c16 100644 --- a/src/test/fixtures/spotify/builders.test.ts +++ b/src/test/fixtures/spotify/builders.test.ts @@ -2,10 +2,11 @@ import fs from 'fs' import path from 'path' import { API, apiError, buildAlbum, buildArtist, buildAudioFeatures, buildFullAlbum, buildFullArtist, buildLocalTrack, buildMe, - buildPage, buildPlaylist, buildPlaylistItem, buildSavedTrack, buildTrack, buildUser, pageAt, paginate, + buildPage, buildPlaylist, buildPlaylistItem, buildSavedAlbum, buildSavedTrack, buildTrack, buildUser, pageAt, paginate, } from './builders' import me from './live/me.json' import savedTracks from './live/me-tracks.json' +import savedAlbums from './live/me-albums-limit-50.json' import playlists from './live/users-exportifytestuser-playlists-offset-0-limit-20.json' import playlistTracks from './live/playlists-fakeplaylist0000000001-tracks-offset-0-limit-100.json' import artists from './live/artists-1.json' @@ -70,6 +71,15 @@ describe('entity builders', () => { .toMatchObject({ added_at: '2020-01-01T00:00:00Z', track: { name: 'Song' } }) }) + it('build saved albums shaped like a live saved album', () => { + const liveItem = savedAlbums.body.items[0] + + expect(keys(buildSavedAlbum())).toEqual(keys(liveItem)) + expect(keys(buildSavedAlbum().album)).toEqual(keys(liveItem.album)) + expect(buildSavedAlbum({ album: buildFullAlbum({ name: 'Record' }), added_at: '2020-01-01T00:00:00Z' })) + .toMatchObject({ added_at: '2020-01-01T00:00:00Z', album: { name: 'Record' } }) + }) + it('build playlist items shaped like a live playlist item', () => { const liveItem = playlistTracks.body.items[0] const item = buildPlaylistItem({ track: buildTrack({ name: 'Song' }), added_by: buildUser({ id: 'someone' }) }) diff --git a/src/test/fixtures/spotify/builders.ts b/src/test/fixtures/spotify/builders.ts index 13d74f4..274ac31 100644 --- a/src/test/fixtures/spotify/builders.ts +++ b/src/test/fixtures/spotify/builders.ts @@ -1,5 +1,6 @@ import me from './live/me.json' import savedTracks from './live/me-tracks.json' +import savedAlbums from './live/me-albums-limit-50.json' import playlists from './live/users-exportifytestuser-playlists-offset-0-limit-20.json' import playlistTracks from './live/playlists-fakeplaylist0000000001-tracks-offset-0-limit-100.json' import artists from './live/artists-1.json' @@ -9,7 +10,7 @@ import error401 from './documented/error-401.json' import error429 from './documented/error-429.json' import error503 from './documented/error-503.json' import { - Album, Artist, AudioFeatures, CurrentUser, Paging, PlaylistItem, PublicUser, SavedTrack, SimplifiedAlbum, SimplifiedArtist, + Album, Artist, AudioFeatures, CurrentUser, Paging, PlaylistItem, PublicUser, SavedAlbum, SavedTrack, SimplifiedAlbum, SimplifiedArtist, SimplifiedPlaylist, Track, UserReference, } from '../../../spotify' @@ -18,6 +19,7 @@ export const API = 'https://api.spotify.com/v1' // Typing the templates checks the response types against the recorded fixtures const meTemplate: CurrentUser = me.body const savedTrackTemplate: SavedTrack = savedTracks.body.items[0] +const savedAlbumTemplate: SavedAlbum = savedAlbums.body.items[0] const playlistTemplate: SimplifiedPlaylist = playlists.body.items.find(playlist => playlist.owner.id === me.body.id)! const playlistItemTemplate: PlaylistItem & { added_by: UserReference, track: Track } = playlistTracks.body.items[0] const fullArtistTemplate: Artist = artists.body.artists[0] @@ -87,6 +89,9 @@ export function buildLocalTrack({ name, artist, album, duration_ms }: { name: st export const buildSavedTrack = (overrides: Partial = {}): SavedTrack => ({ added_at: savedTrackTemplate.added_at, track: buildTrack(), ...overrides }) +export const buildSavedAlbum = (overrides: Partial = {}): SavedAlbum => + ({ added_at: savedAlbumTemplate.added_at, album: buildFullAlbum(), ...overrides }) + type PlaylistItemOverrides = Partial> & { added_by?: UserReference } export function buildPlaylistItem({ track = buildTrack(), added_by = buildUser(), ...overrides }: PlaylistItemOverrides = {}): PlaylistItem { From 9a49b02a64d163838fbf2ab503e28e224f3b73f1 Mon Sep 17 00:00:00 2001 From: Howard Wilson Date: Tue, 20 Oct 2026 20:24:01 +0200 Subject: [PATCH 2/3] Translate saved albums strings The album count is the app's first plural key, so the locale test compares keys without their plural suffix: Arabic needs six forms, Japanese one. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales.test.ts | 9 ++++++--- src/i18n/locales/ar/translation.json | 12 ++++++++++++ src/i18n/locales/de/translation.json | 8 ++++++++ src/i18n/locales/el/translation.json | 8 ++++++++ src/i18n/locales/es/translation.json | 8 ++++++++ src/i18n/locales/fr/translation.json | 8 ++++++++ src/i18n/locales/it/translation.json | 8 ++++++++ src/i18n/locales/ja/translation.json | 7 +++++++ src/i18n/locales/nl/translation.json | 8 ++++++++ src/i18n/locales/pt/translation.json | 8 ++++++++ src/i18n/locales/sv/translation.json | 8 ++++++++ src/i18n/locales/tr/translation.json | 8 ++++++++ 12 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts index 5123357..c1cbc59 100644 --- a/src/i18n/locales.test.ts +++ b/src/i18n/locales.test.ts @@ -12,9 +12,13 @@ function flatten(translations: Translations, prefix = ""): Record (text.match(/{{\s*\w+\s*}}/g) ?? []).map(placeholder => placeholder.replace(/[{}\s]/g, "")).sort() // Keys under these prefixes fall back to English until someone translates them -const englishOnly = ["api_error.", "saved_albums."] +const englishOnly = ["api_error."] const translatable = (key: string) => !englishOnly.some(prefix => key.startsWith(prefix)) +// Languages need different plural forms, so keys are compared without their plural suffix +const pluralBase = (key: string) => key.replace(/_(zero|one|two|few|many|other)$/, "") +const baseKeys = (keys: string[]) => [...new Set(keys.filter(translatable).map(pluralBase))].sort() + const en = flatten(locales.en) const others = Object.keys(locales).filter(locale => locale !== "en") @@ -27,8 +31,7 @@ describe.each(others)("%s", locale => { const translations = flatten(locales[locale]) test("has the same keys as en", () => { - expect(Object.keys(translations).filter(translatable).sort()).toEqual(Object.keys(en).filter(translatable).sort()) - expect(Object.keys(translations).filter(key => !(key in en))).toEqual([]) + expect(baseKeys(Object.keys(translations))).toEqual(baseKeys(Object.keys(en))) }) test("uses the same placeholders as en", () => { diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 21e11a3..11c0e71 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -50,6 +50,18 @@ "not_supported": "هذه القائمة غير مدعومة", "export": "تصدير" }, + "saved_albums": { + "name": "الألبومات المحفوظة", + "album_zero": "ألبوم", + "album_one": "ألبوم", + "album_two": "ألبومان", + "album_few": "ألبومات", + "album_many": "ألبومًا", + "album_other": "ألبوم", + "album_type": "نوع الألبوم", + "total_tracks": "إجمالي الأغاني", + "upc": "UPC" + }, "track": { "track_uri": "رابط الأغنية (URI)", "track_name": "اسم الأغنية", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 578f588..cb28c3a 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -50,6 +50,14 @@ "not_supported": "Diese Playlist wird nicht unterstützt", "export": "Exportieren" }, + "saved_albums": { + "name": "Gespeicherte Alben", + "album_one": "Album", + "album_other": "Alben", + "album_type": "Albumtyp", + "total_tracks": "Anzahl der Titel", + "upc": "UPC" + }, "track": { "track_uri": "Track-URI", "track_name": "Track-Name", diff --git a/src/i18n/locales/el/translation.json b/src/i18n/locales/el/translation.json index 256b8f0..0f0617c 100644 --- a/src/i18n/locales/el/translation.json +++ b/src/i18n/locales/el/translation.json @@ -50,6 +50,14 @@ "not_supported": "Αυτή η λίστα δεν υποστηρίζεται", "export": "Εξαγωγή" }, + "saved_albums": { + "name": "Αποθηκευμένα άλμπουμ", + "album_one": "άλμπουμ", + "album_other": "άλμπουμ", + "album_type": "Τύπος άλμπουμ", + "total_tracks": "Σύνολο κομματιών", + "upc": "UPC" + }, "track": { "track_uri": "URI κομματιού", "track_name": "Όνομα κομματιού", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 81dc3fc..6d582d9 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -50,6 +50,14 @@ "not_supported": "Esta playlist no es compatible", "export": "Exportar" }, + "saved_albums": { + "name": "Álbumes guardados", + "album_one": "álbum", + "album_other": "álbumes", + "album_type": "Tipo de álbum", + "total_tracks": "Total de canciones", + "upc": "UPC" + }, "track": { "track_uri": "URI de la canción", "track_name": "Nombre de la canción", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index ecca01d..398cc0f 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -50,6 +50,14 @@ "not_supported": "Cette playlist n'est pas prise en charge", "export": "Exporter" }, + "saved_albums": { + "name": "Albums enregistrés", + "album_one": "album", + "album_other": "albums", + "album_type": "Type d'album", + "total_tracks": "Nombre de titres", + "upc": "UPC" + }, "track": { "track_uri": "URI du titre", "track_name": "Nom du titre", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index e69c01f..4fa3a48 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -50,6 +50,14 @@ "not_supported": "Questa playlist non è supportata", "export": "Esporta" }, + "saved_albums": { + "name": "Album salvati", + "album_one": "album", + "album_other": "album", + "album_type": "Tipo di album", + "total_tracks": "Totale tracce", + "upc": "UPC" + }, "track": { "track_uri": "URI della traccia", "track_name": "Nome della traccia", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index caf99f3..0a86bea 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -50,6 +50,13 @@ "not_supported": "このプレイリストはサポートされていません", "export": "エクスポート" }, + "saved_albums": { + "name": "保存したアルバム", + "album_other": "枚", + "album_type": "アルバムタイプ", + "total_tracks": "総トラック数", + "upc": "UPC" + }, "track": { "track_uri": "トラックURI", "track_name": "トラック名", diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index c7193a4..b30ba77 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -50,6 +50,14 @@ "not_supported": "Deze afspeellijst wordt niet ondersteund", "export": "Exporteren" }, + "saved_albums": { + "name": "Opgeslagen albums", + "album_one": "album", + "album_other": "albums", + "album_type": "Albumtype", + "total_tracks": "Totaal aantal nummers", + "upc": "UPC" + }, "track": { "track_uri": "Nummer URI", "track_name": "Nummernaam", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index b702d10..2cf4b76 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -50,6 +50,14 @@ "not_supported": "Esta playlist não é suportada", "export": "Exportar" }, + "saved_albums": { + "name": "Álbuns salvos", + "album_one": "álbum", + "album_other": "álbuns", + "album_type": "Tipo de álbum", + "total_tracks": "Total de faixas", + "upc": "UPC" + }, "track": { "track_uri": "URI da faixa", "track_name": "Nome da faixa", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index b90e821..f3d232a 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -50,6 +50,14 @@ "not_supported": "Denna spellista stöds inte", "export": "Exportera" }, + "saved_albums": { + "name": "Sparade album", + "album_one": "album", + "album_other": "album", + "album_type": "Albumtyp", + "total_tracks": "Totalt antal låtar", + "upc": "UPC" + }, "track": { "track_uri": "Låtens URI", "track_name": "Låtens namn", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 371b499..7eb93ba 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -50,6 +50,14 @@ "not_supported": "Bu çalma listesi desteklenmiyor", "export": "Dışa Aktar" }, + "saved_albums": { + "name": "Kaydedilen Albümler", + "album_one": "albüm", + "album_other": "albüm", + "album_type": "Albüm Türü", + "total_tracks": "Toplam Parça", + "upc": "UPC" + }, "track": { "track_uri": "Parça URI", "track_name": "Parça Adı", From e1581306a8ec64d1bf94e2964190d08a52933fbc Mon Sep 17 00:00:00 2001 From: Howard Wilson Date: Tue, 20 Oct 2026 20:30:47 +0200 Subject: [PATCH 3/3] Count every exported file in the Export All progress bar The bar's maximum was the playlist count, which leaves out Liked Songs and Saved Albums, so it filled up and stopped animating before the last files were exported. Co-Authored-By: Claude Opus 5.5 --- src/components/PlaylistTable.test.tsx | 20 ++++++++++++++++++++ src/components/PlaylistTable.tsx | 13 ++++++++----- src/components/PlaylistsExporter.tsx | 4 ++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/PlaylistTable.test.tsx b/src/components/PlaylistTable.test.tsx index 0b63b47..561d7e8 100644 --- a/src/components/PlaylistTable.test.tsx +++ b/src/components/PlaylistTable.test.tsx @@ -306,6 +306,26 @@ describe("saved albums", () => { expect(files['saved_albums.csv'][0]).toEqual(twoStar) }) + test("Export All's progress counts Liked Songs and Saved Albums", async () => { + serveSavedAlbums(liveSavedAlbums.body.items) + let releaseLastFile = () => {} + const lastFile = new Promise(resolve => { releaseLastFile = resolve }) + server.use(http.get(ghostpoetTracksUrl, async () => { await lastFile })) + renderTable() + + await userEvent.click(await screen.findByRole('button', { name: /Export All/ })) + + await waitFor(() => expect(screen.getByRole('progressbar')).toHaveTextContent(`Exporting ${ghostpoetPlaylist}...`)) + expect(screen.getByRole('progressbar')).toHaveValue(2) + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '3') + + releaseLastFile() + + await waitFor(() => expect(screen.getByRole('progressbar')).toHaveTextContent('Done!')) + expect(screen.getByRole('progressbar')).toHaveValue(3) + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '3') + }) + test("a failed export shows a message and can be tried again", async () => { serveSavedAlbums(liveSavedAlbums.body.items) renderTable() diff --git a/src/components/PlaylistTable.tsx b/src/components/PlaylistTable.tsx index a701fb2..a0ad487 100644 --- a/src/components/PlaylistTable.tsx +++ b/src/components/PlaylistTable.tsx @@ -40,7 +40,8 @@ class PlaylistTable extends React.Component { progressBar: { show: false, label: "", - value: 0 + value: 0, + max: 0 }, config: { includeArtistsData: false, @@ -146,19 +147,21 @@ class PlaylistTable extends React.Component { progressBar: { show: true, label: this.props.i18n.t("exporting_done"), - value: this.state.playlistCount + value: this.state.progressBar.max, + max: this.state.progressBar.max } }) } - handlePlaylistExportStarted = (playlistName: string, doneCount: number) => { + handlePlaylistExportStarted = (playlistName: string, doneCount: number, totalCount: number) => { Bugsnag.leaveBreadcrumb(`Started exporting playlist ${playlistName}`) this.setState({ progressBar: { show: true, label: this.props.i18n.t("exporting_playlist", { playlistName: playlistName }), - value: doneCount + value: doneCount, + max: totalCount } }) } @@ -221,7 +224,7 @@ class PlaylistTable extends React.Component { } render() { - const progressBar = + const progressBar = if (this.state.initialized) { return ( diff --git a/src/components/PlaylistsExporter.tsx b/src/components/PlaylistsExporter.tsx index bfb0bf2..5d2f5d4 100644 --- a/src/components/PlaylistsExporter.tsx +++ b/src/components/PlaylistsExporter.tsx @@ -14,7 +14,7 @@ interface PlaylistsExporterProps extends WithTranslation { playlistsData: PlaylistsData searchQuery: string config: ExportConfig - onPlaylistExportStarted: (playlistName: string, doneCount: number) => void + onPlaylistExportStarted: (playlistName: string, doneCount: number, totalCount: number) => void onPlaylistsExportDone: () => void onApiError: (error: unknown) => void } @@ -34,7 +34,7 @@ class PlaylistsExporter extends React.Component { let doneCount = 0 for (const playlist of playlists) { - this.props.onPlaylistExportStarted(playlist.name, doneCount) + this.props.onPlaylistExportStarted(playlist.name, doneCount, playlists.length) const exporter = "albums" in playlist ? new SavedAlbumsExporter(playlist) : new PlaylistExporter(playlist, config) const csvData = await exporter.csvData()