Skip to content

Commit 0c9b26f

Browse files
fix(embed): hydrate track list for slug-based playlist/album embeds (#14468)
Fixes #14467 ## Bug Slug-based collection embeds (e.g. `/embed/album/AcidRayn/iterations-of-truth-volume-1-the-foundation`, `/embed/playlist/AcidRayn/halleluyah`) rendered only the header — artwork, title, artist — with no track list or playback controls, while hash-id embeds (e.g. `/embed/album/vjgoJWp`) worked fine. ## Root cause The embed player resolves slug URLs through `getCollectionByPermalink`, which calls the SDK's `getBulkPlaylists({ permalink })` endpoint. That endpoint returns the collection's metadata **but with an empty `tracks` array** — only `track_count` is set. The hash-id path uses `getPlaylist({ playlistId })`, which hydrates the full track list. Verified directly against `api.audius.co`: | Lookup | `track_count` | `tracks.length` | |---|---|---| | `GET /v1/playlists?permalink=/AcidRayn/album/iterations-...` | 17 | **0** | | `GET /v1/playlists/vjgoJWp` | 17 | 17 | | `GET /v1/playlists?permalink=/AcidRayn/playlist/halleluyah` | 96 | **0** | | `GET /v1/playlists/RNE1GmG` | 96 | 96 | Because the header renders from collection metadata but `CollectionPlayerContainer` reads `collection.tracks`, the player came up empty. ## Fix In [`getCollectionByPermalink`](packages/embed/src/util/BedtimeClient.js), after resolving the permalink to obtain the collection's hash id, re-fetch the full collection by id (`getCollectionWithHashId`) so the track list is hydrated — mirroring the hash-id code path. Returns `null` early if the permalink doesn't resolve. This adds one extra request only on the slug path (the hash-id path is unchanged). Applies to both playlists and albums, since both resolve through the same function. ## Testing - Confirmed the API behavior above against production with `curl`. - Added `packages/embed/src/util/BedtimeClient.test.js` covering: track-list hydration via the follow-up by-id fetch, correct permalink construction for the `album` path segment, and the unresolved-permalink → `null` case. Verified these tests **fail on the pre-fix code** and pass after. - `npx jest` (3/3 pass) and `eslint` clean on the touched files. ## Notes for reviewer - The embed package had no test harness. I added a minimal `jest.config.cjs` (+ `jest.setup.cjs` window shim) and a `test` script. Config uses the `node` environment because the monorepo's hoisted `jest-environment-jsdom` is v26 (incompatible with jest 29) and ignores `testEnvironmentOptions.url`; the only DOM dependency at import is `window.audiusSdk`, which the setup shim covers. Babel presets are declared inline in the jest transform so the Vite/esbuild build is untouched. - **Interpretation called out:** the issue framed this as a "broken slug resolver." The resolver does resolve — it just returned an under-hydrated object. The user-visible symptom (header but no tracks) matches a missing track list, which is what this fixes. If instead the intent was that slug URLs should never have needed a second request, that would be a backend change to populate `tracks` on the permalink endpoint — out of scope for the embed client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 83c747c commit 0c9b26f

5 files changed

Lines changed: 123 additions & 1 deletion

File tree

packages/embed/jest.config.cjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Jest config scoped to the embed package's unit tests.
2+
// Babel presets are declared inline here (rather than a root babel.config.js)
3+
// so the Vite/esbuild build pipeline is unaffected.
4+
module.exports = {
5+
testEnvironment: 'node',
6+
// BedtimeClient touches `window` at import time; jest.setup provides a shim
7+
// (the hoisted jsdom environment is an incompatible version in this repo).
8+
setupFiles: ['<rootDir>/jest.setup.cjs'],
9+
transform: {
10+
'^.+\\.(js|jsx)$': [
11+
'babel-jest',
12+
{
13+
presets: [
14+
['@babel/preset-env', { targets: { node: 'current' } }],
15+
['@babel/preset-react', { runtime: 'automatic' }]
16+
]
17+
}
18+
]
19+
}
20+
}

packages/embed/jest.setup.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// BedtimeClient assigns `window.audiusSdk` at import time. Provide a minimal
2+
// `window` so the module can load under the node test environment (the hoisted
3+
// jsdom environment is an incompatible version in this monorepo).
4+
if (typeof globalThis.window === 'undefined') {
5+
globalThis.window = globalThis
6+
}

packages/embed/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build:prod": "env-cmd -f .env.prod turbo run build && npm run build-api && mv build build-production",
1111
"build-api": "webpack --config src/api/webpack.config.js -o build/api.js",
1212
"deploy:prod": "npx wrangler publish --env production",
13+
"test": "jest",
1314
"lint:fix": "eslint --cache --fix --ext=js,jsx,ts,tsx src",
1415
"lint": "eslint --cache --ext=js,jsx,ts,tsx src",
1516
"lint:env": "dotenv-linter",

packages/embed/src/util/BedtimeClient.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ export const getCollectionByPermalink = async (handle, slug, type) => {
112112
const res = await audiusSdk.playlists.getBulkPlaylists({
113113
permalink: [permalink]
114114
})
115-
return getFormattedCollectionResponse(res.data)
115+
const collection = getFormattedCollectionResponse(res.data)
116+
if (!collection) return null
117+
// The bulk/permalink endpoint resolves collection metadata but does not
118+
// hydrate the track list (it returns an empty `tracks` array), which leaves
119+
// the embed player with only a header and no playable tracks. Re-fetch the
120+
// full collection by its (hash) id to get the populated track list.
121+
return getCollectionWithHashId(collection.id)
116122
}
117123

118124
export const getEntityEvents = async (entityId, entityType) => {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const mockGetBulkPlaylists = jest.fn()
2+
const mockGetPlaylist = jest.fn()
3+
const mockGetBulkTracks = jest.fn()
4+
5+
jest.mock('@audius/sdk', () => ({
6+
sdk: () => ({
7+
playlists: {
8+
getBulkPlaylists: (...args) => mockGetBulkPlaylists(...args),
9+
getPlaylist: (...args) => mockGetPlaylist(...args)
10+
},
11+
tracks: {
12+
getBulkTracks: (...args) => mockGetBulkTracks(...args)
13+
},
14+
events: {}
15+
})
16+
}))
17+
18+
// Avoid loading amplitude-js (and its browser globals) at import time.
19+
jest.mock('../analytics/analytics', () => ({
20+
recordListen: jest.fn()
21+
}))
22+
23+
const { getCollectionByPermalink } = require('./BedtimeClient')
24+
25+
describe('getCollectionByPermalink', () => {
26+
beforeEach(() => {
27+
mockGetBulkPlaylists.mockReset()
28+
mockGetPlaylist.mockReset()
29+
})
30+
31+
it('re-fetches the full collection by id so the track list is hydrated', async () => {
32+
// The bulk/permalink endpoint resolves metadata but returns an empty
33+
// `tracks` array (this is what produced the header-only embed bug).
34+
mockGetBulkPlaylists.mockResolvedValue({
35+
data: [{ id: 'vjgoJWp', playlistName: 'Halleluyah', tracks: [] }]
36+
})
37+
// Fetching by (hash) id hydrates the full track list.
38+
mockGetPlaylist.mockResolvedValue({
39+
data: [
40+
{
41+
id: 'vjgoJWp',
42+
playlistName: 'Halleluyah',
43+
tracks: [{ id: 't1' }, { id: 't2' }]
44+
}
45+
]
46+
})
47+
48+
const collection = await getCollectionByPermalink(
49+
'AcidRayn',
50+
'halleluyah',
51+
'playlist'
52+
)
53+
54+
expect(mockGetBulkPlaylists).toHaveBeenCalledWith({
55+
permalink: ['/AcidRayn/playlist/halleluyah']
56+
})
57+
// Must follow up with a by-id fetch using the resolved hash id.
58+
expect(mockGetPlaylist).toHaveBeenCalledWith({ playlistId: 'vjgoJWp' })
59+
expect(collection.tracks).toHaveLength(2)
60+
})
61+
62+
it('builds the album permalink with the album path segment', async () => {
63+
mockGetBulkPlaylists.mockResolvedValue({
64+
data: [{ id: 'vjgoJWp', tracks: [] }]
65+
})
66+
mockGetPlaylist.mockResolvedValue({
67+
data: [{ id: 'vjgoJWp', tracks: [{ id: 't1' }] }]
68+
})
69+
70+
await getCollectionByPermalink('AcidRayn', 'iterations', 'album')
71+
72+
expect(mockGetBulkPlaylists).toHaveBeenCalledWith({
73+
permalink: ['/AcidRayn/album/iterations']
74+
})
75+
})
76+
77+
it('returns null without a follow-up fetch when the permalink does not resolve', async () => {
78+
mockGetBulkPlaylists.mockResolvedValue({ data: [] })
79+
80+
const collection = await getCollectionByPermalink(
81+
'nobody',
82+
'nope',
83+
'playlist'
84+
)
85+
86+
expect(collection).toBeNull()
87+
expect(mockGetPlaylist).not.toHaveBeenCalled()
88+
})
89+
})

0 commit comments

Comments
 (0)