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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Dashboard sidebar redesigned as a guild command center: persistent guild block at top (avatar, name, Management Console subtitle, switch-server action), navigation reorganized into 6 operational groups (Overview / Moderation / Automation / Community / Media / Integrations), sharper active-item indicator, and collapsible mobile drawer with spring animation. (#379)
- Autoplay recommendation engine now emits `session novelty` reason tag (+0.15 score boost) for candidates whose artist has not appeared anywhere in the current session, and `similar energy` reason tag (+0.10) for tracks within ±30% duration of the current track. (#380)
- Last.fm top tracks are now used as additional autoplay seeds: when a user has a linked Last.fm account, their 3-month top 20 tracks are fetched, cached for 1 hour, and randomly sampled to discover taste-aware candidates with a `last.fm taste` reason tag. (#382)
- Last.fm artist and title normalizers strip YouTube `- Topic` suffix, split multi-artist strings to keep only the primary, and remove `(Official Video)`, `(Official Audio)`, `(feat. X)`, and similar noise patterns before scrobbling. (#382)

### Fixed

- Guild automation API endpoints (`/manifest`, `/status`, `/capture`, `/plan`, `/apply`, `/reconcile`, `/cutover`, `/presets/criativaria/apply`) now require `settings` module access via RBAC instead of bare session authentication, closing a privilege escalation path. (#381)
- Last.fm scrobble and nowPlaying duration was always NaN because `track.duration` in discord-player 7 is a formatted string (`"3:45"`), not a number. Fixed to use `track.durationMS / 1000`. (#382)

## [2.6.40] - 2026-03-30

Expand Down
8 changes: 3 additions & 5 deletions packages/bot/src/handlers/player/trackNowPlaying.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,7 @@ export async function updateLastFmNowPlaying(
const sessionKey = await getSessionKeyForUser(requesterId)
if (!sessionKey) return
const durationSec =
typeof track.duration === 'number'
? Math.round(track.duration / 1000)
: undefined
track.durationMS > 0 ? Math.round(track.durationMS / 1000) : undefined
try {
await lastFmUpdateNowPlaying(
track.author,
Expand All @@ -182,8 +180,8 @@ export async function scrobbleCurrentTrackIfLastFm(
lastFmTrackStartTime.delete(queue.guild.id)
const timestamp = startedAt ?? Math.floor(Date.now() / 1000)
const durationSec =
typeof trackToScrobble.duration === 'number'
? Math.round(trackToScrobble.duration / 1000)
trackToScrobble.durationMS > 0
? Math.round(trackToScrobble.durationMS / 1000)
: undefined
try {
await lastFmScrobble(
Expand Down
4 changes: 4 additions & 0 deletions packages/bot/src/lastfm/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export {
isLastFmConfigured,
getSessionKeyForUser,
getTopTracks,
normalizeLastFmArtist,
normalizeLastFmTitle,
updateNowPlaying,
scrobble,
} from './lastFmApi'
export type { LastFmTopTrack, LastFmPeriod } from './lastFmApi'
126 changes: 123 additions & 3 deletions packages/bot/src/lastfm/lastFmApi.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { getSessionKeyForUser, updateNowPlaying } from './lastFmApi'
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from '@jest/globals'
import {
getSessionKeyForUser,
updateNowPlaying,
normalizeLastFmArtist,
normalizeLastFmTitle,
getTopTracks,
} from './lastFmApi'

const getSessionKeyMock = jest.fn()
const fetchMock = jest.fn()
Expand Down Expand Up @@ -50,11 +63,118 @@ describe('lastFmApi', () => {
it('sends signed updateNowPlaying payload', async () => {
await updateNowPlaying('Artist Name', 'Track Name', 187, 'session-123')

const [, request] = fetchMock.mock.calls.at(-1) as [string, { body: string }]
const [, request] = fetchMock.mock.calls.at(-1) as [
string,
{ body: string },
]
expect(request.body).toContain('method=track.updateNowPlaying')
expect(request.body).toContain('artist=Artist+Name')
expect(request.body).toContain('track=Track+Name')
expect(request.body).toContain('duration=187')
expect(request.body).toContain('api_sig=')
})

describe('normalizeLastFmArtist', () => {
it('strips " - Topic" suffix', () => {
expect(normalizeLastFmArtist('Doja Cat - Topic')).toBe('Doja Cat')
})

it('takes first artist when multiple are separated by comma', () => {
expect(normalizeLastFmArtist('Artist A, Artist B')).toBe('Artist A')
})

it('takes first artist when separated by slash', () => {
expect(normalizeLastFmArtist('Artist A / Artist B')).toBe(
'Artist A',
)
})

it('returns unchanged when no separators', () => {
expect(normalizeLastFmArtist('Kendrick Lamar')).toBe(
'Kendrick Lamar',
)
})
})

describe('normalizeLastFmTitle', () => {
it('removes (Official Video) suffix', () => {
expect(normalizeLastFmTitle('Track Name (Official Video)')).toBe(
'Track Name',
)
})

it('removes [Official Music Video] suffix', () => {
expect(
normalizeLastFmTitle('Track Name [Official Music Video]'),
).toBe('Track Name')
})

it('removes feat. clause', () => {
expect(
normalizeLastFmTitle('Track Name (feat. Other Artist)'),
).toBe('Track Name')
})

it('removes (ft. Other Artist) bracketed clause', () => {
expect(normalizeLastFmTitle('Track Name (ft. Other Artist)')).toBe(
'Track Name',
)
})

it('returns unchanged for clean titles', () => {
expect(normalizeLastFmTitle('HUMBLE.')).toBe('HUMBLE.')
})
})

describe('getTopTracks', () => {
it('returns mapped tracks on success', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
toptracks: {
track: [
{
name: 'Song A',
artist: { name: 'Artist X' },
playcount: '42',
},
],
},
}),
})

const tracks = await getTopTracks('username', '3month', 5)

expect(tracks).toHaveLength(1)
expect(tracks[0]).toEqual({
artist: 'Artist X',
title: 'Song A',
playCount: 42,
})
})

it('returns empty array when fetch fails', async () => {
fetchMock.mockRejectedValueOnce(new Error('network'))

const tracks = await getTopTracks('username')

expect(tracks).toEqual([])
})

it('returns empty array when api_key is not configured', async () => {
delete process.env.LASTFM_API_KEY

const tracks = await getTopTracks('username')

expect(tracks).toEqual([])
})

it('returns empty array on non-ok response', async () => {
fetchMock.mockResolvedValueOnce({ ok: false })

const tracks = await getTopTracks('username')

expect(tracks).toEqual([])
})
})
})
66 changes: 62 additions & 4 deletions packages/bot/src/lastfm/lastFmApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,64 @@
}
}

const TOPIC_SUFFIX = / - topic$/i
const ARTIST_SEPARATORS = /\s*[,/]\s*/
const TITLE_NOISE_PARENS =
/\s*[([](official\s*(music\s*)?video|official\s*audio|audio|lyric\s*video|lyrics?|live|hd|4k|ft\.?[^)\]]*|feat\.?[^)\]]*)[)\]]/gi

Check warning on line 87 in packages/bot/src/lastfm/lastFmApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its complexity from 35 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ1AFGP7QOkk1IKUUNBM&open=AZ1AFGP7QOkk1IKUUNBM&pullRequest=382
const FEAT_CLAUSE = /\s*[\[(]?feat\.?\s+[^\])[]+[\])]?/gi

