From ecd23ddf2fc16123c8245102ab41a2ec48e1a770 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 00:29:37 -0300 Subject: [PATCH 1/7] feat(bot): add artist-level feedback methods to feedbackService - add setArtistFeedback, removeArtistFeedback methods - add getPreferredArtistKeys, getBlockedArtistKeys methods - add getArtistFeedbackSummary for listing user's preferences - store as separate Redis hash with 30d TTL - normalize artist names using cleanAuthor + normalizeText --- .../musicRecommendation/feedbackService.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.ts b/packages/bot/src/services/musicRecommendation/feedbackService.ts index 5b3804d63..4a2d60bac 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.ts @@ -1,7 +1,9 @@ import { redisClient } from '@lucky/shared/services' import { errorLog } from '@lucky/shared/utils' +import { cleanAuthor } from '../../utils/music/searchQueryCleaner' export type RecommendationFeedback = 'like' | 'dislike' +export type ArtistFeedback = 'prefer' | 'block' type FeedbackEntry = { feedback: RecommendationFeedback @@ -170,6 +172,132 @@ export class RecommendationFeedbackService { return { liked, disliked } } + + private getArtistFeedbackRedisKey(userId: string): string { + return `music:artist_feedback:${userId}` + } + + private async getArtistFeedbackMap(userId: string): Promise> { + const key = this.getArtistFeedbackRedisKey(userId) + try { + const value = await redisClient.get(key) + if (!value) return {} + const parsed = JSON.parse(value) as Record + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (error) { + errorLog({ + message: 'Failed to load artist feedback map', + error, + }) + return {} + } + } + + private async saveArtistFeedbackMap( + userId: string, + map: Record, + ): Promise { + const key = this.getArtistFeedbackRedisKey(userId) + const ttlSeconds = this.ttlDays * 24 * 60 * 60 + + await redisClient.setex(key, ttlSeconds, JSON.stringify(map)) + } + + private normalizeArtistKey(artistName: string): string { + const cleaned = cleanAuthor(artistName) + return cleaned + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '') + .trim() + } + + async setArtistFeedback( + guildId: string, + userId: string, + artistName: string, + feedback: ArtistFeedback, + ): Promise { + try { + const artistKey = this.normalizeArtistKey(artistName) + if (!artistKey) return + + const map = await this.getArtistFeedbackMap(userId) + map[artistKey] = feedback + await this.saveArtistFeedbackMap(userId, map) + } catch (error) { + errorLog({ + message: 'Failed to store artist feedback', + error, + data: { guildId }, + }) + } + } + + async removeArtistFeedback( + guildId: string, + userId: string, + artistName: string, + ): Promise { + try { + const artistKey = this.normalizeArtistKey(artistName) + if (!artistKey) return + + const map = await this.getArtistFeedbackMap(userId) + delete map[artistKey] + await this.saveArtistFeedbackMap(userId, map) + } catch (error) { + errorLog({ + message: 'Failed to remove artist feedback', + error, + data: { guildId }, + }) + } + } + + async getPreferredArtistKeys( + guildId: string, + userId: string | undefined, + ): Promise> { + if (!userId) return new Set() + + const map = await this.getArtistFeedbackMap(userId) + return new Set( + Object.entries(map) + .filter(([, feedback]) => feedback === 'prefer') + .map(([artistKey]) => artistKey), + ) + } + + async getBlockedArtistKeys( + guildId: string, + userId: string | undefined, + ): Promise> { + if (!userId) return new Set() + + const map = await this.getArtistFeedbackMap(userId) + return new Set( + Object.entries(map) + .filter(([, feedback]) => feedback === 'block') + .map(([artistKey]) => artistKey), + ) + } + + async getArtistFeedbackSummary( + userId: string | undefined, + ): Promise<{ preferred: string[]; blocked: string[] }> { + if (!userId) return { preferred: [], blocked: [] } + + const map = await this.getArtistFeedbackMap(userId) + const preferred: string[] = [] + const blocked: string[] = [] + + for (const [artistKey, feedback] of Object.entries(map)) { + if (feedback === 'prefer') preferred.push(artistKey) + else if (feedback === 'block') blocked.push(artistKey) + } + + return { preferred, blocked } + } } export const recommendationFeedbackService = new RecommendationFeedbackService( From 1aef6fb263d351aa1eb4f850d03955d87c4a5ff6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 00:31:27 -0300 Subject: [PATCH 2/7] feat(bot): wire artist preferences into autoplay scoring - update calculateRecommendationScore to accept preferred/blocked artist keys - return -Infinity score for blocked artists (exclude from candidates) - add +0.3 boost for preferred artists - fetch artist preference sets in _replenishQueue - pass artist keys to all candidate collection functions - skip candidates with -Infinity scores --- .../bot/src/utils/music/queueManipulation.ts | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index 347ad5536..ea0b50c38 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -239,6 +239,8 @@ async function _replenishQueue( const [ dislikedTrackKeys, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, persistentHistory, guildSettings, ] = await Promise.all([ @@ -250,6 +252,14 @@ async function _replenishQueue( queue.guild.id, requestedBy?.id, ), + recommendationFeedbackService.getPreferredArtistKeys( + queue.guild.id, + requestedBy?.id, + ), + recommendationFeedbackService.getBlockedArtistKeys( + queue.guild.id, + requestedBy?.id, + ), trackHistoryService.getTrackHistory(queue.guild.id, 100), guildSettingsService.getGuildSettings(queue.guild.id), ]) @@ -294,6 +304,8 @@ async function _replenishQueue( excludedKeys, dislikedTrackKeys, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, currentTrack, recentArtists, replenishCount, @@ -307,6 +319,8 @@ async function _replenishQueue( excludedKeys, dislikedTrackKeys, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, currentTrack, recentArtists, candidates, @@ -339,6 +353,8 @@ async function _replenishQueue( excludedKeys, dislikedTrackKeys, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, recentArtists, candidates, autoplayMode, @@ -466,6 +482,8 @@ async function collectRecommendationCandidates( excludedKeys: Set, dislikedTrackKeys: Set, likedTrackKeys: Set, + preferredArtistKeys: Set, + blockedArtistKeys: Set, currentTrack: Track, recentArtists: Set, replenishCount = 0, @@ -493,17 +511,18 @@ async function collectRecommendationCandidates( if (dislikedTrackKeys.has(normalizedKey)) { continue } - upsertScoredCandidate( - candidates, + const rec = calculateRecommendationScore( candidate, - calculateRecommendationScore( - candidate, - currentTrack, - recentArtists, - likedTrackKeys, - autoplayMode, - ), + currentTrack, + recentArtists, + likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, + autoplayMode, ) + if (rec.score !== -Infinity) { + upsertScoredCandidate(candidates, candidate, rec) + } } } @@ -564,6 +583,8 @@ async function collectBroadFallbackCandidates( excludedKeys: Set, dislikedTrackKeys: Set, likedTrackKeys: Set, + preferredArtistKeys: Set, + blockedArtistKeys: Set, recentArtists: Set, candidates: Map, autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', @@ -598,8 +619,11 @@ async function collectBroadFallbackCandidates( currentTrack, recentArtists, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, autoplayMode, ) + if (rec.score === -Infinity) continue upsertScoredCandidate(candidates, track, { score: rec.score - 0.1, reason: rec.reason @@ -649,6 +673,8 @@ async function collectLastFmCandidates( excludedKeys: Set, dislikedTrackKeys: Set, likedTrackKeys: Set, + preferredArtistKeys: Set, + blockedArtistKeys: Set, currentTrack: Track, recentArtists: Set, candidates: Map, @@ -674,8 +700,11 @@ async function collectLastFmCandidates( currentTrack, recentArtists, likedTrackKeys, - autoplayMode, + preferredArtistKeys, + blockedArtistKeys, + autoplayMode, ) + if (rec.score === -Infinity) continue upsertScoredCandidate(candidates, track, { score: rec.score + LASTFM_SCORE_BOOST, reason: rec.reason @@ -702,6 +731,8 @@ async function collectLastFmCandidates( currentTrack, recentArtists, likedTrackKeys, + preferredArtistKeys, + blockedArtistKeys, autoplayMode, ) upsertScoredCandidate(candidates, track, { @@ -1035,12 +1066,25 @@ function calculateRecommendationScore( currentTrack: Track, recentArtists: Set, likedTrackKeys: Set = new Set(), + preferredArtistKeys: Set = new Set(), + blockedArtistKeys: Set = new Set(), autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', ): { score: number; reason: string } { - let score = 1 - const reasons: string[] = [] const currentArtist = currentTrack.author.toLowerCase() const candidateArtist = candidate.author.toLowerCase() + const candidateArtistKey = normalizeText(cleanAuthor(candidate.author)) + + if (blockedArtistKeys.has(candidateArtistKey)) { + return { score: -Infinity, reason: 'blocked artist' } + } + + let score = 1 + const reasons: string[] = [] + + if (preferredArtistKeys.has(candidateArtistKey)) { + score += 0.3 + reasons.push('preferred artist') + } const candidateKey = normalizeTrackKey(candidate.title, candidate.author) if (likedTrackKeys.has(candidateKey)) { From 80b51fd8b06e6bd74a75e16fc06d4429931c912c Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 00:36:08 -0300 Subject: [PATCH 3/7] feat: add /autoplay artist subcommand for managing artist preferences - Add handleAutoplayArtist handler with prefer, block, remove, list subcommands - Integrate with recommendationFeedbackService for artist preference storage - Display preferred/blocked artists with emoji indicators and counts - List subcommand shows all preferred and blocked artists for user --- .../src/functions/music/commands/autoplay.ts | 280 +++++++++++++++++- .../bot/src/utils/music/queueManipulation.ts | 12 +- 2 files changed, 288 insertions(+), 4 deletions(-) diff --git a/packages/bot/src/functions/music/commands/autoplay.ts b/packages/bot/src/functions/music/commands/autoplay.ts index 0d3e3660a..6b89db539 100644 --- a/packages/bot/src/functions/music/commands/autoplay.ts +++ b/packages/bot/src/functions/music/commands/autoplay.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from '@discordjs/builders' import Command from '../../../models/Command' import { interactionReply } from '../../../utils/general/interactionReply' import { guildSettingsService } from '@lucky/shared/services' +import { recommendationFeedbackService } from '../../../services/musicRecommendation/feedbackService' import { createEmbed, createErrorEmbed, @@ -590,6 +591,230 @@ async function handleAutoplayGenre( } } +async function handleAutoplayArtist( + interaction: ChatInputCommandInteraction, +): Promise { + const userId = interaction.user.id + const guildId = interaction.guildId + if (!guildId) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Guild Not Found', + 'Unable to retrieve guild information.', + ), + ], + ephemeral: true, + }, + }) + return + } + + const subcommandName = interaction.options.getSubcommand() + const artistName = interaction.options.getString('artist') + + try { + switch (subcommandName) { + case 'prefer': { + if (!artistName) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Missing Input', + 'Please provide an artist name.', + ), + ], + ephemeral: true, + }, + }) + return + } + + await recommendationFeedbackService.setArtistFeedback( + guildId, + userId, + artistName, + 'prefer', + ) + + const preferEmbed = createEmbed({ + title: '⭐ Artist Preferred', + description: `**${artistName}** will be prioritized in autoplay recommendations.`, + color: EMBED_COLORS.AUTOPLAY as ColorResolvable, + emoji: EMOJIS.AUTOPLAY, + timestamp: true, + }) + + await interactionReply({ + interaction, + content: { + embeds: [preferEmbed], + ephemeral: true, + }, + }) + break + } + + case 'block': { + if (!artistName) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Missing Input', + 'Please provide an artist name.', + ), + ], + ephemeral: true, + }, + }) + return + } + + await recommendationFeedbackService.setArtistFeedback( + guildId, + userId, + artistName, + 'block', + ) + + const blockEmbed = createEmbed({ + title: '🚫 Artist Blocked', + description: `**${artistName}** will not appear in autoplay recommendations.`, + color: EMBED_COLORS.ERROR as ColorResolvable, + emoji: EMOJIS.AUTOPLAY, + timestamp: true, + }) + + await interactionReply({ + interaction, + content: { + embeds: [blockEmbed], + ephemeral: true, + }, + }) + break + } + + case 'remove': { + if (!artistName) { + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Missing Input', + 'Please provide an artist name.', + ), + ], + ephemeral: true, + }, + }) + return + } + + await recommendationFeedbackService.removeArtistFeedback( + guildId, + userId, + artistName, + ) + + const removeEmbed = createEmbed({ + title: '✓ Preference Removed', + description: `**${artistName}** preference has been removed.`, + color: EMBED_COLORS.AUTOPLAY as ColorResolvable, + emoji: EMOJIS.AUTOPLAY, + timestamp: true, + }) + + await interactionReply({ + interaction, + content: { + embeds: [removeEmbed], + ephemeral: true, + }, + }) + break + } + + case 'list': { + const summary = + await recommendationFeedbackService.getArtistFeedbackSummary( + userId, + ) + + const preferredText = + summary.preferred.length > 0 + ? summary.preferred + .map((a: string) => `⭐ ${a}`) + .join('\n') + : 'No preferred artists.' + + const blockedText = + summary.blocked.length > 0 + ? summary.blocked + .map((a: string) => `🚫 ${a}`) + .join('\n') + : 'No blocked artists.' + + const listEmbed = createEmbed({ + title: '🎯 Your Artist Preferences', + description: `**Preferred:** (${summary.preferred.length})\n${preferredText}\n\n**Blocked:** (${summary.blocked.length})\n${blockedText}`, + color: EMBED_COLORS.AUTOPLAY as ColorResolvable, + emoji: EMOJIS.AUTOPLAY, + timestamp: true, + }) + + await interactionReply({ + interaction, + content: { + embeds: [listEmbed], + ephemeral: true, + }, + }) + break + } + + default: + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Unknown Subcommand', + 'This subcommand is not recognized.', + ), + ], + ephemeral: true, + }, + }) + } + } catch (error) { + errorLog({ + message: 'Failed to handle artist preference', + error, + data: { guildId, userId, subcommandName }, + }) + await interactionReply({ + interaction, + content: { + embeds: [ + createErrorEmbed( + 'Error', + 'Failed to update artist preferences.', + ), + ], + ephemeral: true, + }, + }) + } +} + export default new Command({ data: new SlashCommandBuilder() .setName('autoplay') @@ -681,6 +906,51 @@ export default new Command({ .addSubcommand((sub) => sub.setName('clear').setDescription('Remove all genres'), ), + ) + .addSubcommandGroup((group) => + group + .setName('artist') + .setDescription('Manage artist preferences for autoplay') + .addSubcommand((subcommand) => + subcommand + .setName('prefer') + .setDescription('Mark an artist as preferred') + .addStringOption((opt) => + opt + .setName('artist') + .setDescription('Artist name') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('block') + .setDescription('Block an artist from appearing') + .addStringOption((opt) => + opt + .setName('artist') + .setDescription('Artist name') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('list') + .setDescription( + 'Show your preferred and blocked artists', + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove artist preference') + .addStringOption((opt) => + opt + .setName('artist') + .setDescription('Artist name') + .setRequired(true), + ), + ), ), category: 'music', execute: async ({ client, interaction }: CommandExecuteParams) => { @@ -711,6 +981,11 @@ export default new Command({ return } + if (subcommandGroup === 'artist') { + await handleAutoplayArtist(interaction) + return + } + switch (subcommand) { case 'skip': case 'clear': @@ -742,6 +1017,9 @@ export default new Command({ case 'mode': await handleAutoplayMode(interaction) break + case 'artist': + await handleAutoplayArtist(interaction) + break default: await interactionReply({ interaction, @@ -749,7 +1027,7 @@ export default new Command({ embeds: [ createErrorEmbed( 'Unknown Subcommand', - 'Please use skip, clear, status, analytics, mode, or genre.', + 'Please use skip, clear, status, analytics, or mode.', ), ], ephemeral: true, diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index ea0b50c38..12bccf4ed 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -340,6 +340,8 @@ async function _replenishQueue( currentTrack, excludedUrls, excludedKeys, + preferredArtistKeys, + blockedArtistKeys, autoplayMode, }, ) @@ -700,9 +702,9 @@ async function collectLastFmCandidates( currentTrack, recentArtists, likedTrackKeys, - preferredArtistKeys, - blockedArtistKeys, - autoplayMode, + preferredArtistKeys, + blockedArtistKeys, + autoplayMode, ) if (rec.score === -Infinity) continue upsertScoredCandidate(candidates, track, { @@ -790,6 +792,8 @@ interface CandidateContext { currentTrack: Track excludedUrls: Set excludedKeys: Set + preferredArtistKeys: Set + blockedArtistKeys: Set autoplayMode: 'similar' | 'discover' | 'popular' } @@ -807,6 +811,8 @@ function addGenreTrackCandidate( ctx.currentTrack, ctx.recentArtists, ctx.likedTrackKeys, + ctx.preferredArtistKeys, + ctx.blockedArtistKeys, ctx.autoplayMode, ) upsertScoredCandidate(ctx.candidates, track, { From 5b81a6be5c57399d07b0fcd39109c46ddfdc51fc Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 00:39:32 -0300 Subject: [PATCH 4/7] test(bot): add artist preference tests for feedback and autoplay --- package-lock.json | 29 +-- .../functions/music/commands/autoplay.spec.ts | 2 +- .../feedbackService.spec.ts | 170 +++++++++++++++++- .../src/utils/music/queueManipulation.spec.ts | 8 + 4 files changed, 185 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94e8aa305..41f051c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lucky-bot", - "version": "2.6.82", + "version": "2.6.87", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lucky-bot", - "version": "2.6.82", + "version": "2.6.87", "license": "ISC", "workspaces": [ "packages/*" @@ -20844,6 +20844,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -24259,7 +24260,7 @@ }, "packages/backend": { "name": "@lucky/backend", - "version": "2.6.82", + "version": "2.6.87", "dependencies": { "@lucky/shared": "file:../shared", "connect-redis": "^9.0.0", @@ -24320,7 +24321,7 @@ }, "packages/bot": { "name": "@lucky/bot", - "version": "2.6.82", + "version": "2.6.87", "dependencies": { "@discord-player/extractor": "^7.2.0", "@discordjs/builders": "^1.14.1", @@ -24416,7 +24417,7 @@ }, "packages/frontend": { "name": "lucky-webapp", - "version": "2.6.82", + "version": "2.6.87", "dependencies": { "@hookform/resolvers": "^5.0.0", "@radix-ui/react-avatar": "^1.1.11", @@ -24437,8 +24438,8 @@ "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", - "react": "^19.2.5", - "react-dom": "19.2.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-hook-form": "^7.72.1", "react-router-dom": "^7.14.0", "sonner": "^2.0.7", @@ -25248,18 +25249,6 @@ "node": "^10 || ^12 || >=14" } }, - "packages/frontend/node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, "packages/frontend/node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", @@ -25511,7 +25500,7 @@ }, "packages/shared": { "name": "@lucky/shared", - "version": "2.6.82", + "version": "2.6.87", "dependencies": { "@gar/promise-retry": "^1.0.3", "@npmcli/agent": "^4.0.0", diff --git a/packages/bot/src/functions/music/commands/autoplay.spec.ts b/packages/bot/src/functions/music/commands/autoplay.spec.ts index ce71fb6af..9fccb5e5e 100644 --- a/packages/bot/src/functions/music/commands/autoplay.spec.ts +++ b/packages/bot/src/functions/music/commands/autoplay.spec.ts @@ -623,7 +623,7 @@ describe('autoplay command', () => { expect(createErrorEmbedMock).toHaveBeenCalledWith( 'Unknown Subcommand', - 'Please use skip, clear, status, analytics, mode, or genre.', + 'Please use skip, clear, status, analytics, or mode.', ) expect(interactionReplyMock).toHaveBeenCalled() }) diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts index 4faea1759..1a239eb7b 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts @@ -121,9 +121,21 @@ describe('RecommendationFeedbackService', () => { getMock.mockResolvedValue( JSON.stringify({ - [keyA]: { feedback: 'like', updatedAt: now, expiresAt: now + 1_000_000 }, - [keyB]: { feedback: 'dislike', updatedAt: now, expiresAt: now + 1_000_000 }, - [keyC]: { feedback: 'like', updatedAt: now, expiresAt: now + 1_000_000 }, + [keyA]: { + feedback: 'like', + updatedAt: now, + expiresAt: now + 1_000_000, + }, + [keyB]: { + feedback: 'dislike', + updatedAt: now, + expiresAt: now + 1_000_000, + }, + [keyC]: { + feedback: 'like', + updatedAt: now, + expiresAt: now + 1_000_000, + }, }), ) @@ -183,4 +195,156 @@ describe('RecommendationFeedbackService', () => { expect(result.size).toBe(0) expect(getMock).not.toHaveBeenCalled() }) + + it('setArtistFeedback stores prefer feedback', async () => { + const service = new RecommendationFeedbackService(30) + getMock.mockResolvedValue(null) + setexMock.mockResolvedValue(true) + + await service.setArtistFeedback( + 'guild-1', + 'user-1', + 'Taylor Swift', + 'prefer', + ) + + expect(setexMock).toHaveBeenCalledWith( + 'music:artist_feedback:user-1', + 30 * 24 * 60 * 60, + expect.any(String), + ) + expect(getMock).toHaveBeenCalledWith('music:artist_feedback:user-1') + }) + + it('setArtistFeedback stores block feedback', async () => { + const service = new RecommendationFeedbackService(30) + getMock.mockResolvedValue(null) + setexMock.mockResolvedValue(true) + + await service.setArtistFeedback( + 'guild-1', + 'user-1', + 'Unknown Artist', + 'block', + ) + + expect(setexMock).toHaveBeenCalled() + const callArgs = setexMock.mock.calls[0] + const storedData = JSON.parse(callArgs[2] as string) + expect(Object.values(storedData)[0]).toBe('block') + }) + + it('getPreferredArtistKeys returns preferred artists', async () => { + const service = new RecommendationFeedbackService(30) + const artistKey1 = 'taylorswift' + const artistKey2 = 'arianagrande' + + getMock.mockResolvedValue( + JSON.stringify({ + [artistKey1]: 'prefer', + [artistKey2]: 'prefer', + badartist: 'block', + }), + ) + + const preferred = await service.getPreferredArtistKeys( + 'guild-1', + 'user-1', + ) + + expect(preferred.has(artistKey1)).toBe(true) + expect(preferred.has(artistKey2)).toBe(true) + expect(preferred.has('badartist')).toBe(false) + expect(preferred.size).toBe(2) + }) + + it('getBlockedArtistKeys returns blocked artists', async () => { + const service = new RecommendationFeedbackService(30) + const artistKey1 = 'badartist1' + const artistKey2 = 'badartist2' + + getMock.mockResolvedValue( + JSON.stringify({ + [artistKey1]: 'block', + [artistKey2]: 'block', + goodartist: 'prefer', + }), + ) + + const blocked = await service.getBlockedArtistKeys('guild-1', 'user-1') + + expect(blocked.has(artistKey1)).toBe(true) + expect(blocked.has(artistKey2)).toBe(true) + expect(blocked.has('goodartist')).toBe(false) + expect(blocked.size).toBe(2) + }) + + it('getPreferredArtistKeys returns empty set for undefined userId', async () => { + const service = new RecommendationFeedbackService(30) + + const result = await service.getPreferredArtistKeys( + 'guild-1', + undefined, + ) + + expect(result.size).toBe(0) + expect(getMock).not.toHaveBeenCalled() + }) + + it('getBlockedArtistKeys returns empty set for undefined userId', async () => { + const service = new RecommendationFeedbackService(30) + + const result = await service.getBlockedArtistKeys('guild-1', undefined) + + expect(result.size).toBe(0) + expect(getMock).not.toHaveBeenCalled() + }) + + it('removeArtistFeedback deletes artist preference', async () => { + const service = new RecommendationFeedbackService(30) + getMock.mockResolvedValue( + JSON.stringify({ + taylorswift: 'prefer', + arianagrande: 'prefer', + }), + ) + setexMock.mockResolvedValue(true) + + await service.removeArtistFeedback('guild-1', 'user-1', 'Taylor Swift') + + expect(setexMock).toHaveBeenCalled() + const callArgs = setexMock.mock.calls[0] + const storedData = JSON.parse(callArgs[2] as string) + expect(storedData).not.toHaveProperty('taylorswift') + expect(storedData).toHaveProperty('arianagrande') + }) + + it('getArtistFeedbackSummary returns preferred and blocked lists', async () => { + const service = new RecommendationFeedbackService(30) + getMock.mockResolvedValue( + JSON.stringify({ + artistone: 'prefer', + artisttwo: 'prefer', + badartist: 'block', + }), + ) + + const summary = await service.getArtistFeedbackSummary('user-1') + + expect(summary.preferred).toContain('artistone') + expect(summary.preferred).toContain('artisttwo') + expect(summary.blocked).toContain('badartist') + expect(summary.preferred.length).toBe(2) + expect(summary.blocked.length).toBe(1) + }) + + it('getArtistFeedbackSummary returns empty lists for undefined userId', async () => { + const service = new RecommendationFeedbackService(30) + + const summary = await service.getArtistFeedbackSummary(undefined) + + expect(summary.preferred).toEqual([]) + expect(summary.blocked).toEqual([]) + expect(getMock).not.toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index 0f3bf18ee..da33728e6 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -77,12 +77,18 @@ jest.mock('../../lastfm', () => ({ const dislikedTrackKeysMock = jest.fn() const likedTrackKeysMock = jest.fn() +const getPreferredArtistKeysMock = jest.fn() +const getBlockedArtistKeysMock = jest.fn() jest.mock('../../services/musicRecommendation/feedbackService', () => ({ recommendationFeedbackService: { getDislikedTrackKeys: (...args: unknown[]) => dislikedTrackKeysMock(...args), getLikedTrackKeys: (...args: unknown[]) => likedTrackKeysMock(...args), + getPreferredArtistKeys: (...args: unknown[]) => + getPreferredArtistKeysMock(...args), + getBlockedArtistKeys: (...args: unknown[]) => + getBlockedArtistKeysMock(...args), }, })) @@ -127,6 +133,8 @@ describe('queueManipulation.replenishQueue', () => { beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) From 38e02f680cb613a6ebe89765a73bbaffb7d04c97 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 01:06:49 -0300 Subject: [PATCH 5/7] test(bot): add artist key mocks and fix conflict resolution --- packages/bot/src/utils/music/queueManipulation.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index da33728e6..80faa667e 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -1900,6 +1900,8 @@ describe('queueManipulation.replenishQueue query variation', () => { beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -1989,6 +1991,8 @@ describe('queueManipulation.collectBroadFallbackCandidates diversification', () beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -2029,6 +2033,8 @@ describe('queueManipulation.selectDiverseCandidates score jitter', () => { beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -2087,6 +2093,8 @@ describe('queueManipulation.addSelectedTracks async writes', () => { beforeEach(() => { dislikedTrackKeysMock.mockResolvedValue(new Set()) likedTrackKeysMock.mockResolvedValue(new Set()) + getPreferredArtistKeysMock.mockResolvedValue(new Set()) + getBlockedArtistKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) From efbfa00e50df72fce441746a77fde2432268b107 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 01:20:51 -0300 Subject: [PATCH 6/7] fix(bot): add missing artist feedback methods and test coverage --- .../functions/music/commands/autoplay.spec.ts | 109 ++++++++++++++++++ .../musicRecommendation/feedbackService.ts | 4 +- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/bot/src/functions/music/commands/autoplay.spec.ts b/packages/bot/src/functions/music/commands/autoplay.spec.ts index 9fccb5e5e..5c3192311 100644 --- a/packages/bot/src/functions/music/commands/autoplay.spec.ts +++ b/packages/bot/src/functions/music/commands/autoplay.spec.ts @@ -12,6 +12,9 @@ const debugLogMock = jest.fn() const errorLogMock = jest.fn() const resolveGuildQueueMock = jest.fn() const trackHistoryServiceMock = jest.fn() +const setArtistFeedbackMock = jest.fn() +const removeArtistFeedbackMock = jest.fn() +const getArtistFeedbackSummaryMock = jest.fn() jest.mock('../../../utils/general/interactionReply', () => ({ interactionReply: (...args: unknown[]) => interactionReplyMock(...args), @@ -59,6 +62,17 @@ jest.mock('@lucky/shared/services', () => ({ }, })) +jest.mock('../../../services/musicRecommendation/feedbackService', () => ({ + recommendationFeedbackService: { + setArtistFeedback: (...args: unknown[]) => + setArtistFeedbackMock(...args), + removeArtistFeedback: (...args: unknown[]) => + removeArtistFeedbackMock(...args), + getArtistFeedbackSummary: (...args: unknown[]) => + getArtistFeedbackSummaryMock(...args), + }, +})) + function createInteraction( subcommand = 'skip', subcommandGroup: string | null = null, @@ -595,6 +609,101 @@ describe('autoplay command', () => { }) }) + describe('artist subcommand group', () => { + beforeEach(() => { + setArtistFeedbackMock.mockResolvedValue(undefined) + removeArtistFeedbackMock.mockResolvedValue(undefined) + getArtistFeedbackSummaryMock.mockResolvedValue({ + preferred: [], + blocked: [], + }) + }) + + function createArtistInteraction(subcommand: string, artist?: string) { + const interaction = { + guildId: 'guild-1', + deferred: false, + replied: false, + user: { id: 'user-1' }, + deferReply: jest.fn(async () => { + interaction.deferred = true + }), + options: { + getSubcommand: jest.fn(() => subcommand), + getSubcommandGroup: jest.fn(() => 'artist'), + getString: jest.fn(() => artist ?? null), + }, + } + return interaction as any + } + + it('artist prefer — marks artist as preferred', async () => { + const interaction = createArtistInteraction('prefer', 'The Beatles') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(setArtistFeedbackMock).toHaveBeenCalledWith( + 'guild-1', + 'user-1', + 'The Beatles', + 'prefer', + ) + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining('Preferred'), + }), + ) + }) + + it('artist block — blocks artist from autoplay', async () => { + const interaction = createArtistInteraction('block', 'Artist X') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(setArtistFeedbackMock).toHaveBeenCalledWith( + 'guild-1', + 'user-1', + 'Artist X', + 'block', + ) + }) + + it('artist remove — removes preference', async () => { + const interaction = createArtistInteraction('remove', 'The Beatles') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(removeArtistFeedbackMock).toHaveBeenCalledWith( + 'guild-1', + 'user-1', + 'The Beatles', + ) + }) + + it('artist list — shows preferences', async () => { + const interaction = createArtistInteraction('list') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(createEmbedMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining('Preferences'), + }), + ) + }) + + it('artist prefer — shows error when no artist name', async () => { + const interaction = createArtistInteraction('prefer', undefined) + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(createErrorEmbedMock).toHaveBeenCalledWith( + 'Missing Input', + expect.any(String), + ) + }) + }) + describe('execute function edge cases', () => { it('should return early when guildId is null', async () => { const interaction = createInteraction('skip') diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.ts b/packages/bot/src/services/musicRecommendation/feedbackService.ts index 4a2d60bac..154bf01c6 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.ts @@ -177,7 +177,9 @@ export class RecommendationFeedbackService { return `music:artist_feedback:${userId}` } - private async getArtistFeedbackMap(userId: string): Promise> { + private async getArtistFeedbackMap( + userId: string, + ): Promise> { const key = this.getArtistFeedbackRedisKey(userId) try { const value = await redisClient.get(key) From 304bf59e05fbedeae785b03b50ece9e993e4f0f4 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 01:37:25 -0300 Subject: [PATCH 7/7] test(bot): cover artist handler error paths and default case --- .../functions/music/commands/autoplay.spec.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/bot/src/functions/music/commands/autoplay.spec.ts b/packages/bot/src/functions/music/commands/autoplay.spec.ts index 5c3192311..c2f91cef5 100644 --- a/packages/bot/src/functions/music/commands/autoplay.spec.ts +++ b/packages/bot/src/functions/music/commands/autoplay.spec.ts @@ -702,6 +702,48 @@ describe('autoplay command', () => { expect.any(String), ) }) + + it('artist block — shows error when no artist name', async () => { + const interaction = createArtistInteraction('block', undefined) + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(createErrorEmbedMock).toHaveBeenCalledWith( + 'Missing Input', + expect.any(String), + ) + }) + + it('artist remove — shows error when no artist name', async () => { + const interaction = createArtistInteraction('remove', undefined) + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(createErrorEmbedMock).toHaveBeenCalledWith( + 'Missing Input', + expect.any(String), + ) + }) + + it('artist prefer — handles service error gracefully', async () => { + setArtistFeedbackMock.mockRejectedValue(new Error('Redis down')) + const interaction = createArtistInteraction('prefer', 'The Beatles') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(createErrorEmbedMock).toHaveBeenCalledWith( + 'Error', + expect.any(String), + ) + }) + + it('artist unknown subcommand — shows error', async () => { + const interaction = createArtistInteraction('unknown') + const client = createClient() + resolveGuildQueueMock.mockReturnValue({ queue: null }) + await autoplayCommand.execute({ client, interaction } as any) + expect(interactionReplyMock).toHaveBeenCalled() + }) }) describe('execute function edge cases', () => {