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
16 changes: 16 additions & 0 deletions packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 6 additions & 8 deletions packages/bot/src/utils/music/autoplay/candidateScorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
20 changes: 19 additions & 1 deletion packages/bot/src/utils/music/autoplay/replenisher.spec.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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)
})
})
47 changes: 34 additions & 13 deletions packages/bot/src/utils/music/autoplay/replenisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading