backend-0.2.0 => main #1

Merged
raphix merged 6 commits from backend-0.2.0 into main 2025-03-01 17:03:17 +00:00
19 changed files with 5175 additions and 12 deletions
Showing only changes of commit 6f3847138b - Show all commits

4596
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "chopin-backend", "name": "chopin-backend",
"version": "0.1.0", "version": "0.2.0",
"description": "Discord Bot for music - Fetching everywhere !", "description": "Discord Bot for music - Fetching everywhere !",
"main": "src/main.js", "main": "src/main.js",
"nodemonConfig": { "nodemonConfig": {
@@ -18,13 +18,21 @@
"author": "Raphix", "author": "Raphix",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.18.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"discord-player": "^7.1.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"express": "^4.21.2", "express": "^4.21.2",
"ffmpeg-static": "^5.2.0",
"ffprobe": "^1.1.2",
"ffprobe-static": "^3.1.0",
"libsodium-wrappers": "^0.7.15",
"loguix": "^1.4.2", "loguix": "^1.4.2",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"pm2": "^5.4.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"webmetrik": "^0.1.4" "webmetrik": "^0.1.4",
"ytdl-core": "^4.11.5"
} }
} }

View File

@@ -53,10 +53,18 @@ class Command {
}) })
SlashCommand.addStringOption(option => option.setName(SelOption.name).setDescription(SelOption.description).setRequired(SelOption.required).addChoices(choices)) SlashCommand.addStringOption(option => option.setName(SelOption.name).setDescription(SelOption.description).setRequired(SelOption.required).addChoices(choices))
} }
if(SelOption.type === "FILE") {
SlashCommand.addAttachmentOption(option => option.setName(SelOption.name).setDescription(SelOption.description).setRequired(SelOption.required))
}
}) })
} }
/**
* @type {SlashCommandBuilder}
* @param {Client} client
* @param {Interaction} interaction
*/
this.data = {data: SlashCommand, async execute(client, interaction) {callback(client, interaction)}} this.data = {data: SlashCommand, async execute(client, interaction) {callback(client, interaction)}}
} }

View File

@@ -50,10 +50,6 @@ client.commands = new Collection()
} }
dlog.step.end("d_commands_refresh") dlog.step.end("d_commands_refresh")
} catch (error) { } catch (error) {
// And of course, make sure you catch and log any errors! // And of course, make sure you catch and log any errors!

View File

@@ -18,7 +18,7 @@ const command = new Command("help", "Affiche la liste des commandes", (client, i
option.choices.forEach(choice => { option.choices.forEach(choice => {
choices.push(choice.name) choices.push(choice.name)
}) })
CommandName += " " + choices.join(" | ") CommandName += " <" + choices.join(" | ") +">"
} }
}) })
} }

View File

@@ -0,0 +1,51 @@
const {Command} = require('../Command');
const {Embed, EmbedError} = require('../Embed');
const { Player } = require('../../player/Player');
const { Song } = require('../../player/Song');
const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => {
if(!interaction.member.voice.channel) return interaction.reply({content:"Vous devez rejoindre un salon vocal pour lire un(e) titre / playlist !", ephemeral: true})
const media = interaction.options.get("media")
const now = interaction.options.getBoolean("now") || false
if(media.attachment.contentType != "audio/mpeg" && media.attachment.contentType != "audio/wav") return new EmbedError("Le média doit être un fichier audio MP3 ou WAV !").send(interaction)
const channel = interaction.member.voice.channel
const song = new Song()
await song.processMedia(media, interaction.user.username)
const player = new Player(channel.guildId)
player.join(channel)
const embed = new Embed()
embed.setColor(0x15e6ed)
if(now) {
player.play(song)
embed.setTitle('**Lecture immédiate**')
} else {
player.add(song)
embed.setTitle('**Ajout à liste de lecture**')
}
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **'+ song.readduration, "")
embed.addField("**Artiste : **" + song.author, "")
embed.addField('**Demandé par **' + interaction.member.user.username,"")
embed.setThumbnail(song.thumbnail)
embed.send(interaction)
}, [{type: "FILE", name: "media", description: "Fichier audio à lire", required: true},
{type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}]
)
module.exports = {command}

View File

