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
text/typescript
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);
}
}
}