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
6 changes: 1 addition & 5 deletions packages/bot/src/utils/music/noiseTerms.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,5 @@
"single version",
"album version"
],
"bareTitleNoise": [
"lyrics",
"legendado",
"traduzido"
]
"bareTitleNoise": ["lyrics", "legendado", "traduzido", "legendas"]
}
129 changes: 109 additions & 20 deletions packages/bot/src/utils/music/queueManipulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2902,6 +2902,77 @@ describe('queueManipulation — multi-user VC blend', () => {
)
})

it('rejects candidates over 15 minutes as track too long', async () => {
const addTrackMock = jest.fn()
const queue = createQueueMock({
currentTrack: {
url: 'https://example.com/current',
title: 'Current Song',
author: 'Current Artist',
id: 'curr',
durationMS: 200000,
requestedBy: { id: 'user-1' },
} as unknown as Track,
player: {
search: jest.fn().mockResolvedValue({
tracks: [
{
url: 'https://example.com/looped',
title: 'Pearl Jam - Sirens (07:05:14)',
author: 'SomeFanChannel',
id: 'loop1',
durationMS: 25_514_000,
requestedBy: null,
},
],
}),
},
addTrack: addTrackMock,
metadata: { requestedBy: { id: 'user-1' } },
})

await replenishQueue(queue as unknown as GuildQueue)

expect(addTrackMock).not.toHaveBeenCalled()
})

it('penalizes low-quality uploads with noise indicators in title', async () => {
const addTrackMock = jest.fn()
const queue = createQueueMock({
currentTrack: {
url: 'https://example.com/current',
title: 'Current Song',
author: 'Current Artist',
id: 'curr',
durationMS: 200000,
requestedBy: { id: 'user-1' },
} as unknown as Track,
player: {
search: jest.fn().mockResolvedValue({
tracks: [
{
url: 'https://example.com/noisy',
title: 'Pearl Jam - Sirens (Legendado)',
author: 'FanChannel',
id: 'noisy1',
durationMS: 312000,
requestedBy: null,
},
],
}),
},
addTrack: addTrackMock,
metadata: { requestedBy: { id: 'user-1' } },
})

await replenishQueue(queue as unknown as GuildQueue)

const addedTrack = addTrackMock.mock.calls[0]?.[0] as Track
expect(addedTrack?.metadata?.recommendationReason).toContain(
'low quality upload',
)
})