@@ -0,0 +1,24 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const { Player } = require("../../player/Player")
const command = new Command("pause", "Mettre en pause la musique en cours", (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour mettre en pause la musique !").send(interaction)
const channel = interaction.member.voice.channel
const player = new Player(channel.guildId)
player.pause()
// Réponse en embed
const embed = new Embed()
embed.setColor(0x00ff66)
embed.setTitle('Musique en pause')
embed.setDescription("La musique a été mise en pause")
embed.send(interaction)
})
module.exports = {command}

View File

@@ -0,0 +1,15 @@
const { Command } = require("../Command");
const { Embed, EmbedError } = require("../Embed");
const { Player } = require("../../player/Player");
const command = new Command("play", "Jouer une musique à partir d'un lien dans un salon vocal", (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour jouer une musique !").send(interaction)
const url = interaction.options.get("url")
const channel = interaction.member.voice.channel
}, [{type: "STRING", name: "url", description: "Lien audio (Youtube / Soundclound / Spotify)", required: true}]
)
module.exports = {command}

View File

@@ -0,0 +1,20 @@
const {Embed} = require("../Embed")
const {Command} = require("../Command")
const {restart} = require("../../utils/Maintenance")
// Nécéssite une raison pour redémarrer le bot
const command = new Command("restart", "Redémarre le bot", (client, interaction) => {
const reason = interaction.options.getString("reason")
restart(reason)
const embed = new Embed()
embed.setColor(150, 20, 20)
embed.setTitle('Redémarrage')
embed.setDescription("Veuillez patientez, le bot va redémarrer dans un instant ! :arrows_counterclockwise:")
embed.addField('Raison', reason)
embed.send(interaction)
},
[{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}]
)
module.exports = {command}

View File

@@ -86,4 +86,14 @@ class Embed {
} }
} }
module.exports = {Embed} class EmbedError extends Embed {
constructor(message) {
super()
this.setColor(150, 20, 20)
this.setTitle('Erreur')
this.setThumbnail("https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Dialog-error-round.svg/2048px-Dialog-error-round.svg.png")
this.setDescription(message)
}
}
module.exports = {Embed, EmbedError}

View File

173
backend/src/player/List.js Normal file
View File

@@ -0,0 +1,173 @@
const { Database } = require('../utils/Database/Database')
const { __glob } = require('../utils/GlobalVars')
const PreviousDB = new Database("previous", __glob.PREVIOUSFILE, {})
const {LogType} = require("loguix")
const clog = new LogType("List")
const AllLists = new Map() // Map<guildId, List>
class List {
next;
current;
shuffle;
guildId;
constructor(guildId) {
if(guildId === null) {
clog.error("Impossible de créer une liste, car guildId est null")
return
}
if(AllLists.has(guildId)) {
return AllLists.get(guildId)
}
// Add PreviousDB.data[this.guildId]
if(PreviousDB.data[guildId] === undefined) {
PreviousDB.data[guildId] = new Array()
savePrevious()
}
AllLists.set(guildId, this)
this.next = new Array();
this.current = null;
this.shuffle = false;
this.guildId = guildId;
}
getNext() {
return this.next;
}
getNextSong() {
if(this.next.length > 0) {
return this.next[0];
} else {
return null;
}
}
nextSong() {
var song = null;
if(!this.shuffle) {
song = this.next[0]
this.next.splice(0, 1)
} else {
const randomIndex = Math.floor(Math.random() * this.next.length);
song = this.next[randomIndex]
this.next.splice(randomIndex, 1)
}
return song
}
clearNext() {
this.next = new Array();
}
addNextSong(song) {
this.next.push(song)
}
removeNextByIndex(index) {
this.next.splice(index, 1)
}
moveSongToUpNext(index) {
const song = this.next[index]
this.next.splice(index, 1)
this.next.unshift(song)
}
getPrevious() {
return PreviousDB.data[this.guildId];
}
getPreviousSong() {
if(PreviousDB.data[this.guildId].length > 0) {
const song = PreviousDB.data[this.guildId][0]
PreviousDB.data[this.guildId].splice(0, 1)
savePrevious()
return song
} else {
return null;
}
}
clearPrevious() {
PreviousDB.data[this.guildId] = new Array();
savePrevious();
}
addPreviousSongToNextByIndex(index) {
const song = PreviousDB.data[this.guildId][index]
this.next.push(song)
}
addPreviousSong(song) {
PreviousDB.data[this.guildId].unshift(song)
savePrevious()
}
getCurrent() {
return this.current;
}
setCurrent(value) {
this.current = value;
}
destroy() {
this.clearNext();
this.current = null
this.shuffle = false;
}
setShuffle(value) {
this.shuffle = value;
}
isShuffle() {
return this.shuffle;
}
// Play the song with the index in the queue and delete it from the queue
playByIndex(index, typelist) {
var index = data[0]
var list = data[1]
if(typelist == ListType.NEXT) {
const song = this.next[index]
this.next.splice(index, 1)
return song
} else if(typelist == ListType.PREVIOUS) {
const song = PreviousDB.data[this.guildId][index]
return song
}
}
}
const ListType = {
NEXT: "0",
PREVIOUS: "1"
}
function savePrevious() {
for(const guildId in PreviousDB.data) {
if(PreviousDB.data[guildId].length > 50) {
PreviousDB.data[guildId].splice(50, PreviousDB.data[guildId].length - 50)
}
}
PreviousDB.save();
}
module.exports = { List, ListType }

