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..561d7e8 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,158 @@ 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("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() + 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/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 856fe1f..5d2f5d4 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" @@ -13,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 } @@ -33,9 +34,9 @@ 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 = 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..c1cbc59 100644 --- a/src/i18n/locales.test.ts +++ b/src/i18n/locales.test.ts @@ -15,6 +15,10 @@ const placeholders = (text: string) => (text.match(/{{\s*\w+\s*}}/g) ?? []).map( 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/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/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ı", 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 {