From a634b91b3eb9ca6c0f807d686ead00ce26419a3d Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 8 May 2026 15:59:52 -0300 Subject: [PATCH] feat(autoplay): wire recentSkipCount into mood detection Track early skips (< 30%) per guild with an LRU cache in trackHandlers. The counter increments on each qualifying skip and resets when a track plays to >80% completion. detectSessionMood now receives the real skip count instead of the hardcoded 0, so session mood correctly shifts toward energetic/exploratory when the listener is skipping rapidly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/handlers/player/trackHandlers.spec.ts | 43 +++++++++++++++++++ .../bot/src/handlers/player/trackHandlers.ts | 13 ++++++ .../src/utils/music/autoplay/replenisher.ts | 3 +- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/bot/src/handlers/player/trackHandlers.spec.ts b/packages/bot/src/handlers/player/trackHandlers.spec.ts index 14ccc9e1e..347350020 100644 --- a/packages/bot/src/handlers/player/trackHandlers.spec.ts +++ b/packages/bot/src/handlers/player/trackHandlers.spec.ts @@ -4,6 +4,7 @@ import { lastPlayedTracks, recentlyPlayedTracks, setupTrackHandlers, + getRecentSkipCount, } from './trackHandlers' const QueueRepeatMode = { @@ -569,6 +570,48 @@ describe('trackHandlers autoplay replenishment', () => { ) }) + it('increments getRecentSkipCount on early skip and resets on track completion', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const queue = { + ...createQueue(QueueRepeatMode.AUTOPLAY), + guild: { id: 'guild-skip-count', name: 'Skip Count Guild' }, + } as unknown as GuildQueue + const track = { ...createTrack('skip-count-user'), durationMS: 100000 } + + await handlers.playerStart(queue, track) + jest.advanceTimersByTime(10000) // 10% through — early skip + await handlers.playerSkip(queue, track) + expect(getRecentSkipCount(queue.guild.id)).toBe(1) + + // Another early skip increments further + await handlers.playerStart(queue, track) + jest.advanceTimersByTime(10000) + await handlers.playerSkip(queue, track) + expect(getRecentSkipCount(queue.guild.id)).toBe(2) + + // Completing a track (>80%) resets the counter + await handlers.playerStart(queue, track) + jest.advanceTimersByTime(90000) + await handlers.playerFinish(queue, track) + expect(getRecentSkipCount(queue.guild.id)).toBe(0) + }) + + it('does not increment getRecentSkipCount when skip is after 30% of track', async () => { + jest.useFakeTimers() + const handlers = setupHandlers() + const queue = { + ...createQueue(QueueRepeatMode.AUTOPLAY), + guild: { id: 'guild-skip-late', name: 'Skip Late Guild' }, + } as unknown as GuildQueue + const track = { ...createTrack('skip-late-user'), durationMS: 100000 } + + await handlers.playerStart(queue, track) + jest.advanceTimersByTime(50000) // 50% through — late skip + await handlers.playerSkip(queue, track) + expect(getRecentSkipCount(queue.guild.id)).toBe(0) + }) + it('does not record feedback on playerSkip when track < 20 seconds duration', async () => { jest.useFakeTimers() const handlers = setupHandlers() diff --git a/packages/bot/src/handlers/player/trackHandlers.ts b/packages/bot/src/handlers/player/trackHandlers.ts index 14e190c8f..777f57e70 100644 --- a/packages/bot/src/handlers/player/trackHandlers.ts +++ b/packages/bot/src/handlers/player/trackHandlers.ts @@ -43,6 +43,16 @@ const guildTrackStartTimes = new LRUCache({ updateAgeOnGet: true, }) +const guildRecentSkipCounts = new LRUCache({ + max: MAX_GUILD_ENTRIES, + ttl: TRACK_STATE_TTL_MS, + updateAgeOnGet: true, +}) + +export function getRecentSkipCount(guildId: string): number { + return guildRecentSkipCounts.get(guildId) ?? 0 +} + export type TrackHistoryEntry = { url: string title: string @@ -278,6 +288,7 @@ const handlePlayerFinish = async ( (Date.now() - startTime) / track.durationMS if (completionRatio > 0.8) { await recordImplicitTrackFeedback(track, 'implicit_like') + guildRecentSkipCounts.delete(queue.guild.id) } } guildTrackStartTimes.delete(queue.guild.id) @@ -317,6 +328,8 @@ const handlePlayerSkip = async ( const skipRatio = (Date.now() - startTime) / track.durationMS if (skipRatio < 0.3) { await recordImplicitTrackFeedback(track, 'implicit_dislike') + const current = guildRecentSkipCounts.get(queue.guild.id) ?? 0 + guildRecentSkipCounts.set(queue.guild.id, current + 1) } } guildTrackStartTimes.delete(queue.guild.id) diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index e0ffbf6ed..b4a47e1c5 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -12,6 +12,7 @@ import { getArtistPopularity, } from '../../../spotify/spotifyApi' import { detectSessionMood, type SessionMood } from './sessionMood' +import { getRecentSkipCount } from '../../../handlers/player/trackHandlers' import { collectRecommendationCandidates, SERTANEJO_TAGS, @@ -116,7 +117,7 @@ async function _replenishQueue( Math.abs(allHistoryTracks.length - guildMoodCache.historyLen) < 3 ? guildMoodCache.mood : (() => { - const mood = detectSessionMood(allHistoryTracks, 0 /* TODO: wire recentSkipCount from skip tracking */) + const mood = detectSessionMood(allHistoryTracks, getRecentSkipCount(replenishGuildId)) sessionMoodCache.set(replenishGuildId, { mood, historyLen: allHistoryTracks.length,