Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 25 additions & 49 deletions packages/bot/src/handlers/player/trackHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,34 +63,16 @@ function normalizeTrackKeyForFeedback(title: string, author: string): string {
return `${normalizedTitle}::${normalizedAuthor}`
}

async function recordPlayBehavior(
guildId: string,
track: Track | undefined,
requestedById: string | undefined,
minDurationMs: number,
ratioThreshold: number,
feedbackType: 'implicit_like' | 'implicit_dislike',
async function recordImplicitTrackFeedback(
track: Track,
type: '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,
)
}
const requesterId =
track.requestedBy?.id ??
(track.metadata as { requestedById?: string } | undefined)?.requestedById
if (!requesterId) return
const trackKey = normalizeTrackKeyForFeedback(track.title, track.author)
await recommendationFeedbackService.recordImplicitFeedback(requesterId, trackKey, type)
}

function evictOldEntries(): void {
Expand Down Expand Up @@ -265,17 +247,14 @@ const handlePlayerFinish = 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,
0,
0.8,
'implicit_like',
)
const startTime = guildTrackStartTimes.get(queue.guild.id)
if (startTime && track.durationMS) {
const completionRatio = (Date.now() - startTime) / track.durationMS
if (completionRatio > 0.8) {
await recordImplicitTrackFeedback(track, 'implicit_like')
}
}
guildTrackStartTimes.delete(queue.guild.id)
}

if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return
Expand Down Expand Up @@ -312,17 +291,14 @@ 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',
)
const startTime = guildTrackStartTimes.get(queue.guild.id)
if (startTime && track.durationMS && track.durationMS > 20_000) {
const skipRatio = (Date.now() - startTime) / track.durationMS
if (skipRatio < 0.3) {
await recordImplicitTrackFeedback(track, 'implicit_dislike')
}
}
guildTrackStartTimes.delete(queue.guild.id)
}

if (musicWatchdogService.isIntentionalStop(queue.guild.id)) return
Expand Down
128 changes: 128 additions & 0 deletions packages/bot/src/services/musicRecommendation/feedbackService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,116 @@ describe('RecommendationFeedbackService', () => {
expect(summary.blocked).toEqual([])
expect(getMock).not.toHaveBeenCalled()
})

it('getLikedTrackWeights returns weighted map with decay', async () => {
const service = new RecommendationFeedbackService(30)
const key = service.buildTrackKey('Song A', 'Artist A')
const now = Date.now()
const updatedAt = now - 24 * 60 * 60 * 1000

getMock.mockResolvedValue(
JSON.stringify({
[key]: {
feedback: 'like',
updatedAt,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
},
}),
)

const weights = await service.getLikedTrackWeights('user-1', now)

expect(weights.has(key)).toBe(true)
const weight = weights.get(key)
expect(weight).toBeGreaterThan(0.95)
expect(weight).toBeLessThanOrEqual(1.0)
})
Comment thread
LucasSantana-Dev marked this conversation as resolved.

it('getDislikedTrackWeights returns weighted map with decay', async () => {
const service = new RecommendationFeedbackService(30)
const key = service.buildTrackKey('Song B', 'Artist B')
const now = Date.now()
const updatedAt = now - 24 * 60 * 60 * 1000

getMock.mockResolvedValue(
JSON.stringify({
[key]: {
feedback: 'dislike',
updatedAt,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
},
}),
)

const weights = await service.getDislikedTrackWeights('user-1', now)

expect(weights.has(key)).toBe(true)
const weight = weights.get(key)
expect(weight).toBeGreaterThan(0.95)
expect(weight).toBeLessThanOrEqual(1.0)
})

it('getLikedTrackWeights returns empty map for undefined userId', async () => {
const service = new RecommendationFeedbackService(30)

const weights = await service.getLikedTrackWeights('')

expect(weights.size).toBe(0)
expect(getMock).not.toHaveBeenCalled()
})

it('getDislikedTrackWeights returns empty map for undefined userId', async () => {
const service = new RecommendationFeedbackService(30)

const weights = await service.getDislikedTrackWeights('')

expect(weights.size).toBe(0)
expect(getMock).not.toHaveBeenCalled()
})

it('getLikedTrackWeights prunes expired entries and saves', async () => {
const now = 50_000
const service = new RecommendationFeedbackService(30)
const key = service.buildTrackKey('Song D', 'Artist D')

getMock.mockResolvedValue(
JSON.stringify({
[key]: {
feedback: 'like',
updatedAt: now - 10_000,
expiresAt: now - 1,
},
}),
)
setexMock.mockResolvedValue(true)

const weights = await service.getLikedTrackWeights('user-1', now)

expect(weights.size).toBe(0)
expect(setexMock).toHaveBeenCalled()
})

