From 0f43e7de12e0bf4522cbf4cec5c59a079d9777fa Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 15 Mar 2026 13:07:30 -0300 Subject: [PATCH 1/3] feat(bot): show recommendation reason tags in queue display (#266) --- CHANGELOG.md | 5 + .../music/commands/queue/queueDisplay.spec.ts | 105 ++++++++++++++++++ .../music/commands/queue/queueDisplay.ts | 9 +- .../music/commands/queue/queueEmbed.ts | 12 +- .../functions/music/commands/queue/types.ts | 2 + 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7529e6cb..7539113fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (removed videos, geo-blocked content, dead links) in addition to the existing structural check. Configurable via `QUEUE_RESCUE_PROBE_TIMEOUT_MS` and `QUEUE_RESCUE_REFILL_THRESHOLD` env vars. +- `/queue show` upcoming tracks list now appends an inline **recommendation + reason tag** (e.g. `fresh artist rotation`, `similar title mood`) next to + autoplay-sourced tracks, making the autoplay decision transparent at a glance. +- **Now Playing** section in `/queue show` appends a `Recommended because:` + line when the current track originated from autoplay. ### Changed diff --git a/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts b/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts new file mode 100644 index 000000000..f8266ac4e --- /dev/null +++ b/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { createTrackListDisplay, formatTrackForDisplay } from './queueDisplay' +import type { QueueDisplayOptions } from './types' + +const getTrackInfoMock = jest.fn() + +jest.mock('../../../../utils/music/trackUtils', () => ({ + getTrackInfo: (...args: unknown[]) => getTrackInfoMock(...args), +})) + +jest.mock('../../../../utils/music/titleComparison', () => ({ + isSimilarTitle: jest.fn(() => Promise.resolve(false)), +})) + +const defaultOptions: QueueDisplayOptions = { + showCurrentTrack: true, + showUpcomingTracks: true, + maxTracksToShow: 10, + showTotalDuration: true, + showQueueStats: true, +} + +function createTrack(overrides: Record = {}): unknown { + return { + title: 'Test Track', + author: 'Test Artist', + url: 'https://example.com/track', + thumbnail: undefined, + requestedBy: undefined, + metadata: {}, + ...overrides, + } +} + +describe('queueDisplay', () => { + beforeEach(() => { + jest.clearAllMocks() + getTrackInfoMock.mockResolvedValue({ duration: '3:30' }) + }) + + describe('formatTrackForDisplay', () => { + it('returns basic display info without recommendation reason for normal tracks', async () => { + const track = createTrack() + + const result = await formatTrackForDisplay(track as any, 1, defaultOptions) + + expect(result.title).toBe('Test Track') + expect(result.author).toBe('Test Artist') + expect(result.isAutoplay).toBeUndefined() + expect(result.recommendationReason).toBeUndefined() + }) + + it('surfaces isAutoplay and recommendationReason from track metadata', async () => { + const track = createTrack({ + metadata: { isAutoplay: true, recommendationReason: 'fresh artist rotation' }, + }) + + const result = await formatTrackForDisplay(track as any, 1, defaultOptions) + + expect(result.isAutoplay).toBe(true) + expect(result.recommendationReason).toBe('fresh artist rotation') + }) + }) + + describe('createTrackListDisplay', () => { + it('renders plain track entry without reason tag for non-autoplay tracks', async () => { + const track = createTrack() + const result = await createTrackListDisplay([track as any], defaultOptions) + + expect(result).toContain('[Test Track]') + expect(result).not.toContain('_') + }) + + it('appends recommendation reason tag for autoplay tracks', async () => { + const track = createTrack({ + metadata: { isAutoplay: true, recommendationReason: 'fresh artist rotation' }, + }) + + const result = await createTrackListDisplay([track as any], defaultOptions) + + expect(result).toContain('fresh artist rotation') + expect(result).toContain('_fresh artist rotation_') + }) + + it('does not append reason tag when isAutoplay is true but reason is empty', async () => { + const track = createTrack({ + metadata: { isAutoplay: true, recommendationReason: '' }, + }) + + const result = await createTrackListDisplay([track as any], defaultOptions) + + expect(result).not.toContain('_') + }) + + it('shows overflow message when tracks exceed maxTracksToShow', async () => { + const tracks = Array.from({ length: 15 }, (_, i) => + createTrack({ title: `Track ${i}` }), + ) + const options = { ...defaultOptions, maxTracksToShow: 10 } + const result = await createTrackListDisplay(tracks as any[], options) + + expect(result).toContain('5 more tracks') + }) + }) +}) diff --git a/packages/bot/src/functions/music/commands/queue/queueDisplay.ts b/packages/bot/src/functions/music/commands/queue/queueDisplay.ts index 82bff9839..f02844af2 100644 --- a/packages/bot/src/functions/music/commands/queue/queueDisplay.ts +++ b/packages/bot/src/functions/music/commands/queue/queueDisplay.ts @@ -12,6 +12,7 @@ export async function formatTrackForDisplay( _options: QueueDisplayOptions, ): Promise { const trackInfo = await getTrackInfo(track) + const metadata = (track.metadata ?? {}) as { isAutoplay?: boolean; recommendationReason?: string } return { title: track.title, @@ -21,6 +22,8 @@ export async function formatTrackForDisplay( thumbnail: track.thumbnail, requestedBy: track.requestedBy?.username, position, + isAutoplay: metadata.isAutoplay, + recommendationReason: metadata.recommendationReason, } } @@ -38,7 +41,11 @@ export async function createTrackListDisplay( const track = displayTracks[i] const trackInfo = await formatTrackForDisplay(track, i + 1, options) - const trackDisplay = `${i + 1}. [${trackInfo.title}](${trackInfo.url}) - ${trackInfo.author} (${trackInfo.duration})` + const reasonTag = + trackInfo.isAutoplay && trackInfo.recommendationReason + ? ` — _${trackInfo.recommendationReason}_` + : '' + const trackDisplay = `${i + 1}. [${trackInfo.title}](${trackInfo.url}) - ${trackInfo.author} (${trackInfo.duration})${reasonTag}` trackDisplays.push(trackDisplay) } diff --git a/packages/bot/src/functions/music/commands/queue/queueEmbed.ts b/packages/bot/src/functions/music/commands/queue/queueEmbed.ts index a78dbd976..387967c0e 100644 --- a/packages/bot/src/functions/music/commands/queue/queueEmbed.ts +++ b/packages/bot/src/functions/music/commands/queue/queueEmbed.ts @@ -16,9 +16,17 @@ function addCurrentTrackInfo( ): void { if (options.showCurrentTrack && queue.currentTrack) { const { currentTrack } = queue + const metadata = (currentTrack.metadata ?? {}) as { + isAutoplay?: boolean + recommendationReason?: string + } + const reasonLine = + metadata.isAutoplay && metadata.recommendationReason + ? `\nRecommended because: _${metadata.recommendationReason}_` + : '' embed.addFields({ - name: '🎵 Now Playing', - value: `[${currentTrack.title}](${currentTrack.url}) by **${currentTrack.author}**`, + name: '\u{1f3b5} Now Playing', + value: `[${currentTrack.title}](${currentTrack.url}) by **${currentTrack.author}**${reasonLine}`, inline: false, }) diff --git a/packages/bot/src/functions/music/commands/queue/types.ts b/packages/bot/src/functions/music/commands/queue/types.ts index d24b7b316..7ee24d5f6 100644 --- a/packages/bot/src/functions/music/commands/queue/types.ts +++ b/packages/bot/src/functions/music/commands/queue/types.ts @@ -33,4 +33,6 @@ export type TrackDisplayInfo = { thumbnail?: string requestedBy?: string position: number + isAutoplay?: boolean + recommendationReason?: string } From 0963bbf08d712e9b4c1a7383240a4e3928a133b6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 15 Mar 2026 13:20:59 -0300 Subject: [PATCH 2/3] test(bot): add coverage for findSimilarTracksInQueue and createQueueSummary --- .../music/commands/queue/queueDisplay.spec.ts | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts b/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts index f8266ac4e..9017fe1f7 100644 --- a/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts +++ b/packages/bot/src/functions/music/commands/queue/queueDisplay.spec.ts @@ -1,15 +1,21 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { createTrackListDisplay, formatTrackForDisplay } from './queueDisplay' +import { + createQueueSummary, + createTrackListDisplay, + findSimilarTracksInQueue, + formatTrackForDisplay, +} from './queueDisplay' import type { QueueDisplayOptions } from './types' const getTrackInfoMock = jest.fn() +const isSimilarTitleMock = jest.fn() jest.mock('../../../../utils/music/trackUtils', () => ({ getTrackInfo: (...args: unknown[]) => getTrackInfoMock(...args), })) jest.mock('../../../../utils/music/titleComparison', () => ({ - isSimilarTitle: jest.fn(() => Promise.resolve(false)), + isSimilarTitle: (...args: unknown[]) => isSimilarTitleMock(...args), })) const defaultOptions: QueueDisplayOptions = { @@ -36,6 +42,7 @@ describe('queueDisplay', () => { beforeEach(() => { jest.clearAllMocks() getTrackInfoMock.mockResolvedValue({ duration: '3:30' }) + isSimilarTitleMock.mockResolvedValue(false) }) describe('formatTrackForDisplay', () => { @@ -102,4 +109,53 @@ describe('queueDisplay', () => { expect(result).toContain('5 more tracks') }) }) + + describe('findSimilarTracksInQueue', () => { + it('returns tracks whose titles are similar to the current track', async () => { + const current = createTrack({ title: 'Amazing Song' }) + const similar = createTrack({ title: 'Amazing Song (Remix)' }) + const different = createTrack({ title: 'Something Else' }) + + isSimilarTitleMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const result = await findSimilarTracksInQueue( + current as any, + [similar as any, different as any], + ) + + expect(result).toHaveLength(1) + expect(result[0]).toBe(similar) + }) + + it('returns empty array when no tracks are similar', async () => { + const current = createTrack({ title: 'Amazing Song' }) + const unrelated = createTrack({ title: 'Completely Different' }) + + isSimilarTitleMock.mockResolvedValue(false) + + const result = await findSimilarTracksInQueue(current as any, [unrelated as any]) + + expect(result).toHaveLength(0) + }) + }) + + describe('createQueueSummary', () => { + it('returns total tracks and duration without position when currentPosition is 0', () => { + const result = createQueueSummary(10, '45:00', 0) + + expect(result).toContain('**Total Tracks:** 10') + expect(result).toContain('**Total Duration:** 45:00') + expect(result).not.toContain('Current Position') + }) + + it('includes current position formatted as M:SS when currentPosition is greater than 0', () => { + const result = createQueueSummary(5, '20:00', 90) + + expect(result).toContain('**Total Tracks:** 5') + expect(result).toContain('**Total Duration:** 20:00') + expect(result).toContain('**Current Position:** 1:30') + }) + }) }) From f828cabdaed529b021dc52aa7b56c3a1fdf1d2c5 Mon Sep 17 00:00:00 2001 From: LucasSantana-Dev Date: Sun, 15 Mar 2026 17:30:15 -0300 Subject: [PATCH 3/3] test(bot): add queueEmbed unit tests for reason-tag coverage Cover createQueueEmbed, createEmptyQueueEmbed, and createQueueErrorEmbed. Key cases: now-playing field with/without autoplay reason line, thumbnail set/skip, upcoming tracks populated/empty, stats fields, option flags. --- .../music/commands/queue/queueEmbed.spec.ts | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/bot/src/functions/music/commands/queue/queueEmbed.spec.ts diff --git a/packages/bot/src/functions/music/commands/queue/queueEmbed.spec.ts b/packages/bot/src/functions/music/commands/queue/queueEmbed.spec.ts new file mode 100644 index 000000000..f9ed5aed8 --- /dev/null +++ b/packages/bot/src/functions/music/commands/queue/queueEmbed.spec.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { createQueueEmbed, createEmptyQueueEmbed, createQueueErrorEmbed } from './queueEmbed' +import type { QueueDisplayOptions } from './types' + +const addFieldsMock = jest.fn().mockReturnThis() +const setThumbnailMock = jest.fn().mockReturnThis() +const mockEmbed = { addFields: addFieldsMock, setThumbnail: setThumbnailMock } + +const createEmbedMock = jest.fn(() => mockEmbed) +const calculateQueueStatsMock = jest.fn() +const getQueueStatusMock = jest.fn() +const createTrackListDisplayMock = jest.fn() +const createQueueSummaryMock = jest.fn() + +jest.mock('../../../../utils/general/embeds', () => ({ + createEmbed: (...args: unknown[]) => createEmbedMock(...args), + EMBED_COLORS: { QUEUE: '#00b0f4', ERROR: '#ed4245' }, + EMOJIS: { QUEUE: '📄', ERROR: '❌' }, +})) + +jest.mock('./queueStats', () => ({ + calculateQueueStats: (...args: unknown[]) => calculateQueueStatsMock(...args), + getQueueStatus: (...args: unknown[]) => getQueueStatusMock(...args), +})) + +jest.mock('./queueDisplay', () => ({ + createTrackListDisplay: (...args: unknown[]) => createTrackListDisplayMock(...args), + createQueueSummary: (...args: unknown[]) => createQueueSummaryMock(...args), +})) + +const defaultOptions: QueueDisplayOptions = { + showCurrentTrack: true, + showUpcomingTracks: true, + maxTracksToShow: 10, + showTotalDuration: true, + showQueueStats: true, +} + +function createQueue(overrides: Record = {}): unknown { + return { + currentTrack: null, + tracks: { toArray: () => [] }, + ...overrides, + } +} + +function createTrack(overrides: Record = {}): unknown { + return { + title: 'Test Track', + author: 'Test Artist', + url: 'https://example.com/track', + thumbnail: undefined, + metadata: {}, + ...overrides, + } +} + +describe('queueEmbed', () => { + beforeEach(() => { + jest.clearAllMocks() + createEmbedMock.mockReturnValue(mockEmbed as any) + calculateQueueStatsMock.mockResolvedValue({ + totalTracks: 2, + totalDuration: '7:00', + currentPosition: 0, + }) + getQueueStatusMock.mockReturnValue('Playing') + createTrackListDisplayMock.mockResolvedValue('1. Track One\n2. Track Two') + createQueueSummaryMock.mockReturnValue('**Total Tracks:** 2') + }) + + describe('createQueueEmbed', () => { + it('adds now-playing field without reason line for non-autoplay tracks', async () => { + const track = createTrack() + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + const nowPlayingCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '\u{1f3b5} Now Playing', + ) + expect(nowPlayingCall).toBeDefined() + const field = nowPlayingCall![0] as { value: string } + expect(field.value).toBe( + '[Test Track](https://example.com/track) by **Test Artist**', + ) + expect(field.value).not.toContain('Recommended because') + }) + + it('appends recommendation reason line for autoplay tracks with a reason', async () => { + const track = createTrack({ + metadata: { isAutoplay: true, recommendationReason: 'fresh artist rotation' }, + }) + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + const nowPlayingCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '\u{1f3b5} Now Playing', + ) + expect(nowPlayingCall).toBeDefined() + const field = nowPlayingCall![0] as { value: string } + expect(field.value).toContain( + '\nRecommended because: _fresh artist rotation_', + ) + }) + + it('does not append reason line when isAutoplay is true but reason is absent', async () => { + const track = createTrack({ metadata: { isAutoplay: true } }) + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + const nowPlayingCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '\u{1f3b5} Now Playing', + ) + expect(nowPlayingCall).toBeDefined() + const field = nowPlayingCall![0] as { value: string } + expect(field.value).not.toContain('Recommended because') + }) + + it('does not append reason line when recommendationReason is set but isAutoplay is false', async () => { + const track = createTrack({ + metadata: { isAutoplay: false, recommendationReason: 'some reason' }, + }) + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + const nowPlayingCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '\u{1f3b5} Now Playing', + ) + expect(nowPlayingCall).toBeDefined() + const field = nowPlayingCall![0] as { value: string } + expect(field.value).not.toContain('Recommended because') + }) + + it('sets thumbnail when track has one', async () => { + const track = createTrack({ thumbnail: 'https://example.com/thumb.jpg' }) + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + expect(setThumbnailMock).toHaveBeenCalledWith('https://example.com/thumb.jpg') + }) + + it('does not call setThumbnail when track has no thumbnail', async () => { + const track = createTrack({ thumbnail: undefined }) + const queue = createQueue({ currentTrack: track }) + + await createQueueEmbed(queue as any, defaultOptions) + + expect(setThumbnailMock).not.toHaveBeenCalled() + }) + + it('adds upcoming tracks field when tracks exist in queue', async () => { + const track = createTrack() + const queue = createQueue({ + currentTrack: null, + tracks: { toArray: () => [track] }, + }) + + await createQueueEmbed(queue as any, defaultOptions) + + const upcomingCall = addFieldsMock.mock.calls.find( + (call) => String((call[0] as any).name).startsWith('📋 Upcoming Tracks ('), + ) + expect(upcomingCall).toBeDefined() + }) + + it('adds empty upcoming tracks field when queue is empty', async () => { + const queue = createQueue({ currentTrack: null, tracks: { toArray: () => [] } }) + + await createQueueEmbed(queue as any, defaultOptions) + + const emptyCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).value === 'No tracks in queue', + ) + expect(emptyCall).toBeDefined() + }) + + it('adds queue stats and status fields', async () => { + const queue = createQueue() + + await createQueueEmbed(queue as any, defaultOptions) + + const statsCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '📊 Queue Statistics', + ) + const statusCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '🎛️ Status', + ) + expect(statsCall).toBeDefined() + expect(statusCall).toBeDefined() + }) + + it('skips now-playing field when showCurrentTrack is false', async () => { + const track = createTrack() + const queue = createQueue({ currentTrack: track }) + const options = { ...defaultOptions, showCurrentTrack: false } + + await createQueueEmbed(queue as any, options) + + const nowPlayingCall = addFieldsMock.mock.calls.find( + (call) => (call[0] as any).name === '\u{1f3b5} Now Playing', + ) + expect(nowPlayingCall).toBeUndefined() + }) + + it('skips upcoming tracks field when showUpcomingTracks is false', async () => { + const track = createTrack() + const queue = createQueue({ tracks: { toArray: () => [track] } }) + const options = { ...defaultOptions, showUpcomingTracks: false } + + await createQueueEmbed(queue as any, options) + + const upcomingCall = addFieldsMock.mock.calls.find( + (call) => String((call[0] as any).name ?? '').startsWith('📋 Upcoming'), + ) + expect(upcomingCall).toBeUndefined() + }) + + it('returns the embed instance', async () => { + const queue = createQueue() + + const result = await createQueueEmbed(queue as any, defaultOptions) + + expect(result).toBe(mockEmbed) + }) + }) + + describe('createEmptyQueueEmbed', () => { + it('calls createEmbed with empty queue description', () => { + createEmptyQueueEmbed() + + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'The queue is empty. Add some tracks to get started!', + }), + ) + }) + }) + + describe('createQueueErrorEmbed', () => { + it('calls createEmbed with the provided error message', () => { + createQueueErrorEmbed('Something went wrong') + + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Something went wrong', + }), + ) + }) + }) +})