Check warning on line 88 in packages/bot/src/lastfm/lastFmApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unnecessary escape character: \[.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ1AFGP7QOkk1IKUUNBO&open=AZ1AFGP7QOkk1IKUUNBO&pullRequest=382

export function normalizeLastFmArtist(raw: string): string {
return raw.replace(TOPIC_SUFFIX, '').split(ARTIST_SEPARATORS)[0].trim()
}

export function normalizeLastFmTitle(raw: string): string {
return raw.replace(TITLE_NOISE_PARENS, '').replace(FEAT_CLAUSE, '').trim()

Check warning on line 95 in packages/bot/src/lastfm/lastFmApi.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_NexusBot&issues=AZ1AFGP7QOkk1IKUUNBQ&open=AZ1AFGP7QOkk1IKUUNBQ&pullRequest=382

Check warning on line 95 in packages/bot/src/lastfm/lastFmApi.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_NexusBot&issues=AZ1AFGP7QOkk1IKUUNBP&open=AZ1AFGP7QOkk1IKUUNBP&pullRequest=382
Comment on lines +84 to +95

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

The new normalizers rewrite canonical artist/title metadata.

Splitting on raw , / / turns valid artists like Tyler, The Creator and AC/DC into bad scrobbles, and stripping (Live) collapses a distinct release into the studio track. These helpers sit on every now-playing/scrobble path, so the bad metadata also feeds back into the user’s Last.fm top tracks and autoplay seeds.

Proposed fix
-const ARTIST_SEPARATORS = /\s*[,/]\s*/
 const TITLE_NOISE_PARENS =
-    /\s*[([](official\s*(music\s*)?video|official\s*audio|audio|lyric\s*video|lyrics?|live|hd|4k|ft\.?[^)\]]*|feat\.?[^)\]]*)[)\]]/gi
+    /\s*[([](official\s*(music\s*)?video|official\s*audio|audio|lyric\s*video|lyrics?|hd|4k|ft\.?[^)\]]*|feat\.?[^)\]]*)[)\]]/gi

 export function normalizeLastFmArtist(raw: string): string {
-    return raw.replace(TOPIC_SUFFIX, '').split(ARTIST_SEPARATORS)[0].trim()
+    return raw.replace(TOPIC_SUFFIX, '').trim()
 }

