diff --git a/src/client/components/MusicPlayer.ts b/src/client/components/MusicPlayer.ts index 731557a..99c731f 100644 --- a/src/client/components/MusicPlayer.ts +++ b/src/client/components/MusicPlayer.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; -import { ActivityType, ChannelType, Collection, Guild, GuildTextBasedChannel, VoiceChannel } from 'discord.js'; -import { AudioPlayer, AudioPlayerStatus, AudioResource, PlayerSubscription, VoiceConnection, createAudioPlayer, createAudioResource, joinVoiceChannel } from '@discordjs/voice'; +import { ActivityType, ChannelType, Collection, Guild, GuildTextBasedChannel } from 'discord.js'; +import { AudioPlayer, AudioPlayerStatus, AudioResource, PlayerSubscription, VoiceConnection, VoiceConnectionState, VoiceConnectionStatus, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice'; import { LoggerClient } from '@navy.gif/logger'; import Initialisable from '../../interfaces/Initialisable.js'; @@ -56,6 +56,7 @@ class MusicPlayer implements Initialisable this.#currentSong = null; this.#player.on(AudioPlayerStatus.Idle, this.playNext.bind(this)); + this.#player.on('error', (err) => this.#logger.error(err)); } get ready () @@ -226,13 +227,13 @@ class MusicPlayer implements Initialisable this.#shuffleList = this.#library.getShufflePlaylist(); - this.initialiseVoiceChannels(); + this.#initialiseVoiceChannels(); this.#ready = true; this.playNext(); } - initialiseVoiceChannels () + #initialiseVoiceChannels () { for (const config of this.#options.guilds) { @@ -243,25 +244,27 @@ class MusicPlayer implements Initialisable this.#logger.warn(`Invalid id pair given: Invalid guild ${id}`); continue; } - const channel = guild.channels.resolve(voiceChannel); - if (channel?.type !== ChannelType.GuildVoice || !channel.joinable) - { - this.#logger.warn(`Invalid id pair given: Invalid channel ${id}`); - continue; - } - if (!this.joinVoiceChannel(guild, channel, textOutput)) + if (!this.#joinVoiceChannel(guild, voiceChannel, textOutput)) continue; } } - joinVoiceChannel (guild: Guild, channel: VoiceChannel, output?: string) + #joinVoiceChannel (guild: Guild, channelId: string, output?: string) { + const channel = guild.channels.resolve(channelId); + if (channel?.type !== ChannelType.GuildVoice || !channel.joinable) + { + this.#logger.warn(`Invalid voice channel given for guild ${guild.id}: ${channelId}`); + return false; + } + const connection = joinVoiceChannel({ channelId: channel.id, guildId: guild.id, adapterCreator: guild.voiceAdapterCreator }); + const subscription = connection.subscribe(this.#player); if (!subscription) { @@ -269,6 +272,11 @@ class MusicPlayer implements Initialisable return false; } + connection.on('error', error => this.#logger.error('Voice connection error:', error)); + connection.on(VoiceConnectionStatus.Disconnected, (...args) => this.#handleDisconnect(...args, guild)); + if (this.#player.state.status === AudioPlayerStatus.AutoPaused) + this.#player.unpause(); + let textChannel = null; if (output) { @@ -284,6 +292,36 @@ class MusicPlayer implements Initialisable return true; } + async #handleDisconnect (oldState: VoiceConnectionState, newState: VoiceConnectionState, guild: Guild): Promise + { + this.#logger.debug(`Voice connection in ${guild.name} changed state from ${oldState.status} to ${newState.status}`); + const connectionData = this.#connections.get(guild.id); + if (!connectionData) + return; + + const { connection } = connectionData; + try + { + await Promise.race([ + entersState(connection, VoiceConnectionStatus.Signalling, 5_000), + entersState(connection, VoiceConnectionStatus.Connecting, 5_000) + ]); + } + catch + { + this.#logger.info(`Connection in ${guild.name} was terminated, attempting reconnect`); + // this.#logger.error('Promise error:', err as Error); + connection.destroy(); + this.#connections.delete(guild.id); + const details = this.#options.guilds.find((conf) => conf.id === guild.id); + if (!details) + return; + this.#joinVoiceChannel(guild, details.voiceChannel, details.textOutput); + } + // if (newState.status !== VoiceConnectionStatus.Disconnected) + // return; + } + } export default MusicPlayer; \ No newline at end of file diff --git a/src/client/components/downloaders/Spotify.ts b/src/client/components/downloaders/Spotify.ts index 15e2759..9932060 100644 --- a/src/client/components/downloaders/Spotify.ts +++ b/src/client/components/downloaders/Spotify.ts @@ -7,11 +7,20 @@ import DiscordClient from '../../DiscordClient.js'; import path from 'node:path'; import MusicPlayerError from '../../../errors/MusicPlayerError.js'; +type QueueEntry = { + resolve: (result: DownloaderResult) => void, + reject: (error: Error) => void, + id: string, + url: string +} + class SpotifyDownloader extends Downloader { #executable; #available; #options: string[]; + #queue: QueueEntry[]; + #processing: boolean; constructor (client: DiscordClient) { super(client, { @@ -25,12 +34,15 @@ class SpotifyDownloader extends Downloader '--download-quality very_high', '--download-real-time true', '--md-allgenres true', - '--skip-previously-downloaded true', + // '--skip-previously-downloaded true', '--output "{artist} --- {song_name} --- {album}.{ext}"', '--print-downloads true', ]; if (process.env.CREDENTIAL_PATH) this.#options.push('--credentials-location ' + process.env.CREDENTIAL_PATH); + + this.#queue = []; + this.#processing = false; } override test (): void | Promise @@ -79,36 +91,61 @@ class SpotifyDownloader extends Downloader }); } + this.logger.info(`Queueing song for download: ${url}`); return new Promise((resolve, reject) => { - childProcess.exec(`"${this.#executable}" --root-path "${this.downloadDir}" ${this.#options.join(' ')} ${url}`, (error, stdout, stderr) => - { - if (error) - return reject(error); - - const data = this.#getSongData(id); - if (!data) - throw new Error('Failed to find file reference'); - - const { fileName, artist, song, album, ext } = data; - const filePath = path.join(this.downloadDir!, fileName); - - if (stderr && !stderr.includes('charmap')) - this.logger.debug('stderr', stderr); - if (stdout) - this.logger.debug('stdout', stdout); - resolve({ - filePath, - artist, - song, - album, - ext - }); + this.#queue.push({ + resolve, reject, id, url }); + this.#processQueue(); // child.stdout?.on('data', (chunk => this.logger.debug('child stdout:', chunk))); }); } + async #processQueue () + { + if (this.#processing) + return; + + const entry = this.#queue.shift(); + if (!entry) + return; + this.#processing = true; + + const { resolve, reject, id, url } = entry; + this.logger.info(`Processing queue, downloading ${url}`); + childProcess.exec(`"${this.#executable}" --root-path "${this.downloadDir}" ${this.#options.join(' ')} ${url}`, (error, stdout, stderr) => + { + if (error) + return reject(error); + + if (stderr) // && !stderr.includes('charmap') + this.logger.debug('stderr', stderr); + if (stdout) + this.logger.debug('stdout', stdout); + + const data = this.#getSongData(id); + if (!data) + throw new Error('Failed to find file reference'); + + const { fileName, artist, song, album, ext } = data; + const filePath = path.join(this.downloadDir!, fileName); + + resolve({ + filePath, + artist, + song, + album, + ext + }); + + this.#processing = false; + if (this.#queue.length) + process.nextTick(this.#processQueue); + }); + + } + #getSongData (id: string) { const songIdsPath = path.join(this.downloadDir!, '.song_ids');