From 6e4b496a20b558ae6527ea4ebe4f51cdc7d0640d Mon Sep 17 00:00:00 2001
From: Adolanium <94890352+Adolanium@users.noreply.github.com>
Date: Mon, 27 Jul 2026 06:51:32 +0300
Subject: [PATCH 1/2] feat(music): surface recommendationReason in nowplaying
and queue
Plumb recommendationReason through shared types, now-playing, and the
queue list. Validate string metadata at the discord-player boundary and
in mapTrack. Truncate the Why this track embed field to Discord's 1024
char cap.
---
.../src/functions/music/commands/songinfo.ts | 4 +-
.../bot/src/handlers/webMusic/mappers.spec.ts | 14 +++++
packages/bot/src/handlers/webMusic/mappers.ts | 8 +++
.../responseEmbeds/buildTrackEmbed.spec.ts | 39 +++++++++---
.../general/responseEmbeds/buildTrackEmbed.ts | 63 ++++++++++++++++++-
.../src/utils/general/responseEmbeds/index.ts | 1 +
packages/bot/src/utils/music/trackFields.ts | 4 +-
.../src/components/Music/QueueList.test.tsx | 15 +++++
.../src/components/Music/QueueList.tsx | 8 +++
packages/frontend/src/pages/Music.tsx | 13 +++-
packages/frontend/src/types/music.ts | 2 +
packages/shared/src/types/music.ts | 1 +
12 files changed, 156 insertions(+), 16 deletions(-)
diff --git a/packages/bot/src/functions/music/commands/songinfo.ts b/packages/bot/src/functions/music/commands/songinfo.ts
index 2226500f9..eb0fb2ccb 100644
--- a/packages/bot/src/functions/music/commands/songinfo.ts
+++ b/packages/bot/src/functions/music/commands/songinfo.ts
@@ -4,7 +4,7 @@ import { assertDefined } from '@lucky/shared/utils/guards'
import { interactionReply } from '../../../utils/general/interactionReply'
import {
buildTrackEmbed,
- trackToData,
+ playerTrackToData,
} from '../../../utils/general/responseEmbeds'
import type { CommandExecuteParams } from '../../../types/CommandData'
import {
@@ -27,7 +27,7 @@ export default new Command({
if (!(await requireQueue(queue, interaction))) return
if (!(await requireCurrentTrack(queue, interaction))) return
- const trackData = trackToData(
+ const trackData = playerTrackToData(
assertDefined(
track,
'track present after requireCurrentTrack guard',
diff --git a/packages/bot/src/handlers/webMusic/mappers.spec.ts b/packages/bot/src/handlers/webMusic/mappers.spec.ts
index b85eeba90..6be844e8f 100644
--- a/packages/bot/src/handlers/webMusic/mappers.spec.ts
+++ b/packages/bot/src/handlers/webMusic/mappers.spec.ts
@@ -81,6 +81,20 @@ describe('mapTrack', () => {
const track = mapTrack(makeRawTrack({ requestedBy: null }))
expect(track.requestedBy).toBeUndefined()
})
+
+ it('forwards recommendationReason from track metadata', () => {
+ const track = mapTrack(
+ makeRawTrack({
+ metadata: { isAutoplay: true, recommendationReason: 'similar vibes' },
+ }),
+ )
+ expect(track.recommendationReason).toBe('similar vibes')
+ })
+
+ it('omits recommendationReason when metadata has none', () => {
+ const track = mapTrack(makeRawTrack({ metadata: { isAutoplay: true } }))
+ expect(track.recommendationReason).toBeUndefined()
+ })
})
describe('repeatModeToString', () => {
diff --git a/packages/bot/src/handlers/webMusic/mappers.ts b/packages/bot/src/handlers/webMusic/mappers.ts
index b68763a72..7ea50b5b8 100644
--- a/packages/bot/src/handlers/webMusic/mappers.ts
+++ b/packages/bot/src/handlers/webMusic/mappers.ts
@@ -16,11 +16,18 @@ interface RawTrack {
durationMS: number
requestedBy?: { username?: string } | null
source?: string
+ metadata?: {
+ isAutoplay?: boolean
+ recommendationReason?: string
+ } | null
}
const KNOWN_SOURCES = ['youtube', 'spotify', 'soundcloud']
export function mapTrack(track: RawTrack): TrackInfo {
+ const rawReason = track.metadata?.recommendationReason
+ const reason =
+ typeof rawReason === 'string' ? rawReason : undefined
return {
id: track.id,
title: track.title,
@@ -33,6 +40,7 @@ export function mapTrack(track: RawTrack): TrackInfo {
source: (KNOWN_SOURCES.includes(track.source ?? '')
? track.source
: 'unknown') as TrackInfo['source'],
+ ...(reason ? { recommendationReason: reason } : {}),
}
}
diff --git a/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.spec.ts b/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.spec.ts
index dcaaee1da..1b2003446 100644
--- a/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.spec.ts
+++ b/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.spec.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from '@jest/globals'
import {
buildTrackEmbed,
buildCommandTrackEmbed,
+ playerTrackToData,
trackToData,
} from './buildTrackEmbed'
import { detectSource } from '../../music/nowPlayingEmbed'
@@ -67,6 +68,16 @@ describe('buildTrackEmbed', () => {
expect(progress?.inline).toBe(false)
})
+ it('adds a Why this track field when recommendationReason is set', () => {
+ const embed = buildTrackEmbed(
+ { ...baseTrack, recommendationReason: 'similar vibes' },
+ 'playing',
+ fakeUser,
+ )
+ const why = embed.data.fields?.find((f) => f.name === 'Why this track')
+ expect(why?.value).toBe('similar vibes')
+ })
+
it('omits the Progress field when no/null progress bar', () => {
const noOpts = buildTrackEmbed(baseTrack, 'playing', fakeUser)
const nullBar = buildTrackEmbed(baseTrack, 'playing', fakeUser, {
@@ -134,7 +145,7 @@ describe('trackToData', () => {
}
it('maps all fields and formats duration correctly', () => {
- const data = trackToData(fakeTrack as never)
+ const data = trackToData(fakeTrack)
expect(data.title).toBe('Test Song')
expect(data.author).toBe('Test Artist')
expect(data.url).toBe(fakeTrack.url)
@@ -143,17 +154,33 @@ describe('trackToData', () => {
expect(data.duration).toBe('3:35')
})
+ it('forwards recommendationReason from track metadata', () => {
+ const data = trackToData({
+ ...fakeTrack,
+ metadata: { recommendationReason: 'same artist' },
+ })
+ expect(data.recommendationReason).toBe('same artist')
+ })
+
+ it('rejects invalid recommendation metadata from discord-player', () => {
+ const data = playerTrackToData({
+ ...fakeTrack,
+ metadata: { recommendationReason: 123 },
+ })
+ expect(data.recommendationReason).toBeUndefined()
+ })
+
it.each([
[0, undefined],
[65000, '1:05'],
])('handles edge cases: durationMS=%i → %s', (durationMS, expected) => {
- const data = trackToData({ ...fakeTrack, durationMS } as never)
+ const data = trackToData({ ...fakeTrack, durationMS })
expect(data.duration).toBe(expected)
if (durationMS === 0) {
const noSource = trackToData({
...fakeTrack,
source: undefined,
- } as never)
+ })
expect(noSource.source).toBeNull()
}
})
@@ -170,11 +197,7 @@ describe('buildCommandTrackEmbed', () => {
}
it('builds embed with status label and track data', () => {
- const embed = buildCommandTrackEmbed(
- track as never,
- '⏸️ Paused',
- fakeUser,
- )
+ const embed = buildCommandTrackEmbed(track, '⏸️ Paused', fakeUser)
expect(embed.data.author?.name).toBe('⏸️ Paused')
expect(embed.data.title).toBe(track.title)
expect(embed.data.footer?.text).toContain(fakeUser.tag)
diff --git a/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.ts b/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.ts
index 59276af5f..3ce20b4d2 100644
--- a/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.ts
+++ b/packages/bot/src/utils/general/responseEmbeds/buildTrackEmbed.ts
@@ -1,6 +1,7 @@
import { EmbedBuilder } from 'discord.js'
import type { User } from 'discord.js'
import type { Track } from 'discord-player'
+import type { TrackMetadata } from '@lucky/shared/types'
import { detectSource } from '../../music/nowPlayingEmbed'
import { trackSource } from '../../music/trackFields'
import { formatDurationClock } from '../formatDuration'
@@ -14,6 +15,8 @@ export type TrackData = {
thumbnail?: string
duration?: string
source?: string | null
+ /** Why autoplay picked this track, when present. */
+ recommendationReason?: string
}
const KIND_LABELS: Record
+ {track.recommendationReason} +
+ ) : null} diff --git a/packages/frontend/src/pages/Music.tsx b/packages/frontend/src/pages/Music.tsx index 952796e13..5ec4440e5 100644 --- a/packages/frontend/src/pages/Music.tsx +++ b/packages/frontend/src/pages/Music.tsx @@ -157,7 +157,9 @@ function NowPlayingHero({ onVolumeChange: (v: number) => void }) { const { t } = useTranslation() - const currentTrack = state.tracks[0] + // Prefer the live currentTrack from SSE; fall back to the head of the + // upcoming queue for older state payloads that only filled tracks[]. + const currentTrack = state.currentTrack ?? state.tracks[0] if (!currentTrack) { return ( @@ -208,9 +210,16 @@ function NowPlayingHero({+
{currentTrack.author || t('music.unknown')}
+ {currentTrack.recommendationReason ? ( ++ {currentTrack.recommendationReason} +
+ ) : ( + + )}