If you still want collaborator collapsing, it needs to happen from structured provider metadata upstream rather than by splitting raw display strings.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 95-95: Prefer String#replaceAll() over String#replace().

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9L&open=AZ1AEYVJgetBJX8TlZ9L&pullRequest=382


[warning] 87-87: Simplify this regular expression to reduce its complexity from 35 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9I&open=AZ1AEYVJgetBJX8TlZ9I&pullRequest=382


[warning] 88-88: Unnecessary escape character: [.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9K&open=AZ1AEYVJgetBJX8TlZ9K&pullRequest=382


[warning] 95-95: Prefer String#replaceAll() over String#replace().

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9M&open=AZ1AEYVJgetBJX8TlZ9M&pullRequest=382

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/lastfm/lastFmApi.ts` around lines 84 - 95, Current
normalizers rewrite canonical metadata: stop collapsing valid artist names and
altering titles by removing meaningful parentheticals and splitting on
commas/slashes. In normalizeLastFmArtist and constants (ARTIST_SEPARATORS)
revert the split-on-separators behavior so the function only strips the
TOPIC_SUFFIX and trims the raw string (do not split on commas or slashes). In
normalizeLastFmTitle and constants (TITLE_NOISE_PARENS, FEAT_CLAUSE) stop
stripping generic parentheticals like "(Live)" or removing feat clauses from
display titles; only remove the explicit "- topic" suffix and optionally remove
very specific noise tokens such as "official video"/"official
audio"/"lyrics"/"hd"/"4k" when they appear as standalone trailing tags,
preserving other parenthetical content and "feat." text.

}

export type LastFmTopTrack = {
artist: string
title: string
playCount: number
}
export type LastFmPeriod = '7day' | '1month' | '3month' | '6month' | '12month'

export async function getTopTracks(
lastFmUsername: string,
period: LastFmPeriod = '3month',
limit = 20,
): Promise<LastFmTopTrack[]> {
const config = getApiConfig()
if (!config) return []
const params = new URLSearchParams({
method: 'user.getTopTracks',
user: lastFmUsername,
period,
limit: String(limit),
api_key: config.apiKey,
format: 'json',
})
try {
const res = await fetch(`${API_BASE}?${params.toString()}`)
if (!res.ok) return []
const data = (await res.json()) as {
toptracks?: {
track?: Array<{
name: string
artist: { name: string }
playcount: string
}>
}
}
return (data.toptracks?.track ?? []).map((t) => ({
artist: t.artist.name,
title: t.name,
playCount: parseInt(t.playcount, 10) || 0,

Check warning on line 135 in packages/bot/src/lastfm/lastFmApi.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ1AFGP7QOkk1IKUUNBR&open=AZ1AFGP7QOkk1IKUUNBR&pullRequest=382
}))
} catch {
return []
Comment on lines +120 to +138

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

Don’t cache transient Last.fm failures as “no top tracks.”

Right now every non-OK/JSON/network failure becomes []. packages/bot/src/utils/music/autoplay/lastFmSeeds.ts then caches that array for 1 hour, so one Last.fm blip disables taste seeding until the TTL expires. Throw on transport/response failures and only return [] for a real empty payload.

Proposed fix
     try {
         const res = await fetch(`${API_BASE}?${params.toString()}`)
-        if (!res.ok) return []
+        if (!res.ok) {
+            throw new Error(
+                `Last.fm user.getTopTracks failed with ${res.status}`,
+            )
+        }
         const data = (await res.json()) as {
             toptracks?: {
                 track?: Array<{
@@
         return (data.toptracks?.track ?? []).map((t) => ({
             artist: t.artist.name,
             title: t.name,
             playCount: parseInt(t.playcount, 10) || 0,
         }))
-    } catch {
-        return []
+    } catch (error) {
+        throw new Error('Last.fm user.getTopTracks failed', { cause: error })
     }
 }
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 135-135: Prefer Number.parseInt over parseInt.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9N&open=AZ1AEYVJgetBJX8TlZ9N&pullRequest=382

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/lastfm/lastFmApi.ts` around lines 120 - 138, The code
currently turns any fetch failure or non-OK response into an empty array; change
it so transport/response failures throw while only a genuinely empty payload
returns []. Specifically, in the fetch block around API_BASE and
params.toString(), replace the early `if (!res.ok) return []` with code that
throws an Error (include status/text), and in the catch block rethrow the caught
error instead of returning []; only after successfully parsing JSON inspect
data.toptracks?.track and return [] if that array is absent/empty; keep the
mapping that uses parseInt(t.playcount, 10) || 0 and the fields
artist/name/title unchanged so callers of this Last.fm helper (the fetch to
API_BASE) receive errors for transient failures but still get [] for a real
empty result.

}
Comment on lines +120 to +139

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 | 🟡 Minor