View File

@@ -0,0 +1,18 @@
const {createAudioResource, VoiceConnectionStatus} = require('@discordjs/voice');
const {LogType} = require('loguix')
const clog = new LogType("Media")
const plog = require("loguix").getInstance("Player")
async function play(instance, song) {
//const resource = await song.getResource()
const resource = createAudioResource(song.url)
console.log(resource)
// Wait until connection is ready
instance.connection.once(VoiceConnectionStatus.Ready, async () => {
instance.player.play(resource)
})
plog.log(`GUILD : ${instance.guildId} - Lecture de la musique : ${song.title}`)
}
module.exports = {play}

View File

@@ -0,0 +1,126 @@
const { joinVoiceChannel, getVoiceConnection, entersState, VoiceConnectionStatus, createAudioPlayer, AudioPlayerStatus } = require('@discordjs/voice');
const {List} = require('./List')
const {LogType} = require("loguix");
const plog = new LogType("Player")
const clog = new LogType("Signal")
const media = require('./Method/Media');
const AllPlayers = new Map()
class Player {
connection;
player;
guildId;
queue;
constructor(guildId) {
if(this.guildId === null) {
clog.error("Impossible de créer un Player, car guildId est null")
return
}
if(AllPlayers.has(guildId)) {
return AllPlayers.get(guildId)
}
this.connection = null
this.player = null
this.guildId = guildId
this.queue = new List(guildId)
AllPlayers.set(guildId, this)
}
async join(channel) {
if(getVoiceConnection(channel.guild.id)) {
clog.log(`GUILD : ${this.guildId} - Une connexion existe déjà pour ce serveur`)
return
}
this.connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
this.player = createAudioPlayer()
this.connection.subscribe(this.player)
this.connection.on('stateChange', (oldState, newState) => {
clog.log(`GUILD : ${this.guildId} - [STATE] OLD : "${oldState.status}" NEW : "${newState.status}"`);
});
this.player.on('error', error => {
plog.error(`GUILD : ${this.guildId} - Une erreur est survenue dans le player`);
plog.error(error);
});
this.player.on(AudioPlayerStatus.Idle, () => {
if(this.queue.next.length > 0) {
//TODO : Play next song
}
});
this.player.on(AudioPlayerStatus.Playing, () => {
plog.log(`GUILD : ${this.guildId} - Le player est en train de jouer le contenu suivant : ${this.queue.current.title}`);
});
}
checkConnection() {
if(this.connection === null) {
clog.error(`GUILD : ${this.guildId} - La connection n'est pas définie`)
return true
}
if(this.player === null) {
plog.error(`GUILD : ${this.guildId} - Le player n'est pas défini`)
return true
}
}
async play(song) {
if(song.type = "attachment") {
media.play(this, song)
}
}
async add(song) {
if(this.player.state.status = AudioPlayerStatus.Idle && this.queue.current === null && this.queue.next.length === 0) {
this.play(song)
return
}
this.queue.addNextSong(song)
}
async pause() {
if(this.player.state.status = AudioPlayerStatus.Paused) {
this.player.unpause()
} else {
this.player.pause()
}
}
async leave() {
// Détruit la connection et le player et l'enlève de la liste des
this.connection.destroy()
this.player.stop()
AllPlayers.delete(this.guildId)
clog.log("Connection détruite avec le guildId : " + this.guildId)
plog.log("Player détruit avec le guildId : " + this.guildId)
}
}
module.exports = {Player}
/*
You can access created connections elsewhere in your code without having to track the connections yourself. It is best practice to not track the voice connections yourself as you may forget to clean them up once they are destroyed, leading to memory leaks.
const connection = getVoiceConnection(myVoiceChannel.guild.id);
*/

