From 14b7ca16a3223bfd4d121297a03517097c44a6b8 Mon Sep 17 00:00:00 2001 From: Agustin Torres Date: Mon, 26 Oct 2026 04:46:24 +0000 Subject: [PATCH] Adapt to the api.spotify.com API change effective 2026-03-09 Playlist items are now read from `item` and the playlist's `items` href and total, and the artist and album lookups request one id at a time because the batch endpoints are gone. `popularity`, `label` and `followers` are now optional and exported as empty when missing. Mocks and tests use the new URLs and shapes. I haven't run the tests or the type checker. Notice spotify-2026-02-development-mode about api.spotify.com's API describes the change. It was read from a file and is not signed by the provider. --- src/components/PlaylistExporter.test.ts | 8 ++--- src/components/PlaylistTable.test.tsx | 4 +-- src/components/SavedAlbumsExporter.ts | 4 +-- src/components/data/TracksAlbumData.ts | 15 ++++---- src/components/data/TracksArtistsData.ts | 11 +++--- src/components/data/TracksBaseData.ts | 19 +++++++--- src/components/data/TracksData.test.ts | 46 +++++++++++++----------- src/mocks/handlers.ts | 31 ++++++++-------- src/spotify.ts | 12 +++---- 9 files changed, 81 insertions(+), 69 deletions(-) diff --git a/src/components/PlaylistExporter.test.ts b/src/components/PlaylistExporter.test.ts index 7255a88..0b274f9 100644 --- a/src/components/PlaylistExporter.test.ts +++ b/src/components/PlaylistExporter.test.ts @@ -2,7 +2,7 @@ import "i18n/config" import { server } from "../mocks/server" 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 { API, buildArtist, buildFullAlbum, buildFullArtist, buildPlaylist, buildPlaylistItem, buildTrack, buildUser } from "../test/fixtures/spotify/builders" import PlaylistExporter from "./PlaylistExporter" import { Track } from "../spotify" @@ -17,7 +17,7 @@ const playlist = buildPlaylist({ id: 'p1', name: 'Mixtape', tracks: { total: 1 } 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' })))) + server.use(pagedGet(`${API}/playlists/p1/items`, tracks.map(track => buildPlaylistItem({ track, added_by: buildUser({ id: 'dj' }), added_at: '2024-01-02T03:04:05Z' })))) beforeEach(() => { localStorage.setItem("access_token", "TEST_ACCESS_TOKEN") @@ -35,9 +35,9 @@ describe("PlaylistExporter", () => { test("appends artist, audio feature and album columns in that order", async () => { serveTracks(buildTrack()) server.use( - spotifyGet(`${API}/artists`, () => ({ artists: [] })), + spotifyGet(`${API}/artists/:id`, () => buildFullArtist()), spotifyGet(`${API}/audio-features`, () => ({ audio_features: [] })), - spotifyGet(`${API}/albums`, () => ({ albums: [] })), + spotifyGet(`${API}/albums/:id`, () => buildFullAlbum()), ) const config = { includeArtistsData: true, includeAudioFeaturesData: true, includeAlbumData: true } diff --git a/src/components/PlaylistTable.test.tsx b/src/components/PlaylistTable.test.tsx index 561d7e8..7555f69 100644 --- a/src/components/PlaylistTable.test.tsx +++ b/src/components/PlaylistTable.test.tsx @@ -354,7 +354,7 @@ describe("single playlist exporting", () => { const { records } = await exportPlaylist("Liked", { includeArtistsData: true }) expect(records).toEqual([{ ...crying, "Artist Genres": "nottingham indie" }]) - expect(requestedUrls()).toEqual([...likedRequests, 'https://api.spotify.com/v1/artists?ids=4TXdHyuAOl3rAOFmZ6MeKz']) + expect(requestedUrls()).toEqual([...likedRequests, 'https://api.spotify.com/v1/artists/4TXdHyuAOl3rAOFmZ6MeKz']) }) test("including additional audio features data", async () => { @@ -387,7 +387,7 @@ describe("single playlist exporting", () => { "Label": "Beggars Banquet", "Copyrights": "C 2016 Beggars Banquet Records Ltd., P 2016 Beggars Banquet Records Ltd.", }]) - expect(requestedUrls()).toEqual([...likedRequests, 'https://api.spotify.com/v1/albums?ids=4iwv7b8gDPKztLkKCbWyhi']) + expect(requestedUrls()).toEqual([...likedRequests, 'https://api.spotify.com/v1/albums/4iwv7b8gDPKztLkKCbWyhi']) }) test("tracks without album data omit it", async () => { diff --git a/src/components/SavedAlbumsExporter.ts b/src/components/SavedAlbumsExporter.ts index 5819a5c..c764993 100644 --- a/src/components/SavedAlbumsExporter.ts +++ b/src/components/SavedAlbumsExporter.ts @@ -49,9 +49,9 @@ class SavedAlbumsExporter { album.album_type ?? '', album.release_date ?? '', album.total_tracks ?? '', - album.label, + album.label ?? '', album.external_ids.upc ?? '', - album.popularity, + album.popularity ?? '', album.images[0]?.url ?? '', added_at ]) diff --git a/src/components/data/TracksAlbumData.ts b/src/components/data/TracksAlbumData.ts index 0ebe997..bd8cfff 100644 --- a/src/components/data/TracksAlbumData.ts +++ b/src/components/data/TracksAlbumData.ts @@ -1,7 +1,7 @@ import i18n from "../../i18n/config" import TracksData from "./TracksData" import { apiCall } from "helpers" -import { Album, AlbumsResponse, Track } from "spotify" +import { Album, Track } from "spotify" class TracksAlbumData extends TracksData { ALBUM_LIMIT = 20 @@ -26,24 +26,25 @@ class TracksAlbumData extends TracksData { const requests = [] - for (let offset = 0; offset < albumIds.length; offset = offset + this.ALBUM_LIMIT) { - requests.push(`https://api.spotify.com/v1/albums?ids=${albumIds.slice(offset, offset + this.ALBUM_LIMIT)}`) + // Batch fetches are gone, so each album is requested on its own + for (const albumId of albumIds) { + requests.push(`https://api.spotify.com/v1/albums/${albumId}`) } - const albumPromises = requests.map((request) => apiCall(request)) + const albumPromises = requests.map((request) => apiCall(request)) const albumResponses = await Promise.all(albumPromises) const albumDataById = new Map( - albumResponses.flatMap((response) => response.data.albums.filter((album): album is Album => album != null).map((album) => { + albumResponses.map((response) => response.data).filter((album): album is Album => album != null).map((album) => { return [ album.id, [ album.genres.join(", "), - album.label, + album.label ?? "", album.copyrights.map((c) => `${c.type} ${c.text}`).join(", ") ] ] - })) + }) ) return new Map( diff --git a/src/components/data/TracksArtistsData.ts b/src/components/data/TracksArtistsData.ts index c1664a3..2541f7d 100644 --- a/src/components/data/TracksArtistsData.ts +++ b/src/components/data/TracksArtistsData.ts @@ -1,7 +1,7 @@ import i18n from "../../i18n/config" import TracksData from "./TracksData" import { apiCall } from "helpers" -import { ArtistsResponse, Track } from "spotify" +import { Artist, Track } from "spotify" class TracksArtistsData extends TracksData { ARTIST_LIMIT = 50 @@ -30,14 +30,15 @@ class TracksArtistsData extends TracksData { const requests = [] - for (let offset = 0; offset < artistIds.length; offset = offset + this.ARTIST_LIMIT) { - requests.push(`https://api.spotify.com/v1/artists?ids=${artistIds.slice(offset, offset + this.ARTIST_LIMIT)}`) + // Batch fetches are gone, so each artist is requested on its own + for (const artistId of artistIds) { + requests.push(`https://api.spotify.com/v1/artists/${artistId}`) } - const artistPromises = requests.map(request => { return apiCall(request) }) + const artistPromises = requests.map(request => { return apiCall(request) }) const artistResponses = await Promise.all(artistPromises) - const artistsById = new Map(artistResponses.flatMap((response) => response.data.artists).map((artist) => [artist.id, artist])) + const artistsById = new Map(artistResponses.map((response) => response.data).map((artist) => [artist.id, artist])) return new Map(this.tracks.map((track) => { return [ diff --git a/src/components/data/TracksBaseData.ts b/src/components/data/TracksBaseData.ts index 7310b24..6a6c9ca 100644 --- a/src/components/data/TracksBaseData.ts +++ b/src/components/data/TracksBaseData.ts @@ -11,7 +11,14 @@ export interface TrackItem { track: Track } -const hasTrack = (item: PlaylistItem | SavedTrack): item is TrackItem => item.track != null +// Playlist items carry their track as item, saved tracks as track +const trackOf = (item: PlaylistItem | SavedTrack) => 'item' in item ? item.item : item.track + +const toTrackItems = (items: (PlaylistItem | SavedTrack)[]): TrackItem[] => + items.flatMap(item => { + const track = trackOf(item) + return track == null ? [] : [{ ...item, track }] + }) class TracksBaseData extends TracksData { playlist: TracksPlaylist @@ -71,7 +78,7 @@ class TracksBaseData extends TracksData { item.track.duration_ms, item.track.preview_url == null ? '' : item.track.preview_url, item.track.explicit, - item.track.popularity, + item.track.popularity ?? '', item.track.external_ids.isrc == null ? '' : item.track.external_ids.isrc ] ] @@ -88,15 +95,17 @@ class TracksBaseData extends TracksData { const requests = [] const limit = this.playlist.id === "liked" ? 50 : 100 // The most /me/tracks and playlist items each allow - for (let offset = 0; offset < this.playlist.tracks.total; offset = offset + limit) { - requests.push(`${this.playlist.tracks.href.split('?')[0]}?offset=${offset}&limit=${limit}`) + const contents = 'items' in this.playlist ? this.playlist.items : this.playlist.tracks + + for (let offset = 0; offset < contents.total; offset = offset + limit) { + requests.push(`${contents.href.split('?')[0]}?offset=${offset}&limit=${limit}`) } const trackPromises = requests.map(request => { return apiCall>(request) }) const trackResponses = await Promise.all(trackPromises) this.playlistItems = trackResponses.flatMap(response => { - return response.data.items.filter(hasTrack) // Exclude null track attributes + return toTrackItems(response.data.items) // Exclude null tracks }) } } diff --git a/src/components/data/TracksData.test.ts b/src/components/data/TracksData.test.ts index c321fcc..4360140 100644 --- a/src/components/data/TracksData.test.ts +++ b/src/components/data/TracksData.test.ts @@ -12,7 +12,8 @@ import TracksAudioFeaturesData from "./TracksAudioFeaturesData" import TracksAlbumData from "./TracksAlbumData" const requestedUrls = () => handlerCalled.mock.calls.map(([url]) => url) -const requestedIds = (request: Request) => new URL(request.url).searchParams.get('ids')!.split(',') +const lastPathSegment = (request: Request) => new URL(request.url).pathname.split('/').pop()! +const requestedIds =(request: Request) => new URL(request.url).searchParams.get('ids')!.split(',') const range = (count: number) => Array.from({ length: count }, (_, i) => i) beforeEach(() => { @@ -23,14 +24,14 @@ describe("TracksBaseData", () => { test("loads every page of a playlist 100 items at a time, skipping null tracks", async () => { const playlist = buildPlaylist({ id: 'p1', tracks: { total: 150 } }) const items = range(150).map(i => buildPlaylistItem({ track: i === 7 ? null : buildTrack({ id: `t${i}` }) })) - server.use(pagedGet(`${API}/playlists/p1/tracks`, items)) + server.use(pagedGet(`${API}/playlists/p1/items`, items)) const trackItems = await new TracksBaseData(playlist).trackItems() expect(trackItems.map(item => item.track.id)).toEqual(range(150).filter(i => i !== 7).map(i => `t${i}`)) expect(requestedUrls()).toEqual([ - `${API}/playlists/p1/tracks?offset=0&limit=100`, - `${API}/playlists/p1/tracks?offset=100&limit=100`, + `${API}/playlists/p1/items?offset=0&limit=100`, + `${API}/playlists/p1/items?offset=100&limit=100`, ]) }) @@ -50,7 +51,7 @@ describe("TracksBaseData", () => { }) test("only requests the playlist once across trackItems and data", async () => { - server.use(pagedGet(`${API}/playlists/p1/tracks`, [buildPlaylistItem()])) + server.use(pagedGet(`${API}/playlists/p1/items`, [buildPlaylistItem()])) const tracksData = new TracksBaseData(buildPlaylist({ id: 'p1', tracks: { total: 1 } })) await tracksData.trackItems() @@ -73,7 +74,7 @@ describe("TracksBaseData", () => { popularity: 70, external_ids: {}, }) - server.use(pagedGet(`${API}/playlists/p1/tracks`, [buildPlaylistItem({ track })])) + server.use(pagedGet(`${API}/playlists/p1/items`, [buildPlaylistItem({ track })])) const data = await new TracksBaseData(buildPlaylist({ id: 'p1', tracks: { total: 1 } })).data() @@ -89,7 +90,7 @@ describe("TracksBaseData", () => { const episode = buildTrack({ id: 'e1', type: 'episode', uri: 'spotify:episode:e1', artists: [show], album: buildAlbum({ id: 's1', type: 'show', artists: [show] }), }) - server.use(pagedGet(`${API}/playlists/p1/tracks`, [buildPlaylistItem({ track: episode })])) + server.use(pagedGet(`${API}/playlists/p1/items`, [buildPlaylistItem({ track: episode })])) const data = await new TracksBaseData(buildPlaylist({ id: 'p1', tracks: { total: 1 } })).data() @@ -100,24 +101,25 @@ describe("TracksBaseData", () => { describe("TracksArtistsData", () => { beforeEach(() => { - server.use(spotifyGet(`${API}/artists`, request => ({ - artists: requestedIds(request).filter(id => id !== 'unknown').map(id => buildFullArtist({ id, genres: [`${id} rock`, `${id} pop`] })), - }))) + server.use(spotifyGet(`${API}/artists/:id`, request => { + const id = lastPathSegment(request) + return buildFullArtist({ id, genres: [`${id} rock`, `${id} pop`] }) + })) }) - test("fetches each distinct artist once, 50 per request", async () => { + test("fetches each distinct artist once, on its own", async () => { const shared = buildArtist({ id: 'shared' }) const tracks = range(60).map(i => buildTrack({ id: `t${i}`, artists: [shared, buildArtist({ id: `a${i}` })] })) await new TracksArtistsData(tracks).data() - expect(requestedUrls().map(url => new URL(url).searchParams.get('ids')!.split(',').length)).toEqual([50, 11]) - expect(new Set(requestedUrls().flatMap(url => new URL(url).searchParams.get('ids')!.split(','))).size).toBe(61) + expect(requestedUrls()).toHaveLength(61) + expect(new Set(requestedUrls()).size).toBe(61) }) - test("joins the genres of every artist on a track, skipping unknown and local artists", async () => { + test("joins the genres of every artist on a track, skipping local artists", async () => { const tracks = [ - buildTrack({ id: 't1', artists: [buildArtist({ id: 'a1' }), buildArtist({ id: 'unknown' }), buildArtist({ id: 'a2' })] }), + buildTrack({ id: 't1', artists: [buildArtist({ id: 'a1' }), buildArtist({ id: 'a2' })] }), buildLocalTrack({ name: 'Demo', artist: 'Me', album: 'Tapes', duration_ms: 1000 }), ] @@ -154,22 +156,24 @@ describe("TracksAudioFeaturesData", () => { describe("TracksAlbumData", () => { beforeEach(() => { - server.use(spotifyGet(`${API}/albums`, request => ({ - albums: requestedIds(request).map(id => id === 'gone' ? null : buildFullAlbum({ + server.use(spotifyGet(`${API}/albums/:id`, request => { + const id = lastPathSegment(request) + return id === 'gone' ? null as unknown as object : buildFullAlbum({ id, genres: ['indie', 'rock'], label: `${id} Records`, copyrights: [{ text: '2020 Label', type: 'C' }, { text: '2020 Label', type: 'P' }], - })), - }))) + }) + })) }) - test("fetches each distinct album once, 20 per request", async () => { + test("fetches each distinct album once, on its own", async () => { const tracks = range(25).flatMap(i => [buildTrack({ id: `t${i}`, album: buildAlbum({ id: `al${i}` }) }), buildTrack({ id: `u${i}`, album: buildAlbum({ id: `al${i}` }) })]) await new TracksAlbumData(tracks).data() - expect(requestedUrls().map(url => new URL(url).searchParams.get('ids')!.split(',').length)).toEqual([20, 5]) + expect(requestedUrls()).toHaveLength(25) + expect(new Set(requestedUrls()).size).toBe(25) }) test("formats genres, label and copyrights, blanking missing and local albums", async () => { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index df49ace..06f2ffe 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -72,7 +72,7 @@ const ghostpoetPlaylist = buildPlaylist({ tracks: { total: 10 }, }) const playlistsUrl = `${API}/me/playlists` -export const ghostpoetTracksUrl = `${API}/playlists/4XOGDpHMrVoH33uJEwHWU5/tracks` +export const ghostpoetTracksUrl = `${API}/playlists/4XOGDpHMrVoH33uJEwHWU5/items` const addedByWatsonbox = (track: Track | null, added_at = '2020-11-03T15:19:04Z') => buildPlaylistItem({ track, added_at, added_by: watsonbox }) @@ -114,9 +114,8 @@ export const handlers = [ tracksPage([addedByWatsonbox(oneTwos), addedByWatsonbox(usAgainstWhatever)]), - spotifyGet(`${API}/artists`, () => ({ - artists: [buildFullArtist({ id: sixBySeven.id, name: sixBySeven.name, genres: ['nottingham indie'] })], - }), { ids: sixBySeven.id }), + spotifyGet(`${API}/artists/${sixBySeven.id}`, () => + buildFullArtist({ id: sixBySeven.id, name: sixBySeven.name, genres: ['nottingham indie'] })), spotifyGet(`${API}/audio-features`, () => ({ audio_features: [buildAudioFeatures({ @@ -137,18 +136,16 @@ export const handlers = [ })], }), { ids: crying.id }), - spotifyGet(`${API}/albums`, () => ({ - albums: [buildFullAlbum({ - ...crying.album, - album_type: 'compilation', - genres: ['something', 'something else'], - label: 'Beggars Banquet', - copyrights: [ - { text: '2016 Beggars Banquet Records Ltd.', type: 'C' }, - { text: '2016 Beggars Banquet Records Ltd.', type: 'P' }, - ], - })], - }), { ids: crying.album.id }), + spotifyGet(`${API}/albums/${crying.album.id}`, () => buildFullAlbum({ + ...crying.album, + album_type: 'compilation', + genres: ['something', 'something else'], + label: 'Beggars Banquet', + copyrights: [ + { text: '2016 Beggars Banquet Records Ltd.', type: 'C' }, + { text: '2016 Beggars Banquet Records Ltd.', type: 'P' }, + ], + })), ] export const missingPlaylistsHandlers = [ @@ -157,7 +154,7 @@ export const missingPlaylistsHandlers = [ ] export const nullAlbumHandlers = [ - spotifyGet(`${API}/albums`, () => ({ albums: [null] }), { ids: crying.album.id }), + spotifyGet(`${API}/albums/${crying.album.id}`, () => null as unknown as object), ] export const nullTrackHandlers = [ diff --git a/src/spotify.ts b/src/spotify.ts index 3f92ff8..2cf2ae5 100644 --- a/src/spotify.ts +++ b/src/spotify.ts @@ -25,7 +25,7 @@ export interface UserReference { export interface PublicUser extends UserReference { display_name: string | null } export interface CurrentUser extends PublicUser { - followers: { href: string | null; total: number } + followers?: { href: string | null; total: number } images: Image[] } @@ -43,10 +43,10 @@ export interface Artist extends SimplifiedArtist { id: string name: string uri: string - followers: { href: string | null; total: number } + followers?: { href: string | null; total: number } genres: string[] images: Image[] - popularity: number + popularity?: number } // Local tracks have no album id, uri, release date or images @@ -72,8 +72,8 @@ export interface Album extends SimplifiedAlbum { copyrights: { text: string; type: string }[] external_ids: { upc?: string; ean?: string; isrc?: string } genres: string[] - label: string - popularity: number + label?: string + popularity?: number } export interface Track { @@ -90,7 +90,7 @@ export interface Track { is_local: boolean is_playable?: boolean name: string - popularity: number + popularity?: number preview_url: string | null track_number: number type: string