Skip to content
Merged
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
56 changes: 56 additions & 0 deletions packages/bot/src/spotify/spotifyApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,5 +518,61 @@ describe('spotifyApi', () => {
expect(result).toHaveLength(1)
expect(result[0].id).toBe('good')
})

it('passes audio feature constraints as URL parameters when provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tracks: [] }),
})

await getSpotifyRecommendations('token', ['seed1'], 10, {
energy: 0.8,
valence: 0.6,
danceability: 0.75,
})

const call = fetchMock.mock.calls[0]!
const url = String(call[0])
expect(url).toContain('min_energy=0.55')
expect(url).toContain('max_energy=1.00')
expect(url).toContain('min_valence=0.35')
expect(url).toContain('max_valence=0.85')
expect(url).toContain('min_danceability=0.50')
expect(url).toContain('max_danceability=1.00')
})

it('does not pass audio feature constraints when audioConstraints is undefined', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tracks: [] }),
})

await getSpotifyRecommendations('token', ['seed1'], 10, undefined)

const call = fetchMock.mock.calls[0]!
const url = String(call[0])
expect(url).not.toContain('min_energy')
expect(url).not.toContain('min_valence')
expect(url).not.toContain('min_danceability')
})

it('clamps audio constraint bounds to [0, 1]', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tracks: [] }),
})

await getSpotifyRecommendations('token', ['seed1'], 10, {
energy: 0.1,
valence: 0.95,
})

const call = fetchMock.mock.calls[0]!
const url = String(call[0])
expect(url).toContain('min_energy=0.00')
expect(url).toContain('max_energy=0.35')
expect(url).toContain('min_valence=0.70')
expect(url).toContain('max_valence=1.00')
})
})
})
71 changes: 71 additions & 0 deletions packages/bot/src/spotify/spotifyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,88 @@ export interface SpotifyRecommendationTrack {
duration_ms: number
}

export type SpotifyAudioFeatureConstraints = {
energy?: number
valence?: number
danceability?: number
}

export async function getSpotifyRecommendations(
accessToken: string,
seedTrackIds: string[],
limit = 10,
audioConstraints?: SpotifyAudioFeatureConstraints,
): Promise<SpotifyRecommendationTrack[]> {
if (seedTrackIds.length === 0) return []
try {
const params = new URLSearchParams({
seed_tracks: seedTrackIds.slice(0, 5).join(','),
limit: String(Math.min(limit, 100)),
})

if (audioConstraints) {
const TOLERANCE = 0.25
if (audioConstraints.energy !== undefined) {
params.set(
'min_energy',
String(
Math.max(
0,
audioConstraints.energy - TOLERANCE,
).toFixed(2),
),
)
params.set(
'max_energy',
String(
Math.min(
1,
audioConstraints.energy + TOLERANCE,
).toFixed(2),
),
)
}
if (audioConstraints.valence !== undefined) {
params.set(
'min_valence',
String(
Math.max(
0,
audioConstraints.valence - TOLERANCE,
).toFixed(2),
),
)
params.set(
'max_valence',
String(
Math.min(
1,
audioConstraints.valence + TOLERANCE,
).toFixed(2),
),
)
}
if (audioConstraints.danceability !== undefined) {
params.set(
'min_danceability',
String(
Math.max(
0,
audioConstraints.danceability - TOLERANCE,
).toFixed(2),
),
)
params.set(
'max_danceability',
String(
Math.min(
1,
audioConstraints.danceability + TOLERANCE,
).toFixed(2),
),
)
}
}
const res = await fetch(
`https://api.spotify.com/v1/recommendations?${params.toString()}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
Expand Down
31 changes: 21 additions & 10 deletions packages/bot/src/utils/music/queueManipulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3097,11 +3097,16 @@ describe('queueManipulation — multi-user VC blend', () => {

await replenishQueue(queue as unknown as GuildQueue)

expect(spotifyApiMock.getSpotifyRecommendations).toHaveBeenCalledWith(
'tok-abc',
['seedid123'],
15,
)
const recsCalls = spotifyApiMock.getSpotifyRecommendations.mock.calls
expect(recsCalls.length).toBeGreaterThan(0)
const [callToken, callIds, callLimit, callConstraints] = recsCalls[0]!
expect(callToken).toBe('tok-abc')
expect(callIds).toEqual(['seedid123'])
expect(callLimit).toBe(15)
Comment thread
LucasSantana-Dev marked this conversation as resolved.
expect(
callConstraints === undefined ||
typeof callConstraints === 'object',
).toBe(true)
})

it('skips Spotify recommendations when no access token is available', async () => {
Expand Down Expand Up @@ -3165,11 +3170,17 @@ describe('queueManipulation — multi-user VC blend', () => {
await replenishQueue(queue as unknown as GuildQueue)

expect(spotifyApiMock.searchSpotifyTrack).toHaveBeenCalled()
expect(spotifyApiMock.getSpotifyRecommendations).toHaveBeenCalledWith(
'tok-resolve',
['resolved-spotify-id'],
15,
)
const recsCalls2 = spotifyApiMock.getSpotifyRecommendations.mock.calls
expect(recsCalls2.length).toBeGreaterThan(0)
const [callToken2, callIds2, callLimit2, callConstraints2] =
recsCalls2[0]!
expect(callToken2).toBe('tok-resolve')
expect(callIds2).toEqual(['resolved-spotify-id'])
expect(callLimit2).toBe(15)
expect(
callConstraints2 === undefined ||
typeof callConstraints2 === 'object',
).toBe(true)
})

it('adds spotify recommendation results as scored candidates', async () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/bot/src/utils/music/queueManipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ async function _replenishQueue(
implicitDislikeKeys,
implicitLikeKeys,
sessionMood,
currentFeatures,
)
debugLog({
message: 'Autoplay: recommendation candidates',
Expand Down Expand Up @@ -769,6 +770,7 @@ async function collectSpotifyRecommendationCandidates(
implicitDislikeKeys: Set<string>,
implicitLikeKeys: Set<string>,
sessionMood: SessionMood | null,
currentFeatures: SpotifyAudioFeatures | null,
): Promise<void> {
if (!requestedBy) return
const token = await Promise.resolve(
Expand Down Expand Up @@ -798,7 +800,19 @@ async function collectSpotifyRecommendationCandidates(
}

if (seedIds.length === 0) return
const recs = await getSpotifyRecommendations(token, seedIds, 15)
const audioConstraints = currentFeatures
? {
energy: currentFeatures.energy,
valence: currentFeatures.valence,
danceability: currentFeatures.danceability,
}
: undefined
const recs = await getSpotifyRecommendations(
token,
seedIds,
15,
audioConstraints,
)
if (recs.length === 0) return

debugLog({
Expand Down Expand Up @@ -866,6 +880,7 @@ async function collectRecommendationCandidates(
implicitDislikeKeys: Set<string> = new Set(),
implicitLikeKeys: Set<string> = new Set(),
sessionMood: SessionMood | null = null,
currentFeatures: SpotifyAudioFeatures | null = null,
): Promise<Map<string, ScoredTrack>> {
const candidates = new Map<string, ScoredTrack>()

Expand All @@ -887,6 +902,7 @@ async function collectRecommendationCandidates(
implicitDislikeKeys,
implicitLikeKeys,
sessionMood,
currentFeatures,
)

for (const seed of seedTracks) {
Expand Down
Loading