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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/components/PlaylistExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
Expand All @@ -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 }

Expand Down
4 changes: 2 additions & 2 deletions src/components/PlaylistTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/SavedAlbumsExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
])
Expand Down
15 changes: 8 additions & 7 deletions src/components/data/TracksAlbumData.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<AlbumsResponse>(request))
const albumPromises = requests.map((request) => apiCall<Album | null>(request))
const albumResponses = await Promise.all(albumPromises)

const albumDataById = new Map<string, string[]>(
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<string, string[]>(
Expand Down
11 changes: 6 additions & 5 deletions src/components/data/TracksArtistsData.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ArtistsResponse>(request) })
const artistPromises = requests.map(request => { return apiCall<Artist>(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<string, string[]>(this.tracks.map((track) => {
return [
Expand Down
19 changes: 14 additions & 5 deletions src/components/data/TracksBaseData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
]
]
Expand All @@ -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<Paging<PlaylistItem | SavedTrack>>(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
})
}
}
Expand Down
46 changes: 25 additions & 21 deletions src/components/data/TracksData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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`,
])
})

Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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 }),
]

Expand Down Expand Up @@ -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 () => {
Expand Down
31 changes: 14 additions & 17 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -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({
Expand All @@ -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 = [
Expand All @@ -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 = [
Expand Down
12 changes: 6 additions & 6 deletions src/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down