diff --git a/src/commands/play.ts b/src/commands/play.ts index 7e5dee1..5ef4c1d 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -4,6 +4,7 @@ import {Except} from 'type-fest'; import {SlashCommandBuilder} from '@discordjs/builders'; import shuffle from 'array-shuffle'; import {inject, injectable} from 'inversify'; +import Spotify from 'spotify-web-api-node'; import Command from '.'; import {TYPES} from '../types.js'; import {QueuedSong, STATUS} from '../services/player.js'; @@ -12,7 +13,10 @@ import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channe import errorMsg from '../utils/error-msg.js'; import GetSongs from '../services/get-songs.js'; import {prisma} from '../utils/db.js'; -import getYouTubeSuggestionsFor from '../utils/get-youtube-suggestions-for.js'; +import ThirdParty from '../services/third-party.js'; +import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js'; +import KeyValueCacheProvider from '../services/key-value-cache.js'; +import {ONE_HOUR_IN_SECONDS} from '../utils/constants.js'; @injectable() export default class implements Command { @@ -35,10 +39,14 @@ export default class implements Command { private readonly playerManager: PlayerManager; private readonly getSongs: GetSongs; + private readonly spotify: Spotify; + private readonly cache: KeyValueCacheProvider; - constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs) { + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs, @inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider) { this.playerManager = playerManager; this.getSongs = getSongs; + this.spotify = thirdParty.spotify; + this.cache = cache; } // eslint-disable-next-line complexity @@ -201,9 +209,16 @@ export default class implements Command { return interaction.respond([]); } - await interaction.respond((await getYouTubeSuggestionsFor(query)).map(s => ({ - name: s, - value: s, - }))); + const suggestions = await this.cache.wrap( + getYouTubeAndSpotifySuggestionsFor, + query, + this.spotify, + 10, + { + expiresIn: ONE_HOUR_IN_SECONDS, + key: `autocomplete:${query}`, + }); + + await interaction.respond(suggestions); } } diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index 780f7ec..996a638 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -15,12 +15,10 @@ import {cleanUrl} from '../utils/url.js'; import ThirdParty from './third-party.js'; import Config from './config.js'; import KeyValueCacheProvider from './key-value-cache.js'; +import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js'; type QueuedSongWithoutChannel = Except; -const ONE_HOUR_IN_SECONDS = 60 * 60; -const ONE_MINUTE_IN_SECONDS = 1 * 60; - @injectable() export default class { private readonly youtube: YouTube; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..70d9e2c --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,2 @@ +export const ONE_HOUR_IN_SECONDS = 60 * 60; +export const ONE_MINUTE_IN_SECONDS = 1 * 60; diff --git a/src/utils/get-youtube-and-spotify-suggestions-for.ts b/src/utils/get-youtube-and-spotify-suggestions-for.ts new file mode 100644 index 0000000..10f9394 --- /dev/null +++ b/src/utils/get-youtube-and-spotify-suggestions-for.ts @@ -0,0 +1,70 @@ +import {ApplicationCommandOptionChoice} from 'discord.js'; +import SpotifyWebApi from 'spotify-web-api-node'; +import getYouTubeSuggestionsFor from './get-youtube-suggestions-for.js'; + +const filterDuplicates = (items: T[]) => { + const results: T[] = []; + + for (const item of items) { + if (!results.some(result => result.name === item.name)) { + results.push(item); + } + } + + return results; +}; + +const getYouTubeAndSpotifySuggestionsFor = async (query: string, spotify: SpotifyWebApi, limit = 10): Promise => { + const [youtubeSuggestions, spotifyResults] = await Promise.all([ + getYouTubeSuggestionsFor(query), + spotify.search(query, ['track', 'album'], {limit: 5}), + ]); + + const totalYouTubeResults = youtubeSuggestions.length; + + const spotifyAlbums = filterDuplicates(spotifyResults.body.albums?.items ?? []); + const spotifyTracks = filterDuplicates(spotifyResults.body.tracks?.items ?? []); + + const totalSpotifyResults = spotifyAlbums.length + spotifyTracks.length; + + // Number of results for each source should be roughly the same. + // If we don't have enough Spotify suggestions, prioritize YouTube results. + const maxSpotifySuggestions = Math.floor(limit / 2); + const numOfSpotifySuggestions = Math.min(maxSpotifySuggestions, totalSpotifyResults); + + const maxYouTubeSuggestions = limit - numOfSpotifySuggestions; + const numOfYouTubeSuggestions = Math.min(maxYouTubeSuggestions, totalYouTubeResults); + + const suggestions: ApplicationCommandOptionChoice[] = []; + + suggestions.push( + ...youtubeSuggestions + .slice(0, numOfYouTubeSuggestions) + .map(suggestion => ({ + name: `YouTube: ${suggestion}`, + value: suggestion, + }), + )); + + const maxSpotifyAlbums = Math.floor(numOfSpotifySuggestions / 2); + const numOfSpotifyAlbums = Math.min(maxSpotifyAlbums, spotifyResults.body.albums?.items.length ?? 0); + const maxSpotifyTracks = numOfSpotifySuggestions - numOfSpotifyAlbums; + + suggestions.push( + ...spotifyAlbums.slice(0, maxSpotifyAlbums).map(album => ({ + name: `Spotify: 💿 ${album.name}${album.artists.length > 0 ? ` - ${album.artists[0].name}` : ''}`, + value: `spotify:album:${album.id}`, + })), + ); + + suggestions.push( + ...spotifyTracks.slice(0, maxSpotifyTracks).map(track => ({ + name: `Spotify: 🎵 ${track.name}${track.artists.length > 0 ? ` - ${track.artists[0].name}` : ''}`, + value: `spotify:track:${track.id}`, + })), + ); + + return suggestions; +}; + +export default getYouTubeAndSpotifySuggestionsFor;