Skip to content
Closed
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
230 changes: 230 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { GuildQueue } from 'discord-player'

Check warning on line 1 in packages/bot/src/utils/music/autoplay/lastFmSeeder.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'/home/runner/work/Lucky/Lucky/node_modules/discord-player/dist/index.mjs' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldF2xD0pevEjPrJC&open=AZ2YldF2xD0pevEjPrJC&pullRequest=665
import type { User } from 'discord.js'
import { lastFmLinkService } from '@lucky/shared/services'
import { getSimilarTracks } from '../../../lastfm'
import {
cleanSearchQuery,
cleanTitle,
cleanAuthor,
} from '../searchQueryCleaner'
import {
consumeLastFmSeedSlice,
consumeBlendedSeedSlice,

} from './lastFmSeeds'
import type { SessionMood } from './sessionMood'
import { searchLastFmQuery, calculateRecommendationScore } from '../queueManipulation.js'
import type { Track } from 'discord-player'

Check warning on line 17 in packages/bot/src/utils/music/autoplay/lastFmSeeder.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'/home/runner/work/Lucky/Lucky/node_modules/discord-player/dist/index.mjs' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldF2xD0pevEjPrJD&open=AZ2YldF2xD0pevEjPrJD&pullRequest=665

// Types (duplicated from queueManipulation.ts to avoid circular dependency)
type ScoredTrack = {
track: Track
score: number
reason: string
}

// Constants
const AUTOPLAY_BUFFER_SIZE = 8
const LASTFM_SEED_COUNT = 3
const LASTFM_SCORE_BOOST = 0.0

Check warning on line 29 in packages/bot/src/utils/music/autoplay/lastFmSeeder.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=AZ2YldF2xD0pevEjPrJE&open=AZ2YldF2xD0pevEjPrJE&pullRequest=665
const MAX_SIMILAR_LOOKUPS = 5

// Helper functions (duplicated from queueManipulation.ts to avoid circular dependency)
function stripFeaturing(author: string): string {
const lower = author.toLowerCase()
for (const kw of [' feat ', ' ft ', ' con ', ' with ']) {
const idx = lower.indexOf(kw)
if (idx >= 0) return author.slice(0, idx)
}
return author
}

function normalizeText(value?: string): string {
return (value ?? '')
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '')
.trim()
}

function normalizeTrackKey(title?: string, author?: string): string {
const cleanedTitle = title ? cleanTitle(title) : ''
const primaryAuthor = author
? stripFeaturing(cleanAuthor(author).split(',')[0] ?? '').trim()
: ''
return `${normalizeText(cleanedTitle)}::${normalizeText(primaryAuthor)}`
}

function getTrackKey(track: Track): string {
return track.id || track.url || normalizeTrackKey(track.title, track.author)
}

function shouldIncludeCandidate(
track: Track,
excludedUrls: Set<string>,
excludedKeys: Set<string>,
): boolean {
if (track.url && excludedUrls.has(track.url)) return false

const normalizedKey = normalizeTrackKey(track.title, track.author)
if (normalizedKey !== '::' && excludedKeys.has(normalizedKey))
return false
const trackKey = getTrackKey(track)
if (excludedKeys.has(trackKey)) return false

return true
}

function upsertScoredCandidate(
candidates: Map<string, ScoredTrack>,
candidate: Track,
recommendation: { score: number; reason: string },
): void {
const normalizedKey = normalizeTrackKey(candidate.title, candidate.author)
const candidateKey =
normalizedKey !== '::' ? normalizedKey : getTrackKey(candidate)

Check warning on line 84 in packages/bot/src/utils/music/autoplay/lastFmSeeder.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldF2xD0pevEjPrJF&open=AZ2YldF2xD0pevEjPrJF&pullRequest=665
const existing = candidates.get(candidateKey)

if (!existing || recommendation.score > existing.score) {
candidates.set(candidateKey, {
track: candidate,
score: recommendation.score,
reason: recommendation.reason,
})
}
}

