Repository navigation
feat(bot): intelligent autoplay — skip/completion signals, loved tracks, artist frequency, mood matching #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, Track>() | ||
|
|
||
| const guildTrackStartTimes = new Map<string, number>() | ||
|
|
||
| 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<void> { | ||
| 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<void> => { | ||
| 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<void> => { | ||
| 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', | ||
| ) | ||
| } | ||
|
Comment on lines
265
to
+279
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decouple implicit feedback from scrobble/history failures. Both handlers await Also applies to: 314-326 🤖 Prompt for AI Agents |
||
|
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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([]) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+521
to
+525
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden the non-OK test; current setup can pass for the wrong reason. At Line 521, Suggested test fix it('returns empty array on non-ok response', async () => {
- fetchMock.mockResolvedValueOnce({ ok: false })
+ fetchMock.mockResolvedValueOnce({
+ ok: false,
+ json: async () => ({
+ lovedtracks: {
+ track: [{ name: 'Should Not Be Returned', artist: { name: 'Ignored' } }],
+ },
+ }),
+ })
const result = await getLovedTracks('testuser')
expect(result).toEqual([])
})This will also expose the missing 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| 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([]) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a strict
> 20sgate for skip feedback.The current check allows
durationMS === 20_000, but the feature is described as recording early skips only for tracks longer than 20 seconds. This should be<= minDurationMshere, otherwise 20.0s tracks are misclassified.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents