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
198 changes: 198 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { QueryType, type Track, type GuildQueue } from 'discord-player'
import type { User } from 'discord.js'
import { lastFmLinkService } from '@lucky/shared/services'
import {
consumeLastFmSeedSlice,
consumeBlendedSeedSlice,
} from './lastFmSeeds'
import { getSimilarTracks } from '../../../lastfm'
import {
cleanSearchQuery,
cleanTitle,
} from '../searchQueryCleaner'
import type { SessionMood } from './sessionMood'
import { calculateRecommendationScore } from './candidateScorer'
import {
shouldIncludeCandidate,
upsertScoredCandidate,
normalizeTrackKey,
} from '../queueManipulation'
import type { QueueMetadata } from '../../../types/QueueMetadata'

const LASTFM_SEED_COUNT = 3
const LASTFM_SCORE_BOOST = 0.0

Check warning on line 23 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=AZ2Yp6AVQydYG71oe4Nu&open=AZ2Yp6AVQydYG71oe4Nu&pullRequest=668
const MAX_SIMILAR_LOOKUPS = 5
const SEARCH_RESULTS_LIMIT = 8
const MAX_AUTOPLAY_DURATION_MS = 10 * 60 * 1000
const AUTOPLAY_BUFFER_SIZE = 8

type ScoredTrack = {
track: Track
score: number
reason: string
}

export async function collectLastFmCandidates(

Check failure on line 35 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=AZ2Yp6AVQydYG71oe4Nw&open=AZ2Yp6AVQydYG71oe4Nw&pullRequest=668

Check warning on line 35 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=AZ2Yp6AVQydYG71oe4Nv&open=AZ2Yp6AVQydYG71oe4Nv&pullRequest=668
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
}
}
}

export async function searchLastFmQuery(
queue: GuildQueue,
query: string,
requestedBy: User,
): Promise<Track[]> {
const engines: QueryType[] = [
QueryType.SPOTIFY_SEARCH,
QueryType.YOUTUBE_SEARCH,
QueryType.AUTO,
]
for (const engine of engines) {
try {
const result = await queue.player.search(query, {
requestedBy,
searchEngine: engine,
})
const tracks = result.tracks
.filter(
(t) =>
!t.durationMS ||
t.durationMS <= MAX_AUTOPLAY_DURATION_MS,
)
.slice(0, SEARCH_RESULTS_LIMIT)
if (tracks.length > 0) return tracks
} catch {
continue
}
}
return []
}
168 changes: 3 additions & 165 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=AZ2Yp6A4QydYG71oe4Nx&open=AZ2Yp6A4QydYG71oe4Nx&pullRequest=668
spotifyLinkService,
} from '@lucky/shared/services'
import {
Expand All @@ -25,8 +25,8 @@
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=AZ2Yp6A4QydYG71oe4Ny&open=AZ2Yp6A4QydYG71oe4Ny&pullRequest=668
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=AZ2Yp6A4QydYG71oe4Nz&open=AZ2Yp6A4QydYG71oe4Nz&pullRequest=668
} from './autoplay/lastFmSeeds'
import { detectSessionMood, type SessionMood } from './autoplay/sessionMood'
import {
Expand All @@ -47,7 +47,8 @@
addSelectedTracks,
purgeDuplicatesOfCurrentTrack,
} from './autoplay/diversitySelector'
import { collectLastFmCandidates, searchLastFmQuery } from './autoplay/lastFmSeeder'
import { getSimilarTracks, getTagTopTracks } from '../../lastfm'

Check warning on line 51 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=AZ2Yp6A4QydYG71oe4N0&open=AZ2Yp6A4QydYG71oe4N0&pullRequest=668
import {
cleanSearchQuery,
cleanTitle,
Expand Down Expand Up @@ -801,171 +802,6 @@
}


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(
queue: GuildQueue,
query: string,
requestedBy: User,
): Promise<Track[]> {
const engines: QueryType[] = [
QueryType.SPOTIFY_SEARCH,
QueryType.YOUTUBE_SEARCH,
QueryType.AUTO,
]
for (const engine of engines) {
try {
const result = await queue.player.search(query, {
requestedBy,
searchEngine: engine,
})
const tracks = result.tracks
.filter(
(t) =>
!t.durationMS ||
t.durationMS <= MAX_AUTOPLAY_DURATION_MS,
)
.slice(0, SEARCH_RESULTS_LIMIT)
if (tracks.length > 0) return tracks
} catch {
continue
}
}
return []
}

const GENRE_SCORE_BOOST = 0.1
const MAX_GENRES = 3
const MAX_TRACKS_PER_GENRE = 20
Expand Down Expand Up @@ -1755,3 +1591,5 @@

await replenishQueue(queue)
}

export { collectLastFmCandidates }

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `collectLastFmCandidates`.

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