diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts index 32c0278bb..a833fa3ab 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts @@ -182,6 +182,22 @@ describe('candidateScorer', () => { expect(result.signals).toContain(signal) }) + it('does not reward cross-artist title-word overlap (name similarity removed)', () => { + const result = calculateRecommendationScore({ + candidate: createTrack({ + title: 'Purple Rain Forever', + author: 'Different Artist', + source: 'youtube', + }), + currentTrack: createTrack({ + title: 'Purple Rain', + author: 'Prince', + }), + recentArtists: new Set(), + }) + expect(result.signals).not.toContain('similar title mood') + }) + it('boosts popular mode based on liked weight', () => { const result = calculateRecommendationScore({ candidate: createTrack({ diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.ts index 8d1438784..12f3a7c5c 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.ts @@ -399,14 +399,12 @@ export function calculateRecommendationScore(ctx: ScoringContext): { } else if (candidate.source && candidate.source !== currentTrack.source) { signals.push('source variety') } - const tokenScore = sharedTitleTokenScore( - candidate.title, - currentTrack.title, - ) - score += tokenScore - if (tokenScore > 0) { - signals.push('similar title mood') - } + // NOTE: the cross-artist title-token bonus ("similar title mood") was + // removed — rewarding shared title words across different artists is name + // similarity, not musical similarity, and it pulled in near-identically + // named tracks (often variants of the same song → duplicate-ish entries). + // Same-artist album coherence is still captured by the 'album match' signal + // above; musical similarity comes from Last.fm match + genre families. if ( currentTrack.durationMS && candidate.durationMS && diff --git a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts index a9af40481..fbe41d4ca 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals' import type { Track, GuildQueue } from 'discord-player' -import { replenishQueue } from './replenisher' +import { replenishQueue, popularityBoost } from './replenisher' jest.mock('@lucky/shared/utils', () => ({ debugLog: jest.fn(), @@ -426,3 +426,21 @@ describe('clearSessionMoodCache', () => { expect(typeof clearSessionMoodCache).toBe('function') }) }) + +describe('popularityBoost', () => { + it('boosts high-popularity artists in popular mode, nothing below the threshold', () => { + expect(popularityBoost('popular', 80)).toBeCloseTo(0.12, 5) + expect(popularityBoost('popular', 50)).toBe(0) + }) + + it('boosts low-popularity artists in discover mode, nothing above the threshold', () => { + expect(popularityBoost('discover', 30)).toBeCloseTo(0.12, 5) + expect(popularityBoost('discover', 60)).toBe(0) + }) + + it('applies a mild popularity gradient in similar mode', () => { + expect(popularityBoost('similar', 100)).toBeCloseTo(0.12, 5) + expect(popularityBoost('similar', 50)).toBeCloseTo(0.06, 5) + expect(popularityBoost('similar', 0)).toBe(0) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index 2107d741f..870f52f11 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -55,6 +55,30 @@ const AUTOPLAY_BUFFER_SIZE_PREMIUM = 16 const HISTORY_SEED_LIMIT = 3 const MAX_TRACKS_PER_ARTIST = 2 const MAX_TRACKS_PER_SOURCE = 3 +// 'similar' mode popularity re-rank: a mild gradient (popularity/100 × weight) +// applied POST-selection to the genre-safe candidates, so well-known songs are +// favoured over obscure name-matches without pulling in new (off-genre) tracks. +const SIMILAR_POPULARITY_WEIGHT = 0.12 +const POPULARITY_RERANK_HEAD = 5 +// Flat boost for the discover/popular mode thresholds. +const POPULARITY_MODE_BOOST = 0.12 + +/** + * Post-selection popularity boost for a candidate, by autoplay mode. Applied to + * already-selected (genre-safe, de-duped) candidates only, so it reorders + * vetted tracks without introducing new (off-genre) ones. + * - popular: flat boost for high-popularity (≥70) artists + * - discover: flat boost for low-popularity (≤40) artists + * - similar: mild gradient favouring well-known artists (tie-breaker) + */ +export function popularityBoost( + mode: 'similar' | 'discover' | 'popular', + popularity: number, +): number { + if (mode === 'popular') return popularity >= 70 ? POPULARITY_MODE_BOOST : 0 + if (mode === 'discover') return popularity <= 40 ? POPULARITY_MODE_BOOST : 0 + return (popularity / 100) * SIMILAR_POPULARITY_WEIGHT +} // Concurrency lock: prevents duplicate queue insertions when playerStart and // playerFinish events fire within milliseconds (playerStart + playerFinish) cannot both read @@ -464,29 +488,26 @@ async function _replenishQueue( currentTrack.author, ) - if ( - (autoplayMode === 'discover' || autoplayMode === 'popular') && - requestedBy?.id - ) { + if (requestedBy?.id) { const token = await Promise.resolve( spotifyLinkService.getValidAccessToken(requestedBy.id), ).catch(() => null) if (token) { + // Re-rank a bounded head of the already-selected (genre-safe, + // de-duped) candidates by artist popularity. Post-selection + // only: it reorders vetted candidates and never introduces new + // ones, so popularity weighting can't reintroduce off-genre + // mainstream drift. + const rerankHead = + autoplayMode === 'similar' ? POPULARITY_RERANK_HEAD : 3 await Promise.all( - enriched.slice(0, 3).map(async (track) => { + enriched.slice(0, rerankHead).map(async (track) => { const popularity = await getArtistPopularity( token, track.track.author, ).catch(() => null) if (popularity === null) return - if (autoplayMode === 'popular' && popularity >= 70) { - track.score += 0.12 - } else if ( - autoplayMode === 'discover' && - popularity <= 40 - ) { - track.score += 0.12 - } + track.score += popularityBoost(autoplayMode, popularity) }), ) enriched.sort((a, b) => b.score - a.score)