diff --git a/package-lock.json b/package-lock.json index d53c72857..d9c8ae3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15325,9 +15325,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "devOptional": true, "funding": [ { @@ -16385,9 +16385,9 @@ "license": "ISC" }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "devOptional": true, "license": "MIT", "engines": { diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts index 35221b390..a81fdb93b 100644 --- a/packages/bot/src/spotify/spotifyApi.spec.ts +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -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) }) }) }) diff --git a/packages/bot/src/spotify/spotifyApi.ts b/packages/bot/src/spotify/spotifyApi.ts index 4583205ef..29869ed8d 100644 --- a/packages/bot/src/spotify/spotifyApi.ts +++ b/packages/bot/src/spotify/spotifyApi.ts @@ -467,24 +467,67 @@ export async function getUserTopArtistsAndTracks( export async function getUserSavedTracks( accessToken: string, - limit = 50, + limit = 200, ): Promise { + 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 + } + + 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 [] diff --git a/packages/bot/src/spotify/spotifyUserSeeds.spec.ts b/packages/bot/src/spotify/spotifyUserSeeds.spec.ts index 89b4c8d6f..c7338cfa3 100644 --- a/packages/bot/src/spotify/spotifyUserSeeds.spec.ts +++ b/packages/bot/src/spotify/spotifyUserSeeds.spec.ts @@ -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 () => { @@ -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', diff --git a/packages/bot/src/spotify/spotifyUserSeeds.ts b/packages/bot/src/spotify/spotifyUserSeeds.ts index 46ffe70fd..8ad65e9e7 100644 --- a/packages/bot/src/spotify/spotifyUserSeeds.ts +++ b/packages/bot/src/spotify/spotifyUserSeeds.ts @@ -15,13 +15,13 @@ interface SeededUserEntry { fetched: number } +const CACHE_TTL_MS = 30 * 60 * 1000 + const userSeedsCache = new LRUCache({ max: 500, - ttl: 5 * 60 * 1000, + ttl: CACHE_TTL_MS, }) -const CACHE_TTL_MS = 5 * 60 * 1000 - export async function getUserSpotifySeeds(userId: string): Promise { const now = Date.now() const cached = userSeedsCache.get(userId) @@ -78,6 +78,7 @@ export async function getUserSpotifySeeds(userId: string): Promise ({ + getArtistTopTags: jest.fn(), +})) + +describe('hasGenreTag', () => { + it('returns false when tags array is empty', () => { + expect(hasGenreTag([], ['rock', 'pop'])).toBe(false) + }) + + it('returns true when a tag matches a genre variant', () => { + expect(hasGenreTag(['sertanejo', 'country'], ['sertanejo'])).toBe(true) + }) + + it('returns false when no tags match', () => { + expect(hasGenreTag(['rock', 'indie'], ['sertanejo', 'forró'])).toBe(false) + }) + + it('is case-insensitive', () => { + expect(hasGenreTag(['Sertanejo'], ['sertanejo'])).toBe(true) + expect(hasGenreTag(['sertanejo'], ['SERTANEJO'])).toBe(true) + }) + + it('trims whitespace from tags and variants', () => { + expect(hasGenreTag([' sertanejo '], ['sertanejo'])).toBe(true) + }) + + it('returns false when variants array is empty', () => { + expect(hasGenreTag(['rock'], [])).toBe(false) + }) +}) + +describe('createArtistTagFetcher', () => { + it('returns an empty array for undefined artist', async () => { + const fetcher = createArtistTagFetcher() + const result = await fetcher(undefined) + expect(result).toEqual([]) + }) + + it('returns an empty array for empty string artist', async () => { + const fetcher = createArtistTagFetcher() + const result = await fetcher('') + expect(result).toEqual([]) + }) + + it('returns an empty array for whitespace-only artist', async () => { + const fetcher = createArtistTagFetcher() + const result = await fetcher(' ') + expect(result).toEqual([]) + }) + + it('fetches and caches tags for a known artist', async () => { + const { getArtistTopTags } = require('../../../lastfm') + ;(getArtistTopTags as jest.Mock).mockResolvedValue(['rock', 'indie']) + + const fetcher = createArtistTagFetcher() + const result1 = await fetcher('Radiohead') + const result2 = await fetcher('radiohead') + + expect(result1).toEqual(['rock', 'indie']) + expect(result2).toEqual(['rock', 'indie']) + expect((getArtistTopTags as jest.Mock).mock.calls.length).toBe(1) + }) + + it('returns empty array when Last.fm fetch fails', async () => { + const { getArtistTopTags } = require('../../../lastfm') + ;(getArtistTopTags as jest.Mock).mockRejectedValue(new Error('API down')) + + const fetcher = createArtistTagFetcher() + const result = await fetcher('Some Artist') + + expect(result).toEqual([]) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/artistTagCache.ts b/packages/bot/src/utils/music/autoplay/artistTagCache.ts index cad6c5da9..8ea3491c8 100644 --- a/packages/bot/src/utils/music/autoplay/artistTagCache.ts +++ b/packages/bot/src/utils/music/autoplay/artistTagCache.ts @@ -2,6 +2,12 @@ import { getArtistTopTags } from '../../../lastfm' export type ArtistTagFetcher = (artist: string | undefined) => Promise +export function hasGenreTag(tags: string[], genreVariants: string[]): boolean { + if (tags.length === 0) return false + const normalized = tags.map((t) => t.toLowerCase().trim()) + return genreVariants.some((g) => normalized.includes(g.toLowerCase().trim())) +} + /** * Per-pass de-duplicating Last.fm artist-tag fetcher. The replenisher creates * one of these per autoplay pass and threads it through every candidate diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts index a38d82755..c2a73b458 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts @@ -531,5 +531,138 @@ describe('candidateCollector', () => { }), ) }) + + it('blocks sertanejo candidates when blockSertanejo=true and tags match', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue(['sertanejo']) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, // blockSertanejo + ) + + expect(result.size).toBe(0) + }) + + it('allows sertanejo candidates when blockSertanejo=false', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue(['sertanejo']) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + false, // blockSertanejo + ) + + expect(result.size).toBeGreaterThan(0) + }) + + it('does not block sertanejo when tags are empty (fail-open when Last.fm unavailable)', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const serTanejoTrack = createTrack({ title: 'Saudade do Nordeste', author: 'Jorge e Mateus' }) + searchSeedCandidatesMock.mockResolvedValue([serTanejoTrack]) + + const getArtistTags = jest.fn().mockResolvedValue([]) // no tags returned + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, // blockSertanejo is true but tags are empty — must not block + ) + + expect(result.size).toBeGreaterThan(0) + }) + + it('falls back to empty tags when getArtistTags rejects', async () => { + collectSpotifyRecommendationCandidatesMock.mockResolvedValue(undefined) + const track = createTrack({ title: 'Some Song', author: 'Some Artist' }) + searchSeedCandidatesMock.mockResolvedValue([track]) + + const getArtistTags = jest.fn().mockRejectedValue(new Error('Last.fm down')) + + const result = await collectRecommendationCandidates( + createGuildQueue(), + [createTrack()], + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + createTrack(), + new Set(), + 0, + 'similar', + new Map(), + new Set(), + new Set(), + null, + null, + { getArtistTags }, + true, + ) + + // fail-open: track should not be blocked when tag fetch errors + expect(result.size).toBeGreaterThan(0) + }) }) }) diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.ts index 7e0fdb089..53555c603 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.ts @@ -12,10 +12,18 @@ import { normalizeTrackKey, } from '../queueManipulation' import { isDuplicateCandidate } from './diversitySelector' -import { createArtistTagFetcher, type ArtistTagFetcher } from './artistTagCache' +import { createArtistTagFetcher, hasGenreTag, type ArtistTagFetcher } from './artistTagCache' import type { ScoredTrack } from './diversitySelector' export type { ScoredTrack } +export const SERTANEJO_TAGS = [ + 'sertanejo', + 'sertanejo universitário', + 'sertanejo pop', + 'música sertaneja', + 'forró', +] + /** * Include a candidate in the pool if it hasn't been played recently * and isn't in the disliked set. @@ -103,6 +111,7 @@ export async function collectRecommendationCandidates( currentTrackTags?: string[] sessionGenreFamilies?: Set } = {}, + blockSertanejo = false, ): Promise> { const candidates = new Map() const getArtistTags = genreContext.getArtistTags ?? createArtistTagFetcher() @@ -156,7 +165,13 @@ export async function collectRecommendationCandidates( if (dislikedWeight !== undefined && dislikedWeight > 0.5) { continue } - const tags = await getArtistTags(candidate.author) + const tags = await getArtistTags(candidate.author).catch((err: unknown) => { + debugLog({ message: 'candidateCollector: getArtistTags failed', data: { author: candidate.author, err } }) + return [] as string[] + }) + if (blockSertanejo && tags.length > 0 && hasGenreTag(tags, SERTANEJO_TAGS)) { + continue + } const rec = calculateRecommendationScore({ candidate, currentTrack, diff --git a/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts b/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts index 53d8945b4..9faaa5173 100644 --- a/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts +++ b/packages/bot/src/utils/music/autoplay/diversitySelector.spec.ts @@ -247,6 +247,54 @@ describe('diversitySelector', () => { ), ).toBe(false) }) + + test('detects remastered version as duplicate of base title in excludedKeys', () => { + // "bohemian rhapsody" is excluded; "bohemian rhapsody - remastered" should match + const excludedUrls = new Set() + const excludedKeys = new Set(['bohemian rhapsody']) + + const track: Partial = { + url: 'https://open.spotify.com/track/xyz', + title: 'Bohemian Rhapsody - Remastered', + author: 'Queen', + } + + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) + + test('detects live version as duplicate of base title', () => { + const excludedUrls = new Set() + const excludedKeys = new Set(['hotel california']) + + const track: Partial = { + url: 'https://open.spotify.com/track/abc', + title: 'Hotel California - Live', + author: 'Eagles', + } + + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) + + test('does not strip mid-title variant words — only suffix', () => { + // "live" in the middle of the title should not be stripped + const excludedUrls = new Set() + const excludedKeys = new Set(['live and let die']) + + const track: Partial = { + url: 'https://open.spotify.com/track/def', + title: 'Live and Let Die - Remastered', + author: 'Wings', + } + + // "live and let die" after stripping " - Remastered" → still matches + expect( + isDuplicateCandidate(track as Track, excludedUrls, excludedKeys), + ).toBe(true) + }) }) describe('selectDiverseCandidates', () => { diff --git a/packages/bot/src/utils/music/autoplay/diversitySelector.ts b/packages/bot/src/utils/music/autoplay/diversitySelector.ts index d95ac5cd6..d533d0e2a 100644 --- a/packages/bot/src/utils/music/autoplay/diversitySelector.ts +++ b/packages/bot/src/utils/music/autoplay/diversitySelector.ts @@ -13,7 +13,7 @@ interface ScoredTrack { const MAX_TRACKS_PER_ARTIST = 2 const MAX_TRACKS_PER_SOURCE = 3 -const FUZZY_TITLE_THRESHOLD = 0.82 +const FUZZY_TITLE_THRESHOLD = 0.75 function randomJitter(max: number): number { return Math.random() * max // NOSONAR - non-cryptographic jitter for diversity selection @@ -55,9 +55,72 @@ function normalizeTitleOnly(title?: string): string { } function stripFeaturing(author: string): string { - return author - .split(/\s+(?:feat|ft)\.?(?:\s|$)/i)[0] // NOSONAR - bounded alternation, no catastrophic backtracking - .replace(/\s*\([^)]*feat[^)]*\)\s*/gi, '') // NOSONAR - [^)]* bounded by paren close, no backtracking + const lower = author.toLowerCase() + let cut = author.length + for (const marker of [' feat ', ' feat.', ' ft ', ' ft.']) { + const idx = lower.indexOf(marker) + if (idx >= 0 && idx < cut) cut = idx + } + let result = author.slice(0, cut) + // Remove parenthetical groups containing "feat" or "ft" + let i = 0 + while (i < result.length) { + const open = result.indexOf('(', i) + if (open === -1) break + const close = result.indexOf(')', open + 1) + if (close === -1) break + const inner = result.slice(open + 1, close).toLowerCase() + if (inner.includes('feat') || inner.startsWith('ft ') || inner.startsWith('ft.')) { + result = (result.slice(0, open) + result.slice(close + 1)).trim() + i = 0 + } else { + i = close + 1 + } + } + return result.trim() +} + +const VARIANT_KEYWORDS = [ + 'remastered', 'remaster', 'remixed', 'remix', + 'radio edit', 'extended mix', 'club mix', 'vip mix', + 'edit', 'version', 'acoustic', 'live', 'cover', +] + +function startsWithYear(s: string): boolean { + return s.length >= 5 && s[4] === ' ' && + s[0] >= '0' && s[0] <= '9' && + s[1] >= '0' && s[1] <= '9' && + s[2] >= '0' && s[2] <= '9' && + s[3] >= '0' && s[3] <= '9' +} + +function stripVariantSuffix(title: string): string { + const lower = title.toLowerCase() + const trimmedLower = lower.trimEnd() + + // Strip parenthetical variant suffix at end: (Remastered) or [2015 Live] + for (const [openChar, closeChar] of [['(', ')'], ['[', ']']] as [string, string][]) { + if (trimmedLower[trimmedLower.length - 1] !== closeChar) continue + const lastOpen = trimmedLower.lastIndexOf(openChar) + if (lastOpen < 0) continue + let inner = trimmedLower.slice(lastOpen + 1, trimmedLower.length - 1).trim() + if (startsWithYear(inner)) inner = inner.slice(5) + if (VARIANT_KEYWORDS.some((v) => inner === v || inner.startsWith(v + ' '))) { + return title.slice(0, lastOpen).trimEnd() + } + } + + // Strip dash-prefixed variant suffix at end: - Remastered or – 2015 Live + for (const sep of [' - ', ' – ']) { + const idx = lower.lastIndexOf(sep) + if (idx < 0) continue + let rest = lower.slice(idx + sep.length).trimEnd() + if (startsWithYear(rest)) rest = rest.slice(5) + if (VARIANT_KEYWORDS.some((v) => rest === v || rest.startsWith(v + ' '))) { + return title.slice(0, idx).trimEnd() + } + } + return title } function getAllHistoryTracks(queue: GuildQueue): Track[] { @@ -116,6 +179,8 @@ export function buildExcludedKeys( for (const t of allTracks) { keys.push(normalizeTrackKey(t.title, t.author)) keys.push(normalizeTitleOnly(t.title)) + const variantStripped = stripVariantSuffix(t.title ?? '') + if (variantStripped) keys.push(normalizeTitleOnly(variantStripped)) const core = extractSongCore(t.title ?? '', t.author) if (core) keys.push(normalizeText(core)) } @@ -139,6 +204,8 @@ export function isDuplicateCandidate( if (core !== null && excludedKeys.has(normalizeText(core))) return true const candidateTitle = normalizeTitleOnly(track.title) + const candidateTitleStripped = normalizeTitleOnly(stripVariantSuffix(track.title ?? '')) + if (candidateTitleStripped && excludedKeys.has(candidateTitleStripped)) return true if (candidateTitle.length >= 5) { for (const key of excludedKeys) { if (key.includes('::') || key.length < 5) continue diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts index 27a76e283..f2e08802f 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeder.spec.ts @@ -26,6 +26,7 @@ jest.mock('@lucky/shared/services', () => ({ })) jest.mock('./lastFmSeeds', () => ({ + LASTFM_SEED_COUNT: 15, consumeLastFmSeedSlice: (...args: unknown[]) => consumeLastFmSeedSliceMock(...args), consumeBlendedSeedSlice: (...args: unknown[]) => consumeBlendedSeedSliceMock(...args), isLovedSeed: (...args: unknown[]) => isLovedSeedMock(...args), @@ -179,7 +180,7 @@ describe('collectLastFmCandidates', () => { new Set(), new Set(), createTrack(), new Set(), candidates, ) - expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 3) + expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 15) }) it('uses blended seed when multiple VC members are linked', async () => { @@ -389,7 +390,7 @@ describe('collectLastFmCandidates', () => { new Set(), new Set(), createTrack(), new Set(), candidates, ) - expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 3) + expect(consumeLastFmSeedSliceMock).toHaveBeenCalledWith('user-1', 15) expect(consumeBlendedSeedSliceMock).not.toHaveBeenCalled() }) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts index 0d18fd49e..fe2283780 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts @@ -5,6 +5,7 @@ import { consumeLastFmSeedSlice, consumeBlendedSeedSlice, isLovedSeed, + LASTFM_SEED_COUNT, } from './lastFmSeeds' import { getSimilarTracks, getTagTopTracks } from '../../../lastfm' import { createArtistTagFetcher, type ArtistTagFetcher } from './artistTagCache' @@ -22,10 +23,9 @@ import { import type { QueueMetadata } from '../../../types/QueueMetadata' import type { ScoredTrack } from './diversitySelector'; -const LASTFM_SEED_COUNT = 3 const LASTFM_SCORE_BOOST = 0.20 const LOVED_SEED_EXTRA_BOOST = 0.10 -const MAX_SIMILAR_LOOKUPS = 5 +const MAX_SIMILAR_LOOKUPS = 15 const SEARCH_RESULTS_LIMIT = 8 const MAX_AUTOPLAY_DURATION_MS = 10 * 60 * 1000 const AUTOPLAY_BUFFER_SIZE = 8 @@ -98,6 +98,7 @@ export async function collectLastFmCandidates( genreContext.sessionGenreFamilies ?? new Set() for (const seed of seedSlice) { + if (candidates.size >= AUTOPLAY_BUFFER_SIZE) break const lovedBoost = isLovedSeed(requestedBy.id, seed.artist, seed.title) ? LOVED_SEED_EXTRA_BOOST : 0 diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts index bc5a12cc3..4f6708fae 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts @@ -201,23 +201,24 @@ describe('getLastFmSeedSlice', () => { it('stops at pool length without wraparound within slice', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - ]) + // Pool of 20 so advancing by LASTFM_SEED_COUNT=15 leaves 5 items (indices 15–19) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-wrap') advanceLastFmSeedOffset('user-wrap') - const slice = getLastFmSeedSlice('user-wrap', 3) + const slice = getLastFmSeedSlice('user-wrap', 8) - expect(slice).toHaveLength(3) - expect(slice[0]).toEqual({ artist: 'A2', title: 'S2' }) - expect(slice[1]).toEqual({ artist: 'A3', title: 'S3' }) - expect(slice[2]).toEqual({ artist: 'A4', title: 'S4' }) + expect(slice).toHaveLength(5) + expect(slice[0]).toEqual({ artist: 'A16', title: 'S16' }) + expect(slice[4]).toEqual({ artist: 'A20', title: 'S20' }) }) it('returns at most min(count, tracks.length) items without wraparound', async () => { @@ -238,13 +239,13 @@ describe('getLastFmSeedSlice', () => { it('uses default LASTFM_SEED_COUNT when count not specified', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - ]) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-default-count') @@ -267,14 +268,14 @@ describe('advanceLastFmSeedOffset', () => { it('increments offset using modulo wrap-around', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - { artist: 'A6', title: 'S6', playCount: 6 }, - ]) + // Pool of 20 so (0+15)%20 = 15, no wrap + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-advance') @@ -338,14 +339,14 @@ describe('getLastFmCacheOffset', () => { it('tracks offset progression through advances', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - { artist: 'A6', title: 'S6', playCount: 6 }, - ]) + // Pool of 20 so (0+15)%20 = 15, no wrap + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) await getLastFmSeedTracks('user-track-offset') @@ -433,13 +434,13 @@ describe('consumeLastFmSeedSlice', () => { it('uses default LASTFM_SEED_COUNT when count not specified', async () => { getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' }) - getTopTracksMock.mockResolvedValue([ - { artist: 'A1', title: 'S1', playCount: 1 }, - { artist: 'A2', title: 'S2', playCount: 2 }, - { artist: 'A3', title: 'S3', playCount: 3 }, - { artist: 'A4', title: 'S4', playCount: 4 }, - { artist: 'A5', title: 'S5', playCount: 5 }, - ]) + getTopTracksMock.mockResolvedValue( + Array.from({ length: 20 }, (_, i) => ({ + artist: `A${i + 1}`, + title: `S${i + 1}`, + playCount: i + 1, + })), + ) const slice = await consumeLastFmSeedSlice('user-default-consume') diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts index 810015116..25138a399 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts @@ -11,7 +11,7 @@ import { cleanTitle } from '../searchQueryCleaner' const CACHE_TTL_MS = 15 * 60 * 1000 const TOP_TRACKS_LIMIT = 50 const RECENT_TRACKS_LIMIT = 30 -export const LASTFM_SEED_COUNT = 5 +export const LASTFM_SEED_COUNT = 15 type CacheEntry = { tracks: { artist: string; title: string }[] diff --git a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts index 03503e74b..f783adc1f 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts @@ -80,10 +80,16 @@ function createTrack(overrides: Partial = {}): Track { } as Track } +function createTracksMap(entries: [string, Track][] = []): Map & { toArray: () => Track[] } { + const map = new Map(entries) as Map & { toArray: () => Track[] } + map.toArray = () => [...map.values()] + return map +} + function createGuildQueue(overrides: Partial = {}): GuildQueue { return { guild: { id: 'guildid' }, - tracks: new Map(), + tracks: createTracksMap(), currentTrack: createTrack(), metadata: {}, history: { tracks: { toArray: () => [] } }, @@ -193,11 +199,12 @@ describe('replenishQueue', () => { it('should skip replenish if queue is full (>= AUTOPLAY_BUFFER_SIZE)', async () => { const queue = createGuildQueue() - const tracks = new Map() + const entries: [string, Track][] = [] for (let i = 0; i < 10; i++) { - tracks.set(`track${i}`, createTrack({ id: `track${i}` })) + const track = createTrack({ id: `track${i}`, metadata: { isAutoplay: true } as Record }) + entries.push([`track${i}`, track]) } - queue.tracks = tracks + queue.tracks = createTracksMap(entries) const { collectRecommendationCandidates } = require('./candidateCollector') @@ -209,6 +216,25 @@ describe('replenishQueue', () => { ).not.toHaveBeenCalled() }) + it('should replenish when queue has user-added tracks but autoplay count is below buffer', async () => { + const queue = createGuildQueue() + const entries: [string, Track][] = [] + for (let i = 0; i < 8; i++) { + const track = createTrack({ id: `user${i}`, metadata: undefined }) + entries.push([`user${i}`, track]) + } + const autoTrack = createTrack({ id: 'auto0', metadata: { isAutoplay: true } as Record }) + entries.push(['auto0', autoTrack]) + queue.tracks = createTracksMap(entries) + + const { collectRecommendationCandidates } = + require('./candidateCollector') + + await replenishQueue(queue) + + expect(collectRecommendationCandidates).toHaveBeenCalled() + }) + it('should handle errors gracefully without throwing', async () => { const queue = createGuildQueue() const { collectRecommendationCandidates } = diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index 60658a52e..e0ffbf6ed 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -14,9 +14,11 @@ import { import { detectSessionMood, type SessionMood } from './sessionMood' import { collectRecommendationCandidates, + SERTANEJO_TAGS, } from './candidateCollector' import { createArtistTagFetcher, + hasGenreTag, type ArtistTagFetcher, } from './artistTagCache' import { getGenreFamilies } from './candidateScorer' @@ -100,7 +102,10 @@ async function _replenishQueue( const bufferSize = (await premiumService.isPremium(queue.guild.id)) ? AUTOPLAY_BUFFER_SIZE_PREMIUM : AUTOPLAY_BUFFER_SIZE - const missingTracks = bufferSize - queue.tracks.size + const autoplayInQueue = [...queue.tracks.toArray()].filter( + (t) => (t.metadata as { isAutoplay?: boolean } | undefined)?.isAutoplay === true, + ).length + const missingTracks = bufferSize - autoplayInQueue if (missingTracks <= 0) return const allHistoryTracks = getAllHistoryTracks(queue) @@ -233,6 +238,13 @@ async function _replenishQueue( historyTracks, getArtistTags, ) + const seedIsSertanejo = currentTrackTags.length > 0 + ? hasGenreTag(currentTrackTags, SERTANEJO_TAGS) + : false + // Block sertanejo candidates unless the seed itself is sertanejo — fail-open + // when tags are absent (Last.fm unlinked) to avoid over-filtering. + const blockSertanejo = !seedIsSertanejo + const candidateGenreContext = { getArtistTags, currentTrackTags, @@ -244,6 +256,7 @@ async function _replenishQueue( guildId, currentTrackTagCount: currentTrackTags.length, sessionGenreFamilies: Array.from(sessionGenreFamilies), + blockSertanejo, }, }) const candidates = await collectRecommendationCandidates( @@ -266,6 +279,7 @@ async function _replenishQueue( sessionMood, currentFeatures, candidateGenreContext, + blockSertanejo, ) sourcesCounts.recommendation = candidates.size candidatePoolSize = candidates.size diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts index 6adc89168..295975d37 100644 --- a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts @@ -81,9 +81,13 @@ export async function collectSpotifyRecommendationCandidates( getUserSpotifySeeds(requestedBy.id), ).catch(() => null) - const seedIds = seedTracks + const likedSeedIds = userSpotifySeeds?.likedTrackIds?.slice(0, 3) ?? [] + const queueSeedIds = seedTracks .map(extractSpotifyTrackId) .filter((id): id is string => id !== null) + // Liked tracks take priority; fill remaining slots from queue history (max 5 total per Spotify API limit) + const seedIds = [...likedSeedIds, ...queueSeedIds] + .filter((id, i, arr) => arr.indexOf(id) === i) .slice(0, 5) if (seedIds.length === 0) { diff --git a/packages/bot/src/utils/music/candidateFallback.spec.ts b/packages/bot/src/utils/music/candidateFallback.spec.ts index 1b50a6578..35ed8ffb5 100644 --- a/packages/bot/src/utils/music/candidateFallback.spec.ts +++ b/packages/bot/src/utils/music/candidateFallback.spec.ts @@ -16,6 +16,12 @@ const calculateGenreFamilyPenaltyMock = jest.fn() const artistTagFetcherMock = jest.fn() const createArtistTagFetcherMock = jest.fn() +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + warnLog: jest.fn(), +})) + jest.mock('discord-player', () => ({ QueryType: { SPOTIFY_SEARCH: 'spotify_search', AUTO: 'auto' }, })) @@ -445,4 +451,185 @@ describe('collectBroadFallbackCandidates', () => { expect(upsertScoredCandidateMock).not.toHaveBeenCalled() }) + + it('fetches artist tags via genreContext.getArtistTags and passes them to scorer', async () => { + const foundTrack = { title: 'Eres Fiel', author: 'Marcos Witt', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue(['latin christian', 'spanish gospel']) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock, currentTrackTags: ['rock'], sessionGenreFamilies: new Set(['rock_metal']) }, + ) + + expect(getArtistTagsMock).toHaveBeenCalledWith('Marcos Witt') + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ + candidateTags: ['latin christian', 'spanish gospel'], + currentTrackTags: ['rock'], + sessionGenreFamilies: expect.any(Set), + }), + }), + ) + }) + + it('falls back to empty tags when getArtistTags rejects', async () => { + const foundTrack = { title: 'Song', author: 'Artist', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockRejectedValue(new Error('lastfm down')) + const candidates = new Map() + + await expect( + collectBroadFallbackCandidates( + queue, + currentTrack, + null, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ), + ).resolves.toBeUndefined() + + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ candidateTags: [] }), + }), + ) + }) + + it('uses Spotify genres as fallback tags when Last.fm returns empty', async () => { + const foundTrack = { title: 'Hallelujah', author: 'Felipe Dutra', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Coldplay', title: 'Yellow', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue([]) + getValidAccessTokenMock.mockResolvedValue('spotify-token') + getArtistGenresMock.mockResolvedValue(['latin gospel', 'latin christian']) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).toHaveBeenCalledWith('spotify-token', 'Felipe Dutra') + expect(calculateRecommendationScoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + genreContext: expect.objectContaining({ + candidateTags: ['latin gospel', 'latin christian'], + }), + }), + ) + }) + + it('does not fetch Spotify genres when Last.fm tags are already populated', async () => { + const foundTrack = { title: 'Song', author: 'Artist', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue(['rock', 'indie']) + getValidAccessTokenMock.mockResolvedValue('spotify-token') + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).not.toHaveBeenCalled() + }) + + it('does not fetch Spotify genres when no user token is available', async () => { + const foundTrack = { title: 'Hallelujah', author: 'Felipe Dutra', url: 'u2', durationMS: 180_000 } + const searchMock = jest.fn().mockResolvedValue({ tracks: [foundTrack] }) + const queue = { player: { search: searchMock } } as never + const currentTrack = { author: 'Artist', title: 'Song', url: 'u1', durationMS: 200_000 } as never + const getArtistTagsMock = jest.fn().mockResolvedValue([]) + getValidAccessTokenMock.mockResolvedValue(null) + const candidates = new Map() + + await collectBroadFallbackCandidates( + queue, + currentTrack, + { id: 'user1' } as never, + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + new Set(), + candidates, + 'similar', + new Map(), + new Set(), + new Set(), + null, + { getArtistTags: getArtistTagsMock }, + ) + + expect(getArtistGenresMock).not.toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/utils/music/candidateFallback.ts b/packages/bot/src/utils/music/candidateFallback.ts index 261d42cd1..c66575076 100644 --- a/packages/bot/src/utils/music/candidateFallback.ts +++ b/packages/bot/src/utils/music/candidateFallback.ts @@ -1,5 +1,6 @@ import { QueryType, type Track, type GuildQueue } from 'discord-player' import type { User } from 'discord.js' +import { debugLog } from '@lucky/shared/utils' import { getBatchAudioFeatures, getArtistGenres, type SpotifyAudioFeatures } from '../../spotify/spotifyApi' import { spotifyLinkService } from '@lucky/shared/services' import { getTagTopTracks } from '../../lastfm' @@ -110,6 +111,14 @@ export async function collectBroadFallbackCandidates( `${currentTrack.author} popular`, ].filter(Boolean) + // Obtain Spotify token once for the whole fallback pass — used to enrich + // genre tags when Last.fm is not linked (getArtistTags returns []). + // Promise.resolve() guards against stubs that return undefined rather than + // a Promise (matches the pattern used throughout replenisher.ts). + const spotifyToken = requestedBy?.id // NOSONAR — userId is an internal Discord snowflake, not user-controlled URL data + ? await Promise.resolve(spotifyLinkService.getValidAccessToken(requestedBy.id)).catch(() => null) + : null + for (const query of fallbackQueries) { try { const result = await queue.player.search(query, { @@ -132,7 +141,17 @@ export async function collectBroadFallbackCandidates( const dislikedWeight = dislikedWeights.get(key) if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue - const tags = await getArtistTags(track.author) + let candidateTags = await getArtistTags(track.author).catch((err: unknown) => { + debugLog({ message: 'candidateFallback: getArtistTags failed', data: { author: track.author, err } }) + return [] as string[] + }) + // When Last.fm returns nothing (not linked or artist unknown), + // use Spotify's genre data so the cross-locale Spanish veto in + // candidateScorer can still fire for Spanish gospel artists + // that have non-Spanish-looking artist names / titles. + if (candidateTags.length === 0 && spotifyToken) { + candidateTags = await getArtistGenres(spotifyToken, track.author).catch(() => []) // NOSONAR — track.author used as URLSearchParams search query inside getArtistGenres; no raw URL interpolation + } const rec = calculateRecommendationScore({ candidate: track, currentTrack, @@ -147,7 +166,7 @@ export async function collectBroadFallbackCandidates( dislikedWeights, sessionMood, genreContext: { - candidateTags: tags, + candidateTags, currentTrackTags, sessionGenreFamilies, }, @@ -254,7 +273,10 @@ export async function enrichWithAudioFeatures( const candidateGenres = await getArtistGenres( token, track.track.author, - ).catch(() => []) + ).catch((err: unknown) => { + debugLog({ message: 'candidateFallback: getArtistGenres failed', data: { author: track.track.author, err } }) + return [] + }) const genrePenalty = calculateGenreFamilyPenalty( currentGenres, candidateGenres, diff --git a/packages/bot/src/utils/music/languageHeuristics.spec.ts b/packages/bot/src/utils/music/languageHeuristics.spec.ts index bf0bf1b85..875ca197e 100644 --- a/packages/bot/src/utils/music/languageHeuristics.spec.ts +++ b/packages/bot/src/utils/music/languageHeuristics.spec.ts @@ -74,6 +74,29 @@ describe('languageHeuristics', () => { ).toBe(false) }) + it('should detect Spanish worship titles without ñ/¿/¡', () => { + // These slip through when Last.fm tags are absent — common gospel titles + expect(detectSpanishMarkers('Eres Fiel', undefined)).toBe(true) + expect(detectSpanishMarkers('Fuego de Tu Presencia', undefined)).toBe(true) + expect(detectSpanishMarkers('Los Cielos Cuentan', undefined)).toBe(true) + expect(detectSpanishMarkers('Tiempo de Alabanza', undefined)).toBe(true) + expect(detectSpanishMarkers('Tu Gracia', undefined)).toBe(true) + expect(detectSpanishMarkers('Gracias a Dios', undefined)).toBe(true) + expect(detectSpanishMarkers('Nuevo Corazon', undefined)).toBe(true) + expect(detectSpanishMarkers('Pueblo de Dios', undefined)).toBe(true) + expect(detectSpanishMarkers('Llena Mi Copa', undefined)).toBe(true) + expect(detectSpanishMarkers('Una Noche Mas', undefined)).toBe(true) + }) + + it('should not flag English or Portuguese tracks on new tokens', () => { + expect(detectSpanishMarkers('Amazing Grace', undefined)).toBe(false) + expect(detectSpanishMarkers('Hillsong United', undefined)).toBe(false) + // Portuguese "presença" uses ç — won't match "presencia" + expect(detectSpanishMarkers('Presença de Deus', undefined)).toBe(false) + // Portuguese "graça" uses ç — won't match "gracia" + expect(detectSpanishMarkers('Graça e Paz', undefined)).toBe(false) + }) + it('should let Portuguese veto win when both signals appear', () => { // Brazilian artist Anitta has English/Spanish/Portuguese mixes. // "El Que Espera Por Mim" — has Spanish stopwords ("el") but diff --git a/packages/bot/src/utils/music/languageHeuristics.ts b/packages/bot/src/utils/music/languageHeuristics.ts index 50e992431..c67dd4cdb 100644 --- a/packages/bot/src/utils/music/languageHeuristics.ts +++ b/packages/bot/src/utils/music/languageHeuristics.ts @@ -66,6 +66,21 @@ const SPANISH_DISTINCT_TOKENS = [ 'niño', 'niña', 'niños', 'niñas', 'tu gloria', 'tus alabanzas', + // Spanish worship music words whose Portuguese equivalents are spelled + // differently — catches gospel/worship titles that contain no ñ/¿/¡. + 'fuego', // Portuguese: fogo + 'cielo', 'cielos',// Portuguese: céu/céus + 'presencia', // Portuguese: presença (always has ç) + 'alabanza', 'alabanzas', // Portuguese: louvor/louvores + 'alabar', // Portuguese: louvar + 'gracia', 'gracias', // Portuguese: graça / obrigado (different roots entirely) + 'eres', // Portuguese: és + 'nuevo', 'nueva', // Portuguese: novo/nova (different vowel pattern) + 'pueblo', // Portuguese: povo + 'tierra', // Portuguese: terra ("ie" diphthong is Spanish-specific) + 'llena', 'llenas', 'lleno', // Portuguese: cheia/cheio (double-l never in Portuguese) + 'noche', // Portuguese: noite + 'hoy', // Portuguese: hoje ] // Words distinctive to Portuguese (Brazilian or European). Hits here veto @@ -106,14 +121,28 @@ const PORTUGUESE_DISTINCT_TOKENS = [ 'glória', ] +// Literal regex — not dynamic, never built from variables. +const LETTER_RE = /[a-záéíóúüñãõç]/i + +function hasWordMatch(text: string, token: string): boolean { + let i = text.indexOf(token) + while (i !== -1) { + const before = i > 0 ? text[i - 1] : '' + const after = i + token.length < text.length ? text[i + token.length] : '' + if ((!before || !LETTER_RE.test(before)) && (!after || !LETTER_RE.test(after))) { + return true + } + i = text.indexOf(token, i + 1) + } + return false +} + function countMatches(text: string, tokens: string[]): number { if (!text) return 0 const lower = text.toLowerCase() let count = 0 for (const token of tokens) { - const escaped = token.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`(?:^|[^a-záéíóúüñãõç])${escaped}(?:$|[^a-záéíóúüñãõç])`, 'i') - if (re.test(lower)) count++ + if (hasWordMatch(lower, token.toLowerCase())) count++ } return count } diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index a1eae6680..d2f3c599e 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -79,6 +79,7 @@ const consumeLastFmSeedSliceMock = jest.fn() const consumeBlendedSeedSliceMock = jest.fn() jest.mock('./autoplay/lastFmSeeds', () => ({ + LASTFM_SEED_COUNT: 15, consumeLastFmSeedSlice: (...args: unknown[]) => consumeLastFmSeedSliceMock(...args), consumeBlendedSeedSlice: (...args: unknown[]) => @@ -191,9 +192,9 @@ describe('queueManipulation.replenishQueue', () => { }): Promise { const queue = createQueueMock({ currentTrack: { - title: 'Song A', - author: 'Artist A', - url: 'https://example.com/a', + title: 'Bohemian Rhapsody', + author: 'Queen', + url: 'https://example.com/bohemian', } as unknown as Track, metadata: options.queueRequestedById ? { requestedBy: { id: options.queueRequestedById } } @@ -206,10 +207,10 @@ describe('queueManipulation.replenishQueue', () => { search: jest.fn().mockResolvedValue({ tracks: [ { - title: options.candidateTitle ?? 'Song B', - author: options.candidateAuthor ?? 'Artist B', + title: options.candidateTitle ?? 'Stairway to Heaven', + author: options.candidateAuthor ?? 'Led Zeppelin', url: - options.candidateUrl ?? 'https://example.com/b', + options.candidateUrl ?? 'https://example.com/stairway', metadata: options.candidateMetadata ?? {}, }, ], @@ -227,9 +228,9 @@ describe('queueManipulation.replenishQueue', () => { size: 1, toArray: jest.fn().mockReturnValue([ { - title: 'Queued Song', - author: 'Queued Artist', - url: 'https://example.com/q', + title: 'Highway to Hell', + author: 'AC/DC', + url: 'https://example.com/highway', }, ]), }, @@ -237,24 +238,24 @@ describe('queueManipulation.replenishQueue', () => { search: jest.fn().mockResolvedValue({ tracks: [ { - title: 'Song B', - author: 'Artist B', - url: 'https://example.com/b', + title: 'Stairway to Heaven', + author: 'Led Zeppelin', + url: 'https://example.com/stairway', }, { - title: 'Song C', - author: 'Artist C', - url: 'https://example.com/c', + title: 'Smells Like Teen Spirit', + author: 'Nirvana', + url: 'https://example.com/nirvana', }, { - title: 'Song D', - author: 'Artist D', - url: 'https://example.com/d', + title: 'Purple Rain', + author: 'Prince', + url: 'https://example.com/prince', }, { - title: 'Song E', - author: 'Artist E', - url: 'https://example.com/e', + title: 'Imagine', + author: 'John Lennon', + url: 'https://example.com/lennon', }, ], }), @@ -277,8 +278,17 @@ describe('queueManipulation.replenishQueue', () => { }) it('does not search when queue already has buffer size', async () => { + const autoplayTracks = Array.from({ length: 8 }, (_, i) => ({ + title: `Autoplay Track ${i + 1}`, + author: `Artist ${i + 1}`, + url: `https://example.com/autoplay-${i + 1}`, + metadata: { isAutoplay: true }, + })) const queue = createQueueMock({ - tracks: { size: 8, toArray: jest.fn().mockReturnValue([]) }, + tracks: { + size: 8, + toArray: jest.fn().mockReturnValue(autoplayTracks), + }, }) await replenishQueue(queue as unknown as GuildQueue) @@ -629,9 +639,9 @@ describe('queueManipulation.replenishQueue', () => { it('returns without adding tracks when candidate set is exhausted', async () => { const queue = await replenishWithSingleCandidate({ - candidateTitle: 'Song A clone', - candidateAuthor: 'Artist A', - candidateUrl: 'https://example.com/a', + candidateTitle: 'Bohemian Rhapsody', + candidateAuthor: 'Queen', + candidateUrl: 'https://example.com/bohemian', }) expect(queue.addTrack).not.toHaveBeenCalled()