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
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand All @@ -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),
}))

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/bot/src/functions/music/commands/songinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions packages/bot/src/handlers/webMusic/mappers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/bot/src/handlers/webMusic/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 } : {}),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from '@jest/globals'
import {
buildTrackEmbed,
buildCommandTrackEmbed,
playerTrackToData,
trackToData,
} from './buildTrackEmbed'
import { detectSource } from '../../music/nowPlayingEmbed'
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
})
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<TrackEmbedKind, string> = {
Expand All @@ -23,6 +26,18 @@ const KIND_LABELS: Record<TrackEmbedKind, string> = {
history: 'From History',
}

type TrackDataSource = Pick<
Track<TrackMetadata>,
'title' | 'author' | 'url' | 'thumbnail' | 'durationMS' | 'source'
> &
Partial<Pick<Track<TrackMetadata>, 'metadata'>>

type PlayerTrackDataSource = Pick<
Track<unknown>,
'title' | 'author' | 'url' | 'thumbnail' | 'durationMS' | 'source'
> &
Partial<Pick<Track<unknown>, 'metadata'>>

export type TrackEmbedOptions = {
/**
* A pre-rendered playback progress bar (e.g. discord-player's
Expand Down Expand Up @@ -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',
Expand All @@ -79,17 +108,31 @@ export function buildTrackEmbed(
}

export function buildCommandTrackEmbed(
track: Track,
track: PlayerTrackDataSource,
statusLabel: string,
requestedBy: Pick<User, 'tag' | 'displayAvatarURL'>,
): ReturnType<typeof buildTrackEmbed> {
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,
Expand All @@ -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 }
}
1 change: 1 addition & 0 deletions packages/bot/src/utils/general/responseEmbeds/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
buildTrackEmbed,
buildCommandTrackEmbed,
playerTrackToData,
trackToData,
type TrackEmbedKind,
type TrackData,
Expand Down
4 changes: 3 additions & 1 deletion packages/bot/src/utils/music/trackFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Track, 'source'>,
): TrackSource | undefined {
const source: unknown = track.source
return typeof source === 'string' ? (source as TrackSource) : undefined
}
Expand Down
15 changes: 15 additions & 0 deletions packages/frontend/src/components/Music/QueueList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<QueueList
tracks={tracks}
onRemove={onRemove}
onMove={onMove}
onClear={onClear}
/>,
)

expect(screen.getByText('similar vibes')).toBeInTheDocument()
})

test('shows singular track count for 1 track', () => {
render(
<QueueList
Expand Down
8 changes: 8 additions & 0 deletions packages/frontend/src/components/Music/QueueList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ const QueueItem = memo(function QueueItem({
<p className='type-meta text-lucky-text-secondary truncate'>
{track.author}
</p>
{track.recommendationReason ? (
<p
className='type-meta text-lucky-text-tertiary truncate'
title={track.recommendationReason}
>
{track.recommendationReason}
</p>
) : null}
</div>

<span className='type-meta text-lucky-text-secondary tabular-nums shrink-0 hidden sm:block'>
Expand Down
13 changes: 11 additions & 2 deletions packages/frontend/src/pages/Music.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -208,9 +210,16 @@ function NowPlayingHero({
<h2 className='type-h2 text-lucky-text-primary mb-1 line-clamp-2'>
{currentTrack.title || t('music.unknown')}
</h2>
<p className='type-body text-lucky-text-secondary mb-4'>
<p className='type-body text-lucky-text-secondary mb-1'>
{currentTrack.author || t('music.unknown')}
</p>
{currentTrack.recommendationReason ? (
<p className='type-body-sm text-lucky-text-tertiary mb-4 line-clamp-2'>
{currentTrack.recommendationReason}
</p>
) : (
<div className='mb-4' />
)}
</div>

<div>
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/types/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/music.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface TrackMetadata {
isAutoplay?: boolean
source?: string
engine?: string
recommendationReason?: string
}

export interface TrackTimestamp {
Expand Down
Loading