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
44 changes: 43 additions & 1 deletion packages/bot/src/functions/music/commands/autoplay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jest.mock('@lucky/shared/utils', () => ({

const getGuildSettingsMock = jest.fn()
const updateGuildSettingsMock = jest.fn()
const getLastFmLinkMock = jest.fn()

jest.mock('@lucky/shared/services', () => ({
guildSettingsService: {
Expand All @@ -60,6 +61,9 @@ jest.mock('@lucky/shared/services', () => ({
getAutoplayStats: (...args: unknown[]) =>
trackHistoryServiceMock(...args),
},
lastFmLinkService: {
getByDiscordId: (...args: unknown[]) => getLastFmLinkMock(...args),
},
}))

jest.mock('../../../services/musicRecommendation/feedbackService', () => ({
Expand Down Expand Up @@ -94,10 +98,11 @@ function createInteraction(
return interaction as any
}

function createQueue() {
function createQueue(metadata: Record<string, unknown> = {}) {
return {
guild: { id: 'guild-1' },
currentTrack: { title: 'Current Song' },
metadata,
tracks: {
size: 3,
at: jest.fn((index: number) => {
Expand Down Expand Up @@ -131,6 +136,7 @@ describe('autoplay command', () => {
jest.clearAllMocks()
getGuildSettingsMock.mockResolvedValue({ autoplayMode: 'similar' })
updateGuildSettingsMock.mockResolvedValue(true)
getLastFmLinkMock.mockResolvedValue(null)
})

describe('structure', () => {
Expand Down Expand Up @@ -232,6 +238,42 @@ describe('autoplay command', () => {
expect(embed.title).toContain('Status')
expect(interactionReplyMock).toHaveBeenCalled()
})

it('should show blend info when multiple vc members have last.fm', async () => {
getLastFmLinkMock.mockResolvedValue({ lastFmUsername: 'user' })
const interaction = createInteraction('status')
const queue = createQueue({ vcMemberIds: ['user-1', 'user-2'] })
const client = createClient()

resolveGuildQueueMock.mockReturnValue({ queue })

await autoplayCommand.execute({ client, interaction } as any)

expect(createEmbedMock).toHaveBeenCalledWith(
expect.objectContaining({
description: expect.stringContaining('Blending taste'),
}),
)
})

it('should not show blend when only one member has last.fm', async () => {
getLastFmLinkMock
.mockResolvedValueOnce({ lastFmUsername: 'user1' })
.mockResolvedValueOnce(null)
const interaction = createInteraction('status')
const queue = createQueue({ vcMemberIds: ['user-1', 'user-2'] })
const client = createClient()

resolveGuildQueueMock.mockReturnValue({ queue })

await autoplayCommand.execute({ client, interaction } as any)

expect(createEmbedMock).toHaveBeenCalledWith(
expect.objectContaining({
description: expect.not.stringContaining('Blending taste'),
}),
)
})
})

describe('error handling', () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/bot/src/functions/music/commands/autoplay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SlashCommandBuilder } from '@discordjs/builders'
import Command from '../../../models/Command'
import { interactionReply } from '../../../utils/general/interactionReply'
import { guildSettingsService } from '@lucky/shared/services'
import { guildSettingsService, lastFmLinkService } from '@lucky/shared/services'
import { recommendationFeedbackService } from '../../../services/musicRecommendation/feedbackService'
import {
createEmbed,
Expand All @@ -16,6 +16,7 @@ import { resolveGuildQueue } from '../../../utils/music/queueResolver'
import { trackHistoryService } from '@lucky/shared/services'
import type { ColorResolvable, ChatInputCommandInteraction } from 'discord.js'
import type { GuildQueue } from 'discord-player'
import type { QueueMetadata } from '../../../types/QueueMetadata'

function isAutoplayTrack(track: any): boolean {
return (track?.metadata as any)?.isAutoplay === true
Expand Down Expand Up @@ -154,9 +155,29 @@ async function handleAutoplayStatus(
}
}

let descriptionLines = [
`**Autoplay tracks queued:** ${autoplayCount}`,
'**Last.fm integration:** Connected',
]

const metadata = queue.metadata as QueueMetadata
const vcMemberIds = metadata?.vcMemberIds ?? []
if (vcMemberIds.length > 1) {
const linkedUsers = await Promise.all(
vcMemberIds.map(async (id) => {
const link = await lastFmLinkService.getByDiscordId(id)
return link?.lastFmUsername ? id : null
}),
)
const linkedCount = linkedUsers.filter((id) => id !== null).length
if (linkedCount > 1) {
descriptionLines.push(`🎭 Blending taste for ${linkedCount} users`)
}
}

const statusEmbed = createEmbed({
title: '📊 Autoplay Status',
description: `**Autoplay tracks queued:** ${autoplayCount}\n**Last.fm integration:** Connected`,
description: descriptionLines.join('\n'),
color: EMBED_COLORS.AUTOPLAY as ColorResolvable,
emoji: EMOJIS.AUTOPLAY,
timestamp: true,
Expand Down
32 changes: 32 additions & 0 deletions packages/bot/src/functions/music/commands/play/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,4 +805,36 @@ describe('play command', () => {
}),
)
})

it('captures vc member ids in queue metadata when voiceChannel.members is set', async () => {
let capturedPlayOptions: any

const client = createClient(
(_track: unknown, _query: unknown, opts: unknown) => {
capturedPlayOptions = opts
return Promise.resolve()
},
)

const interaction = createInteraction('guild-1')
interaction.member.voice.channel.members = new Map([
['user-1', { id: 'user-1' }],
['user-2', { id: 'user-2' }],
['bot-1', { id: 'bot-1' }],
])
client.user = { id: 'bot-1' }

getGuildSettingsMock.mockResolvedValue({ autoPlayEnabled: false })
resolveGuildQueueMock.mockReturnValue({ queue: null })
canAddTracksMock.mockReturnValue({ allowed: true, limit: 10 })

await playCommand.execute({ client, interaction } as any)

const vcMemberIds =
capturedPlayOptions?.nodeOptions?.metadata?.vcMemberIds
expect(vcMemberIds).toBeDefined()
expect(vcMemberIds).toContain('user-1')
expect(vcMemberIds).toContain('user-2')
expect(vcMemberIds).not.toContain('bot-1')
})
})
6 changes: 6 additions & 0 deletions packages/bot/src/functions/music/commands/play/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,17 @@ export default new Command({
)

const searchEngine = resolveSearchEngine(query, provider)
const vcMemberIds = voiceChannel.members
? Array.from(voiceChannel.members.values())
.filter((m) => m.id !== client.user?.id)
.map((m) => m.id)
: []
const playOptions = {
nodeOptions: {
metadata: {
channel: interaction.channel,
requestedBy: interaction.user,
vcMemberIds,
},
connectionTimeout:
ENVIRONMENT_CONFIG.PLAYER.CONNECTION_TIMEOUT,
Expand Down
5 changes: 4 additions & 1 deletion packages/bot/src/lastfm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ export {
scrobble,
} from './lastFmApi'
export type { LastFmTopTrack, LastFmPeriod } from './lastFmApi'
export { consumeLastFmSeedSlice } from '../utils/music/autoplay/lastFmSeeds'
export {
consumeLastFmSeedSlice,
consumeBlendedSeedSlice,
} from '../utils/music/autoplay/lastFmSeeds'
1 change: 1 addition & 0 deletions packages/bot/src/types/QueueMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export interface QueueMetadata {
channel?: TextChannel | null
requestedBy?: User | null
client?: CustomClient | null
vcMemberIds?: string[]
}
102 changes: 102 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
advanceLastFmSeedOffset,
getLastFmCacheOffset,
consumeLastFmSeedSlice,
consumeBlendedSeedSlice,
LASTFM_SEED_COUNT,
} from './lastFmSeeds'

Expand Down Expand Up @@ -428,3 +429,104 @@ describe('consumeLastFmSeedSlice', () => {
expect(slice).toEqual([])
})
})

describe('consumeBlendedSeedSlice', () => {
beforeEach(() => {
jest.clearAllMocks()
getRecentTracksMock.mockResolvedValue([])
})

afterEach(() => {
jest.clearAllMocks()
})

it('returns empty array when userIds is empty', async () => {
const slice = await consumeBlendedSeedSlice([], 5)

expect(slice).toEqual([])
})

it('interleaves tracks from two users round-robin style', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' })
getTopTracksMock.mockResolvedValue([
{ artist: 'UserA1', title: 'SongA1', playCount: 1 },
{ artist: 'UserA2', title: 'SongA2', playCount: 2 },
{ artist: 'UserA3', title: 'SongA3', playCount: 3 },
{ artist: 'UserA4', title: 'SongA4', playCount: 4 },
])

const slice = await consumeBlendedSeedSlice(
['user-alpha', 'user-beta'],
4,
)

expect(slice.length).toBeGreaterThan(0)
expect(slice[0]).toEqual(
expect.objectContaining({
artist: expect.any(String),
title: expect.any(String),
}),
)
})

it('deduplicates identical tracks across users', async () => {
const sharedTrack = {
artist: 'Shared Artist',
title: 'Shared Song',
playCount: 5,
}
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' })
getTopTracksMock.mockResolvedValue([sharedTrack])

const slice = await consumeBlendedSeedSlice(['user-x', 'user-y'], 2)

expect(slice).toHaveLength(1)
expect(slice[0]).toEqual({
artist: 'Shared Artist',
title: 'Shared Song',
})
})

it('respects count limit', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'user123' })
getTopTracksMock.mockResolvedValue([
{ artist: 'A1', title: 'S1', playCount: 1 },
{ artist: 'A2', title: 'S2', playCount: 2 },
{ artist: 'A3', title: 'S3', playCount: 3 },
])

const slice = await consumeBlendedSeedSlice(['user-p', 'user-q'], 2)

expect(slice.length).toBeLessThanOrEqual(2)
})

it('falls back to single-user mode when only one user provided', async () => {
getByDiscordIdMock.mockResolvedValue({ lastFmUsername: 'solo-user' })
getTopTracksMock.mockResolvedValue([
{ artist: 'Solo1', title: 'Track1', playCount: 1 },
{ artist: 'Solo2', title: 'Track2', playCount: 2 },
])

const slice = await consumeBlendedSeedSlice(['solo-user'], 2)

expect(slice.length).toBeGreaterThan(0)
expect(slice[0]).toEqual(
expect.objectContaining({
artist: expect.stringMatching(/^Solo[12]$/),
title: expect.any(String),
}),
)
})

it('returns empty array when all users have no Last.fm link', async () => {
getByDiscordIdMock.mockResolvedValue(null)
getTopTracksMock.mockResolvedValue([])

const slice = await consumeBlendedSeedSlice(
['no-link-1', 'no-link-2'],
5,
)

expect(slice).toEqual([])
})
})
26 changes: 26 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,29 @@ export async function consumeLastFmSeedSlice(
consumeLocks.set(userId, next)
return next
}

export async function consumeBlendedSeedSlice(
userIds: string[],
count: number,
): Promise<{ artist: string; title: string }[]> {
if (userIds.length === 0) return []

const perUserCount = Math.ceil(count / userIds.length)
const slices = await Promise.all(
userIds.map((id) => consumeLastFmSeedSlice(id, perUserCount)),
)

const interleaved: { artist: string; title: string }[] = []
const maxLen = Math.max(...slices.map((s) => s.length))

for (let i = 0; i < maxLen; i++) {
for (const slice of slices) {
if (i < slice.length) {
interleaved.push(slice[i])
}
}
}

const deduped = deduplicateTracks(interleaved)
return deduped.slice(0, count)
}
Loading