From fd782219eff8016a00e87f0c8e44af3a3ba74be6 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Sun, 19 Sep 2021 22:04:34 -0400 Subject: [PATCH] Move to ESM, use ytsr, implement caching Closes #315 --- package.json | 12 ++- src/bot.ts | 22 ++--- src/commands/clear.ts | 6 +- src/commands/config.ts | 4 +- src/commands/disconnect.ts | 6 +- src/commands/fseek.ts | 8 +- src/commands/help.ts | 6 +- src/commands/pause.ts | 8 +- src/commands/play.ts | 15 +-- src/commands/queue.ts | 16 +-- src/commands/seek.ts | 10 +- src/commands/shortcuts.ts | 4 +- src/commands/shuffle.ts | 6 +- src/commands/skip.ts | 8 +- src/commands/unskip.ts | 6 +- src/events/guild-create.ts | 4 +- src/events/voice-state-update.ts | 8 +- src/index.ts | 12 +-- src/inversify.config.ts | 43 ++++---- src/managers/player.ts | 6 +- src/models/cache.ts | 15 +++ src/models/index.ts | 6 +- src/services/cache.ts | 52 ++++++++++ src/services/get-songs.ts | 115 ++++++++++++++++------ src/services/natural-language-commands.ts | 8 +- src/services/player.ts | 2 +- src/services/third-party.ts | 9 +- src/types.ts | 1 + src/utils/db.ts | 6 +- tsconfig.json | 2 +- yarn.lock | 46 ++++++--- 31 files changed, 314 insertions(+), 158 deletions(-) create mode 100644 src/models/cache.ts create mode 100644 src/services/cache.ts diff --git a/package.json b/package.json index eec72d8..7d93ea5 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,16 @@ "name": "muse", "version": "0.1.0", "description": "🎧 a self-hosted Discord music bot that doesn't suck ", - "main": "dist/index.js", + "exports": "./dist/index.js", "repository": "git@github.com:codetheweb/muse.git", "author": "Max Isom ", "license": "MIT", "private": true, "types": "dts/types", + "type": "module", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "files": [ "dist", "dts" @@ -83,13 +87,15 @@ "node-emoji": "^1.10.0", "p-event": "^4.2.0", "p-limit": "^3.1.0", + "p-queue": "^7.1.0", "reflect-metadata": "^0.1.13", "sequelize": "^5.22.4", "sequelize-typescript": "^1.1.0", "spotify-uri": "^2.2.0", "spotify-web-api-node": "^5.0.2", "sqlite3": "^5.0.2", - "youtube.ts": "^0.2.0", - "ytdl-core": "^4.9.1" + "youtube.ts": "^0.2.2", + "ytdl-core": "^4.9.1", + "ytsr": "^3.5.3" } } diff --git a/src/bot.ts b/src/bot.ts index 00deb17..6e6060b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,16 +1,16 @@ import {Client, Message, Collection} from 'discord.js'; import {inject, injectable} from 'inversify'; -import {TYPES} from './types'; -import {Settings, Shortcut} from './models'; -import container from './inversify.config'; -import Command from './commands'; -import debug from './utils/debug'; -import NaturalLanguage from './services/natural-language-commands'; -import handleGuildCreate from './events/guild-create'; -import handleVoiceStateUpdate from './events/voice-state-update'; -import errorMsg from './utils/error-msg'; -import {isUserInVoice} from './utils/channels'; -import Config from './services/config'; +import {TYPES} from './types.js'; +import {Settings, Shortcut} from './models/index.js'; +import container from './inversify.config.js'; +import Command from './commands/index.js'; +import debug from './utils/debug.js'; +import NaturalLanguage from './services/natural-language-commands.js'; +import handleGuildCreate from './events/guild-create.js'; +import handleVoiceStateUpdate from './events/voice-state-update.js'; +import errorMsg from './utils/error-msg.js'; +import {isUserInVoice} from './utils/channels.js'; +import Config from './services/config.js'; @injectable() export default class { diff --git a/src/commands/clear.ts b/src/commands/clear.ts index f9b1853..189b8af 100644 --- a/src/commands/clear.ts +++ b/src/commands/clear.ts @@ -1,7 +1,7 @@ -import {Message} from 'discord.js'; -import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; +import {Message} from 'discord.js'; +import {TYPES} from '../types.js'; +import PlayerManager from '../managers/player.js'; import Command from '.'; @injectable() diff --git a/src/commands/config.ts b/src/commands/config.ts index 7452107..6ecad6b 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,7 +1,7 @@ import {TextChannel, Message, GuildChannel} from 'discord.js'; import {injectable} from 'inversify'; -import {Settings} from '../models'; -import errorMsg from '../utils/error-msg'; +import {Settings} from '../models/index.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/disconnect.ts b/src/commands/disconnect.ts index 937fc2e..7280b51 100644 --- a/src/commands/disconnect.ts +++ b/src/commands/disconnect.ts @@ -1,8 +1,8 @@ import {Message} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/fseek.ts b/src/commands/fseek.ts index 19d9fbf..850889d 100644 --- a/src/commands/fseek.ts +++ b/src/commands/fseek.ts @@ -1,9 +1,9 @@ import {Message, TextChannel} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import LoadingMessage from '../utils/loading-message'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import LoadingMessage from '../utils/loading-message.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/help.ts b/src/commands/help.ts index 3c230fb..05bc565 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,9 +1,9 @@ import {Message} from 'discord.js'; import {injectable} from 'inversify'; import Command from '.'; -import {TYPES} from '../types'; -import {Settings} from '../models'; -import container from '../inversify.config'; +import {TYPES} from '../types.js'; +import {Settings} from '../models/index.js'; +import container from '../inversify.config.js'; @injectable() export default class implements Command { diff --git a/src/commands/pause.ts b/src/commands/pause.ts index 0771e11..4f57e95 100644 --- a/src/commands/pause.ts +++ b/src/commands/pause.ts @@ -1,9 +1,9 @@ import {Message} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import {STATUS} from '../services/player'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import {STATUS} from '../services/player.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/play.ts b/src/commands/play.ts index 4886da2..7013626 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,15 +1,15 @@ import {TextChannel, Message} from 'discord.js'; import {URL} from 'url'; import {Except} from 'type-fest'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import {QueuedSong, STATUS} from '../services/player'; -import PlayerManager from '../managers/player'; -import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels'; -import LoadingMessage from '../utils/loading-message'; -import errorMsg from '../utils/error-msg'; +import {QueuedSong, STATUS} from '../services/player.js'; +import PlayerManager from '../managers/player.js'; +import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js'; +import LoadingMessage from '../utils/loading-message.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; -import GetSongs from '../services/get-songs'; +import GetSongs from '../services/get-songs.js'; @injectable() export default class implements Command { @@ -124,6 +124,7 @@ export default class implements Command { if (song) { newSongs.push(song); } else { + console.log(_); await res.stop(errorMsg('that doesn\'t exist')); return; } diff --git a/src/commands/queue.ts b/src/commands/queue.ts index 0abc63c..a4bdc1e 100644 --- a/src/commands/queue.ts +++ b/src/commands/queue.ts @@ -1,13 +1,13 @@ import {Message, MessageEmbed} from 'discord.js'; -import {TYPES} from '../types'; -import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import {STATUS} from '../services/player'; -import Command from '.'; -import getProgressBar from '../utils/get-progress-bar'; -import errorMsg from '../utils/error-msg'; -import {prettyTime} from '../utils/time'; import getYouTubeID from 'get-youtube-id'; +import {inject, injectable} from 'inversify'; +import {TYPES} from '../types.js'; +import PlayerManager from '../managers/player.js'; +import {STATUS} from '../services/player.js'; +import Command from '.'; +import getProgressBar from '../utils/get-progress-bar.js'; +import errorMsg from '../utils/error-msg.js'; +import {prettyTime} from '../utils/time.js'; const PAGE_SIZE = 10; diff --git a/src/commands/seek.ts b/src/commands/seek.ts index edbb5bd..ae064ef 100644 --- a/src/commands/seek.ts +++ b/src/commands/seek.ts @@ -1,11 +1,11 @@ import {Message, TextChannel} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import LoadingMessage from '../utils/loading-message'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import LoadingMessage from '../utils/loading-message.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; -import {parseTime} from '../utils/time'; +import {parseTime} from '../utils/time.js'; @injectable() export default class implements Command { diff --git a/src/commands/shortcuts.ts b/src/commands/shortcuts.ts index 2b15812..57ed245 100644 --- a/src/commands/shortcuts.ts +++ b/src/commands/shortcuts.ts @@ -1,7 +1,7 @@ import {Message} from 'discord.js'; import {injectable} from 'inversify'; -import {Shortcut, Settings} from '../models'; -import errorMsg from '../utils/error-msg'; +import {Shortcut, Settings} from '../models/index.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/shuffle.ts b/src/commands/shuffle.ts index c9a007d..0f1d832 100644 --- a/src/commands/shuffle.ts +++ b/src/commands/shuffle.ts @@ -1,8 +1,8 @@ import {Message} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/commands/skip.ts b/src/commands/skip.ts index b12729b..7330888 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -1,10 +1,10 @@ import {Message, TextChannel} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; +import PlayerManager from '../managers/player.js'; import Command from '.'; -import LoadingMessage from '../utils/loading-message'; -import errorMsg from '../utils/error-msg'; +import LoadingMessage from '../utils/loading-message.js'; +import errorMsg from '../utils/error-msg.js'; @injectable() export default class implements Command { diff --git a/src/commands/unskip.ts b/src/commands/unskip.ts index 5dba251..9444b8e 100644 --- a/src/commands/unskip.ts +++ b/src/commands/unskip.ts @@ -1,8 +1,8 @@ import {Message} from 'discord.js'; -import {TYPES} from '../types'; +import {TYPES} from '../types.js'; import {inject, injectable} from 'inversify'; -import PlayerManager from '../managers/player'; -import errorMsg from '../utils/error-msg'; +import PlayerManager from '../managers/player.js'; +import errorMsg from '../utils/error-msg.js'; import Command from '.'; @injectable() diff --git a/src/events/guild-create.ts b/src/events/guild-create.ts index 4bf6af2..9b8bb34 100644 --- a/src/events/guild-create.ts +++ b/src/events/guild-create.ts @@ -1,8 +1,8 @@ import {Guild, TextChannel, Message} from 'discord.js'; import emoji from 'node-emoji'; import pEvent from 'p-event'; -import {Settings} from '../models'; -import {chunk} from '../utils/arrays'; +import {Settings} from '../models/index.js'; +import {chunk} from '../utils/arrays.js'; const DEFAULT_PREFIX = '!'; diff --git a/src/events/voice-state-update.ts b/src/events/voice-state-update.ts index c16177d..6440949 100644 --- a/src/events/voice-state-update.ts +++ b/src/events/voice-state-update.ts @@ -1,8 +1,8 @@ import {VoiceState} from 'discord.js'; -import container from '../inversify.config'; -import {TYPES} from '../types'; -import PlayerManager from '../managers/player'; -import {getSizeWithoutBots} from '../utils/channels'; +import container from '../inversify.config.js'; +import {TYPES} from '../types.js'; +import PlayerManager from '../managers/player.js'; +import {getSizeWithoutBots} from '../utils/channels.js'; export default (oldState: VoiceState, _: VoiceState): void => { const playerManager = container.get(TYPES.Managers.Player); diff --git a/src/index.ts b/src/index.ts index 7b1aac4..5bf5bab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ import makeDir from 'make-dir'; import path from 'path'; -import container from './inversify.config'; -import {TYPES} from './types'; -import Bot from './bot'; -import {sequelize} from './utils/db'; -import Config from './services/config'; +import container from './inversify.config.js'; +import {TYPES} from './types.js'; +import Bot from './bot.js'; +import {sequelize} from './utils/db.js'; +import Config from './services/config.js'; const bot = container.get(TYPES.Bot); @@ -16,7 +16,7 @@ const bot = container.get(TYPES.Bot); await makeDir(config.CACHE_DIR); await makeDir(path.join(config.CACHE_DIR, 'tmp')); - await sequelize.sync({}); + await sequelize.sync({alter: true}); await bot.listen(); })(); diff --git a/src/inversify.config.ts b/src/inversify.config.ts index 0b60f6f..1aa367d 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -1,33 +1,34 @@ import 'reflect-metadata'; import {Container} from 'inversify'; -import {TYPES} from './types'; -import Bot from './bot'; +import {TYPES} from './types.js'; +import Bot from './bot.js'; import {Client} from 'discord.js'; -import ConfigProvider from './services/config'; +import ConfigProvider from './services/config.js'; // Managers -import PlayerManager from './managers/player'; +import PlayerManager from './managers/player.js'; // Helpers -import GetSongs from './services/get-songs'; -import NaturalLanguage from './services/natural-language-commands'; +import GetSongs from './services/get-songs.js'; +import NaturalLanguage from './services/natural-language-commands.js'; // Comands import Command from './commands'; -import Clear from './commands/clear'; -import Config from './commands/config'; -import Disconnect from './commands/disconnect'; -import ForwardSeek from './commands/fseek'; -import Help from './commands/help'; -import Pause from './commands/pause'; -import Play from './commands/play'; -import QueueCommad from './commands/queue'; -import Seek from './commands/seek'; -import Shortcuts from './commands/shortcuts'; -import Shuffle from './commands/shuffle'; -import Skip from './commands/skip'; -import Unskip from './commands/unskip'; -import ThirdParty from './services/third-party'; +import Clear from './commands/clear.js'; +import Config from './commands/config.js'; +import Disconnect from './commands/disconnect.js'; +import ForwardSeek from './commands/fseek.js'; +import Help from './commands/help.js'; +import Pause from './commands/pause.js'; +import Play from './commands/play.js'; +import QueueCommad from './commands/queue.js'; +import Seek from './commands/seek.js'; +import Shortcuts from './commands/shortcuts.js'; +import Shuffle from './commands/shuffle.js'; +import Skip from './commands/skip.js'; +import Unskip from './commands/unskip.js'; +import ThirdParty from './services/third-party.js'; +import CacheProvider from './services/cache.js'; let container = new Container(); @@ -67,4 +68,6 @@ container.bind(TYPES.Config).toConstantValue(new ConfigProvider()); // Static libraries container.bind(TYPES.ThirdParty).to(ThirdParty); +container.bind(TYPES.Cache).to(CacheProvider); + export default container; diff --git a/src/managers/player.ts b/src/managers/player.ts index 71fbd55..02e4ba0 100644 --- a/src/managers/player.ts +++ b/src/managers/player.ts @@ -1,8 +1,8 @@ import {inject, injectable} from 'inversify'; -import {TYPES} from '../types'; -import Player from '../services/player'; import {Client} from 'discord.js'; -import Config from '../services/config'; +import {TYPES} from '../types.js'; +import Player from '../services/player.js'; +import Config from '../services/config.js'; @injectable() export default class { diff --git a/src/models/cache.ts b/src/models/cache.ts new file mode 100644 index 0000000..ebf8dad --- /dev/null +++ b/src/models/cache.ts @@ -0,0 +1,15 @@ +import {Table, Column, PrimaryKey, Model} from 'sequelize-typescript'; +import sequelize from 'sequelize'; + +@Table +export default class Cache extends Model { + @PrimaryKey + @Column + key!: string; + + @Column(sequelize.TEXT) + value!: string; + + @Column + expiresAt!: Date; +} diff --git a/src/models/index.ts b/src/models/index.ts index 2971df5..b0cd909 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,7 +1,9 @@ -import Settings from './settings'; -import Shortcut from './shortcut'; +import Cache from './cache.js'; +import Settings from './settings.js'; +import Shortcut from './shortcut.js'; export { + Cache, Settings, Shortcut }; diff --git a/src/services/cache.ts b/src/services/cache.ts new file mode 100644 index 0000000..d76eab9 --- /dev/null +++ b/src/services/cache.ts @@ -0,0 +1,52 @@ +import {injectable} from 'inversify'; +import {Cache} from '../models/index.js'; +import debug from '../utils/debug.js'; + +type Seconds = number; + +type Options = { + expiresIn: Seconds; + key?: string; +}; + +const futureTimeToDate = (time: Seconds) => new Date(new Date().getTime() + (time * 1000)); + +@injectable() +export default class CacheProvider { + async wrap(func: (...options: any) => Promise, ...options: T): Promise { + if (options.length === 0) { + throw new Error('Missing cache options'); + } + + const functionArgs = options.slice(0, options.length - 1); + + const { + key = JSON.stringify(functionArgs), + expiresIn + } = options[options.length - 1] as Options; + + const cachedResult = await Cache.findByPk(key); + + if (cachedResult) { + if (new Date() < cachedResult.expiresAt) { + debug(`Cache hit: ${key}`); + return JSON.parse(cachedResult.value); + } + + await cachedResult.destroy(); + } + + debug(`Cache miss: ${key}`); + + const result = await func(...options as any[]); + + // Save result + await Cache.upsert({ + key, + value: JSON.stringify(result), + expiresAt: futureTimeToDate(expiresIn) + }); + + return result; + } +} diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts index 0a6524f..e02c837 100644 --- a/src/services/get-songs.ts +++ b/src/services/get-songs.ts @@ -2,37 +2,73 @@ import {URL} from 'url'; import {inject, injectable} from 'inversify'; import {toSeconds, parse} from 'iso8601-duration'; import got from 'got'; +import ytsr, {Video} from 'ytsr'; import spotifyURI from 'spotify-uri'; import Spotify from 'spotify-web-api-node'; import YouTube, {YoutubePlaylistItem} from 'youtube.ts'; -import pLimit from 'p-limit'; +import PQueue from 'p-queue'; import shuffle from 'array-shuffle'; import {Except} from 'type-fest'; -import {QueuedSong, QueuedPlaylist} from '../services/player'; -import {TYPES} from '../types'; -import {cleanUrl} from '../utils/url'; -import ThirdParty from './third-party'; -import Config from './config'; +import {QueuedSong, QueuedPlaylist} from '../services/player.js'; +import {TYPES} from '../types.js'; +import {cleanUrl} from '../utils/url.js'; +import ThirdParty from './third-party.js'; +import Config from './config.js'; +import CacheProvider from './cache.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; private readonly youtubeKey: string; private readonly spotify: Spotify; + private readonly cache: CacheProvider; - constructor(@inject(TYPES.ThirdParty) thirdParty: ThirdParty, @inject(TYPES.Config) config: Config) { + private readonly ytsrQueue: PQueue; + + constructor( + @inject(TYPES.ThirdParty) thirdParty: ThirdParty, + @inject(TYPES.Config) config: Config, + @inject(TYPES.Cache) cache: CacheProvider) { this.youtube = thirdParty.youtube; this.youtubeKey = config.YOUTUBE_API_KEY; this.spotify = thirdParty.spotify; + this.cache = cache; + + this.ytsrQueue = new PQueue({concurrency: 4}); } async youtubeVideoSearch(query: string): Promise { try { - const {items: [video]} = await this.youtube.videos.search({q: query, maxResults: 1, type: 'video'}); + const {items} = await this.ytsrQueue.add(async () => this.cache.wrap( + ytsr, + query, + { + limit: 10 + }, + { + expiresIn: ONE_HOUR_IN_SECONDS + } + )); - return await this.youtubeVideo(video.id.videoId); + let firstVideo: Video | undefined; + + for (const item of items) { + if (item.type === 'video') { + firstVideo = item; + break; + } + } + + if (!firstVideo) { + throw new Error('No video found.'); + } + + return await this.youtubeVideo(firstVideo.id); } catch (_: unknown) { return null; } @@ -40,7 +76,13 @@ export default class { async youtubeVideo(url: string): Promise { try { - const videoDetails = await this.youtube.videos.get(cleanUrl(url)); + const videoDetails = await this.cache.wrap( + this.youtube.videos.get, + cleanUrl(url), + { + expiresIn: ONE_HOUR_IN_SECONDS + } + ); return { title: videoDetails.snippet.title, @@ -57,7 +99,13 @@ export default class { async youtubePlaylist(listId: string): Promise { // YouTube playlist - const playlist = await this.youtube.playlists.get(listId); + const playlist = await this.cache.wrap( + this.youtube.playlists.get, + listId, + { + expiresIn: ONE_MINUTE_IN_SECONDS + } + ); interface VideoDetailsResponse { id: string; @@ -75,7 +123,14 @@ export default class { while (playlistVideos.length !== playlist.contentDetails.itemCount) { // eslint-disable-next-line no-await-in-loop - const {items, nextPageToken} = await this.youtube.playlists.items(listId, {maxResults: '50', pageToken: nextToken}); + const {items, nextPageToken} = await this.cache.wrap( + this.youtube.playlists.items, + listId, + {maxResults: '50', pageToken: nextToken}, + { + expiresIn: ONE_MINUTE_IN_SECONDS + } + ); nextToken = nextPageToken; @@ -84,11 +139,24 @@ export default class { // Start fetching extra details about videos videoDetailsPromises.push((async () => { // Unfortunately, package doesn't provide a method for this - const {items: videoDetailItems}: {items: VideoDetailsResponse[]} = await got('https://www.googleapis.com/youtube/v3/videos', {searchParams: { - part: 'contentDetails', - id: items.map(item => item.contentDetails.videoId).join(','), - key: this.youtubeKey - }}).json(); + const {items: videoDetailItems} = await this.cache.wrap( + () => { + return got( + 'https://www.googleapis.com/youtube/v3/videos', + { + searchParams: { + part: 'contentDetails', + id: items.map(item => item.contentDetails.videoId).join(','), + key: this.youtubeKey, + responseType: 'json' + } + } + ).json(); + }, + { + expiresIn: ONE_MINUTE_IN_SECONDS + } + ); videoDetails.push(...videoDetailItems); })()); @@ -193,9 +261,7 @@ export default class { tracks = shuffled.slice(0, 50); } - // Limit concurrency so hopefully we don't get banned for searching - const limit = pLimit(5); - let songs = await Promise.all(tracks.map(async track => limit(async () => this.spotifyToYouTube(track, playlist)))); + let songs = await Promise.all(tracks.map(async track => this.spotifyToYouTube(track, playlist))); let nSongsNotFound = 0; @@ -215,14 +281,7 @@ export default class { private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, _: QueuedPlaylist | null): Promise { try { - const {items} = await this.youtube.videos.search({q: `"${track.name}" "${track.artists[0].name}"`, maxResults: 10}); - const videoResult = items[0]; - - if (!videoResult) { - throw new Error('No video found for query.'); - } - - return await this.youtubeVideo(videoResult.id.videoId); + return await this.youtubeVideoSearch(`"${track.name}" "${track.artists[0].name}"`); } catch (_: unknown) { return null; } diff --git a/src/services/natural-language-commands.ts b/src/services/natural-language-commands.ts index c6348c1..39e547d 100644 --- a/src/services/natural-language-commands.ts +++ b/src/services/natural-language-commands.ts @@ -1,9 +1,9 @@ import {inject, injectable} from 'inversify'; import {Message, Guild, GuildMember} from 'discord.js'; -import {TYPES} from '../types'; -import PlayerManager from '../managers/player'; -import {QueuedSong} from '../services/player'; -import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels'; +import {TYPES} from '../types.js'; +import PlayerManager from '../managers/player.js'; +import {QueuedSong} from '../services/player.js'; +import {getMostPopularVoiceChannel, getMemberVoiceChannel} from '../utils/channels.js'; @injectable() export default class { diff --git a/src/services/player.ts b/src/services/player.ts index 5a9fa83..8897597 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -7,7 +7,7 @@ import ytdl from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; import ffmpeg from 'fluent-ffmpeg'; import shuffle from 'array-shuffle'; -import errorMsg from '../utils/error-msg'; +import errorMsg from '../utils/error-msg.js'; export interface QueuedPlaylist { title: string; diff --git a/src/services/third-party.ts b/src/services/third-party.ts index 00b9269..7958438 100644 --- a/src/services/third-party.ts +++ b/src/services/third-party.ts @@ -1,8 +1,8 @@ import {inject, injectable} from 'inversify'; import SpotifyWebApi from 'spotify-web-api-node'; -import Youtube from 'youtube.ts'; -import {TYPES} from '../types'; -import Config from './config'; +import Youtube from 'youtube.ts/dist/youtube.js'; +import {TYPES} from '../types.js'; +import Config from './config.js'; @injectable() export default class ThirdParty { @@ -12,7 +12,8 @@ export default class ThirdParty { private spotifyTokenTimerId?: NodeJS.Timeout; constructor(@inject(TYPES.Config) config: Config) { - this.youtube = new Youtube(config.YOUTUBE_API_KEY); + // Library is transpiled incorrectly + this.youtube = new ((Youtube as any).default)(config.YOUTUBE_API_KEY); this.spotify = new SpotifyWebApi({ clientId: config.SPOTIFY_CLIENT_ID, clientSecret: config.SPOTIFY_CLIENT_SECRET diff --git a/src/types.ts b/src/types.ts index a64ec49..776097f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export const TYPES = { Bot: Symbol('Bot'), + Cache: Symbol('Cache'), Client: Symbol('Client'), Config: Symbol('Config'), Command: Symbol('Command'), diff --git a/src/utils/db.ts b/src/utils/db.ts index 01d5e01..4a15875 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,12 +1,12 @@ import {Sequelize} from 'sequelize-typescript'; import path from 'path'; -import {DATA_DIR} from '../services/config'; -import {Settings, Shortcut} from '../models'; +import {DATA_DIR} from '../services/config.js'; +import {Cache, Settings, Shortcut} from '../models/index.js'; export const sequelize = new Sequelize({ dialect: 'sqlite', database: 'muse', storage: path.join(DATA_DIR, 'db.sqlite'), - models: [Settings, Shortcut], + models: [Cache, Settings, Shortcut], logging: false }); diff --git a/tsconfig.json b/tsconfig.json index f8546fb..d424d91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "lib": ["ES2019"], "target": "es2018", - "module": "commonjs", + "module": "ES2020", "moduleResolution": "node", "declaration": true, "outDir": "dist", diff --git a/yarn.lock b/yarn.lock index 15fca20..b3c1236 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1153,6 +1153,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -1997,7 +2002,7 @@ mimic-response@^3.1.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== -miniget@^4.0.0: +miniget@^4.0.0, miniget@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/miniget/-/miniget-4.2.1.tgz#11a1c24817a059e292378eb9cff4328d9240c665" integrity sha512-O/DduzDR6f+oDtVype9S/Qu5hhnx73EDYGyZKwU/qN82lehFZdfhoa4DT51SpsO+8epYrB3gcRmws56ROfTIoQ== @@ -2332,6 +2337,14 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-queue@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-7.1.0.tgz#c2bb28f8dc0ebf3fadb985b8706cf2ce5fe5f275" + integrity sha512-V+0vPJbhYkBqknPp0qnaz+dWcj8cNepfXZcsVIVEHPbFQXMPwrzCNIiM4FoxGtwHXtPzVCPHDvqCr1YrOJX2Gw== + dependencies: + eventemitter3 "^4.0.7" + p-timeout "^5.0.0" + p-timeout@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" @@ -2339,6 +2352,11 @@ p-timeout@^3.1.0: dependencies: p-finally "^1.0.0" +p-timeout@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-5.0.0.tgz#0f5dc08422e7243f8317669c461734cd1257a8dc" + integrity sha512-z+bU/N7L1SABsqKnQzvAnINgPX7NHdzwUV+gHyJE7VGNDZSr03rhcPODCZSWiiT9k+gf74QPmzcZzqJRvxYZow== + package-json@^6.3.0: version "6.5.0" resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" @@ -3287,22 +3305,13 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -youtube.ts@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/youtube.ts/-/youtube.ts-0.2.0.tgz#a6cbc153bda9aa712a10098b22060877fb2995f3" - integrity sha512-cRHN/7L9FO+DkdKTzvUAiB/hQo+ZtzLb4GfVywPTTtf9JBol87fqzLKf86cBWwuXLOJzJNLB/wvZmUmsxpbByQ== +youtube.ts@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/youtube.ts/-/youtube.ts-0.2.2.tgz#d068bd13da7d2a98743d78682d54535d9c1a2833" + integrity sha512-2p5RGp66h77XlLlc2YyxSDU1Gql9cWDhq35TS1kAr5wSU/yIWxeXeuqKyKaFBW8uNqWUkQCiyUbn5tfqPgyzeQ== dependencies: axios "^0.19.0" - ytdl-core "^4.0.3" - -ytdl-core@^4.0.3: - version "4.8.3" - resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.8.3.tgz#21570d1834db13dec7828cf87bbf4c83c0fe68d7" - integrity sha512-cWCBeX4FCgjcKmuVK384MT582RIAakpUSeMF/NPVmhO8cWiG+LeQLnBordvLolb0iXYzfUvalgmycYAE5Sy6Xw== - dependencies: - m3u8stream "^0.8.3" - miniget "^4.0.0" - sax "^1.1.3" + ytdl-core "^4.9.1" ytdl-core@^4.9.1: version "4.9.1" @@ -3312,3 +3321,10 @@ ytdl-core@^4.9.1: m3u8stream "^0.8.3" miniget "^4.0.0" sax "^1.1.3" + +ytsr@^3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/ytsr/-/ytsr-3.5.3.tgz#88e8e2df11ce53c28b456b5510272495cb42ac3a" + integrity sha512-BEyIKbQULmk27hiVUQ1cBszAqP8roPBOQTWPZpBioKxjSZBeicfgF2qPIQoY7koodQwRuo1DmCFz3DyrXjADxg== + dependencies: + miniget "^4.2.1"