Version 2.0.0 - update of everything

This commit is contained in:
raphix committed 2026-10-07 14:04:26 +02:00
1 parent 164a1da009
commit 6017e03eac
85 files changed
+13065 -2107

No files matched your search

+60 -14
View File
@@ -2,30 +2,76 @@ const bot = require('./Bot')
const dlog = require('loguix').getInstance('Discord')
const {ActivityType} = require('discord.js')
// Regroupement des mises à jour de présence : pas de clignotement entre deux morceaux, pas de rate limit
const REFRESH_DELAY = 1500
// Statuts où le bot "écoute" quelque chose
const ACTIVE_STATUS = ["playing", "loading", "stalled"]
let refreshTimer = null
// Dernière présence envoyée : on n'appelle Discord que si elle change
let lastActivity = null
function getActivity() {
const client = bot.getClient()
return client.user.presence.activities[0]
}
function applyActivity(text) {
const client = bot.getClient()
if(!client?.user) return false
if(text === lastActivity) return true
try {
client.user.setActivity(text, {
type: ActivityType.Listening
});
lastActivity = text
dlog.log(`Activité mise à jour : ${text}`)
return true
} catch(e) {
dlog.error(`Impossible de mettre à jour l'activité : ${e?.message || e}`)
return false
}
}
// Set All type of activities
function setMusicActivity(songName, artistName, imageUrl) {
const client = bot.getClient()
client.user.setActivity(`${songName} - ${artistName}`,{
type: ActivityType.Listening,
url: imageUrl
});
dlog.log(`Activité mise à jour : ${songName} - ${artistName}`)
function setMusicActivity(songName, artistName) {
applyActivity(`${songName} - ${artistName}`)
}
function idleActivity() {
const client = bot.getClient()
client.user.setActivity("le silence absolu", {
type: ActivityType.Listening
});
dlog.log(`Activité mise à jour : rien`)
applyActivity("le silence absolu")
}
/**
* Calcule la présence à partir de TOUS les players (cohérente en multi-serveur)
*/
function computeActivity() {
const { getAllPlayers } = require('../player/Player')
const active = getAllPlayers().filter(player => ACTIVE_STATUS.includes(player.status) && player.queue?.current)
if(active.length === 0) return "le silence absolu"
if(active.length === 1) {
const song = active[0].queue.current
return `${song.title} - ${song.author}`
}
return `de la musique sur ${active.length} serveurs`
}
/**
* Demande une mise à jour de la présence (debounce de 1,5 s)
*/
function refresh() {
if(refreshTimer) return
refreshTimer = setTimeout(() => {
refreshTimer = null
try {
applyActivity(computeActivity())
} catch(e) {
dlog.error(`Erreur lors du calcul de l'activité : ${e?.message || e}`)
}
}, REFRESH_DELAY)
refreshTimer.unref?.()
}
module.exports = {getActivity, setMusicActivity, idleActivity}
module.exports = {getActivity, setMusicActivity, idleActivity, refresh}
+154 -54
View File
@@ -4,8 +4,8 @@ const path = require("path")
const { __glob } = require("../utils/GlobalVars")
const { LogType } = require("loguix")
const config = require("../utils/Database/Configuration")
const metric = require("webmetrik")
const { Player } = require("../player/Player")
const SafeMetric = require("../utils/SafeMetric")
const { AllPlayers } = require("../player/Player")
const {refreshAllUserInformation} = require("../server/auth/User")
const serverSettings = require("./ServerSettings")
const { Embed, EmbedError } = require("./Embed")
@@ -75,7 +75,8 @@ function getGuildRoles(guildId) {
}
function getChannel(guildId, channelId) {
return client.guilds.cache.get(guildId).channels.cache.get(channelId)
// Guilde quittée ou inconnue : undefined plutôt qu'une TypeError
return client.guilds.cache.get(guildId)?.channels.cache.get(channelId)
}
function init() {
@@ -106,38 +107,53 @@ function init() {
if(!interaction.isChatInputCommand()) return;
var numberOfCommands = new metric.Metric("numberOfCommands", "Nombre de commandes éxécutées")
numberOfCommands.setValue(numberOfCommands.getValue() + 1)
var numberOfCommandsServer = new metric.Metric("numberOfCommands_" + interaction.guild.id, "Nombre de commandes éxécutées sur le serveur : " + interaction.guild.name)
numberOfCommandsServer.setValue(numberOfCommandsServer.getValue() + 1)
// Compteurs de commandes (SafeMetric ne lève jamais d'exception)
SafeMetric.increment("numberOfCommands", 1, "Nombre de commandes éxécutées")
if(interaction.guildId) {
SafeMetric.increment("numberOfCommands_" + interaction.guildId, 1, "Nombre de commandes éxécutées sur le serveur : " + (interaction.guild?.name ?? interaction.guildId))
}
const command = client.commands.get(interaction.commandName)
const roleProtected = await serverSettings.getSecureRole(interaction.guild.id) || false
const command = client.commands?.get(interaction.commandName)
if(!command) {
dlog.warn("Commande inconnue ou pas encore chargée : /" + interaction.commandName)
return
}
var roleProtected = false
var havePermission = true;
if(roleProtected) {
await interaction.member.fetch()
if(!interaction.member.roles.cache.has(roleProtected.id)) {
havePermission = false;
try {
roleProtected = interaction.guildId ? (await serverSettings.getSecureRole(interaction.guildId) || false) : false
if(roleProtected) {
await interaction.member.fetch()
if(!interaction.member.roles.cache.has(roleProtected.id)) {
havePermission = false;
}
}
} catch(error) {
// Vérification impossible : on refuse par sécurité si un rôle est exigé
dlog.error("Vérification du rôle sécurisé impossible pour /" + interaction.commandName + " : " + (error?.stack || error))
if(roleProtected) havePermission = false
}
try {
// Create a metric to count the number of commands executed by each user
const userCommand = new metric.Metric("userCommand_" + interaction.member.user.username, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.member.user.username)
userCommand.setValue(userCommand.getValue() + 1)
SafeMetric.increment("userCommand_" + interaction.user.username, 1, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.user.username)
dlog.log(interaction.member.user.username + "-> /" + interaction.commandName)
if(havePermission) {
command.execute(client, interaction)
// await : les erreurs asynchrones de la commande remontent dans ce try/catch
await command.execute(client, interaction)
} else {
const embed = new EmbedError(`L'utilisation du Bot est réservée aux membres ayant le rôle "${roleProtected.name}"`, interaction, true)
embed.setTitle("Accès refusé")
embed.send()
await embed.send()
}
} catch(error) {
dlog.error(interaction.member.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE")
dlog.error(error)
interaction.reply({content:"Erreur lors de l'éxécution de la commande !", ephemeral: true})
dlog.error(interaction.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE")
dlog.error(error?.stack || error)
// Réponse d'erreur selon l'état de l'interaction (déjà différée ou non), sans jamais lever
const errorReply = {content:"Erreur lors de l'éxécution de la commande !", ephemeral: true}
const replyPromise = (interaction.replied || interaction.deferred) ? interaction.editReply(errorReply) : interaction.reply(errorReply)
await Promise.resolve(replyPromise).catch(() => {})
}
})
@@ -156,11 +172,13 @@ function init() {
// If a new guild is added, we will add it to the guilds map
client.on("guildCreate", async (guild) => {
// Déploiement des commandes slash sur le nouveau serveur (sinon absentes jusqu'au redémarrage)
require("./CommandUpdater").registerGuild(guild.id)
await refreshGuilds()
glog.log("Guilde ajoutée : " + guild.name + " (" + guild.id + ")")
process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
})
client.on("guildDelete", (guild) => {
@@ -168,11 +186,13 @@ function init() {
guilds.delete(guild.id)
glog.log("Guilde supprimée : " + guild.name + " (" + guild.id + ")")
process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
})
client.on('guildUpdate', async () => {
await refreshGuilds()
process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
})
client.on("voiceStateUpdate", (oldMember, newMember) => {
@@ -181,32 +201,14 @@ function init() {
channelId: newMember.channelId,
})
const player = new Player(newMember.guild.id)
if(player.connection && player.channelId) {
client.channels.fetch(player.channelId).then(channel => {
if(channel.members.size <= 1) {
// If the player is alone in the channel, we will destroy it in 10 minutes
// 10 minutes = 600000 ms
// 10 second = 10000 ms
timers.set(newMember.guild.id, setTimeout(() => {
const getPlayer = new Player(newMember.guild.id)
if(getPlayer.connection && player.channelId) {
getPlayer.leave()
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé : " + channel.name)
}
}, 600000))
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé dans 10 minutess : " + channel.name)
} else {
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player n'est pas seul dans le channel : " + channel.name)
clearTimeout(timers.get(newMember.guild.id))
timers.delete(newMember.guild.id)
}
})
try {
// Le bot lui-même a changé de salon (déplacement par un modérateur) ou a été expulsé
if(newMember.id === client.user?.id) {
AllPlayers.get(newMember.guild.id)?.onBotVoiceState(newMember)
}
checkAutoLeave(newMember.guild.id)
} catch(error) {
dlog.error("[Automatic Task] Erreur lors de la vérification de l'auto-départ : " + (error?.stack || error))
}
process.emit("VOCAL_UPDATE")
@@ -218,17 +220,46 @@ function init() {
client.login(config.getToken())
}
// Un seul rafraîchissement à la fois : les appels concurrents (arrivées de membres, guildUpdate...) sont regroupés
let refreshing = null
let refreshQueued = false
async function refreshGuilds() {
if(refreshing) {
refreshQueued = true
return refreshing
}
refreshing = (async () => {
do {
refreshQueued = false
try {
await doRefreshGuilds()
} catch(error) {
glog.step.error("d_refresh_guilds", error?.message || error)
dlog.error("Erreur lors du rafraichissement des guildes : " + (error?.stack || error))
}
} while(refreshQueued)
})().finally(() => { refreshing = null })
return refreshing
}
async function doRefreshGuilds() {
glog.step.init("d_refresh_guilds", "Rafraichissement des guildes")
await client.guilds.fetch()
for(const guild of client.guilds.cache.values()) {
await guild.members.fetch()
try {
await guild.members.fetch()
} catch(error) {
// GuildMembersTimeout : on garde le cache actuel plutôt que d'abandonner toutes les guildes
glog.warn("Impossible de récupérer les membres de la guilde : " + guild.name + " (" + guild.id + ") : " + (error?.message || error))
}
var allMembersOfGuild = guild.members.cache.map(member => member.user.id)
const missingPermissions = checkRequiredPermission(guild.members.me)
const missingPermissions = guild.members.me ? checkRequiredPermission(guild.members.me) : []
if(missingPermissions.length > 0) {
dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name)
guild.leave()
return
dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name + " (manquantes : " + missingPermissions.join(", ") + ")")
guild.leave().catch(error => dlog.error("Impossible de quitter la guilde " + guild.name + " : " + (error?.message || error)))
// continue (et non return) : les guildes suivantes doivent quand même être rafraichies
continue
}
guilds.set(guild.id, {
id: guild.id,
@@ -258,6 +289,75 @@ function checkRequiredPermission(guildMember) {
return requiredPermissions.filter(permission => !guildMember.permissions.has(permission));
}
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles}
// Départ automatique quand plus aucun humain n'écoute (10 minutes)
const AUTO_LEAVE_DELAY = 10 * 60 * 1000
/**
* Nombre de membres non-bots dans le salon vocal du player (cache des voice states, pas de requête)
*/
function countListeners(player) {
if(!player?.channelId) return 0
const channel = client.channels.cache.get(player.channelId)
if(!channel?.members) return 0
return channel.members.filter(member => !member.user.bot).size
}
/**
* Programme ou annule le départ automatique d'une guilde.
* Un seul minuteur par guilde : il n'est jamais écrasé, et il est annulé dès que quelqu'un revient.
*/
function checkAutoLeave(guildId) {
const player = AllPlayers.get(guildId)
if(!player || !player.isConnected() || !player.channelId) {
// Plus de player : rien à surveiller
if(timers.has(guildId)) {
clearTimeout(timers.get(guildId))
timers.delete(guildId)
}
player?.setAutoLeaveAt(null)
return
}
if(countListeners(player) > 0) {
if(timers.has(guildId)) {
clearTimeout(timers.get(guildId))
timers.delete(guildId)
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Départ automatique annulé, quelqu'un écoute dans : " + player.channelName)
}
player.setAutoLeaveAt(null)
return
}
// Déjà programmé : on ne remplace pas le minuteur existant
if(timers.has(guildId)) return
const leaveAt = Date.now() + AUTO_LEAVE_DELAY
timers.set(guildId, setTimeout(() => {
timers.delete(guildId)
const current = AllPlayers.get(guildId)
if(!current || !current.isConnected()) return
// Revérification au moment du départ
if(countListeners(current) > 0) {
current.setAutoLeaveAt(null)
return
}
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé : " + current.channelName)
current.leave("auto")
}, AUTO_LEAVE_DELAY))
player.setAutoLeaveAt(leaveAt)
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé dans 10 minutes : " + player.channelName)
}
/**
* Annule le minuteur d'auto-départ d'une guilde (utilisé quand l'admin fait quitter le serveur au bot)
*/
function clearAutoLeaveTimer(guildId) {
if(typeof timers === "undefined" || !timers.has(guildId)) return false
clearTimeout(timers.get(guildId))
timers.delete(guildId)
return true
}
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles, refreshGuilds, checkRequiredPermission, clearAutoLeaveTimer}
+2 -1
View File
@@ -65,7 +65,8 @@ class Command {
* @param {Client} client
* @param {Interaction} interaction
*/
this.data = {data: SlashCommand, async execute(client, interaction) {callback(client, interaction)}}
// On renvoie la promesse du callback : l'appelant (Bot.js) l'attend et capture ses erreurs
this.data = {data: SlashCommand, async execute(client, interaction) {return await callback(client, interaction)}}
}
+74 -26
View File
@@ -1,53 +1,71 @@
function init() {
const bot = require("./Bot")
const client = bot.getClient()
const { Collection, REST, Routes } = require("discord.js")
const fs = require("node:fs")
const path = require("path")
const {__glob} = require("../utils/GlobalVars")
const dlog = require("loguix").getInstance("Discord")
const config = require("../utils/Database/Configuration")
// Commandes au format JSON (envoyées à Discord) et client REST, initialisés par init()
var commands = null
var rest = null
function getLog() {
return require("loguix").getInstance("Discord")
}
function init() {
const bot = require("./Bot")
const client = bot.getClient()
const dlog = getLog()
client.commands = new Collection()
dlog.step.init("d_init", "Initialisation du Bot Discord")
dlog.step.init("d_get_commands", "Récupération des commandes en local depuis : " + __glob.COMMANDS)
const commands = [];
commands = [];
// Grab all the command files from the commands directory you created earlier
const commandsPath = __glob.COMMANDS
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
// Les commandes de développement ("__DEBUG.js", "__TEST.js"...) ne sont chargées qu'en développement :
// sinon elles seraient déployées sur toutes les guildes, sans contrôle de permission
const devMode = process.env.NODE_ENV === "development"
const commandFiles = fs.readdirSync(commandsPath).filter(file => {
if(!file.endsWith('.js')) return false
if(file.startsWith("__") && !devMode) {
dlog.log("Commande de développement ignorée (NODE_ENV != development) : " + file)
return false
}
return true
});
for (const file of commandFiles) {
const cmd = require(commandsPath + path.sep + file);
client.commands.set(cmd.command.getName(), cmd.command.getData())
// Une commande invalide ne doit pas empêcher le chargement des autres
try {
const cmd = require(commandsPath + path.sep + file);
client.commands.set(cmd.command.getName(), cmd.command.getData())
commands.push(cmd.command.getData().data.toJSON());
commands.push(cmd.command.getData().data.toJSON());
} catch (error) {
dlog.error("Impossible de charger la commande '" + file + "' : " + (error?.stack || error))
}
}
dlog.step.end("d_get_commands")
const rest = new REST().setToken(config.getToken());
rest = new REST().setToken(config.getToken());
if(commands.length != 0) {
(async () => {
try {
const guilds = client.guilds.cache.map(guild => guild.id);
dlog.step.init("d_commands_refresh", `Refreshing ${guilds.length} guilds (/) commands.`);
for (const guildId of guilds) {
const data = await rest.put(
Routes.applicationGuildCommands(client.user.id, guildId),
{ body: commands },
);
// Log the guilds where the commands have been refreshed with number of commands
dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId).name}"`);
// Une guilde en erreur (permissions, guilde quittée…) ne bloque pas les suivantes
await registerGuild(guildId)
}
dlog.step.end("d_commands_refresh")
@@ -56,8 +74,8 @@ client.commands = new Collection()
dlog.error(error)
}
})();
} else {
@@ -71,4 +89,34 @@ client.commands = new Collection()
}
module.exports = {init}
/**
* Déploie les commandes slash sur une guilde (ex : à l'arrivée sur un nouveau serveur).
* Ne lève jamais d'exception.
* @param {string} guildId
* @returns {Promise<boolean>} true si les commandes ont été déployées
*/
async function registerGuild(guildId) {
const dlog = getLog()
if(!commands || !rest) {
// init() n'a pas encore tourné : il déploiera sur toutes les guildes du cache
dlog.warn(`Commandes non chargées, déploiement différé pour la guilde "${guildId}"`)
return false
}
if(commands.length == 0) return false
try {
const client = require("./Bot").getClient()
const data = await rest.put(
Routes.applicationGuildCommands(client.user.id, guildId),
{ body: commands },
);
// Log the guilds where the commands have been refreshed with number of commands
dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId)?.name}"`);
return true
} catch (error) {
dlog.error(`Impossible de déployer les commandes sur la guilde "${guildId}" : ` + (error?.stack || error))
return false
}
}
module.exports = {init, registerGuild}
+16 -22
View File
@@ -1,34 +1,28 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const {Player, AllPlayers} = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
const command = new Command("leave", "Quitter le salon vocal", (client, interaction) => {
const command = new Command("leave", "Quitter le salon vocal", async (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour arrêter le bot !", interaction)
const channel = interaction.member.voice.channel
var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) {
const player = AllPlayers.get(channel.guildId)
if(!player?.connected) {
return embed.returnError("Le bot n'est pas connecté à ce salon vocal")
}
player.leave()
embed.setColor(200, 20, 20)
embed.setTitle('**Déconnexion**')
embed.setDescription('Déconnexion du salon vocal')
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/phone-51-64.png")
embed.send()
} else {
embed.returnError("Le bot n'est pas connecté à ce salon vocal")
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté à ce salon vocal")
}
// Annule aussi un chargement en cours (yt-dlp tué)
await player.leave("command")
embed.setColor(200, 20, 20)
embed.setTitle('**Déconnexion**')
embed.setDescription('Déconnexion du salon vocal')
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/phone-51-64.png")
embed.send()
})
module.exports = {command}
+17 -16
View File
@@ -1,8 +1,9 @@
const {Command} = require('../Command');
const {Embed, EmbedError} = require('../Embed');
const { Player } = require('../../player/Player');
const { getPlayer } = require('../../player/Player');
const { Song } = require('../../player/Song');
const history = require('../../playlists/History');
const { checkSong } = require('../../player/SongCheck');
const history = require('../../playlists/History');
const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => {
@@ -20,36 +21,36 @@ const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal
const song = new Song()
await song.processMedia(media, interaction.user.username)
const player = new Player(channel.guildId)
player.join(channel)
if(!checkSong(song)) return embed.returnError("Ce média ne peut pas être lu")
const player = getPlayer(channel.guildId)
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
if(now) {
player.play(song)
if(now) {
// Lecture immédiate : la musique en cours part dans l'historique
await player.playNow(song)
embed.setTitle('**Lecture immédiate**')
} else {
player.add(song)
await player.add(song)
embed.setTitle('**Ajout à liste de lecture**')
}
history.addToPersonalHistory(interaction.user.id, song)
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()
}, [{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}
module.exports = {command}
+27 -27
View File
@@ -1,44 +1,44 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const { Player } = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", (client, interaction) => {
const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", async (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour mettre en pause la musique !", interaction)
const channel = interaction.member.voice.channel
const player = new Player(channel.guildId)
const result = player.pause()
var embed = new Embed(interaction)
embed.setColor(0x03ff2d)
result.then((pause) => {
// Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
if(pause == "no_music") {
embed.returnError("Il n'y a pas de musique en cours de lecture")
} else if(pause) {
embed.setTitle('Musique en pause')
embed.setDescription("La musique a été mise en pause")
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png")
} else {
embed.setTitle('Musique reprise')
embed.setDescription("La musique a été reprise")
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/play-64.png")
}
// Résultat réel : {ok, paused, reason}
const result = await player.pause()
embed.send()
})
// Réponse en embed
if(!result.ok) {
embed.returnError("Il n'y a pas de musique en cours de lecture")
return
}
const loading = player.status === "loading" || player.status === "stalled"
if(result.paused) {
embed.setTitle('Musique en pause')
embed.setDescription(loading ? "La musique sera mise en pause dès le début de la lecture" : "La musique a été mise en pause")
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png")
} else {
embed.setTitle('Musique reprise')
embed.setDescription(loading ? "La musique sera lue dès qu'elle sera prête" : "La musique a été reprise")
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/play-64.png")
}
embed.send()
})
module.exports = {command}
module.exports = {command}
+59 -66
View File
@@ -1,7 +1,8 @@
const { Command } = require("../Command");
const { Embed, EmbedError } = require("../Embed");
const { Player } = require("../../player/Player");
const { getPlayer } = require("../../player/Player");
const Finder = require("../../player/Finder");
const { checkSong } = require("../../player/SongCheck");
const { Playlist } = require("../../playlists/Playlist");
const spotify = require("../../media/SpotifyInformation");
const history = require("../../playlists/History");
@@ -14,80 +15,72 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
const channel = interaction.member.voice.channel
const now = interaction.options.getBoolean("now") || false
const embed = new Embed(interaction)
await Finder.search(url.value).then(async (song) => {
if(!song) return embed.returnError("Impossible de trouver la musique à partir du lien donné ou des mots clés donnés")
const player = new Player(channel.guildId)
player.join(channel)
embed.setColor(0x15e6ed)
// Check if song is playlist
if(song instanceof Playlist) {
embed.setDescription('**Playlist : **' + song.songs.length + ' musiques')
embed.addField('**Titre : **' + song.title, "")
embed.addField('**Demandé par : **', interaction.member.user.username,)
embed.addField('**Auteur : **', song.author)
embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase()))
if(!song.type == "spotify") {
embed.addField('**Durée : **', song.readduration)
}
embed.addField('**Lien : **', song.url)
embed.addField(":warning: La récupération des musiques peut prendre du temps", "Veuillez patienter ... et éviter de lancer d'autres commandes")
embed.setThumbnail(song.thumbnail)
} else {
embed.setDescription('**Titre : **' + song.title)
let song = await Finder.search(url.value)
if(!song) return embed.returnError("Impossible de trouver la musique à partir du lien donné ou des mots clés donnés")
if(!(song instanceof Playlist) && !checkSong(song)) return embed.returnError("Cette musique ne peut pas être lue (source non prise en charge)")
const player = getPlayer(channel.guildId)
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
embed.setColor(0x15e6ed)
// Check if song is playlist
if(song instanceof Playlist) {
embed.setDescription('**Playlist : **' + song.songs.length + ' musiques')
embed.addField('**Titre : **' + song.title, "")
embed.addField('**Demandé par : **', interaction.member.user.username,)
embed.addField('**Auteur : **', song.author)
embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase()))
if(song.type != "spotify") {
embed.addField('**Durée : **', song.readduration)
embed.addField("**Artiste : **",song.author)
embed.addField('**Demandé par **' + interaction.member.user.username, "")
embed.addField("**Lien :** ", song.url)
embed.setThumbnail(song.thumbnail)
}
embed.addField('**Lien : **', song.url)
embed.addField(":warning: La récupération des musiques peut prendre du temps", "Veuillez patienter ... et éviter de lancer d'autres commandes")
if(now) {
embed.setTitle("Lecture immédiate")
} else {
embed.setTitle("Ajoutée à la file d'attente")
}
embed.setThumbnail(song.thumbnail)
} else {
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.addField("**Lien :** ", song.url)
embed.setThumbnail(song.thumbnail)
}
if(now) {
embed.setTitle("Lecture immédiate")
} else {
embed.setTitle("Ajoutée à la file d'attente")
}
if(song instanceof Playlist) {
embed.send()
if(song instanceof Playlist) {
if(song.type == "spotify") {
song = await spotify.getTracks(song)
}
player.readPlaylist(song, now)
} else {
if(now) {
player.play(song)
} else {
player.add(song)
}
history.addToPersonalHistory(interaction.user.id, song)
if(song.type == "spotify") {
song = await spotify.getTracks(song)
}
await player.readPlaylist(song, now)
return
}
// Lecture immédiate : la musique en cours part dans l'historique ; sinon ajout à la file
if(now) {
await player.playNow(song)
} else {
await player.add(song)
}
embed.send()
history.addToPersonalHistory(interaction.user.id, song)
})
}, [{type: "STRING", name: "url", description: "Recherche / Lien audio (Youtube / Soundclound / Spotify)", required: true},
{type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}]
)
module.exports = {command}
module.exports = {command}
+23 -38
View File
@@ -1,51 +1,36 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const { Player, AllPlayers } = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
const command = new Command("previous", "Passe à la musique précédente", (client, interaction) => {
const command = new Command("previous", "Passe à la musique précédente", async (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction)
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique précédente !", interaction)
const channel = interaction.member.voice.channel
var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) {
const player = new Player(channel.guildId)
const result = player.previous()
embed.setColor(0x15e6ed)
result.then((song) => {
if(song == "no_music") {
embed.returnError("Il n'y a pas de musique précédemment jouée")
} else if(song) {
// Result is a song
embed.setTitle('**Musique précédente !**')
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **'+ song.readduration, "")
embed.addField("**Artiste : **" + song.author, "")
embed.setThumbnail(song.thumbnail)
embed.send()
}
})
} else {
return embed.returnError("Le bot n'est pas connecté", interaction)
// Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
const song = await player.previous()
// Réponse envoyée dans tous les cas (sinon l'interaction expire)
if(song == "no_music" || !song) {
return embed.returnError("Il n'y a pas de musique précédemment jouée")
}
embed.setColor(0x15e6ed)
embed.setTitle('**Musique précédente !**')
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **'+ song.readduration, "")
embed.addField("**Artiste : **" + song.author, "")
embed.setThumbnail(song.thumbnail)
embed.send()
})
module.exports = {command}
module.exports = {command}
+29 -33
View File
@@ -1,43 +1,39 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const { Player, AllPlayers } = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
// Nombre de musiques détaillées dans l'embed
const MAX_LISTED = 10
const command = new Command("liste", "Affiche la file d'attente", (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la file d'attente !", interaction)
const channel = interaction.member.voice.channel
var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) {
const player = new Player(channel.guildId)
const queue = player.queue.getNext()
if(queue.length == 0) {
embed.returnError("Il n'y a pas de musique en file d'attente")
} else if(queue.length > 0) {
// Result is a song
embed.setColor(0x15e6ed)
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png")
embed.setTitle('**File d\'attente :**')
embed.setDescription('**' + queue.length + ' musiques**')
queue.forEach((song, index) => {
// max 24 fields
if(index > 10) return
embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`)
})
embed.send()
}
} else {
embed.returnError("Le bot n'est pas connecté")
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
const queue = player.queue.getNext()
if(queue.length == 0) {
return embed.returnError("Il n'y a pas de musique en file d'attente")
}
embed.setColor(0x15e6ed)
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png")
embed.setTitle('**File d\'attente :**')
embed.setDescription('**' + queue.length + ' musiques**')
queue.slice(0, MAX_LISTED).forEach((song, index) => {
embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`)
})
if(queue.length > MAX_LISTED) {
embed.addField(`... et ${queue.length - MAX_LISTED} autre(s)`, "")
}
embed.send()
})
module.exports = {command}
module.exports = {command}
+19 -7
View File
@@ -1,27 +1,39 @@
const {Embed} = require("../Embed")
const {Command} = require("../Command")
const {restart} = require("../../utils/Maintenance")
const users = require("../../server/auth/User")
const {restart, isRestarting} = require("../../utils/Maintenance")
const users = require("../../server/auth/User")
const { LogType } = require("loguix")
const clog = new LogType("Maintenance")
// Nécéssite une raison pour redémarrer le bot
const command = new Command("restart", "Redémarre le bot", (client, interaction) => {
// Check if user is admin from users list
// Check if user is admin from users list
const user = users.getUserById(interaction.user.id)
if(!user || !user.isAdmin()) {
if(!user || !user.isAdmin() || user.isFullBanned()) {
interaction.reply({content: "Vous n'êtes pas admin", ephemeral: true})
return
}
const reason = interaction.options.getString("reason")
restart(reason)
const reason = (interaction.options.getString("reason") || "").trim().slice(0, 200)
if(!reason) {
interaction.reply({content: "Une raison est nécessaire pour redémarrer le bot", ephemeral: true})
return
}
if(isRestarting()) {
interaction.reply({content: "Un redémarrage est déjà en cours", ephemeral: true})
return
}
clog.warn(`[ADMIN] ${interaction.user.username} -> redémarrage depuis Discord : ${reason}`)
// On répond d'abord, le redémarrage effectif a lieu après un court délai (voir Maintenance.restart)
const embed = new Embed(interaction)
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()
restart(reason)
},
[{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}]
)
module.exports = {command}
module.exports = {command}
+23 -35
View File
@@ -1,48 +1,36 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const { Player, AllPlayers } = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
const command = new Command("skip", "Passe à la musique suivante", async (client, interaction) => {
const command = new Command("skip", "Passe à la musique suivante", (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction)
const channel = interaction.member.voice.channel
var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) {
const player = new Player(channel.guildId)
const result = player.skip()
embed.setColor(0x15e6ed)
result.then((song) => {
if(song == "no_music") {
embed.returnError("Il n'y a pas de musique en file d'attente", interaction)
} else if(song) {
// Result is a song
embed.setColor(0x15e6ed)
embed.setTitle('**Musique suivante !**')
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **'+ song.readduration, "")
embed.addField("**Artiste : **" + song.author, "")
embed.setThumbnail(song.thumbnail)
}
embed.send()
})
} else {
return embed.returnError("Le bot n'est pas connecté", interaction)
// Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
const song = await player.skip()
if(song == "no_music" || !song) {
return embed.returnError("Il n'y a pas de musique en file d'attente")
}
// Le chargement démarre : l'état (chargement / erreur) est visible sur le site
embed.setColor(0x15e6ed)
embed.setTitle('**Musique suivante !**')
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **'+ song.readduration, "")
embed.addField("**Artiste : **" + song.author, "")
embed.setThumbnail(song.thumbnail)
embed.send()
})
module.exports = {command}
module.exports = {command}
+38 -27
View File
@@ -1,34 +1,45 @@
const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed")
const {Player} = require("../../player/Player")
const { AllPlayers } = require("../../player/Player")
const { getReadableDuration } = require("../../utils/TimeConverter")
// Libellés des statuts du player
const STATUS_LABELS = {
loading: "Chargement…",
playing: "En lecture",
paused: "En pause",
stalled: "Connexion vocale instable",
idle: "Inactif",
offline: "Déconnecté",
}
const command = new Command("state", "Affiche la musique en cours", (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la musique en cours !", interaction)
const channel = interaction.member.voice.channel
const player = new Player(channel.guildId)
const song = player.queue.getCurrent()
if(!song) {
var embed = new EmbedError("Il n'y a pas de musique en cours de lecture", interaction)
} else if(song) {
var embed = new Embed(interaction)
// Result is a song
embed.setColor(0x15e6ed)
embed.setTitle('**Musique en cours :**')
embed.setDescription('**Titre : **' + song.title)
embed.addField('**Durée : **', song.readduration)
embed.addField("**Artiste : **", song.author)
embed.setThumbnail(song.thumbnail)
embed.send()
}
// Pas de création de player : simple lecture de l'état
const player = AllPlayers.get(channel.guildId)
const song = player?.queue?.getCurrent()
if(!song) {
return new EmbedError("Il n'y a pas de musique en cours de lecture", interaction)
}
var embed = new Embed(interaction)
embed.setColor(0x15e6ed)
embed.setTitle('**Musique en cours :**')
embed.setDescription('**Titre : **' + song.title)
const durationMs = player.getDurationMs()
const position = getReadableDuration(Math.floor(player.getPositionMs() / 1000))
embed.addField('**Position : **', durationMs ? `${position} / ${song.readduration}` : `${position} (durée inconnue / LIVE)`)
embed.addField('**Statut : **', STATUS_LABELS[player.status] ?? player.status)
embed.addField("**Artiste : **", song.author)
embed.setThumbnail(song.thumbnail)
embed.send()
})
module.exports = {command}
+51 -10
View File
@@ -1,4 +1,27 @@
const { EmbedBuilder, ActionRowBuilder } = require("discord.js");
const { LogType } = require("loguix");
const elog = new LogType("Embed")
// Limites Discord : au-delà, EmbedBuilder lève une exception
const MAX_TITLE = 256
const MAX_DESCRIPTION = 4096
const MAX_FIELD_NAME = 256
const MAX_FIELD_VALUE = 1024
// Délai maximal d'attente du deferReply avant d'éditer la réponse
const DEFER_TIMEOUT = 5000
/**
* Tronque un texte à la limite Discord (jamais vide : caractère invisible par défaut)
* @param {*} value
* @param {number} max
* @returns {string}
*/
function clampText(value, max) {
const text = String(value ?? '')
if(text.length === 0) return '​'
return text.length > max ? text.slice(0, max - 1) + '…' : text
}
class Embed {
fields;
@@ -8,8 +31,9 @@ class Embed {
this.fields = []
this.buttons = []
this.isSended = false
this.deferPromise = null
if(interaction) {
interaction.deferReply({ ephemeral: ephemeral }).then(() => {
this.deferPromise = interaction.deferReply({ ephemeral: ephemeral }).then(() => {
this.isSended = true
}).catch(() => {
this.isSended = true
@@ -20,12 +44,12 @@ class Embed {
}
setTitle(title) {
this.embed.setTitle(title)
this.embed.setTitle(title == null ? null : clampText(title, MAX_TITLE))
return this
}
setDescription(description) {
this.embed.setDescription(description)
this.embed.setDescription(description == null ? null : clampText(description, MAX_DESCRIPTION))
return this
}
@@ -86,7 +110,7 @@ class Embed {
addField(name, value, inline) {
if(!inline) inline = false;
this.fields.push({name: name, value: value, inline: inline})
this.fields.push({name: clampText(name, MAX_FIELD_NAME), value: clampText(value, MAX_FIELD_VALUE), inline: inline})
return this
}
@@ -101,8 +125,8 @@ class Embed {
}
build() {
//Add Fields to an object
this.embed.addFields(this.fields)
//Add Fields to an object (setFields : un second build ne duplique pas les champs)
this.embed.setFields(this.fields)
if(this.buttons.length > 0) {
this.actionRow = new ActionRowBuilder()
.addComponents(this.buttons);
@@ -110,13 +134,30 @@ class Embed {
return this.embed
}
/**
* Édite la réponse de l'interaction avec l'embed. Ne lève jamais d'exception.
* @returns {Promise<boolean>} true si la réponse a été envoyée
*/
async send() {
// Add a secutiry check to avoid sending an embed if the interaction is not defined and retry one again
while(!this.isSended) {
await new Promise(resolve => setTimeout(resolve, 50));
// Sans interaction (embed destiné à un salon, ex : rapport), il n'y a rien à éditer
if(!this.interaction) return false
// Attente du deferReply, avec un délai maximal (plus de boucle infinie)
if(!this.isSended && this.deferPromise) {
let timer = null
await Promise.race([
this.deferPromise,
new Promise(resolve => { timer = setTimeout(resolve, DEFER_TIMEOUT) })
])
clearTimeout(timer)
}
if(this.ephemeral === undefined) this.ephemeral = false;
this.interaction.editReply({ embeds: [this.build()], components: this.buttons.length > 0 ? [this.actionRow] : [] })
try {
await this.interaction.editReply({ embeds: [this.build()], components: this.buttons.length > 0 ? [this.actionRow] : [] })
return true
} catch (error) {
elog.error("Impossible d'envoyer l'embed de la commande /" + (this.interaction.commandName || "?") + " : " + (error?.message || error))
return false
}
}
async returnError(message) {
+14 -12
View File
@@ -23,32 +23,34 @@ if(!config.getMediaGuildId() || !config.getMediaChannelId()) {
var channel = null
discordBot.getClient().on("ready", () => {
discordBot.getClient().on("ready", async () => {
try {
channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId())
} catch (e) {
}
if(!channel) {
wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.")
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.")
if(!channel || typeof channel.send !== "function") {
channel = null
wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.")
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.")
connected = false
return
}
// Le canal existe : la base est utilisable (comme avant), le message de démarrage n'est qu'informatif
connected = true
wlog.step.end("init_db")
try {
const dateTime = new Date()
const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' })
const time = dateTime.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })
const message = `[LOGS] La base de données multimédia a été initialisée le ${date} à ${time}`
channel.send(message)
// await : un refus de Discord (permissions manquantes…) ne doit pas devenir un rejet non géré
await channel.send(message)
wlog.log("La base de données multimédia a été initialisée avec succès.")
wlog.step.end("init_db")
connected = true
} catch (e) {
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.")
wlog.step.error("init_db","Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.")
connected = false
} catch (e) {
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e))
}
})
+21 -8
View File
@@ -14,6 +14,7 @@ class Report {
const embed = new Embed()
embed.setDescription('**Version : **' + packageJson.version)
embed.setTitle("Rapport de : " + provider)
// L'embed n'est pas lié à une interaction : on l'envoie nous-mêmes dans le salon (voir send())
var levelString = null
if(level == "bug") {
@@ -26,24 +27,36 @@ class Report {
}
embed.addField("Type", levelString)
embed.addField("Description", desc)
// Champ limité à 1024 caractères par Discord : on tronque la description
const description = String(desc ?? "")
embed.addField("Description", description.length > 1000 ? description.slice(0, 1000) + "…" : description)
this.embed = embed
}
/**
* Envoie le rapport dans le salon configuré. Ne lève jamais d'exception.
* @returns {Promise<boolean>} true si le rapport a été envoyé
*/
async send() {
if(!this.report_channel || this.report_channel == "") {
log.getInstance("Discord").error("Pas de channel de rapport configuré")
return false
} else {
const channel = await this.client.channels.fetch(this.report_channel)
channel.send({embeds: [this.embed.build()]})
if(this.report_contact && this.report_contact != "") channel.send({content: "<@" + this.report_contact + ">"})
return true
try {
const channel = await this.client.channels.fetch(this.report_channel)
if(!channel || typeof channel.send !== "function") {
log.getInstance("Discord").error("Le channel de rapport est introuvable ou n'accepte pas de messages : " + this.report_channel)
return false
}
await channel.send({embeds: [this.embed.build()]})
if(this.report_contact && this.report_contact != "") await channel.send({content: "<@" + this.report_contact + ">"})
return true
} catch (error) {
log.getInstance("Discord").error("Impossible d'envoyer le rapport : " + (error?.message || error))
return false
}
}
}
+21 -7
View File
@@ -4,9 +4,15 @@ const { __glob } = require('../utils/GlobalVars');
const { Database } = require('../utils/Database/Database');
const ServerDB = new Database("server_settings", __glob.SERVER_DB, {});
/**
* Lecture seule : ne crée jamais d'entrée (un guildId inconnu ou aléatoire ne doit pas provoquer d'écriture disque)
* @returns {{id, name}|null}
*/
function getSecureRole(guildId) {
checkKey(guildId);
var role = ServerDB.getData()[guildId].secureRole || null;
if (typeof guildId !== "string" || guildId === "") return null;
const data = ServerDB.getData() || {};
if (!Object.prototype.hasOwnProperty.call(data, guildId)) return null;
var role = data[guildId]?.secureRole || null;
if(role) {
if(role.name === "@everyone") {
return null;
@@ -15,21 +21,29 @@ function getSecureRole(guildId) {
return role;
}
function setSecureRole(guildId, roleId) {
/**
* @param {string} guildId
* @param {{id, name}|null} role rôle requis pour utiliser le bot (null = aucun)
*/
function setSecureRole(guildId, role) {
// Identifiant Discord numérique uniquement (pas de clé arbitraire comme "__proto__")
if (typeof guildId !== "string" || !/^\d{1,25}$/.test(guildId)) {
clog.warn("Impossible de définir un rôle sécurisé sans identifiant de serveur");
return false;
}
checkKey(guildId);
ServerDB.getData()[guildId].secureRole = roleId;
ServerDB.getData()[guildId].secureRole = role || null;
ServerDB.save();
clog.log(`Rôle sécurisé du serveur ${guildId} : ${role?.name ?? "aucun"}`);
process.emit("USERS_UPDATE")
return true;
}
async function checkKey(guildId) {
function checkKey(guildId) {
const data = ServerDB.getData();
if (!data[guildId]) {
data[guildId] = { secureRole: null };
ServerDB.save();
}
}
module.exports = {
+12 -8
View File
@@ -2,6 +2,9 @@ const { LogType } = require("loguix");
const plog = new LogType('Lyrics');
const urls = require('./urls.json');
// Délai maximal de chaque requête : sans réponse du fournisseur, /PLAYER/LYRICS restait en attente indéfiniment
const REQUEST_TIMEOUT = 8000
// Make sure Url exists and get lyrics for the first item only
async function getLyrics(name) {
let result = null;
@@ -10,7 +13,8 @@ async function getLyrics(name) {
method: 'GET',
headers: {
'content-type': 'application/json'
}
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
});
const searchData = await searchResponse.json();
@@ -26,18 +30,18 @@ async function getLyrics(name) {
method: 'GET',
headers: {
'content-type': 'application/json'
}
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
});
const lyricsData = await lyricsResponse.json();
console.log(lyricsData);
if (lyricsData && lyricsData && lyricsData.lyrics) {
if (lyricsData && lyricsData.lyrics) {
result = lyricsData.lyrics;
} else {
plog.error('Invalid response structure:', lyricsData);
plog.error('Invalid response structure: ' + JSON.stringify(lyricsData).slice(0, 200));
return null;
}
} catch (error) {
plog.error('Error fetching lyrics data:', error);
plog.error('Error fetching lyrics data: ' + (error?.message || error));
return null;
}
} else {
@@ -49,10 +53,10 @@ async function getLyrics(name) {
return null;
}
} catch (error) {
plog.error('Error fetching search data:', error);
plog.error('Error fetching search data: ' + (error?.message || error));
return null;
}
return result;
}
module.exports = { getLyrics };
module.exports = { getLyrics };
+116 -4
View File
@@ -7,19 +7,131 @@
const { __glob } = require("./utils/GlobalVars")
require("loguix").setup(__glob.LOGS, __glob.PACKAGEINFO)
setupProcessHandlers()
// Métriques : chargement unique et nettoyage de metrics.json (remplace webmetrik)
const SafeMetric = require("./utils/SafeMetric")
SafeMetric.init()
const config = require("./utils/Database/Configuration")
const metric = require("webmetrik")
metric.setMetricFile(__glob.METRIC_FILE)
metric.publishMetrics("8001", "subsonicsMetricsRaph")
SafeMetric.publish(8001, process.env.METRICS_KEY || "subsonicsMetricsRaph")
// SETUP
setup();
setup().catch((error) => {
// Démarrage impossible : on quitte pour que Docker relance le conteneur
safeLogError("Échec du démarrage : " + (error?.stack || error))
shutdown("SETUP_ERROR", 1)
});
// Fin du chargement synchrone de main.js (voir le handler uncaughtException)
mainLoaded = true
async function setup() {
await require("./utils/WarpProxy").start()
// Module YouTube : migration des cookies, binaire yt-dlp, mises à jour automatiques (ne bloque jamais le démarrage)
await require("./youtube").init()
const DiscordBot = require("./discord/Bot")
await DiscordBot.init()
const Server = require("./server/Server")
await Server.init()
}
// GESTION DU PROCESS (erreurs globales et arrêt propre)
// var (hissée) : vaut undefined tant que le code synchrone de main.js n'est pas allé au bout
var mainLoaded
var processLog = null
function getProcessLog() {
if(!processLog) processLog = new (require("loguix").LogType)("Process")
return processLog
}
/**
* Journalise sans jamais lever (l'écriture du log elle-même peut échouer)
*/
function safeLogError(text, level = "error") {
try {
getProcessLog()[level](text)
} catch (e) {
console.error("[Process] - [" + level.toUpperCase() + "] - " + text)
}
}
/**
* Handlers globaux : une erreur isolée est journalisée sans tuer le process.
* En cas de rafale d'exceptions (état probablement incohérent), on quitte pour que Docker relance.
*/
function setupProcessHandlers() {
// loguix enregistre son propre handler 'uncaughtException' qui FERME son flux de logs :
// après la première exception plus rien n'était écrit dans logs/. On le retire.
process.removeAllListeners("uncaughtException")
const MAX_EXCEPTIONS = 10
const EXCEPTION_WINDOW = 60 * 1000
var exceptionTimes = []
var handling = false
process.on("uncaughtException", (error, origin) => {
// Garde anti-réentrance : si journaliser provoque une nouvelle exception, on ne boucle pas
if(handling) {
console.error(error)
return
}
handling = true
try {
safeLogError("Exception non capturée (" + origin + ") : " + (error?.stack || error))
// Erreur pendant le chargement initial (config, modules…) : démarrage impossible, on quitte
if(!mainLoaded) {
shutdown("STARTUP_EXCEPTION", 1)
return
}
const now = Date.now()
exceptionTimes = exceptionTimes.filter(t => now - t < EXCEPTION_WINDOW)
exceptionTimes.push(now)
if(exceptionTimes.length >= MAX_EXCEPTIONS) {
safeLogError(`${MAX_EXCEPTIONS} exceptions en moins de ${EXCEPTION_WINDOW / 1000} s : arrêt du process`)
shutdown("UNCAUGHT_EXCEPTION", 1)
}
} finally {
handling = false
}
})
process.on("unhandledRejection", (reason) => {
safeLogError("Promesse rejetée non gérée : " + (reason?.stack || reason))
})
// Node est PID 1 sous Docker : sans handler, SIGTERM est ignoré et docker stop finit en SIGKILL
process.on("SIGTERM", () => shutdown("SIGTERM", 0))
process.on("SIGINT", () => shutdown("SIGINT", 0))
}
var shuttingDown = false
/**
* Arrêt propre : sauvegarde des métriques, déconnexion de Discord, puis sortie
* @param {string} reason
* @param {number} code
*/
function shutdown(reason, code) {
if(shuttingDown) {
// Second signal : sortie immédiate
process.exit(code)
}
shuttingDown = true
process.exitCode = code
safeLogError("Arrêt du process demandé (" + reason + ")", code === 0 ? "warn" : "error")
// Délai maximal : on ne reste jamais bloqué dans l'arrêt
setTimeout(() => process.exit(code), 3000)
try {
require("./utils/SafeMetric").flush()
} catch (e) {
safeLogError("Sauvegarde des métriques impossible : " + (e?.stack || e))
}
// Les bases JSON sont écrites de façon synchrone à chaque modification : rien à vider
Promise.resolve()
.then(() => {
// Uniquement si le bot a déjà été chargé (pas de require qui le démarrerait)
const botPath = require.resolve("./discord/Bot")
if(require.cache[botPath]) return require("./discord/Bot").getClient().destroy()
})
.catch((e) => safeLogError("Déconnexion de Discord impossible : " + (e?.stack || e)))
.finally(() => process.exit(code))
}
+15 -8
View File
@@ -1,10 +1,23 @@
const ffprobe = require('ffprobe');
const ffprobeStatic = require('ffprobe-static');
const { getReadableDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
const clog = require("loguix").getInstance("Song")
const path = require('path');
const fs = require('fs');
/**
* ffprobe renvoie la durée sous forme de CHAÎNE ("116.088163") : on la convertit en nombre de secondes.
* Sans conversion, les additions de durées deviennent des concaténations (cause des NaN dans les statistiques).
* Durée absente ou invalide → 0 (inconnue), pour que le morceau reste jouable.
*/
function applyDuration(instance, info) {
const raw = info?.streams?.[0]?.duration ?? info?.format?.duration;
const duration = toSafeDuration(parseFloat(raw));
instance.duration = duration;
instance.readduration = getReadableDuration(duration);
}
async function getMediaInformation(instance, media, provider) {
let tmpFile = null;
@@ -28,10 +41,7 @@ async function getMediaInformation(instance, media, provider) {
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
// 4. Récupérer les informations
if (info.streams?.[0]?.duration_ts) {
instance.duration = info.streams[0].duration;
instance.readduration = getReadableDuration(instance.duration);
}
applyDuration(instance, info);
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
@@ -76,10 +86,7 @@ async function getMediaInformationFromUrl(instance, url) {
// 3. Lancer ffprobe sur le fichier local
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
if (info.streams?.[0]?.duration_ts) {
instance.duration = info.streams[0].duration;
instance.readduration = getReadableDuration(instance.duration);
}
applyDuration(instance, info);
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
+11 -7
View File
@@ -4,6 +4,7 @@ const {Song} = require('../player/Song');
const {Playlist} = require('../playlists/Playlist');
const {Soundcloud} = require('soundcloud.ts')
const {getReadableDuration} = require('../utils/TimeConverter');
const {toSafeDuration} = require('../utils/SafeMetric');
const soundcloud = new Soundcloud();
@@ -22,8 +23,9 @@ async function getTrack(url) {
song.url = info.permalink_url;
song.thumbnail = info.artwork_url;
song.id = info.id;
song.duration = info.duration / 1000;
song.readduration = getReadableDuration(info.duration / 1000);
// Durée SoundCloud en millisecondes ; absente ou invalide → 0 (inconnue)
song.duration = toSafeDuration(Number(info.duration) / 1000);
song.readduration = getReadableDuration(song.duration);
song.type = "soundcloud";
return song;
@@ -56,18 +58,20 @@ async function getPlaylist(url) {
playlist.songs = [];
playlist.type = "soundcloud";
for(const track of info.tracks) {
for(const track of info.tracks || []) {
// Piste incomplète (supprimée / privée) : on l'ignore au lieu de faire échouer toute la playlist
if(!track || !track.permalink_url) continue;
const song = new Song();
song.title = track.title;
song.author = track.user.username;
song.author = track.user?.username ?? song.author;
song.url = track.permalink_url;
song.thumbnail = track.artwork_url;
song.id = track.id;
song.duration = track.duration / 1000;
song.readduration = getReadableDuration(track.duration / 1000);
song.duration = toSafeDuration(Number(track.duration) / 1000);
song.readduration = getReadableDuration(song.duration);
song.type = "soundcloud";
playlist.duration += track.duration / 1000;
playlist.duration += song.duration;
playlist.songs.push(song);
}
+199 -77
View File
@@ -1,26 +1,107 @@
const {LogType} = require('loguix');
const {LogType} = require('loguix');
const clog = new LogType("SpotifyInformation");
const config = require('../utils/Database/Configuration');
const SPOTIFY_CLIENT_ID = config.getSpotifyClientId()
const SPOTIFY_CLIENT_SECRET = config.getSpotifyClientSecret()
const SpotifyWebApi = require('spotify-web-api-node');
const {Playlist} = require('../playlists/Playlist');
const {Song} = require('../player/Song');
const youtube = require("../media/YoutubeInformation");
const {getReadableDuration} = require('../utils/TimeConverter');
const {toSafeDuration} = require('../utils/SafeMetric');
const {mapLimit} = require('../utils/Limiter');
const spotifyApi = new SpotifyWebApi({
clientId: SPOTIFY_CLIENT_ID,
clientSecret: SPOTIFY_CLIENT_SECRET,
});
const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
// Chaque titre déclenche une recherche YouTube : on borne la taille des imports
const MAX_TRACKS = 500;
// Conversions Spotify → YouTube simultanées (le limiteur YouTube partagé reste le plafond global)
const CONVERSION_CONCURRENCY = 3;
// Identifiants lus au moment de demander un jeton : une modification depuis l'admin s'applique sans redémarrage
const spotifyApi = new SpotifyWebApi();
// Le jeton client est valable une heure : on le garde en cache au lieu d'en redemander un à chaque requête
let tokenExpiresAt = 0;
// Identifiants ayant servi à obtenir le jeton courant (détecte un changement de configuration)
let tokenCredentials = null;
// Demande de jeton en cours, partagée par les appels simultanés
let tokenPromise = null;
function resetToken() {
tokenExpiresAt = 0;
tokenCredentials = null;
spotifyApi.resetAccessToken();
}
// Configuration modifiée (admin) : le prochain appel redemandera un jeton avec les nouveaux identifiants
process.on("CONFIG_UPDATE", resetToken);
async function ensureToken() {
const clientId = config.getSpotifyClientId();
const clientSecret = config.getSpotifyClientSecret();
if(!clientId || !clientSecret) {
throw new Error("Identifiants Spotify non configurés (api.spotify.clientId / clientSecret)");
}
const credentials = clientId + ":" + clientSecret;
if(tokenCredentials !== null && tokenCredentials !== credentials) resetToken();
if(Date.now() < tokenExpiresAt) return;
if(!tokenPromise) {
tokenPromise = (async () => {
spotifyApi.setClientId(clientId);
spotifyApi.setClientSecret(clientSecret);
const data = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(data.body['access_token']);
const expiresIn = Number(data.body['expires_in']) || 3600;
// Marge d'une minute avant l'expiration réelle
tokenExpiresAt = Date.now() + Math.max(0, expiresIn - 60) * 1000;
tokenCredentials = credentials;
})().finally(() => { tokenPromise = null; });
}
await tokenPromise;
}
/**
* Un titre Spotify est exploitable s'il a un nom et un artiste.
* Les titres supprimés ou indisponibles valent null, les épisodes de podcast n'ont pas d'artistes.
*/
function isValidTrack(track) {
return !!track && typeof track.name === "string" && track.name.length > 0
&& Array.isArray(track.artists) && !!track.artists[0]?.name;
}
/**
* Récupère les pages suivantes d'une liste paginée Spotify (playlist ou album)
* @param {Object} firstPage Première page ({items, next, total})
* @param {(offset: number) => Promise<Object>} fetchPage Renvoie la page commençant à offset
* @param {(item: Object) => Object} mapItem Transforme un élément de page en titre
*/
async function collectAllTracks(firstPage, fetchPage, mapItem) {
const tracks = [];
let page = firstPage;
while (page && Array.isArray(page.items)) {
for (const item of page.items) {
if (tracks.length >= MAX_TRACKS) break;
tracks.push(mapItem(item));
}
if (!page.next || tracks.length >= MAX_TRACKS || page.items.length === 0) break;
try {
page = await fetchPage((page.offset ?? 0) + page.items.length);
} catch (error) {
clog.warn("Impossible de récupérer la suite des titres Spotify, import partiel : " + error);
break;
}
}
if (tracks.length >= MAX_TRACKS && page?.total > MAX_TRACKS) {
clog.warn(`Import Spotify limité aux ${MAX_TRACKS} premiers titres (sur ${page.total})`);
}
return tracks;
}
async function getSong(url) {
try {
const data = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(data.body['access_token']);
await ensureToken();
const parts = url.split('/');
const trackId = parts[parts.length - 1];
const trackId = parts[parts.length - 1]?.split('?')[0];
if(!trackId) {
clog.error("Impossible de récupérer l'identifiant de la piste Spotify à partir de l'URL");
@@ -28,12 +109,17 @@ async function getSong(url) {
}
const trackInfo = await spotifyApi.getTrack(trackId);
if(!isValidTrack(trackInfo?.body)) {
clog.error("La piste Spotify est indisponible ou incomplète : " + trackId);
return null;
}
const trackName = trackInfo.body.name;
const artistName = trackInfo.body.artists[0].name;
return `${trackName} - ${artistName}`;
} catch (error) {
clog.error("Impossible de récupérer les informations de la piste Spotify à partir de l'URL");
clog.error(error);
return null;
@@ -43,10 +129,8 @@ async function getSong(url) {
async function getAlbum(url) {
try {
const creditdata = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(creditdata.body['access_token']);
await ensureToken();
const parts = url.split('/');
const albumId = parts[parts.indexOf('album') + 1].split('?')[0];
@@ -59,42 +143,46 @@ async function getAlbum(url) {
return null;
}
clog.log("Informations de l'album récupérées : " + info.name);
clog.log("Informations de l'album récupérées : " + info.name);
const playlist = new Playlist()
const playlist = new Playlist()
playlist.title = info.name;
playlist.author = info.artists[0].name;
playlist.authorId = info.artists[0].id;
playlist.thumbnail = info.images[0].url;
playlist.url = info.external_urls.spotify;
playlist.author = info.artists?.[0]?.name ?? "Auteur inconnu";
playlist.authorId = info.artists?.[0]?.id;
playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
playlist.url = info.external_urls?.spotify;
playlist.id = albumId;
playlist.type = "spotify";
playlist.songs = info.tracks.items;
// Un album ne renvoie que ses 50 premiers titres : on récupère les pages suivantes
playlist.songs = await collectAllTracks(
info.tracks,
async (offset) => (await spotifyApi.getAlbumTracks(albumId, { offset, limit: 50 })).body,
(item) => item
);
return playlist;
} catch (error) {
clog.error("Impossible de récupérer les informations de l'album Spotify à partir de l'URL");
clog.error(error);
return null;
}
}
async function getPlaylist(url) {
// Get the playlist and return a Playlist Object
try {
const creditdata = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(creditdata.body['access_token']);
await ensureToken();
const parts = url.split('/');
const playlistId = parts[parts.indexOf('playlist') + 1].split('?')[0];
const data = await spotifyApi.getPlaylist(playlistId)
const info = data.body;
if(!info) {
@@ -102,78 +190,112 @@ async function getPlaylist(url) {
return null;
}
clog.log("Informations de la playlist récupérées : " + info.name);
clog.log("Informations de la playlist récupérées : " + info.name);
const playlist = new Playlist()
const playlist = new Playlist()
playlist.title = info.name;
playlist.author = info.owner.display_name;
playlist.authorId = info.owner.id;
playlist.thumbnail = info.images[0].url;
playlist.url = info.external_urls.spotify;
playlist.author = info.owner?.display_name ?? "Auteur inconnu";
playlist.authorId = info.owner?.id;
playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
playlist.url = info.external_urls?.spotify;
playlist.id = playlistId;
playlist.type = "spotify";
for(const track of info.tracks.items) {
playlist.songs.push(track.track);
}
// Une playlist ne renvoie que ses 100 premiers titres : on récupère les pages suivantes
// (les titres null — supprimés, indisponibles — sont filtrés plus tard par getTracks)
playlist.songs = await collectAllTracks(
info.tracks,
async (offset) => (await spotifyApi.getPlaylistTracks(playlistId, { offset, limit: 100 })).body,
(item) => item?.track ?? null
);
return playlist;
} catch (error) {
clog.error("Impossible de récupérer les informations de l'album Spotify à partir de l'URL");
clog.error("Impossible de récupérer les informations de la playlist Spotify à partir de l'URL");
clog.error(error);
return null;
}
}
/**
* Convertit les titres Spotify d'une playlist en musiques YouTube jouables.
* Modifie la playlist (songs, duration, readduration) et la renvoie, même en cas d'erreur.
* @param {Playlist} playlist
* @returns {Promise<Playlist>}
*/
async function getTracks(playlist) {
const tracks = playlist.songs
playlistSongs = [];
for(const track of tracks) {
var trackName = track.name;
var artistName = track.artists[0].name;
var queryForYoutube = `${trackName} - ${artistName}`;
const tracks = Array.isArray(playlist?.songs) ? playlist.songs : [];
var urlYoutubeFounded = await youtube.getQuery(queryForYoutube).then(function(songFind) {
if(!songFind) return null;
return songFind.url;
});
clog.log("URL de la vidéo YouTube trouvée : " + urlYoutubeFounded);
// Concurrence bornée, ordre conservé ; un titre en erreur vaut null au lieu de faire échouer tout l'import
const converted = await mapLimit(tracks, CONVERSION_CONCURRENCY, (track) => convertTrack(track, playlist), (error) => {
clog.error("Erreur lors de la conversion d'un titre Spotify : " + error);
return null;
});
if(!urlYoutubeFounded) {
clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube);
} else {
const song = new Song();
song.title = track.name;
song.author = track.artists[0].name;
song.url = urlYoutubeFounded;
song.thumbnail = playlist.thumbnail;
song.id = track.id;
song.duration = track.duration_ms / 1000;
song.readduration = getReadableDuration(track.duration_ms / 1000);
song.type = "youtube";
playlist.duration += track.duration_ms / 1000;
playlistSongs.push(song);
}
// When finish do this
// Tableau local : deux imports simultanés ne doivent jamais partager leurs titres
const playlistSongs = converted.filter(Boolean);
const totalDuration = playlistSongs.reduce((acc, song) => acc + toSafeDuration(song.duration), 0);
if(tracks.length > 0) {
clog.log(`Conversion Spotify → YouTube : ${playlistSongs.length}/${tracks.length} titres pour « ${playlist?.title} »`);
}
playlist.readduration = getReadableDuration(playlist.duration);
if(playlist) {
playlist.songs = playlistSongs;
playlist.duration = totalDuration;
playlist.readduration = getReadableDuration(totalDuration);
}
return playlist;
}
return playlist;
/**
* Convertit un titre Spotify en musique YouTube jouable
* @param {Object} track Titre Spotify (ou musique déjà convertie)
* @param {Playlist} playlist Playlist d'origine (miniature par défaut)
* @returns {Promise<Song|null>} null si le titre est ignoré ou introuvable
*/
async function convertTrack(track, playlist) {
// Titre déjà converti (playlist rafraîchie ou déjà importée) : on le garde tel quel
if(track && track.type === "youtube" && track.url) {
return track;
}
if(!isValidTrack(track)) {
clog.warn("Titre Spotify ignoré (supprimé, indisponible ou épisode de podcast)");
return null;
}
const trackName = track.name;
const artistName = track.artists[0].name;
const queryForYoutube = `${trackName} - ${artistName}`;
const songFind = await youtube.getQuery(queryForYoutube);
const urlYoutubeFounded = songFind ? songFind.url : null;
if(!urlYoutubeFounded) {
clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube);
return null;
}
const song = new Song();
const duration = toSafeDuration(Number(track.duration_ms) / 1000);
song.title = trackName;
song.author = artistName;
song.url = urlYoutubeFounded;
song.thumbnail = track.album?.images?.[0]?.url ?? playlist?.thumbnail ?? song.thumbnail;
song.id = track.id ?? songFind.id;
song.duration = duration;
song.readduration = getReadableDuration(duration);
song.type = "youtube";
return song;
}
module.exports = {getSong, getAlbum, getPlaylist, getTracks}
module.exports = {getSong, getAlbum, getPlaylist, getTracks}
+248 -52
View File
@@ -2,9 +2,55 @@ const { LogType } = require('loguix');
const clog = new LogType("YoutubeInformation");
const { Song } = require('../player/Song');
const { Playlist } = require('../playlists/Playlist');
const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter');
const { getReadableDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
const { createLimiter, createCache } = require('../utils/Limiter');
const yts = require("yt-search")
const ytfps = require('ytfps');
// Les métadonnées sont récupérées en scrapant youtube.com depuis la même IP que yt-dlp :
// on limite les rafales (concurrence + espacement) et on met les réponses en cache pour éviter les blocages
const youtubeLimiter = createLimiter(2, { minIntervalMs: 250 });
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const videoCache = createCache({ ttlMs: 6 * HOUR, max: 2000 }); // videoId → vidéo yt-search
const queryCache = createCache({ ttlMs: 30 * MINUTE, max: 500 }); // requête → vidéos yt-search
const listCache = createCache({ ttlMs: 10 * MINUTE, max: 100 }); // listId → playlist yt-search
const avatarCache = createCache({ ttlMs: 24 * HOUR, max: 1000 }); // URL de chaîne → URL de l'avatar
// Un échec de récupération d'avatar est retenu moins longtemps pour retenter plus tard
const AVATAR_FAILURE_TTL = HOUR;
const AVATAR_TIMEOUT_MS = 8000;
// En-têtes d'un navigateur de bureau réel ; SOCS/CONSENT évitent la page de consentement (hôte dans l'UE)
const BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Cookie": "SOCS=CAI; CONSENT=YES+cb",
};
const VIDEO_ID_REGEX = /(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/;
/**
* Appel yt-search passant par le limiteur partagé
* @param {Object} options Options yt-search ({query} | {videoId} | {listId})
*/
function limitedYts(options) {
return youtubeLimiter.run(() => yts(options));
}
/**
* Vidéo yt-search par identifiant (cache 6 h, requêtes simultanées dédupliquées)
* @param {string} videoId
* @returns {Promise<Object|null>}
*/
function fetchVideo(videoId) {
return videoCache.wrap(videoId, async () => (await limitedYts({ videoId })) || null);
}
function normalizeQuery(query) {
return query.trim().replace(/\s+/g, " ").toLowerCase();
}
async function getQuery(query, multiple) {
if (!query || typeof query !== 'string') {
@@ -14,15 +60,26 @@ async function getQuery(query, multiple) {
try {
const limit = multiple ? 25 : 1;
const searchResults = await yts({ query: query, limit: limit });
const videos = searchResults.videos;
// yt-search ignore "limit" (une page de résultats quoi qu'il arrive) :
// le même scrape sert donc aux recherches simples et multiples
const videos = await queryCache.wrap(normalizeQuery(query), async () => {
const searchResults = await limitedYts({ query: query });
const found = Array.isArray(searchResults?.videos) ? searchResults.videos : [];
// Résultat vide non mis en cache (blocage passager possible)
return found.length > 0 ? found : null;
});
if (videos.length === 0) {
if (!videos || videos.length === 0) {
clog.error("Impossible de récupérer le lien de la vidéo YouTube à partir de la requête");
return null;
}
const songs = await Promise.all(videos.map(video => new Song().processYoutubeVideo(video)));
// Les résultats contiennent déjà durée / auteur : ils alimentent le cache des vidéos
for (const video of videos) {
if (video?.videoId && !videoCache.has(video.videoId)) videoCache.set(video.videoId, video);
}
const songs = await Promise.all(videos.slice(0, limit).map(video => new Song().processYoutubeVideo(video)));
return multiple ? songs.filter(song => song !== null) : songs[0];
} catch (error) {
clog.error('Erreur lors de la recherche YouTube: ' + error);
@@ -31,15 +88,16 @@ async function getQuery(query, multiple) {
}
async function getVideo(url) {
const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/);
const videoId = typeof url === "string" ? url.match(VIDEO_ID_REGEX) : null;
if (videoId === null) {
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
return null;
}
try {
const video = await yts({videoId: videoId[1]});
const video = await fetchVideo(videoId[1]);
if (video) {
// Nouvelle instance à chaque appel : la vidéo en cache n'est jamais modifiée par les appelants
const songReturn = new Song();
await songReturn.processYoutubeVideo(video);
return songReturn;
@@ -60,24 +118,18 @@ async function getPlaylist(url) {
}
try {
// If watch?v= is present in the url with list?=, remove it and the code behind and transform it to playlist?list=
var playlistId;
// Get &list= in the url until the first & or ?
// Identifiant après list= (jusqu'au premier caractère hors identifiant, & ou ?)
let playlistId;
if (url.includes("list=")) {
playlistId = url.match(/(list=)([a-zA-Z0-9_-]+)/);
}
if (playlistId === null) {
if (!playlistId) {
clog.error("Impossible de récupérer l'identifiant de la playlist YouTube à partir de l'URL");
return null;
}
const playlistInfo = await yts({ listId: playlistId[2] });
const playlistInfo = await listCache.wrap(playlistId[2], () => fetchPlaylistInfo(playlistId[2]));
if (!playlistInfo) {
clog.error("Impossible de récupérer la playlist YouTube à partir de l'identifiant");
@@ -86,19 +138,22 @@ async function getPlaylist(url) {
const playlist = new Playlist();
playlist.type = "youtube";
playlist.author = playlistInfo.author.name;
playlist.authorId = playlistInfo.author.url;
playlist.authorAvatar = await getYouTubeProfilePicture(playlistInfo.author.url);
playlist.author = playlistInfo.author?.name ?? playlist.author;
playlist.authorId = playlistInfo.author?.url;
// Avatar déjà présent dans la page de la playlist : évite une requête vers la chaîne
playlist.authorAvatar = playlistInfo.authorAvatar || await getYouTubeProfilePicture(playlistInfo.author?.url);
playlist.title = playlistInfo.title;
playlist.thumbnail = playlistInfo.thumbnail;
playlist.url = `https://www.youtube.com/playlist?list=${playlistId[2]}`;
playlist.id = playlistInfo.listId;
playlist.views = playlistInfo.views;
for (const video of playlistInfo.videos) {
for (const video of playlistInfo.videos || []) {
if (video?.videoId && !videoCache.has(video.videoId)) videoCache.set(video.videoId, video);
const song = new Song();
await song.processYoutubeVideo(video);
playlist.duration += song.duration;
// Live ou durée inconnue → 0 : la somme reste toujours un nombre
playlist.duration += toSafeDuration(song.duration);
playlist.songs.push(song);
}
playlist.readduration = getReadableDuration(playlist.duration);
@@ -109,16 +164,130 @@ async function getPlaylist(url) {
}
}
/**
* Récupère une playlist au format yt-search ({title, listId, thumbnail, views, author, videos}).
* yt-search ne sait plus lire la nouvelle mise en page des playlists (lockupViewModel) :
* on lit d'abord la page nous-mêmes (une seule requête), yt-search ne sert que de repli.
* @param {string} listId
* @returns {Promise<Object|null>}
*/
async function fetchPlaylistInfo(listId) {
try {
const info = await fetchPlaylistPage(listId);
if (info) return info;
} catch (error) {
clog.warn(`Lecture directe de la playlist YouTube ${listId} impossible, repli sur yt-search : ${error?.message || error}`);
}
return (await limitedYts({ listId })) || null;
}
/**
* Parcourt récursivement un objet JSON et renvoie toutes les valeurs de la clé donnée
*/
function findAllByKey(root, key, out = []) {
if (Array.isArray(root)) {
for (const item of root) findAllByKey(item, key, out);
} else if (root && typeof root === "object") {
for (const k of Object.keys(root)) {
if (k === key) out.push(root[k]);
else findAllByKey(root[k], key, out);
}
}
return out;
}
/**
* "4:01" / "1:02:03" → secondes ; texte non numérique (LIVE, PREMIERE…) → 0
*/
function parseTimestamp(text) {
if (typeof text !== "string" || !/^\d+(:\d{1,2}){0,2}$/.test(text.trim())) return 0;
return text.trim().split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
}
/**
* URL absolue de chaîne à partir d'une commande de navigation YouTube
*/
function channelUrlFromRuns(textObject) {
const endpoint = textObject?.commandRuns?.[0]?.onTap?.innertubeCommand?.browseEndpoint;
if (endpoint?.canonicalBaseUrl) return "https://www.youtube.com" + endpoint.canonicalBaseUrl;
if (endpoint?.browseId) return "https://www.youtube.com/channel/" + endpoint.browseId;
return undefined;
}
/**
* Lit la page d'une playlist (100 premières vidéos, comme yt-search)
* @param {string} listId
* @returns {Promise<Object|null>} null si la mise en page n'est pas reconnue
*/
async function fetchPlaylistPage(listId) {
const url = `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}&hl=en&gl=US`;
const html = await youtubeLimiter.run(async () => {
// Page en anglais : textes ("by …", "… views") et titres non traduits, comme yt-search
const res = await fetch(url, {
headers: { ...BROWSER_HEADERS, "Accept-Language": "en-US,en;q=0.9" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
});
const match = html.match(/ytInitialData\s*=\s*({.*?});\s*<\/script>/s);
if (!match) return null;
const data = JSON.parse(match[1]);
const videos = [];
for (const lockup of findAllByKey(data, "lockupViewModel")) {
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO" || !lockup.contentId) continue;
const meta = lockup.metadata?.lockupMetadataViewModel;
// Vidéos privées / supprimées : présentes dans la liste mais illisibles
if (/^\[(private|deleted) video\]$/i.test(meta?.title?.content ?? "")) continue;
const authorText = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text;
const badge = findAllByKey(lockup.contentImage, "thumbnailBadgeViewModel")[0];
const seconds = parseTimestamp(badge?.text);
videos.push({
title: meta?.title?.content ?? "Aucun titre",
videoId: lockup.contentId,
listId,
thumbnail: `https://i.ytimg.com/vi/${lockup.contentId}/hqdefault.jpg`,
duration: { seconds, timestamp: badge?.text ?? "" },
author: { name: authorText?.content ?? "Auteur inconnu", url: channelUrlFromRuns(authorText) },
});
}
if (videos.length === 0) return null;
const header = findAllByKey(data, "pageHeaderViewModel")[0];
const headerRows = header?.metadata?.contentMetadataViewModel?.metadataRows || [];
const ownerPart = headerRows[0]?.metadataParts?.[0];
const ownerStack = ownerPart?.avatarStack?.avatarStackViewModel;
const ownerText = ownerPart?.text ?? ownerStack?.text;
const viewsText = headerRows.flatMap(row => row.metadataParts || []).map(part => part?.text?.content || "").find(text => /views?$/i.test(text));
const thumbnails = data.microformat?.microformatDataRenderer?.thumbnail?.thumbnails;
return {
title: data.metadata?.playlistMetadataRenderer?.title ?? header?.title?.dynamicTextViewModel?.text?.content ?? "Aucun titre",
listId,
url: `https://www.youtube.com/playlist?list=${listId}`,
thumbnail: thumbnails?.[thumbnails.length - 1]?.url ?? videos[0].thumbnail,
views: viewsText ? Number(viewsText.replace(/\D/g, "")) || 0 : 0,
author: {
name: (ownerText?.content ?? "Auteur inconnu").replace(/^by\s+/i, ""),
url: channelUrlFromRuns(ownerText),
},
authorAvatar: ownerStack?.avatars?.[0]?.avatarViewModel?.image?.sources?.[0]?.url ?? null,
videos,
};
}
async function getSecondsFromUrl(url) {
const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/);
const videoId = typeof url === "string" ? url.match(VIDEO_ID_REGEX) : null;
if (videoId === null) {
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
return null;
}
try {
const video = await yts({ videoId: videoId[1] });
const video = await fetchVideo(videoId[1]);
if (video) {
return video.duration.seconds;
// 0 pour un live ou une durée invalide (null reste réservé à l'échec de récupération)
return toSafeDuration(video.duration?.seconds);
} else {
clog.error("Impossible de récupérer la vidéo YouTube à partir de l'identifiant");
return null;
@@ -129,36 +298,63 @@ async function getSecondsFromUrl(url) {
}
}
/**
* Extrait l'avatar d'une chaîne depuis le HTML de sa page
* @param {string} html
* @returns {string|null}
*/
function extractAvatar(html) {
// Balise img avec la classe yt-spec-avatar-shape__image
const imgRegex = /<img[^>]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i;
const imgMatch = html.match(imgRegex);
if (imgMatch && imgMatch[1]) {
return imgMatch[1];
}
// Sinon : avatar dans le JSON embarqué
const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/;
const match = html.match(jsonRegex);
if (match && match[1]) {
return match[1].replace(/\\u0026/g, "&");
}
return null;
}
/**
* Avatar d'une chaîne YouTube (cache 24 h, échecs retenus 1 h)
* @param {string} channelUrl URL de la chaîne (https://www.youtube.com/...)
* @returns {Promise<string|null>}
*/
async function getYouTubeProfilePicture(channelUrl) {
let parsed;
try {
const res = await fetch(channelUrl, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
});
const html = await res.text();
// Match img with yt-spec-avatar-shape__image in class list
const imgRegex = /<img[^>]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i;
const imgMatch = html.match(imgRegex);
if (imgMatch && imgMatch[1]) {
return imgMatch[1];
}
// Fallback: look for avatar in embedded JSON
const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/;
const match = html.match(jsonRegex);
if (match && match[1]) {
return match[1].replace(/\\u0026/g, "&"); // Decode \u0026 to &
}
console.warn("Photo non trouvée pour :", channelUrl);
return null;
} catch (err) {
console.error("Erreur :", err);
parsed = new URL(channelUrl);
} catch {
return null;
}
// Uniquement des pages youtube.com en http(s) (l'URL vient d'un scrape)
if (!/^https?:$/.test(parsed.protocol) || !/(^|\.)youtube\.com$/i.test(parsed.hostname) || parsed.pathname.endsWith("/undefined")) {
return null;
}
return avatarCache.wrap(parsed.href, async () => {
try {
const html = await youtubeLimiter.run(async () => {
const res = await fetch(parsed.href, {
headers: BROWSER_HEADERS,
signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
});
const avatar = extractAvatar(html);
if (!avatar) clog.warn("Photo de profil introuvable pour la chaîne : " + parsed.href);
return avatar;
} catch (error) {
clog.warn("Impossible de récupérer la photo de profil de la chaîne " + parsed.href + " : " + (error?.message || error));
return null;
}
}, { ttlFor: (value) => value ? 24 * HOUR : AVATAR_FAILURE_TTL });
}
module.exports = { getQuery, getVideo, getPlaylist, getSecondsFromUrl };
+49 -8
View File
@@ -1,10 +1,41 @@
const {LogType} = require('loguix');
const clog = new LogType("Finder");
const Resolver = require('../utils/Resolver');
const { QueryType } = require('../utils/QueryType');
const { Links } = require('../utils/Links');
const youtube = require("../media/YoutubeInformation")
const spotify = require("../media/SpotifyInformation")
const soundcloud = require("../media/SoundcloudInformation")
// Titres Spotify convertis pour un simple résultat de recherche
const SEARCH_PREVIEW_TRACKS = 25
/**
* Récupère une playlist ou un album Spotify et convertit ses titres en musiques YouTube jouables
* (les appelants reçoivent des Song et non des titres Spotify bruts, sans url ni type).
* @param {"playlist"|"album"} kind
* @param {string} url
* @param {number} [maxTracks] - 0 = tous ; sinon aperçu limité (recherche) pour éviter une rafale de requêtes YouTube
* @returns {Promise<Playlist|null>} null si introuvable, ou si aucun titre n'a pu être converti
*/
async function getSpotifyCollection(kind, url, maxTracks = 0) {
const playlist = kind === "album" ? await spotify.getAlbum(url) : await spotify.getPlaylist(url)
if(!playlist) return null
if(maxTracks > 0 && Array.isArray(playlist.songs) && playlist.songs.length > maxTracks) {
playlist.totalTracks = playlist.songs.length
playlist.truncated = true
playlist.songs = playlist.songs.slice(0, maxTracks)
}
const trackCount = Array.isArray(playlist.songs) ? playlist.songs.length : 0
// getTracks est idempotent : les appelants qui le rappellent ensuite ne relancent aucune recherche
await spotify.getTracks(playlist)
if(trackCount > 0 && playlist.songs.length === 0) {
// Échec total (YouTube indisponible ou bloqué) : on ne renvoie pas une playlist vide
// (un rafraîchissement écraserait sinon la playlist enregistrée)
clog.error(`Aucun titre de « ${playlist.title} » n'a pu être converti depuis Spotify`)
return null
}
return playlist
}
async function search(query, multiple, forceType) {
if(!query) return null
@@ -12,7 +43,9 @@ async function search(query, multiple, forceType) {
if(!forceType) forceType = null
if(forceType == "PLAYLIST") {
if(query.includes("spotify")) {
return await spotify.getPlaylist(query)
// Lien d'album collé dans "playlist" : même conversion
const kind = /\/album\//.test(query) ? "album" : "playlist"
return await getSpotifyCollection(kind, query)
} else if(query.includes("soundcloud")) {
return await soundcloud.getPlaylist(query)
} else if(query.includes("youtube")) {
@@ -24,10 +57,15 @@ async function search(query, multiple, forceType) {
if(forceType == "SONG") {
if(query.includes("spotify")) {
return await spotify.getSong(query)
// getSong renvoie "titre - artiste" : on cherche la musique YouTube correspondante
const spotifyQuery = await spotify.getSong(query)
return spotifyQuery ? await youtube.getQuery(spotifyQuery, multiple) : null
} else if(query.includes("soundcloud")) {
return await soundcloud.getTrack(query)
} else if(query.includes("youtube")) {
if(Resolver.getQueryType(query) == QueryType.YOUTUBE_VIDEO) {
return await youtube.getVideo(query)
}
return await youtube.getQuery(query, multiple)
} else {
return null
@@ -36,7 +74,7 @@ async function search(query, multiple, forceType) {
if(forceType == "ALBUM") {
if(query.includes("spotify")) {
return await spotify.getAlbum(query)
return await getSpotifyCollection("album", query)
} else if(query.includes("youtube")) {
return await youtube.getQuery(query, multiple)
} else {
@@ -58,14 +96,17 @@ async function search(query, multiple, forceType) {
return await youtube.getPlaylist(query)
}
if(type == QueryType.SPOTIFY_SONG) {
return await youtube.getQuery(await spotify.getSong(query))
const spotifyQuery = await spotify.getSong(query)
return spotifyQuery ? await youtube.getQuery(spotifyQuery) : null
}
// Recherche (multiple) : simple aperçu ; l'import complet passe par forceType "PLAYLIST"
const preview = multiple ? SEARCH_PREVIEW_TRACKS : 0
if(type == QueryType.SPOTIFY_ALBUM) {
return await spotify.getAlbum(query)
return await getSpotifyCollection("album", query, preview)
}
if(type == QueryType.SPOTIFY_PLAYLIST) {
return await spotify.getPlaylist(query)
return await getSpotifyCollection("playlist", query, preview)
}
if(type == QueryType.SOUNDCLOUD_TRACK) {
@@ -79,4 +120,4 @@ async function search(query, multiple, forceType) {
//MORELATER: Add more providers
}
module.exports = {search}
module.exports = {search}
+125 -67
View File
@@ -1,36 +1,63 @@
const { Database } = require('../utils/Database/Database')
const { __glob } = require('../utils/GlobalVars')
const PreviousDB = new Database("previous", __glob.PREVIOUSFILE, {})
const PreviousDB = new Database("previous", __glob.PREVIOUSFILE, {})
const {LogType} = require("loguix")
const clog = new LogType("List")
const { Song } = require('./Song')
const AllLists = new Map() // Map<guildId, List>
// Taille max de l'historique par guilde
const MAX_PREVIOUS = 50
// Écriture différée de l'historique (une rafale de skips = une seule écriture disque)
const SAVE_DELAY = 1000
class List {
next;
current;
shuffle;
guildId;
// Appelé à chaque modification (branché par le Player sur markDirty)
onChange = null;
constructor(guildId) {
if(guildId === null) {
if(!guildId) {
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;
// Création paresseuse de l'entrée d'historique (voir getPreviousArray)
}
/**
* Signale une modification au Player (émission d'état regroupée)
*/
changed() {
if(typeof this.onChange !== "function") return
try {
this.onChange()
} catch(e) {
clog.error("Erreur lors de la notification de modification de la liste : " + (e?.message || e))
}
}
/**
* Historique de la guilde (créé si absent)
* @returns {Array}
*/
getPreviousArray() {
if(!Array.isArray(PreviousDB.data[this.guildId])) {
PreviousDB.data[this.guildId] = new Array()
}
return PreviousDB.data[this.guildId]
}
getNext() {
return this.next;
}
@@ -41,7 +68,7 @@ class List {
} else {
return null;
}
}
nextSong() {
@@ -56,45 +83,51 @@ class List {
const randomIndex = Math.floor(Math.random() * this.next.length);
song = this.next[randomIndex]
this.next.splice(randomIndex, 1)
}
this.setCurrent(song)
process.emit("PLAYERS_UPDATE")
return song
this.setCurrent(song ?? null)
return song ?? null
}
clearNext() {
this.next = new Array();
process.emit("PLAYERS_UPDATE")
this.changed()
}
addNextSong(song) {
if(!song) return
this.next.push(song)
process.emit("PLAYERS_UPDATE")
this.changed()
}
firstNext(song) {
if(!song) return
this.next.unshift(song)
process.emit("PLAYERS_UPDATE")
this.changed()
}
removeNextByIndex(index) {
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
this.next.splice(index, 1)
process.emit("PLAYERS_UPDATE")
this.changed()
return true
}
moveSongToUpNext(index) {
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
const song = this.next[index]
this.next.splice(index, 1)
this.next.unshift(song)
process.emit("PLAYERS_UPDATE")
this.changed()
return true
}
getPrevious() {
const previousList = new Array()
for(const song of PreviousDB.data[this.guildId]) {
for(const song of this.getPreviousArray()) {
if(!song) continue
previousList.push(new Song(song))
}
return previousList;
@@ -102,46 +135,49 @@ class List {
}
getPreviousSong() {
if(PreviousDB.data[this.guildId].length > 0) {
return new Song(PreviousDB.data[this.guildId][0])
const previous = this.getPreviousArray()
if(previous.length > 0 && previous[0]) {
return new Song(previous[0])
} else {
return null;
}
}
previousSong() {
const previous = this.getPreviousArray()
if(previous.length === 0) return null
if(this.current != null) {
this.firstNext(this.current)
}
if(PreviousDB.data[this.guildId].length > 0) {
const song = PreviousDB.data[this.guildId][0]
// Remove the song from the previous list
PreviousDB.data[this.guildId].splice(0, 1)
savePrevious()
return new Song(song)
} else {
return null;
}
const song = previous[0]
// Remove the song from the previous list
previous.splice(0, 1)
savePrevious()
this.changed()
return song ? new Song(song) : null
}
clearPrevious() {
PreviousDB.data[this.guildId] = new Array();
savePrevious();
process.emit("PLAYERS_UPDATE")
this.changed()
}
addPreviousSongToNextByIndex(index) {
const song = PreviousDB.data[this.guildId][index]
const song = this.getPreviousArray()[index]
if(!song) return false
this.next.push(song)
process.emit("PLAYERS_UPDATE")
this.changed()
return true
}
addPreviousSong(song) {
PreviousDB.data[this.guildId].unshift(song)
if(!song) return
this.getPreviousArray().unshift(song)
savePrevious()
process.emit("PLAYERS_UPDATE")
this.changed()
}
getCurrent() {
@@ -149,71 +185,72 @@ class List {
}
setCurrent(value) {
this.current = value;
process.emit("PLAYERS_UPDATE")
this.current = value ?? null;
this.changed()
}
destroy() {
this.clearNext();
this.next = new Array();
this.current = null
this.shuffle = false;
AllLists.delete(this.guildId)
process.emit("PLAYERS_UPDATE")
if(AllLists.get(this.guildId) === this) AllLists.delete(this.guildId)
this.changed()
this.onChange = null
}
setShuffle() {
this.shuffle = !this.shuffle;
this.changed()
}
isShuffle() {
return this.shuffle;
}
// Play the song with the index in the queue and delete it from the queue
// Retire et renvoie la musique d'index donné de la file (NEXT), ou la renvoie depuis l'historique (PREVIOUS)
playByIndex(index, typelist) {
var index = data[0]
var list = data[1]
if(typelist == ListType.NEXT) {
const song = this.next[index]
if(!song) return null
this.next.splice(index, 1)
this.changed()
return song
} else if(typelist == ListType.PREVIOUS) {
const song = PreviousDB.data[this.guildId][index]
return song
const song = this.getPreviousArray()[index]
return song ? new Song(song) : null
}
process.emit("PLAYERS_UPDATE")
return null
}
addNextPlaylist(playlist, firstAlreadyPlayed) {
if(firstAlreadyPlayed) {
playlist.songs.shift()
// Copie : la playlist enregistrée de l'utilisateur ne doit jamais être modifiée
const songs = Array.isArray(playlist?.songs) ? playlist.songs.slice(firstAlreadyPlayed ? 1 : 0) : []
for(const song of songs) {
if(song) this.next.push(song)
}
for(const song of playlist.songs) {
this.addNextSong(song)
}
process.emit("PLAYERS_UPDATE")
// Une seule notification pour toute la playlist
this.changed()
}
moveNext(fromIndex, toIndex) {
// Check if fromIndex and toIndex are valid
if(fromIndex < 0 || fromIndex >= this.next.length + 1 || toIndex < 0 || toIndex >= this.next.length + 1) {
if(!Number.isInteger(fromIndex) || !Number.isInteger(toIndex) || fromIndex < 0 || fromIndex >= this.next.length || toIndex < 0 || toIndex > this.next.length) {
clog.error("Impossible de déplacer la musique, car l'index est invalide, GuildId : " + this.guildId)
return
return false
}
if(fromIndex == toIndex) return;
if(fromIndex == toIndex) return true;
const song = this.next[fromIndex]
this.next.splice(fromIndex, 1)
this.next.splice(toIndex, 0, song)
process.emit("PLAYERS_UPDATE")
this.changed()
return true
}
@@ -225,15 +262,36 @@ const ListType = {
PREVIOUS: "1"
}
function savePrevious() {
let saveTimer = null
function flushPrevious() {
if(saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
for(const guildId in PreviousDB.data) {
if(PreviousDB.data[guildId].length > 50) {
PreviousDB.data[guildId].splice(50, PreviousDB.data[guildId].length - 50)
if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
}
}
PreviousDB.save();
}
module.exports = { List, ListType }
function savePrevious() {
// L'historique en mémoire est borné immédiatement, l'écriture disque est différée
for(const guildId in PreviousDB.data) {
if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
}
}
if(saveTimer) return
saveTimer = setTimeout(flushPrevious, SAVE_DELAY)
saveTimer.unref?.()
}
// Écriture des dernières modifications à l'arrêt du processus
process.once("exit", () => {
if(saveTimer) flushPrevious()
})
module.exports = { List, ListType }
+38 -13
View File
@@ -1,19 +1,44 @@
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice');
const { Readable } = require('stream')
const {LogType} = require('loguix')
const clog = new LogType("Media")
const plog = require("loguix").getInstance("Player")
const ffmpeg = require('fluent-ffmpeg')
const { isAllowedUrl } = require('../SongCheck')
async function getStream(song) {
try {
const stream = await fetch(song.url).then(res => res.body);
return stream;
} catch(e) {
clog.error("Erreur lors de la lecture de la musique : " + song.title)
clog.error(e)
}
// Les pièces jointes sont lues depuis le début : le seek n'est pas supporté (le player le refuse)
const seekable = false
/**
* @param {Object} song
* @param {number} seekSec - ignoré (seek non supporté)
* @param {{signal?: AbortSignal}} options
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekSec = 0, { signal } = {}) {
if(signal?.aborted) return null
// Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF)
if(!song || !isAllowedUrl("attachment", song.url)) {
clog.error("URL de pièce jointe refusée : " + JSON.stringify(song?.url))
return null
}
try {
const res = await fetch(song.url, { signal })
if(!res.ok || !res.body) {
clog.error(`Erreur HTTP ${res.status} lors de la lecture de la musique : ${song.title}`)
try { await res.body?.cancel() } catch(e) {}
return null
}
// Flux Node (et non flux web) : le player suit la fin réelle du flux
const stream = Readable.fromWeb(res.body)
if(signal?.aborted) {
stream.destroy()
return null
}
return stream
} catch(e) {
if(signal?.aborted) return null
clog.error("Erreur lors de la lecture de la musique : " + song.title)
clog.error(e)
return null
}
}
module.exports = {getStream}
module.exports = {getStream, seekable}
+34 -16
View File
@@ -1,25 +1,43 @@
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice');
const {LogType} = require('loguix')
const clog = new LogType("Soundcloud-Stream")
const {Soundcloud} = require('soundcloud.ts')
const ffmpeg = require('fluent-ffmpeg')
const { isAllowedUrl } = require('../SongCheck')
const soundcloud = new Soundcloud();
async function getStream(song) {
try {
var stream = await soundcloud.util.streamTrack(song.url)
return stream
} catch(e) {
clog.error("Erreur lors de la récupération du stream : " + song.title)
clog.error(e)
}
// Le flux SoundCloud est lu depuis le début : le seek n'est pas supporté (le player le refuse)
const seekable = false
/**
* @param {Object} song
* @param {number} seekSec - ignoré (seek non supporté)
* @param {{signal?: AbortSignal}} options
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekSec = 0, { signal } = {}) {
if(signal?.aborted) return null
if(!song || !isAllowedUrl("soundcloud", song.url)) {
clog.error("URL SoundCloud refusée : " + JSON.stringify(song?.url))
return null
}
try {
const stream = await soundcloud.util.streamTrack(song.url)
if(!stream) {
clog.error("Aucun flux renvoyé par SoundCloud pour : " + song.title)
return null
}
// Chargement remplacé entre-temps (skip, leave...) : on libère le flux
if(signal?.aborted) {
stream.destroy?.()
return null
}
signal?.addEventListener('abort', () => stream.destroy?.(), { once: true })
return stream
} catch(e) {
clog.error("Erreur lors de la récupération du stream : " + song.title)
clog.error(e)
return null
}
}
module.exports = {getStream}
module.exports = {getStream, seekable}
+426 -176
View File
@@ -1,228 +1,478 @@
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Stream");
const { spawn, exec } = require('child_process');
const fs = require('fs');
const { PassThrough } = require('stream');
const { __glob } = require('../../utils/GlobalVars');
const warp = require('../../utils/WarpProxy');
const { cookies, settings, ytdlp, errors, diagnostics, plan } = require('../../youtube');
// Erreurs liées à l'IP / la session : une autre route a des chances de passer
const RETRYABLE_ERRORS = /HTTP Error 403|403: Forbidden|HTTP Error 429|Sign in to confirm/i;
// Délai max avant de recevoir les premières données audio
const FIRST_DATA_TIMEOUT = 30 * 1000;
// Variable globale pour stocker le processus actif
let currentYtProcess = null;
// Incrémenté à chaque appel de getStream, permet de savoir si un appel a été remplacé par un plus récent
let generation = 0;
// Opus d'abord (pas de transcodage côté Discord), puis le meilleur audio, puis n'importe quoi
const FORMAT = "bestaudio[acodec=opus]/bestaudio/best";
// Délai max avant de recevoir les premières données audio (par tentative)
const FIRST_DATA_TIMEOUT = 20 * 1000;
// Seek natif : ffmpeg doit décoder tout le début avant de produire du son (marge par seconde sautée)
const NATIVE_SEEK_MS_PER_SECOND = 50;
// Plus de nouvelle tentative au-delà de ce délai total
const GLOBAL_BUDGET = 60 * 1000;
const MAX_ATTEMPTS = 4;
// Tampon entre yt-dlp et le player (~1 Mo, soit plusieurs dizaines de secondes d'audio) : absorbe les coupures réseau
const STREAM_BUFFER = 1 << 20;
// Lignes d'erreur / avertissement gardées par processus (classement + logs)
const MAX_STDERR_LINES = 40;
// Échecs liés au contenu : la route n'y est pour rien (pas de revérification de WARP)
const CONTENT_CLASSES = new Set(["PRIVATE", "UNAVAILABLE", "AGE_RESTRICTED", "LOGIN_REQUIRED", "MEMBERS_ONLY", "BINARY_MISSING"]);
/**
* Tue un processus yt-dlp et ses enfants (ffmpeg)
* Processus yt-dlp actifs par guilde (pour l'administration).
* Plus aucun état global partagé entre guildes : chaque appel de getStream est indépendant.
* @type {Map<string, {pid: number, route: "warp"|"direct", identity: "anon"|"cookies", songId: string|null, startedAt: number}>}
*/
const activeProcesses = new Map();
let binaryMissingLogged = false;
/**
* Tue un processus yt-dlp et ses enfants (ffmpeg), puis attend sa fin réelle (2 s max)
*/
function killProcess(proc) {
return new Promise((resolve) => {
if (!proc || proc.exitCode !== null) return resolve();
const pid = proc.pid;
clog.log(`[YT-DLP] Nettoyage violent du processus PID: ${pid}`);
// Détection de l'OS pour utiliser la bonne commande de kill
const isWindows = process.platform === 'win32';
if (isWindows) {
// Sur Windows, taskkill /T (Tree) /F (Force) est nécessaire pour tuer les enfants (ffmpeg)
try {
exec(`taskkill /pid ${pid} /T /F`, () => {
// Peu importe l'erreur (ex: processus déjà mort), on considère que c'est fini
resolve();
});
} catch (e) {
// Fallback si taskkill échoue
try { proc.kill('SIGKILL'); } catch (e2) {}
resolve();
}
} else {
// Sur Linux/Mac, on tente de tuer le groupe de processus
try {
process.kill(-pid, 'SIGKILL');
} catch (e) {
// Fallback si le group kill échoue
try { proc.kill('SIGKILL'); } catch (e2) {}
}
resolve();
}
});
}
/**
* Tue le processus yt-dlp en cours proprement et attend sa fin réelle.
* Cela garantit qu'aucun flux ne se chevauche.
*/
async function killCurrentProcess() {
const proc = currentYtProcess;
currentYtProcess = null;
await killProcess(proc);
return ytdlp.killTree(proc);
}
function routeLabel(proxy) {
return proxy ? `WARP (${proxy})` : "directe";
return proxy ? "WARP" : "directe";
}
function attemptLabel(attempt) {
const parts = [`route ${routeLabel(attempt.proxy)}`, attempt.identity === "cookies" ? "avec compte" : "sans compte"];
if (attempt.clients) parts.push(`clients ${attempt.clients}`);
return parts.join(", ");
}
/**
* Lance yt-dlp sur une route donnée et attend les premières données.
* Résout { stream } en cas de succès, ou { stream: null, reason, retryable } si la route a échoué avant le début du flux.
* Refuse toute URL qui ne commence pas par http(s):// (une valeur comme "--exec=..." serait lue comme une option)
*/
function runAttempt(song, seekTime, proxy) {
return new Promise((resolve, reject) => {
clog.log(`[YT-DLP] Lancement pour : ${song.url} (Début: ${seekTime}s, Route: ${routeLabel(proxy)})`);
function isSafeUrl(url) {
return typeof url === 'string' && /^https?:\/\/[^\s]+$/i.test(url);
}
const ytArgs = [
song.url,
/**
* Lance une tentative (yt-dlp, + ffmpeg pour le seek natif) et attend les premières données.
* Résout { stream } en cas de succès, ou { stream: null, cls, message, aborted? } si la tentative a échoué avant le début du flux.
* @param {Object} song
* @param {number} seekTime - secondes entières
* @param {{proxy: string|null, identity: "anon"|"cookies", clients: string, seek: "native"|"sections"|null}} attempt
* @param {{signal?: AbortSignal, guildId?: string}} options
*/
async function runAttempt(song, seekTime, attempt, { signal, guildId } = {}) {
if (signal?.aborted) return { stream: null, aborted: true, cls: null, message: "annulé" };
// Copie privée des cookies : yt-dlp réécrit son fichier en sortant
let copy = null;
if (attempt.identity === "cookies") {
try {
copy = await cookies.acquireCopy();
} catch (e) {
return { stream: null, cls: "UNKNOWN", message: `Copie des cookies impossible : ${e?.message || e}` };
}
if (!copy) return { stream: null, cls: "UNKNOWN", message: "Aucun cookie disponible" };
if (signal?.aborted) {
copy.release();
return { stream: null, aborted: true, cls: null, message: "annulé" };
}
}
let ffmpegPath = null;
if (attempt.seek === "native") {
const ffmpeg = ytdlp.resolveFfmpeg();
if (!ffmpeg) {
copy?.release();
return { stream: null, cls: "BINARY_MISSING", message: "ffmpeg introuvable : impossible de reprendre en cours de morceau" };
}
ffmpegPath = ffmpeg.path;
}
return new Promise((resolve) => {
const args = [
...ytdlp.baseArgs({ proxy: attempt.proxy, cookiesPath: copy?.path, playerClients: attempt.clients }),
'-f', FORMAT,
'--buffer-size', '1M',
'-o', '-',
// Sélecteur large : Audio pur OU Vidéo (ffmpeg se débrouillera)
'-f', 'bestaudio/best',
'--buffer-size', '16K',
'--js-runtimes', 'node',
];
// Seek par ffmpeg côté yt-dlp (longs sauts uniquement : lecture non bornée, plus exposée aux 403)
if (attempt.seek === "sections") args.push('--download-sections', `*${seekTime}-inf`);
// L'URL vient en dernier, après "--" : elle ne peut jamais être interprétée comme une option
args.push('--', song.url);
// --- GESTION DU TIMECODE (SEEK) ---
if (seekTime && seekTime > 0) {
ytArgs.push('--download-sections', `*${Math.round(seekTime)}-inf`);
}
// --- GESTION DES COOKIES ---
if (__glob.COOKIES && fs.existsSync(__glob.COOKIES)) {
ytArgs.push('--cookies', __glob.COOKIES);
ytArgs.push('--no-cache-dir');
}
// --- GESTION DU PROXY WARP ---
if (proxy) {
ytArgs.push('--proxy', proxy);
}
// Lancement du nouveau processus
const yt = spawn('yt-dlp', ytArgs);
// On met à jour la variable globale tout de suite
currentYtProcess = yt;
// Flux renvoyé au player : il ne reçoit rien tant qu'on ne sait pas si la route fonctionne
const out = new PassThrough();
let errorLogs = "";
const route = attempt.proxy ? "warp" : "direct";
const out = new PassThrough({ highWaterMark: STREAM_BUFFER });
const important = [];
let settled = false;
// Arrêt volontaire (skip, seek, leave, échec de tentative) : la sortie n'est pas une anomalie
let killedByUs = false;
let ytClosed = false;
let ytCode = null;
let ffClosed = !ffmpegPath;
let ytStarted = false;
let timer = null;
let yt;
let ff = null;
const fail = (reason) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
yt.stdout.unpipe(out);
out.destroy();
if (currentYtProcess === yt) currentYtProcess = null;
killProcess(yt);
resolve({
stream: null,
reason,
// Via WARP, tout échec justifie un essai en direct ; en direct, seulement les erreurs d'IP / session
retryable: Boolean(proxy) || RETRYABLE_ERRORS.test(errorLogs)
});
const releaseCopy = () => copy?.release();
// Seek natif : yt-dlp a tout envoyé sans erreur et ffmpeg n'a rien produit
const failPastEnd = () => fail("EMPTY_DOWNLOAD", `position de départ (${seekTime}s) au-delà de la fin du morceau`, { final: true });
const kill = () => {
if (killedByUs) return;
killedByUs = true;
if (yt) ytdlp.killTree(yt);
if (ff) ytdlp.killTree(ff);
};
const timeout = setTimeout(() => fail(`aucune donnée après ${FIRST_DATA_TIMEOUT / 1000}s`), FIRST_DATA_TIMEOUT);
const cleanupListener = () => {
if (ytClosed && ffClosed) signal?.removeEventListener('abort', onAbort);
};
yt.stderr.on('data', (data) => {
const msg = data.toString();
console.log(msg);
if (!msg.includes('[download]') && !msg.includes('[youtube]')) {
errorLogs += msg;
const fail = (cls, message, extra = {}) => {
if (settled) return;
settled = true;
clearTimeout(timer);
(ff ? ff.stdout : yt?.stdout)?.unpipe(out);
out.destroy();
kill();
resolve({ stream: null, cls, message, ...extra });
};
const succeed = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ stream: out, route });
};
// Annulation par le player (nouveau chargement, seek, leave) : avant ou après le début du flux
const onAbort = () => {
if (!settled) {
fail(null, "annulé", { aborted: true });
} else {
kill();
out.destroy();
}
// On n'attend pas la fin du processus pour changer de route
if (!settled && RETRYABLE_ERRORS.test(errorLogs)) {
fail(errorLogs.trim().split('\n').pop());
};
signal?.addEventListener('abort', onAbort, { once: true });
// --- Analyse de stderr ligne par ligne (les erreurs [youtube] ne sont plus perdues) ---
const handleLine = (raw) => {
const line = raw.trim();
if (!line) return;
if (/cookies are no longer valid/i.test(line) && attempt.identity === "cookies"
&& copy?.importedAt === cookies.getStatus().importedAt) {
cookies.markInvalid("YouTube a refusé les cookies pendant la lecture (rotation)");
}
// Informations et progression ignorées
if (!/^(ERROR|WARNING):/i.test(line) && !/HTTP Error|Got error|Giving up/i.test(line)) return;
important.push(errors.sanitize(line));
if (important.length > MAX_STDERR_LINES) important.shift();
// Erreur avant toute donnée : inutile d'attendre la fin des réessais de yt-dlp
if (!settled && (/^ERROR:/i.test(line) || (!/^WARNING:/i.test(line) && /HTTP Error 403/i.test(line)))) {
const c = errors.classify(important);
fail(c.cls, c.message);
}
};
const lineReader = (onLine) => {
let partial = "";
return {
push: (chunk) => {
partial += chunk.toString();
const lines = partial.split(/\r\n|\n|\r/);
partial = lines.pop();
if (partial.length > 16384) partial = partial.slice(-16384);
for (const l of lines) onLine(l);
},
flush: () => {
if (partial) onLine(partial);
partial = "";
}
};
};
// --- yt-dlp ---
try {
yt = ytdlp.spawnYtdlp(args);
} catch (e) {
releaseCopy();
signal?.removeEventListener('abort', onAbort);
settled = true;
return resolve({ stream: null, cls: e?.code === 'ENOENT' ? "BINARY_MISSING" : "UNKNOWN", message: `Lancement de yt-dlp impossible : ${e?.message || e}` });
}
yt.stdin?.end();
if (guildId && yt.pid) {
activeProcesses.set(guildId, { pid: yt.pid, route, identity: attempt.identity, songId: song.id ?? null, startedAt: Date.now() });
}
const forgetProcess = () => {
if (guildId && activeProcesses.get(guildId)?.pid === yt.pid) activeProcesses.delete(guildId);
};
const ytLines = lineReader(handleLine);
yt.stderr.on('data', ytLines.push);
yt.stderr.on('end', ytLines.flush);
// --- ffmpeg (seek natif) : yt-dlp télécharge depuis le début, ffmpeg décode et jette jusqu'à la position ---
let ffErrors = [];
if (ffmpegPath) {
try {
ff = ytdlp.spawnTracked(ffmpegPath, [
'-hide_banner', '-loglevel', 'error',
'-i', 'pipe:0',
// -ss APRÈS -i : décodage puis abandon jusqu'à la position (précis, sans requête HTTP)
'-ss', String(seekTime),
'-vn', '-f', 's16le', '-ar', '48000', '-ac', '2',
'pipe:1'
]);
} catch (e) {
ff = null;
ffClosed = true;
// yt-dlp est déjà lancé : copie des cookies et suivi libérés à sa fin
const cleanup = () => { ytClosed = true; releaseCopy(); forgetProcess(); cleanupListener(); };
yt.once('close', cleanup);
yt.once('error', cleanup);
fail("BINARY_MISSING", `Lancement de ffmpeg impossible : ${e?.message || e}`);
return;
}
// Le player lit du PCM brut (StreamType.Raw) : pas de second ffmpeg côté @discordjs/voice
out.streamType = "raw";
const ffLines = lineReader((l) => {
const line = l.trim();
if (!line) return;
ffErrors.push(errors.sanitize(line));
if (ffErrors.length > 10) ffErrors.shift();
});
ff.stderr.on('data', ffLines.push);
ff.stderr.on('end', ffLines.flush);
// ffmpeg tué ou fermé : l'écriture vers son entrée échoue (EPIPE), sans conséquence
ff.stdin.on('error', () => {});
yt.stdout.pipe(ff.stdin);
ff.on('error', (err) => {
ffClosed = true;
cleanupListener();
if (!settled) {
fail(err.code === 'ENOENT' ? "BINARY_MISSING" : "UNKNOWN", err.code === 'ENOENT' ? "ffmpeg introuvable" : `Erreur ffmpeg : ${err.message}`);
} else {
out.abnormalEnd = true;
out.destroy();
kill();
}
});
ff.on('close', (code) => {
ffClosed = true;
cleanupListener();
if (!settled) {
const detail = ffErrors.length ? ffErrors[ffErrors.length - 1] : "";
// yt-dlp a tout envoyé sans erreur et ffmpeg n'a rien produit : la position est après la fin du morceau
// (si yt-dlp n'a pas encore fini, sa fermeture tranchera)
if (code === 0 && !ytClosed) return;
if (code === 0 && ytCode === 0) failPastEnd();
else if (code === 0) fail("EMPTY_DOWNLOAD", `ffmpeg n'a produit aucun son${detail ? ` : ${detail}` : ''}`);
else fail("UNKNOWN", `ffmpeg arrêté (code ${code})${detail ? ` : ${detail}` : ''}`);
return;
}
if (!killedByUs && code !== 0) {
out.abnormalEnd = true;
out.exitCode = code;
clog.warn(`[FFMPEG] Arrêt code ${code} en cours de lecture${ffErrors.length ? ` : ${ffErrors[ffErrors.length - 1]}` : ''}`);
}
});
ff.stdout.on('error', () => kill());
ff.stdout.once('data', succeed);
ff.stdout.pipe(out);
} else {
yt.stdout.once('data', succeed);
yt.stdout.pipe(out);
}
// Délai pour les premières données : yt-dlp d'abord, puis (seek natif) le décodage par ffmpeg
timer = setTimeout(() => fail("TIMEOUT", `aucune donnée après ${FIRST_DATA_TIMEOUT / 1000}s`), FIRST_DATA_TIMEOUT);
yt.stdout.once('data', () => {
ytStarted = true;
if (!ffmpegPath || settled) return;
clearTimeout(timer);
const decodeMs = FIRST_DATA_TIMEOUT + seekTime * NATIVE_SEEK_MS_PER_SECOND;
timer = setTimeout(() => fail("TIMEOUT", `aucun son après ${Math.round(decodeMs / 1000)}s de décodage (seek ${seekTime}s)`), decodeMs);
});
yt.on('error', (err) => {
clog.error("[YT-DLP] Erreur au lancement.", err);
// Si c'est ce processus qui est en cours, on le clean
if (currentYtProcess === yt) currentYtProcess = null;
clearTimeout(timeout);
if (!settled) {
settled = true;
reject(err);
ytClosed = true;
releaseCopy();
forgetProcess();
cleanupListener();
if (err.code === 'ENOENT') {
// Binaire installé entre-temps : on le recherchera au prochain lancement
ytdlp.resolveBinary({ force: true });
fail("BINARY_MISSING", "yt-dlp introuvable (installez yt-dlp ou définissez YTDLP_PATH)");
} else {
fail("UNKNOWN", `Lancement de yt-dlp impossible : ${err.message}`);
}
if (ff) ytdlp.killTree(ff);
});
yt.on('close', (code) => {
// Nettoyage de la référence globale si c'est bien nous
if (currentYtProcess === yt) currentYtProcess = null;
ytClosed = true;
ytCode = code;
// yt-dlp a fini d'écrire sa copie des cookies : on peut la supprimer
releaseCopy();
forgetProcess();
cleanupListener();
if (code !== 0 && code !== null && code !== 143 && code !== 137) { // 137 = SIGKILL
clog.warn(`[YT-DLP] Arrêt code ${code}. Logs : ${errorLogs}`);
if (!settled) {
// Seek natif : yt-dlp peut avoir tout téléchargé avant que ffmpeg n'atteigne la position
if (ffmpegPath && code === 0 && ytStarted) {
if (ffClosed && ff?.exitCode === 0) failPastEnd();
return;
}
const c = errors.classify(important);
if (c.cls === "UNKNOWN" && code === 0) fail("EMPTY_DOWNLOAD", "yt-dlp s'est arrêté sans envoyer de données");
else fail(c.cls, c.cls === "UNKNOWN" ? `arrêt code ${code} avant toute donnée${important.length ? ` : ${c.message}` : ''}` : c.message);
return;
}
// Le flux avait commencé : une sortie en erreur (403 en cours de route, coupure...) n'est pas une fin de morceau.
// Le player lit ce marqueur à la fin de la ressource pour reprendre depuis la position courante.
if (!killedByUs && code !== 0) {
out.abnormalEnd = true;
out.exitCode = code;
const c = errors.classify(important);
clog.warn(`[YT-DLP] Arrêt code ${code} en cours de lecture (${attemptLabel(attempt)}) : ${c.cls} - ${c.message}`);
}
fail(`arrêt code ${code} avant toute donnée`);
});
// --- SÉCURITÉ ANTI-OVERRIDE SUR LE FLUX ---
// Si le stream se ferme (le bot quitte le vocal ou skip), on tue yt-dlp
// Si le flux se ferme (le bot quitte le vocal, skip, seek), on tue yt-dlp, ffmpeg et leurs enfants
out.on('close', () => {
if (yt.exitCode === null && !yt.killed) yt.kill();
// Fin naturelle du tube : les processus sortent d'eux-mêmes. Les tuer ici masquerait
// une sortie en erreur (abnormalEnd) et le player passerait au morceau suivant au lieu de reprendre.
if (out.readableEnded) return;
const ytAlive = yt.exitCode === null && yt.signalCode === null;
const ffAlive = ff && ff.exitCode === null && ff.signalCode === null;
if (ytAlive || ffAlive) kill();
});
yt.stdout.on('error', () => {
if (!yt.killed) yt.kill();
});
yt.stdout.once('data', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve({ stream: out });
});
yt.stdout.pipe(out);
yt.stdout.on('error', () => kill());
});
}
/**
* @param {Object} song - L'objet contenant l'URL
* @param {number} seekTime - Le temps de démarrage en secondes (par défaut 0)
* @param {{signal?: AbortSignal, onProgress?: function({route: string, attempt: number, identity: string}), guildId?: string}} options
* @returns {Promise<import('stream').Readable|null>} le flux (streamType "raw" = PCM s16le 48 kHz stéréo), ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekTime = 0) {
// Incrémenté avant le kill pour que l'appel précédent sache tout de suite qu'il est remplacé
const myGeneration = ++generation;
// ÉTAPE 1 : On s'assure que l'ancien processus est BIEN mort avant de faire quoi que ce soit.
await killCurrentProcess();
// ÉTAPE 2 : On essaie chaque route (WARP puis directe, selon l'état du proxy)
const routes = warp.getRoutes();
for (let i = 0; i < routes.length; i++) {
// Un nouvel appel (skip, seek...) a pris la main : on n'envoie rien au player
if (myGeneration !== generation) return null;
const proxy = routes[i];
const result = await runAttempt(song, seekTime, proxy);
if (result.stream) return result.stream;
if (myGeneration !== generation) return null;
if (proxy) warp.reportFailure();
const next = routes[i + 1];
if (!result.retryable || next === undefined) {
clog.error(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason}`);
break;
}
clog.warn(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason} - Nouvelle tentative via la route ${routeLabel(next)}`);
async function getStream(song, seekTime = 0, { signal, onProgress, guildId } = {}) {
if (!song || !isSafeUrl(song.url)) {
clog.error(`[YT-DLP] URL refusée : ${JSON.stringify(song?.url)}`);
return null;
}
// Flux vide : le player passe en Idle et enchaîne sur la musique suivante
const empty = new PassThrough();
empty.end();
return empty;
const seek = Number.isFinite(Number(seekTime)) && Number(seekTime) > 0 ? Math.floor(Number(seekTime)) : 0;
const s = settings.get();
// Routes figées pour toute la lecture (WARP puis directe, selon l'état du proxy)
const routes = warp.getRoutes();
const seekMode = plan.seekModeFor(s.seekStrategy, seek);
const started = Date.now();
const history = [];
let last = null;
while (true) {
// Un nouvel appel (skip, seek, leave...) a pris la main : on n'envoie rien au player
if (signal?.aborted) return null;
if (history.length > 0 && Date.now() - started > GLOBAL_BUDGET) {
clog.warn(`[YT-DLP] Délai global de ${GLOBAL_BUDGET / 1000}s dépassé, abandon`);
break;
}
const attempt = plan.planNext({
routes,
mode: s.cookiesMode,
cookiesValid: cookies.isValid(),
cookiesOnProxy: s.cookiesOnProxy,
clients: s.playerClients,
fallbackClients: s.fallbackPlayerClients,
seekMode,
history,
maxAttempts: MAX_ATTEMPTS
});
if (!attempt) break;
const n = history.length + 1;
const route = attempt.proxy ? "warp" : "direct";
try {
onProgress?.({ route, attempt: n, identity: attempt.identity });
} catch (e) {
clog.warn(`[YT-DLP] Erreur dans onProgress : ${e?.message || e}`);
}
const seekLabel = attempt.seek ? `, début ${seek}s (${attempt.seek === "native" ? "natif" : "ffmpeg"})` : "";
clog.log(`[YT-DLP] Tentative ${n} : ${song.url} (${attemptLabel(attempt)}${seekLabel}${n > 1 ? `, ${attempt.reason}` : ""}${guildId ? `, guilde ${guildId}` : ''})`);
const t0 = Date.now();
let result;
try {
result = await runAttempt(song, seek, attempt, { signal, guildId });
} catch (e) {
result = { stream: null, cls: "UNKNOWN", message: e?.message || String(e) };
}
const entry = {
guildId: guildId ?? null,
songId: song.id ?? null,
title: song.title ?? null,
route,
identity: attempt.identity,
clients: attempt.clients,
seek: attempt.seek,
ms: Date.now() - t0
};
if (result.stream) {
// Annulé pendant la dernière étape : on ne renvoie jamais un flux périmé
if (signal?.aborted) {
result.stream.destroy();
diagnostics.record({ ...entry, outcome: "aborted" });
return null;
}
diagnostics.record({ ...entry, outcome: "ok" });
binaryMissingLogged = false;
if (n > 1) clog.log(`[YT-DLP] Flux obtenu à la tentative ${n} (${attemptLabel(attempt)})`);
return result.stream;
}
if (result.aborted || signal?.aborted) {
diagnostics.record({ ...entry, outcome: "aborted" });
return null;
}
const cls = result.cls || "UNKNOWN";
diagnostics.record({ ...entry, outcome: "failed", cls, message: result.message });
last = { cls, message: result.message };
// Échec qui ne dépend ni de la route ni du client (seek après la fin) : inutile de réessayer
if (result.final) {
history.push({ ...attempt, cls });
break;
}
if (cls === "BINARY_MISSING") {
if (!binaryMissingLogged) {
binaryMissingLogged = true;
clog.error(`[YT-DLP] ${result.message} - la lecture YouTube est impossible tant que le binaire manque`);
}
return null;
}
clog.warn(`[YT-DLP] Échec tentative ${n} (${attemptLabel(attempt)}) : ${cls} - ${result.message}`);
// Échec via le proxy : on revérifie WARP tout de suite au lieu d'attendre l'intervalle
if (attempt.proxy && !CONTENT_CLASSES.has(cls)) warp.reportFailure();
// Série de blocages : une version plus récente de yt-dlp corrige souvent le problème
if (diagnostics.BLOCK_CLASSES.has(cls)) ytdlp.maybeUpdateAfterFailures();
history.push({ proxy: attempt.proxy, identity: attempt.identity, clients: attempt.clients, seek: attempt.seek, cls });
}
// Aucun flux : le player affiche l'erreur et passe à la suite
if (last) clog.error(`[YT-DLP] Lecture impossible : ${song.url} après ${history.length} tentative(s) - ${last.cls} : ${last.message}`);
return null;
}
module.exports = { getStream };
module.exports = { getStream, activeProcesses, killProcess };
+901 -261
View File
File diff suppressed because it is too large. Load diff
+18 -12
View File
@@ -3,6 +3,7 @@ const {LogType} = require('loguix')
const clog = new LogType("Song")
const MediaInformation = require('../media/MediaInformation')
const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
class Song {
title = "Aucun titre";
@@ -11,11 +12,12 @@ class Song {
authorId;
url;
thumbnail = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png" ;
duration;
// Durée en secondes : toujours un nombre, 0 = live ou inconnue
duration = 0;
readduration;
form = "SONG";
form = "SONG";
type;
userAddedId;
userAddedId;
constructor(properties) {
if(properties) {
@@ -25,11 +27,12 @@ class Song {
this.author = properties.author ?? this.author
this.url = properties.url ?? this.url
this.thumbnail = properties.thumbnail ?? this.thumbnail
this.duration = properties.duration ?? this.duration
this.readduration = properties.readduration ?? this.readduration
this.type = properties.type ?? this.type
// Durée normalisée (chaîne, NaN, négatif ou aberrant => 0) : ne fausse plus les statistiques
this.duration = toSafeDuration(properties.duration)
// Jamais de confiance dans le readduration reçu : recalculé depuis la durée normalisée
this.readduration = getReadableDuration(this.duration)
this.authorId = properties.authorId ?? this.authorId
}
}
@@ -41,14 +44,16 @@ class Song {
this.url = media.attachment.url
this.id = media.attachment.name
this.type = "attachment"
// In face, duration is null, get the metadata of the file to get the duration
await MediaInformation.getMediaInformation(this, media)
this.duration = toSafeDuration(this.duration)
this.readduration = getReadableDuration(this.duration)
} else {
clog.error("Impossible de traiter le média")
}
}
async processYoutubeVideo(video) {
@@ -60,11 +65,12 @@ class Song {
this.type = "youtube"
this.id = video.videoId
this.duration = video.duration.seconds
// 0 pour les lives (affichés en LIVE côté client)
this.duration = toSafeDuration(video?.duration?.seconds)
this.readduration = getReadableDuration(this.duration)
return this
}
}
}
module.exports = {Song}
+94 -28
View File
@@ -2,43 +2,109 @@ const {LogType} = require("loguix")
const {Song} = require("./Song")
const slog = new LogType("SongCheck")
// Types de sources que le player sait lire
const KNOWN_TYPES = ["youtube", "soundcloud", "attachment"]
// Hôtes autorisés par type : le serveur ne contacte jamais une URL arbitraire fournie par un client (SSRF)
const ALLOWED_HOSTS = {
youtube: (host) => host === "youtu.be" || host === "youtube.com" || host.endsWith(".youtube.com"),
soundcloud: (host) => host === "soundcloud.com" || host.endsWith(".soundcloud.com"),
attachment: (host) => host === "cdn.discordapp.com" || host === "media.discordapp.net",
}
// Bornes des champs texte (un titre de 100 Mo bloquerait chaque sauvegarde)
const MAX_TEXT = 200
const MAX_URL = 2000
/**
* Vérifie qu'une URL est en http(s) et pointe vers un hôte autorisé pour le type donné
* @param {string} type
* @param {string} url
* @returns {boolean}
*/
function isAllowedUrl(type, url) {
if(typeof url !== "string" || url.length > MAX_URL) return false
if(!/^https?:\/\//i.test(url)) return false
const check = ALLOWED_HOSTS[type]
if(!check) return false
try {
const parsed = new URL(url)
if(parsed.protocol !== "http:" && parsed.protocol !== "https:") return false
if(parsed.username || parsed.password) return false
return check(parsed.hostname.toLowerCase())
} catch(e) {
return false
}
}
function clampText(value, max) {
if(value === undefined || value === null) return undefined
if(typeof value !== "string" && typeof value !== "number") return undefined
const text = String(value)
return text.length > max ? text.slice(0, max) : text
}
/**
* Vérifie qu'une musique peut être lue : type connu et URL autorisée pour ce type.
* Reste tolérant pour les anciennes entrées (titre, auteur ou durée manquants : valeurs par défaut).
* @param {Object} song
* @returns {boolean}
*/
function checkSong(song) {
if(!(song instanceof Song)) {
slog.error("La musique n'est pas une instance de la classe Song")
// Check if the song is valid and if it has all the required properties
if(song.title && song.id && song.author && song.url && song.duration && song.readduration && song.type) {
slog.log("Acceptation de la musique : " + song.title)
return true
} else {
slog.error("La musique n'est pas valide")
return false
}
if(!song || typeof song !== "object") {
slog.error("La musique n'est pas valide")
return false
}
if(!song.type || !KNOWN_TYPES.includes(song.type)) {
slog.error("La musique n'a pas de type valide : " + JSON.stringify(song.type))
return false
}
if(!song.url) {
slog.error("La musique n'a pas d'url")
return false
}
if(!song.title) {
slog.error("La musique n'a pas de titre")
if(!isAllowedUrl(song.type, song.url)) {
slog.error("L'url de la musique n'est pas autorisée pour le type " + song.type + " : " + String(song.url).slice(0, 200))
return false
}
if(!song.author) {
slog.error("La musique n'a pas d'auteur")
return false
}
if(song.duration == null) {
slog.error("La musique n'a pas de durée")
return false
}
if(song.readduration == null) {
slog.error("La musique n'a pas de durée lisible")
return false
}
if(!song.type) {
slog.error("La musique n'a pas de type")
return false
if(!(song instanceof Song)) {
// Objet brut (base de données, playlist, client) : accepté, il sera normalisé par sanitizeSong
slog.log("Acceptation de la musique : " + String(song.title).slice(0, MAX_TEXT))
}
return true
}
module.exports = {checkSong}
/**
* Construit une instance de Song propre à partir de données non fiables (client, playlists.json, historique).
* Durée normalisée (0 = live / inconnue), textes bornés, miniature en http(s) uniquement.
* @param {Object|string} raw
* @returns {Song|null} null si la musique ne peut pas être lue
*/
function sanitizeSong(raw) {
let data = raw
if(typeof data === "string") {
try {
data = JSON.parse(data)
} catch(e) {
slog.error("Musique reçue illisible (JSON invalide)")
return null
}
}
if(!data || typeof data !== "object" || Array.isArray(data)) return null
if(!checkSong(data)) return null
const song = new Song({
type: data.type,
url: data.url,
title: clampText(data.title, MAX_TEXT),
id: clampText(data.id, MAX_TEXT),
author: clampText(data.author, MAX_TEXT),
authorId: clampText(data.authorId, MAX_URL),
thumbnail: typeof data.thumbnail === "string" && data.thumbnail.length <= MAX_URL && /^https?:\/\//i.test(data.thumbnail) ? data.thumbnail : undefined,
duration: data.duration,
})
if(typeof data.createdAt === "string") song.createdAt = data.createdAt.slice(0, 64)
return song
}
module.exports = {checkSong, sanitizeSong, isAllowedUrl, KNOWN_TYPES}
+366 -54
View File
@@ -3,85 +3,397 @@ const alog = new LogType("GoogleOAuth2");
const { google } = require('googleapis');
const config = require("../../utils/Database/Configuration");
const Users = require('../../server/auth/User');
const store = require('./TokenStore');
const { createStateStore, pickTokens, bestThumbnail } = require('./Utils');
const clientId = config.getYoutubeApiClientId();
const clientSecret = config.getYoutubeApiClientSecret();
const redirectUri = config.getWebsiteLink() + "/oauth2callback";
const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
// Durée de validité d'une demande de connexion (state OAuth)
const STATE_TTL_MS = 10 * 60 * 1000;
// Délai max d'un appel aux serveurs Google (échange du code, révocation, API)
const REQUEST_TIMEOUT_MS = 20 * 1000;
const states = createStateStore({ ttlMs: STATE_TTL_MS, maxPerKey: 5 });
// userId -> OAuth2Client avec les jetons enregistrés (recréé si la configuration change)
const clients = new Map();
// Utilisateurs dont la synchronisation est en cours
const syncing = new Set();
const oAuth2Map = new Map();
/**
* Erreur typée de l'intégration Google (message en français, affichable à l'utilisateur)
*/
class GoogleError extends Error {
constructor(code, message) {
super(message);
this.name = "GoogleError";
this.code = code;
}
}
// --- Configuration (lue à chaque appel : elle peut changer à chaud) ---
function getConfig() {
const clientId = config.getYoutubeApiClientId() || "";
const clientSecret = config.getYoutubeApiClientSecret() || "";
const website = String(config.getWebsiteLink() || "").replace(/\/+$/, "");
return {
clientId,
clientSecret,
redirectUri: website ? website + "/oauth2callback" : ""
};
}
function isConfigured() {
const { clientId, clientSecret, redirectUri } = getConfig();
return !!(clientId && clientSecret && redirectUri);
}
function newClient() {
const { clientId, clientSecret, redirectUri } = getConfig();
if (!clientId || !clientSecret || !redirectUri) return null;
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}
function dropClient(userId) {
const client = clients.get(userId);
if (client) client.removeAllListeners("tokens");
clients.delete(userId);
}
function clearClients() {
for (const userId of [...clients.keys()]) dropClient(userId);
}
// Identifiants modifiés depuis l'administration : les clients en cache utilisent les anciens
process.on("CONFIG_UPDATE", () => {
if (clients.size > 0) alog.log("Configuration modifiée : réinitialisation des clients Google OAuth2");
clearClients();
});
function withTimeout(promise, ms, label) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(Object.assign(new Error(`Délai dépassé (${label})`), { code: "ETIMEDOUT" })), ms);
if (typeof timer.unref === "function") timer.unref();
})
]).finally(() => clearTimeout(timer));
}
function emitUpdate(userId) {
try {
process.emit("GOOGLE_UPDATE", userId);
} catch (e) {
alog.error("Erreur lors de l'émission de GOOGLE_UPDATE : " + (e?.message || e));
}
}
// --- Erreurs de l'API ---
/**
* Traduit une erreur googleapis / gaxios en GoogleError (message français, jamais de jeton)
* @param {*} error
* @returns {GoogleError}
*/
function describeApiError(error) {
if (error instanceof GoogleError) return error;
const status = error?.response?.status ?? error?.status ?? (Number.isInteger(error?.code) ? error.code : null);
const data = error?.response?.data;
const oauthError = typeof data?.error === "string" ? data.error : null;
const reason = error?.errors?.[0]?.reason || data?.error?.errors?.[0]?.reason || null;
const message = String(error?.message || "");
if (oauthError === "invalid_grant" || /invalid_grant/i.test(message)) {
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google a expiré ou a été révoquée : reconnectez votre compte YouTube");
}
if (oauthError === "invalid_client" || oauthError === "unauthorized_client" || /invalid_client|unauthorized_client/i.test(message)) {
return new GoogleError("CONFIG_ERROR", "Les identifiants de l'application Google sont invalides (configuration du bot)");
}
if (oauthError === "redirect_uri_mismatch" || /redirect_uri_mismatch/i.test(message)) {
return new GoogleError("CONFIG_ERROR", "L'URI de redirection n'est pas autorisée dans la console Google (attendu : <site>/oauth2callback)");
}
if (["quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded"].includes(reason)) {
return new GoogleError("QUOTA", "Quota de l'API YouTube dépassé, réessayez plus tard");
}
if (reason === "playlistNotFound" || status === 404) {
return new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
}
if (status === 401) {
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google n'est plus valide : reconnectez votre compte YouTube");
}
if (status === 403) {
return new GoogleError("FORBIDDEN", "Accès refusé par l'API YouTube" + (reason ? " (" + reason + ")" : ""));
}
if (["ENOTFOUND", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code) || /timeout|Délai dépassé/i.test(message)) {
return new GoogleError("NETWORK", "Impossible de joindre les serveurs Google, réessayez plus tard");
}
return new GoogleError("API_ERROR", "Erreur de l'API YouTube" + (status ? " (HTTP " + status + ")" : ""));
}
/**
* Journalise une erreur d'API et marque le compte comme invalide si l'autorisation est perdue
* @param {string} userId
* @param {*} error
* @returns {GoogleError}
*/
function handleApiError(userId, error) {
const described = describeApiError(error);
alog.error(`Erreur Google pour l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`);
if (described.code === "AUTH_EXPIRED") markInvalid(userId, described.message);
return described;
}
function markInvalid(userId, reason) {
dropClient(userId);
const changed = store.update(userId, (record) => {
record.invalid = true;
record.invalidReason = reason || "Autorisation invalide";
});
if (changed) {
alog.warn(`Compte Google de l'utilisateur ${userId} marqué comme invalide : ${reason}`);
emitUpdate(userId);
}
}
// --- Connexion du compte ---
/**
* Génère l'URL de consentement Google pour l'utilisateur (state aléatoire lié à son id, valable 10 min)
* @param {string} userId
* @returns {string|null} URL, ou null si l'intégration n'est pas configurée
*/
function createAuthUrl(userId) {
if(!checkCredientials()) return null;
var oAuth2Client;
if (!checkCredientials()) return null;
const user = Users.getUserById(userId);
if (!user) {
alog.error(`User with ID ${userId} not found.`);
return null;
}
if (!clientId || !clientSecret) {
alog.error("YouTube API client ID or secret is not set in the configuration.");
} else {
oAuth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
redirectUri
);
alog.log("Google OAuth2 client initialized successfully.");
}
if (!oAuth2Client) {
alog.error("OAuth2 client is not initialized. Please check your configuration.");
alog.error(`Utilisateur ${userId} introuvable.`);
return null;
}
oAuth2Map.set(userId, oAuth2Client);
alog.log(`OAuth2 client created for user ${userId}.`);
return oAuth2Client.generateAuthUrl({
const client = newClient();
if (!client) return null;
const state = states.create(userId);
alog.log(`Demande de connexion Google créée pour l'utilisateur ${userId}.`);
return client.generateAuthUrl({
access_type: 'offline',
// Le consentement explicite garantit l'envoi d'un refresh_token à chaque liaison
prompt: 'consent',
include_granted_scopes: true,
scope: SCOPES,
state
});
}
async function getAuthorization(userId, code) {
if(!checkCredientials()) return null;
/**
* Lie le compte Google de l'utilisateur à partir du code renvoyé par Google.
* @param {string} userId
* @param {string} code
* @param {string} state
* @returns {Promise<object>} statut du compte (voir getStatus)
* @throws {GoogleError}
*/
async function linkAccount(userId, code, state) {
if (!isConfigured()) throw new GoogleError("NOT_CONFIGURED", "La connexion YouTube n'est pas configurée sur ce bot");
if (typeof code !== "string" || code.length === 0 || code.length > 2048) {
throw new GoogleError("INVALID_PARAMS", "Code d'autorisation Google manquant ou invalide");
}
if (!states.consume(state, userId)) {
throw new GoogleError("INVALID_STATE", "La demande de connexion a expiré ou n'est pas valide, veuillez réessayer");
}
const client = newClient();
let tokens;
try {
const user = Users.getUserById(userId);
if (!user) {
alog.error(`User with ID ${userId} not found.`);
return null;
}
oAuth2Client = oAuth2Map.get(userId);
if (!oAuth2Client) {
alog.error(`OAuth2 client for user ${userId} not found. Please create an OAuth2 client first.`);
return null;
}
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
alog.log(`OAuth2 client credentials set for user ${userId}.`);
return oAuth2Client;
const response = await withTimeout(client.getToken(code), REQUEST_TIMEOUT_MS, "échange du code");
tokens = pickTokens(response.tokens);
} catch (error) {
alog.error(`Error during OAuth2 authorization for user ${userId}:`, error);
const described = describeApiError(error);
alog.error(`Échec de l'échange du code Google pour l'utilisateur ${userId} : ${error?.message || error}`);
// Code expiré / déjà utilisé : message dédié
if (described.code === "AUTH_EXPIRED") throw new GoogleError("TOKEN_ERROR", "Le code d'autorisation Google a expiré, veuillez réessayer");
throw described;
}
if (!tokens.access_token) throw new GoogleError("TOKEN_ERROR", "Google n'a renvoyé aucun jeton d'accès");
const previous = store.get(userId);
// Google ne renvoie le refresh_token qu'au premier consentement : on garde l'ancien si besoin
if (!tokens.refresh_token && previous?.tokens?.refresh_token) tokens.refresh_token = previous.tokens.refresh_token;
if (!tokens.refresh_token) alog.warn(`Aucun refresh_token reçu pour l'utilisateur ${userId} : la liaison expirera avec le jeton d'accès`);
client.setCredentials(tokens);
// Chaîne YouTube du compte (titre affiché, playlist "J'aime")
let channel = null;
let likesPlaylistId = null;
try {
const youtube = google.youtube({ version: 'v3', auth: client, timeout: REQUEST_TIMEOUT_MS });
const response = await youtube.channels.list({ part: ['snippet', 'contentDetails'], mine: true, maxResults: 1 });
const item = response?.data?.items?.[0];
if (item) {
channel = {
id: item.id,
title: item.snippet?.title || null,
thumbnail: bestThumbnail(item.snippet?.thumbnails)
};
likesPlaylistId = item.contentDetails?.relatedPlaylists?.likes || null;
}
} catch (error) {
// Non bloquant : un compte Google sans chaîne YouTube reste utilisable
alog.warn(`Impossible de lire la chaîne YouTube de l'utilisateur ${userId} : ${error?.message || error}`);
}
dropClient(userId);
store.set(userId, {
tokens,
linkedAt: new Date().toISOString(),
channel,
likesPlaylistId,
lastSync: previous?.lastSync || null,
invalid: false,
invalidReason: null
});
attachClient(userId, client);
alog.log(`Compte Google lié pour l'utilisateur ${userId}` + (channel?.title ? ` (chaîne : ${channel.title})` : ""));
emitUpdate(userId);
return getStatus(userId);
}
/**
* Enregistre un client en cache et persiste les jetons rafraîchis par la librairie
*/
function attachClient(userId, client) {
client.on('tokens', (fresh) => {
const picked = pickTokens(fresh);
// Compte délié entre-temps : rien à enregistrer
store.update(userId, (record) => {
record.tokens = { ...(record.tokens || {}), ...picked };
});
});
clients.set(userId, client);
return client;
}
/**
* Client OAuth2 authentifié de l'utilisateur (jetons persistés), ou null si le compte n'est pas (ou plus) lié
* @param {string} userId
* @returns {import('google-auth-library').OAuth2Client|null}
*/
function getClient(userId) {
const record = store.get(userId);
if (!record?.tokens || record.invalid) return null;
if (clients.has(userId)) return clients.get(userId);
const client = newClient();
if (!client) return null;
client.setCredentials(record.tokens);
return attachClient(userId, client);
}
/**
* Délie le compte Google : révocation chez Google (au mieux) puis suppression des jetons
* @param {string} userId
* @param {{revoke?: boolean}} [options]
* @returns {Promise<{removed: boolean, revoked: boolean}>}
*/
async function unlink(userId, { revoke = true } = {}) {
const record = store.get(userId);
dropClient(userId);
states.clear(userId);
if (!record) return { removed: false, revoked: false };
const token = record.tokens?.refresh_token || record.tokens?.access_token;
// Suppression locale d'abord : même si Google ne répond pas, plus rien n'est conservé
store.remove(userId);
emitUpdate(userId);
let revoked = false;
if (revoke && token) {
try {
// La révocation ne nécessite pas les identifiants de l'application
await withTimeout(new google.auth.OAuth2().revokeToken(token), REQUEST_TIMEOUT_MS, "révocation");
revoked = true;
} catch (error) {
alog.warn(`Révocation du jeton Google impossible pour l'utilisateur ${userId} : ${error?.message || error}`);
}
}
alog.log(`Compte Google délié pour l'utilisateur ${userId}` + (revoked ? " (jeton révoqué)" : ""));
return { removed: true, revoked };
}
/**
* Statut du compte Google de l'utilisateur (jamais de jeton)
* @param {string} userId
*/
function getStatus(userId) {
const record = store.get(userId);
const linked = !!record?.tokens;
return {
configured: isConfigured(),
linked,
valid: linked && !record.invalid,
invalidReason: linked && record.invalid ? (record.invalidReason || null) : null,
channel: linked ? (record.channel || null) : null,
linkedAt: linked ? (record.linkedAt || null) : null,
lastSync: linked ? (record.lastSync || null) : null,
syncing: syncing.has(userId)
};
}
function isLinked(userId) {
const record = store.get(userId);
return !!(record?.tokens && !record.invalid);
}
function getRecord(userId) {
return store.get(userId);
}
function setSyncing(userId, value) {
if (value) syncing.add(userId); else syncing.delete(userId);
emitUpdate(userId);
}
function isSyncing(userId) {
return syncing.has(userId);
}
function setLastSync(userId, lastSync) {
store.update(userId, (record) => {
record.lastSync = lastSync;
});
}
// Compatibilité : ancien nom (renvoie le client authentifié ou null)
async function getAuthorization(userId, code, state) {
try {
await linkAccount(userId, code, state);
return getClient(userId);
} catch (error) {
alog.error(`Erreur lors de l'autorisation OAuth2 de l'utilisateur ${userId} : ` + (error?.message || error));
return null;
}
}
function checkCredientials() {
if (!clientId || !clientSecret) {
alog.error("YouTube API client ID or secret is not set in the configuration.");
if (!isConfigured()) {
alog.warn("Identifiants de l'API YouTube (clientId / clientSecret) ou lien du site absents de la configuration.");
return false;
}
return true;
}
}
module.exports = {
SCOPES,
GoogleError,
isConfigured,
createAuthUrl,
linkAccount,
getAuthorization,
getOAuth2Client: (userId) => oAuth2Map.get(userId),
oAuth2Map
getClient,
getOAuth2Client: getClient,
unlink,
getStatus,
isLinked,
getRecord,
setSyncing,
isSyncing,
setLastSync,
describeApiError,
handleApiError,
markInvalid,
REQUEST_TIMEOUT_MS
};
const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
+76
View File
@@ -0,0 +1,76 @@
// Stockage des comptes Google liés (jetons OAuth2 par utilisateur) dans data/google.json
// Ce fichier contient des refresh_token : jamais exposé à l'administration ni renvoyé au client.
const fs = require("fs")
const path = require("path")
const { Database } = require("../../utils/Database/Database")
const { __glob } = require("../../utils/GlobalVars")
const { LogType } = require("loguix")
const glog = new LogType("GoogleOAuth2")
const GOOGLE_FILE = path.join(__glob.DATA, "google.json")
// { [userId]: { tokens, linkedAt, channel, likesPlaylistId, lastSync, invalid, invalidReason } }
const googleDB = new Database("google", GOOGLE_FILE, {})
restrictPermissions()
// Droits 600 là où c'est possible (sans effet sous Windows)
function restrictPermissions() {
try {
if (fs.existsSync(GOOGLE_FILE)) fs.chmodSync(GOOGLE_FILE, 0o600)
} catch (e) {
// Système de fichiers sans permissions POSIX : rien à faire
}
}
function save() {
googleDB.save()
restrictPermissions()
}
/**
* @param {string} userId
* @returns {object|null}
*/
function get(userId) {
if (!userId) return null
const record = googleDB.data[userId]
return record && typeof record === "object" ? record : null
}
function set(userId, record) {
if (!userId) return
googleDB.data[userId] = record
save()
}
/**
* Modifie un enregistrement existant (ignoré si le compte n'est plus lié)
* @param {string} userId
* @param {(record: object) => void} mutator
* @returns {boolean}
*/
function update(userId, mutator) {
const record = get(userId)
if (!record) return false
mutator(record)
save()
return true
}
function remove(userId) {
if (!userId || !googleDB.data[userId]) return false
delete googleDB.data[userId]
save()
glog.log(`Données Google supprimées pour l'utilisateur ${userId}`)
return true
}
module.exports = {
get,
set,
update,
remove,
GOOGLE_FILE
}
+171
View File
@@ -0,0 +1,171 @@
// Fonctions pures de l'intégration Google / YouTube Data API (aucune dépendance : testables seules)
const crypto = require("crypto")
/**
* Convertit une durée ISO-8601 de l'API YouTube (ex : "PT1H2M3S", "P1DT2H", "PT0S", "P0D") en secondes.
* Les années / mois (jamais renvoyés pour une vidéo) sont refusés.
* @param {string} value
* @returns {number|null} secondes (entier >= 0), ou null si le format est invalide
*/
function parseIsoDuration(value) {
if (typeof value !== "string") return null
const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:[.,]\d+)?)S)?)?$/.exec(value.trim().toUpperCase())
if (!match) return null
// "P" seul ou "PT" seul ne sont pas des durées valides
if (match.slice(1).every(part => part === undefined)) return null
const [, weeks, days, hours, minutes, seconds] = match
const total = (Number(weeks) || 0) * 604800
+ (Number(days) || 0) * 86400
+ (Number(hours) || 0) * 3600
+ (Number(minutes) || 0) * 60
+ (Number(String(seconds || "0").replace(",", ".")) || 0)
return Number.isFinite(total) ? Math.floor(total) : null
}
/**
* Parcourt une liste paginée par pageToken (API Google) jusqu'à épuisement ou jusqu'à la limite.
* @param {(pageToken: string|undefined) => Promise<{items?: Array, nextPageToken?: string}>} fetchPage
* @param {{maxItems?: number, maxPages?: number}} [options]
* @returns {Promise<{items: Array, truncated: boolean, pages: number}>}
*/
async function collectPages(fetchPage, { maxItems = 500, maxPages = 100 } = {}) {
const items = []
const seenTokens = new Set()
let pageToken = undefined
let pages = 0
let truncated = false
while (true) {
if (pages >= maxPages) {
truncated = true
break
}
const page = await fetchPage(pageToken)
pages++
const pageItems = Array.isArray(page?.items) ? page.items : []
for (const item of pageItems) {
if (items.length >= maxItems) {
truncated = true
break
}
items.push(item)
}
const next = page?.nextPageToken
if (!next) break
// Limite atteinte alors que Google annonce encore des pages
if (items.length >= maxItems) {
truncated = true
break
}
// Sécurité : un jeton déjà vu provoquerait une boucle infinie
if (seenTokens.has(next)) break
seenTokens.add(next)
pageToken = next
}
return { items, truncated, pages }
}
/**
* Découpe un tableau en paquets de taille fixe (ex : 50 ids par appel videos.list)
* @template T
* @param {T[]} array
* @param {number} size
* @returns {T[][]}
*/
function chunk(array, size) {
const out = []
for (let i = 0; i < array.length; i += size) out.push(array.slice(i, i + size))
return out
}
/**
* Magasin de "state" OAuth : jeton aléatoire à usage unique, lié à un utilisateur, avec expiration.
* @param {{ttlMs?: number, maxPerKey?: number, now?: () => number}} [options]
*/
function createStateStore({ ttlMs = 10 * 60 * 1000, maxPerKey = 5, now = Date.now } = {}) {
// state -> { userId, expiresAt }
const states = new Map()
function sweep() {
const t = now()
for (const [state, entry] of states) {
if (entry.expiresAt <= t) states.delete(state)
}
}
return {
/**
* Crée un state pour l'utilisateur (les plus anciens sont retirés au-delà de maxPerKey)
* @param {string} userId
* @returns {string}
*/
create(userId) {
sweep()
const owned = [...states.entries()].filter(([, entry]) => entry.userId === userId)
while (owned.length >= maxPerKey) {
const [oldest] = owned.shift()
states.delete(oldest)
}
const state = crypto.randomBytes(24).toString("hex")
states.set(state, { userId, expiresAt: now() + ttlMs })
return state
},
/**
* Vérifie et consomme un state : valide seulement pour le même utilisateur et avant expiration
* @param {string} state
* @param {string} userId
* @returns {boolean}
*/
consume(state, userId) {
sweep()
if (typeof state !== "string" || !states.has(state)) return false
const entry = states.get(state)
// Un state présenté par un autre utilisateur n'est pas consommé (il reste utilisable par son propriétaire)
if (entry.userId !== userId) return false
states.delete(state)
return true
},
/** Retire tous les states d'un utilisateur */
clear(userId) {
for (const [state, entry] of states) {
if (userId === undefined || entry.userId === userId) states.delete(state)
}
},
size() {
sweep()
return states.size
}
}
}
/**
* Ne conserve que les champs utiles d'une réponse de jetons Google
* @param {object} tokens
*/
function pickTokens(tokens) {
const out = {}
if (!tokens || typeof tokens !== "object") return out
for (const key of ["access_token", "refresh_token", "expiry_date", "scope", "token_type", "id_token"]) {
if (tokens[key] !== undefined && tokens[key] !== null) out[key] = tokens[key]
}
return out
}
/**
* Meilleure miniature disponible d'un objet "thumbnails" de l'API (toutes optionnelles)
* @param {object} thumbnails
* @returns {string|null}
*/
function bestThumbnail(thumbnails) {
if (!thumbnails || typeof thumbnails !== "object") return null
return thumbnails.medium?.url || thumbnails.high?.url || thumbnails.standard?.url || thumbnails.default?.url || thumbnails.maxres?.url || null
}
module.exports = {
parseIsoDuration,
collectPages,
chunk,
createStateStore,
pickTokens,
bestThumbnail
}
+225 -40
View File
@@ -3,60 +3,245 @@ const { LogType } = require('loguix');
const alog = new LogType("YoutubeAPI");
const OAuth2 = require('./OAuth2');
const Users = require('../../server/auth/User');
const { GoogleError } = OAuth2;
const { collectPages, chunk, parseIsoDuration, bestThumbnail } = require('./Utils');
async function getYoutubePlaylists(userId) {
const user = Users.getUserById(userId);
if (!user) {
alog.error(`User with ID ${userId} not found.`);
return null;
}
// Limites d'une synchronisation (quota YouTube : 1 unité par page de 50)
const MAX_PLAYLISTS = 50;
const MAX_ITEMS_PER_PLAYLIST = 500;
const PAGE_SIZE = 50;
const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
// Titre de la playlist "J'aime" (Google renvoie "Liked videos" ou rien)
const LIKES_TITLE = "Vidéos « J'aime »";
const oAuth2Client = OAuth2.getOAuth2Client(userId);
if (!oAuth2Client) {
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`);
return null;
}
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
/**
* Service YouTube authentifié pour l'utilisateur
* @param {string} userId
* @throws {GoogleError} NOT_LINKED
*/
function getService(userId) {
const auth = OAuth2.getClient(userId);
if (!auth) throw new GoogleError("NOT_LINKED", "Aucun compte YouTube valide n'est lié");
return google.youtube({ version: 'v3', auth, timeout: OAuth2.REQUEST_TIMEOUT_MS });
}
/**
* Exécute un appel à l'API ; toute erreur est traduite (et le compte marqué invalide si besoin)
*/
async function call(userId, fn) {
try {
const response = await youtube.playlists.list({
part: 'snippet,contentDetails',
mine: true,
maxResults: 50
});
alog.log(`Retrieved playlists for user ${userId}.`);
return response.data.items;
return await fn();
} catch (error) {
alog.error(`Error retrieving playlists for user ${userId}:`, error);
return null;
throw OAuth2.handleApiError(userId, error);
}
}
function getYoutubePlaylistSongs(playlistId, userId) {
const user = Users.getUserById(userId);
if (!user) {
alog.error(`User with ID ${userId} not found.`);
return null;
/**
* Playlists de l'utilisateur (paginées, limitées à MAX_PLAYLISTS)
* @param {string} userId
* @param {{max?: number}} [options]
* @returns {Promise<Array>} éléments bruts de playlists.list
*/
async function getYoutubePlaylists(userId, { max = MAX_PLAYLISTS } = {}) {
const youtube = getService(userId);
const { items, truncated } = await collectPages((pageToken) => call(userId, async () => {
const response = await youtube.playlists.list({
part: ['snippet', 'contentDetails'],
mine: true,
maxResults: PAGE_SIZE,
pageToken
});
return response.data;
}), { maxItems: max });
if (truncated) alog.warn(`Playlists YouTube de l'utilisateur ${userId} limitées à ${max}`);
alog.log(`${items.length} playlist(s) YouTube récupérée(s) pour l'utilisateur ${userId}.`);
return items;
}
/**
* Informations d'une playlist par son id YouTube
* @returns {Promise<object|null>}
*/
async function getPlaylistInfo(userId, youtubeId) {
const youtube = getService(userId);
const response = await call(userId, () => youtube.playlists.list({
part: ['snippet', 'contentDetails'],
id: [youtubeId],
maxResults: 1
}));
return response?.data?.items?.[0] || null;
}
/**
* Éléments d'une playlist (paginés, limités à MAX_ITEMS_PER_PLAYLIST)
* @param {string} youtubeId
* @param {string} userId
* @returns {Promise<{items: Array, truncated: boolean}>}
*/
async function getPlaylistItems(userId, youtubeId, { max = MAX_ITEMS_PER_PLAYLIST } = {}) {
const youtube = getService(userId);
return collectPages((pageToken) => call(userId, async () => {
const response = await youtube.playlistItems.list({
part: ['snippet', 'contentDetails'],
playlistId: youtubeId,
maxResults: PAGE_SIZE,
pageToken
});
return response.data;
}), { maxItems: max });
}
// Compatibilité : ancienne signature (playlistId, userId) → { data: { items } }
async function getYoutubePlaylistSongs(playlistId, userId) {
const { items } = await getPlaylistItems(userId, playlistId);
return { data: { items } };
}
/**
* Durées des vidéos par paquets de 50 ids (1 unité de quota par appel)
* @param {string} userId
* @param {string[]} ids
* @param {Map<string, number>} [cache] durées déjà connues (partagées entre playlists)
* @returns {Promise<Map<string, number>>} id -> secondes (absent si inconnue)
*/
async function getVideoDurations(userId, ids, cache = new Map()) {
const youtube = getService(userId);
const missing = [...new Set(ids.filter(id => id && !cache.has(id)))];
for (const ids50 of chunk(missing, 50)) {
const response = await call(userId, () => youtube.videos.list({
part: ['contentDetails'],
id: ids50,
maxResults: 50
}));
for (const video of response?.data?.items || []) {
const seconds = parseIsoDuration(video?.contentDetails?.duration);
if (seconds !== null) cache.set(video.id, seconds);
}
}
return cache;
}
const oAuth2Client = OAuth2.getOAuth2Client(userId);
if (!oAuth2Client) {
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`);
return null;
/**
* Convertit les éléments de playlistItems.list en musiques jouables (vidéos privées / supprimées ignorées)
* @param {Array} items
* @param {Map<string, number>} durations
*/
function toSongs(items, durations) {
const songs = [];
const seen = new Set();
for (const item of items) {
const snippet = item?.snippet;
const videoId = item?.contentDetails?.videoId || snippet?.resourceId?.videoId;
if (!videoId || seen.has(videoId)) continue;
// Vidéo privée ou supprimée : aucune chaîne propriétaire
if (!snippet?.videoOwnerChannelId) continue;
seen.add(videoId);
songs.push({
id: videoId,
title: snippet.title || "Aucun titre",
author: snippet.videoOwnerChannelTitle || "Auteur inconnu",
authorId: `https://www.youtube.com/channel/${snippet.videoOwnerChannelId}`,
url: `https://www.youtube.com/watch?v=${videoId}`,
thumbnail: bestThumbnail(snippet.thumbnails) || DEFAULT_THUMBNAIL,
duration: durations.get(videoId) ?? 0,
type: "youtube"
});
}
return songs;
}
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
/**
* Playlist complète (métadonnées + musiques + durées) au format neutre utilisé par PlaylistManager
* @param {string} userId
* @param {object} info élément playlists.list, ou {id, snippet} construit pour la playlist "J'aime"
* @param {Map<string, number>} cache durées partagées
*/
async function buildPlaylist(userId, info, cache) {
const { items, truncated } = await getPlaylistItems(userId, info.id);
const ids = items.map(item => item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId).filter(Boolean);
await getVideoDurations(userId, ids, cache);
const songs = toSongs(items, cache);
if (truncated) alog.warn(`Playlist YouTube ${info.id} limitée à ${MAX_ITEMS_PER_PLAYLIST} éléments`);
return {
youtubeId: info.id,
title: info.snippet?.title || "Playlist YouTube",
description: info.snippet?.description || "",
author: info.snippet?.channelTitle || "Auteur inconnu",
authorId: info.snippet?.channelId ? `https://www.youtube.com/channel/${info.snippet.channelId}` : null,
thumbnail: bestThumbnail(info.snippet?.thumbnails) || songs[0]?.thumbnail || DEFAULT_THUMBNAIL,
url: `https://www.youtube.com/playlist?list=${info.id}`,
songs,
truncated
};
}
return youtube.playlistItems.list({
part: 'snippet',
playlistId: playlistId,
maxResults: 50
});
function likesInfo(userId) {
const record = OAuth2.getRecord(userId);
if (!record?.likesPlaylistId) return null;
return {
id: record.likesPlaylistId,
snippet: {
title: LIKES_TITLE,
description: "Vidéos aimées sur YouTube",
channelTitle: record.channel?.title || null,
channelId: record.channel?.id || null,
thumbnails: null
}
};
}
/**
* Récupère toutes les playlists de l'utilisateur (et ses « J'aime ») avec leurs musiques.
* Une playlist en erreur est ignorée ; une perte d'autorisation ou un quota épuisé interrompt tout.
* @param {string} userId
* @returns {Promise<{playlists: Array, skipped: number}>}
*/
async function fetchLibrary(userId) {
const infos = await getYoutubePlaylists(userId);
const likes = likesInfo(userId);
if (likes && !infos.some(info => info.id === likes.id)) infos.unshift(likes);
const cache = new Map();
const playlists = [];
let skipped = 0;
for (const info of infos) {
if (!info?.id) continue;
try {
playlists.push(await buildPlaylist(userId, info, cache));
} catch (error) {
if (["AUTH_EXPIRED", "QUOTA", "CONFIG_ERROR", "NOT_LINKED"].includes(error?.code)) throw error;
skipped++;
alog.warn(`Playlist YouTube ${info.id} ignorée : ${error?.message || error}`);
}
}
return { playlists, skipped };
}
/**
* Récupère une seule playlist (rafraîchissement d'une playlist importée)
* @param {string} userId
* @param {string} youtubeId
* @returns {Promise<object>}
* @throws {GoogleError}
*/
async function fetchPlaylist(userId, youtubeId) {
if (typeof youtubeId !== "string" || !/^[\w-]{2,64}$/.test(youtubeId)) {
throw new GoogleError("INVALID_PARAMS", "Identifiant de playlist YouTube invalide");
}
const likes = likesInfo(userId);
let info = likes && likes.id === youtubeId ? likes : await getPlaylistInfo(userId, youtubeId);
if (!info) throw new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
return buildPlaylist(userId, info, new Map());
}
module.exports = {
MAX_PLAYLISTS,
MAX_ITEMS_PER_PLAYLIST,
getYoutubePlaylists,
getYoutubePlaylistSongs
};
getYoutubePlaylistSongs,
getPlaylistItems,
getVideoDurations,
fetchLibrary,
fetchPlaylist,
toSongs
};
+36 -1
View File
@@ -2,9 +2,44 @@ const {LogType} = require("loguix")
const hlog = new LogType("PersonalHistory")
const {__glob} = require("../utils/GlobalVars")
const { Database } = require("../utils/Database/Database")
const { getReadableDuration } = require("../utils/TimeConverter")
const { toSafeNumber, toSafeDuration } = require("../utils/SafeMetric")
const historyDb = new Database("history", __glob.HISTORY_DB, {})
historyDb.load()
/**
* Copie de l'entrée avec une durée toujours numérique (jamais NaN, chaîne ou négative).
* Une playlist peut dépasser 24 h : seule une musique est bornée par toSafeDuration.
* @param {Object} entry
* @returns {Object}
*/
function sanitizeEntry(entry) {
if (!entry || typeof entry !== "object" || !("duration" in entry)) return entry
const isPlaylist = entry.form === "PLAYLIST"
const duration = isPlaylist ? Math.max(0, toSafeNumber(entry.duration, 0)) : toSafeDuration(entry.duration)
return { ...entry, duration, readduration: getReadableDuration(duration) }
}
// Au démarrage : répare les durées corrompues déjà enregistrées
try {
let repaired = 0
for (const userId of Object.keys(historyDb.data || {})) {
const entries = historyDb.data[userId]
if (!Array.isArray(entries)) continue
historyDb.data[userId] = entries.map(entry => {
const clean = sanitizeEntry(entry)
if (clean !== entry && (clean.duration !== entry.duration || clean.readduration !== entry.readduration)) repaired++
return clean
})
}
if (repaired > 0) {
hlog.warn(`Durées corrigées pour ${repaired} entrée(s) de l'historique`)
historyDb.save()
}
} catch (e) {
hlog.error("Erreur lors de la vérification des durées de l'historique : " + e)
}
/**
* @param {string} userId
* @returns {Array<Object>}
@@ -40,7 +75,7 @@ function addToPersonalHistory(userId, entry) {
if (history.length >= 25) {
history.shift();
}
history.push(entry)
history.push(sanitizeEntry(entry))
historyDb.save();
}
+13 -2
View File
@@ -1,4 +1,15 @@
const { getReadableDuration } = require("../utils/TimeConverter");
const { toSafeDuration } = require("../utils/SafeMetric");
/**
* Somme sûre des durées d'une liste de musiques (durée absente, chaîne ou NaN → 0)
* @param {Array<Object>} songs
* @returns {number}
*/
function computeDuration(songs) {
if (!Array.isArray(songs)) return 0;
return songs.reduce((acc, song) => acc + toSafeDuration(song?.duration), 0);
}
class Playlist {
title = "Aucun titre";
@@ -23,7 +34,7 @@ class Playlist {
this.thumbnail = thumbnail;
// Make the some of durations of the songs
if(this.songs.length > 0) {
this.duration = this.songs.reduce((acc, song) => acc + song.duration, 0);
this.duration = computeDuration(this.songs);
this.readduration = getReadableDuration(this.duration);
}
this.description = description;
@@ -33,5 +44,5 @@ class Playlist {
}
}
module.exports = {Playlist};
module.exports = {Playlist, computeDuration};
+292 -145
View File
@@ -1,22 +1,110 @@
const {Database} = require('../utils/Database/Database');
const {__glob} = require('../utils/GlobalVars');
const {Playlist} = require('./Playlist');
const {Playlist, computeDuration} = require('./Playlist');
const {LogType} = require('loguix');
const clog = new LogType("PlaylistManager");
const Finder = require('../player/Finder');
const spotify = require('../media/SpotifyInformation');
const { getYoutubePlaylistSongs } = require('./Google/YoutubeList');
const { auth } = require('googleapis/build/src/apis/abusiveexperiencereport');
const YoutubeList = require('./Google/YoutubeList');
const GoogleOAuth2 = require('./Google/OAuth2');
const { getReadableDuration } = require('../utils/TimeConverter');
const { getSecondsFromUrl } = require('../media/YoutubeInformation');
const { toSafeDuration } = require('../utils/SafeMetric');
const songCheck = require('../player/SongCheck');
// Délai minimal entre deux synchronisations YouTube réussies d'un même utilisateur (quota de l'API)
const SYNC_COOLDOWN_MS = 60 * 1000;
const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {});
/**
* Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...)
*/
function sameId(a, b) {
if (a === undefined || a === null || b === undefined || b === null) return false;
return String(a) === String(b);
}
/**
* @param {string} id
* @param {string} name
/**
* Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de l'utilisateur
* (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation)
* @param {Array} userPlaylists
* @returns {string}
*/
function newPlaylistId(userPlaylists) {
let candidate = Date.now();
const used = new Set((userPlaylists || []).map(p => String(p?.playlistId)));
while (used.has(String(candidate))) candidate++;
return String(candidate);
}
/**
* Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible.
* Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée.
* @returns {boolean} true si la musique a été modifiée
*/
function sanitizeSongDuration(song) {
if (!song || typeof song !== "object") return false;
const duration = toSafeDuration(song.duration);
const readduration = getReadableDuration(duration);
if (song.duration === duration && song.readduration === readduration) return false;
song.duration = duration;
song.readduration = readduration;
return true;
}
/**
* Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne)
* @returns {boolean} true si la playlist a été modifiée
*/
function refreshPlaylistDuration(playlist) {
if (!playlist || typeof playlist !== "object") return false;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
let changed = false;
for (const song of playlist.songs) {
if (sanitizeSongDuration(song)) changed = true;
}
const duration = computeDuration(playlist.songs);
const readduration = getReadableDuration(duration);
if (playlist.duration !== duration || playlist.readduration !== readduration) {
playlist.duration = duration;
playlist.readduration = readduration;
changed = true;
}
return changed;
}
// Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN)
// et donne un identifiant (chaîne) aux playlists qui n'en ont pas (anciens imports YouTube)
try {
let repaired = 0;
let identified = 0;
for (const userId of Object.keys(playlistDB.data || {})) {
const userPlaylists = playlistDB.data[userId];
if (!Array.isArray(userPlaylists)) continue;
for (const playlist of userPlaylists) {
if (refreshPlaylistDuration(playlist)) repaired++;
if (playlist && typeof playlist === "object") {
if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") {
playlist.playlistId = newPlaylistId(userPlaylists);
identified++;
} else if (typeof playlist.playlistId !== "string") {
playlist.playlistId = String(playlist.playlistId);
identified++;
}
}
}
}
if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`);
if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`);
if (repaired > 0 || identified > 0) playlistDB.save();
} catch (e) {
clog.error("Erreur lors de la vérification des playlists : " + e);
}
/**
* @param {string} id
* @returns {Array<Playlist>}
* @description Renvoie la liste des playlists de l'utilisateur
*/
@@ -30,17 +118,17 @@ function getPlaylistsOfUser(id) {
playlistDB.save();
return playlistDB.data[id];
}
}
/**
* @param {string} id
* @param {string} playlistId
* @param {string} id
* @param {string} playlistId
* @returns {Playlist}
*/
function getPlaylistOfUser(id, playlistId) {
const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId);
const playlist = playlists.find(p => sameId(p.playlistId, playlistId));
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
@@ -64,75 +152,76 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
}
if(playlistFounded instanceof Playlist) {
playlist = playlistFounded;
}
if(playlist.type === "spotify") {
playlist.songs = await spotify.getTracks(playlist);
}
if(!failed && playlist.type === "spotify") {
// getTracks remplace playlist.songs et renvoie la playlist elle-même
// (ne pas l'assigner à playlist.songs : référence circulaire impossible à sauvegarder)
await spotify.getTracks(playlist);
}
})
}
playlist.playlistId = new String(Date.now());
if(failed) {
clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`);
return null;
}
refreshPlaylistDuration(playlist);
// Chaîne primitive : un objet String ne serait jamais égal (===) à l'identifiant envoyé par le client
playlist.playlistId = newPlaylistId(playlists);
playlists.push(playlist);
playlistDB.save();
clog.log(`Ajout de la playlist ${name} pour l'utilisateur ${id}`);
return playlist;
}
}
function removePlaylist(id, playlistId) {
const playlists = getPlaylistsOfUser(id);
const index = playlists.findIndex(p => p.playlistId === playlistId);
const index = playlists.findIndex(p => sameId(p.playlistId, playlistId));
if (index === -1) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return;
return false;
}
playlists.splice(index, 1);
playlistDB.save();
clog.log(`Suppression de la playlist ${playlistId} pour l'utilisateur ${id}`);
return true;
}
function getPlaylist(id, playlistId) {
const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId);
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
return playlist;
return getPlaylistOfUser(id, playlistId);
}
/**
* Copie une playlist vers un autre utilisateur (copie indépendante, nouvel identifiant).
* Refusée si le destinataire possède déjà une playlist du même titre.
* @returns {Playlist|null} la copie, ou null
*/
function copyPlaylist(fromId, toId, playlistId) {
const playlists = getPlaylistsOfUser(fromId);
const playlist = playlists.find(p => p.playlistId === playlistId);
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${fromId}`);
return null;
}
const playlist = getPlaylistOfUser(fromId, playlistId);
if (!playlist) return null;
const toPlaylists = getPlaylistsOfUser(toId);
// Check if the playlist already exists in the target user
// Doublon : même titre chez le destinataire (Playlist n'a pas de champ "name")
if (toPlaylists.find(p => p.title === playlist.title)) {
clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`);
return null;
}
toPlaylists.push(playlist);
// Copie profonde : un même objet partagé entre deux utilisateurs serait modifié des deux côtés
const copy = JSON.parse(JSON.stringify(playlist));
copy.playlistId = newPlaylistId(toPlaylists);
// Une playlist synchronisée appartient au compte Google de l'expéditeur : la copie devient une playlist classique
delete copy.source;
toPlaylists.push(copy);
playlistDB.save();
clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`);
return false;
return copy;
}
function renamePlaylist(id, playlistId, newName) {
const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId);
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null;
playlist.title = newName;
playlistDB.save();
clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`);
return playlist;
}
function addSong(id, playlistId, song) {
@@ -149,121 +238,157 @@ function addSong(id, playlistId, song) {
clog.error(`La chanson ${song} n'est pas valide`);
return null;
}
const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId);
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
// Check the integrity of the song
if (!song.id || !song.title || !song.url) {
clog.error(`La chanson ${song.title} n'est pas valide`);
const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null;
// Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement
song = songCheck.sanitizeSong(song);
if (!song || !song.id || !song.title) {
clog.error(`La chanson reçue n'est pas valide`);
return null;
}
if (!Array.isArray(playlist.songs)) playlist.songs = [];
playlist.songs.push(song);
// Recalculate the songs duration and readduration
playlist.duration += song.duration;
playlist.readduration = getReadableDuration(playlist.duration);
// Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue)
refreshPlaylistDuration(playlist);
playlistDB.save();
clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist;
}
function removeSong(id, playlistId, songId) {
const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId);
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
const index = playlist.songs.findIndex(s => s.id === songId);
if (index === -1) {
clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`);
return null;
}
playlist.duration -= playlist.songs[index].duration;
playlist.songs.splice(index, 1);
playlist.readduration = getReadableDuration(playlist.duration);
// Recalcul complet plutôt qu'une soustraction (durée manquante → NaN, résultat négatif...)
refreshPlaylistDuration(playlist);
playlistDB.save();
clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist;
}
async function processYoutubeData(userId, data) {
if (!data || data.length === 0) {
// --- YOUTUBE (compte Google lié) ---
/**
* Construit une playlist enregistrable à partir des données neutres de YoutubeList
* @param {object} data résultat de YoutubeList.buildPlaylist
* @param {string} playlistId identifiant local (conservé d'une synchronisation à l'autre)
* @returns {Playlist}
*/
function playlistFromYoutube(data, playlistId) {
const playlist = new Playlist(data.title, data.url, data.author, data.authorId, data.songs, data.thumbnail, null, null, data.description || "Aucune description disponible");
playlist.id = data.youtubeId;
playlist.youtubeId = data.youtubeId;
// "google" : rafraîchie via l'API YouTube avec le compte du propriétaire (playlists privées, "J'aime")
playlist.source = "google";
playlist.type = "youtube";
playlist.playlistId = playlistId;
playlist.syncedAt = new Date().toISOString();
refreshPlaylistDuration(playlist);
return playlist;
}
/**
* Index d'une playlist déjà importée correspondant à une playlist YouTube
*/
function findImportedIndex(userPlaylists, youtubeId) {
const synced = userPlaylists.findIndex(p => p?.source === "google" && p.youtubeId === youtubeId);
if (synced !== -1) return synced;
// Anciens imports (sans "source") : id = identifiant YouTube
return userPlaylists.findIndex(p => p?.type === "youtube" && !p.source && p.id === youtubeId);
}
/**
* Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur.
* Les playlists déjà importées gardent leur identifiant local et leur position.
* @param {string} userId
* @param {Array} data
* @returns {Array<Playlist>}
*/
function processYoutubeData(userId, data) {
if (!Array.isArray(data) || data.length === 0) {
clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`);
return [];
}
const playlists = [];
for (const item of data) {
if (item.snippet && item.contentDetails) {
const playlist = new Playlist();
playlist.id = item.id;
playlist.title = item.snippet.title;
playlist.url = `https://www.youtube.com/playlist?list=${item.id}`;
playlist.description = item.snippet.description || "Aucune description disponible";
playlist.author = item.snippet.channelTitle;
playlist.thumbnail = item.snippet.thumbnails.default.url;
playlist.authorId = `https://www.youtube.com/channel/${item.snippet.channelId}`;
playlist.songs = []; // You can fetch songs later if needed
await getYoutubePlaylistSongs(item.id, userId).then(songsData => {
if (songsData && songsData.data && songsData.data.items) {
playlist.songs = songsData.data.items.map(song => ({
id: song.snippet.resourceId.videoId,
title: song.snippet.title,
author: song.snippet.videoOwnerChannelTitle,
authorId: `https://www.youtube.com/channel/${song.snippet.videoOwnerChannelId}`,
url: `https://www.youtube.com/watch?v=${song.snippet.resourceId.videoId}`,
thumbnail: song.snippet?.thumbnails?.default?.url || "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png",
}));
// Add readduration for every items in songs
} else {
clog.warn(`Aucune chanson trouvée pour la playlist ${item.id}`);
}
}).catch(err => {
clog.error(`Erreur lors de la récupération des chansons pour la playlist ${item.id}:`, err);
});
for (const song of playlist.songs) {
// If authorId is not defined, delete the song
if (song.authorId == "https://www.youtube.com/channel/undefined") {
clog.warn(`L'auteur de la chanson ${song.title} (${song.id}) n'est pas défini. Suppression de la chanson.`);
playlist.songs.splice(playlist.songs.indexOf(song), 1);
continue; // Skip this song
}
song.duration = await getSecondsFromUrl(song.url);
if (song.duration === null) {
clog.warn(`Impossible de récupérer la durée de la chanson ${song.title} (${song.id})`);
song.duration = 0; // Set to 0 if duration cannot be fetched
} else {
song.readduration = getReadableDuration(song.duration);
playlist.duration += song.duration; // Initialize duration if not set
}
}
playlist.readduration = getReadableDuration(playlist.duration);
playlist.type = "youtube";
playlists.push(playlist);
} else {
clog.warn(`Données YouTube manquantes pour l'élément ${item.id}`);
}
};
clog.log(`Traitement des données YouTube pour l'utilisateur ${userId} terminé. Nombre de playlists trouvées : ${playlists.length}`);
// Save the playlists to the user's playlist collection
const userPlaylists = getPlaylistsOfUser(userId);
// Remove existing playlists with the same IDs to avoid duplicates
for (const playlist of playlists) {
const existingIndex = userPlaylists.findIndex(p => p.id === playlist.id);
if (existingIndex !== -1) {
userPlaylists.splice(existingIndex, 1); // Remove existing playlist with the same ID
const result = [];
for (const item of data) {
if (!item?.youtubeId) continue;
const index = findImportedIndex(userPlaylists, item.youtubeId);
if (index !== -1) {
const playlist = playlistFromYoutube(item, userPlaylists[index].playlistId || newPlaylistId(userPlaylists));
userPlaylists.splice(index, 1, playlist);
result.push(playlist);
} else {
const playlist = playlistFromYoutube(item, newPlaylistId(userPlaylists));
userPlaylists.push(playlist);
result.push(playlist);
}
}
userPlaylists.push(...playlists);
playlistDB.save();
clog.log(`Playlists ajoutées pour l'utilisateur ${userId}. Nombre total de playlists : ${userPlaylists.length}`);
return playlists;
clog.log(`Playlists YouTube importées pour l'utilisateur ${userId} : ${result.length}. Nombre total de playlists : ${userPlaylists.length}`);
return result;
}
/**
* Synchronise toutes les playlists YouTube du compte Google lié (une seule à la fois par utilisateur).
* @param {string} userId
* @param {{force?: boolean}} [options] force : ignore le délai entre deux synchronisations
* @returns {Promise<{ok: boolean, error?: string, message?: string, playlists?: number, songs?: number, skipped?: number}>}
*/
async function syncYoutube(userId, { force = false } = {}) {
if (!GoogleOAuth2.isLinked(userId)) {
return { ok: false, error: "NOT_LINKED", message: "Aucun compte YouTube valide n'est lié" };
}
if (GoogleOAuth2.isSyncing(userId)) {
return { ok: false, error: "ALREADY_SYNCING", message: "Une synchronisation est déjà en cours" };
}
const lastSync = GoogleOAuth2.getRecord(userId)?.lastSync;
const lastAt = lastSync?.at ? Date.parse(lastSync.at) : NaN;
if (!force && lastSync?.ok && Number.isFinite(lastAt) && Date.now() - lastAt < SYNC_COOLDOWN_MS) {
return { ok: false, error: "COOLDOWN", message: "Synchronisation effectuée il y a moins d'une minute, réessayez plus tard" };
}
// Marqué avant le premier await : deux demandes simultanées ne lancent qu'une synchronisation
GoogleOAuth2.setSyncing(userId, true);
const startedAt = Date.now();
try {
clog.log(`Synchronisation YouTube de l'utilisateur ${userId}...`);
const { playlists, skipped } = await YoutubeList.fetchLibrary(userId);
const imported = processYoutubeData(userId, playlists);
const songs = imported.reduce((acc, p) => acc + (p.songs?.length || 0), 0);
GoogleOAuth2.setLastSync(userId, {
at: new Date().toISOString(),
ok: true,
playlists: imported.length,
songs,
skipped,
message: skipped > 0 ? `${skipped} playlist(s) ignorée(s)` : null
});
clog.log(`Synchronisation YouTube terminée pour l'utilisateur ${userId} : ${imported.length} playlist(s), ${songs} musique(s) en ${Date.now() - startedAt} ms`);
process.emit("PLAYLISTS_UPDATE", userId);
return { ok: true, playlists: imported.length, songs, skipped };
} catch (error) {
const described = GoogleOAuth2.describeApiError(error);
clog.error(`Échec de la synchronisation YouTube de l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`);
GoogleOAuth2.setLastSync(userId, {
at: new Date().toISOString(),
ok: false,
playlists: 0,
songs: 0,
skipped: 0,
message: described.message
});
return { ok: false, error: described.code, message: described.message };
} finally {
GoogleOAuth2.setSyncing(userId, false);
}
}
async function deleteUserPlaylists(userId) {
@@ -277,39 +402,60 @@ async function deleteUserPlaylists(userId) {
}
async function refreshPlaylist(userId, playlistId) {
var playlist = getPlaylistOfUser(userId, playlistId);
if (!playlist) {
const current = getPlaylistOfUser(userId, playlistId);
if (!current) {
clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId} avec l'ID ${playlistId}`);
return null;
}
const localId = current.playlistId;
var playlist = current;
let failed = false;
// If playlistHasUrl, refresh the playlist
if (playlist.url) {
await Finder.search(playlist.url, false, "PLAYLIST").then(async (playlistFounded) => {
if (current.source === "google" && current.youtubeId && GoogleOAuth2.isLinked(userId)) {
// Playlist importée : l'API (compte du propriétaire) voit aussi les playlists privées et "J'aime"
try {
const data = await YoutubeList.fetchPlaylist(userId, current.youtubeId);
playlist = playlistFromYoutube(data, localId);
} catch (error) {
clog.warn(`Échec de la mise à jour via l'API YouTube de la playlist ${playlistId} : ${error?.message || error}`);
failed = true;
}
} else if (current.url) {
// Sinon : recherche publique par l'URL (les playlists privées échouent)
await Finder.search(current.url, false, "PLAYLIST").then(async (playlistFounded) => {
if(!playlistFounded) {
failed = true;
}
if(playlistFounded instanceof Playlist) {
playlist = playlistFounded;
}
if(playlist.type === "spotify") {
playlist.songs = await spotify.getTracks(playlist);
}
if(!failed && playlist.type === "spotify") {
// getTracks remplace playlist.songs et renvoie la playlist elle-même
await spotify.getTracks(playlist);
}
})
// Une playlist importée rafraîchie publiquement reste rattachée au compte Google
if (!failed && current.source === "google" && playlist !== current) {
playlist.source = "google";
playlist.youtubeId = current.youtubeId;
}
}
if(failed) {
clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`);
return null;
}
playlist.playlistId = playlistId;
refreshPlaylistDuration(playlist);
playlist.playlistId = localId;
const playlists = getPlaylistsOfUser(userId);
// Remove the older one
// Push at the same index
const existingIndex = playlists.findIndex(p => p.playlistId === playlistId);
// Remplacement à la même position
const existingIndex = playlists.findIndex(p => sameId(p.playlistId, localId));
if (existingIndex === -1) {
// Supprimée entre-temps : splice(-1) aurait remplacé la dernière playlist de l'utilisateur
clog.warn(`La playlist ${playlistId} a été supprimée pendant sa mise à jour pour l'utilisateur ${userId}`);
return null;
}
playlists.splice(existingIndex, 1, playlist);
playlistDB.save();
return playlist;
@@ -326,6 +472,7 @@ module.exports = {
addSong,
removeSong,
processYoutubeData,
syncYoutube,
deleteUserPlaylists,
refreshPlaylist
}
}
+2072
View File
File diff suppressed because it is too large. Load diff
+518 -432
View File
File diff suppressed because it is too large. Load diff
+109 -97
View File
@@ -1,11 +1,35 @@
const { LogType } = require('loguix');
const { LogType } = require('loguix');
const dlog = new LogType("DiscordAuth");
const { getWebsiteLink, getClientSecret } = require('../../utils/Database/Configuration');
// Délai maximal d'une requête vers l'API Discord
const REQUEST_TIMEOUT = 10 * 1000;
/**
* Appelle l'API Discord et renvoie le JSON, en levant une erreur si le statut HTTP n'est pas 2xx
* (429 / 401 renvoient un JSON {message, retry_after} qui ne doit jamais être pris pour un utilisateur)
*/
async function discordFetch(url, options = {}) {
const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
let body = null;
try {
body = await resp.json();
} catch (e) {
body = null;
}
if (!resp.ok) {
const detail = body?.error_description || body?.error || body?.message || resp.statusText;
const error = new Error(`HTTP ${resp.status} : ${detail}`);
error.status = resp.status;
throw error;
}
return body;
}
/**
* @returns {Promise<{auth, identity}|"ACCESS_TOKEN_ERROR"|"USER_INFO_ERROR">}
*/
async function getDiscordUser(sessionId, auth_code) {
return new Promise((resolve, reject) => {
const discordBot = require("../../discord/Bot")
const discordBotClient = discordBot.getClient()
dlog.step.init("discord_auth_" + sessionId, "Authentification Discord de la session :" + sessionId);
@@ -18,109 +42,97 @@ async function getDiscordUser(sessionId, auth_code) {
params.append("grant_type", "authorization_code");
params.append("code", auth_code);
params.append("redirect_uri", getWebsiteLink() + "/redirect");
params.append("scope", "identify guilds");
fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
}).then(accessTokenResp => accessTokenResp.json()).then(accessToken => {
if (accessToken.error) {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + accessToken.error + " : " + accessToken.error_description);
resolve("ACCESS_TOKEN_ERROR");
return;
}
dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId);
fetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
},
}).then(userResp => userResp.json()).then(user => {
dlog.log("Récupération réussi des informations de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")");
// Get the guilds of the user
fetch("https://discord.com/api/users/@me/guilds", {
headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
},
}).then(guildsResp => guildsResp.json()).then(guilds => {
dlog.log("Récupération réussi des guildes de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")");
dlog.step.end("discord_auth_" + sessionId)
const userData = {
auth: accessToken,
identity: user,
guilds: guilds,
}
resolve(userData);
}).catch(err => {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des guildes de l'utilisateur Discord" + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")" + " associé à la session : " + sessionId + " : " + err);
resolve("GUILDS_ERROR");
});
}).catch(err => {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err);
resolve( "USER_INFO_ERROR");
})
}).catch(err => {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err);
resolve("ACCESS_TOKEN_ERROR");
})
})
}
function refreshToken(refresh_token) {
return new Promise((resolve, reject) => {
const discordBot = require("../../discord/Bot")
const params = new URLSearchParams();
params.append("client_id", discordBot.getClient().user.id);
params.append("client_secret", getClientSecret());
params.append("grant_type", "refresh_token");
params.append("refresh_token", refresh_token);
params.append("scope", "identify guilds");
fetch("https://discord.com/api/oauth2/token", {
let accessToken;
try {
accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
}).then(accessTokenResp => accessTokenResp.json()).then(accessToken => {
if (accessToken.error) {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken.error + " : " + accessToken.error_description);
resolve(null);
return;
}
resolve(accessToken);
}).catch(err => {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err);
resolve(null);
})
})
}
});
if (!accessToken?.access_token || accessToken.error) {
throw new Error((accessToken?.error || "Réponse invalide") + " : " + (accessToken?.error_description || ""));
}
} catch (err) {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err.message);
return "ACCESS_TOKEN_ERROR";
}
dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId);
function getUserIdentity(accessToken) {
return new Promise((resolve, reject) => {
fetch("https://discord.com/api/users/@me", {
// Le scope demandé par le site est "identify" : l'appel /users/@me/guilds était toujours refusé et inutilisé
try {
const user = await discordFetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
},
}).then(userResp => userResp.json()).then(user => {
if (user.error) {
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + user.error + " : " + user.error_description);
resolve(null);
return;
}
resolve(user);
}).catch(err => {
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err);
resolve(null);
})
})
});
if (!user?.id) throw new Error("Identité Discord sans identifiant");
dlog.log("Récupération réussi des informations de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")");
dlog.step.end("discord_auth_" + sessionId)
return {
auth: accessToken,
identity: user,
};
} catch (err) {
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err.message);
return "USER_INFO_ERROR";
}
}
module.exports = {getDiscordUser, refreshToken, getUserIdentity}
/**
* @returns {Promise<Object|null>} nouveau token d'accès, null en cas d'échec
*/
async function refreshToken(refresh_token) {
if (!refresh_token) return null;
const discordBot = require("../../discord/Bot")
const params = new URLSearchParams();
params.append("client_id", discordBot.getClient().user.id);
params.append("client_secret", getClientSecret());
params.append("grant_type", "refresh_token");
params.append("refresh_token", refresh_token);
try {
const accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
if (!accessToken?.access_token || accessToken.error) {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken?.error + " : " + accessToken?.error_description);
return null;
}
return accessToken;
} catch (err) {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err.message);
return null;
}
}
/**
* @returns {Promise<Object|null>} identité Discord (/users/@me), null en cas d'échec
*/
async function getUserIdentity(accessToken) {
if (!accessToken?.access_token) return null;
try {
const user = await discordFetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
},
});
if (!user?.id) {
dlog.error("Réponse de Discord sans identifiant utilisateur");
return null;
}
return user;
} catch (err) {
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err.message);
return null;
}
}
module.exports = {getDiscordUser, refreshToken, getUserIdentity}
+47 -10
View File
@@ -1,33 +1,70 @@
const { LogType } = require('loguix');
const { generateSessionId } = require('../../utils/TokenManager');
const { generateSessionId } = require('../../utils/TokenManager');
const clog = new LogType("Session");
const sessions = new Array();
// Une session OAuth n'est utile que le temps de la redirection Discord : on l'expire après 10 minutes
const SESSION_TTL = 10 * 60 * 1000;
const CLEAN_INTERVAL = 60 * 1000;
// sessionId -> date de création (ms)
const sessions = new Map();
/**
* Supprime les sessions expirées (OAuth jamais terminé)
*/
function purgeExpired() {
const now = Date.now();
let removed = 0;
for (const [sessionId, createdAt] of sessions) {
if (now - createdAt > SESSION_TTL) {
sessions.delete(sessionId);
removed++;
}
}
if (removed > 0) clog.log(`${removed} session(s) expirée(s) supprimée(s).`);
return removed;
}
setInterval(purgeExpired, CLEAN_INTERVAL).unref();
function checkSession(sessionId) {
return sessions.includes(sessionId);
if (typeof sessionId !== "string") return false;
const createdAt = sessions.get(sessionId);
if (createdAt === undefined) return false;
if (Date.now() - createdAt > SESSION_TTL) {
sessions.delete(sessionId);
clog.warn(`Session ${sessionId} expirée.`);
return false;
}
return true;
}
function addSession() {
purgeExpired();
const sessionId = generateSessionId();
if (checkSession(sessionId)) {
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`);
if (sessions.has(sessionId)) {
clog.warn(`La session ${sessionId} existe déjà, génération d'un nouvel identifiant.`);
return addSession(); // Recursively generate a new session ID if it already exists
}
sessions.push(sessionId);
sessions.set(sessionId, Date.now());
clog.log(`Nouvelle session ${sessionId} ajoutée.`);
return sessionId;
}
function removeSession(sessionId) {
const index = sessions.indexOf(sessionId);
if (index > -1) {
sessions.splice(index, 1);
if (sessions.delete(sessionId)) {
clog.log(`Suppression de la session ${sessionId}.`);
} else {
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`);
}
}
module.exports = {checkSession, addSession, removeSession};
/**
* Nombre de sessions OAuth en attente (vue d'ensemble de l'administration)
*/
function getSessionCount() {
purgeExpired();
return sessions.size;
}
module.exports = {checkSession, addSession, removeSession, getSessionCount};
+214 -68
View File
@@ -4,53 +4,86 @@ const { generateToken } = require('../../utils/TokenManager');
const { LogType } = require('loguix');
const clog = new LogType("User");
const discordAuth = require('./DiscordAuth');
const e = require('cors');
const UserDB = new Database("Users", __glob.USERFILE, []);
var userList = new Array();
/**
* Ajoute ou retire un label. value === undefined : bascule (ancien comportement).
* @returns {boolean} présence du label après l'opération
*/
function applyLabel(labels, label, value) {
const has = labels.includes(label);
const wanted = value === undefined ? !has : !!value;
if (wanted && !has) labels.push(label);
if (!wanted && has) {
// Retire toutes les occurrences (doublons éventuels dans users.json)
for (let i = labels.length - 1; i >= 0; i--) {
if (labels[i] === label) labels.splice(i, 1);
}
}
return wanted;
}
class User {
auth;
identity;
tokens;
labels;
constructor(auth, identity, tokens, labels) {
banInfo;
constructor(auth, identity, tokens, labels, banInfo) {
this.auth = auth;
this.identity = identity;
this.tokens = tokens;
this.labels = labels;
this.tokens = tokens || [];
this.labels = labels || [];
// Raison et date du bannissement global : {reason, at} ou null
this.banInfo = banInfo || null;
}
setAdmin() {
/**
* @param {boolean} [value] true = admin, false = retiré, undefined = bascule
* @returns {boolean|null} nouvel état
*/
setAdmin(value) {
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null;
}
if (userInUserList.labels.includes("ADMIN")) {
userInUserList.labels.splice(userInUserList.labels.indexOf("ADMIN"), 1);
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`);
} else {
userInUserList.labels.push("ADMIN");
const wasAdmin = userInUserList.labels.includes("ADMIN");
const isAdmin = applyLabel(userInUserList.labels, "ADMIN", value);
if (wasAdmin === isAdmin) return isAdmin;
if (isAdmin) {
clog.log(`L'utilisateur ${this.identity.username} est maintenant admin.`);
} else {
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`);
}
saveUsers()
return isAdmin;
}
setBan(guildId) {
/**
* @param {string} guildId
* @param {boolean} [value] undefined = bascule
* @returns {boolean|null} nouvel état
*/
setBan(guildId, value) {
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null;
}
const banLabel = `BAN_${guildId}`;
if (userInUserList.labels.includes(banLabel)) {
userInUserList.labels.splice(userInUserList.labels.indexOf(banLabel), 1);
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`);
} else {
userInUserList.labels.push(banLabel);
const wasBanned = userInUserList.labels.includes(banLabel);
const banned = applyLabel(userInUserList.labels, banLabel, value);
if (wasBanned === banned) return banned;
if (banned) {
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni du serveur ${guildId}.`);
} else {
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`);
}
saveUsers()
return banned;
}
createToken() {
@@ -118,20 +151,36 @@ class User {
}
setFullBan() {
/**
* Bannissement global
* @param {boolean} [value] undefined = bascule
* @param {string} [reason]
* @returns {boolean|null} nouvel état
*/
setFullBan(value, reason) {
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null;
}
if (userInUserList.labels.find(label => label == "BAN")) {
userInUserList.labels.splice(userInUserList.labels.indexOf("BAN"), 1);
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`);
const wasBanned = userInUserList.labels.includes("BAN");
const banned = applyLabel(userInUserList.labels, "BAN", value);
if (banned) {
if (!wasBanned || reason) {
userInUserList.banInfo = { reason: reason ? String(reason).slice(0, 500) : null, at: new Date().toISOString() };
}
} else {
userInUserList.labels.push("BAN");
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`);
userInUserList.banInfo = null;
}
if (wasBanned !== banned) {
if (banned) {
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`);
} else {
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`);
}
}
saveUsers()
return banned;
}
isBanned(guildId) {
@@ -148,6 +197,9 @@ class User {
const modLabel = `MOD_${guildId}`;
return this.labels.includes(modLabel) || this.isAdmin();
}
isDeleted() {
return this.labels.includes("DELETED");
}
justUpdated() {
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
@@ -187,13 +239,19 @@ class User {
async function refreshAllUserInformation() {
await loadUsers();
clog.log("Récupération des informations de tous les utilisateurs...");
for (const user of userList) {
await updateCredientials(user.identity.id);
// Une seule sauvegarde à la fin plutôt qu'une réécriture complète du fichier par utilisateur
for (const user of [...userList]) {
if (!user.auth) continue;
await updateCredientials(user.identity.id, false);
}
saveUsers();
}
async function updateCredientials(id) {
/**
* @param {string} id
* @param {boolean} [save=true] false pour regrouper les sauvegardes (rafraîchissement global)
*/
async function updateCredientials(id, save = true) {
const user = getUserById(id);
if (!user) {
clog.warn(`Utilisateur ${id} non trouvé.`);
@@ -223,7 +281,7 @@ async function updateCredientials(id) {
else {
clog.warn(`Aucune authentification trouvée pour l'utilisateur ${user.identity.username} (${user.identity.id})`);
}
saveUsers();
if (save) saveUsers();
return user.auth;
}
@@ -277,6 +335,11 @@ async function updateIdentity(id) {
* @returns {User} user
*/
async function addUser(auth, identity) {
// Une réponse d'erreur de Discord (429, 401...) n'a pas d'id : on ne crée jamais d'utilisateur fantôme
if (!identity || typeof identity.id !== "string" || identity.id === "") {
clog.error("Ajout d'un utilisateur refusé : identité Discord invalide");
return null;
}
// Check if the user already exists
const existingUser = userList.find(user => user.identity.id === identity.id);
if (existingUser) {
@@ -320,12 +383,12 @@ function removeToken(token) {
if (index > -1) {
user.tokens.splice(index, 1);
saveUsers();
clog.log(`Token ${token} supprimé pour l'utilisateur ${user.identity.username}.`);
clog.log(`Token supprimé pour l'utilisateur ${user.identity.username}.`);
} else {
clog.warn(`Token ${token} non trouvé pour l'utilisateur ${user.identity.username}.`);
clog.warn(`Token non trouvé pour l'utilisateur ${user.identity.username}.`);
}
} else {
clog.warn(`Utilisateur avec le token "${token}" non trouvé.`);
clog.warn(`Utilisateur introuvable pour le token fourni.`);
}
return user;
}
@@ -336,7 +399,7 @@ function addToken(id) {
const token = generateToken();
user.tokens.push(token);
saveUsers();
clog.log(`Token "${token}" ajouté pour l'utilisateur ${user.identity.username}.`);
clog.log(`Token ajouté pour l'utilisateur ${user.identity.username}.`);
return token;
} else {
clog.warn(`Utilisateur ${id} non trouvé.`);
@@ -392,74 +455,151 @@ function getSimpleUser(id) {
// SET LABELS
async function setAdmin(id) {
/**
* Toutes les fonctions de labels acceptent une valeur explicite (idempotent) ;
* sans valeur, elles basculent l'état comme avant (compatibilité).
* @returns {Promise<boolean|null>} nouvel état, null si l'utilisateur est introuvable ou protégé
*/
async function setAdmin(id, value) {
const user = getUserById(id);
if (user) {
await user.setAdmin();
await saveUsers();
return user.setAdmin(value);
} else {
clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
}
}
async function setGuildMod(id, guildId) {
async function setGuildMod(id, guildId, value) {
const user = getUserById(id);
if (user) {
const modLabel = `MOD_${guildId}`;
if (user.labels.includes(modLabel)) {
await user.labels.splice(user.labels.indexOf(modLabel), 1);
clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`);
} else {
await user.labels.push(modLabel);
clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`);
const wasMod = user.labels.includes(modLabel);
const isMod = applyLabel(user.labels, modLabel, value);
if (wasMod !== isMod) {
if (isMod) {
clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`);
} else {
clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`);
}
saveUsers();
}
await saveUsers();
return isMod;
} else {
clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
}
}
async function setGuildBan(id, guildId) {
async function setGuildBan(id, guildId, value) {
const user = getUserById(id);
if (user) {
if(user.isAdmin()) {
if(user.isAdmin() && (value === undefined ? !user.isBanned(guildId) : value)) {
clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
return;
return null;
}
await user.setBan(guildId);
await saveUsers();
return user.setBan(guildId, value);
} else {
clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
}
}
async function setFullBan(id) {
async function setFullBan(id, value, reason) {
const user = getUserById(id);
if (user) {
if(user.isAdmin()) {
if(user.isAdmin() && (value === undefined ? !user.isFullBanned() : value)) {
clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
return;
return null;
}
await user.setFullBan();
await saveUsers();
return user.setFullBan(value, reason);
} else {
clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
}
}
function deleteAccount(id) {
/**
* Révoque toutes les sessions web (tokens) d'un utilisateur
* @returns {number} nombre de tokens révoqués
*/
function revokeTokens(id) {
const user = getUserById(id);
if (user) {
user.labels = user.labels.filter(label => label.includes('BAN'));
user.labels.push('DELETED'); // Add a deleted label
user.tokens = []; // Clear tokens
user.auth = null; // Clear authentication
user.identity = { id: user.identity.id, username: user.identity.username }; // Keep only identity information
saveUsers();
clog.log(`Suppression du compte de l'utilisateur ${user.identity.username}.`);
} else {
if (!user) {
clog.warn(`Utilisateur ${id} non trouvé.`);
return 0;
}
const count = user.tokens?.length || 0;
if (count > 0) user.clearToken();
return count;
}
/**
* Suppression douce : garde l'entrée et les labels BAN (un banni ne retrouve pas un compte vierge),
* ajoute DELETED et efface tokens, authentification et identité détaillée.
* @param {string} id
* @param {{purge?: boolean}} [options] purge : supprime aussi playlists, historique et médias
* @returns {Promise<{playlists: number, history: number, media: number}|null>} éléments purgés, null si introuvable
*/
async function deleteAccount(id, { purge = false } = {}) {
const user = getUserById(id);
if (!user) {
clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
}
user.labels = user.labels.filter(label => label.includes('BAN'));
user.labels.push('DELETED'); // Add a deleted label
user.tokens = []; // Clear tokens
user.auth = null; // Clear authentication
user.identity = { id: user.identity.id, username: user.identity.username }; // Keep only identity information
saveUsers();
clog.log(`Suppression du compte de l'utilisateur ${user.identity.username}.`);
// Jetons Google (identifiants, pas des données) : toujours supprimés, révocation chez Google sans attendre
try {
// Chargement différé : OAuth2.js dépend de ce module
require('../../playlists/Google/OAuth2').unlink(id, { revoke: true }).catch((e) => {
clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`);
});
} catch (e) {
clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`);
}
const purged = { playlists: 0, history: 0, media: 0 };
if (!purge) return purged;
// Chargement différé : évite les dépendances circulaires au démarrage
const { getDatabase } = require('../../utils/Database/Database');
try {
const playlistDB = getDatabase("Playlists");
purged.playlists = playlistDB?.data?.[id]?.length || 0;
await require('../../playlists/PlaylistManager').deleteUserPlaylists(id);
} catch (e) {
clog.error(`Purge des playlists de ${id} impossible : ${e.message}`);
}
try {
const historyDB = getDatabase("history");
purged.history = historyDB?.data?.[id]?.length || 0;
if (historyDB?.data?.[id]) {
delete historyDB.data[id];
historyDB.save();
}
} catch (e) {
clog.error(`Purge de l'historique de ${id} impossible : ${e.message}`);
}
try {
const mediaDB = getDatabase("media");
if (Array.isArray(mediaDB?.data)) {
const before = mediaDB.data.length;
mediaDB.data = mediaDB.data.filter(m => m.userId !== id);
purged.media = before - mediaDB.data.length;
if (purged.media > 0) mediaDB.save();
}
} catch (e) {
clog.error(`Purge des médias de ${id} impossible : ${e.message}`);
}
clog.log(`Purge des données de l'utilisateur ${id} : ${purged.playlists} playlist(s), ${purged.history} entrée(s) d'historique, ${purged.media} média(s).`);
return purged;
}
// USERS DB
@@ -468,12 +608,15 @@ function loadUsers() {
UserDB.load()
userList = new Array();
for (const user of UserDB.getData()) {
if(!user?.identity?.id) {
clog.warn("Entrée utilisateur sans identifiant ignorée dans users.json.");
continue;
}
if(user?.labels?.includes("DELETED")) {
clog.log(`Utilisateur ${user.identity.id} marqué comme supprimé, ignoré.`);
userList.push(new User(null, user.identity, [], user.labels));
userList.push(new User(null, user.identity, [], user.labels, user.banInfo));
continue; // Skip deleted users
}
userList.push(new User(user.auth, user.identity, user.tokens, user.labels));
userList.push(new User(user.auth, user.identity, user.tokens, user.labels, user.banInfo));
}
clog.log(`Chargement de ${userList.length} utilisateurs.`);
return userList;
@@ -481,12 +624,14 @@ function loadUsers() {
function saveUsers() {
UserDB.data = userList.map(user => {
return {
const data = {
auth: user.auth,
identity: user.identity,
tokens: user.tokens,
labels: user.labels,
};
if (user.banInfo) data.banInfo = user.banInfo;
return data;
});
UserDB.save()
clog.log(`Sauvegarde de ${userList.length} utilisateurs.`);
@@ -503,9 +648,9 @@ function clearNeedUpdateForUsers() {
}
module.exports = {User}
module.exports = {
addUser,
User,
addUser,
setFullBan,
removeUser,
getUserByToken,
@@ -522,5 +667,6 @@ module.exports = {
refreshAllUserInformation,
updateIdentity,
clearNeedUpdateForUsers,
deleteAccount
deleteAccount,
revokeTokens
};
+181 -15
View File
@@ -2,7 +2,6 @@ const {Database} = require("./Database")
const {__glob} = require("../GlobalVars")
const {LogType} = require("loguix")
const path = require("path")
const { get } = require("http")
const clog = new LogType("Configuration")
@@ -31,33 +30,194 @@ const config = new Database("config", __glob.DATA + path.sep + "config.json", {
}
})
/**
* Clés modifiables depuis l'administration (liste blanche : aucune autre clé n'est accessible).
* - type : snowflake | url | port | text
* - secret : la valeur n'est jamais renvoyée (seulement {set, length}) ni journalisée
* - optional : la chaîne vide est acceptée (efface la valeur)
* - requiresRestart : la valeur n'est lue qu'au démarrage (ou à la connexion du bot)
*/
const KEYS = {
"token": { type: "text", secret: true, optional: false, requiresRestart: true, max: 200 },
// Lu à chaque connexion OAuth Discord (DiscordAuth.js) : pris en compte à chaud
"client_secret": { type: "text", secret: true, optional: false, requiresRestart: false, max: 200 },
"website": { type: "url", secret: false, optional: true, requiresRestart: false, max: 300 },
"server_port": { type: "port", secret: false, optional: false, requiresRestart: true },
"report.channel": { type: "snowflake", secret: false, optional: true, requiresRestart: false },
"report.contact": { type: "snowflake", secret: false, optional: true, requiresRestart: false },
// MediaBase résout le salon une seule fois, quand le bot se connecte à Discord
"media.guildId": { type: "snowflake", secret: false, optional: true, requiresRestart: true },
"media.channelId": { type: "snowflake", secret: false, optional: true, requiresRestart: true },
"api.youtube.clientId": { type: "text", secret: false, optional: true, requiresRestart: false, max: 300 },
"api.youtube.clientSecret": { type: "text", secret: true, optional: true, requiresRestart: false, max: 300 },
"api.spotify.clientId": { type: "text", secret: false, optional: true, requiresRestart: false, max: 200 },
"api.spotify.clientSecret": { type: "text", secret: true, optional: true, requiresRestart: false, max: 200 }
}
const SNOWFLAKE = /^\d{5,25}$/
function invalid(message) {
return Object.assign(new Error(message), { code: "INVALID_PARAMS" })
}
function readPath(key) {
let node = config.data
for (const part of key.split(".")) {
if (node === null || typeof node !== "object") return undefined
node = node[part]
}
return node
}
function writePath(key, value) {
const parts = key.split(".")
let node = config.data
for (const part of parts.slice(0, -1)) {
// Fichier édité à la main sans la section : on la recrée
if (node[part] === null || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {}
node = node[part]
}
node[parts[parts.length - 1]] = value
}
/**
* Valide et normalise une valeur pour une clé de la liste blanche (fonction pure).
* @returns {string|number} valeur à enregistrer
* @throws {Error} code INVALID_PARAMS, message en français
*/
function validateValue(key, value) {
const def = KEYS[key]
if (!def) throw invalid(`Clé de configuration inconnue : "${key}"`)
if (def.type === "port") {
const n = typeof value === "string" && value.trim() !== "" ? Number(value.trim()) : value
if (!Number.isInteger(n) || n < 1 || n > 65535) throw invalid("Port invalide (entier entre 1 et 65535)")
return n
}
if (typeof value !== "string") throw invalid(`Valeur invalide pour "${key}" (texte attendu)`)
const text = value.trim()
if (text === "") {
if (!def.optional) throw invalid(`"${key}" ne peut pas être vide`)
return ""
}
if (/[\r\n\0]/.test(text)) throw invalid(`Valeur invalide pour "${key}" (caractères interdits)`)
switch (def.type) {
case "snowflake":
if (!SNOWFLAKE.test(text)) throw invalid(`Identifiant Discord invalide pour "${key}" (5 à 25 chiffres)`)
return text
case "url": {
let parsed = null
try {
parsed = new URL(text)
} catch (e) {
parsed = null
}
if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(text)) throw invalid("Adresse du site invalide (lien http(s) attendu)")
if (parsed.username || parsed.password) throw invalid("L'adresse du site ne doit pas contenir d'identifiants")
if (parsed.search || parsed.hash) throw invalid("L'adresse du site ne doit contenir ni paramètres (?) ni ancre (#)")
if (text.length > def.max) throw invalid(`Adresse du site trop longue (${def.max} caractères max)`)
// Sans "/" final : les modules ajoutent "/redirect" et "/oauth2callback"
return (parsed.origin + parsed.pathname).replace(/\/+$/, "")
}
default:
// Jetons et identifiants d'API ne contiennent jamais d'espace (copier-coller incomplet)
if (/\s/.test(text)) throw invalid(`Valeur invalide pour "${key}" (espaces interdits)`)
if (text.length > def.max) throw invalid(`Valeur trop longue pour "${key}" (${def.max} caractères max)`)
return text
}
}
// Valeurs lues au démarrage : une clé « redémarrage requis » modifiée depuis est signalée en attente
const bootValues = {}
for (const [key, def] of Object.entries(KEYS)) {
if (def.requiresRestart) bootValues[key] = readPath(key)
}
function isPendingRestart(key) {
return !!KEYS[key]?.requiresRestart && readPath(key) !== bootValues[key]
}
/**
* Configuration pour l'administration : jamais la valeur d'un secret, seulement {set, length}
*/
function getMasked() {
const result = {}
for (const [key, def] of Object.entries(KEYS)) {
const raw = readPath(key)
const entry = {
type: def.type,
secret: def.secret,
optional: def.optional,
requiresRestart: def.requiresRestart,
pendingRestart: isPendingRestart(key)
}
if (def.secret) {
const text = typeof raw === "string" ? raw : ""
entry.set = text.length > 0
entry.length = text.length
} else {
entry.value = raw === undefined || raw === null ? (def.type === "port" ? null : "") : raw
}
result[key] = entry
}
return result
}
/**
* Modifie une clé de la liste blanche, enregistre config.json puis prévient les modules (CONFIG_UPDATE)
* @returns {{key: string, changed: boolean, requiresRestart: boolean, pendingRestart: boolean}}
*/
function setValue(key, value) {
const normalized = validateValue(key, value)
const def = KEYS[key]
const changed = readPath(key) !== normalized
if (changed) {
writePath(key, normalized)
config.save()
// Jamais la valeur dans les logs (secrets)
clog.log(`Configuration modifiée : ${key}` + (def.requiresRestart ? " (redémarrage requis)" : ""))
try {
process.emit("CONFIG_UPDATE", { key })
} catch (e) {
clog.error(`Notification de la modification de configuration impossible : ${e?.message || e}`)
}
}
return { key, changed, requiresRestart: def.requiresRestart, pendingRestart: isPendingRestart(key) }
}
function getKeys() {
return Object.keys(KEYS)
}
function getToken() {
return config.data.token
}
function getReportChannel() {
return config.data.report.channel
return config.data.report?.channel
}
function getReportContact() {
return config.data.report.contact
}
return config.data.report?.contact
}
function getYoutubeApiClientId() {
return config.data.api.youtube.clientId
return config.data.api?.youtube?.clientId
}
function getYoutubeApiClientSecret() {
return config.data.api.youtube.clientSecret
return config.data.api?.youtube?.clientSecret
}
function getSpotifyClientId() {
return config.data.api.spotify.clientId
return config.data.api?.spotify?.clientId
}
function getSpotifyClientSecret() {
return config.data.api.spotify.clientSecret
return config.data.api?.spotify?.clientSecret
}
function getWebsiteLink() {
@@ -73,15 +233,16 @@ function getClientSecret() {
}
function getMediaGuildId() {
return config.data.media.guildId
return config.data.media?.guildId
}
function getMediaChannelId() {
return config.data.media.channelId
return config.data.media?.channelId
}
if(getToken() == "") {
clog.error("Impossible de démarrer sans token valide")
if(!getToken()) {
// Sous Docker, data/ n'est pas dans l'image : config.json doit être déposé dans le volume monté sur /app/data
clog.error("Impossible de démarrer sans token valide : renseignez 'token' dans " + __glob.DATA + path.sep + "config.json")
process.exit(1)
}
@@ -97,6 +258,11 @@ module.exports = {
getWebsiteLink,
getPort,
getMediaGuildId,
getMediaChannelId
}
getMediaChannelId,
// Administration
getMasked,
setValue,
validateValue,
getKeys
}
+121 -25
View File
@@ -11,76 +11,172 @@ function getDatabase(name) {
return AllDatabases[name]
}
/**
* Écriture atomique : fichier temporaire puis renommage, pour ne jamais laisser
* un fichier tronqué si le process est tué pendant l'écriture.
* Sous Windows / OneDrive, le renommage peut échouer (fichier verrouillé) : on retente
* puis on se replie sur une écriture directe.
* @param {string} filePath
* @param {string} content
*/
function writeFileAtomic(filePath, content) {
const tmp = filePath + ".tmp"
// Le temporaire reprend les droits du fichier existant (ex : google.json en 600, jamais lisible même brièvement)
let mode
try { mode = fs.statSync(filePath).mode & 0o777 } catch(e) { /* nouveau fichier : droits par défaut */ }
fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : undefined)
let lastError = null
for(let attempt = 0; attempt < 3; attempt++) {
try {
fs.renameSync(tmp, filePath)
return
} catch(e) {
lastError = e
}
}
// Repli : écriture directe (non atomique) puis nettoyage du temporaire
clog.warn(`Renommage atomique impossible pour '${filePath}' (${lastError?.code || lastError}), écriture directe`)
fs.writeFileSync(filePath, content)
try { fs.unlinkSync(tmp) } catch(e) { /* temporaire déjà absent */ }
}
class Database {
data;
path;
name;
empty;
constructor(name, path, empty) {
if(name == undefined || path == undefined) throw clog.error("Impossible de créer une base de données sans nom ou sans chemin")
if(name == undefined || path == undefined) {
clog.error("Impossible de créer une base de données sans nom ou sans chemin")
throw new Error("Impossible de créer une base de données sans nom ou sans chemin")
}
clog.log(`Enregistrement de la base de données '${name}' contenu dans ${path}`)
this.name = name;
this.path = path;
this.data = {};
this.loaded = false;
if(empty) this.empty = empty; else this.empty = {}
this.data = this.getEmpty();
this.load()
AllDatabases[name] = this
}
// Copie profonde de la valeur par défaut (jamais partagée avec this.data)
getEmpty() {
return JSON.parse(JSON.stringify(this.empty))
}
load() {
clog.log(`Chargement de la base de données '${this.name}'`)
try {
this.create()
this.update()
} catch(e) {
clog.error(`Erreur lors du chargement de la base de données '${this.name}'`)
clog.error(e)
clog.error(e?.stack || e)
}
}
create() {
try {
if(!fs.existsSync(this.path)) {
clog.warn(`Le fichier de la base de données '${this.name}' n'existe pas, création du fichier`)
fs.writeFileSync(this.path, JSON.stringify(this.empty, null, 2))
fs.mkdirSync(path.dirname(this.path), { recursive: true })
writeFileAtomic(this.path, JSON.stringify(this.empty, null, 2))
}
} catch(e) {
clog.error(`Erreur lors de la création de la base de données '${this.name}'`)
clog.error(e)
clog.error(e?.stack || e)
}
}
save() {
try {
clog.log(`Sauvegarde de la base de données '${this.name}'`)
fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2))
const content = JSON.stringify(this.data, null, 2)
if(content === undefined) throw new Error("Données non sérialisables")
writeFileAtomic(this.path, content)
} catch(e) {
clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`)
clog.error(e)
clog.error(e?.stack || e)
}
// Assure that the database is up to date and reloaded
this.update()
// La mémoire fait foi : plus de relecture du fichier après la sauvegarde.
// (Relire remplaçait this.data par un nouvel objet : les références détenues
// ailleurs devenaient orphelines et des modifications concurrentes étaient perdues.)
// Pour recharger explicitement depuis le disque, appeler load() ou update().
}
/**
* Vérifie que la donnée lue a le même type que la valeur par défaut (tableau / objet)
* @param {*} parsed
* @returns {boolean}
*/
isValidShape(parsed) {
if(parsed === null || typeof parsed !== "object") return false
return Array.isArray(parsed) === Array.isArray(this.empty)
}
/**
* Met de côté un fichier illisible sous le nom <fichier>.corrupted-<timestamp>
* @param {string} filePath
*/
quarantine(filePath) {
const target = filePath + ".corrupted-" + Date.now()
try {
fs.renameSync(filePath, target)
} catch(e) {
// Renommage refusé (Windows / OneDrive) : copie à la place
fs.copyFileSync(filePath, target)
}
clog.warn(`Fichier corrompu de la base de données '${this.name}' sauvegardé sous : ${target}`)
}
update() {
try{
clog.log(`Mise à jour de la base de données '${this.name}'`)
let rawdata = fs.readFileSync(this.path);
this.data = JSON.parse(rawdata);
clog.log(`Base de donnée '${this.name}' chargée avec succès`)
let rawdata
try {
rawdata = fs.readFileSync(this.path, "utf-8");
} catch(e) {
clog.error(`Erreur lors de la mise à jour de la base de données '${this.name}'`)
clog.error(e)
// Erreur d'accès (verrou, permission…) : on garde les données en mémoire, sans rien écraser
clog.error(`Erreur lors de la lecture de la base de données '${this.name}'`)
clog.error(e?.stack || e)
return
}
let parsed
let valid = false
try {
// Retrait d'un éventuel BOM UTF-8 (fichier édité à la main sous Windows)
parsed = JSON.parse(rawdata.replace(/^/, ""));
valid = this.isValidShape(parsed)
if(!valid) clog.error(`Contenu inattendu dans la base de données '${this.name}' (type ${Array.isArray(parsed) ? "tableau" : typeof parsed} au lieu de ${Array.isArray(this.empty) ? "tableau" : "objet"})`)
} catch(e) {
clog.error(`Fichier JSON invalide pour la base de données '${this.name}' : ${e?.message || e}`)
}
if(valid) {
this.data = parsed;
this.loaded = true
return
}
// Fichier corrompu : on le met de côté au lieu de l'écraser plus tard avec une base vide
try {
this.quarantine(this.path)
} catch(e) {
clog.error(`Impossible de sauvegarder le fichier corrompu de la base de données '${this.name}', aucune réécriture`)
clog.error(e?.stack || e)
return
}
if(this.loaded) {
// Données déjà chargées en mémoire : elles font foi, on réécrit un fichier sain
clog.warn(`Base de données '${this.name}' : réécriture depuis les données en mémoire`)
} else {
clog.warn(`Base de données '${this.name}' : réinitialisation avec la valeur par défaut`)
this.data = this.getEmpty()
this.loaded = true
}
this.save()
}
getData() {
@@ -91,4 +187,4 @@ class Database {
module.exports = {Database, getDatabase}
module.exports = {Database, getDatabase}
+10 -2
View File
@@ -4,7 +4,7 @@ const version = JSON.parse(require('fs').readFileSync(root + path.sep + "package
const __glob = {
PACKAGEINFO: root + path.sep + "package.json",
ROOT: root + + path.sep,
ROOT: root + path.sep,
SRC: root + path.sep + "src",
LOGS: root + path.sep + "logs",
DATA: root + path.sep + "data",
@@ -19,7 +19,15 @@ const __glob = {
VERSION: version,
CHANGELOG_PATH: root + path.sep + "CHANGELOG.html",
COOKIES: root + path.sep + "data" + path.sep + "cookies.txt",
PROXY: root + path.sep + "data" + path.sep + "proxy.json"
PROXY: root + path.sep + "data" + path.sep + "proxy.json",
// Ancien export JSON des cookies (navigateur) : importé une seule fois par le module youtube
COOKIES_JSON: root + path.sep + "data" + path.sep + "cookies.json",
// Module youtube : cookies maîtres, état, réglages et cache persistant de yt-dlp
YOUTUBE_DIR: root + path.sep + "data" + path.sep + "youtube",
YOUTUBE_COOKIES: root + path.sep + "data" + path.sep + "youtube" + path.sep + "cookies.txt",
YOUTUBE_STATE: root + path.sep + "data" + path.sep + "youtube" + path.sep + "state.json",
YOUTUBE_SETTINGS: root + path.sep + "data" + path.sep + "youtube" + path.sep + "settings.json",
YOUTUBE_CACHE: root + path.sep + "data" + path.sep + "youtube" + path.sep + "cache"
}
module.exports = {__glob}
+187
View File
@@ -0,0 +1,187 @@
/**
* Petits utilitaires sans dépendance pour limiter la charge envoyée aux services externes
* (YouTube, Spotify…) : limiteur de concurrence, cache TTL borné et déduplication des requêtes en cours.
*/
/**
* Crée un limiteur de concurrence : au plus `concurrency` fonctions s'exécutent en même temps,
* les suivantes attendent dans une file (ordre d'arrivée).
* @param {number} concurrency Nombre maximal d'exécutions simultanées (>= 1)
* @param {{minIntervalMs?: number}} [options] Délai minimal entre deux démarrages (lisse les rafales)
* @returns {{run: <T>(fn: () => Promise<T>|T) => Promise<T>, readonly active: number, readonly pending: number}}
*/
function createLimiter(concurrency, options = {}) {
const max = Math.max(1, Math.floor(Number(concurrency)) || 1);
const minIntervalMs = Math.max(0, Number(options.minIntervalMs) || 0);
const queue = [];
let active = 0;
let lastStart = 0;
let timer = null;
function next() {
if (timer || active >= max || queue.length === 0) return;
// Espacement minimal entre deux démarrages : on réessaie plus tard sans bloquer la boucle
const wait = lastStart + minIntervalMs - Date.now();
if (wait > 0) {
timer = setTimeout(() => {
timer = null;
next();
}, wait);
return;
}
const { fn, resolve, reject } = queue.shift();
active++;
lastStart = Date.now();
// Promise.resolve().then : une exception synchrone de fn devient un rejet
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
active--;
next();
});
// Plusieurs places libres : on démarre les suivants (sous réserve de l'espacement)
next();
}
function run(fn) {
return new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
return {
run,
get active() { return active; },
get pending() { return queue.length; },
};
}
/**
* Applique `fn` à chaque élément avec une concurrence bornée, en conservant l'ordre des résultats.
* Un élément en erreur donne `onError(error, item, index)` (undefined par défaut) au lieu de tout faire échouer.
* @template T, R
* @param {T[]} items
* @param {number} concurrency
* @param {(item: T, index: number) => Promise<R>} fn
* @param {(error: any, item: T, index: number) => R} [onError]
* @returns {Promise<R[]>}
*/
async function mapLimit(items, concurrency, fn, onError) {
const list = Array.isArray(items) ? items : [];
const limiter = createLimiter(concurrency);
return Promise.all(list.map((item, index) => limiter.run(async () => {
try {
return await fn(item, index);
} catch (error) {
return onError ? onError(error, item, index) : undefined;
}
})));
}
/**
* Déduplication des requêtes en cours : deux appels simultanés pour la même clé partagent la même promesse.
* @returns {{run: <T>(key: string, fn: () => Promise<T>) => Promise<T>, readonly size: number}}
*/
function createInflight() {
const pending = new Map();
function run(key, fn) {
if (pending.has(key)) return pending.get(key);
const promise = Promise.resolve()
.then(fn)
.finally(() => pending.delete(key));
pending.set(key, promise);
return promise;
}
return {
run,
get size() { return pending.size; },
};
}
/**
* Cache clé → valeur avec expiration (TTL) et taille maximale (les entrées les moins récemment lues sont évincées).
* `get` renvoie undefined si la clé est absente ou expirée : `null` peut donc être mis en cache volontairement.
* @param {{ttlMs: number, max?: number}} options
*/
function createCache({ ttlMs, max = 500 } = {}) {
const defaultTtl = Math.max(0, Number(ttlMs) || 0);
const maxSize = Math.max(1, Math.floor(Number(max)) || 1);
const entries = new Map(); // clé → {value, expiresAt}
const inflight = createInflight();
let hits = 0;
let misses = 0;
function get(key) {
const entry = entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
entries.delete(key);
return undefined;
}
// Réinsertion : l'ordre du Map sert d'ordre LRU
entries.delete(key);
entries.set(key, entry);
return entry.value;
}
function has(key) {
return get(key) !== undefined;
}
function set(key, value, ttl = defaultTtl) {
const duration = Number(ttl);
if (value === undefined || !(duration > 0)) return;
entries.delete(key);
entries.set(key, { value, expiresAt: Date.now() + duration });
prune();
}
function prune() {
if (entries.size <= maxSize) return;
const now = Date.now();
// D'abord les entrées expirées, puis les plus anciennes jusqu'à revenir sous la limite
for (const [key, entry] of entries) {
if (entry.expiresAt <= now) entries.delete(key);
}
while (entries.size > maxSize) {
entries.delete(entries.keys().next().value);
}
}
/**
* Renvoie la valeur en cache ou l'obtient via `fn` (un seul appel simultané par clé).
* @param {string} key
* @param {() => Promise<any>} fn
* @param {{ttlFor?: (value: any) => number}} [options] TTL selon le résultat (0 = ne pas mettre en cache).
* Par défaut : TTL du cache, sauf pour null/undefined qui ne sont pas mis en cache.
*/
async function wrap(key, fn, options = {}) {
const cached = get(key);
if (cached !== undefined) {
hits++;
return cached;
}
misses++;
return inflight.run(key, async () => {
const value = await fn();
const ttl = options.ttlFor ? options.ttlFor(value) : (value == null ? 0 : defaultTtl);
set(key, value, ttl);
return value;
});
}
return {
get,
has,
set,
wrap,
delete: (key) => entries.delete(key),
clear: () => entries.clear(),
get size() { return entries.size; },
stats: () => ({ size: entries.size, max: maxSize, hits, misses, inflight: inflight.size }),
};
}
module.exports = { createLimiter, mapLimit, createInflight, createCache };
+435
View File
@@ -0,0 +1,435 @@
/**
* Lecture des fichiers de logs écrits par loguix (backend/logs) pour l'interface d'administration.
* loguix n'expose ni le nom du fichier courant ni de flux : on relit donc les fichiers sur disque.
* - listLogs / readLog : liste et lecture paginée par plage d'octets (jamais de lecture complète synchrone)
* - startTail / stopTail : suivi temps réel du fichier courant via fs.watchFile (fs.watch n'est pas fiable sous Docker/OneDrive)
* - deleteLog : suppression d'un ancien fichier (jamais le fichier courant)
*/
const fs = require('fs');
const path = require('path');
const { LogType } = require('loguix');
const { __glob } = require('./GlobalVars');
const clog = new LogType("LogReader");
// Nom de fichier loguix : YYYY-MM-DD-HHh-MMm-SSs.log (ordre lexicographique = ordre chronologique)
const NAME_REGEX = /^[\w-]+\.log$/;
// [DD/MM - HHh-MMm-SSs] - [Type] - [INFO|WARN|ERROR] - [STEP] - message
const LINE_REGEX = /^\[(\d{2}\/\d{2} - \d{2}h-\d{2}m-\d{2}s)\] - (?:\[([^\]]+)\] - )?\[(INFO|WARN|ERROR|FATAL)\] - (?:\[(STEP)\] - )?(.*)$/;
const END_REGEX = /\[END OF LOGS\]/;
const DEFAULT_READ = 256 * 1024;
const MAX_READ = 1024 * 1024;
const BUFFER_SIZE = 500;
const TAIL_INTERVAL = 1000;
const TAIL_DEBOUNCE = 500;
let currentFile = null;
// État du suivi temps réel
const tail = {
active: false,
path: null,
size: 0,
remainder: "",
n: 0,
buffer: [],
pending: [],
timer: null,
onLines: null,
previous: null
};
/**
* Détermine le fichier courant : le plus récent du dossier au moment du premier appel
* (loguix.setup est appelé dans main.js avant tout require du serveur).
*/
function init() {
if (currentFile) return currentFile;
try {
const files = fs.readdirSync(__glob.LOGS).filter(f => NAME_REGEX.test(f)).sort();
currentFile = files.length > 0 ? files[files.length - 1] : null;
} catch (e) {
clog.error(`Impossible de lire le dossier des logs : ${e.message}`);
currentFile = null;
}
return currentFile;
}
function getCurrentName() {
return init();
}
function isValidName(name) {
return typeof name === "string" && name.length <= 100 && path.basename(name) === name && NAME_REGEX.test(name);
}
/**
* Valide le nom et vérifie qu'il existe réellement dans le dossier (protection contre le path traversal)
* @returns {Promise<string|null>} chemin absolu ou null
*/
async function resolveLog(name) {
if (!isValidName(name)) return null;
let files;
try {
files = await fs.promises.readdir(__glob.LOGS);
} catch (e) {
return null;
}
if (!files.includes(name)) return null;
const full = path.resolve(__glob.LOGS, name);
if (path.dirname(full) !== path.resolve(__glob.LOGS)) return null;
return full;
}
/**
* Découpe une ligne loguix. Une ligne qui ne correspond pas au format est la suite de la précédente
* (trace d'erreur sur plusieurs lignes) : elle hérite du type et du niveau pour que les filtres la gardent.
*/
function parseLine(raw, n, previous) {
const match = LINE_REGEX.exec(raw);
if (match) {
return {
n,
time: match[1],
type: match[2] ?? null,
level: match[3],
step: match[4] === "STEP",
message: match[5],
raw
};
}
// Ligne FATAL de loguix : "[date] - [FATAL] - ..." est déjà couverte par la regex, ici les lignes libres
return {
n,
time: null,
type: previous?.type ?? null,
level: previous?.level ?? null,
step: false,
message: raw,
raw,
continuation: true
};
}
function parseLines(rawLines, startN = 0) {
const out = [];
let previous = null;
let n = startN;
for (const raw of rawLines) {
if (raw === "") continue;
const line = parseLine(raw, n++, previous);
out.push(line);
previous = line;
}
return out;
}
/**
* Filtres optionnels : levels (["INFO","WARN",...]), type (exact, insensible à la casse), search (texte)
*/
function filterLines(lines, { levels, type, search } = {}) {
let result = lines;
if (Array.isArray(levels) && levels.length > 0) {
const set = new Set(levels.map(l => String(l).toUpperCase()));
result = result.filter(l => l.level && set.has(l.level));
}
if (typeof type === "string" && type.trim() !== "") {
const t = type.trim().toLowerCase();
result = result.filter(l => (l.type || "").toLowerCase() === t);
}
if (typeof search === "string" && search.trim() !== "") {
const s = search.trim().toLowerCase();
result = result.filter(l => l.raw.toLowerCase().includes(s));
}
return result;
}
/**
* @returns {Promise<Array<{name, size, modifiedAt, current}>>} trié du plus récent au plus ancien
*/
async function listLogs() {
const current = init();
let files;
try {
files = await fs.promises.readdir(__glob.LOGS);
} catch (e) {
clog.error(`Impossible de lister les logs : ${e.message}`);
return [];
}
const result = [];
for (const name of files.filter(f => NAME_REGEX.test(f))) {
try {
const stat = await fs.promises.stat(path.join(__glob.LOGS, name));
if (!stat.isFile()) continue;
result.push({ name, size: stat.size, modifiedAt: stat.mtime.toISOString(), current: name === current });
} catch (e) {
// Fichier supprimé entre readdir et stat : ignoré
}
}
return result.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
}
async function readRange(fullPath, offset, length) {
const handle = await fs.promises.open(fullPath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, offset);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
/**
* Lecture paginée d'un fichier de logs.
* Sans offset : lit la fin du fichier (limit derniers octets).
* @returns {Promise<{name, size, offset, nextOffset, eof, lines}|null>} null si le fichier n'existe pas
*/
async function readLog(name, { offset, limit, levels, type, search } = {}) {
const fullPath = await resolveLog(name);
if (!fullPath) return null;
const stat = await fs.promises.stat(fullPath);
const size = stat.size;
let len = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : DEFAULT_READ;
len = Math.min(len, MAX_READ);
let start;
if (offset === undefined || offset === null) {
start = Math.max(0, size - len);
} else {
start = Math.max(0, Math.min(Math.floor(Number(offset)) || 0, size));
}
const end = Math.min(size, start + len);
let text = "";
let lineStart = start === 0;
if (end > start) {
// On lit un octet de plus avant le début pour savoir si l'on tombe pile sur un début de ligne
const readFrom = start > 0 ? start - 1 : 0;
const buf = await readRange(fullPath, readFrom, end - readFrom);
if (start > 0) {
lineStart = buf[0] === 0x0a;
text = buf.subarray(1).toString("utf-8");
} else {
text = buf.toString("utf-8");
}
}
let rawLines = text.split(/\r?\n/);
let consumed = end;
// Première ligne partielle si on ne commence pas au début d'une ligne
if (!lineStart && rawLines.length > 0) {
rawLines.shift();
}
// Dernière ligne incomplète : elle sera relue au prochain appel
if (end < size && rawLines.length > 1) {
const last = rawLines.pop();
consumed = end - Buffer.byteLength(last, "utf-8");
}
const lines = filterLines(parseLines(rawLines), { levels, type, search });
return {
name,
size,
offset: start,
nextOffset: consumed,
eof: consumed >= size,
lines
};
}
/**
* Le journal courant est-il interrompu ? (loguix ferme le flux après une exception non capturée)
*/
async function isInterrupted() {
const current = init();
if (!current) return false;
try {
const fullPath = path.join(__glob.LOGS, current);
const stat = await fs.promises.stat(fullPath);
const len = Math.min(stat.size, 512);
if (len === 0) return false;
const text = (await readRange(fullPath, stat.size - len, len)).toString("utf-8");
const lines = text.split(/\r?\n/).filter(l => l !== "");
return lines.length > 0 && END_REGEX.test(lines[lines.length - 1]);
} catch (e) {
return false;
}
}
/**
* Supprime un ancien fichier de logs.
* @returns {Promise<"deleted"|"not_found"|"protected">}
*/
async function deleteLog(name) {
if (isValidName(name) && name === init()) return "protected";
const fullPath = await resolveLog(name);
if (!fullPath) return "not_found";
await fs.promises.unlink(fullPath);
clog.warn(`Fichier de logs supprimé : ${name}`);
return "deleted";
}
// TEMPS RÉEL
function pushBuffer(lines) {
tail.buffer.push(...lines);
if (tail.buffer.length > BUFFER_SIZE) {
tail.buffer.splice(0, tail.buffer.length - BUFFER_SIZE);
}
}
function flushPending() {
tail.timer = null;
if (tail.pending.length === 0) return;
const lines = tail.pending;
tail.pending = [];
try {
if (tail.onLines) tail.onLines({ name: currentFile, lines });
} catch (e) {
clog.error(`Erreur lors de l'envoi des nouvelles lignes de logs : ${e.message}`);
}
}
/**
* Lit ce qui a été ajouté au fichier depuis la dernière lecture
*/
async function readNewData() {
if (!tail.active || !tail.path) return;
let stat;
try {
stat = await fs.promises.stat(tail.path);
} catch (e) {
return;
}
// Fichier tronqué ou recréé : on repart du début
if (stat.size < tail.size) {
tail.size = 0;
tail.remainder = "";
}
if (stat.size === tail.size) return;
const from = tail.size;
const length = Math.min(stat.size - from, MAX_READ);
let chunk;
try {
chunk = (await readRange(tail.path, from, length)).toString("utf-8");
} catch (e) {
clog.error(`Lecture du fichier de logs courant impossible : ${e.message}`);
return;
}
tail.size = from + length;
const text = tail.remainder + chunk;
const rawLines = text.split(/\r?\n/);
tail.remainder = rawLines.pop() ?? "";
const lines = [];
for (const raw of rawLines) {
if (raw === "") continue;
const line = parseLine(raw, tail.n++, tail.previous);
tail.previous = line;
lines.push(line);
}
if (lines.length === 0) return;
pushBuffer(lines);
tail.pending.push(...lines);
if (!tail.timer) {
tail.timer = setTimeout(flushPending, TAIL_DEBOUNCE);
tail.timer.unref?.();
}
// Plus de 1 Mio ajouté d'un coup : on lit la suite au prochain passage
if (tail.size < stat.size) setImmediate(readNewData);
}
/**
* Remplit le tampon avec la fin du fichier courant (au démarrage du suivi)
*/
async function primeBuffer() {
tail.buffer = [];
tail.n = 0;
tail.previous = null;
tail.remainder = "";
try {
const stat = await fs.promises.stat(tail.path);
const len = Math.min(stat.size, DEFAULT_READ);
const start = stat.size - len;
let text = len > 0 ? (await readRange(tail.path, start, len)).toString("utf-8") : "";
const rawLines = text.split(/\r?\n/);
if (start > 0) rawLines.shift();
tail.remainder = rawLines.pop() ?? "";
const lines = parseLines(rawLines).slice(-BUFFER_SIZE);
// Renumérotation continue pour les lignes suivantes
lines.forEach((l, i) => { l.n = i; });
tail.n = lines.length;
tail.previous = lines[lines.length - 1] || null;
tail.buffer = lines;
tail.size = stat.size;
} catch (e) {
tail.size = 0;
}
}
function onWatch(curr, prev) {
if (curr.size !== prev.size || curr.mtimeMs !== prev.mtimeMs) {
readNewData().catch(e => clog.error(`Suivi des logs : ${e.message}`));
}
}
/**
* Démarre le suivi du fichier courant.
* @param {function({name, lines})} onLines appelé par lots (debounce 500 ms)
*/
async function startTail(onLines) {
tail.onLines = onLines;
if (tail.active) return;
const current = init();
if (!current) return;
tail.active = true;
tail.path = path.join(__glob.LOGS, current);
await primeBuffer();
fs.watchFile(tail.path, { interval: TAIL_INTERVAL, persistent: false }, onWatch);
clog.log(`Suivi temps réel du fichier de logs ${current} démarré`);
}
function stopTail() {
if (!tail.active) return;
fs.unwatchFile(tail.path, onWatch);
if (tail.timer) clearTimeout(tail.timer);
tail.timer = null;
tail.pending = [];
// Plus aucun abonné : le rappel ne doit pas survivre à l'arrêt
tail.onLines = null;
tail.active = false;
clog.log("Suivi temps réel des logs arrêté");
}
function isTailing() {
return tail.active;
}
/**
* Dernières lignes du tampon temps réel
*/
function getBuffer(count = 300) {
return tail.buffer.slice(-count);
}
module.exports = {
init,
getCurrentName,
isValidName,
listLogs,
readLog,
deleteLog,
isInterrupted,
parseLine,
startTail,
stopTail,
isTailing,
getBuffer
};
+91 -6
View File
@@ -1,11 +1,96 @@
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")
// Délai entre l'annonce et l'arrêt : laisse le temps de répondre aux clients et de quitter les salons vocaux
const RESTART_DELAY = 1000
let restarting = false
function isRestarting() {
return restarting
}
module.exports = {restart}
/**
* Sauvegarde ce qui peut l'être avant l'arrêt (métriques en mémoire, salons vocaux)
*/
function prepareShutdown(reason) {
try {
require("./SafeMetric").flush()
} catch (e) {
clog.error(`Sauvegarde des métriques impossible avant le redémarrage : ${e.message}`)
}
try {
const players = require("../player/Player")
for (const player of players.getAllPlayers()) {
if (player.isConnected?.()) Promise.resolve(player.leave("restart")).catch(() => {})
}
} catch (e) {
clog.error(`Impossible de quitter les salons vocaux avant le redémarrage : ${e.message}`)
}
}
function exitProcess() {
clog.warn("Arrêt du processus, il sera relancé par le superviseur (Docker restart: unless-stopped)")
// Laisse le temps à loguix d'écrire les dernières lignes
setTimeout(() => process.exit(0), 300)
}
/**
* Redémarre le bot.
* - sous pm2 (process.env.pm_id défini) : pm2.restart du processus courant
* - sinon : process.exit(0), Docker relance le conteneur (restart: unless-stopped).
* Attention : nodemon (npm start en local) ne relance pas après un arrêt propre.
* @param {string} reason
* @returns {boolean} false si un redémarrage est déjà en cours
*/
function restart(reason) {
if (restarting) {
clog.warn("Redémarrage déjà en cours, demande ignorée")
return false
}
restarting = true
const at = new Date().toISOString()
clog.warn("Redémarrage du serveur Subsonics")
clog.warn(`Raison : ${reason}`)
// Prévient les clients web (relayé par le module Admin vers "/MAINTENANCE")
try {
process.emit("MAINTENANCE", { type: "restart", reason: reason ? String(reason) : null, at })
} catch (e) {
clog.error(`Notification de maintenance impossible : ${e.message}`)
}
setTimeout(() => {
prepareShutdown(reason)
if (process.env.pm_id !== undefined) {
let pm2
try {
pm2 = require("pm2")
} catch (e) {
clog.error(`pm2 indisponible : ${e.message}`)
return exitProcess()
}
pm2.connect((err) => {
if (err) {
clog.error(`Connexion à pm2 impossible : ${err.message || err}`)
return exitProcess()
}
const target = process.env.name || process.env.pm_id
clog.warn(`Redémarrage via pm2 du processus : ${target}`)
pm2.restart(target, (err2) => {
if (err2) {
clog.error(`Redémarrage pm2 impossible : ${err2.message || err2}`)
pm2.disconnect()
return exitProcess()
}
pm2.disconnect()
})
})
} else {
exitProcess()
}
}, RESTART_DELAY)
return true
}
module.exports = {restart, isRestarting}
+263
View File
@@ -0,0 +1,263 @@
/**
* Métriques sécurisées : remplace webmetrik.Metric en gardant le même format de fichier
* ([{name, description, value}]). Toutes les valeurs sont des nombres finis >= 0,
* aucune méthode ne lève d'exception, et l'écriture sur disque est atomique et différée.
*/
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const { LogType } = require('loguix');
const { __glob } = require('./GlobalVars');
const mlog = new LogType("Metrics");
// Un morceau de plus de 24 h est forcément une valeur aberrante
const MAX_SONG_SECONDS = 24 * 60 * 60;
const SAVE_DELAY = 2000;
const cache = new Map();
let file = __glob.METRIC_FILE;
let saveTimer = null;
let ready = false;
/**
* Convertit une valeur en nombre fini. Les chaînes ne sont acceptées que si elles sont strictement numériques :
* une concaténation comme "117290116.088163116..." est rejetée.
*/
function toSafeNumber(value, fallback = 0) {
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
if (typeof value === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(value)) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
return fallback;
}
/**
* Durée en secondes valide (]0 ; 24 h]), sinon 0 (live, inconnue ou aberrante)
*/
function toSafeDuration(value) {
const n = toSafeNumber(value, 0);
return n > 0 && n <= MAX_SONG_SECONDS ? n : 0;
}
function archive(suffix, content) {
const target = `${file}.${suffix}-${Date.now()}.json`;
try {
fs.writeFileSync(target, content);
return target;
} catch (e) {
mlog.error(`Impossible d'archiver ${target} : ${e.message}`);
return null;
}
}
/**
* Charge le fichier une seule fois au démarrage et nettoie les valeurs corrompues (archivées à côté).
* @param {{file?: string, overrides?: Object<string, number>}} options
*/
function init({ file: metricFile = __glob.METRIC_FILE, overrides = {} } = {}) {
if (ready) return;
file = metricFile;
let raw = [];
if (fs.existsSync(file)) {
const text = fs.readFileSync(file, "utf-8");
try {
raw = JSON.parse(text);
} catch (e) {
const saved = archive("unreadable", text);
mlog.error(`metrics.json illisible (${e.message}), copie conservée dans ${saved}`);
raw = [];
}
}
if (!Array.isArray(raw)) raw = [];
const corrupted = [];
for (const entry of raw) {
if (!entry || typeof entry.name !== "string" || !entry.name) continue;
let value = entry.name in overrides ? overrides[entry.name] : toSafeNumber(entry.value, NaN);
if (!Number.isFinite(value) || value < 0) {
corrupted.push(entry);
value = 0;
}
// Doublons : on additionne plutôt que de perdre une des deux valeurs
const previous = cache.get(entry.name);
cache.set(entry.name, {
name: entry.name,
description: typeof entry.description === "string" ? entry.description : entry.name,
value: (previous?.value ?? 0) + value
});
}
if (corrupted.length > 0) {
const saved = archive("corrupted", JSON.stringify(corrupted, null, 2));
mlog.warn(`${corrupted.length} métrique(s) corrompue(s) remise(s) à 0 : ${corrupted.map(c => c.name).join(", ")} (originaux dans ${saved})`);
}
ready = true;
flush();
process.once("beforeExit", flush);
process.once("exit", flush);
}
function ensure(name, description) {
let metric = cache.get(name);
if (!metric) {
metric = { name, description: description || name, value: 0 };
cache.set(name, metric);
} else if (description && metric.description !== description) {
metric.description = description;
}
return metric;
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
flush();
}, SAVE_DELAY);
saveTimer.unref?.();
}
/**
* Écrit immédiatement le cache sur disque (fichier temporaire puis renommage)
*/
function flush() {
if (!ready) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const json = JSON.stringify([...cache.values()], (key, value) => (typeof value === "number" && !Number.isFinite(value) ? 0 : value), 2);
const tmp = file + ".tmp";
try {
fs.writeFileSync(tmp, json);
fs.renameSync(tmp, file);
} catch (e) {
// Le renommage peut échouer sous Windows si le fichier est verrouillé (OneDrive, antivirus)
try {
fs.writeFileSync(file, json);
} catch (e2) {
mlog.error(`Sauvegarde des métriques impossible : ${e2.message}`);
}
}
}
function get(name) {
return toSafeNumber(cache.get(name)?.value, 0);
}
/**
* Ajoute amount (fini et >= 0) à la métrique. Renvoie la nouvelle valeur, ne lève jamais.
*/
function increment(name, amount = 1, description) {
try {
const delta = toSafeNumber(amount, NaN);
if (!Number.isFinite(delta) || delta < 0) {
mlog.warn(`Incrément invalide ignoré pour ${name} : ${JSON.stringify(amount)}`);
return get(name);
}
const metric = ensure(name, description);
const next = toSafeNumber(metric.value) + delta;
metric.value = Number.isFinite(next) ? next : 0;
scheduleSave();
return metric.value;
} catch (e) {
mlog.error(`Erreur sur la métrique ${name} : ${e.message}`);
return 0;
}
}
/**
* Ajoute une durée en secondes. Les durées invalides (NaN, chaîne, négatif, > 24 h) sont ignorées.
*/
function addDuration(name, seconds, description) {
const safe = toSafeDuration(seconds);
if (safe === 0) {
if (seconds !== 0 && seconds !== null && seconds !== undefined) {
mlog.warn(`Durée invalide ignorée pour ${name} : ${JSON.stringify(seconds)}`);
}
return get(name);
}
return increment(name, safe, description);
}
function set(name, value, description) {
const v = toSafeNumber(value, NaN);
if (!Number.isFinite(v) || v < 0) return false;
ensure(name, description).value = v;
scheduleSave();
return true;
}
function getAll() {
return [...cache.values()].map(m => ({ name: m.name, description: m.description, value: toSafeNumber(m.value) }));
}
function getGuildMetrics(guildId) {
const suffix = "_" + guildId;
return getAll().filter(m => m.name.endsWith(suffix));
}
function remove(name) {
const removed = cache.delete(name);
if (removed) scheduleSave();
return removed;
}
/**
* Remet à 0 les valeurs non finies encore présentes en mémoire. Renvoie le nombre de métriques réparées.
*/
function repair() {
let repaired = 0;
for (const metric of cache.values()) {
if (typeof metric.value !== "number" || !Number.isFinite(metric.value) || metric.value < 0) {
metric.value = 0;
repaired++;
}
}
if (repaired > 0) flush();
return repaired;
}
/**
* Remplace webmetrik.publishMetrics : GET /metrics?privatekey=KEY
*/
function publish(port, key, host = "0.0.0.0") {
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/metrics") {
res.writeHead(404, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Not found" }));
}
const given = Buffer.from(String(url.searchParams.get("privatekey") ?? ""));
const expected = Buffer.from(String(key));
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
res.writeHead(403, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Invalid private key" }));
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(getAll()));
} catch (e) {
mlog.error(`Serveur de métriques : ${e.message}`);
try {
res.writeHead(500);
res.end("{}");
} catch (e2) {}
}
});
server.on("error", (e) => mlog.error(`Serveur de métriques (port ${port}) : ${e.message}`));
server.listen(port, host);
return server;
}
module.exports = {
init, increment, addDuration, set, get, getAll, getGuildMetrics, remove, repair, flush, publish,
toSafeNumber, toSafeDuration, MAX_SONG_SECONDS
};
+53 -37
View File
@@ -1,46 +1,62 @@
/**
* Conversion de durées. Toute entrée invalide (NaN, Infinity, undefined, chaîne non numérique, négatif)
* est ramenée à 0 : on n'affiche jamais "NaN". Même format que frontend/src/utils/TimeConverter.js.
*/
/**
* Convertit une valeur en nombre de secondes fini et >= 0 (sinon 0).
* Les chaînes ne sont acceptées que si elles sont strictement numériques ("116.088163" oui, "117290116.0881631..." non).
*/
function toSeconds(duration) {
let value = NaN
if (typeof duration === "number") {
value = duration
} else if (typeof duration === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(duration)) {
value = Number(duration)
}
if (!Number.isFinite(value) || value < 0) return 0
return Math.floor(value)
}
function pad(number) {
return String(number).padStart(2, "0")
}
/**
* @param {number} duration Durée en secondes
* @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide)
*/
function getReadableDuration(duration) {
var max = ""
duration *= 1000;
const total = toSeconds(duration)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
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 (hours > 0) {
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
}
if(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + "h" + maxmin + "m" + maxsec
} else {
max = maxmin + "m" + maxsec + "s"
}
return max
return `${minutes}m${pad(seconds)}s`
}
/**
* @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss
* @returns {number} Secondes (0 si le format est invalide)
*/
function getSecondsDuration(duration) {
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss
var durationArray = duration.split(":");
var seconds = 0;
if(durationArray.length == 3) {
seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]);
} else if(durationArray.length == 2) {
seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]);
} else {
seconds = parseInt(durationArray[0]);
if (typeof duration === "number") return toSeconds(duration)
if (typeof duration !== "string" || duration.trim() === "") return 0
const parts = duration.trim().split(":").map(part => parseInt(part, 10))
if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0
// On lit de droite à gauche : secondes, minutes, heures
let seconds = 0
let multiplier = 1
for (let i = parts.length - 1; i >= 0; i--) {
seconds += parts[i] * multiplier
multiplier *= 60
}
return seconds;
return seconds
}
module.exports = {getReadableDuration, getSecondsDuration}
module.exports = {getReadableDuration, getSecondsDuration}
+282 -24
View File
@@ -2,11 +2,14 @@ const { LogType } = require('loguix');
const clog = new LogType("WARP-Proxy");
const net = require('net');
const fs = require('fs');
const path = require('path');
const { __glob } = require('./GlobalVars');
const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace";
const CHECK_INTERVAL = 60 * 1000;
const CHECK_TIMEOUT = 8 * 1000;
const URL_MAX_LENGTH = 500;
const ALLOWED_PROTOCOLS = ["http:", "https:", "socks5:", "socks5h:"];
const state = {
url: null,
@@ -20,20 +23,42 @@ const state = {
let started = false;
let pendingCheck = null;
let interval = null;
// Incrémenté à chaque changement de configuration : le résultat d'un contrôle lancé avant est ignoré
let generation = 0;
/**
* Contenu de data/proxy.json ({} si absent ou illisible)
*/
function readFileConfig({ quiet = false } = {}) {
if (!fs.existsSync(__glob.PROXY)) return {};
try {
const parsed = JSON.parse(fs.readFileSync(__glob.PROXY, "utf-8").replace(/^\uFEFF/, ""));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (e) {
if (!quiet) clog.error(`Impossible de lire ${__glob.PROXY} : ${e.message}`);
return {};
}
}
/**
* Champs imposés par l'environnement (non modifiables depuis l'administration)
*/
function getLocks() {
const urlLocked = !!process.env.WARP_PROXY;
return {
url: urlLocked,
enabled: urlLocked,
fallbackDirect: process.env.WARP_FALLBACK_DIRECT !== undefined
};
}
/**
* Priorité : variable d'environnement WARP_PROXY, puis data/proxy.json.
* Format de proxy.json : { "enabled": true, "url": "http://127.0.0.1:1080", "fallbackDirect": true }
*/
function loadConfig() {
let fileConfig = {};
if (fs.existsSync(__glob.PROXY)) {
try {
fileConfig = JSON.parse(fs.readFileSync(__glob.PROXY, "utf-8"));
} catch (e) {
clog.error(`Impossible de lire ${__glob.PROXY} : ${e.message}`);
}
}
const fileConfig = readFileConfig();
const url = process.env.WARP_PROXY || (fileConfig.enabled !== false ? fileConfig.url : null) || null;
const fallbackDirect = process.env.WARP_FALLBACK_DIRECT !== undefined
@@ -73,7 +98,7 @@ async function fetchTrace(proxyUrl) {
*/
function tcpProbe(host, port) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port: Number(port) });
const socket = net.connect({ host, port: Number(port) || 1080 });
socket.setTimeout(CHECK_TIMEOUT);
socket.once('connect', () => { socket.destroy(); resolve(); });
socket.once('timeout', () => { socket.destroy(); reject(new Error("Délai de connexion dépassé")); });
@@ -82,48 +107,80 @@ function tcpProbe(host, port) {
}
async function runCheck() {
const gen = generation;
const url = state.url;
const wasHealthy = state.healthy;
const firstCheck = state.lastCheck === null;
const result = { healthy: false, warp: state.warp, ip: state.ip, lastError: null };
try {
const parsed = new URL(state.url);
const parsed = new URL(url);
if (parsed.protocol.startsWith("http")) {
const trace = await fetchTrace(state.url);
state.warp = trace.warp;
state.ip = trace.ip;
const trace = await fetchTrace(url);
result.warp = trace.warp;
result.ip = trace.ip;
if (trace.warp !== "on" && trace.warp !== "plus") {
throw new Error(`Proxy joignable mais le trafic ne passe pas par WARP (warp=${trace.warp})`);
}
} else {
await tcpProbe(parsed.hostname, parsed.port);
}
state.healthy = true;
state.lastError = null;
result.healthy = true;
} catch (e) {
state.healthy = false;
state.lastError = e.cause?.message || e.message;
result.healthy = false;
result.lastError = e.cause?.message || e.message;
}
// Configuration modifiée pendant le contrôle : ce résultat concerne l'ancien proxy
if (gen !== generation || url !== state.url) return state.healthy;
state.healthy = result.healthy;
state.warp = result.warp;
state.ip = result.ip;
state.lastError = result.lastError;
state.lastCheck = Date.now();
if (state.healthy && (!wasHealthy || firstCheck)) {
clog.log(`Proxy WARP opérationnel (${state.url}) - IP de sortie : ${state.ip ?? "inconnue"}`);
clog.log(`Proxy WARP opérationnel (${maskUrl(state.url)}) - IP de sortie : ${state.ip ?? "inconnue"}`);
}
if (!state.healthy && (wasHealthy || firstCheck)) {
const fallback = state.fallbackDirect ? "connexion directe utilisée en attendant" : "aucun repli configuré";
clog.warn(`Proxy WARP indisponible (${state.url}) : ${state.lastError} - ${fallback}`);
clog.warn(`Proxy WARP indisponible (${maskUrl(state.url)}) : ${state.lastError} - ${fallback}`);
}
// Prévient l'interface d'administration quand l'état change (et au premier contrôle)
if (firstCheck || wasHealthy !== state.healthy) notifyUpdate();
return state.healthy;
}
function notifyUpdate() {
try {
process.emit("WARP_UPDATE", getStatus());
} catch (e) {
clog.error(`Notification de l'état WARP impossible : ${e.message}`);
}
}
function checkHealth() {
if (!state.url) return Promise.resolve(false);
if (!pendingCheck) {
pendingCheck = runCheck().finally(() => { pendingCheck = null; });
const check = runCheck().finally(() => {
// Ne pas effacer un contrôle plus récent (lancé après un changement de configuration)
if (pendingCheck === check) pendingCheck = null;
});
pendingCheck = check;
}
return pendingCheck;
}
function ensureInterval() {
if (interval) return;
// Sans proxy, checkHealth() ne fait rien : l'intervalle peut rester en place
interval = setInterval(checkHealth, CHECK_INTERVAL);
interval.unref();
}
async function start() {
if (started) return;
started = true;
@@ -138,7 +195,154 @@ async function start() {
}
await checkHealth();
setInterval(checkHealth, CHECK_INTERVAL).unref();
ensureInterval();
}
/**
* Valide une modification de configuration (fonction pure, sans lecture ni écriture).
* @param {{enabled?: boolean, url?: string|null, fallbackDirect?: boolean}} patch
* @param {{url: boolean, enabled: boolean, fallbackDirect: boolean}} [locks]
* @returns {{enabled?: boolean, url?: string|null, fallbackDirect?: boolean}} patch normalisé
* @throws {Error} code INVALID_PARAMS, message en français
*/
function validateConfigPatch(patch, locks = getLocks()) {
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_PARAMS" });
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw invalid("Configuration invalide (objet attendu)");
const allowed = ["enabled", "url", "fallbackDirect"];
const unknown = Object.keys(patch).filter(key => !allowed.includes(key));
if (unknown.length) throw invalid(`Champ inconnu : ${unknown.join(", ")}`);
const result = {};
for (const key of allowed) {
if (patch[key] === undefined) continue;
if (locks[key]) {
throw invalid(`"${key}" est défini par une variable d'environnement (${key === "fallbackDirect" ? "WARP_FALLBACK_DIRECT" : "WARP_PROXY"}) et ne peut pas être modifié ici`);
}
}
if (patch.enabled !== undefined) {
if (typeof patch.enabled !== "boolean") throw invalid("\"enabled\" invalide (booléen attendu)");
result.enabled = patch.enabled;
}
if (patch.fallbackDirect !== undefined) {
if (typeof patch.fallbackDirect !== "boolean") throw invalid("\"fallbackDirect\" invalide (booléen attendu)");
result.fallbackDirect = patch.fallbackDirect;
}
if (patch.url !== undefined) {
if (patch.url === null || (typeof patch.url === "string" && patch.url.trim() === "")) {
result.url = null;
} else {
if (typeof patch.url !== "string") throw invalid("URL du proxy invalide (texte attendu)");
const text = patch.url.trim();
if (text.length > URL_MAX_LENGTH) throw invalid(`URL du proxy trop longue (${URL_MAX_LENGTH} caractères max)`);
if (/\s/.test(text)) throw invalid("URL du proxy invalide (espaces interdits)");
let parsed = null;
try {
parsed = new URL(text);
} catch (e) {
parsed = null;
}
if (!parsed || !ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
throw invalid("URL du proxy invalide (http://, https://, socks5:// ou socks5h://hôte:port attendu)");
}
if (!parsed.hostname) throw invalid("URL du proxy invalide (hôte manquant)");
if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
throw invalid("URL du proxy invalide (ni chemin, ni paramètres, ni ancre)");
}
if (parsed.protocol.startsWith("socks") && !parsed.port) throw invalid("URL du proxy invalide (port obligatoire pour SOCKS)");
result.url = text.replace(/\/+$/, "");
}
}
if (result.enabled === true && result.url === null) throw invalid("Impossible d'activer le proxy sans URL");
return result;
}
/**
* Écriture atomique de data/proxy.json (contient d'éventuels identifiants : droits 600 si possible)
*/
function writeFileConfig(content) {
fs.mkdirSync(path.dirname(__glob.PROXY), { recursive: true });
const tmp = __glob.PROXY + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(content, null, 2), { mode: 0o600 });
let lastError = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.renameSync(tmp, __glob.PROXY);
lastError = null;
break;
} catch (e) {
lastError = e;
}
}
if (lastError) {
// Windows / OneDrive : renommage parfois refusé (fichier verrouillé), écriture directe en repli
fs.writeFileSync(__glob.PROXY, JSON.stringify(content, null, 2), { mode: 0o600 });
try { fs.unlinkSync(tmp); } catch (e) { /* temporaire déjà absent */ }
}
try { fs.chmodSync(__glob.PROXY, 0o600); } catch (e) { /* sans effet sous Windows */ }
}
/**
* Relit la configuration et l'applique immédiatement (état de santé remis à zéro si l'URL change)
*/
function reload() {
const config = loadConfig();
const urlChanged = config.url !== state.url;
const fallbackChanged = config.fallbackDirect !== state.fallbackDirect;
state.fallbackDirect = config.fallbackDirect;
if (urlChanged) {
generation++;
pendingCheck = null;
state.url = config.url;
state.healthy = false;
state.warp = null;
state.ip = null;
state.lastCheck = null;
state.lastError = null;
}
started = true;
if (state.url) ensureInterval();
if (urlChanged || fallbackChanged) notifyUpdate();
return { urlChanged, fallbackChanged };
}
/**
* Modifie data/proxy.json depuis l'administration, recharge la configuration et revérifie aussitôt le proxy.
* url absente = URL actuelle du fichier conservée (permet de changer les options sans renvoyer d'identifiants).
* @param {{check?: boolean}} [options] check:false = sans attendre le contrôle (l'appelant le lance lui-même)
* @returns {Promise<{status: Object, changes: string[]}>}
*/
async function setConfig(patch, { check = true } = {}) {
const normalized = validateConfigPatch(patch);
const before = readFileConfig();
const next = {
enabled: before.enabled !== false,
url: typeof before.url === "string" && before.url ? before.url : null,
fallbackDirect: before.fallbackDirect !== false
};
if (normalized.url !== undefined) next.url = normalized.url;
if (normalized.enabled !== undefined) next.enabled = normalized.enabled;
if (normalized.fallbackDirect !== undefined) next.fallbackDirect = normalized.fallbackDirect;
if (next.enabled && !next.url) {
if (normalized.enabled === true) throw Object.assign(new Error("Impossible d'activer le proxy sans URL"), { code: "INVALID_PARAMS" });
// URL effacée : le proxy est désactivé
next.enabled = false;
}
// Description des changements (URL masquée : jamais d'identifiants)
const changes = [];
if ((before.enabled !== false) !== next.enabled) changes.push(`activé: ${before.enabled !== false} -> ${next.enabled}`);
if ((before.url || null) !== next.url) changes.push(`url: ${maskUrl(before.url) || "aucune"} -> ${maskUrl(next.url) || "aucune"}`);
if ((before.fallbackDirect !== false) !== next.fallbackDirect) changes.push(`repli direct: ${before.fallbackDirect !== false} -> ${next.fallbackDirect}`);
if (changes.length) {
// Les champs inconnus du fichier (ajoutés à la main) sont conservés
writeFileConfig({ ...before, ...next });
clog.log(`Configuration du proxy modifiée depuis l'administration : ${changes.join(", ")}`);
}
const { urlChanged } = reload();
// Contrôle immédiat du (nouveau) proxy : l'interface reçoit un état à jour dans la réponse
if (check && state.url && (urlChanged || changes.length)) await checkHealth();
return { status: getStatus(), changes };
}
/**
@@ -157,8 +361,62 @@ function reportFailure() {
checkHealth();
}
function getStatus() {
return { ...state };
/**
* Masque les identifiants éventuels de l'URL du proxy (http://user:pass@hote:port -> http://***@hote:port)
*/
function maskUrl(url) {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
return `${parsed.protocol}//***@${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
}
return url;
} catch (e) {
// URL illisible : on ne renvoie rien qui pourrait contenir un secret
return "***";
}
}
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus };
function hasCredentials(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return !!(parsed.username || parsed.password);
} catch (e) {
return false;
}
}
/**
* État du proxy pour l'interface d'administration (jamais l'URL brute)
*/
function getStatus() {
// Lu à chaque appel (vue d'ensemble toutes les 10 s) : sans journaliser un fichier illisible à chaque fois
const fileConfig = readFileConfig({ quiet: true });
const locks = getLocks();
const fileUrl = typeof fileConfig.url === "string" && fileConfig.url ? fileConfig.url : null;
return {
configured: !!state.url,
url: maskUrl(state.url),
fallbackDirect: state.fallbackDirect,
healthy: state.healthy,
warp: state.warp ?? null,
ip: state.ip ?? null,
lastCheck: state.lastCheck ? new Date(state.lastCheck).toISOString() : null,
lastError: state.lastError ?? null,
routes: getRoutes().map(route => (route ? "warp" : "direct")),
checkIntervalMs: CHECK_INTERVAL,
// Édition depuis l'administration
source: process.env.WARP_PROXY ? "env" : (fileUrl ? "file" : "none"),
locked: locks,
file: {
enabled: fileConfig.enabled !== false && !!fileUrl,
url: maskUrl(fileUrl),
hasCredentials: hasCredentials(fileUrl),
fallbackDirect: fileConfig.fallbackDirect !== false
}
};
}
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus, maskUrl, setConfig, validateConfigPatch, reload };
+206
View File
@@ -0,0 +1,206 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/check.js
* Description: Vérifications en direct : validité des cookies et test de lecture (extraction sans téléchargement)
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Check");
const warp = require('../utils/WarpProxy');
const cookies = require('./cookies');
const settings = require('./settings');
const ytdlp = require('./ytdlp');
const errors = require('./errors');
const CHECK_TIMEOUT = 60 * 1000;
// Même sélection de format que la lecture
const FORMAT = "bestaudio[acodec=opus]/bestaudio/best";
let pendingCookieCheck = null;
// Import concerné par la vérification en cours (un nouvel import ne doit pas recevoir le résultat de l'ancien)
let pendingCookieKey = null;
let pendingTest = null;
function busyError() {
const err = new Error("Un test de lecture est déjà en cours");
err.code = "BUSY";
return err;
}
/**
* Lignes utiles de stderr (erreurs / avertissements), nettoyées, pour l'administration
*/
function stderrTail(stderr, max = 8) {
const lines = String(stderr || '').split(/\r?\n/)
.map(l => l.trim())
.filter(l => /^(ERROR|WARNING):/i.test(l) || /HTTP Error|Got error/i.test(l));
return errors.sanitize(lines.slice(-max).join('\n'));
}
/**
* Route préférée pour utiliser le compte : la directe si elle fait partie des routes, sinon le proxy
*/
function cookieRoute() {
const routes = warp.getRoutes();
if (routes.includes(null)) return null;
return routes[0] ?? null;
}
/**
* Vérifie en direct que yt-dlp reconnaît le compte (une requête, sur une copie des cookies).
* @returns {Promise<{ok: boolean, loggedIn: boolean, premium: boolean, rotated: boolean, cls: string|null, message: string, route: "warp"|"direct", ms: number, status: object}>}
*/
function checkCookies() {
const key = cookies.getStatus().importedAt;
if (pendingCookieCheck && pendingCookieKey === key) return pendingCookieCheck;
pendingCookieKey = key;
const current = (async () => {
const started = Date.now();
const copy = await cookies.acquireCopy();
if (!copy) {
return { ok: false, loggedIn: false, premium: false, rotated: false, cls: null, message: "Aucun cookie enregistré", route: "direct", ms: 0, status: cookies.getStatus() };
}
const proxy = cookieRoute();
const route = proxy ? "warp" : "direct";
let result;
try {
const args = [
...ytdlp.baseArgs({ proxy, cookiesPath: copy.path, playerClients: settings.get().playerClients }),
'-v', '--skip-download', '--print', 'id',
'--', settings.get().testUrl
];
result = await ytdlp.run(args, { timeoutMs: CHECK_TIMEOUT });
} finally {
copy.release();
}
const stderr = result.stderr || "";
const loggedIn = /Found YouTube account cookies/i.test(stderr);
const premium = /Detected YouTube Premium/i.test(stderr);
const rotated = /cookies are no longer valid/i.test(stderr);
const ok = result.code === 0 && loggedIn && !rotated;
let cls = null;
let message;
if (rotated) {
cls = "COOKIES_ROTATED";
message = "YouTube a invalidé ces cookies (rotation de sécurité ou déconnexion) : importez-en de nouveaux";
} else if (result.code !== 0) {
const classified = errors.classify(stderr);
cls = classified.cls;
message = classified.message;
} else if (!loggedIn) {
message = "yt-dlp ne reconnaît aucun compte dans ces cookies";
} else {
message = premium ? "Compte reconnu (YouTube Premium)" : "Compte reconnu";
}
const lastCheck = { at: new Date().toISOString(), ok, loggedIn: loggedIn && !rotated, premium, message, cls, route };
// Cookies remplacés ou supprimés pendant la vérification : le résultat concerne les anciens, on ne l'enregistre pas
if (cookies.getStatus().importedAt !== key) {
clog.log("Vérification des cookies ignorée : les cookies ont changé entre-temps");
return { ok, loggedIn: lastCheck.loggedIn, premium, rotated, cls, message, route, ms: Date.now() - started, stale: true, status: cookies.getStatus() };
}
cookies.setLastCheck(lastCheck);
if (rotated) cookies.markInvalid("Cookies refusés par YouTube lors de la vérification (rotation)");
const log = `Vérification des cookies (route ${route === "warp" ? "WARP" : "directe"}) : ${message}${cls ? ` [${cls}]` : ''}`;
if (ok) clog.log(log); else clog.warn(log);
return { ok, loggedIn: lastCheck.loggedIn, premium, rotated, cls, message, route, ms: Date.now() - started, status: cookies.getStatus() };
})().finally(() => {
if (pendingCookieCheck === current) {
pendingCookieCheck = null;
pendingCookieKey = null;
}
});
pendingCookieCheck = current;
return current;
}
// Hôtes YouTube uniquement : une autre URL partirait dans l'extracteur générique de yt-dlp (accès au réseau interne)
const YOUTUBE_HOSTS = /^(?:www\.|m\.|music\.)?youtube\.com$|^youtu\.be$/i;
function isSafeUrl(url) {
if (typeof url !== 'string' || !/^https?:\/\/[^\s]+$/i.test(url)) return false;
try {
return YOUTUBE_HOSTS.test(new URL(url).hostname);
} catch (e) {
return false;
}
}
/**
* Test de lecture : extraction complète (même format que la lecture) sans téléchargement.
* @param {{url?: string, route?: "auto"|"warp"|"direct", identity?: "auto"|"anon"|"cookies", clients?: string}} [options]
* @returns {Promise<{ok: boolean, route: string, identity: string, clients: string, cls: string|null, message: string, format: object|null, ms: number, version: string|null}>}
*/
async function testPlayback({ url, route = "auto", identity = "auto", clients } = {}) {
if (pendingTest) throw busyError();
const s = settings.get();
const target = url || s.testUrl;
const chosenClients = clients === undefined || clients === null ? s.playerClients : settings.normalizeClients(clients);
const fail = (message) => ({ ok: false, route, identity, clients: chosenClients, cls: null, message, format: null, ms: 0, version: ytdlp.getCachedVersion() });
if (!isSafeUrl(target)) return fail("URL invalide : lien YouTube (youtube.com ou youtu.be) attendu");
// Route
const routes = warp.getRoutes();
let proxy;
if (route === "warp") {
proxy = routes.find(r => r) ?? undefined;
if (!proxy) return fail("Proxy WARP non configuré ou indisponible");
} else if (route === "direct") {
proxy = null;
} else {
proxy = routes[0] ?? null;
}
const routeName = proxy ? "warp" : "direct";
// Identité
let useCookies;
if (identity === "cookies") {
if (!cookies.getStatus().present) return fail("Aucun cookie enregistré");
useCookies = true;
} else if (identity === "anon") {
useCookies = false;
} else {
useCookies = s.cookiesMode === "always" && cookies.isValid();
}
pendingTest = (async () => {
const started = Date.now();
const copy = useCookies ? await cookies.acquireCopy() : null;
let result;
try {
const args = [
...ytdlp.baseArgs({ proxy, cookiesPath: copy?.path, playerClients: chosenClients }),
'-f', FORMAT,
'--print', '%(format_id)s|%(ext)s|%(acodec)s|%(abr)s',
'--', target
];
result = await ytdlp.run(args, { timeoutMs: CHECK_TIMEOUT });
} finally {
copy?.release();
}
const ms = Date.now() - started;
const version = await ytdlp.getVersion().catch(() => null);
const base = { route: routeName, identity: useCookies ? "cookies" : "anon", clients: chosenClients, ms, version };
const line = result.stdout.trim().split(/\r?\n/).pop() || "";
const classified = errors.classify(result.stderr);
if (classified.cookiesRotated) cookies.markInvalid("Cookies refusés par YouTube lors d'un test de lecture (rotation)");
if (result.code === 0 && line.includes('|')) {
const [formatId, ext, acodec, abr] = line.split('|');
clog.log(`Test de lecture réussi (route ${routeName}, ${base.identity}) : format ${formatId} (${ext}, ${acodec}) en ${ms} ms`);
return { ok: true, ...base, cls: null, message: "Extraction réussie", format: { id: formatId, ext, acodec, abr: abr === "NA" ? null : Number(abr) || abr }, details: stderrTail(result.stderr, 4) };
}
clog.warn(`Test de lecture échoué (route ${routeName}, ${base.identity}) : ${classified.cls} - ${classified.message}`);
return { ok: false, ...base, cls: classified.cls, message: classified.message, format: null, details: stderrTail(result.stderr) };
})().finally(() => {
pendingTest = null;
});
return pendingTest;
}
module.exports = { checkCookies, testPlayback, FORMAT };
+563
View File
@@ -0,0 +1,563 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/cookies.js
* Description: Cookies YouTube : lecture (Netscape / JSON), validation, fichier maître et copies temporaires pour yt-dlp
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Cookies");
const fs = require('fs');
const os = require('os');
const path = require('path');
const { __glob } = require('../utils/GlobalVars');
const { writeFileAtomic, readJson, writeJson, emitUpdate } = require('./util');
const NETSCAPE_HEADER = "# Netscape HTTP Cookie File";
const HTTPONLY_PREFIX = "#HttpOnly_";
const COPY_PREFIX = "chopin-ytc-";
// Copie temporaire oubliée (crash) : supprimée au démarrage au-delà de cet âge
const STALE_COPY_AGE = 60 * 60 * 1000;
// Règle de yt-dlp (_has_auth_cookies) : LOGIN_INFO + l'un des SAPISID, pour .youtube.com
const LOGIN_COOKIE = "LOGIN_INFO";
const SAPISID_COOKIES = ["SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID"];
// Cookies de session Google attendus pour un compte connecté
const AUTH_COOKIES = ["LOGIN_INFO", "SID", "HSID", "SSID", "APISID", "SAPISID",
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PAPISID", "__Secure-3PAPISID",
"__Secure-1PSIDTS", "__Secure-3PSIDTS"];
const ALLOWED_DOMAINS = ["youtube.com", "google.com"];
const SOURCES = /^(paste|file|legacy|browser:[a-z]+)$/;
/**
* Erreur typée renvoyée à l'administration (message en français)
*/
function cookieError(message) {
const err = new Error(message);
err.code = "INVALID_COOKIES";
return err;
}
function bareDomain(domain) {
return String(domain || '').trim().toLowerCase().replace(/^\./, '');
}
function isAllowedDomain(domain) {
const d = bareDomain(domain);
return ALLOWED_DOMAINS.some(allowed => d === allowed || d.endsWith("." + allowed));
}
function isYoutubeDomain(domain) {
const d = bareDomain(domain);
return d === "youtube.com" || d.endsWith(".youtube.com");
}
/**
* Le cookie est envoyé à https://www.youtube.com (portée utilisée par yt-dlp pour la règle de connexion)
*/
function appliesToYoutube(cookie) {
const d = bareDomain(cookie.domain);
if (d === "www.youtube.com") return true;
return d === "youtube.com" && cookie.includeSubdomains;
}
/**
* Expiration en secondes (0 = cookie de session). Accepte secondes (même décimales), millisecondes ou date ISO.
*/
function toExpires(value) {
if (value === undefined || value === null || value === '' || value === false) return 0;
let n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) {
const parsed = Date.parse(String(value));
if (!Number.isFinite(parsed)) return 0;
n = parsed / 1000;
}
// Valeur en millisecondes (certains exports)
if (n > 1e11) n = n / 1000;
return n > 0 ? Math.floor(n) : 0;
}
function toBool(value) {
if (typeof value === 'boolean') return value;
return /^(true|1|yes)$/i.test(String(value ?? '').trim());
}
/**
* Nom / valeur utilisables dans un fichier Netscape (ni tabulation ni retour à la ligne)
*/
function isSafeField(text, allowEmpty) {
if (typeof text !== 'string') return false;
if (!allowEmpty && text.length === 0) return false;
return !/[\t\r\n]/.test(text);
}
function normalizeCookie(raw) {
const domain = String(raw.domain || '').trim();
const name = String(raw.name ?? '').trim();
const value = raw.value === undefined || raw.value === null ? '' : String(raw.value);
if (!domain || !isSafeField(name, false) || /[\s;=]/.test(name) || !isSafeField(value, true)) return null;
// includeSubdomains suit le point initial du domaine (cohérence exigée par le format Netscape)
const includeSubdomains = domain.startsWith('.');
return {
domain: domain.toLowerCase(),
includeSubdomains,
path: String(raw.path || '/').trim() || '/',
secure: Boolean(raw.secure),
expires: raw.expires,
name,
value,
httpOnly: Boolean(raw.httpOnly)
};
}
/**
* Export JSON d'extension (EditThisCookie, Cookie-Editor, "Get cookies.txt LOCALLY")
*/
function parseJsonEntries(list, warnings) {
const cookies = [];
let bad = 0;
for (const entry of list) {
if (!entry || typeof entry !== 'object' || entry.name === undefined || !entry.domain) {
bad++;
continue;
}
let domain = String(entry.domain).trim();
const hostOnly = entry.hostOnly === true || entry.hostOnly === "true";
// Cookie de domaine (non hostOnly) : le point initial étend le cookie aux sous-domaines
if (!hostOnly && !domain.startsWith('.')) domain = "." + domain;
if (hostOnly) domain = domain.replace(/^\./, '');
const session = entry.session === true || entry.session === "true";
const expires = session ? 0 : toExpires(entry.expirationDate ?? entry.expires ?? entry.expiry ?? entry.expirationTime);
const cookie = normalizeCookie({
domain,
path: entry.path,
secure: toBool(entry.secure),
expires,
name: entry.name,
value: entry.value,
httpOnly: toBool(entry.httpOnly)
});
if (cookie) cookies.push(cookie); else bad++;
}
if (bad) warnings.push(`${bad} entrée(s) JSON ignorée(s) (format invalide)`);
return cookies;
}
/**
* Fichier texte Netscape (cookies.txt), avec ou sans en-tête, fins de ligne CRLF acceptées
*/
function parseNetscape(text, warnings) {
const cookies = [];
let bad = 0;
for (let line of text.split(/\r\n|\n|\r/)) {
let httpOnly = false;
if (line.startsWith(HTTPONLY_PREFIX)) {
httpOnly = true;
line = line.slice(HTTPONLY_PREFIX.length);
} else if (line.trim() === '' || line.trimStart().startsWith('#')) {
continue;
}
let fields = line.split('\t');
let separator = '\t';
// Tabulations remplacées par des espaces lors d'un copier-coller
if (fields.length < 6) {
fields = line.trim().split(/\s+/);
separator = ' ';
}
if (fields.length < 6) {
bad++;
continue;
}
// Valeur vide : 6 champs seulement ; valeur coupée par le séparateur : on recolle
const [domain, flag, cookiePath, secure, expires, name, ...rest] = fields;
const value = rest.join(separator);
if (!/^(TRUE|FALSE)$/i.test(flag) || !/^(TRUE|FALSE)$/i.test(secure) || !/^-?\d+(\.\d+)?$/.test(expires.trim())) {
bad++;
continue;
}
const cookie = normalizeCookie({
domain: flag.toUpperCase() === "TRUE" && !domain.startsWith('.') ? "." + domain : domain,
path: cookiePath,
secure: secure.toUpperCase() === "TRUE",
expires: toExpires(expires),
name,
value: value.trimEnd(),
httpOnly
});
if (cookie) cookies.push(cookie); else bad++;
}
if (bad) warnings.push(`${bad} ligne(s) ignorée(s) (format invalide)`);
return cookies;
}
/**
* Lit un fichier de cookies (Netscape ou export JSON) et ne garde que les cookies YouTube / Google valides.
* @param {string} text
* @param {{now?: number}} [options] - now en secondes (tests)
* @returns {{cookies: Array<{domain: string, includeSubdomains: boolean, path: string, secure: boolean, expires: number, name: string, value: string, httpOnly: boolean}>, warnings: string[]}}
* @throws {Error} code INVALID_COOKIES si le contenu n'est pas reconnu
*/
function parseCookies(text, { now } = {}) {
if (typeof text !== 'string' || !text.trim()) throw cookieError("Le contenu des cookies est vide");
const warnings = [];
const content = text.replace(/^/, '').trim();
let cookies;
if (content.startsWith('[') || content.startsWith('{')) {
let data;
try {
data = JSON.parse(content);
} catch (e) {
throw cookieError("Export JSON illisible : vérifiez que le fichier est complet");
}
const list = Array.isArray(data) ? data : (Array.isArray(data?.cookies) ? data.cookies : null);
if (!list) throw cookieError("Export JSON non reconnu : une liste de cookies est attendue");
cookies = parseJsonEntries(list, warnings);
} else {
cookies = parseNetscape(content, warnings);
}
if (cookies.length === 0) {
throw cookieError("Aucun cookie reconnu : fichier cookies.txt (format Netscape) ou export JSON attendu");
}
// Uniquement YouTube / Google : le reste ne sert pas et n'a pas à être stocké
const allowed = cookies.filter(c => isAllowedDomain(c.domain));
if (allowed.length < cookies.length) {
warnings.push(`${cookies.length - allowed.length} cookie(s) d'autres sites ignoré(s)`);
}
const nowSec = now ?? Math.floor(Date.now() / 1000);
const alive = allowed.filter(c => !(c.expires > 0 && c.expires <= nowSec));
if (alive.length < allowed.length) {
warnings.push(`${allowed.length - alive.length} cookie(s) expiré(s) ignoré(s)`);
}
// Doublons (même domaine / chemin / nom) : la dernière occurrence l'emporte
const unique = new Map();
for (const cookie of alive) unique.set(`${cookie.domain}\t${cookie.path}\t${cookie.name}`, cookie);
return { cookies: [...unique.values()], warnings };
}
/**
* Fichier Netscape (fins de ligne LF) lisible par yt-dlp
*/
function serializeCookies(cookies) {
const lines = [NETSCAPE_HEADER, "# Fichier géré par Subsonics Chopin : ne pas modifier à la main", ""];
for (const c of cookies) {
const domain = c.includeSubdomains ? "." + bareDomain(c.domain) : bareDomain(c.domain);
lines.push([
(c.httpOnly ? HTTPONLY_PREFIX : "") + domain,
c.includeSubdomains ? "TRUE" : "FALSE",
c.path || "/",
c.secure ? "TRUE" : "FALSE",
String(c.expires || 0),
c.name,
c.value
].join("\t"));
}
return lines.join("\n") + "\n";
}
/**
* Résumé sans aucune valeur de cookie
*/
function analyze(cookies) {
const list = Array.isArray(cookies) ? cookies : [];
const domains = [...new Set(list.map(c => c.domain))].sort();
const yt = list.filter(appliesToYoutube);
const ytNames = new Set(yt.map(c => c.name));
const authNames = AUTH_COOKIES.filter(name => ytNames.has(name));
const missing = AUTH_COOKIES.filter(name => !ytNames.has(name));
const loggedIn = ytNames.has(LOGIN_COOKIE) && SAPISID_COOKIES.some(name => ytNames.has(name));
const authCookies = yt.filter(c => AUTH_COOKIES.includes(c.name));
const authExpiries = authCookies.map(c => c.expires).filter(e => e > 0);
const allExpiries = list.map(c => c.expires).filter(e => e > 0);
return {
count: list.length,
youtubeCount: list.filter(c => isYoutubeDomain(c.domain)).length,
domains,
loggedIn,
authNames,
missing,
earliestAuthExpiry: authExpiries.length ? new Date(Math.min(...authExpiries) * 1000).toISOString() : null,
latestExpiry: allExpiries.length ? new Date(Math.max(...allExpiries) * 1000).toISOString() : null,
// Au moins un cookie de connexion sans date d'expiration (perdu à la fermeture du navigateur)
hasSessionOnly: authCookies.some(c => !c.expires)
};
}
// --- Stockage (fichier maître + état) ---
const EMPTY_STATE = {
importedAt: null,
importedBy: null,
source: null,
invalidatedAt: null,
invalidReason: null,
lastCheck: null
};
let cache = null; // { content, analysis, error } du fichier maître
let state = null;
const liveCopies = new Set();
function masterPath() {
return __glob.YOUTUBE_COOKIES;
}
function loadState() {
if (!state) state = { ...EMPTY_STATE, ...readJson(__glob.YOUTUBE_STATE, {}) };
return state;
}
function saveState() {
writeJson(__glob.YOUTUBE_STATE, state);
}
/**
* Lecture (mise en cache) du fichier maître
*/
function loadMaster() {
if (cache) return cache;
if (!fs.existsSync(masterPath())) {
cache = { content: null, analysis: analyze([]), error: null };
return cache;
}
try {
const { cookies } = parseCookies(fs.readFileSync(masterPath(), 'utf-8'));
cache = { content: serializeCookies(cookies), analysis: analyze(cookies), error: null };
} catch (e) {
cache = { content: null, analysis: analyze([]), error: e.message };
}
return cache;
}
/**
* Cookies utilisables pour la lecture : présents, connectés et non invalidés
*/
function isValid() {
const master = loadMaster();
return Boolean(master.content && master.analysis.loggedIn && !loadState().invalidatedAt);
}
function getStatus() {
const master = loadMaster();
const st = loadState();
const present = Boolean(master.content);
return {
present,
loggedIn: present && master.analysis.loggedIn,
valid: present && master.analysis.loggedIn && !st.invalidatedAt,
invalidatedAt: present ? st.invalidatedAt : null,
invalidReason: present ? st.invalidReason : null,
importedAt: present ? st.importedAt : null,
importedBy: present ? st.importedBy : null,
source: present ? st.source : null,
count: master.analysis.count,
authNames: master.analysis.authNames,
missing: master.analysis.missing,
earliestAuthExpiry: master.analysis.earliestAuthExpiry,
hasSessionOnly: master.analysis.hasSessionOnly,
lastCheck: present ? st.lastCheck : null,
error: master.error
};
}
/**
* Importe des cookies (collage, fichier, navigateur, ancien fichier) et remplace le fichier maître.
* @param {string} text
* @param {{source?: string, by?: {id: string, username: string}|null}} options
* @returns {{status: object, warnings: string[]}}
*/
function importCookies(text, { source = "paste", by = null } = {}) {
const { cookies, warnings } = parseCookies(text);
const analysis = analyze(cookies);
if (analysis.youtubeCount === 0) {
throw cookieError("Aucun cookie youtube.com valide : exportez les cookies depuis un onglet youtube.com");
}
if (!analysis.loggedIn) {
warnings.push("Aucun cookie de connexion : ces cookies ne correspondent pas à un compte YouTube connecté");
}
const content = serializeCookies(cookies);
writeFileAtomic(masterPath(), content, 0o600);
cache = { content, analysis, error: null };
state = {
...EMPTY_STATE,
importedAt: new Date().toISOString(),
importedBy: by && by.id ? { id: String(by.id), username: String(by.username ?? '') } : null,
source: SOURCES.test(String(source)) ? String(source) : "paste"
};
saveState();
clog.log(`Cookies YouTube importés (${state.source}${state.importedBy ? `, par ${state.importedBy.username}` : ''}) : ${analysis.count} cookie(s), ${analysis.loggedIn ? "compte connecté" : "aucun compte connecté"}`);
emitUpdate();
return { status: getStatus(), warnings };
}
function deleteCookies() {
try {
fs.rmSync(masterPath(), { force: true });
} catch (e) {
clog.error(`Suppression des cookies impossible : ${e.message}`);
throw e;
}
cache = null;
state = { ...EMPTY_STATE };
saveState();
clog.log("Cookies YouTube supprimés");
emitUpdate();
return getStatus();
}
/**
* Marque les cookies comme invalides (rotation, session révoquée). Idempotent.
* @param {string} reason
*/
function markInvalid(reason) {
if (!loadMaster().content) return false;
const st = loadState();
if (st.invalidatedAt) return false;
st.invalidatedAt = new Date().toISOString();
st.invalidReason = String(reason || "Cookies refusés par YouTube").slice(0, 300);
saveState();
clog.warn(`Cookies YouTube invalidés : ${st.invalidReason} - lecture sans compte en attendant un nouvel import`);
emitUpdate();
return true;
}
/**
* Résultat d'une vérification en direct (check.js)
* @param {{at: string, ok: boolean, loggedIn: boolean, premium: boolean, message: string}} result
*/
function setLastCheck(result) {
if (!loadMaster().content) return;
const st = loadState();
st.lastCheck = result;
// yt-dlp a reconnu le compte : une invalidation précédente était donc injustifiée
if (result.ok && result.loggedIn && st.invalidatedAt) {
st.invalidatedAt = null;
st.invalidReason = null;
}
saveState();
emitUpdate();
}
/**
* Copie privée du fichier maître pour un processus yt-dlp (qui réécrit son fichier de cookies en sortant).
* @returns {Promise<{path: string, release: function(): void}|null>} null si aucun cookie
*/
async function acquireCopy() {
const master = loadMaster();
if (!master.content) return null;
// Import auquel appartient la copie (un processus lancé avant un nouvel import ne doit pas invalider le nouveau)
const importedAt = loadState().importedAt;
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), COPY_PREFIX));
liveCopies.add(dir);
let released = false;
const release = () => {
if (released) return;
released = true;
liveCopies.delete(dir);
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* déjà supprimé */ }
};
try {
const file = path.join(dir, "cookies.txt");
await fs.promises.writeFile(file, master.content, { mode: 0o600 });
return { path: file, release, importedAt };
} catch (e) {
release();
throw e;
}
}
/**
* Supprime les copies temporaires abandonnées (crash, arrêt brutal)
* @param {number} [maxAgeMs]
*/
function sweepStaleCopies(maxAgeMs = STALE_COPY_AGE) {
let removed = 0;
try {
const tmp = os.tmpdir();
for (const entry of fs.readdirSync(tmp)) {
if (!entry.startsWith(COPY_PREFIX)) continue;
const full = path.join(tmp, entry);
if (liveCopies.has(full)) continue;
try {
if (Date.now() - fs.statSync(full).mtimeMs < maxAgeMs) continue;
fs.rmSync(full, { recursive: true, force: true });
removed++;
} catch (e) {
// Supprimé entre-temps
}
}
} catch (e) {
clog.warn(`Nettoyage des copies de cookies impossible : ${e.message}`);
}
if (removed) clog.log(`${removed} copie(s) temporaire(s) de cookies supprimée(s)`);
return removed;
}
function liveCopyCount() {
return liveCopies.size;
}
// Arrêt du process : aucune copie de cookies ne doit rester dans le dossier temporaire
process.once('exit', () => {
for (const dir of liveCopies) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignoré */ }
}
});
/**
* Migration des anciens fichiers data/cookies.txt et data/cookies.json (jamais modifiés ni supprimés).
* Le premier fichier qui donne un compte connecté l'emporte, sinon le premier lisible.
* @returns {{migrated: boolean, from?: string, loggedIn?: boolean}}
*/
function migrateLegacy() {
// state.json présent : les cookies sont déjà gérés ici (une suppression volontaire ne doit pas réimporter les anciens)
if (fs.existsSync(masterPath()) || fs.existsSync(__glob.YOUTUBE_STATE)) return { migrated: false };
const candidates = [__glob.COOKIES, __glob.COOKIES_JSON].filter(file => file && fs.existsSync(file));
if (candidates.length === 0) return { migrated: false };
let fallback = null;
for (const file of candidates) {
let text;
try {
text = fs.readFileSync(file, 'utf-8');
const { cookies } = parseCookies(text);
const analysis = analyze(cookies);
if (analysis.youtubeCount === 0) continue;
if (analysis.loggedIn) return doMigrate(file, text, true);
if (!fallback) fallback = { file, text };
} catch (e) {
clog.warn(`Ancien fichier de cookies illisible (${path.basename(file)}) : ${e.message}`);
}
}
if (fallback) return doMigrate(fallback.file, fallback.text, false);
return { migrated: false };
}
function doMigrate(file, text, loggedIn) {
importCookies(text, { source: "legacy" });
clog.log(`Anciens cookies repris depuis data/${path.basename(file)} (fichier d'origine conservé)${loggedIn ? "" : " - aucun compte connecté"}`);
return { migrated: true, from: path.basename(file), loggedIn };
}
/**
* Oublie le cache (tests, ou après modification externe)
*/
function reload() {
cache = null;
state = null;
}
module.exports = {
parseCookies, serializeCookies, analyze,
importCookies, deleteCookies, getStatus, isValid, markInvalid, setLastCheck,
acquireCopy, sweepStaleCopies, liveCopyCount, migrateLegacy, reload,
AUTH_COOKIES
};
+110
View File
@@ -0,0 +1,110 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/diagnostics.js
* Description: Historique des tentatives de lecture (100 dernières) et compteurs par classe d'erreur
*/
const { emitUpdate } = require('./util');
const MAX_ENTRIES = 100;
const EMIT_DEBOUNCE = 1000;
// Échecs qui signalent un blocage (IP, session, client) : une série déclenche une mise à jour de yt-dlp
const BLOCK_CLASSES = new Set(["BOT_CHECK", "IP_BLOCKED", "CAPTCHA", "HTTP_403", "POT_MISSING", "SABR"]);
let entries = [];
let counters = {};
let totals = { ok: 0, failed: 0, aborted: 0 };
let consecutiveBlocks = 0;
let since = new Date().toISOString();
let lastFailure = null;
let lastSuccess = null;
let emitTimer = null;
function scheduleEmit() {
if (emitTimer) return;
emitTimer = setTimeout(() => {
emitTimer = null;
emitUpdate();
}, EMIT_DEBOUNCE);
emitTimer.unref?.();
}
/**
* Enregistre une tentative de lecture
* @param {{guildId?: string|null, songId?: string|null, title?: string|null, route: "warp"|"direct", identity: "anon"|"cookies", clients?: string, outcome: "ok"|"failed"|"aborted", cls?: string|null, message?: string|null, ms?: number}} attempt
*/
function record(attempt) {
const entry = {
at: new Date().toISOString(),
guildId: attempt.guildId ?? null,
songId: attempt.songId ?? null,
title: attempt.title ? String(attempt.title).slice(0, 200) : null,
route: attempt.route === "warp" ? "warp" : "direct",
identity: attempt.identity === "cookies" ? "cookies" : "anon",
clients: attempt.clients || "",
seek: attempt.seek || null,
outcome: ["ok", "failed", "aborted"].includes(attempt.outcome) ? attempt.outcome : "failed",
cls: attempt.outcome === "ok" ? null : (attempt.cls || null),
message: attempt.message ? String(attempt.message).slice(0, 300) : null,
ms: Number.isFinite(attempt.ms) ? Math.round(attempt.ms) : null
};
entries.push(entry);
if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES);
totals[entry.outcome]++;
if (entry.outcome === "ok") {
consecutiveBlocks = 0;
lastSuccess = entry.at;
} else if (entry.outcome === "failed") {
const cls = entry.cls || "UNKNOWN";
counters[cls] = (counters[cls] || 0) + 1;
if (BLOCK_CLASSES.has(cls)) consecutiveBlocks++;
lastFailure = { at: entry.at, cls, message: entry.message, route: entry.route, identity: entry.identity };
}
scheduleEmit();
return entry;
}
/**
* Tentatives récentes, de la plus récente à la plus ancienne
* @param {number} [limit]
*/
function getRecent(limit = 50) {
const n = Math.max(0, Math.min(MAX_ENTRIES, Number(limit) || 0));
return entries.slice(-n).reverse();
}
function getConsecutiveBlocks() {
return consecutiveBlocks;
}
function getSummary() {
const recent = entries.slice(-20).filter(e => e.outcome !== "aborted");
return {
since,
total: totals.ok + totals.failed + totals.aborted,
ok: totals.ok,
failed: totals.failed,
aborted: totals.aborted,
byClass: { ...counters },
consecutiveBlocks,
lastFailure,
lastSuccess,
// Taux d'échec sur les 20 dernières tentatives (pastille de l'administration)
recentFailureRate: recent.length ? Math.round(100 * recent.filter(e => e.outcome === "failed").length / recent.length) / 100 : null,
recentCount: recent.length
};
}
function clear() {
entries = [];
counters = {};
totals = { ok: 0, failed: 0, aborted: 0 };
consecutiveBlocks = 0;
since = new Date().toISOString();
lastFailure = null;
lastSuccess = null;
scheduleEmit();
}
module.exports = { record, getRecent, getSummary, getConsecutiveBlocks, clear, BLOCK_CLASSES };
+147
View File
@@ -0,0 +1,147 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/errors.js
* Description: Classement des erreurs yt-dlp (stderr) et nettoyage des textes avant journalisation
*/
// "you're" peut s'écrire avec une apostrophe droite ou typographique (’) : on accepte n'importe quel caractère
const COOKIES_ROTATED_RE = /account cookies are no longer valid|cookies are no longer valid/i;
const RULES = [
// Lancement impossible (binaire absent) : message produit par Node / par nous
{ cls: "BINARY_MISSING", retry: "none", re: /\bENOENT\b|(?:yt-dlp|ffmpeg) introuvable|not recognized as an internal or external command|No such file or directory.*yt-dlp/i },
{ cls: "COOKIES_ROTATED", retry: "cookies", re: COOKIES_ROTATED_RE },
{ cls: "PRIVATE", retry: "none", re: /\bPrivate video\b|video is private/i },
{ cls: "MEMBERS_ONLY", retry: "cookies", re: /available to this channel.?s members|members[- ]only content|Join this channel to get access/i },
{ cls: "AGE_RESTRICTED", retry: "cookies", re: /confirm your age|age[- ]restricted|inappropriate for some users/i },
{ cls: "BOT_CHECK", retry: "route", re: /confirm you.?re not a bot/i },
{ cls: "CAPTCHA", retry: "route", re: /requiring a captcha|captcha challenge/i },
{ cls: "IP_BLOCKED", retry: "route", re: /IP is likely being blocked|All player responses are invalid/i },
// Doit passer avant UNAVAILABLE : "This content isn't available, try again later"
{ cls: "RATE_LIMITED", retry: "route", re: /content isn.?t available, try again later|rate[- ]limited by YouTube|session has been rate[- ]limited/i },
{ cls: "GEO_BLOCKED", retry: "route", re: /not made this video available in your country|not available in your country|geo[- ]?restrict/i },
{ cls: "LOGIN_REQUIRED", retry: "cookies", re: /Login details are needed|Sign in to confirm|requires? (?:a )?login|Use --cookies-from-browser or --cookies for the authentication|This video requires payment/i },
{ cls: "UNAVAILABLE", retry: "none", re: /Video unavailable|This video is unavailable|video has been removed|video is no longer available|account associated with this video has been terminated|content isn.?t available|Unsupported URL|Incomplete YouTube ID|is not a valid URL/i },
// Causes précises avant les codes HTTP : l'avertissement PO token cite lui-même "HTTP Error 403"
{ cls: "SABR", retry: "client", re: /\bSABR\b|formats have been skipped as they are missing a url/i },
{ cls: "POT_MISSING", retry: "client", re: /PO Token|po_token|\bPOT\b.*(?:missing|required)/i },
{ cls: "JS_CHALLENGE", retry: "client", re: /n challenge solving failed|Signature solving failed|No supported JavaScript runtime|nsig extraction failed|Signature extraction failed|challenge solver/i },
{ cls: "HTTP_429", retry: "route", re: /HTTP Error 429|Too Many Requests/i },
{ cls: "HTTP_403", retry: "client", re: /HTTP Error 403|403:? Forbidden/i },
{ cls: "PAGE_RELOAD", retry: "route", re: /page needs to be reloaded/i },
{ cls: "EMPTY_DOWNLOAD", retry: "client", re: /downloaded file is empty/i },
{ cls: "FORMAT_UNAVAILABLE", retry: "client", re: /Requested format is not available|No video formats found|Only images are available/i },
{ cls: "TIMEOUT", retry: "route", re: /timed out|time-?out|aucune donnée après/i },
];
// Causes profondes cherchées dans les avertissements quand l'erreur finale est générique
const ROOT_CAUSES = new Set(["SABR", "POT_MISSING", "JS_CHALLENGE", "PAGE_RELOAD"]);
const GENERIC = new Set(["FORMAT_UNAVAILABLE", "EMPTY_DOWNLOAD", "UNKNOWN"]);
const RETRY = Object.fromEntries(RULES.map(rule => [rule.cls, rule.retry]));
RETRY.UNKNOWN = "route";
const CLASSES = [...RULES.map(rule => rule.cls), "UNKNOWN"];
/**
* Ligne d'erreur (et pas simple information) : ERROR:, ou erreur HTTP pendant le téléchargement
*/
function isErrorLine(line) {
// Un avertissement peut citer "HTTP Error 403" (formats PO token ignorés) sans être une erreur
if (/^WARNING:/i.test(line)) return false;
return /^ERROR:/i.test(line) || /\bGot error:|Giving up after|HTTP Error \d{3}/i.test(line);
}
function matchLines(lines) {
for (const rule of RULES) {
const line = lines.find(l => rule.re.test(l));
if (line) return { rule, line };
}
return null;
}
/**
* Message lisible : sans le préfixe ERROR:/WARNING:, nettoyé et tronqué
*/
function cleanMessage(line) {
const text = sanitize(String(line || '').replace(/^(?:ERROR|WARNING):\s*/i, '').trim());
return text.length > 300 ? text.slice(0, 297) + '...' : text;
}
/**
* Classe une sortie d'erreur yt-dlp (analyse ligne par ligne).
* Les lignes ERROR priment ; si l'erreur finale est générique (format indisponible, fichier vide...),
* une cause profonde trouvée dans les avertissements (SABR, PO token, défi JS) la remplace.
* @param {string|string[]} stderrText
* @returns {{cls: string, retry: "route"|"cookies"|"client"|"none", message: string, cookiesRotated: boolean, hints: string[]}}
*/
function classify(stderrText) {
const lines = (Array.isArray(stderrText) ? stderrText.join('\n') : String(stderrText || ''))
.split(/\r?\n|\r/)
.map(l => l.trim())
.filter(Boolean);
const cookiesRotated = lines.some(l => COOKIES_ROTATED_RE.test(l));
// Information seule (client ignoré faute de support des cookies) : jamais une cause d'échec
const useful = lines.filter(l => !/Skipping client .* since it does not support cookies/i.test(l));
const errorLines = useful.filter(isErrorLine);
let found = errorLines.length ? matchLines(errorLines) : null;
// Avertissement de rotation : signalé par le drapeau, la classe vient de l'erreur réelle
if (found && found.rule.cls === "COOKIES_ROTATED") {
const other = matchLines(errorLines.filter(l => !COOKIES_ROTATED_RE.test(l)));
if (other) found = other;
}
if (!found) found = matchLines(useful.filter(l => !COOKIES_ROTATED_RE.test(l))) || (cookiesRotated ? matchLines(useful) : null);
let cls = found ? found.rule.cls : "UNKNOWN";
let line = found ? found.line : (errorLines[errorLines.length - 1] || useful[useful.length - 1] || "");
// Cause profonde dans les avertissements
const hints = [];
for (const l of useful) {
for (const rule of RULES) {
if (ROOT_CAUSES.has(rule.cls) && rule.re.test(l) && !hints.includes(rule.cls)) hints.push(rule.cls);
}
}
if (GENERIC.has(cls) && hints.length) {
const root = hints[0];
const rootLine = useful.find(l => RULES.find(r => r.cls === root).re.test(l));
cls = root;
line = rootLine || line;
}
return {
cls,
retry: RETRY[cls] || "route",
message: cleanMessage(line) || "Erreur inconnue",
cookiesRotated,
hints
};
}
/**
* Retire tout ce qui ressemble à un secret : valeurs de cookies, en-têtes d'authentification,
* chemin du fichier de cookies, identifiants de proxy, jetons PO.
* @param {string} text
* @returns {string}
*/
function sanitize(text) {
if (text === null || text === undefined) return '';
return String(text)
// Identifiants dans une URL (proxy http://user:pass@hote)
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:'"]+(?::[^\s/@'"]*)?@/gi, '$1***@')
// Option --cookies <chemin> (chemin avec ou sans guillemets)
.replace(/(--cookies(?:-from-browser)?\s+)("[^"]*"|'[^']*'|\S+)/gi, '$1<masqué>')
// Copies temporaires des cookies
.replace(/chopin-ytc-[A-Za-z0-9]+/g, 'chopin-ytc-***')
// En-têtes HTTP sensibles
.replace(/((?:Set-)?Cookie|Authorization|X-Goog-Visitor-Id|X-Youtube-Identity-Token)(["']?\s*[:=]\s*["']?)[^\r\n"']+/gi, '$1$2***')
.replace(/\b(SAPISIDHASH|SAPISID1PHASH|SAPISID3PHASH)\s+\S+/g, '$1 ***')
// Valeurs de cookies connus (nom=valeur ou nom\tvaleur)
.replace(/\b((?:__Secure-|__Host-)[\w-]+|SID|HSID|SSID|APISID|SAPISID|LOGIN_INFO|SIDCC|PREF|VISITOR_INFO1_LIVE|VISITOR_PRIVACY_METADATA|YSC|SOCS|NID|GPS)(=|\t)[^;\s]+/g, '$1$2***')
// Jetons PO / visitor data
.replace(/\b(po_token|pot|visitor_data|visitorData)(=|:\s*)[\w%.\-/+]{8,}/gi, '$1$2***');
}
module.exports = { classify, sanitize, isErrorLine, CLASSES, RETRY };
+91
View File
@@ -0,0 +1,91 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/index.js
* Description: Point d'entrée : initialisation (dossiers, migration des cookies, version, mises à jour) et état global
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube");
const fs = require('fs');
const { __glob } = require('../utils/GlobalVars');
const cookies = require('./cookies');
const settings = require('./settings');
const ytdlp = require('./ytdlp');
const errors = require('./errors');
const diagnostics = require('./diagnostics');
const check = require('./check');
const plan = require('./plan');
let initialized = false;
/**
* Initialisation au démarrage du bot. Ne lève jamais : un problème YouTube ne doit pas empêcher le démarrage.
*/
async function init() {
if (initialized) return;
initialized = true;
try {
fs.mkdirSync(__glob.YOUTUBE_CACHE, { recursive: true });
cookies.sweepStaleCopies();
ytdlp.sweepWindowsTemp();
const migration = cookies.migrateLegacy();
const status = cookies.getStatus();
const s = settings.get();
clog.log(`Cookies YouTube : ${status.present ? `${status.count} cookie(s), ${status.valid ? "compte connecté" : status.invalidatedAt ? "invalidés" : "aucun compte connecté"}` : "aucun"} - mode ${s.cookiesMode}`);
if (status.error) clog.warn(`Fichier de cookies illisible : ${status.error}`);
const bin = ytdlp.resolveBinary();
const ffmpeg = ytdlp.resolveFfmpeg();
if (bin.source === "missing") {
clog.error("yt-dlp introuvable (ni YTDLP_PATH, ni PATH, ni node_modules/youtube-dl-exec) : la lecture YouTube est impossible");
} else {
clog.log(`yt-dlp : ${bin.path} (${bin.source})`);
}
if (!ffmpeg) clog.warn("ffmpeg introuvable : le seek YouTube ne fonctionnera pas");
if (s.potProviderUrl) clog.log(`Fournisseur de PO token : ${settings.maskUrl(s.potProviderUrl)}`);
// Version en arrière-plan (ne retarde pas le démarrage)
ytdlp.getVersion().then((version) => {
if (version) clog.log(`Version de yt-dlp : ${version}`);
else if (bin.source !== "missing") clog.warn("Version de yt-dlp inconnue (le binaire ne répond pas)");
}).catch(() => {});
ytdlp.startAutoUpdate();
// Anciens cookies repris : on vérifie tout de suite s'ils sont encore acceptés par YouTube
if (migration.migrated && migration.loggedIn) {
const timer = setTimeout(() => {
check.checkCookies().catch((e) => clog.warn(`Vérification des cookies repris impossible : ${e?.message || e}`));
}, 15000);
timer.unref?.();
}
} catch (e) {
clog.error(`Initialisation du module YouTube incomplète : ${e?.stack || e}`);
}
}
/**
* URL du fournisseur de PO token, masquée
*/
function potStatus() {
const url = settings.get().potProviderUrl;
return { configured: Boolean(url), url: url ? settings.maskUrl(url) : null, locked: settings.isLocked("potProviderUrl") };
}
/**
* État complet pour l'administration
*/
function getStatus() {
const info = ytdlp.getInfo();
return {
ytdlp: info.ytdlp,
ffmpeg: info.ffmpeg,
cookies: cookies.getStatus(),
settings: settings.getPublic(),
pot: potStatus(),
diagnostics: diagnostics.getSummary(),
activeProcesses: ytdlp.activeCount()
};
}
module.exports = { init, getStatus, cookies, settings, ytdlp, errors, diagnostics, check, plan };
+150
View File
@@ -0,0 +1,150 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/plan.js
* Description: Plan des tentatives de lecture (route, identité, clients, méthode de seek) - fonctions pures
*/
// Au-delà, le seek "auto" passe par --download-sections (évite de télécharger tout le début)
const NATIVE_SEEK_MAX = 1200;
// Contenu inaccessible quelle que soit la route : inutile de continuer
const FATAL = new Set(["PRIVATE", "UNAVAILABLE", "BINARY_MISSING"]);
// Accès lié au compte : sans cookies, aucune autre route ne réussira
const NEEDS_ACCOUNT = new Set(["AGE_RESTRICTED", "LOGIN_REQUIRED", "MEMBERS_ONLY"]);
// Blocage de l'IP / de la session anonyme : autre route, puis cookies
const BLOCKED = new Set(["BOT_CHECK", "IP_BLOCKED", "CAPTCHA"]);
// Problème de client / format : une tentative avec les clients de repli
const CLIENT_ISSUES = new Set(["HTTP_403", "FORMAT_UNAVAILABLE", "SABR", "POT_MISSING", "EMPTY_DOWNLOAD"]);
/**
* Méthode de seek selon le réglage
* @param {"auto"|"ffmpeg"|"native"} strategy
* @param {number} seek - secondes
* @returns {"native"|"sections"|null}
*/
function seekModeFor(strategy, seek) {
if (!seek || seek <= 0) return null;
if (strategy === "ffmpeg") return "sections";
if (strategy === "native") return "native";
return seek <= NATIVE_SEEK_MAX ? "native" : "sections";
}
function attemptKey(a) {
return `${a.proxy || "direct"}|${a.identity}|${a.clients || ""}|${a.seek || ""}`;
}
/**
* Prochaine tentative à lancer, d'après les tentatives déjà faites et leur classe d'échec.
* Fonction pure : rejoue l'historique à chaque appel.
* @param {Object} input
* @param {Array<string|null>} input.routes - ordre des routes (null = directe), cf. warp.getRoutes()
* @param {"fallback"|"always"|"never"} input.mode - cookiesMode
* @param {boolean} input.cookiesValid - cookies présents, connectés et non invalidés (au moment de l'appel)
* @param {boolean} [input.cookiesOnProxy]
* @param {string} [input.clients] - playerClients ("" = défaut de yt-dlp)
* @param {string} [input.fallbackClients] - fallbackPlayerClients ("" = désactivé)
* @param {"native"|"sections"|null} [input.seekMode]
* @param {Array<{proxy: string|null, identity: string, clients: string, seek: string|null, cls: string}>} [input.history]
* @param {number} [input.maxAttempts]
* @returns {{proxy: string|null, identity: "anon"|"cookies", clients: string, seek: "native"|"sections"|null, reason: string}|null}
*/
function planNext({
routes,
mode = "fallback",
cookiesValid = false,
cookiesOnProxy = false,
clients = "",
fallbackClients = "",
seekMode = null,
history = [],
maxAttempts = 4
}) {
const allRoutes = Array.isArray(routes) && routes.length ? routes : [null];
if (history.length >= maxAttempts) return null;
const useCookies = cookiesValid && mode !== "never";
// Routes autorisées avec le compte : la directe, sauf réglage contraire ou si le proxy est la seule route
let cookieRoutes = cookiesOnProxy ? allRoutes : allRoutes.filter(r => r === null);
if (cookieRoutes.length === 0) cookieRoutes = allRoutes;
// Méthode de seek courante (repasse en natif après un 403 de ffmpeg)
let seekNow = seekMode;
const make = (proxy, identity, reason, overrides = {}) => ({
proxy,
identity,
clients: clients || "",
seek: seekNow,
reason,
...overrides
});
// Plan de base
let queue;
if (mode === "always" && useCookies) {
queue = allRoutes.map(r => cookieRoutes.includes(r) ? make(r, "cookies", "base") : make(r, "anon", "base"));
} else {
queue = allRoutes.map(r => make(r, "anon", "base"));
}
const cookieAttempts = (reason) => cookieRoutes.map(r => make(r, "cookies", reason));
const tried = new Set();
let cookiesAdded = mode === "always";
let clientAdded = false;
let nativeAdded = false;
for (const h of history) {
const key = attemptKey(h);
tried.add(key);
queue = queue.filter(a => attemptKey(a) !== key);
const cls = h.cls || "UNKNOWN";
if (FATAL.has(cls)) return null;
if (cls === "GEO_BLOCKED" || cls === "RATE_LIMITED") {
// Blocage lié à l'IP (pays, limite de débit) : seule une autre route peut aider
queue = queue.filter(a => a.proxy !== h.proxy);
continue;
}
if (NEEDS_ACCOUNT.has(cls)) {
// Déjà avec le compte, ou pas de compte utilisable : rien d'autre ne passera
if (h.identity === "cookies" || !useCookies) return null;
queue = queue.filter(a => a.identity === "cookies");
if (!cookiesAdded || queue.length === 0) queue = cookieAttempts("compte requis");
cookiesAdded = true;
continue;
}
if (BLOCKED.has(cls)) {
// Autres routes d'abord (autre IP), puis le compte en dernier recours
if (useCookies && !cookiesAdded) {
queue = queue.concat(cookieAttempts("blocage"));
cookiesAdded = true;
}
continue;
}
if (h.seek === "sections" && cls === "HTTP_403" && !nativeAdded) {
// ffmpeg (--download-sections) lit sans requêtes bornées : 403 fréquent, on repasse en natif
nativeAdded = true;
seekNow = "native";
queue = queue.map(a => a.seek === "sections" ? { ...a, seek: "native" } : a);
queue.unshift({ proxy: h.proxy, identity: h.identity, clients: h.clients || "", seek: "native", reason: "seek natif" });
continue;
}
if (CLIENT_ISSUES.has(cls) && fallbackClients && !clientAdded && h.clients !== fallbackClients) {
clientAdded = true;
queue.unshift({ proxy: h.proxy, identity: h.identity, clients: fallbackClients, seek: h.seek ?? seekNow, reason: "clients de repli" });
}
// Autres classes (délai, 429, page à recharger, défi JS, inconnue) : tentative suivante du plan
}
// Cookies invalidés entre-temps (rotation) : plus aucune tentative avec le compte
if (!useCookies) queue = queue.filter(a => a.identity !== "cookies");
// Mode "always" sans cookies utilisables : le plan de base est anonyme
queue = queue.filter(a => !tried.has(attemptKey(a)));
return queue[0] || null;
}
module.exports = { planNext, seekModeFor, attemptKey, NATIVE_SEEK_MAX, FATAL, NEEDS_ACCOUNT, BLOCKED, CLIENT_ISSUES };
+219
View File
@@ -0,0 +1,219 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/settings.js
* Description: Réglages YouTube / yt-dlp (data/youtube/settings.json), les variables d'environnement l'emportent
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Settings");
const { __glob } = require('../utils/GlobalVars');
const { readJson, writeJson, emitUpdate } = require('./util');
const DEFAULTS = Object.freeze({
// fallback : sans compte d'abord, cookies seulement après une vérification anti-robot / âge / connexion
cookiesMode: "fallback",
// Évite d'utiliser le compte depuis les IP tournantes de WARP (sauf si c'est la seule route)
cookiesOnProxy: false,
// "" = clients par défaut de yt-dlp (recommandé)
playerClients: "",
// Une tentative de plus avec ces clients après un 403 / format indisponible / SABR / PO token
fallbackPlayerClients: "web_safari,tv_downgraded",
potProviderUrl: "",
forceIpv4: true,
autoUpdate: true,
seekStrategy: "auto",
testUrl: "https://www.youtube.com/watch?v=jNQXAC9IVRw"
});
const ENV = {
cookiesMode: "YTDLP_COOKIES_MODE",
cookiesOnProxy: "YTDLP_COOKIES_ON_PROXY",
playerClients: "YTDLP_PLAYER_CLIENTS",
fallbackPlayerClients: "YTDLP_FALLBACK_PLAYER_CLIENTS",
potProviderUrl: "YTDLP_POT_PROVIDER",
forceIpv4: "YTDLP_FORCE_IPV4",
autoUpdate: "YTDLP_AUTO_UPDATE",
seekStrategy: "YTDLP_SEEK_STRATEGY",
testUrl: "YTDLP_TEST_URL"
};
const YOUTUBE_HOSTS = /^(?:www\.|m\.|music\.)?youtube\.com$|^youtu\.be$/i;
function invalid(message) {
const err = new Error(message);
err.code = "INVALID_PARAMS";
return err;
}
function toBoolean(key, value) {
if (typeof value === 'boolean') return value;
if (/^(true|1|yes|on)$/i.test(String(value).trim())) return true;
if (/^(false|0|no|off)$/i.test(String(value).trim())) return false;
throw invalid(`${key} : valeur booléenne attendue`);
}
function toClients(key, value) {
const text = String(value ?? '').trim().toLowerCase().replace(/\s+/g, '');
if (text.length > 200 || !/^[a-z_,\-]*$/.test(text)) {
throw invalid(`${key} : liste de clients invalide (lettres, "_", "-" et virgules uniquement)`);
}
return text.split(',').filter(Boolean).join(',');
}
const VALIDATORS = {
cookiesMode: (value) => {
if (!["fallback", "always", "never"].includes(value)) throw invalid("cookiesMode : fallback, always ou never attendu");
return value;
},
cookiesOnProxy: (value) => toBoolean("cookiesOnProxy", value),
playerClients: (value) => toClients("playerClients", value),
fallbackPlayerClients: (value) => toClients("fallbackPlayerClients", value),
potProviderUrl: (value) => {
const text = String(value ?? '').trim();
if (!text) return "";
let parsed;
try { parsed = new URL(text); } catch (e) { throw invalid("potProviderUrl : URL invalide"); }
if (!/^https?:$/.test(parsed.protocol) || /[\s;"']/.test(text)) throw invalid("potProviderUrl : URL http(s) attendue");
return text.replace(/\/+$/, '');
},
forceIpv4: (value) => toBoolean("forceIpv4", value),
autoUpdate: (value) => toBoolean("autoUpdate", value),
seekStrategy: (value) => {
if (!["auto", "ffmpeg", "native"].includes(value)) throw invalid("seekStrategy : auto, ffmpeg ou native attendu");
return value;
},
testUrl: (value) => {
const text = String(value ?? '').trim();
let parsed;
try { parsed = new URL(text); } catch (e) { throw invalid("testUrl : URL invalide"); }
if (!/^https?:$/.test(parsed.protocol) || !YOUTUBE_HOSTS.test(parsed.hostname) || /\s/.test(text)) {
throw invalid("testUrl : lien YouTube http(s) attendu");
}
return text;
}
};
let fileValues = null;
let envValues = null;
function loadFile() {
if (fileValues) return fileValues;
const raw = readJson(__glob.YOUTUBE_SETTINGS, {});
fileValues = {};
for (const key of Object.keys(DEFAULTS)) {
if (raw[key] === undefined) continue;
try {
fileValues[key] = VALIDATORS[key](raw[key]);
} catch (e) {
clog.warn(`Réglage ignoré dans settings.json : ${e.message}`);
}
}
return fileValues;
}
function loadEnv() {
if (envValues) return envValues;
envValues = {};
for (const [key, name] of Object.entries(ENV)) {
if (process.env[name] === undefined || process.env[name] === '') continue;
try {
envValues[key] = VALIDATORS[key](process.env[name]);
} catch (e) {
clog.warn(`Variable d'environnement ${name} ignorée : ${e.message}`);
}
}
return envValues;
}
/**
* Valeurs effectives (défaut < settings.json < environnement)
*/
function get() {
return { ...DEFAULTS, ...loadFile(), ...loadEnv() };
}
function isLocked(key) {
return Object.prototype.hasOwnProperty.call(loadEnv(), key);
}
/**
* URL sans identifiants éventuels (pour l'affichage)
*/
function maskUrl(url) {
if (!url) return "";
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
parsed.username = "***";
parsed.password = "";
}
return parsed.toString().replace(/\/$/, '');
} catch (e) {
return "***";
}
}
/**
* Pour l'administration : { clé: { value, default, locked, env } } (URL du fournisseur PO token masquée)
*/
function getPublic() {
const values = get();
const result = {};
for (const key of Object.keys(DEFAULTS)) {
const value = key === "potProviderUrl" ? maskUrl(values[key]) : values[key];
result[key] = { value, default: DEFAULTS[key], locked: isLocked(key), env: ENV[key] };
}
return result;
}
/**
* Vérifie un correctif sans l'appliquer (clés inconnues ou verrouillées refusées)
* @param {Object} patch
* @returns {Object} valeurs normalisées
*/
function validate(patch) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid("Réglages invalides");
const clean = {};
for (const [key, value] of Object.entries(patch)) {
if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) throw invalid(`Réglage inconnu : ${key}`);
if (isLocked(key)) throw invalid(`${key} est défini par la variable d'environnement ${ENV[key]}`);
clean[key] = VALIDATORS[key](value);
}
return clean;
}
/**
* Applique et enregistre un correctif de réglages
* @param {Object} patch
* @param {{id?: string, username?: string}|null} by
* @returns {Object} getPublic()
*/
function update(patch, by = null) {
const clean = validate(patch);
const changed = Object.keys(clean).filter(key => get()[key] !== clean[key]);
if (changed.length === 0) return getPublic();
const next = { ...loadFile(), ...clean };
writeJson(__glob.YOUTUBE_SETTINGS, next);
fileValues = next;
clog.log(`Réglages YouTube modifiés${by?.username ? ` par ${by.username}` : ''} : ${changed.join(', ')}`);
emitUpdate();
return getPublic();
}
/**
* Relit settings.json et l'environnement (tests)
*/
function reload() {
fileValues = null;
envValues = null;
}
/**
* Liste de clients yt-dlp normalisée (test de lecture), lève INVALID_PARAMS si invalide
*/
function normalizeClients(value) {
return toClients("clients", value);
}
module.exports = { get, getPublic, update, validate, normalizeClients, isLocked, reload, maskUrl, DEFAULTS, ENV };
+97
View File
@@ -0,0 +1,97 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/util.js
* Description: Petits utilitaires partagés (fichiers, événements, recherche dans le PATH)
*/
const fs = require('fs');
const path = require('path');
const isWindows = process.platform === 'win32';
/**
* Écriture atomique : fichier temporaire puis renommage (jamais de fichier tronqué).
* Sous Windows / OneDrive le renommage peut échouer (fichier verrouillé) : on retente puis on écrit directement.
* @param {string} filePath
* @param {string} content
* @param {number} [mode] - permissions (ignorées sous Windows)
*/
function writeFileAtomic(filePath, content, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.tmp`;
const options = mode ? { mode } : undefined;
fs.writeFileSync(tmp, content, options);
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.renameSync(tmp, filePath);
if (mode) {
try { fs.chmodSync(filePath, mode); } catch (e) { /* non supporté (Windows) */ }
}
return;
} catch (e) {
// Fichier verrouillé : nouvel essai
}
}
// Repli : écriture directe (non atomique) puis nettoyage du temporaire
fs.writeFileSync(filePath, content, options);
try { fs.unlinkSync(tmp); } catch (e) { /* temporaire déjà absent */ }
}
/**
* Lit un fichier JSON, ou renvoie la valeur par défaut s'il est absent / illisible
*/
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
return data && typeof data === 'object' ? data : fallback;
} catch (e) {
return fallback;
}
}
function writeJson(filePath, data) {
writeFileAtomic(filePath, JSON.stringify(data, null, 2));
}
/**
* Prévient l'administration qu'un état visible a changé (Admin.js relaie, avec anti-rebond)
*/
function emitUpdate() {
try {
process.emit("YOUTUBE_UPDATE");
} catch (e) {
// Un écouteur défaillant ne doit jamais casser la lecture
}
}
/**
* Cherche un exécutable dans le PATH sans le lancer.
* Sous Windows seuls les .exe/.com sont retenus (un .cmd/.bat demanderait un shell).
* @param {string} name - nom sans extension
* @returns {string|null}
*/
function findOnPath(name) {
const dirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
const exts = isWindows ? ['.exe', '.com'] : [''];
for (const dir of dirs) {
for (const ext of exts) {
const candidate = path.join(dir.replace(/^"|"$/g, ''), name + ext);
try {
const stat = fs.statSync(candidate);
if (!stat.isFile()) continue;
if (!isWindows) fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch (e) {
// Absent ou non exécutable : dossier suivant
}
}
}
return null;
}
function toIso(ms) {
return ms ? new Date(ms).toISOString() : null;
}
module.exports = { isWindows, writeFileAtomic, readJson, writeJson, emitUpdate, findOnPath, toIso };
+457
View File
@@ -0,0 +1,457 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/ytdlp.js
* Description: Binaires yt-dlp / ffmpeg, arguments communs, lancement et arrêt des processus, mises à jour
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-YtDlp");
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, execFile } = require('child_process');
const { __glob } = require('../utils/GlobalVars');
const settings = require('./settings');
const diagnostics = require('./diagnostics');
const { sanitize } = require('./errors');
const { isWindows, findOnPath, emitUpdate, toIso } = require('./util');
const KILL_TIMEOUT = 2000;
const VERSION_TTL_OK = 6 * 60 * 60 * 1000;
const VERSION_TTL_FAIL = 60 * 1000;
const UPDATE_TIMEOUT = 3 * 60 * 1000;
const AUTO_UPDATE_INTERVAL = 24 * 60 * 60 * 1000;
const FAILURE_UPDATE_MIN_INTERVAL = 6 * 60 * 60 * 1000;
const FAILURE_STREAK = 5;
const OUTPUT_CAP = 256 * 1024;
// Windows : yt-dlp.exe (PyInstaller) s'extrait dans %TEMP%_MEIxxxx et ne nettoie pas s'il est tué de force (~25 Mo à chaque fois).
// On lui donne un dossier temporaire dédié, vidé dès qu'aucun yt-dlp ne tourne.
const WIN_TEMP = path.join(os.tmpdir(), 'chopin-ytdlp-tmp');
// Tous les processus lancés par le module (lecture, vérifications, mises à jour)
const children = new Set();
let binaryCache = null;
let ffmpegCache = undefined;
const versionCache = { version: null, at: 0, ok: false, pending: null };
let updating = null;
let lastUpdate = null;
let autoTimer = null;
let lastFailureUpdateAt = 0;
// --- Binaires ---
/**
* Binaire yt-dlp : YTDLP_PATH, puis PATH, puis celui de youtube-dl-exec (poste de dev Windows)
* @param {{force?: boolean}} [options]
* @returns {{path: string, source: "env"|"path"|"bundled"|"missing"}}
*/
function resolveBinary({ force = false } = {}) {
if (binaryCache && !force) return binaryCache;
const fromEnv = process.env.YTDLP_PATH;
if (fromEnv) {
if (fs.existsSync(fromEnv)) {
binaryCache = { path: fromEnv, source: "env" };
return binaryCache;
}
clog.warn(`YTDLP_PATH ne pointe vers aucun fichier, recherche dans le PATH`);
}
const onPath = findOnPath("yt-dlp");
if (onPath) {
binaryCache = { path: onPath, source: "path" };
return binaryCache;
}
const bundled = path.join(__glob.ROOT, "node_modules", "youtube-dl-exec", "bin", isWindows ? "yt-dlp.exe" : "yt-dlp");
if (fs.existsSync(bundled)) {
binaryCache = { path: bundled, source: "bundled" };
return binaryCache;
}
// Introuvable : le lancement échouera (ENOENT) et sera classé BINARY_MISSING
binaryCache = { path: "yt-dlp", source: "missing" };
return binaryCache;
}
/**
* ffmpeg : PATH, puis ffmpeg-static
* @returns {{path: string, source: "path"|"ffmpeg-static"}|null}
*/
function resolveFfmpeg({ force = false } = {}) {
if (ffmpegCache !== undefined && !force) return ffmpegCache;
const onPath = findOnPath("ffmpeg");
if (onPath) {
ffmpegCache = { path: onPath, source: "path" };
return ffmpegCache;
}
try {
const staticPath = require('ffmpeg-static');
if (staticPath && fs.existsSync(staticPath)) {
ffmpegCache = { path: staticPath, source: "ffmpeg-static" };
return ffmpegCache;
}
} catch (e) {
// Module absent
}
ffmpegCache = null;
return ffmpegCache;
}
// --- Arguments ---
/**
* Arguments communs à toutes les commandes yt-dlp
* @param {{proxy?: string|null, cookiesPath?: string|null, playerClients?: string}} options
* @returns {string[]}
*/
function baseArgs({ proxy = null, cookiesPath = null, playerClients = "" } = {}) {
const s = settings.get();
const args = [
'--no-playlist',
'--no-progress',
// Résolution des défis JS (n / signature) avec le Node.js du bot
'--js-runtimes', 'node',
// Cache persistant (JS du lecteur, jetons) : moins de requêtes à chaque lecture
'--cache-dir', __glob.YOUTUBE_CACHE,
'--retries', '3',
'--fragment-retries', '3',
'--extractor-retries', '2',
'--retry-sleep', 'http:exp=1:8',
];
// Évite les écarts IPv4 / IPv6 entre extraction et téléchargement (URL liées à l'IP)
if (s.forceIpv4) args.push('-4');
const ffmpeg = resolveFfmpeg();
if (ffmpeg) args.push('--ffmpeg-location', ffmpeg.path);
if (s.potProviderUrl) {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=${s.potProviderUrl}`);
}
if (playerClients) {
args.push('--extractor-args', `youtube:player_client=${playerClients}`);
}
if (proxy) args.push('--proxy', proxy);
if (cookiesPath) args.push('--cookies', cookiesPath);
return args;
}
// --- Processus ---
/**
* Lance un processus suivi (groupe dédié sous Linux pour pouvoir tuer ses enfants)
* @param {string} command
* @param {string[]} args
* @returns {import('child_process').ChildProcess}
*/
function spawnTracked(command, args, { env } = {}) {
const child = spawn(command, args, {
detached: !isWindows,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
env
});
children.add(child);
const forget = () => {
if (!children.delete(child)) return;
// Les fichiers restent verrouillés quelques instants après la mort du processus
if (isWindows && ytdlpChildren.delete(child) && ytdlpChildren.size === 0) setTimeout(sweepWindowsTemp, 1500).unref?.();
};
child.once('close', forget);
child.once('error', forget);
return child;
}
const ytdlpChildren = new Set();
/**
* Lance yt-dlp avec le binaire résolu
*/
function spawnYtdlp(args) {
let env;
if (isWindows) {
try { fs.mkdirSync(WIN_TEMP, { recursive: true }); } catch (e) { /* dossier temporaire par défaut */ }
env = { ...process.env, TEMP: WIN_TEMP, TMP: WIN_TEMP };
}
const child = spawnTracked(resolveBinary().path, args, { env });
if (isWindows) ytdlpChildren.add(child);
return child;
}
/**
* Windows : supprime les extractions PyInstaller laissées par les yt-dlp tués (uniquement quand aucun ne tourne)
*/
function sweepWindowsTemp() {
if (!isWindows || ytdlpChildren.size > 0) return;
let entries;
try { entries = fs.readdirSync(WIN_TEMP); } catch (e) { return; }
for (const entry of entries) {
try { fs.rmSync(path.join(WIN_TEMP, entry), { recursive: true, force: true }); } catch (e) { /* encore verrouillé */ }
}
}
/**
* Tue un processus et ses enfants, puis attend sa fin réelle (2 s max)
* @param {import('child_process').ChildProcess} proc
* @returns {Promise<void>}
*/
function killTree(proc) {
return new Promise((resolve) => {
if (!proc || !proc.pid || proc.exitCode !== null || proc.signalCode !== null) return resolve();
const pid = proc.pid;
const timer = setTimeout(resolve, KILL_TIMEOUT);
proc.once('close', () => {
clearTimeout(timer);
resolve();
});
if (isWindows) {
// taskkill /T (arbre) /F (forcé) : tue aussi les enfants (ffmpeg de --download-sections)
try {
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, (error) => {
if (error && proc.exitCode === null) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
});
} catch (e) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
} else {
// Lancé en "detached" : chef de son groupe, on tue tout le groupe
try {
process.kill(-pid, 'SIGKILL');
} catch (e) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
}
});
}
function activeCount() {
return children.size;
}
// Arrêt du bot : les processus détachés ne doivent pas survivre
process.once('exit', () => {
for (const child of children) {
try {
if (!isWindows && child.pid) process.kill(-child.pid, 'SIGKILL');
else child.kill('SIGKILL');
} catch (e) { /* déjà mort */ }
}
});
function appendCapped(current, chunk) {
const next = current + chunk;
// On garde la fin (les erreurs de yt-dlp arrivent en dernier)
return next.length > OUTPUT_CAP ? next.slice(-OUTPUT_CAP) : next;
}
/**
* Commande yt-dlp courte (version, vérification, test) avec sorties plafonnées
* @param {string[]} args
* @param {{timeoutMs?: number, signal?: AbortSignal, command?: string}} [options]
* @returns {Promise<{code: number|null, stdout: string, stderr: string, timedOut: boolean, aborted: boolean, error: string|null, ms: number}>}
*/
function run(args, { timeoutMs = 60000, signal, command } = {}) {
return new Promise((resolve) => {
const started = Date.now();
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let done = false;
let child;
const finish = (code, error = null) => {
if (done) return;
done = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
resolve({ code, stdout, stderr, timedOut, aborted, error, ms: Date.now() - started });
};
const onAbort = () => {
aborted = true;
killTree(child);
};
const timer = setTimeout(() => {
timedOut = true;
stderr += `\nERROR: délai dépassé (${Math.round(timeoutMs / 1000)} s, timed out)`;
killTree(child);
}, timeoutMs);
try {
child = command ? spawnTracked(command, args) : spawnYtdlp(args);
} catch (e) {
return finish(null, e.code || e.message);
}
child.stdin?.end();
if (signal?.aborted) onAbort();
else signal?.addEventListener('abort', onAbort, { once: true });
child.stdout.on('data', (d) => { stdout = appendCapped(stdout, d.toString()); });
child.stderr.on('data', (d) => { stderr = appendCapped(stderr, d.toString()); });
child.on('error', (err) => {
if (err.code === 'ENOENT') {
stderr += `\nERROR: yt-dlp introuvable (ENOENT)`;
if (!command) binaryCache = null;
}
finish(null, err.code || err.message);
});
child.on('close', (code) => finish(code));
});
}
// --- Version et mises à jour ---
/**
* Version de yt-dlp (cache : 6 h si connue, 1 min en cas d'échec)
* @param {{force?: boolean}} [options]
* @returns {Promise<string|null>}
*/
function getVersion({ force = false } = {}) {
const ttl = versionCache.ok ? VERSION_TTL_OK : VERSION_TTL_FAIL;
if (!force && versionCache.at && Date.now() - versionCache.at < ttl) return Promise.resolve(versionCache.version);
if (versionCache.pending) return versionCache.pending;
versionCache.pending = run(['--version'], { timeoutMs: 20000 }).then((result) => {
const version = result.code === 0 ? (result.stdout.trim().split(/\r?\n/)[0] || null) : null;
versionCache.version = version;
versionCache.ok = Boolean(version);
versionCache.at = Date.now();
return version;
}).finally(() => {
versionCache.pending = null;
});
return versionCache.pending;
}
function getCachedVersion() {
return versionCache.version;
}
function tail(text, max = 1500) {
const clean = sanitize(text).trim();
return clean.length > max ? "..." + clean.slice(-max) : clean;
}
/**
* Met à jour yt-dlp (yt-dlp -U). Un seul appel à la fois.
* @param {{reason?: string}} [options]
* @returns {Promise<{ok: boolean, updatable: boolean, before: string|null, after: string|null, output: string, at: string, reason: string, message: string}>}
*/
function update({ reason = "manuel" } = {}) {
if (updating) return updating;
updating = (async () => {
emitUpdate();
const before = await getVersion({ force: true });
const bin = resolveBinary();
clog.log(`Mise à jour de yt-dlp (${reason}) - version actuelle : ${before ?? "inconnue"} (${bin.source})`);
const result = await run(['-U'], { timeoutMs: UPDATE_TIMEOUT });
const output = `${result.stdout}\n${result.stderr}`;
const after = await getVersion({ force: true });
// Installation par pip / gestionnaire de paquets : yt-dlp refuse de se mettre à jour lui-même
const notUpdatable = /installed yt-dlp with|package manager|setup\.py|Use that to update|not updatable|cannot be updated|self-update is not supported/i.test(output);
const upToDate = /is up to date/i.test(output);
const ok = !notUpdatable && result.code === 0;
let message;
if (result.error === 'ENOENT') message = "yt-dlp introuvable";
else if (notUpdatable) message = "Cette installation de yt-dlp ne peut pas se mettre à jour elle-même (pip / paquet système)";
else if (!ok) {
// Ex. : limite de l'API GitHub atteinte ("HTTP Error 403: rate limit exceeded")
const errorLine = output.split(/\r?\n/).reverse().find(l => /^ERROR:/i.test(l.trim()));
message = result.timedOut
? "Délai de mise à jour dépassé"
: `Échec de la mise à jour (code ${result.code})${errorLine ? ` : ${sanitize(errorLine.trim().replace(/^ERROR:\s*/i, '')).slice(0, 200)}` : ''}`;
}
else if (before && after && before !== after) message = `Mise à jour : ${before} → ${after}`;
else message = upToDate || before === after ? "yt-dlp est déjà à jour" : "Mise à jour terminée";
lastUpdate = {
at: new Date().toISOString(),
reason,
before,
after,
ok,
updatable: !notUpdatable,
message,
output: tail(output)
};
if (ok) clog.log(`yt-dlp : ${message}`);
else clog.warn(`yt-dlp : ${message}`);
return lastUpdate;
})().finally(() => {
updating = null;
emitUpdate();
});
return updating;
}
function isUpdating() {
return Boolean(updating);
}
function getLastUpdate() {
return lastUpdate;
}
/**
* Mise à jour au démarrage puis toutes les 24 h (si autoUpdate est actif au moment du passage)
*/
function startAutoUpdate() {
if (autoTimer) return;
const tick = () => {
if (!settings.get().autoUpdate) return;
update({ reason: "automatique" }).catch((e) => clog.error(`Mise à jour automatique impossible : ${e?.message || e}`));
};
const first = setTimeout(tick, 5000);
first.unref?.();
autoTimer = setInterval(tick, AUTO_UPDATE_INTERVAL);
autoTimer.unref?.();
}
/**
* Après une série d'échecs de type blocage (403, anti-robot...), une version récente corrige souvent le problème.
* Au plus une fois toutes les 6 h.
* @returns {boolean} true si une mise à jour a été lancée
*/
function maybeUpdateAfterFailures() {
const streak = diagnostics.getConsecutiveBlocks();
if (streak < FAILURE_STREAK || !settings.get().autoUpdate || updating) return false;
if (lastFailureUpdateAt && Date.now() - lastFailureUpdateAt < FAILURE_UPDATE_MIN_INTERVAL) return false;
lastFailureUpdateAt = Date.now();
clog.warn(`${streak} échecs de lecture YouTube d'affilée (blocage) : mise à jour de yt-dlp`);
update({ reason: "échecs répétés" }).catch(() => {});
return true;
}
/**
* Informations pour l'administration
*/
function getInfo() {
const bin = resolveBinary();
const ffmpeg = resolveFfmpeg();
return {
ytdlp: {
path: bin.path,
source: bin.source,
version: versionCache.version,
versionCheckedAt: toIso(versionCache.at),
updating: isUpdating(),
lastUpdate,
autoUpdate: settings.get().autoUpdate
},
ffmpeg: ffmpeg ? { path: ffmpeg.path, source: ffmpeg.source } : { path: null, source: "missing" }
};
}
module.exports = {
resolveBinary, resolveFfmpeg, baseArgs,
spawnTracked, spawnYtdlp, killTree, activeCount, run,
getVersion, getCachedVersion, update, isUpdating, getLastUpdate, startAutoUpdate, maybeUpdateAfterFailures,
getInfo, sweepWindowsTemp
};