UNPKG

music-start-pro

Version:

Music Start Pro is a discord bot that can play YouTube music by slash command.

337 lines (298 loc) 12.2 kB
import { Interaction, CommandInteraction, GuildMember, VoiceChannel, GuildTextBasedChannel, } from 'discord.js'; import { AudioPlayerStatus, AudioPlayer, AudioResource, entersState, joinVoiceChannel, VoiceConnection, createAudioPlayer, createAudioResource, NoSubscriberBehavior, StreamType, DiscordGatewayAdapterCreator } from '@discordjs/voice'; import { MusicInfo } from './musicInfo'; import { Util } from './util'; import { Queue } from './queue'; import ytdl from '@distube/ytdl-core'; import { messages } from './language.json'; import { Commands } from './commands'; import * as fs from 'fs'; interface langMap { [key: string]: string; } /** * An instance of Bucket represents one guild in Discord. * Use Bucket.find(`id`) to fetch the instance. * Bucket.find(`id`) creates new instance if `id` is new one, else returns the instance we created. */ export class Bucket { public id: string; private connection: VoiceConnection | null = null; private interaction: CommandInteraction | Interaction | null = null; private voiceChannel: VoiceChannel | null = null; private resource: AudioResource | null = null; public player: AudioPlayer = this.createPlayer(); public verbose: boolean = true; // if set false, the player does not show the information when starting playing song. private _playerErrorLock: boolean = false; // set true when player is error. private _playerVolume: number = .64; private _lang: string = "en"; private _repeat: boolean = false; readonly queue: Queue = new Queue(); private static _useLog: boolean = true; private static _logFn: string = ''; static disableLog() { Bucket._useLog = false; } static load(fn: string) { // Even if the file does not exist, set _logFn nonetheless. Bucket._logFn = fn; // if the file does not exist, exit if (!fs.existsSync(fn)) return; const data = JSON.parse(fs.readFileSync(fn, { encoding: 'utf-8', flag: 'r' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.keys(data).forEach((k: any) => { const e = data[k]; const bucket = new Bucket(k); bucket.lang = e.lang; bucket.volume = e.volume; e.queue.forEach((f: MusicInfo) => { bucket.queue.add(new MusicInfo(f.url, f.title, f.likes, f.viewCount, f.playCounter)); }); Bucket.instant.set(k, bucket); }); } // store all instance of Bucket when _useLog is true store() { if (!Bucket._useLog) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ret: any = {}; Bucket.instant.forEach((e: Bucket) => { ret[e.id] = { lang: this.lang, volume: this.volume, queue: this.queue.toList() }; }); fs.writeFileSync(Bucket._logFn, JSON.stringify(ret), { flag: 'w' }); } static instant: Map<string, Bucket> = new Map(); constructor(id: string) { if (id == undefined) throw ('no guild id when fetch Bucket'); console.log('bucket id:', id); this.id = id; Bucket.instant.set(this.id, this); } static find(id: string): Bucket { // https://tutorial.eyehunts.com/js/javascript-double-question-mark-vs-double-pipe-code/ return Bucket.instant.get(id) ?? new Bucket(id); } get playing(): boolean { return this.player.state.status === 'playing'; } /** * Join the bot to the voice channel. * This method also updates the value of `this.interaction`. * @param interaction * @returns true if connect success */ connect(interaction: Interaction): boolean { this.interaction = interaction; if (interaction.member instanceof GuildMember && interaction.member.voice.channel) { const voiceChannel = <VoiceChannel>interaction.member.voice.channel; this.voiceChannel = voiceChannel; this.connection = joinVoiceChannel({ channelId: voiceChannel.id, guildId: voiceChannel.guild.id, selfDeaf: true, selfMute: false, adapterCreator: voiceChannel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator }); this.connection.subscribe(this.player); return true; } return false; } disconnect() { this.connection?.destroy(); } /** * Create a player that automatically plays the next song when finish playing. * When play the next song, show the information of it if `this.verbose` is true. * * @returns an audio player object */ createPlayer(): AudioPlayer { const player = createAudioPlayer({ debug: true, behaviors: { noSubscriber: NoSubscriberBehavior.Pause, } }); player.on(AudioPlayerStatus.Playing, () => { // if there is no one in voice channel when the player is playing, pause. if (this.voiceChannel != null && this.voiceChannel?.members.size <= 1) { player.pause(); const channel = this.interaction?.channel as GuildTextBasedChannel | null | undefined; channel?.send((messages.paused_because_no_one_in_channel as langMap)[this.lang]); } }); // https://discordjs.guide/voice/audio-player.html#life-cycle // DEBUG: // player.on(AudioPlayerStatus.Buffering, () => { // console.log("buffering"); // }); // When the bot is not in any voice channels, the player is automatically paused. // player.on(AudioPlayerStatus.AutoPaused, () => { // console.log("autoPaused"); // }); // player.on(AudioPlayerStatus.Paused, () => { // console.log("paused"); // }); // player.on(AudioPlayerStatus.Idle, () => { // console.log("idle"); // }); /** * 當播放器錯誤發生時,state會依序進入以下狀態: * 1. onError * 2. buffering * 3. onFinish * Thus, we can set _playerErrorLock to be `true` and fix error in the state of `onFinish` * Then, set _playerErrorLock to be `false` */ player.on('error', (error) => { console.log('播放器發生錯誤!'); console.log(error.message); console.log(error.name); console.log(error.stack); this._playerErrorLock = true; const channel = this.interaction?.channel as GuildTextBasedChannel | null | undefined; channel?.send(Util.createEmbedMessage((messages.error as langMap)[this.lang], `${(messages.player_error as langMap)[this.lang]} ${Util.randomCry()}`, true)); console.log('Reset player'); this.player = this.createPlayer(); // disconnect from voice channel this.disconnect(); }); // this block handles // (1) player error // (2) play next song when player finished player.on('stateChange', (oldState, newState) => { // onfinish() if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { // If the Idle state is entered from a non-Idle state, it means that an audio resource has finished playing. // The queue is then processed to start playing the next track, if one is available. if (this._playerErrorLock) { // error occurred // fake finish() console.log('Error line169 bucket.ts'); } else { // real finish() // update play counter // if call removeAll(), the current will out of bound. if (!this.queue.current) return; this.queue.current.playCounter++; this.store(); if (!this._repeat) { this.queue.next(1); } this.play(this.queue.current).then(() => { if (this.verbose) { const channel = this.interaction?.channel as GuildTextBasedChannel | null | undefined; channel?.send(Util.createMusicInfoMessage(this.queue.current)); } }); } this._playerErrorLock = false; } else if (newState.status === AudioPlayerStatus.Playing) { // onstart() } }); return player; } /** * Plays music on `this.player` by given MusicInfo. * @param music * @param begin start at `begin` milliseconds */ private async play(music: MusicInfo, begin?: number): Promise<void> { if (this.connection === null) { throw ((messages.robot_not_in_voice_channel as langMap)[this.lang]); } // if the user not joined voice channel yet const stream = ytdl(music.url, { quality: 'highestaudio', filter: 'audioonly', highWaterMark: 1 << 25, // 32 MB begin: begin ? begin : 0, // begin: This option is not very reliable for non-live videos }); // ytdlInfo seems to be expired after a period of time // const stream = ytdl.downloadFromInfo(music.ytdlInfo, {...}); // DEBUG // number - Chunk length in bytes or segment number. // number - Total bytes or segments downloaded. // number - Total bytes or segments. // stream.on('progress', (chunkSize, downloadedSize, totalSize) => { // this._playerDownloadedChunk = downloadedSize; // this._playerTotalChunk = totalSize; // }); this.resource = createAudioResource(stream, { inputType: StreamType.Arbitrary, inlineVolume: true, }); this.resource?.volume?.setVolume(this.volume); try { this.player.play(this.resource); await entersState(this.player, AudioPlayerStatus.Playing, 3e4); } catch (e) { const channel = this.interaction?.channel as GuildTextBasedChannel | null | undefined; channel?.send(Util.createEmbedMessage((messages.error as langMap)[this.lang], `${e}`, true)); console.error("line274 bucket.ts play() error", e, "try to reset player"); this.player = this.createPlayer(); if (this.interaction != null) { this.connect(this.interaction as Interaction); } channel?.send(Util.createEmbedMessage((messages.error as langMap)[this.lang], `${(messages.player_error as langMap)[this.lang]} ${Util.randomCry()}`, true)); } } // play() + edit reply async playAndEditReplyDefault(music: MusicInfo, interaction: CommandInteraction | null) { this.play(music).then(() => { interaction?.editReply(Util.createMusicInfoMessage(music)); }).catch(e => { interaction?.editReply(Util.createEmbedMessage((messages.error as langMap)[this.lang], `${e}`, true)); }); } // @return final `repeat` state toggleRepeat() { this._repeat = !this._repeat; return this._repeat; } get volume(): number { return this._playerVolume; } // @param: volume is in [0, 1] set volume(vol: number) { this._playerVolume = vol; this.resource?.volume?.setVolume(vol); } get lang(): string { return this._lang; } // set language and re-register command. set lang(lang: string) { this._lang = lang; if (this.interaction != null) { Commands.register(this.interaction.guild, this._lang); } } }