export async function collectLastFmCandidates(

Check warning on line 96 in packages/bot/src/utils/music/autoplay/lastFmSeeder.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Async function 'collectLastFmCandidates' has too many parameters (17). Maximum allowed is 7.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldF2xD0pevEjPrJG&open=AZ2YldF2xD0pevEjPrJG&pullRequest=665

Check failure on line 96 in packages/bot/src/utils/music/autoplay/lastFmSeeder.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 43 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldF2xD0pevEjPrJH&open=AZ2YldF2xD0pevEjPrJH&pullRequest=665
queue: GuildQueue,
requestedBy: User,
excludedUrls: Set<string>,
excludedKeys: Set<string>,
dislikedWeights: Map<string, number>,
likedWeights: Map<string, number>,
preferredArtistKeys: Set<string>,
blockedArtistKeys: Set<string>,
currentTrack: Track,
recentArtists: Set<string>,
candidates: Map<string, ScoredTrack>,
autoplayMode: 'similar' | 'discover' | 'popular' = 'similar',
artistFrequency: Map<string, number> = new Map(),
implicitDislikeKeys: Set<string> = new Set(),
implicitLikeKeys: Set<string> = new Set(),
sessionMood: SessionMood | null = null,
contributionWeights?: Map<string, number>,
): Promise<void> {
const metadata = queue.metadata as {
vcMemberIds?: string[]
}
const vcMemberIds = metadata?.vcMemberIds ?? []

const otherUserIds = vcMemberIds.filter((id) => id !== requestedBy.id)

let seedSlice: { artist: string; title: string }[] = []
if (otherUserIds.length > 0) {
const linkedUsers = await Promise.all(
[requestedBy.id, ...otherUserIds].map(async (id) => {
const link = await lastFmLinkService.getByDiscordId(id)
return link?.lastFmUsername ? id : null
}),
)
const linkedUserIds = linkedUsers.filter(
(id) => id !== null,
) as string[]

if (linkedUserIds.length > 1) {
seedSlice = await consumeBlendedSeedSlice(
linkedUserIds,
LASTFM_SEED_COUNT,
contributionWeights,
)
} else if (linkedUserIds.length === 1) {
seedSlice = await consumeLastFmSeedSlice(
linkedUserIds[0],
LASTFM_SEED_COUNT,
)
}
} else {
seedSlice = await consumeLastFmSeedSlice(
requestedBy.id,
LASTFM_SEED_COUNT,
)
}

if (seedSlice.length === 0) return

for (const seed of seedSlice) {
const query = cleanSearchQuery(seed.title, seed.artist)
const tracks = await searchLastFmQuery(queue, query, requestedBy)
for (const track of tracks) {
if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys))
continue
const normalizedKey = normalizeTrackKey(track.title, track.author)
const dislikedWeight = dislikedWeights.get(normalizedKey)
if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue
const rec = calculateRecommendationScore(
track,
currentTrack,
recentArtists,
likedWeights,
preferredArtistKeys,
blockedArtistKeys,
autoplayMode,
artistFrequency,
implicitDislikeKeys,
implicitLikeKeys,
dislikedWeights,
sessionMood,
true,
)
if (rec.score === -Infinity) continue
upsertScoredCandidate(candidates, track, {
score: rec.score + LASTFM_SCORE_BOOST,
reason: rec.reason
? `${rec.reason} • last.fm taste`
: 'last.fm taste',
})
}

const similar = await getSimilarTracks(
seed.artist,
cleanTitle(seed.title),
)
for (const s of similar.slice(0, MAX_SIMILAR_LOOKUPS)) {
const query = cleanSearchQuery(s.title, s.artist)
const tracks = await searchLastFmQuery(queue, query, requestedBy)
for (const track of tracks) {
if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys))
continue
const normalizedKey = normalizeTrackKey(
track.title,
track.author,
)
const dislikedWeight = dislikedWeights.get(normalizedKey)
if (dislikedWeight !== undefined && dislikedWeight > 0.5)
continue
const rec = calculateRecommendationScore(
track,
currentTrack,
recentArtists,
likedWeights,
preferredArtistKeys,
blockedArtistKeys,
autoplayMode,
artistFrequency,
implicitDislikeKeys,
implicitLikeKeys,
dislikedWeights,
null,
true,
)
upsertScoredCandidate(candidates, track, {
score: (rec.score + LASTFM_SCORE_BOOST) * (s.match / 100),
reason: rec.reason
? `${rec.reason} • similar to your taste`
: 'similar to your taste',
})
}
if (candidates.size >= AUTOPLAY_BUFFER_SIZE) break
}
}
}
139 changes: 3 additions & 136 deletions packages/bot/src/utils/music/queueManipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import {
trackHistoryService,
guildSettingsService,
lastFmLinkService,

Check warning on line 15 in packages/bot/src/utils/music/queueManipulation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'lastFmLinkService'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldKTxD0pevEjPrJI&open=AZ2YldKTxD0pevEjPrJI&pullRequest=665
spotifyLinkService,
} from '@lucky/shared/services'
import {
Expand All @@ -25,9 +25,10 @@
type SpotifyAudioFeatures,
} from '../../spotify/spotifyApi'
import {
consumeLastFmSeedSlice,

Check warning on line 28 in packages/bot/src/utils/music/queueManipulation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'consumeLastFmSeedSlice'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldKTxD0pevEjPrJJ&open=AZ2YldKTxD0pevEjPrJJ&pullRequest=665
consumeBlendedSeedSlice,

Check warning on line 29 in packages/bot/src/utils/music/queueManipulation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'consumeBlendedSeedSlice'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldKTxD0pevEjPrJK&open=AZ2YldKTxD0pevEjPrJK&pullRequest=665
} from './autoplay/lastFmSeeds'
import { collectLastFmCandidates } from './autoplay/lastFmSeeder.js'
import { detectSessionMood, type SessionMood } from './autoplay/sessionMood'
import {
buildExcludedUrls,
Expand All @@ -41,7 +42,7 @@
import {
cleanSearchQuery,
cleanTitle,
cleanAuthor,

Check warning on line 45 in packages/bot/src/utils/music/queueManipulation.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'getSimilarTracks'.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2YldKTxD0pevEjPrJL&open=AZ2YldKTxD0pevEjPrJL&pullRequest=665
extractSongCore,
} from './searchQueryCleaner'
import { calculateStringSimilarity } from './duplicateDetection/similarityChecker'
Expand Down Expand Up @@ -1114,141 +1115,7 @@
}
}

