diff --git a/src/commands/play.ts b/src/commands/play.ts index 3eb8f0b..7b9a934 100644 --- a/src/commands/play.ts +++ b/src/commands/play.ts @@ -1,15 +1,8 @@ import {TextChannel, Message} from 'discord.js'; -import YouTube from 'youtube.ts'; -import Spotify from 'spotify-web-api-node'; import {URL} from 'url'; -import ytsr from 'ytsr'; -import pLimit from 'p-limit'; -import spotifyURI from 'spotify-uri'; -import got from 'got'; -import {parse, toSeconds} from 'iso8601-duration'; import {TYPES} from '../types'; import {inject, injectable} from 'inversify'; -import {QueuedSong, QueuedPlaylist} from '../services/queue'; +import {QueuedSong} from '../services/queue'; import {STATUS} from '../services/player'; import QueueManager from '../managers/queue'; import PlayerManager from '../managers/player'; @@ -17,6 +10,7 @@ import {getMostPopularVoiceChannel} from '../utils/channels'; import LoadingMessage from '../utils/loading-message'; import errorMsg from '../utils/error-msg'; import Command from '.'; +import GetSongs from '../services/get-songs'; @injectable() export default class implements Command { @@ -33,16 +27,12 @@ export default class implements Command { private readonly queueManager: QueueManager; private readonly playerManager: PlayerManager; - private readonly youtube: YouTube; - private readonly youtubeKey: string; - private readonly spotify: Spotify; + private readonly getSongs: GetSongs; - constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Lib.YouTube) youtube: YouTube, @inject(TYPES.Config.YOUTUBE_API_KEY) youtubeKey: string, @inject(TYPES.Lib.Spotify) spotify: Spotify) { + constructor(@inject(TYPES.Managers.Queue) queueManager: QueueManager, @inject(TYPES.Managers.Player) playerManager: PlayerManager, @inject(TYPES.Services.GetSongs) getSongs: GetSongs) { this.queueManager = queueManager; this.playerManager = playerManager; - this.youtube = youtube; - this.youtubeKey = youtubeKey; - this.spotify = spotify; + this.getSongs = getSongs; } public async execute(msg: Message, args: string []): Promise { @@ -79,18 +69,7 @@ export default class implements Command { const newSongs: QueuedSong[] = []; - const addSingleSong = async (source: string): Promise => { - const videoDetails = await this.youtube.videos.get(source); - - newSongs.push({ - title: videoDetails.snippet.title, - artist: videoDetails.snippet.channelTitle, - length: toSeconds(parse(videoDetails.contentDetails.duration)), - url: videoDetails.id, - playlist: null, - isLive: videoDetails.snippet.liveBroadcastContent === 'live' - }); - }; + let nSongsNotFound = 0; // Test if it's a complete URL try { @@ -102,169 +81,48 @@ export default class implements Command { // YouTube source if (url.searchParams.get('list')) { // YouTube playlist - const playlist = await this.youtube.playlists.get(url.searchParams.get('list') as string); - const {items} = await this.youtube.playlists.items(url.searchParams.get('list') as string, {maxResults: '50'}); - - // Unfortunately, package doesn't provide a method for this - const res: any = 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 queuedPlaylist = {title: playlist.snippet.title, source: playlist.id}; - - items.forEach(video => { - const length = toSeconds(parse(res.items.find((i: any) => i.id === video.contentDetails.videoId).contentDetails.duration)); - - newSongs.push({ - title: video.snippet.title, - artist: video.snippet.channelTitle, - length, - url: video.contentDetails.videoId, - playlist: queuedPlaylist, - isLive: false - }); - }); + newSongs.push(...await this.getSongs.youtubePlaylist(url.searchParams.get('list') as string)); } else { // Single video - try { - await addSingleSong(url.href); - } catch (error) { - await res.stop('that doesn\'t exist'); + const song = await this.getSongs.youtubeVideo(url.href); + + if (song) { + newSongs.push(song); + } else { + await res.stop(errorMsg('that doesn\'t exist')); return; } } } else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') { - // Spotify source - const parsed = spotifyURI.parse(args[0]); + const [convertedSongs, nMissing] = await this.getSongs.spotifySource(args[0]); - const tracks: SpotifyApi.TrackObjectSimplified[] = []; + nSongsNotFound = nMissing; - let playlist: QueuedPlaylist | null = null; - - switch (parsed.type) { - case 'album': { - const uri = parsed as spotifyURI.Album; - - const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]); - - tracks.push(...items); - - playlist = {title: album.name, source: album.href}; - break; - } - - case 'playlist': { - const uri = parsed as spotifyURI.Playlist; - - let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 1})]); - - playlist = {title: playlistResponse.name, source: playlistResponse.href}; - - tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); - - while (tracksResponse.next) { - // eslint-disable-next-line no-await-in-loop - ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, { - limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '1', 10), - offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10) - })); - - tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); - } - - break; - } - - case 'track': { - const uri = parsed as spotifyURI.Track; - - const {body} = await this.spotify.getTrack(uri.id); - - tracks.push(body); - break; - } - - case 'artist': { - // Await res.stop('ope, can\'t add a whole artist'); - const uri = parsed as spotifyURI.Artist; - - const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US'); - - tracks.push(...body.tracks); - break; - } - - default: { - await res.stop('huh?'); - return; - } - } - - // Search YouTube for each track - const searchForTrack = async (track: SpotifyApi.TrackObjectSimplified): Promise => { - try { - const {items} = await ytsr(`${track.name} ${track.artists[0].name} offical`, {limit: 5}); - const video = items.find((item: { type: string }) => item.type === 'video'); - - if (!video) { - throw new Error('No video found for query.'); - } - - return { - title: video.title, - artist: track.artists[0].name, - length: track.duration_ms / 1000, - url: video.link, - playlist, - isLive: video.live - }; - } catch (_) { - // TODO: handle error - return null; - } - }; - - // Limit concurrency so hopefully we don't get banned - const limit = pLimit(3); - let songs = await Promise.all(tracks.map(async track => limit(async () => searchForTrack(track)))); - - // Get rid of null values - songs = songs.reduce((accum: QueuedSong[], song) => { - if (song) { - accum.push(song); - } - - return accum; - }, []); - - newSongs.push(...(songs as QueuedSong[])); + newSongs.push(...convertedSongs); } } catch (_) { // Not a URL, must search YouTube const query = args.join(' '); - try { - const {items: [video]} = await this.youtube.videos.search({q: query, maxResults: 1, type: 'video'}); + const song = await this.getSongs.youtubeVideoSearch(query); - await addSingleSong(video.id.videoId); - } catch (_) { - await res.stop('that doesn\'t exist'); + if (song) { + newSongs.push(song); + } else { + await res.stop(errorMsg('that doesn\'t exist')); return; } } if (newSongs.length === 0) { - // TODO: better response - await res.stop('huh?'); + await res.stop(errorMsg('no songs found')); return; } newSongs.forEach(song => this.queueManager.get(msg.guild!.id).add(song)); // TODO: better response - await res.stop('song(s) queued'); + await res.stop(`song(s) queued (${nSongsNotFound} not found)`); if (this.playerManager.get(msg.guild!.id).voiceConnection === null) { await this.playerManager.get(msg.guild!.id).connect(targetVoiceChannel); diff --git a/src/commands/skip.ts b/src/commands/skip.ts index 9e5ec4b..3160100 100644 --- a/src/commands/skip.ts +++ b/src/commands/skip.ts @@ -34,6 +34,7 @@ export default class implements Command { await msg.channel.send('keep \'er movin\''); } catch (_) { + console.log(_); await msg.channel.send('no song to skip to'); } } diff --git a/src/inversify.config.ts b/src/inversify.config.ts index c25d32b..463e08a 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -19,6 +19,9 @@ import { import PlayerManager from './managers/player'; import QueueManager from './managers/queue'; +// Helpers +import GetSongs from './services/get-songs'; + // Comands import Command from './commands'; import Clear from './commands/clear'; @@ -44,6 +47,9 @@ container.bind(TYPES.Client).toConstantValue(new Client()); container.bind(TYPES.Managers.Player).to(PlayerManager).inSingletonScope(); container.bind(TYPES.Managers.Queue).to(QueueManager).inSingletonScope(); +// Helpers +container.bind(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope(); + // Commands container.bind(TYPES.Command).to(Clear).inSingletonScope(); container.bind(TYPES.Command).to(Config).inSingletonScope(); diff --git a/src/services/get-songs.ts b/src/services/get-songs.ts new file mode 100644 index 0000000..19fc6c0 --- /dev/null +++ b/src/services/get-songs.ts @@ -0,0 +1,185 @@ +import {URL} from 'url'; +import {inject, injectable} from 'inversify'; +import {toSeconds, parse} from 'iso8601-duration'; +import got from 'got'; +import spotifyURI from 'spotify-uri'; +import Spotify from 'spotify-web-api-node'; +import ytsr from 'ytsr'; +import YouTube from 'youtube.ts'; +import pLimit from 'p-limit'; +import {QueuedSong, QueuedPlaylist} from '../services/queue'; +import {TYPES} from '../types'; + +@injectable() +export default class { + private readonly youtube: YouTube; + private readonly youtubeKey: string; + private readonly spotify: Spotify; + + constructor(@inject(TYPES.Lib.YouTube) youtube: YouTube, @inject(TYPES.Config.YOUTUBE_API_KEY) youtubeKey: string, @inject(TYPES.Lib.Spotify) spotify: Spotify) { + this.youtube = youtube; + this.youtubeKey = youtubeKey; + this.spotify = spotify; + } + + async youtubeVideoSearch(query: string): Promise { + try { + const {items: [video]} = await this.youtube.videos.search({q: query, maxResults: 1, type: 'video'}); + + return await this.youtubeVideo(video.id.videoId); + } catch (_) { + return null; + } + } + + async youtubeVideo(url: string): Promise { + try { + const videoDetails = await this.youtube.videos.get(url); + + return { + title: videoDetails.snippet.title, + artist: videoDetails.snippet.channelTitle, + length: toSeconds(parse(videoDetails.contentDetails.duration)), + url: videoDetails.id, + playlist: null, + isLive: videoDetails.snippet.liveBroadcastContent === 'live' + }; + } catch (_) { + return null; + } + } + + async youtubePlaylist(listId: string): Promise { + // YouTube playlist + const playlist = await this.youtube.playlists.get(listId); + const {items} = await this.youtube.playlists.items(listId, {maxResults: '50'}); + + // Unfortunately, package doesn't provide a method for this + const res: any = 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 queuedPlaylist = {title: playlist.snippet.title, source: playlist.id}; + + return items.map(video => { + const length = toSeconds(parse(res.items.find((i: any) => i.id === video.contentDetails.videoId).contentDetails.duration)); + + return { + title: video.snippet.title, + artist: video.snippet.channelTitle, + length, + url: video.contentDetails.videoId, + playlist: queuedPlaylist, + isLive: false + }; + }); + } + + async spotifySource(url: string): Promise<[QueuedSong[], number]> { + const parsed = spotifyURI.parse(url); + + const tracks: SpotifyApi.TrackObjectSimplified[] = []; + + let playlist: QueuedPlaylist | null = null; + + switch (parsed.type) { + case 'album': { + const uri = parsed as spotifyURI.Album; + + const [{body: album}, {body: {items}}] = await Promise.all([this.spotify.getAlbum(uri.id), this.spotify.getAlbumTracks(uri.id, {limit: 50})]); + + tracks.push(...items); + + playlist = {title: album.name, source: album.href}; + break; + } + + case 'playlist': { + const uri = parsed as spotifyURI.Playlist; + + let [{body: playlistResponse}, {body: tracksResponse}] = await Promise.all([this.spotify.getPlaylist(uri.id), this.spotify.getPlaylistTracks(uri.id, {limit: 1})]); + + playlist = {title: playlistResponse.name, source: playlistResponse.href}; + + tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); + + while (tracksResponse.next) { + // eslint-disable-next-line no-await-in-loop + ({body: tracksResponse} = await this.spotify.getPlaylistTracks(uri.id, { + limit: parseInt(new URL(tracksResponse.next).searchParams.get('limit') ?? '1', 10), + offset: parseInt(new URL(tracksResponse.next).searchParams.get('offset') ?? '0', 10) + })); + + tracks.push(...tracksResponse.items.map(playlistItem => playlistItem.track)); + } + + break; + } + + case 'track': { + const uri = parsed as spotifyURI.Track; + + const {body} = await this.spotify.getTrack(uri.id); + + tracks.push(body); + break; + } + + case 'artist': { + const uri = parsed as spotifyURI.Artist; + + const {body} = await this.spotify.getArtistTopTracks(uri.id, 'US'); + + tracks.push(...body.tracks); + break; + } + + default: { + return [[], 0]; + } + } + + // Limit concurrency so hopefully we don't get banned for searching + const limit = pLimit(3); + let songs = await Promise.all(tracks.map(async track => limit(async () => this.spotifyToYouTube(track, playlist)))); + + let nSongsNotFound = 0; + + // Get rid of null values + songs = songs.reduce((accum: QueuedSong[], song) => { + if (song) { + accum.push(song); + } else { + nSongsNotFound++; + } + + return accum; + }, []); + + return [songs as QueuedSong[], nSongsNotFound]; + } + + private async spotifyToYouTube(track: SpotifyApi.TrackObjectSimplified, playlist: QueuedPlaylist | null): Promise { + try { + const {items} = await ytsr(`${track.name} ${track.artists[0].name} offical`, {limit: 5}); + const video = items.find((item: { type: string }) => item.type === 'video'); + + if (!video) { + throw new Error('No video found for query.'); + } + + return { + title: video.title, + artist: track.artists[0].name, + length: track.duration_ms / 1000, + url: video.link, + playlist, + isLive: video.live + }; + } catch (_) { + return null; + } + } +} diff --git a/src/services/player.ts b/src/services/player.ts index ff14e23..9629547 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -64,9 +64,12 @@ export default class { throw new Error('No song currently playing'); } - await this.waitForCache(currentSong.url); - - this.dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds}); + if (await this.isCached(currentSong.url)) { + this.dispatcher = this.voiceConnection.play(this.getCachedPath(currentSong.url), {seek: positionSeconds}); + } else { + const stream = await this.getStream(currentSong.url, {seek: positionSeconds}); + this.dispatcher = this.voiceConnection.play(stream, {type: 'webm/opus'}); + } this.attachListeners(); this.startTrackingPosition(positionSeconds); @@ -147,32 +150,7 @@ export default class { } } - private async waitForCache(url: string, maxRetries = 500, retryDelay = 200): 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 { + private async getStream(url: string, options: {seek?: number} = {}): Promise { const cachedPath = this.getCachedPath(url); if (await this.isCached(url)) { @@ -187,7 +165,6 @@ export default class { 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 | undefined => { if (formats[0].live) { @@ -204,7 +181,6 @@ export default class { if (!format) { format = nextBestFormat(info.formats); - canDirectPlay = false; if (!format) { // If still no format is found, throw @@ -212,21 +188,21 @@ export default class { } } - let youtubeStream: Readable; + const inputOptions = [ + '-reconnect', + '1', + '-reconnect_streamed', + '1', + '-reconnect_delay_max', + '5' + ]; - if (canDirectPlay) { - youtubeStream = ytdl.downloadFromInfo(info, {format}); - } else { - youtubeStream = ffmpeg(format.url).inputOptions([ - '-reconnect', - '1', - '-reconnect_streamed', - '1', - '-reconnect_delay_max', - '5' - ]).noVideo().audioCodec('libopus').outputFormat('webm').pipe() as PassThrough; + if (options.seek) { + inputOptions.push('-ss', options.seek.toString()); } + const youtubeStream = ffmpeg(format.url).inputOptions(inputOptions).noVideo().audioCodec('libopus').outputFormat('webm').pipe() as PassThrough; + const capacitor = new WriteStream(); youtubeStream.pipe(capacitor); diff --git a/src/services/queue.ts b/src/services/queue.ts index 1c766f1..eff8a7b 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -19,7 +19,7 @@ export default class { private position = 0; forward(): void { - if (this.position + 1 <= this.size()) { + if (this.position <= this.size() + 1) { this.position++; } else { throw new Error('No songs in queue to forward to.'); diff --git a/src/types.ts b/src/types.ts index 9d12fa7..0384bc9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,5 +16,8 @@ export const TYPES = { Managers: { Player: Symbol('PlayerManager'), Queue: Symbol('QueueManager') + }, + Services: { + GetSongs: Symbol('GetSongs') } }; diff --git a/src/utils/error-msg.ts b/src/utils/error-msg.ts index 7325776..832de3c 100644 --- a/src/utils/error-msg.ts +++ b/src/utils/error-msg.ts @@ -5,7 +5,7 @@ export default (error?: string | Error): string => { if (typeof error === 'string') { str = `🚫 ${error}`; } else if (error instanceof Error) { - str = `🚫 error: ${error.name}`; + str = `🚫 ope: ${error.name}`; } }