it('decay weight reduces to 0.15 after 30 days', async () => {
const baseTime = 100_000
const service = new RecommendationFeedbackService(30)
const key = service.buildTrackKey('Song C', 'Artist C')
const thirtyDaysAgo = baseTime - 30 * 24 * 60 * 60 * 1000

getMock.mockResolvedValue(
JSON.stringify({
[key]: {
feedback: 'like',
updatedAt: thirtyDaysAgo,
expiresAt: baseTime + 1_000_000,
},
}),
)

const weights = await service.getLikedTrackWeights('user-1', baseTime)

const weight = weights.get(key)
expect(weight).toBeCloseTo(0.15, 1)
})
})

describe('implicit feedback', () => {
Expand Down Expand Up @@ -434,5 +544,23 @@ describe('implicit feedback', () => {

await expect(service.recordImplicitFeedback('user-1', 'key', 'implicit_like')).resolves.toBeUndefined()
})

it('getLikedTrackWeights returns empty map when no feedback data', async () => {
getMock.mockResolvedValue(null)
const service = new RecommendationFeedbackService(30)

const weights = await service.getLikedTrackWeights('user-1')

expect(weights.size).toBe(0)
})

it('getDislikedTrackWeights returns empty map when no feedback data', async () => {
getMock.mockResolvedValue(null)
const service = new RecommendationFeedbackService(30)

const weights = await service.getDislikedTrackWeights('user-1')

expect(weights.size).toBe(0)
})
})

74 changes: 55 additions & 19 deletions packages/bot/src/services/musicRecommendation/feedbackService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
export type RecommendationFeedback = 'like' | 'dislike'
export type ArtistFeedback = 'prefer' | 'block'

function decayWeight(updatedAt: number): number {
const daysSince = (Date.now() - updatedAt) / 86_400_000
return Math.max(0.15, 1.0 - (daysSince / 30) * 0.85)

Check warning on line 10 in packages/bot/src/services/musicRecommendation/feedbackService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use a zero fraction in the number.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2El75hB1HMB1XN73lo&open=AZ2El75hB1HMB1XN73lo&pullRequest=578
Comment thread
LucasSantana-Dev marked this conversation as resolved.
}

type FeedbackEntry = {
feedback: RecommendationFeedback
updatedAt: number
Expand Down Expand Up @@ -124,20 +129,25 @@
return { map: next, changed }
}

private async getTrackKeysByFeedback(
userId: string | undefined,
type: RecommendationFeedback,
now = Date.now(),
): Promise<Set<string>> {
if (!userId) return new Set<string>()

private async getValidFeedbackMap(
userId: string,
now: number,
): Promise<FeedbackMap> {
const map = await this.getFeedbackMap(userId)
const { map: validMap, changed } = this.pruneExpired(map, now)

if (changed) {
await this.saveFeedbackMap(userId, validMap)
}
return validMap
}

private async getTrackKeysByFeedback(
userId: string | undefined,
type: RecommendationFeedback,
now = Date.now(),
): Promise<Set<string>> {
if (!userId) return new Set<string>()
const validMap = await this.getValidFeedbackMap(userId, now)
return new Set(
Object.entries(validMap)
.filter(([, entry]) => entry.feedback === type)
Expand All @@ -161,6 +171,37 @@
return this.getTrackKeysByFeedback(userId, 'like', now)
}

private async getTrackWeightsByFeedback(
userId: string,
type: RecommendationFeedback,
now: number,
): Promise<Map<string, number>> {
const validMap = await this.getValidFeedbackMap(userId, now)
const weights = new Map<string, number>()
for (const [trackKey, entry] of Object.entries(validMap)) {
if (entry.feedback === type) {
weights.set(trackKey, decayWeight(entry.updatedAt))
}
}
return weights
}

async getLikedTrackWeights(
userId: string,
now = Date.now(),
): Promise<Map<string, number>> {
if (!userId) return new Map<string, number>()
return this.getTrackWeightsByFeedback(userId, 'like', now)
}

async getDislikedTrackWeights(
userId: string,
now = Date.now(),
): Promise<Map<string, number>> {
if (!userId) return new Map<string, number>()
return this.getTrackWeightsByFeedback(userId, 'dislike', now)
}

async getFeedbackCounts(
userId: string | undefined,
now = Date.now(),
Expand Down Expand Up @@ -377,17 +418,12 @@
userId: string,
type: 'implicit_dislike' | 'implicit_like',
): Promise<Set<string>> {
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<string>()
}
const map = await this.getImplicitFeedbackMap(userId)
return new Set(
Object.entries(map)
.filter(([, entry]) => entry.type === type)
.map(([trackKey]) => trackKey),
)
}
Comment thread
LucasSantana-Dev marked this conversation as resolved.

async getImplicitDislikeKeys(userId: string): Promise<Set<string>> {
Expand Down
Loading
Loading