diff --git a/packages/bot/src/lastfm/index.ts b/packages/bot/src/lastfm/index.ts index 419c9971e..1f3f8084e 100644 --- a/packages/bot/src/lastfm/index.ts +++ b/packages/bot/src/lastfm/index.ts @@ -6,6 +6,7 @@ export { getSimilarTracks, getTagTopTracks, getLovedTracks, + getArtistTopTracks, isLastFmInvalidSessionError, normalizeLastFmArtist, normalizeLastFmTitle, diff --git a/packages/bot/src/lastfm/lastFmApi.ts b/packages/bot/src/lastfm/lastFmApi.ts index c26dd450b..03de211c9 100644 --- a/packages/bot/src/lastfm/lastFmApi.ts +++ b/packages/bot/src/lastfm/lastFmApi.ts @@ -320,3 +320,31 @@ export async function getLovedTracks( return [] } } + +export async function getArtistTopTracks( + artist: string, + limit = 10, +): Promise<{ artist: string; title: string }[]> { + const config = getApiConfig() + if (!config) return [] + try { + const response = await fetch( + `${API_BASE}?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&limit=${limit}&format=json&api_key=${config.apiKey}`, + ) + const data = (await response.json()) as { + toptracks?: { + track?: Array<{ + name: string + artist: { name: string } + }> + } + } + return (data.toptracks?.track ?? []).map((t) => ({ + artist: t.artist.name, + title: t.name, + })) + } catch (err) { + logAndSwallow(err, 'lastfm.getArtistTopTracks', { artist }) + return [] + } +} diff --git a/packages/bot/src/services/musicRecommendation/feedbackService.ts b/packages/bot/src/services/musicRecommendation/feedbackService.ts index 2368142b6..2db05ad51 100644 --- a/packages/bot/src/services/musicRecommendation/feedbackService.ts +++ b/packages/bot/src/services/musicRecommendation/feedbackService.ts @@ -470,8 +470,31 @@ export class RecommendationFeedbackService { async getImplicitLikeKeys(userId: string): Promise> { return this.getImplicitKeysByType(userId, 'implicit_like') } -} + + async getPreferredArtistNames( + guildId: string, + userId: string | undefined, + ): Promise> { + if (!userId) return new Set() + + try { + const db = getPrismaClient() + const prefs = await db.userArtistPreference.findMany({ + where: { discordUserId: userId, guildId, preference: 'prefer' }, + select: { artistName: true }, + }) + return new Set( + prefs + .map((p) => p.artistName.trim()) + .filter((name) => name.length > 0), + ) + } catch { + return new Set() + } + } export const recommendationFeedbackService = new RecommendationFeedbackService( parseInt(process.env.AUTOPLAY_FEEDBACK_TTL_DAYS ?? '30', 10), ) + +} \ No newline at end of file diff --git a/packages/bot/src/spotify/spotifyApi.spec.ts b/packages/bot/src/spotify/spotifyApi.spec.ts index 396ef14cd..e83b2a92a 100644 --- a/packages/bot/src/spotify/spotifyApi.spec.ts +++ b/packages/bot/src/spotify/spotifyApi.spec.ts @@ -43,102 +43,6 @@ describe('spotifyApi', () => { global.fetch = originalFetch }) - describe('getAudioFeatures', () => { - it('returns audio features on successful response', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - energy: 0.8, - valence: 0.75, - danceability: 0.65, - tempo: 120, - acousticness: 0.1, - }), - }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toEqual({ - energy: 0.8, - valence: 0.75, - danceability: 0.65, - tempo: 120, - acousticness: 0.1, - }) - }) - - it('uses default values for missing optional properties', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - energy: 0.8, - valence: 0.75, - }), - }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toEqual({ - energy: 0.8, - valence: 0.75, - danceability: 0, - tempo: 0, - acousticness: 0, - }) - }) - - it('returns null when response is not ok', async () => { - fetchMock.mockResolvedValue({ ok: false }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('returns null when json parsing fails', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => { - throw new Error('JSON parse error') - }, - }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('returns null when energy is missing', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ valence: 0.75, danceability: 0.65 }), - }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('returns null when valence is not a number', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ energy: 0.8, valence: 'high' }), - }) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - - it('catches and returns null on fetch error', async () => { - fetchMock.mockRejectedValue(new Error('Network error')) - - const result = await getAudioFeatures('test-token', 'track-123') - - expect(result).toBeNull() - }) - }) - describe('searchSpotifyTrack', () => { it('returns track id on successful search', async () => { fetchMock.mockResolvedValue({ @@ -229,136 +133,6 @@ describe('spotifyApi', () => { }) }) - describe('getBatchAudioFeatures', () => { - it('returns empty map for empty ids array', async () => { - const result = await getBatchAudioFeatures('token', []) - expect(result.size).toBe(0) - }) - - it('fetches audio features for multiple tracks', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - audio_features: [ - { - id: 'track1', - energy: 0.8, - valence: 0.7, - danceability: 0.6, - tempo: 120, - acousticness: 0.1, - }, - { - id: 'track2', - energy: 0.5, - valence: 0.6, - danceability: 0.7, - tempo: 100, - acousticness: 0.3, - }, - ], - }), - }) - - const result = await getBatchAudioFeatures('token', [ - 'track1', - 'track2', - ]) - - expect(result.size).toBe(2) - expect(result.get('track1')).toEqual({ - energy: 0.8, - valence: 0.7, - danceability: 0.6, - tempo: 120, - acousticness: 0.1, - }) - expect(result.get('track2')).toEqual({ - energy: 0.5, - valence: 0.6, - danceability: 0.7, - tempo: 100, - acousticness: 0.3, - }) - }) - - it('skips null entries in audio_features array', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - audio_features: [ - { - id: 'track1', - energy: 0.8, - valence: 0.7, - danceability: 0.6, - tempo: 120, - acousticness: 0.1, - }, - null, - { - id: 'track3', - energy: 0.5, - valence: 0.6, - danceability: 0.7, - tempo: 100, - acousticness: 0.3, - }, - ], - }), - }) - - const result = await getBatchAudioFeatures('token', [ - 'track1', - 'invalid', - 'track3', - ]) - - expect(result.size).toBe(2) - expect(result.has('track1')).toBe(true) - expect(result.has('track3')).toBe(true) - }) - - it('returns empty map on fetch error', async () => { - fetchMock.mockResolvedValue({ ok: false }) - - const result = await getBatchAudioFeatures('token', ['track1']) - expect(result.size).toBe(0) - }) - - it('returns empty map on json parse error', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => { - throw new Error('JSON error') - }, - }) - - const result = await getBatchAudioFeatures('token', ['track1']) - expect(result.size).toBe(0) - }) - - it('handles missing optional audio feature fields', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - audio_features: [ - { id: 'track1', energy: 0.8, valence: 0.7 }, - ], - }), - }) - - const result = await getBatchAudioFeatures('token', ['track1']) - expect(result.get('track1')).toEqual({ - energy: 0.8, - valence: 0.7, - danceability: 0, - tempo: 0, - acousticness: 0, - }) - }) - }) - describe('getArtistPopularity', () => { it('returns artist popularity from search', async () => { fetchMock.mockResolvedValue({ @@ -409,176 +183,6 @@ describe('spotifyApi', () => { }) }) - describe('getSpotifyRecommendations', () => { - it('returns empty array when seedTrackIds is empty', async () => { - const result = await getSpotifyRecommendations('token', []) - expect(result).toEqual([]) - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('returns tracks on successful response', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - tracks: [ - { - id: 'rec1', - name: 'Recommended Track', - artists: [{ name: 'Artist A' }], - duration_ms: 200000, - }, - { - id: 'rec2', - name: 'Another Track', - artists: [ - { name: 'Artist B' }, - { name: 'Artist C' }, - ], - duration_ms: 180000, - }, - ], - }), - }) - - const result = await getSpotifyRecommendations( - 'token', - ['seed1', 'seed2'], - 10, - ) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - id: 'rec1', - name: 'Recommended Track', - artists: [{ name: 'Artist A' }], - duration_ms: 200000, - }) - expect(result[1].artists).toHaveLength(2) - }) - - it('slices seed track ids to max 5', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ tracks: [] }), - }) - - await getSpotifyRecommendations('token', [ - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - ]) - - const url = fetchMock.mock.calls[0]?.[0] as string - const params = new URLSearchParams(url.split('?')[1]) - expect(params.get('seed_tracks')?.split(',').length).toBe(5) - }) - - it('returns empty array when response is not ok', async () => { - fetchMock.mockResolvedValue({ ok: false }) - const result = await getSpotifyRecommendations('token', ['seed1']) - expect(result).toEqual([]) - }) - - it('returns empty array on network error', async () => { - fetchMock.mockRejectedValue(new Error('network error')) - const result = await getSpotifyRecommendations('token', ['seed1']) - expect(result).toEqual([]) - }) - - it('filters out tracks missing id or name', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - tracks: [ - { - id: 'good', - name: 'Good Track', - artists: [], - duration_ms: 200000, - }, - { - id: null, - name: 'No ID', - artists: [], - duration_ms: 100000, - }, - { - id: 'noid2', - name: null, - artists: [], - duration_ms: 100000, - }, - ], - }), - }) - - const result = await getSpotifyRecommendations('token', ['seed1']) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('good') - }) - - it('passes audio feature constraints as URL parameters when provided', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ tracks: [] }), - }) - - await getSpotifyRecommendations('token', ['seed1'], 10, { - energy: 0.8, - valence: 0.6, - danceability: 0.75, - }) - - const call = fetchMock.mock.calls[0]! - const url = String(call[0]) - expect(url).toContain('min_energy=0.55') - expect(url).toContain('max_energy=1.00') - expect(url).toContain('min_valence=0.35') - expect(url).toContain('max_valence=0.85') - expect(url).toContain('min_danceability=0.50') - expect(url).toContain('max_danceability=1.00') - }) - - it('does not pass audio feature constraints when audioConstraints is undefined', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ tracks: [] }), - }) - - await getSpotifyRecommendations('token', ['seed1'], 10, undefined) - - const call = fetchMock.mock.calls[0]! - const url = String(call[0]) - expect(url).not.toContain('min_energy') - expect(url).not.toContain('min_valence') - expect(url).not.toContain('min_danceability') - }) - - it('clamps audio constraint bounds to [0, 1]', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ tracks: [] }), - }) - - await getSpotifyRecommendations('token', ['seed1'], 10, { - energy: 0.1, - valence: 0.95, - }) - - const call = fetchMock.mock.calls[0]! - const url = String(call[0]) - expect(url).toContain('min_energy=0.00') - expect(url).toContain('max_energy=0.35') - expect(url).toContain('min_valence=0.70') - expect(url).toContain('max_valence=1.00') - }) - }) -}) - describe('getArtistGenres', () => { it('returns empty array when response not ok', async () => { fetchMock.mockResolvedValue({ ok: false }) diff --git a/packages/bot/src/spotify/spotifyApi.ts b/packages/bot/src/spotify/spotifyApi.ts index 48b0e8341..3b5eae131 100644 --- a/packages/bot/src/spotify/spotifyApi.ts +++ b/packages/bot/src/spotify/spotifyApi.ts @@ -1,132 +1,7 @@ import { logAndSwallow } from '@lucky/shared/utils/error' import { debugLog } from '@lucky/shared/utils/general/log' - -export interface SpotifyRecommendationTrack { - id: string - name: string - artists: { name: string }[] - duration_ms: number -} - -export type SpotifyAudioFeatureConstraints = { - energy?: number - valence?: number - danceability?: number -} - -export async function getSpotifyRecommendations( - accessToken: string, - seedTrackIds: string[], - limit = 10, - audioConstraints?: SpotifyAudioFeatureConstraints, -): Promise { - if (seedTrackIds.length === 0) return [] - try { - const params = new URLSearchParams({ - seed_tracks: seedTrackIds.slice(0, 5).join(','), - limit: String(Math.min(limit, 100)), - }) - - if (audioConstraints) { - const TOLERANCE = 0.25 - if (audioConstraints.energy !== undefined) { - params.set( - 'min_energy', - String( - Math.max( - 0, - audioConstraints.energy - TOLERANCE, - ).toFixed(2), - ), - ) - params.set( - 'max_energy', - String( - Math.min( - 1, - audioConstraints.energy + TOLERANCE, - ).toFixed(2), - ), - ) - } - if (audioConstraints.valence !== undefined) { - params.set( - 'min_valence', - String( - Math.max( - 0, - audioConstraints.valence - TOLERANCE, - ).toFixed(2), - ), - ) - params.set( - 'max_valence', - String( - Math.min( - 1, - audioConstraints.valence + TOLERANCE, - ).toFixed(2), - ), - ) - } - if (audioConstraints.danceability !== undefined) { - params.set( - 'min_danceability', - String( - Math.max( - 0, - audioConstraints.danceability - TOLERANCE, - ).toFixed(2), - ), - ) - params.set( - 'max_danceability', - String( - Math.min( - 1, - audioConstraints.danceability + TOLERANCE, - ).toFixed(2), - ), - ) - } - } - const res = await fetch( - `https://api.spotify.com/v1/recommendations?${params.toString()}`, - { headers: { Authorization: `Bearer ${accessToken}` } }, - ) - if (!res.ok) return [] - const data = (await res.json().catch(() => null)) as { - tracks?: Array<{ - id?: string - name?: string - artists?: Array<{ name?: string }> - duration_ms?: number - }> - } - return (data?.tracks ?? []) - .filter((t) => t.id && t.name) - .map((t) => ({ - id: t.id!, - name: t.name!, - artists: (t.artists ?? []).map((a) => ({ name: a.name ?? '' })), - duration_ms: t.duration_ms ?? 0, - })) - } catch (err) { - logAndSwallow(err, 'spotify.getRecommendations', { seedTrackIds: seedTrackIds.length }) - return [] - } -} - import { LRUCache } from 'lru-cache' -export interface SpotifyAudioFeatures { - energy: number - valence: number - danceability: number - tempo: number - acousticness: number -} - interface PopularityEntry { value: number | null } @@ -145,109 +20,6 @@ const artistGenresCache = new LRUCache({ ttl: 24 * 60 * 60 * 1000, }) -export async function getAudioFeatures( - accessToken: string, - spotifyTrackId: string, -): Promise { - try { - const res = await fetch( - `https://api.spotify.com/v1/audio-features/${spotifyTrackId}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ) - - if (!res.ok) { - return null - } - - const data = (await res.json().catch(() => null)) as { - energy?: number - valence?: number - danceability?: number - tempo?: number - acousticness?: number - } - - if (!data?.energy || typeof data.valence !== 'number') { - return null - } - - return { - energy: data.energy, - valence: data.valence, - danceability: data.danceability ?? 0, - tempo: data.tempo ?? 0, - acousticness: data.acousticness ?? 0, - } - } catch (err) { - logAndSwallow(err, 'spotify.getAudioFeatures', { spotifyTrackId }) - return null - } -} - -export async function getBatchAudioFeatures( - accessToken: string, - spotifyIds: string[], -): Promise> { - if (spotifyIds.length === 0) return new Map() - - try { - const ids = spotifyIds.slice(0, 100).join(',') - const res = await fetch( - `https://api.spotify.com/v1/audio-features?ids=${ids}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ) - - if (!res.ok) return new Map() - - const data = (await res.json().catch(() => null)) as { - audio_features?: Array<{ - id?: string - energy?: number - valence?: number - danceability?: number - tempo?: number - acousticness?: number - } | null> - } - - const result = new Map() - if (!data?.audio_features) return result - - for (const feature of data.audio_features) { - if ( - !feature?.id || - typeof feature.energy !== 'number' || - typeof feature.valence !== 'number' - ) { - continue - } - - result.set(feature.id, { - energy: feature.energy, - valence: feature.valence, - danceability: feature.danceability ?? 0, - tempo: feature.tempo ?? 0, - acousticness: feature.acousticness ?? 0, - }) - } - - return result - } catch (err) { - logAndSwallow(err, 'spotify.getBatchAudioFeatures', { count: spotifyIds.length }) - return new Map() - } -} - export async function getArtistPopularity( accessToken: string, artistName: string, diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts index 91eeefc00..8c06d146d 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.spec.ts @@ -219,41 +219,6 @@ describe('candidateCollector', () => { ) }) - it('should collect candidates from Spotify API', async () => { - // Mock Spotify recommender to add candidates - collectSpotifyRecommendationCandidatesMock.mockImplementation( - async (_queue, _seeds, _requestedBy, _excludedUrls, _excludedKeys, _disliked, _liked, _preferred, _blocked, _current, _recent, candidates) => { - upsertScoredCandidate( - candidates, - createTrack({ - title: 'Spotify Result', - author: 'Spotify Artist', - }), - { score: 0.6, reason: 'spotify' }, - ) - }, - ) - searchSeedCandidatesMock.mockResolvedValue([]) - - const queue = createGuildQueue() - const seedTracks = [createTrack()] - - const result = await collectRecommendationCandidates( - queue, - seedTracks, - null, - new Set(), - new Set(), - new Map(), - new Map(), - new Set(), - new Set(), - createTrack(), - new Set(), - 0, - 'similar', - ) - expect(result.size).toBe(1) expect(collectSpotifyRecommendationCandidatesMock).toHaveBeenCalled() }) diff --git a/packages/bot/src/utils/music/autoplay/candidateCollector.ts b/packages/bot/src/utils/music/autoplay/candidateCollector.ts index bbe349ccc..9855f5aa1 100644 --- a/packages/bot/src/utils/music/autoplay/candidateCollector.ts +++ b/packages/bot/src/utils/music/autoplay/candidateCollector.ts @@ -1,10 +1,8 @@ import type { Track, GuildQueue } from 'discord-player' import type { User } from 'discord.js' import { debugLog } from '@lucky/shared/utils' -import type { SpotifyAudioFeatures } from '../../../spotify/spotifyApi' import type { SessionMood } from './sessionMood' import { - collectSpotifyRecommendationCandidates, searchSeedCandidates, } from './spotifyRecommender' import { @@ -56,10 +54,9 @@ export function upsertScoredCandidate( /** * Collect recommendation candidates from multiple sources: - * - Spotify Recommendations API (based on seed tracks) * - Seed track similar searches (YouTube, Spotify) * - * This is the main aggregator that orchestrates Spotify + LastFm + YouTube sourcing. + * This is the main aggregator that orchestrates LastFm + YouTube sourcing. * Last.fm is handled separately by collectLastFmCandidates in _replenishQueue. */ export async function collectRecommendationCandidates( @@ -80,32 +77,9 @@ export async function collectRecommendationCandidates( implicitDislikeKeys: Set = new Set(), implicitLikeKeys: Set = new Set(), sessionMood: SessionMood | null = null, - currentFeatures: SpotifyAudioFeatures | null = null, ): Promise> { const candidates = new Map() - // Collect from Spotify Recommendations API - await collectSpotifyRecommendationCandidates( - queue, - seedTracks, - requestedBy, - excludedUrls, - excludedKeys, - dislikedWeights, - likedWeights, - preferredArtistKeys, - blockedArtistKeys, - currentTrack, - recentArtists, - candidates, - autoplayMode, - artistFrequency, - implicitDislikeKeys, - implicitLikeKeys, - sessionMood, - currentFeatures, - ) - // Collect from seed track searches (YouTube, Spotify similar) for (const seed of seedTracks) { const seedCandidates = await searchSeedCandidates( diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts index 5e71eb63a..7a2676c1d 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts @@ -427,260 +427,3 @@ describe('candidateScorer', () => { }) }) - describe('enrichWithAudioFeatures', () => { - it('returns tracks unchanged when no token available', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue(null) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - null, - ) - - expect(result).toEqual(tracks) - }) - - it('returns tracks unchanged when token fetch fails', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockRejectedValue(new Error('Token error')) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - null, - ) - - expect(result).toEqual(tracks) - }) - - it('returns tracks unchanged when no Spotify tracks found', async () => { - const tracks = [ - { - track: createTrack({ url: 'https://youtube.com/watch?v=123' }), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ) - - expect(result).toEqual(tracks) - }) - - it('handles getBatchAudioFeatures error gracefully', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockRejectedValue(new Error('API error')) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ) - - expect(result).toEqual(tracks) - }) - - it('handles getArtistGenres error when fetching current artist genres', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getArtistGenresMock.mockRejectedValue(new Error('Genre fetch failed')) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - 'Current Artist', - ) - - expect(result).toEqual(tracks) - }) - - it('boosts score for close energy/valence match', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockResolvedValue( - new Map([ - [ - 'testid', - { - energy: 0.52, - valence: 0.52, - } as SpotifyAudioFeatures, - ], - ]), - ) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ) - - expect(result[0].score).toBeGreaterThan(1) - }) - - it('partially boosts for moderate energy/valence delta', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockResolvedValue( - new Map([ - [ - 'testid', - { - energy: 0.65, - valence: 0.75, - } as SpotifyAudioFeatures, - ], - ]), - ) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ) - - expect(result[0].score).toBeGreaterThan(1) - }) - - it('penalizes high energy/valence delta', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockResolvedValue( - new Map([ - [ - 'testid', - { - energy: 0.95, - valence: 0.95, - } as SpotifyAudioFeatures, - ], - ]), - ) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.1, valence: 0.1 } as SpotifyAudioFeatures, - ) - - expect(result[0].score).toBeLessThan(1) - }) - - it('applies genre family penalty when fetching candidate genres', async () => { - const tracks = [ - { - track: createTrack(), - score: 1, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockResolvedValue( - new Map([ - [ - 'testid', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ], - ]), - ) - - let callCount = 0 - getArtistGenresMock.mockImplementation(async () => { - callCount++ - if (callCount === 1) return ['rock', 'metal'] - return ['pop', 'dance'] - }) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - 'Current Artist', - ) - - expect(result[0].score).toBeLessThan(1) - expect(result[0].reason).toContain('genre family drift') - }) - - it('sorts results by score descending', async () => { - const tracks = [ - { - track: createTrack({ - title: 'Low Score', - url: 'https://open.spotify.com/track/lowid', - }), - score: 0.5, - reason: 'test', - }, - { - track: createTrack({ - title: 'High Score', - url: 'https://open.spotify.com/track/highid', - }), - score: 2, - reason: 'test', - }, - ] - spotifyLinkServiceMock.mockResolvedValue('valid-token') - getBatchAudioFeaturesMock.mockResolvedValue(new Map()) - - const result = await enrichWithAudioFeatures( - tracks, - 'user-123', - { energy: 0.5, valence: 0.5 } as SpotifyAudioFeatures, - ) - - expect(result[0].score).toBeGreaterThan(result[1].score) - }) - }) -}) diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.ts index d7f617047..907ca2eca 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.ts @@ -1,42 +1,12 @@ import { LRUCache } from 'lru-cache' import type { Track } from 'discord-player' -import type { SpotifyAudioFeatures } from '../../../spotify/spotifyApi' import { - getBatchAudioFeatures, getArtistGenres, } from '../../../spotify/spotifyApi' import { spotifyLinkService } from '@lucky/shared/services' import type { SessionMood } from './sessionMood' import { cleanAuthor } from '../searchQueryCleaner' -interface AudioFeatureEntry { - value: SpotifyAudioFeatures | null -} - -const audioFeatureCache = new LRUCache({ - max: 10000, - ttl: 24 * 60 * 60 * 1000, -}) - -type ScoredTrack = { - track: Track - score: number - reason: string -} - -const GENRE_FAMILIES = { - rap_hiphop: ['hip hop', 'rap', 'trap', 'drill', 'gangster rap', 'g-funk'], - rnb_soul: ['r&b', 'soul', 'neo soul'], - electronic: ['edm', 'house', 'techno', 'trance', 'dubstep', 'drum and bass', 'electro', 'synthwave'], - rock_metal: ['rock', 'metal', 'punk', 'grunge', 'alternative'], - pop: ['pop', 'dance pop', 'latin pop', 'k-pop', 'indie pop'], - latin: ['reggaeton', 'forró', 'samba', 'bossa nova', 'latin trap', 'trap latino'], - country_folk: ['country', 'folk', 'bluegrass'], - jazz_classical: ['jazz', 'classical', 'orchestral'], - world: ['afrobeat', 'desi', 'bhangra'], - ambient_chill: ['lofi', 'chillwave', 'downtempo', 'ambient'], -} - const AMBIENT_NOISE_RE = /\b(?:rain sounds?|rain for sleep|ocean waves?|waves? sounds?|nature sounds?|forest sounds?|thunder sounds?|white noise|brown noise|pink noise|asmr|sleep sounds?|sleep music|relaxing rain|ambient sounds?|binaural beats?|solfeggio|healing frequ|528 ?hz|432 ?hz|963 ?hz|chakra healing|spa music|massage music|yoga music|deep sleep|baby sleep|guided meditation|meditation music)\b/i // NOSONAR S5852 — trusted track title from internal API, not user input @@ -61,6 +31,35 @@ function normalizeTrackKey(title?: string, author?: string): string { return `${normalizeText(title)}::${normalizeText(cleanedAuthor)}` } +const GENRE_FAMILIES = { + rap_hiphop: ['hip hop', 'rap', 'trap', 'drill', 'gangster rap', 'g-funk'], + rnb_soul: ['r&b', 'soul', 'neo soul'], + electronic: [ + 'edm', + 'house', + 'techno', + 'trance', + 'dubstep', + 'drum and bass', + 'electro', + 'synthwave', + ], + rock_metal: ['rock', 'metal', 'punk', 'grunge', 'alternative'], + pop: ['pop', 'dance pop', 'latin pop', 'k-pop', 'indie pop'], + latin: [ + 'reggaeton', + 'forró', + 'samba', + 'bossa nova', + 'latin trap', + 'trap latino', + ], + country_folk: ['country', 'folk', 'bluegrass'], + jazz_classical: ['jazz', 'classical', 'orchestral'], + world: ['afrobeat', 'desi', 'bhangra'], + ambient_chill: ['lofi', 'chillwave', 'downtempo', 'ambient'], +} + export function getGenreFamilies(genres: string[]): Set { const families = new Set() const lowerGenres = genres.map((g) => g.toLowerCase()) @@ -342,77 +341,3 @@ function splitTokens(value: string): string[] { .filter((token) => token.length > 2) } -export async function enrichWithAudioFeatures( - tracks: ScoredTrack[], - userId: string, - currentFeatures: SpotifyAudioFeatures | null, - currentArtistName?: string, -): Promise { - if (!currentFeatures || !userId) return tracks - - const token = await Promise.resolve( - spotifyLinkService.getValidAccessToken(userId), - ).catch(() => null) - if (!token) return tracks - - const spotifyIds: string[] = [] - const idToTrack = new Map() - - for (const track of tracks) { - if (track.track.url?.includes('open.spotify.com/track/')) { - const match = track.track.url.match(/track\/([a-zA-Z0-9]+)/) - if (match?.[1]) { - spotifyIds.push(match[1]) - idToTrack.set(match[1], track) - } - } - } - - if (spotifyIds.length === 0) return tracks - - const features = await getBatchAudioFeatures(token, spotifyIds).catch( - () => new Map(), - ) - - let currentGenres: string[] = [] - if (currentArtistName) { - currentGenres = await getArtistGenres(token, currentArtistName).catch( - () => [], - ) - } - - for (const [id, feature] of features) { - const track = idToTrack.get(id) - if (!track) continue - - const energyDelta = Math.abs(feature.energy - currentFeatures.energy) - const valenceDelta = Math.abs(feature.valence - currentFeatures.valence) - - if (energyDelta < 0.15 && valenceDelta < 0.2) { - track.score += 0.15 - } else if (energyDelta < 0.3 || valenceDelta < 0.35) { - track.score += 0.07 - } else if (energyDelta > 0.6) { - track.score -= 0.1 - } - - if (currentGenres.length > 0) { - const candidateGenres = await getArtistGenres( - token, - track.track.author, - ).catch(() => []) - const genrePenalty = calculateGenreFamilyPenalty( - currentGenres, - candidateGenres, - ) - if (genrePenalty !== 0) { - track.score += genrePenalty - if (genrePenalty < -0.3) { - track.reason += ' • genre family drift' - } - } - } - } - - return tracks.sort((a, b) => b.score - a.score) -} diff --git a/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.spec.ts b/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.spec.ts new file mode 100644 index 000000000..ec58448f6 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.spec.ts @@ -0,0 +1,131 @@ +import { + describe, + it, + expect, + beforeEach, + jest, + afterEach, +} from '@jest/globals' +import type { GuildQueue, Track } from 'discord-player' +import type { User } from 'discord.js' +import { collectPreferredArtistCandidates } from './preferredArtistSeeder' +import * as lastFm from '../../../lastfm' + +jest.mock('../../../lastfm') + +describe('preferredArtistSeeder', () => { + const getArtistTopTracksMock = jest.mocked(lastFm.getArtistTopTracks) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should collect preferred artist candidates', async () => { + const mockQueue = { + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + title: 'Track 1', + author: 'Artist', + url: 'http://example.com/track1', + durationMS: 180000, + } as Track, + ], + }), + }, + } as unknown as GuildQueue + + const mockUser = { id: 'user-123' } as User + + getArtistTopTracksMock.mockResolvedValue([ + { artist: 'Test Artist', title: 'Top Track 1' }, + ]) + + const candidates = new Map() + + await collectPreferredArtistCandidates( + mockQueue, + mockUser, + new Set(['Test Artist']), + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + { title: 'Current', author: 'Current Artist' } as Track, + new Set(), + candidates, + ) + + expect(candidates.size).toBeGreaterThan(0) + expect(getArtistTopTracksMock).toHaveBeenCalledWith('Test Artist', 6) + }) + + it('should handle empty preferred artists gracefully', async () => { + const mockQueue = { player: {} } as unknown as GuildQueue + const mockUser = { id: 'user-123' } as User + const candidates = new Map() + + await collectPreferredArtistCandidates( + mockQueue, + mockUser, + new Set(), + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + { title: 'Current', author: 'Current Artist' } as Track, + new Set(), + candidates, + ) + + expect(candidates.size).toBe(0) + expect(getArtistTopTracksMock).not.toHaveBeenCalled() + }) + + it('should skip tracks longer than max duration', async () => { + const mockQueue = { + player: { + search: jest.fn().mockResolvedValue({ + tracks: [ + { + title: 'Too Long Track', + author: 'Artist', + url: 'http://example.com/track', + durationMS: 15 * 60 * 1000, + } as Track, + ], + }), + }, + } as unknown as GuildQueue + + const mockUser = { id: 'user-123' } as User + + getArtistTopTracksMock.mockResolvedValue([ + { artist: 'Test Artist', title: 'Long Track' }, + ]) + + const candidates = new Map() + + await collectPreferredArtistCandidates( + mockQueue, + mockUser, + new Set(['Test Artist']), + new Set(), + new Set(), + new Map(), + new Map(), + new Set(), + new Set(), + { title: 'Current', author: 'Current Artist' } as Track, + new Set(), + candidates, + ) + + expect(candidates.size).toBe(0) + }) +}) diff --git a/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.ts b/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.ts new file mode 100644 index 000000000..ade1d6bd0 --- /dev/null +++ b/packages/bot/src/utils/music/autoplay/preferredArtistSeeder.ts @@ -0,0 +1,140 @@ +import { QueryType, type Track, type GuildQueue } from 'discord-player' +import type { User } from 'discord.js' +import { debugLog } from '@lucky/shared/utils' +import { getArtistTopTracks } from '../../../lastfm' +import { + cleanSearchQuery, + cleanTitle, +} from '../searchQueryCleaner' +import type { SessionMood } from './sessionMood' +import { calculateRecommendationScore } from './candidateScorer' +import { + shouldIncludeCandidate, + upsertScoredCandidate, + normalizeTrackKey, +} from '../queueManipulation' + +const MAX_AUTOPLAY_DURATION_MS = 10 * 60 * 1000 +const SEARCH_RESULTS_LIMIT = 8 +const MAX_PREFERRED_ARTISTS_PER_CALL = 5 + +type ScoredTrack = { + track: Track + score: number + reason: string +} + +export async function collectPreferredArtistCandidates( + queue: GuildQueue, + requestedBy: User | null, + preferredArtistNames: Set, + excludedUrls: Set, + excludedKeys: Set, + dislikedWeights: Map, + likedWeights: Map, + preferredArtistKeys: Set, + blockedArtistKeys: Set, + currentTrack: Track, + recentArtists: Set, + candidates: Map, + autoplayMode: 'similar' | 'discover' | 'popular' = 'similar', + artistFrequency: Map = new Map(), + implicitDislikeKeys: Set = new Set(), + implicitLikeKeys: Set = new Set(), + sessionMood: SessionMood | null = null, +): Promise { + if (!requestedBy || preferredArtistNames.size === 0) return + + const artistNames = Array.from(preferredArtistNames).slice( + 0, + MAX_PREFERRED_ARTISTS_PER_CALL, + ) + + for (const artistName of artistNames) { + const topTracks = await getArtistTopTracks(artistName, 6) + + if (topTracks.length === 0) continue + + for (const track of topTracks) { + const query = cleanSearchQuery(track.title, track.artist) + + try { + const searchResult = await queue.player.search(query, { + requestedBy: requestedBy ?? undefined, + searchEngine: QueryType.AUTO, + }) + + for (const foundTrack of searchResult.tracks) { + if ( + !foundTrack.durationMS || + foundTrack.durationMS > MAX_AUTOPLAY_DURATION_MS + ) { + continue + } + + if ( + !shouldIncludeCandidate( + foundTrack, + excludedUrls, + excludedKeys, + ) + ) { + continue + } + + const normalizedKey = normalizeTrackKey( + foundTrack.title, + foundTrack.author, + ) + const dislikedWeight = dislikedWeights.get(normalizedKey) + if ( + dislikedWeight !== undefined && + dislikedWeight > 0.5 + ) { + continue + } + + const rec = calculateRecommendationScore( + foundTrack, + currentTrack, + recentArtists, + likedWeights, + preferredArtistKeys, + blockedArtistKeys, + autoplayMode, + artistFrequency, + implicitDislikeKeys, + implicitLikeKeys, + dislikedWeights, + sessionMood, + ) + + if (rec.score === -Infinity) continue + + const score = rec.score + 0.3 + 0.2 + + upsertScoredCandidate(candidates, foundTrack, { + score, + reason: 'preferred artist', + }) + + break + } + } catch (error) { + debugLog({ + message: 'Preferred artist search failed', + data: { + artistName, + trackTitle: track.title, + error: String(error), + }, + }) + } + } + } + + debugLog({ + message: 'Preferred artist candidates collected', + data: { artistCount: artistNames.length, candidateCount: candidates.size }, + }) +} diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index 88a8c2405..f0f56a3eb 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -22,13 +22,12 @@ import { purgeDuplicatesOfCurrentTrack, } from './diversitySelector' import { collectLastFmCandidates } from './lastFmSeeder' +import { collectPreferredArtistCandidates } from './preferredArtistSeeder' import { cleanAuthor } from '../searchQueryCleaner' import type { QueueMetadata } from '../../../types/QueueMetadata' import { collectBroadFallbackCandidates, collectGenreCandidates, - enrichWithAudioFeatures, - getTrackAudioFeatures, interleaveByArtist, buildVcContributionWeights, } from '../queueManipulation' @@ -104,6 +103,7 @@ async function _replenishQueue( implicitLikeKeys, allPreferredSets, allBlockedSets, + allPreferredNames, ] = await Promise.all([ recommendationFeedbackService.getLikedTrackWeights( requestedBy?.id ?? '', @@ -135,6 +135,15 @@ async function _replenishQueue( ), ), ), + ], + Promise.all( + allMemberIds.map((id) => + recommendationFeedbackService.getPreferredArtistNames( + queue.guild.id, + id, + ), + ), + ), ]) const preferredArtistKeys = new Set( @@ -178,11 +187,6 @@ async function _replenishQueue( }) const recentArtists = buildRecentArtists(currentTrack, historyTracks) const artistFrequency = buildArtistFrequency(persistentHistory) - const currentFeatures = requestedBy?.id - ? await getTrackAudioFeatures(currentTrack, requestedBy.id).catch( - () => null, - ) - : null const guildId = queue.guild.id const replenishCount = replenishCounters.get(guildId) ?? 0 const candidates = await collectRecommendationCandidates( @@ -203,7 +207,7 @@ async function _replenishQueue( implicitDislikeKeys, implicitLikeKeys, sessionMood, - currentFeatures, + ) debugLog({ message: 'Autoplay: recommendation candidates', @@ -322,16 +326,7 @@ async function _replenishQueue( ), ) - const currentAudioFeatures = await getTrackAudioFeatures( - currentTrack, - requestedBy?.id ?? '', - ) - const enriched = await enrichWithAudioFeatures( - selected, - requestedBy?.id ?? '', - currentAudioFeatures, - currentTrack.author, - ) + const enriched = selected if ( (autoplayMode === 'discover' || autoplayMode === 'popular') && diff --git a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts index 49dadec88..91a24d460 100644 --- a/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts +++ b/packages/bot/src/utils/music/autoplay/spotifyRecommender.ts @@ -5,164 +5,20 @@ import { } from 'discord-player' import type { User } from 'discord.js' import { debugLog, warnLog } from '@lucky/shared/utils' -import { spotifyLinkService } from '@lucky/shared/services' -import { - searchSpotifyTrack, - getSpotifyRecommendations, - type SpotifyAudioFeatures, -} from '../../../spotify/spotifyApi' -import { getUserSpotifySeeds } from '../../../spotify/spotifyUserSeeds' import { cleanTitle, cleanAuthor, extractSongCore, cleanSearchQuery, } from '../searchQueryCleaner' -import type { SessionMood } from './sessionMood' import { - shouldIncludeCandidate, - calculateRecommendationScore, - upsertScoredCandidate, - normalizeTrackKey, normalizeText, - extractSpotifyTrackId, } from '../queueManipulation' const MAX_AUTOPLAY_DURATION_MS = 7 * 60 * 1000 const SEARCH_RESULTS_LIMIT = 8 const QUERY_MODIFIERS = ['', 'similar', 'like', 'playlist', 'mix'] -type ScoredTrack = { - track: Track - score: number - reason: string -} - -export async function collectSpotifyRecommendationCandidates( - queue: GuildQueue, - seedTracks: Track[], - requestedBy: User | null, - excludedUrls: Set, - excludedKeys: Set, - dislikedWeights: Map, - likedWeights: Map, - preferredArtistKeys: Set, - blockedArtistKeys: Set, - currentTrack: Track, - recentArtists: Set, - candidates: Map, - autoplayMode: 'similar' | 'discover' | 'popular', - artistFrequency: Map, - implicitDislikeKeys: Set, - implicitLikeKeys: Set, - sessionMood: SessionMood | null, - currentFeatures: SpotifyAudioFeatures | null, -): Promise { - if (!requestedBy) return - const token = await Promise.resolve( - spotifyLinkService.getValidAccessToken(requestedBy.id), - ).catch(() => null) - if (!token) return - - const userSpotifySeeds = await Promise.resolve( - getUserSpotifySeeds(requestedBy.id), - ).catch(() => null) - - const seedIds = seedTracks - .map(extractSpotifyTrackId) - .filter((id): id is string => id !== null) - .slice(0, 5) - - if (seedIds.length === 0) { - const resolved = await Promise.allSettled( - seedTracks.slice(0, 3).map((s) => { - const core = extractSongCore(s.title ?? '', s.author) - return searchSpotifyTrack( - token, - core ?? cleanTitle(s.title ?? ''), - cleanAuthor(s.author), - ) - }), - ) - resolved.forEach((r) => { - if (r.status === 'fulfilled' && r.value) seedIds.push(r.value) - }) - } - - if (seedIds.length === 0) return - const audioConstraints = currentFeatures - ? { - energy: currentFeatures.energy, - valence: currentFeatures.valence, - danceability: currentFeatures.danceability, - } - : undefined - const recs = await getSpotifyRecommendations( - token, - seedIds, - 15, - audioConstraints, - ) - if (recs.length === 0) return - - debugLog({ - message: 'Autoplay: Spotify recommendations fetched', - data: { count: recs.length, seedCount: seedIds.length }, - }) - - const searchResults = await Promise.allSettled( - recs.map((rec) => { - const spotifyUrl = `https://open.spotify.com/track/${rec.id}` - return queue.player.search(spotifyUrl, { - requestedBy: requestedBy ?? undefined, - searchEngine: QueryType.SPOTIFY_SEARCH, - }) - }), - ) - - for (const result of searchResults) { - if (result.status !== 'fulfilled') continue - const track = result.value.tracks.find( - (t) => !t.durationMS || t.durationMS <= MAX_AUTOPLAY_DURATION_MS, - ) - if (!track) continue - 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, - ) - if (rec.score === -Infinity) continue - let score = rec.score + 0.3 - let reason = rec.reason ? `${rec.reason} • spotify rec` : 'spotify rec' - - if (userSpotifySeeds !== null) { - const trackArtistLower = track.author.toLowerCase() - if (userSpotifySeeds.artistNames.has(trackArtistLower)) { - score += 0.08 - reason += ' • spotify taste' - } - } - - upsertScoredCandidate(candidates, track, { - score, - reason, - }) - } -} - export async function searchSeedCandidates( queue: GuildQueue, seed: Track, diff --git a/packages/bot/src/utils/music/queueManipulation.ts b/packages/bot/src/utils/music/queueManipulation.ts index f938020d0..ad1eddc02 100644 --- a/packages/bot/src/utils/music/queueManipulation.ts +++ b/packages/bot/src/utils/music/queueManipulation.ts @@ -16,13 +16,9 @@ import { spotifyLinkService, } from '@lucky/shared/services' import { - getAudioFeatures, searchSpotifyTrack, - getBatchAudioFeatures, getArtistPopularity, getArtistGenres, - getSpotifyRecommendations, - type SpotifyAudioFeatures, } from '../../spotify/spotifyApi' import { consumeLastFmSeedSlice, @@ -30,7 +26,6 @@ import { } from './autoplay/lastFmSeeds' import { detectSessionMood, type SessionMood } from './autoplay/sessionMood' import { - collectSpotifyRecommendationCandidates, searchSeedCandidates, } from './autoplay/spotifyRecommender' import { @@ -79,67 +74,7 @@ const QUEUE_RESCUE_REFILL_THRESHOLD = Number.parseInt( 10, ) -interface AudioFeatureEntry { - value: SpotifyAudioFeatures | null -} - -const audioFeatureCache = new LRUCache({ - max: 5000, - ttl: 24 * 60 * 60 * 1000, -}) - -export async function getTrackAudioFeatures( - track: Track, - userId: string, -): Promise { - const cacheKey = normalizeTrackKey(track.title, track.author) - - const cached = audioFeatureCache.get(cacheKey) - if (cached !== undefined) { - debugLog({ - message: 'Audio feature cache hit', - data: { cacheKey, hasValue: cached.value !== null }, - }) - return cached.value - } - - debugLog({ - message: 'Audio feature cache miss', - data: { cacheKey, cacheSize: audioFeatureCache.size }, - }) - - const token = await spotifyLinkService.getValidAccessToken(userId) - if (!token) { - audioFeatureCache.set(cacheKey, { value: null }) - return null - } - - let spotifyId: string | null = null - - if (track.url && track.url.includes('open.spotify.com/track/')) { - const match = track.url.match(/track\/([a-zA-Z0-9]+)/) - if (match) { - spotifyId = match[1] - } - } - - if (!spotifyId) { - spotifyId = await searchSpotifyTrack( - token, - cleanTitle(track.title ?? ''), - cleanAuthor(track.author ?? ''), - ) - } - - if (!spotifyId) { - audioFeatureCache.set(cacheKey, { value: null }) - return null - } - const features = await getAudioFeatures(token, spotifyId).catch(() => null) - audioFeatureCache.set(cacheKey, { value: features }) - return features -} export type QueueRescueResult = { removedTracks: number @@ -474,80 +409,6 @@ export async function collectGenreCandidates( } } -export async function enrichWithAudioFeatures( - tracks: ScoredTrack[], - userId: string, - currentFeatures: SpotifyAudioFeatures | null, - currentArtistName?: string, -): Promise { - if (!currentFeatures || !userId) return tracks - - const token = await Promise.resolve( - spotifyLinkService.getValidAccessToken(userId), - ).catch(() => null) - if (!token) return tracks - - const spotifyIds: string[] = [] - const idToTrack = new Map() - - for (const track of tracks) { - if (track.track.url?.includes('open.spotify.com/track/')) { - const match = track.track.url.match(/track\/([a-zA-Z0-9]+)/) - if (match?.[1]) { - spotifyIds.push(match[1]) - idToTrack.set(match[1], track) - } - } - } - - if (spotifyIds.length === 0) return tracks - - const features = await getBatchAudioFeatures(token, spotifyIds).catch( - () => new Map(), - ) - - let currentGenres: string[] = [] - if (currentArtistName) { - currentGenres = await getArtistGenres(token, currentArtistName).catch( - () => [], - ) - } - - for (const [id, feature] of features) { - const track = idToTrack.get(id) - if (!track) continue - - const energyDelta = Math.abs(feature.energy - currentFeatures.energy) - const valenceDelta = Math.abs(feature.valence - currentFeatures.valence) - - if (energyDelta < 0.15 && valenceDelta < 0.2) { - track.score += 0.15 - } else if (energyDelta < 0.3 || valenceDelta < 0.35) { - track.score += 0.07 - } else if (energyDelta > 0.6) { - track.score -= 0.1 - } - - if (currentGenres.length > 0) { - const candidateGenres = await getArtistGenres( - token, - track.track.author, - ).catch(() => []) - const genrePenalty = calculateGenreFamilyPenalty( - currentGenres, - candidateGenres, - ) - if (genrePenalty !== 0) { - track.score += genrePenalty - if (genrePenalty < -0.3) { - track.reason += ' • genre family drift' - } - } - } - } - - return tracks.sort((a, b) => b.score - a.score) -} export function interleaveByArtist(tracks: ScoredTrack[]): ScoredTrack[] { const groups = new Map()