From 6017e03eac1d5fc04645b4ebd03463edc4eabf66 Mon Sep 17 00:00:00 2001 From: Raphix Date: Wed, 7 Oct 2026 14:04:26 +0200 Subject: [PATCH] Version 2.0.0 - update of everything --- .dockerignore | 29 + Dockerfile | 42 +- docker-compose.dev.yml | 48 + docker-compose.yml | 21 + package.json | 2 +- src/discord/Activity.js | 74 +- src/discord/Bot.js | 208 ++- src/discord/Command.js | 3 +- src/discord/CommandUpdater.js | 100 +- src/discord/Commands/Leave.js | 38 +- src/discord/Commands/Media.js | 33 +- src/discord/Commands/Pause.js | 54 +- src/discord/Commands/Play.js | 125 +- src/discord/Commands/Previous.js | 61 +- src/discord/Commands/Queue.js | 62 +- src/discord/Commands/Restart.js | 26 +- src/discord/Commands/Skip.js | 58 +- src/discord/Commands/State.js | 65 +- src/discord/Embed.js | 61 +- src/discord/MediaBase.js | 26 +- src/discord/ReportSender.js | 29 +- src/discord/ServerSettings.js | 28 +- src/lyrics/Lyrics.js | 20 +- src/main.js | 120 +- src/media/MediaInformation.js | 23 +- src/media/SoundcloudInformation.js | 18 +- src/media/SpotifyInformation.js | 276 +++- src/media/YoutubeInformation.js | 300 +++- src/player/Finder.js | 57 +- src/player/List.js | 192 ++- src/player/Method/Media.js | 51 +- src/player/Method/Soundcloud.js | 50 +- src/player/Method/Youtube.js | 602 +++++--- src/player/Player.js | 1162 +++++++++++---- src/player/Song.js | 30 +- src/player/SongCheck.js | 122 +- src/playlists/Google/OAuth2.js | 420 +++++- src/playlists/Google/TokenStore.js | 76 + src/playlists/Google/Utils.js | 171 +++ src/playlists/Google/YoutubeList.js | 265 +++- src/playlists/History.js | 37 +- src/playlists/Playlist.js | 15 +- src/playlists/PlaylistManager.js | 437 ++++-- src/server/Admin.js | 2072 +++++++++++++++++++++++++++ src/server/Server.js | 950 ++++++------ src/server/auth/DiscordAuth.js | 206 +-- src/server/auth/Session.js | 57 +- src/server/auth/User.js | 282 +++- src/utils/Database/Configuration.js | 196 ++- src/utils/Database/Database.js | 146 +- src/utils/GlobalVars.js | 12 +- src/utils/Limiter.js | 187 +++ src/utils/LogReader.js | 435 ++++++ src/utils/Maintenance.js | 97 +- src/utils/SafeMetric.js | 263 ++++ src/utils/TimeConverter.js | 90 +- src/utils/WarpProxy.js | 306 +++- src/youtube/check.js | 206 +++ src/youtube/cookies.js | 563 ++++++++ src/youtube/diagnostics.js | 110 ++ src/youtube/errors.js | 147 ++ src/youtube/index.js | 91 ++ src/youtube/plan.js | 150 ++ src/youtube/settings.js | 219 +++ src/youtube/util.js | 97 ++ src/youtube/ytdlp.js | 457 ++++++ tests/dev/.gitignore | 1 + tests/dev/README.md | 10 + tests/dev/_setup.js | 46 + tests/dev/admin-config.test.js | 356 +++++ tests/dev/admin-youtube.test.js | 247 ++++ tests/dev/avatar.live.test.js | 22 + tests/dev/check.e2e.js | 75 + tests/dev/cookies.test.js | 183 +++ tests/dev/errors.test.js | 113 ++ tests/dev/google-flow.test.js | 257 ++++ tests/dev/google-utils.test.js | 84 ++ tests/dev/limiter.test.js | 120 ++ tests/dev/metadata.live.test.js | 107 ++ tests/dev/plan.test.js | 113 ++ tests/dev/server-socket.test.js | 227 +++ tests/dev/stream.e2e.js | 155 ++ tests/dev/stream.edge.js | 55 + tests/dev/undef-check.js | 20 + tests/dev/update.check.js | 5 + 85 files changed, 13065 insertions(+), 2107 deletions(-) create mode 100644 .dockerignore create mode 100644 docker-compose.dev.yml create mode 100644 src/playlists/Google/TokenStore.js create mode 100644 src/playlists/Google/Utils.js create mode 100644 src/server/Admin.js create mode 100644 src/utils/Limiter.js create mode 100644 src/utils/LogReader.js create mode 100644 src/utils/SafeMetric.js create mode 100644 src/youtube/check.js create mode 100644 src/youtube/cookies.js create mode 100644 src/youtube/diagnostics.js create mode 100644 src/youtube/errors.js create mode 100644 src/youtube/index.js create mode 100644 src/youtube/plan.js create mode 100644 src/youtube/settings.js create mode 100644 src/youtube/util.js create mode 100644 src/youtube/ytdlp.js create mode 100644 tests/dev/.gitignore create mode 100644 tests/dev/README.md create mode 100644 tests/dev/_setup.js create mode 100644 tests/dev/admin-config.test.js create mode 100644 tests/dev/admin-youtube.test.js create mode 100644 tests/dev/avatar.live.test.js create mode 100644 tests/dev/check.e2e.js create mode 100644 tests/dev/cookies.test.js create mode 100644 tests/dev/errors.test.js create mode 100644 tests/dev/google-flow.test.js create mode 100644 tests/dev/google-utils.test.js create mode 100644 tests/dev/limiter.test.js create mode 100644 tests/dev/metadata.live.test.js create mode 100644 tests/dev/plan.test.js create mode 100644 tests/dev/server-socket.test.js create mode 100644 tests/dev/stream.e2e.js create mode 100644 tests/dev/stream.edge.js create mode 100644 tests/dev/undef-check.js create mode 100644 tests/dev/update.check.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df0f667 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile index a21e13e..eb23dd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +# (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"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4a97d58 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 9043093..2829441 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index 470f3d6..88f4599 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/discord/Activity.js b/src/discord/Activity.js index c3d957b..630514e 100644 --- a/src/discord/Activity.js +++ b/src/discord/Activity.js @@ -2,30 +2,76 @@ const bot = require('./Bot') const dlog = require('loguix').getInstance('Discord') const {ActivityType} = require('discord.js') +// Regroupement des mises à jour de présence : pas de clignotement entre deux morceaux, pas de rate limit +const REFRESH_DELAY = 1500 +// Statuts où le bot "écoute" quelque chose +const ACTIVE_STATUS = ["playing", "loading", "stalled"] + +let refreshTimer = null +// Dernière présence envoyée : on n'appelle Discord que si elle change +let lastActivity = null + function getActivity() { const client = bot.getClient() return client.user.presence.activities[0] } +function applyActivity(text) { + const client = bot.getClient() + if(!client?.user) return false + if(text === lastActivity) return true + try { + client.user.setActivity(text, { + type: ActivityType.Listening + }); + lastActivity = text + dlog.log(`Activité mise à jour : ${text}`) + return true + } catch(e) { + dlog.error(`Impossible de mettre à jour l'activité : ${e?.message || e}`) + return false + } +} + // Set All type of activities -function setMusicActivity(songName, artistName, imageUrl) { - const client = bot.getClient() - client.user.setActivity(`${songName} - ${artistName}`,{ - type: ActivityType.Listening, - url: imageUrl - }); - dlog.log(`Activité mise à jour : ${songName} - ${artistName}`) +function setMusicActivity(songName, artistName) { + applyActivity(`${songName} - ${artistName}`) } function idleActivity() { - const client = bot.getClient() - client.user.setActivity("le silence absolu", { - type: ActivityType.Listening - - }); - dlog.log(`Activité mise à jour : rien`) + applyActivity("le silence absolu") +} + +/** + * Calcule la présence à partir de TOUS les players (cohérente en multi-serveur) + */ +function computeActivity() { + const { getAllPlayers } = require('../player/Player') + const active = getAllPlayers().filter(player => ACTIVE_STATUS.includes(player.status) && player.queue?.current) + if(active.length === 0) return "le silence absolu" + if(active.length === 1) { + const song = active[0].queue.current + return `${song.title} - ${song.author}` + } + return `de la musique sur ${active.length} serveurs` +} + +/** + * Demande une mise à jour de la présence (debounce de 1,5 s) + */ +function refresh() { + if(refreshTimer) return + refreshTimer = setTimeout(() => { + refreshTimer = null + try { + applyActivity(computeActivity()) + } catch(e) { + dlog.error(`Erreur lors du calcul de l'activité : ${e?.message || e}`) + } + }, REFRESH_DELAY) + refreshTimer.unref?.() } -module.exports = {getActivity, setMusicActivity, idleActivity} +module.exports = {getActivity, setMusicActivity, idleActivity, refresh} diff --git a/src/discord/Bot.js b/src/discord/Bot.js index 10e814e..2ddb2f9 100644 --- a/src/discord/Bot.js +++ b/src/discord/Bot.js @@ -4,8 +4,8 @@ const path = require("path") const { __glob } = require("../utils/GlobalVars") const { LogType } = require("loguix") const config = require("../utils/Database/Configuration") -const metric = require("webmetrik") -const { Player } = require("../player/Player") +const SafeMetric = require("../utils/SafeMetric") +const { AllPlayers } = require("../player/Player") const {refreshAllUserInformation} = require("../server/auth/User") const serverSettings = require("./ServerSettings") const { Embed, EmbedError } = require("./Embed") @@ -75,7 +75,8 @@ function getGuildRoles(guildId) { } function getChannel(guildId, channelId) { - return client.guilds.cache.get(guildId).channels.cache.get(channelId) + // Guilde quittée ou inconnue : undefined plutôt qu'une TypeError + return client.guilds.cache.get(guildId)?.channels.cache.get(channelId) } function init() { @@ -106,38 +107,53 @@ function init() { if(!interaction.isChatInputCommand()) return; - var numberOfCommands = new metric.Metric("numberOfCommands", "Nombre de commandes éxécutées") - numberOfCommands.setValue(numberOfCommands.getValue() + 1) - var numberOfCommandsServer = new metric.Metric("numberOfCommands_" + interaction.guild.id, "Nombre de commandes éxécutées sur le serveur : " + interaction.guild.name) - numberOfCommandsServer.setValue(numberOfCommandsServer.getValue() + 1) + // Compteurs de commandes (SafeMetric ne lève jamais d'exception) + SafeMetric.increment("numberOfCommands", 1, "Nombre de commandes éxécutées") + if(interaction.guildId) { + SafeMetric.increment("numberOfCommands_" + interaction.guildId, 1, "Nombre de commandes éxécutées sur le serveur : " + (interaction.guild?.name ?? interaction.guildId)) + } - const command = client.commands.get(interaction.commandName) - const roleProtected = await serverSettings.getSecureRole(interaction.guild.id) || false + const command = client.commands?.get(interaction.commandName) + if(!command) { + dlog.warn("Commande inconnue ou pas encore chargée : /" + interaction.commandName) + return + } + var roleProtected = false var havePermission = true; - if(roleProtected) { - await interaction.member.fetch() - if(!interaction.member.roles.cache.has(roleProtected.id)) { - havePermission = false; + try { + roleProtected = interaction.guildId ? (await serverSettings.getSecureRole(interaction.guildId) || false) : false + if(roleProtected) { + await interaction.member.fetch() + if(!interaction.member.roles.cache.has(roleProtected.id)) { + havePermission = false; + } } + } catch(error) { + // Vérification impossible : on refuse par sécurité si un rôle est exigé + dlog.error("Vérification du rôle sécurisé impossible pour /" + interaction.commandName + " : " + (error?.stack || error)) + if(roleProtected) havePermission = false } try { // Create a metric to count the number of commands executed by each user - const userCommand = new metric.Metric("userCommand_" + interaction.member.user.username, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.member.user.username) - userCommand.setValue(userCommand.getValue() + 1) + SafeMetric.increment("userCommand_" + interaction.user.username, 1, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.user.username) dlog.log(interaction.member.user.username + "-> /" + interaction.commandName) if(havePermission) { - command.execute(client, interaction) + // await : les erreurs asynchrones de la commande remontent dans ce try/catch + await command.execute(client, interaction) } else { const embed = new EmbedError(`L'utilisation du Bot est réservée aux membres ayant le rôle "${roleProtected.name}"`, interaction, true) embed.setTitle("Accès refusé") - embed.send() + await embed.send() } } catch(error) { - dlog.error(interaction.member.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE") - dlog.error(error) - interaction.reply({content:"Erreur lors de l'éxécution de la commande !", ephemeral: true}) + dlog.error(interaction.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE") + dlog.error(error?.stack || error) + // Réponse d'erreur selon l'état de l'interaction (déjà différée ou non), sans jamais lever + const errorReply = {content:"Erreur lors de l'éxécution de la commande !", ephemeral: true} + const replyPromise = (interaction.replied || interaction.deferred) ? interaction.editReply(errorReply) : interaction.reply(errorReply) + await Promise.resolve(replyPromise).catch(() => {}) } }) @@ -156,11 +172,13 @@ function init() { // If a new guild is added, we will add it to the guilds map client.on("guildCreate", async (guild) => { - + // Déploiement des commandes slash sur le nouveau serveur (sinon absentes jusqu'au redémarrage) + require("./CommandUpdater").registerGuild(guild.id) await refreshGuilds() glog.log("Guilde ajoutée : " + guild.name + " (" + guild.id + ")") process.emit("USERS_UPDATE") - + process.emit("GUILDS_UPDATE") + }) client.on("guildDelete", (guild) => { @@ -168,11 +186,13 @@ function init() { guilds.delete(guild.id) glog.log("Guilde supprimée : " + guild.name + " (" + guild.id + ")") process.emit("USERS_UPDATE") + process.emit("GUILDS_UPDATE") }) client.on('guildUpdate', async () => { await refreshGuilds() process.emit("USERS_UPDATE") + process.emit("GUILDS_UPDATE") }) client.on("voiceStateUpdate", (oldMember, newMember) => { @@ -181,32 +201,14 @@ function init() { channelId: newMember.channelId, }) - const player = new Player(newMember.guild.id) - - if(player.connection && player.channelId) { - client.channels.fetch(player.channelId).then(channel => { - - if(channel.members.size <= 1) { - - // If the player is alone in the channel, we will destroy it in 10 minutes - // 10 minutes = 600000 ms - // 10 second = 10000 ms - timers.set(newMember.guild.id, setTimeout(() => { - const getPlayer = new Player(newMember.guild.id) - if(getPlayer.connection && player.channelId) { - getPlayer.leave() - dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé : " + channel.name) - } - - }, 600000)) - dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé dans 10 minutess : " + channel.name) - } else { - dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player n'est pas seul dans le channel : " + channel.name) - clearTimeout(timers.get(newMember.guild.id)) - timers.delete(newMember.guild.id) - } - }) - + try { + // Le bot lui-même a changé de salon (déplacement par un modérateur) ou a été expulsé + if(newMember.id === client.user?.id) { + AllPlayers.get(newMember.guild.id)?.onBotVoiceState(newMember) + } + checkAutoLeave(newMember.guild.id) + } catch(error) { + dlog.error("[Automatic Task] Erreur lors de la vérification de l'auto-départ : " + (error?.stack || error)) } process.emit("VOCAL_UPDATE") @@ -218,17 +220,46 @@ function init() { client.login(config.getToken()) } +// Un seul rafraîchissement à la fois : les appels concurrents (arrivées de membres, guildUpdate...) sont regroupés +let refreshing = null +let refreshQueued = false + async function refreshGuilds() { + if(refreshing) { + refreshQueued = true + return refreshing + } + refreshing = (async () => { + do { + refreshQueued = false + try { + await doRefreshGuilds() + } catch(error) { + glog.step.error("d_refresh_guilds", error?.message || error) + dlog.error("Erreur lors du rafraichissement des guildes : " + (error?.stack || error)) + } + } while(refreshQueued) + })().finally(() => { refreshing = null }) + return refreshing +} + +async function doRefreshGuilds() { glog.step.init("d_refresh_guilds", "Rafraichissement des guildes") await client.guilds.fetch() for(const guild of client.guilds.cache.values()) { - await guild.members.fetch() + try { + await guild.members.fetch() + } catch(error) { + // GuildMembersTimeout : on garde le cache actuel plutôt que d'abandonner toutes les guildes + glog.warn("Impossible de récupérer les membres de la guilde : " + guild.name + " (" + guild.id + ") : " + (error?.message || error)) + } var allMembersOfGuild = guild.members.cache.map(member => member.user.id) - const missingPermissions = checkRequiredPermission(guild.members.me) + const missingPermissions = guild.members.me ? checkRequiredPermission(guild.members.me) : [] if(missingPermissions.length > 0) { - dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name) - guild.leave() - return + dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name + " (manquantes : " + missingPermissions.join(", ") + ")") + guild.leave().catch(error => dlog.error("Impossible de quitter la guilde " + guild.name + " : " + (error?.message || error))) + // continue (et non return) : les guildes suivantes doivent quand même être rafraichies + continue } guilds.set(guild.id, { id: guild.id, @@ -258,6 +289,75 @@ function checkRequiredPermission(guildMember) { return requiredPermissions.filter(permission => !guildMember.permissions.has(permission)); } -module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles} +// Départ automatique quand plus aucun humain n'écoute (10 minutes) +const AUTO_LEAVE_DELAY = 10 * 60 * 1000 + +/** + * Nombre de membres non-bots dans le salon vocal du player (cache des voice states, pas de requête) + */ +function countListeners(player) { + if(!player?.channelId) return 0 + const channel = client.channels.cache.get(player.channelId) + if(!channel?.members) return 0 + return channel.members.filter(member => !member.user.bot).size +} + +/** + * Programme ou annule le départ automatique d'une guilde. + * Un seul minuteur par guilde : il n'est jamais écrasé, et il est annulé dès que quelqu'un revient. + */ +function checkAutoLeave(guildId) { + const player = AllPlayers.get(guildId) + if(!player || !player.isConnected() || !player.channelId) { + // Plus de player : rien à surveiller + if(timers.has(guildId)) { + clearTimeout(timers.get(guildId)) + timers.delete(guildId) + } + player?.setAutoLeaveAt(null) + return + } + + if(countListeners(player) > 0) { + if(timers.has(guildId)) { + clearTimeout(timers.get(guildId)) + timers.delete(guildId) + dlog.log("[Automatic Task] Guild Id :" + guildId + " - Départ automatique annulé, quelqu'un écoute dans : " + player.channelName) + } + player.setAutoLeaveAt(null) + return + } + + // Déjà programmé : on ne remplace pas le minuteur existant + if(timers.has(guildId)) return + + const leaveAt = Date.now() + AUTO_LEAVE_DELAY + timers.set(guildId, setTimeout(() => { + timers.delete(guildId) + const current = AllPlayers.get(guildId) + if(!current || !current.isConnected()) return + // Revérification au moment du départ + if(countListeners(current) > 0) { + current.setAutoLeaveAt(null) + return + } + dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé : " + current.channelName) + current.leave("auto") + }, AUTO_LEAVE_DELAY)) + player.setAutoLeaveAt(leaveAt) + dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé dans 10 minutes : " + player.channelName) +} + +/** + * Annule le minuteur d'auto-départ d'une guilde (utilisé quand l'admin fait quitter le serveur au bot) + */ +function clearAutoLeaveTimer(guildId) { + if(typeof timers === "undefined" || !timers.has(guildId)) return false + clearTimeout(timers.get(guildId)) + timers.delete(guildId) + return true +} + +module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles, refreshGuilds, checkRequiredPermission, clearAutoLeaveTimer} diff --git a/src/discord/Command.js b/src/discord/Command.js index cb1fce3..b0ce97d 100644 --- a/src/discord/Command.js +++ b/src/discord/Command.js @@ -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)}} } diff --git a/src/discord/CommandUpdater.js b/src/discord/CommandUpdater.js index 484e55f..8454c56 100644 --- a/src/discord/CommandUpdater.js +++ b/src/discord/CommandUpdater.js @@ -1,53 +1,71 @@ -function init() { - -const bot = require("./Bot") -const client = bot.getClient() const { Collection, REST, Routes } = require("discord.js") const fs = require("node:fs") const path = require("path") const {__glob} = require("../utils/GlobalVars") -const dlog = require("loguix").getInstance("Discord") const config = require("../utils/Database/Configuration") +// Commandes au format JSON (envoyées à Discord) et client REST, initialisés par init() +var commands = null +var rest = null + +function getLog() { + return require("loguix").getInstance("Discord") +} + +function init() { + +const bot = require("./Bot") +const client = bot.getClient() +const dlog = getLog() + client.commands = new Collection() dlog.step.init("d_init", "Initialisation du Bot Discord") dlog.step.init("d_get_commands", "Récupération des commandes en local depuis : " + __glob.COMMANDS) - const commands = []; + commands = []; // Grab all the command files from the commands directory you created earlier const commandsPath = __glob.COMMANDS - const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + // Les commandes de développement ("__DEBUG.js", "__TEST.js"...) ne sont chargées qu'en développement : + // sinon elles seraient déployées sur toutes les guildes, sans contrôle de permission + const devMode = process.env.NODE_ENV === "development" + const commandFiles = fs.readdirSync(commandsPath).filter(file => { + if(!file.endsWith('.js')) return false + if(file.startsWith("__") && !devMode) { + dlog.log("Commande de développement ignorée (NODE_ENV != development) : " + file) + return false + } + return true + }); for (const file of commandFiles) { - const cmd = require(commandsPath + path.sep + file); - client.commands.set(cmd.command.getName(), cmd.command.getData()) + // Une commande invalide ne doit pas empêcher le chargement des autres + try { + const cmd = require(commandsPath + path.sep + file); + client.commands.set(cmd.command.getName(), cmd.command.getData()) - commands.push(cmd.command.getData().data.toJSON()); + commands.push(cmd.command.getData().data.toJSON()); + } catch (error) { + dlog.error("Impossible de charger la commande '" + file + "' : " + (error?.stack || error)) + } } - + dlog.step.end("d_get_commands") - const rest = new REST().setToken(config.getToken()); - + rest = new REST().setToken(config.getToken()); + if(commands.length != 0) { (async () => { try { - - + + const guilds = client.guilds.cache.map(guild => guild.id); dlog.step.init("d_commands_refresh", `Refreshing ${guilds.length} guilds (/) commands.`); for (const guildId of guilds) { - const data = await rest.put( - Routes.applicationGuildCommands(client.user.id, guildId), - { body: commands }, - ); - - // Log the guilds where the commands have been refreshed with number of commands - dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId).name}"`); - + // Une guilde en erreur (permissions, guilde quittée…) ne bloque pas les suivantes + await registerGuild(guildId) } dlog.step.end("d_commands_refresh") @@ -56,8 +74,8 @@ client.commands = new Collection() dlog.error(error) } - - + + })(); } else { @@ -71,4 +89,34 @@ client.commands = new Collection() } -module.exports = {init} \ No newline at end of file +/** + * 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} 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} diff --git a/src/discord/Commands/Leave.js b/src/discord/Commands/Leave.js index 737846f..daef8ca 100644 --- a/src/discord/Commands/Leave.js +++ b/src/discord/Commands/Leave.js @@ -1,34 +1,28 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const {Player, AllPlayers} = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") -const command = new Command("leave", "Quitter le salon vocal", (client, interaction) => { +const command = new Command("leave", "Quitter le salon vocal", async (client, interaction) => { if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour arrêter le bot !", interaction) const channel = interaction.member.voice.channel var embed = new Embed(interaction) - - if(AllPlayers.has(channel.guildId)) { - const player = AllPlayers.get(channel.guildId) - if(!player?.connected) { - return embed.returnError("Le bot n'est pas connecté à ce salon vocal") - } - player.leave() - - embed.setColor(200, 20, 20) - embed.setTitle('**Déconnexion**') - embed.setDescription('Déconnexion du salon vocal') - embed.setThumbnail("https://www.iconsdb.com/icons/download/white/phone-51-64.png") - - embed.send() - - } else { - - embed.returnError("Le bot n'est pas connecté à ce salon vocal") + const player = AllPlayers.get(channel.guildId) + if(!player || !player.isConnected()) { + return embed.returnError("Le bot n'est pas connecté à ce salon vocal") } - - + + // Annule aussi un chargement en cours (yt-dlp tué) + await player.leave("command") + + embed.setColor(200, 20, 20) + embed.setTitle('**Déconnexion**') + embed.setDescription('Déconnexion du salon vocal') + embed.setThumbnail("https://www.iconsdb.com/icons/download/white/phone-51-64.png") + + embed.send() + }) module.exports = {command} diff --git a/src/discord/Commands/Media.js b/src/discord/Commands/Media.js index 0d746b8..56b2ecc 100644 --- a/src/discord/Commands/Media.js +++ b/src/discord/Commands/Media.js @@ -1,8 +1,9 @@ const {Command} = require('../Command'); const {Embed, EmbedError} = require('../Embed'); -const { Player } = require('../../player/Player'); +const { getPlayer } = require('../../player/Player'); const { Song } = require('../../player/Song'); -const history = require('../../playlists/History'); +const { checkSong } = require('../../player/SongCheck'); +const history = require('../../playlists/History'); const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => { @@ -20,36 +21,36 @@ const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal const song = new Song() await song.processMedia(media, interaction.user.username) - const player = new Player(channel.guildId) - player.join(channel) + if(!checkSong(song)) return embed.returnError("Ce média ne peut pas être lu") - - + const player = getPlayer(channel.guildId) + if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal") - if(now) { - player.play(song) + if(now) { + // Lecture immédiate : la musique en cours part dans l'historique + await player.playNow(song) embed.setTitle('**Lecture immédiate**') - + } else { - player.add(song) + await player.add(song) embed.setTitle('**Ajout à liste de lecture**') - + } history.addToPersonalHistory(interaction.user.id, song) - + embed.setDescription('**Titre : **' + song.title) embed.addField('**Durée : **', song.readduration) embed.addField("**Artiste : **",song.author) embed.addField('**Demandé par **' + interaction.member.user.username, "") embed.setThumbnail(song.thumbnail) - - + + embed.send() - + }, [{type: "FILE", name: "media", description: "Fichier audio à lire", required: true}, {type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}] ) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Pause.js b/src/discord/Commands/Pause.js index 16b6c69..1d60a17 100644 --- a/src/discord/Commands/Pause.js +++ b/src/discord/Commands/Pause.js @@ -1,44 +1,44 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const { Player } = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") -const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", (client, interaction) => { +const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", async (client, interaction) => { if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour mettre en pause la musique !", interaction) const channel = interaction.member.voice.channel - const player = new Player(channel.guildId) - const result = player.pause() - - var embed = new Embed(interaction) embed.setColor(0x03ff2d) - result.then((pause) => { + // Pas de création de player : le bot doit déjà être connecté + const player = AllPlayers.get(channel.guildId) + if(!player || !player.isConnected()) { + return embed.returnError("Le bot n'est pas connecté") + } - if(pause == "no_music") { - embed.returnError("Il n'y a pas de musique en cours de lecture") - - } else if(pause) { - embed.setTitle('Musique en pause') - embed.setDescription("La musique a été mise en pause") - embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png") - - - } else { - embed.setTitle('Musique reprise') - embed.setDescription("La musique a été reprise") - embed.setThumbnail("https://www.iconsdb.com/icons/download/white/play-64.png") - } + // Résultat réel : {ok, paused, reason} + const result = await player.pause() - embed.send() - }) - - // Réponse en embed + if(!result.ok) { + embed.returnError("Il n'y a pas de musique en cours de lecture") + return + } - + const loading = player.status === "loading" || player.status === "stalled" + + if(result.paused) { + embed.setTitle('Musique en pause') + embed.setDescription(loading ? "La musique sera mise en pause dès le début de la lecture" : "La musique a été mise en pause") + embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png") + } else { + embed.setTitle('Musique reprise') + embed.setDescription(loading ? "La musique sera lue dès qu'elle sera prête" : "La musique a été reprise") + embed.setThumbnail("https://www.iconsdb.com/icons/download/white/play-64.png") + } + + embed.send() }) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Play.js b/src/discord/Commands/Play.js index 645c457..52ce166 100644 --- a/src/discord/Commands/Play.js +++ b/src/discord/Commands/Play.js @@ -1,7 +1,8 @@ const { Command } = require("../Command"); const { Embed, EmbedError } = require("../Embed"); -const { Player } = require("../../player/Player"); +const { getPlayer } = require("../../player/Player"); const Finder = require("../../player/Finder"); +const { checkSong } = require("../../player/SongCheck"); const { Playlist } = require("../../playlists/Playlist"); const spotify = require("../../media/SpotifyInformation"); const history = require("../../playlists/History"); @@ -14,80 +15,72 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans const channel = interaction.member.voice.channel const now = interaction.options.getBoolean("now") || false const embed = new Embed(interaction) - await Finder.search(url.value).then(async (song) => { - if(!song) return embed.returnError("Impossible de trouver la musique à partir du lien donné ou des mots clés donnés") - - const player = new Player(channel.guildId) - player.join(channel) - - - embed.setColor(0x15e6ed) - - // Check if song is playlist - if(song instanceof Playlist) { - - embed.setDescription('**Playlist : **' + song.songs.length + ' musiques') - embed.addField('**Titre : **' + song.title, "") - embed.addField('**Demandé par : **', interaction.member.user.username,) - embed.addField('**Auteur : **', song.author) - embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase())) - if(!song.type == "spotify") { - embed.addField('**Durée : **', song.readduration) - } - embed.addField('**Lien : **', song.url) - embed.addField(":warning: La récupération des musiques peut prendre du temps", "Veuillez patienter ... et éviter de lancer d'autres commandes") - - embed.setThumbnail(song.thumbnail) - - } else { - - embed.setDescription('**Titre : **' + song.title) + let song = await Finder.search(url.value) + if(!song) return embed.returnError("Impossible de trouver la musique à partir du lien donné ou des mots clés donnés") + if(!(song instanceof Playlist) && !checkSong(song)) return embed.returnError("Cette musique ne peut pas être lue (source non prise en charge)") + + const player = getPlayer(channel.guildId) + if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal") + + embed.setColor(0x15e6ed) + + // Check if song is playlist + if(song instanceof Playlist) { + + embed.setDescription('**Playlist : **' + song.songs.length + ' musiques') + embed.addField('**Titre : **' + song.title, "") + embed.addField('**Demandé par : **', interaction.member.user.username,) + embed.addField('**Auteur : **', song.author) + embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase())) + if(song.type != "spotify") { embed.addField('**Durée : **', song.readduration) - embed.addField("**Artiste : **",song.author) - embed.addField('**Demandé par **' + interaction.member.user.username, "") - embed.addField("**Lien :** ", song.url) - embed.setThumbnail(song.thumbnail) - - } + embed.addField('**Lien : **', song.url) + embed.addField(":warning: La récupération des musiques peut prendre du temps", "Veuillez patienter ... et éviter de lancer d'autres commandes") - if(now) { - embed.setTitle("Lecture immédiate") - } else { - embed.setTitle("Ajoutée à la file d'attente") - } + embed.setThumbnail(song.thumbnail) + + } else { + + embed.setDescription('**Titre : **' + song.title) + embed.addField('**Durée : **', song.readduration) + embed.addField("**Artiste : **",song.author) + embed.addField('**Demandé par **' + interaction.member.user.username, "") + embed.addField("**Lien :** ", song.url) + embed.setThumbnail(song.thumbnail) + + + } + + if(now) { + embed.setTitle("Lecture immédiate") + } else { + embed.setTitle("Ajoutée à la file d'attente") + } + + if(song instanceof Playlist) { embed.send() - - if(song instanceof Playlist) { - if(song.type == "spotify") { - song = await spotify.getTracks(song) - } - player.readPlaylist(song, now) - } else { - - - if(now) { - - player.play(song) - - - } else { - player.add(song) - } - - history.addToPersonalHistory(interaction.user.id, song) - + if(song.type == "spotify") { + song = await spotify.getTracks(song) } + await player.readPlaylist(song, now) + return + } + + // Lecture immédiate : la musique en cours part dans l'historique ; sinon ajout à la file + if(now) { + await player.playNow(song) + } else { + await player.add(song) + } + embed.send() + + history.addToPersonalHistory(interaction.user.id, song) - - - - }) - }, [{type: "STRING", name: "url", description: "Recherche / Lien audio (Youtube / Soundclound / Spotify)", required: true}, {type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}] ) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Previous.js b/src/discord/Commands/Previous.js index 9538891..6ca62f5 100644 --- a/src/discord/Commands/Previous.js +++ b/src/discord/Commands/Previous.js @@ -1,51 +1,36 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const { Player, AllPlayers } = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") -const command = new Command("previous", "Passe à la musique précédente", (client, interaction) => { +const command = new Command("previous", "Passe à la musique précédente", async (client, interaction) => { - if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction) + if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique précédente !", interaction) const channel = interaction.member.voice.channel var embed = new Embed(interaction) - if(AllPlayers.has(channel.guildId)) { - - const player = new Player(channel.guildId) - const result = player.previous() - - - - embed.setColor(0x15e6ed) - - result.then((song) => { - - if(song == "no_music") { - embed.returnError("Il n'y a pas de musique précédemment jouée") - - } else if(song) { - - // Result is a song - - - embed.setTitle('**Musique précédente !**') - embed.setDescription('**Titre : **' + song.title) - embed.addField('**Durée : **'+ song.readduration, "") - embed.addField("**Artiste : **" + song.author, "") - embed.setThumbnail(song.thumbnail) - - embed.send() - } - - - }) - - } else { - return embed.returnError("Le bot n'est pas connecté", interaction) + // Pas de création de player : le bot doit déjà être connecté + const player = AllPlayers.get(channel.guildId) + if(!player || !player.isConnected()) { + return embed.returnError("Le bot n'est pas connecté") } - + const song = await player.previous() + + // Réponse envoyée dans tous les cas (sinon l'interaction expire) + if(song == "no_music" || !song) { + return embed.returnError("Il n'y a pas de musique précédemment jouée") + } + + embed.setColor(0x15e6ed) + embed.setTitle('**Musique précédente !**') + embed.setDescription('**Titre : **' + song.title) + embed.addField('**Durée : **'+ song.readduration, "") + embed.addField("**Artiste : **" + song.author, "") + embed.setThumbnail(song.thumbnail) + + embed.send() }) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Queue.js b/src/discord/Commands/Queue.js index f493280..4433820 100644 --- a/src/discord/Commands/Queue.js +++ b/src/discord/Commands/Queue.js @@ -1,43 +1,39 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const { Player, AllPlayers } = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") + +// Nombre de musiques détaillées dans l'embed +const MAX_LISTED = 10 const command = new Command("liste", "Affiche la file d'attente", (client, interaction) => { - + + if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la file d'attente !", interaction) + const channel = interaction.member.voice.channel var embed = new Embed(interaction) - - if(AllPlayers.has(channel.guildId)) { - - const player = new Player(channel.guildId) - const queue = player.queue.getNext() - - - - if(queue.length == 0) { - embed.returnError("Il n'y a pas de musique en file d'attente") - - } else if(queue.length > 0) { - - // Result is a song - embed.setColor(0x15e6ed) - embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png") - embed.setTitle('**File d\'attente :**') - embed.setDescription('**' + queue.length + ' musiques**') - queue.forEach((song, index) => { - // max 24 fields - if(index > 10) return - embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`) - }) - embed.send() - } - - - - } else { - embed.returnError("Le bot n'est pas connecté") + const player = AllPlayers.get(channel.guildId) + if(!player || !player.isConnected()) { + return embed.returnError("Le bot n'est pas connecté") } + + const queue = player.queue.getNext() + + if(queue.length == 0) { + return embed.returnError("Il n'y a pas de musique en file d'attente") + } + + embed.setColor(0x15e6ed) + embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png") + embed.setTitle('**File d\'attente :**') + embed.setDescription('**' + queue.length + ' musiques**') + queue.slice(0, MAX_LISTED).forEach((song, index) => { + embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`) + }) + if(queue.length > MAX_LISTED) { + embed.addField(`... et ${queue.length - MAX_LISTED} autre(s)`, "") + } + embed.send() }) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Restart.js b/src/discord/Commands/Restart.js index 76f6ee0..7eccc58 100644 --- a/src/discord/Commands/Restart.js +++ b/src/discord/Commands/Restart.js @@ -1,27 +1,39 @@ const {Embed} = require("../Embed") const {Command} = require("../Command") -const {restart} = require("../../utils/Maintenance") -const users = require("../../server/auth/User") +const {restart, isRestarting} = require("../../utils/Maintenance") +const users = require("../../server/auth/User") +const { LogType } = require("loguix") +const clog = new LogType("Maintenance") // Nécéssite une raison pour redémarrer le bot const command = new Command("restart", "Redémarre le bot", (client, interaction) => { - // Check if user is admin from users list + // Check if user is admin from users list const user = users.getUserById(interaction.user.id) - if(!user || !user.isAdmin()) { + if(!user || !user.isAdmin() || user.isFullBanned()) { interaction.reply({content: "Vous n'êtes pas admin", ephemeral: true}) return } - const reason = interaction.options.getString("reason") - restart(reason) + const reason = (interaction.options.getString("reason") || "").trim().slice(0, 200) + if(!reason) { + interaction.reply({content: "Une raison est nécessaire pour redémarrer le bot", ephemeral: true}) + return + } + if(isRestarting()) { + interaction.reply({content: "Un redémarrage est déjà en cours", ephemeral: true}) + return + } + clog.warn(`[ADMIN] ${interaction.user.username} -> redémarrage depuis Discord : ${reason}`) + // On répond d'abord, le redémarrage effectif a lieu après un court délai (voir Maintenance.restart) const embed = new Embed(interaction) embed.setColor(150, 20, 20) embed.setTitle('Redémarrage') embed.setDescription("Veuillez patientez, le bot va redémarrer dans un instant ! :arrows_counterclockwise:") embed.addField('Raison', reason) embed.send() + restart(reason) }, [{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}] ) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/Skip.js b/src/discord/Commands/Skip.js index b5556a9..101e72f 100644 --- a/src/discord/Commands/Skip.js +++ b/src/discord/Commands/Skip.js @@ -1,48 +1,36 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const { Player, AllPlayers } = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") + +const command = new Command("skip", "Passe à la musique suivante", async (client, interaction) => { -const command = new Command("skip", "Passe à la musique suivante", (client, interaction) => { - if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction) const channel = interaction.member.voice.channel var embed = new Embed(interaction) - if(AllPlayers.has(channel.guildId)) { - - const player = new Player(channel.guildId) - const result = player.skip() - - embed.setColor(0x15e6ed) - result.then((song) => { - - if(song == "no_music") { - embed.returnError("Il n'y a pas de musique en file d'attente", interaction) - - } else if(song) { - - // Result is a song - embed.setColor(0x15e6ed) - - embed.setTitle('**Musique suivante !**') - embed.setDescription('**Titre : **' + song.title) - embed.addField('**Durée : **'+ song.readduration, "") - embed.addField("**Artiste : **" + song.author, "") - embed.setThumbnail(song.thumbnail) - - - } - - embed.send() - }) - - } else { - return embed.returnError("Le bot n'est pas connecté", interaction) + // Pas de création de player : le bot doit déjà être connecté + const player = AllPlayers.get(channel.guildId) + if(!player || !player.isConnected()) { + return embed.returnError("Le bot n'est pas connecté") } - + const song = await player.skip() + + if(song == "no_music" || !song) { + return embed.returnError("Il n'y a pas de musique en file d'attente") + } + + // Le chargement démarre : l'état (chargement / erreur) est visible sur le site + embed.setColor(0x15e6ed) + embed.setTitle('**Musique suivante !**') + embed.setDescription('**Titre : **' + song.title) + embed.addField('**Durée : **'+ song.readduration, "") + embed.addField("**Artiste : **" + song.author, "") + embed.setThumbnail(song.thumbnail) + + embed.send() }) -module.exports = {command} \ No newline at end of file +module.exports = {command} diff --git a/src/discord/Commands/State.js b/src/discord/Commands/State.js index 05daaad..eb463c4 100644 --- a/src/discord/Commands/State.js +++ b/src/discord/Commands/State.js @@ -1,34 +1,45 @@ const {Command} = require("../Command") const {Embed, EmbedError} = require("../Embed") -const {Player} = require("../../player/Player") +const { AllPlayers } = require("../../player/Player") +const { getReadableDuration } = require("../../utils/TimeConverter") + +// Libellés des statuts du player +const STATUS_LABELS = { + loading: "Chargement…", + playing: "En lecture", + paused: "En pause", + stalled: "Connexion vocale instable", + idle: "Inactif", + offline: "Déconnecté", +} const command = new Command("state", "Affiche la musique en cours", (client, interaction) => { - + + if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la musique en cours !", interaction) + const channel = interaction.member.voice.channel - const player = new Player(channel.guildId) - const song = player.queue.getCurrent() - - - - - if(!song) { - var embed = new EmbedError("Il n'y a pas de musique en cours de lecture", interaction) - - } else if(song) { - var embed = new Embed(interaction) - // Result is a song - embed.setColor(0x15e6ed) - - embed.setTitle('**Musique en cours :**') - embed.setDescription('**Titre : **' + song.title) - embed.addField('**Durée : **', song.readduration) - embed.addField("**Artiste : **", song.author) - embed.setThumbnail(song.thumbnail) - - embed.send() - } - - + // Pas de création de player : simple lecture de l'état + const player = AllPlayers.get(channel.guildId) + const song = player?.queue?.getCurrent() + + if(!song) { + return new EmbedError("Il n'y a pas de musique en cours de lecture", interaction) + } + + var embed = new Embed(interaction) + embed.setColor(0x15e6ed) + + embed.setTitle('**Musique en cours :**') + embed.setDescription('**Titre : **' + song.title) + const durationMs = player.getDurationMs() + const position = getReadableDuration(Math.floor(player.getPositionMs() / 1000)) + embed.addField('**Position : **', durationMs ? `${position} / ${song.readduration}` : `${position} (durée inconnue / LIVE)`) + embed.addField('**Statut : **', STATUS_LABELS[player.status] ?? player.status) + embed.addField("**Artiste : **", song.author) + embed.setThumbnail(song.thumbnail) + + embed.send() + }) - + module.exports = {command} diff --git a/src/discord/Embed.js b/src/discord/Embed.js index ee5d460..1a61507 100644 --- a/src/discord/Embed.js +++ b/src/discord/Embed.js @@ -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} 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) { diff --git a/src/discord/MediaBase.js b/src/discord/MediaBase.js index bca270c..8b8b65f 100644 --- a/src/discord/MediaBase.js +++ b/src/discord/MediaBase.js @@ -23,32 +23,34 @@ if(!config.getMediaGuildId() || !config.getMediaChannelId()) { var channel = null -discordBot.getClient().on("ready", () => { +discordBot.getClient().on("ready", async () => { try { channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId()) } catch (e) { } - if(!channel) { - wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.") - wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.") + if(!channel || typeof channel.send !== "function") { + channel = null + wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.") + wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.") + connected = false + return } - + // Le canal existe : la base est utilisable (comme avant), le message de démarrage n'est qu'informatif + connected = true + wlog.step.end("init_db") try { const dateTime = new Date() const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' }) const time = dateTime.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' }) const message = `[LOGS] La base de données multimédia a été initialisée le ${date} à ${time}` - channel.send(message) + // await : un refus de Discord (permissions manquantes…) ne doit pas devenir un rejet non géré + await channel.send(message) wlog.log("La base de données multimédia a été initialisée avec succès.") - wlog.step.end("init_db") - connected = true - } catch (e) { - wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.") - wlog.step.error("init_db","Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.") - connected = false + } catch (e) { + wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e)) } }) diff --git a/src/discord/ReportSender.js b/src/discord/ReportSender.js index 42036f9..2a93140 100644 --- a/src/discord/ReportSender.js +++ b/src/discord/ReportSender.js @@ -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} true si le rapport a été envoyé + */ async send() { if(!this.report_channel || this.report_channel == "") { log.getInstance("Discord").error("Pas de channel de rapport configuré") return false } else { - const channel = await this.client.channels.fetch(this.report_channel) - channel.send({embeds: [this.embed.build()]}) - if(this.report_contact && this.report_contact != "") channel.send({content: "<@" + this.report_contact + ">"}) - return true + try { + const channel = await this.client.channels.fetch(this.report_channel) + if(!channel || typeof channel.send !== "function") { + log.getInstance("Discord").error("Le channel de rapport est introuvable ou n'accepte pas de messages : " + this.report_channel) + return false + } + await channel.send({embeds: [this.embed.build()]}) + if(this.report_contact && this.report_contact != "") await channel.send({content: "<@" + this.report_contact + ">"}) + return true + } catch (error) { + log.getInstance("Discord").error("Impossible d'envoyer le rapport : " + (error?.message || error)) + return false + } } - - - } diff --git a/src/discord/ServerSettings.js b/src/discord/ServerSettings.js index bd1a8c6..6426cba 100644 --- a/src/discord/ServerSettings.js +++ b/src/discord/ServerSettings.js @@ -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 = { diff --git a/src/lyrics/Lyrics.js b/src/lyrics/Lyrics.js index 981ae66..ce2cd3d 100644 --- a/src/lyrics/Lyrics.js +++ b/src/lyrics/Lyrics.js @@ -2,6 +2,9 @@ const { LogType } = require("loguix"); const plog = new LogType('Lyrics'); const urls = require('./urls.json'); +// Délai maximal de chaque requête : sans réponse du fournisseur, /PLAYER/LYRICS restait en attente indéfiniment +const REQUEST_TIMEOUT = 8000 + // Make sure Url exists and get lyrics for the first item only async function getLyrics(name) { let result = null; @@ -10,7 +13,8 @@ async function getLyrics(name) { method: 'GET', headers: { 'content-type': 'application/json' - } + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT) }); const searchData = await searchResponse.json(); @@ -26,18 +30,18 @@ async function getLyrics(name) { method: 'GET', headers: { 'content-type': 'application/json' - } + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT) }); const lyricsData = await lyricsResponse.json(); - console.log(lyricsData); - if (lyricsData && lyricsData && lyricsData.lyrics) { + if (lyricsData && lyricsData.lyrics) { result = lyricsData.lyrics; } else { - plog.error('Invalid response structure:', lyricsData); + plog.error('Invalid response structure: ' + JSON.stringify(lyricsData).slice(0, 200)); return null; } } catch (error) { - plog.error('Error fetching lyrics data:', error); + plog.error('Error fetching lyrics data: ' + (error?.message || error)); return null; } } else { @@ -49,10 +53,10 @@ async function getLyrics(name) { return null; } } catch (error) { - plog.error('Error fetching search data:', error); + plog.error('Error fetching search data: ' + (error?.message || error)); return null; } return result; } -module.exports = { getLyrics }; \ No newline at end of file +module.exports = { getLyrics }; diff --git a/src/main.js b/src/main.js index f3df33b..a3510ff 100644 --- a/src/main.js +++ b/src/main.js @@ -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)) } \ No newline at end of file diff --git a/src/media/MediaInformation.js b/src/media/MediaInformation.js index b8b4466..959287d 100644 --- a/src/media/MediaInformation.js +++ b/src/media/MediaInformation.js @@ -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"; diff --git a/src/media/SoundcloudInformation.js b/src/media/SoundcloudInformation.js index 1643005..f703e75 100644 --- a/src/media/SoundcloudInformation.js +++ b/src/media/SoundcloudInformation.js @@ -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); } diff --git a/src/media/SpotifyInformation.js b/src/media/SpotifyInformation.js index c5337e7..07bcb4f 100644 --- a/src/media/SpotifyInformation.js +++ b/src/media/SpotifyInformation.js @@ -1,26 +1,107 @@ -const {LogType} = require('loguix'); +const {LogType} = require('loguix'); const clog = new LogType("SpotifyInformation"); const config = require('../utils/Database/Configuration'); -const SPOTIFY_CLIENT_ID = config.getSpotifyClientId() -const SPOTIFY_CLIENT_SECRET = config.getSpotifyClientSecret() const SpotifyWebApi = require('spotify-web-api-node'); const {Playlist} = require('../playlists/Playlist'); const {Song} = require('../player/Song'); const youtube = require("../media/YoutubeInformation"); const {getReadableDuration} = require('../utils/TimeConverter'); +const {toSafeDuration} = require('../utils/SafeMetric'); +const {mapLimit} = require('../utils/Limiter'); -const spotifyApi = new SpotifyWebApi({ - clientId: SPOTIFY_CLIENT_ID, - clientSecret: SPOTIFY_CLIENT_SECRET, -}); +const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png"; +// Chaque titre déclenche une recherche YouTube : on borne la taille des imports +const MAX_TRACKS = 500; +// Conversions Spotify → YouTube simultanées (le limiteur YouTube partagé reste le plafond global) +const CONVERSION_CONCURRENCY = 3; + +// Identifiants lus au moment de demander un jeton : une modification depuis l'admin s'applique sans redémarrage +const spotifyApi = new SpotifyWebApi(); + +// Le jeton client est valable une heure : on le garde en cache au lieu d'en redemander un à chaque requête +let tokenExpiresAt = 0; +// Identifiants ayant servi à obtenir le jeton courant (détecte un changement de configuration) +let tokenCredentials = null; +// Demande de jeton en cours, partagée par les appels simultanés +let tokenPromise = null; + +function resetToken() { + tokenExpiresAt = 0; + tokenCredentials = null; + spotifyApi.resetAccessToken(); +} + +// Configuration modifiée (admin) : le prochain appel redemandera un jeton avec les nouveaux identifiants +process.on("CONFIG_UPDATE", resetToken); + +async function ensureToken() { + const clientId = config.getSpotifyClientId(); + const clientSecret = config.getSpotifyClientSecret(); + if(!clientId || !clientSecret) { + throw new Error("Identifiants Spotify non configurés (api.spotify.clientId / clientSecret)"); + } + const credentials = clientId + ":" + clientSecret; + if(tokenCredentials !== null && tokenCredentials !== credentials) resetToken(); + if(Date.now() < tokenExpiresAt) return; + + if(!tokenPromise) { + tokenPromise = (async () => { + spotifyApi.setClientId(clientId); + spotifyApi.setClientSecret(clientSecret); + const data = await spotifyApi.clientCredentialsGrant(); + spotifyApi.setAccessToken(data.body['access_token']); + const expiresIn = Number(data.body['expires_in']) || 3600; + // Marge d'une minute avant l'expiration réelle + tokenExpiresAt = Date.now() + Math.max(0, expiresIn - 60) * 1000; + tokenCredentials = credentials; + })().finally(() => { tokenPromise = null; }); + } + await tokenPromise; +} + +/** + * Un titre Spotify est exploitable s'il a un nom et un artiste. + * Les titres supprimés ou indisponibles valent null, les épisodes de podcast n'ont pas d'artistes. + */ +function isValidTrack(track) { + return !!track && typeof track.name === "string" && track.name.length > 0 + && Array.isArray(track.artists) && !!track.artists[0]?.name; +} + +/** + * Récupère les pages suivantes d'une liste paginée Spotify (playlist ou album) + * @param {Object} firstPage Première page ({items, next, total}) + * @param {(offset: number) => Promise} fetchPage Renvoie la page commençant à offset + * @param {(item: Object) => Object} mapItem Transforme un élément de page en titre + */ +async function collectAllTracks(firstPage, fetchPage, mapItem) { + const tracks = []; + let page = firstPage; + while (page && Array.isArray(page.items)) { + for (const item of page.items) { + if (tracks.length >= MAX_TRACKS) break; + tracks.push(mapItem(item)); + } + if (!page.next || tracks.length >= MAX_TRACKS || page.items.length === 0) break; + try { + page = await fetchPage((page.offset ?? 0) + page.items.length); + } catch (error) { + clog.warn("Impossible de récupérer la suite des titres Spotify, import partiel : " + error); + break; + } + } + if (tracks.length >= MAX_TRACKS && page?.total > MAX_TRACKS) { + clog.warn(`Import Spotify limité aux ${MAX_TRACKS} premiers titres (sur ${page.total})`); + } + return tracks; +} async function getSong(url) { try { - const data = await spotifyApi.clientCredentialsGrant(); - spotifyApi.setAccessToken(data.body['access_token']); + await ensureToken(); const parts = url.split('/'); - const trackId = parts[parts.length - 1]; + const trackId = parts[parts.length - 1]?.split('?')[0]; if(!trackId) { clog.error("Impossible de récupérer l'identifiant de la piste Spotify à partir de l'URL"); @@ -28,12 +109,17 @@ async function getSong(url) { } const trackInfo = await spotifyApi.getTrack(trackId); + if(!isValidTrack(trackInfo?.body)) { + clog.error("La piste Spotify est indisponible ou incomplète : " + trackId); + return null; + } + const trackName = trackInfo.body.name; const artistName = trackInfo.body.artists[0].name; return `${trackName} - ${artistName}`; } catch (error) { - + clog.error("Impossible de récupérer les informations de la piste Spotify à partir de l'URL"); clog.error(error); return null; @@ -43,10 +129,8 @@ async function getSong(url) { async function getAlbum(url) { try { - - const creditdata = await spotifyApi.clientCredentialsGrant(); - spotifyApi.setAccessToken(creditdata.body['access_token']); + await ensureToken(); const parts = url.split('/'); const albumId = parts[parts.indexOf('album') + 1].split('?')[0]; @@ -59,42 +143,46 @@ async function getAlbum(url) { return null; } - clog.log("Informations de l'album récupérées : " + info.name); + clog.log("Informations de l'album récupérées : " + info.name); - const playlist = new Playlist() + const playlist = new Playlist() playlist.title = info.name; - playlist.author = info.artists[0].name; - playlist.authorId = info.artists[0].id; - playlist.thumbnail = info.images[0].url; - playlist.url = info.external_urls.spotify; + playlist.author = info.artists?.[0]?.name ?? "Auteur inconnu"; + playlist.authorId = info.artists?.[0]?.id; + playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL; + playlist.url = info.external_urls?.spotify; playlist.id = albumId; playlist.type = "spotify"; - playlist.songs = info.tracks.items; + // Un album ne renvoie que ses 50 premiers titres : on récupère les pages suivantes + playlist.songs = await collectAllTracks( + info.tracks, + async (offset) => (await spotifyApi.getAlbumTracks(albumId, { offset, limit: 50 })).body, + (item) => item + ); return playlist; } catch (error) { - + clog.error("Impossible de récupérer les informations de l'album Spotify à partir de l'URL"); clog.error(error); return null; } - - + + } async function getPlaylist(url) { // Get the playlist and return a Playlist Object try { - const creditdata = await spotifyApi.clientCredentialsGrant(); - spotifyApi.setAccessToken(creditdata.body['access_token']); + await ensureToken(); const parts = url.split('/'); const playlistId = parts[parts.indexOf('playlist') + 1].split('?')[0]; const data = await spotifyApi.getPlaylist(playlistId) - + const info = data.body; if(!info) { @@ -102,78 +190,112 @@ async function getPlaylist(url) { return null; } - clog.log("Informations de la playlist récupérées : " + info.name); + clog.log("Informations de la playlist récupérées : " + info.name); - const playlist = new Playlist() + const playlist = new Playlist() playlist.title = info.name; - playlist.author = info.owner.display_name; - playlist.authorId = info.owner.id; - playlist.thumbnail = info.images[0].url; - playlist.url = info.external_urls.spotify; + playlist.author = info.owner?.display_name ?? "Auteur inconnu"; + playlist.authorId = info.owner?.id; + playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL; + playlist.url = info.external_urls?.spotify; playlist.id = playlistId; playlist.type = "spotify"; - - for(const track of info.tracks.items) { - playlist.songs.push(track.track); - } + + // Une playlist ne renvoie que ses 100 premiers titres : on récupère les pages suivantes + // (les titres null — supprimés, indisponibles — sont filtrés plus tard par getTracks) + playlist.songs = await collectAllTracks( + info.tracks, + async (offset) => (await spotifyApi.getPlaylistTracks(playlistId, { offset, limit: 100 })).body, + (item) => item?.track ?? null + ); return playlist; - + } catch (error) { - - clog.error("Impossible de récupérer les informations de l'album Spotify à partir de l'URL"); + + clog.error("Impossible de récupérer les informations de la playlist Spotify à partir de l'URL"); clog.error(error); return null; } - + } +/** + * Convertit les titres Spotify d'une playlist en musiques YouTube jouables. + * Modifie la playlist (songs, duration, readduration) et la renvoie, même en cas d'erreur. + * @param {Playlist} playlist + * @returns {Promise} + */ async function getTracks(playlist) { - const tracks = playlist.songs - playlistSongs = []; - for(const track of tracks) { - - var trackName = track.name; - var artistName = track.artists[0].name; - var queryForYoutube = `${trackName} - ${artistName}`; + const tracks = Array.isArray(playlist?.songs) ? playlist.songs : []; - var urlYoutubeFounded = await youtube.getQuery(queryForYoutube).then(function(songFind) { - if(!songFind) return null; - return songFind.url; - }); - - clog.log("URL de la vidéo YouTube trouvée : " + urlYoutubeFounded); + // Concurrence bornée, ordre conservé ; un titre en erreur vaut null au lieu de faire échouer tout l'import + const converted = await mapLimit(tracks, CONVERSION_CONCURRENCY, (track) => convertTrack(track, playlist), (error) => { + clog.error("Erreur lors de la conversion d'un titre Spotify : " + error); + return null; + }); - if(!urlYoutubeFounded) { - clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube); - - } else { - const song = new Song(); - - song.title = track.name; - song.author = track.artists[0].name; - song.url = urlYoutubeFounded; - song.thumbnail = playlist.thumbnail; - song.id = track.id; - song.duration = track.duration_ms / 1000; - song.readduration = getReadableDuration(track.duration_ms / 1000); - song.type = "youtube"; - - playlist.duration += track.duration_ms / 1000; - playlistSongs.push(song); - } - - // When finish do this + // Tableau local : deux imports simultanés ne doivent jamais partager leurs titres + const playlistSongs = converted.filter(Boolean); + const totalDuration = playlistSongs.reduce((acc, song) => acc + toSafeDuration(song.duration), 0); + if(tracks.length > 0) { + clog.log(`Conversion Spotify → YouTube : ${playlistSongs.length}/${tracks.length} titres pour « ${playlist?.title} »`); } - playlist.readduration = getReadableDuration(playlist.duration); + if(playlist) { playlist.songs = playlistSongs; + playlist.duration = totalDuration; + playlist.readduration = getReadableDuration(totalDuration); + } + return playlist; +} - return playlist; +/** + * Convertit un titre Spotify en musique YouTube jouable + * @param {Object} track Titre Spotify (ou musique déjà convertie) + * @param {Playlist} playlist Playlist d'origine (miniature par défaut) + * @returns {Promise} 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} \ No newline at end of file +module.exports = {getSong, getAlbum, getPlaylist, getTracks} diff --git a/src/media/YoutubeInformation.js b/src/media/YoutubeInformation.js index af43f0e..e1f6386 100644 --- a/src/media/YoutubeInformation.js +++ b/src/media/YoutubeInformation.js @@ -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} + */ +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} + */ +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} null si la mise en page n'est pas reconnue + */ +async function fetchPlaylistPage(listId) { + const url = `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}&hl=en&gl=US`; + const html = await youtubeLimiter.run(async () => { + // Page en anglais : textes ("by …", "… views") et titres non traduits, comme yt-search + const res = await fetch(url, { + headers: { ...BROWSER_HEADERS, "Accept-Language": "en-US,en;q=0.9" }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + return res.text(); + }); + const match = html.match(/ytInitialData\s*=\s*({.*?});\s*<\/script>/s); + if (!match) return null; + const data = JSON.parse(match[1]); + + const videos = []; + for (const lockup of findAllByKey(data, "lockupViewModel")) { + if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO" || !lockup.contentId) continue; + const meta = lockup.metadata?.lockupMetadataViewModel; + // Vidéos privées / supprimées : présentes dans la liste mais illisibles + if (/^\[(private|deleted) video\]$/i.test(meta?.title?.content ?? "")) continue; + const authorText = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text; + const badge = findAllByKey(lockup.contentImage, "thumbnailBadgeViewModel")[0]; + const seconds = parseTimestamp(badge?.text); + videos.push({ + title: meta?.title?.content ?? "Aucun titre", + videoId: lockup.contentId, + listId, + thumbnail: `https://i.ytimg.com/vi/${lockup.contentId}/hqdefault.jpg`, + duration: { seconds, timestamp: badge?.text ?? "" }, + author: { name: authorText?.content ?? "Auteur inconnu", url: channelUrlFromRuns(authorText) }, + }); + } + if (videos.length === 0) return null; + + const header = findAllByKey(data, "pageHeaderViewModel")[0]; + const headerRows = header?.metadata?.contentMetadataViewModel?.metadataRows || []; + const ownerPart = headerRows[0]?.metadataParts?.[0]; + const ownerStack = ownerPart?.avatarStack?.avatarStackViewModel; + const ownerText = ownerPart?.text ?? ownerStack?.text; + const viewsText = headerRows.flatMap(row => row.metadataParts || []).map(part => part?.text?.content || "").find(text => /views?$/i.test(text)); + const thumbnails = data.microformat?.microformatDataRenderer?.thumbnail?.thumbnails; + + return { + title: data.metadata?.playlistMetadataRenderer?.title ?? header?.title?.dynamicTextViewModel?.text?.content ?? "Aucun titre", + listId, + url: `https://www.youtube.com/playlist?list=${listId}`, + thumbnail: thumbnails?.[thumbnails.length - 1]?.url ?? videos[0].thumbnail, + views: viewsText ? Number(viewsText.replace(/\D/g, "")) || 0 : 0, + author: { + name: (ownerText?.content ?? "Auteur inconnu").replace(/^by\s+/i, ""), + url: channelUrlFromRuns(ownerText), + }, + authorAvatar: ownerStack?.avatars?.[0]?.avatarViewModel?.image?.sources?.[0]?.url ?? null, + videos, + }; +} + async function getSecondsFromUrl(url) { - const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/); + const videoId = typeof url === "string" ? url.match(VIDEO_ID_REGEX) : null; if (videoId === null) { clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL"); return null; } try { - const video = await yts({ videoId: videoId[1] }); + const video = await fetchVideo(videoId[1]); if (video) { - return video.duration.seconds; + // 0 pour un live ou une durée invalide (null reste réservé à l'échec de récupération) + return toSafeDuration(video.duration?.seconds); } else { clog.error("Impossible de récupérer la vidéo YouTube à partir de l'identifiant"); return null; @@ -129,36 +298,63 @@ async function getSecondsFromUrl(url) { } } +/** + * Extrait l'avatar d'une chaîne depuis le HTML de sa page + * @param {string} html + * @returns {string|null} + */ +function extractAvatar(html) { + // Balise img avec la classe yt-spec-avatar-shape__image + const imgRegex = /]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i; + const imgMatch = html.match(imgRegex); + if (imgMatch && imgMatch[1]) { + return imgMatch[1]; + } + + // Sinon : avatar dans le JSON embarqué + const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/; + const match = html.match(jsonRegex); + if (match && match[1]) { + return match[1].replace(/\\u0026/g, "&"); + } + return null; +} + +/** + * Avatar d'une chaîne YouTube (cache 24 h, échecs retenus 1 h) + * @param {string} channelUrl URL de la chaîne (https://www.youtube.com/...) + * @returns {Promise} + */ async function getYouTubeProfilePicture(channelUrl) { + let parsed; try { - const res = await fetch(channelUrl, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" - } - }); - const html = await res.text(); - - // Match img with yt-spec-avatar-shape__image in class list - const imgRegex = /]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i; - const imgMatch = html.match(imgRegex); - - if (imgMatch && imgMatch[1]) { - return imgMatch[1]; - } - - // Fallback: look for avatar in embedded JSON - const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/; - const match = html.match(jsonRegex); - if (match && match[1]) { - return match[1].replace(/\\u0026/g, "&"); // Decode \u0026 to & - } - - console.warn("Photo non trouvée pour :", channelUrl); - return null; - } catch (err) { - console.error("Erreur :", err); + parsed = new URL(channelUrl); + } catch { return null; } + // Uniquement des pages youtube.com en http(s) (l'URL vient d'un scrape) + if (!/^https?:$/.test(parsed.protocol) || !/(^|\.)youtube\.com$/i.test(parsed.hostname) || parsed.pathname.endsWith("/undefined")) { + return null; + } + + return avatarCache.wrap(parsed.href, async () => { + try { + const html = await youtubeLimiter.run(async () => { + const res = await fetch(parsed.href, { + headers: BROWSER_HEADERS, + signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + return res.text(); + }); + const avatar = extractAvatar(html); + if (!avatar) clog.warn("Photo de profil introuvable pour la chaîne : " + parsed.href); + return avatar; + } catch (error) { + clog.warn("Impossible de récupérer la photo de profil de la chaîne " + parsed.href + " : " + (error?.message || error)); + return null; + } + }, { ttlFor: (value) => value ? 24 * HOUR : AVATAR_FAILURE_TTL }); } module.exports = { getQuery, getVideo, getPlaylist, getSecondsFromUrl }; diff --git a/src/player/Finder.js b/src/player/Finder.js index 2a8f7e6..2e6a395 100644 --- a/src/player/Finder.js +++ b/src/player/Finder.js @@ -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} null si introuvable, ou si aucun titre n'a pu être converti + */ +async function getSpotifyCollection(kind, url, maxTracks = 0) { + const playlist = kind === "album" ? await spotify.getAlbum(url) : await spotify.getPlaylist(url) + if(!playlist) return null + if(maxTracks > 0 && Array.isArray(playlist.songs) && playlist.songs.length > maxTracks) { + playlist.totalTracks = playlist.songs.length + playlist.truncated = true + playlist.songs = playlist.songs.slice(0, maxTracks) + } + const trackCount = Array.isArray(playlist.songs) ? playlist.songs.length : 0 + // getTracks est idempotent : les appelants qui le rappellent ensuite ne relancent aucune recherche + await spotify.getTracks(playlist) + if(trackCount > 0 && playlist.songs.length === 0) { + // Échec total (YouTube indisponible ou bloqué) : on ne renvoie pas une playlist vide + // (un rafraîchissement écraserait sinon la playlist enregistrée) + clog.error(`Aucun titre de « ${playlist.title} » n'a pu être converti depuis Spotify`) + return null + } + return playlist +} async function search(query, multiple, forceType) { if(!query) return null @@ -12,7 +43,9 @@ async function search(query, multiple, forceType) { if(!forceType) forceType = null if(forceType == "PLAYLIST") { if(query.includes("spotify")) { - return await spotify.getPlaylist(query) + // Lien d'album collé dans "playlist" : même conversion + const kind = /\/album\//.test(query) ? "album" : "playlist" + return await getSpotifyCollection(kind, query) } else if(query.includes("soundcloud")) { return await soundcloud.getPlaylist(query) } else if(query.includes("youtube")) { @@ -24,10 +57,15 @@ async function search(query, multiple, forceType) { if(forceType == "SONG") { if(query.includes("spotify")) { - return await spotify.getSong(query) + // getSong renvoie "titre - artiste" : on cherche la musique YouTube correspondante + const spotifyQuery = await spotify.getSong(query) + return spotifyQuery ? await youtube.getQuery(spotifyQuery, multiple) : null } else if(query.includes("soundcloud")) { return await soundcloud.getTrack(query) } else if(query.includes("youtube")) { + if(Resolver.getQueryType(query) == QueryType.YOUTUBE_VIDEO) { + return await youtube.getVideo(query) + } return await youtube.getQuery(query, multiple) } else { return null @@ -36,7 +74,7 @@ async function search(query, multiple, forceType) { if(forceType == "ALBUM") { if(query.includes("spotify")) { - return await spotify.getAlbum(query) + return await getSpotifyCollection("album", query) } else if(query.includes("youtube")) { return await youtube.getQuery(query, multiple) } else { @@ -58,14 +96,17 @@ async function search(query, multiple, forceType) { return await youtube.getPlaylist(query) } if(type == QueryType.SPOTIFY_SONG) { - return await youtube.getQuery(await spotify.getSong(query)) + const spotifyQuery = await spotify.getSong(query) + return spotifyQuery ? await youtube.getQuery(spotifyQuery) : null } + // Recherche (multiple) : simple aperçu ; l'import complet passe par forceType "PLAYLIST" + const preview = multiple ? SEARCH_PREVIEW_TRACKS : 0 if(type == QueryType.SPOTIFY_ALBUM) { - return await spotify.getAlbum(query) + return await getSpotifyCollection("album", query, preview) } if(type == QueryType.SPOTIFY_PLAYLIST) { - return await spotify.getPlaylist(query) + return await getSpotifyCollection("playlist", query, preview) } if(type == QueryType.SOUNDCLOUD_TRACK) { @@ -79,4 +120,4 @@ async function search(query, multiple, forceType) { //MORELATER: Add more providers } -module.exports = {search} \ No newline at end of file +module.exports = {search} diff --git a/src/player/List.js b/src/player/List.js index 1bfcc15..d55bd00 100644 --- a/src/player/List.js +++ b/src/player/List.js @@ -1,36 +1,63 @@ const { Database } = require('../utils/Database/Database') const { __glob } = require('../utils/GlobalVars') -const PreviousDB = new Database("previous", __glob.PREVIOUSFILE, {}) +const PreviousDB = new Database("previous", __glob.PREVIOUSFILE, {}) const {LogType} = require("loguix") const clog = new LogType("List") const { Song } = require('./Song') const AllLists = new Map() // Map +// Taille max de l'historique par guilde +const MAX_PREVIOUS = 50 +// Écriture différée de l'historique (une rafale de skips = une seule écriture disque) +const SAVE_DELAY = 1000 + class List { next; current; shuffle; guildId; + // Appelé à chaque modification (branché par le Player sur markDirty) + onChange = null; constructor(guildId) { - if(guildId === null) { + if(!guildId) { clog.error("Impossible de créer une liste, car guildId est null") return } if(AllLists.has(guildId)) { return AllLists.get(guildId) } - // Add PreviousDB.data[this.guildId] - if(PreviousDB.data[guildId] === undefined) { - PreviousDB.data[guildId] = new Array() - savePrevious() - } AllLists.set(guildId, this) this.next = new Array(); this.current = null; this.shuffle = false; this.guildId = guildId; + // Création paresseuse de l'entrée d'historique (voir getPreviousArray) } + + /** + * Signale une modification au Player (émission d'état regroupée) + */ + changed() { + if(typeof this.onChange !== "function") return + try { + this.onChange() + } catch(e) { + clog.error("Erreur lors de la notification de modification de la liste : " + (e?.message || e)) + } + } + + /** + * Historique de la guilde (créé si absent) + * @returns {Array} + */ + getPreviousArray() { + if(!Array.isArray(PreviousDB.data[this.guildId])) { + PreviousDB.data[this.guildId] = new Array() + } + return PreviousDB.data[this.guildId] + } + getNext() { return this.next; } @@ -41,7 +68,7 @@ class List { } else { return null; } - + } nextSong() { @@ -56,45 +83,51 @@ class List { const randomIndex = Math.floor(Math.random() * this.next.length); song = this.next[randomIndex] this.next.splice(randomIndex, 1) - + } - this.setCurrent(song) - process.emit("PLAYERS_UPDATE") - return song + this.setCurrent(song ?? null) + return song ?? null } clearNext() { this.next = new Array(); - process.emit("PLAYERS_UPDATE") + this.changed() } addNextSong(song) { + if(!song) return this.next.push(song) - process.emit("PLAYERS_UPDATE") + this.changed() } firstNext(song) { + if(!song) return this.next.unshift(song) - process.emit("PLAYERS_UPDATE") + this.changed() } removeNextByIndex(index) { + if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false this.next.splice(index, 1) - process.emit("PLAYERS_UPDATE") + this.changed() + return true } moveSongToUpNext(index) { + if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false const song = this.next[index] this.next.splice(index, 1) this.next.unshift(song) - process.emit("PLAYERS_UPDATE") + this.changed() + return true } getPrevious() { const previousList = new Array() - for(const song of PreviousDB.data[this.guildId]) { + for(const song of this.getPreviousArray()) { + if(!song) continue previousList.push(new Song(song)) } return previousList; @@ -102,46 +135,49 @@ class List { } getPreviousSong() { - if(PreviousDB.data[this.guildId].length > 0) { - return new Song(PreviousDB.data[this.guildId][0]) + const previous = this.getPreviousArray() + if(previous.length > 0 && previous[0]) { + return new Song(previous[0]) } else { return null; } - + } previousSong() { + const previous = this.getPreviousArray() + if(previous.length === 0) return null if(this.current != null) { this.firstNext(this.current) } - if(PreviousDB.data[this.guildId].length > 0) { - const song = PreviousDB.data[this.guildId][0] - // Remove the song from the previous list - PreviousDB.data[this.guildId].splice(0, 1) - savePrevious() - return new Song(song) - } else { - return null; - } - + const song = previous[0] + // Remove the song from the previous list + previous.splice(0, 1) + savePrevious() + this.changed() + return song ? new Song(song) : null + } clearPrevious() { PreviousDB.data[this.guildId] = new Array(); savePrevious(); - process.emit("PLAYERS_UPDATE") + this.changed() } addPreviousSongToNextByIndex(index) { - const song = PreviousDB.data[this.guildId][index] + const song = this.getPreviousArray()[index] + if(!song) return false this.next.push(song) - process.emit("PLAYERS_UPDATE") + this.changed() + return true } addPreviousSong(song) { - PreviousDB.data[this.guildId].unshift(song) + if(!song) return + this.getPreviousArray().unshift(song) savePrevious() - process.emit("PLAYERS_UPDATE") + this.changed() } getCurrent() { @@ -149,71 +185,72 @@ class List { } setCurrent(value) { - this.current = value; - process.emit("PLAYERS_UPDATE") + this.current = value ?? null; + this.changed() } destroy() { - this.clearNext(); + this.next = new Array(); this.current = null this.shuffle = false; - AllLists.delete(this.guildId) - process.emit("PLAYERS_UPDATE") + if(AllLists.get(this.guildId) === this) AllLists.delete(this.guildId) + this.changed() + this.onChange = null } setShuffle() { this.shuffle = !this.shuffle; + this.changed() } isShuffle() { return this.shuffle; } - // Play the song with the index in the queue and delete it from the queue + // Retire et renvoie la musique d'index donné de la file (NEXT), ou la renvoie depuis l'historique (PREVIOUS) playByIndex(index, typelist) { - var index = data[0] - var list = data[1] - if(typelist == ListType.NEXT) { const song = this.next[index] + if(!song) return null this.next.splice(index, 1) - + this.changed() return song } else if(typelist == ListType.PREVIOUS) { - - const song = PreviousDB.data[this.guildId][index] - return song + + const song = this.getPreviousArray()[index] + return song ? new Song(song) : null } - process.emit("PLAYERS_UPDATE") + return null } addNextPlaylist(playlist, firstAlreadyPlayed) { - if(firstAlreadyPlayed) { - playlist.songs.shift() + // Copie : la playlist enregistrée de l'utilisateur ne doit jamais être modifiée + const songs = Array.isArray(playlist?.songs) ? playlist.songs.slice(firstAlreadyPlayed ? 1 : 0) : [] + + for(const song of songs) { + if(song) this.next.push(song) } - - for(const song of playlist.songs) { - this.addNextSong(song) - } - process.emit("PLAYERS_UPDATE") - + // Une seule notification pour toute la playlist + this.changed() + } moveNext(fromIndex, toIndex) { // Check if fromIndex and toIndex are valid - if(fromIndex < 0 || fromIndex >= this.next.length + 1 || toIndex < 0 || toIndex >= this.next.length + 1) { + if(!Number.isInteger(fromIndex) || !Number.isInteger(toIndex) || fromIndex < 0 || fromIndex >= this.next.length || toIndex < 0 || toIndex > this.next.length) { clog.error("Impossible de déplacer la musique, car l'index est invalide, GuildId : " + this.guildId) - return + return false } - if(fromIndex == toIndex) return; + if(fromIndex == toIndex) return true; const song = this.next[fromIndex] this.next.splice(fromIndex, 1) this.next.splice(toIndex, 0, song) - process.emit("PLAYERS_UPDATE") + this.changed() + return true } @@ -225,15 +262,36 @@ const ListType = { PREVIOUS: "1" } -function savePrevious() { +let saveTimer = null +function flushPrevious() { + if(saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + } for(const guildId in PreviousDB.data) { - if(PreviousDB.data[guildId].length > 50) { - PreviousDB.data[guildId].splice(50, PreviousDB.data[guildId].length - 50) + if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) { + PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS) } } - PreviousDB.save(); } -module.exports = { List, ListType } \ No newline at end of file +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 } diff --git a/src/player/Method/Media.js b/src/player/Method/Media.js index 880f40c..c305f89 100644 --- a/src/player/Method/Media.js +++ b/src/player/Method/Media.js @@ -1,19 +1,44 @@ -const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice'); +const { Readable } = require('stream') const {LogType} = require('loguix') const clog = new LogType("Media") -const plog = require("loguix").getInstance("Player") -const ffmpeg = require('fluent-ffmpeg') +const { isAllowedUrl } = require('../SongCheck') -async function getStream(song) { - try { - const stream = await fetch(song.url).then(res => res.body); - return stream; - } catch(e) { - clog.error("Erreur lors de la lecture de la musique : " + song.title) - clog.error(e) - } - +// Les pièces jointes sont lues depuis le début : le seek n'est pas supporté (le player le refuse) +const seekable = false +/** + * @param {Object} song + * @param {number} seekSec - ignoré (seek non supporté) + * @param {{signal?: AbortSignal}} options + * @returns {Promise} le flux, ou null en cas d'échec / d'annulation + */ +async function getStream(song, seekSec = 0, { signal } = {}) { + if(signal?.aborted) return null + // Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF) + if(!song || !isAllowedUrl("attachment", song.url)) { + clog.error("URL de pièce jointe refusée : " + JSON.stringify(song?.url)) + return null + } + try { + const res = await fetch(song.url, { signal }) + if(!res.ok || !res.body) { + clog.error(`Erreur HTTP ${res.status} lors de la lecture de la musique : ${song.title}`) + try { await res.body?.cancel() } catch(e) {} + return null + } + // Flux Node (et non flux web) : le player suit la fin réelle du flux + const stream = Readable.fromWeb(res.body) + if(signal?.aborted) { + stream.destroy() + return null + } + return stream + } catch(e) { + if(signal?.aborted) return null + clog.error("Erreur lors de la lecture de la musique : " + song.title) + clog.error(e) + return null + } } -module.exports = {getStream} \ No newline at end of file +module.exports = {getStream, seekable} diff --git a/src/player/Method/Soundcloud.js b/src/player/Method/Soundcloud.js index 467f42a..3632c69 100644 --- a/src/player/Method/Soundcloud.js +++ b/src/player/Method/Soundcloud.js @@ -1,25 +1,43 @@ -const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice'); const {LogType} = require('loguix') const clog = new LogType("Soundcloud-Stream") const {Soundcloud} = require('soundcloud.ts') -const ffmpeg = require('fluent-ffmpeg') +const { isAllowedUrl } = require('../SongCheck') const soundcloud = new Soundcloud(); -async function getStream(song) { - try { - - var stream = await soundcloud.util.streamTrack(song.url) - return stream - - - - } catch(e) { - clog.error("Erreur lors de la récupération du stream : " + song.title) - clog.error(e) - } - +// Le flux SoundCloud est lu depuis le début : le seek n'est pas supporté (le player le refuse) +const seekable = false +/** + * @param {Object} song + * @param {number} seekSec - ignoré (seek non supporté) + * @param {{signal?: AbortSignal}} options + * @returns {Promise} le flux, ou null en cas d'échec / d'annulation + */ +async function getStream(song, seekSec = 0, { signal } = {}) { + if(signal?.aborted) return null + if(!song || !isAllowedUrl("soundcloud", song.url)) { + clog.error("URL SoundCloud refusée : " + JSON.stringify(song?.url)) + return null + } + try { + const stream = await soundcloud.util.streamTrack(song.url) + if(!stream) { + clog.error("Aucun flux renvoyé par SoundCloud pour : " + song.title) + return null + } + // Chargement remplacé entre-temps (skip, leave...) : on libère le flux + if(signal?.aborted) { + stream.destroy?.() + return null + } + signal?.addEventListener('abort', () => stream.destroy?.(), { once: true }) + return stream + } catch(e) { + clog.error("Erreur lors de la récupération du stream : " + song.title) + clog.error(e) + return null + } } -module.exports = {getStream} \ No newline at end of file +module.exports = {getStream, seekable} diff --git a/src/player/Method/Youtube.js b/src/player/Method/Youtube.js index a7183fb..ec2f4e1 100644 --- a/src/player/Method/Youtube.js +++ b/src/player/Method/Youtube.js @@ -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} + */ +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} le flux (streamType "raw" = PCM s16le 48 kHz stéréo), ou null en cas d'échec / d'annulation */ -async function getStream(song, seekTime = 0) { - // Incrémenté avant le kill pour que l'appel précédent sache tout de suite qu'il est remplacé - const myGeneration = ++generation; - - // ÉTAPE 1 : On s'assure que l'ancien processus est BIEN mort avant de faire quoi que ce soit. - await killCurrentProcess(); - - // ÉTAPE 2 : On essaie chaque route (WARP puis directe, selon l'état du proxy) - const routes = warp.getRoutes(); - - for (let i = 0; i < routes.length; i++) { - // Un nouvel appel (skip, seek...) a pris la main : on n'envoie rien au player - if (myGeneration !== generation) return null; - - const proxy = routes[i]; - const result = await runAttempt(song, seekTime, proxy); - - if (result.stream) return result.stream; - if (myGeneration !== generation) return null; - - if (proxy) warp.reportFailure(); - - const next = routes[i + 1]; - if (!result.retryable || next === undefined) { - clog.error(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason}`); - break; - } - clog.warn(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason} - Nouvelle tentative via la route ${routeLabel(next)}`); +async function getStream(song, seekTime = 0, { signal, onProgress, guildId } = {}) { + if (!song || !isSafeUrl(song.url)) { + clog.error(`[YT-DLP] URL refusée : ${JSON.stringify(song?.url)}`); + return null; } - // Flux vide : le player passe en Idle et enchaîne sur la musique suivante - const empty = new PassThrough(); - empty.end(); - return empty; + const seek = Number.isFinite(Number(seekTime)) && Number(seekTime) > 0 ? Math.floor(Number(seekTime)) : 0; + const s = settings.get(); + // Routes figées pour toute la lecture (WARP puis directe, selon l'état du proxy) + const routes = warp.getRoutes(); + const seekMode = plan.seekModeFor(s.seekStrategy, seek); + const started = Date.now(); + const history = []; + let last = null; + + while (true) { + // Un nouvel appel (skip, seek, leave...) a pris la main : on n'envoie rien au player + if (signal?.aborted) return null; + if (history.length > 0 && Date.now() - started > GLOBAL_BUDGET) { + clog.warn(`[YT-DLP] Délai global de ${GLOBAL_BUDGET / 1000}s dépassé, abandon`); + break; + } + + const attempt = plan.planNext({ + routes, + mode: s.cookiesMode, + cookiesValid: cookies.isValid(), + cookiesOnProxy: s.cookiesOnProxy, + clients: s.playerClients, + fallbackClients: s.fallbackPlayerClients, + seekMode, + history, + maxAttempts: MAX_ATTEMPTS + }); + if (!attempt) break; + + const n = history.length + 1; + const route = attempt.proxy ? "warp" : "direct"; + try { + onProgress?.({ route, attempt: n, identity: attempt.identity }); + } catch (e) { + clog.warn(`[YT-DLP] Erreur dans onProgress : ${e?.message || e}`); + } + const seekLabel = attempt.seek ? `, début ${seek}s (${attempt.seek === "native" ? "natif" : "ffmpeg"})` : ""; + clog.log(`[YT-DLP] Tentative ${n} : ${song.url} (${attemptLabel(attempt)}${seekLabel}${n > 1 ? `, ${attempt.reason}` : ""}${guildId ? `, guilde ${guildId}` : ''})`); + + const t0 = Date.now(); + let result; + try { + result = await runAttempt(song, seek, attempt, { signal, guildId }); + } catch (e) { + result = { stream: null, cls: "UNKNOWN", message: e?.message || String(e) }; + } + const entry = { + guildId: guildId ?? null, + songId: song.id ?? null, + title: song.title ?? null, + route, + identity: attempt.identity, + clients: attempt.clients, + seek: attempt.seek, + ms: Date.now() - t0 + }; + + if (result.stream) { + // Annulé pendant la dernière étape : on ne renvoie jamais un flux périmé + if (signal?.aborted) { + result.stream.destroy(); + diagnostics.record({ ...entry, outcome: "aborted" }); + return null; + } + diagnostics.record({ ...entry, outcome: "ok" }); + binaryMissingLogged = false; + if (n > 1) clog.log(`[YT-DLP] Flux obtenu à la tentative ${n} (${attemptLabel(attempt)})`); + return result.stream; + } + if (result.aborted || signal?.aborted) { + diagnostics.record({ ...entry, outcome: "aborted" }); + return null; + } + + const cls = result.cls || "UNKNOWN"; + diagnostics.record({ ...entry, outcome: "failed", cls, message: result.message }); + last = { cls, message: result.message }; + // Échec qui ne dépend ni de la route ni du client (seek après la fin) : inutile de réessayer + if (result.final) { + history.push({ ...attempt, cls }); + break; + } + + if (cls === "BINARY_MISSING") { + if (!binaryMissingLogged) { + binaryMissingLogged = true; + clog.error(`[YT-DLP] ${result.message} - la lecture YouTube est impossible tant que le binaire manque`); + } + return null; + } + clog.warn(`[YT-DLP] Échec tentative ${n} (${attemptLabel(attempt)}) : ${cls} - ${result.message}`); + + // Échec via le proxy : on revérifie WARP tout de suite au lieu d'attendre l'intervalle + if (attempt.proxy && !CONTENT_CLASSES.has(cls)) warp.reportFailure(); + // Série de blocages : une version plus récente de yt-dlp corrige souvent le problème + if (diagnostics.BLOCK_CLASSES.has(cls)) ytdlp.maybeUpdateAfterFailures(); + + history.push({ proxy: attempt.proxy, identity: attempt.identity, clients: attempt.clients, seek: attempt.seek, cls }); + } + + // Aucun flux : le player affiche l'erreur et passe à la suite + if (last) clog.error(`[YT-DLP] Lecture impossible : ${song.url} après ${history.length} tentative(s) - ${last.cls} : ${last.message}`); + return null; } -module.exports = { getStream }; +module.exports = { getStream, activeProcesses, killProcess }; diff --git a/src/player/Player.js b/src/player/Player.js index 2318f46..e9c5188 100644 --- a/src/player/Player.js +++ b/src/player/Player.js @@ -1,11 +1,9 @@ -const { joinVoiceChannel, getVoiceConnection, VoiceConnectionStatus, createAudioPlayer, AudioPlayerStatus, StreamType, createAudioResource } = require('@discordjs/voice'); +const { joinVoiceChannel, VoiceConnectionStatus, createAudioPlayer, AudioPlayerStatus, StreamType, createAudioResource, NoSubscriberBehavior, entersState } = require('@discordjs/voice'); +const crypto = require('crypto') const {List} = require('./List') const {LogType} = require("loguix"); const songCheck = require('./SongCheck') -const ffmpeg = require('fluent-ffmpeg') -const fs = require('fs') -const { PassThrough } = require('stream'); -const { Metric } = require('webmetrik') +const SafeMetric = require('../utils/SafeMetric') const plog = new LogType("Player") const clog = new LogType("Signal") @@ -15,380 +13,985 @@ const youtube = require('./Method/Youtube'); const soundcloud = require('./Method/Soundcloud'); const AllPlayers = new Map() +// Dernière instance détruite par guilde : permet d'envoyer un état "offline" avec un seq cohérent +const LastInstances = new Map() + +// Sources par type de musique +const METHODS = { + youtube: youtube, + soundcloud: soundcloud, + attachment: media, +} +// Seul yt-dlp sait démarrer à une position donnée (--download-sections) +const SEEKABLE_TYPES = ["youtube"] + +// Regroupement des émissions d'état (une rafale de modifications = une seule émission) +const STATE_DELAY = 50 +// Recalage périodique des clients pendant la lecture +const HEARTBEAT_INTERVAL = 5000 +// 250 trames de 20 ms = 5 s de sous-alimentation tolérées avant que la lib ne termine la ressource +const MAX_MISSED_FRAMES = 250 +// Échecs de chargement consécutifs avant d'arrêter d'enchaîner (évite une boucle infinie) +const MAX_CONSECUTIVE_FAILURES = 3 +// Une lecture plus courte est considérée comme un échec (garde anti-boucle avec loop) +const MIN_VALID_PLAY_MS = 2000 +// Délais de reconnexion vocale +const RECONNECT_TIMEOUT = 5000 +const READY_TIMEOUT = 20000 +// Taille des listes envoyées au client +const NEXT_LIMIT = 100 +const PREVIOUS_LIMIT = 50 + +// Connexions vocales déjà écoutées (joinVoiceChannel peut renvoyer une connexion existante) +const listenedConnections = new WeakSet() + +const STATUS = { + OFFLINE: "offline", + IDLE: "idle", + LOADING: "loading", + PLAYING: "playing", + PAUSED: "paused", + STALLED: "stalled", +} + +function refreshActivity() { + try { + require('../discord/Activity').refresh() + } catch(e) { + plog.error("Impossible de rafraîchir l'activité Discord : " + (e?.message || e)) + } +} + +function destroyStream(stream) { + try { + stream?.destroy?.() + } catch(e) { + // Flux déjà détruit + } +} + +/** + * Copie sérialisable d'une musique pour l'état envoyé aux clients + * @param {Object} song + */ +function serializeSong(song) { + if(!song) return null + const duration = SafeMetric.toSafeDuration(song.duration) + const data = { + id: song.id ?? null, + title: song.title ?? null, + author: song.author ?? null, + authorId: song.authorId ?? null, + url: song.url ?? null, + thumbnail: song.thumbnail ?? null, + type: song.type ?? null, + duration: duration, + readduration: song.readduration ?? null, + } + if(song.createdAt) data.createdAt = song.createdAt + return data +} class Player { - connection; - connected = false; - player; + connection = null; + // Un seul AudioPlayer par guilde, créé au premier join et abonné une seule fois + audioPlayer = null; guildId; - channelId; - channelName; + instanceId; + channelId = null; + channelName = null; queue; - currentResource; loop = false; + + status = STATUS.OFFLINE; + // Ressource en cours (les événements des autres ressources sont ignorés) + resource = null; + // Position de départ de la ressource (seek / reprise), en ms + seekOffsetMs = 0; + // Jeton de chargement : tout chargement dont le jeton n'est plus le dernier est abandonné + loadToken = 0; + loader = null; + pendingPause = false; + lastError = null; + loading = null; + seq = 0; + autoLeaveAt = null; + consecutiveFailures = 0; + // Une seule reprise automatique par morceau après une fin anormale du flux + resumed = false; + // Le démarrage du morceau courant a déjà été compté dans numberOfMusicPlayed + countedPlay = false; + destroyed = false; + _dirty = null; + _readyGuard = false; + constructor(guildId) { - if(this.guildId === null) { + if(!guildId) { clog.error("Impossible de créer un Player, car guildId est null") return } - - if(AllPlayers.has(guildId)) { + if(AllPlayers.has(guildId)) { return AllPlayers.get(guildId) } - this.connection = null - this.player = null this.guildId = guildId + this.instanceId = crypto.randomUUID() this.queue = new List(guildId) + this.queue.onChange = () => this.markDirty() AllPlayers.set(guildId, this) - } + // --- Compatibilité avec l'ancien code --- + + get connected() { + return this.isConnected() + } + + get player() { + return this.audioPlayer + } + + get currentResource() { + return this.resource + } + + // --- Connexion vocale --- + async join(channel) { - - if(getVoiceConnection(channel.guild.id)) { - clog.log(`GUILD : ${this.guildId} - Une connexion existe déjà pour ce serveur`) - return + if(!channel) return false + if(this.destroyed) { + // Instance détruite par leave() : il faut passer par getPlayer() pour en créer une nouvelle + clog.error(`GUILD : ${this.guildId} - Impossible de rejoindre un salon avec un player détruit`) + return false } - + if(this.isConnected()) { + clog.log(`GUILD : ${this.guildId} - Une connexion existe déjà pour ce serveur`) + return true + } + this.ensureAudioPlayer() this.joinChannel(channel) - - this.player = createAudioPlayer() - this.generatePlayerEvents() - + return true } isConnected() { - return this.connected + return Boolean(this.connection) && this.connection.state.status !== VoiceConnectionStatus.Destroyed + } + + ensureAudioPlayer() { + if(this.audioPlayer) return + this.audioPlayer = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + maxMissedFrames: MAX_MISSED_FRAMES + } + }) + this.generatePlayerEvents() } joinChannel(channel) { - this.channelId = channel.id + this.channelId = channel.id this.channelName = channel.name - this.connection = joinVoiceChannel({ + const connection = joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, adapterCreator: channel.guild.voiceAdapterCreator, selfDeaf: false, selfMute: false }); + this.connection = connection + connection.subscribe(this.audioPlayer) + this.generateConnectionEvents(connection) - this.connection.on('stateChange', (oldState, newState) => { + if(this.status === STATUS.OFFLINE) this.status = STATUS.IDLE + AllPlayers.set(this.guildId, this) + clog.log(`GUILD : ${this.guildId} - Connexion au salon : ${channel.name}`) + this.markDirty() + } + + generateConnectionEvents(connection) { + if(listenedConnections.has(connection)) return + listenedConnections.add(connection) + + connection.on('stateChange', async (oldState, newState) => { + // Événement d'une ancienne connexion (après leave) + if(connection !== this.connection) return + if(oldState.status === newState.status) return clog.log(`GUILD : ${this.guildId} - [STATE] OLD : "${oldState.status}" NEW : "${newState.status}"`); - - // Si la connection est fermée, on détruit le player + this.markDirty() if(newState.status === VoiceConnectionStatus.Disconnected) { - this.leave() + // Déplacement du bot ou microcoupure : on laisse 5 s à la connexion pour se rétablir + try { + await Promise.race([ + entersState(connection, VoiceConnectionStatus.Signalling, RECONNECT_TIMEOUT), + entersState(connection, VoiceConnectionStatus.Connecting, RECONNECT_TIMEOUT), + ]) + clog.log(`GUILD : ${this.guildId} - Reconnexion vocale en cours`) + } catch(e) { + if(connection === this.connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { + clog.warn(`GUILD : ${this.guildId} - Connexion vocale perdue, déconnexion du player`) + this.leave("disconnected") + } + } + } else if(newState.status === VoiceConnectionStatus.Signalling || newState.status === VoiceConnectionStatus.Connecting) { + // Garde-fou : la connexion doit redevenir prête en 20 s + if(this._readyGuard) return + this._readyGuard = true + try { + await entersState(connection, VoiceConnectionStatus.Ready, READY_TIMEOUT) + } catch(e) { + if(connection === this.connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { + clog.warn(`GUILD : ${this.guildId} - La connexion vocale n'est pas redevenue prête en ${READY_TIMEOUT / 1000}s, déconnexion du player`) + this.leave("connection_timeout") + } + } finally { + this._readyGuard = false + } + } else if(newState.status === VoiceConnectionStatus.Ready) { + // Le salon a pu changer (déplacement par un modérateur) + const channelId = connection.joinConfig?.channelId + if(channelId && channelId !== this.channelId) { + this.updateChannel(channelId) + } } }); - this.connected = true - AllPlayers.set(this.guildId, this) - process.emit("PLAYERS_UPDATE") + connection.on('error', (error) => { + clog.error(`GUILD : ${this.guildId} - Erreur sur la connexion vocale : ${error?.message || error}`) + }) } + /** + * Met à jour le salon affiché (déplacement du bot) + * @param {string} channelId + * @param {string} [channelName] + */ + updateChannel(channelId, channelName) { + if(!channelId) return + if(!channelName) { + try { + channelName = require('../discord/Bot').getClient()?.channels?.cache?.get(channelId)?.name + } catch(e) { + channelName = null + } + } + const changed = channelId !== this.channelId || (channelName && channelName !== this.channelName) + this.channelId = channelId + if(channelName) this.channelName = channelName + if(changed) { + clog.log(`GUILD : ${this.guildId} - Salon vocal du bot : ${this.channelName} (${channelId})`) + this.markDirty() + } + } + + /** + * Appelé par Bot.js sur un voiceStateUpdate du bot lui-même + * @param {import('discord.js').VoiceState} newState + */ + onBotVoiceState(newState) { + if(!this.isConnected()) return + const channelId = newState?.channelId ?? null + if(!channelId) { + // Expulsion : la connexion passe en Disconnected, la reconnexion échoue et le player quitte au bout de 5 s + clog.log(`GUILD : ${this.guildId} - Le bot n'est plus dans un salon vocal`) + this.markDirty() + return + } + this.updateChannel(channelId, newState.channel?.name) + } + + changeChannel(channel) { + if(!this.isConnected() || !channel) return false + if(this.connection.joinConfig?.channelId === channel.id) return true + + // rejoin garde la même connexion et donc l'abonnement à l'AudioPlayer : la lecture continue + const ok = this.connection.rejoin({ channelId: channel.id, selfDeaf: false, selfMute: false }) + if(ok) this.updateChannel(channel.id, channel.name) + this.markDirty() + return ok + } + + // --- Événements de l'AudioPlayer --- + generatePlayerEvents() { + const audioPlayer = this.audioPlayer - const Activity = require('../discord/Activity'); - - this.player.on('error', error => { - plog.error(`GUILD : ${this.guildId} - Une erreur est survenue dans le player`); - plog.error(error); - console.error(error); - process.emit("PLAYERS_UPDATE") + audioPlayer.on('error', error => { + if(audioPlayer !== this.audioPlayer) return + // Erreur d'une ancienne ressource : sans importance + if(error?.resource && error.resource !== this.resource) return + plog.error(`GUILD : ${this.guildId} - Une erreur est survenue dans le player : ${error?.message || error}`); + this.setError("PLAYBACK_ERROR", error?.message || "Erreur de lecture", this.queue?.current?.id) + // La lib passe ensuite en Idle : onTrackEnd gère la suite }); - - this.player.on(AudioPlayerStatus.Idle, () => { - if(this.checkConnection()) return - // Si la musique est en boucle, on relance la musique - if(this.loop) { - this.play(this.queue.current) - return + + audioPlayer.on('stateChange', (oldState, newState) => { + if(audioPlayer !== this.audioPlayer) return + try { + this.onAudioStateChange(oldState, newState) + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Erreur dans le changement d'état du player : ${e?.stack || e}`) } - // Si la musique n'est pas en boucle, on passe à la musique suivante - Activity.idleActivity() - this.queue.setCurrent(null) - if(this.queue.next.length > 0) { - this.play(this.queue.nextSong()) - } - process.emit("PLAYERS_UPDATE") - }); - - this.player.on(AudioPlayerStatus.Playing, () => { - if(this.checkConnection()) return - plog.log(`GUILD : ${this.guildId} - Le player est en train de jouer le contenu suivant : ${this.queue.current.title}`); - Activity.setMusicActivity(this.queue.current.title, this.queue.current.author, this.queue.current.thumbnail) - process.emit("PLAYERS_UPDATE") - }); } + onAudioStateChange(oldState, newState) { + if(newState.status === AudioPlayerStatus.Idle) { + // Seule la fin de la ressource courante compte (plus d'Idle "fantôme" d'une ancienne ressource) + if(oldState.resource && oldState.resource === this.resource) { + this.onTrackEnd(oldState.resource) + } + return + } - checkConnection() { - if(this.connection === null) { - // clog.error(`GUILD : ${this.guildId} - La connection n'est pas définie`) - return true + // Événement d'une autre ressource que la courante : ignoré + if(newState.resource !== this.resource) return + if(oldState.status === newState.status) return + + switch(newState.status) { + case AudioPlayerStatus.Playing: + this.status = STATUS.PLAYING + this.onResourcePlaying(newState.resource) + break + case AudioPlayerStatus.Paused: + this.status = STATUS.PAUSED + break + case AudioPlayerStatus.AutoPaused: + this.status = STATUS.STALLED + break + case AudioPlayerStatus.Buffering: + // Premier remplissage de la ressource : on reste en chargement + if(this.status !== STATUS.LOADING) this.status = STATUS.STALLED + break } - if(this.player === null) { - plog.error(`GUILD : ${this.guildId} - Le player n'est pas défini`) - return true + this.markDirty() + } + + /** + * Passage en lecture de la ressource courante (premier démarrage ou reprise après pause / coupure) + */ + onResourcePlaying(resource) { + const meta = resource.metadata || {} + if(!meta.played) { + meta.played = true + this.loading = null + const song = this.queue.current + if(song) { + plog.log(`GUILD : ${this.guildId} - Le player est en train de jouer le contenu suivant : ${song.title}`); + } + // Statistique : +1 au démarrage réel du morceau (pas au seek ni à la reprise automatique) + if(!this.countedPlay) { + this.countedPlay = true + SafeMetric.increment("numberOfMusicPlayed_" + this.guildId, 1, "Nombre de musiques jouées sur le serveur : " + this.guildId) + } } + // Pause demandée pendant le chargement : appliquée dès le démarrage effectif + if(this.pendingPause) { + this.pendingPause = false + if(this.audioPlayer.pause()) { + this.status = STATUS.PAUSED + plog.log(`GUILD : ${this.guildId} - La musique a été mise en pause (demandée pendant le chargement)`) + } + } + } + + // --- État --- + + /** + * Demande une émission d'état (regroupée sur 50 ms) + */ + markDirty() { + if(this._dirty) return + this._dirty = setTimeout(() => { + this._dirty = null + this.seq++ + if(this.destroyed) { + LastInstances.set(this.guildId, { instanceId: this.instanceId, seq: this.seq }) + } + try { + process.emit("PLAYER_STATE", this.guildId) + // Ancien événement conservé comme alias + process.emit("PLAYERS_UPDATE") + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Erreur lors de l'émission de l'état : ${e?.stack || e}`) + } + refreshActivity() + }, STATE_DELAY) + } + + getDurationMs() { + const duration = SafeMetric.toSafeDuration(this.queue?.current?.duration) + return duration > 0 ? Math.round(duration * 1000) : null + } + + /** + * Position réelle de lecture en ms : offset de départ + audio réellement lu par la ressource + * (figée en pause, en autopause et en sous-alimentation) + */ + getPositionMs() { + if(!this.queue?.current) return 0 + let raw = this.seekOffsetMs + if(this.status !== STATUS.LOADING && this.resource) { + raw += this.resource.playbackDuration || 0 + } + const max = this.getDurationMs() ?? Infinity + const value = Math.max(0, Math.min(raw, max)) + return Number.isFinite(value) ? Math.floor(value) : 0 + } + + isSeekable(song = this.queue?.current) { + if(!song || !this.isConnected()) return false + return SEEKABLE_TYPES.includes(song.type) && SafeMetric.toSafeDuration(song.duration) > 0 } getState() { - const playerStatus = this.player?.state?.status ?? false; - const connectionStatus = this.connection?.state?.status ?? false; - const state = { - current: this.queue.current, - next: this.queue.next, - previous: this.queue.getPrevious(), - loop: this.loop, - shuffle: this.queue.shuffle, - paused: playerStatus === AudioPlayerStatus.Paused, - playing: playerStatus === AudioPlayerStatus.Playing, - duration: this.getDuration(), - playerState: playerStatus, - connectionState: connectionStatus, - channelId: this.channelId, - channelName: this.channelName, - guildId: this.guildId, + const queue = this.queue + const next = queue?.next || [] + const playerStatus = this.audioPlayer?.state?.status ?? false + const connectionStatus = this.connection?.state?.status ?? null + const positionMs = this.getPositionMs() + let previous = [] + try { + previous = queue ? queue.getPrevious().slice(0, PREVIOUS_LIMIT).map(serializeSong) : [] + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Historique illisible : ${e?.message || e}`) } - return state + return { + v: 2, + guildId: this.guildId, + instanceId: this.instanceId, + seq: this.seq, + serverTime: Date.now(), + status: this.status, + connection: { + status: connectionStatus, + channelId: this.channelId ?? null, + channelName: this.channelName ?? null, + }, + current: serializeSong(queue?.current), + position: { + ms: positionMs, + rate: this.status === STATUS.PLAYING ? 1 : 0, + }, + durationMs: this.getDurationMs(), + seekable: this.isSeekable(), + loop: this.loop, + shuffle: Boolean(queue?.shuffle), + pendingPause: this.pendingPause, + loading: this.loading ? { ...this.loading } : null, + lastError: this.lastError, + next: next.slice(0, NEXT_LIMIT).map(serializeSong), + nextCount: next.length, + previous: previous, + autoLeaveAt: this.autoLeaveAt, + // Compatibilité avec l'ancien client + paused: this.status === STATUS.PAUSED, + playing: this.status === STATUS.PLAYING, + duration: positionMs / 1000, + playerState: playerStatus, + connectionState: connectionStatus ?? false, + channelId: this.channelId ?? null, + channelName: this.channelName ?? null, + } + } + + setError(code, message, songId) { + this.lastError = { code: code, message: String(message ?? ""), songId: songId ?? null, at: Date.now() } + this.markDirty() + } + + /** + * Heure (ms) du départ automatique programmé par Bot.js, ou null + * @param {number|null} ms + */ + setAutoLeaveAt(ms) { + const value = Number.isFinite(ms) ? ms : null + if(value === this.autoLeaveAt) return + this.autoLeaveAt = value + this.markDirty() } async setLoop() { - if(this.checkConnection()) return + if(!this.isConnected()) return null this.loop = !this.loop if(this.loop) { plog.log(`GUILD : ${this.guildId} - La musique est en boucle`) } else { plog.log(`GUILD : ${this.guildId} - La musique n'est plus en boucle`) } - process.emit("PLAYERS_UPDATE") + this.markDirty() + return this.loop } async setShuffle() { - if(this.checkConnection()) return + if(!this.isConnected()) return null this.queue.shuffle = !this.queue.shuffle if(this.queue.shuffle) { plog.log(`GUILD : ${this.guildId} - La musique est en mode aléatoire`) } else { plog.log(`GUILD : ${this.guildId} - La musique n'est plus en mode aléatoire`) } - process.emit("PLAYERS_UPDATE") + this.markDirty() + return this.queue.shuffle } - async play(song) { + // --- Lecture --- - if(!songCheck.checkSong(song)) return - if(this.checkConnection()) return - if(this.queue.current != null) { - this.player.stop() - } - - var numberOfMusicPlayedPerServer = new Metric("numberOfMusicPlayed_" + this.guildId, "Nombre de musiques jouées sur le serveur : " + this.guildId) - numberOfMusicPlayedPerServer.setValue(numberOfMusicPlayedPerServer.getValue() + 1) - - var numberOfSecondsPlayedPerServer = new Metric("numberOfSecondsPlayed_" + this.guildId, "Temps jouée sur le serveur : " + this.guildId) - numberOfSecondsPlayedPerServer.setValue(numberOfSecondsPlayedPerServer.getValue() + song.duration) - - this.queue.setCurrent(song) - this.stream = await this.getStream(song) - - if(this.stream === null) { - plog.error(`GUILD : ${this.guildId} - Impossible de lire la musique : ${song.title} avec le type : ${song.type}`) - return - } - - this.playStream(this.stream) - - plog.log(`GUILD : ${this.guildId} - Lecture de la musique : ${song.title} - Type : ${song.type}`) + async getStream(song, seek = 0, options = {}) { + const method = METHODS[song?.type] + if(!method) return null + return method.getStream(song, seek, options) } - async getStream(song, duration = 0) { + /** + * Comptabilise le temps réellement joué par une ressource (une seule fois) + */ + accountPlayed(resource) { + const meta = resource?.metadata + if(!meta || meta.counted) return + meta.counted = true + const seconds = (resource.playbackDuration || 0) / 1000 + if(Number.isFinite(seconds) && seconds > 0) { + SafeMetric.addDuration("numberOfSecondsPlayed_" + this.guildId, seconds, "Temps jouée sur le serveur : " + this.guildId) + } + } + + /** + * Retire la ressource courante. Elle n'est plus "courante" avant l'arrêt : l'Idle qui suit est ignoré. + */ + releaseResource() { + const old = this.resource + this.resource = null + if(!old) return + this.accountPlayed(old) + if(this.audioPlayer && this.audioPlayer.state.status !== AudioPlayerStatus.Idle && this.audioPlayer.state.resource === old) { + this.audioPlayer.stop(true) + } else { + destroyStream(old.playStream) + } + } + + /** + * Charge et joue une musique. Renvoie true si la ressource a été confiée à l'AudioPlayer. + * @param {Object} song + * @param {{seek?: number, newTrack?: boolean, keepPause?: boolean}} options + * seek : position de départ en secondes ; newTrack=false pour un seek ou une reprise (pas de nouvelle statistique) + */ + async play(song, options = {}) { + const { seek = 0, newTrack = true, keepPause = false } = options + if(!this.isConnected() || !this.audioPlayer) { + plog.warn(`GUILD : ${this.guildId} - Lecture impossible : le bot n'est pas connecté`) + return false + } + const safe = songCheck.sanitizeSong(song) + if(!safe) { + plog.error(`GUILD : ${this.guildId} - Musique refusée : ${String(song?.title).slice(0, 200)}`) + this.setError("INVALID_SONG", "Musique invalide ou source non autorisée", song?.id) + return false + } + + const token = ++this.loadToken + + // Arrêt immédiat de la ressource précédente : le son correspond toujours à l'état affiché + this.releaseResource() + + // Annule le chargement précédent (et tue son yt-dlp) + this.loader?.abort() + const loader = new AbortController() + this.loader = loader + + if(newTrack) { + this.resumed = false + this.countedPlay = false + } + this.pendingPause = keepPause ? true : false + + const seekSec = Number.isFinite(Number(seek)) && Number(seek) > 0 ? Math.floor(Number(seek)) : 0 + this.queue.setCurrent(safe) + this.seekOffsetMs = seekSec * 1000 + this.status = STATUS.LOADING + this.loading = { songId: safe.id ?? null, since: Date.now(), attempt: 1, route: null } + this.markDirty() + let stream = null - if(song.type == "attachment") { - stream = await media.getStream(song) - } - if(song.type == 'youtube') { - stream = await youtube.getStream(song, duration) - } - if(song.type == "soundcloud") { - stream = await soundcloud.getStream(song) + try { + stream = await this.getStream(safe, seekSec, { + signal: loader.signal, + guildId: this.guildId, + onProgress: (progress) => { + if(token !== this.loadToken || !this.loading) return + Object.assign(this.loading, progress) + this.markDirty() + } + }) + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Erreur lors du chargement de : ${safe.title} - ${e?.message || e}`) + stream = null } - return stream + // Chargement remplacé entre-temps (skip, seek, stop, leave) : on jette le flux + if(token !== this.loadToken || !this.isConnected()) { + destroyStream(stream) + return false + } + if(!stream) { + this.onLoadFailed(safe) + return false + } + + let resource + try { + resource = createAudioResource(stream, { + // PCM brut déjà décodé (seek natif YouTube) ou flux conteneur quelconque + inputType: stream.streamType === "raw" ? StreamType.Raw : StreamType.Arbitrary, + metadata: { token: token, songId: safe.id ?? null, source: stream, sourceEnded: false, played: false, counted: false } + }); + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Impossible de créer la ressource audio : ${e?.message || e}`) + destroyStream(stream) + this.onLoadFailed(safe) + return false + } + // Fin normale de la source (toutes les données lues) : sert à repérer une coupure en cours de lecture + stream.once?.('end', () => { resource.metadata.sourceEnded = true }) + + this.resource = resource + // Pas de stop() avant : la ressource précédente est déjà libérée, et le filtre ignore ses événements + try { + this.audioPlayer.play(resource) + } catch(e) { + plog.error(`GUILD : ${this.guildId} - Impossible de lancer la ressource audio : ${e?.message || e}`) + this.resource = null + destroyStream(stream) + this.onLoadFailed(safe) + return false + } + + plog.log(`GUILD : ${this.guildId} - Lecture de la musique : ${safe.title} - Type : ${safe.type}${seekSec ? ` - Début : ${seekSec}s` : ""}`) + return true } - async add(song) { - if(this.player?.state?.status == AudioPlayerStatus.Idle && this.queue.current === null && this.queue.next.length === 0) { - this.play(song) + /** + * Lance une lecture sans l'attendre (le résultat arrive par l'état) + */ + startPlay(song, options) { + this.play(song, options).catch((e) => { + plog.error(`GUILD : ${this.guildId} - Erreur de lecture : ${e?.stack || e}`) + }) + } + + /** + * Lecture immédiate : la musique en cours part dans l'historique + */ + async playNow(song) { + if(!this.isConnected()) return false + if(!songCheck.checkSong(song)) return false + if(this.queue.current) this.queue.addPreviousSong(this.queue.current) + this.startPlay(song) + return true + } + + /** + * Échec du chargement d'une source : erreur exposée, passage à la suite sans respecter loop + */ + onLoadFailed(song) { + this.consecutiveFailures++ + this.loading = null + this.releaseResource() + plog.error(`GUILD : ${this.guildId} - Impossible de lire la musique : ${song?.title} avec le type : ${song?.type}`) + this.setError("SOURCE_FAILED", `Impossible de lire « ${song?.title ?? "musique inconnue"} »`, song?.id) + + if(this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + plog.error(`GUILD : ${this.guildId} - ${this.consecutiveFailures} échecs consécutifs, arrêt de la lecture`) + if(this.loop) { + this.loop = false + plog.warn(`GUILD : ${this.guildId} - Boucle désactivée après des échecs répétés`) + } + this.consecutiveFailures = 0 + this.setError("TOO_MANY_FAILURES", "Plusieurs musiques n'ont pas pu être lues, lecture arrêtée", song?.id) + this.finishQueue() return - } + } + + if(this.queue.next.length > 0) { + this.startPlay(this.queue.nextSong()) + return + } + this.finishQueue() + } + + /** + * Plus rien à jouer : la musique courante part dans l'historique, état idle + */ + finishQueue() { + const song = this.queue.current + this.releaseResource() + if(song) this.queue.addPreviousSong(song) + this.queue.setCurrent(null) + this.seekOffsetMs = 0 + this.loading = null + this.pendingPause = false + this.status = this.isConnected() ? STATUS.IDLE : STATUS.OFFLINE + this.markDirty() + } + + /** + * Fin de la ressource courante (fin naturelle, coupure, erreur) + */ + onTrackEnd(resource) { + const song = this.queue.current + const meta = resource?.metadata || {} + const positionMs = this.getPositionMs() + const playedMs = resource?.playbackDuration || 0 + this.accountPlayed(resource) + this.resource = null + + // La ressource n'a jamais joué : c'est un échec de chargement (flux vide, format illisible...) + if(!meta.played) { + this.onLoadFailed(song) + return + } + + // Fin anormale : yt-dlp sorti en erreur, ou flux source jamais terminé (coupure > 5 s) + const abnormal = Boolean(meta.source?.abnormalEnd) || !meta.sourceEnded + if(abnormal && song && song.type === "youtube") { + if(!this.resumed) { + this.resumed = true + const seek = this.isSeekable(song) ? Math.floor(positionMs / 1000) : 0 + plog.warn(`GUILD : ${this.guildId} - Fin anormale du flux de « ${song.title} » à ${Math.floor(positionMs / 1000)}s, reprise à ${seek}s`) + this.startPlay(song, { seek: seek, newTrack: false }) + return + } + this.setError("STREAM_INTERRUPTED", `La lecture de « ${song.title} » a été interrompue`, song.id) + } + + // Garde anti-boucle infinie : une lecture quasi nulle compte comme un échec + if(playedMs < MIN_VALID_PLAY_MS) { + this.consecutiveFailures++ + if(this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + plog.error(`GUILD : ${this.guildId} - Lectures trop courtes à répétition, arrêt de la lecture`) + this.loop = false + this.consecutiveFailures = 0 + this.setError("TOO_MANY_FAILURES", "Plusieurs musiques n'ont pas pu être lues, lecture arrêtée", song?.id) + this.finishQueue() + return + } + } else { + this.consecutiveFailures = 0 + } + + if(this.loop && song) { + this.startPlay(song) + return + } + if(this.queue.next.length > 0) { + this.startPlay(this.queue.nextSong()) + return + } + this.finishQueue() + } + + async add(raw) { + // Données client : textes bornés, durée et URL vérifiées (sinon diffusées à toute la guilde) + const song = songCheck.sanitizeSong(raw) + if(!song) return false + if(this.status === STATUS.IDLE && this.queue.current === null && this.queue.next.length === 0) { + this.startPlay(song) + return true + } this.queue.addNextSong(song) plog.log(`GUILD : ${this.guildId} - La musique a été ajoutée à la liste de lecture : ${song.title}`) + return true } async readPlaylist(playlist, now) { - if(this.player?.state?.status == AudioPlayerStatus.Idle && this.queue.current === null && this.queue.next.length === 0) { - this.play(playlist.songs[0]) - this.queue.addNextPlaylist(playlist, true) - return - } - if(now) this.play(playlist.songs[0]) - this.queue.addNextPlaylist(playlist, now) - - plog.log(`GUILD : ${this.guildId} - La playlist a été ajoutée à la liste de lecture : ${playlist.title}`) - } - - async pause() { - if(this.checkConnection()) return "no_music" - if(this.player.state.status == AudioPlayerStatus.Paused) { - this.player.unpause() - plog.log(`GUILD : ${this.guildId} - La musique a été reprise`) - process.emit("PLAYERS_UPDATE") + const songs = Array.isArray(playlist?.songs) ? playlist.songs.map(s => songCheck.sanitizeSong(s)).filter(Boolean) : [] + if(songs.length === 0) { + plog.warn(`GUILD : ${this.guildId} - Playlist vide : ${playlist?.title}`) return false - } else { - this.player.pause() - plog.log(`GUILD : ${this.guildId} - La musique a été mise en pause`) - process.emit("PLAYERS_UPDATE") - return true } - - + const idle = this.status === STATUS.IDLE && this.queue.current === null && this.queue.next.length === 0 + if(idle || now) { + if(now) { + this.playNow(songs[0]) + } else { + this.startPlay(songs[0]) + } + this.queue.addNextPlaylist({ songs: songs }, true) + } else { + this.queue.addNextPlaylist({ songs: songs }, false) + } + plog.log(`GUILD : ${this.guildId} - La playlist a été ajoutée à la liste de lecture : ${playlist.title}`) + return true } - async leave() { - const Activity = require('../discord/Activity'); - if(this.checkConnection()) return - if(this.queue.current != null) { - this.queue.addPreviousSong(this.queue.current) + /** + * Bascule pause / lecture. Ne lève jamais. + * @returns {Promise<{ok: boolean, paused: boolean, reason?: string}>} + */ + async pause() { + switch(this.status) { + case STATUS.LOADING: + case STATUS.STALLED: + // Appliquée au démarrage (ou au retour) effectif de la lecture + this.pendingPause = !this.pendingPause + plog.log(`GUILD : ${this.guildId} - Pause ${this.pendingPause ? "demandée" : "annulée"} pendant le chargement`) + this.markDirty() + return { ok: true, paused: this.pendingPause } + case STATUS.PLAYING: { + const ok = Boolean(this.audioPlayer?.pause()) + if(ok) { + this.status = STATUS.PAUSED + plog.log(`GUILD : ${this.guildId} - La musique a été mise en pause`) + this.markDirty() + return { ok: true, paused: true } + } + return { ok: false, paused: false, reason: "not_playing" } + } + case STATUS.PAUSED: { + const ok = Boolean(this.audioPlayer?.unpause()) + if(ok) { + this.status = STATUS.PLAYING + plog.log(`GUILD : ${this.guildId} - La musique a été reprise`) + this.markDirty() + return { ok: true, paused: false } + } + return { ok: false, paused: true, reason: "not_paused" } + } + default: + return { ok: false, paused: false, reason: "no_music" } } - // Détruit la connection et le player et l'enlève de la liste des - this.connection.destroy() - this.player.stop() - this.player = null + } + + /** + * Pause idempotente + * @param {boolean} value + */ + async setPaused(value) { + const wanted = Boolean(value) + let paused + if(this.status === STATUS.PAUSED) paused = true + else if(this.status === STATUS.PLAYING) paused = false + else if(this.status === STATUS.LOADING || this.status === STATUS.STALLED) paused = this.pendingPause + else return { ok: false, paused: false, reason: "no_music" } + if(paused === wanted) return { ok: true, paused: paused } + return this.pause() + } + + /** + * Vide la file, arrête le morceau courant (ajouté à l'historique) + */ + async stop() { + ++this.loadToken + this.queue.clearNext() + this.finishQueue() + this.loader?.abort() + this.loader = null + plog.log(`GUILD : ${this.guildId} - Lecture arrêtée`) + return true + } + + async leave(reason = "manual") { + if(this.destroyed) return false + ++this.loadToken + + const song = this.queue.current + this.releaseResource() + if(song) this.queue.addPreviousSong(song) + // Annule un chargement en cours et tue le yt-dlp associé + this.loader?.abort() + this.loader = null + + // Détruit le player et la connexion + if(this.audioPlayer) { + this.audioPlayer.removeAllListeners() + try { this.audioPlayer.stop(true) } catch(e) {} + this.audioPlayer = null + } + const connection = this.connection this.connection = null + if(connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { + try { connection.destroy() } catch(e) { + clog.error(`GUILD : ${this.guildId} - Erreur lors de la destruction de la connexion : ${e?.message || e}`) + } + } + this.channelId = null this.channelName = null - this.connected = false - Activity.idleActivity() + this.status = STATUS.OFFLINE + this.loading = null + this.pendingPause = false + this.autoLeaveAt = null + this.seekOffsetMs = 0 this.queue.destroy() - AllPlayers.delete(this.guildId) - clog.log("Connection détruite avec le guildId : " + this.guildId) + this.destroyed = true + if(AllPlayers.get(this.guildId) === this) AllPlayers.delete(this.guildId) + clog.log(`Connection détruite avec le guildId : ${this.guildId} (raison : ${reason})`) plog.log("Player détruit avec le guildId : " + this.guildId) - process.emit("PLAYERS_UPDATE") + // Émet l'état offline (le Player n'est plus dans AllPlayers) + this.markDirty() + return true } + /** + * Déplace la lecture + * @param {number} seconds + * @returns {Promise<{ok: boolean, reason?: string}>} + */ + async seek(seconds) { + const song = this.queue?.current + if(!this.isConnected() || !song) return { ok: false, reason: "no_music" } + if(!this.isSeekable(song)) return { ok: false, reason: "not_seekable" } + const time = Number(seconds) + if(!Number.isFinite(time) || time < 0) return { ok: false, reason: "invalid_time" } + const duration = SafeMetric.toSafeDuration(song.duration) + if(duration > 0 && time >= duration) return { ok: false, reason: "out_of_range" } + + // La pause est conservée après le déplacement + const keepPause = this.status === STATUS.PAUSED || this.pendingPause + plog.log(`GUILD : ${this.guildId} - Lecture déplacée à ${Math.floor(time)}s.`); + this.startPlay(song, { seek: Math.floor(time), newTrack: false, keepPause: keepPause }) + return { ok: true } + } + + // Compatibilité : ancien nom (accepte un nombre ou {time}) async setDuration(duration) { - - if (this.checkConnection()) return; - if (this.queue.current == null) return; - if (this.currentResource == null) return; - - const maxDuration = this.queue.current.duration; - if (duration.time > maxDuration) { - plog.error(`GUILD : ${this.guildId} - La durée demandée dépasse la durée maximale de la musique.`); - return; - } - - this.stream = await this.getStream(this.queue.current, duration.time); - if (this.stream === null) { - plog.error(`GUILD : ${this.guildId} - Impossible de lire la musique : ${this.queue.current.title} avec le type : ${this.queue.current.type}`); - return; - } - - // Si stream est un lien, ouvrir le stream à partir du lien - - if(typeof this.stream === "string") { - this.stream = fs.createReadStream(this.stream) - } - this.playStream(this.stream); // Jouer le nouveau flux - - this.currentResource.playbackDuration = duration.time * 1000; // Mettre à jour la durée de lecture du resource - console.log(this.currentResource.playbackDuration) - console.log(this.getDuration()) - - plog.log(`GUILD : ${this.guildId} - Lecture déplacée à ${duration.time}s.`); - - } - - playStream(stream) { - if(this.checkConnection()) return - if(this.player !== null) this.player.stop(); - - this.player = createAudioPlayer() - this.generatePlayerEvents() - - const resource = createAudioResource(stream, { inputType: StreamType.Arbitrary }); - - this.setCurrentResource(resource) - this.player.play(resource); - this.connection.subscribe(this.player); - process.emit("PLAYERS_UPDATE") + const time = typeof duration === "object" && duration !== null ? duration.time : duration + return this.seek(time) } + // Compatibilité : position en secondes getDuration() { - // Return the duration of player - - if(this.checkConnection()) return - if(this.queue.current == null) return - if(this.currentResource == null) return - return this.currentResource.playbackDuration / 1000 - - } - - setCurrentResource(value) { - this.currentResource = value; - } - - changeChannel(channel) { - if(this.checkConnection()) return - if(this.connection === null) return - if(this.connection.channelId === channel.id) return - - this.connection.destroy() - this.joinChannel(channel) - - // Si la musique est en cours de lecture, on la relance avec le bon timecode - - if(this.player) { - this.connection.subscribe(this.player); - } - process.emit("PLAYERS_UPDATE") + return this.getPositionMs() / 1000 } async skip() { - - if(this.checkConnection()) return "no_music" + if(!this.isConnected()) return "no_music" if(this.queue.next.length === 0) { return "no_music" } const songSkip = this.queue.nextSong() - this.play(songSkip) - process.emit("PLAYERS_UPDATE") + if(!songSkip) return "no_music" + this.startPlay(songSkip) return songSkip } async previous() { - - if(this.checkConnection()) return "no_music" - if(this.queue.getPrevious().length === 0) { + if(!this.isConnected()) return "no_music" + if(this.queue.getPreviousSong() === null) { return "no_music" } - const songPrevious = this.queue.previousSong() - this.play(songPrevious) - process.emit("PLAYERS_UPDATE") + if(!songPrevious) return "no_music" + this.startPlay(songPrevious) return songPrevious } } /** - * - * @param {string} guildId + * Crée le player s'il n'existe pas. Ne jamais l'appeler dans un émetteur ou une boucle d'état (utiliser AllPlayers.get). + * @param {string} guildId * @returns {Player} player */ function getPlayer(guildId) { @@ -411,14 +1014,51 @@ function isPlayer(guildId) { return AllPlayers.has(guildId) } +/** + * État d'une guilde sans player (après leave, ou jamais connecté) + * @param {string} guildId + */ +function offlineState(guildId) { + const last = LastInstances.get(guildId) + return { + v: 2, + guildId: guildId, + instanceId: last?.instanceId ?? "offline", + seq: last?.seq ?? 0, + serverTime: Date.now(), + status: STATUS.OFFLINE, + connection: { status: null, channelId: null, channelName: null }, + current: null, + position: { ms: 0, rate: 0 }, + durationMs: null, + seekable: false, + loop: false, + shuffle: false, + pendingPause: false, + loading: null, + lastError: null, + next: [], + nextCount: 0, + previous: [], + autoLeaveAt: null, + // Compatibilité avec l'ancien client + paused: false, + playing: false, + duration: 0, + playerState: false, + connectionState: false, + channelId: null, + channelName: null, + } +} -module.exports = {Player, AllPlayers, getPlayer, isPlayer, getAllPlayers} +// Heartbeat : recale régulièrement les clients des players en lecture +const heartbeat = setInterval(() => { + for(const player of AllPlayers.values()) { + if(player.status === STATUS.PLAYING) player.markDirty() + } +}, HEARTBEAT_INTERVAL) +heartbeat.unref?.() -/* - -You can access created connections elsewhere in your code without having to track the connections yourself. It is best practice to not track the voice connections yourself as you may forget to clean them up once they are destroyed, leading to memory leaks. - -const connection = getVoiceConnection(myVoiceChannel.guild.id); - -*/ \ No newline at end of file +module.exports = {Player, AllPlayers, getPlayer, isPlayer, getAllPlayers, offlineState, STATUS} diff --git a/src/player/Song.js b/src/player/Song.js index a3141c7..8ae810d 100644 --- a/src/player/Song.js +++ b/src/player/Song.js @@ -3,6 +3,7 @@ const {LogType} = require('loguix') const clog = new LogType("Song") const MediaInformation = require('../media/MediaInformation') const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter'); +const { toSafeDuration } = require('../utils/SafeMetric'); class Song { title = "Aucun titre"; @@ -11,11 +12,12 @@ class Song { authorId; url; thumbnail = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png" ; - duration; + // Durée en secondes : toujours un nombre, 0 = live ou inconnue + duration = 0; readduration; - form = "SONG"; + form = "SONG"; type; - userAddedId; + userAddedId; constructor(properties) { if(properties) { @@ -25,11 +27,12 @@ class Song { this.author = properties.author ?? this.author this.url = properties.url ?? this.url this.thumbnail = properties.thumbnail ?? this.thumbnail - this.duration = properties.duration ?? this.duration - this.readduration = properties.readduration ?? this.readduration - this.type = properties.type ?? this.type + // Durée normalisée (chaîne, NaN, négatif ou aberrant => 0) : ne fausse plus les statistiques + this.duration = toSafeDuration(properties.duration) + // Jamais de confiance dans le readduration reçu : recalculé depuis la durée normalisée + this.readduration = getReadableDuration(this.duration) this.authorId = properties.authorId ?? this.authorId - + } } @@ -41,14 +44,16 @@ class Song { this.url = media.attachment.url this.id = media.attachment.name this.type = "attachment" - + // In face, duration is null, get the metadata of the file to get the duration await MediaInformation.getMediaInformation(this, media) + this.duration = toSafeDuration(this.duration) + this.readduration = getReadableDuration(this.duration) } else { clog.error("Impossible de traiter le média") } - + } async processYoutubeVideo(video) { @@ -60,11 +65,12 @@ class Song { this.type = "youtube" this.id = video.videoId - this.duration = video.duration.seconds + // 0 pour les lives (affichés en LIVE côté client) + this.duration = toSafeDuration(video?.duration?.seconds) this.readduration = getReadableDuration(this.duration) return this - } - + } + } module.exports = {Song} diff --git a/src/player/SongCheck.js b/src/player/SongCheck.js index a936313..70c75a3 100644 --- a/src/player/SongCheck.js +++ b/src/player/SongCheck.js @@ -2,43 +2,109 @@ const {LogType} = require("loguix") const {Song} = require("./Song") const slog = new LogType("SongCheck") +// Types de sources que le player sait lire +const KNOWN_TYPES = ["youtube", "soundcloud", "attachment"] + +// Hôtes autorisés par type : le serveur ne contacte jamais une URL arbitraire fournie par un client (SSRF) +const ALLOWED_HOSTS = { + youtube: (host) => host === "youtu.be" || host === "youtube.com" || host.endsWith(".youtube.com"), + soundcloud: (host) => host === "soundcloud.com" || host.endsWith(".soundcloud.com"), + attachment: (host) => host === "cdn.discordapp.com" || host === "media.discordapp.net", +} + +// Bornes des champs texte (un titre de 100 Mo bloquerait chaque sauvegarde) +const MAX_TEXT = 200 +const MAX_URL = 2000 + +/** + * Vérifie qu'une URL est en http(s) et pointe vers un hôte autorisé pour le type donné + * @param {string} type + * @param {string} url + * @returns {boolean} + */ +function isAllowedUrl(type, url) { + if(typeof url !== "string" || url.length > MAX_URL) return false + if(!/^https?:\/\//i.test(url)) return false + const check = ALLOWED_HOSTS[type] + if(!check) return false + try { + const parsed = new URL(url) + if(parsed.protocol !== "http:" && parsed.protocol !== "https:") return false + if(parsed.username || parsed.password) return false + return check(parsed.hostname.toLowerCase()) + } catch(e) { + return false + } +} + +function clampText(value, max) { + if(value === undefined || value === null) return undefined + if(typeof value !== "string" && typeof value !== "number") return undefined + const text = String(value) + return text.length > max ? text.slice(0, max) : text +} + +/** + * Vérifie qu'une musique peut être lue : type connu et URL autorisée pour ce type. + * Reste tolérant pour les anciennes entrées (titre, auteur ou durée manquants : valeurs par défaut). + * @param {Object} song + * @returns {boolean} + */ function checkSong(song) { - if(!(song instanceof Song)) { - slog.error("La musique n'est pas une instance de la classe Song") - // Check if the song is valid and if it has all the required properties - if(song.title && song.id && song.author && song.url && song.duration && song.readduration && song.type) { - slog.log("Acceptation de la musique : " + song.title) - return true - } else { - slog.error("La musique n'est pas valide") - return false - } + if(!song || typeof song !== "object") { + slog.error("La musique n'est pas valide") + return false + } + if(!song.type || !KNOWN_TYPES.includes(song.type)) { + slog.error("La musique n'a pas de type valide : " + JSON.stringify(song.type)) + return false } if(!song.url) { slog.error("La musique n'a pas d'url") return false } - if(!song.title) { - slog.error("La musique n'a pas de titre") + if(!isAllowedUrl(song.type, song.url)) { + slog.error("L'url de la musique n'est pas autorisée pour le type " + song.type + " : " + String(song.url).slice(0, 200)) return false } - if(!song.author) { - slog.error("La musique n'a pas d'auteur") - return false - } - if(song.duration == null) { - slog.error("La musique n'a pas de durée") - return false - } - if(song.readduration == null) { - slog.error("La musique n'a pas de durée lisible") - return false - } - if(!song.type) { - slog.error("La musique n'a pas de type") - return false + if(!(song instanceof Song)) { + // Objet brut (base de données, playlist, client) : accepté, il sera normalisé par sanitizeSong + slog.log("Acceptation de la musique : " + String(song.title).slice(0, MAX_TEXT)) } return true } -module.exports = {checkSong} \ No newline at end of file +/** + * 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} diff --git a/src/playlists/Google/OAuth2.js b/src/playlists/Google/OAuth2.js index 72c21ce..e6dbe8d 100644 --- a/src/playlists/Google/OAuth2.js +++ b/src/playlists/Google/OAuth2.js @@ -3,85 +3,397 @@ const alog = new LogType("GoogleOAuth2"); const { google } = require('googleapis'); const config = require("../../utils/Database/Configuration"); const Users = require('../../server/auth/User'); +const store = require('./TokenStore'); +const { createStateStore, pickTokens, bestThumbnail } = require('./Utils'); -const clientId = config.getYoutubeApiClientId(); -const clientSecret = config.getYoutubeApiClientSecret(); -const redirectUri = config.getWebsiteLink() + "/oauth2callback"; +const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly']; +// Durée de validité d'une demande de connexion (state OAuth) +const STATE_TTL_MS = 10 * 60 * 1000; +// Délai max d'un appel aux serveurs Google (échange du code, révocation, API) +const REQUEST_TIMEOUT_MS = 20 * 1000; +const states = createStateStore({ ttlMs: STATE_TTL_MS, maxPerKey: 5 }); +// userId -> OAuth2Client avec les jetons enregistrés (recréé si la configuration change) +const clients = new Map(); +// Utilisateurs dont la synchronisation est en cours +const syncing = new Set(); -const oAuth2Map = new Map(); - +/** + * Erreur typée de l'intégration Google (message en français, affichable à l'utilisateur) + */ +class GoogleError extends Error { + constructor(code, message) { + super(message); + this.name = "GoogleError"; + this.code = code; + } +} + +// --- Configuration (lue à chaque appel : elle peut changer à chaud) --- + +function getConfig() { + const clientId = config.getYoutubeApiClientId() || ""; + const clientSecret = config.getYoutubeApiClientSecret() || ""; + const website = String(config.getWebsiteLink() || "").replace(/\/+$/, ""); + return { + clientId, + clientSecret, + redirectUri: website ? website + "/oauth2callback" : "" + }; +} + +function isConfigured() { + const { clientId, clientSecret, redirectUri } = getConfig(); + return !!(clientId && clientSecret && redirectUri); +} + +function newClient() { + const { clientId, clientSecret, redirectUri } = getConfig(); + if (!clientId || !clientSecret || !redirectUri) return null; + return new google.auth.OAuth2(clientId, clientSecret, redirectUri); +} + +function dropClient(userId) { + const client = clients.get(userId); + if (client) client.removeAllListeners("tokens"); + clients.delete(userId); +} + +function clearClients() { + for (const userId of [...clients.keys()]) dropClient(userId); +} + +// Identifiants modifiés depuis l'administration : les clients en cache utilisent les anciens +process.on("CONFIG_UPDATE", () => { + if (clients.size > 0) alog.log("Configuration modifiée : réinitialisation des clients Google OAuth2"); + clearClients(); +}); + +function withTimeout(promise, ms, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(Object.assign(new Error(`Délai dépassé (${label})`), { code: "ETIMEDOUT" })), ms); + if (typeof timer.unref === "function") timer.unref(); + }) + ]).finally(() => clearTimeout(timer)); +} + +function emitUpdate(userId) { + try { + process.emit("GOOGLE_UPDATE", userId); + } catch (e) { + alog.error("Erreur lors de l'émission de GOOGLE_UPDATE : " + (e?.message || e)); + } +} + +// --- Erreurs de l'API --- + +/** + * Traduit une erreur googleapis / gaxios en GoogleError (message français, jamais de jeton) + * @param {*} error + * @returns {GoogleError} + */ +function describeApiError(error) { + if (error instanceof GoogleError) return error; + const status = error?.response?.status ?? error?.status ?? (Number.isInteger(error?.code) ? error.code : null); + const data = error?.response?.data; + const oauthError = typeof data?.error === "string" ? data.error : null; + const reason = error?.errors?.[0]?.reason || data?.error?.errors?.[0]?.reason || null; + const message = String(error?.message || ""); + + if (oauthError === "invalid_grant" || /invalid_grant/i.test(message)) { + return new GoogleError("AUTH_EXPIRED", "L'autorisation Google a expiré ou a été révoquée : reconnectez votre compte YouTube"); + } + if (oauthError === "invalid_client" || oauthError === "unauthorized_client" || /invalid_client|unauthorized_client/i.test(message)) { + return new GoogleError("CONFIG_ERROR", "Les identifiants de l'application Google sont invalides (configuration du bot)"); + } + if (oauthError === "redirect_uri_mismatch" || /redirect_uri_mismatch/i.test(message)) { + return new GoogleError("CONFIG_ERROR", "L'URI de redirection n'est pas autorisée dans la console Google (attendu : /oauth2callback)"); + } + if (["quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded"].includes(reason)) { + return new GoogleError("QUOTA", "Quota de l'API YouTube dépassé, réessayez plus tard"); + } + if (reason === "playlistNotFound" || status === 404) { + return new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)"); + } + if (status === 401) { + return new GoogleError("AUTH_EXPIRED", "L'autorisation Google n'est plus valide : reconnectez votre compte YouTube"); + } + if (status === 403) { + return new GoogleError("FORBIDDEN", "Accès refusé par l'API YouTube" + (reason ? " (" + reason + ")" : "")); + } + if (["ENOTFOUND", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code) || /timeout|Délai dépassé/i.test(message)) { + return new GoogleError("NETWORK", "Impossible de joindre les serveurs Google, réessayez plus tard"); + } + return new GoogleError("API_ERROR", "Erreur de l'API YouTube" + (status ? " (HTTP " + status + ")" : "")); +} + +/** + * Journalise une erreur d'API et marque le compte comme invalide si l'autorisation est perdue + * @param {string} userId + * @param {*} error + * @returns {GoogleError} + */ +function handleApiError(userId, error) { + const described = describeApiError(error); + alog.error(`Erreur Google pour l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`); + if (described.code === "AUTH_EXPIRED") markInvalid(userId, described.message); + return described; +} + +function markInvalid(userId, reason) { + dropClient(userId); + const changed = store.update(userId, (record) => { + record.invalid = true; + record.invalidReason = reason || "Autorisation invalide"; + }); + if (changed) { + alog.warn(`Compte Google de l'utilisateur ${userId} marqué comme invalide : ${reason}`); + emitUpdate(userId); + } +} + +// --- Connexion du compte --- + +/** + * Génère l'URL de consentement Google pour l'utilisateur (state aléatoire lié à son id, valable 10 min) + * @param {string} userId + * @returns {string|null} URL, ou null si l'intégration n'est pas configurée + */ function createAuthUrl(userId) { - if(!checkCredientials()) return null; - var oAuth2Client; + if (!checkCredientials()) return null; const user = Users.getUserById(userId); if (!user) { - alog.error(`User with ID ${userId} not found.`); - return null; - } - if (!clientId || !clientSecret) { - alog.error("YouTube API client ID or secret is not set in the configuration."); - } else { - oAuth2Client = new google.auth.OAuth2( - clientId, - clientSecret, - redirectUri - ); - - alog.log("Google OAuth2 client initialized successfully."); - } - - if (!oAuth2Client) { - alog.error("OAuth2 client is not initialized. Please check your configuration."); + alog.error(`Utilisateur ${userId} introuvable.`); return null; } - oAuth2Map.set(userId, oAuth2Client); - alog.log(`OAuth2 client created for user ${userId}.`); - return oAuth2Client.generateAuthUrl({ + const client = newClient(); + if (!client) return null; + const state = states.create(userId); + alog.log(`Demande de connexion Google créée pour l'utilisateur ${userId}.`); + return client.generateAuthUrl({ access_type: 'offline', + // Le consentement explicite garantit l'envoi d'un refresh_token à chaque liaison + prompt: 'consent', + include_granted_scopes: true, scope: SCOPES, + state }); } -async function getAuthorization(userId, code) { - if(!checkCredientials()) return null; +/** + * Lie le compte Google de l'utilisateur à partir du code renvoyé par Google. + * @param {string} userId + * @param {string} code + * @param {string} state + * @returns {Promise} statut du compte (voir getStatus) + * @throws {GoogleError} + */ +async function linkAccount(userId, code, state) { + if (!isConfigured()) throw new GoogleError("NOT_CONFIGURED", "La connexion YouTube n'est pas configurée sur ce bot"); + if (typeof code !== "string" || code.length === 0 || code.length > 2048) { + throw new GoogleError("INVALID_PARAMS", "Code d'autorisation Google manquant ou invalide"); + } + if (!states.consume(state, userId)) { + throw new GoogleError("INVALID_STATE", "La demande de connexion a expiré ou n'est pas valide, veuillez réessayer"); + } + const client = newClient(); + let tokens; try { - const user = Users.getUserById(userId); - if (!user) { - alog.error(`User with ID ${userId} not found.`); - return null; - } - oAuth2Client = oAuth2Map.get(userId); - if (!oAuth2Client) { - alog.error(`OAuth2 client for user ${userId} not found. Please create an OAuth2 client first.`); - return null; - } - const { tokens } = await oAuth2Client.getToken(code); - oAuth2Client.setCredentials(tokens); - alog.log(`OAuth2 client credentials set for user ${userId}.`); - return oAuth2Client; + const response = await withTimeout(client.getToken(code), REQUEST_TIMEOUT_MS, "échange du code"); + tokens = pickTokens(response.tokens); } catch (error) { - alog.error(`Error during OAuth2 authorization for user ${userId}:`, error); + const described = describeApiError(error); + alog.error(`Échec de l'échange du code Google pour l'utilisateur ${userId} : ${error?.message || error}`); + // Code expiré / déjà utilisé : message dédié + if (described.code === "AUTH_EXPIRED") throw new GoogleError("TOKEN_ERROR", "Le code d'autorisation Google a expiré, veuillez réessayer"); + throw described; + } + if (!tokens.access_token) throw new GoogleError("TOKEN_ERROR", "Google n'a renvoyé aucun jeton d'accès"); + + const previous = store.get(userId); + // Google ne renvoie le refresh_token qu'au premier consentement : on garde l'ancien si besoin + if (!tokens.refresh_token && previous?.tokens?.refresh_token) tokens.refresh_token = previous.tokens.refresh_token; + if (!tokens.refresh_token) alog.warn(`Aucun refresh_token reçu pour l'utilisateur ${userId} : la liaison expirera avec le jeton d'accès`); + client.setCredentials(tokens); + + // Chaîne YouTube du compte (titre affiché, playlist "J'aime") + let channel = null; + let likesPlaylistId = null; + try { + const youtube = google.youtube({ version: 'v3', auth: client, timeout: REQUEST_TIMEOUT_MS }); + const response = await youtube.channels.list({ part: ['snippet', 'contentDetails'], mine: true, maxResults: 1 }); + const item = response?.data?.items?.[0]; + if (item) { + channel = { + id: item.id, + title: item.snippet?.title || null, + thumbnail: bestThumbnail(item.snippet?.thumbnails) + }; + likesPlaylistId = item.contentDetails?.relatedPlaylists?.likes || null; + } + } catch (error) { + // Non bloquant : un compte Google sans chaîne YouTube reste utilisable + alog.warn(`Impossible de lire la chaîne YouTube de l'utilisateur ${userId} : ${error?.message || error}`); + } + + dropClient(userId); + store.set(userId, { + tokens, + linkedAt: new Date().toISOString(), + channel, + likesPlaylistId, + lastSync: previous?.lastSync || null, + invalid: false, + invalidReason: null + }); + attachClient(userId, client); + alog.log(`Compte Google lié pour l'utilisateur ${userId}` + (channel?.title ? ` (chaîne : ${channel.title})` : "")); + emitUpdate(userId); + return getStatus(userId); +} + +/** + * Enregistre un client en cache et persiste les jetons rafraîchis par la librairie + */ +function attachClient(userId, client) { + client.on('tokens', (fresh) => { + const picked = pickTokens(fresh); + // Compte délié entre-temps : rien à enregistrer + store.update(userId, (record) => { + record.tokens = { ...(record.tokens || {}), ...picked }; + }); + }); + clients.set(userId, client); + return client; +} + +/** + * Client OAuth2 authentifié de l'utilisateur (jetons persistés), ou null si le compte n'est pas (ou plus) lié + * @param {string} userId + * @returns {import('google-auth-library').OAuth2Client|null} + */ +function getClient(userId) { + const record = store.get(userId); + if (!record?.tokens || record.invalid) return null; + if (clients.has(userId)) return clients.get(userId); + const client = newClient(); + if (!client) return null; + client.setCredentials(record.tokens); + return attachClient(userId, client); +} + +/** + * Délie le compte Google : révocation chez Google (au mieux) puis suppression des jetons + * @param {string} userId + * @param {{revoke?: boolean}} [options] + * @returns {Promise<{removed: boolean, revoked: boolean}>} + */ +async function unlink(userId, { revoke = true } = {}) { + const record = store.get(userId); + dropClient(userId); + states.clear(userId); + if (!record) return { removed: false, revoked: false }; + const token = record.tokens?.refresh_token || record.tokens?.access_token; + // Suppression locale d'abord : même si Google ne répond pas, plus rien n'est conservé + store.remove(userId); + emitUpdate(userId); + let revoked = false; + if (revoke && token) { + try { + // La révocation ne nécessite pas les identifiants de l'application + await withTimeout(new google.auth.OAuth2().revokeToken(token), REQUEST_TIMEOUT_MS, "révocation"); + revoked = true; + } catch (error) { + alog.warn(`Révocation du jeton Google impossible pour l'utilisateur ${userId} : ${error?.message || error}`); + } + } + alog.log(`Compte Google délié pour l'utilisateur ${userId}` + (revoked ? " (jeton révoqué)" : "")); + return { removed: true, revoked }; +} + +/** + * Statut du compte Google de l'utilisateur (jamais de jeton) + * @param {string} userId + */ +function getStatus(userId) { + const record = store.get(userId); + const linked = !!record?.tokens; + return { + configured: isConfigured(), + linked, + valid: linked && !record.invalid, + invalidReason: linked && record.invalid ? (record.invalidReason || null) : null, + channel: linked ? (record.channel || null) : null, + linkedAt: linked ? (record.linkedAt || null) : null, + lastSync: linked ? (record.lastSync || null) : null, + syncing: syncing.has(userId) + }; +} + +function isLinked(userId) { + const record = store.get(userId); + return !!(record?.tokens && !record.invalid); +} + +function getRecord(userId) { + return store.get(userId); +} + +function setSyncing(userId, value) { + if (value) syncing.add(userId); else syncing.delete(userId); + emitUpdate(userId); +} + +function isSyncing(userId) { + return syncing.has(userId); +} + +function setLastSync(userId, lastSync) { + store.update(userId, (record) => { + record.lastSync = lastSync; + }); +} + +// Compatibilité : ancien nom (renvoie le client authentifié ou null) +async function getAuthorization(userId, code, state) { + try { + await linkAccount(userId, code, state); + return getClient(userId); + } catch (error) { + alog.error(`Erreur lors de l'autorisation OAuth2 de l'utilisateur ${userId} : ` + (error?.message || error)); return null; } - - } function checkCredientials() { - if (!clientId || !clientSecret) { - alog.error("YouTube API client ID or secret is not set in the configuration."); + if (!isConfigured()) { + alog.warn("Identifiants de l'API YouTube (clientId / clientSecret) ou lien du site absents de la configuration."); return false; } return true; -} +} module.exports = { + SCOPES, + GoogleError, + isConfigured, createAuthUrl, + linkAccount, getAuthorization, - getOAuth2Client: (userId) => oAuth2Map.get(userId), - oAuth2Map + getClient, + getOAuth2Client: getClient, + unlink, + getStatus, + isLinked, + getRecord, + setSyncing, + isSyncing, + setLastSync, + describeApiError, + handleApiError, + markInvalid, + REQUEST_TIMEOUT_MS }; - -const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly']; - diff --git a/src/playlists/Google/TokenStore.js b/src/playlists/Google/TokenStore.js new file mode 100644 index 0000000..5c3c019 --- /dev/null +++ b/src/playlists/Google/TokenStore.js @@ -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 +} diff --git a/src/playlists/Google/Utils.js b/src/playlists/Google/Utils.js new file mode 100644 index 0000000..4175300 --- /dev/null +++ b/src/playlists/Google/Utils.js @@ -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 +} diff --git a/src/playlists/Google/YoutubeList.js b/src/playlists/Google/YoutubeList.js index 10759ae..a5cb94c 100644 --- a/src/playlists/Google/YoutubeList.js +++ b/src/playlists/Google/YoutubeList.js @@ -3,60 +3,245 @@ const { LogType } = require('loguix'); const alog = new LogType("YoutubeAPI"); const OAuth2 = require('./OAuth2'); -const Users = require('../../server/auth/User'); +const { GoogleError } = OAuth2; +const { collectPages, chunk, parseIsoDuration, bestThumbnail } = require('./Utils'); -async function getYoutubePlaylists(userId) { - const user = Users.getUserById(userId); - if (!user) { - alog.error(`User with ID ${userId} not found.`); - return null; - } +// Limites d'une synchronisation (quota YouTube : 1 unité par page de 50) +const MAX_PLAYLISTS = 50; +const MAX_ITEMS_PER_PLAYLIST = 500; +const PAGE_SIZE = 50; +const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png"; +// Titre de la playlist "J'aime" (Google renvoie "Liked videos" ou rien) +const LIKES_TITLE = "Vidéos « J'aime »"; - const oAuth2Client = OAuth2.getOAuth2Client(userId); - if (!oAuth2Client) { - alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`); - return null; - } - - const youtube = google.youtube({ version: 'v3', auth: oAuth2Client }); +/** + * Service YouTube authentifié pour l'utilisateur + * @param {string} userId + * @throws {GoogleError} NOT_LINKED + */ +function getService(userId) { + const auth = OAuth2.getClient(userId); + if (!auth) throw new GoogleError("NOT_LINKED", "Aucun compte YouTube valide n'est lié"); + return google.youtube({ version: 'v3', auth, timeout: OAuth2.REQUEST_TIMEOUT_MS }); +} +/** + * Exécute un appel à l'API ; toute erreur est traduite (et le compte marqué invalide si besoin) + */ +async function call(userId, fn) { try { - const response = await youtube.playlists.list({ - part: 'snippet,contentDetails', - mine: true, - maxResults: 50 - }); - alog.log(`Retrieved playlists for user ${userId}.`); - return response.data.items; + return await fn(); } catch (error) { - alog.error(`Error retrieving playlists for user ${userId}:`, error); - return null; + throw OAuth2.handleApiError(userId, error); } } -function getYoutubePlaylistSongs(playlistId, userId) { - const user = Users.getUserById(userId); - if (!user) { - alog.error(`User with ID ${userId} not found.`); - return null; +/** + * Playlists de l'utilisateur (paginées, limitées à MAX_PLAYLISTS) + * @param {string} userId + * @param {{max?: number}} [options] + * @returns {Promise} é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} + */ +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} [cache] durées déjà connues (partagées entre playlists) + * @returns {Promise>} id -> secondes (absent si inconnue) + */ +async function getVideoDurations(userId, ids, cache = new Map()) { + const youtube = getService(userId); + const missing = [...new Set(ids.filter(id => id && !cache.has(id)))]; + for (const ids50 of chunk(missing, 50)) { + const response = await call(userId, () => youtube.videos.list({ + part: ['contentDetails'], + id: ids50, + maxResults: 50 + })); + for (const video of response?.data?.items || []) { + const seconds = parseIsoDuration(video?.contentDetails?.duration); + if (seconds !== null) cache.set(video.id, seconds); + } } + return cache; +} - const oAuth2Client = OAuth2.getOAuth2Client(userId); - if (!oAuth2Client) { - alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`); - return null; +/** + * Convertit les éléments de playlistItems.list en musiques jouables (vidéos privées / supprimées ignorées) + * @param {Array} items + * @param {Map} durations + */ +function toSongs(items, durations) { + const songs = []; + const seen = new Set(); + for (const item of items) { + const snippet = item?.snippet; + const videoId = item?.contentDetails?.videoId || snippet?.resourceId?.videoId; + if (!videoId || seen.has(videoId)) continue; + // Vidéo privée ou supprimée : aucune chaîne propriétaire + if (!snippet?.videoOwnerChannelId) continue; + seen.add(videoId); + songs.push({ + id: videoId, + title: snippet.title || "Aucun titre", + author: snippet.videoOwnerChannelTitle || "Auteur inconnu", + authorId: `https://www.youtube.com/channel/${snippet.videoOwnerChannelId}`, + url: `https://www.youtube.com/watch?v=${videoId}`, + thumbnail: bestThumbnail(snippet.thumbnails) || DEFAULT_THUMBNAIL, + duration: durations.get(videoId) ?? 0, + type: "youtube" + }); } + return songs; +} - const youtube = google.youtube({ version: 'v3', auth: oAuth2Client }); +/** + * Playlist complète (métadonnées + musiques + durées) au format neutre utilisé par PlaylistManager + * @param {string} userId + * @param {object} info élément playlists.list, ou {id, snippet} construit pour la playlist "J'aime" + * @param {Map} cache durées partagées + */ +async function buildPlaylist(userId, info, cache) { + const { items, truncated } = await getPlaylistItems(userId, info.id); + const ids = items.map(item => item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId).filter(Boolean); + await getVideoDurations(userId, ids, cache); + const songs = toSongs(items, cache); + if (truncated) alog.warn(`Playlist YouTube ${info.id} limitée à ${MAX_ITEMS_PER_PLAYLIST} éléments`); + return { + youtubeId: info.id, + title: info.snippet?.title || "Playlist YouTube", + description: info.snippet?.description || "", + author: info.snippet?.channelTitle || "Auteur inconnu", + authorId: info.snippet?.channelId ? `https://www.youtube.com/channel/${info.snippet.channelId}` : null, + thumbnail: bestThumbnail(info.snippet?.thumbnails) || songs[0]?.thumbnail || DEFAULT_THUMBNAIL, + url: `https://www.youtube.com/playlist?list=${info.id}`, + songs, + truncated + }; +} - return youtube.playlistItems.list({ - part: 'snippet', - playlistId: playlistId, - maxResults: 50 - }); +function likesInfo(userId) { + const record = OAuth2.getRecord(userId); + if (!record?.likesPlaylistId) return null; + return { + id: record.likesPlaylistId, + snippet: { + title: LIKES_TITLE, + description: "Vidéos aimées sur YouTube", + channelTitle: record.channel?.title || null, + channelId: record.channel?.id || null, + thumbnails: null + } + }; +} + +/** + * Récupère toutes les playlists de l'utilisateur (et ses « J'aime ») avec leurs musiques. + * Une playlist en erreur est ignorée ; une perte d'autorisation ou un quota épuisé interrompt tout. + * @param {string} userId + * @returns {Promise<{playlists: Array, skipped: number}>} + */ +async function fetchLibrary(userId) { + const infos = await getYoutubePlaylists(userId); + const likes = likesInfo(userId); + if (likes && !infos.some(info => info.id === likes.id)) infos.unshift(likes); + + const cache = new Map(); + const playlists = []; + let skipped = 0; + for (const info of infos) { + if (!info?.id) continue; + try { + playlists.push(await buildPlaylist(userId, info, cache)); + } catch (error) { + if (["AUTH_EXPIRED", "QUOTA", "CONFIG_ERROR", "NOT_LINKED"].includes(error?.code)) throw error; + skipped++; + alog.warn(`Playlist YouTube ${info.id} ignorée : ${error?.message || error}`); + } + } + return { playlists, skipped }; +} + +/** + * Récupère une seule playlist (rafraîchissement d'une playlist importée) + * @param {string} userId + * @param {string} youtubeId + * @returns {Promise} + * @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 -}; \ No newline at end of file + getYoutubePlaylistSongs, + getPlaylistItems, + getVideoDurations, + fetchLibrary, + fetchPlaylist, + toSongs +}; diff --git a/src/playlists/History.js b/src/playlists/History.js index 1cf041e..c056fe5 100644 --- a/src/playlists/History.js +++ b/src/playlists/History.js @@ -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} @@ -40,7 +75,7 @@ function addToPersonalHistory(userId, entry) { if (history.length >= 25) { history.shift(); } - history.push(entry) + history.push(sanitizeEntry(entry)) historyDb.save(); } diff --git a/src/playlists/Playlist.js b/src/playlists/Playlist.js index 05dcc85..b561867 100644 --- a/src/playlists/Playlist.js +++ b/src/playlists/Playlist.js @@ -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} 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}; diff --git a/src/playlists/PlaylistManager.js b/src/playlists/PlaylistManager.js index 5076798..be4d47c 100644 --- a/src/playlists/PlaylistManager.js +++ b/src/playlists/PlaylistManager.js @@ -1,22 +1,110 @@ const {Database} = require('../utils/Database/Database'); const {__glob} = require('../utils/GlobalVars'); -const {Playlist} = require('./Playlist'); +const {Playlist, computeDuration} = require('./Playlist'); const {LogType} = require('loguix'); const clog = new LogType("PlaylistManager"); const Finder = require('../player/Finder'); const spotify = require('../media/SpotifyInformation'); -const { getYoutubePlaylistSongs } = require('./Google/YoutubeList'); -const { auth } = require('googleapis/build/src/apis/abusiveexperiencereport'); +const YoutubeList = require('./Google/YoutubeList'); +const GoogleOAuth2 = require('./Google/OAuth2'); const { getReadableDuration } = require('../utils/TimeConverter'); -const { getSecondsFromUrl } = require('../media/YoutubeInformation'); +const { toSafeDuration } = require('../utils/SafeMetric'); +const songCheck = require('../player/SongCheck'); + +// Délai minimal entre deux synchronisations YouTube réussies d'un même utilisateur (quota de l'API) +const SYNC_COOLDOWN_MS = 60 * 1000; const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {}); +/** + * Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...) + */ +function sameId(a, b) { + if (a === undefined || a === null || b === undefined || b === null) return false; + return String(a) === String(b); +} -/** -* @param {string} id -* @param {string} name +/** + * Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de l'utilisateur + * (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation) + * @param {Array} userPlaylists + * @returns {string} + */ +function newPlaylistId(userPlaylists) { + let candidate = Date.now(); + const used = new Set((userPlaylists || []).map(p => String(p?.playlistId))); + while (used.has(String(candidate))) candidate++; + return String(candidate); +} + +/** + * Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible. + * Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée. + * @returns {boolean} true si la musique a été modifiée + */ +function sanitizeSongDuration(song) { + if (!song || typeof song !== "object") return false; + const duration = toSafeDuration(song.duration); + const readduration = getReadableDuration(duration); + if (song.duration === duration && song.readduration === readduration) return false; + song.duration = duration; + song.readduration = readduration; + return true; +} + +/** + * Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne) + * @returns {boolean} true si la playlist a été modifiée + */ +function refreshPlaylistDuration(playlist) { + if (!playlist || typeof playlist !== "object") return false; + if (!Array.isArray(playlist.songs)) playlist.songs = []; + let changed = false; + for (const song of playlist.songs) { + if (sanitizeSongDuration(song)) changed = true; + } + const duration = computeDuration(playlist.songs); + const readduration = getReadableDuration(duration); + if (playlist.duration !== duration || playlist.readduration !== readduration) { + playlist.duration = duration; + playlist.readduration = readduration; + changed = true; + } + return changed; +} + +// Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN) +// et donne un identifiant (chaîne) aux playlists qui n'en ont pas (anciens imports YouTube) +try { + let repaired = 0; + let identified = 0; + for (const userId of Object.keys(playlistDB.data || {})) { + const userPlaylists = playlistDB.data[userId]; + if (!Array.isArray(userPlaylists)) continue; + for (const playlist of userPlaylists) { + if (refreshPlaylistDuration(playlist)) repaired++; + if (playlist && typeof playlist === "object") { + if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") { + playlist.playlistId = newPlaylistId(userPlaylists); + identified++; + } else if (typeof playlist.playlistId !== "string") { + playlist.playlistId = String(playlist.playlistId); + identified++; + } + } + } + } + if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`); + if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`); + if (repaired > 0 || identified > 0) playlistDB.save(); +} catch (e) { + clog.error("Erreur lors de la vérification des playlists : " + e); +} + + +/** +* @param {string} id * @returns {Array} * @description Renvoie la liste des playlists de l'utilisateur */ @@ -30,17 +118,17 @@ function getPlaylistsOfUser(id) { playlistDB.save(); return playlistDB.data[id]; } - + } /** - * @param {string} id - * @param {string} playlistId + * @param {string} id + * @param {string} playlistId * @returns {Playlist} */ function getPlaylistOfUser(id, playlistId) { const playlists = getPlaylistsOfUser(id); - const playlist = playlists.find(p => p.playlistId === playlistId); + const playlist = playlists.find(p => sameId(p.playlistId, playlistId)); if (!playlist) { clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); return null; @@ -64,75 +152,76 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) { } if(playlistFounded instanceof Playlist) { playlist = playlistFounded; - } - if(playlist.type === "spotify") { - playlist.songs = await spotify.getTracks(playlist); + } + if(!failed && playlist.type === "spotify") { + // getTracks remplace playlist.songs et renvoie la playlist elle-même + // (ne pas l'assigner à playlist.songs : référence circulaire impossible à sauvegarder) + await spotify.getTracks(playlist); } }) } - playlist.playlistId = new String(Date.now()); if(failed) { clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`); return null; } + refreshPlaylistDuration(playlist); + // Chaîne primitive : un objet String ne serait jamais égal (===) à l'identifiant envoyé par le client + playlist.playlistId = newPlaylistId(playlists); playlists.push(playlist); playlistDB.save(); clog.log(`Ajout de la playlist ${name} pour l'utilisateur ${id}`); return playlist; -} +} function removePlaylist(id, playlistId) { const playlists = getPlaylistsOfUser(id); - const index = playlists.findIndex(p => p.playlistId === playlistId); + const index = playlists.findIndex(p => sameId(p.playlistId, playlistId)); if (index === -1) { clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); - return; + return false; } playlists.splice(index, 1); playlistDB.save(); clog.log(`Suppression de la playlist ${playlistId} pour l'utilisateur ${id}`); + return true; } function getPlaylist(id, playlistId) { - const playlists = getPlaylistsOfUser(id); - const playlist = playlists.find(p => p.playlistId === playlistId); - if (!playlist) { - clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); - return null; - } - return playlist; + return getPlaylistOfUser(id, playlistId); } +/** + * Copie une playlist vers un autre utilisateur (copie indépendante, nouvel identifiant). + * Refusée si le destinataire possède déjà une playlist du même titre. + * @returns {Playlist|null} la copie, ou null + */ function copyPlaylist(fromId, toId, playlistId) { - const playlists = getPlaylistsOfUser(fromId); - const playlist = playlists.find(p => p.playlistId === playlistId); - if (!playlist) { - clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${fromId}`); - return null; - } + const playlist = getPlaylistOfUser(fromId, playlistId); + if (!playlist) return null; const toPlaylists = getPlaylistsOfUser(toId); - // Check if the playlist already exists in the target user + // Doublon : même titre chez le destinataire (Playlist n'a pas de champ "name") if (toPlaylists.find(p => p.title === playlist.title)) { clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`); return null; } - toPlaylists.push(playlist); + // Copie profonde : un même objet partagé entre deux utilisateurs serait modifié des deux côtés + const copy = JSON.parse(JSON.stringify(playlist)); + copy.playlistId = newPlaylistId(toPlaylists); + // Une playlist synchronisée appartient au compte Google de l'expéditeur : la copie devient une playlist classique + delete copy.source; + toPlaylists.push(copy); playlistDB.save(); clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`); - - return false; + return copy; } function renamePlaylist(id, playlistId, newName) { - const playlists = getPlaylistsOfUser(id); - const playlist = playlists.find(p => p.playlistId === playlistId); - if (!playlist) { - clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); - return null; - } + const playlist = getPlaylistOfUser(id, playlistId); + if (!playlist) return null; playlist.title = newName; playlistDB.save(); clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`); + return playlist; } function addSong(id, playlistId, song) { @@ -149,121 +238,157 @@ function addSong(id, playlistId, song) { clog.error(`La chanson ${song} n'est pas valide`); return null; } - const playlists = getPlaylistsOfUser(id); - const playlist = playlists.find(p => p.playlistId === playlistId); - if (!playlist) { - clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); - return null; - } - // Check the integrity of the song - if (!song.id || !song.title || !song.url) { - clog.error(`La chanson ${song.title} n'est pas valide`); + const playlist = getPlaylistOfUser(id, playlistId); + if (!playlist) return null; + // Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement + song = songCheck.sanitizeSong(song); + if (!song || !song.id || !song.title) { + clog.error(`La chanson reçue n'est pas valide`); return null; } + if (!Array.isArray(playlist.songs)) playlist.songs = []; playlist.songs.push(song); - // Recalculate the songs duration and readduration - playlist.duration += song.duration; - playlist.readduration = getReadableDuration(playlist.duration); + // Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue) + refreshPlaylistDuration(playlist); playlistDB.save(); clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`); + return playlist; } function removeSong(id, playlistId, songId) { - const playlists = getPlaylistsOfUser(id); - const playlist = playlists.find(p => p.playlistId === playlistId); - if (!playlist) { - clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); - return null; - } + const playlist = getPlaylistOfUser(id, playlistId); + if (!playlist) return null; + if (!Array.isArray(playlist.songs)) playlist.songs = []; const index = playlist.songs.findIndex(s => s.id === songId); if (index === -1) { clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`); return null; } - playlist.duration -= playlist.songs[index].duration; playlist.songs.splice(index, 1); - playlist.readduration = getReadableDuration(playlist.duration); + // Recalcul complet plutôt qu'une soustraction (durée manquante → NaN, résultat négatif...) + refreshPlaylistDuration(playlist); playlistDB.save(); clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`); + return playlist; } -async function processYoutubeData(userId, data) { - if (!data || data.length === 0) { +// --- YOUTUBE (compte Google lié) --- + +/** + * Construit une playlist enregistrable à partir des données neutres de YoutubeList + * @param {object} data résultat de YoutubeList.buildPlaylist + * @param {string} playlistId identifiant local (conservé d'une synchronisation à l'autre) + * @returns {Playlist} + */ +function playlistFromYoutube(data, playlistId) { + const playlist = new Playlist(data.title, data.url, data.author, data.authorId, data.songs, data.thumbnail, null, null, data.description || "Aucune description disponible"); + playlist.id = data.youtubeId; + playlist.youtubeId = data.youtubeId; + // "google" : rafraîchie via l'API YouTube avec le compte du propriétaire (playlists privées, "J'aime") + playlist.source = "google"; + playlist.type = "youtube"; + playlist.playlistId = playlistId; + playlist.syncedAt = new Date().toISOString(); + refreshPlaylistDuration(playlist); + return playlist; +} + +/** + * Index d'une playlist déjà importée correspondant à une playlist YouTube + */ +function findImportedIndex(userPlaylists, youtubeId) { + const synced = userPlaylists.findIndex(p => p?.source === "google" && p.youtubeId === youtubeId); + if (synced !== -1) return synced; + // Anciens imports (sans "source") : id = identifiant YouTube + return userPlaylists.findIndex(p => p?.type === "youtube" && !p.source && p.id === youtubeId); +} + +/** + * Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur. + * Les playlists déjà importées gardent leur identifiant local et leur position. + * @param {string} userId + * @param {Array} data + * @returns {Array} + */ +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 -} \ No newline at end of file +} diff --git a/src/server/Admin.js b/src/server/Admin.js new file mode 100644 index 0000000..cb6c17a --- /dev/null +++ b/src/server/Admin.js @@ -0,0 +1,2072 @@ +/** + * [Subsonics Chopin - Backend] - Interface d'administration générale + * Toutes les routes Socket.IO "/ADMIN/*" : vue d'ensemble, logs, comptes, serveurs Discord, players, WARP, YouTube, + * métriques et maintenance. Contrat : réponse {ok:true, data} ou {ok:false, error, message} via l'ack + * (ou socket.emit(name, env) sans ack). + * + * - attach({ io, socket, socketUser }) : enregistre les routes pour un socket authentifié, SANS condition : + * les droits sont revérifiés à chaque appel sur l'utilisateur relu dans users.json (jamais un objet périmé). + * - init(io) : poussées globales vers les rooms "ADMIN" et "ADMIN_LOGS" (appelé une seule fois). + */ +const os = require('os') +const fs = require('fs') +const path = require('path') +const { LogType } = require('loguix') + +const users = require('./auth/User') +const session = require('./auth/Session') +const players = require('../player/Player') +const discordBot = require('../discord/Bot') +const serverSettings = require('../discord/ServerSettings') +const playlists = require('../playlists/PlaylistManager') +const warp = require('../utils/WarpProxy') +const SafeMetric = require('../utils/SafeMetric') +const LogReader = require('../utils/LogReader') +const maintenance = require('../utils/Maintenance') +const configuration = require('../utils/Database/Configuration') +const { getDatabase } = require('../utils/Database/Database') +const { __glob } = require('../utils/GlobalVars') +const youtube = require('../youtube') + +const alog = new LogType("Admin") + +const ADMIN_ROOM = "ADMIN" +const LOGS_ROOM = "ADMIN_LOGS" +const OVERVIEW_INTERVAL = 10 * 1000 +const WARP_CHECK_TIMEOUT = 10 * 1000 +const SNOWFLAKE = /^\d{5,25}$/ +// YouTube : attente max de la version de yt-dlp avant de répondre (le sondage continue en arrière-plan) +const YOUTUBE_VERSION_WAIT = 3000 +// Import depuis un navigateur : sous le délai de 60 s du client +const YOUTUBE_BROWSER_TIMEOUT = 50 * 1000 +const YOUTUBE_RECENT = 50 +const COOKIES_MAX_LENGTH = 1000000 +const COOKIE_IMPORT_SOURCES = ["paste", "file"] +const YOUTUBE_BROWSERS = ["firefox", "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "safari"] +// Navigateurs Chromium : base de cookies verrouillée / chiffrée sous Windows +const CHROMIUM_BROWSERS = ["chrome", "chromium", "edge", "brave", "opera", "vivaldi"] +const YOUTUBE_TEST_ROUTES = ["auto", "warp", "direct"] +const YOUTUBE_TEST_IDENTITIES = ["auto", "anon", "cookies"] +const YOUTUBE_SETTINGS_MAX_KEYS = 20 +// Test des identifiants Spotify (jeton client credentials) +const SPOTIFY_TEST_TIMEOUT = 15 * 1000 +// Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger +const YTDLP_PROCESS_POLL = 2000 +// Jamais "config" : contient le token du bot et les secrets +const DATABASES = ["Users", "Playlists", "history", "media", "previous", "server_settings"] +const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"] +const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"] +const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"] + +let io = null +let initialized = false +let browserImportPending = false + +class AdminError extends Error { + /** + * @param {"FORBIDDEN"|"INVALID_PARAMS"|"NOT_FOUND"|"SELF_ACTION"|"TARGET_PROTECTED"|"UNAVAILABLE"|"INTERNAL"} code + * @param {string} [message] + */ + constructor(code, message) { + super(message || code) + this.code = code + } +} + +// UTILITAIRES + +function safeNum(value) { + const n = Number(value) + return Number.isFinite(n) ? n : 0 +} + +function toIso(value) { + if (value === null || value === undefined || value === "") return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function asObject(payload) { + return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {} +} + +function requireId(value, name = "userId") { + if (typeof value !== "string" || !SNOWFLAKE.test(value)) { + throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide`) + } + return value +} + +function requireBool(value, name = "value") { + if (typeof value !== "boolean") { + throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (booléen attendu)`) + } + return value +} + +function requireString(value, name, min, max) { + if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`) + const text = value.trim() + if (text.length < min || text.length > max) { + throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (${min} à ${max} caractères)`) + } + return text +} + +function optionalString(value, name, max) { + if (value === undefined || value === null) return null + if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`) + const text = value.trim() + if (text.length > max) throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" trop long (${max} caractères max)`) + return text === "" ? null : text +} + +function optionalInt(value, name, min, max, fallback) { + if (value === undefined || value === null) return fallback + const n = Number(value) + if (!Number.isInteger(n) || n < min || n > max) { + throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (entier entre ${min} et ${max})`) + } + return n +} + +/** + * Les anciens endpoints acceptent un userId en chaîne, les nouveaux un objet {userId} + */ +function userIdFrom(payload) { + if (typeof payload === "string") return requireId(payload) + return requireId(asObject(payload).userId) +} + +function describeUser(user) { + return `${user?.identity?.username ?? "inconnu"} (${user?.identity?.id ?? "?"})` +} + +/** + * Journalise chaque action mutante + */ +function audit(me, action, target = "") { + alog.warn(`[ADMIN] ${me.identity.username} -> ${action}${target ? " " + target : ""}`) +} + +function roomSize(room) { + return io?.sockets.adapter.rooms.get(room)?.size || 0 +} + +function getClient() { + return discordBot.getClient() +} + +function getYoutubeProcesses() { + try { + const map = require('../player/Method/Youtube').activeProcesses + return map instanceof Map ? map : null + } catch (e) { + return null + } +} + +function getMediaBase() { + try { + return require('../discord/MediaBase') + } catch (e) { + return null + } +} + +function dbData(name, fallback) { + const data = getDatabase(name)?.data + return data === undefined || data === null ? fallback : data +} + +/** + * Coalesce les appels : fn est exécutée au plus une fois par délai + */ +function debounce(fn, delay) { + let timer = null + return () => { + if (timer) return + timer = setTimeout(async () => { + timer = null + try { + await fn() + } catch (e) { + alog.error(`Poussée admin impossible : ${e?.stack || e}`) + } + }, delay) + timer.unref?.() + } +} + +// TYPES SÉRIALISÉS + +function userLite(identity) { + return { + id: identity?.id ?? null, + username: identity?.username ?? null, + global_name: identity?.global_name ?? null, + avatar: identity?.avatar ?? null + } +} + +function songLite(song) { + if (!song || typeof song !== "object") return null + const duration = Number(song.duration) + return { + id: song.id ?? null, + title: song.title ?? null, + author: song.author ?? null, + authorId: song.authorId ?? null, + url: song.url ?? null, + thumbnail: song.thumbnail ?? null, + duration: Number.isFinite(duration) && duration > 0 ? duration : null, + readduration: song.readduration ?? null, + type: song.type ?? null + } +} + +/** + * Index des sockets authentifiés par utilisateur (socket.data.userId posé par Server.js à la connexion) + * @returns {Map} + */ +function socketIndex() { + const byUser = new Map() + if (!io) return byUser + for (const socket of io.sockets.sockets.values()) { + const userId = socket.data?.userId + if (!userId) continue + if (!byUser.has(userId)) byUser.set(userId, []) + byUser.get(userId).push(socket) + } + return byUser +} + +function socketGuildId(socket) { + const guilds = discordBot.getGuilds() + for (const room of socket.rooms) { + if (guilds.has(room)) return room + } + return null +} + +/** + * Données partagées pour construire plusieurs résumés sans tout recalculer + */ +function buildContext() { + const guildsByUser = new Map() + for (const [guildId, guild] of discordBot.getGuilds()) { + for (const memberId of guild.allMembers || []) { + if (!guildsByUser.has(memberId)) guildsByUser.set(memberId, []) + guildsByUser.get(memberId).push(guildId) + } + } + const mediaCounts = new Map() + const media = dbData("media", []) + if (Array.isArray(media)) { + for (const m of media) mediaCounts.set(m?.userId, (mediaCounts.get(m?.userId) || 0) + 1) + } + const registered = new Set(users.getUsers().filter(u => !u.labels?.includes("DELETED")).map(u => u.identity.id)) + return { + sockets: socketIndex(), + guildsByUser, + playlists: dbData("Playlists", {}), + history: dbData("history", {}), + mediaCounts, + registered + } +} + +function userSummary(user, ctx = buildContext()) { + const id = user.identity.id + const labels = Array.isArray(user.labels) ? [...user.labels] : [] + const sockets = ctx.sockets.get(id) || [] + const updated = labels.find(l => l.startsWith("UPDATED[")) + let currentGuildId = null + for (const socket of sockets) { + currentGuildId = socketGuildId(socket) + if (currentGuildId) break + } + const playlistsOfUser = ctx.playlists?.[id] + const historyOfUser = ctx.history?.[id] + return { + ...userLite(user.identity), + labels, + isAdmin: labels.includes("ADMIN"), + isFullBanned: labels.includes("BAN"), + isDeleted: labels.includes("DELETED"), + modGuilds: labels.filter(l => l.startsWith("MOD_")).map(l => l.slice(4)), + bannedGuilds: labels.filter(l => l.startsWith("BAN_")).map(l => l.slice(4)), + banReason: user.banInfo?.reason ?? null, + bannedAt: toIso(user.banInfo?.at), + lastIdentityUpdate: updated ? toIso(updated.slice("UPDATED[".length, -1)) : null, + hasDiscordAuth: !!user.auth, + tokenCount: Array.isArray(user.tokens) ? user.tokens.length : 0, + online: sockets.length > 0, + socketCount: sockets.length, + currentGuildId, + guildIds: ctx.guildsByUser.get(id) || [], + playlistCount: Array.isArray(playlistsOfUser) ? playlistsOfUser.length : 0, + historyCount: Array.isArray(historyOfUser) ? historyOfUser.length : 0, + mediaCount: ctx.mediaCounts.get(id) || 0 + } +} + +function isConnectedPlayer(player) { + if (!player) return false + try { + if (typeof player.isConnected === "function") return !!player.isConnected() + } catch (e) { + return false + } + return !!player.connection +} + +function connectedPlayers() { + return players.getAllPlayers().filter(isConnectedPlayer) +} + +/** + * Statut simplifié d'un état de player (v2 : state.status ; ancien format : playerState) + */ +function playerStatus(state) { + if (state?.status) return state.status + if (state?.playing) return "playing" + if (state?.paused) return "paused" + return "idle" +} + +/** + * PlayerSummary = état v2 du player (next tronqué à 10) + informations d'administration + */ +function playerSummary(player, { full = false } = {}) { + let state + try { + state = player.getState() + } catch (e) { + alog.error(`Lecture de l'état du player ${player?.guildId} impossible : ${e?.stack || e}`) + return null + } + if (!state) return null + const guildId = state.guildId || player.guildId + const guild = discordBot.getGuilds().get(guildId) + const channelId = state.connection?.channelId ?? state.channelId ?? player.channelId ?? null + + let listeners = 0 + try { + const channel = channelId ? discordBot.getChannel(guildId, channelId) : null + listeners = channel?.members ? channel.members.filter(m => !m.user?.bot).size : 0 + } catch (e) { + listeners = 0 + } + + const proc = getYoutubeProcesses()?.get(guildId) + const next = Array.isArray(state.next) ? state.next : [] + const previous = Array.isArray(state.previous) ? state.previous : [] + // Player.lastError n'est jamais effacé : une erreur sur une autre musique que celle en cours de lecture est résolue + const lastErrorStale = !!(state.lastError && state.status === "playing" && state.current?.id + && state.lastError.songId !== state.current.id) + return { + ...state, + guildId, + lastErrorStale, + next: full ? next : next.slice(0, 10), + nextCount: Number.isFinite(state.nextCount) ? state.nextCount : next.length, + // Résumé : 20 derniers titres ; détail complet : tout l'historique renvoyé par getState() + previous: full ? previous : previous.slice(0, 20), + guildName: guild?.name ?? null, + guildIcon: guild?.icon ?? null, + listeners, + webListeners: roomSize(guildId), + ytdlp: proc ? { pid: proc.pid ?? null, route: proc.route ?? null, startedAt: toIso(proc.startedAt) } : null + } +} + +function playersList() { + return connectedPlayers().map(p => playerSummary(p)).filter(Boolean) +} + +function guildStats(guildId) { + return { + commands: safeNum(SafeMetric.get("numberOfCommands_" + guildId)), + musicPlayed: safeNum(SafeMetric.get("numberOfMusicPlayed_" + guildId)), + secondsPlayed: safeNum(SafeMetric.get("numberOfSecondsPlayed_" + guildId)) + } +} + +/** + * Copie sérialisable d'une guilde (jamais l'objet partagé de Bot.js, jamais allMembers) + */ +function guildSummary(guildData, ctx = buildContext()) { + const id = guildData.id + const discordGuild = getClient().guilds.cache.get(id) + + let missingPermissions = [] + try { + if (discordGuild?.members?.me && typeof discordBot.checkRequiredPermission === "function") { + missingPermissions = discordBot.checkRequiredPermission(discordGuild.members.me) + } + } catch (e) { + missingPermissions = [] + } + + let owner = { id: guildData.owner ?? null } + const ownerMember = discordGuild?.members?.cache.get(guildData.owner) + if (ownerMember?.user) { + owner = { + id: ownerMember.user.id, + username: ownerMember.user.username ?? null, + global_name: ownerMember.user.globalName ?? null, + avatar: ownerMember.user.avatar ?? null + } + } + + // Utilisateurs web distincts dans la room de la guilde + const webUsers = new Set() + for (const socketId of io?.sockets.adapter.rooms.get(id) || []) { + const userId = io.sockets.sockets.get(socketId)?.data?.userId + if (userId) webUsers.add(userId) + } + + const members = guildData.allMembers || [] + const secureRole = serverSettings.getSecureRole(id) + const player = players.AllPlayers.get(id) + return { + id, + name: guildData.name ?? null, + icon: guildData.icon ?? null, + banner: guildData.banner ?? null, + description: guildData.description ?? null, + owner, + memberCount: members.length, + registeredMembers: members.filter(m => ctx.registered.has(m)).length, + webUsersOnline: webUsers.size, + joinedAt: toIso(guildData.joinedAt), + createdAt: toIso(guildData.createdAt), + features: Array.isArray(guildData.features) ? [...guildData.features] : [], + secureRole: secureRole ? { id: secureRole.id ?? null, name: secureRole.name ?? null } : null, + missingPermissions, + isMediaGuild: !!id && id === configuration.getMediaGuildId(), + player: isConnectedPlayer(player) ? playerSummary(player) : null, + stats: guildStats(id) + } +} + +function guildsList(search) { + const ctx = buildContext() + let list = [...discordBot.getGuilds().values()] + if (search) { + const s = search.toLowerCase() + list = list.filter(g => (g.name || "").toLowerCase().includes(s) || g.id.includes(s)) + } + return list.map(g => guildSummary(g, ctx)).sort((a, b) => (a.name || "").localeCompare(b.name || "")) +} + +function onlineUsersPayload() { + const sockets = socketIndex() + const online = [] + for (const [userId, list] of sockets) { + const user = users.getUserById(userId) + if (!user) continue + let guildId = null + for (const socket of list) { + guildId = socketGuildId(socket) + if (guildId) break + } + online.push({ ...userLite(user.identity), socketCount: list.length, guildId }) + } + const registered = users.getUsers().filter(u => !u.labels?.includes("DELETED")).length + return { online, counts: { registered, online: online.length } } +} + +// MÉTRIQUES + +function metricsData() { + const all = SafeMetric.getAll() + const raw = all.map(m => ({ + name: m.name, + description: m.description ?? m.name, + value: safeNum(m.value), + invalid: !Number.isFinite(Number(m.value)) + })) + const byGuildMap = new Map() + const ensureGuild = (guildId) => { + if (!byGuildMap.has(guildId)) { + byGuildMap.set(guildId, { + guildId, + guildName: discordBot.getGuilds().get(guildId)?.name ?? null, + commands: 0, + musicPlayed: 0, + secondsPlayed: 0 + }) + } + return byGuildMap.get(guildId) + } + const byUser = [] + let commands = 0 + for (const m of raw) { + if (m.name === "numberOfCommands") commands = m.value + else if (m.name.startsWith("numberOfCommands_")) ensureGuild(m.name.slice("numberOfCommands_".length)).commands = m.value + else if (m.name.startsWith("numberOfMusicPlayed_")) ensureGuild(m.name.slice("numberOfMusicPlayed_".length)).musicPlayed = m.value + else if (m.name.startsWith("numberOfSecondsPlayed_")) ensureGuild(m.name.slice("numberOfSecondsPlayed_".length)).secondsPlayed = m.value + else if (m.name.startsWith("userCommand_")) byUser.push({ key: m.name.slice("userCommand_".length), commands: m.value }) + } + const byGuild = [...byGuildMap.values()] + return { + global: { + commands, + musicPlayed: byGuild.reduce((acc, g) => acc + g.musicPlayed, 0), + secondsPlayed: byGuild.reduce((acc, g) => acc + g.secondsPlayed, 0) + }, + byGuild: byGuild.sort((a, b) => b.commands - a.commands), + byUser: byUser.sort((a, b) => b.commands - a.commands), + raw + } +} + +// YOUTUBE + +/** + * Erreurs typées du module youtube -> AdminError (leurs messages sont déjà en français) + */ +function youtubeError(e) { + if (e instanceof AdminError) return e + switch (e?.code) { + case "INVALID_COOKIES": + case "INVALID_PARAMS": + return new AdminError("INVALID_PARAMS", e.message) + case "BUSY": + return new AdminError("UNAVAILABLE", e.message) + default: + return e + } +} + +/** + * Relance le sondage de la version de yt-dlp si son cache a expiré, sans bloquer plus de quelques secondes + * (getVersion() répond directement depuis son cache : 6 h si connue, 1 min en cas d'échec) + */ +async function refreshYtdlpVersion() { + let timer + try { + await Promise.race([ + youtube.ytdlp.getVersion().catch(() => null), + new Promise((resolve) => { + timer = setTimeout(resolve, YOUTUBE_VERSION_WAIT) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} + +/** + * État complet de la section YouTube : youtube.getStatus() + tentatives récentes (avec le nom du serveur) + */ +function youtubeStatus() { + const guilds = discordBot.getGuilds() + const recent = youtube.diagnostics.getRecent(YOUTUBE_RECENT).map(entry => ({ + ...entry, + guildName: entry.guildId ? (guilds.get(entry.guildId)?.name ?? null) : null + })) + return { ...youtube.getStatus(), recent } +} + +/** + * Résumé yt-dlp de la vue d'ensemble (clés present / updatedAt conservées pour les anciens clients) + */ +async function youtubeOverview() { + const activeProcesses = getYoutubeProcesses()?.size ?? 0 + try { + await refreshYtdlpVersion() + const status = youtube.getStatus() + const cookies = status.cookies || {} + const summary = status.diagnostics || {} + return { + version: status.ytdlp?.version ?? null, + source: status.ytdlp?.source ?? null, + updating: !!status.ytdlp?.updating, + cookies: { + present: !!cookies.present, + loggedIn: !!cookies.loggedIn, + valid: !!cookies.valid, + invalidatedAt: cookies.invalidatedAt ?? null, + invalidReason: cookies.invalidReason ?? null, + importedAt: cookies.importedAt ?? null, + updatedAt: cookies.importedAt ?? null, + earliestAuthExpiry: cookies.earliestAuthExpiry ?? null, + lastCheck: cookies.lastCheck ? { at: cookies.lastCheck.at ?? null, ok: !!cookies.lastCheck.ok } : null + }, + cookiesMode: status.settings?.cookiesMode?.value ?? null, + activeProcesses, + lastFailure: summary.lastFailure ? { at: summary.lastFailure.at ?? null, cls: summary.lastFailure.cls ?? null } : null, + recentFailureRate: summary.recentFailureRate ?? null, + recentCount: safeNum(summary.recentCount) + } + } catch (e) { + alog.error(`État YouTube illisible pour la vue d'ensemble : ${e?.stack || e}`) + return { version: null, cookies: { present: false, updatedAt: null }, activeProcesses } + } +} + +/** + * Lance la vérification en direct des cookies sans l'attendre (le résultat arrive par "/ADMIN/YOUTUBE/UPDATE") + * @returns {boolean} true si une vérification a été lancée + */ +function startCookieCheck() { + if (!youtube.cookies.getStatus().loggedIn) return false + youtube.check.checkCookies().catch((e) => alog.warn(`Vérification des cookies YouTube impossible : ${e?.message || e}`)) + return true +} + +/** + * Description d'une modification de réglages pour l'audit (URL du fournisseur de PO token masquée) + */ +function describeSettingsChange(before, after) { + const format = (key, value) => key === "potProviderUrl" ? (youtube.settings.maskUrl(value) || '""') : JSON.stringify(value) + return Object.keys(after) + .filter(key => before[key] !== after[key]) + .map(key => `${key}: ${format(key, before[key])} -> ${format(key, after[key])}`) + .join(", ") +} + +function isContainer() { + try { + return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv") + } catch (e) { + return false + } +} + +/** + * Message d'échec de --cookies-from-browser (dernière ligne ERROR nettoyée + explication) + */ +function browserFailureMessage(browser, result) { + const line = String(result?.stderr || "").split(/\r?\n/).map(l => l.trim()).reverse().find(l => /^ERROR:/i.test(l)) + let detail + if (result?.timedOut) detail = "délai dépassé" + else if (line) detail = youtube.errors.sanitize(line.replace(/^ERROR:\s*/i, "")).slice(0, 300) + else detail = `aucun cookie exporté (code ${result?.code ?? "?"})` + let hint = "Cette méthode ne fonctionne que si le bot tourne sur la même machine et la même session que le navigateur." + if (CHROMIUM_BROWSERS.includes(browser) && process.platform === "win32") { + hint += " Sous Windows, Chrome et ses dérivés verrouillent et chiffrent leurs cookies : fermez complètement le navigateur, ou préférez Firefox." + } + return `Lecture des cookies de ${browser} impossible : ${detail}. ${hint}` +} + +/** + * Importe les cookies d'un navigateur installé sur la machine du bot (dev / local uniquement) : + * yt-dlp --cookies-from-browser les exporte dans un fichier temporaire, aussitôt importé puis supprimé. + */ +async function importFromBrowser(browser, profile, by) { + if (isContainer()) { + throw new AdminError("UNAVAILABLE", "Le bot tourne dans un conteneur (Docker) : il n'a accès à aucun navigateur. Utilisez l'assistant de connexion (export d'un fichier cookies.txt).") + } + if (browserImportPending) throw new AdminError("UNAVAILABLE", "Un import depuis un navigateur est déjà en cours") + browserImportPending = true + // Préfixe des copies de cookies du module youtube : nettoyé au démarrage en cas de crash + let dir = null + try { + dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chopin-ytc-")) + const file = path.join(dir, "cookies.txt") + const spec = profile ? `${browser}:${profile}` : browser + const args = [ + ...youtube.ytdlp.baseArgs({ proxy: null }), + "--cookies-from-browser", spec, + "--cookies", file, + "--skip-download", + "--", youtube.settings.get().testUrl + ] + // yt-dlp écrit le fichier de cookies en se fermant, même si l'extraction de la vidéo échoue + const result = await youtube.ytdlp.run(args, { timeoutMs: YOUTUBE_BROWSER_TIMEOUT }) + let text = null + try { + text = await fs.promises.readFile(file, "utf-8") + } catch (e) { + text = null + } + if (!text || !text.trim()) throw new AdminError("UNAVAILABLE", browserFailureMessage(browser, result)) + try { + return youtube.cookies.importCookies(text, { source: "browser:" + browser, by }) + } catch (e) { + if (e?.code === "INVALID_COOKIES") { + throw new AdminError("INVALID_PARAMS", `${e.message} (êtes-vous connecté à YouTube dans ${browser}${profile ? `, profil « ${profile} »` : ""} ?)`) + } + throw e + } + } finally { + browserImportPending = false + // Le fichier temporaire contient TOUS les cookies du navigateur : supprimé dans tous les cas + if (dir) await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}) + } +} + +function adminIdentity(me) { + return { id: me.identity.id, username: me.identity.username } +} + +// CONFIGURATION ET INTÉGRATIONS + +// Dernier test des identifiants Spotify (oublié dès qu'ils changent) +let spotifyLastTest = null + +function websiteBase() { + return String(configuration.getWebsiteLink() || "").replace(/\/+$/, "") +} + +/** + * Comptes Google liés, lus dans data/google.json (lecture seule : jamais de jeton renvoyé) + */ +function googleAccountsInfo() { + const data = dbData("google", null) + if (!data || typeof data !== "object" || Array.isArray(data)) return { available: false, linked: 0, invalid: 0 } + let linked = 0 + let invalid = 0 + for (const record of Object.values(data)) { + if (!record || typeof record !== "object" || !record.tokens) continue + linked++ + if (record.invalid) invalid++ + } + return { available: true, linked, invalid } +} + +function youtubeCookiesSummary() { + try { + const cookies = youtube.cookies.getStatus() + return { + present: !!cookies.present, + loggedIn: !!cookies.loggedIn, + valid: !!cookies.valid, + invalidReason: cookies.invalidReason ?? null, + importedAt: cookies.importedAt ?? null + } + } catch (e) { + return null + } +} + +/** + * État des intégrations pour la section Configuration (aucun secret, seulement des indicateurs) + */ +function integrationsStatus() { + const website = websiteBase() + const google = googleAccountsInfo() + const warpStatus = warp.getStatus() + const client = getClient() + return { + website: website || null, + discord: { + ready: !!discordBot.isReady(), + tag: client?.user?.tag ?? null, + // À déclarer dans le portail développeur Discord (OAuth2 > Redirects) + redirectUri: website ? website + "/redirect" : null + }, + google: { + configured: !!(configuration.getYoutubeApiClientId() && configuration.getYoutubeApiClientSecret() && website), + clientIdSet: !!configuration.getYoutubeApiClientId(), + clientSecretSet: !!configuration.getYoutubeApiClientSecret(), + // URI de redirection autorisée à déclarer dans la console Google Cloud + redirectUri: website ? website + "/oauth2callback" : null, + accountsAvailable: google.available, + linkedUsers: google.linked, + invalidUsers: google.invalid + }, + spotify: { + configured: !!(configuration.getSpotifyClientId() && configuration.getSpotifyClientSecret()), + lastTest: spotifyLastTest + }, + warp: { + configured: !!warpStatus.configured, + healthy: !!warpStatus.healthy, + url: warpStatus.url ?? null, + routes: warpStatus.routes || [] + }, + youtube: { cookies: youtubeCookiesSummary() }, + media: mediaStatus(), + report: { + channelSet: !!configuration.getReportChannel(), + contactSet: !!configuration.getReportContact() + } + } +} + +function configPayload() { + const config = configuration.getMasked() + return { + config, + restartRequired: Object.values(config).some(entry => entry.pendingRestart), + integrations: integrationsStatus() + } +} + +/** + * Demande un jeton client credentials à Spotify avec les identifiants enregistrés + * (instance dédiée : le jeton en cache de SpotifyInformation n'est pas touché) + */ +async function testSpotify() { + const clientId = configuration.getSpotifyClientId() + const clientSecret = configuration.getSpotifyClientSecret() + if (!clientId || !clientSecret) throw new AdminError("UNAVAILABLE", "Identifiants Spotify non configurés") + const SpotifyWebApi = require('spotify-web-api-node') + const api = new SpotifyWebApi({ clientId, clientSecret }) + const startedAt = Date.now() + let timer + let result + try { + const data = await Promise.race([ + api.clientCredentialsGrant(), + new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("délai dépassé")), SPOTIFY_TEST_TIMEOUT) + timer.unref?.() + }) + ]) + const expiresIn = Number(data?.body?.expires_in) + result = { ok: true, message: "Jeton obtenu : identifiants valides", expiresIn: Number.isFinite(expiresIn) ? expiresIn : null } + } catch (e) { + const status = Number(e?.statusCode) + let detail = String(e?.body?.error_description || e?.body?.error || e?.message || e) + // Par précaution : jamais le secret dans un message renvoyé ou journalisé + detail = detail.split(clientSecret).join("***").slice(0, 200) + result = { + ok: false, + message: status === 400 || status === 401 ? `Identifiants refusés par Spotify (${detail})` : `Spotify injoignable : ${detail}` + } + } finally { + clearTimeout(timer) + } + spotifyLastTest = { ...result, at: new Date().toISOString(), ms: Date.now() - startedAt } + return spotifyLastTest +} + +// VUE D'ENSEMBLE + +async function databasesInfo(withEntries = false) { + const result = [] + for (const name of DATABASES) { + const db = getDatabase(name) + if (!db?.path) continue + const info = { name, sizeBytes: 0, modifiedAt: null } + try { + const stat = await fs.promises.stat(db.path) + info.sizeBytes = stat.size + info.modifiedAt = stat.mtime.toISOString() + } catch (e) { + // Fichier absent : taille 0 + } + if (withEntries) { + const data = db.data + info.entries = Array.isArray(data) ? data.length : (data && typeof data === "object" ? Object.keys(data).length : 0) + } + result.push(info) + } + return result +} + +function packageName() { + try { + return JSON.parse(fs.readFileSync(__glob.PACKAGEINFO, "utf-8")).name || "chopin-backend" + } catch (e) { + return "chopin-backend" + } +} + +function mediaStatus() { + const configured = !!(configuration.getMediaGuildId() && configuration.getMediaChannelId()) + let connected = false + const mediaBase = getMediaBase() + if (typeof mediaBase?.isConnected === "function") { + connected = !!mediaBase.isConnected() + } else if (configured) { + // À défaut d'accesseur : le salon multimédia est-il visible par le bot ? + try { + connected = !!discordBot.getChannel(configuration.getMediaGuildId(), configuration.getMediaChannelId()) + } catch (e) { + connected = false + } + } + return { configured, connected } +} + +let cachedPackageName = null + +async function buildOverview() { + const client = getClient() + const uptime = process.uptime() + const memory = process.memoryUsage() + + // Joueurs + const playerCounts = { connected: 0, playing: 0, paused: 0, idle: 0 } + for (const player of connectedPlayers()) { + playerCounts.connected++ + let status = "idle" + try { + status = playerStatus(player.getState()) + } catch (e) { + status = "idle" + } + if (status === "playing") playerCounts.playing++ + else if (status === "paused") playerCounts.paused++ + else playerCounts.idle++ + } + + // Comptes + const allUsers = users.getUsers() + const userCounts = { registered: 0, admins: 0, fullBanned: 0, deleted: 0 } + for (const user of allUsers) { + const labels = user.labels || [] + if (labels.includes("DELETED")) { + userCounts.deleted++ + continue + } + userCounts.registered++ + if (labels.includes("ADMIN")) userCounts.admins++ + if (labels.includes("BAN")) userCounts.fullBanned++ + } + + // Web + const sockets = socketIndex() + let adminsOnline = 0 + for (const userId of sockets.keys()) { + if (users.getUserById(userId)?.isAdmin()) adminsOnline++ + } + + // Discord + let voiceConnections = 0 + try { + voiceConnections = require('@discordjs/voice').getVoiceConnections().size + } catch (e) { + voiceConnections = 0 + } + const activity = client?.user?.presence?.activities?.[0] + + const metrics = metricsData() + if (!cachedPackageName) cachedPackageName = packageName() + + return { + app: { + name: cachedPackageName, + version: __glob.VERSION, + node: process.version, + platform: process.platform, + pid: process.pid, + startedAt: new Date(Date.now() - uptime * 1000).toISOString(), + uptime: Math.floor(uptime), + pm2: process.env.pm_id !== undefined + }, + memory: { + rss: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + external: memory.external + }, + system: { + loadavg: os.loadavg().map(safeNum), + totalmem: os.totalmem(), + freemem: os.freemem(), + cpus: os.cpus().length, + hostname: os.hostname() + }, + discord: { + ready: !!discordBot.isReady(), + id: client?.user?.id ?? null, + tag: client?.user?.tag ?? null, + avatar: client?.user ? client.user.displayAvatarURL() : null, + ping: safeNum(client?.ws?.ping), + readyAt: toIso(client?.readyAt), + guildCount: client?.guilds?.cache?.size ?? 0, + voiceConnections, + activity: activity ? (activity.state || activity.name || null) : null + }, + web: { + sockets: io ? io.sockets.sockets.size : 0, + usersOnline: sockets.size, + pendingSessions: typeof session.getSessionCount === "function" ? session.getSessionCount() : 0, + adminsOnline + }, + players: playerCounts, + users: userCounts, + warp: warp.getStatus(), + ytdlp: await youtubeOverview(), + media: mediaStatus(), + databases: await databasesInfo(false), + stats: metrics.global, + logs: { + current: LogReader.getCurrentName(), + interrupted: await LogReader.isInterrupted() + } + } +} + +// SYNCHRONISATION DES SOCKETS + +/** + * Applique les droits actuels de l'utilisateur à tous ses sockets (après chaque mutation de labels) : + * room ADMIN, sortie des guildes bannies, déconnexion si banni globalement ou supprimé. + */ +function syncUserSockets(userId) { + if (!io || !userId) return 0 + const user = users.getUserById(userId) + const guilds = discordBot.getGuilds() + let affected = 0 + for (const socket of [...io.sockets.sockets.values()]) { + if (socket.data?.userId !== userId) continue + affected++ + if (!user || user.labels.includes("DELETED") || user.isFullBanned()) { + socket.emit("AUTH_ERROR", user?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte a été supprimé") + socket.disconnect(true) + continue + } + if (user.isAdmin()) { + socket.join(ADMIN_ROOM) + } else { + socket.leave(ADMIN_ROOM) + socket.leave(LOGS_ROOM) + } + for (const room of [...socket.rooms]) { + if (guilds.has(room) && user.isBanned(room)) socket.leave(room) + } + } + process.emit("USERS_UPDATE") + return affected +} + +/** + * Déconnecte toutes les sessions d'un utilisateur + * @returns {number} nombre de sockets déconnectés + */ +function disconnectUser(userId, message) { + if (!io) return 0 + let count = 0 + for (const socket of [...io.sockets.sockets.values()]) { + if (socket.data?.userId !== userId) continue + socket.emit("AUTH_ERROR", message) + socket.disconnect(true) + count++ + } + return count +} + +// ACTIONS SUR LES COMPTES + +function getTarget(userId) { + const target = users.getUserById(userId) + if (!target) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") + return target +} + +function freshSummary(userId) { + const user = users.getUserById(userId) + if (!user) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") + return userSummary(user) +} + +async function setAdminValue(me, userId, value) { + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits") + const target = getTarget(userId) + if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") + const state = await users.setAdmin(userId, value) + if (state === null) throw new AdminError("INTERNAL", "Modification impossible") + audit(me, value ? "promotion admin" : "retrait admin", describeUser(target)) + syncUserSockets(userId) + return freshSummary(userId) +} + +async function setFullBanValue(me, userId, value, reason) { + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même") + const target = getTarget(userId) + if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni") + const state = await users.setFullBan(userId, value, reason || undefined) + if (state === null) throw new AdminError("INTERNAL", "Modification impossible") + audit(me, value ? "bannissement global" : "levée du bannissement global", describeUser(target) + (reason ? ` - raison : ${reason}` : "")) + syncUserSockets(userId) + return freshSummary(userId) +} + +async function deleteUserAccount(me, userId, purge) { + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas supprimer votre propre compte ici") + const target = getTarget(userId) + if (target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être supprimé") + // Déconnexion d'abord : les sockets ne doivent plus agir pendant la suppression + disconnectUser(userId, "Votre compte a été supprimé") + const purged = await users.deleteAccount(userId, { purge }) + if (purged === null) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") + audit(me, purge ? "suppression du compte (avec purge)" : "suppression du compte", describeUser(target)) + syncUserSockets(userId) + return { deleted: true, purged } +} + +// ACTIONS SUR LES PLAYERS + +function getConnectedPlayer(guildId) { + const player = players.AllPlayers.get(guildId) + if (!isConnectedPlayer(player)) throw new AdminError("NOT_FOUND", "Aucun player connecté sur ce serveur") + return player +} + +/** + * @param {boolean} [value] loop / shuffle : état voulu (idempotent) ; absent = bascule (ancien client) + */ +async function runPlayerAction(player, action, value) { + const guildId = player.guildId + switch (action) { + case "pause": + case "resume": { + const value = action === "pause" + let result + if (typeof player.setPaused === "function") { + result = await player.setPaused(value) + } else { + // Ancienne API : pause() bascule, on vérifie l'état avant pour rester idempotent + const status = player.player?.state?.status + const paused = status === "paused" || status === "autopaused" + if (paused !== value) await player.pause() + result = { ok: true, paused: value } + } + if (result && result.ok === false) throw new AdminError("UNAVAILABLE", result.reason || "Action impossible") + break + } + case "skip": + case "previous": { + const result = await player[action]() + if (!result || result === "no_music") throw new AdminError("UNAVAILABLE", "no_music") + break + } + case "stop": + if (typeof player.stop === "function") { + await player.stop() + } else { + player.loop = false + player.queue?.clearNext?.() + player.player?.stop(true) + } + break + case "leave": + await player.leave("admin") + return null + case "loop": + if (value === undefined || !!player.loop !== value) await player.setLoop() + break + case "shuffle": + if (value === undefined || !!player.queue?.shuffle !== value) await player.setShuffle() + break + case "clear": + player.queue.clearNext() + break + } + const current = players.AllPlayers.get(guildId) + return isConnectedPlayer(current) ? playerSummary(current) : null +} + +// LOGS TEMPS RÉEL + +// Démarrage du suivi en cours : un second abonnement l'attend au lieu de renvoyer un tampon encore vide +let tailStarting = null + +async function ensureTail() { + if (tailStarting) return tailStarting + if (LogReader.isTailing()) return + tailStarting = LogReader.startTail(({ name, lines }) => { + if (!io || roomSize(LOGS_ROOM) === 0) return + io.to(LOGS_ROOM).emit("/ADMIN/LOGS/APPEND", { name, lines }) + }).finally(() => { + tailStarting = null + }) + return tailStarting +} + +/** + * Offset du début de la première ligne complète d'une lecture commençant à `start` + * (LogReader renvoie l'offset demandé et ignore la ligne partielle : le client en a besoin pour + * « Charger les lignes plus anciennes » sans perdre la ligne à cheval sur deux blocs) + */ +async function firstLineOffset(name, start, end) { + if (!(start > 0)) return 0 + const handle = await fs.promises.open(path.join(__glob.LOGS, name), "r") + try { + const buffer = Buffer.alloc(64 * 1024) + // On part de l'octet précédent : s'il s'agit d'un saut de ligne, start est déjà un début de ligne + let position = start - 1 + while (position < end) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, end - position), position) + if (!bytesRead) break + const index = buffer.subarray(0, bytesRead).indexOf(0x0a) + if (index !== -1) return position + index + 1 + position += bytesRead + } + return end + } finally { + await handle.close() + } +} + +// ENREGISTREMENT DES ROUTES + +/** + * Enregistre toutes les routes d'administration pour un socket authentifié. + * @param {{io?: import('socket.io').Server, socket: import('socket.io').Socket, socketUser: function|Object}} options + * socketUser : fonction renvoyant l'utilisateur courant du socket (ou l'objet lui-même) + */ +function attach({ io: ioInstance, socket, socketUser }) { + if (!io && ioInstance) io = ioInstance + + function getMe() { + const current = typeof socketUser === "function" ? socketUser() : socketUser + const id = current?.identity?.id || socket.data?.userId + return id ? users.getUserById(id) : null + } + + /** + * Route d'administration : droits relus à chaque appel, réponse par enveloppe {ok, data|error} + */ + function AdminRequest(name, handler) { + socket.on(name, async (payload, ack) => { + if (typeof payload === "function" && ack === undefined) { + ack = payload + payload = undefined + } + const reply = (env) => { + if (typeof ack === "function") ack(env) + else socket.emit(name, env) + } + const me = getMe() + if (!me || !me.isAdmin() || me.isFullBanned() || me.labels.includes("DELETED")) { + alog.warn(`[ADMIN] Accès refusé à ${name} pour ${describeUser(me)} - Socket : ${socket.id}`) + socket.leave(ADMIN_ROOM) + socket.leave(LOGS_ROOM) + return reply({ ok: false, error: "FORBIDDEN", message: "Accès réservé aux administrateurs" }) + } + try { + const data = await handler(payload, me) + reply({ ok: true, data: data === undefined ? null : data }) + } catch (e) { + if (e instanceof AdminError) { + return reply({ ok: false, error: e.code, message: e.message }) + } + alog.error(`${name} : ${e?.stack || e}`) + reply({ ok: false, error: "INTERNAL", message: String(e?.message || e) }) + } + }) + } + + // 1. VUE D'ENSEMBLE + + AdminRequest("/ADMIN/OVERVIEW", async () => buildOverview()) + + // 2. LOGS + + AdminRequest("/ADMIN/LOGS/LIST", async () => LogReader.listLogs()) + + // Ancien nom : renvoie la liste, sans le contenu (l'ancienne version lisait tout en synchrone) + AdminRequest("/ADMIN/LOGS", async () => LogReader.listLogs()) + + AdminRequest("/ADMIN/LOGS/READ", async (payload) => { + const data = asObject(payload) + if (!LogReader.isValidName(data.name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide") + const offset = optionalInt(data.offset, "offset", 0, Number.MAX_SAFE_INTEGER, undefined) + const limit = optionalInt(data.limit, "limit", 1, 1048576, 262144) + let levels + if (data.levels !== undefined && data.levels !== null) { + if (!Array.isArray(data.levels) || data.levels.some(l => typeof l !== "string" || !LOG_LEVELS.includes(l.toUpperCase()))) { + throw new AdminError("INVALID_PARAMS", "Paramètre \"levels\" invalide") + } + levels = data.levels.map(l => l.toUpperCase()) + } + const type = optionalString(data.type, "type", 64) + const search = optionalString(data.search, "search", 200) + const result = await LogReader.readLog(data.name, { offset, limit, levels, type, search }) + if (!result) throw new AdminError("NOT_FOUND", "Fichier de logs introuvable") + let lineStart = result.offset + try { + lineStart = await firstLineOffset(data.name, result.offset, result.nextOffset) + } catch (e) { + lineStart = result.offset + } + return { ...result, firstLineOffset: lineStart } + }) + + AdminRequest("/ADMIN/LOGS/SUBSCRIBE", async () => { + socket.join(LOGS_ROOM) + await ensureTail() + return { name: LogReader.getCurrentName(), lines: LogReader.getBuffer(300) } + }) + + AdminRequest("/ADMIN/LOGS/UNSUBSCRIBE", async () => { + socket.leave(LOGS_ROOM) + return true + }) + + AdminRequest("/ADMIN/LOGS/DELETE", async (payload, me) => { + const name = asObject(payload).name + if (!LogReader.isValidName(name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide") + const result = await LogReader.deleteLog(name) + if (result === "protected") throw new AdminError("TARGET_PROTECTED", "Le fichier de logs courant ne peut pas être supprimé") + if (result === "not_found") throw new AdminError("NOT_FOUND", "Fichier de logs introuvable") + audit(me, "suppression du fichier de logs", name) + return { deleted: name } + }) + + // 3. COMPTES + + AdminRequest("/ADMIN/USERS/LIST", async (payload) => { + const data = asObject(payload) + const search = optionalString(data.search, "search", 100) + const filter = data.filter === undefined || data.filter === null ? "all" : data.filter + if (!USER_FILTERS.includes(filter)) throw new AdminError("INVALID_PARAMS", "Paramètre \"filter\" invalide") + const page = optionalInt(data.page, "page", 1, 100000, 1) + const pageSize = optionalInt(data.pageSize, "pageSize", 1, 200, 50) + + const ctx = buildContext() + let list = users.getUsers().map(u => userSummary(u, ctx)) + if (search) { + const s = search.toLowerCase() + list = list.filter(u => (u.id || "").includes(s) + || (u.username || "").toLowerCase().includes(s) + || (u.global_name || "").toLowerCase().includes(s)) + } + switch (filter) { + case "online": list = list.filter(u => u.online); break + case "admin": list = list.filter(u => u.isAdmin); break + case "banned": list = list.filter(u => u.isFullBanned || u.bannedGuilds.length > 0); break + case "deleted": list = list.filter(u => u.isDeleted); break + } + // En ligne d'abord, puis ordre alphabétique + list.sort((a, b) => (b.online - a.online) || (a.global_name || a.username || "").localeCompare(b.global_name || b.username || "")) + const start = (page - 1) * pageSize + return { total: list.length, page, pageSize, items: list.slice(start, start + pageSize) } + }) + + AdminRequest("/ADMIN/USERS/GET", async (payload) => { + const userId = userIdFrom(payload) + const user = getTarget(userId) + const ctx = buildContext() + const summary = userSummary(user, ctx) + + const guilds = [] + for (const guildId of summary.guildIds) { + const guild = discordBot.getGuilds().get(guildId) + if (!guild) continue + guilds.push({ + id: guildId, + name: guild.name ?? null, + icon: guild.icon ?? null, + isOwner: guild.owner === userId, + isMod: user.isMod(guildId), + isBanned: user.isBanned(guildId) + }) + } + + const sockets = (ctx.sockets.get(userId) || []).map(s => ({ + socketId: s.id, + address: s.handshake?.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() || s.handshake?.address || null, + userAgent: s.handshake?.headers?.["user-agent"] ?? null, + connectedAt: toIso(s.handshake?.issued), + guildId: socketGuildId(s) + })) + + const userPlaylists = Array.isArray(ctx.playlists?.[userId]) ? ctx.playlists[userId] : [] + const userHistory = Array.isArray(ctx.history?.[userId]) ? ctx.history[userId] : [] + const media = dbData("media", []) + + return { + ...summary, + // Identité Discord complète (jamais auth ni tokens) + identity: { ...user.identity }, + guilds, + sockets, + playlists: userPlaylists.map(p => ({ + playlistId: p?.playlistId != null ? String(p.playlistId) : null, + title: p?.title ?? null, + type: p?.type ?? null, + songCount: Array.isArray(p?.songs) ? p.songs.length : 0 + })), + history: userHistory.map(songLite).filter(Boolean), + media: (Array.isArray(media) ? media : []).filter(m => m?.userId === userId).map(m => ({ + id: m.id ?? null, + name: m.name ?? null, + size: safeNum(m.size), + createdAt: toIso(m.createdAt), + url: m.url ?? null + })) + } + }) + + AdminRequest("/ADMIN/USERS/SET_ADMIN", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const value = requireBool(data.value) + return setAdminValue(me, userId, value) + }) + + // Ancien nom : bascule, userId en chaîne + AdminRequest("/ADMIN/USERS/SWITCH_ADMIN", async (payload, me) => { + const userId = userIdFrom(payload) + const target = getTarget(userId) + return setAdminValue(me, userId, !target.isAdmin()) + }) + + AdminRequest("/ADMIN/USERS/SET_FULL_BAN", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const value = requireBool(data.value) + const reason = optionalString(data.reason, "reason", 500) + return setFullBanValue(me, userId, value, reason) + }) + + // Ancien nom : bascule, userId en chaîne + AdminRequest("/ADMIN/USERS/FULL_BAN", async (payload, me) => { + const userId = userIdFrom(payload) + const target = getTarget(userId) + return setFullBanValue(me, userId, !target.isFullBanned(), null) + }) + + AdminRequest("/ADMIN/USERS/SET_GUILD_BAN", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const guildId = requireId(data.guildId, "guildId") + const value = requireBool(data.value) + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même") + const target = getTarget(userId) + if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") + if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni") + const state = await users.setGuildBan(userId, guildId, value) + if (state === null) throw new AdminError("INTERNAL", "Modification impossible") + audit(me, value ? "bannissement du serveur" : "levée du bannissement du serveur", `${describeUser(target)} sur ${guildId}`) + if (value && io) io.in("user:" + userId).socketsLeave(guildId) + syncUserSockets(userId) + return freshSummary(userId) + }) + + AdminRequest("/ADMIN/USERS/SET_GUILD_MOD", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const guildId = requireId(data.guildId, "guildId") + const value = requireBool(data.value) + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits") + const target = getTarget(userId) + if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") + if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") + if (value && (target.isFullBanned() || target.labels.includes("BAN_" + guildId))) { + throw new AdminError("INVALID_PARAMS", "Cet utilisateur est banni : levez d'abord le bannissement") + } + const state = await users.setGuildMod(userId, guildId, value) + if (state === null) throw new AdminError("INTERNAL", "Modification impossible") + audit(me, value ? "nomination modérateur" : "retrait modérateur", `${describeUser(target)} sur ${guildId}`) + syncUserSockets(userId) + return freshSummary(userId) + }) + + AdminRequest("/ADMIN/USERS/DISCONNECT", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const revokeTokens = data.revokeTokens === undefined ? false : requireBool(data.revokeTokens, "revokeTokens") + const message = optionalString(data.message, "message", 200) + if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Utilisez la déconnexion de votre compte") + const target = getTarget(userId) + const disconnectedSockets = disconnectUser(userId, message || "Vous avez été déconnecté par un administrateur") + const revokedTokens = revokeTokens ? users.revokeTokens(userId) : 0 + audit(me, revokeTokens ? "déconnexion et révocation des sessions" : "déconnexion", `${describeUser(target)} (${disconnectedSockets} socket(s), ${revokedTokens} token(s))`) + process.emit("USERS_UPDATE") + return { disconnectedSockets, revokedTokens } + }) + + AdminRequest("/ADMIN/USERS/REFRESH", async (payload, me) => { + const userId = userIdFrom(payload) + const target = getTarget(userId) + if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") + if (!target.auth) throw new AdminError("UNAVAILABLE", "Aucune authentification Discord pour ce compte") + const identity = await users.updateIdentity(userId) + if (!identity) throw new AdminError("UNAVAILABLE", "L'API Discord n'a pas répondu correctement") + users.getUserById(userId)?.justUpdated() + audit(me, "rafraîchissement de l'identité", describeUser(target)) + process.emit("USERS_UPDATE") + return freshSummary(userId) + }) + + AdminRequest("/ADMIN/USERS/DELETE", async (payload, me) => { + const userId = userIdFrom(payload) + const data = asObject(payload) + // Purge uniquement sur demande explicite (l'ancien format "id" seul ne purge plus rien) + const purge = data.purge === undefined || data.purge === null ? false : requireBool(data.purge, "purge") + return deleteUserAccount(me, userId, purge) + }) + + // 4. SERVEURS DISCORD + + AdminRequest("/ADMIN/GUILDS/LIST", async (payload) => { + const search = optionalString(asObject(payload).search, "search", 100) + return guildsList(search) + }) + + AdminRequest("/ADMIN/GUILDS/GET", async (payload) => { + const guildId = requireId(asObject(payload).guildId, "guildId") + const guildData = discordBot.getGuilds().get(guildId) + if (!guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable") + const ctx = buildContext() + const summary = guildSummary(guildData, ctx) + const discordGuild = getClient().guilds.cache.get(guildId) + + const roles = discordBot.getGuildRoles(guildId).sort((a, b) => b.position - a.position) + + const members = [] + for (const memberId of guildData.allMembers || []) { + const user = users.getUserById(memberId) + if (!user || user.labels.includes("DELETED")) continue + members.push({ + ...userLite(user.identity), + isOwner: guildData.owner === memberId, + isMod: user.isMod(guildId), + isBanned: user.isBanned(guildId), + isAdmin: user.isAdmin(), + online: ctx.sockets.has(memberId) + }) + } + + const voiceChannels = [] + if (discordGuild) { + for (const channel of discordGuild.channels.cache.values()) { + if (typeof channel.isVoiceBased !== "function" || !channel.isVoiceBased()) continue + voiceChannels.push({ + id: channel.id, + name: channel.name, + members: channel.members ? channel.members.filter(m => !m.user?.bot).size : 0 + }) + } + } + + const metrics = SafeMetric.getGuildMetrics(guildId).map(m => ({ + name: m.name, + description: m.description ?? m.name, + value: safeNum(m.value), + invalid: !Number.isFinite(Number(m.value)) + })) + + return { ...summary, roles, members, voiceChannels, metrics } + }) + + AdminRequest("/ADMIN/GUILDS/LEAVE", async (payload, me) => { + const data = asObject(payload) + const guildId = requireId(data.guildId, "guildId") + if (typeof data.confirmName !== "string") throw new AdminError("INVALID_PARAMS", "Confirmation du nom du serveur requise") + const discordGuild = getClient().guilds.cache.get(guildId) + const guildData = discordBot.getGuilds().get(guildId) + if (!discordGuild && !guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable") + if (guildId === configuration.getMediaGuildId()) { + throw new AdminError("TARGET_PROTECTED", "Le serveur de la base multimédia ne peut pas être quitté") + } + const name = discordGuild?.name ?? guildData?.name + if (data.confirmName.trim() !== String(name ?? "").trim()) throw new AdminError("INVALID_PARAMS", "Le nom de confirmation ne correspond pas au nom du serveur") + if (!discordGuild) throw new AdminError("NOT_FOUND", "Le bot n'est plus sur ce serveur") + + const player = players.AllPlayers.get(guildId) + if (isConnectedPlayer(player)) { + try { + await player.leave("admin") + } catch (e) { + alog.error(`Impossible de quitter le salon vocal de ${guildId} : ${e?.message || e}`) + } + } + if (typeof discordBot.clearAutoLeaveTimer === "function") discordBot.clearAutoLeaveTimer(guildId) + try { + await discordGuild.leave() + } catch (e) { + throw new AdminError("UNAVAILABLE", `Discord a refusé le départ du serveur : ${e?.message || e}`) + } + audit(me, "départ du serveur", `${name} (${guildId})`) + // guildDelete retire aussi l'entrée, on le fait tout de suite pour une réponse cohérente + discordBot.getGuilds().delete(guildId) + if (io) { + io.to(guildId).emit("/GUILD/UPDATE") + io.in(guildId).socketsLeave(guildId) + } + process.emit("USERS_UPDATE") + process.emit("GUILDS_UPDATE") + return { left: true, guildId } + }) + + AdminRequest("/ADMIN/GUILDS/SET_SECURE_ROLE", async (payload, me) => { + const data = asObject(payload) + const guildId = requireId(data.guildId, "guildId") + if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") + let role = null + if (data.roleId !== null && data.roleId !== undefined) { + const roleId = requireId(data.roleId, "roleId") + const found = discordBot.getGuildRoles(guildId).find(r => r.id === roleId) + if (!found) throw new AdminError("INVALID_PARAMS", "Rôle introuvable sur ce serveur") + // @everyone équivaut à aucun rôle + role = found.name === "@everyone" ? null : { id: found.id, name: found.name, color: found.color, position: found.position } + } + serverSettings.setSecureRole(guildId, role) + audit(me, "rôle sécurisé", `${guildId} -> ${role ? role.name + " (" + role.id + ")" : "aucun"}`) + process.emit("GUILDS_UPDATE") + return { secureRole: role ? { id: role.id, name: role.name } : null } + }) + + AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => { + if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible") + await discordBot.refreshGuilds() + audit(me, "rafraîchissement des serveurs") + process.emit("USERS_UPDATE") + return guildsList(null) + }) + + // 5. PLAYERS + + AdminRequest("/ADMIN/PLAYERS/LIST", async () => playersList()) + + AdminRequest("/ADMIN/PLAYERS/GET", async (payload) => { + const guildId = requireId(asObject(payload).guildId, "guildId") + const player = getConnectedPlayer(guildId) + const summary = playerSummary(player, { full: true }) + if (!summary) throw new AdminError("UNAVAILABLE", "État du player illisible") + return summary + }) + + AdminRequest("/ADMIN/PLAYERS/ACTION", async (payload, me) => { + const data = asObject(payload) + const guildId = requireId(data.guildId, "guildId") + if (!PLAYER_ACTIONS.includes(data.action)) throw new AdminError("INVALID_PARAMS", "Action inconnue") + // loop / shuffle : valeur explicite facultative (sinon bascule) + let value + if ((data.action === "loop" || data.action === "shuffle") && data.value !== undefined && data.value !== null) { + value = requireBool(data.value) + } + const player = getConnectedPlayer(guildId) + const guildName = discordBot.getGuilds().get(guildId)?.name ?? guildId + const result = await runPlayerAction(player, data.action, value) + // Journalisé après coup : une action refusée (UNAVAILABLE...) n'apparaît pas comme effectuée + audit(me, `player ${data.action}${value === undefined ? "" : (value ? " (activé)" : " (désactivé)")}`, `sur ${guildName} (${guildId})`) + return result + }) + + // Ancien nom (corrigé) : états bruts des players connectés + AdminRequest("/ADMIN/PLAYER/GETALLSTATE", async () => { + const states = [] + for (const player of connectedPlayers()) { + try { + states.push(player.getState()) + } catch (e) { + alog.error(`Lecture de l'état du player ${player.guildId} impossible : ${e?.message || e}`) + } + } + return states + }) + + // 6. WARP + + AdminRequest("/ADMIN/WARP/STATUS", async () => warp.getStatus()) + + AdminRequest("/ADMIN/WARP/CHECK", async () => { + if (!warp.getStatus().configured) throw new AdminError("UNAVAILABLE", "Aucun proxy WARP configuré") + let timer + try { + await Promise.race([ + warp.checkHealth(), + new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new AdminError("UNAVAILABLE", "Délai de vérification dépassé")), WARP_CHECK_TIMEOUT) + }) + ]) + } finally { + clearTimeout(timer) + } + return warp.getStatus() + }) + + AdminRequest("/ADMIN/WARP/CONFIG", async (payload, me) => { + const data = asObject(payload) + const patch = {} + for (const key of ["enabled", "url", "fallbackDirect"]) { + if (data[key] !== undefined) patch[key] = data[key] + } + if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée") + let result + try { + // Champs verrouillés par l'environnement, URL invalide... : INVALID_PARAMS + result = await warp.setConfig(patch, { check: false }) + } catch (e) { + if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message) + throw e + } + // Jamais l'URL brute dans le journal (identifiants éventuels) : setConfig masque déjà les URL + if (result.changes.length) audit(me, "configuration WARP", result.changes.join(", ")) + // Contrôle immédiat du proxy, borné pour répondre avant le délai du client + if (warp.getStatus().configured) { + let timer + try { + await Promise.race([ + warp.checkHealth(), + new Promise((resolve) => { + timer = setTimeout(resolve, WARP_CHECK_TIMEOUT) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } + } + return { status: warp.getStatus(), changes: result.changes } + }) + + // 7. MÉTRIQUES + + AdminRequest("/ADMIN/METRICS", async () => metricsData()) + + AdminRequest("/ADMIN/METRICS/REPAIR", async (payload, me) => { + const repaired = SafeMetric.repair() + audit(me, "réparation des métriques", `${repaired} valeur(s) corrigée(s)`) + return { repaired } + }) + + AdminRequest("/ADMIN/METRICS/DELETE", async (payload, me) => { + const name = requireString(asObject(payload).name, "name", 1, 200) + if (!SafeMetric.remove(name)) throw new AdminError("NOT_FOUND", "Métrique introuvable") + audit(me, "suppression de la métrique", name) + return { deleted: name } + }) + + // 8. MAINTENANCE + + AdminRequest("/ADMIN/MAINTENANCE/RESTART", async (payload, me) => { + // Une chaîne seule reste acceptée (ancien client) + const reason = requireString(typeof payload === "string" ? payload : asObject(payload).reason, "reason", 3, 200) + if (maintenance.isRestarting()) throw new AdminError("UNAVAILABLE", "Un redémarrage est déjà en cours") + audit(me, "redémarrage", `raison : ${reason}`) + const at = new Date().toISOString() + // Après la réponse : annonce "/MAINTENANCE" (via l'événement MAINTENANCE), départ des salons vocaux puis arrêt + setTimeout(() => maintenance.restart(reason), 50) + return { restarting: true, at } + }) + + AdminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", async (payload, me) => { + const data = asObject(payload) + const message = requireString(data.message, "message", 1, 500) + const level = data.level === undefined || data.level === null ? "info" : data.level + if (level !== "info" && level !== "warn") throw new AdminError("INVALID_PARAMS", "Paramètre \"level\" invalide (info ou warn)") + if (io) io.emit("/ANNOUNCE", { message, level, at: new Date().toISOString() }) + audit(me, "annonce globale", `[${level}] ${message}`) + return true + }) + + AdminRequest("/ADMIN/DATABASES", async () => databasesInfo(true)) + + AdminRequest("/ADMIN/PLAYLISTS/LIST", async (payload) => { + const data = asObject(payload) + const userId = data.userId === undefined || data.userId === null ? null : requireId(data.userId) + const all = dbData("Playlists", {}) + const result = [] + for (const ownerId of Object.keys(all)) { + if (userId && ownerId !== userId) continue + const list = Array.isArray(all[ownerId]) ? all[ownerId] : [] + if (list.length === 0 && !userId) continue + const owner = users.getUserById(ownerId) + result.push({ + userId: ownerId, + username: owner?.identity?.username ?? null, + // Identité légère pour l'affichage (jamais auth ni tokens) + owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null, + playlists: list.map(p => ({ + playlistId: p?.playlistId != null ? String(p.playlistId) : null, + title: p?.title ?? null, + type: p?.type ?? null, + // "google" : synchronisée depuis le compte Google du propriétaire + source: p?.source ?? null, + url: typeof p?.url === "string" ? p.url : null, + thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null, + duration: safeNum(p?.duration), + syncedAt: toIso(p?.syncedAt), + songCount: Array.isArray(p?.songs) ? p.songs.length : 0 + })) + }) + } + return result + }) + + AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => { + const data = asObject(payload) + const userId = requireId(data.userId) + const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64) + const list = dbData("Playlists", {})[userId] + const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null + if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable") + if (playlists.removePlaylist(userId, playlist.playlistId) === false) throw new AdminError("NOT_FOUND", "Playlist introuvable") + audit(me, "suppression de la playlist", `"${playlist.title}" (${playlistId}) de ${userId}`) + // Le propriétaire connecté recharge ses playlists + process.emit("PLAYLISTS_UPDATE", userId) + return { deleted: true } + }) + + // 9. YOUTUBE (cookies, yt-dlp, réglages, diagnostics) + // Jamais de contenu de cookie dans les réponses ni dans les logs : seulement des noms, des compteurs et des dates + + AdminRequest("/ADMIN/YOUTUBE/STATUS", async () => { + await refreshYtdlpVersion() + return youtubeStatus() + }) + + AdminRequest("/ADMIN/YOUTUBE/COOKIES/IMPORT", async (payload, me) => { + const data = asObject(payload) + if (typeof data.content !== "string" || !data.content.trim() || data.content.length > COOKIES_MAX_LENGTH) { + throw new AdminError("INVALID_PARAMS", `Contenu des cookies invalide (1 à ${COOKIES_MAX_LENGTH} caractères)`) + } + const source = data.source === undefined || data.source === null ? "paste" : data.source + if (!COOKIE_IMPORT_SOURCES.includes(source)) throw new AdminError("INVALID_PARAMS", "Paramètre \"source\" invalide (paste ou file)") + let result + try { + result = youtube.cookies.importCookies(data.content, { source, by: adminIdentity(me) }) + } catch (e) { + throw youtubeError(e) + } + audit(me, "import des cookies YouTube", `(${source === "file" ? "fichier" : "collage"}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`) + // Vérification en direct en arrière-plan : le client reçoit le résultat par la poussée + const checking = startCookieCheck() + return { status: youtubeStatus(), warnings: result.warnings, checking } + }) + + AdminRequest("/ADMIN/YOUTUBE/COOKIES/BROWSER", async (payload, me) => { + const data = asObject(payload) + if (!YOUTUBE_BROWSERS.includes(data.browser)) throw new AdminError("INVALID_PARAMS", "Navigateur inconnu") + const profile = optionalString(data.profile, "profile", 200) + // Syntaxe de yt-dlp : NAVIGATEUR[:PROFIL][::CONTENEUR] (pas de retour à la ligne, pas de "::") + if (profile && (/[\r\n\0]/.test(profile) || profile.includes("::"))) throw new AdminError("INVALID_PARAMS", "Paramètre \"profile\" invalide") + let result + try { + result = await importFromBrowser(data.browser, profile, adminIdentity(me)) + } catch (e) { + throw youtubeError(e) + } + audit(me, "import des cookies YouTube", `(navigateur ${data.browser}${profile ? ", profil personnalisé" : ""}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`) + const checking = startCookieCheck() + return { status: youtubeStatus(), warnings: result.warnings, checking } + }) + + AdminRequest("/ADMIN/YOUTUBE/COOKIES/CHECK", async (payload, me) => { + if (!youtube.cookies.getStatus().present) throw new AdminError("UNAVAILABLE", "Aucun cookie YouTube enregistré") + let check + try { + check = await youtube.check.checkCookies() + } catch (e) { + throw youtubeError(e) + } + // L'état des cookies est déjà dans "status" + const { status: _cookieStatus, ...result } = check + audit(me, "vérification des cookies YouTube", `${result.ok ? "réussie" : "échouée"} : ${result.message}`) + return { result, status: youtubeStatus() } + }) + + AdminRequest("/ADMIN/YOUTUBE/COOKIES/DELETE", async (payload, me) => { + const before = youtube.cookies.getStatus() + try { + youtube.cookies.deleteCookies() + } catch (e) { + throw new AdminError("INTERNAL", `Suppression des cookies impossible : ${e?.message || e}`) + } + audit(me, "suppression des cookies YouTube", before.present ? `(${before.count} cookie(s))` : "(aucun cookie enregistré)") + return youtubeStatus() + }) + + AdminRequest("/ADMIN/YOUTUBE/SETTINGS", async (payload, me) => { + const patch = asObject(payload).patch + if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new AdminError("INVALID_PARAMS", "Paramètre \"patch\" invalide (objet attendu)") + const keys = Object.keys(patch) + if (keys.length === 0) throw new AdminError("INVALID_PARAMS", "Aucun réglage à modifier") + if (keys.length > YOUTUBE_SETTINGS_MAX_KEYS) throw new AdminError("INVALID_PARAMS", "Trop de réglages dans la requête") + const before = youtube.settings.get() + try { + // Clés inconnues, valeurs invalides et clés verrouillées par l'environnement : INVALID_PARAMS + youtube.settings.update(patch, adminIdentity(me)) + } catch (e) { + throw youtubeError(e) + } + const changes = describeSettingsChange(before, youtube.settings.get()) + if (changes) audit(me, "réglages YouTube", changes) + return youtubeStatus() + }) + + AdminRequest("/ADMIN/YOUTUBE/TEST", async (payload) => { + const data = asObject(payload) + const url = optionalString(data.url, "url", 500) + if (url) { + let parsed = null + try { + parsed = new URL(url) + } catch (e) { + parsed = null + } + if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(url)) throw new AdminError("INVALID_PARAMS", "Paramètre \"url\" invalide (lien http(s) attendu)") + } + const route = data.route === undefined || data.route === null ? "auto" : data.route + if (!YOUTUBE_TEST_ROUTES.includes(route)) throw new AdminError("INVALID_PARAMS", "Paramètre \"route\" invalide (auto, warp ou direct)") + const identity = data.identity === undefined || data.identity === null ? "auto" : data.identity + if (!YOUTUBE_TEST_IDENTITIES.includes(identity)) throw new AdminError("INVALID_PARAMS", "Paramètre \"identity\" invalide (auto, anon ou cookies)") + // Vide ou absent : clients des réglages + const clients = optionalString(data.clients, "clients", 200) + try { + return await youtube.check.testPlayback({ url: url || undefined, route, identity, clients: clients ?? undefined }) + } catch (e) { + throw youtubeError(e) + } + }) + + AdminRequest("/ADMIN/YOUTUBE/UPDATE", async (payload, me) => { + let result + try { + // Une mise à jour déjà en cours (automatique) est simplement attendue + result = await youtube.ytdlp.update({ reason: `manuelle, par ${me.identity.username}` }) + } catch (e) { + throw youtubeError(e) + } + audit(me, "mise à jour de yt-dlp", result?.message || "") + return { result, status: youtubeStatus() } + }) + + AdminRequest("/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR", async (payload, me) => { + youtube.diagnostics.clear() + audit(me, "effacement des diagnostics YouTube") + return youtubeStatus() + }) + + // 10. CONFIGURATION (config.json) ET INTÉGRATIONS + // Les secrets ne sont jamais renvoyés ni journalisés : seulement {set, length} + + AdminRequest("/ADMIN/CONFIG/GET", async () => configPayload()) + + AdminRequest("/ADMIN/CONFIG/SET", async (payload, me) => { + const data = asObject(payload) + if (typeof data.key !== "string" || !configuration.getKeys().includes(data.key)) { + throw new AdminError("INVALID_PARAMS", "Clé de configuration inconnue") + } + if (data.value === undefined || data.value === null) throw new AdminError("INVALID_PARAMS", "Paramètre \"value\" manquant") + let result + try { + result = configuration.setValue(data.key, data.value) + } catch (e) { + if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message) + throw e + } + if (result.changed) { + if (data.key.startsWith("api.spotify.")) spotifyLastTest = null + // Clé seulement, jamais la valeur + audit(me, "modification de la configuration", data.key + (result.requiresRestart ? " (redémarrage requis)" : "")) + // Serveur de la base multimédia : protégé dans la liste des serveurs + if (data.key === "media.guildId") process.emit("GUILDS_UPDATE") + } + return { ...configPayload(), result } + }) + + AdminRequest("/ADMIN/CONFIG/SPOTIFY/TEST", async (payload, me) => { + const result = await testSpotify() + audit(me, "test des identifiants Spotify", result.ok ? "réussi" : "échoué") + return { result, integrations: integrationsStatus() } + }) +} + +// POUSSÉES GLOBALES + +/** + * Initialisation unique : poussées périodiques et événementielles vers les rooms ADMIN / ADMIN_LOGS + * @param {import('socket.io').Server} ioInstance + * @param {Object} [deps] réservé (dépendances supplémentaires) + */ +function init(ioInstance, deps = {}) { + if (initialized) return + initialized = true + io = ioInstance + LogReader.init() + + // Vue d'ensemble toutes les 10 s, seulement si un admin regarde + setInterval(async () => { + if (roomSize(ADMIN_ROOM) === 0) return + try { + io.to(ADMIN_ROOM).emit("/ADMIN/OVERVIEW/UPDATE", await buildOverview()) + } catch (e) { + alog.error(`Vue d'ensemble admin impossible : ${e?.stack || e}`) + } + }, OVERVIEW_INTERVAL).unref() + + // Players : chaque changement d'état (coalescé à 250 ms) + const pushPlayers = debounce(() => { + if (roomSize(ADMIN_ROOM) === 0) return + io.to(ADMIN_ROOM).emit("/ADMIN/PLAYERS/UPDATE", playersList()) + }, 250) + process.on("PLAYER_STATE", pushPlayers) + process.on("PLAYERS_UPDATE", pushPlayers) + + // Processus yt-dlp (PID / route affichés dans les players et la section WARP) : Youtube.js ne déclenche + // pas d'état player à leur démarrage ou à leur fin, on compare donc leur signature toutes les 2 s + let ytdlpSignature = "" + setInterval(() => { + if (roomSize(ADMIN_ROOM) === 0) return + const map = getYoutubeProcesses() + const signature = map ? [...map.entries()].map(([guildId, proc]) => `${guildId}:${proc?.pid}:${proc?.route}`).sort().join("|") : "" + if (signature === ytdlpSignature) return + ytdlpSignature = signature + pushPlayers() + }, YTDLP_PROCESS_POLL).unref() + + // Utilisateurs connectés + const pushUsers = debounce(() => { + if (roomSize(ADMIN_ROOM) === 0) return + io.to(ADMIN_ROOM).emit("/ADMIN/USERS/UPDATE", onlineUsersPayload()) + }, 300) + process.on("USERS_UPDATE", pushUsers) + + // Serveurs Discord + const pushGuilds = debounce(() => { + if (roomSize(ADMIN_ROOM) === 0) return + io.to(ADMIN_ROOM).emit("/ADMIN/GUILDS/UPDATE", guildsList(null)) + }, 500) + process.on("GUILDS_UPDATE", pushGuilds) + + // WARP : changement d'état du proxy + process.on("WARP_UPDATE", () => { + if (roomSize(ADMIN_ROOM) === 0) return + try { + io.to(ADMIN_ROOM).emit("/ADMIN/WARP/UPDATE", warp.getStatus()) + } catch (e) { + alog.error(`Poussée WARP impossible : ${e?.message || e}`) + } + }) + + // YouTube : cookies, réglages, mises à jour de yt-dlp, tentatives de lecture (coalescé à 500 ms) + const pushYoutube = debounce(() => { + if (roomSize(ADMIN_ROOM) === 0) return + io.to(ADMIN_ROOM).emit("/ADMIN/YOUTUBE/UPDATE", youtubeStatus()) + }, 500) + process.on("YOUTUBE_UPDATE", pushYoutube) + + // Configuration modifiée (par un autre admin) : version masquée, jamais de secret + const pushConfig = debounce(() => { + if (roomSize(ADMIN_ROOM) === 0) return + io.to(ADMIN_ROOM).emit("/ADMIN/CONFIG/UPDATE", configPayload()) + }, 300) + process.on("CONFIG_UPDATE", pushConfig) + + // Maintenance (redémarrage depuis l'admin ou la commande /restart) : prévient tous les clients + process.on("MAINTENANCE", (payload) => { + try { + io.emit("/MAINTENANCE", { + type: payload?.type || "restart", + reason: payload?.reason ?? null, + at: payload?.at || new Date().toISOString() + }) + } catch (e) { + alog.error(`Annonce de maintenance impossible : ${e?.message || e}`) + } + }) + + // Plus personne n'écoute les logs : on arrête la surveillance du fichier + io.of("/").adapter.on("delete-room", (room) => { + if (room === LOGS_ROOM) LogReader.stopTail() + }) + + alog.log("Module d'administration initialisé") +} + +module.exports = { + init, + attach, + AdminError, + syncUserSockets, + disconnectUser, + ADMIN_ROOM, + LOGS_ROOM +} diff --git a/src/server/Server.js b/src/server/Server.js index 6ba1fc7..9d69f63 100644 --- a/src/server/Server.js +++ b/src/server/Server.js @@ -1,6 +1,6 @@ -const {LogType} = require('loguix') +const {LogType} = require('loguix') const wlog = new LogType("Server") -const metrics = require("webmetrik") +const SafeMetric = require("../utils/SafeMetric") const fs = require("fs") const path = require("path") @@ -22,61 +22,137 @@ const lyrics = require("../lyrics/Lyrics") const serverSettings = require("../discord/ServerSettings") const mediaBase = require("../discord/MediaBase") const googleApis = require("../playlists/Google/OAuth2") -const youtubeApi = require("../playlists/Google/YoutubeList") const configuration = require("../utils/Database/Configuration") const { List } = require('../player/List') -const { restart } = require('../utils/Maintenance') +const Admin = require("./Admin") const { isAudioFile } = require('../utils/AudioBufferCheck') const { Song } = require('../player/Song') const { getMediaInformationFromUrl } = require('../media/MediaInformation') -const allConnectedUsers = new Array() -const guildConnectedUsers = new Map() -const UsersBySocket = new Map() +// Suivi des connexions : la source de vérité est io.sockets.sockets (socket.data.userId posé à la connexion) +// et les rooms Socket.IO ("user:", ""). Les listes de présence en sont dérivées à la demande : +// un utilisateur connecté sur plusieurs appareils reste présent tant qu'il lui reste un socket dans la room. +var io = null + +// Délai de regroupement des mises à jour de présence (rafales de connexions / changements de guilde) +const USERS_UPDATE_DELAY = 200 + +// Dernier état de connexion vocale connu par guilde (pour ne notifier /GUILD/UPDATE qu'aux changements) +const lastPlayerConnected = new Map() + +/** + * Identifiants distincts des utilisateurs ayant au moins un socket dans la room + * @param {string} room + * @returns {Set} + */ +function roomUserIds(room) { + const ids = new Set() + if(!io) return ids + const socketIds = io.sockets.adapter.rooms.get(room) + if(!socketIds) return ids + for(const socketId of socketIds) { + const userId = io.sockets.sockets.get(socketId)?.data?.userId + if(userId) ids.add(userId) + } + return ids +} + +function roomSize(room) { + return io?.sockets.adapter.rooms.get(room)?.size || 0 +} + +/** + * Identités publiques des utilisateurs connectés sur une guilde (une entrée par utilisateur, quel que soit le nombre d'appareils) + * @param {string} guildId + * @returns {Array<{id: string, username: string, global_name: string, avatar: string}>} + */ +function getGuildConnectedUsers(guildId) { + const list = [] + if(!guildId) return list + for(const userId of roomUserIds(guildId)) { + const user = users.getUserById(userId) + if(!user || user.isDeleted()) continue + list.push({ + id: user.identity.id, + username: user.identity.username, + global_name: user.identity.global_name, + avatar: user.identity.avatar, + }) + } + return list +} + +// Notifie les membres d'une guilde (et les sockets présents dans sa room) que la liste des serveurs a changé +function notifyGuildUpdate(guildId) { + if(!io) return + const guild = discordBot.getGuilds().get(guildId) + const rooms = [guildId] + for(const memberId of guild?.allMembers || []) rooms.push("user:" + memberId) + io.to(rooms).emit("/GUILD/UPDATE") +} function init() { - - - wlog.step.init("server_init", "Initialisation du serveur Socket.IO") - const httpServer = createServer() - const io = new Server(httpServer, { + // Le handler HTTP n'est appelé par socket.io que pour les requêtes hors /socket.io/ + const httpServer = createServer((req, res) => { + // Route de santé (healthcheck Docker / supervision) + if(req.method === "GET" && req.url.split("?")[0] === "/health") { + res.writeHead(200, {"Content-Type": "application/json"}) + return res.end(JSON.stringify({ok: true, uptime: process.uptime()})) + } + res.writeHead(404, {"Content-Type": "application/json"}) + res.end(JSON.stringify({ok: false, error: "NOT_FOUND"})) + }) + io = new Server(httpServer, { cors: { origin: "*" }, maxHttpBufferSize: 300 * 1024 * 1024 }) - - process.on("PLAYERS_UPDATE", () => { - if(io) { - io.sockets.emit("/GUILD/UPDATE") - // Get all players and send them to client subscribed to the guild - for(var guild of discordBot.getGuilds().keys()) { - const player = players.getPlayer(guild) - if(player) { - io.to(player.guildId).emit("/PLAYER/UPDATE", player.getState()) - wlog.log("Envoi de l'état du player de la guilde : " + player.guildId + " à tous les utilisateurs connectés") - } + // Interface d'administration : poussées globales vers les rooms ADMIN / ADMIN_LOGS (une seule fois) + Admin.init(io) + + // État d'un player (regroupé par Player.markDirty) : envoyé uniquement aux sockets de sa guilde. + // On ne crée JAMAIS de player ici (lecture de AllPlayers seulement). + process.on("PLAYER_STATE", (guildId) => { + if(!io || !guildId) return + try { + const player = players.AllPlayers.get(guildId) + if(roomSize(guildId) > 0) { + io.to(guildId).emit("/PLAYER/UPDATE", player ? player.getState() : players.offlineState(guildId)) } + // La liste des serveurs (pastille "connecté") ne change qu'à la connexion / déconnexion du bot + const connected = !!(player && player.isConnected()) + if(lastPlayerConnected.get(guildId) !== connected) { + lastPlayerConnected.set(guildId, connected) + notifyGuildUpdate(guildId) + } + } catch(e) { + wlog.error("Erreur lors de l'envoi de l'état du player de la guilde " + guildId + " : " + (e?.stack || e)) } }) - process.on("USERS_UPDATE", async () => { - if(io) { - // Get all players and send them to client subscribed to the guild - for(var guild of discordBot.getGuilds().keys()) { - if(guildConnectedUsers.has(guild)) { - io.to(guild).emit("/USERS/UPDATE", {"id": guild, "members": guildConnectedUsers.get(guild)} ) - io.to("ADMIN").emit("ALL_USERS_UPDATE", allConnectedUsers) - wlog.log("Envoi de la liste des utilisateurs connectés (" + guildConnectedUsers.get(guild).length +") à la guilde : " + guild + " à tous les utilisateurs connectés") + var usersUpdateTimer = null + process.on("USERS_UPDATE", () => { + if(!io || usersUpdateTimer) return + usersUpdateTimer = setTimeout(() => { + usersUpdateTimer = null + try { + // Listes recalculées à partir des sockets réellement connectés + for(const guildId of discordBot.getGuilds().keys()) { + if(roomSize(guildId) === 0) continue + io.to(guildId).emit("/USERS/UPDATE", {"id": guildId, "members": getGuildConnectedUsers(guildId)}) } + io.sockets.emit("/USER/READY") + } catch(e) { + wlog.error("Erreur lors de la mise à jour des utilisateurs connectés : " + (e?.stack || e)) } - io.sockets.emit("/USER/READY") - } + }, USERS_UPDATE_DELAY) + if(typeof usersUpdateTimer.unref === "function") usersUpdateTimer.unref() }) process.on("VOCAL_UPDATE", async () => { @@ -85,16 +161,25 @@ function init() { } }) + // Compte Google : statut poussé à toutes les sessions de l'utilisateur (liaison, synchronisation, révocation) + process.on("GOOGLE_UPDATE", (userId) => { + if(!io || !userId) return + try { + io.to("user:" + userId).emit("/GOOGLE/STATUS/UPDATE", googleApis.getStatus(userId)) + } catch(e) { + wlog.error("Erreur lors de l'envoi du statut Google : " + (e?.stack || e)) + } + }) + + // Playlists modifiées hors d'une requête de l'utilisateur (synchronisation YouTube, playlist reçue) + process.on("PLAYLISTS_UPDATE", (userId) => { + if(!io || !userId) return + io.to("user:" + userId).emit("/PLAYLISTS/UPDATE") + }) + io.on("connection", async (socket) => { var socketUser; - - // Make sure Discord Bot is loaded and make an interruption until it is loaded - while(!await discordBot.isReady()) { - wlog.warn("Attente de traitement : "+ socket.id + " : Le bot Discord n'est pas encore chargé, attente de 0.5 seconde... (Avoid Rate Limit)") - await new Promise(resolve => setTimeout(resolve, 500)) - } - - wlog.log(`Connexion d'un client : ${socket.id}`) + var disconnectHandled = false socket.on("disconnect", (info) => { handleDisconnect() @@ -102,11 +187,19 @@ function init() { }) socket.on("error", (error) => { - handleDisconnect() wlog.error("Erreur sur le socket : " + socket.id + " - " + error) }) - - if(socket.handshake.auth == undefined || socket.handshake.auth == {}) { + + // Make sure Discord Bot is loaded and make an interruption until it is loaded + while(!await discordBot.isReady()) { + wlog.warn("Attente de traitement : "+ socket.id + " : Le bot Discord n'est pas encore chargé, attente de 0.5 seconde... (Avoid Rate Limit)") + await new Promise(resolve => setTimeout(resolve, 500)) + if(socket.disconnected) return + } + + wlog.log(`Connexion d'un client : ${socket.id}`) + + if(!socket.handshake.auth || typeof socket.handshake.auth !== "object") { wlog.warn("Authentification manquant pour le client :" + socket.id) sendSession() return @@ -126,22 +219,28 @@ function init() { if(auth_code) { const discordUser = await discordAuth.getDiscordUser(sessionId, auth_code) session.removeSession(sessionId) - if(discordUser == "USER_INFO_ERROR" || discordUser == "ACCESS_TOKEN_ERROR") { + // Toute réponse sous forme de chaîne (ou sans identité) est une erreur + if(typeof discordUser === "string" || !discordUser?.identity?.id) { wlog.warn("Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId) - socket.emit("AUTH_ERROR", discordUser) + socket.emit("AUTH_ERROR", typeof discordUser === "string" ? discordUser : "USER_INFO_ERROR") socket.disconnect() return } else { const loggedUser = await users.addUser(discordUser.auth, discordUser.identity) + if(!loggedUser) { + socket.emit("AUTH_ERROR", "USER_INFO_ERROR") + socket.disconnect() + return + } const newToken = await loggedUser.createToken() socket.emit("NEW_TOKEN", newToken) token = newToken inLogin = true wlog.log("Utilisateur Discord associé à la session : " + sessionId + " récupéré avec succès") - + } - - + + } else { wlog.warn("Code d'authentification manquant pour le client :" + socket.id) socket.emit("AUTH_ERROR", "Code manquant invalide") @@ -149,7 +248,7 @@ function init() { return } } - } + } if(!token) { wlog.warn("Token manquant pour le client :" + socket.id) @@ -179,7 +278,7 @@ function init() { if (!(await users.updateIdentity(socketUser.identity.id))) { wlog.error("Erreur lors de la mise à jour des informations de l'utilisateur : " + socketUser.identity.id); socket.emit("AUTH_ERROR", "Mise à jour des informations de l'utilisateur impossible"); - wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) + wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) socket.disconnect(); return; } @@ -187,36 +286,38 @@ function init() { } else { wlog.log("Pas de mise à jour des informations de l'utilisateur : " + socketUser.identity.id + " car l'utilisateur vient de se connecter") } - + } else { wlog.log("L'utilisateur '" + socketUser.identity.username + "' s'est connecté via la session : " + sessionId) } } + // Client parti pendant les traitements asynchrones : rien à enregistrer + if(socket.disconnected) return + socketUser = users.getUserByToken(token) - + if(socketUser) { var actualGuildId = null - if(allConnectedUsers.includes(socketUser.identity)) { - wlog.warn("L'utilisateur '" + socketUser.identity.username + "' est déjà connecté sur un autre appareil") - return - } else { - allConnectedUsers.push(socketUser.identity) - UsersBySocket.set(socketUser.identity.id, socket.id) - - } - - wlog.log("Utilisateur connecté : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) - + // Bannissement global : on coupe AVANT d'enregistrer le moindre handler if(socketUser.isFullBanned()) { wlog.warn("Utilisateur banni : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) socket.emit("AUTH_ERROR", "Vous êtes banni du serveur") - socket.disconnect() + socket.disconnect(true) + return } + + // Plusieurs appareils / onglets par utilisateur : chaque socket est rattaché à l'id de l'utilisateur + // et à la room "user:" (déconnexion, ban et suppression agissent sur toutes ses sessions) + socket.data.userId = socketUser.identity.id + socket.join("user:" + socketUser.identity.id) + + wlog.log("Utilisateur connecté : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) + if(socketUser.isAdmin()) { - socket.join("ADMIN") + socket.join(Admin.ADMIN_ROOM) wlog.log("Utilisateur admin identifié : " + socketUser.identity.username + " (" + socketUser.identity.id + ")") } @@ -225,13 +326,12 @@ function init() { // CHECKED : 24/04/2025 IORequest("/USER/INFO", () => { - socketUser = users.getUserById(socketUser.identity.id) - socketUser.identity['isAdmin'] = socketUser.isAdmin() + // socketUser est relu par IORequest ; copie de l'identité pour ne pas écrire isAdmin dans users.json var guildPresents = getUserGuilds(); - + IOAnswer("/USER/INFO", { - identity: socketUser.identity, - guilds: guildPresents, + identity: {...socketUser.identity, isAdmin: socketUser.isAdmin()}, + guilds: guildPresents, labels: socketUser.labels, history: history.getPersonalHistory(socketUser.identity.id), }, true) @@ -245,24 +345,27 @@ function init() { //CHECKED : 24/04/2025 IORequest("/USER/SIGNOUT", () => { socketUser.removeToken(token) - IOAnswer("/USER/SIGNOUT", true) + IOAnswer("/USER/SIGNOUT", true) socket.disconnect() }) - IORequest("/USER/DELETE", async () => { + IORequest("/USER/DELETE", async () => { + const userId = socketUser.identity.id socketUser.removeToken(token) - await users.deleteAccount(socketUser.identity.id) - await playlists.deleteUserPlaylists(socketUser.identity.id) - await history.clearPersonalHistory(socketUser.identity.id) - await IOAnswer("/USER/DELETE", true) - socket.disconnect() + // deleteAccount supprime aussi les jetons Google (révoqués chez Google) + await users.deleteAccount(userId) + await playlists.deleteUserPlaylists(userId) + await history.clearPersonalHistory(userId) + IOAnswer("/USER/DELETE", true) + // Toutes les sessions de l'utilisateur (autres appareils compris) + io.in("user:" + userId).disconnectSockets(true) + process.emit("USERS_UPDATE") }) - // CHECKED : 24/04/2025 + // CHECKED : 24/04/2025 IORequest("/USERS/LIST", () => { - if(!checkUserGuild(socketUser, actualGuildId)) return - if(!guildConnectedUsers.has(actualGuildId)) return IOAnswer("/USERS/LIST", false) - IOAnswer("/USERS/LIST", guildConnectedUsers.get(actualGuildId)) + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/USERS/LIST", false) + IOAnswer("/USERS/LIST", getGuildConnectedUsers(actualGuildId)) }) IORequest("/VERSION", () => { @@ -270,7 +373,7 @@ function init() { }) IORequest("/CHANGELOG", () => { - const changelogPath = __glob.CHANGELOG_PATH + const changelogPath = __glob.CHANGELOG_PATH if(!fs.existsSync(changelogPath)) { wlog.warn("Aucun changelog trouvé à l'emplacement : " + changelogPath) return IOAnswer("/CHANGELOG", false) @@ -282,7 +385,7 @@ function init() { // PLAYERS IORequest("/PLAYER/LYRICS", async () => { - if(!checkUserGuild(socketUser, actualGuildId)) return + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYER/LYRICS", false) const player = await verifyPlayerAction(actualGuildId) if(!player) return IOAnswer("/PLAYER/LYRICS", false) if(!player.queue?.current) { @@ -303,36 +406,36 @@ function init() { //CHECKED : 03/05/2025 - IORequest("/PLAYER/PREVIOUS/LIST", () => { - if(!checkUserGuild(socketUser, actualGuildId)) return + IORequest("/PLAYER/PREVIOUS/LIST", () => { + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYER/PREVIOUS/LIST", false) const list = new List(actualGuildId) IOAnswer("/PLAYER/PREVIOUS/LIST", list.getPrevious()) }) // ChECKED : 03/05/2025 IORequest("/GUILD/JOIN", async (guildId) => { - if(!checkUserGuild(socketUser, guildId)) return IOAnswer("/GUILD/JOIN", false) + if(typeof guildId !== "string" || !checkUserGuild(socketUser, guildId)) return IOAnswer("/GUILD/JOIN", false) if(socket.rooms.has(guildId)) { wlog.warn("[USUAL] - L'utilisateur '" + socketUser.identity.username + "' est déjà dans la room de la guilde : " + guildId) - IOAnswer("/GUILD/JOIN", true, true) - } else { - // Make him to leave all the other rooms except the ADMIN room if he is admin - await socket.rooms.forEach((room) => { - if(room != "ADMIN" && room != guildId && room != socket.id) { - socket.leave(room) - wlog.log("L'utilisateur '" + socketUser.identity.username + "' quitte la room de la guilde: " + room) - removeGuildConnectedUser(socketUser.identity.id) - } - }) - socket.join(guildId) - wlog.log("L'utilisateur '" + socketUser.identity.username + "' rejoint la room de la guilde : " + guildId) - addGuildConnectedUser(socketUser.identity, guildId) actualGuildId = guildId IOAnswer("/GUILD/JOIN", true, true) - process.emit("PLAYERS_UPDATE") - process.emit("USERS_UPDATE") + return } - + // Quitte uniquement les rooms des autres guildes : les rooms personnelles ("user:"), + // d'administration (ADMIN, ADMIN_LOGS) et la room du socket sont conservées + for(const room of [...socket.rooms]) { + if(isPersistentRoom(room) || room === guildId) continue + socket.leave(room) + wlog.log("L'utilisateur '" + socketUser.identity.username + "' quitte la room de la guilde: " + room) + } + socket.join(guildId) + wlog.log("L'utilisateur '" + socketUser.identity.username + "' rejoint la room de la guilde : " + guildId) + actualGuildId = guildId + IOAnswer("/GUILD/JOIN", true, true) + // État actuel du player pour ce seul socket (aucune diffusion globale) + const player = players.AllPlayers.get(guildId) + socket.emit("/PLAYER/UPDATE", player ? player.getState() : players.offlineState(guildId)) + process.emit("USERS_UPDATE") }, true) IORequest("/GUILD/INFO", () => { @@ -342,6 +445,7 @@ function init() { wlog.warn("Aucune guilde trouvée pour l'id : " + actualGuildId) return IOAnswer("/GUILD/INFO", false) } + const player = players.AllPlayers.get(actualGuildId) const guildData = { id: guild.id, name: guild.name, @@ -349,31 +453,16 @@ function init() { owner: guild.owner, members: new Array(), serverMember: guild.allMembers.length, - connected: players.getPlayer(actualGuildId) && players.getPlayer(actualGuildId).isConnected(), + connected: !!(player && player.isConnected()), labels: socketUser.labels, isOwner: socketUser.identity.id === guild.owner, isMod: socketUser.isMod(actualGuildId), isAdmin: socketUser.isAdmin(), } - for(var user of guildConnectedUsers.get(actualGuildId) || []) { - const userData = users.getUserById(user.id) - if(userData && userData.identity.id != socketUser.identity.id) { - let infos = { - id: userData.identity.id, - username: userData.identity.username, - global_name: userData.identity.global_name, - avatar: userData.identity.avatar, - isAdmin: userData.isAdmin(), - isOwner: userData.identity.id == guild.owner, - isMod: userData.isMod(actualGuildId), - } - if(guildData.members.find(m => m.id == infos.id)) continue - guildData.members.push(infos) - } - } + guildData.members = connectedMembers(actualGuildId, guild.owner) IOAnswer("/GUILD/INFO", guildData, true) }, true) - + IORequest("/GUILD/LIST", () => { const guildData = getUserGuilds() @@ -386,71 +475,70 @@ function init() { if(!player) return IOAnswer("/PLAYER/STATE", false) IOAnswer("/PLAYER/STATE", await player.getState(), true) }, true) - + // CHECKED : 03/05/2025 IORequest("/PLAYER/PAUSE", () => { - handlePlayerAction(actualGuildId, (player) => player.pause(), "/PLAYER/PAUSE"); + return handlePlayerAction(actualGuildId, (player) => player.pause(), "/PLAYER/PAUSE"); }); - + // CHECKED : 03/05/2025 IORequest("/PLAYER/BACKWARD", () => { - handlePlayerAction(actualGuildId, (player) => player.previous(), "/PLAYER/BACKWARD"); + return handlePlayerAction(actualGuildId, (player) => player.previous(), "/PLAYER/BACKWARD"); }); - + // CHECKED : 03/05/2025 IORequest("/PLAYER/FORWARD", () => { - handlePlayerAction(actualGuildId, (player) => player.skip(), "/PLAYER/FORWARD"); + return handlePlayerAction(actualGuildId, (player) => player.skip(), "/PLAYER/FORWARD"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/LOOP", () => { - handlePlayerAction(actualGuildId, (player) => player.setLoop(), "/PLAYER/LOOP"); + return handlePlayerAction(actualGuildId, (player) => player.setLoop(), "/PLAYER/LOOP"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/SHUFFLE", () => { - handlePlayerAction(actualGuildId, (player) => player.setShuffle(), "/PLAYER/SHUFFLE"); + return handlePlayerAction(actualGuildId, (player) => player.setShuffle(), "/PLAYER/SHUFFLE"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/DISCONNECT", () => { - handlePlayerAction(actualGuildId, (player) => player.leave(), "/PLAYER/DISCONNECT"); + return handlePlayerAction(actualGuildId, (player) => player.leave(), "/PLAYER/DISCONNECT"); }); // CHECKED : 03/05/2025 IORequest("/PLAYER/CHANNEL/CHANGE", () => { - handlePlayerAction(actualGuildId, (player) => { + return handlePlayerAction(actualGuildId, (player) => { const channel = getUserChannel(false, true) - if(!channel) { - IOAnswer("/PLAYER/CHANNEL/CHANGE", false) - return - } - player.changeChannel(channel) + if(!channel) return false + return player.changeChannel(channel) }, "/PLAYER/CHANNEL/CHANGE"); }); // CHECKED : 03/05/2025 - IORequest("/PLAYER/SEEK", (time) => { - if(!time) return IOAnswer("/PLAYER/SEEK", false) - handlePlayerAction(actualGuildId, (player) => { + IORequest("/PLAYER/SEEK", (data) => { + // Accepte { time } (client actuel) ou un nombre ; 0 est une position valide + const raw = data !== null && typeof data === "object" ? data.time : data + const time = Number(raw) + if(raw === null || raw === undefined || raw === "" || !Number.isFinite(time) || time < 0) return IOAnswer("/PLAYER/SEEK", false) + return handlePlayerAction(actualGuildId, (player) => { // Check if current is not null - if(player.queue.current == null) { - wlog.warn("Le player de la guilde : " + guildId + " n'a pas de musique en cours") - IOAnswer("/PLAYER/SEEK", false) - return + if(player.queue?.current == null) { + wlog.warn("Le player de la guilde : " + actualGuildId + " n'a pas de musique en cours") + return false } - player.setDuration(time) + return player.setDuration(time) }, "/PLAYER/SEEK"); }); - // CHECKED : 04/05/2025 + // CHECKED : 04/05/2025 IORequest("/QUEUE/PLAY", (data) => { if(!data) return IOAnswer("/QUEUE/PLAY", false) const {index, listType, now} = data if(index == null) return IOAnswer("/QUEUE/PLAY", false) if(listType == null) return IOAnswer("/QUEUE/PLAY", false) - if(!checkUserGuild(socketUser, actualGuildId)) return + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/QUEUE/PLAY", false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/QUEUE/PLAY", false) var song; @@ -464,7 +552,7 @@ function init() { if(!song) return IOAnswer("/QUEUE/PLAY", false) if(listType == "next") player.queue.removeNextByIndex(index) if(now) { - player.play(song) + player.playNow(song) } else { player.add(song) } @@ -475,16 +563,16 @@ function init() { // CHECKED : 04/05/2025 IORequest("/QUEUE/NEXT/DELETE", (index) => { if(index == null) return IOAnswer("/QUEUE/NEXT/DELETE", false) - handlePlayerAction(actualGuildId, (player) => { + return handlePlayerAction(actualGuildId, (player) => { const next = player.queue.getNext() - if(!next[index]) return IOAnswer("/QUEUE/NEXT/DELETE", false); + if(!next[index]) return false player.queue.removeNextByIndex(index) }, "/QUEUE/NEXT/DELETE") }) // CHECKED : 04/05/2025 IORequest("/QUEUE/NEXT/DELETEALL", () => { - handlePlayerAction(actualGuildId, (player) => player.queue.clearNext(), "/QUEUE/NEXT/DELETEALL") + return handlePlayerAction(actualGuildId, (player) => player.queue.clearNext(), "/QUEUE/NEXT/DELETEALL") }) // CHECKED : 04/05/2025 @@ -493,18 +581,19 @@ function init() { const {index, newIndex} = data if(index == null) return IOAnswer("/QUEUE/NEXT/MOVE", false) if(newIndex == null) return IOAnswer("/QUEUE/NEXT/MOVE", false) - handlePlayerAction(actualGuildId, (player) => { + return handlePlayerAction(actualGuildId, (player) => { const next = player.queue.getNext() - if(!next[index]) return IOAnswer("/QUEUE/NEXT/MOVE", false); + if(!next[index]) return false player.queue.moveNext(index, newIndex) }, "/QUEUE/NEXT/MOVE") }) // SEARCH - // CHECKED : 24/04/2025 + // CHECKED : 24/04/2025 IORequest("/SEARCH", async (query) => { - IOAnswer("/SEARCH", await Finder.search(query, true)) + if(typeof query !== "string" || query.trim() === "" || query.length > 1000) return IOAnswer("/SEARCH", false) + IOAnswer("/SEARCH", await Finder.search(query, true)) }) // CHECKED : 03/05/2025 @@ -516,11 +605,11 @@ function init() { song = JSON.parse(song) } song = new Song(song) - if(!checkUserGuild(socketUser, actualGuildId)) return + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/SEARCH/PLAY", false) const player = new Player(actualGuildId) if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/SEARCH/PLAY", false) if(now) { - player.play(song) + player.playNow(song) } else { player.add(song) } @@ -532,7 +621,9 @@ function init() { IORequest("/SEARCH/PLAYLIST", async (data) => { if(!data) return IOAnswer("/SEARCH/PLAYLIST", false) const {url, now} = data - if(!url) return IOAnswer("/SEARCH/PLAYLIST", false) + if(!url || typeof url !== "string") return IOAnswer("/SEARCH/PLAYLIST", false) + // Droits vérifiés AVANT de créer un player (et avant la recherche) + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/SEARCH/PLAYLIST", false) const playlist = await Finder.search(url, true, "PLAYLIST") if(!playlist) return IOAnswer("/SEARCH/PLAYLIST", false) const player = new Player(actualGuildId) @@ -577,23 +668,60 @@ function init() { IOAnswer("/UPLOAD/FILE/DELETE", true) }) - // GOOGLE API + // GOOGLE API (compte YouTube lié : import et synchronisation des playlists) + // Réponses : statut = {configured, linked, valid, invalidReason, channel, linkedAt, lastSync, syncing} + // actions = {ok:true, status, ...} ou {ok:false, error:CODE, message} - IORequest("/GOOGLE/AUTH", () => { - IOAnswer("/GOOGLE/AUTH", googleApis.createAuthUrl(socketUser.identity.id)) + IORequest("/GOOGLE/STATUS", (_, ack) => { + answer("/GOOGLE/STATUS", googleApis.getStatus(socketUser.identity.id), ack, true) + }, true) + + // URL de consentement Google (null si l'intégration n'est pas configurée) + IORequest("/GOOGLE/AUTH", (_, ack) => { + answer("/GOOGLE/AUTH", googleApis.createAuthUrl(socketUser.identity.id), ack) }) - IORequest("/GOOGLE/YOUTUBE/ADD_PLAYLIST", async (code) => { - if(!code) { - IOAnswer("/GOOGLE/YOUTUBE/ADD_PLAYLIST", false) + // Retour de Google : {code, state} → liaison du compte puis première synchronisation en arrière-plan + const handleGoogleCallback = (requestName) => async (data, ack) => { + const userId = socketUser.identity.id + const code = data !== null && typeof data === "object" ? data.code : data + const state = data !== null && typeof data === "object" ? data.state : null + try { + const status = await googleApis.linkAccount(userId, code, state) + // Pas d'attente : la synchronisation peut durer, son résultat est poussé + // par /GOOGLE/STATUS/UPDATE et /PLAYLISTS/UPDATE + playlists.syncYoutube(userId, { force: true }).catch((e) => { + wlog.error("Erreur lors de la synchronisation YouTube de " + userId + " : " + (e?.stack || e)) + }) + answer(requestName, { ok: true, status: { ...status, syncing: true } }, ack) + } catch(e) { + const error = googleApis.describeApiError(e) + wlog.warn("Liaison Google refusée pour '" + socketUser.identity.username + "' : " + error.code + " - " + error.message) + answer(requestName, { ok: false, error: error.code, message: error.message }, ack) } - const token = await googleApis.getAuthorization(socketUser.identity.id, code) - if(!token) { - IOAnswer("/GOOGLE/YOUTUBE/ADD_PLAYLIST", false) - return + } + IORequest("/GOOGLE/CALLBACK", handleGoogleCallback("/GOOGLE/CALLBACK")) + // Ancien nom conservé pour compatibilité (même charge utile {code, state}) + IORequest("/GOOGLE/YOUTUBE/ADD_PLAYLIST", handleGoogleCallback("/GOOGLE/YOUTUBE/ADD_PLAYLIST")) + + IORequest("/GOOGLE/YOUTUBE/SYNC", (_, ack) => { + const userId = socketUser.identity.id + const wasSyncing = googleApis.isSyncing(userId) + const pending = playlists.syncYoutube(userId) + // Les refus (non lié, déjà en cours, délai) sont immédiats : une synchronisation lancée marque "syncing" sans attendre + if(wasSyncing || !googleApis.isSyncing(userId)) { + return pending.then((result) => { + answer("/GOOGLE/YOUTUBE/SYNC", { ...result, status: googleApis.getStatus(userId) }, ack) + }) } - playlists.processYoutubeData(socketUser.identity.id, await youtubeApi.getYoutubePlaylists(socketUser.identity.id)) - IOAnswer("/GOOGLE/YOUTUBE/ADD_PLAYLIST", true) + pending.catch((e) => wlog.error("Erreur lors de la synchronisation YouTube de " + userId + " : " + (e?.stack || e))) + answer("/GOOGLE/YOUTUBE/SYNC", { ok: true, started: true, status: googleApis.getStatus(userId) }, ack) + }) + + IORequest("/GOOGLE/REVOKE", async (_, ack) => { + const userId = socketUser.identity.id + const result = await googleApis.unlink(userId, { revoke: true }) + answer("/GOOGLE/REVOKE", { ok: true, ...result, status: googleApis.getStatus(userId) }, ack) }) @@ -610,7 +738,7 @@ function init() { IORequest("/PLAYLISTS/CREATE", async (data) => { if(!data) return IOAnswer("/PLAYLISTS/CREATE", false) const {name, url} = data - if(!name) return IOAnswer("/PLAYLISTS/CREATE", false) + if(!name || typeof name !== "string") return IOAnswer("/PLAYLISTS/CREATE", false) const playlist = await playlists.addPlaylist(socketUser.identity.id, name, url, socketUser.identity.username, socketUser.identity.id, socketUser.identity.avatar) if(!playlist) return IOAnswer("/PLAYLISTS/CREATE", false) IOAnswer("/PLAYLISTS/CREATE", playlist) @@ -619,8 +747,7 @@ function init() { // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/DELETE", (data) => { if(!data) return IOAnswer("/PLAYLISTS/DELETE", false) - playlists.removePlaylist(socketUser.identity.id, data) - IOAnswer("/PLAYLISTS/DELETE", true) + IOAnswer("/PLAYLISTS/DELETE", playlists.removePlaylist(socketUser.identity.id, data)) }) // CHECKED : 24/04/2025 @@ -629,36 +756,38 @@ function init() { IOAnswer("/PLAYLISTS/LIST", playlist) }) - // CHECKED : 30/04/2025 + // Envoie une copie d'une playlist à un utilisateur partageant au moins un serveur + // {playlistId (ou name, ancien nom du champ), toId} IORequest("/PLAYLISTS/SEND", (data) => { if(!data) return IOAnswer("/PLAYLISTS/SEND", false) - const {name, toId} = data - if(!name || !toId) return IOAnswer("/PLAYLISTS/SEND", false) - // Check if toId is in the same guilds as the user - // Check if the toId exists and have a playlist with the same name + const toId = data.toId + const playlistId = data.playlistId ?? data.id ?? data.name + if(!playlistId || !toId || typeof toId !== "string") return IOAnswer("/PLAYLISTS/SEND", false) + const fromId = socketUser.identity.id + if(toId === fromId) return IOAnswer("/PLAYLISTS/SEND", false) const toUser = users.getUserById(toId) - if(!toUser) return IOAnswer("/PLAYLISTS/SEND", false) - const toPlaylists = playlists.getPlaylistsOfUser(toUser.identity.id) - const fromPlaylist = playlists.getPlaylistOfUser(socketUser.identity.id, name) - if(!fromPlaylist) return IOAnswer("/PLAYLISTS/SEND", false) - if(toPlaylists.find(p => p.name == name)) return IOAnswer("/PLAYLISTS/SEND", false) - playlists.copyPlaylist(socketUser.identity.id, toUser.identity.id, name) - IOAnswer("/PLAYLISTS/SEND", true) + if(!toUser || toUser.isDeleted()) return IOAnswer("/PLAYLISTS/SEND", false) + const shareGuild = [...discordBot.getGuilds().values()].some(g => g.allMembers?.includes(fromId) && g.allMembers?.includes(toId)) + if(!shareGuild) return IOAnswer("/PLAYLISTS/SEND", false) + // copyPlaylist refuse un doublon de titre chez le destinataire + const copy = playlists.copyPlaylist(fromId, toId, playlistId) + if(copy) process.emit("PLAYLISTS_UPDATE", toId) + IOAnswer("/PLAYLISTS/SEND", !!copy) }) // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/RENAME", (data) => { if(!data) return IOAnswer("/PLAYLISTS/RENAME", false) const {id, newName} = data - if(!id || !newName) return IOAnswer("/PLAYLISTS/RENAME", false) + if(!id || !newName || typeof newName !== "string" || newName.trim() === "" || newName.length > 200) return IOAnswer("/PLAYLISTS/RENAME", false) var playlist = playlists.getPlaylist(socketUser.identity.id, id) if(!playlist) return IOAnswer("/PLAYLISTS/RENAME", false) - playlists.renamePlaylist(socketUser.identity.id, id, newName) + playlists.renamePlaylist(socketUser.identity.id, id, newName.trim()) playlist = playlists.getPlaylist(socketUser.identity.id, id) - IOAnswer("/PLAYLISTS/RENAME", playlist) + IOAnswer("/PLAYLISTS/RENAME", playlist) }) - // CHECKED : 30/04/2025 + // CHECKED : 30/04/2025 IORequest("/PLAYLISTS/ADD_SONG", (data) => { if(!data) return IOAnswer("/PLAYLISTS/ADD_SONG", false) const {id, song} = data @@ -677,10 +806,10 @@ function init() { if(!id || !songId) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false) var playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) if(!playlist) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false) - + playlists.removeSong(socketUser.identity.id, id, songId) playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) - + IOAnswer("/PLAYLISTS/REMOVE_SONG", playlist) }) @@ -693,7 +822,7 @@ function init() { const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, name) if(!playlist) return IOAnswer("/PLAYLISTS/PLAY", false) const player = new Player(actualGuildId) - if(!await connectToPlayer(actualGuildId, player)) return IOAnswer("/PLAYLISTS/PLAY", false) + if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/PLAYLISTS/PLAY", false) player.readPlaylist(playlist, now) IOAnswer("/PLAYLISTS/PLAY", true) }) @@ -708,97 +837,29 @@ function init() { // ADMIN - if(socketUser.isAdmin()) { - // CHECKED : 24/04/2025 - IORequest("/ADMIN/LOGS", () => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/LOGS", false) - const logs_data = new Array() - const logs_folder = fs.readdirSync(__glob.LOGS) - for(var log of logs_folder) { - logs_data.push({"name":log, "value": fs.readFileSync(__glob.LOGS + path.sep + log).toString()}) - } - IOAnswer("/ADMIN/LOGS", logs_data) - }) + // Toutes les routes /ADMIN/* (Admin.js) sont enregistrées sans condition : + // les droits sont revérifiés à chaque appel sur l'utilisateur relu (promotion / rétrogradation en cours de session) + Admin.attach({ io, socket, socketUser: () => socketUser }) - // CHECKED : 24/04/2025 - IORequest("/ADMIN/MAINTENANCE/RESTART", (reason) => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/MAINTENANCE/RESTART", false) - if(!reason) return IOAnswer("/ADMIN/MAINTENANCE/RESTART", false) - restart(reason) - }) - - // CHECKED : 24/04/2025 - IORequest("/ADMIN/USERS/SWITCH_ADMIN", (userId) => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/USERS/SWITCH_ADMIN", false) - if(socketUser.identity.id == userId) return IOAnswer("/ADMIN/USERS/SWITCH_ADMIN", false) - if(!users.getUserById(userId)) return IOAnswer("/ADMIN/USERS/SWITCH_ADMIN", false) - users.setAdmin(userId) - process.emit("USERS_UPDATE") - IOAnswer("/ADMIN/USERS/SWITCH_ADMIN", true) - }) - - // CHECKED : 24/04/2025 - IORequest("/ADMIN/USERS/FULL_BAN", (userId) => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/USERS/FULL_BAN", false) - if(socketUser.identity.id == userId) return IOAnswer("/ADMIN/USERS/FULL_BAN", false) - if(!users.getUserById(userId)) return IOAnswer("/ADMIN/USERS/FULL_BAN", false) - if(users.getUserById(userId).isAdmin()) return IOAnswer("/ADMIN/USERS/FULL_BAN", false) - users.setFullBan(userId) - process.emit("USERS_UPDATE") - IOAnswer("/ADMIN/USERS/FULL_BAN", true) - }) - - - // CHECKED : 24/04/2025 - IORequest("/ADMIN/USERS/DELETE", (userId) => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/USERS/DELETE", false) - if(socketUser.identity.id == userId) return IOAnswer("/ADMIN/USERS/DELETE", false) - if(!users.getUserById(userId)) return IOAnswer("/ADMIN/USERS/DELETE", false) - if(users.getUserById(userId).isAdmin()) return IOAnswer("/ADMIN/USERS/DELETE", false) - - users.removeUser(userId) - const userSocket = UsersBySocket.get(userId) - if(userSocket) { - const socket = io.sockets.sockets.get(userSocket) - if(socket) { - socket.emit("AUTH_ERROR", "Votre compte a été supprimé") - socket.disconnect() - } - } - IOAnswer("/ADMIN/USERS/DELETE", true) - }) - - // CHECKED : 24/04/2025 - IORequest("/ADMIN/PLAYER/GETALLSTATE", async () => { - if(!socketUser.isAdmin()) return IOAnswer("/ADMIN/PLAYER/GETTALLSTATE", false) - const allPlayers = players.getAllPlayers() - const states = new Array() - for(var player in allPlayers) { - await states.push(await player.getState()) - } - IOAnswer("/ADMIN/PLAYER/GETALLSTATE", states) - }) - - - } - // CHECKED : 24/04/2025 IORequest("/OWNER/USERS/SWITCH_MOD", async (userId) => { if(!userId || !actualGuildId) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) if(socketUser.identity.id == userId) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) const guild = discordBot.getGuilds().get(actualGuildId) - if(!socketUser.identity.id === guild.owner) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) + // Réservé au propriétaire du serveur (ou à un administrateur du bot) + if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) + if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) if(userId == guild.owner) return IOAnswer("/OWNER/USERS/SWITCH_MOD", false) await users.setGuildMod(userId, actualGuildId) - await process.emit("USERS_UPDATE") + process.emit("USERS_UPDATE") IOAnswer("/OWNER/USERS/SWITCH_MOD", true) }) IORequest("/OWNER/ROLES/GET", async () => { if(!actualGuildId) return IOAnswer("/OWNER/ROLES/GET", false) const guild = discordBot.getGuilds().get(actualGuildId) - if(!socketUser.identity.id === guild.owner) return IOAnswer("/OWNER/ROLES/GET", false) + if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/ROLES/GET", false) const rolesSecure = discordBot.getGuildRoles(actualGuildId) const actualRole = await serverSettings.getSecureRole(actualGuildId) // Move the actual role at the start of the array @@ -820,33 +881,40 @@ function init() { IORequest("/OWNER/ROLES/SET", async (data) => { if(!actualGuildId) return IOAnswer("/OWNER/ROLES/SET", false) const guild = discordBot.getGuilds().get(actualGuildId) - if(!socketUser.identity.id === guild.owner) return IOAnswer("/OWNER/ROLES/SET", false) - await serverSettings.setSecureRole(actualGuildId, data) + if(!isGuildOwnerOrAdmin(guild)) return IOAnswer("/OWNER/ROLES/SET", false) + // null = retrait de la protection ; sinon le rôle doit exister sur le serveur + // et on n'enregistre que les données issues de Discord (jamais l'objet du client) + var role = null + if(data) { + role = discordBot.getGuildRoles(actualGuildId).find(r => r.id === data?.id) || null + if(!role) return IOAnswer("/OWNER/ROLES/SET", false) + } + await serverSettings.setSecureRole(actualGuildId, role) IOAnswer("/OWNER/ROLES/SET", true) }) + // Vrai si l'utilisateur du socket est le propriétaire de la guilde ou un administrateur du bot + function isGuildOwnerOrAdmin(guild) { + if(!guild) return false + if(socketUser.identity.id === guild.owner) return true + return socketUser.isAdmin() + } + // CHECKED : 24/04/2025 IORequest("/MOD/USERS/BAN", async (userId) => { - if(!userId || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false) + if(!userId || typeof userId !== "string" || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false) const guild = discordBot.getGuilds().get(actualGuildId) if(!guild) return IOAnswer("/MOD/USERS/BAN", false) if(!socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return IOAnswer("/MOD/USERS/BAN", false) if(socketUser.identity.id == userId) return IOAnswer("/MOD/USERS/BAN", false) const user = users.getUserById(userId) - if(user.isMod(actualGuildId) || user.identity.id == guild.owner) return IOAnswer("/MOD/USERS/BAN", false) + if(!user || user.isMod(actualGuildId) || user.identity.id == guild.owner) return IOAnswer("/MOD/USERS/BAN", false) + // Bascule ban / déban sur la guilde active await users.setGuildBan(userId, actualGuildId) - // If user is connected, disconnect him - const userSocket = UsersBySocket.get(userId) - if(userSocket) { - const socket = io.sockets.sockets.get(userSocket) - if(socket) { - removeGuildConnectedUser(userId) - if(socket.rooms.has(actualGuildId)) { - socket.leave(actualGuildId) - } - } - } - await process.emit("USERS_UPDATE") + // Toutes les sessions de l'utilisateur quittent la room de la guilde s'il est désormais banni + // (syncUserSockets émet aussi USERS_UPDATE) + Admin.syncUserSockets(userId) + io.to("user:" + userId).emit("/GUILD/UPDATE") IOAnswer("/MOD/USERS/BAN", true) }) @@ -862,11 +930,14 @@ function init() { const userData = users.getUserById(user) if(!userData) continue if(userData.labels.includes("DELETED")) continue - userData.identity['isAdmin'] = userData.isAdmin() - userData.identity['isMod'] = userData.isMod(actualGuildId) - userData.identity['isOwner'] = userData.identity.id == guild.owner + // Copie : ces drapeaux ne doivent pas être enregistrés dans users.json guildUserList.push({ - identity: userData.identity, + identity: { + ...userData.identity, + isAdmin: userData.isAdmin(), + isMod: userData.isMod(actualGuildId), + isOwner: userData.identity.id == guild.owner, + }, isBanned: userData.isBanned(actualGuildId), }) } @@ -876,33 +947,69 @@ function init() { // UTILS // CHECKED : 24/04/2025 - IORequest("/REPORT", (data) => { + IORequest("/REPORT", async (data) => { if(!data) return IOAnswer("/REPORT", false) - if(!data["level"] || !data["desc"]) return IOAnswer("/REPORT", false) - const report = new Report(socketUser.identity.username, data["level"], data["desc"]).send() - IOAnswer("/REPORT", true) + const level = data["level"] + const desc = data["desc"] + if(!level || typeof level !== "string" || !desc || typeof desc !== "string" || desc.trim() === "") return IOAnswer("/REPORT", false) + // Réponse = résultat réel de l'envoi dans le salon de rapport + const sent = await new Report(socketUser.identity.username, level, desc.slice(0, 4000)).send() + if(!sent) wlog.warn("Le rapport de '" + socketUser.identity.username + "' n'a pas pu être envoyé") + IOAnswer("/REPORT", sent === true) }) IORequest("/MOD/STATS", () => { - if(!actualGuildId) return IOAnswer("/MOD/USERS/LIST", false) + // Toutes les réponses partent sur /MOD/STATS (le front attend ce canal) + const answer = (value) => IOAnswer("/MOD/STATS", value, true) + if(!actualGuildId) return answer(false) const guild = discordBot.getGuilds().get(actualGuildId) - if(!guild) return IOAnswer("/MOD/USERS/LIST", false) - if(!socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return IOAnswer("/MOD/USERS/LIST", false) - if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/MOD/USERS/LIST", false) - const metrics = JSON.parse(fs.readFileSync(__glob.METRIC_FILE)) - const metricsToSend = new Array() - for(var metric of metrics) { - if(metric.name.includes(actualGuildId)) { - metricsToSend.push(metric) - } - } - IOAnswer("/MOD/STATS", metricsToSend, true) + if(!guild) return answer(false) + const isAdmin = socketUser.isAdmin() + if(!isAdmin && !socketUser.isMod(actualGuildId) && socketUser.identity.id !== guild.owner) return answer(false) + if(!isAdmin && !checkUserGuild(socketUser, actualGuildId)) return answer(false) + // Métriques lues depuis le cache mémoire : noms terminant exactement par _, valeurs numériques finies + const metricsToSend = SafeMetric.getGuildMetrics(actualGuildId).map(metric => ({ + name: metric.name, + description: typeof metric.description === "string" ? metric.description : metric.name, + value: SafeMetric.toSafeNumber(metric.value, 0) + })) + answer(metricsToSend) }, true) - // Functions + // Functions + + // Rooms conservées lors d'un changement de guilde + function isPersistentRoom(room) { + return room === socket.id + || room === Admin.ADMIN_ROOM + || room === Admin.LOGS_ROOM + || (typeof room === "string" && room.startsWith("user:")) + } + + /** + * Membres connectés d'une guilde (hors utilisateur du socket), avec leurs rôles + */ + function connectedMembers(guildId, ownerId) { + const members = [] + for(const user of getGuildConnectedUsers(guildId)) { + if(user.id === socketUser.identity.id) continue + const userData = users.getUserById(user.id) + if(!userData) continue + members.push({ + id: userData.identity.id, + username: userData.identity.username, + global_name: userData.identity.global_name, + avatar: userData.identity.avatar, + isAdmin: userData.isAdmin(), + isOwner: userData.identity.id == ownerId, + isMod: userData.isMod(guildId), + }) + } + return members + } function getUserChannel(usual, force) { const botChannel = getBotChannel() @@ -915,7 +1022,7 @@ function init() { const channelId = member.channelId const guildId = member.guildId if(guildId != actualGuildId) { - if(!usual) wlog.warn("La guilde active : " + actualGuildId + " ne correspond pas à la guilde du channel vocal : " + guildId + " de l'utilisateur '" + socketUser.identity.username + "'") + if(!usual) wlog.warn("La guilde active : " + actualGuildId + " ne correspond pas à la guilde du channel vocal : " + guildId + " de l'utilisateur '" + socketUser.identity.username + "'") return null } const channel = discordBot.getChannel(guildId, channelId) @@ -933,52 +1040,30 @@ function init() { function getUserGuilds() { var guildPresents = new Array(); var guildsOfBot = discordBot.getGuilds() + const isAdmin = socketUser.isAdmin() for(var guild of guildsOfBot) { if(guild[1].allMembers.includes(socketUser.identity.id)) { - const guildData = guild[1] - guildData['members'] = new Array() + // Copie : les champs propres à l'utilisateur ne doivent pas être écrits sur l'objet partagé du bot + const guildData = {...guild[1]} guildData.serverMember = guild[1].allMembers.length guildData.restricted = false const secureRole = serverSettings.getSecureRole(guild[0]) - if(secureRole && socketUser.identity.id !== discordBot.getGuilds().get(guild[0]).owner) { - const member = discordBot.getGuildMember(guild[0], socketUser.identity.id) - if(!member.roles.cache.has(secureRole.id) && socketUser.identity.id !== guild[1].owner && !socketUser.isAdmin()) { - guildData.restricted = true - } else { - guildData.restricted = false - } + if(secureRole && socketUser.identity.id !== guild[1].owner && !isAdmin) { + const member = discordBot.getGuildMember(guild[0], socketUser.identity.id) + guildData.restricted = !member?.roles?.cache?.has(secureRole.id) } guildData.allowed = true - for(var user of guildConnectedUsers.get(guild[0]) || []) { - const userData = users.getUserById(user.id) - if(userData && userData.identity.id != socketUser.identity.id) { - let infos = { - id: userData.identity.id, - global_name: userData.identity.global_name, - username: userData.identity.username, - avatar: userData.identity.avatar, - isAdmin: userData.isAdmin(), - isOwner: userData.identity.id == guild[1].owner, - isMod: userData.isMod(guild[0]), - } - // If it's already in guildData.members, skip - if(guildData.members.find(m => m.id == infos.id)) continue - guildData.members.push(infos) + guildData.members = connectedMembers(guild[0], guild[1].owner) - } - } - // Send if the bot is connected to the guild - if(players.getPlayer(guild[0]) && players.getPlayer(guild[0]).isConnected()) { - guildData.connected = true - } else { - guildData.connected = false - } + const player = players.AllPlayers.get(guild[0]) + guildData.connected = !!(player && player.isConnected()) // Leave the room if the user is not in the guild if(socket.rooms.has(guild[0]) && !checkUserGuild(socketUser, guild[0])) { socket.leave(guild[0]) - removeGuildConnectedUser(socketUser.identity.id) + if(actualGuildId === guild[0]) actualGuildId = null + process.emit("USERS_UPDATE") wlog.warn("L'utilisateur '" + socketUser.identity.username + "' quitte la room de la guilde : " + guild[0] + " car il n'est pas dans la guilde) /!\\") } guildPresents.push(guildData) @@ -988,10 +1073,8 @@ function init() { } function getBotChannel() { - const playersList = players.getAllPlayers() - if(!playersList || playersList.length == 0) return null - const player = playersList.find(p => p.isConnected() && p.guildId == actualGuildId) - if(player) { + const player = players.AllPlayers.get(actualGuildId) + if(player && player.isConnected()) { const channel = discordBot.getChannel(actualGuildId, player.channelId) if(channel) { return channel @@ -1001,20 +1084,22 @@ function init() { } /** - * @param {Player} player + * @param {Player} player */ function connectToPlayer(guildId, player) { if(!checkUserGuild(socketUser, guildId)) return false - if(player.isConnected()) true + // Déjà connecté sur cette guilde : on ne déplace pas le bot + if(player.isConnected()) return true const channel = getUserChannel() if(!channel) return false player.join(channel) return true } + // Player existant uniquement (jamais de création ici) async function verifyPlayerAction(guildId) { if (!checkUserGuild(socketUser, guildId)) return null; - const player = players.getPlayer(guildId); + const player = players.AllPlayers.get(guildId); if (player) { return player; } else { @@ -1025,21 +1110,13 @@ function init() { function checkUserGuild(socketUser, guildId) { - if(!guildId) { + if(!guildId || typeof guildId !== "string") { wlog.warn("Aucun guildId n'est actif pour l'utilisateur : " + socketUser.identity.username) return false } - // Check role if secure role is set - const secureRole = serverSettings.getSecureRole(guildId) - if(secureRole && socketUser.identity.id !== discordBot.getGuilds().get(guildId).owner) { - const member = discordBot.getGuildMember(guildId, socketUser.identity.id) - if(member.roles.cache.has(secureRole.id) == false) { - wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'a pas le rôle requis pour accéder à la guilde : " + guildId) - return false - } - } - // Check if the guildId is referenced in the bot guilds - if(!discordBot.getGuilds().has(guildId)) { + // Check if the guildId is referenced in the bot guilds + const guild = discordBot.getGuilds().get(guildId) + if(!guild) { wlog.warn("La guilde : " + guildId + " n'est pas référencée dans le bot") return false } @@ -1047,12 +1124,21 @@ function init() { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' est banni de la guilde : " + guildId) return false } - const allGuilds = discordBot.getGuilds() - - if(!allGuilds.get(guildId).allMembers.includes(socketUser.identity.id)) { + const isAdmin = socketUser.isAdmin() + // Check role if secure role is set (propriétaire et administrateurs du bot exemptés, comme dans getUserGuilds) + const secureRole = serverSettings.getSecureRole(guildId) + if(secureRole && socketUser.identity.id !== guild.owner && !isAdmin) { + const member = discordBot.getGuildMember(guildId, socketUser.identity.id) + if(!member?.roles?.cache?.has(secureRole.id)) { + wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'a pas le rôle requis pour accéder à la guilde : " + guildId) + return false + } + } + + if(!guild.allMembers.includes(socketUser.identity.id)) { wlog.warn("L'utilisateur '" + socketUser.identity.username + "' n'est pas membre de la guilde : " + guildId) // Si user admin, override - if(!socketUser.isAdmin()) { + if(!isAdmin) { return false } wlog.log("L'utilisateur '" + socketUser.identity.username + "' est admin donc à le droit d'accéder à la guilde : " + guildId) @@ -1061,40 +1147,38 @@ function init() { return true } - /** - * @param {function(Player)} action - The action to perform on the player. - */ - async function handlePlayerAction(guildId, action, actionName) { - if (!checkUserGuild(socketUser, guildId)) return; - const player = players.getPlayer(guildId); - if (player) { - await action(player); - wlog.log(`L'utilisateur '${socketUser.identity.username}' effectue l'action '${actionName}' sur le player de la guilde : ${guildId}`); - IOAnswer(actionName, true); - } else { - wlog.warn(`Le player de la guilde : ${guildId} n'existe pas`); - IOAnswer(actionName, false); - } - - + // Réponse refusée par le player (false, "no_music" ou {ok:false, reason}) : transmise telle quelle au client + function isRefusal(result) { + return result === false || result === "no_music" || (result !== null && typeof result === "object" && result.ok === false) } - + + /** + * Exécute une action sur le player EXISTANT de la guilde et répond une seule fois + * @param {function(Player)} action - renvoie false / "no_music" / {ok:false} en cas de refus + */ + async function handlePlayerAction(guildId, action, actionName) { + if (!checkUserGuild(socketUser, guildId)) return IOAnswer(actionName, false); + const player = players.AllPlayers.get(guildId); + if (!player) { + wlog.warn(`Le player de la guilde : ${guildId} n'existe pas`); + return IOAnswer(actionName, false); + } + const result = await action(player); + wlog.log(`L'utilisateur '${socketUser.identity.username}' effectue l'action '${actionName}' sur le player de la guilde : ${guildId}`); + if (isRefusal(result)) return IOAnswer(actionName, result); + IOAnswer(actionName, true); + } + } - + function handleDisconnect() { - if(socketUser) { - wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) - allConnectedUsers.splice(allConnectedUsers.indexOf(socketUser.identity), 1) - removeGuildConnectedUser(socketUser.identity.id) - process.emit("USERS_UPDATE") - // Remove every rooms include admin - socket.rooms.forEach((room) => { - socket.leave(room) - }) - UsersBySocket.delete(socketUser.identity.id) - } + // Les rooms sont quittées automatiquement par Socket.IO : la présence est recalculée depuis les sockets restants + if(disconnectHandled || !socketUser) return + disconnectHandled = true + wlog.log("Déconnexion de l'utilisateur : " + socketUser.identity.username + " (" + socketUser.identity.id + ") - Socket : " + socket.id) + process.emit("USERS_UPDATE") } function sendSession() { @@ -1104,23 +1188,60 @@ function init() { socket.disconnect() } - + + /** + * Enregistre une route. À chaque requête : + * - l'utilisateur est relu (bannissement, droits, suppression appliqués immédiatement à tous ses sockets) ; + * - toute exception est journalisée et le client reçoit false (jamais de rejet non géré). + * Le callback reçoit (valeur, ack) : ack est la fonction d'acquittement socket.io si le client en fournit une. + */ function IORequest(RequestName, RequestCallback, usual) { - socket.on(RequestName, (value) => { + socket.on(RequestName, async (value, ack) => { + if(typeof value === "function" && ack === undefined) { + ack = value + value = undefined + } + const fresh = users.getUserById(socket.data.userId) + if(!fresh || fresh.isDeleted() || fresh.isFullBanned()) { + wlog.warn("Requête refusée (" + RequestName + ") : compte supprimé ou banni - Socket : " + socket.id) + socket.emit("AUTH_ERROR", fresh?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte n'est plus valide") + socket.disconnect(true) + return + } + socketUser = fresh if(!usual) { - wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + RequestName + " - [RECIEVED]") + wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + RequestName + " - [RECIEVED]") + } + try { + await RequestCallback(value, typeof ack === "function" ? ack : undefined) + } catch(e) { + wlog.error("Erreur lors du traitement de " + RequestName + " pour '" + socketUser?.identity?.username + "' : " + (e?.stack || e)) + try { + if(typeof ack === "function") ack(false) + else IOAnswer(RequestName, false, true) + } catch(err) { + // Socket fermé entre-temps : rien à répondre + } } - RequestCallback(value) }) - + } function IOAnswer(AnswerName, AnswerValue, usual) { if(!usual) { - wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + AnswerName + " - [ANSWERED]") + wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + AnswerName + " - [ANSWERED]") } socket.emit(AnswerName, AnswerValue) } + // Réponse par acquittement si le client en a fourni un, sinon par l'événement du même nom + function answer(AnswerName, AnswerValue, ack, usual) { + if(typeof ack === "function") { + if(!usual) wlog.log(socketUser.identity.username + " - Socket : " + socket.id + " - " + AnswerName + " - [ACK]") + return ack(AnswerValue) + } + IOAnswer(AnswerName, AnswerValue, usual) + } + }) httpServer.listen(configuration.getPort(), () => { @@ -1128,41 +1249,6 @@ function init() { wlog.step.end("server_init") }) - function addGuildConnectedUser(user, guildId) { - // Check if the user is already connected to the guild - if(!guildConnectedUsers.has(guildId)) { - guildConnectedUsers.set(guildId, new Array()) - } - - if(guildConnectedUsers.get(guildId).includes(user)) { - wlog.warn("L'utilisateur '" + user.username + "' est déjà connecté à la guilde : " + guildId) - return - } else { - guildConnectedUsers.get(guildId).push(user) - } - - } - - - function removeGuildConnectedUser(userId) { - for(var guild of guildConnectedUsers.keys()) { - const users = guildConnectedUsers.get(guild) - const userIndex = users.findIndex(u => u.id == userId) - if(userIndex != -1) { - users.splice(userIndex, 1) - } - } - } - - - } - - - - - - - -module.exports = {init} \ No newline at end of file +module.exports = {init} diff --git a/src/server/auth/DiscordAuth.js b/src/server/auth/DiscordAuth.js index 59ab385..0b734cc 100644 --- a/src/server/auth/DiscordAuth.js +++ b/src/server/auth/DiscordAuth.js @@ -1,11 +1,35 @@ -const { LogType } = require('loguix'); +const { LogType } = require('loguix'); const dlog = new LogType("DiscordAuth"); const { getWebsiteLink, getClientSecret } = require('../../utils/Database/Configuration'); +// Délai maximal d'une requête vers l'API Discord +const REQUEST_TIMEOUT = 10 * 1000; +/** + * Appelle l'API Discord et renvoie le JSON, en levant une erreur si le statut HTTP n'est pas 2xx + * (429 / 401 renvoient un JSON {message, retry_after} qui ne doit jamais être pris pour un utilisateur) + */ +async function discordFetch(url, options = {}) { + const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT) }); + let body = null; + try { + body = await resp.json(); + } catch (e) { + body = null; + } + if (!resp.ok) { + const detail = body?.error_description || body?.error || body?.message || resp.statusText; + const error = new Error(`HTTP ${resp.status} : ${detail}`); + error.status = resp.status; + throw error; + } + return body; +} + +/** + * @returns {Promise<{auth, identity}|"ACCESS_TOKEN_ERROR"|"USER_INFO_ERROR">} + */ async function getDiscordUser(sessionId, auth_code) { - - return new Promise((resolve, reject) => { const discordBot = require("../../discord/Bot") const discordBotClient = discordBot.getClient() dlog.step.init("discord_auth_" + sessionId, "Authentification Discord de la session :" + sessionId); @@ -18,109 +42,97 @@ async function getDiscordUser(sessionId, auth_code) { params.append("grant_type", "authorization_code"); params.append("code", auth_code); params.append("redirect_uri", getWebsiteLink() + "/redirect"); - params.append("scope", "identify guilds"); - fetch("https://discord.com/api/oauth2/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: params, - }).then(accessTokenResp => accessTokenResp.json()).then(accessToken => { - if (accessToken.error) { - dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + accessToken.error + " : " + accessToken.error_description); - resolve("ACCESS_TOKEN_ERROR"); - return; - } - - - dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId); - - fetch("https://discord.com/api/users/@me", { - headers: { - authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }, - }).then(userResp => userResp.json()).then(user => { - dlog.log("Récupération réussi des informations de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")"); - // Get the guilds of the user - fetch("https://discord.com/api/users/@me/guilds", { - headers: { - authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }, - }).then(guildsResp => guildsResp.json()).then(guilds => { - dlog.log("Récupération réussi des guildes de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")"); - dlog.step.end("discord_auth_" + sessionId) - const userData = { - auth: accessToken, - identity: user, - guilds: guilds, - } - resolve(userData); - }).catch(err => { - dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des guildes de l'utilisateur Discord" + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")" + " associé à la session : " + sessionId + " : " + err); - resolve("GUILDS_ERROR"); - }); - - }).catch(err => { - dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err); - resolve( "USER_INFO_ERROR"); - }) - - }).catch(err => { - dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err); - resolve("ACCESS_TOKEN_ERROR"); - }) - }) -} - -function refreshToken(refresh_token) { - return new Promise((resolve, reject) => { - const discordBot = require("../../discord/Bot") - const params = new URLSearchParams(); - params.append("client_id", discordBot.getClient().user.id); - params.append("client_secret", getClientSecret()); - params.append("grant_type", "refresh_token"); - params.append("refresh_token", refresh_token); - params.append("scope", "identify guilds"); - - fetch("https://discord.com/api/oauth2/token", { + let accessToken; + try { + accessToken = await discordFetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params, - }).then(accessTokenResp => accessTokenResp.json()).then(accessToken => { - if (accessToken.error) { - dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken.error + " : " + accessToken.error_description); - resolve(null); - return; - } - resolve(accessToken); - }).catch(err => { - dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err); - resolve(null); - }) - }) -} + }); + if (!accessToken?.access_token || accessToken.error) { + throw new Error((accessToken?.error || "Réponse invalide") + " : " + (accessToken?.error_description || "")); + } + } catch (err) { + dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err.message); + return "ACCESS_TOKEN_ERROR"; + } + dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId); -function getUserIdentity(accessToken) { - return new Promise((resolve, reject) => { - fetch("https://discord.com/api/users/@me", { + // Le scope demandé par le site est "identify" : l'appel /users/@me/guilds était toujours refusé et inutilisé + try { + const user = await discordFetch("https://discord.com/api/users/@me", { headers: { authorization: `${accessToken.token_type} ${accessToken.access_token}`, }, - }).then(userResp => userResp.json()).then(user => { - if (user.error) { - dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + user.error + " : " + user.error_description); - resolve(null); - return; - } - resolve(user); - }).catch(err => { - dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err); - resolve(null); - }) - }) + }); + if (!user?.id) throw new Error("Identité Discord sans identifiant"); + dlog.log("Récupération réussi des informations de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")"); + dlog.step.end("discord_auth_" + sessionId) + return { + auth: accessToken, + identity: user, + }; + } catch (err) { + dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err.message); + return "USER_INFO_ERROR"; + } } -module.exports = {getDiscordUser, refreshToken, getUserIdentity} \ No newline at end of file + +/** + * @returns {Promise} nouveau token d'accès, null en cas d'échec + */ +async function refreshToken(refresh_token) { + if (!refresh_token) return null; + const discordBot = require("../../discord/Bot") + const params = new URLSearchParams(); + params.append("client_id", discordBot.getClient().user.id); + params.append("client_secret", getClientSecret()); + params.append("grant_type", "refresh_token"); + params.append("refresh_token", refresh_token); + + try { + const accessToken = await discordFetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + if (!accessToken?.access_token || accessToken.error) { + dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken?.error + " : " + accessToken?.error_description); + return null; + } + return accessToken; + } catch (err) { + dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err.message); + return null; + } +} + +/** + * @returns {Promise} identité Discord (/users/@me), null en cas d'échec + */ +async function getUserIdentity(accessToken) { + if (!accessToken?.access_token) return null; + try { + const user = await discordFetch("https://discord.com/api/users/@me", { + headers: { + authorization: `${accessToken.token_type} ${accessToken.access_token}`, + }, + }); + if (!user?.id) { + dlog.error("Réponse de Discord sans identifiant utilisateur"); + return null; + } + return user; + } catch (err) { + dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err.message); + return null; + } +} + +module.exports = {getDiscordUser, refreshToken, getUserIdentity} diff --git a/src/server/auth/Session.js b/src/server/auth/Session.js index 7341403..81503c0 100644 --- a/src/server/auth/Session.js +++ b/src/server/auth/Session.js @@ -1,33 +1,70 @@ const { LogType } = require('loguix'); -const { generateSessionId } = require('../../utils/TokenManager'); +const { generateSessionId } = require('../../utils/TokenManager'); const clog = new LogType("Session"); -const sessions = new Array(); +// Une session OAuth n'est utile que le temps de la redirection Discord : on l'expire après 10 minutes +const SESSION_TTL = 10 * 60 * 1000; +const CLEAN_INTERVAL = 60 * 1000; +// sessionId -> date de création (ms) +const sessions = new Map(); + +/** + * Supprime les sessions expirées (OAuth jamais terminé) + */ +function purgeExpired() { + const now = Date.now(); + let removed = 0; + for (const [sessionId, createdAt] of sessions) { + if (now - createdAt > SESSION_TTL) { + sessions.delete(sessionId); + removed++; + } + } + if (removed > 0) clog.log(`${removed} session(s) expirée(s) supprimée(s).`); + return removed; +} + +setInterval(purgeExpired, CLEAN_INTERVAL).unref(); function checkSession(sessionId) { - return sessions.includes(sessionId); + if (typeof sessionId !== "string") return false; + const createdAt = sessions.get(sessionId); + if (createdAt === undefined) return false; + if (Date.now() - createdAt > SESSION_TTL) { + sessions.delete(sessionId); + clog.warn(`Session ${sessionId} expirée.`); + return false; + } + return true; } function addSession() { + purgeExpired(); const sessionId = generateSessionId(); - if (checkSession(sessionId)) { - clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`); + if (sessions.has(sessionId)) { + clog.warn(`La session ${sessionId} existe déjà, génération d'un nouvel identifiant.`); return addSession(); // Recursively generate a new session ID if it already exists } - sessions.push(sessionId); + sessions.set(sessionId, Date.now()); clog.log(`Nouvelle session ${sessionId} ajoutée.`); return sessionId; } function removeSession(sessionId) { - const index = sessions.indexOf(sessionId); - if (index > -1) { - sessions.splice(index, 1); + if (sessions.delete(sessionId)) { clog.log(`Suppression de la session ${sessionId}.`); } else { clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`); } } -module.exports = {checkSession, addSession, removeSession}; \ No newline at end of file +/** + * Nombre de sessions OAuth en attente (vue d'ensemble de l'administration) + */ +function getSessionCount() { + purgeExpired(); + return sessions.size; +} + +module.exports = {checkSession, addSession, removeSession, getSessionCount}; diff --git a/src/server/auth/User.js b/src/server/auth/User.js index 58a5cdf..0e4efdd 100644 --- a/src/server/auth/User.js +++ b/src/server/auth/User.js @@ -4,53 +4,86 @@ const { generateToken } = require('../../utils/TokenManager'); const { LogType } = require('loguix'); const clog = new LogType("User"); const discordAuth = require('./DiscordAuth'); -const e = require('cors'); const UserDB = new Database("Users", __glob.USERFILE, []); var userList = new Array(); + +/** + * Ajoute ou retire un label. value === undefined : bascule (ancien comportement). + * @returns {boolean} présence du label après l'opération + */ +function applyLabel(labels, label, value) { + const has = labels.includes(label); + const wanted = value === undefined ? !has : !!value; + if (wanted && !has) labels.push(label); + if (!wanted && has) { + // Retire toutes les occurrences (doublons éventuels dans users.json) + for (let i = labels.length - 1; i >= 0; i--) { + if (labels[i] === label) labels.splice(i, 1); + } + } + return wanted; +} + class User { auth; identity; tokens; labels; - constructor(auth, identity, tokens, labels) { + banInfo; + constructor(auth, identity, tokens, labels, banInfo) { this.auth = auth; this.identity = identity; - this.tokens = tokens; - this.labels = labels; + this.tokens = tokens || []; + this.labels = labels || []; + // Raison et date du bannissement global : {reason, at} ou null + this.banInfo = banInfo || null; } - setAdmin() { + /** + * @param {boolean} [value] true = admin, false = retiré, undefined = bascule + * @returns {boolean|null} nouvel état + */ + setAdmin(value) { const userInUserList = userList.find(user => user.identity.id === this.identity.id); if (!userInUserList) { clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); return null; } - if (userInUserList.labels.includes("ADMIN")) { - userInUserList.labels.splice(userInUserList.labels.indexOf("ADMIN"), 1); - clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`); - } else { - userInUserList.labels.push("ADMIN"); + const wasAdmin = userInUserList.labels.includes("ADMIN"); + const isAdmin = applyLabel(userInUserList.labels, "ADMIN", value); + if (wasAdmin === isAdmin) return isAdmin; + if (isAdmin) { clog.log(`L'utilisateur ${this.identity.username} est maintenant admin.`); + } else { + clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`); } saveUsers() + return isAdmin; } - setBan(guildId) { + /** + * @param {string} guildId + * @param {boolean} [value] undefined = bascule + * @returns {boolean|null} nouvel état + */ + setBan(guildId, value) { const userInUserList = userList.find(user => user.identity.id === this.identity.id); if (!userInUserList) { clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); return null; } const banLabel = `BAN_${guildId}`; - if (userInUserList.labels.includes(banLabel)) { - userInUserList.labels.splice(userInUserList.labels.indexOf(banLabel), 1); - clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`); - } else { - userInUserList.labels.push(banLabel); + const wasBanned = userInUserList.labels.includes(banLabel); + const banned = applyLabel(userInUserList.labels, banLabel, value); + if (wasBanned === banned) return banned; + if (banned) { clog.log(`L'utilisateur ${this.identity.username} est maintenant banni du serveur ${guildId}.`); + } else { + clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`); } saveUsers() + return banned; } createToken() { @@ -118,20 +151,36 @@ class User { } - setFullBan() { + /** + * Bannissement global + * @param {boolean} [value] undefined = bascule + * @param {string} [reason] + * @returns {boolean|null} nouvel état + */ + setFullBan(value, reason) { const userInUserList = userList.find(user => user.identity.id === this.identity.id); if (!userInUserList) { clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); return null; } - if (userInUserList.labels.find(label => label == "BAN")) { - userInUserList.labels.splice(userInUserList.labels.indexOf("BAN"), 1); - clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`); + const wasBanned = userInUserList.labels.includes("BAN"); + const banned = applyLabel(userInUserList.labels, "BAN", value); + if (banned) { + if (!wasBanned || reason) { + userInUserList.banInfo = { reason: reason ? String(reason).slice(0, 500) : null, at: new Date().toISOString() }; + } } else { - userInUserList.labels.push("BAN"); - clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`); + userInUserList.banInfo = null; + } + if (wasBanned !== banned) { + if (banned) { + clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`); + } else { + clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`); + } } saveUsers() + return banned; } isBanned(guildId) { @@ -148,6 +197,9 @@ class User { const modLabel = `MOD_${guildId}`; return this.labels.includes(modLabel) || this.isAdmin(); } + isDeleted() { + return this.labels.includes("DELETED"); + } justUpdated() { const userInUserList = userList.find(user => user.identity.id === this.identity.id); @@ -187,13 +239,19 @@ class User { async function refreshAllUserInformation() { await loadUsers(); clog.log("Récupération des informations de tous les utilisateurs..."); - for (const user of userList) { - await updateCredientials(user.identity.id); + // Une seule sauvegarde à la fin plutôt qu'une réécriture complète du fichier par utilisateur + for (const user of [...userList]) { + if (!user.auth) continue; + await updateCredientials(user.identity.id, false); } saveUsers(); } -async function updateCredientials(id) { +/** + * @param {string} id + * @param {boolean} [save=true] false pour regrouper les sauvegardes (rafraîchissement global) + */ +async function updateCredientials(id, save = true) { const user = getUserById(id); if (!user) { clog.warn(`Utilisateur ${id} non trouvé.`); @@ -223,7 +281,7 @@ async function updateCredientials(id) { else { clog.warn(`Aucune authentification trouvée pour l'utilisateur ${user.identity.username} (${user.identity.id})`); } - saveUsers(); + if (save) saveUsers(); return user.auth; } @@ -277,6 +335,11 @@ async function updateIdentity(id) { * @returns {User} user */ async function addUser(auth, identity) { + // Une réponse d'erreur de Discord (429, 401...) n'a pas d'id : on ne crée jamais d'utilisateur fantôme + if (!identity || typeof identity.id !== "string" || identity.id === "") { + clog.error("Ajout d'un utilisateur refusé : identité Discord invalide"); + return null; + } // Check if the user already exists const existingUser = userList.find(user => user.identity.id === identity.id); if (existingUser) { @@ -320,12 +383,12 @@ function removeToken(token) { if (index > -1) { user.tokens.splice(index, 1); saveUsers(); - clog.log(`Token ${token} supprimé pour l'utilisateur ${user.identity.username}.`); + clog.log(`Token supprimé pour l'utilisateur ${user.identity.username}.`); } else { - clog.warn(`Token ${token} non trouvé pour l'utilisateur ${user.identity.username}.`); + clog.warn(`Token non trouvé pour l'utilisateur ${user.identity.username}.`); } } else { - clog.warn(`Utilisateur avec le token "${token}" non trouvé.`); + clog.warn(`Utilisateur introuvable pour le token fourni.`); } return user; } @@ -336,7 +399,7 @@ function addToken(id) { const token = generateToken(); user.tokens.push(token); saveUsers(); - clog.log(`Token "${token}" ajouté pour l'utilisateur ${user.identity.username}.`); + clog.log(`Token ajouté pour l'utilisateur ${user.identity.username}.`); return token; } else { clog.warn(`Utilisateur ${id} non trouvé.`); @@ -392,74 +455,151 @@ function getSimpleUser(id) { // SET LABELS -async function setAdmin(id) { +/** + * Toutes les fonctions de labels acceptent une valeur explicite (idempotent) ; + * sans valeur, elles basculent l'état comme avant (compatibilité). + * @returns {Promise} nouvel état, null si l'utilisateur est introuvable ou protégé + */ +async function setAdmin(id, value) { const user = getUserById(id); if (user) { - await user.setAdmin(); - await saveUsers(); + return user.setAdmin(value); } else { clog.warn(`Utilisateur ${id} non trouvé.`); + return null; } } -async function setGuildMod(id, guildId) { +async function setGuildMod(id, guildId, value) { const user = getUserById(id); if (user) { const modLabel = `MOD_${guildId}`; - if (user.labels.includes(modLabel)) { - await user.labels.splice(user.labels.indexOf(modLabel), 1); - clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`); - } else { - await user.labels.push(modLabel); - clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`); + const wasMod = user.labels.includes(modLabel); + const isMod = applyLabel(user.labels, modLabel, value); + if (wasMod !== isMod) { + if (isMod) { + clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`); + } else { + clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`); + } + saveUsers(); } - await saveUsers(); + return isMod; } else { clog.warn(`Utilisateur ${id} non trouvé.`); + return null; } } -async function setGuildBan(id, guildId) { +async function setGuildBan(id, guildId, value) { const user = getUserById(id); if (user) { - if(user.isAdmin()) { + if(user.isAdmin() && (value === undefined ? !user.isBanned(guildId) : value)) { clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`); - return; + return null; } - await user.setBan(guildId); - await saveUsers(); + return user.setBan(guildId, value); } else { clog.warn(`Utilisateur ${id} non trouvé.`); + return null; } } -async function setFullBan(id) { +async function setFullBan(id, value, reason) { const user = getUserById(id); if (user) { - if(user.isAdmin()) { + if(user.isAdmin() && (value === undefined ? !user.isFullBanned() : value)) { clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`); - return; + return null; } - await user.setFullBan(); - await saveUsers(); + return user.setFullBan(value, reason); } else { clog.warn(`Utilisateur ${id} non trouvé.`); + return null; } } -function deleteAccount(id) { +/** + * Révoque toutes les sessions web (tokens) d'un utilisateur + * @returns {number} nombre de tokens révoqués + */ +function revokeTokens(id) { const user = getUserById(id); - if (user) { - user.labels = user.labels.filter(label => label.includes('BAN')); - user.labels.push('DELETED'); // Add a deleted label - user.tokens = []; // Clear tokens - user.auth = null; // Clear authentication - user.identity = { id: user.identity.id, username: user.identity.username }; // Keep only identity information - saveUsers(); - clog.log(`Suppression du compte de l'utilisateur ${user.identity.username}.`); - } else { + if (!user) { clog.warn(`Utilisateur ${id} non trouvé.`); + return 0; } + const count = user.tokens?.length || 0; + if (count > 0) user.clearToken(); + return count; +} + +/** + * Suppression douce : garde l'entrée et les labels BAN (un banni ne retrouve pas un compte vierge), + * ajoute DELETED et efface tokens, authentification et identité détaillée. + * @param {string} id + * @param {{purge?: boolean}} [options] purge : supprime aussi playlists, historique et médias + * @returns {Promise<{playlists: number, history: number, media: number}|null>} éléments purgés, null si introuvable + */ +async function deleteAccount(id, { purge = false } = {}) { + const user = getUserById(id); + if (!user) { + clog.warn(`Utilisateur ${id} non trouvé.`); + return null; + } + user.labels = user.labels.filter(label => label.includes('BAN')); + user.labels.push('DELETED'); // Add a deleted label + user.tokens = []; // Clear tokens + user.auth = null; // Clear authentication + user.identity = { id: user.identity.id, username: user.identity.username }; // Keep only identity information + saveUsers(); + clog.log(`Suppression du compte de l'utilisateur ${user.identity.username}.`); + + // Jetons Google (identifiants, pas des données) : toujours supprimés, révocation chez Google sans attendre + try { + // Chargement différé : OAuth2.js dépend de ce module + require('../../playlists/Google/OAuth2').unlink(id, { revoke: true }).catch((e) => { + clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`); + }); + } catch (e) { + clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`); + } + + const purged = { playlists: 0, history: 0, media: 0 }; + if (!purge) return purged; + + // Chargement différé : évite les dépendances circulaires au démarrage + const { getDatabase } = require('../../utils/Database/Database'); + try { + const playlistDB = getDatabase("Playlists"); + purged.playlists = playlistDB?.data?.[id]?.length || 0; + await require('../../playlists/PlaylistManager').deleteUserPlaylists(id); + } catch (e) { + clog.error(`Purge des playlists de ${id} impossible : ${e.message}`); + } + try { + const historyDB = getDatabase("history"); + purged.history = historyDB?.data?.[id]?.length || 0; + if (historyDB?.data?.[id]) { + delete historyDB.data[id]; + historyDB.save(); + } + } catch (e) { + clog.error(`Purge de l'historique de ${id} impossible : ${e.message}`); + } + try { + const mediaDB = getDatabase("media"); + if (Array.isArray(mediaDB?.data)) { + const before = mediaDB.data.length; + mediaDB.data = mediaDB.data.filter(m => m.userId !== id); + purged.media = before - mediaDB.data.length; + if (purged.media > 0) mediaDB.save(); + } + } catch (e) { + clog.error(`Purge des médias de ${id} impossible : ${e.message}`); + } + clog.log(`Purge des données de l'utilisateur ${id} : ${purged.playlists} playlist(s), ${purged.history} entrée(s) d'historique, ${purged.media} média(s).`); + return purged; } // USERS DB @@ -468,12 +608,15 @@ function loadUsers() { UserDB.load() userList = new Array(); for (const user of UserDB.getData()) { + if(!user?.identity?.id) { + clog.warn("Entrée utilisateur sans identifiant ignorée dans users.json."); + continue; + } if(user?.labels?.includes("DELETED")) { - clog.log(`Utilisateur ${user.identity.id} marqué comme supprimé, ignoré.`); - userList.push(new User(null, user.identity, [], user.labels)); + userList.push(new User(null, user.identity, [], user.labels, user.banInfo)); continue; // Skip deleted users } - userList.push(new User(user.auth, user.identity, user.tokens, user.labels)); + userList.push(new User(user.auth, user.identity, user.tokens, user.labels, user.banInfo)); } clog.log(`Chargement de ${userList.length} utilisateurs.`); return userList; @@ -481,12 +624,14 @@ function loadUsers() { function saveUsers() { UserDB.data = userList.map(user => { - return { + const data = { auth: user.auth, identity: user.identity, tokens: user.tokens, labels: user.labels, }; + if (user.banInfo) data.banInfo = user.banInfo; + return data; }); UserDB.save() clog.log(`Sauvegarde de ${userList.length} utilisateurs.`); @@ -503,9 +648,9 @@ function clearNeedUpdateForUsers() { } -module.exports = {User} module.exports = { - addUser, + User, + addUser, setFullBan, removeUser, getUserByToken, @@ -522,5 +667,6 @@ module.exports = { refreshAllUserInformation, updateIdentity, clearNeedUpdateForUsers, - deleteAccount + deleteAccount, + revokeTokens }; diff --git a/src/utils/Database/Configuration.js b/src/utils/Database/Configuration.js index 9933a8e..5f8c045 100644 --- a/src/utils/Database/Configuration.js +++ b/src/utils/Database/Configuration.js @@ -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 - -} \ No newline at end of file + getMediaChannelId, + // Administration + getMasked, + setValue, + validateValue, + getKeys + +} diff --git a/src/utils/Database/Database.js b/src/utils/Database/Database.js index 47f3d59..d4f098c 100644 --- a/src/utils/Database/Database.js +++ b/src/utils/Database/Database.js @@ -11,76 +11,172 @@ function getDatabase(name) { return AllDatabases[name] } +/** + * Écriture atomique : fichier temporaire puis renommage, pour ne jamais laisser + * un fichier tronqué si le process est tué pendant l'écriture. + * Sous Windows / OneDrive, le renommage peut échouer (fichier verrouillé) : on retente + * puis on se replie sur une écriture directe. + * @param {string} filePath + * @param {string} content + */ +function writeFileAtomic(filePath, content) { + const tmp = filePath + ".tmp" + // Le temporaire reprend les droits du fichier existant (ex : google.json en 600, jamais lisible même brièvement) + let mode + try { mode = fs.statSync(filePath).mode & 0o777 } catch(e) { /* nouveau fichier : droits par défaut */ } + fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : undefined) + let lastError = null + for(let attempt = 0; attempt < 3; attempt++) { + try { + fs.renameSync(tmp, filePath) + return + } catch(e) { + lastError = e + } + } + // Repli : écriture directe (non atomique) puis nettoyage du temporaire + clog.warn(`Renommage atomique impossible pour '${filePath}' (${lastError?.code || lastError}), écriture directe`) + fs.writeFileSync(filePath, content) + try { fs.unlinkSync(tmp) } catch(e) { /* temporaire déjà absent */ } +} + class Database { data; path; name; empty; constructor(name, path, empty) { - if(name == undefined || path == undefined) throw clog.error("Impossible de créer une base de données sans nom ou sans chemin") + if(name == undefined || path == undefined) { + clog.error("Impossible de créer une base de données sans nom ou sans chemin") + throw new Error("Impossible de créer une base de données sans nom ou sans chemin") + } clog.log(`Enregistrement de la base de données '${name}' contenu dans ${path}`) this.name = name; this.path = path; - this.data = {}; + this.loaded = false; if(empty) this.empty = empty; else this.empty = {} + this.data = this.getEmpty(); this.load() AllDatabases[name] = this } + // Copie profonde de la valeur par défaut (jamais partagée avec this.data) + getEmpty() { + return JSON.parse(JSON.stringify(this.empty)) + } + load() { - clog.log(`Chargement de la base de données '${this.name}'`) try { this.create() this.update() } catch(e) { clog.error(`Erreur lors du chargement de la base de données '${this.name}'`) - clog.error(e) + clog.error(e?.stack || e) } - + } create() { try { if(!fs.existsSync(this.path)) { clog.warn(`Le fichier de la base de données '${this.name}' n'existe pas, création du fichier`) - fs.writeFileSync(this.path, JSON.stringify(this.empty, null, 2)) + fs.mkdirSync(path.dirname(this.path), { recursive: true }) + writeFileAtomic(this.path, JSON.stringify(this.empty, null, 2)) } } catch(e) { clog.error(`Erreur lors de la création de la base de données '${this.name}'`) - clog.error(e) + clog.error(e?.stack || e) } } save() { try { - clog.log(`Sauvegarde de la base de données '${this.name}'`) - fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2)) - + const content = JSON.stringify(this.data, null, 2) + if(content === undefined) throw new Error("Données non sérialisables") + writeFileAtomic(this.path, content) } catch(e) { clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`) - clog.error(e) + clog.error(e?.stack || e) } - // Assure that the database is up to date and reloaded - this.update() - + // La mémoire fait foi : plus de relecture du fichier après la sauvegarde. + // (Relire remplaçait this.data par un nouvel objet : les références détenues + // ailleurs devenaient orphelines et des modifications concurrentes étaient perdues.) + // Pour recharger explicitement depuis le disque, appeler load() ou update(). + } + + /** + * Vérifie que la donnée lue a le même type que la valeur par défaut (tableau / objet) + * @param {*} parsed + * @returns {boolean} + */ + isValidShape(parsed) { + if(parsed === null || typeof parsed !== "object") return false + return Array.isArray(parsed) === Array.isArray(this.empty) + } + + /** + * Met de côté un fichier illisible sous le nom .corrupted- + * @param {string} filePath + */ + quarantine(filePath) { + const target = filePath + ".corrupted-" + Date.now() + try { + fs.renameSync(filePath, target) + } catch(e) { + // Renommage refusé (Windows / OneDrive) : copie à la place + fs.copyFileSync(filePath, target) + } + clog.warn(`Fichier corrompu de la base de données '${this.name}' sauvegardé sous : ${target}`) } update() { - try{ - clog.log(`Mise à jour de la base de données '${this.name}'`) - let rawdata = fs.readFileSync(this.path); - this.data = JSON.parse(rawdata); - clog.log(`Base de donnée '${this.name}' chargée avec succès`) - + let rawdata + try { + rawdata = fs.readFileSync(this.path, "utf-8"); } catch(e) { - clog.error(`Erreur lors de la mise à jour de la base de données '${this.name}'`) - clog.error(e) - + // Erreur d'accès (verrou, permission…) : on garde les données en mémoire, sans rien écraser + clog.error(`Erreur lors de la lecture de la base de données '${this.name}'`) + clog.error(e?.stack || e) + return } - + let parsed + let valid = false + try { + // Retrait d'un éventuel BOM UTF-8 (fichier édité à la main sous Windows) + parsed = JSON.parse(rawdata.replace(/^/, "")); + valid = this.isValidShape(parsed) + if(!valid) clog.error(`Contenu inattendu dans la base de données '${this.name}' (type ${Array.isArray(parsed) ? "tableau" : typeof parsed} au lieu de ${Array.isArray(this.empty) ? "tableau" : "objet"})`) + } catch(e) { + clog.error(`Fichier JSON invalide pour la base de données '${this.name}' : ${e?.message || e}`) + } + + if(valid) { + this.data = parsed; + this.loaded = true + return + } + + // Fichier corrompu : on le met de côté au lieu de l'écraser plus tard avec une base vide + try { + this.quarantine(this.path) + } catch(e) { + clog.error(`Impossible de sauvegarder le fichier corrompu de la base de données '${this.name}', aucune réécriture`) + clog.error(e?.stack || e) + return + } + + if(this.loaded) { + // Données déjà chargées en mémoire : elles font foi, on réécrit un fichier sain + clog.warn(`Base de données '${this.name}' : réécriture depuis les données en mémoire`) + } else { + clog.warn(`Base de données '${this.name}' : réinitialisation avec la valeur par défaut`) + this.data = this.getEmpty() + this.loaded = true + } + this.save() } getData() { @@ -91,4 +187,4 @@ class Database { -module.exports = {Database, getDatabase} \ No newline at end of file +module.exports = {Database, getDatabase} diff --git a/src/utils/GlobalVars.js b/src/utils/GlobalVars.js index 9d3f48d..e856c4f 100644 --- a/src/utils/GlobalVars.js +++ b/src/utils/GlobalVars.js @@ -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} \ No newline at end of file diff --git a/src/utils/Limiter.js b/src/utils/Limiter.js new file mode 100644 index 0000000..739f351 --- /dev/null +++ b/src/utils/Limiter.js @@ -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: (fn: () => Promise|T) => Promise, 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} fn + * @param {(error: any, item: T, index: number) => R} [onError] + * @returns {Promise} + */ +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: (key: string, fn: () => Promise) => Promise, 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} 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 }; diff --git a/src/utils/LogReader.js b/src/utils/LogReader.js new file mode 100644 index 0000000..c6c9212 --- /dev/null +++ b/src/utils/LogReader.js @@ -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} 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>} 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 +}; diff --git a/src/utils/Maintenance.js b/src/utils/Maintenance.js index 5a772fd..cc74cc4 100644 --- a/src/utils/Maintenance.js +++ b/src/utils/Maintenance.js @@ -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} \ No newline at end of file +/** + * 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} diff --git a/src/utils/SafeMetric.js b/src/utils/SafeMetric.js new file mode 100644 index 0000000..c17d6d2 --- /dev/null +++ b/src/utils/SafeMetric.js @@ -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}} 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 +}; diff --git a/src/utils/TimeConverter.js b/src/utils/TimeConverter.js index 73e2d3e..206e2cb 100644 --- a/src/utils/TimeConverter.js +++ b/src/utils/TimeConverter.js @@ -1,46 +1,62 @@ +/** + * Conversion de durées. Toute entrée invalide (NaN, Infinity, undefined, chaîne non numérique, négatif) + * est ramenée à 0 : on n'affiche jamais "NaN". Même format que frontend/src/utils/TimeConverter.js. + */ + +/** + * Convertit une valeur en nombre de secondes fini et >= 0 (sinon 0). + * Les chaînes ne sont acceptées que si elles sont strictement numériques ("116.088163" oui, "117290116.0881631..." non). + */ +function toSeconds(duration) { + let value = NaN + if (typeof duration === "number") { + value = duration + } else if (typeof duration === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(duration)) { + value = Number(duration) + } + if (!Number.isFinite(value) || value < 0) return 0 + return Math.floor(value) +} + +function pad(number) { + return String(number).padStart(2, "0") +} + +/** + * @param {number} duration Durée en secondes + * @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide) + */ function getReadableDuration(duration) { - var max = "" - duration *= 1000; + const total = toSeconds(duration) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 - const maxhours = Math.floor(duration / 3600000); - - var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60); - var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60); - - - if (maxsec < 10) { - maxsec = `0${maxsec}`; + if (hours > 0) { + return `${hours}h${pad(minutes)}m${pad(seconds)}s` } - - - if(maxhours != 0) { - - if (maxmin < 10) { - maxmin = `0${maxmin}`; - } - - - max = maxhours + "h" + maxmin + "m" + maxsec - } else { - max = maxmin + "m" + maxsec + "s" - - } - - return max + return `${minutes}m${pad(seconds)}s` } +/** + * @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss + * @returns {number} Secondes (0 si le format est invalide) + */ function getSecondsDuration(duration) { - // Duration is in format hh:mm:ss and can be just m:ss or mm:ss - var durationArray = duration.split(":"); - var seconds = 0; - if(durationArray.length == 3) { - seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]); - } else if(durationArray.length == 2) { - seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]); - } else { - seconds = parseInt(durationArray[0]); + if (typeof duration === "number") return toSeconds(duration) + if (typeof duration !== "string" || duration.trim() === "") return 0 + + const parts = duration.trim().split(":").map(part => parseInt(part, 10)) + if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0 + + // On lit de droite à gauche : secondes, minutes, heures + let seconds = 0 + let multiplier = 1 + for (let i = parts.length - 1; i >= 0; i--) { + seconds += parts[i] * multiplier + multiplier *= 60 } - return seconds; + return seconds } -module.exports = {getReadableDuration, getSecondsDuration} \ No newline at end of file +module.exports = {getReadableDuration, getSecondsDuration} diff --git a/src/utils/WarpProxy.js b/src/utils/WarpProxy.js index ea13f61..3010d2d 100644 --- a/src/utils/WarpProxy.js +++ b/src/utils/WarpProxy.js @@ -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 }; diff --git a/src/youtube/check.js b/src/youtube/check.js new file mode 100644 index 0000000..dbfdf2e --- /dev/null +++ b/src/youtube/check.js @@ -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 }; diff --git a/src/youtube/cookies.js b/src/youtube/cookies.js new file mode 100644 index 0000000..419abe1 --- /dev/null +++ b/src/youtube/cookies.js @@ -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 +}; diff --git a/src/youtube/diagnostics.js b/src/youtube/diagnostics.js new file mode 100644 index 0000000..531b8b8 --- /dev/null +++ b/src/youtube/diagnostics.js @@ -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 }; diff --git a/src/youtube/errors.js b/src/youtube/errors.js new file mode 100644 index 0000000..33fb3e4 --- /dev/null +++ b/src/youtube/errors.js @@ -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 avec ou sans guillemets) + .replace(/(--cookies(?:-from-browser)?\s+)("[^"]*"|'[^']*'|\S+)/gi, '$1') + // 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 }; diff --git a/src/youtube/index.js b/src/youtube/index.js new file mode 100644 index 0000000..a8018ee --- /dev/null +++ b/src/youtube/index.js @@ -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 }; diff --git a/src/youtube/plan.js b/src/youtube/plan.js new file mode 100644 index 0000000..d0b6a67 --- /dev/null +++ b/src/youtube/plan.js @@ -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} 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 }; diff --git a/src/youtube/settings.js b/src/youtube/settings.js new file mode 100644 index 0000000..358a92a --- /dev/null +++ b/src/youtube/settings.js @@ -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 }; diff --git a/src/youtube/util.js b/src/youtube/util.js new file mode 100644 index 0000000..2558e71 --- /dev/null +++ b/src/youtube/util.js @@ -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 }; diff --git a/src/youtube/ytdlp.js b/src/youtube/ytdlp.js new file mode 100644 index 0000000..bba4100 --- /dev/null +++ b/src/youtube/ytdlp.js @@ -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} + */ +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} + */ +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 +}; diff --git a/tests/dev/.gitignore b/tests/dev/.gitignore new file mode 100644 index 0000000..333c1e9 --- /dev/null +++ b/tests/dev/.gitignore @@ -0,0 +1 @@ +logs/ diff --git a/tests/dev/README.md b/tests/dev/README.md new file mode 100644 index 0000000..ea67479 --- /dev/null +++ b/tests/dev/README.md @@ -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/.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). diff --git a/tests/dev/_setup.js b/tests/dev/_setup.js new file mode 100644 index 0000000..e108cff --- /dev/null +++ b/tests/dev/_setup.js @@ -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 }; diff --git a/tests/dev/admin-config.test.js b/tests/dev/admin-config.test.js new file mode 100644 index 0000000..9a2fb9c --- /dev/null +++ b/tests/dev/admin-config.test.js @@ -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); }); diff --git a/tests/dev/admin-youtube.test.js b/tests/dev/admin-youtube.test.js new file mode 100644 index 0000000..e4bbbe0 --- /dev/null +++ b/tests/dev/admin-youtube.test.js @@ -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); }); diff --git a/tests/dev/avatar.live.test.js b/tests/dev/avatar.live.test.js new file mode 100644 index 0000000..f449ae3 --- /dev/null +++ b/tests/dev/avatar.live.test.js @@ -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); }); diff --git a/tests/dev/check.e2e.js b/tests/dev/check.e2e.js new file mode 100644 index 0000000..3e7daf2 --- /dev/null +++ b/tests/dev/check.e2e.js @@ -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; }); diff --git a/tests/dev/cookies.test.js b/tests/dev/cookies.test.js new file mode 100644 index 0000000..825501e --- /dev/null +++ b/tests/dev/cookies.test.js @@ -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('Login'))); +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(); +})(); diff --git a/tests/dev/errors.test.js b/tests/dev/errors.test.js new file mode 100644 index 0000000..f0e91a1 --- /dev/null +++ b/tests/dev/errors.test.js @@ -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: (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(); diff --git a/tests/dev/google-flow.test.js b/tests/dev/google-flow.test.js new file mode 100644 index 0000000..123057e --- /dev/null +++ b/tests/dev/google-flow.test.js @@ -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) }) diff --git a/tests/dev/google-utils.test.js b/tests/dev/google-utils.test.js new file mode 100644 index 0000000..854cf9b --- /dev/null +++ b/tests/dev/google-utils.test.js @@ -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) }) diff --git a/tests/dev/limiter.test.js b/tests/dev/limiter.test.js new file mode 100644 index 0000000..c2d2ef9 --- /dev/null +++ b/tests/dev/limiter.test.js @@ -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); +})(); diff --git a/tests/dev/metadata.live.test.js b/tests/dev/metadata.live.test.js new file mode 100644 index 0000000..0ce40a8 --- /dev/null +++ b/tests/dev/metadata.live.test.js @@ -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); }); diff --git a/tests/dev/plan.test.js b/tests/dev/plan.test.js new file mode 100644 index 0000000..b3ba559 --- /dev/null +++ b/tests/dev/plan.test.js @@ -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(); diff --git a/tests/dev/server-socket.test.js b/tests/dev/server-socket.test.js new file mode 100644 index 0000000..97c113c --- /dev/null +++ b/tests/dev/server-socket.test.js @@ -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) }) diff --git a/tests/dev/stream.e2e.js b/tests/dev/stream.e2e.js new file mode 100644 index 0000000..cf1f4ce --- /dev/null +++ b/tests/dev/stream.e2e.js @@ -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; }); diff --git a/tests/dev/stream.edge.js b/tests/dev/stream.edge.js new file mode 100644 index 0000000..6b0e7d8 --- /dev/null +++ b/tests/dev/stream.edge.js @@ -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; }); diff --git a/tests/dev/undef-check.js b/tests/dev/undef-check.js new file mode 100644 index 0000000..f7e7eff --- /dev/null +++ b/tests/dev/undef-check.js @@ -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é") diff --git a/tests/dev/update.check.js b/tests/dev/update.check.js new file mode 100644 index 0000000..1a8e7f1 --- /dev/null +++ b/tests/dev/update.check.js @@ -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 })));