it('applies outer duration-ratio partial boost (0.7–0.8 range)', async () => {
const addTrackMock = jest.fn()
const queue = createQueueMock({
Expand Down Expand Up @@ -3252,9 +3323,17 @@ describe('queueManipulation — Spotify priority', () => {
})

it('uses song-core query for Spotify engine when seed has artist-song format', async () => {
const searchMock = jest
.fn()
.mockResolvedValue({ tracks: [{ title: 'Shape of You', author: 'Ed Sheeran', url: 'https://open.spotify.com/track/abc', source: 'spotify', durationMS: 234000 }] })
const searchMock = jest.fn().mockResolvedValue({
tracks: [
{
title: 'Shape of You',
author: 'Ed Sheeran',
url: 'https://open.spotify.com/track/abc',
source: 'spotify',
durationMS: 234000,
},
],
})

const queue = createQueueMock({
currentTrack: {
Expand All @@ -3271,7 +3350,9 @@ describe('queueManipulation — Spotify priority', () => {
await replenishQueue(queue as unknown as GuildQueue)

const firstCallQuery: string = searchMock.mock.calls[0]?.[0] ?? ''
expect(firstCallQuery).not.toContain('Ed Sheeran - Shape of You Ed Sheeran')
expect(firstCallQuery).not.toContain(
'Ed Sheeran - Shape of You Ed Sheeran',
)
expect(firstCallQuery).toContain('Shape of You')
expect(firstCallQuery).toContain('Ed Sheeran')
})
Expand Down Expand Up @@ -3416,20 +3497,24 @@ describe('queueManipulation — Spotify priority', () => {

it('never appends query modifiers to the Spotify engine query on subsequent replenish cycles', async () => {
const capturedQueries: Array<{ query: string; engine: unknown }> = []
const searchMock = jest.fn().mockImplementation((query: string, opts: { searchEngine: unknown }) => {
capturedQueries.push({ query, engine: opts?.searchEngine })
return Promise.resolve({
tracks: [
{
title: 'Shape of You',
author: 'Ed Sheeran',
url: 'https://open.spotify.com/track/shapeofyou',
source: 'spotify',
durationMS: 234000,
},
],
})
})
const searchMock = jest
.fn()
.mockImplementation(
(query: string, opts: { searchEngine: unknown }) => {
capturedQueries.push({ query, engine: opts?.searchEngine })
return Promise.resolve({
tracks: [
{
title: 'Shape of You',
author: 'Ed Sheeran',
url: 'https://open.spotify.com/track/shapeofyou',
source: 'spotify',
durationMS: 234000,
},
],
})
},
)

const queue = createQueueMock({
guild: { id: 'guild-spotify-modifier-test' },
Expand Down Expand Up @@ -3626,8 +3711,12 @@ describe('queueManipulation — diversity improvements', () => {

await replenishQueue(queue as unknown as GuildQueue)

const artistsAdded = addedTracks.map((t) => (t as { author: string }).author)
const anatomiaCount = artistsAdded.filter((a) => a === 'ANATOMIA').length
const artistsAdded = addedTracks.map(
(t) => (t as { author: string }).author,
)
const anatomiaCount = artistsAdded.filter(
(a) => a === 'ANATOMIA',
).length
expect(anatomiaCount).toBeLessThanOrEqual(1)
})
})
42 changes: 37 additions & 5 deletions packages/bot/src/utils/music/queueManipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,13 @@ async function _replenishQueue(

const seedArtistKey = currentTrack.author.toLowerCase()
const selected = interleaveByArtist(
selectDiverseCandidates(candidates, missingTracks, MAX_TRACKS_PER_ARTIST, MAX_TRACKS_PER_SOURCE, seedArtistKey),
selectDiverseCandidates(
candidates,
missingTracks,
MAX_TRACKS_PER_ARTIST,
MAX_TRACKS_PER_SOURCE,
seedArtistKey,
),
)

const currentAudioFeatures = await getTrackAudioFeatures(
Expand Down Expand Up @@ -816,7 +822,10 @@ function extractTitleArtistFromSong(
const left = cleanedTitle.slice(0, idx).trim()
if (/[()[\]]/.test(left) || left.length < 2) continue
const right = cleanedTitle.slice(idx + sep.length).trim()
if (corePrefix.length >= 3 && normalizeText(left).startsWith(corePrefix)) {
if (
corePrefix.length >= 3 &&
normalizeText(left).startsWith(corePrefix)
) {
return right
}
return left
Expand Down Expand Up @@ -849,7 +858,10 @@ async function searchSeedCandidates(
} else {
const songCore = extractSongCore(seed.title, seed.author)
if (songCore) {
const titleArtist = extractTitleArtistFromSong(cleanedTitle, songCore)
const titleArtist = extractTitleArtistFromSong(
cleanedTitle,
songCore,
)
spotifyBase = `${songCore} ${titleArtist ?? cleanedAuthor}`.trim()
} else {
spotifyBase = baseQuery
Expand Down Expand Up @@ -883,7 +895,8 @@ async function searchSeedCandidates(
if (tracks.length > 0) {
if (idx > 0) {
warnLog({
message: 'Autoplay: Spotify returned 0 results, using fallback',
message:
'Autoplay: Spotify returned 0 results, using fallback',
data: {
fallbackEngine: engine,
spotifyQuery,
Expand Down Expand Up @@ -1617,6 +1630,14 @@ function calculateRecommendationScore(
return { score: -Infinity, reason: 'blocked artist' }
}

const MAX_CANDIDATE_DURATION_MS = 15 * 60 * 1000
if (
candidate.durationMS &&
candidate.durationMS > MAX_CANDIDATE_DURATION_MS
) {
return { score: -Infinity, reason: 'track too long' }
}

let score = 1
const reasons: string[] = []

Expand Down Expand Up @@ -1737,7 +1758,7 @@ function calculateRecommendationScore(
}

if (candidate.source === 'spotify') {
score += 0.15
score += 0.4
reasons.push('spotify preferred')
}

Expand All @@ -1750,6 +1771,17 @@ function calculateRecommendationScore(
reasons.push('version variant')
}

if (
/\b(?:legendad[ao]|traduzido|tradução|legendas?)\b/i.test(
candidate.title ?? '',
) ||
/\(tributo[^)]*\)/i.test(candidate.title ?? '') ||
/\(\d{1,2}:\d{2}:\d{2}\)/.test(candidate.title ?? '')
) {
score -= 0.4
reasons.push('low quality upload')
}

if (autoplayMode === 'discover') {
if (!recentArtists.has(candidateArtist)) {
score += 0.25
Expand Down
40 changes: 36 additions & 4 deletions packages/bot/src/utils/music/searchQueryCleaner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,9 @@ describe('cleanTitle — hyphenated version suffixes', () => {

describe('cleanTitle — Acústico variants', () => {
it('strips "(Acústico ao vivo)" parenthetical', () => {
expect(cleanTitle('ANATOMIA - Eu sei que é você (Acústico ao vivo)')).toBe(
'ANATOMIA - Eu sei que é você',
)
expect(
cleanTitle('ANATOMIA - Eu sei que é você (Acústico ao vivo)'),
).toBe('ANATOMIA - Eu sei que é você')
})

it('strips "(Acústico)" parenthetical', () => {
Expand Down Expand Up @@ -452,7 +452,9 @@ describe('cleanTitle — Cover variants', () => {
})

it('strips "(Cover - Ao vivo)" parenthetical with dash and extra content', () => {
expect(cleanTitle('ANATOMIA - Água viva (Cover - Ao vivo)')).toBe('ANATOMIA - Água viva')
expect(cleanTitle('ANATOMIA - Água viva (Cover - Ao vivo)')).toBe(
'ANATOMIA - Água viva',
)
})

it('strips "[Cover]" bracketed', () => {
Expand All @@ -463,3 +465,33 @@ describe('cleanTitle — Cover variants', () => {
expect(cleanTitle('Água viva - Cover')).toBe('Água viva')
})
})

describe('cleanTitle — tribute and duration annotation noise', () => {
it('strips "(Tributo ao Batman)" parenthetical', () => {
expect(cleanTitle('Pearl Jam - Sirens (Tributo ao Batman)')).toBe(
'Pearl Jam - Sirens',
)
})

it('strips "[Tributo a Led Zeppelin]" bracket', () => {
expect(cleanTitle('Stairway to Heaven [Tributo a Led Zeppelin]')).toBe(
'Stairway to Heaven',
)
})

it('strips "(Homenagem a Raul Seixas)" parenthetical', () => {
expect(
cleanTitle('Metamorfose Ambulante (Homenagem a Raul Seixas)'),
).toBe('Metamorfose Ambulante')
})

it('strips HH:MM:SS duration annotation from title', () => {
expect(cleanTitle('Pearl Jam - Sirens (Legendado) (07:05:14)')).toBe(
'Pearl Jam - Sirens',
)
})

it('leaves MM:SS intact (only strip 3-part HH:MM:SS)', () => {
expect(cleanTitle('Song Title (03:42)')).toBe('Song Title (03:42)')
})
})
24 changes: 20 additions & 4 deletions packages/bot/src/utils/music/searchQueryCleaner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@
function buildTermRe(term: string, type: TermPattern): RegExp {
const inner = termInner(term)
switch (type) {
case 'paren': return new RegExp(`\\(${inner}[^)]*\\)`, 'gi') // NOSONAR
case 'bracket': return new RegExp(`\\[${inner}[^\\]]*\\]`, 'gi') // NOSONAR
case 'suffix': return new RegExp(`^${inner}(?:\\s{0,3}(?:version|edit|mix))?$`, 'i') // NOSONAR
default: return new RegExp(`\\b${inner}\\b`, 'gi') // NOSONAR
case 'paren':
return new RegExp(`\\(${inner}[^)]*\\)`, 'gi') // NOSONAR
case 'bracket':
return new RegExp(`\\[${inner}[^\\]]*\\]`, 'gi') // NOSONAR
case 'suffix':
return new RegExp(
`^${inner}(?:\\s{0,3}(?:version|edit|mix))?$`,

Check warning on line 38 in packages/bot/src/utils/music/searchQueryCleaner.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2I611gbleU8_0pJPmu&open=AZ2I611gbleU8_0pJPmu&pullRequest=594
'i',
) // NOSONAR
default:
return new RegExp(`\\b${inner}\\b`, 'gi') // NOSONAR
}
}

Expand Down Expand Up @@ -144,6 +151,15 @@
/\(ac[uú]stico[^)]*\)/gi,
/\[ac[uú]stico[^\]]*\]/gi,

// Tribute / homage tags (e.g. "(Tributo ao Batman)")
/\(tributo[^)]*\)/gi,
/\[tributo[^\]]*\]/gi,
/\(homenagem[^)]*\)/gi,
/\[homenagem[^\]]*\]/gi,

// Duration annotations in parentheses: "(07:05:14)" — looped/extended YouTube uploads
/\(\d{1,2}:\d{2}:\d{2}\)/g,

// Dynamic patterns built from noiseTerms.json at module init
...DYNAMIC_NOISE_PATTERNS,
]
Expand Down
Loading