Skip to content
54 changes: 53 additions & 1 deletion packages/bot/src/handlers/player/playerFactory.bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,20 @@ describe('findMatchingSoundCloudResult', () => {
).toBe('sc://1')
})

it('rejects when duration is > 15s off', () => {
it('rejects when duration is > 30s off', () => {
expect(
findMatchingSoundCloudResult('Bohemian Rhapsody', '10:00', results),
).toBeUndefined()
})

it('accepts when duration is 16-30s off (relaxed tolerance)', () => {
expect(
findMatchingSoundCloudResult('Bohemian Rhapsody', '5:55', [
{ name: 'Bohemian Rhapsody', url: 'sc://1', durationInSec: 374 },
])?.url,
).toBe('sc://1')
})

it('accepts when track has no duration', () => {
expect(
findMatchingSoundCloudResult(
Expand All @@ -146,6 +154,28 @@ describe('findMatchingSoundCloudResult', () => {
]),
).toBeUndefined()
})

it('matches Brazilian funk track when result is missing 1 compound token (75% threshold)', () => {
const query =
'MC Ryan SP MC Jacara e MC Meno K - Posso Ate Nao Te Dar Flores (DJ Japa NK e DJ Davi DogDog)'
const results = [
{
name: 'MC Ryan SP, MC Jacaré e MC Meno K - Posso Até Não Te Dar Flores (DJ Japa NK e DJ Davi Dog Dog)',
url: 'sc://funk',
durationInSec: 194,
},
]
expect(findMatchingSoundCloudResult(query, '3:14', results)?.url).toBe(
'sc://funk',
)
})

it('returns undefined when fewer than 75% of tokens match', () => {
const results = [{ name: 'Completely Different Song', url: 'sc://x', durationInSec: 180 }]
expect(
findMatchingSoundCloudResult('Bohemian Rhapsody Queen', '3:00', results),
).toBeUndefined()
})
})

describe('streamViaSoundCloud', () => {
Expand Down Expand Up @@ -340,6 +370,28 @@ describe('createResilientStream', () => {
expect(playdlSearchMock).not.toHaveBeenCalled()
})

it('falls back to core title (stripped parentheticals) when title-only search fails', async () => {
spawnMock.mockReturnValue(makeSpawnError(1))
playdlSearchMock
.mockResolvedValueOnce([{ name: 'Unrelated', url: 'sc://miss', durationInSec: 180 }])
.mockResolvedValueOnce([{ name: 'Unrelated', url: 'sc://miss2', durationInSec: 180 }])
.mockResolvedValueOnce([
{
name: 'Bohemian Rhapsody',
url: 'sc://core',
durationInSec: 354,
},
])
playdlStreamMock.mockResolvedValueOnce({ stream: fakeStream })

const result = await createResilientStream(
makeTrack({ title: 'Bohemian Rhapsody (Official Music Live Session)' }),
)
expect(result).toBe(fakeStream)
expect(playdlSearchMock).toHaveBeenCalledTimes(3)
expect(playdlStreamMock).toHaveBeenCalledWith('sc://core')
})

it('throws "Bridge exhausted" when track has no URL and SoundCloud fails', async () => {
playdlSearchMock.mockResolvedValue([])

Expand Down
48 changes: 37 additions & 11 deletions packages/bot/src/handlers/player/playerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,28 @@

try {
return await streamViaSoundCloud(cleanedTitle, track.duration)
} catch (titleOnlyError) {
} catch {
debugLog({
message: 'Bridge: title-only SoundCloud failed, retrying without parentheticals',
data: { cleanedTitle },
})
}

const openParen = cleanedTitle.indexOf('(')
const coreTitle = openParen > 0 ? cleanedTitle.slice(0, openParen).trim() : cleanedTitle
if (coreTitle && coreTitle !== cleanedTitle) {
try {
return await streamViaSoundCloud(coreTitle, track.duration)
} catch (coreError) {
errorLog({
message: 'Bridge: all stages exhausted',
error: coreError,
data: { title: track.title, coreTitle },
})
}
} else {
errorLog({
message: 'Bridge: all stages exhausted',
error: titleOnlyError,
data: { title: track.title },
})
}
Expand Down Expand Up @@ -294,14 +312,20 @@
durationInSec?: number
}

/**
* Minimum fraction of query tokens that must appear in the result name.
* Using 0.75 instead of 1.0 tolerates minor title differences:
* - Accent stripping ("Jacaré" → "jacar" vs "Jacare" → "jacare")
* - Compound word splits ("DogDog" vs "Dog Dog" → "dog dog")
* - DJ credits missing or abbreviated on SoundCloud
*/
const TITLE_MATCH_THRESHOLD = 0.75

/**
* Pure match logic, exported so tests can cover it without spinning up play-dl.
*
* Matches a candidate result iff every non-empty token in the cleaned query
* appears in the normalized result name. This is tighter than the previous
* symmetric substring check, which allowed short candidate names (e.g. a
* 4-character remix title) to match a long query because
* `queryNorm.includes(resultNorm)` was true.
* Requires at least TITLE_MATCH_THRESHOLD fraction of query tokens to appear
* in the result name, and duration within 30 seconds when available.
*/
export function findMatchingSoundCloudResult(
query: string,
Expand All @@ -311,7 +335,7 @@
const queryNorm = normalizeForMatch(query)
if (!queryNorm) return undefined

const tokens = queryNorm.split(/\s+/).filter(Boolean)
const tokens = queryNorm.split(/ +/).filter(Boolean)
if (tokens.length === 0) return undefined

const trackSec = parseDurationString(trackDuration)
Expand All @@ -320,18 +344,20 @@
const resultNorm = normalizeForMatch(result.name)
if (!resultNorm) return false

const titleMatch = tokens.every((token) => resultNorm.includes(token))
const matched = tokens.filter((token) => resultNorm.includes(token)).length
const titleMatch = matched / tokens.length >= TITLE_MATCH_THRESHOLD
if (!titleMatch) return false

if (trackSec === null || !result.durationInSec) return true
return Math.abs(result.durationInSec - trackSec) <= 15
return Math.abs(result.durationInSec - trackSec) <= 30
})
}

function normalizeForMatch(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/[^a-z0-9 ]/g, '')

Check warning on line 359 in packages/bot/src/handlers/player/playerFactory.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ14xMqykVtj5U5sKas_&open=AZ14xMqykVtj5U5sKas_&pullRequest=532
.replace(/ {2,}/g, ' ')

Check warning on line 360 in packages/bot/src/handlers/player/playerFactory.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ14t_rE87T5dO55PKY8&open=AZ14t_rE87T5dO55PKY8&pullRequest=532
.trim()
Comment on lines 356 to 361

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and read the specific section
cat -n packages/bot/src/handlers/player/playerFactory.ts | sed -n '350,365p'

Repository: LucasSantana-Dev/Lucky

Length of output: 650


🏁 Script executed:

# Also check the broader context of the normalizeForMatch function
cat -n packages/bot/src/handlers/player/playerFactory.ts | sed -n '340,370p'

Repository: LucasSantana-Dev/Lucky

Length of output: 1348


🏁 Script executed:

# Let's see how normalizeForMatch is used in the codebase
rg -n "normalizeForMatch" packages/bot/src/handlers/player/playerFactory.ts -B 2 -A 2

Repository: LucasSantana-Dev/Lucky

Length of output: 535


🏁 Script executed:

# Check the entire playerFactory.ts file size to understand scope
wc -l packages/bot/src/handlers/player/playerFactory.ts

Repository: LucasSantana-Dev/Lucky

Length of output: 119


🏁 Script executed:

# Check tsconfig.json for target
cat tsconfig.json 2>/dev/null || echo "tsconfig.json not found at root"

Repository: LucasSantana-Dev/Lucky

Length of output: 1132


🏁 Script executed:

# Check for TypeScript config in bot package
find packages/bot -name "tsconfig.json" -o -name "tsconfig.*.json" | head -5

Repository: LucasSantana-Dev/Lucky

Length of output: 92


🏁 Script executed:

# Check package.json for Node.js version requirement and TypeScript target
cat package.json | grep -A 5 -B 5 "engines\|target" || echo "Checking for engines field..."

Repository: LucasSantana-Dev/Lucky

Length of output: 295


🏁 Script executed:

# Check if there's a root tsconfig
cat -n tsconfig.json 2>/dev/null | head -20

Repository: LucasSantana-Dev/Lucky

Length of output: 796


Diacritics are being deleted, not normalized.

The current implementation removes accented characters entirely (Jacaré → jacar, Jacare → jacare), leaving accent-only variants unmatched despite the PR's stated root cause being accent normalization.

Proposed fix
 function normalizeForMatch(value: string): string {
     return value
+        .normalize('NFD')
+        .replace(/\p{M}+/gu, '')
         .toLowerCase()
         .replace(/[^a-z0-9 ]/g, '')
         .replace(/ {2,}/g, ' ')
         .trim()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function normalizeForMatch(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/[^a-z0-9 ]/g, '')
.replace(/ {2,}/g, ' ')
.trim()
function normalizeForMatch(value: string): string {
return value
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.replace(/[^a-z0-9 ]/g, '')
.replace(/ {2,}/g, ' ')
.trim()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/handlers/player/playerFactory.ts` around lines 355 - 360, In
normalizeForMatch, stop stripping accented letters and instead apply Unicode NFD
normalization and remove only combining marks: call value.normalize('NFD'), then
remove diacritics with .replace(/\p{M}/gu, ''), then lowercase and strip any
non-letter/number/space characters with a Unicode-aware regex like
.replace(/[^\p{L}\p{N} ]+/gu, ''), collapse multiple spaces and trim; this
preserves base letters (e.g., "Jacaré" → "jacare") while still removing
punctuation and extra spaces.

}

Expand Down