async function collectLastFmCandidates(
queue: GuildQueue,
requestedBy: User,
excludedUrls: Set<string>,
excludedKeys: Set<string>,
dislikedWeights: Map<string, number>,
likedWeights: Map<string, number>,
preferredArtistKeys: Set<string>,
blockedArtistKeys: Set<string>,
currentTrack: Track,
recentArtists: Set<string>,
candidates: Map<string, ScoredTrack>,
autoplayMode: 'similar' | 'discover' | 'popular' = 'similar',
artistFrequency: Map<string, number> = new Map(),
implicitDislikeKeys: Set<string> = new Set(),
implicitLikeKeys: Set<string> = new Set(),
sessionMood: SessionMood | null = null,
contributionWeights?: Map<string, number>,
): Promise<void> {
const metadata = queue.metadata as QueueMetadata
const vcMemberIds = metadata?.vcMemberIds ?? []

const otherUserIds = vcMemberIds.filter((id) => id !== requestedBy.id)

let seedSlice: { artist: string; title: string }[] = []
if (otherUserIds.length > 0) {
const linkedUsers = await Promise.all(
[requestedBy.id, ...otherUserIds].map(async (id) => {
const link = await lastFmLinkService.getByDiscordId(id)
return link?.lastFmUsername ? id : null
}),
)
const linkedUserIds = linkedUsers.filter(
(id) => id !== null,
) as string[]

if (linkedUserIds.length > 1) {
seedSlice = await consumeBlendedSeedSlice(
linkedUserIds,
LASTFM_SEED_COUNT,
contributionWeights,
)
} else if (linkedUserIds.length === 1) {
seedSlice = await consumeLastFmSeedSlice(
linkedUserIds[0],
LASTFM_SEED_COUNT,
)
}
} else {
seedSlice = await consumeLastFmSeedSlice(
requestedBy.id,
LASTFM_SEED_COUNT,
)
}

if (seedSlice.length === 0) return

for (const seed of seedSlice) {
const query = cleanSearchQuery(seed.title, seed.artist)
const tracks = await searchLastFmQuery(queue, query, requestedBy)
for (const track of tracks) {
if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys))
continue
const normalizedKey = normalizeTrackKey(track.title, track.author)
const dislikedWeight = dislikedWeights.get(normalizedKey)
if (dislikedWeight !== undefined && dislikedWeight > 0.5) continue
const rec = calculateRecommendationScore(
track,
currentTrack,
recentArtists,
likedWeights,
preferredArtistKeys,
blockedArtistKeys,
autoplayMode,
artistFrequency,
implicitDislikeKeys,
implicitLikeKeys,
dislikedWeights,
sessionMood,
true,
)
if (rec.score === -Infinity) continue
upsertScoredCandidate(candidates, track, {
score: rec.score + LASTFM_SCORE_BOOST,
reason: rec.reason
? `${rec.reason} • last.fm taste`
: 'last.fm taste',
})
}

const similar = await getSimilarTracks(
seed.artist,
cleanTitle(seed.title),
)
for (const s of similar.slice(0, MAX_SIMILAR_LOOKUPS)) {
const query = cleanSearchQuery(s.title, s.artist)
const tracks = await searchLastFmQuery(queue, query, requestedBy)
for (const track of tracks) {
if (!shouldIncludeCandidate(track, excludedUrls, excludedKeys))
continue
const normalizedKey = normalizeTrackKey(
track.title,
track.author,
)
const dislikedWeight = dislikedWeights.get(normalizedKey)
if (dislikedWeight !== undefined && dislikedWeight > 0.5)
continue
const rec = calculateRecommendationScore(
track,
currentTrack,
recentArtists,
likedWeights,
preferredArtistKeys,
blockedArtistKeys,
autoplayMode,
artistFrequency,
implicitDislikeKeys,
implicitLikeKeys,
dislikedWeights,
null,
true,
)
upsertScoredCandidate(candidates, track, {
score: (rec.score + LASTFM_SCORE_BOOST) * (s.match / 100),
reason: rec.reason
? `${rec.reason} • similar to your taste`
: 'similar to your taste',
})
}
if (candidates.size >= AUTOPLAY_BUFFER_SIZE) break
}
}
}

async function searchLastFmQuery(
export async function searchLastFmQuery(
queue: GuildQueue,
query: string,
requestedBy: User,
Expand Down Expand Up @@ -1701,7 +1568,7 @@
return isStrongGenre ? -0.6 : -0.3
}

function calculateRecommendationScore(
export function calculateRecommendationScore(
candidate: Track,
currentTrack: Track,
recentArtists: Set<string>,
Expand Down
Loading