From 6e7fe45b7c48fe5b83797cdb069e329550a430bd Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 30 Mar 2026 15:45:21 -0300 Subject: [PATCH 1/3] feat(bot): last.fm duration fix, normalizers, and top tracks seeds - Fix scrobble/nowPlaying duration: use track.durationMS (number in ms) instead of track.duration (formatted string) in discord-player 7 - Add artist/title normalizers to strip YouTube noise from scrobbles - Add getTopTracks via user.getTopTracks API (3month, 20 tracks) - Add getLastFmSeedTracks with 1h in-memory cache per Discord user - Integrate Last.fm seeds into replenishQueue with +0.1 score boost - 27 new tests (591 total passing) --- .../src/handlers/player/trackNowPlaying.ts | 8 +- packages/bot/src/lastfm/index.ts | 4 + packages/bot/src/lastfm/lastFmApi.spec.ts | 126 +++++++++++++++++- packages/bot/src/lastfm/lastFmApi.ts | 66 ++++++++- .../utils/music/autoplay/lastFmSeeds.spec.ts | 92 +++++++++++++ .../src/utils/music/autoplay/lastFmSeeds.ts | 52 ++++++++ .../src/utils/music/queueManipulation.spec.ts | 8 ++ .../bot/src/utils/music/queueManipulation.ts | 77 +++++++++++ 8 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts create mode 100644 packages/bot/src/utils/music/autoplay/lastFmSeeds.ts diff --git a/packages/bot/src/handlers/player/trackNowPlaying.ts b/packages/bot/src/handlers/player/trackNowPlaying.ts index ff60025eb..c338304bc 100644 --- a/packages/bot/src/handlers/player/trackNowPlaying.ts +++ b/packages/bot/src/handlers/player/trackNowPlaying.ts @@ -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, @@ -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( diff --git a/packages/bot/src/lastfm/index.ts b/packages/bot/src/lastfm/index.ts index 5b0cde3d8..ad582a0ba 100644 --- a/packages/bot/src/lastfm/index.ts +++ b/packages/bot/src/lastfm/index.ts @@ -1,6 +1,10 @@ export { isLastFmConfigured, getSessionKeyForUser, + getTopTracks, + normalizeLastFmArtist, + normalizeLastFmTitle, updateNowPlaying, scrobble, } from './lastFmApi' +export type { LastFmTopTrack, LastFmPeriod } from './lastFmApi' diff --git a/packages/bot/src/lastfm/lastFmApi.spec.ts b/packages/bot/src/lastfm/lastFmApi.spec.ts index 1147c91a4..90390fe07 100644 --- a/packages/bot/src/lastfm/lastFmApi.spec.ts +++ b/packages/bot/src/lastfm/lastFmApi.spec.ts @@ -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() @@ -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([]) + }) + }) }) diff --git a/packages/bot/src/lastfm/lastFmApi.ts b/packages/bot/src/lastfm/lastFmApi.ts index c839003a4..4856376c2 100644 --- a/packages/bot/src/lastfm/lastFmApi.ts +++ b/packages/bot/src/lastfm/lastFmApi.ts @@ -81,6 +81,64 @@ async function signedPost( } } +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 +const FEAT_CLAUSE = /\s*[\[(]?feat\.?\s+[^\])[]+[\])]?/gi + +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() +} + +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 { + 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, + })) + } catch { + return [] + } +} + export async function updateNowPlaying( artist: string, track: string, @@ -89,8 +147,8 @@ export async function updateNowPlaying( ): Promise { if (!sessionKey || !getApiConfig()) return const params: Record = { - artist: artist.trim(), - track: track.trim(), + artist: normalizeLastFmArtist(artist), + track: normalizeLastFmTitle(track), } if (durationSec != null && durationSec > 0) { params.duration = String(Math.round(durationSec)) @@ -107,8 +165,8 @@ export async function scrobble( ): Promise { if (!sessionKey || !getApiConfig()) return const params: Record = { - artist: artist.trim(), - track: track.trim(), + artist: normalizeLastFmArtist(artist), + track: normalizeLastFmTitle(track), timestamp: String(Math.floor(timestamp)), } if (durationSec != null && durationSec > 0) { diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts new file mode 100644 index 000000000..bb469a234 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts @@ -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([]) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts new file mode 100644 index 000000000..d1472dda6 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts @@ -0,0 +1,52 @@ +import { lastFmLinkService } from '@lucky/shared/services' +import { getTopTracks } from '../../../lastfm' +import { debugLog, errorLog } from '@lucky/shared/utils' + +const CACHE_TTL_MS = 60 * 60 * 1000 +const TOP_TRACKS_LIMIT = 20 + +type CacheEntry = { + tracks: { artist: string; title: string }[] + expiresAt: number +} + +const cache = new Map() + +export async function getLastFmSeedTracks( + discordUserId: string, +): Promise<{ artist: string; title: string }[]> { + const cached = cache.get(discordUserId) + if (cached && cached.expiresAt > Date.now()) { + return cached.tracks + } + + try { + const link = await lastFmLinkService.getByDiscordId(discordUserId) + if (!link?.lastFmUsername) return [] + + const topTracks = await getTopTracks( + link.lastFmUsername, + '3month', + TOP_TRACKS_LIMIT, + ) + const tracks = topTracks.map((t) => ({ + artist: t.artist, + title: t.title, + })) + + cache.set(discordUserId, { + tracks, + expiresAt: Date.now() + CACHE_TTL_MS, + }) + + debugLog({ + message: 'Loaded Last.fm seed tracks', + data: { discordUserId, count: tracks.length }, + }) + + return tracks + } catch (error) { + errorLog({ message: 'Failed to load Last.fm seed tracks', error }) + return [] + } +} diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 56494d06e..6c8c7aef7 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -42,6 +42,13 @@ jest.mock('@lucky/shared/utils', () => ({ errorLog: jest.fn(), })) +const getLastFmSeedTracksMock = jest.fn() + +jest.mock('./autoplay/lastFmSeeds', () => ({ + getLastFmSeedTracks: (...args: unknown[]) => + getLastFmSeedTracksMock(...args), +})) + const dislikedTrackKeysMock = jest.fn() const likedTrackKeysMock = jest.fn() @@ -93,6 +100,7 @@ describe('queueManipulation.replenishQueue', () => { beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getLastFmSeedTracksMock.mockResolvedValue([]) }) async function replenishWithSingleCandidate(options: { diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index a39f855ef..f6606f79f 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -8,12 +8,15 @@ import { randomInt } from 'node:crypto' import type { User } from 'discord.js' import { debugLog, errorLog } from '@lucky/shared/utils' import { recommendationFeedbackService } from '../../services/musicRecommendation/feedbackService' +import { getLastFmSeedTracks } from './autoplay/lastFmSeeds' const AUTOPLAY_BUFFER_SIZE = 8 const HISTORY_SEED_LIMIT = 3 const SEARCH_RESULTS_LIMIT = 8 const MAX_TRACKS_PER_ARTIST = 2 const MAX_TRACKS_PER_SOURCE = 3 +const LASTFM_SEED_COUNT = 3 +const LASTFM_SCORE_BOOST = 0.1 const QUEUE_RESCUE_PROBE_TIMEOUT_MS = Number.parseInt( process.env.QUEUE_RESCUE_PROBE_TIMEOUT_MS ?? '5000', 10, @@ -232,6 +235,19 @@ export async function replenishQueue(queue: GuildQueue): Promise { currentTrack, recentArtists, ) + if (requestedBy?.id) { + await collectLastFmCandidates( + queue, + requestedBy, + excludedUrls, + excludedKeys, + dislikedTrackKeys, + likedTrackKeys, + currentTrack, + recentArtists, + candidates, + ) + } const selected = selectDiverseCandidates(candidates, missingTracks) addSelectedTracks( @@ -413,6 +429,67 @@ function upsertScoredCandidate( } } +async function collectLastFmCandidates( + queue: GuildQueue, + requestedBy: User, + excludedUrls: Set, + excludedKeys: Set, + dislikedTrackKeys: Set, + likedTrackKeys: Set, + currentTrack: Track, + recentArtists: Set, + candidates: Map, +): Promise { + const lastFmTracks = await getLastFmSeedTracks(requestedBy.id) + if (lastFmTracks.length === 0) return + + const pool = lastFmTracks.slice(0, 10) + const seeds: typeof lastFmTracks = [] + while (seeds.length < LASTFM_SEED_COUNT && pool.length > 0) { + const idx = pool.length > 1 ? randomInt(pool.length) : 0 + seeds.push(...pool.splice(idx, 1)) + } + + for (const seed of seeds) { + const query = `${seed.title} ${seed.artist}`.trim() + const tracks = await searchLastFmQuery(queue, query, requestedBy) + for (const track of tracks) { + if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys)) + continue + const normalizedKey = normalizeTrackKey(track.title, track.author) + if (dislikedTrackKeys.has(normalizedKey)) continue + const rec = calculateRecommendationScore( + track, + currentTrack, + recentArtists, + likedTrackKeys, + ) + upsertScoredCandidate(candidates, track, { + score: rec.score + LASTFM_SCORE_BOOST, + reason: rec.reason + ? `${rec.reason} • last.fm taste` + : 'last.fm taste', + }) + } + } +} + +async function searchLastFmQuery( + queue: GuildQueue, + query: string, + requestedBy: User, +): Promise { + try { + const result = await queue.player.search(query, { + requestedBy, + searchEngine: QueryType.AUTO, + }) + return result.tracks.slice(0, SEARCH_RESULTS_LIMIT) + } catch { + return [] + } +} + function selectDiverseCandidates( candidates: Map, missingTracks: number, From e2ca6d633585aba22a6d4d83cbd706b96d5c45ba Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 30 Mar 2026 15:46:10 -0300 Subject: [PATCH 2/3] docs: add changelog entries for PR #382 last.fm improvements --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f515a7b5..60ea99f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 3898e484b31f956f3157dd4ab48979835db2898e Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 30 Mar 2026 15:57:59 -0300 Subject: [PATCH 3/3] test(bot): add coverage for collectLastFmCandidates path --- .../src/utils/music/queueManipulation.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 6c8c7aef7..b9a8aeae6 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -649,6 +649,51 @@ describe('queueManipulation.replenishQueue', () => { ?.recommendationReason, ).toContain('similar energy') }) + + it('collects lastfm seed tracks and searches for recommendations', async () => { + getLastFmSeedTracksMock.mockResolvedValueOnce([ + { artist: 'Radiohead', title: 'Paranoid Android' }, + { artist: 'Muse', title: 'Hysteria' }, + ]) + + const queue = createQueueMock({ + tracks: { size: 0, toArray: jest.fn().mockReturnValue([]) }, + currentTrack: { + title: 'Song A', + author: 'Artist A', + url: 'https://example.com/a', + requestedBy: { id: 'user-1' }, + } as unknown as Track, + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + title: 'Karma Police', + author: 'Radiohead', + url: 'https://example.com/karma', + }, + ], + }), + }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(getLastFmSeedTracksMock).toHaveBeenCalledWith('user-1') + expect(queue.player.search).toHaveBeenCalledWith( + expect.stringContaining('Paranoid Android'), + expect.objectContaining({ searchEngine: QueryType.AUTO }), + ) + expect(queue.addTrack).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com/karma', + metadata: expect.objectContaining({ + isAutoplay: true, + recommendationReason: expect.stringContaining('last.fm'), + }), + }), + ) + }) }) describe('queueManipulation.queueOperations', () => {