diff --git a/packages/bot/src/functions/music/commands/nowplaying.spec.ts b/packages/bot/src/functions/music/commands/nowplaying.spec.ts index e7b535e4a..f28a8c64c 100644 --- a/packages/bot/src/functions/music/commands/nowplaying.spec.ts +++ b/packages/bot/src/functions/music/commands/nowplaying.spec.ts @@ -5,7 +5,7 @@ const requireQueueMock = jest.fn() const requireCurrentTrackMock = jest.fn() const interactionReplyMock = jest.fn() const resolveGuildQueueMock = jest.fn() -const trackToDataMock = jest.fn((track: unknown) => ({ +const playerTrackToDataMock = jest.fn((track: unknown) => ({ title: (track as { title: string }).title, })) const buildTrackEmbedMock = jest.fn(() => ({ embed: 'nowplaying' })) @@ -25,7 +25,7 @@ jest.mock('../../../utils/music/queueResolver', () => ({ })) jest.mock('../../../utils/general/responseEmbeds', () => ({ - trackToData: (...args: unknown[]) => trackToDataMock(...args), + playerTrackToData: (...args: unknown[]) => playerTrackToDataMock(...args), buildTrackEmbed: (...args: unknown[]) => buildTrackEmbedMock(...args), })) @@ -89,7 +89,7 @@ describe('nowplaying command', () => { interaction: makeInteraction(), } as any) - expect(trackToDataMock).toHaveBeenCalledWith(track) + expect(playerTrackToDataMock).toHaveBeenCalledWith(track) expect(createProgressBar).toHaveBeenCalledWith({ length: 18, timecodes: true, 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 = { @@ -23,6 +26,18 @@ const KIND_LABELS: Record = { history: 'From History', } +type TrackDataSource = Pick< + Track, + 'title' | 'author' | 'url' | 'thumbnail' | 'durationMS' | 'source' +> & + Partial, 'metadata'>> + +type PlayerTrackDataSource = Pick< + Track, + 'title' | 'author' | 'url' | 'thumbnail' | 'durationMS' | 'source' +> & + Partial, 'metadata'>> + export type TrackEmbedOptions = { /** * A pre-rendered playback progress bar (e.g. discord-player's @@ -66,6 +81,20 @@ export function buildTrackEmbed( } fields.push({ name: 'Source', value: badge.label, inline: true }) + if (track.recommendationReason) { + // Discord embed field values cap at 1024; open-ended autoplay signals + // from Last.fm/Spotify can theoretically exceed that and drop the embed. + const reason = + track.recommendationReason.length > 1024 + ? `${track.recommendationReason.slice(0, 1021)}...` + : track.recommendationReason + fields.push({ + name: 'Why this track', + value: reason, + inline: false, + }) + } + if (options?.progressBar) { fields.push({ name: 'Progress', @@ -79,17 +108,31 @@ export function buildTrackEmbed( } export function buildCommandTrackEmbed( - track: Track, + track: PlayerTrackDataSource, statusLabel: string, requestedBy: Pick, ): ReturnType { - const trackData = trackToData(track) + const trackData = playerTrackToData(track) const embed = buildTrackEmbed(trackData, 'playing', requestedBy) embed.setAuthor({ name: statusLabel }) return embed } -export function trackToData(track: Track): TrackData { +export function playerTrackToData(track: PlayerTrackDataSource): TrackData { + return trackToData({ + title: track.title, + author: track.author, + url: track.url, + thumbnail: track.thumbnail, + durationMS: track.durationMS, + source: track.source, + metadata: normalizeTrackMetadata(track.metadata), + }) +} + +export function trackToData(track: TrackDataSource): TrackData { + const recommendationReason = track.metadata?.recommendationReason + return { title: track.title, author: track.author, @@ -99,5 +142,19 @@ export function trackToData(track: Track): TrackData { ? formatDurationClock(Math.floor(track.durationMS / 1000)) : undefined, source: trackSource(track) ?? null, + ...(recommendationReason ? { recommendationReason } : {}), } } + +function normalizeTrackMetadata(metadata: unknown): TrackMetadata | null { + if ( + typeof metadata !== 'object' || + metadata === null || + !('recommendationReason' in metadata) || + typeof metadata.recommendationReason !== 'string' + ) { + return null + } + + return { recommendationReason: metadata.recommendationReason } +} diff --git a/packages/bot/src/utils/general/responseEmbeds/index.ts b/packages/bot/src/utils/general/responseEmbeds/index.ts index 93a1f50c7..089b45ebd 100644 --- a/packages/bot/src/utils/general/responseEmbeds/index.ts +++ b/packages/bot/src/utils/general/responseEmbeds/index.ts @@ -1,6 +1,7 @@ export { buildTrackEmbed, buildCommandTrackEmbed, + playerTrackToData, trackToData, type TrackEmbedKind, type TrackData, diff --git a/packages/bot/src/utils/music/trackFields.ts b/packages/bot/src/utils/music/trackFields.ts index 95c2a5c17..ae3000cb4 100644 --- a/packages/bot/src/utils/music/trackFields.ts +++ b/packages/bot/src/utils/music/trackFields.ts @@ -16,7 +16,9 @@ import type { Track, TrackSource } from 'discord-player' * {@link TrackSource} union. Returns `undefined` when the source is absent at * runtime so callers keep their existing fallbacks. */ -export function trackSource(track: Track): TrackSource | undefined { +export function trackSource( + track: Pick, +): TrackSource | undefined { const source: unknown = track.source return typeof source === 'string' ? (source as TrackSource) : undefined } diff --git a/packages/frontend/src/components/Music/QueueList.test.tsx b/packages/frontend/src/components/Music/QueueList.test.tsx index 8ed2edef3..2a8a64247 100644 --- a/packages/frontend/src/components/Music/QueueList.test.tsx +++ b/packages/frontend/src/components/Music/QueueList.test.tsx @@ -78,6 +78,21 @@ describe('QueueList', () => { expect(screen.getByText('(3 tracks)')).toBeInTheDocument() }) + test('shows recommendationReason when present on a track', () => { + const tracks = makeTracks(1) + tracks[0].recommendationReason = 'similar vibes' + render( + , + ) + + expect(screen.getByText('similar vibes')).toBeInTheDocument() + }) + test('shows singular track count for 1 track', () => { render( {track.author}

+ {track.recommendationReason ? ( +

+ {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.title || t('music.unknown')}

-

+

{currentTrack.author || t('music.unknown')}

+ {currentTrack.recommendationReason ? ( +

+ {currentTrack.recommendationReason} +

+ ) : ( +
+ )}
diff --git a/packages/frontend/src/types/music.ts b/packages/frontend/src/types/music.ts index 8002e8379..107cf9b57 100644 --- a/packages/frontend/src/types/music.ts +++ b/packages/frontend/src/types/music.ts @@ -8,6 +8,8 @@ export interface TrackInfo { durationFormatted: string requestedBy?: string source: 'youtube' | 'spotify' | 'soundcloud' | 'unknown' + /** Why autoplay picked this track, when present. */ + recommendationReason?: string } export interface QueueState { diff --git a/packages/shared/src/types/music.ts b/packages/shared/src/types/music.ts index a86769bc3..fc0915700 100644 --- a/packages/shared/src/types/music.ts +++ b/packages/shared/src/types/music.ts @@ -8,6 +8,7 @@ export interface TrackMetadata { isAutoplay?: boolean source?: string engine?: string + recommendationReason?: string } export interface TrackTimestamp {