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
+12748 -1790

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Dépendances : réinstallées dans l'image (les binaires Windows du poste de dev sont inutilisables sous Alpine)
node_modules
npm-debug.log*
# Données et secrets (token Discord, cookies YouTube, comptes, tokens OAuth) : fournis par le volume /app/data
data
.env
.env.*
*.pem
*.key
# Logs et fichiers temporaires
logs
tmp
*.log
*.tmp
# Dépôt git et fichiers de développement
.git
.gitignore
.vscode
.idea
Dockerfile
.dockerignore
docker-compose.yml
README.md
TODOS.md
# CHANGELOG.html est volontairement conservé : il est servi par la requête /CHANGELOG
+34 -8
View File
@@ -2,24 +2,50 @@ FROM node:lts-alpine
WORKDIR /app
# Version du plugin PO token (bgutil) : doit correspondre à l'image du service bgutil-provider (docker-compose.yml)
ARG BGUTIL_VERSION=2.0.1
# 1. Installer les dépendances système requises
# python3 : indispensable pour faire tourner yt-dlp
# ffmpeg : nécessaire pour certaines conversions audio ou formats spécifiques
# curl : pour télécharger yt-dlp
# python3 : indispensable pour faire tourner yt-dlp (version zipimport, auto-actualisable avec -U)
# ffmpeg : seek YouTube (décodage du flux) et certains formats
# curl : pour télécharger yt-dlp et le plugin bgutil
RUN apk add --no-cache bash python3 ffmpeg curl
# 2. Installer yt-dlp (Dernière version officielle)
# 2. Installer yt-dlp (dernière version officielle, build zipimport : inclut yt-dlp-ejs pour les défis JS)
# On le place dans /usr/local/bin pour qu'il soit accessible globalement dans le PATH
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
RUN curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# 3. Installation des dépendances Node.js
# 3. Plugin bgutil : yt-dlp obtient des PO tokens auprès du service bgutil-provider
# (/etc/yt-dlp/plugins/ est lu par yt-dlp, les archives .zip y sont acceptées telles quelles)
RUN mkdir -p /etc/yt-dlp/plugins && \
curl -fL "https://github.com/Brainicism/bgutil-ytdlp-pot-provider/releases/download/${BGUTIL_VERSION}/bgutil-ytdlp-pot-provider.zip" \
-o /etc/yt-dlp/plugins/bgutil-ytdlp-pot-provider.zip
# 4. Installation des dépendances Node.js
# youtube-dl-exec téléchargerait un second yt-dlp (build glibc, inutilisable sous Alpine) : inutile, celui du PATH est utilisé
ENV YOUTUBE_DL_SKIP_DOWNLOAD=true
COPY package*.json ./
RUN npm install
# 4. Copie du reste du code
# 5. Copie du reste du code
# Voir .dockerignore : node_modules (binaires Windows), data/ (token, cookies, comptes), logs/ et .git
# ne sont PAS copiés dans l'image. data/ est fourni par le volume monté sur /app/data (docker-compose.yml) :
# config.json doit y être déposé avant le premier démarrage, sinon le bot crée une config vide et s'arrête.
# data/youtube/ (cookies YouTube, réglages, cache de yt-dlp) est créé au démarrage dans ce même volume.
COPY . .
# logs/ n'est plus copié : loguix crée son dossier de façon asynchrone et ouvre son fichier aussitôt,
# le dossier doit donc exister au démarrage (même sans volume monté sur /app/logs)
RUN mkdir -p /app/logs /app/data
EXPOSE 4000
# État de santé : route HTTP /health du serveur (port 4000, celui exposé ci-dessus)
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget -qO- http://127.0.0.1:4000/health > /dev/null || exit 1
# Mise à jour de yt-dlp à chaque démarrage : une version obsolète provoque des erreurs 403
CMD ["sh", "-c", "yt-dlp -U || true; npm run start"]
# (le bot la refait ensuite toutes les 24 h et après une série d'échecs, voir src/youtube/ytdlp.js)
# exec : node remplace sh et devient PID 1 (il reçoit SIGTERM pour un arrêt propre).
# Plus de nodemon en production : un crash termine le process et Docker relance le conteneur (restart: unless-stopped).
CMD ["sh", "-c", "yt-dlp -U || true; exec node src/main.js"]
+48
View File
@@ -0,0 +1,48 @@
# Services Docker du développement local, lancés par ./chopin sous WSL (Docker Engine, pas Docker Desktop).
# Le backend et le frontend tournent hors Docker (Node.js Linux) : seuls WARP et bgutil sont ici.
# Usage manuel : docker compose -f docker-compose.dev.yml -p chopin-dev up -d
name: chopin-dev
services:
# Proxy Cloudflare WARP (HTTP + SOCKS5 sur 1080) utilisé par yt-dlp : WARP_PROXY=http://127.0.0.1:1080
warp:
image: caomingjun/warp
container_name: chopin-dev-warp
# Pas de relance au démarrage de Docker : seulement après un plantage
restart: on-failure
# Requis pour que WARP puisse créer son interface TUN
device_cgroup_rules:
- "c 10:200 rwm"
cap_add:
- MKNOD
- AUDIT_WRITE
- NET_ADMIN
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv4.conf.all.src_valid_mark=1
environment:
- WARP_SLEEP=2
# - WARP_LICENSE_KEY= # optionnel : clé WARP+
# Uniquement sur la boucle locale : sinon ce serait un proxy ouvert
ports:
- "127.0.0.1:1080:1080"
volumes:
# Inscription WARP conservée entre deux lancements
- chopin-dev-warp-data:/var/lib/cloudflare-warp
# Fournisseur de PO tokens pour yt-dlp (le plugin bgutil est installé côté WSL par ./chopin).
# Réseau de l'hôte : yt-dlp transmet son --proxy (http://127.0.0.1:1080) dans chaque requête,
# et bgutil s'en sert lui-même. Dans un réseau bridge, 127.0.0.1 serait le conteneur bgutil,
# pas le port WARP publié ; avec network_mode: host, c'est bien la boucle locale de WSL.
# Le serveur n'a pas d'authentification : on l'écoute seulement sur 127.0.0.1 (l'image écoute
# par défaut sur 0.0.0.0, d'où la commande). La version doit correspondre à BGUTIL_VERSION dans ./chopin.
bgutil-provider:
image: brainicism/bgutil-ytdlp-pot-provider:2.0.1
container_name: chopin-dev-bgutil
restart: on-failure
init: true
network_mode: host
command: ["--host", "127.0.0.1", "--port", "4416"]
volumes:
chopin-dev-warp-data:
+21
View File
@@ -25,23 +25,44 @@ services:
volumes:
- subsonics-warp-data:/var/lib/cloudflare-warp
# Fournisseur de PO tokens pour yt-dlp (plugin bgutil installé dans l'image du backend)
# Aucun port publié : le serveur n'a pas d'authentification, il reste sur le réseau interne.
# Il reçoit le --proxy de yt-dlp dans chaque requête : warp:1080 lui est joignable sur ce même réseau.
# La version doit correspondre à BGUTIL_VERSION dans le Dockerfile.
bgutil-provider:
image: brainicism/bgutil-ytdlp-pot-provider:2.0.1
container_name: subsonics-bgutil
restart: unless-stopped
init: true
subsonics-backend:
build:
context: . # dossier backend
dockerfile: Dockerfile
args:
- BGUTIL_VERSION=2.0.1
container_name: subsonics-backend
# Init minimal (tini) : node n'est pas PID 1, les enfants orphelins de yt-dlp (node, ffmpeg) sont bien récupérés
init: true
ports:
- "4000:4000"
environment:
- WARP_PROXY=http://warp:1080
# Si WARP tombe ou se fait bloquer, on retente en connexion directe
- WARP_FALLBACK_DIRECT=true
# PO tokens (verrouillé dans l'administration tant que la variable est définie)
- YTDLP_POT_PROVIDER=http://bgutil-provider:4416
depends_on:
- warp
- bgutil-provider
volumes:
# Contient aussi data/youtube/ : cookies YouTube, réglages et cache de yt-dlp
- subsonics-backend-data:/app/data
# Logs persistés (consultables depuis l'interface d'administration)
- subsonics-backend-logs:/app/logs
restart: unless-stopped
volumes:
subsonics-backend-data:
subsonics-backend-logs:
subsonics-warp-data:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "chopin-backend",
"version": "1.5.0",
"version": "2.0.0",
"description": "Discord Bot for music - Fetching everywhere !",
"main": "src/main.js",
"nodemonConfig": {
+59 -13
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
applyActivity("le silence absolu")
}
});
dlog.log(`Activité mise à jour : rien`)
/**
* 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}
+147 -47
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;
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,10 +172,12 @@ 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")
})
@@ -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)
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)
}
}, 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)
}
})
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()) {
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)}}
}
+65 -17
View File
@@ -1,34 +1,58 @@
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) {
// 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());
} 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) {
@@ -40,14 +64,8 @@ client.commands = new Collection()
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")
@@ -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}
+5 -11
View File
@@ -1,20 +1,20 @@
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) {
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté à ce salon vocal")
}
player.leave()
// Annule aussi un chargement en cours (yt-dlp tué)
await player.leave("command")
embed.setColor(200, 20, 20)
embed.setTitle('**Déconnexion**')
@@ -23,12 +23,6 @@ const command = new Command("leave", "Quitter le salon vocal", (client, interact
embed.send()
} else {
embed.returnError("Le bot n'est pas connecté à ce salon vocal")
}
})
module.exports = {command}
+8 -7
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 { Song } = require('../../player/Song');
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,18 +21,18 @@ 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)
// 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**')
}
+18 -18
View File
@@ -1,43 +1,43 @@
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") {
// Résultat réel : {ok, paused, reason}
const result = await player.pause()
if(!result.ok) {
embed.returnError("Il n'y a pas de musique en cours de lecture")
return
}
} else if(pause) {
const loading = player.status === "loading" || player.status === "stalled"
if(result.paused) {
embed.setTitle('Musique en pause')
embed.setDescription("La musique a été mise 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("La musique a été 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()
})
// Réponse en embed
})
+19 -26
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,12 +15,13 @@ 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) => {
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 = new Player(channel.guildId)
player.join(channel)
const player = getPlayer(channel.guildId)
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
embed.setColor(0x15e6ed)
@@ -31,7 +33,7 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
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") {
if(song.type != "spotify") {
embed.addField('**Durée : **', song.readduration)
}
embed.addField('**Lien : **', song.url)
@@ -58,34 +60,25 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
embed.setTitle("Ajoutée à la file d'attente")
}
embed.send()
if(song instanceof Playlist) {
embed.send()
if(song.type == "spotify") {
song = await spotify.getTracks(song)
}
player.readPlaylist(song, now)
} else {
if(now) {
player.play(song)
} else {
player.add(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}]
)
+13 -28
View File
@@ -1,33 +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("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()
// 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)
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, "")
@@ -35,16 +30,6 @@ const command = new Command("previous", "Passe à la musique précédente", (cli
embed.setThumbnail(song.thumbnail)
embed.send()
}
})
} else {
return embed.returnError("Le bot n'est pas connecté", interaction)
}
})
+16 -20
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)
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
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")
return 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
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()
}
} else {
embed.returnError("Le bot n'est pas connecté")
}
})
module.exports = {command}
+16 -4
View File
@@ -1,25 +1,37 @@
const {Embed} = require("../Embed")
const {Command} = require("../Command")
const {restart} = require("../../utils/Maintenance")
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
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}]
)
+13 -25
View File
@@ -1,47 +1,35 @@
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", (client, interaction) => {
const command = new Command("skip", "Passe à la musique suivante", async (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)) {
// 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 player = new Player(channel.guildId)
const result = player.skip()
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)
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)
}
})
+23 -12
View File
@@ -1,33 +1,44 @@
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()
// Pas de création de player : simple lecture de l'état
const player = AllPlayers.get(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)
return 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)
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()
}
})
+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) {
+11 -9
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) {
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
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e))
}
})
+19 -6
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 {
try {
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 + ">"})
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 = {
+11 -7
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,7 +53,7 @@ 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;
+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);
}
+183 -61
View File
@@ -1,26 +1,107 @@
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,6 +109,11 @@ 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;
@@ -44,9 +130,7 @@ 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];
@@ -63,13 +147,18 @@ async function getAlbum(url) {
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;
@@ -87,8 +176,7 @@ 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];
@@ -106,74 +194,108 @@ async function getPlaylist(url) {
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) {
const tracks = Array.isArray(playlist?.songs) ? playlist.songs : [];
var trackName = track.name;
var artistName = track.artists[0].name;
var queryForYoutube = `${trackName} - ${artistName}`;
var urlYoutubeFounded = await youtube.getQuery(queryForYoutube).then(function(songFind) {
if(!songFind) return null;
return songFind.url;
// 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;
});
clog.log("URL de la vidéo YouTube trouvée : " + urlYoutubeFounded);
// 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(!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);
if(tracks.length > 0) {
clog.log(`Conversion Spotify → YouTube : ${playlistSongs.length}/${tracks.length} titres pour « ${playlist?.title} »`);
}
// When finish do this
}
playlist.readduration = getReadableDuration(playlist.duration);
if(playlist) {
playlist.songs = playlistSongs;
playlist.duration = totalDuration;
playlist.readduration = getReadableDuration(totalDuration);
}
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}
+239 -43
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) {
}
}
async function getYouTubeProfilePicture(channelUrl) {
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
/**
* 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];
}
// Fallback: look for avatar in embedded JSON
// 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, "&"); // Decode \u0026 to &
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 {
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;
}
console.warn("Photo non trouvée pour :", channelUrl);
return null;
} catch (err) {
console.error("Erreur :", err);
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 };
+48 -7
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) {
+114 -56
View File
@@ -7,30 +7,57 @@ 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;
}
@@ -58,43 +85,49 @@ class List {
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,8 +135,9 @@ 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;
}
@@ -111,37 +145,39 @@ class List {
}
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]
const song = previous[0]
// Remove the song from the previous list
PreviousDB.data[this.guildId].splice(0, 1)
previous.splice(0, 1)
savePrevious()
return new Song(song)
} else {
return null;
}
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 playlist.songs) {
this.addNextSong(song)
for(const song of songs) {
if(song) this.next.push(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();
}
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 }
+34 -9
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) {
// 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 stream = await fetch(song.url).then(res => res.body);
return stream;
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}
+29 -11
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) {
// 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 {
var stream = await soundcloud.util.streamTrack(song.url)
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}
+421 -171
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;
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;
}
// É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 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;
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}`);
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;
}
clog.warn(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason} - Nouvelle tentative via la route ${routeLabel(next)}`);
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;
}
// Flux vide : le player passe en Idle et enchaîne sur la musique suivante
const empty = new PassThrough();
empty.end();
return empty;
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 };
+892 -252
View File
File diff suppressed because it is too large. Load diff
+11 -5
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,7 +12,8 @@ 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";
type;
@@ -25,9 +27,10 @@ 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
}
@@ -44,6 +47,8 @@ class Song {
// 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")
@@ -60,7 +65,8 @@ 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
}
+91 -25
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 {
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}
+361 -49
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();
function createAuthUrl(userId) {
if(!checkCredientials()) return null;
var oAuth2Client;
const user = Users.getUserById(userId);
if (!user) {
alog.error(`User with ID ${userId} not found.`);
return null;
/**
* 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;
}
if (!clientId || !clientSecret) {
alog.error("YouTube API client ID or secret is not set in the configuration.");
} else {
oAuth2Client = new google.auth.OAuth2(
}
// --- 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
);
redirectUri: website ? website + "/oauth2callback" : ""
};
}
alog.log("Google OAuth2 client initialized successfully.");
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));
}
}
if (!oAuth2Client) {
alog.error("OAuth2 client is not initialized. Please check your configuration.");
// --- 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;
const user = Users.getUserById(userId);
if (!user) {
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);
return null;
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
}
+228 -43
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;
}
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 });
return youtube.playlistItems.list({
part: 'snippet',
playlistId: playlistId,
maxResults: 50
/**
* 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;
}
/**
* 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;
}
/**
* 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
};
}
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};
+283 -136
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);
}
/**
* 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
* @param {string} name
* @returns {Array<Playlist>}
* @description Renvoie la liste des playlists de l'utilisateur
*/
@@ -40,7 +128,7 @@ function getPlaylistsOfUser(id) {
*/
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;
@@ -65,16 +153,20 @@ 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();
@@ -84,55 +176,52 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
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
+471 -385
View File
File diff suppressed because it is too large. Load diff
+79 -67
View File
@@ -2,10 +2,34 @@ 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", {
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.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;
});
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);
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?.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 + ")");
// 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 = {
return {
auth: accessToken,
identity: user,
guilds: guilds,
};
} 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";
}
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) => {
/**
* @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);
params.append("scope", "identify guilds");
fetch("https://discord.com/api/oauth2/token", {
try {
const 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;
});
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;
}
resolve(accessToken);
}).catch(err => {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err);
resolve(null);
})
})
}
function getUserIdentity(accessToken) {
return new Promise((resolve, reject) => {
fetch("https://discord.com/api/users/@me", {
/**
* @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}`,
},
}).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;
});
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;
}
resolve(user);
}).catch(err => {
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err);
resolve(null);
})
})
}
module.exports = {getDiscordUser, refreshToken, getUserIdentity}
+46 -9
View File
@@ -2,32 +2,69 @@ const { LogType } = require('loguix');
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};
+205 -59
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");
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,64 +455,98 @@ 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);
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}.`);
}
await saveUsers();
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) {
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
@@ -457,9 +554,52 @@ function deleteAccount(id) {
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 {
clog.warn(`Utilisateur ${id} non trouvé.`);
// 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,8 +648,8 @@ function clearNeedUpdateForUsers() {
}
module.exports = {User}
module.exports = {
User,
addUser,
setFullBan,
removeUser,
@@ -522,5 +667,6 @@ module.exports = {
refreshAllUserInformation,
updateIdentity,
clearNeedUpdateForUsers,
deleteAccount
deleteAccount,
revokeTokens
};
+178 -12
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
}
+117 -21
View File
@@ -11,31 +11,68 @@ 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)
}
}
@@ -44,43 +81,102 @@ class Database {
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() {
+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
};
+54 -38
View File
@@ -1,46 +1,62 @@
function getReadableDuration(duration) {
var max = ""
duration *= 1000;
/**
* 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.
*/
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}`;
/**
* 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(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + "h" + maxmin + "m" + maxsec
} else {
max = maxmin + "m" + maxsec + "s"
}
return max
if (!Number.isFinite(value) || value < 0) return 0
return Math.floor(value)
}
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]);
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) {
const total = toSeconds(duration)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
if (hours > 0) {
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
}
return seconds;
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) {
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
}
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
};
+1
View File
@@ -0,0 +1 @@
logs/
+10
View File
@@ -0,0 +1,10 @@
# Tests de développement (ad hoc)
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `stream.edge.js`
- Réseau (YouTube / GitHub réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git).
+46
View File
@@ -0,0 +1,46 @@
// Environnement de test : loguix dans le scratchpad, données YouTube isolées (jamais backend/data/youtube)
const path = require('path');
const fs = require('fs');
const os = require('os');
const BACKEND = require("path").resolve(__dirname, "../..");
// Données jetables hors du dépôt
const SCRATCH = path.join(os.tmpdir(), 'chopin-tests');
fs.mkdirSync(SCRATCH, { recursive: true });
const LOGS = path.join(__dirname, 'logs');
fs.mkdirSync(LOGS, { recursive: true });
const loguix = require(path.join(BACKEND, 'node_modules', 'loguix'));
loguix.setup(LOGS, path.join(BACKEND, 'package.json'));
const { __glob } = require(path.join(BACKEND, 'src', 'utils', 'GlobalVars'));
function isolateData(name = 'ytdata') {
const dir = fs.mkdtempSync(path.join(SCRATCH, `${name}-`));
__glob.YOUTUBE_DIR = dir;
__glob.YOUTUBE_COOKIES = path.join(dir, 'cookies.txt');
__glob.YOUTUBE_STATE = path.join(dir, 'state.json');
__glob.YOUTUBE_SETTINGS = path.join(dir, 'settings.json');
// Le cache yt-dlp peut rester partagé entre les essais (dans le scratchpad)
__glob.YOUTUBE_CACHE = path.join(SCRATCH, 'ytcache');
// Par défaut aucun ancien fichier : chaque test choisit
__glob.COOKIES = path.join(dir, 'legacy-none.txt');
__glob.COOKIES_JSON = path.join(dir, 'legacy-none.json');
return dir;
}
let failures = 0, passes = 0;
function check(name, cond, extra) {
if (cond) { passes++; console.log(` ok ${name}`); }
else { failures++; console.log(` FAIL ${name}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`); }
}
function done() {
console.log(`\n${passes} ok, ${failures} échec(s)`);
process.exitCode = failures ? 1 : 0;
}
function req(rel) { return require(path.join(BACKEND, rel)); }
function chopinTmpDirs() {
return fs.readdirSync(os.tmpdir()).filter(e => e.startsWith('chopin-ytc-'));
}
module.exports = { BACKEND, SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs };
+356
View File
@@ -0,0 +1,356 @@
// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes)
// de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les
// vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier
// temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé).
const Module = require('module');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup');
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
const DATA_DIR = path.join(BACKEND, 'data');
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-'));
for (const [key, value] of Object.entries(__glob)) {
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
}
}
__glob.DATA = tmp;
isolateData('admin-config-yt');
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); }
process.env.YTDLP_AUTO_UPDATE = 'false';
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
// Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log
const SECRETS = {
token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123',
client_secret: 'FAKECLIENTSECRET_9876543210',
youtube: 'FAKEYTSECRET_aaaabbbbcccc',
spotify: 'FAKESPOTIFYSECRET_ddddeeee',
refresh: 'FAKEREFRESHTOKEN_ffffgggg',
proxyPass: 'FAKEPROXYPASS_hhhh'
};
fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({
token: SECRETS.token,
client_secret: SECRETS.client_secret,
report: { channel: '123456789012345678', contact: '' },
api: {
youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube },
spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify }
},
website: 'https://chopin.example.org',
server_port: 5000,
media: { guildId: '', channelId: '' }
}, null, 2));
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const owner = {
identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [],
isAdmin: () => false, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
// Player simulé : loop / shuffle comptés pour vérifier l'idempotence
const playerCalls = { setLoop: 0, setShuffle: 0 };
const fakePlayer = {
guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} },
isConnected: () => true,
async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; },
async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; },
getState() {
return {
guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle,
current: { id: 'song-b', title: 'B' },
lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() },
next: [], previous: []
};
}
};
const AllPlayers = new Map([['222222', fakePlayer]]);
const removed = [];
const stubs = {
[norm(SRC + '/server/auth/User')]: {
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
getUsers: () => [admin, owner]
},
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers },
[norm(SRC + '/discord/Bot')]: {
getGuilds: () => guilds, getClient: () => null, isReady: () => false,
getChannel: () => null
},
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {
removePlaylist(userId, playlistId) {
const list = getDatabase('Playlists').data[userId] || [];
const index = list.findIndex(p => String(p.playlistId) === String(playlistId));
if (index === -1) return false;
list.splice(index, 1);
removed.push({ userId, playlistId });
return true;
}
},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
};
// Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué)
let spotifyMode = 'reject';
class FakeSpotify {
constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; }
async clientCredentialsGrant() {
if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } };
throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } });
}
}
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request === 'spotify-web-api-node') return FakeSpotify;
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const { Database, getDatabase } = req('src/utils/Database/Database.js');
// Bases réelles dans le dossier temporaire
const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {});
playlistsDb.data = {
'333333': [
{ playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 },
{ playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' },
{ playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] }
],
'444444': []
};
const googleDb = new Database('google', path.join(tmp, 'google.json'), {});
googleDb.data = {
'333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' },
'555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true }
};
const configuration = req('src/utils/Database/Configuration.js');
const warp = req('src/utils/WarpProxy.js');
const Admin = req('src/server/Admin.js');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
in: () => ({ socketsLeave() {} }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s));
const configEvents = [];
process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload));
const playlistEvents = [];
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
(async () => {
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE'];
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
console.log('# Configuration : validateur pur');
const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
check('clé inconnue refusée', bad('api.discord.secret', 'x'));
check('port non numérique refusé', bad('server_port', 'abc'));
check('port hors limites refusé', bad('server_port', 70000));
check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000);
check('site javascript: refusé', bad('website', 'javascript:alert(1)'));
check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org'));
check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1'));
check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin');
check('snowflake trop court refusé', bad('report.channel', '123'));
check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === '');
check('token vide refusé', bad('token', ' '));
check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def'));
check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef'));
check('type non texte refusé', bad('api.spotify.clientId', 42));
console.log('# CONFIG/GET');
let r = await call('/ADMIN/CONFIG/GET', {});
check('GET ok', r.ok, r);
check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r));
const cfg = r.data.config;
check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg));
check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token);
check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret']));
check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid');
check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false);
const integ = r.data.integrations;
check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google);
check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google);
check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null);
check('WARP non configuré', integ.warp.configured === false);
console.log('# CONFIG/SET');
r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' });
check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' });
check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website' });
check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' });
check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r);
check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents);
emitted.length = 0;
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' });
check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r);
check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz'));
check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents);
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8'));
check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token);
check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz');
await sleep(450);
const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE');
check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz'));
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' });
check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 });
check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' });
check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' });
check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google);
r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' });
check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === '');
r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' });
check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
console.log('# CONFIG/SPOTIFY/TEST');
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r);
check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***'));
check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false);
spotifyMode = 'ok';
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' });
check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' });
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
console.log('# WARP/CONFIG');
const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
const noLocks = { url: false, enabled: false, fallbackDirect: false };
check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks));
check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks));
check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks));
check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks));
check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks));
check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080');
check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null);
check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true }));
r = await call('/ADMIN/WARP/CONFIG', {});
check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' });
check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r);
r = await call('/ADMIN/WARP/CONFIG', { enabled: true });
check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
// Désactivé : aucun contrôle réseau
r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false });
check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r);
check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file);
check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false);
const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8'));
check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true });
check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true);
process.env.WARP_PROXY = 'http://127.0.0.1:9';
process.env.WARP_FALLBACK_DIRECT = 'true';
r = await call('/ADMIN/WARP/STATUS', {});
check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked);
r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' });
check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false });
check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r);
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
r = await call('/ADMIN/WARP/CONFIG', { url: '' });
check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status);
console.log('# PLAYLISTS');
r = await call('/ADMIN/PLAYLISTS/LIST', {});
check('LIST ok', r.ok && Array.isArray(r.data), r);
check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333');
const entry = r.data[0];
check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner);
const lists = entry.playlists;
check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists);
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' });
check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' });
check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r);
check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents);
console.log('# Players / comptes (correctifs)');
r = await call('/ADMIN/PLAYERS/LIST', {});
check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true });
check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false });
check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false });
check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' });
check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' });
check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
console.log('# Journaux');
await sleep(300);
const logDir = path.join(__dirname, 'logs');
const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n');
const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s));
check('aucun secret dans les logs', logged.length === 0, logged);
check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp')));
fs.rmSync(tmp, { recursive: true, force: true });
done();
setTimeout(() => process.exit(process.exitCode || 0), 50);
})().catch((e) => { console.error(e); process.exit(1); });
+247
View File
@@ -0,0 +1,247 @@
// Routes "/ADMIN/YOUTUBE/*" de backend/src/server/Admin.js, appelées par un faux socket.
// Dépendances lourdes (bot Discord, utilisateurs, players, bases...) simulées ; données YouTube isolées
// dans le scratchpad (jamais backend/data). Aucun appel réseau : yt-dlp (run/update/check/test) est simulé.
const Module = require('module');
const path = require('path');
const fs = require('fs');
const { BACKEND, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('admin-yt');
process.env.YTDLP_AUTO_UPDATE = 'false';
process.env.YTDLP_COOKIES_ON_PROXY = 'false'; // verrouillé par l'environnement
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
const stubs = {
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : null, getUsers: () => [admin] },
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => null, getMediaChannelId: () => null },
[norm(SRC + '/utils/Database/Database')]: { getDatabase: () => null },
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map([['222222', { pid: 1 }]]) },
};
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const Admin = req('src/server/Admin.js');
const youtube = req('src/youtube');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Cookies SYNTHÉTIQUES (valeurs factices), domaine YouTube + un domaine étranger
const SECRET = 'FAKESECRET';
const exp = Math.floor(Date.now() / 1000) + 30 * 86400;
const line = (d, n, v) => [d, 'TRUE', '/', 'TRUE', String(exp), n, v].join('\t');
const COOKIES_OK = ['# Netscape HTTP Cookie File',
line('.youtube.com', 'LOGIN_INFO', SECRET + '_login'),
line('.youtube.com', 'SAPISID', SECRET + '_sapisid'),
line('.youtube.com', '__Secure-3PAPISID', SECRET + '_3p'),
line('.youtube.com', 'SID', SECRET + '_sid'),
line('.example.org', 'tracker', SECRET + '_other')].join('\r\n');
const COOKIES_ANON = ['# Netscape HTTP Cookie File', line('.youtube.com', 'YSC', SECRET + '_ysc')].join('\n');
(async () => {
const expectedRoutes = ['STATUS', 'COOKIES/IMPORT', 'COOKIES/BROWSER', 'COOKIES/CHECK', 'COOKIES/DELETE', 'SETTINGS', 'TEST', 'UPDATE', 'DIAGNOSTICS/CLEAR'];
check('routes enregistrées', expectedRoutes.every(r => handlers.has('/ADMIN/YOUTUBE/' + r)));
// Vérification en direct simulée (pas de réseau)
let checkCalls = 0;
youtube.check.checkCookies = async () => {
checkCalls++;
const result = { at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'Compte reconnu', cls: null, route: 'direct' };
youtube.cookies.setLastCheck(result);
return { ...result, rotated: false, ms: 5, status: youtube.cookies.getStatus() };
};
console.log('# STATUS');
let r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('STATUS ok', r.ok, r);
check('STATUS forme', r.data && r.data.ytdlp && r.data.cookies && r.data.settings && r.data.diagnostics && Array.isArray(r.data.recent));
check('aucun cookie au départ', r.data.cookies.present === false);
check('cookiesOnProxy verrouillé', r.data.settings.cookiesOnProxy.locked === true);
console.log('# IMPORT');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: '' });
check('contenu vide refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'x'.repeat(1000001) });
check('contenu > 1 000 000 refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'browser:firefox' });
check('source interdite refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'du texte qui ne ressemble à rien' });
check('contenu illisible -> INVALID_PARAMS avec message FR', !r.ok && r.error === 'INVALID_PARAMS' && /cookie/i.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_ANON, source: 'paste' });
check('import sans compte : ok + avertissement', r.ok && r.data.warnings.some(w => /connexion/i.test(w)) && r.data.checking === false, r.data?.warnings);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'file' });
check('import connecté ok', r.ok && r.data.status.cookies.loggedIn && r.data.status.cookies.valid, r);
check('avertissement domaine étranger', r.ok && r.data.warnings.some(w => /autres sites/.test(w)), r.data?.warnings);
check('vérification lancée', r.data.checking === true);
check('source / auteur enregistrés', r.data.status.cookies.source === 'file' && r.data.status.cookies.importedBy?.username === 'testadmin');
check('aucune valeur de cookie dans la réponse', !JSON.stringify(r).includes(SECRET));
check('fichier maître dans le dossier isolé', fs.existsSync(path.join(dataDir, 'cookies.txt')));
await sleep(50);
check('checkCookies appelé', checkCalls === 1);
console.log('# Poussée');
emitted.length = 0;
await sleep(700);
const push = emitted.filter(e => e.ev === '/ADMIN/YOUTUBE/UPDATE');
check('poussée /ADMIN/YOUTUBE/UPDATE (coalescée)', push.length === 1 && push[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée contient lastCheck', push[0]?.data?.cookies?.lastCheck?.ok === true);
rooms.set('ADMIN', new Set());
emitted.length = 0;
process.emit('YOUTUBE_UPDATE');
await sleep(700);
check('pas de poussée si room ADMIN vide', emitted.length === 0);
rooms.set('ADMIN', new Set(['sock1']));
console.log('# CHECK');
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK ok', r.ok && r.data.result.ok === true && r.data.result.status === undefined && r.data.status.cookies.lastCheck, r);
console.log('# SETTINGS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'always', playerClients: 'web_safari, tv' } });
check('SETTINGS appliqués', r.ok && r.data.settings.cookiesMode.value === 'always' && r.data.settings.playerClients.value === 'web_safari,tv', r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesOnProxy: true } });
check('clé verrouillée refusée', !r.ok && r.error === 'INVALID_PARAMS' && /YTDLP_COOKIES_ON_PROXY/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { seekStrategy: 'n importe quoi' } });
check('valeur invalide refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { inconnu: 1 } });
check('clé inconnue refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: {} });
check('patch vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: [1] });
check('patch tableau refusé', !r.ok && r.error === 'INVALID_PARAMS');
// Retour aux valeurs par défaut
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'fallback', playerClients: '' } });
check('SETTINGS remis par défaut', r.ok && r.data.settings.cookiesMode.value === 'fallback' && r.data.settings.playerClients.value === '');
check('settings.json dans le dossier isolé', fs.existsSync(path.join(dataDir, 'settings.json')));
console.log('# TEST');
let testArgs = null;
const realTest = youtube.check.testPlayback;
youtube.check.testPlayback = async (opts) => { testArgs = opts; return { ok: true, route: 'direct', identity: 'anon', clients: '', cls: null, message: 'Extraction réussie', format: { id: '251' }, ms: 1, version: 'x' }; };
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'direct', identity: 'anon', url: '', clients: '' });
check('TEST transmis (url / clients vides -> réglages)', r.ok && testArgs.route === 'direct' && testArgs.url === undefined && testArgs.clients === undefined, testArgs);
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'moon' });
check('route invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { identity: 'root' });
check('identité invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { url: 'javascript:alert(1)' });
check('url non http refusée', !r.ok && r.error === 'INVALID_PARAMS');
youtube.check.testPlayback = async () => { const e = new Error('Un test de lecture est déjà en cours'); e.code = 'BUSY'; throw e; };
r = await call('/ADMIN/YOUTUBE/TEST', {});
check('BUSY -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
youtube.check.testPlayback = realTest;
// Vraie fonction, sans réseau : clients invalides -> INVALID_PARAMS
r = await call('/ADMIN/YOUTUBE/TEST', { clients: 'web;rm' });
check('clients invalides (vrai testPlayback) -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
console.log('# UPDATE');
youtube.ytdlp.update = async ({ reason }) => ({ ok: true, updatable: true, before: '1', after: '2', message: 'Mise à jour : 1 → 2', output: '', at: new Date().toISOString(), reason });
r = await call('/ADMIN/YOUTUBE/UPDATE', {});
check('UPDATE ok', r.ok && r.data.result.after === '2' && /testadmin/.test(r.data.result.reason) && r.data.status.ytdlp, r);
console.log('# BROWSER (yt-dlp simulé)');
const realRun = youtube.ytdlp.run;
let runArgs = null;
youtube.ytdlp.run = async (args) => {
runArgs = args;
const file = args[args.indexOf('--cookies') + 1];
fs.writeFileSync(file, COOKIES_OK + '\n' + line('.bank.example', 'session', SECRET + '_bank'));
return { code: 1, stdout: '', stderr: 'ERROR: [youtube] x: Sign in to confirm you’re not a bot', timedOut: false };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'default-release' });
check('BROWSER importe malgré l\'échec d\'extraction', r.ok && r.data.status.cookies.source === 'browser:firefox' && r.data.status.cookies.loggedIn, r);
check('arguments --cookies-from-browser', runArgs && runArgs[runArgs.indexOf('--cookies-from-browser') + 1] === 'firefox:default-release' && runArgs.includes('--skip-download'));
const tmpDir = path.dirname(runArgs[runArgs.indexOf('--cookies') + 1]);
check('fichier temporaire du navigateur supprimé', !fs.existsSync(tmpDir), tmpDir);
check('cookies étrangers non stockés', !fs.readFileSync(path.join(dataDir, 'cookies.txt'), 'utf-8').includes('bank'));
youtube.ytdlp.run = async () => ({ code: 1, stdout: '', stderr: 'ERROR: could not find firefox cookies database in /x --cookies /secret/path', timedOut: false });
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox' });
check('échec navigateur -> UNAVAILABLE explicite', !r.ok && r.error === 'UNAVAILABLE' && /même machine/.test(r.message) && !r.message.includes('/secret/path'), r);
youtube.ytdlp.run = async (args) => {
fs.writeFileSync(args[args.indexOf('--cookies') + 1], '# Netscape HTTP Cookie File\n' + line('.bank.example', 'session', 'x'));
return { code: 0, stdout: '', stderr: '' };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'chrome' });
check('navigateur sans cookie YouTube -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /chrome/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'netscape' });
check('navigateur inconnu refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'a::b' });
check('profil invalide refusé', !r.ok && r.error === 'INVALID_PARAMS');
youtube.ytdlp.run = realRun;
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# OVERVIEW');
r = await call('/ADMIN/OVERVIEW', {});
const yt = r.data?.ytdlp;
check('overview.ytdlp compatible', r.ok && yt && 'version' in yt && yt.cookies.present === true && yt.cookies.updatedAt === yt.cookies.importedAt && yt.activeProcesses === 1, r.ok ? yt : r);
check('overview.ytdlp enrichi', yt && yt.cookies.valid === true && 'recentFailureRate' in yt && 'lastFailure' in yt);
console.log('# DIAGNOSTICS');
youtube.diagnostics.record({ guildId: '222222', songId: 's', title: 'Titre', route: 'direct', identity: 'anon', outcome: 'failed', cls: 'HTTP_403', message: 'HTTP Error 403', ms: 100 });
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('recent avec nom du serveur', r.data.recent[0]?.guildName === 'Serveur Test' && r.data.diagnostics.byClass.HTTP_403 === 1, r.data.recent[0]);
r = await call('/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR', {});
check('CLEAR', r.ok && r.data.recent.length === 0 && r.data.diagnostics.total === 0);
console.log('# DELETE');
r = await call('/ADMIN/YOUTUBE/COOKIES/DELETE', {});
check('DELETE', r.ok && r.data.cookies.present === false && !fs.existsSync(path.join(dataDir, 'cookies.txt')));
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK sans cookies -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE');
console.log('# Droits');
admin.labels = []; admin.isAdmin = () => false;
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('non-admin refusé', !r.ok && r.error === 'FORBIDDEN');
admin.isAdmin = () => true; admin.labels = ['ADMIN'];
// Journaux : aucune valeur de cookie
await sleep(300);
const logs = path.join(__dirname, 'logs');
let leaked = false;
for (const f of fs.readdirSync(logs)) if (fs.readFileSync(path.join(logs, f), 'utf-8').includes(SECRET)) leaked = f;
check('aucune valeur de cookie dans les logs', !leaked, leaked);
const auditLines = fs.readdirSync(logs).flatMap(f => fs.readFileSync(path.join(logs, f), 'utf-8').split('\n')).filter(l => l.includes('[ADMIN] testadmin -> ') && /YouTube|yt-dlp/.test(l));
check('audits écrits', auditLines.length >= 6, auditLines.length);
console.log(auditLines.slice(-12).map(l => ' ' + l.trim()).join('\n'));
done();
setTimeout(() => process.exit(process.exitCode), 100);
})().catch((e) => { console.error(e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
// Test de getYouTubeProfilePicture (non exportée) : module recompilé avec une exportation de test
const path = require("path"), fs = require("fs"), Module = require("module"), assert = require("assert");
const SRC = require("path").resolve(__dirname, "../../src");
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
const file = path.join(SRC, "media/YoutubeInformation.js");
const m = new Module(file, module); m.filename = file; m.paths = Module._nodeModulePaths(path.dirname(file));
m._compile(fs.readFileSync(file, "utf8") + "\nmodule.exports.__avatar = getYouTubeProfilePicture;", file);
const get = m.exports.__avatar;
(async () => {
let t = Date.now();
const a1 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #1:", Date.now() - t, "ms", a1);
assert.ok(a1 && a1.startsWith("https://"));
t = Date.now();
const a2 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #2 (cache):", Date.now() - t, "ms");
assert.strictEqual(a2, a1); assert.ok(Date.now() - t < 20);
assert.strictEqual(await get("https://evil.example.com/@x"), null);
assert.strictEqual(await get("https://www.youtube.com/channel/undefined"), null);
assert.strictEqual(await get(undefined), null);
console.log("OK");
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+75
View File
@@ -0,0 +1,75 @@
// check.js (test de lecture, vérification des cookies), settings (env verrouillé), index (init, getStatus)
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('check');
process.env.YTDLP_POT_PROVIDER = 'http://user:pw@bgutil-provider:4416';
process.env.YTDLP_SEEK_STRATEGY = 'nimportequoi';
// Pas de mise à jour automatique pendant ce test (limite de l'API GitHub)
process.env.YTDLP_AUTO_UPDATE = 'false';
const youtube = req('src/youtube');
const { settings, cookies, check: ytcheck, ytdlp } = youtube;
(async () => {
console.log('# settings');
const pub = settings.getPublic();
check('potProviderUrl verrouillé + masqué', pub.potProviderUrl.locked && pub.potProviderUrl.value === 'http://***@bgutil-provider:4416', pub.potProviderUrl);
check('env invalide ignoré (seekStrategy)', pub.seekStrategy.locked === false && pub.seekStrategy.value === 'auto');
let err = null;
try { settings.update({ potProviderUrl: 'http://x' }); } catch (e) { err = e; }
check('clé verrouillée refusée', err?.code === 'INVALID_PARAMS', err?.message);
err = null;
try { settings.update({ playerClients: 'web;rm -rf' }); } catch (e) { err = e; }
check('clients invalides refusés', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ testUrl: 'https://evil.com/watch?v=x' }); } catch (e) { err = e; }
check('testUrl non YouTube refusée', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ inconnu: 1 }); } catch (e) { err = e; }
check('clé inconnue refusée', err?.code === 'INVALID_PARAMS');
settings.update({ cookiesOnProxy: 'true', playerClients: ' Web , mweb ' }, { username: 'test' });
check('normalisation + écriture', settings.get().cookiesOnProxy === true && settings.get().playerClients === 'web,mweb' && JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8')).playerClients === 'web,mweb');
check('env non écrit dans settings.json', !('potProviderUrl' in JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8'))));
settings.update({ playerClients: '', cookiesOnProxy: false });
const base = ytdlp.baseArgs({ proxy: 'http://warp:1080', cookiesPath: '/tmp/x', playerClients: 'mweb' });
console.log(' baseArgs :', youtube.errors.sanitize(base.join(' ')));
check('baseArgs : pot + client + proxy + cookies + -4', base.includes('-4') && base.includes('youtubepot-bgutilhttp:base_url=http://user:pw@bgutil-provider:4416') && base.includes('youtube:player_client=mweb') && base.includes('--proxy') && base.includes('--cookies') && !base.includes('--no-cache-dir'));
// Pas de bgutil local pour les essais réels
delete process.env.YTDLP_POT_PROVIDER;
settings.reload();
console.log('# init + getStatus');
await youtube.init();
const st = youtube.getStatus();
console.log(' getStatus :', JSON.stringify({ ytdlp: { ...st.ytdlp, path: path.basename(st.ytdlp.path) }, ffmpeg: st.ffmpeg.source, cookies: { present: st.cookies.present, valid: st.cookies.valid }, pot: st.pot, activeProcesses: st.activeProcesses, diagnostics: st.diagnostics.total }));
check('getStatus : clés attendues', ['ytdlp', 'ffmpeg', 'cookies', 'settings', 'pot', 'diagnostics', 'activeProcesses'].every(k => k in st));
check('dossier cache créé', fs.existsSync(__glob.YOUTUBE_CACHE));
console.log('# testPlayback (anonyme, directe)');
const t = await ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
console.log(' ', JSON.stringify(t));
check('testPlayback ok + format', t.ok && t.format && t.format.id, t);
let busy = null;
const p1 = ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
try { await ytcheck.testPlayback({}); } catch (e) { busy = e.code; }
await p1;
check('testPlayback : un seul à la fois (BUSY)', busy === 'BUSY');
const bad = await ytcheck.testPlayback({ url: '--exec=calc' });
check('testPlayback : URL refusée', bad.ok === false && /URL/.test(bad.message));
console.log('# checkCookies (sans cookies puis anciens cookies)');
const none = await ytcheck.checkCookies();
check('checkCookies sans cookies', none.ok === false && /Aucun cookie/.test(none.message));
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
cookies.migrateLegacy();
const [c1, c2] = await Promise.all([ytcheck.checkCookies(), ytcheck.checkCookies()]);
console.log(' ', JSON.stringify({ ok: c1.ok, loggedIn: c1.loggedIn, rotated: c1.rotated, cls: c1.cls, message: c1.message, route: c1.route, ms: c1.ms }));
check('checkCookies : appel unique partagé', c1 === c2);
check('checkCookies : lastCheck enregistré', cookies.getStatus().lastCheck?.at && cookies.getStatus().lastCheck.message === c1.message);
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
check('aucun processus actif', ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+183
View File
@@ -0,0 +1,183 @@
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('cookies');
const cookies = req('src/youtube/cookies');
const NOW = 1791360000; // 2026-10-07
const FUT = 1830000000; // 2027-12
const PAST = 1700000000;
const T = (...f) => f.join('\t');
let events = 0;
process.on('YOUTUBE_UPDATE', () => events++);
function throwsInvalid(fn) {
try { fn(); return false; } catch (e) { return e.code === 'INVALID_COOKIES' && /[a-zé]/.test(e.message); }
}
console.log('# Netscape');
{
const loggedIn = [
'# Netscape HTTP Cookie File',
'# commentaire',
'',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB'),
'#HttpOnly_' + T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PSID', 'CCC'),
T('.youtube.com', 'TRUE', '/', 'FALSE', 0, 'PREF', 'f6=40000000'),
T('.youtube.com', 'TRUE', '/', 'TRUE', PAST, 'GPS', '1'),
T('.example.com', 'TRUE', '/', 'FALSE', FUT, 'tracker', 'x'),
T('accounts.google.com', 'FALSE', '/', 'TRUE', FUT, '__Host-GAPS', 'g'),
'ligne cassée sans tabulation',
T('.youtube.com', 'MAYBE', '/', 'TRUE', FUT, 'X', 'y'),
].join('\r\n');
const r = cookies.parseCookies(loggedIn, { now: NOW });
const names = r.cookies.map(c => c.name);
check('CRLF + en-tête : cookies valides lus', names.includes('LOGIN_INFO') && names.includes('SAPISID') && names.includes('PREF'), names);
check('#HttpOnly_ reconnu', r.cookies.find(c => c.name === '__Secure-3PSID')?.httpOnly === true);
check('cookie de session (0) gardé', r.cookies.find(c => c.name === 'PREF')?.expires === 0);
check('expiré retiré', !names.includes('GPS'));
check('domaine étranger retiré', !names.includes('tracker'));
check('google.com gardé (hostOnly)', r.cookies.find(c => c.name === '__Host-GAPS')?.includeSubdomains === false);
check('avertissements (lignes, domaines, expirés)', r.warnings.length === 3, r.warnings);
const a = cookies.analyze(r.cookies);
check('analyze : loggedIn', a.loggedIn === true);
check('analyze : jamais de valeur', !JSON.stringify(a).includes('AAA') && !JSON.stringify(a).includes('BBB'));
check('analyze : authNames', a.authNames.includes('LOGIN_INFO') && a.authNames.includes('SAPISID') && a.authNames.includes('__Secure-3PSID'), a.authNames);
check('analyze : earliestAuthExpiry ISO', a.earliestAuthExpiry === new Date(FUT * 1000).toISOString());
// Sans en-tête, LF, valeur vide, tabulations converties en espaces
const noHeader = [
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
'.youtube.com TRUE / TRUE ' + FUT + ' __Secure-3PAPISID ZZZ',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'EMPTY', ''),
].join('\n');
const r2 = cookies.parseCookies(noHeader, { now: NOW });
check('sans en-tête + espaces au lieu de tabulations', r2.cookies.length === 3, r2.cookies.map(c => c.name));
check('valeur vide acceptée', r2.cookies.find(c => c.name === 'EMPTY')?.value === '');
check('loggedIn via __Secure-3PAPISID', cookies.analyze(r2.cookies).loggedIn === true);
// Règle yt-dlp : LOGIN_INFO seul ou SAPISID seul ne suffit pas
const onlySapisid = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PAPISID', 'x');
check('pas connecté sans LOGIN_INFO', cookies.analyze(cookies.parseCookies(onlySapisid, { now: NOW }).cookies).loggedIn === false);
const onlyLogin = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x');
const aLogin = cookies.analyze(cookies.parseCookies(onlyLogin, { now: NOW }).cookies);
check('pas connecté sans SAPISID', aLogin.loggedIn === false && aLogin.missing.includes('SAPISID'));
// Cookies google.com seuls : ne comptent pas pour youtube.com
const googleOnly = [T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'), T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'x')].join('\n');
check('LOGIN_INFO sur google.com ne compte pas', cookies.analyze(cookies.parseCookies(googleOnly, { now: NOW }).cookies).loggedIn === false);
// Sérialisation : relue à l'identique, LF, en-tête
const text = cookies.serializeCookies(r.cookies);
check('sérialisation : en-tête Netscape + LF', text.startsWith('# Netscape HTTP Cookie File\n') && !text.includes('\r'));
const back = cookies.parseCookies(text, { now: NOW }).cookies;
check('sérialisation : aller-retour', JSON.stringify(back) === JSON.stringify(r.cookies));
}
console.log('# JSON');
{
const arr = [
{ domain: '.youtube.com', expirationDate: FUT + 0.512229, hostOnly: false, httpOnly: true, name: 'LOGIN_INFO', path: '/', sameSite: 'no_restriction', secure: true, session: false, storeId: '0', value: 'AAA', id: 1 },
{ domain: '.youtube.com', expirationDate: FUT + 0.9, hostOnly: false, httpOnly: false, name: 'SAPISID', path: '/', secure: true, session: false, value: 'BBB' },
{ domain: 'www.youtube.com', hostOnly: true, httpOnly: false, name: 'ST-xyz', path: '/', secure: false, session: true, value: 'S' },
{ domain: 'youtube.com', hostOnly: false, name: 'NODOT', path: '/', secure: true, expires: (FUT * 1000), value: 'ms' },
{ domain: '.youtube.com', name: 'OLD', expirationDate: PAST, value: 'x' },
{ domain: '.facebook.com', name: 'fb', expirationDate: FUT, value: 'x' },
{ nope: true },
];
const r = cookies.parseCookies(JSON.stringify(arr), { now: NOW });
const by = Object.fromEntries(r.cookies.map(c => [c.name, c]));
check('JSON : expirationDate décimal arrondi', by.LOGIN_INFO?.expires === FUT);
check('JSON : session -> 0', by['ST-xyz']?.expires === 0);
check('JSON : hostOnly -> sans sous-domaines', by['ST-xyz']?.includeSubdomains === false && by['ST-xyz']?.domain === 'www.youtube.com');
check('JSON : domaine non hostOnly sans point -> .youtube.com', by.NODOT?.domain === '.youtube.com' && by.NODOT?.includeSubdomains === true);
check('JSON : expires en millisecondes', by.NODOT?.expires === FUT);
check('JSON : httpOnly', by.LOGIN_INFO?.httpOnly === true && by.SAPISID?.httpOnly === false);
check('JSON : expiré, étranger et invalide retirés', !by.OLD && !by.fb && r.warnings.length === 3, r.warnings);
check('JSON : loggedIn', cookies.analyze(r.cookies).loggedIn === true);
check('JSON : hasSessionOnly faux (le cookie de session n\'est pas un cookie de connexion)', cookies.analyze(r.cookies).hasSessionOnly === false);
const wrapped = cookies.parseCookies(JSON.stringify({ url: 'https://www.youtube.com', cookies: arr.slice(0, 2) }), { now: NOW });
check('{cookies:[...]} accepté', wrapped.cookies.length === 2 && cookies.analyze(wrapped.cookies).loggedIn);
const withBom = cookies.parseCookies('' + JSON.stringify(arr.slice(0, 2)), { now: NOW });
check('BOM ignoré', withBom.cookies.length === 2);
const expiry = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'b', expiry: FUT }]), { now: NOW });
check('champ expiry (Selenium)', expiry.cookies[0].expires === FUT);
const sessionAuth = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'LOGIN_INFO', value: 'b', session: true }]), { now: NOW });
check('hasSessionOnly vrai pour un cookie de connexion de session', cookies.analyze(sessionAuth.cookies).hasSessionOnly === true);
}
console.log('# Contenus invalides');
check('vide', throwsInvalid(() => cookies.parseCookies(' ')));
check('non-chaîne', throwsInvalid(() => cookies.parseCookies(null)));
check('texte quelconque', throwsInvalid(() => cookies.parseCookies('bonjour\nceci n\'est pas un fichier de cookies')));
check('JSON tronqué', throwsInvalid(() => cookies.parseCookies('[{"domain": ".youtube.com", "name": ')));
check('JSON objet sans cookies', throwsInvalid(() => cookies.parseCookies('{"a": 1}')));
check('JSON liste vide', throwsInvalid(() => cookies.parseCookies('[]')));
check('HTML', throwsInvalid(() => cookies.parseCookies('<html><body>Login</body></html>')));
check('commentaires seuls', throwsInvalid(() => cookies.parseCookies('# Netscape HTTP Cookie File\n# rien\n')));
check('valeur avec retour à la ligne refusée', throwsInvalid(() => cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'x\ny' }]))));
console.log('# Stockage');
const goodText = [T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB')].join('\n');
let threw = false;
try { cookies.importCookies(T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SID', 'x')); } catch (e) { threw = e.code === 'INVALID_COOKIES'; }
check('import refusé sans cookie youtube.com', threw && !fs.existsSync(__glob.YOUTUBE_COOKIES));
const before = events;
const res = cookies.importCookies(goodText, { source: 'paste', by: { id: '42', username: 'raphix' } });
check('import : statut valide', res.status.valid && res.status.loggedIn && res.status.count === 2 && res.status.source === 'paste', res.status);
check('import : importedBy', res.status.importedBy?.username === 'raphix');
check('import : YOUTUBE_UPDATE émis', events > before);
check('import : fichier maître LF + en-tête', fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8').startsWith('# Netscape HTTP Cookie File\n'));
check('statut : jamais de valeur', !JSON.stringify(cookies.getStatus()).includes('AAA'));
const notLogged = cookies.importCookies(T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'PREF', 'x'), { source: 'file' });
check('import non connecté : stocké + avertissement', notLogged.status.present && !notLogged.status.loggedIn && notLogged.warnings.some(w => /connexion/.test(w)), notLogged.warnings);
cookies.importCookies(goodText, { source: 'paste' });
check('markInvalid', cookies.markInvalid('test') === true && cookies.getStatus().valid === false && cookies.getStatus().invalidReason === 'test');
check('markInvalid idempotent', cookies.markInvalid('encore') === false && cookies.getStatus().invalidReason === 'test');
cookies.setLastCheck({ at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'ok' });
check('vérification réussie : invalidation levée', cookies.getStatus().valid === true);
cookies.reload();
check('rechargement depuis le disque', cookies.getStatus().valid === true && cookies.getStatus().count === 2);
(async () => {
const copy = await cookies.acquireCopy();
check('acquireCopy : fichier identique au maître', fs.readFileSync(copy.path, 'utf8') === fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8'));
check('acquireCopy : dossier chopin-ytc-', path.basename(path.dirname(copy.path)).startsWith('chopin-ytc-') && cookies.liveCopyCount() === 1);
copy.release(); copy.release();
check('release : dossier supprimé', !fs.existsSync(path.dirname(copy.path)) && cookies.liveCopyCount() === 0);
const del = cookies.deleteCookies();
check('deleteCookies', !del.present && !fs.existsSync(__glob.YOUTUBE_COOKIES));
check('acquireCopy sans cookies -> null', (await cookies.acquireCopy()) === null);
check('aucune copie temporaire restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# Fichiers réels (lecture seule, analyse sans valeurs)');
const realTxt = path.join(BACKEND, 'data', 'cookies.txt');
const realJson = path.join(BACKEND, 'data', 'cookies.json');
const mtimes = () => [realTxt, realJson].map(f => fs.existsSync(f) ? fs.statSync(f).mtimeMs : null).join(',');
const mtimesBefore = mtimes();
for (const file of [realTxt, realJson]) {
if (!fs.existsSync(file)) { console.log(` (absent) ${path.basename(file)}`); continue; }
try {
const r = cookies.parseCookies(fs.readFileSync(file, 'utf8'));
console.log(` ${path.basename(file)} :`, JSON.stringify(cookies.analyze(r.cookies)), '| avertissements :', JSON.stringify(r.warnings));
} catch (e) {
console.log(` ${path.basename(file)} : erreur ${e.code} ${e.message}`);
}
}
// Migration : les anciens fichiers réels servent de source, la destination est le dossier isolé
cookies.reload();
__glob.COOKIES = realTxt;
__glob.COOKIES_JSON = realJson;
const mig = cookies.migrateLegacy();
const st = cookies.getStatus();
console.log(' migration :', JSON.stringify(mig), '| statut :', JSON.stringify({ present: st.present, loggedIn: st.loggedIn, valid: st.valid, source: st.source, count: st.count, authNames: st.authNames, missing: st.missing, earliestAuthExpiry: st.earliestAuthExpiry }));
check('migration : fichiers d\'origine non modifiés', mtimes() === mtimesBefore);
check('migration : pas de seconde migration', cookies.migrateLegacy().migrated === false);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})();
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const errors = req('src/youtube/errors');
const ID = 'ERROR: [youtube] jNQXAC9IVRw: ';
// Chaque ligne du tableau de research-ytdlp.md §5 (+ variantes réelles)
const CASES = [
// Bot check (apostrophe typographique et droite)
[ID + 'Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies.', 'BOT_CHECK'],
[ID + "Sign in to confirm you're not a bot. This helps protect our community. Learn more", 'BOT_CHECK'],
['ERROR: [youtube] abc: Sign in to confirm youre not a bot', 'BOT_CHECK'],
// IP bloquée
[ID + 'All player responses are invalid. Your IP is likely being blocked by Youtube', 'IP_BLOCKED'],
// Captcha
[ID + 'YouTube is requiring a captcha challenge before playback', 'CAPTCHA'],
// Rate limit
[ID + "This content isn't available, try again later. The current session has been rate-limited by YouTube for up to an hour. It is recommended to use `-t sleep` to add a delay between video requests", 'RATE_LIMITED'],
[ID + 'This content isn’t available, try again later.', 'RATE_LIMITED'],
// 403 au téléchargement
['ERROR: unable to download video data: HTTP Error 403: Forbidden', 'HTTP_403'],
['[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...', 'HTTP_403'],
['ERROR: [download] Giving up after 3 retries: HTTP Error 403: Forbidden', 'HTTP_403'],
// 429
['ERROR: [youtube] abc: Unable to download API page: HTTP Error 429: Too Many Requests', 'HTTP_429'],
// Âge
[ID + 'Sign in to confirm your age. This video may be inappropriate for some users.', 'AGE_RESTRICTED'],
[ID + 'This video is age-restricted and only available on YouTube', 'AGE_RESTRICTED'],
// Connexion / membres
[ID + 'Login details are needed to download this content', 'LOGIN_REQUIRED'],
[ID + "This video is available to this channel's members on level: Member (or any higher level). Join this channel to get access to members-only content like this video, and other exclusive perks.", 'MEMBERS_ONLY'],
[ID + 'Join this channel to get access to members-only content like this video, and other exclusive perks.', 'MEMBERS_ONLY'],
// Privée
[ID + "Private video. Sign in if you've been granted access to this video", 'PRIVATE'],
// Indisponible
[ID + 'Video unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This content isn’t available.', 'UNAVAILABLE'],
[ID + 'This video is unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This video has been removed by the uploader', 'UNAVAILABLE'],
// Géo
[ID + 'The uploader has not made this video available in your country', 'GEO_BLOCKED'],
// PO token (avertissement seul)
['WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with --extractor-args "youtube:po_token=mweb.gvs+XXX".', 'POT_MISSING'],
// SABR
['WARNING: [youtube] jNQXAC9IVRw: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment for your account.', 'SABR'],
['WARNING: [youtube] jNQXAC9IVRw: Some tv client https formats have been skipped as they are missing a url. YouTube may have enabled the SABR-only streaming experiment for the current session.', 'SABR'],
['[debug] [youtube] YouTube is forcing SABR streaming for this client.', 'SABR'],
// Défi JS
['WARNING: [youtube] jNQXAC9IVRw: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed.', 'JS_CHALLENGE'],
['WARNING: [youtube] jNQXAC9IVRw: Signature solving failed: Some formats may be missing.', 'JS_CHALLENGE'],
['WARNING: [youtube] No supported JavaScript runtime could be found. Only deno is enabled by default', 'JS_CHALLENGE'],
// Format
['ERROR: [youtube] jNQXAC9IVRw: Requested format is not available. Use --list-formats for a list of available formats', 'FORMAT_UNAVAILABLE'],
// Page à recharger
[ID + 'The page needs to be reloaded.', 'PAGE_RELOAD'],
// Cookies tournés
['WARNING: [youtube] The provided YouTube account cookies are no longer valid. They have likely been rotated in the browser as a security measure. For tips on how to effectively export YouTube cookies, refer to https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies .', 'COOKIES_ROTATED'],
// Fichier vide
['ERROR: The downloaded file is empty', 'EMPTY_DOWNLOAD'],
// Binaire absent / délai
['spawn yt-dlp ENOENT', 'BINARY_MISSING'],
['ERROR: yt-dlp introuvable (ENOENT)', 'BINARY_MISSING'],
['ERROR: [youtube] abc: Unable to download webpage: <urlopen error timed out> (caused by URLError(TimeoutError(\'timed out\')))', 'TIMEOUT'],
['aucune donnée après 20s', 'TIMEOUT'],
// Informations seules
['WARNING: [youtube] Skipping client "visionos" since it does not support cookies', 'UNKNOWN'],
['ERROR: Login with OAuth is no longer supported', 'UNKNOWN'],
['', 'UNKNOWN'],
];
console.log('# Tableau des messages');
for (const [text, expected] of CASES) {
const r = errors.classify(text);
check(`${expected.padEnd(18)} <- ${text.slice(0, 90)}`, r.cls === expected, r);
}
console.log('# Combinaisons (ligne par ligne)');
{
// L'ancien bug : un bloc contenant [youtube] était ignoré. Ici l'erreur est noyée dans du bruit.
const noisy = [
'[youtube] Extracting URL: https://www.youtube.com/watch?v=jNQXAC9IVRw',
'[youtube] jNQXAC9IVRw: Downloading webpage',
'WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided.',
'ERROR: [youtube] jNQXAC9IVRw: Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication.',
].join('\r\n');
const r = errors.classify(noisy);
check('ERROR prime sur WARNING (BOT_CHECK, indice POT)', r.cls === 'BOT_CHECK' && r.hints.includes('POT_MISSING') && r.retry === 'route', r);
check('message sans préfixe ERROR:', r.message.startsWith('[youtube] jNQXAC9IVRw: Sign in'), r.message);
const rootCause = 'WARNING: [youtube] x: n challenge solving failed: Some formats may be missing.\nERROR: [youtube] x: Requested format is not available. Use --list-formats';
check('format indisponible + défi JS -> JS_CHALLENGE', errors.classify(rootCause).cls === 'JS_CHALLENGE');
const sabrEmpty = 'WARNING: [youtube] x: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment\nERROR: The downloaded file is empty';
check('fichier vide + SABR -> SABR', errors.classify(sabrEmpty).cls === 'SABR');
const rotatedBot = 'WARNING: [youtube] The provided YouTube account cookies are no longer valid.\nERROR: [youtube] x: Sign in to confirm you’re not a bot.';
const rb = errors.classify(rotatedBot);
check('rotation + bot : cls BOT_CHECK, drapeau cookiesRotated', rb.cls === 'BOT_CHECK' && rb.cookiesRotated === true, rb);
const rotatedOnly = errors.classify('WARNING: [youtube] The provided YouTube account cookies are no longer valid.');
check('rotation seule -> COOKIES_ROTATED', rotatedOnly.cls === 'COOKIES_ROTATED' && rotatedOnly.cookiesRotated, rotatedOnly);
const retry403 = '[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...\n[download] Got error: HTTP Error 403: Forbidden. Retrying (2/3)...\nERROR: unable to download video data: HTTP Error 403: Forbidden';
check('403 avec réessais -> HTTP_403 (retry client)', errors.classify(retry403).cls === 'HTTP_403' && errors.classify(retry403).retry === 'client');
check('un WARNING citant "HTTP Error 403" n\'est pas une ligne d\'erreur', errors.isErrorLine('WARNING: [youtube] x: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403.') === false);
check('tableau de lignes accepté', errors.classify(['x', 'ERROR: Private video']).cls === 'PRIVATE');
check('chaque classe a une action', errors.CLASSES.every(c => ['route', 'cookies', 'client', 'none'].includes(errors.RETRY[c])));
}
console.log('# sanitize');
{
const s = errors.sanitize('[debug] Command-line config: [\'--cookies\', \'C:\\\\Temp\\\\chopin-ytc-AbC123\\\\cookies.txt\', \'--proxy\', \'http://user:secret@warp:1080\'] --cookies /tmp/chopin-ytc-x1/cookies.txt Cookie: SID=abcdef; HSID=zzz Authorization: SAPISIDHASH 123_abc SAPISID=VALUE123 __Secure-3PSID=SECRETVALUE po_token=mweb.gvs+AAAAAAAAAAAA');
check('aucun secret', !/secret|abcdef|VALUE123|SECRETVALUE|AAAAAAAAAAAA|123_abc|AbC123|x1\//.test(s), s);
check('proxy masqué', s.includes('http://***@warp:1080'), s);
check('texte normal inchangé', errors.sanitize('ERROR: [youtube] abc: Video unavailable') === 'ERROR: [youtube] abc: Video unavailable');
check('null -> ""', errors.sanitize(null) === '');
}
done();
+257
View File
@@ -0,0 +1,257 @@
// Test du flux Google (OAuth2.js + YoutubeList.js + PlaylistManager.js) avec googleapis, la base de données,
// la configuration, les utilisateurs et loguix simulés : aucun fichier de data/ n'est lu ni écrit, aucun réseau.
const assert = require("assert")
const Module = require("module")
const EventEmitter = require("events")
const path = require("path")
const SRC = require("path").resolve(__dirname, "../../src")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
// --- Simulations ---
const cfg = { id: "client-id", secret: "client-secret", website: "https://chopin.example/" }
const databases = {}
class FakeDatabase {
constructor(name, file, empty) { this.name = name; this.path = file; this.data = JSON.parse(JSON.stringify(empty || {})); this.saves = 0; databases[name] = this }
save() { this.saves++ }
}
const fakeLog = { LogType: class { log() {} warn() {} error() {} }, getInstance: () => ({ log() {}, warn() {}, error() {} }) }
const api = { calls: { playlists: 0, items: 0, videos: 0, channels: 0 }, failWith: null, revoked: [] }
const PLAYLISTS = [
{ id: "PLa", snippet: { title: "Rock", channelTitle: "Moi", channelId: "UCme", thumbnails: { default: { url: "t-a" } } }, contentDetails: { itemCount: 120 } },
{ id: "PLb", snippet: { title: "Calme", channelTitle: "Moi", channelId: "UCme" }, contentDetails: { itemCount: 3 } },
{ id: "PLc", snippet: { title: "Vide", channelTitle: "Moi", channelId: "UCme", thumbnails: null }, contentDetails: { itemCount: 0 } },
]
function itemsFor(listId) {
const count = { PLa: 120, PLb: 3, PLc: 0, LLme: 60 }[listId] ?? 0
return Array.from({ length: count }, (_, i) => {
// LL réutilise les vidéos de PLa (cache des durées partagé)
const vid = (listId === "LLme" ? "PLa" : listId) + "-v" + i
const priv = listId === "PLb" && i === 1
return {
snippet: {
title: priv ? "Private video" : "Titre " + vid,
resourceId: { videoId: vid },
videoOwnerChannelTitle: priv ? undefined : "Artiste",
videoOwnerChannelId: priv ? undefined : "UCart",
thumbnails: { medium: { url: "m-" + vid } }
},
contentDetails: { videoId: vid }
}
})
}
function page(all, token, size) {
const start = token ? Number(token) : 0
return { items: all.slice(start, start + size), nextPageToken: start + size < all.length ? String(start + size) : undefined }
}
function maybeFail() { if (api.failWith) throw api.failWith }
class FakeOAuth2 extends EventEmitter {
constructor(id, secret, uri) { super(); this.args = [id, secret, uri]; this.credentials = null }
generateAuthUrl(opts) { this.lastOpts = opts; return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ ...opts, scope: opts.scope.join(" ") }) }
async getToken(code) {
if (code === "bad") { const e = new Error("invalid_grant"); e.response = { data: { error: "invalid_grant" } }; throw e }
return { tokens: { access_token: "AT-" + code, refresh_token: "RT-" + code, expiry_date: 123, scope: "yt" } }
}
setCredentials(t) { this.credentials = t }
async revokeToken(t) { api.revoked.push(t); return {} }
}
const fakeGoogle = {
google: {
auth: { OAuth2: FakeOAuth2 },
youtube: ({ auth }) => {
assert.ok(auth instanceof FakeOAuth2, "service authentifié")
return {
channels: { list: async () => { api.calls.channels++; return { data: { items: [{ id: "UCme", snippet: { title: "Ma chaîne" }, contentDetails: { relatedPlaylists: { likes: "LLme" } } }] } } } },
playlists: { list: async (p) => {
api.calls.playlists++; maybeFail()
if (p.id) return { data: { items: PLAYLISTS.filter(x => p.id.includes(x.id)) } }
assert.strictEqual(p.mine, true)
return { data: page(PLAYLISTS, p.pageToken, 2) }
} },
playlistItems: { list: async (p) => {
api.calls.items++; maybeFail()
assert.strictEqual(p.maxResults, 50)
return { data: page(itemsFor(p.playlistId), p.pageToken, 50) }
} },
videos: { list: async (p) => {
api.calls.videos++; maybeFail()
assert.ok(p.id.length <= 50, "50 ids max par appel")
return { data: { items: p.id.map((id, i) => ({ id, contentDetails: { duration: "PT" + (i % 5) + "M" + (i % 60) + "S" } })) } }
} },
}
}
}
}
const stubs = {
"googleapis": fakeGoogle,
"loguix": fakeLog,
[norm(SRC + "/utils/Database/Database")]: { Database: FakeDatabase, getDatabase: (n) => databases[n] },
[norm(SRC + "/utils/Database/Configuration")]: { getYoutubeApiClientId: () => cfg.id, getYoutubeApiClientSecret: () => cfg.secret, getWebsiteLink: () => cfg.website },
[norm(SRC + "/server/auth/User")]: { getUserById: (id) => (id ? { identity: { id } } : null) },
[norm(SRC + "/player/Finder")]: { search: async () => null },
[norm(SRC + "/media/SpotifyInformation")]: { getTracks: async (p) => p },
[norm(SRC + "/utils/SafeMetric")]: { toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? Math.floor(n) : 0 } },
}
const origLoad = Module._load
Module._load = function (request, parent, isMain) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const events = []
process.on("GOOGLE_UPDATE", (u) => events.push("GOOGLE_UPDATE:" + u))
process.on("PLAYLISTS_UPDATE", (u) => events.push("PLAYLISTS_UPDATE:" + u))
const OAuth2 = require(SRC + "/playlists/Google/OAuth2.js")
const PM = require(SRC + "/playlists/PlaylistManager.js")
const store = databases.google
assert.ok(store && store.path.endsWith("google.json"), "base google.json")
;(async () => {
// --- Configuration absente : bouton désactivé ---
cfg.id = ""
assert.strictEqual(OAuth2.getStatus("u1").configured, false)
assert.strictEqual(OAuth2.createAuthUrl("u1"), null)
cfg.id = "client-id"
// --- URL de consentement ---
const url = new URL(OAuth2.createAuthUrl("u1"))
const state = url.searchParams.get("state")
assert.strictEqual(url.searchParams.get("prompt"), "consent")
assert.strictEqual(url.searchParams.get("access_type"), "offline")
assert.match(state, /^[0-9a-f]{48}$/)
// --- Liaison : state d'un autre utilisateur / invalide refusé ---
await assert.rejects(OAuth2.linkAccount("u2", "code1", state), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "code1", "nope"), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "", state), { code: "INVALID_PARAMS" })
const status = await OAuth2.linkAccount("u1", "code1", state)
assert.deepStrictEqual([status.linked, status.valid, status.channel.title], [true, true, "Ma chaîne"])
assert.strictEqual(store.data.u1.tokens.refresh_token, "RT-code1")
assert.strictEqual(store.data.u1.likesPlaylistId, "LLme")
await assert.rejects(OAuth2.linkAccount("u1", "code1", state), { code: "INVALID_STATE" }, "state à usage unique")
assert.ok(!JSON.stringify(OAuth2.getStatus("u1")).includes("RT-"), "aucun jeton dans le statut")
// Redirect URI = site + /oauth2callback (slash final retiré)
const client = OAuth2.getClient("u1")
assert.strictEqual(client.args[2], "https://chopin.example/oauth2callback")
assert.strictEqual(OAuth2.getClient("u1"), client, "client en cache")
// --- Jetons rafraîchis par la librairie : persistés, refresh_token conservé ---
client.emit("tokens", { access_token: "AT-new", expiry_date: 999 })
assert.deepStrictEqual([store.data.u1.tokens.access_token, store.data.u1.tokens.refresh_token], ["AT-new", "RT-code1"])
// --- CONFIG_UPDATE : client recréé (identifiants relus) ---
cfg.secret = "secret-2"
process.emit("CONFIG_UPDATE")
const client2 = OAuth2.getClient("u1")
assert.notStrictEqual(client2, client)
assert.strictEqual(client2.args[1], "secret-2")
assert.strictEqual(client2.credentials.access_token, "AT-new")
// --- Synchronisation (+ concurrence) ---
const p1 = PM.syncYoutube("u1", { force: true })
assert.strictEqual(OAuth2.getStatus("u1").syncing, true)
const p2 = await PM.syncYoutube("u1", { force: true })
assert.strictEqual(p2.error, "ALREADY_SYNCING")
const r1 = await p1
assert.strictEqual(r1.ok, true, JSON.stringify(r1))
assert.strictEqual(r1.playlists, 4, "3 playlists + J'aime")
const list = PM.getPlaylistsOfUser("u1")
assert.strictEqual(list.length, 4)
const byYt = Object.fromEntries(list.map(p => [p.youtubeId, p]))
assert.strictEqual(byYt.PLa.songs.length, 120, "pagination 50+50+20")
assert.strictEqual(byYt.PLb.songs.length, 2, "vidéo privée ignorée")
assert.strictEqual(byYt.PLc.songs.length, 0)
assert.strictEqual(byYt.LLme.title, "Vidéos « J'aime »")
for (const p of list) {
assert.strictEqual(typeof p.playlistId, "string")
assert.strictEqual(p.type, "youtube")
assert.strictEqual(p.source, "google")
assert.ok(Number.isFinite(p.duration))
for (const s of p.songs) {
assert.strictEqual(s.type, "youtube")
assert.ok(Number.isFinite(s.duration))
assert.ok(s.readduration)
}
}
assert.strictEqual(new Set(list.map(p => p.playlistId)).size, 4, "identifiants uniques")
assert.strictEqual(byYt.PLa.songs[1].duration, 61, "durée ISO-8601 : PT1M1S")
assert.strictEqual(byYt.PLc.thumbnail, "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png", "miniature absente tolérée")
// 2 pages de playlists ; durées : J'aime (60 → 2 appels), PLa (60 restantes grâce au cache → 2 appels), PLb (1 appel) = 5
assert.strictEqual(api.calls.playlists, 2)
assert.strictEqual(api.calls.videos, 5)
assert.ok(events.includes("PLAYLISTS_UPDATE:u1"))
assert.strictEqual(OAuth2.getStatus("u1").syncing, false)
assert.strictEqual(OAuth2.getStatus("u1").lastSync.ok, true)
// --- Délai entre deux synchronisations, puis resynchronisation : identifiants et ordre conservés ---
assert.strictEqual((await PM.syncYoutube("u1")).error, "COOLDOWN")
const ids = list.map(p => p.playlistId).join(",")
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).ok, true)
assert.strictEqual(PM.getPlaylistsOfUser("u1").map(p => p.playlistId).join(","), ids, "même ordre, mêmes ids")
// --- Lecture par identifiant (chaîne ou nombre) ---
const anyId = PM.getPlaylistsOfUser("u1")[0].playlistId
assert.ok(PM.getPlaylistOfUser("u1", anyId))
assert.ok(PM.getPlaylistOfUser("u1", Number(anyId)))
// --- Rafraîchissement d'une playlist importée via l'API (J'aime) ---
const likesId = PM.getPlaylistsOfUser("u1").find(p => p.youtubeId === "LLme").playlistId
const before = api.calls.items
const refreshed = await PM.refreshPlaylist("u1", likesId)
assert.ok(refreshed && refreshed.playlistId === likesId && refreshed.songs.length === 60)
assert.ok(api.calls.items > before, "rafraîchie via playlistItems.list")
// --- Création classique : identifiant primitif (X1) + copie indépendante (X5) ---
const created = await PM.addPlaylist("u1", "Ma liste", null, "moi", "u1", "av")
assert.strictEqual(typeof created.playlistId, "string")
assert.ok(PM.getPlaylistOfUser("u1", created.playlistId), "retrouvée immédiatement")
const copy = PM.copyPlaylist("u1", "u2", created.playlistId)
assert.ok(copy && copy !== created)
assert.strictEqual(PM.copyPlaylist("u1", "u2", created.playlistId), null, "doublon de titre refusé")
PM.renamePlaylist("u2", copy.playlistId, "Autre")
assert.strictEqual(created.title, "Ma liste", "copie indépendante")
// --- Autorisation révoquée chez Google : compte invalide ---
const err = new Error("invalid_grant"); err.response = { status: 400, data: { error: "invalid_grant" } }
api.failWith = err
const failed = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(failed.error, "AUTH_EXPIRED")
const st = OAuth2.getStatus("u1")
assert.deepStrictEqual([st.linked, st.valid, st.lastSync.ok], [true, false, false])
assert.strictEqual(OAuth2.getClient("u1"), null)
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).error, "NOT_LINKED")
// --- Reconnexion puis quota épuisé ---
const url2 = new URL(OAuth2.createAuthUrl("u1"))
await OAuth2.linkAccount("u1", "code2", url2.searchParams.get("state"))
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "reconnexion")
const q = new Error("quota"); q.errors = [{ reason: "quotaExceeded" }]; q.response = { status: 403, data: {} }
api.failWith = q
const quota = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(quota.error, "QUOTA")
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "quota : compte toujours valide")
// --- Code invalide ---
const url3 = new URL(OAuth2.createAuthUrl("u3"))
await assert.rejects(OAuth2.linkAccount("u3", "bad", url3.searchParams.get("state")), { code: "TOKEN_ERROR" })
// --- Déconnexion : suppression + révocation ---
const res = await OAuth2.unlink("u1")
assert.deepStrictEqual(res, { removed: true, revoked: true })
assert.deepStrictEqual(api.revoked, ["RT-code2"])
assert.strictEqual(store.data.u1, undefined)
assert.strictEqual(OAuth2.getStatus("u1").linked, false)
assert.ok(PM.getPlaylistsOfUser("u1").length >= 4, "playlists importées conservées")
assert.deepStrictEqual(await OAuth2.unlink("u1"), { removed: false, revoked: false })
assert.ok(events.filter(e => e === "GOOGLE_UPDATE:u1").length >= 4)
console.log("google-flow.test.js : OK", JSON.stringify(api.calls))
})().catch((e) => { console.error(e); process.exit(1) })
+84
View File
@@ -0,0 +1,84 @@
// Tests des fonctions pures de backend/src/playlists/Google/Utils.js (aucune dépendance)
const assert = require("assert")
const U = require(require("path").resolve(__dirname, "../../src/playlists/Google/Utils.js"))
;(async () => {
// --- parseIsoDuration ---
const cases = [["PT1H2M3S", 3723], ["PT4M13S", 253], ["PT45S", 45], ["PT0S", 0], ["P0D", 0], ["P1DT2H", 93600],
["PT10M", 600], ["P1W", 604800], ["PT1.5S", 1], ["pt3m", 180], ["P", null], ["PT", null], ["", null],
["1:23", null], [null, null], [123, null], ["P1Y", null], ["PT1H2M3", null]]
for (const [input, expected] of cases) assert.strictEqual(U.parseIsoDuration(input), expected, "parseIsoDuration(" + input + ")")
// --- collectPages : pagination simulée (3 pages de 50 + 1 de 20 = 170) ---
const makeFetcher = (total, size, log) => async (token) => {
log.push(token)
const start = token ? Number(token) : 0
const items = Array.from({ length: Math.min(size, total - start) }, (_, i) => start + i)
const next = start + size < total ? String(start + size) : undefined
return { items, nextPageToken: next }
}
let log = []
let r = await U.collectPages(makeFetcher(170, 50, log), { maxItems: 500 })
assert.strictEqual(r.items.length, 170); assert.strictEqual(r.truncated, false); assert.strictEqual(r.pages, 4)
assert.deepStrictEqual(log, [undefined, "50", "100", "150"])
// limite atteinte au milieu d'une page
log = []
r = await U.collectPages(makeFetcher(1000, 50, log), { maxItems: 120 })
assert.strictEqual(r.items.length, 120); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 3)
assert.deepStrictEqual(r.items.slice(-1), [119])
// limite atteinte pile en fin de page alors que Google annonce encore une page
r = await U.collectPages(makeFetcher(1000, 50, []), { maxItems: 100 })
assert.strictEqual(r.items.length, 100); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 2)
// limite pile sur le total : pas tronqué
r = await U.collectPages(makeFetcher(100, 50, []), { maxItems: 100 })
assert.strictEqual(r.truncated, false); assert.strictEqual(r.items.length, 100)
// jeton répété : pas de boucle infinie
let calls = 0
r = await U.collectPages(async () => { calls++; return { items: [calls], nextPageToken: "SAME" } }, { maxItems: 500 })
assert.strictEqual(calls, 2); assert.strictEqual(r.items.length, 2)
// maxPages
r = await U.collectPages(makeFetcher(1000, 10, []), { maxItems: 500, maxPages: 3 })
assert.strictEqual(r.items.length, 30); assert.strictEqual(r.truncated, true)
// page vide / réponse inattendue
r = await U.collectPages(async () => null)
assert.deepStrictEqual(r.items, [])
// erreur propagée
await assert.rejects(U.collectPages(async () => { throw new Error("boom") }), /boom/)
// --- chunk ---
assert.deepStrictEqual(U.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]])
assert.strictEqual(U.chunk(Array.from({ length: 120 }), 50).length, 3)
assert.deepStrictEqual(U.chunk([], 50), [])
// --- createStateStore (horloge simulée) ---
let now = 1_000_000
const store = U.createStateStore({ ttlMs: 10 * 60 * 1000, maxPerKey: 3, now: () => now })
const s1 = store.create("alice")
assert.match(s1, /^[0-9a-f]{48}$/)
assert.strictEqual(store.consume(s1, "bob"), false, "autre utilisateur refusé")
assert.strictEqual(store.consume(s1, "alice"), true, "propriétaire accepté")
assert.strictEqual(store.consume(s1, "alice"), false, "usage unique")
const s2 = store.create("alice")
now += 10 * 60 * 1000 + 1
assert.strictEqual(store.consume(s2, "alice"), false, "expiré après 10 min")
const s3 = store.create("alice")
now += 9 * 60 * 1000
assert.strictEqual(store.consume(s3, "alice"), true, "valide avant 10 min")
// maxPerKey : les plus anciens sont évincés
const olds = [store.create("carol"), store.create("carol"), store.create("carol"), store.create("carol")]
assert.strictEqual(store.consume(olds[0], "carol"), false, "plus ancien évincé")
assert.strictEqual(store.consume(olds[3], "carol"), true)
store.clear("carol")
assert.strictEqual(store.consume(olds[2], "carol"), false, "clear")
assert.strictEqual(store.consume(undefined, "carol"), false)
assert.strictEqual(store.consume(12, "carol"), false)
// --- pickTokens / bestThumbnail ---
assert.deepStrictEqual(U.pickTokens({ access_token: "a", refresh_token: null, expiry_date: 5, foo: 1 }), { access_token: "a", expiry_date: 5 })
assert.deepStrictEqual(U.pickTokens(null), {})
assert.strictEqual(U.bestThumbnail({ default: { url: "d" }, high: { url: "h" } }), "h")
assert.strictEqual(U.bestThumbnail({}), null)
assert.strictEqual(U.bestThumbnail(undefined), null)
console.log("google-utils.test.js : OK")
})().catch((e) => { console.error(e); process.exit(1) })
+120
View File
@@ -0,0 +1,120 @@
// Tests unitaires de backend/src/utils/Limiter.js (node limiter.test.js)
const assert = require("assert");
const path = require("path").resolve(__dirname, "../../src/utils/Limiter.js");
const { createLimiter, mapLimit, createInflight, createCache } = require(path);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("limiter: concurrence max respectée et résultats corrects", async () => {
const lim = createLimiter(2);
let active = 0, peak = 0;
const res = await Promise.all([1, 2, 3, 4, 5, 6].map(i => lim.run(async () => {
active++; peak = Math.max(peak, active);
await sleep(20);
active--;
return i * 10;
})));
assert.deepStrictEqual(res, [10, 20, 30, 40, 50, 60]);
assert.strictEqual(peak, 2);
assert.strictEqual(lim.active, 0);
assert.strictEqual(lim.pending, 0);
});
test("limiter: erreurs (async et sync) propagées sans bloquer la file", async () => {
const lim = createLimiter(1);
await assert.rejects(lim.run(async () => { throw new Error("a"); }), /a/);
await assert.rejects(lim.run(() => { throw new Error("b"); }), /b/);
assert.strictEqual(await lim.run(() => 42), 42);
});
test("limiter: minIntervalMs espace les démarrages", async () => {
const lim = createLimiter(5, { minIntervalMs: 30 });
const starts = [];
await Promise.all([0, 1, 2, 3].map(() => lim.run(async () => { starts.push(Date.now()); })));
for (let i = 1; i < starts.length; i++) assert.ok(starts[i] - starts[i - 1] >= 25, "écart " + (starts[i] - starts[i - 1]));
});
test("mapLimit: ordre conservé, concurrence bornée, erreurs ignorées", async () => {
let active = 0, peak = 0;
const out = await mapLimit([30, 5, 20, 1, 10], 3, async (ms, i) => {
active++; peak = Math.max(peak, active);
await sleep(ms);
active--;
if (i === 2) throw new Error("x");
return ms;
}, () => null);
assert.deepStrictEqual(out, [30, 5, null, 1, 10]);
assert.strictEqual(peak, 3);
assert.deepStrictEqual(await mapLimit(null, 2, async x => x), []);
});
test("inflight: appels simultanés dédupliqués, libérés ensuite", async () => {
const inf = createInflight();
let calls = 0;
const fn = async () => { calls++; await sleep(10); return "v"; };
const [a, b] = await Promise.all([inf.run("k", fn), inf.run("k", fn)]);
assert.strictEqual(a, "v"); assert.strictEqual(b, "v"); assert.strictEqual(calls, 1);
assert.strictEqual(inf.size, 0);
await inf.run("k", fn);
assert.strictEqual(calls, 2);
await assert.rejects(inf.run("e", async () => { throw new Error("boom"); }));
assert.strictEqual(inf.size, 0);
});
test("cache: TTL, null explicite, expiration", async () => {
const c = createCache({ ttlMs: 40, max: 10 });
c.set("a", 1);
c.set("n", null);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("n"), null);
assert.strictEqual(c.get("absent"), undefined);
c.set("z", 1, 0); // TTL 0 → pas mis en cache
assert.strictEqual(c.get("z"), undefined);
await sleep(60);
assert.strictEqual(c.get("a"), undefined);
assert.strictEqual(c.size, 1); // "n" expiré mais pas encore lu
});
test("cache: taille max et éviction LRU", () => {
const c = createCache({ ttlMs: 10000, max: 3 });
c.set("a", 1); c.set("b", 2); c.set("c", 3);
c.get("a"); // a devient le plus récent
c.set("d", 4);
assert.strictEqual(c.size, 3);
assert.strictEqual(c.get("b"), undefined);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("d"), 4);
});
test("cache.wrap: hit, dédup en cours, null non mis en cache, ttlFor", async () => {
const c = createCache({ ttlMs: 10000, max: 10 });
let calls = 0;
const fn = async () => { calls++; await sleep(10); return { x: 1 }; };
const [a, b] = await Promise.all([c.wrap("k", fn), c.wrap("k", fn)]);
assert.strictEqual(a, b); assert.strictEqual(calls, 1);
await c.wrap("k", fn);
assert.strictEqual(calls, 1);
let nulls = 0;
await c.wrap("n", async () => { nulls++; return null; });
await c.wrap("n", async () => { nulls++; return null; });
assert.strictEqual(nulls, 2);
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
assert.strictEqual(nulls, 3);
await assert.rejects(c.wrap("e", async () => { throw new Error("err"); }));
assert.strictEqual(c.get("e"), undefined);
const s = c.stats();
assert.ok(s.hits >= 2 && s.misses >= 4, JSON.stringify(s));
});
(async () => {
let failed = 0;
for (const t of tests) {
try { await t.fn(); console.log("OK " + t.name); }
catch (e) { failed++; console.log("FAIL " + t.name + "\n " + (e && e.stack || e)); }
}
console.log(`\n${tests.length - failed}/${tests.length} réussis`);
process.exit(failed ? 1 : 0);
})();
+107
View File
@@ -0,0 +1,107 @@
// Test réseau de YoutubeInformation / SpotifyInformation / Finder (cache + limiteur), sans démarrer le bot.
const assert = require("assert");
const path = require("path");
const SRC = require("path").resolve(__dirname, "../../src");
// Logs loguix dans le scratchpad (sinon LogType.error plante : flux non initialisé)
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
// Configuration factice : évite de lire data/config.json (et son process.exit sans token)
const confPath = require.resolve(path.join(SRC, "utils/Database/Configuration.js"));
const fakeConf = { id: "", secret: "" };
require.cache[confPath] = { id: confPath, filename: confPath, loaded: true, exports: {
getSpotifyClientId: () => fakeConf.id,
getSpotifyClientSecret: () => fakeConf.secret,
} };
const yt = require(path.join(SRC, "media/YoutubeInformation.js"));
const spotify = require(path.join(SRC, "media/SpotifyInformation.js"));
const Finder = require(path.join(SRC, "player/Finder.js"));
const { Playlist } = require(path.join(SRC, "playlists/Playlist.js"));
async function timed(label, fn) {
const t = Date.now();
const r = await fn();
const ms = Date.now() - t;
console.log(`${label}: ${ms} ms`);
return [r, ms];
}
(async () => {
// 1) Recherche : 2e appel servi par le cache
const [s1, m1] = await timed("getQuery #1 (réseau)", () => yt.getQuery("Daft Punk Around the World", true));
assert.ok(Array.isArray(s1) && s1.length > 0, "résultats attendus");
const [s2, m2] = await timed("getQuery #2 (cache)", () => yt.getQuery(" daft punk around the world ", false));
assert.ok(s2 && s2.url === s1[0].url, "même premier résultat");
assert.ok(m2 < 20, "le 2e appel doit venir du cache");
assert.notStrictEqual(s2, s1[0], "instances Song distinctes");
// 2) Vidéo par id : 2e appel + getSecondsFromUrl servis par le cache
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const [v1] = await timed("getVideo #1 (réseau)", () => yt.getVideo(url));
assert.ok(v1 && v1.id === "jNQXAC9IVRw" && v1.duration > 0, JSON.stringify(v1));
const [v2, mv2] = await timed("getVideo #2 (cache)", () => yt.getVideo(url));
assert.ok(mv2 < 20 && v2.title === v1.title);
const [secs, ms3] = await timed("getSecondsFromUrl (cache)", () => yt.getSecondsFromUrl("https://youtu.be/jNQXAC9IVRw"));
assert.strictEqual(secs, v1.duration); assert.ok(ms3 < 20);
// 3) Appels simultanés dédupliqués (1 seul scrape)
const [[a, b], md] = await timed("2 recherches simultanées identiques", () => Promise.all([yt.getQuery("lofi hip hop radio"), yt.getQuery("LOFI hip hop radio")]));
assert.ok(a && b && a.url === b.url);
// 4) Durée d'une vidéo issue d'une recherche : déjà en cache
const [secs2, ms4] = await timed("getSecondsFromUrl d'un résultat de recherche", () => yt.getSecondsFromUrl(s1[1].url));
assert.ok(ms4 < 20 && secs2 === s1[1].duration);
// 5) Playlists YouTube (nouvelle mise en page) + avatar ; 2e appel servi par le cache
for (const listUrl of ["https://www.youtube.com/playlist?list=PLdXJrX9OsbOVq2TKvUc3xGUp2Rm8nTUVh", "https://www.youtube.com/watch?v=fcnDmrtj6Sk&list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i"]) {
const [p1] = await timed("getPlaylist #1 (réseau)", () => yt.getPlaylist(listUrl));
assert.ok(p1 && p1.songs.length > 0, "playlist attendue");
const s0 = p1.songs[0];
console.log(" playlist:", p1.title, "| auteur:", p1.author, p1.authorId, "| titres:", p1.songs.length, "| durée:", p1.readduration, "| avatar:", String(p1.authorAvatar).slice(0, 50));
console.log(" 1er titre:", s0.title, "|", s0.author, s0.authorId, "|", s0.readduration, s0.url);
assert.ok(s0.url.startsWith("https://www.youtube.com/watch?v=") && s0.type === "youtube" && s0.duration > 0);
const [p2, mp2] = await timed("getPlaylist #2 (cache)", () => yt.getPlaylist(listUrl));
assert.ok(mp2 < 20 && p2.songs.length === p1.songs.length && p2.authorAvatar === p1.authorAvatar);
const [sec, ms5] = await timed("getSecondsFromUrl d'un titre de playlist", () => yt.getSecondsFromUrl(s0.url));
assert.ok(ms5 < 20 && sec === s0.duration);
}
// Avatar d'une chaîne (en-têtes navigateur + consentement), 2e appel en cache
// 6) getTracks : concurrence 3, ordre conservé, titres invalides ignorés, idempotent
const pl = new Playlist("Test", "https://open.spotify.com/playlist/x");
pl.type = "spotify";
pl.songs = [
{ id: "t1", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 248000 },
null,
{ id: "t2", name: "Bohemian Rhapsody", artists: [{ name: "Queen" }], duration_ms: 354000 },
{ id: "pod", name: "Episode", artists: [] },
{ id: "t3", name: "Billie Jean", artists: [{ name: "Michael Jackson" }], duration_ms: 294000 },
];
const [out] = await timed("getTracks (3 titres)", () => spotify.getTracks(pl));
assert.strictEqual(out, pl);
assert.deepStrictEqual(out.songs.map(s => s.id), ["t1", "t2", "t3"]);
assert.ok(out.songs.every(s => s.type === "youtube" && s.url.startsWith("https://www.youtube.com/watch?v=")));
assert.strictEqual(out.duration, 248 + 354 + 294);
const [, mi] = await timed("getTracks idempotent", () => spotify.getTracks(pl));
assert.ok(mi < 20 && pl.songs.length === 3);
// 7) Finder : playlist Spotify convertie (spotify.getPlaylist simulé), échec total → null
const realGetPlaylist = spotify.getPlaylist;
spotify.getPlaylist = async () => { const p = new Playlist("Sim", "u"); p.type = "spotify"; p.songs = [{ id: "z", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 1000 }]; return p; };
const fp = await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true);
assert.ok(fp instanceof Playlist && fp.songs[0].type === "youtube" && fp.songs[0].url, "Finder doit renvoyer des Song convertis");
const fp2 = await Finder.search("https://open.spotify.com/intl-fr/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", false, "PLAYLIST");
assert.ok(fp2 && fp2.songs[0].url);
spotify.getPlaylist = async () => { const p = new Playlist("Vide", "u"); p.type = "spotify"; p.songs = [null]; return p; };
assert.strictEqual(await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true), null);
spotify.getPlaylist = realGetPlaylist;
console.log("Finder Spotify: OK");
// 8) Spotify sans identifiants : erreur propre (pas d'appel réseau), lecture paresseuse
assert.strictEqual(await spotify.getSong("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"), null);
process.emit("CONFIG_UPDATE");
console.log("Spotify sans identifiants: OK (null)");
console.log("\nTOUS LES TESTS RÉUSSIS");
process.exit(0);
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const plan = req('src/youtube/plan');
const W = 'http://warp:1080';
const ROUTES = { 'warp+direct': [W, null], 'direct': [null], 'warp seul': [W] };
const FB = 'web_safari,tv_downgraded';
const label = (a) => `${a.proxy ? 'W' : 'D'}/${a.identity === 'cookies' ? 'C' : 'A'}${a.clients ? '/fb' : ''}${a.seek ? '/' + a.seek : ''}`;
/**
* Simule getStream : chaque tentative échoue avec la classe donnée (tableau, ou fonction (attempt, i, history))
* okAt : index de la tentative qui réussit ; rotateAt : la tentative i invalide les cookies
*/
function simulate({ routes, mode = 'fallback', cookiesValid = true, cookiesOnProxy = false, fail, okAt = -1, rotateAt = -1, seekMode = null, fallbackClients = FB }) {
const history = [];
const seq = [];
let valid = cookiesValid;
for (let i = 0; i < 10; i++) {
const a = plan.planNext({ routes, mode, cookiesValid: valid, cookiesOnProxy, clients: '', fallbackClients, seekMode, history, maxAttempts: 4 });
if (!a) break;
seq.push(label(a));
if (i === okAt) { seq.push('OK'); break; }
const cls = typeof fail === 'function' ? fail(a, i, history) : (Array.isArray(fail) ? fail[Math.min(i, fail.length - 1)] : fail);
if (i === rotateAt) valid = false;
history.push({ ...a, cls });
}
return seq.join(' > ');
}
function expect(name, input, expected) {
const got = simulate(input);
check(`${name.padEnd(62)} ${got}`, got === expected, { expected });
}
console.log('# Mode never');
expect('never, warp+direct, UNKNOWN', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNKNOWN' }, 'W/A > D/A');
expect('never, warp+direct, BOT_CHECK (jamais de cookies)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('never, warp+direct, HTTP_403 (clients de repli une fois)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'HTTP_403' }, 'W/A > W/A/fb > D/A');
expect('never, direct, LOGIN_REQUIRED (arrêt)', { routes: ROUTES.direct, mode: 'never', fail: 'LOGIN_REQUIRED' }, 'D/A');
expect('never, warp seul, TIMEOUT', { routes: ROUTES['warp seul'], mode: 'never', fail: 'TIMEOUT' }, 'W/A');
expect('never, warp+direct, PRIVATE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'PRIVATE' }, 'W/A');
expect('never, warp+direct, UNAVAILABLE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNAVAILABLE' }, 'W/A');
expect('never, warp+direct, RATE_LIMITED (autre route uniquement)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'RATE_LIMITED' }, 'W/A > D/A');
expect('never, warp+direct, BINARY_MISSING (arrêt)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BINARY_MISSING' }, 'W/A');
console.log('# Mode fallback (cookies valides)');
expect('fallback, warp+direct, BOT_CHECK', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK' }, 'W/A > D/A > D/C');
expect('fallback, warp+direct, IP_BLOCKED, cookiesOnProxy', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'IP_BLOCKED' }, 'W/A > D/A > W/C > D/C');
expect('fallback, direct, CAPTCHA', { routes: ROUTES.direct, fail: 'CAPTCHA' }, 'D/A > D/C');
expect('fallback, warp seul, BOT_CHECK (proxy = seule route)', { routes: ROUTES['warp seul'], fail: 'BOT_CHECK' }, 'W/A > W/C');
expect('fallback, warp+direct, AGE_RESTRICTED (compte directement)', { routes: ROUTES['warp+direct'], fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('fallback, warp+direct, MEMBERS_ONLY', { routes: ROUTES['warp+direct'], fail: 'MEMBERS_ONLY' }, 'W/A > D/C');
expect('fallback, warp+direct, LOGIN_REQUIRED, cookiesOnProxy (refus avec compte = arrêt)', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'LOGIN_REQUIRED' }, 'W/A > W/C');
expect('fallback, warp+direct, UNKNOWN (pas de cookies)', { routes: ROUTES['warp+direct'], fail: 'UNKNOWN' }, 'W/A > D/A');
expect('fallback, warp+direct, GEO_BLOCKED (autre route seulement)', { routes: ROUTES['warp+direct'], fail: 'GEO_BLOCKED' }, 'W/A > D/A');
expect('fallback, direct, GEO_BLOCKED', { routes: ROUTES.direct, fail: 'GEO_BLOCKED' }, 'D/A');
expect('fallback, direct, HTTP_403', { routes: ROUTES.direct, fail: 'HTTP_403' }, 'D/A > D/A/fb');
expect('fallback, direct, SABR', { routes: ROUTES.direct, fail: 'SABR' }, 'D/A > D/A/fb');
expect('fallback, direct, POT_MISSING', { routes: ROUTES.direct, fail: 'POT_MISSING' }, 'D/A > D/A/fb');
expect('fallback, direct, EMPTY_DOWNLOAD', { routes: ROUTES.direct, fail: 'EMPTY_DOWNLOAD' }, 'D/A > D/A/fb');
expect('fallback, direct, FORMAT_UNAVAILABLE, repli désactivé', { routes: ROUTES.direct, fail: 'FORMAT_UNAVAILABLE', fallbackClients: '' }, 'D/A');
expect('fallback, warp+direct, 403 puis BOT_CHECK', { routes: ROUTES['warp+direct'], fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A > W/A/fb > D/A > D/C');
expect('fallback, warp+direct, BOT puis 403 (max 4)', { routes: ROUTES['warp+direct'], fail: ['BOT_CHECK', 'BOT_CHECK', 'HTTP_403'] }, 'W/A > D/A > D/C > D/C/fb');
expect('fallback, warp+direct, réussite à la 2e', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', okAt: 1 }, 'W/A > D/A > OK');
expect('fallback, cookies tournés pendant la tentative avec compte', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', rotateAt: 2 }, 'W/A > D/A > D/C');
expect('fallback, cookies invalides, BOT_CHECK', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('fallback, cookies invalides, AGE_RESTRICTED (arrêt)', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'AGE_RESTRICTED' }, 'W/A');
expect('fallback, compte refusé (AGE avec cookies) -> arrêt', { routes: ROUTES.direct, fail: 'AGE_RESTRICTED' }, 'D/A > D/C');
console.log('# Mode always');
expect('always, warp+direct (pas de compte via WARP)', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'UNKNOWN' }, 'W/A > D/C');
expect('always, warp+direct, cookiesOnProxy', { routes: ROUTES['warp+direct'], mode: 'always', cookiesOnProxy: true, fail: 'UNKNOWN' }, 'W/C > D/C');
expect('always, direct, BOT_CHECK', { routes: ROUTES.direct, mode: 'always', fail: 'BOT_CHECK' }, 'D/C');
expect('always, warp seul (compte autorisé)', { routes: ROUTES['warp seul'], mode: 'always', fail: 'UNKNOWN' }, 'W/C');
expect('always, direct, rotation -> anonyme', { routes: ROUTES.direct, mode: 'always', fail: 'COOKIES_ROTATED', rotateAt: 0 }, 'D/C > D/A');
expect('always, warp+direct, AGE sur WARP anonyme -> compte', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('always, direct, HTTP_403 -> clients de repli avec compte', { routes: ROUTES.direct, mode: 'always', fail: 'HTTP_403' }, 'D/C > D/C/fb');
expect('always sans cookies valides = never', { routes: ROUTES['warp+direct'], mode: 'always', cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('always, PRIVATE', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'PRIVATE' }, 'W/A');
console.log('# Seek');
check('seekModeFor auto 30 -> native', plan.seekModeFor('auto', 30) === 'native');
check('seekModeFor auto 1200 -> native', plan.seekModeFor('auto', 1200) === 'native');
check('seekModeFor auto 1201 -> sections', plan.seekModeFor('auto', 1201) === 'sections');
check('seekModeFor ffmpeg 10 -> sections', plan.seekModeFor('ffmpeg', 10) === 'sections');
check('seekModeFor native 5000 -> native', plan.seekModeFor('native', 5000) === 'native');
check('seekModeFor sans seek -> null', plan.seekModeFor('auto', 0) === null);
expect('sections + HTTP_403 -> natif puis clients de repli', { routes: ROUTES.direct, seekMode: 'sections', fail: 'HTTP_403' }, 'D/A/sections > D/A/native > D/A/fb/native');
expect('sections + 403 sur warp+direct -> la suite en natif', { routes: ROUTES['warp+direct'], seekMode: 'sections', fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A/sections > W/A/native > D/A/native > D/C/native');
expect('natif + UNKNOWN', { routes: ROUTES['warp+direct'], seekMode: 'native', fail: 'UNKNOWN' }, 'W/A/native > D/A/native');
console.log('# Matrice (modes x routes x classes)');
const classes = ['BOT_CHECK', 'IP_BLOCKED', 'CAPTCHA', 'RATE_LIMITED', 'HTTP_403', 'HTTP_429', 'AGE_RESTRICTED', 'LOGIN_REQUIRED', 'MEMBERS_ONLY', 'PRIVATE', 'UNAVAILABLE', 'GEO_BLOCKED', 'POT_MISSING', 'SABR', 'JS_CHALLENGE', 'FORMAT_UNAVAILABLE', 'PAGE_RELOAD', 'COOKIES_ROTATED', 'EMPTY_DOWNLOAD', 'BINARY_MISSING', 'TIMEOUT', 'UNKNOWN'];
let maxLen = 0, dup = 0;
for (const mode of ['never', 'always', 'fallback']) {
for (const [rname, routes] of Object.entries(ROUTES)) {
const row = [];
for (const cls of classes) {
const seq = simulate({ routes, mode, fail: cls });
const parts = seq.split(' > ');
maxLen = Math.max(maxLen, parts.length);
if (new Set(parts).size !== parts.length) dup++;
row.push(`${cls}: ${seq}`);
}
console.log(` [${mode} | ${rname}]`);
for (const r of row) console.log(` ${r}`);
}
}
check('jamais plus de 4 tentatives', maxLen <= 4, maxLen);
check('jamais deux fois la même tentative', dup === 0, dup);
check('pas de compte en mode never', !['warp+direct', 'direct', 'warp seul'].some(r => classes.some(c => simulate({ routes: ROUTES[r], mode: 'never', fail: c }).includes('/C'))));
done();
+227
View File
@@ -0,0 +1,227 @@
// Test de backend/src/server/Server.js avec un vrai serveur socket.io (port local) et des clients socket.io-client.
// Toutes les dépendances (bot Discord, utilisateurs, players, Admin, playlists, Google...) sont simulées :
// aucun bot Discord n'est connecté, aucun fichier de data/ n'est lu ni écrit.
const assert = require("assert")
const Module = require("module")
const path = require("path")
const ROOT = require("path").resolve(__dirname, "../../..")
const SRC = ROOT + "/backend/src"
const { io: ioClient } = require(ROOT + "/frontend/node_modules/socket.io-client")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
const PORT = 45000 + Math.floor(Math.random() * 1000)
const uncaught = []
process.on("uncaughtException", (e) => { uncaught.push(e); console.error("uncaughtException", e) })
process.on("unhandledRejection", (e) => { uncaught.push(e); console.error("unhandledRejection", e) })
// --- Utilisateurs : chaque lecture renvoie un NOUVEL objet (comme loadUsers() après chaque saveUsers()) ---
const raw = {
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
B: { id: "B", tokens: ["tB"], labels: [] },
C: { id: "C", tokens: ["tC"], labels: [] },
}
function makeUser(r) {
if (!r) return null
const labels = [...r.labels]
return {
identity: { id: r.id, username: "user" + r.id, global_name: "User " + r.id, avatar: null },
labels, auth: { ok: true }, tokens: [...r.tokens],
isAdmin: () => labels.includes("ADMIN"),
isFullBanned: () => labels.includes("BAN"),
isBanned: (g) => labels.includes("BAN_" + g),
isMod: (g) => labels.includes("ADMIN") || labels.includes("MOD_" + g),
isDeleted: () => labels.includes("DELETED"),
needUpdate: () => false, justUpdated() {}, clearToken() {},
removeToken(t) { r.tokens = r.tokens.filter(x => x !== t) },
}
}
const usersStub = {
getUserById: (id) => makeUser(raw[id]),
getUserByToken: (t) => makeUser(Object.values(raw).find(r => r.tokens.includes(t))),
setGuildBan: async (id, g) => { const r = raw[id]; const l = "BAN_" + g; r.labels = r.labels.includes(l) ? r.labels.filter(x => x !== l) : [...r.labels, l]; return true },
setGuildMod: async () => true,
deleteAccount: async (id) => { raw[id].labels = ["DELETED"]; raw[id].tokens = [] },
updateIdentity: async () => true,
addUser: async () => null,
}
// --- Bot / players ---
const guilds = new Map([
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }],
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
])
const AllPlayers = new Map()
const seekCalls = []
class FakePlayer {
constructor(guildId) { if (AllPlayers.has(guildId)) return AllPlayers.get(guildId); this.guildId = guildId; this.connected = false; this.queue = { current: { title: "x" } }; AllPlayers.set(guildId, this) }
isConnected() { return this.connected }
getState() { return { v: 2, guildId: this.guildId, status: this.connected ? "playing" : "idle" } }
async setDuration(t) { seekCalls.push(t); return { ok: true } }
async skip() { return "no_music" }
}
let ioRef = null
const adminStub = {
ADMIN_ROOM: "ADMIN", LOGS_ROOM: "ADMIN_LOGS",
init(io) { ioRef = io }, attach() {},
syncUserSockets(userId) {
const user = usersStub.getUserById(userId)
for (const s of ioRef.sockets.sockets.values()) {
if (s.data.userId !== userId) continue
for (const room of [...s.rooms]) if (guilds.has(room) && user.isBanned(room)) s.leave(room)
}
process.emit("USERS_UPDATE")
},
}
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
let reportResult = false
let finderThrows = true
const stubs = {
"loguix": fakeLog,
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v },
[norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} },
[norm(SRC + "/server/auth/User")]: usersStub,
[norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] },
[norm(SRC + "/discord/Bot")]: { isReady: async () => true, getGuilds: () => guilds, getGuildMember: () => null, getMembersVoices: () => new Map(), getChannel: () => null, getGuildRoles: () => [] },
[norm(SRC + "/server/auth/DiscordAuth")]: {},
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
[norm(SRC + "/playlists/PlaylistManager")]: { syncYoutube: async () => ({ ok: true }) },
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
[norm(SRC + "/lyrics/Lyrics")]: {},
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null },
[norm(SRC + "/discord/MediaBase")]: {},
[norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false },
[norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT },
[norm(SRC + "/player/List")]: { List: class { getPrevious() { return [] } } },
[norm(SRC + "/server/Admin")]: adminStub,
[norm(SRC + "/utils/AudioBufferCheck")]: { isAudioFile: () => false },
[norm(SRC + "/player/Song")]: { Song: class {} },
[norm(SRC + "/media/MediaInformation")]: {},
}
const origLoad = Module._load
Module._load = function (request, parent) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const Server = require(SRC + "/server/Server.js")
assert.strictEqual(typeof Server.init, "function", "Server.init exporté")
Server.init()
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const clients = []
function connect(token) {
return new Promise((resolve, reject) => {
const s = ioClient("http://127.0.0.1:" + PORT, { auth: { token }, reconnection: false, transports: ["websocket"] })
clients.push(s)
s.once("/USER/READY", () => resolve(s))
s.once("AUTH_ERROR", (e) => reject(new Error("AUTH_ERROR " + e)))
setTimeout(() => reject(new Error("timeout connexion " + token)), 3000)
})
}
function request(s, event, data) {
return new Promise((resolve, reject) => {
s.once(event, resolve)
s.emit(event, data)
setTimeout(() => reject(new Error("pas de réponse " + event)), 3000)
})
}
function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
;(async () => {
await sleep(100)
const a1 = await connect("tA1"), a2 = await connect("tA2"), b = await connect("tB")
// --- Rooms conservées lors d'un changement de guilde ---
serverSocket(a1).join("ADMIN_LOGS")
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g1"), true)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g2"), true)
const rooms = [...serverSocket(a1).rooms]
assert.ok(rooms.includes("g2") && !rooms.includes("g1"), "guilde précédente quittée : " + rooms)
for (const r of ["user:A", "ADMIN", "ADMIN_LOGS", a1.id]) assert.ok(rooms.includes(r), "room conservée : " + r)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "inconnue"), false)
// --- Présence multi-appareils ---
await request(a1, "/GUILD/JOIN", "g1")
await request(a2, "/GUILD/JOIN", "g1")
await request(b, "/GUILD/JOIN", "g1")
let members = await request(b, "/USERS/LIST")
assert.deepStrictEqual(members.map(m => m.id).sort(), ["A", "B"], "A une seule fois")
a1.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(members.some(m => m.id === "A"), "A toujours présent (2e appareil)")
const info = await request(b, "/GUILD/INFO")
assert.deepStrictEqual(info.members.map(m => m.id), ["A"])
a2.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(!members.some(m => m.id === "A"), "A absent après la dernière déconnexion")
// --- PLAYER_STATE : jamais de création de player, /GUILD/UPDATE seulement au changement de connexion ---
let guildUpdates = 0, playerUpdates = 0
b.on("/GUILD/UPDATE", () => guildUpdates++)
b.on("/PLAYER/UPDATE", () => playerUpdates++)
process.emit("PLAYER_STATE", "g1")
process.emit("PLAYERS_UPDATE")
await sleep(100)
assert.strictEqual(AllPlayers.size, 0, "aucun player créé")
assert.strictEqual(playerUpdates, 1, "état hors-ligne envoyé à la room g1")
const player = new FakePlayer("g1"); player.connected = true
process.emit("PLAYER_STATE", "g1"); await sleep(50)
process.emit("PLAYER_STATE", "g1"); await sleep(50)
assert.strictEqual(guildUpdates, 2, "1re émission + passage à connecté uniquement (" + guildUpdates + ")")
assert.strictEqual((await request(b, "/PLAYER/STATE")).status, "playing")
// --- SEEK : 0 accepté, négatif refusé, refus du player transmis ---
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 0 }), true)
assert.strictEqual(await request(b, "/PLAYER/SEEK", 0), true)
assert.deepStrictEqual(seekCalls, [0, 0])
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: -1 }), false)
assert.strictEqual(await request(b, "/PLAYER/SEEK", {}), false)
player.queue.current = null
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 5 }), false, "pas de musique : false (pas de ReferenceError)")
assert.strictEqual(await request(b, "/PLAYER/FORWARD"), "no_music")
// --- Exception dans un handler : réponse false, process intact ---
assert.strictEqual(await request(b, "/SEARCH", "test"), false)
// --- /REPORT : résultat réel de l'envoi ---
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), false)
reportResult = true
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), true)
// --- /MOD/USERS/BAN (plus de UsersBySocket) + droits relus à chaque requête ---
const c = await connect("tC")
await request(c, "/GUILD/JOIN", "g1")
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "le propriétaire bannit B")
await sleep(50)
assert.ok(!serverSocket(b).rooms.has("g1"), "B a quitté la room g1")
assert.strictEqual(await request(b, "/PLAYER/STATE"), false, "B banni : refus immédiat (utilisateur relu)")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), false)
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "levée du ban")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), true)
// --- Bannissement global en cours de session ---
raw.B.labels.push("BAN")
const authError = new Promise(r => b.once("AUTH_ERROR", r))
b.emit("/USER/INFO")
assert.strictEqual(await authError, "Vous êtes banni du serveur")
await sleep(100)
assert.strictEqual(b.connected, false)
// --- /GOOGLE/STATUS par ack ---
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
assert.strictEqual(status.user, "C")
await sleep(100)
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
console.log("server-socket.test.js : OK")
clients.forEach(s => s.disconnect())
ioRef.close()
setTimeout(() => process.exit(0), 100)
})().catch((e) => { console.error(e); process.exit(1) })
+155
View File
@@ -0,0 +1,155 @@
// Test de bout en bout : vrai yt-dlp, vrai YouTube, sans proxy (WARP non démarré => route directe)
// Usage : node stream.e2e.js [--no-update] [--no-cookies]
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('e2e');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const warp = req('src/utils/WarpProxy');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const args = process.argv.slice(2);
const TARGET = 200 * 1024;
const LONG_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
function procCount(image) {
if (process.platform !== 'win32') return null;
try {
const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${image}`, '/NH'], { encoding: 'utf8', windowsHide: true });
return out.split(/\r?\n/).filter(l => l.toLowerCase().startsWith(image.toLowerCase())).length;
} catch (e) { return null; }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* Lit ~200 Ko puis annule via AbortController
*/
async function readThenAbort(label, seek, { expectRaw = false, url } = {}) {
console.log(`\n# ${label}`);
const controller = new AbortController();
const progress = [];
const t0 = Date.now();
const song = { id: 'e2e', title: 'e2e', url: url || settings.get().testUrl, type: 'youtube' };
const stream = await getStream(song, seek, { signal: controller.signal, guildId: 'e2e-guild', onProgress: (p) => progress.push(p) });
const tStream = Date.now() - t0;
console.log(` getStream -> ${stream ? 'flux' : 'null'} en ${tStream} ms, progression : ${JSON.stringify(progress)}`);
console.log(` activeProcesses pendant la lecture : ${JSON.stringify([...activeProcesses.entries()].map(([g, p]) => ({ g, route: p.route, identity: p.identity, pid: !!p.pid })))}`);
if (!stream) {
console.log(' diagnostics :', JSON.stringify(diagnostics.getRecent(4), null, 0));
return { ok: false };
}
check(`${label} : streamType ${expectRaw ? '"raw"' : 'conteneur'}`, expectRaw ? stream.streamType === 'raw' : stream.streamType === undefined);
const chunks = [];
let total = 0;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 60000);
stream.on('data', (c) => {
if (total >= TARGET) return;
chunks.push(c);
total += c.length;
if (total >= TARGET) { clearTimeout(timer); resolve(); }
});
stream.on('close', () => { clearTimeout(timer); resolve(); });
stream.on('error', () => {});
});
const buf = Buffer.concat(chunks);
console.log(` ${total} octets lus en ${Date.now() - t0} ms`);
check(`${label} : ~200 Ko reçus`, total >= TARGET, total);
if (expectRaw) {
// PCM s16le stéréo : vérifie qu'il y a du signal (pas que des zéros)
let sumSq = 0, n = 0, nonZero = 0;
for (let i = 0; i + 1 < buf.length; i += 2) {
const v = buf.readInt16LE(i);
sumSq += v * v; n++;
if (v !== 0) nonZero++;
}
const rms = Math.sqrt(sumSq / Math.max(1, n));
console.log(` PCM : ${n} échantillons, RMS ${rms.toFixed(1)}, ${(100 * nonZero / Math.max(1, n)).toFixed(1)} % non nuls, ~${(n / 2 / 48000).toFixed(2)} s d'audio`);
check(`${label} : PCM non silencieux`, rms > 10 && nonZero > n / 10);
} else {
// WebM (EBML 1A45DFA3) ou MP4 (ftyp)
const magic = buf.subarray(0, 4).toString('hex');
const isWebm = magic === '1a45dfa3';
const isMp4 = buf.subarray(4, 8).toString() === 'ftyp';
console.log(` conteneur : ${isWebm ? 'WebM' : isMp4 ? 'MP4' : 'inconnu (' + magic + ')'}`);
check(`${label} : conteneur audio reconnu`, isWebm || isMp4);
}
controller.abort();
// Processus tués et copies de cookies supprimées
let waited = 0;
while ((ytdlp.activeCount() > 0 || activeProcesses.size > 0 || cookies.liveCopyCount() > 0) && waited < 5000) {
await sleep(100); waited += 100;
}
console.log(` après abort (${waited} ms) : processus suivis ${ytdlp.activeCount()}, activeProcesses ${activeProcesses.size}, copies de cookies ${cookies.liveCopyCount()}, stream détruit ${stream.destroyed}`);
check(`${label} : tous les processus terminés`, ytdlp.activeCount() === 0 && activeProcesses.size === 0);
check(`${label} : flux détruit`, stream.destroyed === true);
check(`${label} : aucune copie chopin-ytc-* restante`, chopinTmpDirs().length === 0 && cookies.liveCopyCount() === 0, chopinTmpDirs());
return { ok: true };
}
(async () => {
console.log('routes WARP :', JSON.stringify(warp.getRoutes().map(r => r ? 'warp' : 'direct')));
const bin = ytdlp.resolveBinary();
const ff = ytdlp.resolveFfmpeg();
console.log(`yt-dlp : ${bin.source} ${path.relative(BACKEND, bin.path) || bin.path}`);
console.log(`ffmpeg : ${ff?.source} ${ff ? path.relative(BACKEND, ff.path) : '-'}`);
console.log(`version : ${await ytdlp.getVersion({ force: true })}`);
const before = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système avant :', JSON.stringify(before));
if (!args.includes('--no-update')) {
console.log('\n# Mise à jour (yt-dlp -U)');
const up = await ytdlp.update({ reason: 'test e2e' });
console.log(' ', JSON.stringify({ ok: up.ok, updatable: up.updatable, before: up.before, after: up.after, message: up.message }));
console.log(' sortie :', up.output.split('\n').slice(-4).join(' | '));
check('update : version connue après', Boolean(up.after));
}
await readThenAbort('Lecture sans seek (anonyme, directe)', 0);
// testUrl (Me at the zoo) ne dure que 19 s : seek 30 s sur une vidéo plus longue
await readThenAbort('Seek 30 s (natif : yt-dlp -> ffmpeg -> PCM brut)', 30, { expectRaw: true, url: LONG_URL });
console.log('\n# Seek au-delà de la fin (testUrl, 19 s) : un seul essai, sans clients de repli');
const n0 = diagnostics.getRecent(100).length;
const past = await getStream({ id: 'e2e', title: 'e2e', url: settings.get().testUrl, type: 'youtube' }, 30, { guildId: 'e2e-guild' });
const tries = diagnostics.getRecent(100).length - n0;
check('seek après la fin : null en 1 tentative (final)', past === null && tries === 1 && diagnostics.getRecent(1)[0].cls === 'EMPTY_DOWNLOAD', { tries, last: diagnostics.getRecent(1)[0] });
await sleep(2500);
check('seek après la fin : processus terminés', ytdlp.activeCount() === 0, ytdlp.activeCount());
if (!args.includes('--no-cookies')) {
// Anciens cookies (fichiers réels en lecture seule) importés dans le dossier isolé, mode "always"
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
const mig = cookies.migrateLegacy();
console.log('\n# Cookies : migration', JSON.stringify(mig), '-> valides :', cookies.isValid());
if (cookies.isValid()) {
settings.update({ cookiesMode: 'always' }, { username: 'e2e' });
await readThenAbort('Lecture avec compte (mode always, copie temporaire des cookies)', 0);
const st = cookies.getStatus();
console.log(' statut cookies après lecture :', JSON.stringify({ valid: st.valid, invalidatedAt: !!st.invalidatedAt, invalidReason: st.invalidReason }));
}
}
console.log('\n# Diagnostics');
for (const e of diagnostics.getRecent(20).reverse()) {
console.log(` ${e.outcome.padEnd(7)} ${e.route}/${e.identity}${e.clients ? '/' + e.clients : ''}${e.seek ? '/' + e.seek : ''} ${e.ms} ms ${e.cls || ''} ${e.message || ''}`);
}
const s = diagnostics.getSummary();
console.log(' résumé :', JSON.stringify({ ok: s.ok, failed: s.failed, aborted: s.aborted, byClass: s.byClass, consecutiveBlocks: s.consecutiveBlocks }));
await sleep(1000);
console.log(' getStatus().activeProcesses :', youtube.getStatus().activeProcesses);
const after = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système après :', JSON.stringify(after));
check('aucun yt-dlp.exe / ffmpeg.exe orphelin', after.ytdlp === null || (after.ytdlp <= before.ytdlp && after.ffmpeg <= before.ffmpeg), { before, after });
check('aucune copie chopin-ytc-* (fin)', chopinTmpDirs().length === 0, chopinTmpDirs());
fs.rmSync(dataDir, { recursive: true, force: true });
done();
// Timers unref : le process se termine seul
})().catch((e) => { console.error(e); process.exitCode = 1; });
+55
View File
@@ -0,0 +1,55 @@
// Cas limites de getStream : binaire absent, annulation avant les données (avec copie de cookies), URL refusée
const fs = require('fs');
const { SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('edge');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const song = { id: 'edge', title: 'edge', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', type: 'youtube' };
(async () => {
console.log('# URL refusée');
check('URL "--exec=..." refusée', (await getStream({ url: '--exec=calc' })) === null);
console.log('# Binaire absent');
const savedPath = process.env.PATH, savedRoot = __glob.ROOT;
process.env.PATH = '';
__glob.ROOT = SCRATCH + '/';
const bin = ytdlp.resolveBinary({ force: true });
check('resolveBinary -> missing', bin.source === 'missing', bin);
const n0 = diagnostics.getRecent(100).length;
const r1 = await getStream(song, 0, { guildId: 'g' });
const last = diagnostics.getRecent(1)[0];
check('binaire absent : null, 1 tentative, BINARY_MISSING', r1 === null && diagnostics.getRecent(100).length - n0 === 1 && last.cls === 'BINARY_MISSING', last);
process.env.PATH = savedPath;
__glob.ROOT = savedRoot;
check('binaire retrouvé ensuite', ytdlp.resolveBinary({ force: true }).source !== 'missing');
console.log('# Annulation avant les données (avec compte : copie de cookies)');
const T = (...f) => f.join('\t');
cookies.importCookies([T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'LOGIN_INFO', 'fake'), T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'SAPISID', 'fake')].join('\n'));
settings.update({ cookiesMode: 'always' });
const controller = new AbortController();
const p = getStream(song, 0, { signal: controller.signal, guildId: 'g2' });
await sleep(400);
const copiesDuring = cookies.liveCopyCount();
const procDuring = ytdlp.activeCount();
controller.abort();
const r2 = await p;
console.log(` pendant : copies ${copiesDuring}, processus ${procDuring}`);
check('annulation : null', r2 === null);
check('annulation : diagnostics "aborted"', diagnostics.getRecent(1)[0].outcome === 'aborted');
let waited = 0;
while ((ytdlp.activeCount() || cookies.liveCopyCount() || chopinTmpDirs().length) && waited < 5000) { await sleep(100); waited += 100; }
console.log(` après ${waited} ms : processus ${ytdlp.activeCount()}, copies ${cookies.liveCopyCount()}, dossiers ${chopinTmpDirs().length}, activeProcesses ${activeProcesses.size}`);
check('annulation : la copie existait pendant la tentative', copiesDuring === 1 && procDuring >= 1);
check('annulation : processus tués, copie supprimée', ytdlp.activeCount() === 0 && cookies.liveCopyCount() === 0 && chopinTmpDirs().length === 0 && activeProcesses.size === 0);
console.log('# Signal déjà annulé');
const c2 = new AbortController(); c2.abort();
check('signal déjà annulé : null sans processus', (await getStream(song, 0, { signal: c2.signal })) === null && ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+20
View File
@@ -0,0 +1,20 @@
// Détecte les identifiants référencés sans liaison (ReferenceError potentielle) via @babel/traverse
const FE = require("path").resolve(__dirname, "../../../frontend/node_modules") + "/"
const parser = require(FE + "@babel/parser")
const traverse = require(FE + "@babel/traverse").default
const fs = require("fs")
const globals = new Set(Object.getOwnPropertyNames(globalThis).concat(["require","module","exports","__dirname","__filename","process","Buffer","console","setTimeout","clearTimeout","setInterval","clearInterval","setImmediate","URL","fetch","AbortController","structuredClone","undefined","arguments"]))
let bad = 0
for (const file of process.argv.slice(2)) {
const ast = parser.parse(fs.readFileSync(file, "utf8"), { sourceType: "script", allowReturnOutsideFunction: true })
traverse(ast, {
ReferencedIdentifier(p) {
const name = p.node.name
if (p.scope.hasBinding(name, true) || globals.has(name)) return
if (p.parentPath.isMemberExpression({ property: p.node }) && !p.parentPath.node.computed) return
bad++
console.log(file.split("/").pop() + ":" + p.node.loc.start.line + " " + name)
}
})
}
console.log(bad ? bad + " identifiant(s) non lié(s)" : "aucun identifiant non lié")
+5
View File
@@ -0,0 +1,5 @@
const { isolateData, req } = require('./_setup');
isolateData('upd');
const ytdlp = req('src/youtube/ytdlp');
const a = ytdlp.update({ reason: 'test' }), b = ytdlp.update();
a.then(r => console.log('single-flight:', a === b, JSON.stringify({ ok: r.ok, updatable: r.updatable, before: r.before, after: r.after, message: r.message })));