From fb91c8e89cb34465315ac3c9f4f11e27ec577348 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Fri, 13 Mar 2020 20:36:42 -0500 Subject: [PATCH] Add better caching, seek command --- package.json | 2 + src/commands/seek.ts | 33 ++++++ src/inversify.config.ts | 2 + src/services/player.ts | 173 ++++++++++++++++++++++++++++++-- src/utils/get-youtube-stream.ts | 78 -------------- yarn.lock | 83 ++++++++++++++- 6 files changed, 280 insertions(+), 91 deletions(-) create mode 100644 src/commands/seek.ts delete mode 100644 src/utils/get-youtube-stream.ts diff --git a/package.json b/package.json index 05f36a2..dee3c86 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@types/bluebird": "^3.5.30", + "@types/fs-capacitor": "^2.0.0", "@types/node": "^13.9.1", "@types/spotify-web-api-node": "^4.0.1", "@types/validator": "^12.0.1", @@ -66,6 +67,7 @@ "delay": "^4.3.0", "discord.js": "^12.0.2", "dotenv": "^8.2.0", + "fs-capacitor": "^6.1.0", "got": "^10.6.0", "hasha": "^5.2.0", "inversify": "^5.0.1", diff --git a/src/commands/seek.ts b/src/commands/seek.ts new file mode 100644 index 0000000..29c3972 --- /dev/null +++ b/src/commands/seek.ts @@ -0,0 +1,33 @@ +import {Message, TextChannel} from 'discord.js'; +import {TYPES} from '../types'; +import {inject, injectable} from 'inversify'; +import Player from '../services/player'; +import LoadingMessage from '../utils/loading-message'; +import Command from '.'; + +@injectable() +export default class implements Command { + public name = 'seek'; + public description = 'seeks position in currently playing song'; + private readonly player: Player; + + constructor(@inject(TYPES.Services.Player) player: Player) { + this.player = player; + } + + public async execute(msg: Message, args: string []): Promise { + const seekTime = parseInt(args[0], 10); + + const loading = new LoadingMessage(msg.channel as TextChannel, 'hold on a sec'); + + await loading.start(); + + try { + await this.player.seek(msg.guild!.id, seekTime); + + await loading.stop('seeked'); + } catch (_) { + await loading.stop('error somewhere'); + } + } +} diff --git a/src/inversify.config.ts b/src/inversify.config.ts index a042748..f6f178b 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -24,6 +24,7 @@ import Command from './commands'; import Config from './commands/config'; import Play from './commands/play'; import QueueCommad from './commands/queue'; +import Seek from './commands/seek'; let container = new Container(); @@ -39,6 +40,7 @@ container.bind(TYPES.Services.Queue).to(Queue).inSingletonScope(); container.bind(TYPES.Command).to(Config).inSingletonScope(); container.bind(TYPES.Command).to(Play).inSingletonScope(); container.bind(TYPES.Command).to(QueueCommad).inSingletonScope(); +container.bind(TYPES.Command).to(Seek).inSingletonScope(); // Config values container.bind(TYPES.Config.DISCORD_TOKEN).toConstantValue(DISCORD_TOKEN); diff --git a/src/services/player.ts b/src/services/player.ts index 0fd6f62..1970cc7 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -1,8 +1,14 @@ import {inject, injectable} from 'inversify'; import {VoiceConnection, VoiceChannel} from 'discord.js'; +import {promises as fs, createWriteStream} from 'fs'; +import {Readable} from 'stream'; +import path from 'path'; +import hasha from 'hasha'; +import ytdl from 'ytdl-core'; +import {WriteStream} from 'fs-capacitor'; +import prism from 'prism-media'; import {TYPES} from '../types'; -import Queue from './queue'; -import getYouTubeStream from '../utils/get-youtube-stream'; +import Queue, {QueuedSong} from './queue'; export enum Status { Playing, @@ -19,9 +25,11 @@ export interface GuildPlayer { export default class { private readonly guildPlayers = new Map(); private readonly queue: Queue; + private readonly cacheDir: string; - constructor(@inject(TYPES.Services.Queue) queue: Queue) { + constructor(@inject(TYPES.Services.Queue) queue: Queue, @inject(TYPES.Config.CACHE_DIR) cacheDir: string) { this.queue = queue; + this.cacheDir = cacheDir; } async connect(guildId: string, channel: VoiceChannel): Promise { @@ -46,6 +54,23 @@ export default class { } } + async seek(guildId: string, positionSeconds: number): Promise { + const guildPlayer = this.get(guildId); + if (guildPlayer.voiceConnection === null) { + throw new Error('Not connected to a voice channel.'); + } + + const currentSong = this.getCurrentSong(guildId); + + if (!currentSong) { + throw new Error('No song currently playing'); + } + + await this.waitForCache(currentSong.url); + + guildPlayer.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds}); + } + async play(guildId: string): Promise { const guildPlayer = this.get(guildId); if (guildPlayer.voiceConnection === null) { @@ -57,17 +82,18 @@ export default class { return; } - const songs = this.queue.get(guildId); + const currentSong = this.getCurrentSong(guildId); - if (songs.length === 0) { + if (!currentSong) { throw new Error('Queue empty.'); } - const song = songs[0]; - - const stream = await getYouTubeStream(song.url); - - this.get(guildId).voiceConnection!.play(stream, {type: 'webm/opus'}); + if (await this.isCached(currentSong.url)) { + this.get(guildId).voiceConnection!.play(this.getCachedPath(currentSong.url)); + } else { + const stream = await this.getStream(currentSong.url); + this.get(guildId).voiceConnection!.play(stream, {type: 'webm/opus'}); + } guildPlayer.status = Status.Playing; @@ -80,9 +106,136 @@ export default class { return this.guildPlayers.get(guildId) as GuildPlayer; } + private getCurrentSong(guildId: string): QueuedSong|null { + const songs = this.queue.get(guildId); + + if (songs.length === 0) { + return null; + } + + return songs[0]; + } + private initGuild(guildId: string): void { if (!this.guildPlayers.get(guildId)) { this.guildPlayers.set(guildId, {status: Status.Disconnected, voiceConnection: null}); } } + + private getCachedPath(url: string): string { + const hash = hasha(url); + return path.join(this.cacheDir, `${hash}.webm`); + } + + private getCachedPathTemp(url: string): string { + const hash = hasha(url); + + return path.join('/tmp', `${hash}.webm`); + } + + private async isCached(url: string): Promise { + try { + await fs.access(this.getCachedPath(url)); + + return true; + } catch (_) { + return false; + } + } + + private async waitForCache(url: string, maxRetries = 50, retryDelay = 500): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + if (await this.isCached(url)) { + resolve(); + } else { + let nOfChecks = 0; + + const cachedCheck = setInterval(async () => { + if (await this.isCached(url)) { + clearInterval(cachedCheck); + resolve(); + } else { + nOfChecks++; + + if (nOfChecks > maxRetries) { + clearInterval(cachedCheck); + reject(new Error('Timed out waiting for file to become cached.')); + } + } + }, retryDelay); + } + }); + } + + private async getStream(url: string): Promise { + const cachedPath = this.getCachedPath(url); + + if (await this.isCached(url)) { + return cachedPath; + } + + // Not yet cached, must download + const info = await ytdl.getInfo(url); + + const {formats} = info; + + const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000; + + let format = formats.find(filter); + let canDirectPlay = true; + + const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat => { + formats = formats + .filter(format => format.averageBitrate) + .sort((a, b) => b.averageBitrate - a.averageBitrate); + return formats.find(format => !format.bitrate) ?? formats[0]; + }; + + if (!format) { + format = nextBestFormat(info.formats); + canDirectPlay = false; + } + + const cacheTempPath = this.getCachedPathTemp(url); + const cacheStream = createWriteStream(cacheTempPath); + + cacheStream.on('finish', async () => { + await fs.rename(cacheTempPath, cachedPath); + }); + + let youtubeStream: Readable; + + if (canDirectPlay) { + youtubeStream = ytdl.downloadFromInfo(info, {format}); + } else { + youtubeStream = new prism.FFmpeg({ + args: [ + '-reconnect', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + '5', + '-i', + format.url, + '-loglevel', + 'verbose', + '-vn', + '-acodec', + 'libopus', + '-f', + 'webm' + ] + }); + } + + const capacitor = new WriteStream(); + + youtubeStream.pipe(capacitor); + + capacitor.createReadStream().pipe(cacheStream); + + return capacitor.createReadStream(); + } } diff --git a/src/utils/get-youtube-stream.ts b/src/utils/get-youtube-stream.ts deleted file mode 100644 index 1360dce..0000000 --- a/src/utils/get-youtube-stream.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {promises as fs, createReadStream, createWriteStream} from 'fs'; -import {Readable, PassThrough} from 'stream'; -import path from 'path'; -import hasha from 'hasha'; -import ytdl from 'ytdl-core'; -import prism from 'prism-media'; -import {CACHE_DIR} from './config'; - -const nextBestFormat = (formats: ytdl.videoFormat[]): ytdl.videoFormat => { - formats = formats - .filter(format => format.averageBitrate) - .sort((a, b) => b.averageBitrate - a.averageBitrate); - return formats.find(format => !format.bitrate) ?? formats[0]; -}; - -// TODO: are some videos not available in webm/opus? -export default async (url: string): Promise => { - const hash = hasha(url); - const cachedPath = path.join(CACHE_DIR, `${hash}.webm`); - - const info = await ytdl.getInfo(url); - - const {formats} = info; - - const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000; - - let format = formats.find(filter); - let canDirectPlay = true; - - if (!format) { - format = nextBestFormat(info.formats); - canDirectPlay = false; - } - - try { - // Test if file exists - await fs.access(cachedPath); - - // If so, return cached stream - return createReadStream(cachedPath); - } catch (_) { - // Not yet cached, must download - const cacheTempPath = path.join('/tmp', `${hash}.webm`); - const cacheStream = createWriteStream(cacheTempPath); - - const pass = new PassThrough(); - - pass.pipe(cacheStream).on('finish', async () => { - await fs.rename(cacheTempPath, cachedPath); - }); - - if (canDirectPlay) { - return ytdl.downloadFromInfo(info, {format}).pipe(pass); - } - - const transcoder = new prism.FFmpeg({ - args: [ - '-reconnect', - '1', - '-reconnect_streamed', - '1', - '-reconnect_delay_max', - '5', - '-i', - format.url, - '-loglevel', - 'verbose', - '-vn', - '-acodec', - 'libopus', - '-f', - 'webm' - ] - }); - - return transcoder.pipe(pass); - } -}; diff --git a/yarn.lock b/yarn.lock index ce63305..e35e0b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -65,6 +65,13 @@ "@types/node" "*" "@types/responselike" "*" +"@types/cloneable-readable@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/cloneable-readable/-/cloneable-readable-2.0.0.tgz#b5bc6602d4771a5db9d80f3867427e9da5f68a63" + integrity sha512-Q9fTsA3hEbOXmGIZ7StMunr/SCvtdfXDfJcDadYk/MPbS3Xh/fWCsdhW26NVx1XNNcX3SkdBqPkfbOiD6p3q2Q== + dependencies: + "@types/node" "*" + "@types/color-name@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" @@ -75,6 +82,13 @@ resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag== +"@types/fs-capacitor@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz#17113e25817f584f58100fb7a08eed288b81956e" + integrity sha512-FKVPOCFbhCvZxpVAMhdBdTfVfXUpsh15wFHgqOKxh9N9vzWZVuWCSijZ5T4U34XYNnuj2oduh6xcs1i+LPI+BQ== + dependencies: + "@types/node" "*" + "@types/http-cache-semantics@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a" @@ -107,6 +121,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/pump@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/pump/-/pump-1.1.0.tgz#ed5214af511da32b6ee85c8d33ad3d59bb79ad8f" + integrity sha512-YGGbsqf5o7sF8gGANP8ZYxgaRGlFgEAImx5tCvA4YKRCfqbsDQZO48UmWynZzSjbhn0ZWSlsWOcb5NwvOx8KcQ== + dependencies: + "@types/node" "*" + "@types/responselike@*": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" @@ -510,6 +531,14 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +cloneable-readable@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-2.0.1.tgz#fc2240beddbe5621b872acad8104dcc86574e225" + integrity sha512-1ke/wckhpSevGPQzKb+qGHMsuFrSUKQlsKh0PTmscmfAzw8MgONqrg5a0e0Un1YO/cOSS4wAepfXSGus5RoonQ== + dependencies: + inherits "^2.0.1" + readable-stream "^3.3.0" + cls-bluebird@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cls-bluebird/-/cls-bluebird-2.1.0.tgz#37ef1e080a8ffb55c2f4164f536f1919e7968aee" @@ -1070,6 +1099,11 @@ formidable@^1.2.0: resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== +fs-capacitor@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-6.1.0.tgz#457f5868a743fe662caa9bd825be966c3d4641a4" + integrity sha512-YsKGCLAB40P3OKeciIa7cKzt7WkY8QT9ETa2wVIG3fQDHW2h3xtRo0770lUIbPrjCr5Sa+zFhixNJ+2xNxaraQ== + fs-minipass@^1.2.5: version "1.2.7" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" @@ -1339,7 +1373,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@~2.0.3: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -1486,6 +1520,11 @@ is-typedarray@~1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -1653,6 +1692,13 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +memory-streams@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/memory-streams/-/memory-streams-0.1.3.tgz#d9b0017b4b87f1d92f55f2745c9caacb1dc93ceb" + integrity sha512-qVQ/CjkMyMInPaaRMrwWNDvf6boRZXaT/DbQeMYcCWuXPEBf1v8qChOc9OlEVQp2uOvRXa1Qu30fLmKhY6NipA== + dependencies: + readable-stream "~1.0.2" + methods@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -2187,6 +2233,25 @@ readable-stream@^2.0.6, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.3.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@~1.0.2: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readdirp@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" @@ -2310,7 +2375,7 @@ rxjs@^6.5.3: dependencies: tslib "^1.9.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== @@ -2525,6 +2590,18 @@ string-width@^4.1.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -2805,7 +2882,7 @@ url-parse-lax@^1.0.0: dependencies: prepend-http "^1.0.1" -util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=