Add timeout to prevent dangling requests.

The fetch call lacks a timeout, which could leave requests hanging indefinitely if the Last.fm API is unresponsive. Per coding guidelines, API calls should "set timeouts and handle aborts/cancellations."

🛡️ Proposed fix with AbortSignal timeout
     try {
-        const res = await fetch(`${API_BASE}?${params.toString()}`)
+        const res = await fetch(`${API_BASE}?${params.toString()}`, {
+            signal: AbortSignal.timeout(10000),
+        })
         if (!res.ok) return []

As per coding guidelines: "Set timeouts and handle aborts/cancellations; avoid dangling requests in API/network code."

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 135-135: Prefer Number.parseInt over parseInt.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Nexus&issues=AZ1AEYVJgetBJX8TlZ9N&open=AZ1AEYVJgetBJX8TlZ9N&pullRequest=382

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bot/src/lastfm/lastFmApi.ts` around lines 120 - 139, The fetch in
the Last.fm call (the block using API_BASE and params in lastFmApi.ts that
returns mapped toptracks) needs an AbortController timeout to avoid dangling
requests: create an AbortController, start a timer (e.g., setTimeout) to call
controller.abort() after a configurable timeout, pass controller.signal to
fetch(`${API_BASE}?${params.toString()}`), and clear the timer after fetch
completes; ensure the catch handles aborts (returning [] as now) and any timer
is cleaned up to avoid leaks.

}

export async function updateNowPlaying(
artist: string,
track: string,
Expand All @@ -89,8 +147,8 @@
): Promise<void> {
if (!sessionKey || !getApiConfig()) return
const params: Record<string, string> = {
artist: artist.trim(),
track: track.trim(),
artist: normalizeLastFmArtist(artist),
track: normalizeLastFmTitle(track),
}
if (durationSec != null && durationSec > 0) {
params.duration = String(Math.round(durationSec))
Expand All @@ -107,8 +165,8 @@
): Promise<void> {
if (!sessionKey || !getApiConfig()) return
const params: Record<string, string> = {
artist: artist.trim(),
track: track.trim(),
artist: normalizeLastFmArtist(artist),
track: normalizeLastFmTitle(track),
timestamp: String(Math.floor(timestamp)),
}
if (durationSec != null && durationSec > 0) {
Expand Down
92 changes: 92 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from '@jest/globals'
import { getLastFmSeedTracks } from './lastFmSeeds'

const getByDiscordIdMock = jest.fn()
const getTopTracksMock = jest.fn()

jest.mock('@lucky/shared/services', () => ({
lastFmLinkService: {
getByDiscordId: (...args: unknown[]) => getByDiscordIdMock(...args),
},
}))

jest.mock('@lucky/shared/utils', () => ({
debugLog: jest.fn(),
errorLog: jest.fn(),
}))

jest.mock('../../../lastfm', () => ({
getTopTracks: (...args: unknown[]) => getTopTracksMock(...args),
}))

describe('getLastFmSeedTracks', () => {
beforeEach(() => {
jest.clearAllMocks()
})

afterEach(() => {
jest.clearAllMocks()
})

it('returns mapped tracks when user has a Last.fm link', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' })
getTopTracksMock.mockResolvedValue([
{ artist: 'Artist A', title: 'Song A', playCount: 10 },
{ artist: 'Artist B', title: 'Song B', playCount: 5 },
])

const tracks = await getLastFmSeedTracks('discord-user-1')

expect(tracks).toEqual([
{ artist: 'Artist A', title: 'Song A' },
{ artist: 'Artist B', title: 'Song B' },
])
expect(getTopTracksMock).toHaveBeenCalledWith('user123', '3month', 20)
})

it('returns empty array when user has no Last.fm link', async () => {
getByDiscordIdMock.mockResolvedValue(null)

const tracks = await getLastFmSeedTracks('discord-user-2')

expect(tracks).toEqual([])
expect(getTopTracksMock).not.toHaveBeenCalled()
})

it('returns empty array when link has no lastFmUsername', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: null })

const tracks = await getLastFmSeedTracks('discord-user-3')

expect(tracks).toEqual([])
})

it('returns cached result on second call within TTL', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'cached-user' })
getTopTracksMock.mockResolvedValue([
{ artist: 'Artist C', title: 'Song C', playCount: 3 },
])

const first = await getLastFmSeedTracks('discord-user-cache')
const second = await getLastFmSeedTracks('discord-user-cache')

expect(first).toEqual(second)
expect(getTopTracksMock).toHaveBeenCalledTimes(1)
})

it('returns empty array when getTopTracks throws', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'erruser' })
getTopTracksMock.mockRejectedValue(new Error('API error'))

const tracks = await getLastFmSeedTracks('discord-user-err')

expect(tracks).toEqual([])
})
})
Loading
Loading