From 490b4573601f60d631c1c1a2eab2f20ab94b8603 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Apr 2026 20:58:54 -0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20intelligent=20autoplay=20signals?= =?UTF-8?q?=20=E2=80=94=20skip/completion=20tracking,=20loved=20tracks,=20?= =?UTF-8?q?artist=20frequency,=20spotify=20audio=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signals added (all implicit, no commands needed): - skip tracking: guildTrackStartTimes records when track starts; early skip (<30%, track >20s) → implicit_dislike in redis (14d) - completion tracking: >80% played → implicit_like in redis (14d); both feed calculateRecommendationScore - implicit dislike: -0.35 penalty; implicit like: +0.25 boost - last.fm loved tracks: getLovedTracks() merged first in seed list (highest priority over top/recent) - artist frequency: manual-play count from persistent history → +0.10/+0.20/+0.30 gradient - spotify audio features: getAudioFeatures() + searchSpotifyTrack() wired into replenish flow via getTrackAudioFeatures(); results cached per normalized track key - spotify-source candidate boost: +0.08 when both current and candidate are from spotify code quality: - recordPlayBehavior() helper eliminates duplication between skip/finish tracking handlers - getImplicitKeysByType() helper eliminates duplication between dislike/like key getters - sonar cpd exclusions updated for api/seed files --- .../src/handlers/player/trackHandlers.spec.ts | 142 ++++++++++ .../bot/src/handlers/player/trackHandlers.ts | 77 +++++ packages/bot/src/lastfm/index.ts | 1 + packages/bot/src/lastfm/lastFmApi.spec.ts | 42 +++ packages/bot/src/lastfm/lastFmApi.ts | 27 ++ .../feedbackService.spec.ts | 88 ++++++ .../musicRecommendation/feedbackService.ts | 97 +++++++ packages/bot/src/spotify/spotifyApi.spec.ts | 267 ++++++++++++++++++ packages/bot/src/spotify/spotifyApi.ts | 87 ++++++ .../bot/src/spotify/spotifyConfig.spec.ts | 17 +- packages/bot/src/spotify/spotifyConfig.ts | 3 +- .../utils/music/autoplay/lastFmSeeds.spec.ts | 8 + .../src/utils/music/autoplay/lastFmSeeds.ts | 5 +- .../src/utils/music/queueManipulation.spec.ts | 156 ++++++++++ .../bot/src/utils/music/queueManipulation.ts | 146 ++++++++++ sonar-project.properties | 2 +- 16 files changed, 1155 insertions(+), 10 deletions(-) create mode 100644 packages/bot/src/spotify/spotifyApi.spec.ts create mode 100644 packages/bot/src/spotify/spotifyApi.ts diff --git a/packages/bot/src/handlers/player/trackHandlers.spec.ts b/packages/bot/src/handlers/player/trackHandlers.spec.ts index 861e08d3e..82a3e238a 100644 --- a/packages/bot/src/handlers/player/trackHandlers.spec.ts +++ b/packages/bot/src/handlers/player/trackHandlers.spec.ts @@ -29,6 +29,7 @@ const infoLogMock = jest.fn() const debugLogMock = jest.fn() const errorLogMock = jest.fn() const warnLogMock = jest.fn() +const recordImplicitFeedbackMock = jest.fn() jest.mock('discord-player', () => ({ QueueRepeatMode: { @@ -100,6 +101,18 @@ jest.mock('@lucky/shared/utils', () => ({ warnLog: (...args: unknown[]) => warnLogMock(...args), })) +jest.mock('../../services/musicRecommendation/feedbackService', () => ({ + recommendationFeedbackService: { + recordImplicitFeedback: (...args: unknown[]) => + recordImplicitFeedbackMock(...args), + }, +})) + +jest.mock('../../utils/music/searchQueryCleaner', () => ({ + cleanTitle: (title: string) => title, + cleanAuthor: (author: string) => author, +})) + type PlayerEventHandler = (queue: GuildQueue, track?: Track) => Promise function createTrack(requestedById = 'listener-1'): Track { @@ -177,6 +190,7 @@ describe('trackHandlers autoplay replenishment', () => { updateLastFmNowPlayingMock.mockResolvedValue(undefined) scrobbleCurrentTrackIfLastFmMock.mockResolvedValue(undefined) saveSnapshotMock.mockResolvedValue(undefined) + recordImplicitFeedbackMock.mockResolvedValue(undefined) }) afterEach(() => { @@ -483,4 +497,132 @@ describe('trackHandlers autoplay replenishment', () => { message: 'Added "Test Song" to queue in Guild One', }) }) + + it('records implicit like on playerFinish when track played > 80%', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerFinish = handlers.playerFinish + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const finishedTrack = { + ...createTrack('listener-finish-1'), + durationMS: 100000, + } + + await playerStart(queue, finishedTrack) + jest.advanceTimersByTime(85000) + await playerFinish(queue, finishedTrack) + + expect(recordImplicitFeedbackMock).toHaveBeenCalledWith( + 'listener-finish-1', + 'testsong::testartist', + 'implicit_like', + ) + }) + + it('does not record feedback on playerFinish when track played < 80%', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerFinish = handlers.playerFinish + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const finishedTrack = { + ...createTrack('listener-finish-2'), + durationMS: 100000, + } + + await playerStart(queue, finishedTrack) + jest.advanceTimersByTime(60000) + await playerFinish(queue, finishedTrack) + + expect(recordImplicitFeedbackMock).not.toHaveBeenCalled() + }) + + it('records implicit dislike on playerSkip when track played < 30%', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerSkip = handlers.playerSkip + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const skippedTrack = { + ...createTrack('listener-skip-1'), + durationMS: 100000, + } + + await playerStart(queue, skippedTrack) + jest.advanceTimersByTime(20000) + await playerSkip(queue, skippedTrack) + + expect(recordImplicitFeedbackMock).toHaveBeenCalledWith( + 'listener-skip-1', + 'testsong::testartist', + 'implicit_dislike', + ) + }) + + it('does not record feedback on playerSkip when track < 20 seconds duration', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerSkip = handlers.playerSkip + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const shortTrack = { + ...createTrack('listener-skip-2'), + durationMS: 10000, + } + + await playerStart(queue, shortTrack) + jest.advanceTimersByTime(5000) + await playerSkip(queue, shortTrack) + + expect(recordImplicitFeedbackMock).not.toHaveBeenCalled() + }) + + it('does not record feedback on playerSkip when track played > 30%', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerSkip = handlers.playerSkip + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const skippedTrack = { + ...createTrack('listener-skip-3'), + durationMS: 100000, + } + + await playerStart(queue, skippedTrack) + jest.advanceTimersByTime(50000) + await playerSkip(queue, skippedTrack) + + expect(recordImplicitFeedbackMock).not.toHaveBeenCalled() + }) + + it('records implicit like for track with metadata requestedById on playerFinish', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const playerStart = handlers.playerStart + const playerFinish = handlers.playerFinish + const queue = createQueue(QueueRepeatMode.AUTOPLAY) + const metadataTrack = { + id: 'track-3', + title: 'Metadata Track', + author: 'Metadata Artist', + url: 'https://example.com/track-3', + source: 'youtube', + requestedBy: undefined, + metadata: { + requestedById: 'listener-finish-3', + }, + durationMS: 100000, + } as unknown as Track + + await playerStart(queue, metadataTrack) + jest.advanceTimersByTime(85000) + await playerFinish(queue, metadataTrack) + + expect(recordImplicitFeedbackMock).toHaveBeenCalledWith( + 'listener-finish-3', + 'metadatatrack::metadataartist', + 'implicit_like', + ) + }) }) diff --git a/packages/bot/src/handlers/player/trackHandlers.ts b/packages/bot/src/handlers/player/trackHandlers.ts index 2df3bef9c..2e65b151c 100644 --- a/packages/bot/src/handlers/player/trackHandlers.ts +++ b/packages/bot/src/handlers/player/trackHandlers.ts @@ -20,11 +20,15 @@ import { clearIdleTimer, } from '../../utils/music/idleDisconnect' import { clearVotes } from '../../utils/music/voteSkipStore' +import { recommendationFeedbackService } from '../../services/musicRecommendation/feedbackService' +import { cleanTitle, cleanAuthor } from '../../utils/music/searchQueryCleaner' const MAX_GUILD_ENTRIES = 500 export const lastPlayedTracks = new Map() +const guildTrackStartTimes = new Map() + export type TrackHistoryEntry = { url: string title: string @@ -47,6 +51,48 @@ function isAutoplayTrack(track: Track, clientUserId?: string): boolean { ) } +function normalizeTrackKeyForFeedback(title: string, author: string): string { + const normalizedTitle = cleanTitle(title) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '') + .trim() + const normalizedAuthor = cleanAuthor(author) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '') + .trim() + return `${normalizedTitle}::${normalizedAuthor}` +} + +async function recordPlayBehavior( + guildId: string, + track: Track | undefined, + requestedById: string | undefined, + minDurationMs: number, + ratioThreshold: number, + feedbackType: 'implicit_like' | 'implicit_dislike', +): Promise { + if (!track || !requestedById) return + const startTime = guildTrackStartTimes.get(guildId) + if (!startTime || !track.durationMS || track.durationMS < minDurationMs) { + guildTrackStartTimes.delete(guildId) + return + } + const playedMs = Date.now() - startTime + const ratio = playedMs / track.durationMS + guildTrackStartTimes.delete(guildId) + const shouldRecord = + (feedbackType === 'implicit_like' && ratio > ratioThreshold) || + (feedbackType === 'implicit_dislike' && ratio < ratioThreshold) + if (shouldRecord) { + const trackKey = normalizeTrackKeyForFeedback(track.title, track.author) + await recommendationFeedbackService.recordImplicitFeedback( + requestedById, + trackKey, + feedbackType, + ) + } +} + function evictOldEntries(): void { if (lastPlayedTracks.size > MAX_GUILD_ENTRIES) { const oldest = lastPlayedTracks.keys().next().value @@ -162,6 +208,7 @@ const handlePlayerStart = async ( ): Promise => { try { evictOldEntries() + guildTrackStartTimes.set(queue.guild.id, Date.now()) infoLog({ message: `Started playing "${track.title}" in ${queue.guild.name}`, }) @@ -216,6 +263,21 @@ const handlePlayerFinish = async ( ): Promise => { try { await scrobbleAndRecord(queue, track) + + if (track) { + const requesterId = track.requestedBy?.id + ?? (track.metadata as { requestedById?: string } | undefined) + ?.requestedById + await recordPlayBehavior( + queue.guild.id, + track, + requesterId, + 0, + 0.8, + 'implicit_like', + ) + } + if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return await replenishIfAutoplay(queue, track) await musicSessionSnapshotService.saveSnapshot(queue) @@ -248,6 +310,21 @@ const handlePlayerSkip = async ( }, }) await scrobbleAndRecord(queue, track) + + if (track) { + const requesterId = track.requestedBy?.id + ?? (track.metadata as { requestedById?: string } | undefined) + ?.requestedById + await recordPlayBehavior( + queue.guild.id, + track, + requesterId, + 20_000, + 0.3, + 'implicit_dislike', + ) + } + if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return await replenishIfAutoplay(queue, track) await musicSessionSnapshotService.saveSnapshot(queue) diff --git a/packages/bot/src/lastfm/index.ts b/packages/bot/src/lastfm/index.ts index bae49a9c9..419c9971e 100644 --- a/packages/bot/src/lastfm/index.ts +++ b/packages/bot/src/lastfm/index.ts @@ -5,6 +5,7 @@ export { getRecentTracks, getSimilarTracks, getTagTopTracks, + getLovedTracks, isLastFmInvalidSessionError, normalizeLastFmArtist, normalizeLastFmTitle, diff --git a/packages/bot/src/lastfm/lastFmApi.spec.ts b/packages/bot/src/lastfm/lastFmApi.spec.ts index c02ea33ae..5e3f3d827 100644 --- a/packages/bot/src/lastfm/lastFmApi.spec.ts +++ b/packages/bot/src/lastfm/lastFmApi.spec.ts @@ -16,6 +16,7 @@ import { getRecentTracks, getSimilarTracks, getTagTopTracks, + getLovedTracks, } from './lastFmApi' const getSessionKeyMock = @@ -494,4 +495,45 @@ describe('lastFmApi', () => { expect(tracks).toEqual([]) }) }) + + describe('getLovedTracks', () => { + it('returns loved tracks array on success', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + lovedtracks: { + track: [ + { name: 'Loved Song', artist: { name: 'Artist A' } }, + { name: 'Another Fave', artist: { name: 'Artist B' } }, + ], + }, + }), + }) + + const result = await getLovedTracks('testuser', 10) + + expect(result).toEqual([ + { artist: 'Artist A', title: 'Loved Song' }, + { artist: 'Artist B', title: 'Another Fave' }, + ]) + }) + + it('returns empty array on non-ok response', async () => { + fetchMock.mockResolvedValueOnce({ ok: false }) + const result = await getLovedTracks('testuser') + expect(result).toEqual([]) + }) + + it('returns empty array when lovedtracks missing', async () => { + fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) }) + const result = await getLovedTracks('testuser') + expect(result).toEqual([]) + }) + + it('returns empty array on fetch error', async () => { + fetchMock.mockRejectedValueOnce(new Error('network')) + const result = await getLovedTracks('testuser') + expect(result).toEqual([]) + }) + }) }) diff --git a/packages/bot/src/lastfm/lastFmApi.ts b/packages/bot/src/lastfm/lastFmApi.ts index c7f8b5555..cdf972961 100644 --- a/packages/bot/src/lastfm/lastFmApi.ts +++ b/packages/bot/src/lastfm/lastFmApi.ts @@ -279,3 +279,30 @@ export async function getTagTopTracks( return [] } } + +export async function getLovedTracks( + username: string, + limit = 50, +): Promise<{ artist: string; title: string }[]> { + const config = getApiConfig() + if (!config) return [] + try { + const response = await fetch( + `${API_BASE}?method=user.getlovedtracks&user=${encodeURIComponent(username)}&limit=${limit}&format=json&api_key=${config.apiKey}`, + ) + const data = (await response.json()) as { + lovedtracks?: { + track?: Array<{ + name: string + artist: { name: string } + }> + } + } + return (data.lovedtracks?.track ?? []).map((t) => ({ + artist: t.artist.name, + title: t.name, + })) + } catch { + return [] + } +} diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts index 1a239eb7b..bb2222ef9 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.spec.ts @@ -348,3 +348,91 @@ describe('RecommendationFeedbackService', () => { expect(getMock).not.toHaveBeenCalled() }) }) + +describe('implicit feedback', () => { + beforeEach(() => { + getMock.mockReset() + setexMock.mockReset() + }) + + it('recordImplicitFeedback stores implicit_dislike entry', async () => { + getMock.mockResolvedValue(null) + setexMock.mockResolvedValue('OK') + const service = new RecommendationFeedbackService(30) + + await service.recordImplicitFeedback('user-1', 'song::artist', 'implicit_dislike') + + expect(setexMock).toHaveBeenCalledWith( + 'music:implicit_feedback:user-1', + expect.any(Number), + expect.stringContaining('implicit_dislike'), + ) + }) + + it('recordImplicitFeedback trims to 200 entries when exceeded', async () => { + const bigMap: Record = {} + for (let i = 0; i < 201; i++) { + bigMap[`track${i}::artist`] = { type: 'implicit_like', updatedAt: i } + } + getMock.mockResolvedValue(JSON.stringify(bigMap)) + setexMock.mockResolvedValue('OK') + const service = new RecommendationFeedbackService(30) + + await service.recordImplicitFeedback('user-1', 'newtrack::artist', 'implicit_dislike') + + const saved = JSON.parse(setexMock.mock.calls[0][2] as string) + expect(Object.keys(saved).length).toBeLessThanOrEqual(200) + }) + + it('getImplicitDislikeKeys returns skipped tracks', async () => { + getMock.mockResolvedValue(JSON.stringify({ + 'song1::artist': { type: 'implicit_dislike', updatedAt: Date.now() }, + 'song2::artist': { type: 'implicit_like', updatedAt: Date.now() }, + })) + const service = new RecommendationFeedbackService(30) + + const keys = await service.getImplicitDislikeKeys('user-1') + + expect(keys.has('song1::artist')).toBe(true) + expect(keys.has('song2::artist')).toBe(false) + }) + + it('getImplicitLikeKeys returns completed tracks', async () => { + getMock.mockResolvedValue(JSON.stringify({ + 'song1::artist': { type: 'implicit_dislike', updatedAt: Date.now() }, + 'song2::artist': { type: 'implicit_like', updatedAt: Date.now() }, + })) + const service = new RecommendationFeedbackService(30) + + const keys = await service.getImplicitLikeKeys('user-1') + + expect(keys.has('song2::artist')).toBe(true) + expect(keys.has('song1::artist')).toBe(false) + }) + + it('getImplicitDislikeKeys returns empty set on redis error', async () => { + getMock.mockRejectedValue(new Error('redis down')) + const service = new RecommendationFeedbackService(30) + + const keys = await service.getImplicitDislikeKeys('user-1') + + expect(keys.size).toBe(0) + }) + + it('getImplicitLikeKeys returns empty set on redis error', async () => { + getMock.mockRejectedValue(new Error('redis down')) + const service = new RecommendationFeedbackService(30) + + const keys = await service.getImplicitLikeKeys('user-1') + + expect(keys.size).toBe(0) + }) + + it('recordImplicitFeedback handles redis error gracefully', async () => { + getMock.mockRejectedValue(new Error('redis down')) + const service = new RecommendationFeedbackService(30) + + await expect(service.recordImplicitFeedback('user-1', 'key', 'implicit_like')).resolves.toBeUndefined() + }) +}) + diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.ts b/packages/bot/src/services/musicRecommendation/feedbackService.ts index 154bf01c6..a3f8b103e 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.ts @@ -13,6 +13,13 @@ type FeedbackEntry = { type FeedbackMap = Record +type ImplicitFeedbackEntry = { + type: 'implicit_dislike' | 'implicit_like' + updatedAt: number +} + +type ImplicitFeedbackMap = Record + export class RecommendationFeedbackService { constructor(private readonly ttlDays = 30) {} @@ -300,6 +307,96 @@ export class RecommendationFeedbackService { return { preferred, blocked } } + + private getImplicitFeedbackRedisKey(userId: string): string { + return `music:implicit_feedback:${userId}` + } + + private async getImplicitFeedbackMap( + userId: string, + ): Promise { + const key = this.getImplicitFeedbackRedisKey(userId) + try { + const value = await redisClient.get(key) + if (!value) return {} + const parsed = JSON.parse(value) as ImplicitFeedbackMap + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (error) { + errorLog({ + message: 'Failed to load implicit feedback map', + error, + }) + return {} + } + } + + private async saveImplicitFeedbackMap( + userId: string, + map: ImplicitFeedbackMap, + ): Promise { + const key = this.getImplicitFeedbackRedisKey(userId) + const ttlSeconds = 14 * 24 * 60 * 60 + + await redisClient.setex(key, ttlSeconds, JSON.stringify(map)) + } + + async recordImplicitFeedback( + userId: string, + trackKey: string, + type: 'implicit_dislike' | 'implicit_like', + ): Promise { + try { + const map = await this.getImplicitFeedbackMap(userId) + const now = Date.now() + + map[trackKey] = { type, updatedAt: now } + + const entries = Object.entries(map).sort( + (a, b) => a[1].updatedAt - b[1].updatedAt, + ) + + if (entries.length > 200) { + const trimmed: ImplicitFeedbackMap = {} + for (const [key, entry] of entries.slice(-200)) { + trimmed[key] = entry + } + await this.saveImplicitFeedbackMap(userId, trimmed) + } else { + await this.saveImplicitFeedbackMap(userId, map) + } + } catch (error) { + errorLog({ + message: 'Failed to record implicit feedback', + error, + data: { userId, trackKey, type }, + }) + } + } + + private async getImplicitKeysByType( + userId: string, + type: 'implicit_dislike' | 'implicit_like', + ): Promise> { + try { + const map = await this.getImplicitFeedbackMap(userId) + return new Set( + Object.entries(map) + .filter(([, entry]) => entry.type === type) + .map(([trackKey]) => trackKey), + ) + } catch (error) { + errorLog({ message: `Failed to get ${type} keys`, error, data: { userId } }) + return new Set() + } + } + + async getImplicitDislikeKeys(userId: string): Promise> { + return this.getImplicitKeysByType(userId, 'implicit_dislike') + } + + async getImplicitLikeKeys(userId: string): Promise> { + return this.getImplicitKeysByType(userId, 'implicit_like') + } } export const recommendationFeedbackService = new RecommendationFeedbackService( diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts new file mode 100644 index 000000000..5aa3c7fca --- /dev/null +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals' +import { getAudioFeatures, searchSpotifyTrack } from './spotifyApi' + +describe('spotifyApi', () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('getAudioFeatures', () => { + it('returns audio features on successful response', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + energy: 0.8, + valence: 0.75, + danceability: 0.65, + tempo: 120, + acousticness: 0.1, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toEqual({ + energy: 0.8, + valence: 0.75, + danceability: 0.65, + tempo: 120, + acousticness: 0.1, + }) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.spotify.com/v1/audio-features/track-123', + { + method: 'GET', + headers: { + Authorization: 'Bearer test-token', + }, + }, + ) + }) + + it('uses default values for missing optional properties', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + energy: 0.8, + valence: 0.75, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toEqual({ + energy: 0.8, + valence: 0.75, + danceability: 0, + tempo: 0, + acousticness: 0, + }) + }) + + it('returns null when response is not ok', async () => { + const mockResponse = { + ok: false, + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when json parsing fails', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockRejectedValue(new Error('JSON parse error')), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when energy is missing', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + valence: 0.75, + danceability: 0.65, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('returns null when valence is not a number', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + energy: 0.8, + valence: 'high', + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + + it('catches and returns null on fetch error', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')) + + const result = await getAudioFeatures('test-token', 'track-123') + + expect(result).toBeNull() + }) + }) + + describe('searchSpotifyTrack', () => { + it('returns track id on successful search', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + tracks: { + items: [{ id: 'spotify:track:abc123' }], + }, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await searchSpotifyTrack( + 'test-token', + 'Song Title', + 'Artist Name', + ) + + expect(result).toBe('spotify:track:abc123') + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining( + 'https://api.spotify.com/v1/search?q=track%3A%22Song+Title%22+artist%3A%22Artist+Name%22', + ), + { + method: 'GET', + headers: { + Authorization: 'Bearer test-token', + }, + }, + ) + }) + + it('returns null when no tracks found', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + tracks: { + items: [], + }, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await searchSpotifyTrack( + 'test-token', + 'Unknown Song', + 'Unknown Artist', + ) + + expect(result).toBeNull() + }) + + it('returns null when tracks property is missing', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({}), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await searchSpotifyTrack( + 'test-token', + 'Song', + 'Artist', + ) + + expect(result).toBeNull() + }) + + it('returns null when response is not ok', async () => { + const mockResponse = { + ok: false, + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await searchSpotifyTrack( + 'test-token', + 'Song', + 'Artist', + ) + + expect(result).toBeNull() + }) + + it('returns null when json parsing fails', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockRejectedValue(new Error('JSON parse error')), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const result = await searchSpotifyTrack( + 'test-token', + 'Song', + 'Artist', + ) + + expect(result).toBeNull() + }) + + it('catches and returns null on fetch error', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')) + + const result = await searchSpotifyTrack( + 'test-token', + 'Song', + 'Artist', + ) + + expect(result).toBeNull() + }) + + it('encodes special characters in search query', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + tracks: { items: [{ id: 'track-123' }] }, + }), + } + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + await searchSpotifyTrack( + 'test-token', + "Song's Title", + 'Artist & Co.', + ) + + const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0] + expect(fetchUrl).toContain('Song%27s+Title') + expect(fetchUrl).toContain('Artist+%26+Co.') + }) + }) +}) diff --git a/packages/bot/src/spotify/spotifyApi.ts b/packages/bot/src/spotify/spotifyApi.ts new file mode 100644 index 000000000..fcf39d48a --- /dev/null +++ b/packages/bot/src/spotify/spotifyApi.ts @@ -0,0 +1,87 @@ +export interface SpotifyAudioFeatures { + energy: number + valence: number + danceability: number + tempo: number + acousticness: number +} + +export async function getAudioFeatures( + accessToken: string, + spotifyTrackId: string, +): Promise { + try { + const res = await fetch( + `https://api.spotify.com/v1/audio-features/${spotifyTrackId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + if (!res.ok) { + return null + } + + const data = (await res.json().catch(() => null)) as { + energy?: number + valence?: number + danceability?: number + tempo?: number + acousticness?: number + } + + if (!data?.energy || typeof data.valence !== 'number') { + return null + } + + return { + energy: data.energy, + valence: data.valence, + danceability: data.danceability ?? 0, + tempo: data.tempo ?? 0, + acousticness: data.acousticness ?? 0, + } + } catch { + return null + } +} + +export async function searchSpotifyTrack( + accessToken: string, + title: string, + artist: string, +): Promise { + try { + const query = `track:"${title}" artist:"${artist}"` + const params = new URLSearchParams({ + q: query, + type: 'track', + limit: '1', + }) + + const res = await fetch( + `https://api.spotify.com/v1/search?${params.toString()}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + if (!res.ok) { + return null + } + + const data = (await res.json().catch(() => null)) as { + tracks?: { items?: Array<{ id?: string }> } + } + + return data?.tracks?.items?.[0]?.id ?? null + } catch { + return null + } +} diff --git a/packages/bot/src/spotify/spotifyConfig.spec.ts b/packages/bot/src/spotify/spotifyConfig.spec.ts index 814aa196f..f1775cf89 100644 --- a/packages/bot/src/spotify/spotifyConfig.spec.ts +++ b/packages/bot/src/spotify/spotifyConfig.spec.ts @@ -13,7 +13,14 @@ describe('spotifyConfig', () => { process.env = originalEnv }) - it('returns true when all required env vars are set', () => { + it('returns true when client id and secret are set', () => { + process.env.SPOTIFY_CLIENT_ID = 'test-client-id' + process.env.SPOTIFY_CLIENT_SECRET = 'test-secret' + + expect(isSpotifyConfigured()).toBe(true) + }) + + it('returns true when redirect uri is also set', () => { process.env.SPOTIFY_CLIENT_ID = 'test-client-id' process.env.SPOTIFY_CLIENT_SECRET = 'test-secret' process.env.SPOTIFY_REDIRECT_URI = 'https://example.com/callback' @@ -24,7 +31,6 @@ describe('spotifyConfig', () => { it('returns false when SPOTIFY_CLIENT_ID is missing', () => { process.env.SPOTIFY_CLIENT_ID = undefined process.env.SPOTIFY_CLIENT_SECRET = 'test-secret' - process.env.SPOTIFY_REDIRECT_URI = 'https://example.com/callback' expect(isSpotifyConfigured()).toBe(false) }) @@ -32,23 +38,21 @@ describe('spotifyConfig', () => { it('returns false when SPOTIFY_CLIENT_SECRET is missing', () => { process.env.SPOTIFY_CLIENT_ID = 'test-client-id' process.env.SPOTIFY_CLIENT_SECRET = undefined - process.env.SPOTIFY_REDIRECT_URI = 'https://example.com/callback' expect(isSpotifyConfigured()).toBe(false) }) - it('returns false when SPOTIFY_REDIRECT_URI is missing', () => { + it('returns true when SPOTIFY_REDIRECT_URI is missing (optional)', () => { process.env.SPOTIFY_CLIENT_ID = 'test-client-id' process.env.SPOTIFY_CLIENT_SECRET = 'test-secret' process.env.SPOTIFY_REDIRECT_URI = undefined - expect(isSpotifyConfigured()).toBe(false) + expect(isSpotifyConfigured()).toBe(true) }) it('returns false when all env vars are missing', () => { process.env.SPOTIFY_CLIENT_ID = undefined process.env.SPOTIFY_CLIENT_SECRET = undefined - process.env.SPOTIFY_REDIRECT_URI = undefined expect(isSpotifyConfigured()).toBe(false) }) @@ -56,7 +60,6 @@ describe('spotifyConfig', () => { it('returns false when empty strings are provided', () => { process.env.SPOTIFY_CLIENT_ID = '' process.env.SPOTIFY_CLIENT_SECRET = '' - process.env.SPOTIFY_REDIRECT_URI = '' expect(isSpotifyConfigured()).toBe(false) }) diff --git a/packages/bot/src/spotify/spotifyConfig.ts b/packages/bot/src/spotify/spotifyConfig.ts index 67418a9da..2483d283b 100644 --- a/packages/bot/src/spotify/spotifyConfig.ts +++ b/packages/bot/src/spotify/spotifyConfig.ts @@ -1,5 +1,6 @@ export function isSpotifyConfigured(): boolean { return !!( - process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET + process.env.SPOTIFY_CLIENT_ID && + process.env.SPOTIFY_CLIENT_SECRET ) } diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts index 28246c844..65a6f0062 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.spec.ts @@ -19,6 +19,7 @@ import { const getByDiscordIdMock = jest.fn() const getTopTracksMock = jest.fn() const getRecentTracksMock = jest.fn() +const getLovedTracksMock = jest.fn() jest.mock('@lucky/shared/services', () => ({ lastFmLinkService: { @@ -34,11 +35,13 @@ jest.mock('@lucky/shared/utils', () => ({ jest.mock('../../../lastfm', () => ({ getTopTracks: (...args: unknown[]) => getTopTracksMock(...args), getRecentTracks: (...args: unknown[]) => getRecentTracksMock(...args), + getLovedTracks: (...args: unknown[]) => getLovedTracksMock(...args), })) describe('getLastFmSeedTracks', () => { beforeEach(() => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) @@ -146,6 +149,7 @@ describe('getLastFmSeedTracks', () => { describe('getLastFmSeedSlice', () => { beforeEach(async () => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) @@ -234,6 +238,7 @@ describe('getLastFmSeedSlice', () => { describe('advanceLastFmSeedOffset', () => { beforeEach(async () => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) @@ -289,6 +294,7 @@ describe('advanceLastFmSeedOffset', () => { describe('getLastFmCacheOffset', () => { beforeEach(async () => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) @@ -337,6 +343,7 @@ describe('getLastFmCacheOffset', () => { describe('consumeLastFmSeedSlice', () => { beforeEach(() => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) @@ -433,6 +440,7 @@ describe('consumeLastFmSeedSlice', () => { describe('consumeBlendedSeedSlice', () => { beforeEach(() => { jest.clearAllMocks() + getLovedTracksMock.mockResolvedValue([]) getRecentTracksMock.mockResolvedValue([]) }) diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts index 135ef8c48..83c294589 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeds.ts @@ -3,6 +3,7 @@ import { getTopTracks, getRecentTracks, getSimilarTracks, + getLovedTracks, } from '../../../lastfm' import { debugLog, errorLog } from '@lucky/shared/utils' import { cleanTitle } from '../searchQueryCleaner' @@ -49,12 +50,14 @@ export async function getLastFmSeedTracks( const link = await lastFmLinkService.getByDiscordId(discordUserId) if (!link?.lastFmUsername) return [] - const [topTracks, recentTracks] = await Promise.all([ + const [lovedTracks, topTracks, recentTracks] = await Promise.all([ + getLovedTracks(link.lastFmUsername, 50), getTopTracks(link.lastFmUsername, '3month', TOP_TRACKS_LIMIT), getRecentTracks(link.lastFmUsername, RECENT_TRACKS_LIMIT), ]) const merged = deduplicateTracks([ + ...lovedTracks, ...topTracks.map((t) => ({ artist: t.artist, title: t.title })), ...recentTracks, ]) diff --git a/packages/bot/src/utils/music/queueManipulation.spec.ts b/packages/bot/src/utils/music/queueManipulation.spec.ts index ad99d2cea..d2e20eabd 100644 --- a/packages/bot/src/utils/music/queueManipulation.spec.ts +++ b/packages/bot/src/utils/music/queueManipulation.spec.ts @@ -63,6 +63,9 @@ jest.mock('@lucky/shared/services', () => ({ lastFmLinkService: { getByDiscordId: (...args: unknown[]) => getLastFmLinkMock(...args), }, + spotifyLinkService: { + getValidAccessToken: jest.fn().mockResolvedValue(null), + }, })) const consumeLastFmSeedSliceMock = jest.fn() @@ -83,10 +86,17 @@ jest.mock('../../lastfm', () => ({ getTagTopTracks: (...args: unknown[]) => getTagTopTracksMock(...args), })) +jest.mock('../../spotify/spotifyApi', () => ({ + getAudioFeatures: jest.fn().mockResolvedValue(null), + searchSpotifyTrack: jest.fn().mockResolvedValue(null), +})) + const dislikedTrackKeysMock = jest.fn() const likedTrackKeysMock = jest.fn() const getPreferredArtistKeysMock = jest.fn() const getBlockedArtistKeysMock = jest.fn() +const getImplicitDislikeKeysMock = jest.fn() +const getImplicitLikeKeysMock = jest.fn() jest.mock('../../services/musicRecommendation/feedbackService', () => ({ recommendationFeedbackService: { @@ -97,6 +107,10 @@ jest.mock('../../services/musicRecommendation/feedbackService', () => ({ getPreferredArtistKeysMock(...args), getBlockedArtistKeys: (...args: unknown[]) => getBlockedArtistKeysMock(...args), + getImplicitDislikeKeys: (...args: unknown[]) => + getImplicitDislikeKeysMock(...args), + getImplicitLikeKeys: (...args: unknown[]) => + getImplicitLikeKeysMock(...args), }, })) @@ -143,6 +157,8 @@ describe('queueManipulation.replenishQueue', () => { likedTrackKeysMock.mockResolvedValue(new Set()) getPreferredArtistKeysMock.mockResolvedValue(new Set()) getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -1910,6 +1926,8 @@ describe('queueManipulation.replenishQueue query variation', () => { likedTrackKeysMock.mockResolvedValue(new Set()) getPreferredArtistKeysMock.mockResolvedValue(new Set()) getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -2001,6 +2019,8 @@ describe('queueManipulation.collectBroadFallbackCandidates diversification', () likedTrackKeysMock.mockResolvedValue(new Set()) getPreferredArtistKeysMock.mockResolvedValue(new Set()) getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -2043,6 +2063,8 @@ describe('queueManipulation.selectDiverseCandidates score jitter', () => { likedTrackKeysMock.mockResolvedValue(new Set()) getPreferredArtistKeysMock.mockResolvedValue(new Set()) getBlockedArtistKeysMock.mockResolvedValue(new Set()) + getImplicitDislikeKeysMock.mockResolvedValue(new Set()) + getImplicitLikeKeysMock.mockResolvedValue(new Set()) consumeLastFmSeedSliceMock.mockResolvedValue([]) getSimilarTracksMock.mockResolvedValue([]) getTrackHistoryMock.mockResolvedValue([]) @@ -2535,4 +2557,138 @@ describe('queueManipulation — multi-user VC blend', () => { expect(searchMock).toHaveBeenCalled() }) + + it('replenishes queue with implicit like and dislike keys loaded', async () => { + getImplicitLikeKeysMock.mockResolvedValue(new Set(['liked::artist'])) + getImplicitDislikeKeysMock.mockResolvedValue(new Set(['disliked::artist'])) + + const currentTrack = { + url: 'https://example.com/current', + title: 'Different Current Track', + author: 'Current Artist', + id: 'current', + requestedBy: { id: 'user-1' }, + } + const addTrackMock = jest.fn() + const searchMock = jest.fn().mockResolvedValue({ + tracks: [{ + url: 'https://example.com/rec', + title: 'Recommended Track', + author: 'New Artist', + id: 'rec-1', + durationMS: 220000, + requestedBy: null, + }], + }) + const queue = createQueueMock({ + currentTrack, + player: { search: searchMock }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(getImplicitLikeKeysMock).toHaveBeenCalledWith('user-1') + expect(getImplicitDislikeKeysMock).toHaveBeenCalledWith('user-1') + expect(addTrackMock).toHaveBeenCalled() + }) + + it('builds artist frequency from persistent history for scoring', async () => { + getTrackHistoryMock.mockResolvedValue( + Array.from({ length: 6 }, (_, i) => ({ + url: `https://example.com/hist${i}`, + title: `History Track ${i}`, + author: 'Popular Band', + isAutoplay: false, + })) + ) + + const currentTrack = { + url: 'https://example.com/current', + title: 'Unrelated Song', + author: 'Different Artist', + id: 'current', + requestedBy: { id: 'user-1' }, + } + const addTrackMock = jest.fn() + const searchMock = jest.fn().mockResolvedValue({ + tracks: [{ + url: 'https://example.com/band', + title: 'Great Song', + author: 'Popular Band', + id: 'band-1', + durationMS: 180000, + requestedBy: null, + }], + }) + const queue = createQueueMock({ + currentTrack, + player: { search: searchMock }, + addTrack: addTrackMock, + metadata: { requestedBy: { id: 'user-1' } }, + }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(getTrackHistoryMock).toHaveBeenCalled() + expect(addTrackMock).toHaveBeenCalled() + }) + + it('calls getAudioFeatures when spotify token available and track has spotify url', async () => { + const sharedMocks = jest.requireMock('@lucky/shared/services') as any + sharedMocks.spotifyLinkService.getValidAccessToken.mockResolvedValueOnce('spotify-token-abc') + + const spotifyMocks = jest.requireMock('../../spotify/spotifyApi') as any + spotifyMocks.getAudioFeatures.mockResolvedValueOnce({ + energy: 0.75, valence: 0.60, danceability: 0.65, tempo: 128, acousticness: 0.15, + }) + + const currentTrack = { + url: 'https://open.spotify.com/track/testSpotifyTrackId01', + title: 'Spotify Energy Song', + author: 'Spotify Artist', + id: 'testSpotifyTrackId01', + requestedBy: { id: 'user-1' }, + } + const addTrackMock = jest.fn() + const searchMock = jest.fn().mockResolvedValue({ + tracks: [{ url: 'https://example.com/result', title: 'Similar Song', author: 'Other Artist', id: 'r1', durationMS: 200000, requestedBy: null }], + }) + const queue = createQueueMock({ currentTrack, player: { search: searchMock }, addTrack: addTrackMock }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(spotifyMocks.getAudioFeatures).toHaveBeenCalledWith('spotify-token-abc', 'testSpotifyTrackId01') + expect(addTrackMock).toHaveBeenCalled() + }) + + it('calls searchSpotifyTrack when token available but track has no spotify url', async () => { + const sharedMocks = jest.requireMock('@lucky/shared/services') as any + sharedMocks.spotifyLinkService.getValidAccessToken.mockResolvedValueOnce('spotify-token-xyz') + + const spotifyMocks = jest.requireMock('../../spotify/spotifyApi') as any + spotifyMocks.searchSpotifyTrack.mockResolvedValueOnce('found-track-id') + spotifyMocks.getAudioFeatures.mockResolvedValueOnce({ + energy: 0.50, valence: 0.55, danceability: 0.60, tempo: 110, acousticness: 0.30, + }) + + const currentTrack = { + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + title: 'YouTube Song', + author: 'YouTube Artist', + id: 'dQw4w9WgXcQ', + requestedBy: { id: 'user-1' }, + } + const addTrackMock = jest.fn() + const searchMock = jest.fn().mockResolvedValue({ + tracks: [{ url: 'https://example.com/yt-result', title: 'YouTube Similar', author: 'YT Artist', id: 'yt1', durationMS: 210000, requestedBy: null }], + }) + const queue = createQueueMock({ currentTrack, player: { search: searchMock }, addTrack: addTrackMock }) + + await replenishQueue(queue as unknown as GuildQueue) + + expect(spotifyMocks.searchSpotifyTrack).toHaveBeenCalled() + expect(addTrackMock).toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index f06adf823..7ff140be3 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -12,7 +12,9 @@ import { trackHistoryService, guildSettingsService, lastFmLinkService, + spotifyLinkService, } from '@lucky/shared/services' +import { getAudioFeatures, searchSpotifyTrack, type SpotifyAudioFeatures } from '../../spotify/spotifyApi' import { consumeLastFmSeedSlice, consumeBlendedSeedSlice, @@ -38,6 +40,53 @@ const QUEUE_RESCUE_REFILL_THRESHOLD = Number.parseInt( 10, ) +const audioFeatureCache = new Map() + +async function getTrackAudioFeatures( + track: Track, + userId: string, +): Promise { + const cacheKey = normalizeTrackKey(track.title, track.author) + + const cached = audioFeatureCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + const token = await spotifyLinkService.getValidAccessToken(userId) + if (!token) { + audioFeatureCache.set(cacheKey, null) + return null + } + + let spotifyId: string | null = null + + if (track.url && track.url.includes('open.spotify.com/track/')) { + const match = track.url.match(/track\/([a-zA-Z0-9]+)/) + if (match) { + spotifyId = match[1] + } + } + + if (!spotifyId) { + spotifyId = await searchSpotifyTrack( + token, + cleanTitle(track.title ?? ''), + cleanAuthor(track.author ?? ''), + ) + } + + if (!spotifyId) { + audioFeatureCache.set(cacheKey, null) + return null + } + + const features = await getAudioFeatures(token, spotifyId).catch(() => null) + audioFeatureCache.set(cacheKey, features) + return features +} + + type ScoredTrack = { track: Track score: number @@ -248,6 +297,8 @@ async function _replenishQueue( blockedArtistKeys, persistentHistory, guildSettings, + implicitDislikeKeys, + implicitLikeKeys, ] = await Promise.all([ recommendationFeedbackService.getDislikedTrackKeys( queue.guild.id, @@ -267,6 +318,12 @@ async function _replenishQueue( ), trackHistoryService.getTrackHistory(queue.guild.id, 100), guildSettingsService.getGuildSettings(queue.guild.id), + recommendationFeedbackService.getImplicitDislikeKeys( + requestedBy?.id ?? '', + ), + recommendationFeedbackService.getImplicitLikeKeys( + requestedBy?.id ?? '', + ), ]) const autoplayMode = guildSettings?.autoplayMode ?? 'similar' if (persistentHistory.length === 0) { @@ -300,6 +357,10 @@ async function _replenishQueue( }, }) const recentArtists = buildRecentArtists(currentTrack, historyTracks) + const artistFrequency = buildArtistFrequency(persistentHistory) + const currentFeatures = requestedBy?.id + ? await getTrackAudioFeatures(currentTrack, requestedBy.id).catch(() => null) + : null const guildId = queue.guild.id const replenishCount = replenishCounters.get(guildId) ?? 0 const candidates = await collectRecommendationCandidates( @@ -316,6 +377,9 @@ async function _replenishQueue( recentArtists, replenishCount, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) debugLog({ message: 'Autoplay: recommendation candidates', @@ -337,6 +401,9 @@ async function _replenishQueue( recentArtists, candidates, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) debugLog({ message: 'Autoplay: last.fm candidates', @@ -365,6 +432,9 @@ async function _replenishQueue( preferredArtistKeys, blockedArtistKeys, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, }, ) debugLog({ @@ -392,6 +462,9 @@ async function _replenishQueue( recentArtists, candidates, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) debugLog({ message: 'Autoplay: broad fallback candidates', @@ -544,6 +617,23 @@ function buildRecentArtists( ) } +function buildArtistFrequency( + history: { author?: string; isAutoplay?: boolean }[], +): Map { + const freq = new Map() + for (const entry of history) { + if (!entry.isAutoplay && entry.author) { + const key = cleanAuthor(entry.author) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '') + if (key) { + freq.set(key, (freq.get(key) ?? 0) + 1) + } + } + } + return freq +} + async function collectRecommendationCandidates( queue: GuildQueue, seedTracks: Track[], @@ -558,6 +648,9 @@ async function collectRecommendationCandidates( recentArtists: Set, replenishCount = 0, autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', + artistFrequency: Map = new Map(), + implicitDislikeKeys: Set = new Set(), + implicitLikeKeys: Set = new Set(), ): Promise> { const candidates = new Map() @@ -589,6 +682,9 @@ async function collectRecommendationCandidates( preferredArtistKeys, blockedArtistKeys, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) if (rec.score !== -Infinity) { upsertScoredCandidate(candidates, candidate, rec) @@ -658,6 +754,9 @@ async function collectBroadFallbackCandidates( recentArtists: Set, candidates: Map, autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', + artistFrequency: Map = new Map(), + implicitDislikeKeys: Set = new Set(), + implicitLikeKeys: Set = new Set(), ): Promise { const fallbackQueries = [ currentTrack.author, @@ -692,6 +791,9 @@ async function collectBroadFallbackCandidates( preferredArtistKeys, blockedArtistKeys, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) if (rec.score === -Infinity) continue upsertScoredCandidate(candidates, track, { @@ -749,6 +851,9 @@ async function collectLastFmCandidates( recentArtists: Set, candidates: Map, autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', + artistFrequency: Map = new Map(), + implicitDislikeKeys: Set = new Set(), + implicitLikeKeys: Set = new Set(), ): Promise { const metadata = queue.metadata as QueueMetadata const vcMemberIds = metadata?.vcMemberIds ?? [] @@ -804,6 +909,9 @@ async function collectLastFmCandidates( preferredArtistKeys, blockedArtistKeys, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) if (rec.score === -Infinity) continue upsertScoredCandidate(candidates, track, { @@ -835,6 +943,9 @@ async function collectLastFmCandidates( preferredArtistKeys, blockedArtistKeys, autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, ) upsertScoredCandidate(candidates, track, { score: (rec.score + LASTFM_SCORE_BOOST) * (s.match / 100), @@ -894,6 +1005,9 @@ interface CandidateContext { preferredArtistKeys: Set blockedArtistKeys: Set autoplayMode: 'similar' | 'discover' | 'popular' + artistFrequency?: Map + implicitDislikeKeys?: Set + implicitLikeKeys?: Set } function addGenreTrackCandidate( @@ -913,6 +1027,9 @@ function addGenreTrackCandidate( ctx.preferredArtistKeys, ctx.blockedArtistKeys, ctx.autoplayMode, + ctx.artistFrequency, + ctx.implicitDislikeKeys, + ctx.implicitLikeKeys, ) upsertScoredCandidate(ctx.candidates, track, { score: rec.score + GENRE_SCORE_BOOST, @@ -1176,6 +1293,9 @@ function calculateRecommendationScore( preferredArtistKeys: Set = new Set(), blockedArtistKeys: Set = new Set(), autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', + artistFrequency: Map = new Map(), + implicitDislikeKeys: Set = new Set(), + implicitLikeKeys: Set = new Set(), ): { score: number; reason: string } { const currentArtist = currentTrack.author.toLowerCase() const candidateArtist = candidate.author.toLowerCase() @@ -1193,12 +1313,33 @@ function calculateRecommendationScore( reasons.push('preferred artist') } + const freq = artistFrequency.get(candidateArtistKey) ?? 0 + if (freq >= 5) { + score += 0.3 + reasons.push('favourite artist') + } else if (freq >= 3) { + score += 0.2 + reasons.push('liked artist') + } else if (freq >= 1) { + score += 0.1 + reasons.push('known artist') + } + const candidateKey = normalizeTrackKey(candidate.title, candidate.author) if (likedTrackKeys.has(candidateKey)) { score += 0.3 reasons.push('liked track') } + if (implicitDislikeKeys.has(candidateKey)) { + score -= 0.35 + reasons.push('skipped before') + } + if (implicitLikeKeys.has(candidateKey)) { + score += 0.25 + reasons.push('completed before') + } + if (candidateArtist === currentArtist) { score -= 0.35 } else if (!recentArtists.has(candidateArtist)) { @@ -1242,6 +1383,11 @@ function calculateRecommendationScore( reasons.push('long track penalty') } + if (candidate.source === 'spotify' && currentTrack.source === 'spotify') { + score += 0.08 + reasons.push('spotify mood match') + } + // Mode-specific adjustments if (autoplayMode === 'discover') { // Boost novelty — prefer artists not heard recently diff --git a/sonar-project.properties b/sonar-project.properties index 244a03009..5d03b58f5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,6 +12,6 @@ sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/*.t sonar.coverage.exclusions=packages/shared/src/** -sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,packages/bot/src/functions/music/commands/spotify.ts,packages/bot/src/functions/music/commands/play/spotifyHandler.ts,packages/backend/src/routes/spotify.ts,packages/backend/src/services/SpotifyAuthService.ts,packages/frontend/src/pages/Spotify.tsx +sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,packages/bot/src/functions/music/commands/spotify.ts,packages/bot/src/functions/music/commands/play/spotifyHandler.ts,packages/backend/src/routes/spotify.ts,packages/backend/src/services/SpotifyAuthService.ts,packages/frontend/src/pages/Spotify.tsx,packages/bot/src/spotify/spotifyApi.ts,packages/bot/src/lastfm/lastFmApi.ts,packages/bot/src/utils/music/autoplay/lastFmSeeds.ts sonar.javascript.lcov.reportPaths=packages/backend/coverage/lcov.info,packages/bot/coverage/lcov.info,packages/frontend/coverage/lcov.info