View File

@@ -0,0 +1,77 @@
const {LogType} = require('loguix')
const { createAudioResource, StreamType } = require('@discordjs/voice');
const ffprobe = require('ffprobe');
const ffprobeStatic = require('ffprobe-static');
const { getReadableDuration } = require('../utils/TimeConverter');
const clog = new LogType("Song")
class Song {
title = "Aucun titre";
author = "Auteur inconnu"
url;
thumbnail;
duration;
readduration;
type;
async processMedia(media, provider) {
if(provider) this.author = provider
// Check if media is a file or a link
if(media.attachment) {
this.url = media.attachment.url
this.type = "attachment"
// In face, duration is null, get the metadata of the file to get the duration
await getMediaInformation(this, media)
} else {
clog.error("Impossible de traiter le média")
}
}
async getResource() {
const resource = createAudioResource(this.url, {
inputType: StreamType.Arbitrary
})
return resource
}
}
module.exports = {Song}
async function getMediaInformation(instance, media, provider) {
try {
const info = await ffprobe(media.attachment.url, { path: ffprobeStatic.path });
if (info.streams?.[0]?.duration_ts) {
instance.duration = info.streams[0].duration;
instance.readduration = getReadableDuration(instance.duration)
}
// Vérification pour éviter une erreur si `streams[0]` ou `tags` n'existe pas
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
// Obtenir le titre (sinon utiliser le nom du fichier)
instance.title = info.streams?.[0]?.tags?.title ?? media.attachment.name;
// Obtenir l'auteur (s'il existe)
instance.author = info.streams?.[0]?.tags?.artist ?? instance.author;
} catch (err) {
clog.error("Impossible de récupérer les informations de la musique : " + this.name)
clog.error(err)
return null;
}
}

View File

@@ -61,6 +61,9 @@ class Database {
clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`) clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`)
clog.error(e) clog.error(e)
} }
// Assure that the database is up to date and reloaded
this.update()
} }

View File

@@ -8,7 +8,8 @@ const __glob = {
LOGS: root + path.sep + "logs", LOGS: root + path.sep + "logs",
DATA: root + path.sep + "data", DATA: root + path.sep + "data",
COMMANDS: root + path.sep + "src" + path.sep + "discord" + path.sep + "commands", COMMANDS: root + path.sep + "src" + path.sep + "discord" + path.sep + "commands",
METRIC_FILE: root + path.sep + "data" + path.sep + "metrics.json" METRIC_FILE: root + path.sep + "data" + path.sep + "metrics.json",
PREVIOUSFILE: root + path.sep + "data" + path.sep + "previous.json",
} }
module.exports = {__glob} module.exports = {__glob}

View File

@@ -0,0 +1,11 @@
const pm2 = require("pm2")
const { LogType } = require("loguix")
const clog = new LogType("Maintenance")
function restart(reason) {
clog.warn("Redémarrage du serveur Subsonics")
clog.warn(`Reason: ${reason}`)
pm2.restart("Subsonics")
}
module.exports = {restart}

View File

@@ -0,0 +1,32 @@
function getReadableDuration(duration) {
var max = ""
duration *= 1000;
const maxhours = Math.floor(duration / 3600000);
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60);
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60);
if (maxsec < 10) {
maxsec = `0${maxsec}`;
}
if(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + "h" + maxmin + "m" + maxsec
} else {
max = maxmin + "m" + maxsec + "s"
}
return max
}
module.exports = {getReadableDuration}