@persian-caesar/discord-player
Version:
Type definitions and helpers for a Discord music player (MusicPlayer) without external dependencies
523 lines • 18.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MusicPlayer = void 0;
const voice_1 = require("@discordjs/voice");
const types_1 = require("./types");
const html_to_text_1 = require("html-to-text");
const ytdl_core_discord_1 = __importDefault(require("ytdl-core-discord"));
const events_1 = __importDefault(require("events"));
const ytdl_core_1 = __importDefault(require("ytdl-core"));
const play_dl_1 = __importDefault(require("play-dl"));
const ytdl_core_2 = __importDefault(require("@distube/ytdl-core"));
const soundcloud_downloader_1 = __importDefault(require("soundcloud-downloader"));
/**
* MusicPlayer
* - Manages voice connection, playback, queue, history, and loop modes
* - Emits events defined in MusicPlayerEvent
*/
class MusicPlayer extends events_1.default {
channel;
previousQueueOrder = [];
connection = null;
player;
volume;
queue = [];
history = [];
loopQueue = false;
loopTrack = false;
playing = false;
autoLeaveOnEmptyQueue;
autoLeaveOnIdleMs;
idleTimer = null;
shuffield = false;
/**
* @param channel The Discord voice channel to connect to
* @param initialVolume Initial volume in percent (0–100)
* @param options Configuration options (auto-leave, idle timeout)
*/
constructor(channel, initialVolume = 100, options = {}) {
super();
this.channel = channel;
// create the audio player and set volume
this.player = (0, voice_1.createAudioPlayer)();
this.volume = Math.round(initialVolume / 100);
this.autoLeaveOnEmptyQueue = options.autoLeaveOnEmptyQueue ?? true;
this.autoLeaveOnIdleMs = options.autoLeaveOnIdleMs ?? 5 * 60_000;
this.player.on("error", err => {
this.emit(types_1.MusicPlayerEvent.Error, this.createError("Player have error => " + err.message));
});
this.player.on(voice_1.AudioPlayerStatus.Idle, () => this.onIdle());
this.player.on(voice_1.AudioPlayerStatus.Playing, () => this.clearIdleTimer());
}
startIdleTimer() {
if (this.autoLeaveOnIdleMs > 0 && !this.idleTimer && !this.playing) {
this.idleTimer = setTimeout(() => {
this.emit(types_1.MusicPlayerEvent.Disconnect);
this.disconnect();
}, this.autoLeaveOnIdleMs);
return;
}
return;
}
/**
* Search Google and scrape lyrics snippets.
* Returns the lyrics or null if not found.
*
* @param title Song title
* @param artist Optional artist name, for more accurate search
*/
async searchLyrics(title, artist) {
const delim1 = '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">';
const delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
const GOOGLE = "https://www.google.com/search?q=";
let html = "";
const query = encodeURIComponent(`${artist ? artist + " " : ""}${title}`);
// build multiple query URLs with different suffixes
const attempts = [
`${GOOGLE}${query}+lyrics`,
`${GOOGLE}${query}+song+lyrics`,
`${GOOGLE}${query}+song`,
`${GOOGLE}${query}`
];
for (const url of attempts) {
try {
// fetch HTML, split by known delimiters, then strip tags
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
html = await res.text();
break;
}
catch {
continue;
}
}
if (!html)
return null;
let snippet;
try {
[, snippet] = html.split(delim1);
[snippet] = snippet.split(delim2);
}
catch {
return null;
}
const rawLines = snippet.split("\n");
let lyrics = "";
for (const line of rawLines) {
lyrics += (0, html_to_text_1.htmlToText)(line).trim() + "\n";
}
// lyrics = Buffer.from(lyrics, "binary").toString("utf8").trim();
lyrics = lyrics.trim();
return lyrics || null;
}
clearIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
return;
}
return;
}
/**
* Connect to the voice channel if not already connected.
* Waits until the connection is READY or emits an error.
*/
async ensureConnection() {
// joinVoiceChannel will handle reconnection automatically
if (!this.connection) {
this.connection = (0, voice_1.joinVoiceChannel)({
channelId: this.channel.id,
guildId: this.channel.guild.id,
adapterCreator: this.channel.guild.voiceAdapterCreator
});
try {
await (0, voice_1.entersState)(this.connection, voice_1.VoiceConnectionStatus.Ready, 20_000);
this.connection.subscribe(this.player);
}
catch (err) {
// clean up on failure
this.connection.destroy();
this.connection = null;
this.emit(types_1.MusicPlayerEvent.Error, this.createError("Can't connect to the voice channel. => " + err.message));
}
}
}
async search(query) {
if (/^https?:\/\//.test(query))
return query;
// soundcloud
const sc_results = await soundcloud_downloader_1.default.search({
query,
resourceType: "tracks",
limit: 1
});
let url = sc_results.collection?.[0]?.permalink_url;
if (!url) {
// spotify youtube and also soundcloud
const playdl_results = await play_dl_1.default.search(query, {
limit: 1,
source: {
spotify: "track",
soundcloud: "tracks",
youtube: "video",
deezer: "track"
}
});
url = playdl_results[0]?.url;
}
return url;
}
async createStreamFromScdl(url) {
return await soundcloud_downloader_1.default.download(url);
}
async createStreamFromYtdl(url) {
const options = { filter: "audioonly", highWaterMark: 1 << 25 };
let stream = null;
try {
stream = await (0, ytdl_core_2.default)(url, options);
}
catch { }
;
if (!stream)
try {
stream = await (0, ytdl_core_1.default)(url, options);
}
catch { }
;
if (!stream)
try {
stream = await (0, ytdl_core_discord_1.default)(url, options);
}
catch { }
return stream;
}
async createStreamFromPlayDl(url) {
const yt = await play_dl_1.default.stream(url, { quality: 2 });
if (!yt)
return null;
return yt.stream;
}
async fetchMetadata(url) {
try {
// YouTube
if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(url)) {
let info;
try {
info = await ytdl_core_2.default.getBasicInfo(url);
}
catch { }
;
if (!info)
try {
info = await ytdl_core_1.default.getBasicInfo(url);
}
catch { }
;
if (!info)
try {
info = await ytdl_core_discord_1.default.getBasicInfo(url);
}
catch { }
if (!info)
return {
title: undefined,
author: undefined,
duration: undefined,
thumbnail: undefined,
source: "unknown",
url
};
const details = info.videoDetails;
return {
title: details.title,
author: details.author.name,
duration: parseInt(details.lengthSeconds, 10),
thumbnail: details.thumbnails[details.thumbnails.length - 1].url,
source: "youtube",
url
};
}
// SoundCloud
if (/^https?:\/\/(soundcloud\.com|snd\.sc)\//.test(url)) {
const info = await soundcloud_downloader_1.default.getInfo(url);
return {
title: info.title,
author: info.user?.username,
duration: Math.floor(info.duration / 1000),
thumbnail: info.artwork_url || info.user?.avatar_url,
source: "soundcloud",
url
};
}
// Other sources can be added similarly with playdl
const result = await play_dl_1.default.video_basic_info(url);
const vid = result.video_details;
return {
title: vid.title,
author: vid.channel?.name,
duration: vid.durationInSec,
thumbnail: vid.thumbnails?.[0]?.url,
source: "unknown",
url
};
}
catch {
return {
title: undefined,
author: undefined,
duration: undefined,
thumbnail: undefined,
source: "unknown",
url
};
}
}
async playUrl(url, metadata) {
this.playing = true;
this.history.push(url);
// Create audio stream as before
let stream = null;
if (/^https?:\/\/(soundcloud\.com|snd\.sc)\//.test(url))
try {
stream = await this.createStreamFromScdl(url);
}
catch { }
;
if (/^https?:\/\/open\.spotify\.com\/(track|album|playlist)\//.test(url))
if (!stream)
try {
stream = await this.createStreamFromPlayDl(url);
}
catch { }
;
if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(url))
if (!stream)
try {
stream = await this.createStreamFromYtdl(url);
}
catch { }
;
const resource = (0, voice_1.createAudioResource)(stream, { inlineVolume: true });
resource.volume?.setVolume(this.volume);
this.player.play(resource);
if (!this.player.state.resource)
this.player.state.resource = resource;
this.emit(types_1.MusicPlayerEvent.Start, {
metadata,
queue: [...this.queue]
});
return;
}
/**
* Play a song by query or URL.
* If already playing, adds to queue and emits a queueAdd event.
* Otherwise, starts immediate playback via playUrl().
*
* @param input YouTube URL or search query (e.g. “Coldplay Yellow”)
*/
async play(input) {
await this.ensureConnection();
// resolve to a URL, then fetch metadata
const url = await this.search(input);
const metadata = await this.fetchMetadata(url);
if (this.playing) {
// enqueue and notify
this.queue.push(metadata);
this.emit(types_1.MusicPlayerEvent.QueueAdd, {
metadata,
queue: [...this.queue]
});
return;
}
else
// start playback immediately
return await this.playUrl(url, metadata);
}
pause() {
this.player.pause();
this.emit(types_1.MusicPlayerEvent.Pause);
}
resume() {
this.player.unpause();
this.emit(types_1.MusicPlayerEvent.Resume);
}
setVolume(percent) {
percent /= 100;
if (percent < 0 || percent > 2)
this.volume = 2;
else
this.volume = percent;
const resource = this.player.state.resource;
try {
resource.volume.setVolume(this.volume);
}
catch { }
this.emit(types_1.MusicPlayerEvent.VolumeChange, { volume: Math.round(this.volume * 100) });
}
/**
* Handle when the player becomes idle.
* - If loopTrack is on, replay current track
* - Else if queue has items, play next
* - Otherwise, emit finish and optionally disconnect
*/
async onIdle() {
const url = this.history[this.history.length - 1];
const metadata = await this.fetchMetadata(url);
if (this.loopTrack)
return await this.playUrl(url, metadata);
if (this.queue.length) {
const next = this.queue.shift();
if (this.loopQueue)
this.queue.push(next);
const metadata = await this.fetchMetadata(next.url);
return await this.playUrl(next.url, metadata);
}
this.playing = false;
this.emit(types_1.MusicPlayerEvent.Finish, {
queue: [...this.queue],
history: [...this.history]
});
if (this.autoLeaveOnEmptyQueue) {
this.emit(types_1.MusicPlayerEvent.Disconnect);
this.disconnect();
return;
}
else
return this.startIdleTimer();
}
skip() {
this.emit(types_1.MusicPlayerEvent.Skip, { queue: [...this.queue], history: [...this.history] });
this.player.stop();
}
/**
* Jump to the previous track.
* Pops current and last URLs from history, re-queues the current,
* and starts playback of the previous one.
*/
async previous() {
if (this.history.length < 2) {
this.emit(types_1.MusicPlayerEvent.Error, this.createError("No track to previous."));
return;
}
// Remove current URL
this.history.pop();
// Get the one before it
const prevUrl = this.history.pop();
const metadata = await this.fetchMetadata(prevUrl);
// Re-insert into the front of the queue
this.queue.unshift(metadata);
// Notify listeners with updated state
this.emit(types_1.MusicPlayerEvent.Previous, {
metadata,
queue: [...this.queue],
history: [...this.history, prevUrl]
});
// Play the previous track
await this.playUrl(prevUrl, metadata);
return;
}
/**
* Shuffle the queue randomly.
* Saves the current queue order so you can undo.
*/
shuffle() {
// Backup before shuffle
this.previousQueueOrder = [...this.queue];
for (let i = this.queue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.queue[i], this.queue[j]] = [this.queue[j], this.queue[i]];
}
this.shuffield = true;
this.emit(types_1.MusicPlayerEvent.Shuffle, {
queue: [...this.queue]
});
}
/**
* Restore the queue to its previous order,
* excluding any tracks that have already been played.
*/
undoShuffle() {
// Filter out URLs already in history
this.queue = this.previousQueueOrder.filter(meta => !this.history.includes(meta.url));
this.shuffield = false;
this.emit(types_1.MusicPlayerEvent.Shuffle, {
queue: [...this.queue]
});
}
toggleLoopQueue() {
this.loopQueue = !this.loopQueue;
}
isLoopQueue() {
return this.loopQueue;
}
toggleLoopTrack() {
this.loopTrack = !this.loopTrack;
}
isLoopTrack() {
return this.loopTrack;
}
disconnect() {
this.clearIdleTimer();
this.player?.stop();
if (this.connection) {
this.connection.destroy();
this.connection = null;
}
this.playing = false;
this.queue = [];
this.history = [];
this.emit(types_1.MusicPlayerEvent.Disconnect);
}
stop(noLeave = true) {
this.emit(types_1.MusicPlayerEvent.Stop);
this.player.stop();
this.playing = false;
this.queue = [];
this.history = [];
this.clearIdleTimer();
if (!noLeave)
this.disconnect();
return;
}
getQueue() {
return [...this.queue];
}
getVolume() {
const resource = this.player.state.resource;
if (resource && resource.volume && resource.volume.volume)
return Math.round(resource.volume.volume * 100);
return Math.round(this.volume * 100);
}
isPlaying() {
return this.playing;
}
isPaused() {
return this.player && this.player.state.status === voice_1.AudioPlayerStatus.Paused;
}
isShuffiled() {
return this.shuffield;
}
// Custom error class
createError(message) {
class discordPlayerError extends Error {
constructor() {
super();
this.name = "Discord-Player";
this.message = message;
}
}
return new discordPlayerError();
}
}
exports.MusicPlayer = MusicPlayer;
/**
* @copyright
* Code by Sobhan-SRZA (mr.sinre) | https://github.com/Sobhan-SRZA
* Developed for Persian Caesar | https://github.com/Persian-Caesar | https://dsc.gg/persian-caesar
*
* If you encounter any issues or need assistance with this code,
* please make sure to credit "Persian Caesar" in your documentation or communications.
*/
//# sourceMappingURL=MusicPlayer.js.map