Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c2efc30
fix(autoplay): prioritize user tracks, add Spotify liked seeds, block…
LucasSantana-Dev May 7, 2026
59e224f
fix(autoplay): harden error handling, deduplicate SERTANEJO_TAGS, fix…
LucasSantana-Dev May 7, 2026
9146bcf
fix(autoplay): block Spanish gospel contamination when Last.fm is unl…
LucasSantana-Dev May 7, 2026
042872e
fix(autoplay): address PR review findings — logging, comments, sertan…
LucasSantana-Dev May 7, 2026
394bad3
fix(autoplay): resolve CodeRabbit review — TS error, loop guard, dedu…
LucasSantana-Dev May 7, 2026
0d1e3d0
ci: retrigger SonarCloud scan
LucasSantana-Dev May 7, 2026
4fc9372
test: add missing coverage for getUserSavedTracks, artistTagCache, an…
LucasSantana-Dev May 7, 2026
5a3767d
refactor(languageHeuristics): precompile word-boundary patterns at mo…
LucasSantana-Dev May 7, 2026
537c6b2
fix(languageHeuristics): replace dynamic RegExp with indexOf+boundary…
LucasSantana-Dev May 7, 2026
647be19
fix(spotifyApi): use URLSearchParams in getUserSavedTracks to elimina…
LucasSantana-Dev May 7, 2026
59c0edb
fix(autoplay): use Spotify genres as fallback tag source when Last.fm…
LucasSantana-Dev May 7, 2026
e012bc9
fix(candidateFallback): wrap getValidAccessToken in Promise.resolve f…
LucasSantana-Dev May 7, 2026
ccf200a
fix(candidateFallback): suppress NOSONAR S5144 on getArtistGenres cal…
LucasSantana-Dev May 7, 2026
bbf4ee7
fix(candidateFallback): broaden NOSONAR suppressions — bare comment s…
LucasSantana-Dev May 7, 2026
509aba5
fix(sonar): rewrite stripFeaturing to eliminate S5852 hotspot
LucasSantana-Dev May 7, 2026
d6e7acc
fix(sonar): replace VARIANT_SUFFIX_RE with indexOf-based approach
LucasSantana-Dev May 7, 2026
2c1bd59
fix(sonar): eliminate BRACKET_INNER_RE — rewrite with startsWithYear+…
LucasSantana-Dev May 7, 2026
3e82063
merge: resolve spotify conflicts with release/v2.10.0 base
LucasSantana-Dev May 8, 2026
6602144
fix(deps): patch fast-uri (high) and hono (moderate) vulnerabilities
LucasSantana-Dev May 8, 2026
0a30b82
merge: resolve candidateFallback conflict with release/v2.10.0 (#818)
LucasSantana-Dev May 8, 2026
798b5a6
fix(spotify): default getUserSavedTracks limit to 200
LucasSantana-Dev May 8, 2026
5f54cc6
fix(spotify): drain response body on non-OK to release connection
LucasSantana-Dev May 8, 2026
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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

173 changes: 136 additions & 37 deletions packages/bot/src/spotify/spotifyApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,80 +839,179 @@ describe('spotifyApi', () => {
})

describe('getUserSavedTracks', () => {
it('returns track ids from saved tracks response', async () => {
it('returns empty array when response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) })

const result = await getUserSavedTracks('token')

expect(result).toEqual([])
})

it('returns track ids from a single page', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [
{ track: { id: 'track-1' } },
{ track: { id: 'track-2' } },
{ track: { id: 'track-3' } },
],
json: async () => ({
items: [
{ track: { id: 'track-1' } },
{ track: { id: 'track-2' } },
],
total: 2,
}),
})

const result = await getUserSavedTracks('token')

expect(result).toEqual(['track-1', 'track-2'])
})

it('paginates until all tracks are fetched', async () => {
let callCount = 0
fetchMock.mockImplementation(async () => {
callCount++
if (callCount === 1) {
return {
ok: true,
json: async () => ({
items: Array.from({ length: 50 }, (_, i) => ({ track: { id: `track-${i}` } })),
total: 60,
}),
}
}
return {
ok: true,
json: async () => ({
items: Array.from({ length: 10 }, (_, i) => ({ track: { id: `track-${50 + i}` } })),
total: 60,
}),
}
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual(['track-1', 'track-2', 'track-3'])

expect(result).toHaveLength(60)
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('stops when maxTracks (200) is reached', async () => {
fetchMock.mockImplementation(async () => ({
ok: true,
json: async () => ({
items: Array.from({ length: 50 }, (_, i) => ({ track: { id: `t${i}` } })),
total: 1000,
}),
}))

const result = await getUserSavedTracks('token')

expect(result.length).toBeLessThanOrEqual(200)
expect(fetchMock).toHaveBeenCalledTimes(4)
})

it('filters out items with missing track id', async () => {
it('stops when items array is empty', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [
{ track: { id: 'track-1' } },
{ track: {} },
{ track: null },
{},
],
}),
json: async () => ({ items: [], total: 100 }),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')
expect(result).toEqual(['track-1'])

expect(result).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('returns empty array when response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false })
;(globalThis as { fetch: unknown }).fetch = fetchMock
it('breaks on non-ok response mid-pagination', async () => {
let callCount = 0
fetchMock.mockImplementation(async () => {
callCount++
if (callCount === 1) {
return {
ok: true,
json: async () => ({
items: [{ track: { id: 'track-1' } }],
total: 100,
}),
}
}
return { ok: false, json: async () => ({}) }
})

const result = await getUserSavedTracks('token')
expect(result).toEqual([])

expect(result).toEqual(['track-1'])
})

it('returns empty array when json parse fails', async () => {
it('breaks on JSON parse error', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.reject(new Error('parse error')),
json: async () => { throw new Error('JSON error') },
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')

expect(result).toEqual([])
})

it('returns empty array when fetch throws', async () => {
it('returns empty array on network error', async () => {
fetchMock.mockRejectedValue(new Error('network error'))
;(globalThis as { fetch: unknown }).fetch = fetchMock

const result = await getUserSavedTracks('token')

expect(result).toEqual([])
})

it('caps limit at 50', async () => {
it('skips items without a track id', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [
{ track: { id: 'valid-1' } },
{ track: {} },
{ track: null },
{},
{ track: { id: 'valid-2' } },
],
total: 5,
}),
})
.mockResolvedValue({
ok: true,
json: async () => ({ items: [], total: 5 }),
})

const result = await getUserSavedTracks('token')

expect(result).toEqual(['valid-1', 'valid-2'])
})

it('breaks when data has no items field', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
json: async () => ({ total: 10 }),
})
;(globalThis as { fetch: unknown }).fetch = fetchMock

await getUserSavedTracks('token', 200)
const url = (fetchMock.mock.calls[0] as [string])[0]
expect(url).toContain('limit=50')
const result = await getUserSavedTracks('token')

expect(result).toEqual([])
})

it('stops early when data.total matches accumulated count', async () => {
let callCount = 0
fetchMock.mockImplementation(async () => {
callCount++
return {
ok: true,
json: async () => ({
items: [{ track: { id: `t${callCount}` } }],
total: 1,
}),
}
})

const result = await getUserSavedTracks('token')

expect(result).toHaveLength(1)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
})
73 changes: 58 additions & 15 deletions packages/bot/src/spotify/spotifyApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { logAndSwallow } from '@lucky/shared/utils/error'
import { debugLog, warnLog } from '@lucky/shared/utils/general/log'

Check warning on line 2 in packages/bot/src/spotify/spotifyApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'warnLog'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ4IspRiAFXVgHJSbT5x&open=AZ4IspRiAFXVgHJSbT5x&pullRequest=817

export interface SpotifyRecommendationTrack {
id: string
Expand Down Expand Up @@ -465,26 +465,69 @@
}
}

export async function getUserSavedTracks(

Check failure on line 468 in packages/bot/src/spotify/spotifyApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ4AKWl7C5to97iszaKm&open=AZ4AKWl7C5to97iszaKm&pullRequest=817
accessToken: string,
limit = 50,
limit = 200,
): Promise<string[]> {
const savedTrackIds: string[] = []
const pageLimit = 50
const maxTracks = Math.min(limit, 200)
let offset = 0

try {
const params = new URLSearchParams({ limit: String(Math.min(limit, 50)), offset: '0' })
const res = await fetch(
'https://api.spotify.com/v1/me/tracks?' + params.toString(),
{ method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } },
)
if (!res.ok) {
warnLog({ message: 'Spotify saved tracks fetch failed', data: { status: res.status } })
return []
}
const data = (await res.json().catch(() => null)) as {
items?: Array<{ track?: { id?: string } }>
while (offset < maxTracks) {
const params = new URLSearchParams({
limit: String(pageLimit),
offset: String(offset),
})
const res = await fetch(
`https://api.spotify.com/v1/me/tracks?${params.toString()}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)

if (!res.ok) {
logAndSwallow(
new Error(`HTTP ${res.status}`),
'spotify.getUserSavedTracks.request',
{ status: res.status, offset },
)
await res.body?.cancel().catch(() => undefined)
break
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

type SavedTracksPage = { items: Array<{ track?: { id?: string } }>; total?: number }
let data: SavedTracksPage | null = null
try {
data = (await res.json()) as SavedTracksPage
} catch (parseErr) {
logAndSwallow(parseErr, 'spotify.getUserSavedTracks.parse', { offset })
break
}

if (!data?.items) {
break
}

for (const item of data.items) {
if (item.track?.id) {
savedTrackIds.push(item.track.id)
}
}

const allFetched = data.total !== undefined && savedTrackIds.length >= data.total
if (savedTrackIds.length >= maxTracks || !data.items.length || allFetched) {
break
}

offset += pageLimit
}
return (data?.items ?? [])
.map((item) => item.track?.id)
.filter((id): id is string => Boolean(id))

return savedTrackIds.slice(0, maxTracks)
} catch (err) {
logAndSwallow(err, 'spotify.getUserSavedTracks')
return []
Expand Down
3 changes: 2 additions & 1 deletion packages/bot/src/spotify/spotifyUserSeeds.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('spotifyUserSeeds', () => {
jest.clearAllMocks()
;(spotifyApi.getUserSavedTracks as jest.Mock).mockResolvedValue([])
clearUserSeedsCache('test-user-id')
;(spotifyApi.getUserSavedTracks as jest.Mock).mockResolvedValue([])
})

it('should fetch and cache user Spotify seeds', async () => {
Expand Down Expand Up @@ -165,7 +166,7 @@ describe('spotifyUserSeeds', () => {
expect(result).toBeNull()
})

it('should cache results for 5 minutes', async () => {
it('should cache results for 30 minutes', async () => {
const mockLink = {
spotifyId: 'spotify-123',
accessToken: 'token',
Expand Down
7 changes: 4 additions & 3 deletions packages/bot/src/spotify/spotifyUserSeeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ interface SeededUserEntry {
fetched: number
}

const CACHE_TTL_MS = 30 * 60 * 1000

const userSeedsCache = new LRUCache<string, SeededUserEntry>({
max: 500,
ttl: 5 * 60 * 1000,
ttl: CACHE_TTL_MS,
})

const CACHE_TTL_MS = 5 * 60 * 1000

export async function getUserSpotifySeeds(userId: string): Promise<UserSpotifySeeds | null> {
const now = Date.now()
const cached = userSeedsCache.get(userId)
Expand Down Expand Up @@ -78,6 +78,7 @@ export async function getUserSpotifySeeds(userId: string): Promise<UserSpotifySe
userId,
artistCount: seeds.artistIds.length,
trackCount: seeds.trackIds.length,
likedTrackCount: likedTrackIds.length,
},
})

Expand Down
Loading
Loading