Version 2.0.0 - update of everything

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

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Dépendances : réinstallées dans l'image (les binaires Windows du poste de dev sont inutilisables sous Alpine)
node_modules
npm-debug.log*
# Données et secrets (token Discord, cookies YouTube, comptes, tokens OAuth) : fournis par le volume /app/data
data
.env
.env.*
*.pem
*.key
# Logs et fichiers temporaires
logs
tmp
*.log
*.tmp
# Dépôt git et fichiers de développement
.git
.gitignore
.vscode
.idea
Dockerfile
.dockerignore
docker-compose.yml
README.md
TODOS.md
# CHANGELOG.html est volontairement conservé : il est servi par la requête /CHANGELOG
+34 -8
View File
@@ -2,24 +2,50 @@ FROM node:lts-alpine
WORKDIR /app 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 # 1. Installer les dépendances système requises
# python3 : indispensable pour faire tourner yt-dlp # python3 : indispensable pour faire tourner yt-dlp (version zipimport, auto-actualisable avec -U)
# ffmpeg : nécessaire pour certaines conversions audio ou formats spécifiques # ffmpeg : seek YouTube (décodage du flux) et certains formats
# curl : pour télécharger yt-dlp # curl : pour télécharger yt-dlp et le plugin bgutil
RUN apk add --no-cache bash python3 ffmpeg curl 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 # 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 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 ./ COPY package*.json ./
RUN npm install 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 . . 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 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 # Mise à jour de yt-dlp à chaque démarrage : une version obsolète provoque des erreurs 403
CMD ["sh", "-c", "yt-dlp -U || true; npm run start"] # (le bot la refait ensuite toutes les 24 h et après une série d'échecs, voir src/youtube/ytdlp.js)
# exec : node remplace sh et devient PID 1 (il reçoit SIGTERM pour un arrêt propre).
# Plus de nodemon en production : un crash termine le process et Docker relance le conteneur (restart: unless-stopped).
CMD ["sh", "-c", "yt-dlp -U || true; exec node src/main.js"]
+48
View File
@@ -0,0 +1,48 @@
# Services Docker du développement local, lancés par ./chopin sous WSL (Docker Engine, pas Docker Desktop).
# Le backend et le frontend tournent hors Docker (Node.js Linux) : seuls WARP et bgutil sont ici.
# Usage manuel : docker compose -f docker-compose.dev.yml -p chopin-dev up -d
name: chopin-dev
services:
# Proxy Cloudflare WARP (HTTP + SOCKS5 sur 1080) utilisé par yt-dlp : WARP_PROXY=http://127.0.0.1:1080
warp:
image: caomingjun/warp
container_name: chopin-dev-warp
# Pas de relance au démarrage de Docker : seulement après un plantage
restart: on-failure
# Requis pour que WARP puisse créer son interface TUN
device_cgroup_rules:
- "c 10:200 rwm"
cap_add:
- MKNOD
- AUDIT_WRITE
- NET_ADMIN
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv4.conf.all.src_valid_mark=1
environment:
- WARP_SLEEP=2
# - WARP_LICENSE_KEY= # optionnel : clé WARP+
# Uniquement sur la boucle locale : sinon ce serait un proxy ouvert
ports:
- "127.0.0.1:1080:1080"
volumes:
# Inscription WARP conservée entre deux lancements
- chopin-dev-warp-data:/var/lib/cloudflare-warp
# Fournisseur de PO tokens pour yt-dlp (le plugin bgutil est installé côté WSL par ./chopin).
# Réseau de l'hôte : yt-dlp transmet son --proxy (http://127.0.0.1:1080) dans chaque requête,
# et bgutil s'en sert lui-même. Dans un réseau bridge, 127.0.0.1 serait le conteneur bgutil,
# pas le port WARP publié ; avec network_mode: host, c'est bien la boucle locale de WSL.
# Le serveur n'a pas d'authentification : on l'écoute seulement sur 127.0.0.1 (l'image écoute
# par défaut sur 0.0.0.0, d'où la commande). La version doit correspondre à BGUTIL_VERSION dans ./chopin.
bgutil-provider:
image: brainicism/bgutil-ytdlp-pot-provider:2.0.1
container_name: chopin-dev-bgutil
restart: on-failure
init: true
network_mode: host
command: ["--host", "127.0.0.1", "--port", "4416"]
volumes:
chopin-dev-warp-data:
+21
View File
@@ -25,23 +25,44 @@ services:
volumes: volumes:
- subsonics-warp-data:/var/lib/cloudflare-warp - 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: subsonics-backend:
build: build:
context: . # dossier backend context: . # dossier backend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- BGUTIL_VERSION=2.0.1
container_name: subsonics-backend 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: ports:
- "4000:4000" - "4000:4000"
environment: environment:
- WARP_PROXY=http://warp:1080 - WARP_PROXY=http://warp:1080
# Si WARP tombe ou se fait bloquer, on retente en connexion directe # Si WARP tombe ou se fait bloquer, on retente en connexion directe
- WARP_FALLBACK_DIRECT=true - 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: depends_on:
- warp - warp
- bgutil-provider
volumes: volumes:
# Contient aussi data/youtube/ : cookies YouTube, réglages et cache de yt-dlp
- subsonics-backend-data:/app/data - subsonics-backend-data:/app/data
# Logs persistés (consultables depuis l'interface d'administration)
- subsonics-backend-logs:/app/logs
restart: unless-stopped restart: unless-stopped
volumes: volumes:
subsonics-backend-data: subsonics-backend-data:
subsonics-backend-logs:
subsonics-warp-data: subsonics-warp-data:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "chopin-backend", "name": "chopin-backend",
"version": "1.5.0", "version": "2.0.0",
"description": "Discord Bot for music - Fetching everywhere !", "description": "Discord Bot for music - Fetching everywhere !",
"main": "src/main.js", "main": "src/main.js",
"nodemonConfig": { "nodemonConfig": {
+59 -13
View File
@@ -2,30 +2,76 @@ const bot = require('./Bot')
const dlog = require('loguix').getInstance('Discord') const dlog = require('loguix').getInstance('Discord')
const {ActivityType} = require('discord.js') 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() { function getActivity() {
const client = bot.getClient() const client = bot.getClient()
return client.user.presence.activities[0] 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 // Set All type of activities
function setMusicActivity(songName, artistName, imageUrl) { function setMusicActivity(songName, artistName) {
const client = bot.getClient() applyActivity(`${songName} - ${artistName}`)
client.user.setActivity(`${songName} - ${artistName}`,{
type: ActivityType.Listening,
url: imageUrl
});
dlog.log(`Activité mise à jour : ${songName} - ${artistName}`)
} }
function idleActivity() { function idleActivity() {
const client = bot.getClient() applyActivity("le silence absolu")
client.user.setActivity("le silence absolu", { }
type: ActivityType.Listening
}); /**
dlog.log(`Activité mise à jour : rien`) * Calcule la présence à partir de TOUS les players (cohérente en multi-serveur)
*/
function computeActivity() {
const { getAllPlayers } = require('../player/Player')
const active = getAllPlayers().filter(player => ACTIVE_STATUS.includes(player.status) && player.queue?.current)
if(active.length === 0) return "le silence absolu"
if(active.length === 1) {
const song = active[0].queue.current
return `${song.title} - ${song.author}`
}
return `de la musique sur ${active.length} serveurs`
}
/**
* Demande une mise à jour de la présence (debounce de 1,5 s)
*/
function refresh() {
if(refreshTimer) return
refreshTimer = setTimeout(() => {
refreshTimer = null
try {
applyActivity(computeActivity())
} catch(e) {
dlog.error(`Erreur lors du calcul de l'activité : ${e?.message || e}`)
}
}, REFRESH_DELAY)
refreshTimer.unref?.()
} }
module.exports = {getActivity, setMusicActivity, idleActivity} module.exports = {getActivity, setMusicActivity, idleActivity, refresh}
+153 -53
View File
@@ -4,8 +4,8 @@ const path = require("path")
const { __glob } = require("../utils/GlobalVars") const { __glob } = require("../utils/GlobalVars")
const { LogType } = require("loguix") const { LogType } = require("loguix")
const config = require("../utils/Database/Configuration") const config = require("../utils/Database/Configuration")
const metric = require("webmetrik") const SafeMetric = require("../utils/SafeMetric")
const { Player } = require("../player/Player") const { AllPlayers } = require("../player/Player")
const {refreshAllUserInformation} = require("../server/auth/User") const {refreshAllUserInformation} = require("../server/auth/User")
const serverSettings = require("./ServerSettings") const serverSettings = require("./ServerSettings")
const { Embed, EmbedError } = require("./Embed") const { Embed, EmbedError } = require("./Embed")
@@ -75,7 +75,8 @@ function getGuildRoles(guildId) {
} }
function getChannel(guildId, channelId) { 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() { function init() {
@@ -106,38 +107,53 @@ function init() {
if(!interaction.isChatInputCommand()) return; if(!interaction.isChatInputCommand()) return;
var numberOfCommands = new metric.Metric("numberOfCommands", "Nombre de commandes éxécutées") // Compteurs de commandes (SafeMetric ne lève jamais d'exception)
numberOfCommands.setValue(numberOfCommands.getValue() + 1) SafeMetric.increment("numberOfCommands", 1, "Nombre de commandes éxécutées")
var numberOfCommandsServer = new metric.Metric("numberOfCommands_" + interaction.guild.id, "Nombre de commandes éxécutées sur le serveur : " + interaction.guild.name) if(interaction.guildId) {
numberOfCommandsServer.setValue(numberOfCommandsServer.getValue() + 1) 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 command = client.commands?.get(interaction.commandName)
const roleProtected = await serverSettings.getSecureRole(interaction.guild.id) || false if(!command) {
dlog.warn("Commande inconnue ou pas encore chargée : /" + interaction.commandName)
return
}
var roleProtected = false
var havePermission = true; var havePermission = true;
if(roleProtected) { try {
await interaction.member.fetch() roleProtected = interaction.guildId ? (await serverSettings.getSecureRole(interaction.guildId) || false) : false
if(!interaction.member.roles.cache.has(roleProtected.id)) { if(roleProtected) {
havePermission = false; 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 { try {
// Create a metric to count the number of commands executed by each user // 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) SafeMetric.increment("userCommand_" + interaction.user.username, 1, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.user.username)
userCommand.setValue(userCommand.getValue() + 1)
dlog.log(interaction.member.user.username + "-> /" + interaction.commandName) dlog.log(interaction.member.user.username + "-> /" + interaction.commandName)
if(havePermission) { if(havePermission) {
command.execute(client, interaction) // await : les erreurs asynchrones de la commande remontent dans ce try/catch
await command.execute(client, interaction)
} else { } else {
const embed = new EmbedError(`L'utilisation du Bot est réservée aux membres ayant le rôle "${roleProtected.name}"`, interaction, true) 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.setTitle("Accès refusé")
embed.send() await embed.send()
} }
} catch(error) { } catch(error) {
dlog.error(interaction.member.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE") dlog.error(interaction.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE")
dlog.error(error) dlog.error(error?.stack || error)
interaction.reply({content:"Erreur lors de l'éxécution de la commande !", ephemeral: true}) // Réponse d'erreur selon l'état de l'interaction (déjà différée ou non), sans jamais lever
const errorReply = {content:"Erreur lors de l'éxécution de la commande !", ephemeral: true}
const replyPromise = (interaction.replied || interaction.deferred) ? interaction.editReply(errorReply) : interaction.reply(errorReply)
await Promise.resolve(replyPromise).catch(() => {})
} }
}) })
@@ -156,10 +172,12 @@ function init() {
// If a new guild is added, we will add it to the guilds map // If a new guild is added, we will add it to the guilds map
client.on("guildCreate", async (guild) => { 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() await refreshGuilds()
glog.log("Guilde ajoutée : " + guild.name + " (" + guild.id + ")") glog.log("Guilde ajoutée : " + guild.name + " (" + guild.id + ")")
process.emit("USERS_UPDATE") process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
}) })
@@ -168,11 +186,13 @@ function init() {
guilds.delete(guild.id) guilds.delete(guild.id)
glog.log("Guilde supprimée : " + guild.name + " (" + guild.id + ")") glog.log("Guilde supprimée : " + guild.name + " (" + guild.id + ")")
process.emit("USERS_UPDATE") process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
}) })
client.on('guildUpdate', async () => { client.on('guildUpdate', async () => {
await refreshGuilds() await refreshGuilds()
process.emit("USERS_UPDATE") process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
}) })
client.on("voiceStateUpdate", (oldMember, newMember) => { client.on("voiceStateUpdate", (oldMember, newMember) => {
@@ -181,32 +201,14 @@ function init() {
channelId: newMember.channelId, channelId: newMember.channelId,
}) })
const player = new Player(newMember.guild.id) try {
// Le bot lui-même a changé de salon (déplacement par un modérateur) ou a été expulsé
if(player.connection && player.channelId) { if(newMember.id === client.user?.id) {
client.channels.fetch(player.channelId).then(channel => { AllPlayers.get(newMember.guild.id)?.onBotVoiceState(newMember)
}
if(channel.members.size <= 1) { checkAutoLeave(newMember.guild.id)
} catch(error) {
// If the player is alone in the channel, we will destroy it in 10 minutes dlog.error("[Automatic Task] Erreur lors de la vérification de l'auto-départ : " + (error?.stack || error))
// 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)
}
})
} }
process.emit("VOCAL_UPDATE") process.emit("VOCAL_UPDATE")
@@ -218,17 +220,46 @@ function init() {
client.login(config.getToken()) 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() { 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") glog.step.init("d_refresh_guilds", "Rafraichissement des guildes")
await client.guilds.fetch() await client.guilds.fetch()
for(const guild of client.guilds.cache.values()) { 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) 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) { if(missingPermissions.length > 0) {
dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name) dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name + " (manquantes : " + missingPermissions.join(", ") + ")")
guild.leave() guild.leave().catch(error => dlog.error("Impossible de quitter la guilde " + guild.name + " : " + (error?.message || error)))
return // continue (et non return) : les guildes suivantes doivent quand même être rafraichies
continue
} }
guilds.set(guild.id, { guilds.set(guild.id, {
id: guild.id, id: guild.id,
@@ -258,6 +289,75 @@ function checkRequiredPermission(guildMember) {
return requiredPermissions.filter(permission => !guildMember.permissions.has(permission)); return requiredPermissions.filter(permission => !guildMember.permissions.has(permission));
} }
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles} // Départ automatique quand plus aucun humain n'écoute (10 minutes)
const AUTO_LEAVE_DELAY = 10 * 60 * 1000
/**
* Nombre de membres non-bots dans le salon vocal du player (cache des voice states, pas de requête)
*/
function countListeners(player) {
if(!player?.channelId) return 0
const channel = client.channels.cache.get(player.channelId)
if(!channel?.members) return 0
return channel.members.filter(member => !member.user.bot).size
}
/**
* Programme ou annule le départ automatique d'une guilde.
* Un seul minuteur par guilde : il n'est jamais écrasé, et il est annulé dès que quelqu'un revient.
*/
function checkAutoLeave(guildId) {
const player = AllPlayers.get(guildId)
if(!player || !player.isConnected() || !player.channelId) {
// Plus de player : rien à surveiller
if(timers.has(guildId)) {
clearTimeout(timers.get(guildId))
timers.delete(guildId)
}
player?.setAutoLeaveAt(null)
return
}
if(countListeners(player) > 0) {
if(timers.has(guildId)) {
clearTimeout(timers.get(guildId))
timers.delete(guildId)
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Départ automatique annulé, quelqu'un écoute dans : " + player.channelName)
}
player.setAutoLeaveAt(null)
return
}
// Déjà programmé : on ne remplace pas le minuteur existant
if(timers.has(guildId)) return
const leaveAt = Date.now() + AUTO_LEAVE_DELAY
timers.set(guildId, setTimeout(() => {
timers.delete(guildId)
const current = AllPlayers.get(guildId)
if(!current || !current.isConnected()) return
// Revérification au moment du départ
if(countListeners(current) > 0) {
current.setAutoLeaveAt(null)
return
}
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé : " + current.channelName)
current.leave("auto")
}, AUTO_LEAVE_DELAY))
player.setAutoLeaveAt(leaveAt)
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé dans 10 minutes : " + player.channelName)
}
/**
* Annule le minuteur d'auto-départ d'une guilde (utilisé quand l'admin fait quitter le serveur au bot)
*/
function clearAutoLeaveTimer(guildId) {
if(typeof timers === "undefined" || !timers.has(guildId)) return false
clearTimeout(timers.get(guildId))
timers.delete(guildId)
return true
}
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles, refreshGuilds, checkRequiredPermission, clearAutoLeaveTimer}
+2 -1
View File
@@ -65,7 +65,8 @@ class Command {
* @param {Client} client * @param {Client} client
* @param {Interaction} interaction * @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)}}
} }
+68 -20
View File
@@ -1,34 +1,58 @@
function init() {
const bot = require("./Bot")
const client = bot.getClient()
const { Collection, REST, Routes } = require("discord.js") const { Collection, REST, Routes } = require("discord.js")
const fs = require("node:fs") const fs = require("node:fs")
const path = require("path") const path = require("path")
const {__glob} = require("../utils/GlobalVars") const {__glob} = require("../utils/GlobalVars")
const dlog = require("loguix").getInstance("Discord")
const config = require("../utils/Database/Configuration") 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() client.commands = new Collection()
dlog.step.init("d_init", "Initialisation du Bot Discord") 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) 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 // Grab all the command files from the commands directory you created earlier
const commandsPath = __glob.COMMANDS 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) { for (const file of commandFiles) {
const cmd = require(commandsPath + path.sep + file); // Une commande invalide ne doit pas empêcher le chargement des autres
client.commands.set(cmd.command.getName(), cmd.command.getData()) 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") dlog.step.end("d_get_commands")
const rest = new REST().setToken(config.getToken()); rest = new REST().setToken(config.getToken());
if(commands.length != 0) { if(commands.length != 0) {
@@ -40,14 +64,8 @@ client.commands = new Collection()
dlog.step.init("d_commands_refresh", `Refreshing ${guilds.length} guilds (/) commands.`); dlog.step.init("d_commands_refresh", `Refreshing ${guilds.length} guilds (/) commands.`);
for (const guildId of guilds) { for (const guildId of guilds) {
const data = await rest.put( // Une guilde en erreur (permissions, guilde quittée…) ne bloque pas les suivantes
Routes.applicationGuildCommands(client.user.id, guildId), await registerGuild(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}"`);
} }
dlog.step.end("d_commands_refresh") dlog.step.end("d_commands_refresh")
@@ -71,4 +89,34 @@ client.commands = new Collection()
} }
module.exports = {init} /**
* Déploie les commandes slash sur une guilde (ex : à l'arrivée sur un nouveau serveur).
* Ne lève jamais d'exception.
* @param {string} guildId
* @returns {Promise<boolean>} true si les commandes ont été déployées
*/
async function registerGuild(guildId) {
const dlog = getLog()
if(!commands || !rest) {
// init() n'a pas encore tourné : il déploiera sur toutes les guildes du cache
dlog.warn(`Commandes non chargées, déploiement différé pour la guilde "${guildId}"`)
return false
}
if(commands.length == 0) return false
try {
const client = require("./Bot").getClient()
const data = await rest.put(
Routes.applicationGuildCommands(client.user.id, guildId),
{ body: commands },
);
// Log the guilds where the commands have been refreshed with number of commands
dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId)?.name}"`);
return true
} catch (error) {
dlog.error(`Impossible de déployer les commandes sur la guilde "${guildId}" : ` + (error?.stack || error))
return false
}
}
module.exports = {init, registerGuild}
+14 -20
View File
@@ -1,33 +1,27 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") 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) 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 const channel = interaction.member.voice.channel
var embed = new Embed(interaction) var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) { const player = AllPlayers.get(channel.guildId)
const player = AllPlayers.get(channel.guildId) if(!player || !player.isConnected()) {
if(!player?.connected) { return embed.returnError("Le bot n'est pas connecté à ce salon vocal")
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")
} }
// 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()
}) })
+8 -7
View File
@@ -1,7 +1,8 @@
const {Command} = require('../Command'); const {Command} = require('../Command');
const {Embed, EmbedError} = require('../Embed'); const {Embed, EmbedError} = require('../Embed');
const { Player } = require('../../player/Player'); const { getPlayer } = require('../../player/Player');
const { Song } = require('../../player/Song'); const { Song } = require('../../player/Song');
const { checkSong } = require('../../player/SongCheck');
const history = require('../../playlists/History'); const history = require('../../playlists/History');
const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => { const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => {
@@ -20,18 +21,18 @@ const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal
const song = new Song() const song = new Song()
await song.processMedia(media, interaction.user.username) await song.processMedia(media, interaction.user.username)
const player = new Player(channel.guildId) if(!checkSong(song)) return embed.returnError("Ce média ne peut pas être lu")
player.join(channel)
const player = getPlayer(channel.guildId)
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
if(now) { if(now) {
player.play(song) // Lecture immédiate : la musique en cours part dans l'historique
await player.playNow(song)
embed.setTitle('**Lecture immédiate**') embed.setTitle('**Lecture immédiate**')
} else { } else {
player.add(song) await player.add(song)
embed.setTitle('**Ajout à liste de lecture**') embed.setTitle('**Ajout à liste de lecture**')
} }
+24 -24
View File
@@ -1,43 +1,43 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") 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) 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 channel = interaction.member.voice.channel
const player = new Player(channel.guildId)
const result = player.pause()
var embed = new Embed(interaction) var embed = new Embed(interaction)
embed.setColor(0x03ff2d) embed.setColor(0x03ff2d)
result.then((pause) => { // Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
}
if(pause == "no_music") { // Résultat réel : {ok, paused, reason}
embed.returnError("Il n'y a pas de musique en cours de lecture") const result = await player.pause()
} else if(pause) { if(!result.ok) {
embed.setTitle('Musique en pause') embed.returnError("Il n'y a pas de musique en cours de lecture")
embed.setDescription("La musique a été mise en pause") return
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png") }
const loading = player.status === "loading" || player.status === "stalled"
} else { if(result.paused) {
embed.setTitle('Musique reprise') embed.setTitle('Musique en pause')
embed.setDescription("La musique a été reprise") 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/play-64.png") embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png")
} } else {
embed.setTitle('Musique reprise')
embed.send() 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")
}
// Réponse en embed
embed.send()
}) })
+52 -59
View File
@@ -1,7 +1,8 @@
const { Command } = require("../Command"); const { Command } = require("../Command");
const { Embed, EmbedError } = require("../Embed"); const { Embed, EmbedError } = require("../Embed");
const { Player } = require("../../player/Player"); const { getPlayer } = require("../../player/Player");
const Finder = require("../../player/Finder"); const Finder = require("../../player/Finder");
const { checkSong } = require("../../player/SongCheck");
const { Playlist } = require("../../playlists/Playlist"); const { Playlist } = require("../../playlists/Playlist");
const spotify = require("../../media/SpotifyInformation"); const spotify = require("../../media/SpotifyInformation");
const history = require("../../playlists/History"); const history = require("../../playlists/History");
@@ -14,77 +15,69 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
const channel = interaction.member.voice.channel const channel = interaction.member.voice.channel
const now = interaction.options.getBoolean("now") || false const now = interaction.options.getBoolean("now") || false
const embed = new Embed(interaction) 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) let song = await Finder.search(url.value)
player.join(channel) 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) embed.setColor(0x15e6ed)
// Check if song is playlist // Check if song is playlist
if(song instanceof Playlist) { if(song instanceof Playlist) {
embed.setDescription('**Playlist : **' + song.songs.length + ' musiques') embed.setDescription('**Playlist : **' + song.songs.length + ' musiques')
embed.addField('**Titre : **' + song.title, "") embed.addField('**Titre : **' + song.title, "")
embed.addField('**Demandé par : **', interaction.member.user.username,) embed.addField('**Demandé par : **', interaction.member.user.username,)
embed.addField('**Auteur : **', song.author) embed.addField('**Auteur : **', song.author)
embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase())) embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase()))
if(!song.type == "spotify") { if(song.type != "spotify") {
embed.addField('**Durée : **', song.readduration)
}
embed.addField('**Lien : **', song.url)
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)
embed.addField('**Durée : **', song.readduration) 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() embed.send()
if(song.type == "spotify") {
if(song instanceof Playlist) { song = await spotify.getTracks(song)
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)
} }
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: "STRING", name: "url", description: "Recherche / Lien audio (Youtube / Soundclound / Spotify)", required: true},
{type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}] {type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}]
+21 -36
View File
@@ -1,50 +1,35 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") 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 const channel = interaction.member.voice.channel
var embed = new Embed(interaction) var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) { // Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
const player = new Player(channel.guildId) if(!player || !player.isConnected()) {
const result = player.previous() return embed.returnError("Le bot n'est pas connecté")
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)
} }
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()
}) })
+27 -31
View File
@@ -1,43 +1,39 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") 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) => { 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 const channel = interaction.member.voice.channel
var embed = new Embed(interaction) var embed = new Embed(interaction)
const player = AllPlayers.get(channel.guildId)
if(AllPlayers.has(channel.guildId)) { if(!player || !player.isConnected()) {
return embed.returnError("Le bot n'est pas connecté")
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 queue = player.queue.getNext()
if(queue.length == 0) {
return embed.returnError("Il n'y a pas de musique en file d'attente")
}
embed.setColor(0x15e6ed)
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png")
embed.setTitle('**File d\'attente :**')
embed.setDescription('**' + queue.length + ' musiques**')
queue.slice(0, MAX_LISTED).forEach((song, index) => {
embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`)
})
if(queue.length > MAX_LISTED) {
embed.addField(`... et ${queue.length - MAX_LISTED} autre(s)`, "")
}
embed.send()
}) })
module.exports = {command} module.exports = {command}
+16 -4
View File
@@ -1,25 +1,37 @@
const {Embed} = require("../Embed") const {Embed} = require("../Embed")
const {Command} = require("../Command") const {Command} = require("../Command")
const {restart} = require("../../utils/Maintenance") const {restart, isRestarting} = require("../../utils/Maintenance")
const users = require("../../server/auth/User") 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 // Nécéssite une raison pour redémarrer le bot
const command = new Command("restart", "Redémarre le bot", (client, interaction) => { 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) 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}) interaction.reply({content: "Vous n'êtes pas admin", ephemeral: true})
return return
} }
const reason = interaction.options.getString("reason") const reason = (interaction.options.getString("reason") || "").trim().slice(0, 200)
restart(reason) 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) const embed = new Embed(interaction)
embed.setColor(150, 20, 20) embed.setColor(150, 20, 20)
embed.setTitle('Redémarrage') embed.setTitle('Redémarrage')
embed.setDescription("Veuillez patientez, le bot va redémarrer dans un instant ! :arrows_counterclockwise:") embed.setDescription("Veuillez patientez, le bot va redémarrer dans un instant ! :arrows_counterclockwise:")
embed.addField('Raison', reason) embed.addField('Raison', reason)
embed.send() embed.send()
restart(reason)
}, },
[{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}] [{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}]
) )
+20 -32
View File
@@ -1,47 +1,35 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") const {Embed, EmbedError} = require("../Embed")
const { Player, AllPlayers } = require("../../player/Player") const { AllPlayers } = require("../../player/Player")
const command = new Command("skip", "Passe à la musique suivante", (client, interaction) => { const command = new Command("skip", "Passe à la musique suivante", async (client, interaction) => {
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction) 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 const channel = interaction.member.voice.channel
var embed = new Embed(interaction) var embed = new Embed(interaction)
if(AllPlayers.has(channel.guildId)) { // Pas de création de player : le bot doit déjà être connecté
const player = AllPlayers.get(channel.guildId)
const player = new Player(channel.guildId) if(!player || !player.isConnected()) {
const result = player.skip() return embed.returnError("Le bot n'est pas connecté")
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)
} }
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()
}) })
+31 -20
View File
@@ -1,33 +1,44 @@
const {Command} = require("../Command") const {Command} = require("../Command")
const {Embed, EmbedError} = require("../Embed") 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) => { 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 channel = interaction.member.voice.channel
const player = new Player(channel.guildId) // Pas de création de player : simple lecture de l'état
const song = player.queue.getCurrent() 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)
if(!song) { embed.send()
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()
}
}) })
+51 -10
View File
@@ -1,4 +1,27 @@
const { EmbedBuilder, ActionRowBuilder } = require("discord.js"); 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 { class Embed {
fields; fields;
@@ -8,8 +31,9 @@ class Embed {
this.fields = [] this.fields = []
this.buttons = [] this.buttons = []
this.isSended = false this.isSended = false
this.deferPromise = null
if(interaction) { if(interaction) {
interaction.deferReply({ ephemeral: ephemeral }).then(() => { this.deferPromise = interaction.deferReply({ ephemeral: ephemeral }).then(() => {
this.isSended = true this.isSended = true
}).catch(() => { }).catch(() => {
this.isSended = true this.isSended = true
@@ -20,12 +44,12 @@ class Embed {
} }
setTitle(title) { setTitle(title) {
this.embed.setTitle(title) this.embed.setTitle(title == null ? null : clampText(title, MAX_TITLE))
return this return this
} }
setDescription(description) { setDescription(description) {
this.embed.setDescription(description) this.embed.setDescription(description == null ? null : clampText(description, MAX_DESCRIPTION))
return this return this
} }
@@ -86,7 +110,7 @@ class Embed {
addField(name, value, inline) { addField(name, value, inline) {
if(!inline) inline = false; 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 return this
} }
@@ -101,8 +125,8 @@ class Embed {
} }
build() { build() {
//Add Fields to an object //Add Fields to an object (setFields : un second build ne duplique pas les champs)
this.embed.addFields(this.fields) this.embed.setFields(this.fields)
if(this.buttons.length > 0) { if(this.buttons.length > 0) {
this.actionRow = new ActionRowBuilder() this.actionRow = new ActionRowBuilder()
.addComponents(this.buttons); .addComponents(this.buttons);
@@ -110,13 +134,30 @@ class Embed {
return this.embed return this.embed
} }
/**
* Édite la réponse de l'interaction avec l'embed. Ne lève jamais d'exception.
* @returns {Promise<boolean>} true si la réponse a été envoyée
*/
async send() { async send() {
// Add a secutiry check to avoid sending an embed if the interaction is not defined and retry one again // Sans interaction (embed destiné à un salon, ex : rapport), il n'y a rien à éditer
while(!this.isSended) { if(!this.interaction) return false
await new Promise(resolve => setTimeout(resolve, 50)); // 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; 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) { async returnError(message) {
+13 -11
View File
@@ -23,32 +23,34 @@ if(!config.getMediaGuildId() || !config.getMediaChannelId()) {
var channel = null var channel = null
discordBot.getClient().on("ready", () => { discordBot.getClient().on("ready", async () => {
try { try {
channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId()) channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId())
} catch (e) { } catch (e) {
} }
if(!channel) { if(!channel || typeof channel.send !== "function") {
wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.") channel = null
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.") 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 { try {
const dateTime = new Date() const dateTime = new Date()
const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' }) const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' })
const time = dateTime.toLocaleTimeString('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}` 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.log("La base de données multimédia a été initialisée avec succès.")
wlog.step.end("init_db")
connected = true
} catch (e) { } catch (e) {
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.") wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e))
wlog.step.error("init_db","Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.")
connected = false
} }
}) })
+21 -8
View File
@@ -14,6 +14,7 @@ class Report {
const embed = new Embed() const embed = new Embed()
embed.setDescription('**Version : **' + packageJson.version) embed.setDescription('**Version : **' + packageJson.version)
embed.setTitle("Rapport de : " + provider) 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 var levelString = null
if(level == "bug") { if(level == "bug") {
@@ -26,24 +27,36 @@ class Report {
} }
embed.addField("Type", levelString) 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 this.embed = embed
} }
/**
* Envoie le rapport dans le salon configuré. Ne lève jamais d'exception.
* @returns {Promise<boolean>} true si le rapport a été envoyé
*/
async send() { async send() {
if(!this.report_channel || this.report_channel == "") { if(!this.report_channel || this.report_channel == "") {
log.getInstance("Discord").error("Pas de channel de rapport configuré") log.getInstance("Discord").error("Pas de channel de rapport configuré")
return false return false
} else { } else {
const channel = await this.client.channels.fetch(this.report_channel) try {
channel.send({embeds: [this.embed.build()]}) const channel = await this.client.channels.fetch(this.report_channel)
if(this.report_contact && this.report_contact != "") channel.send({content: "<@" + this.report_contact + ">"}) if(!channel || typeof channel.send !== "function") {
return true log.getInstance("Discord").error("Le channel de rapport est introuvable ou n'accepte pas de messages : " + this.report_channel)
return false
}
await channel.send({embeds: [this.embed.build()]})
if(this.report_contact && this.report_contact != "") await channel.send({content: "<@" + this.report_contact + ">"})
return true
} catch (error) {
log.getInstance("Discord").error("Impossible d'envoyer le rapport : " + (error?.message || error))
return false
}
} }
} }
+21 -7
View File
@@ -4,9 +4,15 @@ const { __glob } = require('../utils/GlobalVars');
const { Database } = require('../utils/Database/Database'); const { Database } = require('../utils/Database/Database');
const ServerDB = new Database("server_settings", __glob.SERVER_DB, {}); 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) { function getSecureRole(guildId) {
checkKey(guildId); if (typeof guildId !== "string" || guildId === "") return null;
var role = ServerDB.getData()[guildId].secureRole || null; const data = ServerDB.getData() || {};
if (!Object.prototype.hasOwnProperty.call(data, guildId)) return null;
var role = data[guildId]?.secureRole || null;
if(role) { if(role) {
if(role.name === "@everyone") { if(role.name === "@everyone") {
return null; return null;
@@ -15,21 +21,29 @@ function getSecureRole(guildId) {
return role; 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); checkKey(guildId);
ServerDB.getData()[guildId].secureRole = roleId; ServerDB.getData()[guildId].secureRole = role || null;
ServerDB.save(); ServerDB.save();
clog.log(`Rôle sécurisé du serveur ${guildId} : ${role?.name ?? "aucun"}`);
process.emit("USERS_UPDATE") process.emit("USERS_UPDATE")
return true; return true;
} }
async function checkKey(guildId) { function checkKey(guildId) {
const data = ServerDB.getData(); const data = ServerDB.getData();
if (!data[guildId]) { if (!data[guildId]) {
data[guildId] = { secureRole: null }; data[guildId] = { secureRole: null };
ServerDB.save();
} }
} }
module.exports = { module.exports = {
+11 -7
View File
@@ -2,6 +2,9 @@ const { LogType } = require("loguix");
const plog = new LogType('Lyrics'); const plog = new LogType('Lyrics');
const urls = require('./urls.json'); 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 // Make sure Url exists and get lyrics for the first item only
async function getLyrics(name) { async function getLyrics(name) {
let result = null; let result = null;
@@ -10,7 +13,8 @@ async function getLyrics(name) {
method: 'GET', method: 'GET',
headers: { headers: {
'content-type': 'application/json' 'content-type': 'application/json'
} },
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
}); });
const searchData = await searchResponse.json(); const searchData = await searchResponse.json();
@@ -26,18 +30,18 @@ async function getLyrics(name) {
method: 'GET', method: 'GET',
headers: { headers: {
'content-type': 'application/json' 'content-type': 'application/json'
} },
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
}); });
const lyricsData = await lyricsResponse.json(); const lyricsData = await lyricsResponse.json();
console.log(lyricsData); if (lyricsData && lyricsData.lyrics) {
if (lyricsData && lyricsData && lyricsData.lyrics) {
result = lyricsData.lyrics; result = lyricsData.lyrics;
} else { } else {
plog.error('Invalid response structure:', lyricsData); plog.error('Invalid response structure: ' + JSON.stringify(lyricsData).slice(0, 200));
return null; return null;
} }
} catch (error) { } catch (error) {
plog.error('Error fetching lyrics data:', error); plog.error('Error fetching lyrics data: ' + (error?.message || error));
return null; return null;
} }
} else { } else {
@@ -49,7 +53,7 @@ async function getLyrics(name) {
return null; return null;
} }
} catch (error) { } catch (error) {
plog.error('Error fetching search data:', error); plog.error('Error fetching search data: ' + (error?.message || error));
return null; return null;
} }
return result; return result;
+116 -4
View File
@@ -7,19 +7,131 @@
const { __glob } = require("./utils/GlobalVars") const { __glob } = require("./utils/GlobalVars")
require("loguix").setup(__glob.LOGS, __glob.PACKAGEINFO) 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 config = require("./utils/Database/Configuration")
const metric = require("webmetrik") SafeMetric.publish(8001, process.env.METRICS_KEY || "subsonicsMetricsRaph")
metric.setMetricFile(__glob.METRIC_FILE)
metric.publishMetrics("8001", "subsonicsMetricsRaph")
// SETUP // 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() { async function setup() {
await require("./utils/WarpProxy").start() 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") const DiscordBot = require("./discord/Bot")
await DiscordBot.init() await DiscordBot.init()
const Server = require("./server/Server") const Server = require("./server/Server")
await Server.init() await Server.init()
} }
// GESTION DU PROCESS (erreurs globales et arrêt propre)
// var (hissée) : vaut undefined tant que le code synchrone de main.js n'est pas allé au bout
var mainLoaded
var processLog = null
function getProcessLog() {
if(!processLog) processLog = new (require("loguix").LogType)("Process")
return processLog
}
/**
* Journalise sans jamais lever (l'écriture du log elle-même peut échouer)
*/
function safeLogError(text, level = "error") {
try {
getProcessLog()[level](text)
} catch (e) {
console.error("[Process] - [" + level.toUpperCase() + "] - " + text)
}
}
/**
* Handlers globaux : une erreur isolée est journalisée sans tuer le process.
* En cas de rafale d'exceptions (état probablement incohérent), on quitte pour que Docker relance.
*/
function setupProcessHandlers() {
// loguix enregistre son propre handler 'uncaughtException' qui FERME son flux de logs :
// après la première exception plus rien n'était écrit dans logs/. On le retire.
process.removeAllListeners("uncaughtException")
const MAX_EXCEPTIONS = 10
const EXCEPTION_WINDOW = 60 * 1000
var exceptionTimes = []
var handling = false
process.on("uncaughtException", (error, origin) => {
// Garde anti-réentrance : si journaliser provoque une nouvelle exception, on ne boucle pas
if(handling) {
console.error(error)
return
}
handling = true
try {
safeLogError("Exception non capturée (" + origin + ") : " + (error?.stack || error))
// Erreur pendant le chargement initial (config, modules…) : démarrage impossible, on quitte
if(!mainLoaded) {
shutdown("STARTUP_EXCEPTION", 1)
return
}
const now = Date.now()
exceptionTimes = exceptionTimes.filter(t => now - t < EXCEPTION_WINDOW)
exceptionTimes.push(now)
if(exceptionTimes.length >= MAX_EXCEPTIONS) {
safeLogError(`${MAX_EXCEPTIONS} exceptions en moins de ${EXCEPTION_WINDOW / 1000} s : arrêt du process`)
shutdown("UNCAUGHT_EXCEPTION", 1)
}
} finally {
handling = false
}
})
process.on("unhandledRejection", (reason) => {
safeLogError("Promesse rejetée non gérée : " + (reason?.stack || reason))
})
// Node est PID 1 sous Docker : sans handler, SIGTERM est ignoré et docker stop finit en SIGKILL
process.on("SIGTERM", () => shutdown("SIGTERM", 0))
process.on("SIGINT", () => shutdown("SIGINT", 0))
}
var shuttingDown = false
/**
* Arrêt propre : sauvegarde des métriques, déconnexion de Discord, puis sortie
* @param {string} reason
* @param {number} code
*/
function shutdown(reason, code) {
if(shuttingDown) {
// Second signal : sortie immédiate
process.exit(code)
}
shuttingDown = true
process.exitCode = code
safeLogError("Arrêt du process demandé (" + reason + ")", code === 0 ? "warn" : "error")
// Délai maximal : on ne reste jamais bloqué dans l'arrêt
setTimeout(() => process.exit(code), 3000)
try {
require("./utils/SafeMetric").flush()
} catch (e) {
safeLogError("Sauvegarde des métriques impossible : " + (e?.stack || e))
}
// Les bases JSON sont écrites de façon synchrone à chaque modification : rien à vider
Promise.resolve()
.then(() => {
// Uniquement si le bot a déjà été chargé (pas de require qui le démarrerait)
const botPath = require.resolve("./discord/Bot")
if(require.cache[botPath]) return require("./discord/Bot").getClient().destroy()
})
.catch((e) => safeLogError("Déconnexion de Discord impossible : " + (e?.stack || e)))
.finally(() => process.exit(code))
}
+15 -8
View File
@@ -1,10 +1,23 @@
const ffprobe = require('ffprobe'); const ffprobe = require('ffprobe');
const ffprobeStatic = require('ffprobe-static'); const ffprobeStatic = require('ffprobe-static');
const { getReadableDuration } = require('../utils/TimeConverter'); const { getReadableDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
const clog = require("loguix").getInstance("Song") const clog = require("loguix").getInstance("Song")
const path = require('path'); const path = require('path');
const fs = require('fs'); 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) { async function getMediaInformation(instance, media, provider) {
let tmpFile = null; let tmpFile = null;
@@ -28,10 +41,7 @@ async function getMediaInformation(instance, media, provider) {
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path }); const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
// 4. Récupérer les informations // 4. Récupérer les informations
if (info.streams?.[0]?.duration_ts) { applyDuration(instance, info);
instance.duration = info.streams[0].duration;
instance.readduration = getReadableDuration(instance.duration);
}
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ?? instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png"; "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 // 3. Lancer ffprobe sur le fichier local
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path }); const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
if (info.streams?.[0]?.duration_ts) { applyDuration(instance, info);
instance.duration = info.streams[0].duration;
instance.readduration = getReadableDuration(instance.duration);
}
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ?? instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png"; "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
+11 -7
View File
@@ -4,6 +4,7 @@ const {Song} = require('../player/Song');
const {Playlist} = require('../playlists/Playlist'); const {Playlist} = require('../playlists/Playlist');
const {Soundcloud} = require('soundcloud.ts') const {Soundcloud} = require('soundcloud.ts')
const {getReadableDuration} = require('../utils/TimeConverter'); const {getReadableDuration} = require('../utils/TimeConverter');
const {toSafeDuration} = require('../utils/SafeMetric');
const soundcloud = new Soundcloud(); const soundcloud = new Soundcloud();
@@ -22,8 +23,9 @@ async function getTrack(url) {
song.url = info.permalink_url; song.url = info.permalink_url;
song.thumbnail = info.artwork_url; song.thumbnail = info.artwork_url;
song.id = info.id; song.id = info.id;
song.duration = info.duration / 1000; // Durée SoundCloud en millisecondes ; absente ou invalide → 0 (inconnue)
song.readduration = getReadableDuration(info.duration / 1000); song.duration = toSafeDuration(Number(info.duration) / 1000);
song.readduration = getReadableDuration(song.duration);
song.type = "soundcloud"; song.type = "soundcloud";
return song; return song;
@@ -56,18 +58,20 @@ async function getPlaylist(url) {
playlist.songs = []; playlist.songs = [];
playlist.type = "soundcloud"; 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(); const song = new Song();
song.title = track.title; song.title = track.title;
song.author = track.user.username; song.author = track.user?.username ?? song.author;
song.url = track.permalink_url; song.url = track.permalink_url;
song.thumbnail = track.artwork_url; song.thumbnail = track.artwork_url;
song.id = track.id; song.id = track.id;
song.duration = track.duration / 1000; song.duration = toSafeDuration(Number(track.duration) / 1000);
song.readduration = getReadableDuration(track.duration / 1000); song.readduration = getReadableDuration(song.duration);
song.type = "soundcloud"; song.type = "soundcloud";
playlist.duration += track.duration / 1000; playlist.duration += song.duration;
playlist.songs.push(song); playlist.songs.push(song);
} }
+184 -62
View File
@@ -1,26 +1,107 @@
const {LogType} = require('loguix'); const {LogType} = require('loguix');
const clog = new LogType("SpotifyInformation"); const clog = new LogType("SpotifyInformation");
const config = require('../utils/Database/Configuration'); 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 SpotifyWebApi = require('spotify-web-api-node');
const {Playlist} = require('../playlists/Playlist'); const {Playlist} = require('../playlists/Playlist');
const {Song} = require('../player/Song'); const {Song} = require('../player/Song');
const youtube = require("../media/YoutubeInformation"); const youtube = require("../media/YoutubeInformation");
const {getReadableDuration} = require('../utils/TimeConverter'); const {getReadableDuration} = require('../utils/TimeConverter');
const {toSafeDuration} = require('../utils/SafeMetric');
const {mapLimit} = require('../utils/Limiter');
const spotifyApi = new SpotifyWebApi({ const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
clientId: SPOTIFY_CLIENT_ID, // Chaque titre déclenche une recherche YouTube : on borne la taille des imports
clientSecret: SPOTIFY_CLIENT_SECRET, const MAX_TRACKS = 500;
}); // Conversions Spotify → YouTube simultanées (le limiteur YouTube partagé reste le plafond global)
const CONVERSION_CONCURRENCY = 3;
// Identifiants lus au moment de demander un jeton : une modification depuis l'admin s'applique sans redémarrage
const spotifyApi = new SpotifyWebApi();
// Le jeton client est valable une heure : on le garde en cache au lieu d'en redemander un à chaque requête
let tokenExpiresAt = 0;
// Identifiants ayant servi à obtenir le jeton courant (détecte un changement de configuration)
let tokenCredentials = null;
// Demande de jeton en cours, partagée par les appels simultanés
let tokenPromise = null;
function resetToken() {
tokenExpiresAt = 0;
tokenCredentials = null;
spotifyApi.resetAccessToken();
}
// Configuration modifiée (admin) : le prochain appel redemandera un jeton avec les nouveaux identifiants
process.on("CONFIG_UPDATE", resetToken);
async function ensureToken() {
const clientId = config.getSpotifyClientId();
const clientSecret = config.getSpotifyClientSecret();
if(!clientId || !clientSecret) {
throw new Error("Identifiants Spotify non configurés (api.spotify.clientId / clientSecret)");
}
const credentials = clientId + ":" + clientSecret;
if(tokenCredentials !== null && tokenCredentials !== credentials) resetToken();
if(Date.now() < tokenExpiresAt) return;
if(!tokenPromise) {
tokenPromise = (async () => {
spotifyApi.setClientId(clientId);
spotifyApi.setClientSecret(clientSecret);
const data = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(data.body['access_token']);
const expiresIn = Number(data.body['expires_in']) || 3600;
// Marge d'une minute avant l'expiration réelle
tokenExpiresAt = Date.now() + Math.max(0, expiresIn - 60) * 1000;
tokenCredentials = credentials;
})().finally(() => { tokenPromise = null; });
}
await tokenPromise;
}
/**
* Un titre Spotify est exploitable s'il a un nom et un artiste.
* Les titres supprimés ou indisponibles valent null, les épisodes de podcast n'ont pas d'artistes.
*/
function isValidTrack(track) {
return !!track && typeof track.name === "string" && track.name.length > 0
&& Array.isArray(track.artists) && !!track.artists[0]?.name;
}
/**
* Récupère les pages suivantes d'une liste paginée Spotify (playlist ou album)
* @param {Object} firstPage Première page ({items, next, total})
* @param {(offset: number) => Promise<Object>} fetchPage Renvoie la page commençant à offset
* @param {(item: Object) => Object} mapItem Transforme un élément de page en titre
*/
async function collectAllTracks(firstPage, fetchPage, mapItem) {
const tracks = [];
let page = firstPage;
while (page && Array.isArray(page.items)) {
for (const item of page.items) {
if (tracks.length >= MAX_TRACKS) break;
tracks.push(mapItem(item));
}
if (!page.next || tracks.length >= MAX_TRACKS || page.items.length === 0) break;
try {
page = await fetchPage((page.offset ?? 0) + page.items.length);
} catch (error) {
clog.warn("Impossible de récupérer la suite des titres Spotify, import partiel : " + error);
break;
}
}
if (tracks.length >= MAX_TRACKS && page?.total > MAX_TRACKS) {
clog.warn(`Import Spotify limité aux ${MAX_TRACKS} premiers titres (sur ${page.total})`);
}
return tracks;
}
async function getSong(url) { async function getSong(url) {
try { try {
const data = await spotifyApi.clientCredentialsGrant(); await ensureToken();
spotifyApi.setAccessToken(data.body['access_token']);
const parts = url.split('/'); const parts = url.split('/');
const trackId = parts[parts.length - 1]; const trackId = parts[parts.length - 1]?.split('?')[0];
if(!trackId) { if(!trackId) {
clog.error("Impossible de récupérer l'identifiant de la piste Spotify à partir de l'URL"); clog.error("Impossible de récupérer l'identifiant de la piste Spotify à partir de l'URL");
@@ -28,6 +109,11 @@ async function getSong(url) {
} }
const trackInfo = await spotifyApi.getTrack(trackId); 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 trackName = trackInfo.body.name;
const artistName = trackInfo.body.artists[0].name; const artistName = trackInfo.body.artists[0].name;
@@ -44,9 +130,7 @@ async function getAlbum(url) {
try { try {
await ensureToken();
const creditdata = await spotifyApi.clientCredentialsGrant();
spotifyApi.setAccessToken(creditdata.body['access_token']);
const parts = url.split('/'); const parts = url.split('/');
const albumId = parts[parts.indexOf('album') + 1].split('?')[0]; const albumId = parts[parts.indexOf('album') + 1].split('?')[0];
@@ -63,13 +147,18 @@ async function getAlbum(url) {
const playlist = new Playlist() const playlist = new Playlist()
playlist.title = info.name; playlist.title = info.name;
playlist.author = info.artists[0].name; playlist.author = info.artists?.[0]?.name ?? "Auteur inconnu";
playlist.authorId = info.artists[0].id; playlist.authorId = info.artists?.[0]?.id;
playlist.thumbnail = info.images[0].url; playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
playlist.url = info.external_urls.spotify; playlist.url = info.external_urls?.spotify;
playlist.id = albumId; playlist.id = albumId;
playlist.type = "spotify"; 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; return playlist;
@@ -87,8 +176,7 @@ async function getPlaylist(url) {
// Get the playlist and return a Playlist Object // Get the playlist and return a Playlist Object
try { try {
const creditdata = await spotifyApi.clientCredentialsGrant(); await ensureToken();
spotifyApi.setAccessToken(creditdata.body['access_token']);
const parts = url.split('/'); const parts = url.split('/');
const playlistId = parts[parts.indexOf('playlist') + 1].split('?')[0]; const playlistId = parts[parts.indexOf('playlist') + 1].split('?')[0];
@@ -106,73 +194,107 @@ async function getPlaylist(url) {
const playlist = new Playlist() const playlist = new Playlist()
playlist.title = info.name; playlist.title = info.name;
playlist.author = info.owner.display_name; playlist.author = info.owner?.display_name ?? "Auteur inconnu";
playlist.authorId = info.owner.id; playlist.authorId = info.owner?.id;
playlist.thumbnail = info.images[0].url; playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
playlist.url = info.external_urls.spotify; playlist.url = info.external_urls?.spotify;
playlist.id = playlistId; playlist.id = playlistId;
playlist.type = "spotify"; playlist.type = "spotify";
for(const track of info.tracks.items) { // Une playlist ne renvoie que ses 100 premiers titres : on récupère les pages suivantes
playlist.songs.push(track.track); // (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; return playlist;
} catch (error) { } 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); clog.error(error);
return null; return null;
} }
} }
/**
* Convertit les titres Spotify d'une playlist en musiques YouTube jouables.
* Modifie la playlist (songs, duration, readduration) et la renvoie, même en cas d'erreur.
* @param {Playlist} playlist
* @returns {Promise<Playlist>}
*/
async function getTracks(playlist) { async function getTracks(playlist) {
const tracks = playlist.songs const tracks = Array.isArray(playlist?.songs) ? playlist.songs : [];
playlistSongs = [];
for(const track of tracks) {
var trackName = track.name; // Concurrence bornée, ordre conservé ; un titre en erreur vaut null au lieu de faire échouer tout l'import
var artistName = track.artists[0].name; const converted = await mapLimit(tracks, CONVERSION_CONCURRENCY, (track) => convertTrack(track, playlist), (error) => {
var queryForYoutube = `${trackName} - ${artistName}`; clog.error("Erreur lors de la conversion d'un titre Spotify : " + error);
return null;
});
var urlYoutubeFounded = await youtube.getQuery(queryForYoutube).then(function(songFind) { // Tableau local : deux imports simultanés ne doivent jamais partager leurs titres
if(!songFind) return null; const playlistSongs = converted.filter(Boolean);
return songFind.url; const totalDuration = playlistSongs.reduce((acc, song) => acc + toSafeDuration(song.duration), 0);
});
clog.log("URL de la vidéo YouTube trouvée : " + urlYoutubeFounded);
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
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.songs = playlistSongs;
playlist.duration = totalDuration;
playlist.readduration = getReadableDuration(totalDuration);
}
return playlist;
}
return playlist; /**
* Convertit un titre Spotify en musique YouTube jouable
* @param {Object} track Titre Spotify (ou musique déjà convertie)
* @param {Playlist} playlist Playlist d'origine (miniature par défaut)
* @returns {Promise<Song|null>} null si le titre est ignoré ou introuvable
*/
async function convertTrack(track, playlist) {
// Titre déjà converti (playlist rafraîchie ou déjà importée) : on le garde tel quel
if(track && track.type === "youtube" && track.url) {
return track;
}
if(!isValidTrack(track)) {
clog.warn("Titre Spotify ignoré (supprimé, indisponible ou épisode de podcast)");
return null;
}
const trackName = track.name;
const artistName = track.artists[0].name;
const queryForYoutube = `${trackName} - ${artistName}`;
const songFind = await youtube.getQuery(queryForYoutube);
const urlYoutubeFounded = songFind ? songFind.url : null;
if(!urlYoutubeFounded) {
clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube);
return null;
}
const song = new Song();
const duration = toSafeDuration(Number(track.duration_ms) / 1000);
song.title = trackName;
song.author = artistName;
song.url = urlYoutubeFounded;
song.thumbnail = track.album?.images?.[0]?.url ?? playlist?.thumbnail ?? song.thumbnail;
song.id = track.id ?? songFind.id;
song.duration = duration;
song.readduration = getReadableDuration(duration);
song.type = "youtube";
return song;
} }
+248 -52
View File
@@ -2,9 +2,55 @@ const { LogType } = require('loguix');
const clog = new LogType("YoutubeInformation"); const clog = new LogType("YoutubeInformation");
const { Song } = require('../player/Song'); const { Song } = require('../player/Song');
const { Playlist } = require('../playlists/Playlist'); 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 yts = require("yt-search")
const ytfps = require('ytfps');
// Les métadonnées sont récupérées en scrapant youtube.com depuis la même IP que yt-dlp :
// on limite les rafales (concurrence + espacement) et on met les réponses en cache pour éviter les blocages
const youtubeLimiter = createLimiter(2, { minIntervalMs: 250 });
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const videoCache = createCache({ ttlMs: 6 * HOUR, max: 2000 }); // videoId → vidéo yt-search
const queryCache = createCache({ ttlMs: 30 * MINUTE, max: 500 }); // requête → vidéos yt-search
const listCache = createCache({ ttlMs: 10 * MINUTE, max: 100 }); // listId → playlist yt-search
const avatarCache = createCache({ ttlMs: 24 * HOUR, max: 1000 }); // URL de chaîne → URL de l'avatar
// Un échec de récupération d'avatar est retenu moins longtemps pour retenter plus tard
const AVATAR_FAILURE_TTL = HOUR;
const AVATAR_TIMEOUT_MS = 8000;
// En-têtes d'un navigateur de bureau réel ; SOCS/CONSENT évitent la page de consentement (hôte dans l'UE)
const BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Cookie": "SOCS=CAI; CONSENT=YES+cb",
};
const VIDEO_ID_REGEX = /(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/;
/**
* Appel yt-search passant par le limiteur partagé
* @param {Object} options Options yt-search ({query} | {videoId} | {listId})
*/
function limitedYts(options) {
return youtubeLimiter.run(() => yts(options));
}
/**
* Vidéo yt-search par identifiant (cache 6 h, requêtes simultanées dédupliquées)
* @param {string} videoId
* @returns {Promise<Object|null>}
*/
function fetchVideo(videoId) {
return videoCache.wrap(videoId, async () => (await limitedYts({ videoId })) || null);
}
function normalizeQuery(query) {
return query.trim().replace(/\s+/g, " ").toLowerCase();
}
async function getQuery(query, multiple) { async function getQuery(query, multiple) {
if (!query || typeof query !== 'string') { if (!query || typeof query !== 'string') {
@@ -14,15 +60,26 @@ async function getQuery(query, multiple) {
try { try {
const limit = multiple ? 25 : 1; const limit = multiple ? 25 : 1;
const searchResults = await yts({ query: query, limit: limit }); // yt-search ignore "limit" (une page de résultats quoi qu'il arrive) :
const videos = searchResults.videos; // 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"); clog.error("Impossible de récupérer le lien de la vidéo YouTube à partir de la requête");
return null; 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]; return multiple ? songs.filter(song => song !== null) : songs[0];
} catch (error) { } catch (error) {
clog.error('Erreur lors de la recherche YouTube: ' + error); clog.error('Erreur lors de la recherche YouTube: ' + error);
@@ -31,15 +88,16 @@ async function getQuery(query, multiple) {
} }
async function getVideo(url) { 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) { if (videoId === null) {
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL"); clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
return null; return null;
} }
try { try {
const video = await yts({videoId: videoId[1]}); const video = await fetchVideo(videoId[1]);
if (video) { if (video) {
// Nouvelle instance à chaque appel : la vidéo en cache n'est jamais modifiée par les appelants
const songReturn = new Song(); const songReturn = new Song();
await songReturn.processYoutubeVideo(video); await songReturn.processYoutubeVideo(video);
return songReturn; return songReturn;
@@ -60,24 +118,18 @@ async function getPlaylist(url) {
} }
try { try {
// Identifiant après list= (jusqu'au premier caractère hors identifiant, & ou ?)
// If watch?v= is present in the url with list?=, remove it and the code behind and transform it to playlist?list= let playlistId;
var playlistId;
// Get &list= in the url until the first & or ?
if (url.includes("list=")) { if (url.includes("list=")) {
playlistId = url.match(/(list=)([a-zA-Z0-9_-]+)/); playlistId = url.match(/(list=)([a-zA-Z0-9_-]+)/);
} }
if (!playlistId) {
if (playlistId === null) {
clog.error("Impossible de récupérer l'identifiant de la playlist YouTube à partir de l'URL"); clog.error("Impossible de récupérer l'identifiant de la playlist YouTube à partir de l'URL");
return null; return null;
} }
const playlistInfo = await yts({ listId: playlistId[2] }); const playlistInfo = await listCache.wrap(playlistId[2], () => fetchPlaylistInfo(playlistId[2]));
if (!playlistInfo) { if (!playlistInfo) {
clog.error("Impossible de récupérer la playlist YouTube à partir de l'identifiant"); 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(); const playlist = new Playlist();
playlist.type = "youtube"; playlist.type = "youtube";
playlist.author = playlistInfo.author.name; playlist.author = playlistInfo.author?.name ?? playlist.author;
playlist.authorId = playlistInfo.author.url; playlist.authorId = playlistInfo.author?.url;
playlist.authorAvatar = await getYouTubeProfilePicture(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.title = playlistInfo.title;
playlist.thumbnail = playlistInfo.thumbnail; playlist.thumbnail = playlistInfo.thumbnail;
playlist.url = `https://www.youtube.com/playlist?list=${playlistId[2]}`; playlist.url = `https://www.youtube.com/playlist?list=${playlistId[2]}`;
playlist.id = playlistInfo.listId; playlist.id = playlistInfo.listId;
playlist.views = playlistInfo.views; 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(); const song = new Song();
await song.processYoutubeVideo(video); 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.songs.push(song);
} }
playlist.readduration = getReadableDuration(playlist.duration); playlist.readduration = getReadableDuration(playlist.duration);
@@ -109,16 +164,130 @@ async function getPlaylist(url) {
} }
} }
/**
* Récupère une playlist au format yt-search ({title, listId, thumbnail, views, author, videos}).
* yt-search ne sait plus lire la nouvelle mise en page des playlists (lockupViewModel) :
* on lit d'abord la page nous-mêmes (une seule requête), yt-search ne sert que de repli.
* @param {string} listId
* @returns {Promise<Object|null>}
*/
async function fetchPlaylistInfo(listId) {
try {
const info = await fetchPlaylistPage(listId);
if (info) return info;
} catch (error) {
clog.warn(`Lecture directe de la playlist YouTube ${listId} impossible, repli sur yt-search : ${error?.message || error}`);
}
return (await limitedYts({ listId })) || null;
}
/**
* Parcourt récursivement un objet JSON et renvoie toutes les valeurs de la clé donnée
*/
function findAllByKey(root, key, out = []) {
if (Array.isArray(root)) {
for (const item of root) findAllByKey(item, key, out);
} else if (root && typeof root === "object") {
for (const k of Object.keys(root)) {
if (k === key) out.push(root[k]);
else findAllByKey(root[k], key, out);
}
}
return out;
}
/**
* "4:01" / "1:02:03" → secondes ; texte non numérique (LIVE, PREMIERE…) → 0
*/
function parseTimestamp(text) {
if (typeof text !== "string" || !/^\d+(:\d{1,2}){0,2}$/.test(text.trim())) return 0;
return text.trim().split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
}
/**
* URL absolue de chaîne à partir d'une commande de navigation YouTube
*/
function channelUrlFromRuns(textObject) {
const endpoint = textObject?.commandRuns?.[0]?.onTap?.innertubeCommand?.browseEndpoint;
if (endpoint?.canonicalBaseUrl) return "https://www.youtube.com" + endpoint.canonicalBaseUrl;
if (endpoint?.browseId) return "https://www.youtube.com/channel/" + endpoint.browseId;
return undefined;
}
/**
* Lit la page d'une playlist (100 premières vidéos, comme yt-search)
* @param {string} listId
* @returns {Promise<Object|null>} null si la mise en page n'est pas reconnue
*/
async function fetchPlaylistPage(listId) {
const url = `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}&hl=en&gl=US`;
const html = await youtubeLimiter.run(async () => {
// Page en anglais : textes ("by …", "… views") et titres non traduits, comme yt-search
const res = await fetch(url, {
headers: { ...BROWSER_HEADERS, "Accept-Language": "en-US,en;q=0.9" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
});
const match = html.match(/ytInitialData\s*=\s*({.*?});\s*<\/script>/s);
if (!match) return null;
const data = JSON.parse(match[1]);
const videos = [];
for (const lockup of findAllByKey(data, "lockupViewModel")) {
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO" || !lockup.contentId) continue;
const meta = lockup.metadata?.lockupMetadataViewModel;
// Vidéos privées / supprimées : présentes dans la liste mais illisibles
if (/^\[(private|deleted) video\]$/i.test(meta?.title?.content ?? "")) continue;
const authorText = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text;
const badge = findAllByKey(lockup.contentImage, "thumbnailBadgeViewModel")[0];
const seconds = parseTimestamp(badge?.text);
videos.push({
title: meta?.title?.content ?? "Aucun titre",
videoId: lockup.contentId,
listId,
thumbnail: `https://i.ytimg.com/vi/${lockup.contentId}/hqdefault.jpg`,
duration: { seconds, timestamp: badge?.text ?? "" },
author: { name: authorText?.content ?? "Auteur inconnu", url: channelUrlFromRuns(authorText) },
});
}
if (videos.length === 0) return null;
const header = findAllByKey(data, "pageHeaderViewModel")[0];
const headerRows = header?.metadata?.contentMetadataViewModel?.metadataRows || [];
const ownerPart = headerRows[0]?.metadataParts?.[0];
const ownerStack = ownerPart?.avatarStack?.avatarStackViewModel;
const ownerText = ownerPart?.text ?? ownerStack?.text;
const viewsText = headerRows.flatMap(row => row.metadataParts || []).map(part => part?.text?.content || "").find(text => /views?$/i.test(text));
const thumbnails = data.microformat?.microformatDataRenderer?.thumbnail?.thumbnails;
return {
title: data.metadata?.playlistMetadataRenderer?.title ?? header?.title?.dynamicTextViewModel?.text?.content ?? "Aucun titre",
listId,
url: `https://www.youtube.com/playlist?list=${listId}`,
thumbnail: thumbnails?.[thumbnails.length - 1]?.url ?? videos[0].thumbnail,
views: viewsText ? Number(viewsText.replace(/\D/g, "")) || 0 : 0,
author: {
name: (ownerText?.content ?? "Auteur inconnu").replace(/^by\s+/i, ""),
url: channelUrlFromRuns(ownerText),
},
authorAvatar: ownerStack?.avatars?.[0]?.avatarViewModel?.image?.sources?.[0]?.url ?? null,
videos,
};
}
async function getSecondsFromUrl(url) { 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) { if (videoId === null) {
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL"); clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
return null; return null;
} }
try { try {
const video = await yts({ videoId: videoId[1] }); const video = await fetchVideo(videoId[1]);
if (video) { 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 { } else {
clog.error("Impossible de récupérer la vidéo YouTube à partir de l'identifiant"); clog.error("Impossible de récupérer la vidéo YouTube à partir de l'identifiant");
return null; return null;
@@ -129,36 +298,63 @@ async function getSecondsFromUrl(url) {
} }
} }
/**
* Extrait l'avatar d'une chaîne depuis le HTML de sa page
* @param {string} html
* @returns {string|null}
*/
function extractAvatar(html) {
// Balise img avec la classe yt-spec-avatar-shape__image
const imgRegex = /<img[^>]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i;
const imgMatch = html.match(imgRegex);
if (imgMatch && imgMatch[1]) {
return imgMatch[1];
}
// Sinon : avatar dans le JSON embarqué
const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/;
const match = html.match(jsonRegex);
if (match && match[1]) {
return match[1].replace(/\\u0026/g, "&");
}
return null;
}
/**
* Avatar d'une chaîne YouTube (cache 24 h, échecs retenus 1 h)
* @param {string} channelUrl URL de la chaîne (https://www.youtube.com/...)
* @returns {Promise<string|null>}
*/
async function getYouTubeProfilePicture(channelUrl) { async function getYouTubeProfilePicture(channelUrl) {
let parsed;
try { try {
const res = await fetch(channelUrl, { parsed = new URL(channelUrl);
headers: { } catch {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
});
const html = await res.text();
// Match img with yt-spec-avatar-shape__image in class list
const imgRegex = /<img[^>]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i;
const imgMatch = html.match(imgRegex);
if (imgMatch && imgMatch[1]) {
return imgMatch[1];
}
// Fallback: look for avatar in embedded JSON
const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/;
const match = html.match(jsonRegex);
if (match && match[1]) {
return match[1].replace(/\\u0026/g, "&"); // Decode \u0026 to &
}
console.warn("Photo non trouvée pour :", channelUrl);
return null;
} catch (err) {
console.error("Erreur :", err);
return null; 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 }; module.exports = { getQuery, getVideo, getPlaylist, getSecondsFromUrl };
+48 -7
View File
@@ -1,10 +1,41 @@
const {LogType} = require('loguix');
const clog = new LogType("Finder");
const Resolver = require('../utils/Resolver'); const Resolver = require('../utils/Resolver');
const { QueryType } = require('../utils/QueryType'); const { QueryType } = require('../utils/QueryType');
const { Links } = require('../utils/Links');
const youtube = require("../media/YoutubeInformation") const youtube = require("../media/YoutubeInformation")
const spotify = require("../media/SpotifyInformation") const spotify = require("../media/SpotifyInformation")
const soundcloud = require("../media/SoundcloudInformation") const soundcloud = require("../media/SoundcloudInformation")
// Titres Spotify convertis pour un simple résultat de recherche
const SEARCH_PREVIEW_TRACKS = 25
/**
* Récupère une playlist ou un album Spotify et convertit ses titres en musiques YouTube jouables
* (les appelants reçoivent des Song et non des titres Spotify bruts, sans url ni type).
* @param {"playlist"|"album"} kind
* @param {string} url
* @param {number} [maxTracks] - 0 = tous ; sinon aperçu limité (recherche) pour éviter une rafale de requêtes YouTube
* @returns {Promise<Playlist|null>} null si introuvable, ou si aucun titre n'a pu être converti
*/
async function getSpotifyCollection(kind, url, maxTracks = 0) {
const playlist = kind === "album" ? await spotify.getAlbum(url) : await spotify.getPlaylist(url)
if(!playlist) return null
if(maxTracks > 0 && Array.isArray(playlist.songs) && playlist.songs.length > maxTracks) {
playlist.totalTracks = playlist.songs.length
playlist.truncated = true
playlist.songs = playlist.songs.slice(0, maxTracks)
}
const trackCount = Array.isArray(playlist.songs) ? playlist.songs.length : 0
// getTracks est idempotent : les appelants qui le rappellent ensuite ne relancent aucune recherche
await spotify.getTracks(playlist)
if(trackCount > 0 && playlist.songs.length === 0) {
// Échec total (YouTube indisponible ou bloqué) : on ne renvoie pas une playlist vide
// (un rafraîchissement écraserait sinon la playlist enregistrée)
clog.error(`Aucun titre de « ${playlist.title} » n'a pu être converti depuis Spotify`)
return null
}
return playlist
}
async function search(query, multiple, forceType) { async function search(query, multiple, forceType) {
if(!query) return null if(!query) return null
@@ -12,7 +43,9 @@ async function search(query, multiple, forceType) {
if(!forceType) forceType = null if(!forceType) forceType = null
if(forceType == "PLAYLIST") { if(forceType == "PLAYLIST") {
if(query.includes("spotify")) { 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")) { } else if(query.includes("soundcloud")) {
return await soundcloud.getPlaylist(query) return await soundcloud.getPlaylist(query)
} else if(query.includes("youtube")) { } else if(query.includes("youtube")) {
@@ -24,10 +57,15 @@ async function search(query, multiple, forceType) {
if(forceType == "SONG") { if(forceType == "SONG") {
if(query.includes("spotify")) { 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")) { } else if(query.includes("soundcloud")) {
return await soundcloud.getTrack(query) return await soundcloud.getTrack(query)
} else if(query.includes("youtube")) { } else if(query.includes("youtube")) {
if(Resolver.getQueryType(query) == QueryType.YOUTUBE_VIDEO) {
return await youtube.getVideo(query)
}
return await youtube.getQuery(query, multiple) return await youtube.getQuery(query, multiple)
} else { } else {
return null return null
@@ -36,7 +74,7 @@ async function search(query, multiple, forceType) {
if(forceType == "ALBUM") { if(forceType == "ALBUM") {
if(query.includes("spotify")) { if(query.includes("spotify")) {
return await spotify.getAlbum(query) return await getSpotifyCollection("album", query)
} else if(query.includes("youtube")) { } else if(query.includes("youtube")) {
return await youtube.getQuery(query, multiple) return await youtube.getQuery(query, multiple)
} else { } else {
@@ -58,14 +96,17 @@ async function search(query, multiple, forceType) {
return await youtube.getPlaylist(query) return await youtube.getPlaylist(query)
} }
if(type == QueryType.SPOTIFY_SONG) { 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) { if(type == QueryType.SPOTIFY_ALBUM) {
return await spotify.getAlbum(query) return await getSpotifyCollection("album", query, preview)
} }
if(type == QueryType.SPOTIFY_PLAYLIST) { if(type == QueryType.SPOTIFY_PLAYLIST) {
return await spotify.getPlaylist(query) return await getSpotifyCollection("playlist", query, preview)
} }
if(type == QueryType.SOUNDCLOUD_TRACK) { if(type == QueryType.SOUNDCLOUD_TRACK) {
+116 -58
View File
@@ -7,30 +7,57 @@ const { Song } = require('./Song')
const AllLists = new Map() // Map<guildId, List> const AllLists = new Map() // Map<guildId, List>
// Taille max de l'historique par guilde
const MAX_PREVIOUS = 50
// Écriture différée de l'historique (une rafale de skips = une seule écriture disque)
const SAVE_DELAY = 1000
class List { class List {
next; next;
current; current;
shuffle; shuffle;
guildId; guildId;
// Appelé à chaque modification (branché par le Player sur markDirty)
onChange = null;
constructor(guildId) { constructor(guildId) {
if(guildId === null) { if(!guildId) {
clog.error("Impossible de créer une liste, car guildId est null") clog.error("Impossible de créer une liste, car guildId est null")
return return
} }
if(AllLists.has(guildId)) { if(AllLists.has(guildId)) {
return AllLists.get(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) AllLists.set(guildId, this)
this.next = new Array(); this.next = new Array();
this.current = null; this.current = null;
this.shuffle = false; this.shuffle = false;
this.guildId = guildId; 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() { getNext() {
return this.next; return this.next;
} }
@@ -58,43 +85,49 @@ class List {
this.next.splice(randomIndex, 1) this.next.splice(randomIndex, 1)
} }
this.setCurrent(song) this.setCurrent(song ?? null)
process.emit("PLAYERS_UPDATE") return song ?? null
return song
} }
clearNext() { clearNext() {
this.next = new Array(); this.next = new Array();
process.emit("PLAYERS_UPDATE") this.changed()
} }
addNextSong(song) { addNextSong(song) {
if(!song) return
this.next.push(song) this.next.push(song)
process.emit("PLAYERS_UPDATE") this.changed()
} }
firstNext(song) { firstNext(song) {
if(!song) return
this.next.unshift(song) this.next.unshift(song)
process.emit("PLAYERS_UPDATE") this.changed()
} }
removeNextByIndex(index) { removeNextByIndex(index) {
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
this.next.splice(index, 1) this.next.splice(index, 1)
process.emit("PLAYERS_UPDATE") this.changed()
return true
} }
moveSongToUpNext(index) { moveSongToUpNext(index) {
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
const song = this.next[index] const song = this.next[index]
this.next.splice(index, 1) this.next.splice(index, 1)
this.next.unshift(song) this.next.unshift(song)
process.emit("PLAYERS_UPDATE") this.changed()
return true
} }
getPrevious() { getPrevious() {
const previousList = new Array() 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)) previousList.push(new Song(song))
} }
return previousList; return previousList;
@@ -102,8 +135,9 @@ class List {
} }
getPreviousSong() { getPreviousSong() {
if(PreviousDB.data[this.guildId].length > 0) { const previous = this.getPreviousArray()
return new Song(PreviousDB.data[this.guildId][0]) if(previous.length > 0 && previous[0]) {
return new Song(previous[0])
} else { } else {
return null; return null;
} }
@@ -111,37 +145,39 @@ class List {
} }
previousSong() { previousSong() {
const previous = this.getPreviousArray()
if(previous.length === 0) return null
if(this.current != null) { if(this.current != null) {
this.firstNext(this.current) this.firstNext(this.current)
} }
if(PreviousDB.data[this.guildId].length > 0) { const song = previous[0]
const song = PreviousDB.data[this.guildId][0] // Remove the song from the previous list
// Remove the song from the previous list previous.splice(0, 1)
PreviousDB.data[this.guildId].splice(0, 1) savePrevious()
savePrevious() this.changed()
return new Song(song) return song ? new Song(song) : null
} else {
return null;
}
} }
clearPrevious() { clearPrevious() {
PreviousDB.data[this.guildId] = new Array(); PreviousDB.data[this.guildId] = new Array();
savePrevious(); savePrevious();
process.emit("PLAYERS_UPDATE") this.changed()
} }
addPreviousSongToNextByIndex(index) { addPreviousSongToNextByIndex(index) {
const song = PreviousDB.data[this.guildId][index] const song = this.getPreviousArray()[index]
if(!song) return false
this.next.push(song) this.next.push(song)
process.emit("PLAYERS_UPDATE") this.changed()
return true
} }
addPreviousSong(song) { addPreviousSong(song) {
PreviousDB.data[this.guildId].unshift(song) if(!song) return
this.getPreviousArray().unshift(song)
savePrevious() savePrevious()
process.emit("PLAYERS_UPDATE") this.changed()
} }
getCurrent() { getCurrent() {
@@ -149,71 +185,72 @@ class List {
} }
setCurrent(value) { setCurrent(value) {
this.current = value; this.current = value ?? null;
process.emit("PLAYERS_UPDATE") this.changed()
} }
destroy() { destroy() {
this.clearNext(); this.next = new Array();
this.current = null this.current = null
this.shuffle = false; this.shuffle = false;
AllLists.delete(this.guildId) if(AllLists.get(this.guildId) === this) AllLists.delete(this.guildId)
process.emit("PLAYERS_UPDATE") this.changed()
this.onChange = null
} }
setShuffle() { setShuffle() {
this.shuffle = !this.shuffle; this.shuffle = !this.shuffle;
this.changed()
} }
isShuffle() { isShuffle() {
return this.shuffle; 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) { playByIndex(index, typelist) {
var index = data[0]
var list = data[1]
if(typelist == ListType.NEXT) { if(typelist == ListType.NEXT) {
const song = this.next[index] const song = this.next[index]
if(!song) return null
this.next.splice(index, 1) this.next.splice(index, 1)
this.changed()
return song return song
} else if(typelist == ListType.PREVIOUS) { } else if(typelist == ListType.PREVIOUS) {
const song = PreviousDB.data[this.guildId][index] const song = this.getPreviousArray()[index]
return song return song ? new Song(song) : null
} }
process.emit("PLAYERS_UPDATE") return null
} }
addNextPlaylist(playlist, firstAlreadyPlayed) { addNextPlaylist(playlist, firstAlreadyPlayed) {
if(firstAlreadyPlayed) { // Copie : la playlist enregistrée de l'utilisateur ne doit jamais être modifiée
playlist.songs.shift() const songs = Array.isArray(playlist?.songs) ? playlist.songs.slice(firstAlreadyPlayed ? 1 : 0) : []
}
for(const song of playlist.songs) { for(const song of songs) {
this.addNextSong(song) if(song) this.next.push(song)
} }
process.emit("PLAYERS_UPDATE") // Une seule notification pour toute la playlist
this.changed()
} }
moveNext(fromIndex, toIndex) { moveNext(fromIndex, toIndex) {
// Check if fromIndex and toIndex are valid // 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) 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] const song = this.next[fromIndex]
this.next.splice(fromIndex, 1) this.next.splice(fromIndex, 1)
this.next.splice(toIndex, 0, song) this.next.splice(toIndex, 0, song)
process.emit("PLAYERS_UPDATE") this.changed()
return true
} }
@@ -225,15 +262,36 @@ const ListType = {
PREVIOUS: "1" PREVIOUS: "1"
} }
function savePrevious() { let saveTimer = null
function flushPrevious() {
if(saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
for(const guildId in PreviousDB.data) { for(const guildId in PreviousDB.data) {
if(PreviousDB.data[guildId].length > 50) { if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
PreviousDB.data[guildId].splice(50, PreviousDB.data[guildId].length - 50) PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
} }
} }
PreviousDB.save(); PreviousDB.save();
} }
function savePrevious() {
// L'historique en mémoire est borné immédiatement, l'écriture disque est différée
for(const guildId in PreviousDB.data) {
if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
}
}
if(saveTimer) return
saveTimer = setTimeout(flushPrevious, SAVE_DELAY)
saveTimer.unref?.()
}
// Écriture des dernières modifications à l'arrêt du processus
process.once("exit", () => {
if(saveTimer) flushPrevious()
})
module.exports = { List, ListType } module.exports = { List, ListType }
+38 -13
View File
@@ -1,19 +1,44 @@
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice'); const { Readable } = require('stream')
const {LogType} = require('loguix') const {LogType} = require('loguix')
const clog = new LogType("Media") const clog = new LogType("Media")
const plog = require("loguix").getInstance("Player") const { isAllowedUrl } = require('../SongCheck')
const ffmpeg = require('fluent-ffmpeg')
async function getStream(song) {
try {
const stream = await fetch(song.url).then(res => res.body);
return stream;
} catch(e) {
clog.error("Erreur lors de la lecture de la musique : " + song.title)
clog.error(e)
}
// Les pièces jointes sont lues depuis le début : le seek n'est pas supporté (le player le refuse)
const seekable = false
/**
* @param {Object} song
* @param {number} seekSec - ignoré (seek non supporté)
* @param {{signal?: AbortSignal}} options
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekSec = 0, { signal } = {}) {
if(signal?.aborted) return null
// Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF)
if(!song || !isAllowedUrl("attachment", song.url)) {
clog.error("URL de pièce jointe refusée : " + JSON.stringify(song?.url))
return null
}
try {
const res = await fetch(song.url, { signal })
if(!res.ok || !res.body) {
clog.error(`Erreur HTTP ${res.status} lors de la lecture de la musique : ${song.title}`)
try { await res.body?.cancel() } catch(e) {}
return null
}
// Flux Node (et non flux web) : le player suit la fin réelle du flux
const stream = Readable.fromWeb(res.body)
if(signal?.aborted) {
stream.destroy()
return null
}
return stream
} catch(e) {
if(signal?.aborted) return null
clog.error("Erreur lors de la lecture de la musique : " + song.title)
clog.error(e)
return null
}
} }
module.exports = {getStream} module.exports = {getStream, seekable}
+34 -16
View File
@@ -1,25 +1,43 @@
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice');
const {LogType} = require('loguix') const {LogType} = require('loguix')
const clog = new LogType("Soundcloud-Stream") const clog = new LogType("Soundcloud-Stream")
const {Soundcloud} = require('soundcloud.ts') const {Soundcloud} = require('soundcloud.ts')
const ffmpeg = require('fluent-ffmpeg') const { isAllowedUrl } = require('../SongCheck')
const soundcloud = new Soundcloud(); const soundcloud = new Soundcloud();
async function getStream(song) { // Le flux SoundCloud est lu depuis le début : le seek n'est pas supporté (le player le refuse)
try { const seekable = false
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)
}
/**
* @param {Object} song
* @param {number} seekSec - ignoré (seek non supporté)
* @param {{signal?: AbortSignal}} options
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekSec = 0, { signal } = {}) {
if(signal?.aborted) return null
if(!song || !isAllowedUrl("soundcloud", song.url)) {
clog.error("URL SoundCloud refusée : " + JSON.stringify(song?.url))
return null
}
try {
const stream = await soundcloud.util.streamTrack(song.url)
if(!stream) {
clog.error("Aucun flux renvoyé par SoundCloud pour : " + song.title)
return null
}
// Chargement remplacé entre-temps (skip, leave...) : on libère le flux
if(signal?.aborted) {
stream.destroy?.()
return null
}
signal?.addEventListener('abort', () => stream.destroy?.(), { once: true })
return stream
} catch(e) {
clog.error("Erreur lors de la récupération du stream : " + song.title)
clog.error(e)
return null
}
} }
module.exports = {getStream} module.exports = {getStream, seekable}
+426 -176
View File
@@ -1,228 +1,478 @@
const { LogType } = require('loguix'); const { LogType } = require('loguix');
const clog = new LogType("Youtube-Stream"); const clog = new LogType("Youtube-Stream");
const { spawn, exec } = require('child_process');
const fs = require('fs');
const { PassThrough } = require('stream'); const { PassThrough } = require('stream');
const { __glob } = require('../../utils/GlobalVars');
const warp = require('../../utils/WarpProxy'); 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 // Opus d'abord (pas de transcodage côté Discord), puis le meilleur audio, puis n'importe quoi
const RETRYABLE_ERRORS = /HTTP Error 403|403: Forbidden|HTTP Error 429|Sign in to confirm/i; const FORMAT = "bestaudio[acodec=opus]/bestaudio/best";
// Délai max avant de recevoir les premières données audio // Délai max avant de recevoir les premières données audio (par tentative)
const FIRST_DATA_TIMEOUT = 30 * 1000; 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)
// Variable globale pour stocker le processus actif const NATIVE_SEEK_MS_PER_SECOND = 50;
let currentYtProcess = null; // Plus de nouvelle tentative au-delà de ce délai total
// Incrémenté à chaque appel de getStream, permet de savoir si un appel a été remplacé par un plus récent const GLOBAL_BUDGET = 60 * 1000;
let generation = 0; const MAX_ATTEMPTS = 4;
// Tampon entre yt-dlp et le player (~1 Mo, soit plusieurs dizaines de secondes d'audio) : absorbe les coupures réseau
const STREAM_BUFFER = 1 << 20;
// Lignes d'erreur / avertissement gardées par processus (classement + logs)
const MAX_STDERR_LINES = 40;
// Échecs liés au contenu : la route n'y est pour rien (pas de revérification de WARP)
const CONTENT_CLASSES = new Set(["PRIVATE", "UNAVAILABLE", "AGE_RESTRICTED", "LOGIN_REQUIRED", "MEMBERS_ONLY", "BINARY_MISSING"]);
/** /**
* Tue un processus yt-dlp et ses enfants (ffmpeg) * Processus yt-dlp actifs par guilde (pour l'administration).
* Plus aucun état global partagé entre guildes : chaque appel de getStream est indépendant.
* @type {Map<string, {pid: number, route: "warp"|"direct", identity: "anon"|"cookies", songId: string|null, startedAt: number}>}
*/
const activeProcesses = new Map();
let binaryMissingLogged = false;
/**
* Tue un processus yt-dlp et ses enfants (ffmpeg), puis attend sa fin réelle (2 s max)
*/ */
function killProcess(proc) { function killProcess(proc) {
return new Promise((resolve) => { return ytdlp.killTree(proc);
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);
} }
function routeLabel(proxy) { 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. * Refuse toute URL qui ne commence pas par http(s):// (une valeur comme "--exec=..." serait lue comme une option)
* Résout { stream } en cas de succès, ou { stream: null, reason, retryable } si la route a échoué avant le début du flux.
*/ */
function runAttempt(song, seekTime, proxy) { function isSafeUrl(url) {
return new Promise((resolve, reject) => { return typeof url === 'string' && /^https?:\/\/[^\s]+$/i.test(url);
clog.log(`[YT-DLP] Lancement pour : ${song.url} (Début: ${seekTime}s, Route: ${routeLabel(proxy)})`); }
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', '-', '-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) --- const route = attempt.proxy ? "warp" : "direct";
if (seekTime && seekTime > 0) { const out = new PassThrough({ highWaterMark: STREAM_BUFFER });
ytArgs.push('--download-sections', `*${Math.round(seekTime)}-inf`); const important = [];
}
// --- 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 = "";
let settled = false; 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) => { const releaseCopy = () => copy?.release();
if (settled) return; // Seek natif : yt-dlp a tout envoyé sans erreur et ffmpeg n'a rien produit
settled = true; const failPastEnd = () => fail("EMPTY_DOWNLOAD", `position de départ (${seekTime}s) au-delà de la fin du morceau`, { final: true });
clearTimeout(timeout);
yt.stdout.unpipe(out); const kill = () => {
out.destroy(); if (killedByUs) return;
if (currentYtProcess === yt) currentYtProcess = null; killedByUs = true;
killProcess(yt); if (yt) ytdlp.killTree(yt);
resolve({ if (ff) ytdlp.killTree(ff);
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 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 fail = (cls, message, extra = {}) => {
const msg = data.toString(); if (settled) return;
console.log(msg); settled = true;
if (!msg.includes('[download]') && !msg.includes('[youtube]')) { clearTimeout(timer);
errorLogs += msg; (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)) { signal?.addEventListener('abort', onAbort, { once: true });
fail(errorLogs.trim().split('\n').pop());
// --- 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) => { yt.on('error', (err) => {
clog.error("[YT-DLP] Erreur au lancement.", err); ytClosed = true;
// Si c'est ce processus qui est en cours, on le clean releaseCopy();
if (currentYtProcess === yt) currentYtProcess = null; forgetProcess();
clearTimeout(timeout); cleanupListener();
if (!settled) { if (err.code === 'ENOENT') {
settled = true; // Binaire installé entre-temps : on le recherchera au prochain lancement
reject(err); 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) => { yt.on('close', (code) => {
// Nettoyage de la référence globale si c'est bien nous ytClosed = true;
if (currentYtProcess === yt) currentYtProcess = null; 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 if (!settled) {
clog.warn(`[YT-DLP] Arrêt code ${code}. Logs : ${errorLogs}`); // 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 --- // --- 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', () => { 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', () => 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);
}); });
} }
/** /**
* @param {Object} song - L'objet contenant l'URL * @param {Object} song - L'objet contenant l'URL
* @param {number} seekTime - Le temps de démarrage en secondes (par défaut 0) * @param {number} seekTime - Le temps de démarrage en secondes (par défaut 0)
* @param {{signal?: AbortSignal, onProgress?: function({route: string, attempt: number, identity: string}), guildId?: string}} options
* @returns {Promise<import('stream').Readable|null>} le flux (streamType "raw" = PCM s16le 48 kHz stéréo), ou null en cas d'échec / d'annulation
*/ */
async function getStream(song, seekTime = 0) { async function getStream(song, seekTime = 0, { signal, onProgress, guildId } = {}) {
// Incrémenté avant le kill pour que l'appel précédent sache tout de suite qu'il est remplacé if (!song || !isSafeUrl(song.url)) {
const myGeneration = ++generation; clog.error(`[YT-DLP] URL refusée : ${JSON.stringify(song?.url)}`);
return null;
// ÉTAPE 1 : On s'assure que l'ancien processus est BIEN mort avant de faire quoi que ce soit.
await killCurrentProcess();
// ÉTAPE 2 : On essaie chaque route (WARP puis directe, selon l'état du proxy)
const 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)}`);
} }
// Flux vide : le player passe en Idle et enchaîne sur la musique suivante const seek = Number.isFinite(Number(seekTime)) && Number(seekTime) > 0 ? Math.floor(Number(seekTime)) : 0;
const empty = new PassThrough(); const s = settings.get();
empty.end(); // Routes figées pour toute la lecture (WARP puis directe, selon l'état du proxy)
return empty; 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 };
+896 -256
View File
File diff suppressed because it is too large. Load diff
+11 -5
View File
@@ -3,6 +3,7 @@ const {LogType} = require('loguix')
const clog = new LogType("Song") const clog = new LogType("Song")
const MediaInformation = require('../media/MediaInformation') const MediaInformation = require('../media/MediaInformation')
const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter'); const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
class Song { class Song {
title = "Aucun titre"; title = "Aucun titre";
@@ -11,7 +12,8 @@ class Song {
authorId; authorId;
url; url;
thumbnail = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png" ; 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; readduration;
form = "SONG"; form = "SONG";
type; type;
@@ -25,9 +27,10 @@ class Song {
this.author = properties.author ?? this.author this.author = properties.author ?? this.author
this.url = properties.url ?? this.url this.url = properties.url ?? this.url
this.thumbnail = properties.thumbnail ?? this.thumbnail this.thumbnail = properties.thumbnail ?? this.thumbnail
this.duration = properties.duration ?? this.duration // Durée normalisée (chaîne, NaN, négatif ou aberrant => 0) : ne fausse plus les statistiques
this.readduration = properties.readduration ?? this.readduration this.duration = toSafeDuration(properties.duration)
this.type = properties.type ?? this.type // 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 this.authorId = properties.authorId ?? this.authorId
} }
@@ -44,6 +47,8 @@ class Song {
// In face, duration is null, get the metadata of the file to get the duration // In face, duration is null, get the metadata of the file to get the duration
await MediaInformation.getMediaInformation(this, media) await MediaInformation.getMediaInformation(this, media)
this.duration = toSafeDuration(this.duration)
this.readduration = getReadableDuration(this.duration)
} else { } else {
clog.error("Impossible de traiter le média") clog.error("Impossible de traiter le média")
@@ -60,7 +65,8 @@ class Song {
this.type = "youtube" this.type = "youtube"
this.id = video.videoId 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) this.readduration = getReadableDuration(this.duration)
return this return this
} }
+94 -28
View File
@@ -2,43 +2,109 @@ const {LogType} = require("loguix")
const {Song} = require("./Song") const {Song} = require("./Song")
const slog = new LogType("SongCheck") 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) { function checkSong(song) {
if(!(song instanceof Song)) { if(!song || typeof song !== "object") {
slog.error("La musique n'est pas une instance de la classe Song") slog.error("La musique n'est pas valide")
// Check if the song is valid and if it has all the required properties return false
if(song.title && song.id && song.author && song.url && song.duration && song.readduration && song.type) { }
slog.log("Acceptation de la musique : " + song.title) if(!song.type || !KNOWN_TYPES.includes(song.type)) {
return true slog.error("La musique n'a pas de type valide : " + JSON.stringify(song.type))
} else { return false
slog.error("La musique n'est pas valide")
return false
}
} }
if(!song.url) { if(!song.url) {
slog.error("La musique n'a pas d'url") slog.error("La musique n'a pas d'url")
return false return false
} }
if(!song.title) { if(!isAllowedUrl(song.type, song.url)) {
slog.error("La musique n'a pas de titre") 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 return false
} }
if(!song.author) { if(!(song instanceof Song)) {
slog.error("La musique n'a pas d'auteur") // Objet brut (base de données, playlist, client) : accepté, il sera normalisé par sanitizeSong
return false slog.log("Acceptation de la musique : " + String(song.title).slice(0, MAX_TEXT))
}
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
} }
return true return true
} }
module.exports = {checkSong} /**
* Construit une instance de Song propre à partir de données non fiables (client, playlists.json, historique).
* Durée normalisée (0 = live / inconnue), textes bornés, miniature en http(s) uniquement.
* @param {Object|string} raw
* @returns {Song|null} null si la musique ne peut pas être lue
*/
function sanitizeSong(raw) {
let data = raw
if(typeof data === "string") {
try {
data = JSON.parse(data)
} catch(e) {
slog.error("Musique reçue illisible (JSON invalide)")
return null
}
}
if(!data || typeof data !== "object" || Array.isArray(data)) return null
if(!checkSong(data)) return null
const song = new Song({
type: data.type,
url: data.url,
title: clampText(data.title, MAX_TEXT),
id: clampText(data.id, MAX_TEXT),
author: clampText(data.author, MAX_TEXT),
authorId: clampText(data.authorId, MAX_URL),
thumbnail: typeof data.thumbnail === "string" && data.thumbnail.length <= MAX_URL && /^https?:\/\//i.test(data.thumbnail) ? data.thumbnail : undefined,
duration: data.duration,
})
if(typeof data.createdAt === "string") song.createdAt = data.createdAt.slice(0, 64)
return song
}
module.exports = {checkSong, sanitizeSong, isAllowedUrl, KNOWN_TYPES}
+363 -51
View File
@@ -3,85 +3,397 @@ const alog = new LogType("GoogleOAuth2");
const { google } = require('googleapis'); const { google } = require('googleapis');
const config = require("../../utils/Database/Configuration"); const config = require("../../utils/Database/Configuration");
const Users = require('../../server/auth/User'); const Users = require('../../server/auth/User');
const store = require('./TokenStore');
const { createStateStore, pickTokens, bestThumbnail } = require('./Utils');
const clientId = config.getYoutubeApiClientId(); const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
const clientSecret = config.getYoutubeApiClientSecret(); // Durée de validité d'une demande de connexion (state OAuth)
const redirectUri = config.getWebsiteLink() + "/oauth2callback"; const STATE_TTL_MS = 10 * 60 * 1000;
// Délai max d'un appel aux serveurs Google (échange du code, révocation, API)
const REQUEST_TIMEOUT_MS = 20 * 1000;
const states = createStateStore({ ttlMs: STATE_TTL_MS, maxPerKey: 5 });
// userId -> OAuth2Client avec les jetons enregistrés (recréé si la configuration change)
const clients = new Map();
// Utilisateurs dont la synchronisation est en cours
const syncing = new Set();
const oAuth2Map = new Map(); /**
* Erreur typée de l'intégration Google (message en français, affichable à l'utilisateur)
*/
class GoogleError extends Error {
constructor(code, message) {
super(message);
this.name = "GoogleError";
this.code = code;
}
}
// --- Configuration (lue à chaque appel : elle peut changer à chaud) ---
function getConfig() {
const clientId = config.getYoutubeApiClientId() || "";
const clientSecret = config.getYoutubeApiClientSecret() || "";
const website = String(config.getWebsiteLink() || "").replace(/\/+$/, "");
return {
clientId,
clientSecret,
redirectUri: website ? website + "/oauth2callback" : ""
};
}
function isConfigured() {
const { clientId, clientSecret, redirectUri } = getConfig();
return !!(clientId && clientSecret && redirectUri);
}
function newClient() {
const { clientId, clientSecret, redirectUri } = getConfig();
if (!clientId || !clientSecret || !redirectUri) return null;
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}
function dropClient(userId) {
const client = clients.get(userId);
if (client) client.removeAllListeners("tokens");
clients.delete(userId);
}
function clearClients() {
for (const userId of [...clients.keys()]) dropClient(userId);
}
// Identifiants modifiés depuis l'administration : les clients en cache utilisent les anciens
process.on("CONFIG_UPDATE", () => {
if (clients.size > 0) alog.log("Configuration modifiée : réinitialisation des clients Google OAuth2");
clearClients();
});
function withTimeout(promise, ms, label) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(Object.assign(new Error(`Délai dépassé (${label})`), { code: "ETIMEDOUT" })), ms);
if (typeof timer.unref === "function") timer.unref();
})
]).finally(() => clearTimeout(timer));
}
function emitUpdate(userId) {
try {
process.emit("GOOGLE_UPDATE", userId);
} catch (e) {
alog.error("Erreur lors de l'émission de GOOGLE_UPDATE : " + (e?.message || e));
}
}
// --- Erreurs de l'API ---
/**
* Traduit une erreur googleapis / gaxios en GoogleError (message français, jamais de jeton)
* @param {*} error
* @returns {GoogleError}
*/
function describeApiError(error) {
if (error instanceof GoogleError) return error;
const status = error?.response?.status ?? error?.status ?? (Number.isInteger(error?.code) ? error.code : null);
const data = error?.response?.data;
const oauthError = typeof data?.error === "string" ? data.error : null;
const reason = error?.errors?.[0]?.reason || data?.error?.errors?.[0]?.reason || null;
const message = String(error?.message || "");
if (oauthError === "invalid_grant" || /invalid_grant/i.test(message)) {
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google a expiré ou a été révoquée : reconnectez votre compte YouTube");
}
if (oauthError === "invalid_client" || oauthError === "unauthorized_client" || /invalid_client|unauthorized_client/i.test(message)) {
return new GoogleError("CONFIG_ERROR", "Les identifiants de l'application Google sont invalides (configuration du bot)");
}
if (oauthError === "redirect_uri_mismatch" || /redirect_uri_mismatch/i.test(message)) {
return new GoogleError("CONFIG_ERROR", "L'URI de redirection n'est pas autorisée dans la console Google (attendu : <site>/oauth2callback)");
}
if (["quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded"].includes(reason)) {
return new GoogleError("QUOTA", "Quota de l'API YouTube dépassé, réessayez plus tard");
}
if (reason === "playlistNotFound" || status === 404) {
return new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
}
if (status === 401) {
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google n'est plus valide : reconnectez votre compte YouTube");
}
if (status === 403) {
return new GoogleError("FORBIDDEN", "Accès refusé par l'API YouTube" + (reason ? " (" + reason + ")" : ""));
}
if (["ENOTFOUND", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code) || /timeout|Délai dépassé/i.test(message)) {
return new GoogleError("NETWORK", "Impossible de joindre les serveurs Google, réessayez plus tard");
}
return new GoogleError("API_ERROR", "Erreur de l'API YouTube" + (status ? " (HTTP " + status + ")" : ""));
}
/**
* Journalise une erreur d'API et marque le compte comme invalide si l'autorisation est perdue
* @param {string} userId
* @param {*} error
* @returns {GoogleError}
*/
function handleApiError(userId, error) {
const described = describeApiError(error);
alog.error(`Erreur Google pour l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`);
if (described.code === "AUTH_EXPIRED") markInvalid(userId, described.message);
return described;
}
function markInvalid(userId, reason) {
dropClient(userId);
const changed = store.update(userId, (record) => {
record.invalid = true;
record.invalidReason = reason || "Autorisation invalide";
});
if (changed) {
alog.warn(`Compte Google de l'utilisateur ${userId} marqué comme invalide : ${reason}`);
emitUpdate(userId);
}
}
// --- Connexion du compte ---
/**
* Génère l'URL de consentement Google pour l'utilisateur (state aléatoire lié à son id, valable 10 min)
* @param {string} userId
* @returns {string|null} URL, ou null si l'intégration n'est pas configurée
*/
function createAuthUrl(userId) { function createAuthUrl(userId) {
if(!checkCredientials()) return null; if (!checkCredientials()) return null;
var oAuth2Client;
const user = Users.getUserById(userId); const user = Users.getUserById(userId);
if (!user) { if (!user) {
alog.error(`User with ID ${userId} not found.`); alog.error(`Utilisateur ${userId} introuvable.`);
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.");
return null; return null;
} }
oAuth2Map.set(userId, oAuth2Client); const client = newClient();
alog.log(`OAuth2 client created for user ${userId}.`); if (!client) return null;
return oAuth2Client.generateAuthUrl({ const state = states.create(userId);
alog.log(`Demande de connexion Google créée pour l'utilisateur ${userId}.`);
return client.generateAuthUrl({
access_type: 'offline', access_type: 'offline',
// Le consentement explicite garantit l'envoi d'un refresh_token à chaque liaison
prompt: 'consent',
include_granted_scopes: true,
scope: SCOPES, scope: SCOPES,
state
}); });
} }
async function getAuthorization(userId, code) { /**
if(!checkCredientials()) return null; * Lie le compte Google de l'utilisateur à partir du code renvoyé par Google.
* @param {string} userId
* @param {string} code
* @param {string} state
* @returns {Promise<object>} statut du compte (voir getStatus)
* @throws {GoogleError}
*/
async function linkAccount(userId, code, state) {
if (!isConfigured()) throw new GoogleError("NOT_CONFIGURED", "La connexion YouTube n'est pas configurée sur ce bot");
if (typeof code !== "string" || code.length === 0 || code.length > 2048) {
throw new GoogleError("INVALID_PARAMS", "Code d'autorisation Google manquant ou invalide");
}
if (!states.consume(state, userId)) {
throw new GoogleError("INVALID_STATE", "La demande de connexion a expiré ou n'est pas valide, veuillez réessayer");
}
const client = newClient();
let tokens;
try { try {
const user = Users.getUserById(userId); const response = await withTimeout(client.getToken(code), REQUEST_TIMEOUT_MS, "échange du code");
if (!user) { tokens = pickTokens(response.tokens);
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;
} catch (error) { } catch (error) {
alog.error(`Error during OAuth2 authorization for user ${userId}:`, error); const described = describeApiError(error);
return null; 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() { function checkCredientials() {
if (!clientId || !clientSecret) { if (!isConfigured()) {
alog.error("YouTube API client ID or secret is not set in the configuration."); alog.warn("Identifiants de l'API YouTube (clientId / clientSecret) ou lien du site absents de la configuration.");
return false; return false;
} }
return true; return true;
} }
module.exports = { module.exports = {
SCOPES,
GoogleError,
isConfigured,
createAuthUrl, createAuthUrl,
linkAccount,
getAuthorization, getAuthorization,
getOAuth2Client: (userId) => oAuth2Map.get(userId), getClient,
oAuth2Map getOAuth2Client: getClient,
unlink,
getStatus,
isLinked,
getRecord,
setSyncing,
isSyncing,
setLastSync,
describeApiError,
handleApiError,
markInvalid,
REQUEST_TIMEOUT_MS
}; };
const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
+76
View File
@@ -0,0 +1,76 @@
// Stockage des comptes Google liés (jetons OAuth2 par utilisateur) dans data/google.json
// Ce fichier contient des refresh_token : jamais exposé à l'administration ni renvoyé au client.
const fs = require("fs")
const path = require("path")
const { Database } = require("../../utils/Database/Database")
const { __glob } = require("../../utils/GlobalVars")
const { LogType } = require("loguix")
const glog = new LogType("GoogleOAuth2")
const GOOGLE_FILE = path.join(__glob.DATA, "google.json")
// { [userId]: { tokens, linkedAt, channel, likesPlaylistId, lastSync, invalid, invalidReason } }
const googleDB = new Database("google", GOOGLE_FILE, {})
restrictPermissions()
// Droits 600 là où c'est possible (sans effet sous Windows)
function restrictPermissions() {
try {
if (fs.existsSync(GOOGLE_FILE)) fs.chmodSync(GOOGLE_FILE, 0o600)
} catch (e) {
// Système de fichiers sans permissions POSIX : rien à faire
}
}
function save() {
googleDB.save()
restrictPermissions()
}
/**
* @param {string} userId
* @returns {object|null}
*/
function get(userId) {
if (!userId) return null
const record = googleDB.data[userId]
return record && typeof record === "object" ? record : null
}
function set(userId, record) {
if (!userId) return
googleDB.data[userId] = record
save()
}
/**
* Modifie un enregistrement existant (ignoré si le compte n'est plus lié)
* @param {string} userId
* @param {(record: object) => void} mutator
* @returns {boolean}
*/
function update(userId, mutator) {
const record = get(userId)
if (!record) return false
mutator(record)
save()
return true
}
function remove(userId) {
if (!userId || !googleDB.data[userId]) return false
delete googleDB.data[userId]
save()
glog.log(`Données Google supprimées pour l'utilisateur ${userId}`)
return true
}
module.exports = {
get,
set,
update,
remove,
GOOGLE_FILE
}
+171
View File
@@ -0,0 +1,171 @@
// Fonctions pures de l'intégration Google / YouTube Data API (aucune dépendance : testables seules)
const crypto = require("crypto")
/**
* Convertit une durée ISO-8601 de l'API YouTube (ex : "PT1H2M3S", "P1DT2H", "PT0S", "P0D") en secondes.
* Les années / mois (jamais renvoyés pour une vidéo) sont refusés.
* @param {string} value
* @returns {number|null} secondes (entier >= 0), ou null si le format est invalide
*/
function parseIsoDuration(value) {
if (typeof value !== "string") return null
const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:[.,]\d+)?)S)?)?$/.exec(value.trim().toUpperCase())
if (!match) return null
// "P" seul ou "PT" seul ne sont pas des durées valides
if (match.slice(1).every(part => part === undefined)) return null
const [, weeks, days, hours, minutes, seconds] = match
const total = (Number(weeks) || 0) * 604800
+ (Number(days) || 0) * 86400
+ (Number(hours) || 0) * 3600
+ (Number(minutes) || 0) * 60
+ (Number(String(seconds || "0").replace(",", ".")) || 0)
return Number.isFinite(total) ? Math.floor(total) : null
}
/**
* Parcourt une liste paginée par pageToken (API Google) jusqu'à épuisement ou jusqu'à la limite.
* @param {(pageToken: string|undefined) => Promise<{items?: Array, nextPageToken?: string}>} fetchPage
* @param {{maxItems?: number, maxPages?: number}} [options]
* @returns {Promise<{items: Array, truncated: boolean, pages: number}>}
*/
async function collectPages(fetchPage, { maxItems = 500, maxPages = 100 } = {}) {
const items = []
const seenTokens = new Set()
let pageToken = undefined
let pages = 0
let truncated = false
while (true) {
if (pages >= maxPages) {
truncated = true
break
}
const page = await fetchPage(pageToken)
pages++
const pageItems = Array.isArray(page?.items) ? page.items : []
for (const item of pageItems) {
if (items.length >= maxItems) {
truncated = true
break
}
items.push(item)
}
const next = page?.nextPageToken
if (!next) break
// Limite atteinte alors que Google annonce encore des pages
if (items.length >= maxItems) {
truncated = true
break
}
// Sécurité : un jeton déjà vu provoquerait une boucle infinie
if (seenTokens.has(next)) break
seenTokens.add(next)
pageToken = next
}
return { items, truncated, pages }
}
/**
* Découpe un tableau en paquets de taille fixe (ex : 50 ids par appel videos.list)
* @template T
* @param {T[]} array
* @param {number} size
* @returns {T[][]}
*/
function chunk(array, size) {
const out = []
for (let i = 0; i < array.length; i += size) out.push(array.slice(i, i + size))
return out
}
/**
* Magasin de "state" OAuth : jeton aléatoire à usage unique, lié à un utilisateur, avec expiration.
* @param {{ttlMs?: number, maxPerKey?: number, now?: () => number}} [options]
*/
function createStateStore({ ttlMs = 10 * 60 * 1000, maxPerKey = 5, now = Date.now } = {}) {
// state -> { userId, expiresAt }
const states = new Map()
function sweep() {
const t = now()
for (const [state, entry] of states) {
if (entry.expiresAt <= t) states.delete(state)
}
}
return {
/**
* Crée un state pour l'utilisateur (les plus anciens sont retirés au-delà de maxPerKey)
* @param {string} userId
* @returns {string}
*/
create(userId) {
sweep()
const owned = [...states.entries()].filter(([, entry]) => entry.userId === userId)
while (owned.length >= maxPerKey) {
const [oldest] = owned.shift()
states.delete(oldest)
}
const state = crypto.randomBytes(24).toString("hex")
states.set(state, { userId, expiresAt: now() + ttlMs })
return state
},
/**
* Vérifie et consomme un state : valide seulement pour le même utilisateur et avant expiration
* @param {string} state
* @param {string} userId
* @returns {boolean}
*/
consume(state, userId) {
sweep()
if (typeof state !== "string" || !states.has(state)) return false
const entry = states.get(state)
// Un state présenté par un autre utilisateur n'est pas consommé (il reste utilisable par son propriétaire)
if (entry.userId !== userId) return false
states.delete(state)
return true
},
/** Retire tous les states d'un utilisateur */
clear(userId) {
for (const [state, entry] of states) {
if (userId === undefined || entry.userId === userId) states.delete(state)
}
},
size() {
sweep()
return states.size
}
}
}
/**
* Ne conserve que les champs utiles d'une réponse de jetons Google
* @param {object} tokens
*/
function pickTokens(tokens) {
const out = {}
if (!tokens || typeof tokens !== "object") return out
for (const key of ["access_token", "refresh_token", "expiry_date", "scope", "token_type", "id_token"]) {
if (tokens[key] !== undefined && tokens[key] !== null) out[key] = tokens[key]
}
return out
}
/**
* Meilleure miniature disponible d'un objet "thumbnails" de l'API (toutes optionnelles)
* @param {object} thumbnails
* @returns {string|null}
*/
function bestThumbnail(thumbnails) {
if (!thumbnails || typeof thumbnails !== "object") return null
return thumbnails.medium?.url || thumbnails.high?.url || thumbnails.standard?.url || thumbnails.default?.url || thumbnails.maxres?.url || null
}
module.exports = {
parseIsoDuration,
collectPages,
chunk,
createStateStore,
pickTokens,
bestThumbnail
}
+224 -39
View File
@@ -3,60 +3,245 @@ const { LogType } = require('loguix');
const alog = new LogType("YoutubeAPI"); const alog = new LogType("YoutubeAPI");
const OAuth2 = require('./OAuth2'); const OAuth2 = require('./OAuth2');
const Users = require('../../server/auth/User'); const { GoogleError } = OAuth2;
const { collectPages, chunk, parseIsoDuration, bestThumbnail } = require('./Utils');
async function getYoutubePlaylists(userId) { // Limites d'une synchronisation (quota YouTube : 1 unité par page de 50)
const user = Users.getUserById(userId); const MAX_PLAYLISTS = 50;
if (!user) { const MAX_ITEMS_PER_PLAYLIST = 500;
alog.error(`User with ID ${userId} not found.`); const PAGE_SIZE = 50;
return null; 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) { * Service YouTube authentifié pour l'utilisateur
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`); * @param {string} userId
return null; * @throws {GoogleError} NOT_LINKED
} */
function getService(userId) {
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client }); 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 { try {
const response = await youtube.playlists.list({ return await fn();
part: 'snippet,contentDetails',
mine: true,
maxResults: 50
});
alog.log(`Retrieved playlists for user ${userId}.`);
return response.data.items;
} catch (error) { } catch (error) {
alog.error(`Error retrieving playlists for user ${userId}:`, error); throw OAuth2.handleApiError(userId, error);
return null;
} }
} }
function getYoutubePlaylistSongs(playlistId, userId) { /**
const user = Users.getUserById(userId); * Playlists de l'utilisateur (paginées, limitées à MAX_PLAYLISTS)
if (!user) { * @param {string} userId
alog.error(`User with ID ${userId} not found.`); * @param {{max?: number}} [options]
return null; * @returns {Promise<Array>} éléments bruts de playlists.list
*/
async function getYoutubePlaylists(userId, { max = MAX_PLAYLISTS } = {}) {
const youtube = getService(userId);
const { items, truncated } = await collectPages((pageToken) => call(userId, async () => {
const response = await youtube.playlists.list({
part: ['snippet', 'contentDetails'],
mine: true,
maxResults: PAGE_SIZE,
pageToken
});
return response.data;
}), { maxItems: max });
if (truncated) alog.warn(`Playlists YouTube de l'utilisateur ${userId} limitées à ${max}`);
alog.log(`${items.length} playlist(s) YouTube récupérée(s) pour l'utilisateur ${userId}.`);
return items;
}
/**
* Informations d'une playlist par son id YouTube
* @returns {Promise<object|null>}
*/
async function getPlaylistInfo(userId, youtubeId) {
const youtube = getService(userId);
const response = await call(userId, () => youtube.playlists.list({
part: ['snippet', 'contentDetails'],
id: [youtubeId],
maxResults: 1
}));
return response?.data?.items?.[0] || null;
}
/**
* Éléments d'une playlist (paginés, limités à MAX_ITEMS_PER_PLAYLIST)
* @param {string} youtubeId
* @param {string} userId
* @returns {Promise<{items: Array, truncated: boolean}>}
*/
async function getPlaylistItems(userId, youtubeId, { max = MAX_ITEMS_PER_PLAYLIST } = {}) {
const youtube = getService(userId);
return collectPages((pageToken) => call(userId, async () => {
const response = await youtube.playlistItems.list({
part: ['snippet', 'contentDetails'],
playlistId: youtubeId,
maxResults: PAGE_SIZE,
pageToken
});
return response.data;
}), { maxItems: max });
}
// Compatibilité : ancienne signature (playlistId, userId) → { data: { items } }
async function getYoutubePlaylistSongs(playlistId, userId) {
const { items } = await getPlaylistItems(userId, playlistId);
return { data: { items } };
}
/**
* Durées des vidéos par paquets de 50 ids (1 unité de quota par appel)
* @param {string} userId
* @param {string[]} ids
* @param {Map<string, number>} [cache] durées déjà connues (partagées entre playlists)
* @returns {Promise<Map<string, number>>} id -> secondes (absent si inconnue)
*/
async function getVideoDurations(userId, ids, cache = new Map()) {
const youtube = getService(userId);
const missing = [...new Set(ids.filter(id => id && !cache.has(id)))];
for (const ids50 of chunk(missing, 50)) {
const response = await call(userId, () => youtube.videos.list({
part: ['contentDetails'],
id: ids50,
maxResults: 50
}));
for (const video of response?.data?.items || []) {
const seconds = parseIsoDuration(video?.contentDetails?.duration);
if (seconds !== null) cache.set(video.id, seconds);
}
} }
return cache;
}
const oAuth2Client = OAuth2.getOAuth2Client(userId); /**
if (!oAuth2Client) { * Convertit les éléments de playlistItems.list en musiques jouables (vidéos privées / supprimées ignorées)
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`); * @param {Array} items
return null; * @param {Map<string, number>} durations
*/
function toSongs(items, durations) {
const songs = [];
const seen = new Set();
for (const item of items) {
const snippet = item?.snippet;
const videoId = item?.contentDetails?.videoId || snippet?.resourceId?.videoId;
if (!videoId || seen.has(videoId)) continue;
// Vidéo privée ou supprimée : aucune chaîne propriétaire
if (!snippet?.videoOwnerChannelId) continue;
seen.add(videoId);
songs.push({
id: videoId,
title: snippet.title || "Aucun titre",
author: snippet.videoOwnerChannelTitle || "Auteur inconnu",
authorId: `https://www.youtube.com/channel/${snippet.videoOwnerChannelId}`,
url: `https://www.youtube.com/watch?v=${videoId}`,
thumbnail: bestThumbnail(snippet.thumbnails) || DEFAULT_THUMBNAIL,
duration: durations.get(videoId) ?? 0,
type: "youtube"
});
} }
return songs;
}
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client }); /**
* Playlist complète (métadonnées + musiques + durées) au format neutre utilisé par PlaylistManager
* @param {string} userId
* @param {object} info élément playlists.list, ou {id, snippet} construit pour la playlist "J'aime"
* @param {Map<string, number>} cache durées partagées
*/
async function buildPlaylist(userId, info, cache) {
const { items, truncated } = await getPlaylistItems(userId, info.id);
const ids = items.map(item => item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId).filter(Boolean);
await getVideoDurations(userId, ids, cache);
const songs = toSongs(items, cache);
if (truncated) alog.warn(`Playlist YouTube ${info.id} limitée à ${MAX_ITEMS_PER_PLAYLIST} éléments`);
return {
youtubeId: info.id,
title: info.snippet?.title || "Playlist YouTube",
description: info.snippet?.description || "",
author: info.snippet?.channelTitle || "Auteur inconnu",
authorId: info.snippet?.channelId ? `https://www.youtube.com/channel/${info.snippet.channelId}` : null,
thumbnail: bestThumbnail(info.snippet?.thumbnails) || songs[0]?.thumbnail || DEFAULT_THUMBNAIL,
url: `https://www.youtube.com/playlist?list=${info.id}`,
songs,
truncated
};
}
return youtube.playlistItems.list({ function likesInfo(userId) {
part: 'snippet', const record = OAuth2.getRecord(userId);
playlistId: playlistId, if (!record?.likesPlaylistId) return null;
maxResults: 50 return {
}); id: record.likesPlaylistId,
snippet: {
title: LIKES_TITLE,
description: "Vidéos aimées sur YouTube",
channelTitle: record.channel?.title || null,
channelId: record.channel?.id || null,
thumbnails: null
}
};
}
/**
* Récupère toutes les playlists de l'utilisateur (et ses « J'aime ») avec leurs musiques.
* Une playlist en erreur est ignorée ; une perte d'autorisation ou un quota épuisé interrompt tout.
* @param {string} userId
* @returns {Promise<{playlists: Array, skipped: number}>}
*/
async function fetchLibrary(userId) {
const infos = await getYoutubePlaylists(userId);
const likes = likesInfo(userId);
if (likes && !infos.some(info => info.id === likes.id)) infos.unshift(likes);
const cache = new Map();
const playlists = [];
let skipped = 0;
for (const info of infos) {
if (!info?.id) continue;
try {
playlists.push(await buildPlaylist(userId, info, cache));
} catch (error) {
if (["AUTH_EXPIRED", "QUOTA", "CONFIG_ERROR", "NOT_LINKED"].includes(error?.code)) throw error;
skipped++;
alog.warn(`Playlist YouTube ${info.id} ignorée : ${error?.message || error}`);
}
}
return { playlists, skipped };
}
/**
* Récupère une seule playlist (rafraîchissement d'une playlist importée)
* @param {string} userId
* @param {string} youtubeId
* @returns {Promise<object>}
* @throws {GoogleError}
*/
async function fetchPlaylist(userId, youtubeId) {
if (typeof youtubeId !== "string" || !/^[\w-]{2,64}$/.test(youtubeId)) {
throw new GoogleError("INVALID_PARAMS", "Identifiant de playlist YouTube invalide");
}
const likes = likesInfo(userId);
let info = likes && likes.id === youtubeId ? likes : await getPlaylistInfo(userId, youtubeId);
if (!info) throw new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
return buildPlaylist(userId, info, new Map());
} }
module.exports = { module.exports = {
MAX_PLAYLISTS,
MAX_ITEMS_PER_PLAYLIST,
getYoutubePlaylists, getYoutubePlaylists,
getYoutubePlaylistSongs getYoutubePlaylistSongs,
getPlaylistItems,
getVideoDurations,
fetchLibrary,
fetchPlaylist,
toSongs
}; };
+36 -1
View File
@@ -2,9 +2,44 @@ const {LogType} = require("loguix")
const hlog = new LogType("PersonalHistory") const hlog = new LogType("PersonalHistory")
const {__glob} = require("../utils/GlobalVars") const {__glob} = require("../utils/GlobalVars")
const { Database } = require("../utils/Database/Database") 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, {}) const historyDb = new Database("history", __glob.HISTORY_DB, {})
historyDb.load() 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 * @param {string} userId
* @returns {Array<Object>} * @returns {Array<Object>}
@@ -40,7 +75,7 @@ function addToPersonalHistory(userId, entry) {
if (history.length >= 25) { if (history.length >= 25) {
history.shift(); history.shift();
} }
history.push(entry) history.push(sanitizeEntry(entry))
historyDb.save(); historyDb.save();
} }
+13 -2
View File
@@ -1,4 +1,15 @@
const { getReadableDuration } = require("../utils/TimeConverter"); const { getReadableDuration } = require("../utils/TimeConverter");
const { toSafeDuration } = require("../utils/SafeMetric");
/**
* Somme sûre des durées d'une liste de musiques (durée absente, chaîne ou NaN → 0)
* @param {Array<Object>} songs
* @returns {number}
*/
function computeDuration(songs) {
if (!Array.isArray(songs)) return 0;
return songs.reduce((acc, song) => acc + toSafeDuration(song?.duration), 0);
}
class Playlist { class Playlist {
title = "Aucun titre"; title = "Aucun titre";
@@ -23,7 +34,7 @@ class Playlist {
this.thumbnail = thumbnail; this.thumbnail = thumbnail;
// Make the some of durations of the songs // Make the some of durations of the songs
if(this.songs.length > 0) { 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.readduration = getReadableDuration(this.duration);
} }
this.description = description; this.description = description;
@@ -33,5 +44,5 @@ class Playlist {
} }
} }
module.exports = {Playlist}; module.exports = {Playlist, computeDuration};
+283 -136
View File
@@ -1,22 +1,110 @@
const {Database} = require('../utils/Database/Database'); const {Database} = require('../utils/Database/Database');
const {__glob} = require('../utils/GlobalVars'); const {__glob} = require('../utils/GlobalVars');
const {Playlist} = require('./Playlist'); const {Playlist, computeDuration} = require('./Playlist');
const {LogType} = require('loguix'); const {LogType} = require('loguix');
const clog = new LogType("PlaylistManager"); const clog = new LogType("PlaylistManager");
const Finder = require('../player/Finder'); const Finder = require('../player/Finder');
const spotify = require('../media/SpotifyInformation'); const spotify = require('../media/SpotifyInformation');
const { getYoutubePlaylistSongs } = require('./Google/YoutubeList'); const YoutubeList = require('./Google/YoutubeList');
const { auth } = require('googleapis/build/src/apis/abusiveexperiencereport'); const GoogleOAuth2 = require('./Google/OAuth2');
const { getReadableDuration } = require('../utils/TimeConverter'); 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, {}); const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {});
/**
* Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...)
*/
function sameId(a, b) {
if (a === undefined || a === null || b === undefined || b === null) return false;
return String(a) === String(b);
}
/**
* Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de l'utilisateur
* (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation)
* @param {Array} userPlaylists
* @returns {string}
*/
function newPlaylistId(userPlaylists) {
let candidate = Date.now();
const used = new Set((userPlaylists || []).map(p => String(p?.playlistId)));
while (used.has(String(candidate))) candidate++;
return String(candidate);
}
/**
* Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible.
* Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée.
* @returns {boolean} true si la musique a été modifiée
*/
function sanitizeSongDuration(song) {
if (!song || typeof song !== "object") return false;
const duration = toSafeDuration(song.duration);
const readduration = getReadableDuration(duration);
if (song.duration === duration && song.readduration === readduration) return false;
song.duration = duration;
song.readduration = readduration;
return true;
}
/**
* Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne)
* @returns {boolean} true si la playlist a été modifiée
*/
function refreshPlaylistDuration(playlist) {
if (!playlist || typeof playlist !== "object") return false;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
let changed = false;
for (const song of playlist.songs) {
if (sanitizeSongDuration(song)) changed = true;
}
const duration = computeDuration(playlist.songs);
const readduration = getReadableDuration(duration);
if (playlist.duration !== duration || playlist.readduration !== readduration) {
playlist.duration = duration;
playlist.readduration = readduration;
changed = true;
}
return changed;
}
// Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN)
// et donne un identifiant (chaîne) aux playlists qui n'en ont pas (anciens imports YouTube)
try {
let repaired = 0;
let identified = 0;
for (const userId of Object.keys(playlistDB.data || {})) {
const userPlaylists = playlistDB.data[userId];
if (!Array.isArray(userPlaylists)) continue;
for (const playlist of userPlaylists) {
if (refreshPlaylistDuration(playlist)) repaired++;
if (playlist && typeof playlist === "object") {
if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") {
playlist.playlistId = newPlaylistId(userPlaylists);
identified++;
} else if (typeof playlist.playlistId !== "string") {
playlist.playlistId = String(playlist.playlistId);
identified++;
}
}
}
}
if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`);
if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`);
if (repaired > 0 || identified > 0) playlistDB.save();
} catch (e) {
clog.error("Erreur lors de la vérification des playlists : " + e);
}
/** /**
* @param {string} id * @param {string} id
* @param {string} name
* @returns {Array<Playlist>} * @returns {Array<Playlist>}
* @description Renvoie la liste des playlists de l'utilisateur * @description Renvoie la liste des playlists de l'utilisateur
*/ */
@@ -40,7 +128,7 @@ function getPlaylistsOfUser(id) {
*/ */
function getPlaylistOfUser(id, playlistId) { function getPlaylistOfUser(id, playlistId) {
const playlists = getPlaylistsOfUser(id); const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => p.playlistId === playlistId); const playlist = playlists.find(p => sameId(p.playlistId, playlistId));
if (!playlist) { if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null; return null;
@@ -65,16 +153,20 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
if(playlistFounded instanceof Playlist) { if(playlistFounded instanceof Playlist) {
playlist = playlistFounded; playlist = playlistFounded;
} }
if(playlist.type === "spotify") { if(!failed && playlist.type === "spotify") {
playlist.songs = await spotify.getTracks(playlist); // 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) { if(failed) {
clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`); clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`);
return null; 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); playlists.push(playlist);
playlistDB.save(); playlistDB.save();
@@ -84,55 +176,52 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
function removePlaylist(id, playlistId) { function removePlaylist(id, playlistId) {
const playlists = getPlaylistsOfUser(id); const playlists = getPlaylistsOfUser(id);
const index = playlists.findIndex(p => p.playlistId === playlistId); const index = playlists.findIndex(p => sameId(p.playlistId, playlistId));
if (index === -1) { if (index === -1) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return; return false;
} }
playlists.splice(index, 1); playlists.splice(index, 1);
playlistDB.save(); playlistDB.save();
clog.log(`Suppression de la playlist ${playlistId} pour l'utilisateur ${id}`); clog.log(`Suppression de la playlist ${playlistId} pour l'utilisateur ${id}`);
return true;
} }
function getPlaylist(id, playlistId) { function getPlaylist(id, playlistId) {
const playlists = getPlaylistsOfUser(id); return getPlaylistOfUser(id, playlistId);
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;
} }
/**
* 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) { function copyPlaylist(fromId, toId, playlistId) {
const playlists = getPlaylistsOfUser(fromId); const playlist = getPlaylistOfUser(fromId, playlistId);
const playlist = playlists.find(p => p.playlistId === playlistId); if (!playlist) return null;
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${fromId}`);
return null;
}
const toPlaylists = getPlaylistsOfUser(toId); 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)) { if (toPlaylists.find(p => p.title === playlist.title)) {
clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`); clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`);
return null; 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(); playlistDB.save();
clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`); clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`);
return copy;
return false;
} }
function renamePlaylist(id, playlistId, newName) { function renamePlaylist(id, playlistId, newName) {
const playlists = getPlaylistsOfUser(id); const playlist = getPlaylistOfUser(id, playlistId);
const playlist = playlists.find(p => p.playlistId === playlistId); if (!playlist) return null;
if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
playlist.title = newName; playlist.title = newName;
playlistDB.save(); playlistDB.save();
clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`); clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`);
return playlist;
} }
function addSong(id, playlistId, song) { function addSong(id, playlistId, song) {
@@ -149,121 +238,157 @@ function addSong(id, playlistId, song) {
clog.error(`La chanson ${song} n'est pas valide`); clog.error(`La chanson ${song} n'est pas valide`);
return null; return null;
} }
const playlists = getPlaylistsOfUser(id); const playlist = getPlaylistOfUser(id, playlistId);
const playlist = playlists.find(p => p.playlistId === playlistId); if (!playlist) return null;
if (!playlist) { // Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); song = songCheck.sanitizeSong(song);
return null; if (!song || !song.id || !song.title) {
} clog.error(`La chanson reçue n'est pas valide`);
// Check the integrity of the song
if (!song.id || !song.title || !song.url) {
clog.error(`La chanson ${song.title} n'est pas valide`);
return null; return null;
} }
if (!Array.isArray(playlist.songs)) playlist.songs = [];
playlist.songs.push(song); playlist.songs.push(song);
// Recalculate the songs duration and readduration // Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue)
playlist.duration += song.duration; refreshPlaylistDuration(playlist);
playlist.readduration = getReadableDuration(playlist.duration);
playlistDB.save(); playlistDB.save();
clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`); clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist;
} }
function removeSong(id, playlistId, songId) { function removeSong(id, playlistId, songId) {
const playlists = getPlaylistsOfUser(id); const playlist = getPlaylistOfUser(id, playlistId);
const playlist = playlists.find(p => p.playlistId === playlistId); if (!playlist) return null;
if (!playlist) { if (!Array.isArray(playlist.songs)) playlist.songs = [];
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null;
}
const index = playlist.songs.findIndex(s => s.id === songId); const index = playlist.songs.findIndex(s => s.id === songId);
if (index === -1) { if (index === -1) {
clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`); clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`);
return null; return null;
} }
playlist.duration -= playlist.songs[index].duration;
playlist.songs.splice(index, 1); 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(); playlistDB.save();
clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`); clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist;
} }
async function processYoutubeData(userId, data) { // --- YOUTUBE (compte Google lié) ---
if (!data || data.length === 0) {
/**
* Construit une playlist enregistrable à partir des données neutres de YoutubeList
* @param {object} data résultat de YoutubeList.buildPlaylist
* @param {string} playlistId identifiant local (conservé d'une synchronisation à l'autre)
* @returns {Playlist}
*/
function playlistFromYoutube(data, playlistId) {
const playlist = new Playlist(data.title, data.url, data.author, data.authorId, data.songs, data.thumbnail, null, null, data.description || "Aucune description disponible");
playlist.id = data.youtubeId;
playlist.youtubeId = data.youtubeId;
// "google" : rafraîchie via l'API YouTube avec le compte du propriétaire (playlists privées, "J'aime")
playlist.source = "google";
playlist.type = "youtube";
playlist.playlistId = playlistId;
playlist.syncedAt = new Date().toISOString();
refreshPlaylistDuration(playlist);
return playlist;
}
/**
* Index d'une playlist déjà importée correspondant à une playlist YouTube
*/
function findImportedIndex(userPlaylists, youtubeId) {
const synced = userPlaylists.findIndex(p => p?.source === "google" && p.youtubeId === youtubeId);
if (synced !== -1) return synced;
// Anciens imports (sans "source") : id = identifiant YouTube
return userPlaylists.findIndex(p => p?.type === "youtube" && !p.source && p.id === youtubeId);
}
/**
* Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur.
* Les playlists déjà importées gardent leur identifiant local et leur position.
* @param {string} userId
* @param {Array} data
* @returns {Array<Playlist>}
*/
function processYoutubeData(userId, data) {
if (!Array.isArray(data) || data.length === 0) {
clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`); clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`);
return []; 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); const userPlaylists = getPlaylistsOfUser(userId);
// Remove existing playlists with the same IDs to avoid duplicates const result = [];
for (const playlist of playlists) { for (const item of data) {
const existingIndex = userPlaylists.findIndex(p => p.id === playlist.id); if (!item?.youtubeId) continue;
if (existingIndex !== -1) { const index = findImportedIndex(userPlaylists, item.youtubeId);
userPlaylists.splice(existingIndex, 1); // Remove existing playlist with the same ID 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(); playlistDB.save();
clog.log(`Playlists ajoutées pour l'utilisateur ${userId}. Nombre total de playlists : ${userPlaylists.length}`); clog.log(`Playlists YouTube importées pour l'utilisateur ${userId} : ${result.length}. Nombre total de playlists : ${userPlaylists.length}`);
return playlists; 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) { async function deleteUserPlaylists(userId) {
@@ -277,39 +402,60 @@ async function deleteUserPlaylists(userId) {
} }
async function refreshPlaylist(userId, playlistId) { async function refreshPlaylist(userId, playlistId) {
var playlist = getPlaylistOfUser(userId, playlistId); const current = getPlaylistOfUser(userId, playlistId);
if (!playlist) { if (!current) {
clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId} avec l'ID ${playlistId}`); clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId} avec l'ID ${playlistId}`);
return null; return null;
} }
const localId = current.playlistId;
var playlist = current;
let failed = false; let failed = false;
// If playlistHasUrl, refresh the playlist
if (playlist.url) { if (current.source === "google" && current.youtubeId && GoogleOAuth2.isLinked(userId)) {
await Finder.search(playlist.url, false, "PLAYLIST").then(async (playlistFounded) => { // 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) { if(!playlistFounded) {
failed = true; failed = true;
} }
if(playlistFounded instanceof Playlist) { if(playlistFounded instanceof Playlist) {
playlist = playlistFounded; playlist = playlistFounded;
} }
if(playlist.type === "spotify") { if(!failed && playlist.type === "spotify") {
playlist.songs = await spotify.getTracks(playlist); // 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) { if(failed) {
clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`); clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`);
return null; return null;
} }
playlist.playlistId = playlistId; refreshPlaylistDuration(playlist);
playlist.playlistId = localId;
const playlists = getPlaylistsOfUser(userId); const playlists = getPlaylistsOfUser(userId);
// Remove the older one // Remplacement à la même position
// Push at the same index const existingIndex = playlists.findIndex(p => sameId(p.playlistId, localId));
const existingIndex = playlists.findIndex(p => p.playlistId === playlistId); 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); playlists.splice(existingIndex, 1, playlist);
playlistDB.save(); playlistDB.save();
return playlist; return playlist;
@@ -326,6 +472,7 @@ module.exports = {
addSong, addSong,
removeSong, removeSong,
processYoutubeData, processYoutubeData,
syncYoutube,
deleteUserPlaylists, deleteUserPlaylists,
refreshPlaylist refreshPlaylist
} }
+2072
View File
File diff suppressed because it is too large. Load diff
+476 -390
View File
File diff suppressed because it is too large. Load diff
+107 -95
View File
@@ -2,10 +2,34 @@ const { LogType } = require('loguix');
const dlog = new LogType("DiscordAuth"); const dlog = new LogType("DiscordAuth");
const { getWebsiteLink, getClientSecret } = require('../../utils/Database/Configuration'); 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) { async function getDiscordUser(sessionId, auth_code) {
return new Promise((resolve, reject) => {
const discordBot = require("../../discord/Bot") const discordBot = require("../../discord/Bot")
const discordBotClient = discordBot.getClient() const discordBotClient = discordBot.getClient()
dlog.step.init("discord_auth_" + sessionId, "Authentification Discord de la session :" + sessionId); 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("grant_type", "authorization_code");
params.append("code", auth_code); params.append("code", auth_code);
params.append("redirect_uri", getWebsiteLink() + "/redirect"); params.append("redirect_uri", getWebsiteLink() + "/redirect");
params.append("scope", "identify guilds");
fetch("https://discord.com/api/oauth2/token", { let accessToken;
method: "POST", try {
headers: { accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
"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", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: params, body: params,
}).then(accessTokenResp => accessTokenResp.json()).then(accessToken => { });
if (accessToken.error) { 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); throw new Error((accessToken?.error || "Réponse invalide") + " : " + (accessToken?.error_description || ""));
resolve(null); }
return; } 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);
resolve(accessToken); return "ACCESS_TOKEN_ERROR";
}).catch(err => { }
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err);
resolve(null);
})
})
}
dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId);
function getUserIdentity(accessToken) { // Le scope demandé par le site est "identify" : l'appel /users/@me/guilds était toujours refusé et inutilisé
return new Promise((resolve, reject) => { try {
fetch("https://discord.com/api/users/@me", { const user = await discordFetch("https://discord.com/api/users/@me", {
headers: { headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`, authorization: `${accessToken.token_type} ${accessToken.access_token}`,
}, },
}).then(userResp => userResp.json()).then(user => { });
if (user.error) { if (!user?.id) throw new Error("Identité Discord sans identifiant");
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + user.error + " : " + user.error_description); 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 + ")");
resolve(null); dlog.step.end("discord_auth_" + sessionId)
return; return {
} auth: accessToken,
resolve(user); identity: user,
}).catch(err => { };
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err); } catch (err) {
resolve(null); 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";
}) }
} }
/**
* @returns {Promise<Object|null>} nouveau token d'accès, null en cas d'échec
*/
async function refreshToken(refresh_token) {
if (!refresh_token) return null;
const discordBot = require("../../discord/Bot")
const params = new URLSearchParams();
params.append("client_id", discordBot.getClient().user.id);
params.append("client_secret", getClientSecret());
params.append("grant_type", "refresh_token");
params.append("refresh_token", refresh_token);
try {
const accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
if (!accessToken?.access_token || accessToken.error) {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken?.error + " : " + accessToken?.error_description);
return null;
}
return accessToken;
} catch (err) {
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err.message);
return null;
}
}
/**
* @returns {Promise<Object|null>} identité Discord (/users/@me), null en cas d'échec
*/
async function getUserIdentity(accessToken) {
if (!accessToken?.access_token) return null;
try {
const user = await discordFetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
},
});
if (!user?.id) {
dlog.error("Réponse de Discord sans identifiant utilisateur");
return null;
}
return user;
} catch (err) {
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err.message);
return null;
}
}
module.exports = {getDiscordUser, refreshToken, getUserIdentity} module.exports = {getDiscordUser, refreshToken, getUserIdentity}
+46 -9
View File
@@ -2,32 +2,69 @@ const { LogType } = require('loguix');
const { generateSessionId } = require('../../utils/TokenManager'); const { generateSessionId } = require('../../utils/TokenManager');
const clog = new LogType("Session"); 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) { 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() { function addSession() {
purgeExpired();
const sessionId = generateSessionId(); const sessionId = generateSessionId();
if (checkSession(sessionId)) { if (sessions.has(sessionId)) {
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`); 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 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.`); clog.log(`Nouvelle session ${sessionId} ajoutée.`);
return sessionId; return sessionId;
} }
function removeSession(sessionId) { function removeSession(sessionId) {
const index = sessions.indexOf(sessionId); if (sessions.delete(sessionId)) {
if (index > -1) {
sessions.splice(index, 1);
clog.log(`Suppression de la session ${sessionId}.`); clog.log(`Suppression de la session ${sessionId}.`);
} else { } else {
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`); clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`);
} }
} }
module.exports = {checkSession, addSession, removeSession}; /**
* Nombre de sessions OAuth en attente (vue d'ensemble de l'administration)
*/
function getSessionCount() {
purgeExpired();
return sessions.size;
}
module.exports = {checkSession, addSession, removeSession, getSessionCount};
+213 -67
View File
@@ -4,53 +4,86 @@ const { generateToken } = require('../../utils/TokenManager');
const { LogType } = require('loguix'); const { LogType } = require('loguix');
const clog = new LogType("User"); const clog = new LogType("User");
const discordAuth = require('./DiscordAuth'); const discordAuth = require('./DiscordAuth');
const e = require('cors');
const UserDB = new Database("Users", __glob.USERFILE, []); const UserDB = new Database("Users", __glob.USERFILE, []);
var userList = new Array(); 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 { class User {
auth; auth;
identity; identity;
tokens; tokens;
labels; labels;
constructor(auth, identity, tokens, labels) { banInfo;
constructor(auth, identity, tokens, labels, banInfo) {
this.auth = auth; this.auth = auth;
this.identity = identity; this.identity = identity;
this.tokens = tokens; this.tokens = tokens || [];
this.labels = labels; 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); const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) { if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null; return null;
} }
if (userInUserList.labels.includes("ADMIN")) { const wasAdmin = userInUserList.labels.includes("ADMIN");
userInUserList.labels.splice(userInUserList.labels.indexOf("ADMIN"), 1); const isAdmin = applyLabel(userInUserList.labels, "ADMIN", value);
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`); if (wasAdmin === isAdmin) return isAdmin;
} else { if (isAdmin) {
userInUserList.labels.push("ADMIN");
clog.log(`L'utilisateur ${this.identity.username} est maintenant admin.`); clog.log(`L'utilisateur ${this.identity.username} est maintenant admin.`);
} else {
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`);
} }
saveUsers() 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); const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) { if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null; return null;
} }
const banLabel = `BAN_${guildId}`; const banLabel = `BAN_${guildId}`;
if (userInUserList.labels.includes(banLabel)) { const wasBanned = userInUserList.labels.includes(banLabel);
userInUserList.labels.splice(userInUserList.labels.indexOf(banLabel), 1); const banned = applyLabel(userInUserList.labels, banLabel, value);
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`); if (wasBanned === banned) return banned;
} else { if (banned) {
userInUserList.labels.push(banLabel);
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni du serveur ${guildId}.`); 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() saveUsers()
return banned;
} }
createToken() { 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); const userInUserList = userList.find(user => user.identity.id === this.identity.id);
if (!userInUserList) { if (!userInUserList) {
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`); clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
return null; return null;
} }
if (userInUserList.labels.find(label => label == "BAN")) { const wasBanned = userInUserList.labels.includes("BAN");
userInUserList.labels.splice(userInUserList.labels.indexOf("BAN"), 1); const banned = applyLabel(userInUserList.labels, "BAN", value);
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`); if (banned) {
if (!wasBanned || reason) {
userInUserList.banInfo = { reason: reason ? String(reason).slice(0, 500) : null, at: new Date().toISOString() };
}
} else { } else {
userInUserList.labels.push("BAN"); userInUserList.banInfo = null;
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`); }
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() saveUsers()
return banned;
} }
isBanned(guildId) { isBanned(guildId) {
@@ -148,6 +197,9 @@ class User {
const modLabel = `MOD_${guildId}`; const modLabel = `MOD_${guildId}`;
return this.labels.includes(modLabel) || this.isAdmin(); return this.labels.includes(modLabel) || this.isAdmin();
} }
isDeleted() {
return this.labels.includes("DELETED");
}
justUpdated() { justUpdated() {
const userInUserList = userList.find(user => user.identity.id === this.identity.id); const userInUserList = userList.find(user => user.identity.id === this.identity.id);
@@ -187,13 +239,19 @@ class User {
async function refreshAllUserInformation() { async function refreshAllUserInformation() {
await loadUsers(); await loadUsers();
clog.log("Récupération des informations de tous les utilisateurs..."); clog.log("Récupération des informations de tous les utilisateurs...");
for (const user of userList) { // Une seule sauvegarde à la fin plutôt qu'une réécriture complète du fichier par utilisateur
await updateCredientials(user.identity.id); for (const user of [...userList]) {
if (!user.auth) continue;
await updateCredientials(user.identity.id, false);
} }
saveUsers(); 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); const user = getUserById(id);
if (!user) { if (!user) {
clog.warn(`Utilisateur ${id} non trouvé.`); clog.warn(`Utilisateur ${id} non trouvé.`);
@@ -223,7 +281,7 @@ async function updateCredientials(id) {
else { else {
clog.warn(`Aucune authentification trouvée pour l'utilisateur ${user.identity.username} (${user.identity.id})`); clog.warn(`Aucune authentification trouvée pour l'utilisateur ${user.identity.username} (${user.identity.id})`);
} }
saveUsers(); if (save) saveUsers();
return user.auth; return user.auth;
} }
@@ -277,6 +335,11 @@ async function updateIdentity(id) {
* @returns {User} user * @returns {User} user
*/ */
async function addUser(auth, identity) { 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 // Check if the user already exists
const existingUser = userList.find(user => user.identity.id === identity.id); const existingUser = userList.find(user => user.identity.id === identity.id);
if (existingUser) { if (existingUser) {
@@ -320,12 +383,12 @@ function removeToken(token) {
if (index > -1) { if (index > -1) {
user.tokens.splice(index, 1); user.tokens.splice(index, 1);
saveUsers(); saveUsers();
clog.log(`Token ${token} supprimé pour l'utilisateur ${user.identity.username}.`); clog.log(`Token supprimé pour l'utilisateur ${user.identity.username}.`);
} else { } 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 { } else {
clog.warn(`Utilisateur avec le token "${token}" non trouvé.`); clog.warn(`Utilisateur introuvable pour le token fourni.`);
} }
return user; return user;
} }
@@ -336,7 +399,7 @@ function addToken(id) {
const token = generateToken(); const token = generateToken();
user.tokens.push(token); user.tokens.push(token);
saveUsers(); saveUsers();
clog.log(`Token "${token}" ajouté pour l'utilisateur ${user.identity.username}.`); clog.log(`Token ajouté pour l'utilisateur ${user.identity.username}.`);
return token; return token;
} else { } else {
clog.warn(`Utilisateur ${id} non trouvé.`); clog.warn(`Utilisateur ${id} non trouvé.`);
@@ -392,74 +455,151 @@ function getSimpleUser(id) {
// SET LABELS // SET LABELS
async function setAdmin(id) { /**
* Toutes les fonctions de labels acceptent une valeur explicite (idempotent) ;
* sans valeur, elles basculent l'état comme avant (compatibilité).
* @returns {Promise<boolean|null>} nouvel état, null si l'utilisateur est introuvable ou protégé
*/
async function setAdmin(id, value) {
const user = getUserById(id); const user = getUserById(id);
if (user) { if (user) {
await user.setAdmin(); return user.setAdmin(value);
await saveUsers();
} else { } else {
clog.warn(`Utilisateur ${id} non trouvé.`); clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
} }
} }
async function setGuildMod(id, guildId) { async function setGuildMod(id, guildId, value) {
const user = getUserById(id); const user = getUserById(id);
if (user) { if (user) {
const modLabel = `MOD_${guildId}`; const modLabel = `MOD_${guildId}`;
if (user.labels.includes(modLabel)) { const wasMod = user.labels.includes(modLabel);
await user.labels.splice(user.labels.indexOf(modLabel), 1); const isMod = applyLabel(user.labels, modLabel, value);
clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`); if (wasMod !== isMod) {
} else { if (isMod) {
await user.labels.push(modLabel); clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`);
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 { } else {
clog.warn(`Utilisateur ${id} non trouvé.`); clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
} }
} }
async function setGuildBan(id, guildId) { async function setGuildBan(id, guildId, value) {
const user = getUserById(id); const user = getUserById(id);
if (user) { 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.`); clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
return; return null;
} }
await user.setBan(guildId); return user.setBan(guildId, value);
await saveUsers();
} else { } else {
clog.warn(`Utilisateur ${id} non trouvé.`); clog.warn(`Utilisateur ${id} non trouvé.`);
return null;
} }
} }
async function setFullBan(id) { async function setFullBan(id, value, reason) {
const user = getUserById(id); const user = getUserById(id);
if (user) { 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.`); clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
return; return null;
} }
await user.setFullBan(); return user.setFullBan(value, reason);
await saveUsers();
} else { } else {
clog.warn(`Utilisateur ${id} non trouvé.`); 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); const user = getUserById(id);
if (user) { 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 {
clog.warn(`Utilisateur ${id} non trouvé.`); 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 // USERS DB
@@ -468,12 +608,15 @@ function loadUsers() {
UserDB.load() UserDB.load()
userList = new Array(); userList = new Array();
for (const user of UserDB.getData()) { 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")) { if(user?.labels?.includes("DELETED")) {
clog.log(`Utilisateur ${user.identity.id} marqué comme supprimé, ignoré.`); userList.push(new User(null, user.identity, [], user.labels, user.banInfo));
userList.push(new User(null, user.identity, [], user.labels));
continue; // Skip deleted users 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.`); clog.log(`Chargement de ${userList.length} utilisateurs.`);
return userList; return userList;
@@ -481,12 +624,14 @@ function loadUsers() {
function saveUsers() { function saveUsers() {
UserDB.data = userList.map(user => { UserDB.data = userList.map(user => {
return { const data = {
auth: user.auth, auth: user.auth,
identity: user.identity, identity: user.identity,
tokens: user.tokens, tokens: user.tokens,
labels: user.labels, labels: user.labels,
}; };
if (user.banInfo) data.banInfo = user.banInfo;
return data;
}); });
UserDB.save() UserDB.save()
clog.log(`Sauvegarde de ${userList.length} utilisateurs.`); clog.log(`Sauvegarde de ${userList.length} utilisateurs.`);
@@ -503,8 +648,8 @@ function clearNeedUpdateForUsers() {
} }
module.exports = {User}
module.exports = { module.exports = {
User,
addUser, addUser,
setFullBan, setFullBan,
removeUser, removeUser,
@@ -522,5 +667,6 @@ module.exports = {
refreshAllUserInformation, refreshAllUserInformation,
updateIdentity, updateIdentity,
clearNeedUpdateForUsers, clearNeedUpdateForUsers,
deleteAccount deleteAccount,
revokeTokens
}; };
+178 -12
View File
@@ -2,7 +2,6 @@ const {Database} = require("./Database")
const {__glob} = require("../GlobalVars") const {__glob} = require("../GlobalVars")
const {LogType} = require("loguix") const {LogType} = require("loguix")
const path = require("path") const path = require("path")
const { get } = require("http")
const clog = new LogType("Configuration") 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() { function getToken() {
return config.data.token return config.data.token
} }
function getReportChannel() { function getReportChannel() {
return config.data.report.channel return config.data.report?.channel
} }
function getReportContact() { function getReportContact() {
return config.data.report.contact return config.data.report?.contact
} }
function getYoutubeApiClientId() { function getYoutubeApiClientId() {
return config.data.api.youtube.clientId return config.data.api?.youtube?.clientId
} }
function getYoutubeApiClientSecret() { function getYoutubeApiClientSecret() {
return config.data.api.youtube.clientSecret return config.data.api?.youtube?.clientSecret
} }
function getSpotifyClientId() { function getSpotifyClientId() {
return config.data.api.spotify.clientId return config.data.api?.spotify?.clientId
} }
function getSpotifyClientSecret() { function getSpotifyClientSecret() {
return config.data.api.spotify.clientSecret return config.data.api?.spotify?.clientSecret
} }
function getWebsiteLink() { function getWebsiteLink() {
@@ -73,15 +233,16 @@ function getClientSecret() {
} }
function getMediaGuildId() { function getMediaGuildId() {
return config.data.media.guildId return config.data.media?.guildId
} }
function getMediaChannelId() { function getMediaChannelId() {
return config.data.media.channelId return config.data.media?.channelId
} }
if(getToken() == "") { if(!getToken()) {
clog.error("Impossible de démarrer sans token valide") // 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) process.exit(1)
} }
@@ -97,6 +258,11 @@ module.exports = {
getWebsiteLink, getWebsiteLink,
getPort, getPort,
getMediaGuildId, getMediaGuildId,
getMediaChannelId getMediaChannelId,
// Administration
getMasked,
setValue,
validateValue,
getKeys
} }
+117 -21
View File
@@ -11,31 +11,68 @@ function getDatabase(name) {
return AllDatabases[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 { class Database {
data; data;
path; path;
name; name;
empty; empty;
constructor(name, path, 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}`) clog.log(`Enregistrement de la base de données '${name}' contenu dans ${path}`)
this.name = name; this.name = name;
this.path = path; this.path = path;
this.data = {}; this.loaded = false;
if(empty) this.empty = empty; else this.empty = {} if(empty) this.empty = empty; else this.empty = {}
this.data = this.getEmpty();
this.load() this.load()
AllDatabases[name] = this 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() { load() {
clog.log(`Chargement de la base de données '${this.name}'`)
try { try {
this.create() this.create()
this.update() this.update()
} catch(e) { } catch(e) {
clog.error(`Erreur lors du chargement de la base de données '${this.name}'`) clog.error(`Erreur lors du chargement de la base de données '${this.name}'`)
clog.error(e) clog.error(e?.stack || e)
} }
} }
@@ -44,43 +81,102 @@ class Database {
try { try {
if(!fs.existsSync(this.path)) { if(!fs.existsSync(this.path)) {
clog.warn(`Le fichier de la base de données '${this.name}' n'existe pas, création du fichier`) 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) { } catch(e) {
clog.error(`Erreur lors de la création de la base de données '${this.name}'`) 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() { save() {
try { try {
clog.log(`Sauvegarde de la base de données '${this.name}'`) const content = JSON.stringify(this.data, null, 2)
fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2)) if(content === undefined) throw new Error("Données non sérialisables")
writeFileAtomic(this.path, content)
} catch(e) { } catch(e) {
clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`) clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`)
clog.error(e) clog.error(e?.stack || e)
} }
// Assure that the database is up to date and reloaded // La mémoire fait foi : plus de relecture du fichier après la sauvegarde.
this.update() // (Relire remplaçait this.data par un nouvel objet : les références détenues
// ailleurs devenaient orphelines et des modifications concurrentes étaient perdues.)
// Pour recharger explicitement depuis le disque, appeler load() ou update().
}
/**
* Vérifie que la donnée lue a le même type que la valeur par défaut (tableau / objet)
* @param {*} parsed
* @returns {boolean}
*/
isValidShape(parsed) {
if(parsed === null || typeof parsed !== "object") return false
return Array.isArray(parsed) === Array.isArray(this.empty)
}
/**
* Met de côté un fichier illisible sous le nom <fichier>.corrupted-<timestamp>
* @param {string} filePath
*/
quarantine(filePath) {
const target = filePath + ".corrupted-" + Date.now()
try {
fs.renameSync(filePath, target)
} catch(e) {
// Renommage refusé (Windows / OneDrive) : copie à la place
fs.copyFileSync(filePath, target)
}
clog.warn(`Fichier corrompu de la base de données '${this.name}' sauvegardé sous : ${target}`)
} }
update() { update() {
try{ let rawdata
clog.log(`Mise à jour de la base de données '${this.name}'`) try {
let rawdata = fs.readFileSync(this.path); rawdata = fs.readFileSync(this.path, "utf-8");
this.data = JSON.parse(rawdata);
clog.log(`Base de donnée '${this.name}' chargée avec succès`)
} catch(e) { } catch(e) {
clog.error(`Erreur lors de la mise à jour de la base de données '${this.name}'`) // Erreur d'accès (verrou, permission…) : on garde les données en mémoire, sans rien écraser
clog.error(e) 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() { getData() {
+10 -2
View File
@@ -4,7 +4,7 @@ const version = JSON.parse(require('fs').readFileSync(root + path.sep + "package
const __glob = { const __glob = {
PACKAGEINFO: root + path.sep + "package.json", PACKAGEINFO: root + path.sep + "package.json",
ROOT: root + + path.sep, ROOT: root + path.sep,
SRC: root + path.sep + "src", SRC: root + path.sep + "src",
LOGS: root + path.sep + "logs", LOGS: root + path.sep + "logs",
DATA: root + path.sep + "data", DATA: root + path.sep + "data",
@@ -19,7 +19,15 @@ const __glob = {
VERSION: version, VERSION: version,
CHANGELOG_PATH: root + path.sep + "CHANGELOG.html", CHANGELOG_PATH: root + path.sep + "CHANGELOG.html",
COOKIES: root + path.sep + "data" + path.sep + "cookies.txt", 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} module.exports = {__glob}
+187
View File
@@ -0,0 +1,187 @@
/**
* Petits utilitaires sans dépendance pour limiter la charge envoyée aux services externes
* (YouTube, Spotify…) : limiteur de concurrence, cache TTL borné et déduplication des requêtes en cours.
*/
/**
* Crée un limiteur de concurrence : au plus `concurrency` fonctions s'exécutent en même temps,
* les suivantes attendent dans une file (ordre d'arrivée).
* @param {number} concurrency Nombre maximal d'exécutions simultanées (>= 1)
* @param {{minIntervalMs?: number}} [options] Délai minimal entre deux démarrages (lisse les rafales)
* @returns {{run: <T>(fn: () => Promise<T>|T) => Promise<T>, readonly active: number, readonly pending: number}}
*/
function createLimiter(concurrency, options = {}) {
const max = Math.max(1, Math.floor(Number(concurrency)) || 1);
const minIntervalMs = Math.max(0, Number(options.minIntervalMs) || 0);
const queue = [];
let active = 0;
let lastStart = 0;
let timer = null;
function next() {
if (timer || active >= max || queue.length === 0) return;
// Espacement minimal entre deux démarrages : on réessaie plus tard sans bloquer la boucle
const wait = lastStart + minIntervalMs - Date.now();
if (wait > 0) {
timer = setTimeout(() => {
timer = null;
next();
}, wait);
return;
}
const { fn, resolve, reject } = queue.shift();
active++;
lastStart = Date.now();
// Promise.resolve().then : une exception synchrone de fn devient un rejet
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
active--;
next();
});
// Plusieurs places libres : on démarre les suivants (sous réserve de l'espacement)
next();
}
function run(fn) {
return new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next();
});
}
return {
run,
get active() { return active; },
get pending() { return queue.length; },
};
}
/**
* Applique `fn` à chaque élément avec une concurrence bornée, en conservant l'ordre des résultats.
* Un élément en erreur donne `onError(error, item, index)` (undefined par défaut) au lieu de tout faire échouer.
* @template T, R
* @param {T[]} items
* @param {number} concurrency
* @param {(item: T, index: number) => Promise<R>} fn
* @param {(error: any, item: T, index: number) => R} [onError]
* @returns {Promise<R[]>}
*/
async function mapLimit(items, concurrency, fn, onError) {
const list = Array.isArray(items) ? items : [];
const limiter = createLimiter(concurrency);
return Promise.all(list.map((item, index) => limiter.run(async () => {
try {
return await fn(item, index);
} catch (error) {
return onError ? onError(error, item, index) : undefined;
}
})));
}
/**
* Déduplication des requêtes en cours : deux appels simultanés pour la même clé partagent la même promesse.
* @returns {{run: <T>(key: string, fn: () => Promise<T>) => Promise<T>, readonly size: number}}
*/
function createInflight() {
const pending = new Map();
function run(key, fn) {
if (pending.has(key)) return pending.get(key);
const promise = Promise.resolve()
.then(fn)
.finally(() => pending.delete(key));
pending.set(key, promise);
return promise;
}
return {
run,
get size() { return pending.size; },
};
}
/**
* Cache clé → valeur avec expiration (TTL) et taille maximale (les entrées les moins récemment lues sont évincées).
* `get` renvoie undefined si la clé est absente ou expirée : `null` peut donc être mis en cache volontairement.
* @param {{ttlMs: number, max?: number}} options
*/
function createCache({ ttlMs, max = 500 } = {}) {
const defaultTtl = Math.max(0, Number(ttlMs) || 0);
const maxSize = Math.max(1, Math.floor(Number(max)) || 1);
const entries = new Map(); // clé → {value, expiresAt}
const inflight = createInflight();
let hits = 0;
let misses = 0;
function get(key) {
const entry = entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
entries.delete(key);
return undefined;
}
// Réinsertion : l'ordre du Map sert d'ordre LRU
entries.delete(key);
entries.set(key, entry);
return entry.value;
}
function has(key) {
return get(key) !== undefined;
}
function set(key, value, ttl = defaultTtl) {
const duration = Number(ttl);
if (value === undefined || !(duration > 0)) return;
entries.delete(key);
entries.set(key, { value, expiresAt: Date.now() + duration });
prune();
}
function prune() {
if (entries.size <= maxSize) return;
const now = Date.now();
// D'abord les entrées expirées, puis les plus anciennes jusqu'à revenir sous la limite
for (const [key, entry] of entries) {
if (entry.expiresAt <= now) entries.delete(key);
}
while (entries.size > maxSize) {
entries.delete(entries.keys().next().value);
}
}
/**
* Renvoie la valeur en cache ou l'obtient via `fn` (un seul appel simultané par clé).
* @param {string} key
* @param {() => Promise<any>} fn
* @param {{ttlFor?: (value: any) => number}} [options] TTL selon le résultat (0 = ne pas mettre en cache).
* Par défaut : TTL du cache, sauf pour null/undefined qui ne sont pas mis en cache.
*/
async function wrap(key, fn, options = {}) {
const cached = get(key);
if (cached !== undefined) {
hits++;
return cached;
}
misses++;
return inflight.run(key, async () => {
const value = await fn();
const ttl = options.ttlFor ? options.ttlFor(value) : (value == null ? 0 : defaultTtl);
set(key, value, ttl);
return value;
});
}
return {
get,
has,
set,
wrap,
delete: (key) => entries.delete(key),
clear: () => entries.clear(),
get size() { return entries.size; },
stats: () => ({ size: entries.size, max: maxSize, hits, misses, inflight: inflight.size }),
};
}
module.exports = { createLimiter, mapLimit, createInflight, createCache };
+435
View File
@@ -0,0 +1,435 @@
/**
* Lecture des fichiers de logs écrits par loguix (backend/logs) pour l'interface d'administration.
* loguix n'expose ni le nom du fichier courant ni de flux : on relit donc les fichiers sur disque.
* - listLogs / readLog : liste et lecture paginée par plage d'octets (jamais de lecture complète synchrone)
* - startTail / stopTail : suivi temps réel du fichier courant via fs.watchFile (fs.watch n'est pas fiable sous Docker/OneDrive)
* - deleteLog : suppression d'un ancien fichier (jamais le fichier courant)
*/
const fs = require('fs');
const path = require('path');
const { LogType } = require('loguix');
const { __glob } = require('./GlobalVars');
const clog = new LogType("LogReader");
// Nom de fichier loguix : YYYY-MM-DD-HHh-MMm-SSs.log (ordre lexicographique = ordre chronologique)
const NAME_REGEX = /^[\w-]+\.log$/;
// [DD/MM - HHh-MMm-SSs] - [Type] - [INFO|WARN|ERROR] - [STEP] - message
const LINE_REGEX = /^\[(\d{2}\/\d{2} - \d{2}h-\d{2}m-\d{2}s)\] - (?:\[([^\]]+)\] - )?\[(INFO|WARN|ERROR|FATAL)\] - (?:\[(STEP)\] - )?(.*)$/;
const END_REGEX = /\[END OF LOGS\]/;
const DEFAULT_READ = 256 * 1024;
const MAX_READ = 1024 * 1024;
const BUFFER_SIZE = 500;
const TAIL_INTERVAL = 1000;
const TAIL_DEBOUNCE = 500;
let currentFile = null;
// État du suivi temps réel
const tail = {
active: false,
path: null,
size: 0,
remainder: "",
n: 0,
buffer: [],
pending: [],
timer: null,
onLines: null,
previous: null
};
/**
* Détermine le fichier courant : le plus récent du dossier au moment du premier appel
* (loguix.setup est appelé dans main.js avant tout require du serveur).
*/
function init() {
if (currentFile) return currentFile;
try {
const files = fs.readdirSync(__glob.LOGS).filter(f => NAME_REGEX.test(f)).sort();
currentFile = files.length > 0 ? files[files.length - 1] : null;
} catch (e) {
clog.error(`Impossible de lire le dossier des logs : ${e.message}`);
currentFile = null;
}
return currentFile;
}
function getCurrentName() {
return init();
}
function isValidName(name) {
return typeof name === "string" && name.length <= 100 && path.basename(name) === name && NAME_REGEX.test(name);
}
/**
* Valide le nom et vérifie qu'il existe réellement dans le dossier (protection contre le path traversal)
* @returns {Promise<string|null>} chemin absolu ou null
*/
async function resolveLog(name) {
if (!isValidName(name)) return null;
let files;
try {
files = await fs.promises.readdir(__glob.LOGS);
} catch (e) {
return null;
}
if (!files.includes(name)) return null;
const full = path.resolve(__glob.LOGS, name);
if (path.dirname(full) !== path.resolve(__glob.LOGS)) return null;
return full;
}
/**
* Découpe une ligne loguix. Une ligne qui ne correspond pas au format est la suite de la précédente
* (trace d'erreur sur plusieurs lignes) : elle hérite du type et du niveau pour que les filtres la gardent.
*/
function parseLine(raw, n, previous) {
const match = LINE_REGEX.exec(raw);
if (match) {
return {
n,
time: match[1],
type: match[2] ?? null,
level: match[3],
step: match[4] === "STEP",
message: match[5],
raw
};
}
// Ligne FATAL de loguix : "[date] - [FATAL] - ..." est déjà couverte par la regex, ici les lignes libres
return {
n,
time: null,
type: previous?.type ?? null,
level: previous?.level ?? null,
step: false,
message: raw,
raw,
continuation: true
};
}
function parseLines(rawLines, startN = 0) {
const out = [];
let previous = null;
let n = startN;
for (const raw of rawLines) {
if (raw === "") continue;
const line = parseLine(raw, n++, previous);
out.push(line);
previous = line;
}
return out;
}
/**
* Filtres optionnels : levels (["INFO","WARN",...]), type (exact, insensible à la casse), search (texte)
*/
function filterLines(lines, { levels, type, search } = {}) {
let result = lines;
if (Array.isArray(levels) && levels.length > 0) {
const set = new Set(levels.map(l => String(l).toUpperCase()));
result = result.filter(l => l.level && set.has(l.level));
}
if (typeof type === "string" && type.trim() !== "") {
const t = type.trim().toLowerCase();
result = result.filter(l => (l.type || "").toLowerCase() === t);
}
if (typeof search === "string" && search.trim() !== "") {
const s = search.trim().toLowerCase();
result = result.filter(l => l.raw.toLowerCase().includes(s));
}
return result;
}
/**
* @returns {Promise<Array<{name, size, modifiedAt, current}>>} trié du plus récent au plus ancien
*/
async function listLogs() {
const current = init();
let files;
try {
files = await fs.promises.readdir(__glob.LOGS);
} catch (e) {
clog.error(`Impossible de lister les logs : ${e.message}`);
return [];
}
const result = [];
for (const name of files.filter(f => NAME_REGEX.test(f))) {
try {
const stat = await fs.promises.stat(path.join(__glob.LOGS, name));
if (!stat.isFile()) continue;
result.push({ name, size: stat.size, modifiedAt: stat.mtime.toISOString(), current: name === current });
} catch (e) {
// Fichier supprimé entre readdir et stat : ignoré
}
}
return result.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
}
async function readRange(fullPath, offset, length) {
const handle = await fs.promises.open(fullPath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, offset);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
/**
* Lecture paginée d'un fichier de logs.
* Sans offset : lit la fin du fichier (limit derniers octets).
* @returns {Promise<{name, size, offset, nextOffset, eof, lines}|null>} null si le fichier n'existe pas
*/
async function readLog(name, { offset, limit, levels, type, search } = {}) {
const fullPath = await resolveLog(name);
if (!fullPath) return null;
const stat = await fs.promises.stat(fullPath);
const size = stat.size;
let len = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : DEFAULT_READ;
len = Math.min(len, MAX_READ);
let start;
if (offset === undefined || offset === null) {
start = Math.max(0, size - len);
} else {
start = Math.max(0, Math.min(Math.floor(Number(offset)) || 0, size));
}
const end = Math.min(size, start + len);
let text = "";
let lineStart = start === 0;
if (end > start) {
// On lit un octet de plus avant le début pour savoir si l'on tombe pile sur un début de ligne
const readFrom = start > 0 ? start - 1 : 0;
const buf = await readRange(fullPath, readFrom, end - readFrom);
if (start > 0) {
lineStart = buf[0] === 0x0a;
text = buf.subarray(1).toString("utf-8");
} else {
text = buf.toString("utf-8");
}
}
let rawLines = text.split(/\r?\n/);
let consumed = end;
// Première ligne partielle si on ne commence pas au début d'une ligne
if (!lineStart && rawLines.length > 0) {
rawLines.shift();
}
// Dernière ligne incomplète : elle sera relue au prochain appel
if (end < size && rawLines.length > 1) {
const last = rawLines.pop();
consumed = end - Buffer.byteLength(last, "utf-8");
}
const lines = filterLines(parseLines(rawLines), { levels, type, search });
return {
name,
size,
offset: start,
nextOffset: consumed,
eof: consumed >= size,
lines
};
}
/**
* Le journal courant est-il interrompu ? (loguix ferme le flux après une exception non capturée)
*/
async function isInterrupted() {
const current = init();
if (!current) return false;
try {
const fullPath = path.join(__glob.LOGS, current);
const stat = await fs.promises.stat(fullPath);
const len = Math.min(stat.size, 512);
if (len === 0) return false;
const text = (await readRange(fullPath, stat.size - len, len)).toString("utf-8");
const lines = text.split(/\r?\n/).filter(l => l !== "");
return lines.length > 0 && END_REGEX.test(lines[lines.length - 1]);
} catch (e) {
return false;
}
}
/**
* Supprime un ancien fichier de logs.
* @returns {Promise<"deleted"|"not_found"|"protected">}
*/
async function deleteLog(name) {
if (isValidName(name) && name === init()) return "protected";
const fullPath = await resolveLog(name);
if (!fullPath) return "not_found";
await fs.promises.unlink(fullPath);
clog.warn(`Fichier de logs supprimé : ${name}`);
return "deleted";
}
// TEMPS RÉEL
function pushBuffer(lines) {
tail.buffer.push(...lines);
if (tail.buffer.length > BUFFER_SIZE) {
tail.buffer.splice(0, tail.buffer.length - BUFFER_SIZE);
}
}
function flushPending() {
tail.timer = null;
if (tail.pending.length === 0) return;
const lines = tail.pending;
tail.pending = [];
try {
if (tail.onLines) tail.onLines({ name: currentFile, lines });
} catch (e) {
clog.error(`Erreur lors de l'envoi des nouvelles lignes de logs : ${e.message}`);
}
}
/**
* Lit ce qui a été ajouté au fichier depuis la dernière lecture
*/
async function readNewData() {
if (!tail.active || !tail.path) return;
let stat;
try {
stat = await fs.promises.stat(tail.path);
} catch (e) {
return;
}
// Fichier tronqué ou recréé : on repart du début
if (stat.size < tail.size) {
tail.size = 0;
tail.remainder = "";
}
if (stat.size === tail.size) return;
const from = tail.size;
const length = Math.min(stat.size - from, MAX_READ);
let chunk;
try {
chunk = (await readRange(tail.path, from, length)).toString("utf-8");
} catch (e) {
clog.error(`Lecture du fichier de logs courant impossible : ${e.message}`);
return;
}
tail.size = from + length;
const text = tail.remainder + chunk;
const rawLines = text.split(/\r?\n/);
tail.remainder = rawLines.pop() ?? "";
const lines = [];
for (const raw of rawLines) {
if (raw === "") continue;
const line = parseLine(raw, tail.n++, tail.previous);
tail.previous = line;
lines.push(line);
}
if (lines.length === 0) return;
pushBuffer(lines);
tail.pending.push(...lines);
if (!tail.timer) {
tail.timer = setTimeout(flushPending, TAIL_DEBOUNCE);
tail.timer.unref?.();
}
// Plus de 1 Mio ajouté d'un coup : on lit la suite au prochain passage
if (tail.size < stat.size) setImmediate(readNewData);
}
/**
* Remplit le tampon avec la fin du fichier courant (au démarrage du suivi)
*/
async function primeBuffer() {
tail.buffer = [];
tail.n = 0;
tail.previous = null;
tail.remainder = "";
try {
const stat = await fs.promises.stat(tail.path);
const len = Math.min(stat.size, DEFAULT_READ);
const start = stat.size - len;
let text = len > 0 ? (await readRange(tail.path, start, len)).toString("utf-8") : "";
const rawLines = text.split(/\r?\n/);
if (start > 0) rawLines.shift();
tail.remainder = rawLines.pop() ?? "";
const lines = parseLines(rawLines).slice(-BUFFER_SIZE);
// Renumérotation continue pour les lignes suivantes
lines.forEach((l, i) => { l.n = i; });
tail.n = lines.length;
tail.previous = lines[lines.length - 1] || null;
tail.buffer = lines;
tail.size = stat.size;
} catch (e) {
tail.size = 0;
}
}
function onWatch(curr, prev) {
if (curr.size !== prev.size || curr.mtimeMs !== prev.mtimeMs) {
readNewData().catch(e => clog.error(`Suivi des logs : ${e.message}`));
}
}
/**
* Démarre le suivi du fichier courant.
* @param {function({name, lines})} onLines appelé par lots (debounce 500 ms)
*/
async function startTail(onLines) {
tail.onLines = onLines;
if (tail.active) return;
const current = init();
if (!current) return;
tail.active = true;
tail.path = path.join(__glob.LOGS, current);
await primeBuffer();
fs.watchFile(tail.path, { interval: TAIL_INTERVAL, persistent: false }, onWatch);
clog.log(`Suivi temps réel du fichier de logs ${current} démarré`);
}
function stopTail() {
if (!tail.active) return;
fs.unwatchFile(tail.path, onWatch);
if (tail.timer) clearTimeout(tail.timer);
tail.timer = null;
tail.pending = [];
// Plus aucun abonné : le rappel ne doit pas survivre à l'arrêt
tail.onLines = null;
tail.active = false;
clog.log("Suivi temps réel des logs arrêté");
}
function isTailing() {
return tail.active;
}
/**
* Dernières lignes du tampon temps réel
*/
function getBuffer(count = 300) {
return tail.buffer.slice(-count);
}
module.exports = {
init,
getCurrentName,
isValidName,
listLogs,
readLog,
deleteLog,
isInterrupted,
parseLine,
startTail,
stopTail,
isTailing,
getBuffer
};
+91 -6
View File
@@ -1,11 +1,96 @@
const pm2 = require("pm2")
const { LogType } = require("loguix") const { LogType } = require("loguix")
const clog = new LogType("Maintenance") const clog = new LogType("Maintenance")
function restart(reason) { // Délai entre l'annonce et l'arrêt : laisse le temps de répondre aux clients et de quitter les salons vocaux
clog.warn("Redémarrage du serveur Subsonics") const RESTART_DELAY = 1000
clog.warn(`Reason: ${reason}`)
pm2.restart("Subsonics") let restarting = false
function isRestarting() {
return restarting
} }
module.exports = {restart} /**
* Sauvegarde ce qui peut l'être avant l'arrêt (métriques en mémoire, salons vocaux)
*/
function prepareShutdown(reason) {
try {
require("./SafeMetric").flush()
} catch (e) {
clog.error(`Sauvegarde des métriques impossible avant le redémarrage : ${e.message}`)
}
try {
const players = require("../player/Player")
for (const player of players.getAllPlayers()) {
if (player.isConnected?.()) Promise.resolve(player.leave("restart")).catch(() => {})
}
} catch (e) {
clog.error(`Impossible de quitter les salons vocaux avant le redémarrage : ${e.message}`)
}
}
function exitProcess() {
clog.warn("Arrêt du processus, il sera relancé par le superviseur (Docker restart: unless-stopped)")
// Laisse le temps à loguix d'écrire les dernières lignes
setTimeout(() => process.exit(0), 300)
}
/**
* Redémarre le bot.
* - sous pm2 (process.env.pm_id défini) : pm2.restart du processus courant
* - sinon : process.exit(0), Docker relance le conteneur (restart: unless-stopped).
* Attention : nodemon (npm start en local) ne relance pas après un arrêt propre.
* @param {string} reason
* @returns {boolean} false si un redémarrage est déjà en cours
*/
function restart(reason) {
if (restarting) {
clog.warn("Redémarrage déjà en cours, demande ignorée")
return false
}
restarting = true
const at = new Date().toISOString()
clog.warn("Redémarrage du serveur Subsonics")
clog.warn(`Raison : ${reason}`)
// Prévient les clients web (relayé par le module Admin vers "/MAINTENANCE")
try {
process.emit("MAINTENANCE", { type: "restart", reason: reason ? String(reason) : null, at })
} catch (e) {
clog.error(`Notification de maintenance impossible : ${e.message}`)
}
setTimeout(() => {
prepareShutdown(reason)
if (process.env.pm_id !== undefined) {
let pm2
try {
pm2 = require("pm2")
} catch (e) {
clog.error(`pm2 indisponible : ${e.message}`)
return exitProcess()
}
pm2.connect((err) => {
if (err) {
clog.error(`Connexion à pm2 impossible : ${err.message || err}`)
return exitProcess()
}
const target = process.env.name || process.env.pm_id
clog.warn(`Redémarrage via pm2 du processus : ${target}`)
pm2.restart(target, (err2) => {
if (err2) {
clog.error(`Redémarrage pm2 impossible : ${err2.message || err2}`)
pm2.disconnect()
return exitProcess()
}
pm2.disconnect()
})
})
} else {
exitProcess()
}
}, RESTART_DELAY)
return true
}
module.exports = {restart, isRestarting}
+263
View File
@@ -0,0 +1,263 @@
/**
* Métriques sécurisées : remplace webmetrik.Metric en gardant le même format de fichier
* ([{name, description, value}]). Toutes les valeurs sont des nombres finis >= 0,
* aucune méthode ne lève d'exception, et l'écriture sur disque est atomique et différée.
*/
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const { LogType } = require('loguix');
const { __glob } = require('./GlobalVars');
const mlog = new LogType("Metrics");
// Un morceau de plus de 24 h est forcément une valeur aberrante
const MAX_SONG_SECONDS = 24 * 60 * 60;
const SAVE_DELAY = 2000;
const cache = new Map();
let file = __glob.METRIC_FILE;
let saveTimer = null;
let ready = false;
/**
* Convertit une valeur en nombre fini. Les chaînes ne sont acceptées que si elles sont strictement numériques :
* une concaténation comme "117290116.088163116..." est rejetée.
*/
function toSafeNumber(value, fallback = 0) {
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
if (typeof value === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(value)) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
return fallback;
}
/**
* Durée en secondes valide (]0 ; 24 h]), sinon 0 (live, inconnue ou aberrante)
*/
function toSafeDuration(value) {
const n = toSafeNumber(value, 0);
return n > 0 && n <= MAX_SONG_SECONDS ? n : 0;
}
function archive(suffix, content) {
const target = `${file}.${suffix}-${Date.now()}.json`;
try {
fs.writeFileSync(target, content);
return target;
} catch (e) {
mlog.error(`Impossible d'archiver ${target} : ${e.message}`);
return null;
}
}
/**
* Charge le fichier une seule fois au démarrage et nettoie les valeurs corrompues (archivées à côté).
* @param {{file?: string, overrides?: Object<string, number>}} options
*/
function init({ file: metricFile = __glob.METRIC_FILE, overrides = {} } = {}) {
if (ready) return;
file = metricFile;
let raw = [];
if (fs.existsSync(file)) {
const text = fs.readFileSync(file, "utf-8");
try {
raw = JSON.parse(text);
} catch (e) {
const saved = archive("unreadable", text);
mlog.error(`metrics.json illisible (${e.message}), copie conservée dans ${saved}`);
raw = [];
}
}
if (!Array.isArray(raw)) raw = [];
const corrupted = [];
for (const entry of raw) {
if (!entry || typeof entry.name !== "string" || !entry.name) continue;
let value = entry.name in overrides ? overrides[entry.name] : toSafeNumber(entry.value, NaN);
if (!Number.isFinite(value) || value < 0) {
corrupted.push(entry);
value = 0;
}
// Doublons : on additionne plutôt que de perdre une des deux valeurs
const previous = cache.get(entry.name);
cache.set(entry.name, {
name: entry.name,
description: typeof entry.description === "string" ? entry.description : entry.name,
value: (previous?.value ?? 0) + value
});
}
if (corrupted.length > 0) {
const saved = archive("corrupted", JSON.stringify(corrupted, null, 2));
mlog.warn(`${corrupted.length} métrique(s) corrompue(s) remise(s) à 0 : ${corrupted.map(c => c.name).join(", ")} (originaux dans ${saved})`);
}
ready = true;
flush();
process.once("beforeExit", flush);
process.once("exit", flush);
}
function ensure(name, description) {
let metric = cache.get(name);
if (!metric) {
metric = { name, description: description || name, value: 0 };
cache.set(name, metric);
} else if (description && metric.description !== description) {
metric.description = description;
}
return metric;
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
flush();
}, SAVE_DELAY);
saveTimer.unref?.();
}
/**
* Écrit immédiatement le cache sur disque (fichier temporaire puis renommage)
*/
function flush() {
if (!ready) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const json = JSON.stringify([...cache.values()], (key, value) => (typeof value === "number" && !Number.isFinite(value) ? 0 : value), 2);
const tmp = file + ".tmp";
try {
fs.writeFileSync(tmp, json);
fs.renameSync(tmp, file);
} catch (e) {
// Le renommage peut échouer sous Windows si le fichier est verrouillé (OneDrive, antivirus)
try {
fs.writeFileSync(file, json);
} catch (e2) {
mlog.error(`Sauvegarde des métriques impossible : ${e2.message}`);
}
}
}
function get(name) {
return toSafeNumber(cache.get(name)?.value, 0);
}
/**
* Ajoute amount (fini et >= 0) à la métrique. Renvoie la nouvelle valeur, ne lève jamais.
*/
function increment(name, amount = 1, description) {
try {
const delta = toSafeNumber(amount, NaN);
if (!Number.isFinite(delta) || delta < 0) {
mlog.warn(`Incrément invalide ignoré pour ${name} : ${JSON.stringify(amount)}`);
return get(name);
}
const metric = ensure(name, description);
const next = toSafeNumber(metric.value) + delta;
metric.value = Number.isFinite(next) ? next : 0;
scheduleSave();
return metric.value;
} catch (e) {
mlog.error(`Erreur sur la métrique ${name} : ${e.message}`);
return 0;
}
}
/**
* Ajoute une durée en secondes. Les durées invalides (NaN, chaîne, négatif, > 24 h) sont ignorées.
*/
function addDuration(name, seconds, description) {
const safe = toSafeDuration(seconds);
if (safe === 0) {
if (seconds !== 0 && seconds !== null && seconds !== undefined) {
mlog.warn(`Durée invalide ignorée pour ${name} : ${JSON.stringify(seconds)}`);
}
return get(name);
}
return increment(name, safe, description);
}
function set(name, value, description) {
const v = toSafeNumber(value, NaN);
if (!Number.isFinite(v) || v < 0) return false;
ensure(name, description).value = v;
scheduleSave();
return true;
}
function getAll() {
return [...cache.values()].map(m => ({ name: m.name, description: m.description, value: toSafeNumber(m.value) }));
}
function getGuildMetrics(guildId) {
const suffix = "_" + guildId;
return getAll().filter(m => m.name.endsWith(suffix));
}
function remove(name) {
const removed = cache.delete(name);
if (removed) scheduleSave();
return removed;
}
/**
* Remet à 0 les valeurs non finies encore présentes en mémoire. Renvoie le nombre de métriques réparées.
*/
function repair() {
let repaired = 0;
for (const metric of cache.values()) {
if (typeof metric.value !== "number" || !Number.isFinite(metric.value) || metric.value < 0) {
metric.value = 0;
repaired++;
}
}
if (repaired > 0) flush();
return repaired;
}
/**
* Remplace webmetrik.publishMetrics : GET /metrics?privatekey=KEY
*/
function publish(port, key, host = "0.0.0.0") {
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/metrics") {
res.writeHead(404, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Not found" }));
}
const given = Buffer.from(String(url.searchParams.get("privatekey") ?? ""));
const expected = Buffer.from(String(key));
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
res.writeHead(403, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "Invalid private key" }));
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(getAll()));
} catch (e) {
mlog.error(`Serveur de métriques : ${e.message}`);
try {
res.writeHead(500);
res.end("{}");
} catch (e2) {}
}
});
server.on("error", (e) => mlog.error(`Serveur de métriques (port ${port}) : ${e.message}`));
server.listen(port, host);
return server;
}
module.exports = {
init, increment, addDuration, set, get, getAll, getGuildMetrics, remove, repair, flush, publish,
toSafeNumber, toSafeDuration, MAX_SONG_SECONDS
};
+54 -38
View File
@@ -1,46 +1,62 @@
function getReadableDuration(duration) { /**
var max = "" * Conversion de durées. Toute entrée invalide (NaN, Infinity, undefined, chaîne non numérique, négatif)
duration *= 1000; * est ramenée à 0 : on n'affiche jamais "NaN". Même format que frontend/src/utils/TimeConverter.js.
*/
const maxhours = Math.floor(duration / 3600000); /**
* Convertit une valeur en nombre de secondes fini et >= 0 (sinon 0).
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60); * Les chaînes ne sont acceptées que si elles sont strictement numériques ("116.088163" oui, "117290116.0881631..." non).
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60); */
function toSeconds(duration) {
let value = NaN
if (maxsec < 10) { if (typeof duration === "number") {
maxsec = `0${maxsec}`; 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)
if(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + "h" + maxmin + "m" + maxsec
} else {
max = maxmin + "m" + maxsec + "s"
}
return max
} }
function getSecondsDuration(duration) { function pad(number) {
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss return String(number).padStart(2, "0")
var durationArray = duration.split(":"); }
var seconds = 0;
if(durationArray.length == 3) { /**
seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]); * @param {number} duration Durée en secondes
} else if(durationArray.length == 2) { * @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide)
seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]); */
} else { function getReadableDuration(duration) {
seconds = parseInt(durationArray[0]); const total = toSeconds(duration)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
if (hours > 0) {
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
} }
return seconds; return `${minutes}m${pad(seconds)}s`
}
/**
* @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss
* @returns {number} Secondes (0 si le format est invalide)
*/
function getSecondsDuration(duration) {
if (typeof duration === "number") return toSeconds(duration)
if (typeof duration !== "string" || duration.trim() === "") return 0
const parts = duration.trim().split(":").map(part => parseInt(part, 10))
if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0
// On lit de droite à gauche : secondes, minutes, heures
let seconds = 0
let multiplier = 1
for (let i = parts.length - 1; i >= 0; i--) {
seconds += parts[i] * multiplier
multiplier *= 60
}
return seconds
} }
module.exports = {getReadableDuration, getSecondsDuration} module.exports = {getReadableDuration, getSecondsDuration}
+282 -24
View File
@@ -2,11 +2,14 @@ const { LogType } = require('loguix');
const clog = new LogType("WARP-Proxy"); const clog = new LogType("WARP-Proxy");
const net = require('net'); const net = require('net');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const { __glob } = require('./GlobalVars'); const { __glob } = require('./GlobalVars');
const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace"; const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace";
const CHECK_INTERVAL = 60 * 1000; const CHECK_INTERVAL = 60 * 1000;
const CHECK_TIMEOUT = 8 * 1000; const CHECK_TIMEOUT = 8 * 1000;
const URL_MAX_LENGTH = 500;
const ALLOWED_PROTOCOLS = ["http:", "https:", "socks5:", "socks5h:"];
const state = { const state = {
url: null, url: null,
@@ -20,20 +23,42 @@ const state = {
let started = false; let started = false;
let pendingCheck = null; 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. * 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 } * Format de proxy.json : { "enabled": true, "url": "http://127.0.0.1:1080", "fallbackDirect": true }
*/ */
function loadConfig() { function loadConfig() {
let fileConfig = {}; const fileConfig = readFileConfig();
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 url = process.env.WARP_PROXY || (fileConfig.enabled !== false ? fileConfig.url : null) || null; const url = process.env.WARP_PROXY || (fileConfig.enabled !== false ? fileConfig.url : null) || null;
const fallbackDirect = process.env.WARP_FALLBACK_DIRECT !== undefined const fallbackDirect = process.env.WARP_FALLBACK_DIRECT !== undefined
@@ -73,7 +98,7 @@ async function fetchTrace(proxyUrl) {
*/ */
function tcpProbe(host, port) { function tcpProbe(host, port) {
return new Promise((resolve, reject) => { 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.setTimeout(CHECK_TIMEOUT);
socket.once('connect', () => { socket.destroy(); resolve(); }); socket.once('connect', () => { socket.destroy(); resolve(); });
socket.once('timeout', () => { socket.destroy(); reject(new Error("Délai de connexion dépassé")); }); 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() { async function runCheck() {
const gen = generation;
const url = state.url;
const wasHealthy = state.healthy; const wasHealthy = state.healthy;
const firstCheck = state.lastCheck === null; const firstCheck = state.lastCheck === null;
const result = { healthy: false, warp: state.warp, ip: state.ip, lastError: null };
try { try {
const parsed = new URL(state.url); const parsed = new URL(url);
if (parsed.protocol.startsWith("http")) { if (parsed.protocol.startsWith("http")) {
const trace = await fetchTrace(state.url); const trace = await fetchTrace(url);
state.warp = trace.warp; result.warp = trace.warp;
state.ip = trace.ip; result.ip = trace.ip;
if (trace.warp !== "on" && trace.warp !== "plus") { if (trace.warp !== "on" && trace.warp !== "plus") {
throw new Error(`Proxy joignable mais le trafic ne passe pas par WARP (warp=${trace.warp})`); throw new Error(`Proxy joignable mais le trafic ne passe pas par WARP (warp=${trace.warp})`);
} }
} else { } else {
await tcpProbe(parsed.hostname, parsed.port); await tcpProbe(parsed.hostname, parsed.port);
} }
state.healthy = true; result.healthy = true;
state.lastError = null;
} catch (e) { } catch (e) {
state.healthy = false; result.healthy = false;
state.lastError = e.cause?.message || e.message; 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(); state.lastCheck = Date.now();
if (state.healthy && (!wasHealthy || firstCheck)) { 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)) { if (!state.healthy && (wasHealthy || firstCheck)) {
const fallback = state.fallbackDirect ? "connexion directe utilisée en attendant" : "aucun repli configuré"; 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; 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() { function checkHealth() {
if (!state.url) return Promise.resolve(false); if (!state.url) return Promise.resolve(false);
if (!pendingCheck) { 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; 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() { async function start() {
if (started) return; if (started) return;
started = true; started = true;
@@ -138,7 +195,154 @@ async function start() {
} }
await checkHealth(); 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(); checkHealth();
} }
function getStatus() { /**
return { ...state }; * Masque les identifiants éventuels de l'URL du proxy (http://user:pass@hote:port -> http://***@hote:port)
*/
function maskUrl(url) {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
return `${parsed.protocol}//***@${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
}
return url;
} catch (e) {
// URL illisible : on ne renvoie rien qui pourrait contenir un secret
return "***";
}
} }
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus }; function hasCredentials(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return !!(parsed.username || parsed.password);
} catch (e) {
return false;
}
}
/**
* État du proxy pour l'interface d'administration (jamais l'URL brute)
*/
function getStatus() {
// Lu à chaque appel (vue d'ensemble toutes les 10 s) : sans journaliser un fichier illisible à chaque fois
const fileConfig = readFileConfig({ quiet: true });
const locks = getLocks();
const fileUrl = typeof fileConfig.url === "string" && fileConfig.url ? fileConfig.url : null;
return {
configured: !!state.url,
url: maskUrl(state.url),
fallbackDirect: state.fallbackDirect,
healthy: state.healthy,
warp: state.warp ?? null,
ip: state.ip ?? null,
lastCheck: state.lastCheck ? new Date(state.lastCheck).toISOString() : null,
lastError: state.lastError ?? null,
routes: getRoutes().map(route => (route ? "warp" : "direct")),
checkIntervalMs: CHECK_INTERVAL,
// Édition depuis l'administration
source: process.env.WARP_PROXY ? "env" : (fileUrl ? "file" : "none"),
locked: locks,
file: {
enabled: fileConfig.enabled !== false && !!fileUrl,
url: maskUrl(fileUrl),
hasCredentials: hasCredentials(fileUrl),
fallbackDirect: fileConfig.fallbackDirect !== false
}
};
}
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus, maskUrl, setConfig, validateConfigPatch, reload };
+206
View File
@@ -0,0 +1,206 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/check.js
* Description: Vérifications en direct : validité des cookies et test de lecture (extraction sans téléchargement)
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Check");
const warp = require('../utils/WarpProxy');
const cookies = require('./cookies');
const settings = require('./settings');
const ytdlp = require('./ytdlp');
const errors = require('./errors');
const CHECK_TIMEOUT = 60 * 1000;
// Même sélection de format que la lecture
const FORMAT = "bestaudio[acodec=opus]/bestaudio/best";
let pendingCookieCheck = null;
// Import concerné par la vérification en cours (un nouvel import ne doit pas recevoir le résultat de l'ancien)
let pendingCookieKey = null;
let pendingTest = null;
function busyError() {
const err = new Error("Un test de lecture est déjà en cours");
err.code = "BUSY";
return err;
}
/**
* Lignes utiles de stderr (erreurs / avertissements), nettoyées, pour l'administration
*/
function stderrTail(stderr, max = 8) {
const lines = String(stderr || '').split(/\r?\n/)
.map(l => l.trim())
.filter(l => /^(ERROR|WARNING):/i.test(l) || /HTTP Error|Got error/i.test(l));
return errors.sanitize(lines.slice(-max).join('\n'));
}
/**
* Route préférée pour utiliser le compte : la directe si elle fait partie des routes, sinon le proxy
*/
function cookieRoute() {
const routes = warp.getRoutes();
if (routes.includes(null)) return null;
return routes[0] ?? null;
}
/**
* Vérifie en direct que yt-dlp reconnaît le compte (une requête, sur une copie des cookies).
* @returns {Promise<{ok: boolean, loggedIn: boolean, premium: boolean, rotated: boolean, cls: string|null, message: string, route: "warp"|"direct", ms: number, status: object}>}
*/
function checkCookies() {
const key = cookies.getStatus().importedAt;
if (pendingCookieCheck && pendingCookieKey === key) return pendingCookieCheck;
pendingCookieKey = key;
const current = (async () => {
const started = Date.now();
const copy = await cookies.acquireCopy();
if (!copy) {
return { ok: false, loggedIn: false, premium: false, rotated: false, cls: null, message: "Aucun cookie enregistré", route: "direct", ms: 0, status: cookies.getStatus() };
}
const proxy = cookieRoute();
const route = proxy ? "warp" : "direct";
let result;
try {
const args = [
...ytdlp.baseArgs({ proxy, cookiesPath: copy.path, playerClients: settings.get().playerClients }),
'-v', '--skip-download', '--print', 'id',
'--', settings.get().testUrl
];
result = await ytdlp.run(args, { timeoutMs: CHECK_TIMEOUT });
} finally {
copy.release();
}
const stderr = result.stderr || "";
const loggedIn = /Found YouTube account cookies/i.test(stderr);
const premium = /Detected YouTube Premium/i.test(stderr);
const rotated = /cookies are no longer valid/i.test(stderr);
const ok = result.code === 0 && loggedIn && !rotated;
let cls = null;
let message;
if (rotated) {
cls = "COOKIES_ROTATED";
message = "YouTube a invalidé ces cookies (rotation de sécurité ou déconnexion) : importez-en de nouveaux";
} else if (result.code !== 0) {
const classified = errors.classify(stderr);
cls = classified.cls;
message = classified.message;
} else if (!loggedIn) {
message = "yt-dlp ne reconnaît aucun compte dans ces cookies";
} else {
message = premium ? "Compte reconnu (YouTube Premium)" : "Compte reconnu";
}
const lastCheck = { at: new Date().toISOString(), ok, loggedIn: loggedIn && !rotated, premium, message, cls, route };
// Cookies remplacés ou supprimés pendant la vérification : le résultat concerne les anciens, on ne l'enregistre pas
if (cookies.getStatus().importedAt !== key) {
clog.log("Vérification des cookies ignorée : les cookies ont changé entre-temps");
return { ok, loggedIn: lastCheck.loggedIn, premium, rotated, cls, message, route, ms: Date.now() - started, stale: true, status: cookies.getStatus() };
}
cookies.setLastCheck(lastCheck);
if (rotated) cookies.markInvalid("Cookies refusés par YouTube lors de la vérification (rotation)");
const log = `Vérification des cookies (route ${route === "warp" ? "WARP" : "directe"}) : ${message}${cls ? ` [${cls}]` : ''}`;
if (ok) clog.log(log); else clog.warn(log);
return { ok, loggedIn: lastCheck.loggedIn, premium, rotated, cls, message, route, ms: Date.now() - started, status: cookies.getStatus() };
})().finally(() => {
if (pendingCookieCheck === current) {
pendingCookieCheck = null;
pendingCookieKey = null;
}
});
pendingCookieCheck = current;
return current;
}
// Hôtes YouTube uniquement : une autre URL partirait dans l'extracteur générique de yt-dlp (accès au réseau interne)
const YOUTUBE_HOSTS = /^(?:www\.|m\.|music\.)?youtube\.com$|^youtu\.be$/i;
function isSafeUrl(url) {
if (typeof url !== 'string' || !/^https?:\/\/[^\s]+$/i.test(url)) return false;
try {
return YOUTUBE_HOSTS.test(new URL(url).hostname);
} catch (e) {
return false;
}
}
/**
* Test de lecture : extraction complète (même format que la lecture) sans téléchargement.
* @param {{url?: string, route?: "auto"|"warp"|"direct", identity?: "auto"|"anon"|"cookies", clients?: string}} [options]
* @returns {Promise<{ok: boolean, route: string, identity: string, clients: string, cls: string|null, message: string, format: object|null, ms: number, version: string|null}>}
*/
async function testPlayback({ url, route = "auto", identity = "auto", clients } = {}) {
if (pendingTest) throw busyError();
const s = settings.get();
const target = url || s.testUrl;
const chosenClients = clients === undefined || clients === null ? s.playerClients : settings.normalizeClients(clients);
const fail = (message) => ({ ok: false, route, identity, clients: chosenClients, cls: null, message, format: null, ms: 0, version: ytdlp.getCachedVersion() });
if (!isSafeUrl(target)) return fail("URL invalide : lien YouTube (youtube.com ou youtu.be) attendu");
// Route
const routes = warp.getRoutes();
let proxy;
if (route === "warp") {
proxy = routes.find(r => r) ?? undefined;
if (!proxy) return fail("Proxy WARP non configuré ou indisponible");
} else if (route === "direct") {
proxy = null;
} else {
proxy = routes[0] ?? null;
}
const routeName = proxy ? "warp" : "direct";
// Identité
let useCookies;
if (identity === "cookies") {
if (!cookies.getStatus().present) return fail("Aucun cookie enregistré");
useCookies = true;
} else if (identity === "anon") {
useCookies = false;
} else {
useCookies = s.cookiesMode === "always" && cookies.isValid();
}
pendingTest = (async () => {
const started = Date.now();
const copy = useCookies ? await cookies.acquireCopy() : null;
let result;
try {
const args = [
...ytdlp.baseArgs({ proxy, cookiesPath: copy?.path, playerClients: chosenClients }),
'-f', FORMAT,
'--print', '%(format_id)s|%(ext)s|%(acodec)s|%(abr)s',
'--', target
];
result = await ytdlp.run(args, { timeoutMs: CHECK_TIMEOUT });
} finally {
copy?.release();
}
const ms = Date.now() - started;
const version = await ytdlp.getVersion().catch(() => null);
const base = { route: routeName, identity: useCookies ? "cookies" : "anon", clients: chosenClients, ms, version };
const line = result.stdout.trim().split(/\r?\n/).pop() || "";
const classified = errors.classify(result.stderr);
if (classified.cookiesRotated) cookies.markInvalid("Cookies refusés par YouTube lors d'un test de lecture (rotation)");
if (result.code === 0 && line.includes('|')) {
const [formatId, ext, acodec, abr] = line.split('|');
clog.log(`Test de lecture réussi (route ${routeName}, ${base.identity}) : format ${formatId} (${ext}, ${acodec}) en ${ms} ms`);
return { ok: true, ...base, cls: null, message: "Extraction réussie", format: { id: formatId, ext, acodec, abr: abr === "NA" ? null : Number(abr) || abr }, details: stderrTail(result.stderr, 4) };
}
clog.warn(`Test de lecture échoué (route ${routeName}, ${base.identity}) : ${classified.cls} - ${classified.message}`);
return { ok: false, ...base, cls: classified.cls, message: classified.message, format: null, details: stderrTail(result.stderr) };
})().finally(() => {
pendingTest = null;
});
return pendingTest;
}
module.exports = { checkCookies, testPlayback, FORMAT };
+563
View File
@@ -0,0 +1,563 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/cookies.js
* Description: Cookies YouTube : lecture (Netscape / JSON), validation, fichier maître et copies temporaires pour yt-dlp
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Cookies");
const fs = require('fs');
const os = require('os');
const path = require('path');
const { __glob } = require('../utils/GlobalVars');
const { writeFileAtomic, readJson, writeJson, emitUpdate } = require('./util');
const NETSCAPE_HEADER = "# Netscape HTTP Cookie File";
const HTTPONLY_PREFIX = "#HttpOnly_";
const COPY_PREFIX = "chopin-ytc-";
// Copie temporaire oubliée (crash) : supprimée au démarrage au-delà de cet âge
const STALE_COPY_AGE = 60 * 60 * 1000;
// Règle de yt-dlp (_has_auth_cookies) : LOGIN_INFO + l'un des SAPISID, pour .youtube.com
const LOGIN_COOKIE = "LOGIN_INFO";
const SAPISID_COOKIES = ["SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID"];
// Cookies de session Google attendus pour un compte connecté
const AUTH_COOKIES = ["LOGIN_INFO", "SID", "HSID", "SSID", "APISID", "SAPISID",
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PAPISID", "__Secure-3PAPISID",
"__Secure-1PSIDTS", "__Secure-3PSIDTS"];
const ALLOWED_DOMAINS = ["youtube.com", "google.com"];
const SOURCES = /^(paste|file|legacy|browser:[a-z]+)$/;
/**
* Erreur typée renvoyée à l'administration (message en français)
*/
function cookieError(message) {
const err = new Error(message);
err.code = "INVALID_COOKIES";
return err;
}
function bareDomain(domain) {
return String(domain || '').trim().toLowerCase().replace(/^\./, '');
}
function isAllowedDomain(domain) {
const d = bareDomain(domain);
return ALLOWED_DOMAINS.some(allowed => d === allowed || d.endsWith("." + allowed));
}
function isYoutubeDomain(domain) {
const d = bareDomain(domain);
return d === "youtube.com" || d.endsWith(".youtube.com");
}
/**
* Le cookie est envoyé à https://www.youtube.com (portée utilisée par yt-dlp pour la règle de connexion)
*/
function appliesToYoutube(cookie) {
const d = bareDomain(cookie.domain);
if (d === "www.youtube.com") return true;
return d === "youtube.com" && cookie.includeSubdomains;
}
/**
* Expiration en secondes (0 = cookie de session). Accepte secondes (même décimales), millisecondes ou date ISO.
*/
function toExpires(value) {
if (value === undefined || value === null || value === '' || value === false) return 0;
let n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) {
const parsed = Date.parse(String(value));
if (!Number.isFinite(parsed)) return 0;
n = parsed / 1000;
}
// Valeur en millisecondes (certains exports)
if (n > 1e11) n = n / 1000;
return n > 0 ? Math.floor(n) : 0;
}
function toBool(value) {
if (typeof value === 'boolean') return value;
return /^(true|1|yes)$/i.test(String(value ?? '').trim());
}
/**
* Nom / valeur utilisables dans un fichier Netscape (ni tabulation ni retour à la ligne)
*/
function isSafeField(text, allowEmpty) {
if (typeof text !== 'string') return false;
if (!allowEmpty && text.length === 0) return false;
return !/[\t\r\n]/.test(text);
}
function normalizeCookie(raw) {
const domain = String(raw.domain || '').trim();
const name = String(raw.name ?? '').trim();
const value = raw.value === undefined || raw.value === null ? '' : String(raw.value);
if (!domain || !isSafeField(name, false) || /[\s;=]/.test(name) || !isSafeField(value, true)) return null;
// includeSubdomains suit le point initial du domaine (cohérence exigée par le format Netscape)
const includeSubdomains = domain.startsWith('.');
return {
domain: domain.toLowerCase(),
includeSubdomains,
path: String(raw.path || '/').trim() || '/',
secure: Boolean(raw.secure),
expires: raw.expires,
name,
value,
httpOnly: Boolean(raw.httpOnly)
};
}
/**
* Export JSON d'extension (EditThisCookie, Cookie-Editor, "Get cookies.txt LOCALLY")
*/
function parseJsonEntries(list, warnings) {
const cookies = [];
let bad = 0;
for (const entry of list) {
if (!entry || typeof entry !== 'object' || entry.name === undefined || !entry.domain) {
bad++;
continue;
}
let domain = String(entry.domain).trim();
const hostOnly = entry.hostOnly === true || entry.hostOnly === "true";
// Cookie de domaine (non hostOnly) : le point initial étend le cookie aux sous-domaines
if (!hostOnly && !domain.startsWith('.')) domain = "." + domain;
if (hostOnly) domain = domain.replace(/^\./, '');
const session = entry.session === true || entry.session === "true";
const expires = session ? 0 : toExpires(entry.expirationDate ?? entry.expires ?? entry.expiry ?? entry.expirationTime);
const cookie = normalizeCookie({
domain,
path: entry.path,
secure: toBool(entry.secure),
expires,
name: entry.name,
value: entry.value,
httpOnly: toBool(entry.httpOnly)
});
if (cookie) cookies.push(cookie); else bad++;
}
if (bad) warnings.push(`${bad} entrée(s) JSON ignorée(s) (format invalide)`);
return cookies;
}
/**
* Fichier texte Netscape (cookies.txt), avec ou sans en-tête, fins de ligne CRLF acceptées
*/
function parseNetscape(text, warnings) {
const cookies = [];
let bad = 0;
for (let line of text.split(/\r\n|\n|\r/)) {
let httpOnly = false;
if (line.startsWith(HTTPONLY_PREFIX)) {
httpOnly = true;
line = line.slice(HTTPONLY_PREFIX.length);
} else if (line.trim() === '' || line.trimStart().startsWith('#')) {
continue;
}
let fields = line.split('\t');
let separator = '\t';
// Tabulations remplacées par des espaces lors d'un copier-coller
if (fields.length < 6) {
fields = line.trim().split(/\s+/);
separator = ' ';
}
if (fields.length < 6) {
bad++;
continue;
}
// Valeur vide : 6 champs seulement ; valeur coupée par le séparateur : on recolle
const [domain, flag, cookiePath, secure, expires, name, ...rest] = fields;
const value = rest.join(separator);
if (!/^(TRUE|FALSE)$/i.test(flag) || !/^(TRUE|FALSE)$/i.test(secure) || !/^-?\d+(\.\d+)?$/.test(expires.trim())) {
bad++;
continue;
}
const cookie = normalizeCookie({
domain: flag.toUpperCase() === "TRUE" && !domain.startsWith('.') ? "." + domain : domain,
path: cookiePath,
secure: secure.toUpperCase() === "TRUE",
expires: toExpires(expires),
name,
value: value.trimEnd(),
httpOnly
});
if (cookie) cookies.push(cookie); else bad++;
}
if (bad) warnings.push(`${bad} ligne(s) ignorée(s) (format invalide)`);
return cookies;
}
/**
* Lit un fichier de cookies (Netscape ou export JSON) et ne garde que les cookies YouTube / Google valides.
* @param {string} text
* @param {{now?: number}} [options] - now en secondes (tests)
* @returns {{cookies: Array<{domain: string, includeSubdomains: boolean, path: string, secure: boolean, expires: number, name: string, value: string, httpOnly: boolean}>, warnings: string[]}}
* @throws {Error} code INVALID_COOKIES si le contenu n'est pas reconnu
*/
function parseCookies(text, { now } = {}) {
if (typeof text !== 'string' || !text.trim()) throw cookieError("Le contenu des cookies est vide");
const warnings = [];
const content = text.replace(/^/, '').trim();
let cookies;
if (content.startsWith('[') || content.startsWith('{')) {
let data;
try {
data = JSON.parse(content);
} catch (e) {
throw cookieError("Export JSON illisible : vérifiez que le fichier est complet");
}
const list = Array.isArray(data) ? data : (Array.isArray(data?.cookies) ? data.cookies : null);
if (!list) throw cookieError("Export JSON non reconnu : une liste de cookies est attendue");
cookies = parseJsonEntries(list, warnings);
} else {
cookies = parseNetscape(content, warnings);
}
if (cookies.length === 0) {
throw cookieError("Aucun cookie reconnu : fichier cookies.txt (format Netscape) ou export JSON attendu");
}
// Uniquement YouTube / Google : le reste ne sert pas et n'a pas à être stocké
const allowed = cookies.filter(c => isAllowedDomain(c.domain));
if (allowed.length < cookies.length) {
warnings.push(`${cookies.length - allowed.length} cookie(s) d'autres sites ignoré(s)`);
}
const nowSec = now ?? Math.floor(Date.now() / 1000);
const alive = allowed.filter(c => !(c.expires > 0 && c.expires <= nowSec));
if (alive.length < allowed.length) {
warnings.push(`${allowed.length - alive.length} cookie(s) expiré(s) ignoré(s)`);
}
// Doublons (même domaine / chemin / nom) : la dernière occurrence l'emporte
const unique = new Map();
for (const cookie of alive) unique.set(`${cookie.domain}\t${cookie.path}\t${cookie.name}`, cookie);
return { cookies: [...unique.values()], warnings };
}
/**
* Fichier Netscape (fins de ligne LF) lisible par yt-dlp
*/
function serializeCookies(cookies) {
const lines = [NETSCAPE_HEADER, "# Fichier géré par Subsonics Chopin : ne pas modifier à la main", ""];
for (const c of cookies) {
const domain = c.includeSubdomains ? "." + bareDomain(c.domain) : bareDomain(c.domain);
lines.push([
(c.httpOnly ? HTTPONLY_PREFIX : "") + domain,
c.includeSubdomains ? "TRUE" : "FALSE",
c.path || "/",
c.secure ? "TRUE" : "FALSE",
String(c.expires || 0),
c.name,
c.value
].join("\t"));
}
return lines.join("\n") + "\n";
}
/**
* Résumé sans aucune valeur de cookie
*/
function analyze(cookies) {
const list = Array.isArray(cookies) ? cookies : [];
const domains = [...new Set(list.map(c => c.domain))].sort();
const yt = list.filter(appliesToYoutube);
const ytNames = new Set(yt.map(c => c.name));
const authNames = AUTH_COOKIES.filter(name => ytNames.has(name));
const missing = AUTH_COOKIES.filter(name => !ytNames.has(name));
const loggedIn = ytNames.has(LOGIN_COOKIE) && SAPISID_COOKIES.some(name => ytNames.has(name));
const authCookies = yt.filter(c => AUTH_COOKIES.includes(c.name));
const authExpiries = authCookies.map(c => c.expires).filter(e => e > 0);
const allExpiries = list.map(c => c.expires).filter(e => e > 0);
return {
count: list.length,
youtubeCount: list.filter(c => isYoutubeDomain(c.domain)).length,
domains,
loggedIn,
authNames,
missing,
earliestAuthExpiry: authExpiries.length ? new Date(Math.min(...authExpiries) * 1000).toISOString() : null,
latestExpiry: allExpiries.length ? new Date(Math.max(...allExpiries) * 1000).toISOString() : null,
// Au moins un cookie de connexion sans date d'expiration (perdu à la fermeture du navigateur)
hasSessionOnly: authCookies.some(c => !c.expires)
};
}
// --- Stockage (fichier maître + état) ---
const EMPTY_STATE = {
importedAt: null,
importedBy: null,
source: null,
invalidatedAt: null,
invalidReason: null,
lastCheck: null
};
let cache = null; // { content, analysis, error } du fichier maître
let state = null;
const liveCopies = new Set();
function masterPath() {
return __glob.YOUTUBE_COOKIES;
}
function loadState() {
if (!state) state = { ...EMPTY_STATE, ...readJson(__glob.YOUTUBE_STATE, {}) };
return state;
}
function saveState() {
writeJson(__glob.YOUTUBE_STATE, state);
}
/**
* Lecture (mise en cache) du fichier maître
*/
function loadMaster() {
if (cache) return cache;
if (!fs.existsSync(masterPath())) {
cache = { content: null, analysis: analyze([]), error: null };
return cache;
}
try {
const { cookies } = parseCookies(fs.readFileSync(masterPath(), 'utf-8'));
cache = { content: serializeCookies(cookies), analysis: analyze(cookies), error: null };
} catch (e) {
cache = { content: null, analysis: analyze([]), error: e.message };
}
return cache;
}
/**
* Cookies utilisables pour la lecture : présents, connectés et non invalidés
*/
function isValid() {
const master = loadMaster();
return Boolean(master.content && master.analysis.loggedIn && !loadState().invalidatedAt);
}
function getStatus() {
const master = loadMaster();
const st = loadState();
const present = Boolean(master.content);
return {
present,
loggedIn: present && master.analysis.loggedIn,
valid: present && master.analysis.loggedIn && !st.invalidatedAt,
invalidatedAt: present ? st.invalidatedAt : null,
invalidReason: present ? st.invalidReason : null,
importedAt: present ? st.importedAt : null,
importedBy: present ? st.importedBy : null,
source: present ? st.source : null,
count: master.analysis.count,
authNames: master.analysis.authNames,
missing: master.analysis.missing,
earliestAuthExpiry: master.analysis.earliestAuthExpiry,
hasSessionOnly: master.analysis.hasSessionOnly,
lastCheck: present ? st.lastCheck : null,
error: master.error
};
}
/**
* Importe des cookies (collage, fichier, navigateur, ancien fichier) et remplace le fichier maître.
* @param {string} text
* @param {{source?: string, by?: {id: string, username: string}|null}} options
* @returns {{status: object, warnings: string[]}}
*/
function importCookies(text, { source = "paste", by = null } = {}) {
const { cookies, warnings } = parseCookies(text);
const analysis = analyze(cookies);
if (analysis.youtubeCount === 0) {
throw cookieError("Aucun cookie youtube.com valide : exportez les cookies depuis un onglet youtube.com");
}
if (!analysis.loggedIn) {
warnings.push("Aucun cookie de connexion : ces cookies ne correspondent pas à un compte YouTube connecté");
}
const content = serializeCookies(cookies);
writeFileAtomic(masterPath(), content, 0o600);
cache = { content, analysis, error: null };
state = {
...EMPTY_STATE,
importedAt: new Date().toISOString(),
importedBy: by && by.id ? { id: String(by.id), username: String(by.username ?? '') } : null,
source: SOURCES.test(String(source)) ? String(source) : "paste"
};
saveState();
clog.log(`Cookies YouTube importés (${state.source}${state.importedBy ? `, par ${state.importedBy.username}` : ''}) : ${analysis.count} cookie(s), ${analysis.loggedIn ? "compte connecté" : "aucun compte connecté"}`);
emitUpdate();
return { status: getStatus(), warnings };
}
function deleteCookies() {
try {
fs.rmSync(masterPath(), { force: true });
} catch (e) {
clog.error(`Suppression des cookies impossible : ${e.message}`);
throw e;
}
cache = null;
state = { ...EMPTY_STATE };
saveState();
clog.log("Cookies YouTube supprimés");
emitUpdate();
return getStatus();
}
/**
* Marque les cookies comme invalides (rotation, session révoquée). Idempotent.
* @param {string} reason
*/
function markInvalid(reason) {
if (!loadMaster().content) return false;
const st = loadState();
if (st.invalidatedAt) return false;
st.invalidatedAt = new Date().toISOString();
st.invalidReason = String(reason || "Cookies refusés par YouTube").slice(0, 300);
saveState();
clog.warn(`Cookies YouTube invalidés : ${st.invalidReason} - lecture sans compte en attendant un nouvel import`);
emitUpdate();
return true;
}
/**
* Résultat d'une vérification en direct (check.js)
* @param {{at: string, ok: boolean, loggedIn: boolean, premium: boolean, message: string}} result
*/
function setLastCheck(result) {
if (!loadMaster().content) return;
const st = loadState();
st.lastCheck = result;
// yt-dlp a reconnu le compte : une invalidation précédente était donc injustifiée
if (result.ok && result.loggedIn && st.invalidatedAt) {
st.invalidatedAt = null;
st.invalidReason = null;
}
saveState();
emitUpdate();
}
/**
* Copie privée du fichier maître pour un processus yt-dlp (qui réécrit son fichier de cookies en sortant).
* @returns {Promise<{path: string, release: function(): void}|null>} null si aucun cookie
*/
async function acquireCopy() {
const master = loadMaster();
if (!master.content) return null;
// Import auquel appartient la copie (un processus lancé avant un nouvel import ne doit pas invalider le nouveau)
const importedAt = loadState().importedAt;
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), COPY_PREFIX));
liveCopies.add(dir);
let released = false;
const release = () => {
if (released) return;
released = true;
liveCopies.delete(dir);
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* déjà supprimé */ }
};
try {
const file = path.join(dir, "cookies.txt");
await fs.promises.writeFile(file, master.content, { mode: 0o600 });
return { path: file, release, importedAt };
} catch (e) {
release();
throw e;
}
}
/**
* Supprime les copies temporaires abandonnées (crash, arrêt brutal)
* @param {number} [maxAgeMs]
*/
function sweepStaleCopies(maxAgeMs = STALE_COPY_AGE) {
let removed = 0;
try {
const tmp = os.tmpdir();
for (const entry of fs.readdirSync(tmp)) {
if (!entry.startsWith(COPY_PREFIX)) continue;
const full = path.join(tmp, entry);
if (liveCopies.has(full)) continue;
try {
if (Date.now() - fs.statSync(full).mtimeMs < maxAgeMs) continue;
fs.rmSync(full, { recursive: true, force: true });
removed++;
} catch (e) {
// Supprimé entre-temps
}
}
} catch (e) {
clog.warn(`Nettoyage des copies de cookies impossible : ${e.message}`);
}
if (removed) clog.log(`${removed} copie(s) temporaire(s) de cookies supprimée(s)`);
return removed;
}
function liveCopyCount() {
return liveCopies.size;
}
// Arrêt du process : aucune copie de cookies ne doit rester dans le dossier temporaire
process.once('exit', () => {
for (const dir of liveCopies) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignoré */ }
}
});
/**
* Migration des anciens fichiers data/cookies.txt et data/cookies.json (jamais modifiés ni supprimés).
* Le premier fichier qui donne un compte connecté l'emporte, sinon le premier lisible.
* @returns {{migrated: boolean, from?: string, loggedIn?: boolean}}
*/
function migrateLegacy() {
// state.json présent : les cookies sont déjà gérés ici (une suppression volontaire ne doit pas réimporter les anciens)
if (fs.existsSync(masterPath()) || fs.existsSync(__glob.YOUTUBE_STATE)) return { migrated: false };
const candidates = [__glob.COOKIES, __glob.COOKIES_JSON].filter(file => file && fs.existsSync(file));
if (candidates.length === 0) return { migrated: false };
let fallback = null;
for (const file of candidates) {
let text;
try {
text = fs.readFileSync(file, 'utf-8');
const { cookies } = parseCookies(text);
const analysis = analyze(cookies);
if (analysis.youtubeCount === 0) continue;
if (analysis.loggedIn) return doMigrate(file, text, true);
if (!fallback) fallback = { file, text };
} catch (e) {
clog.warn(`Ancien fichier de cookies illisible (${path.basename(file)}) : ${e.message}`);
}
}
if (fallback) return doMigrate(fallback.file, fallback.text, false);
return { migrated: false };
}
function doMigrate(file, text, loggedIn) {
importCookies(text, { source: "legacy" });
clog.log(`Anciens cookies repris depuis data/${path.basename(file)} (fichier d'origine conservé)${loggedIn ? "" : " - aucun compte connecté"}`);
return { migrated: true, from: path.basename(file), loggedIn };
}
/**
* Oublie le cache (tests, ou après modification externe)
*/
function reload() {
cache = null;
state = null;
}
module.exports = {
parseCookies, serializeCookies, analyze,
importCookies, deleteCookies, getStatus, isValid, markInvalid, setLastCheck,
acquireCopy, sweepStaleCopies, liveCopyCount, migrateLegacy, reload,
AUTH_COOKIES
};
+110
View File
@@ -0,0 +1,110 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/diagnostics.js
* Description: Historique des tentatives de lecture (100 dernières) et compteurs par classe d'erreur
*/
const { emitUpdate } = require('./util');
const MAX_ENTRIES = 100;
const EMIT_DEBOUNCE = 1000;
// Échecs qui signalent un blocage (IP, session, client) : une série déclenche une mise à jour de yt-dlp
const BLOCK_CLASSES = new Set(["BOT_CHECK", "IP_BLOCKED", "CAPTCHA", "HTTP_403", "POT_MISSING", "SABR"]);
let entries = [];
let counters = {};
let totals = { ok: 0, failed: 0, aborted: 0 };
let consecutiveBlocks = 0;
let since = new Date().toISOString();
let lastFailure = null;
let lastSuccess = null;
let emitTimer = null;
function scheduleEmit() {
if (emitTimer) return;
emitTimer = setTimeout(() => {
emitTimer = null;
emitUpdate();
}, EMIT_DEBOUNCE);
emitTimer.unref?.();
}
/**
* Enregistre une tentative de lecture
* @param {{guildId?: string|null, songId?: string|null, title?: string|null, route: "warp"|"direct", identity: "anon"|"cookies", clients?: string, outcome: "ok"|"failed"|"aborted", cls?: string|null, message?: string|null, ms?: number}} attempt
*/
function record(attempt) {
const entry = {
at: new Date().toISOString(),
guildId: attempt.guildId ?? null,
songId: attempt.songId ?? null,
title: attempt.title ? String(attempt.title).slice(0, 200) : null,
route: attempt.route === "warp" ? "warp" : "direct",
identity: attempt.identity === "cookies" ? "cookies" : "anon",
clients: attempt.clients || "",
seek: attempt.seek || null,
outcome: ["ok", "failed", "aborted"].includes(attempt.outcome) ? attempt.outcome : "failed",
cls: attempt.outcome === "ok" ? null : (attempt.cls || null),
message: attempt.message ? String(attempt.message).slice(0, 300) : null,
ms: Number.isFinite(attempt.ms) ? Math.round(attempt.ms) : null
};
entries.push(entry);
if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES);
totals[entry.outcome]++;
if (entry.outcome === "ok") {
consecutiveBlocks = 0;
lastSuccess = entry.at;
} else if (entry.outcome === "failed") {
const cls = entry.cls || "UNKNOWN";
counters[cls] = (counters[cls] || 0) + 1;
if (BLOCK_CLASSES.has(cls)) consecutiveBlocks++;
lastFailure = { at: entry.at, cls, message: entry.message, route: entry.route, identity: entry.identity };
}
scheduleEmit();
return entry;
}
/**
* Tentatives récentes, de la plus récente à la plus ancienne
* @param {number} [limit]
*/
function getRecent(limit = 50) {
const n = Math.max(0, Math.min(MAX_ENTRIES, Number(limit) || 0));
return entries.slice(-n).reverse();
}
function getConsecutiveBlocks() {
return consecutiveBlocks;
}
function getSummary() {
const recent = entries.slice(-20).filter(e => e.outcome !== "aborted");
return {
since,
total: totals.ok + totals.failed + totals.aborted,
ok: totals.ok,
failed: totals.failed,
aborted: totals.aborted,
byClass: { ...counters },
consecutiveBlocks,
lastFailure,
lastSuccess,
// Taux d'échec sur les 20 dernières tentatives (pastille de l'administration)
recentFailureRate: recent.length ? Math.round(100 * recent.filter(e => e.outcome === "failed").length / recent.length) / 100 : null,
recentCount: recent.length
};
}
function clear() {
entries = [];
counters = {};
totals = { ok: 0, failed: 0, aborted: 0 };
consecutiveBlocks = 0;
since = new Date().toISOString();
lastFailure = null;
lastSuccess = null;
scheduleEmit();
}
module.exports = { record, getRecent, getSummary, getConsecutiveBlocks, clear, BLOCK_CLASSES };
+147
View File
@@ -0,0 +1,147 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/errors.js
* Description: Classement des erreurs yt-dlp (stderr) et nettoyage des textes avant journalisation
*/
// "you're" peut s'écrire avec une apostrophe droite ou typographique (’) : on accepte n'importe quel caractère
const COOKIES_ROTATED_RE = /account cookies are no longer valid|cookies are no longer valid/i;
const RULES = [
// Lancement impossible (binaire absent) : message produit par Node / par nous
{ cls: "BINARY_MISSING", retry: "none", re: /\bENOENT\b|(?:yt-dlp|ffmpeg) introuvable|not recognized as an internal or external command|No such file or directory.*yt-dlp/i },
{ cls: "COOKIES_ROTATED", retry: "cookies", re: COOKIES_ROTATED_RE },
{ cls: "PRIVATE", retry: "none", re: /\bPrivate video\b|video is private/i },
{ cls: "MEMBERS_ONLY", retry: "cookies", re: /available to this channel.?s members|members[- ]only content|Join this channel to get access/i },
{ cls: "AGE_RESTRICTED", retry: "cookies", re: /confirm your age|age[- ]restricted|inappropriate for some users/i },
{ cls: "BOT_CHECK", retry: "route", re: /confirm you.?re not a bot/i },
{ cls: "CAPTCHA", retry: "route", re: /requiring a captcha|captcha challenge/i },
{ cls: "IP_BLOCKED", retry: "route", re: /IP is likely being blocked|All player responses are invalid/i },
// Doit passer avant UNAVAILABLE : "This content isn't available, try again later"
{ cls: "RATE_LIMITED", retry: "route", re: /content isn.?t available, try again later|rate[- ]limited by YouTube|session has been rate[- ]limited/i },
{ cls: "GEO_BLOCKED", retry: "route", re: /not made this video available in your country|not available in your country|geo[- ]?restrict/i },
{ cls: "LOGIN_REQUIRED", retry: "cookies", re: /Login details are needed|Sign in to confirm|requires? (?:a )?login|Use --cookies-from-browser or --cookies for the authentication|This video requires payment/i },
{ cls: "UNAVAILABLE", retry: "none", re: /Video unavailable|This video is unavailable|video has been removed|video is no longer available|account associated with this video has been terminated|content isn.?t available|Unsupported URL|Incomplete YouTube ID|is not a valid URL/i },
// Causes précises avant les codes HTTP : l'avertissement PO token cite lui-même "HTTP Error 403"
{ cls: "SABR", retry: "client", re: /\bSABR\b|formats have been skipped as they are missing a url/i },
{ cls: "POT_MISSING", retry: "client", re: /PO Token|po_token|\bPOT\b.*(?:missing|required)/i },
{ cls: "JS_CHALLENGE", retry: "client", re: /n challenge solving failed|Signature solving failed|No supported JavaScript runtime|nsig extraction failed|Signature extraction failed|challenge solver/i },
{ cls: "HTTP_429", retry: "route", re: /HTTP Error 429|Too Many Requests/i },
{ cls: "HTTP_403", retry: "client", re: /HTTP Error 403|403:? Forbidden/i },
{ cls: "PAGE_RELOAD", retry: "route", re: /page needs to be reloaded/i },
{ cls: "EMPTY_DOWNLOAD", retry: "client", re: /downloaded file is empty/i },
{ cls: "FORMAT_UNAVAILABLE", retry: "client", re: /Requested format is not available|No video formats found|Only images are available/i },
{ cls: "TIMEOUT", retry: "route", re: /timed out|time-?out|aucune donnée après/i },
];
// Causes profondes cherchées dans les avertissements quand l'erreur finale est générique
const ROOT_CAUSES = new Set(["SABR", "POT_MISSING", "JS_CHALLENGE", "PAGE_RELOAD"]);
const GENERIC = new Set(["FORMAT_UNAVAILABLE", "EMPTY_DOWNLOAD", "UNKNOWN"]);
const RETRY = Object.fromEntries(RULES.map(rule => [rule.cls, rule.retry]));
RETRY.UNKNOWN = "route";
const CLASSES = [...RULES.map(rule => rule.cls), "UNKNOWN"];
/**
* Ligne d'erreur (et pas simple information) : ERROR:, ou erreur HTTP pendant le téléchargement
*/
function isErrorLine(line) {
// Un avertissement peut citer "HTTP Error 403" (formats PO token ignorés) sans être une erreur
if (/^WARNING:/i.test(line)) return false;
return /^ERROR:/i.test(line) || /\bGot error:|Giving up after|HTTP Error \d{3}/i.test(line);
}
function matchLines(lines) {
for (const rule of RULES) {
const line = lines.find(l => rule.re.test(l));
if (line) return { rule, line };
}
return null;
}
/**
* Message lisible : sans le préfixe ERROR:/WARNING:, nettoyé et tronqué
*/
function cleanMessage(line) {
const text = sanitize(String(line || '').replace(/^(?:ERROR|WARNING):\s*/i, '').trim());
return text.length > 300 ? text.slice(0, 297) + '...' : text;
}
/**
* Classe une sortie d'erreur yt-dlp (analyse ligne par ligne).
* Les lignes ERROR priment ; si l'erreur finale est générique (format indisponible, fichier vide...),
* une cause profonde trouvée dans les avertissements (SABR, PO token, défi JS) la remplace.
* @param {string|string[]} stderrText
* @returns {{cls: string, retry: "route"|"cookies"|"client"|"none", message: string, cookiesRotated: boolean, hints: string[]}}
*/
function classify(stderrText) {
const lines = (Array.isArray(stderrText) ? stderrText.join('\n') : String(stderrText || ''))
.split(/\r?\n|\r/)
.map(l => l.trim())
.filter(Boolean);
const cookiesRotated = lines.some(l => COOKIES_ROTATED_RE.test(l));
// Information seule (client ignoré faute de support des cookies) : jamais une cause d'échec
const useful = lines.filter(l => !/Skipping client .* since it does not support cookies/i.test(l));
const errorLines = useful.filter(isErrorLine);
let found = errorLines.length ? matchLines(errorLines) : null;
// Avertissement de rotation : signalé par le drapeau, la classe vient de l'erreur réelle
if (found && found.rule.cls === "COOKIES_ROTATED") {
const other = matchLines(errorLines.filter(l => !COOKIES_ROTATED_RE.test(l)));
if (other) found = other;
}
if (!found) found = matchLines(useful.filter(l => !COOKIES_ROTATED_RE.test(l))) || (cookiesRotated ? matchLines(useful) : null);
let cls = found ? found.rule.cls : "UNKNOWN";
let line = found ? found.line : (errorLines[errorLines.length - 1] || useful[useful.length - 1] || "");
// Cause profonde dans les avertissements
const hints = [];
for (const l of useful) {
for (const rule of RULES) {
if (ROOT_CAUSES.has(rule.cls) && rule.re.test(l) && !hints.includes(rule.cls)) hints.push(rule.cls);
}
}
if (GENERIC.has(cls) && hints.length) {
const root = hints[0];
const rootLine = useful.find(l => RULES.find(r => r.cls === root).re.test(l));
cls = root;
line = rootLine || line;
}
return {
cls,
retry: RETRY[cls] || "route",
message: cleanMessage(line) || "Erreur inconnue",
cookiesRotated,
hints
};
}
/**
* Retire tout ce qui ressemble à un secret : valeurs de cookies, en-têtes d'authentification,
* chemin du fichier de cookies, identifiants de proxy, jetons PO.
* @param {string} text
* @returns {string}
*/
function sanitize(text) {
if (text === null || text === undefined) return '';
return String(text)
// Identifiants dans une URL (proxy http://user:pass@hote)
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:'"]+(?::[^\s/@'"]*)?@/gi, '$1***@')
// Option --cookies <chemin> (chemin avec ou sans guillemets)
.replace(/(--cookies(?:-from-browser)?\s+)("[^"]*"|'[^']*'|\S+)/gi, '$1<masqué>')
// Copies temporaires des cookies
.replace(/chopin-ytc-[A-Za-z0-9]+/g, 'chopin-ytc-***')
// En-têtes HTTP sensibles
.replace(/((?:Set-)?Cookie|Authorization|X-Goog-Visitor-Id|X-Youtube-Identity-Token)(["']?\s*[:=]\s*["']?)[^\r\n"']+/gi, '$1$2***')
.replace(/\b(SAPISIDHASH|SAPISID1PHASH|SAPISID3PHASH)\s+\S+/g, '$1 ***')
// Valeurs de cookies connus (nom=valeur ou nom\tvaleur)
.replace(/\b((?:__Secure-|__Host-)[\w-]+|SID|HSID|SSID|APISID|SAPISID|LOGIN_INFO|SIDCC|PREF|VISITOR_INFO1_LIVE|VISITOR_PRIVACY_METADATA|YSC|SOCS|NID|GPS)(=|\t)[^;\s]+/g, '$1$2***')
// Jetons PO / visitor data
.replace(/\b(po_token|pot|visitor_data|visitorData)(=|:\s*)[\w%.\-/+]{8,}/gi, '$1$2***');
}
module.exports = { classify, sanitize, isErrorLine, CLASSES, RETRY };
+91
View File
@@ -0,0 +1,91 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/index.js
* Description: Point d'entrée : initialisation (dossiers, migration des cookies, version, mises à jour) et état global
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube");
const fs = require('fs');
const { __glob } = require('../utils/GlobalVars');
const cookies = require('./cookies');
const settings = require('./settings');
const ytdlp = require('./ytdlp');
const errors = require('./errors');
const diagnostics = require('./diagnostics');
const check = require('./check');
const plan = require('./plan');
let initialized = false;
/**
* Initialisation au démarrage du bot. Ne lève jamais : un problème YouTube ne doit pas empêcher le démarrage.
*/
async function init() {
if (initialized) return;
initialized = true;
try {
fs.mkdirSync(__glob.YOUTUBE_CACHE, { recursive: true });
cookies.sweepStaleCopies();
ytdlp.sweepWindowsTemp();
const migration = cookies.migrateLegacy();
const status = cookies.getStatus();
const s = settings.get();
clog.log(`Cookies YouTube : ${status.present ? `${status.count} cookie(s), ${status.valid ? "compte connecté" : status.invalidatedAt ? "invalidés" : "aucun compte connecté"}` : "aucun"} - mode ${s.cookiesMode}`);
if (status.error) clog.warn(`Fichier de cookies illisible : ${status.error}`);
const bin = ytdlp.resolveBinary();
const ffmpeg = ytdlp.resolveFfmpeg();
if (bin.source === "missing") {
clog.error("yt-dlp introuvable (ni YTDLP_PATH, ni PATH, ni node_modules/youtube-dl-exec) : la lecture YouTube est impossible");
} else {
clog.log(`yt-dlp : ${bin.path} (${bin.source})`);
}
if (!ffmpeg) clog.warn("ffmpeg introuvable : le seek YouTube ne fonctionnera pas");
if (s.potProviderUrl) clog.log(`Fournisseur de PO token : ${settings.maskUrl(s.potProviderUrl)}`);
// Version en arrière-plan (ne retarde pas le démarrage)
ytdlp.getVersion().then((version) => {
if (version) clog.log(`Version de yt-dlp : ${version}`);
else if (bin.source !== "missing") clog.warn("Version de yt-dlp inconnue (le binaire ne répond pas)");
}).catch(() => {});
ytdlp.startAutoUpdate();
// Anciens cookies repris : on vérifie tout de suite s'ils sont encore acceptés par YouTube
if (migration.migrated && migration.loggedIn) {
const timer = setTimeout(() => {
check.checkCookies().catch((e) => clog.warn(`Vérification des cookies repris impossible : ${e?.message || e}`));
}, 15000);
timer.unref?.();
}
} catch (e) {
clog.error(`Initialisation du module YouTube incomplète : ${e?.stack || e}`);
}
}
/**
* URL du fournisseur de PO token, masquée
*/
function potStatus() {
const url = settings.get().potProviderUrl;
return { configured: Boolean(url), url: url ? settings.maskUrl(url) : null, locked: settings.isLocked("potProviderUrl") };
}
/**
* État complet pour l'administration
*/
function getStatus() {
const info = ytdlp.getInfo();
return {
ytdlp: info.ytdlp,
ffmpeg: info.ffmpeg,
cookies: cookies.getStatus(),
settings: settings.getPublic(),
pot: potStatus(),
diagnostics: diagnostics.getSummary(),
activeProcesses: ytdlp.activeCount()
};
}
module.exports = { init, getStatus, cookies, settings, ytdlp, errors, diagnostics, check, plan };
+150
View File
@@ -0,0 +1,150 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/plan.js
* Description: Plan des tentatives de lecture (route, identité, clients, méthode de seek) - fonctions pures
*/
// Au-delà, le seek "auto" passe par --download-sections (évite de télécharger tout le début)
const NATIVE_SEEK_MAX = 1200;
// Contenu inaccessible quelle que soit la route : inutile de continuer
const FATAL = new Set(["PRIVATE", "UNAVAILABLE", "BINARY_MISSING"]);
// Accès lié au compte : sans cookies, aucune autre route ne réussira
const NEEDS_ACCOUNT = new Set(["AGE_RESTRICTED", "LOGIN_REQUIRED", "MEMBERS_ONLY"]);
// Blocage de l'IP / de la session anonyme : autre route, puis cookies
const BLOCKED = new Set(["BOT_CHECK", "IP_BLOCKED", "CAPTCHA"]);
// Problème de client / format : une tentative avec les clients de repli
const CLIENT_ISSUES = new Set(["HTTP_403", "FORMAT_UNAVAILABLE", "SABR", "POT_MISSING", "EMPTY_DOWNLOAD"]);
/**
* Méthode de seek selon le réglage
* @param {"auto"|"ffmpeg"|"native"} strategy
* @param {number} seek - secondes
* @returns {"native"|"sections"|null}
*/
function seekModeFor(strategy, seek) {
if (!seek || seek <= 0) return null;
if (strategy === "ffmpeg") return "sections";
if (strategy === "native") return "native";
return seek <= NATIVE_SEEK_MAX ? "native" : "sections";
}
function attemptKey(a) {
return `${a.proxy || "direct"}|${a.identity}|${a.clients || ""}|${a.seek || ""}`;
}
/**
* Prochaine tentative à lancer, d'après les tentatives déjà faites et leur classe d'échec.
* Fonction pure : rejoue l'historique à chaque appel.
* @param {Object} input
* @param {Array<string|null>} input.routes - ordre des routes (null = directe), cf. warp.getRoutes()
* @param {"fallback"|"always"|"never"} input.mode - cookiesMode
* @param {boolean} input.cookiesValid - cookies présents, connectés et non invalidés (au moment de l'appel)
* @param {boolean} [input.cookiesOnProxy]
* @param {string} [input.clients] - playerClients ("" = défaut de yt-dlp)
* @param {string} [input.fallbackClients] - fallbackPlayerClients ("" = désactivé)
* @param {"native"|"sections"|null} [input.seekMode]
* @param {Array<{proxy: string|null, identity: string, clients: string, seek: string|null, cls: string}>} [input.history]
* @param {number} [input.maxAttempts]
* @returns {{proxy: string|null, identity: "anon"|"cookies", clients: string, seek: "native"|"sections"|null, reason: string}|null}
*/
function planNext({
routes,
mode = "fallback",
cookiesValid = false,
cookiesOnProxy = false,
clients = "",
fallbackClients = "",
seekMode = null,
history = [],
maxAttempts = 4
}) {
const allRoutes = Array.isArray(routes) && routes.length ? routes : [null];
if (history.length >= maxAttempts) return null;
const useCookies = cookiesValid && mode !== "never";
// Routes autorisées avec le compte : la directe, sauf réglage contraire ou si le proxy est la seule route
let cookieRoutes = cookiesOnProxy ? allRoutes : allRoutes.filter(r => r === null);
if (cookieRoutes.length === 0) cookieRoutes = allRoutes;
// Méthode de seek courante (repasse en natif après un 403 de ffmpeg)
let seekNow = seekMode;
const make = (proxy, identity, reason, overrides = {}) => ({
proxy,
identity,
clients: clients || "",
seek: seekNow,
reason,
...overrides
});
// Plan de base
let queue;
if (mode === "always" && useCookies) {
queue = allRoutes.map(r => cookieRoutes.includes(r) ? make(r, "cookies", "base") : make(r, "anon", "base"));
} else {
queue = allRoutes.map(r => make(r, "anon", "base"));
}
const cookieAttempts = (reason) => cookieRoutes.map(r => make(r, "cookies", reason));
const tried = new Set();
let cookiesAdded = mode === "always";
let clientAdded = false;
let nativeAdded = false;
for (const h of history) {
const key = attemptKey(h);
tried.add(key);
queue = queue.filter(a => attemptKey(a) !== key);
const cls = h.cls || "UNKNOWN";
if (FATAL.has(cls)) return null;
if (cls === "GEO_BLOCKED" || cls === "RATE_LIMITED") {
// Blocage lié à l'IP (pays, limite de débit) : seule une autre route peut aider
queue = queue.filter(a => a.proxy !== h.proxy);
continue;
}
if (NEEDS_ACCOUNT.has(cls)) {
// Déjà avec le compte, ou pas de compte utilisable : rien d'autre ne passera
if (h.identity === "cookies" || !useCookies) return null;
queue = queue.filter(a => a.identity === "cookies");
if (!cookiesAdded || queue.length === 0) queue = cookieAttempts("compte requis");
cookiesAdded = true;
continue;
}
if (BLOCKED.has(cls)) {
// Autres routes d'abord (autre IP), puis le compte en dernier recours
if (useCookies && !cookiesAdded) {
queue = queue.concat(cookieAttempts("blocage"));
cookiesAdded = true;
}
continue;
}
if (h.seek === "sections" && cls === "HTTP_403" && !nativeAdded) {
// ffmpeg (--download-sections) lit sans requêtes bornées : 403 fréquent, on repasse en natif
nativeAdded = true;
seekNow = "native";
queue = queue.map(a => a.seek === "sections" ? { ...a, seek: "native" } : a);
queue.unshift({ proxy: h.proxy, identity: h.identity, clients: h.clients || "", seek: "native", reason: "seek natif" });
continue;
}
if (CLIENT_ISSUES.has(cls) && fallbackClients && !clientAdded && h.clients !== fallbackClients) {
clientAdded = true;
queue.unshift({ proxy: h.proxy, identity: h.identity, clients: fallbackClients, seek: h.seek ?? seekNow, reason: "clients de repli" });
}
// Autres classes (délai, 429, page à recharger, défi JS, inconnue) : tentative suivante du plan
}
// Cookies invalidés entre-temps (rotation) : plus aucune tentative avec le compte
if (!useCookies) queue = queue.filter(a => a.identity !== "cookies");
// Mode "always" sans cookies utilisables : le plan de base est anonyme
queue = queue.filter(a => !tried.has(attemptKey(a)));
return queue[0] || null;
}
module.exports = { planNext, seekModeFor, attemptKey, NATIVE_SEEK_MAX, FATAL, NEEDS_ACCOUNT, BLOCKED, CLIENT_ISSUES };
+219
View File
@@ -0,0 +1,219 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/settings.js
* Description: Réglages YouTube / yt-dlp (data/youtube/settings.json), les variables d'environnement l'emportent
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-Settings");
const { __glob } = require('../utils/GlobalVars');
const { readJson, writeJson, emitUpdate } = require('./util');
const DEFAULTS = Object.freeze({
// fallback : sans compte d'abord, cookies seulement après une vérification anti-robot / âge / connexion
cookiesMode: "fallback",
// Évite d'utiliser le compte depuis les IP tournantes de WARP (sauf si c'est la seule route)
cookiesOnProxy: false,
// "" = clients par défaut de yt-dlp (recommandé)
playerClients: "",
// Une tentative de plus avec ces clients après un 403 / format indisponible / SABR / PO token
fallbackPlayerClients: "web_safari,tv_downgraded",
potProviderUrl: "",
forceIpv4: true,
autoUpdate: true,
seekStrategy: "auto",
testUrl: "https://www.youtube.com/watch?v=jNQXAC9IVRw"
});
const ENV = {
cookiesMode: "YTDLP_COOKIES_MODE",
cookiesOnProxy: "YTDLP_COOKIES_ON_PROXY",
playerClients: "YTDLP_PLAYER_CLIENTS",
fallbackPlayerClients: "YTDLP_FALLBACK_PLAYER_CLIENTS",
potProviderUrl: "YTDLP_POT_PROVIDER",
forceIpv4: "YTDLP_FORCE_IPV4",
autoUpdate: "YTDLP_AUTO_UPDATE",
seekStrategy: "YTDLP_SEEK_STRATEGY",
testUrl: "YTDLP_TEST_URL"
};
const YOUTUBE_HOSTS = /^(?:www\.|m\.|music\.)?youtube\.com$|^youtu\.be$/i;
function invalid(message) {
const err = new Error(message);
err.code = "INVALID_PARAMS";
return err;
}
function toBoolean(key, value) {
if (typeof value === 'boolean') return value;
if (/^(true|1|yes|on)$/i.test(String(value).trim())) return true;
if (/^(false|0|no|off)$/i.test(String(value).trim())) return false;
throw invalid(`${key} : valeur booléenne attendue`);
}
function toClients(key, value) {
const text = String(value ?? '').trim().toLowerCase().replace(/\s+/g, '');
if (text.length > 200 || !/^[a-z_,\-]*$/.test(text)) {
throw invalid(`${key} : liste de clients invalide (lettres, "_", "-" et virgules uniquement)`);
}
return text.split(',').filter(Boolean).join(',');
}
const VALIDATORS = {
cookiesMode: (value) => {
if (!["fallback", "always", "never"].includes(value)) throw invalid("cookiesMode : fallback, always ou never attendu");
return value;
},
cookiesOnProxy: (value) => toBoolean("cookiesOnProxy", value),
playerClients: (value) => toClients("playerClients", value),
fallbackPlayerClients: (value) => toClients("fallbackPlayerClients", value),
potProviderUrl: (value) => {
const text = String(value ?? '').trim();
if (!text) return "";
let parsed;
try { parsed = new URL(text); } catch (e) { throw invalid("potProviderUrl : URL invalide"); }
if (!/^https?:$/.test(parsed.protocol) || /[\s;"']/.test(text)) throw invalid("potProviderUrl : URL http(s) attendue");
return text.replace(/\/+$/, '');
},
forceIpv4: (value) => toBoolean("forceIpv4", value),
autoUpdate: (value) => toBoolean("autoUpdate", value),
seekStrategy: (value) => {
if (!["auto", "ffmpeg", "native"].includes(value)) throw invalid("seekStrategy : auto, ffmpeg ou native attendu");
return value;
},
testUrl: (value) => {
const text = String(value ?? '').trim();
let parsed;
try { parsed = new URL(text); } catch (e) { throw invalid("testUrl : URL invalide"); }
if (!/^https?:$/.test(parsed.protocol) || !YOUTUBE_HOSTS.test(parsed.hostname) || /\s/.test(text)) {
throw invalid("testUrl : lien YouTube http(s) attendu");
}
return text;
}
};
let fileValues = null;
let envValues = null;
function loadFile() {
if (fileValues) return fileValues;
const raw = readJson(__glob.YOUTUBE_SETTINGS, {});
fileValues = {};
for (const key of Object.keys(DEFAULTS)) {
if (raw[key] === undefined) continue;
try {
fileValues[key] = VALIDATORS[key](raw[key]);
} catch (e) {
clog.warn(`Réglage ignoré dans settings.json : ${e.message}`);
}
}
return fileValues;
}
function loadEnv() {
if (envValues) return envValues;
envValues = {};
for (const [key, name] of Object.entries(ENV)) {
if (process.env[name] === undefined || process.env[name] === '') continue;
try {
envValues[key] = VALIDATORS[key](process.env[name]);
} catch (e) {
clog.warn(`Variable d'environnement ${name} ignorée : ${e.message}`);
}
}
return envValues;
}
/**
* Valeurs effectives (défaut < settings.json < environnement)
*/
function get() {
return { ...DEFAULTS, ...loadFile(), ...loadEnv() };
}
function isLocked(key) {
return Object.prototype.hasOwnProperty.call(loadEnv(), key);
}
/**
* URL sans identifiants éventuels (pour l'affichage)
*/
function maskUrl(url) {
if (!url) return "";
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
parsed.username = "***";
parsed.password = "";
}
return parsed.toString().replace(/\/$/, '');
} catch (e) {
return "***";
}
}
/**
* Pour l'administration : { clé: { value, default, locked, env } } (URL du fournisseur PO token masquée)
*/
function getPublic() {
const values = get();
const result = {};
for (const key of Object.keys(DEFAULTS)) {
const value = key === "potProviderUrl" ? maskUrl(values[key]) : values[key];
result[key] = { value, default: DEFAULTS[key], locked: isLocked(key), env: ENV[key] };
}
return result;
}
/**
* Vérifie un correctif sans l'appliquer (clés inconnues ou verrouillées refusées)
* @param {Object} patch
* @returns {Object} valeurs normalisées
*/
function validate(patch) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid("Réglages invalides");
const clean = {};
for (const [key, value] of Object.entries(patch)) {
if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) throw invalid(`Réglage inconnu : ${key}`);
if (isLocked(key)) throw invalid(`${key} est défini par la variable d'environnement ${ENV[key]}`);
clean[key] = VALIDATORS[key](value);
}
return clean;
}
/**
* Applique et enregistre un correctif de réglages
* @param {Object} patch
* @param {{id?: string, username?: string}|null} by
* @returns {Object} getPublic()
*/
function update(patch, by = null) {
const clean = validate(patch);
const changed = Object.keys(clean).filter(key => get()[key] !== clean[key]);
if (changed.length === 0) return getPublic();
const next = { ...loadFile(), ...clean };
writeJson(__glob.YOUTUBE_SETTINGS, next);
fileValues = next;
clog.log(`Réglages YouTube modifiés${by?.username ? ` par ${by.username}` : ''} : ${changed.join(', ')}`);
emitUpdate();
return getPublic();
}
/**
* Relit settings.json et l'environnement (tests)
*/
function reload() {
fileValues = null;
envValues = null;
}
/**
* Liste de clients yt-dlp normalisée (test de lecture), lève INVALID_PARAMS si invalide
*/
function normalizeClients(value) {
return toClients("clients", value);
}
module.exports = { get, getPublic, update, validate, normalizeClients, isLocked, reload, maskUrl, DEFAULTS, ENV };
+97
View File
@@ -0,0 +1,97 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/util.js
* Description: Petits utilitaires partagés (fichiers, événements, recherche dans le PATH)
*/
const fs = require('fs');
const path = require('path');
const isWindows = process.platform === 'win32';
/**
* Écriture atomique : fichier temporaire puis renommage (jamais de fichier tronqué).
* Sous Windows / OneDrive le renommage peut échouer (fichier verrouillé) : on retente puis on écrit directement.
* @param {string} filePath
* @param {string} content
* @param {number} [mode] - permissions (ignorées sous Windows)
*/
function writeFileAtomic(filePath, content, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.tmp`;
const options = mode ? { mode } : undefined;
fs.writeFileSync(tmp, content, options);
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.renameSync(tmp, filePath);
if (mode) {
try { fs.chmodSync(filePath, mode); } catch (e) { /* non supporté (Windows) */ }
}
return;
} catch (e) {
// Fichier verrouillé : nouvel essai
}
}
// Repli : écriture directe (non atomique) puis nettoyage du temporaire
fs.writeFileSync(filePath, content, options);
try { fs.unlinkSync(tmp); } catch (e) { /* temporaire déjà absent */ }
}
/**
* Lit un fichier JSON, ou renvoie la valeur par défaut s'il est absent / illisible
*/
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
return data && typeof data === 'object' ? data : fallback;
} catch (e) {
return fallback;
}
}
function writeJson(filePath, data) {
writeFileAtomic(filePath, JSON.stringify(data, null, 2));
}
/**
* Prévient l'administration qu'un état visible a changé (Admin.js relaie, avec anti-rebond)
*/
function emitUpdate() {
try {
process.emit("YOUTUBE_UPDATE");
} catch (e) {
// Un écouteur défaillant ne doit jamais casser la lecture
}
}
/**
* Cherche un exécutable dans le PATH sans le lancer.
* Sous Windows seuls les .exe/.com sont retenus (un .cmd/.bat demanderait un shell).
* @param {string} name - nom sans extension
* @returns {string|null}
*/
function findOnPath(name) {
const dirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
const exts = isWindows ? ['.exe', '.com'] : [''];
for (const dir of dirs) {
for (const ext of exts) {
const candidate = path.join(dir.replace(/^"|"$/g, ''), name + ext);
try {
const stat = fs.statSync(candidate);
if (!stat.isFile()) continue;
if (!isWindows) fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch (e) {
// Absent ou non exécutable : dossier suivant
}
}
}
return null;
}
function toIso(ms) {
return ms ? new Date(ms).toISOString() : null;
}
module.exports = { isWindows, writeFileAtomic, readJson, writeJson, emitUpdate, findOnPath, toIso };
+457
View File
@@ -0,0 +1,457 @@
/**
* [Subsonics Chopin - Backend] - Module YouTube
* File: youtube/ytdlp.js
* Description: Binaires yt-dlp / ffmpeg, arguments communs, lancement et arrêt des processus, mises à jour
*/
const { LogType } = require('loguix');
const clog = new LogType("Youtube-YtDlp");
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, execFile } = require('child_process');
const { __glob } = require('../utils/GlobalVars');
const settings = require('./settings');
const diagnostics = require('./diagnostics');
const { sanitize } = require('./errors');
const { isWindows, findOnPath, emitUpdate, toIso } = require('./util');
const KILL_TIMEOUT = 2000;
const VERSION_TTL_OK = 6 * 60 * 60 * 1000;
const VERSION_TTL_FAIL = 60 * 1000;
const UPDATE_TIMEOUT = 3 * 60 * 1000;
const AUTO_UPDATE_INTERVAL = 24 * 60 * 60 * 1000;
const FAILURE_UPDATE_MIN_INTERVAL = 6 * 60 * 60 * 1000;
const FAILURE_STREAK = 5;
const OUTPUT_CAP = 256 * 1024;
// Windows : yt-dlp.exe (PyInstaller) s'extrait dans %TEMP%_MEIxxxx et ne nettoie pas s'il est tué de force (~25 Mo à chaque fois).
// On lui donne un dossier temporaire dédié, vidé dès qu'aucun yt-dlp ne tourne.
const WIN_TEMP = path.join(os.tmpdir(), 'chopin-ytdlp-tmp');
// Tous les processus lancés par le module (lecture, vérifications, mises à jour)
const children = new Set();
let binaryCache = null;
let ffmpegCache = undefined;
const versionCache = { version: null, at: 0, ok: false, pending: null };
let updating = null;
let lastUpdate = null;
let autoTimer = null;
let lastFailureUpdateAt = 0;
// --- Binaires ---
/**
* Binaire yt-dlp : YTDLP_PATH, puis PATH, puis celui de youtube-dl-exec (poste de dev Windows)
* @param {{force?: boolean}} [options]
* @returns {{path: string, source: "env"|"path"|"bundled"|"missing"}}
*/
function resolveBinary({ force = false } = {}) {
if (binaryCache && !force) return binaryCache;
const fromEnv = process.env.YTDLP_PATH;
if (fromEnv) {
if (fs.existsSync(fromEnv)) {
binaryCache = { path: fromEnv, source: "env" };
return binaryCache;
}
clog.warn(`YTDLP_PATH ne pointe vers aucun fichier, recherche dans le PATH`);
}
const onPath = findOnPath("yt-dlp");
if (onPath) {
binaryCache = { path: onPath, source: "path" };
return binaryCache;
}
const bundled = path.join(__glob.ROOT, "node_modules", "youtube-dl-exec", "bin", isWindows ? "yt-dlp.exe" : "yt-dlp");
if (fs.existsSync(bundled)) {
binaryCache = { path: bundled, source: "bundled" };
return binaryCache;
}
// Introuvable : le lancement échouera (ENOENT) et sera classé BINARY_MISSING
binaryCache = { path: "yt-dlp", source: "missing" };
return binaryCache;
}
/**
* ffmpeg : PATH, puis ffmpeg-static
* @returns {{path: string, source: "path"|"ffmpeg-static"}|null}
*/
function resolveFfmpeg({ force = false } = {}) {
if (ffmpegCache !== undefined && !force) return ffmpegCache;
const onPath = findOnPath("ffmpeg");
if (onPath) {
ffmpegCache = { path: onPath, source: "path" };
return ffmpegCache;
}
try {
const staticPath = require('ffmpeg-static');
if (staticPath && fs.existsSync(staticPath)) {
ffmpegCache = { path: staticPath, source: "ffmpeg-static" };
return ffmpegCache;
}
} catch (e) {
// Module absent
}
ffmpegCache = null;
return ffmpegCache;
}
// --- Arguments ---
/**
* Arguments communs à toutes les commandes yt-dlp
* @param {{proxy?: string|null, cookiesPath?: string|null, playerClients?: string}} options
* @returns {string[]}
*/
function baseArgs({ proxy = null, cookiesPath = null, playerClients = "" } = {}) {
const s = settings.get();
const args = [
'--no-playlist',
'--no-progress',
// Résolution des défis JS (n / signature) avec le Node.js du bot
'--js-runtimes', 'node',
// Cache persistant (JS du lecteur, jetons) : moins de requêtes à chaque lecture
'--cache-dir', __glob.YOUTUBE_CACHE,
'--retries', '3',
'--fragment-retries', '3',
'--extractor-retries', '2',
'--retry-sleep', 'http:exp=1:8',
];
// Évite les écarts IPv4 / IPv6 entre extraction et téléchargement (URL liées à l'IP)
if (s.forceIpv4) args.push('-4');
const ffmpeg = resolveFfmpeg();
if (ffmpeg) args.push('--ffmpeg-location', ffmpeg.path);
if (s.potProviderUrl) {
args.push('--extractor-args', `youtubepot-bgutilhttp:base_url=${s.potProviderUrl}`);
}
if (playerClients) {
args.push('--extractor-args', `youtube:player_client=${playerClients}`);
}
if (proxy) args.push('--proxy', proxy);
if (cookiesPath) args.push('--cookies', cookiesPath);
return args;
}
// --- Processus ---
/**
* Lance un processus suivi (groupe dédié sous Linux pour pouvoir tuer ses enfants)
* @param {string} command
* @param {string[]} args
* @returns {import('child_process').ChildProcess}
*/
function spawnTracked(command, args, { env } = {}) {
const child = spawn(command, args, {
detached: !isWindows,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
env
});
children.add(child);
const forget = () => {
if (!children.delete(child)) return;
// Les fichiers restent verrouillés quelques instants après la mort du processus
if (isWindows && ytdlpChildren.delete(child) && ytdlpChildren.size === 0) setTimeout(sweepWindowsTemp, 1500).unref?.();
};
child.once('close', forget);
child.once('error', forget);
return child;
}
const ytdlpChildren = new Set();
/**
* Lance yt-dlp avec le binaire résolu
*/
function spawnYtdlp(args) {
let env;
if (isWindows) {
try { fs.mkdirSync(WIN_TEMP, { recursive: true }); } catch (e) { /* dossier temporaire par défaut */ }
env = { ...process.env, TEMP: WIN_TEMP, TMP: WIN_TEMP };
}
const child = spawnTracked(resolveBinary().path, args, { env });
if (isWindows) ytdlpChildren.add(child);
return child;
}
/**
* Windows : supprime les extractions PyInstaller laissées par les yt-dlp tués (uniquement quand aucun ne tourne)
*/
function sweepWindowsTemp() {
if (!isWindows || ytdlpChildren.size > 0) return;
let entries;
try { entries = fs.readdirSync(WIN_TEMP); } catch (e) { return; }
for (const entry of entries) {
try { fs.rmSync(path.join(WIN_TEMP, entry), { recursive: true, force: true }); } catch (e) { /* encore verrouillé */ }
}
}
/**
* Tue un processus et ses enfants, puis attend sa fin réelle (2 s max)
* @param {import('child_process').ChildProcess} proc
* @returns {Promise<void>}
*/
function killTree(proc) {
return new Promise((resolve) => {
if (!proc || !proc.pid || proc.exitCode !== null || proc.signalCode !== null) return resolve();
const pid = proc.pid;
const timer = setTimeout(resolve, KILL_TIMEOUT);
proc.once('close', () => {
clearTimeout(timer);
resolve();
});
if (isWindows) {
// taskkill /T (arbre) /F (forcé) : tue aussi les enfants (ffmpeg de --download-sections)
try {
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, (error) => {
if (error && proc.exitCode === null) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
});
} catch (e) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
} else {
// Lancé en "detached" : chef de son groupe, on tue tout le groupe
try {
process.kill(-pid, 'SIGKILL');
} catch (e) {
try { proc.kill('SIGKILL'); } catch (e2) { /* déjà mort */ }
}
}
});
}
function activeCount() {
return children.size;
}
// Arrêt du bot : les processus détachés ne doivent pas survivre
process.once('exit', () => {
for (const child of children) {
try {
if (!isWindows && child.pid) process.kill(-child.pid, 'SIGKILL');
else child.kill('SIGKILL');
} catch (e) { /* déjà mort */ }
}
});
function appendCapped(current, chunk) {
const next = current + chunk;
// On garde la fin (les erreurs de yt-dlp arrivent en dernier)
return next.length > OUTPUT_CAP ? next.slice(-OUTPUT_CAP) : next;
}
/**
* Commande yt-dlp courte (version, vérification, test) avec sorties plafonnées
* @param {string[]} args
* @param {{timeoutMs?: number, signal?: AbortSignal, command?: string}} [options]
* @returns {Promise<{code: number|null, stdout: string, stderr: string, timedOut: boolean, aborted: boolean, error: string|null, ms: number}>}
*/
function run(args, { timeoutMs = 60000, signal, command } = {}) {
return new Promise((resolve) => {
const started = Date.now();
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let done = false;
let child;
const finish = (code, error = null) => {
if (done) return;
done = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
resolve({ code, stdout, stderr, timedOut, aborted, error, ms: Date.now() - started });
};
const onAbort = () => {
aborted = true;
killTree(child);
};
const timer = setTimeout(() => {
timedOut = true;
stderr += `\nERROR: délai dépassé (${Math.round(timeoutMs / 1000)} s, timed out)`;
killTree(child);
}, timeoutMs);
try {
child = command ? spawnTracked(command, args) : spawnYtdlp(args);
} catch (e) {
return finish(null, e.code || e.message);
}
child.stdin?.end();
if (signal?.aborted) onAbort();
else signal?.addEventListener('abort', onAbort, { once: true });
child.stdout.on('data', (d) => { stdout = appendCapped(stdout, d.toString()); });
child.stderr.on('data', (d) => { stderr = appendCapped(stderr, d.toString()); });
child.on('error', (err) => {
if (err.code === 'ENOENT') {
stderr += `\nERROR: yt-dlp introuvable (ENOENT)`;
if (!command) binaryCache = null;
}
finish(null, err.code || err.message);
});
child.on('close', (code) => finish(code));
});
}
// --- Version et mises à jour ---
/**
* Version de yt-dlp (cache : 6 h si connue, 1 min en cas d'échec)
* @param {{force?: boolean}} [options]
* @returns {Promise<string|null>}
*/
function getVersion({ force = false } = {}) {
const ttl = versionCache.ok ? VERSION_TTL_OK : VERSION_TTL_FAIL;
if (!force && versionCache.at && Date.now() - versionCache.at < ttl) return Promise.resolve(versionCache.version);
if (versionCache.pending) return versionCache.pending;
versionCache.pending = run(['--version'], { timeoutMs: 20000 }).then((result) => {
const version = result.code === 0 ? (result.stdout.trim().split(/\r?\n/)[0] || null) : null;
versionCache.version = version;
versionCache.ok = Boolean(version);
versionCache.at = Date.now();
return version;
}).finally(() => {
versionCache.pending = null;
});
return versionCache.pending;
}
function getCachedVersion() {
return versionCache.version;
}
function tail(text, max = 1500) {
const clean = sanitize(text).trim();
return clean.length > max ? "..." + clean.slice(-max) : clean;
}
/**
* Met à jour yt-dlp (yt-dlp -U). Un seul appel à la fois.
* @param {{reason?: string}} [options]
* @returns {Promise<{ok: boolean, updatable: boolean, before: string|null, after: string|null, output: string, at: string, reason: string, message: string}>}
*/
function update({ reason = "manuel" } = {}) {
if (updating) return updating;
updating = (async () => {
emitUpdate();
const before = await getVersion({ force: true });
const bin = resolveBinary();
clog.log(`Mise à jour de yt-dlp (${reason}) - version actuelle : ${before ?? "inconnue"} (${bin.source})`);
const result = await run(['-U'], { timeoutMs: UPDATE_TIMEOUT });
const output = `${result.stdout}\n${result.stderr}`;
const after = await getVersion({ force: true });
// Installation par pip / gestionnaire de paquets : yt-dlp refuse de se mettre à jour lui-même
const notUpdatable = /installed yt-dlp with|package manager|setup\.py|Use that to update|not updatable|cannot be updated|self-update is not supported/i.test(output);
const upToDate = /is up to date/i.test(output);
const ok = !notUpdatable && result.code === 0;
let message;
if (result.error === 'ENOENT') message = "yt-dlp introuvable";
else if (notUpdatable) message = "Cette installation de yt-dlp ne peut pas se mettre à jour elle-même (pip / paquet système)";
else if (!ok) {
// Ex. : limite de l'API GitHub atteinte ("HTTP Error 403: rate limit exceeded")
const errorLine = output.split(/\r?\n/).reverse().find(l => /^ERROR:/i.test(l.trim()));
message = result.timedOut
? "Délai de mise à jour dépassé"
: `Échec de la mise à jour (code ${result.code})${errorLine ? ` : ${sanitize(errorLine.trim().replace(/^ERROR:\s*/i, '')).slice(0, 200)}` : ''}`;
}
else if (before && after && before !== after) message = `Mise à jour : ${before} → ${after}`;
else message = upToDate || before === after ? "yt-dlp est déjà à jour" : "Mise à jour terminée";
lastUpdate = {
at: new Date().toISOString(),
reason,
before,
after,
ok,
updatable: !notUpdatable,
message,
output: tail(output)
};
if (ok) clog.log(`yt-dlp : ${message}`);
else clog.warn(`yt-dlp : ${message}`);
return lastUpdate;
})().finally(() => {
updating = null;
emitUpdate();
});
return updating;
}
function isUpdating() {
return Boolean(updating);
}
function getLastUpdate() {
return lastUpdate;
}
/**
* Mise à jour au démarrage puis toutes les 24 h (si autoUpdate est actif au moment du passage)
*/
function startAutoUpdate() {
if (autoTimer) return;
const tick = () => {
if (!settings.get().autoUpdate) return;
update({ reason: "automatique" }).catch((e) => clog.error(`Mise à jour automatique impossible : ${e?.message || e}`));
};
const first = setTimeout(tick, 5000);
first.unref?.();
autoTimer = setInterval(tick, AUTO_UPDATE_INTERVAL);
autoTimer.unref?.();
}
/**
* Après une série d'échecs de type blocage (403, anti-robot...), une version récente corrige souvent le problème.
* Au plus une fois toutes les 6 h.
* @returns {boolean} true si une mise à jour a été lancée
*/
function maybeUpdateAfterFailures() {
const streak = diagnostics.getConsecutiveBlocks();
if (streak < FAILURE_STREAK || !settings.get().autoUpdate || updating) return false;
if (lastFailureUpdateAt && Date.now() - lastFailureUpdateAt < FAILURE_UPDATE_MIN_INTERVAL) return false;
lastFailureUpdateAt = Date.now();
clog.warn(`${streak} échecs de lecture YouTube d'affilée (blocage) : mise à jour de yt-dlp`);
update({ reason: "échecs répétés" }).catch(() => {});
return true;
}
/**
* Informations pour l'administration
*/
function getInfo() {
const bin = resolveBinary();
const ffmpeg = resolveFfmpeg();
return {
ytdlp: {
path: bin.path,
source: bin.source,
version: versionCache.version,
versionCheckedAt: toIso(versionCache.at),
updating: isUpdating(),
lastUpdate,
autoUpdate: settings.get().autoUpdate
},
ffmpeg: ffmpeg ? { path: ffmpeg.path, source: ffmpeg.source } : { path: null, source: "missing" }
};
}
module.exports = {
resolveBinary, resolveFfmpeg, baseArgs,
spawnTracked, spawnYtdlp, killTree, activeCount, run,
getVersion, getCachedVersion, update, isUpdating, getLastUpdate, startAutoUpdate, maybeUpdateAfterFailures,
getInfo, sweepWindowsTemp
};
+1
View File
@@ -0,0 +1 @@
logs/
+10
View File
@@ -0,0 +1,10 @@
# Tests de développement (ad hoc)
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `stream.edge.js`
- Réseau (YouTube / GitHub réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git).
+46
View File
@@ -0,0 +1,46 @@
// Environnement de test : loguix dans le scratchpad, données YouTube isolées (jamais backend/data/youtube)
const path = require('path');
const fs = require('fs');
const os = require('os');
const BACKEND = require("path").resolve(__dirname, "../..");
// Données jetables hors du dépôt
const SCRATCH = path.join(os.tmpdir(), 'chopin-tests');
fs.mkdirSync(SCRATCH, { recursive: true });
const LOGS = path.join(__dirname, 'logs');
fs.mkdirSync(LOGS, { recursive: true });
const loguix = require(path.join(BACKEND, 'node_modules', 'loguix'));
loguix.setup(LOGS, path.join(BACKEND, 'package.json'));
const { __glob } = require(path.join(BACKEND, 'src', 'utils', 'GlobalVars'));
function isolateData(name = 'ytdata') {
const dir = fs.mkdtempSync(path.join(SCRATCH, `${name}-`));
__glob.YOUTUBE_DIR = dir;
__glob.YOUTUBE_COOKIES = path.join(dir, 'cookies.txt');
__glob.YOUTUBE_STATE = path.join(dir, 'state.json');
__glob.YOUTUBE_SETTINGS = path.join(dir, 'settings.json');
// Le cache yt-dlp peut rester partagé entre les essais (dans le scratchpad)
__glob.YOUTUBE_CACHE = path.join(SCRATCH, 'ytcache');
// Par défaut aucun ancien fichier : chaque test choisit
__glob.COOKIES = path.join(dir, 'legacy-none.txt');
__glob.COOKIES_JSON = path.join(dir, 'legacy-none.json');
return dir;
}
let failures = 0, passes = 0;
function check(name, cond, extra) {
if (cond) { passes++; console.log(` ok ${name}`); }
else { failures++; console.log(` FAIL ${name}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`); }
}
function done() {
console.log(`\n${passes} ok, ${failures} échec(s)`);
process.exitCode = failures ? 1 : 0;
}
function req(rel) { return require(path.join(BACKEND, rel)); }
function chopinTmpDirs() {
return fs.readdirSync(os.tmpdir()).filter(e => e.startsWith('chopin-ytc-'));
}
module.exports = { BACKEND, SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs };
+356
View File
@@ -0,0 +1,356 @@
// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes)
// de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les
// vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier
// temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé).
const Module = require('module');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup');
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
const DATA_DIR = path.join(BACKEND, 'data');
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-'));
for (const [key, value] of Object.entries(__glob)) {
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
}
}
__glob.DATA = tmp;
isolateData('admin-config-yt');
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); }
process.env.YTDLP_AUTO_UPDATE = 'false';
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
// Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log
const SECRETS = {
token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123',
client_secret: 'FAKECLIENTSECRET_9876543210',
youtube: 'FAKEYTSECRET_aaaabbbbcccc',
spotify: 'FAKESPOTIFYSECRET_ddddeeee',
refresh: 'FAKEREFRESHTOKEN_ffffgggg',
proxyPass: 'FAKEPROXYPASS_hhhh'
};
fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({
token: SECRETS.token,
client_secret: SECRETS.client_secret,
report: { channel: '123456789012345678', contact: '' },
api: {
youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube },
spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify }
},
website: 'https://chopin.example.org',
server_port: 5000,
media: { guildId: '', channelId: '' }
}, null, 2));
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const owner = {
identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [],
isAdmin: () => false, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
// Player simulé : loop / shuffle comptés pour vérifier l'idempotence
const playerCalls = { setLoop: 0, setShuffle: 0 };
const fakePlayer = {
guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} },
isConnected: () => true,
async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; },
async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; },
getState() {
return {
guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle,
current: { id: 'song-b', title: 'B' },
lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() },
next: [], previous: []
};
}
};
const AllPlayers = new Map([['222222', fakePlayer]]);
const removed = [];
const stubs = {
[norm(SRC + '/server/auth/User')]: {
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
getUsers: () => [admin, owner]
},
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers },
[norm(SRC + '/discord/Bot')]: {
getGuilds: () => guilds, getClient: () => null, isReady: () => false,
getChannel: () => null
},
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {
removePlaylist(userId, playlistId) {
const list = getDatabase('Playlists').data[userId] || [];
const index = list.findIndex(p => String(p.playlistId) === String(playlistId));
if (index === -1) return false;
list.splice(index, 1);
removed.push({ userId, playlistId });
return true;
}
},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
};
// Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué)
let spotifyMode = 'reject';
class FakeSpotify {
constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; }
async clientCredentialsGrant() {
if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } };
throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } });
}
}
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request === 'spotify-web-api-node') return FakeSpotify;
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const { Database, getDatabase } = req('src/utils/Database/Database.js');
// Bases réelles dans le dossier temporaire
const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {});
playlistsDb.data = {
'333333': [
{ playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 },
{ playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' },
{ playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] }
],
'444444': []
};
const googleDb = new Database('google', path.join(tmp, 'google.json'), {});
googleDb.data = {
'333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' },
'555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true }
};
const configuration = req('src/utils/Database/Configuration.js');
const warp = req('src/utils/WarpProxy.js');
const Admin = req('src/server/Admin.js');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
in: () => ({ socketsLeave() {} }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s));
const configEvents = [];
process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload));
const playlistEvents = [];
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
(async () => {
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE'];
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
console.log('# Configuration : validateur pur');
const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
check('clé inconnue refusée', bad('api.discord.secret', 'x'));
check('port non numérique refusé', bad('server_port', 'abc'));
check('port hors limites refusé', bad('server_port', 70000));
check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000);
check('site javascript: refusé', bad('website', 'javascript:alert(1)'));
check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org'));
check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1'));
check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin');
check('snowflake trop court refusé', bad('report.channel', '123'));
check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === '');
check('token vide refusé', bad('token', ' '));
check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def'));
check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef'));
check('type non texte refusé', bad('api.spotify.clientId', 42));
console.log('# CONFIG/GET');
let r = await call('/ADMIN/CONFIG/GET', {});
check('GET ok', r.ok, r);
check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r));
const cfg = r.data.config;
check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg));
check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token);
check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret']));
check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid');
check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false);
const integ = r.data.integrations;
check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google);
check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google);
check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null);
check('WARP non configuré', integ.warp.configured === false);
console.log('# CONFIG/SET');
r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' });
check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' });
check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website' });
check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' });
check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r);
check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents);
emitted.length = 0;
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' });
check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r);
check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz'));
check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents);
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8'));
check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token);
check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz');
await sleep(450);
const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE');
check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz'));
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' });
check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 });
check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' });
check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' });
check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google);
r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' });
check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === '');
r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' });
check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
console.log('# CONFIG/SPOTIFY/TEST');
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r);
check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***'));
check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false);
spotifyMode = 'ok';
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' });
check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' });
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
console.log('# WARP/CONFIG');
const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
const noLocks = { url: false, enabled: false, fallbackDirect: false };
check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks));
check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks));
check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks));
check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks));
check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks));
check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080');
check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null);
check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true }));
r = await call('/ADMIN/WARP/CONFIG', {});
check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' });
check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r);
r = await call('/ADMIN/WARP/CONFIG', { enabled: true });
check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
// Désactivé : aucun contrôle réseau
r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false });
check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r);
check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file);
check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false);
const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8'));
check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true });
check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true);
process.env.WARP_PROXY = 'http://127.0.0.1:9';
process.env.WARP_FALLBACK_DIRECT = 'true';
r = await call('/ADMIN/WARP/STATUS', {});
check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked);
r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' });
check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false });
check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r);
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
r = await call('/ADMIN/WARP/CONFIG', { url: '' });
check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status);
console.log('# PLAYLISTS');
r = await call('/ADMIN/PLAYLISTS/LIST', {});
check('LIST ok', r.ok && Array.isArray(r.data), r);
check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333');
const entry = r.data[0];
check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner);
const lists = entry.playlists;
check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists);
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' });
check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' });
check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r);
check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents);
console.log('# Players / comptes (correctifs)');
r = await call('/ADMIN/PLAYERS/LIST', {});
check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true });
check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false });
check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false });
check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' });
check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' });
check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
console.log('# Journaux');
await sleep(300);
const logDir = path.join(__dirname, 'logs');
const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n');
const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s));
check('aucun secret dans les logs', logged.length === 0, logged);
check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp')));
fs.rmSync(tmp, { recursive: true, force: true });
done();
setTimeout(() => process.exit(process.exitCode || 0), 50);
})().catch((e) => { console.error(e); process.exit(1); });
+247
View File
@@ -0,0 +1,247 @@
// Routes "/ADMIN/YOUTUBE/*" de backend/src/server/Admin.js, appelées par un faux socket.
// Dépendances lourdes (bot Discord, utilisateurs, players, bases...) simulées ; données YouTube isolées
// dans le scratchpad (jamais backend/data). Aucun appel réseau : yt-dlp (run/update/check/test) est simulé.
const Module = require('module');
const path = require('path');
const fs = require('fs');
const { BACKEND, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('admin-yt');
process.env.YTDLP_AUTO_UPDATE = 'false';
process.env.YTDLP_COOKIES_ON_PROXY = 'false'; // verrouillé par l'environnement
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
const stubs = {
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : null, getUsers: () => [admin] },
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => null, getMediaChannelId: () => null },
[norm(SRC + '/utils/Database/Database')]: { getDatabase: () => null },
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map([['222222', { pid: 1 }]]) },
};
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const Admin = req('src/server/Admin.js');
const youtube = req('src/youtube');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Cookies SYNTHÉTIQUES (valeurs factices), domaine YouTube + un domaine étranger
const SECRET = 'FAKESECRET';
const exp = Math.floor(Date.now() / 1000) + 30 * 86400;
const line = (d, n, v) => [d, 'TRUE', '/', 'TRUE', String(exp), n, v].join('\t');
const COOKIES_OK = ['# Netscape HTTP Cookie File',
line('.youtube.com', 'LOGIN_INFO', SECRET + '_login'),
line('.youtube.com', 'SAPISID', SECRET + '_sapisid'),
line('.youtube.com', '__Secure-3PAPISID', SECRET + '_3p'),
line('.youtube.com', 'SID', SECRET + '_sid'),
line('.example.org', 'tracker', SECRET + '_other')].join('\r\n');
const COOKIES_ANON = ['# Netscape HTTP Cookie File', line('.youtube.com', 'YSC', SECRET + '_ysc')].join('\n');
(async () => {
const expectedRoutes = ['STATUS', 'COOKIES/IMPORT', 'COOKIES/BROWSER', 'COOKIES/CHECK', 'COOKIES/DELETE', 'SETTINGS', 'TEST', 'UPDATE', 'DIAGNOSTICS/CLEAR'];
check('routes enregistrées', expectedRoutes.every(r => handlers.has('/ADMIN/YOUTUBE/' + r)));
// Vérification en direct simulée (pas de réseau)
let checkCalls = 0;
youtube.check.checkCookies = async () => {
checkCalls++;
const result = { at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'Compte reconnu', cls: null, route: 'direct' };
youtube.cookies.setLastCheck(result);
return { ...result, rotated: false, ms: 5, status: youtube.cookies.getStatus() };
};
console.log('# STATUS');
let r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('STATUS ok', r.ok, r);
check('STATUS forme', r.data && r.data.ytdlp && r.data.cookies && r.data.settings && r.data.diagnostics && Array.isArray(r.data.recent));
check('aucun cookie au départ', r.data.cookies.present === false);
check('cookiesOnProxy verrouillé', r.data.settings.cookiesOnProxy.locked === true);
console.log('# IMPORT');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: '' });
check('contenu vide refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'x'.repeat(1000001) });
check('contenu > 1 000 000 refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'browser:firefox' });
check('source interdite refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'du texte qui ne ressemble à rien' });
check('contenu illisible -> INVALID_PARAMS avec message FR', !r.ok && r.error === 'INVALID_PARAMS' && /cookie/i.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_ANON, source: 'paste' });
check('import sans compte : ok + avertissement', r.ok && r.data.warnings.some(w => /connexion/i.test(w)) && r.data.checking === false, r.data?.warnings);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'file' });
check('import connecté ok', r.ok && r.data.status.cookies.loggedIn && r.data.status.cookies.valid, r);
check('avertissement domaine étranger', r.ok && r.data.warnings.some(w => /autres sites/.test(w)), r.data?.warnings);
check('vérification lancée', r.data.checking === true);
check('source / auteur enregistrés', r.data.status.cookies.source === 'file' && r.data.status.cookies.importedBy?.username === 'testadmin');
check('aucune valeur de cookie dans la réponse', !JSON.stringify(r).includes(SECRET));
check('fichier maître dans le dossier isolé', fs.existsSync(path.join(dataDir, 'cookies.txt')));
await sleep(50);
check('checkCookies appelé', checkCalls === 1);
console.log('# Poussée');
emitted.length = 0;
await sleep(700);
const push = emitted.filter(e => e.ev === '/ADMIN/YOUTUBE/UPDATE');
check('poussée /ADMIN/YOUTUBE/UPDATE (coalescée)', push.length === 1 && push[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée contient lastCheck', push[0]?.data?.cookies?.lastCheck?.ok === true);
rooms.set('ADMIN', new Set());
emitted.length = 0;
process.emit('YOUTUBE_UPDATE');
await sleep(700);
check('pas de poussée si room ADMIN vide', emitted.length === 0);
rooms.set('ADMIN', new Set(['sock1']));
console.log('# CHECK');
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK ok', r.ok && r.data.result.ok === true && r.data.result.status === undefined && r.data.status.cookies.lastCheck, r);
console.log('# SETTINGS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'always', playerClients: 'web_safari, tv' } });
check('SETTINGS appliqués', r.ok && r.data.settings.cookiesMode.value === 'always' && r.data.settings.playerClients.value === 'web_safari,tv', r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesOnProxy: true } });
check('clé verrouillée refusée', !r.ok && r.error === 'INVALID_PARAMS' && /YTDLP_COOKIES_ON_PROXY/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { seekStrategy: 'n importe quoi' } });
check('valeur invalide refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { inconnu: 1 } });
check('clé inconnue refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: {} });
check('patch vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: [1] });
check('patch tableau refusé', !r.ok && r.error === 'INVALID_PARAMS');
// Retour aux valeurs par défaut
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'fallback', playerClients: '' } });
check('SETTINGS remis par défaut', r.ok && r.data.settings.cookiesMode.value === 'fallback' && r.data.settings.playerClients.value === '');
check('settings.json dans le dossier isolé', fs.existsSync(path.join(dataDir, 'settings.json')));
console.log('# TEST');
let testArgs = null;
const realTest = youtube.check.testPlayback;
youtube.check.testPlayback = async (opts) => { testArgs = opts; return { ok: true, route: 'direct', identity: 'anon', clients: '', cls: null, message: 'Extraction réussie', format: { id: '251' }, ms: 1, version: 'x' }; };
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'direct', identity: 'anon', url: '', clients: '' });
check('TEST transmis (url / clients vides -> réglages)', r.ok && testArgs.route === 'direct' && testArgs.url === undefined && testArgs.clients === undefined, testArgs);
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'moon' });
check('route invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { identity: 'root' });
check('identité invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { url: 'javascript:alert(1)' });
check('url non http refusée', !r.ok && r.error === 'INVALID_PARAMS');
youtube.check.testPlayback = async () => { const e = new Error('Un test de lecture est déjà en cours'); e.code = 'BUSY'; throw e; };
r = await call('/ADMIN/YOUTUBE/TEST', {});
check('BUSY -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
youtube.check.testPlayback = realTest;
// Vraie fonction, sans réseau : clients invalides -> INVALID_PARAMS
r = await call('/ADMIN/YOUTUBE/TEST', { clients: 'web;rm' });
check('clients invalides (vrai testPlayback) -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
console.log('# UPDATE');
youtube.ytdlp.update = async ({ reason }) => ({ ok: true, updatable: true, before: '1', after: '2', message: 'Mise à jour : 1 → 2', output: '', at: new Date().toISOString(), reason });
r = await call('/ADMIN/YOUTUBE/UPDATE', {});
check('UPDATE ok', r.ok && r.data.result.after === '2' && /testadmin/.test(r.data.result.reason) && r.data.status.ytdlp, r);
console.log('# BROWSER (yt-dlp simulé)');
const realRun = youtube.ytdlp.run;
let runArgs = null;
youtube.ytdlp.run = async (args) => {
runArgs = args;
const file = args[args.indexOf('--cookies') + 1];
fs.writeFileSync(file, COOKIES_OK + '\n' + line('.bank.example', 'session', SECRET + '_bank'));
return { code: 1, stdout: '', stderr: 'ERROR: [youtube] x: Sign in to confirm you’re not a bot', timedOut: false };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'default-release' });
check('BROWSER importe malgré l\'échec d\'extraction', r.ok && r.data.status.cookies.source === 'browser:firefox' && r.data.status.cookies.loggedIn, r);
check('arguments --cookies-from-browser', runArgs && runArgs[runArgs.indexOf('--cookies-from-browser') + 1] === 'firefox:default-release' && runArgs.includes('--skip-download'));
const tmpDir = path.dirname(runArgs[runArgs.indexOf('--cookies') + 1]);
check('fichier temporaire du navigateur supprimé', !fs.existsSync(tmpDir), tmpDir);
check('cookies étrangers non stockés', !fs.readFileSync(path.join(dataDir, 'cookies.txt'), 'utf-8').includes('bank'));
youtube.ytdlp.run = async () => ({ code: 1, stdout: '', stderr: 'ERROR: could not find firefox cookies database in /x --cookies /secret/path', timedOut: false });
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox' });
check('échec navigateur -> UNAVAILABLE explicite', !r.ok && r.error === 'UNAVAILABLE' && /même machine/.test(r.message) && !r.message.includes('/secret/path'), r);
youtube.ytdlp.run = async (args) => {
fs.writeFileSync(args[args.indexOf('--cookies') + 1], '# Netscape HTTP Cookie File\n' + line('.bank.example', 'session', 'x'));
return { code: 0, stdout: '', stderr: '' };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'chrome' });
check('navigateur sans cookie YouTube -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /chrome/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'netscape' });
check('navigateur inconnu refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'a::b' });
check('profil invalide refusé', !r.ok && r.error === 'INVALID_PARAMS');
youtube.ytdlp.run = realRun;
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# OVERVIEW');
r = await call('/ADMIN/OVERVIEW', {});
const yt = r.data?.ytdlp;
check('overview.ytdlp compatible', r.ok && yt && 'version' in yt && yt.cookies.present === true && yt.cookies.updatedAt === yt.cookies.importedAt && yt.activeProcesses === 1, r.ok ? yt : r);
check('overview.ytdlp enrichi', yt && yt.cookies.valid === true && 'recentFailureRate' in yt && 'lastFailure' in yt);
console.log('# DIAGNOSTICS');
youtube.diagnostics.record({ guildId: '222222', songId: 's', title: 'Titre', route: 'direct', identity: 'anon', outcome: 'failed', cls: 'HTTP_403', message: 'HTTP Error 403', ms: 100 });
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('recent avec nom du serveur', r.data.recent[0]?.guildName === 'Serveur Test' && r.data.diagnostics.byClass.HTTP_403 === 1, r.data.recent[0]);
r = await call('/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR', {});
check('CLEAR', r.ok && r.data.recent.length === 0 && r.data.diagnostics.total === 0);
console.log('# DELETE');
r = await call('/ADMIN/YOUTUBE/COOKIES/DELETE', {});
check('DELETE', r.ok && r.data.cookies.present === false && !fs.existsSync(path.join(dataDir, 'cookies.txt')));
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK sans cookies -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE');
console.log('# Droits');
admin.labels = []; admin.isAdmin = () => false;
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('non-admin refusé', !r.ok && r.error === 'FORBIDDEN');
admin.isAdmin = () => true; admin.labels = ['ADMIN'];
// Journaux : aucune valeur de cookie
await sleep(300);
const logs = path.join(__dirname, 'logs');
let leaked = false;
for (const f of fs.readdirSync(logs)) if (fs.readFileSync(path.join(logs, f), 'utf-8').includes(SECRET)) leaked = f;
check('aucune valeur de cookie dans les logs', !leaked, leaked);
const auditLines = fs.readdirSync(logs).flatMap(f => fs.readFileSync(path.join(logs, f), 'utf-8').split('\n')).filter(l => l.includes('[ADMIN] testadmin -> ') && /YouTube|yt-dlp/.test(l));
check('audits écrits', auditLines.length >= 6, auditLines.length);
console.log(auditLines.slice(-12).map(l => ' ' + l.trim()).join('\n'));
done();
setTimeout(() => process.exit(process.exitCode), 100);
})().catch((e) => { console.error(e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
// Test de getYouTubeProfilePicture (non exportée) : module recompilé avec une exportation de test
const path = require("path"), fs = require("fs"), Module = require("module"), assert = require("assert");
const SRC = require("path").resolve(__dirname, "../../src");
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
const file = path.join(SRC, "media/YoutubeInformation.js");
const m = new Module(file, module); m.filename = file; m.paths = Module._nodeModulePaths(path.dirname(file));
m._compile(fs.readFileSync(file, "utf8") + "\nmodule.exports.__avatar = getYouTubeProfilePicture;", file);
const get = m.exports.__avatar;
(async () => {
let t = Date.now();
const a1 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #1:", Date.now() - t, "ms", a1);
assert.ok(a1 && a1.startsWith("https://"));
t = Date.now();
const a2 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #2 (cache):", Date.now() - t, "ms");
assert.strictEqual(a2, a1); assert.ok(Date.now() - t < 20);
assert.strictEqual(await get("https://evil.example.com/@x"), null);
assert.strictEqual(await get("https://www.youtube.com/channel/undefined"), null);
assert.strictEqual(await get(undefined), null);
console.log("OK");
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+75
View File
@@ -0,0 +1,75 @@
// check.js (test de lecture, vérification des cookies), settings (env verrouillé), index (init, getStatus)
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('check');
process.env.YTDLP_POT_PROVIDER = 'http://user:pw@bgutil-provider:4416';
process.env.YTDLP_SEEK_STRATEGY = 'nimportequoi';
// Pas de mise à jour automatique pendant ce test (limite de l'API GitHub)
process.env.YTDLP_AUTO_UPDATE = 'false';
const youtube = req('src/youtube');
const { settings, cookies, check: ytcheck, ytdlp } = youtube;
(async () => {
console.log('# settings');
const pub = settings.getPublic();
check('potProviderUrl verrouillé + masqué', pub.potProviderUrl.locked && pub.potProviderUrl.value === 'http://***@bgutil-provider:4416', pub.potProviderUrl);
check('env invalide ignoré (seekStrategy)', pub.seekStrategy.locked === false && pub.seekStrategy.value === 'auto');
let err = null;
try { settings.update({ potProviderUrl: 'http://x' }); } catch (e) { err = e; }
check('clé verrouillée refusée', err?.code === 'INVALID_PARAMS', err?.message);
err = null;
try { settings.update({ playerClients: 'web;rm -rf' }); } catch (e) { err = e; }
check('clients invalides refusés', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ testUrl: 'https://evil.com/watch?v=x' }); } catch (e) { err = e; }
check('testUrl non YouTube refusée', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ inconnu: 1 }); } catch (e) { err = e; }
check('clé inconnue refusée', err?.code === 'INVALID_PARAMS');
settings.update({ cookiesOnProxy: 'true', playerClients: ' Web , mweb ' }, { username: 'test' });
check('normalisation + écriture', settings.get().cookiesOnProxy === true && settings.get().playerClients === 'web,mweb' && JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8')).playerClients === 'web,mweb');
check('env non écrit dans settings.json', !('potProviderUrl' in JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8'))));
settings.update({ playerClients: '', cookiesOnProxy: false });
const base = ytdlp.baseArgs({ proxy: 'http://warp:1080', cookiesPath: '/tmp/x', playerClients: 'mweb' });
console.log(' baseArgs :', youtube.errors.sanitize(base.join(' ')));
check('baseArgs : pot + client + proxy + cookies + -4', base.includes('-4') && base.includes('youtubepot-bgutilhttp:base_url=http://user:pw@bgutil-provider:4416') && base.includes('youtube:player_client=mweb') && base.includes('--proxy') && base.includes('--cookies') && !base.includes('--no-cache-dir'));
// Pas de bgutil local pour les essais réels
delete process.env.YTDLP_POT_PROVIDER;
settings.reload();
console.log('# init + getStatus');
await youtube.init();
const st = youtube.getStatus();
console.log(' getStatus :', JSON.stringify({ ytdlp: { ...st.ytdlp, path: path.basename(st.ytdlp.path) }, ffmpeg: st.ffmpeg.source, cookies: { present: st.cookies.present, valid: st.cookies.valid }, pot: st.pot, activeProcesses: st.activeProcesses, diagnostics: st.diagnostics.total }));
check('getStatus : clés attendues', ['ytdlp', 'ffmpeg', 'cookies', 'settings', 'pot', 'diagnostics', 'activeProcesses'].every(k => k in st));
check('dossier cache créé', fs.existsSync(__glob.YOUTUBE_CACHE));
console.log('# testPlayback (anonyme, directe)');
const t = await ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
console.log(' ', JSON.stringify(t));
check('testPlayback ok + format', t.ok && t.format && t.format.id, t);
let busy = null;
const p1 = ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
try { await ytcheck.testPlayback({}); } catch (e) { busy = e.code; }
await p1;
check('testPlayback : un seul à la fois (BUSY)', busy === 'BUSY');
const bad = await ytcheck.testPlayback({ url: '--exec=calc' });
check('testPlayback : URL refusée', bad.ok === false && /URL/.test(bad.message));
console.log('# checkCookies (sans cookies puis anciens cookies)');
const none = await ytcheck.checkCookies();
check('checkCookies sans cookies', none.ok === false && /Aucun cookie/.test(none.message));
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
cookies.migrateLegacy();
const [c1, c2] = await Promise.all([ytcheck.checkCookies(), ytcheck.checkCookies()]);
console.log(' ', JSON.stringify({ ok: c1.ok, loggedIn: c1.loggedIn, rotated: c1.rotated, cls: c1.cls, message: c1.message, route: c1.route, ms: c1.ms }));
check('checkCookies : appel unique partagé', c1 === c2);
check('checkCookies : lastCheck enregistré', cookies.getStatus().lastCheck?.at && cookies.getStatus().lastCheck.message === c1.message);
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
check('aucun processus actif', ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+183
View File
@@ -0,0 +1,183 @@
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('cookies');
const cookies = req('src/youtube/cookies');
const NOW = 1791360000; // 2026-10-07
const FUT = 1830000000; // 2027-12
const PAST = 1700000000;
const T = (...f) => f.join('\t');
let events = 0;
process.on('YOUTUBE_UPDATE', () => events++);
function throwsInvalid(fn) {
try { fn(); return false; } catch (e) { return e.code === 'INVALID_COOKIES' && /[a-zé]/.test(e.message); }
}
console.log('# Netscape');
{
const loggedIn = [
'# Netscape HTTP Cookie File',
'# commentaire',
'',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB'),
'#HttpOnly_' + T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PSID', 'CCC'),
T('.youtube.com', 'TRUE', '/', 'FALSE', 0, 'PREF', 'f6=40000000'),
T('.youtube.com', 'TRUE', '/', 'TRUE', PAST, 'GPS', '1'),
T('.example.com', 'TRUE', '/', 'FALSE', FUT, 'tracker', 'x'),
T('accounts.google.com', 'FALSE', '/', 'TRUE', FUT, '__Host-GAPS', 'g'),
'ligne cassée sans tabulation',
T('.youtube.com', 'MAYBE', '/', 'TRUE', FUT, 'X', 'y'),
].join('\r\n');
const r = cookies.parseCookies(loggedIn, { now: NOW });
const names = r.cookies.map(c => c.name);
check('CRLF + en-tête : cookies valides lus', names.includes('LOGIN_INFO') && names.includes('SAPISID') && names.includes('PREF'), names);
check('#HttpOnly_ reconnu', r.cookies.find(c => c.name === '__Secure-3PSID')?.httpOnly === true);
check('cookie de session (0) gardé', r.cookies.find(c => c.name === 'PREF')?.expires === 0);
check('expiré retiré', !names.includes('GPS'));
check('domaine étranger retiré', !names.includes('tracker'));
check('google.com gardé (hostOnly)', r.cookies.find(c => c.name === '__Host-GAPS')?.includeSubdomains === false);
check('avertissements (lignes, domaines, expirés)', r.warnings.length === 3, r.warnings);
const a = cookies.analyze(r.cookies);
check('analyze : loggedIn', a.loggedIn === true);
check('analyze : jamais de valeur', !JSON.stringify(a).includes('AAA') && !JSON.stringify(a).includes('BBB'));
check('analyze : authNames', a.authNames.includes('LOGIN_INFO') && a.authNames.includes('SAPISID') && a.authNames.includes('__Secure-3PSID'), a.authNames);
check('analyze : earliestAuthExpiry ISO', a.earliestAuthExpiry === new Date(FUT * 1000).toISOString());
// Sans en-tête, LF, valeur vide, tabulations converties en espaces
const noHeader = [
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
'.youtube.com TRUE / TRUE ' + FUT + ' __Secure-3PAPISID ZZZ',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'EMPTY', ''),
].join('\n');
const r2 = cookies.parseCookies(noHeader, { now: NOW });
check('sans en-tête + espaces au lieu de tabulations', r2.cookies.length === 3, r2.cookies.map(c => c.name));
check('valeur vide acceptée', r2.cookies.find(c => c.name === 'EMPTY')?.value === '');
check('loggedIn via __Secure-3PAPISID', cookies.analyze(r2.cookies).loggedIn === true);
// Règle yt-dlp : LOGIN_INFO seul ou SAPISID seul ne suffit pas
const onlySapisid = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PAPISID', 'x');
check('pas connecté sans LOGIN_INFO', cookies.analyze(cookies.parseCookies(onlySapisid, { now: NOW }).cookies).loggedIn === false);
const onlyLogin = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x');
const aLogin = cookies.analyze(cookies.parseCookies(onlyLogin, { now: NOW }).cookies);
check('pas connecté sans SAPISID', aLogin.loggedIn === false && aLogin.missing.includes('SAPISID'));
// Cookies google.com seuls : ne comptent pas pour youtube.com
const googleOnly = [T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'), T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'x')].join('\n');
check('LOGIN_INFO sur google.com ne compte pas', cookies.analyze(cookies.parseCookies(googleOnly, { now: NOW }).cookies).loggedIn === false);
// Sérialisation : relue à l'identique, LF, en-tête
const text = cookies.serializeCookies(r.cookies);
check('sérialisation : en-tête Netscape + LF', text.startsWith('# Netscape HTTP Cookie File\n') && !text.includes('\r'));
const back = cookies.parseCookies(text, { now: NOW }).cookies;
check('sérialisation : aller-retour', JSON.stringify(back) === JSON.stringify(r.cookies));
}
console.log('# JSON');
{
const arr = [
{ domain: '.youtube.com', expirationDate: FUT + 0.512229, hostOnly: false, httpOnly: true, name: 'LOGIN_INFO', path: '/', sameSite: 'no_restriction', secure: true, session: false, storeId: '0', value: 'AAA', id: 1 },
{ domain: '.youtube.com', expirationDate: FUT + 0.9, hostOnly: false, httpOnly: false, name: 'SAPISID', path: '/', secure: true, session: false, value: 'BBB' },
{ domain: 'www.youtube.com', hostOnly: true, httpOnly: false, name: 'ST-xyz', path: '/', secure: false, session: true, value: 'S' },
{ domain: 'youtube.com', hostOnly: false, name: 'NODOT', path: '/', secure: true, expires: (FUT * 1000), value: 'ms' },
{ domain: '.youtube.com', name: 'OLD', expirationDate: PAST, value: 'x' },
{ domain: '.facebook.com', name: 'fb', expirationDate: FUT, value: 'x' },
{ nope: true },
];
const r = cookies.parseCookies(JSON.stringify(arr), { now: NOW });
const by = Object.fromEntries(r.cookies.map(c => [c.name, c]));
check('JSON : expirationDate décimal arrondi', by.LOGIN_INFO?.expires === FUT);
check('JSON : session -> 0', by['ST-xyz']?.expires === 0);
check('JSON : hostOnly -> sans sous-domaines', by['ST-xyz']?.includeSubdomains === false && by['ST-xyz']?.domain === 'www.youtube.com');
check('JSON : domaine non hostOnly sans point -> .youtube.com', by.NODOT?.domain === '.youtube.com' && by.NODOT?.includeSubdomains === true);
check('JSON : expires en millisecondes', by.NODOT?.expires === FUT);
check('JSON : httpOnly', by.LOGIN_INFO?.httpOnly === true && by.SAPISID?.httpOnly === false);
check('JSON : expiré, étranger et invalide retirés', !by.OLD && !by.fb && r.warnings.length === 3, r.warnings);
check('JSON : loggedIn', cookies.analyze(r.cookies).loggedIn === true);
check('JSON : hasSessionOnly faux (le cookie de session n\'est pas un cookie de connexion)', cookies.analyze(r.cookies).hasSessionOnly === false);
const wrapped = cookies.parseCookies(JSON.stringify({ url: 'https://www.youtube.com', cookies: arr.slice(0, 2) }), { now: NOW });
check('{cookies:[...]} accepté', wrapped.cookies.length === 2 && cookies.analyze(wrapped.cookies).loggedIn);
const withBom = cookies.parseCookies('' + JSON.stringify(arr.slice(0, 2)), { now: NOW });
check('BOM ignoré', withBom.cookies.length === 2);
const expiry = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'b', expiry: FUT }]), { now: NOW });
check('champ expiry (Selenium)', expiry.cookies[0].expires === FUT);
const sessionAuth = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'LOGIN_INFO', value: 'b', session: true }]), { now: NOW });
check('hasSessionOnly vrai pour un cookie de connexion de session', cookies.analyze(sessionAuth.cookies).hasSessionOnly === true);
}
console.log('# Contenus invalides');
check('vide', throwsInvalid(() => cookies.parseCookies(' ')));
check('non-chaîne', throwsInvalid(() => cookies.parseCookies(null)));
check('texte quelconque', throwsInvalid(() => cookies.parseCookies('bonjour\nceci n\'est pas un fichier de cookies')));
check('JSON tronqué', throwsInvalid(() => cookies.parseCookies('[{"domain": ".youtube.com", "name": ')));
check('JSON objet sans cookies', throwsInvalid(() => cookies.parseCookies('{"a": 1}')));
check('JSON liste vide', throwsInvalid(() => cookies.parseCookies('[]')));
check('HTML', throwsInvalid(() => cookies.parseCookies('<html><body>Login</body></html>')));
check('commentaires seuls', throwsInvalid(() => cookies.parseCookies('# Netscape HTTP Cookie File\n# rien\n')));
check('valeur avec retour à la ligne refusée', throwsInvalid(() => cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'x\ny' }]))));
console.log('# Stockage');
const goodText = [T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB')].join('\n');
let threw = false;
try { cookies.importCookies(T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SID', 'x')); } catch (e) { threw = e.code === 'INVALID_COOKIES'; }
check('import refusé sans cookie youtube.com', threw && !fs.existsSync(__glob.YOUTUBE_COOKIES));
const before = events;
const res = cookies.importCookies(goodText, { source: 'paste', by: { id: '42', username: 'raphix' } });
check('import : statut valide', res.status.valid && res.status.loggedIn && res.status.count === 2 && res.status.source === 'paste', res.status);
check('import : importedBy', res.status.importedBy?.username === 'raphix');
check('import : YOUTUBE_UPDATE émis', events > before);
check('import : fichier maître LF + en-tête', fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8').startsWith('# Netscape HTTP Cookie File\n'));
check('statut : jamais de valeur', !JSON.stringify(cookies.getStatus()).includes('AAA'));
const notLogged = cookies.importCookies(T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'PREF', 'x'), { source: 'file' });
check('import non connecté : stocké + avertissement', notLogged.status.present && !notLogged.status.loggedIn && notLogged.warnings.some(w => /connexion/.test(w)), notLogged.warnings);
cookies.importCookies(goodText, { source: 'paste' });
check('markInvalid', cookies.markInvalid('test') === true && cookies.getStatus().valid === false && cookies.getStatus().invalidReason === 'test');
check('markInvalid idempotent', cookies.markInvalid('encore') === false && cookies.getStatus().invalidReason === 'test');
cookies.setLastCheck({ at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'ok' });
check('vérification réussie : invalidation levée', cookies.getStatus().valid === true);
cookies.reload();
check('rechargement depuis le disque', cookies.getStatus().valid === true && cookies.getStatus().count === 2);
(async () => {
const copy = await cookies.acquireCopy();
check('acquireCopy : fichier identique au maître', fs.readFileSync(copy.path, 'utf8') === fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8'));
check('acquireCopy : dossier chopin-ytc-', path.basename(path.dirname(copy.path)).startsWith('chopin-ytc-') && cookies.liveCopyCount() === 1);
copy.release(); copy.release();
check('release : dossier supprimé', !fs.existsSync(path.dirname(copy.path)) && cookies.liveCopyCount() === 0);
const del = cookies.deleteCookies();
check('deleteCookies', !del.present && !fs.existsSync(__glob.YOUTUBE_COOKIES));
check('acquireCopy sans cookies -> null', (await cookies.acquireCopy()) === null);
check('aucune copie temporaire restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# Fichiers réels (lecture seule, analyse sans valeurs)');
const realTxt = path.join(BACKEND, 'data', 'cookies.txt');
const realJson = path.join(BACKEND, 'data', 'cookies.json');
const mtimes = () => [realTxt, realJson].map(f => fs.existsSync(f) ? fs.statSync(f).mtimeMs : null).join(',');
const mtimesBefore = mtimes();
for (const file of [realTxt, realJson]) {
if (!fs.existsSync(file)) { console.log(` (absent) ${path.basename(file)}`); continue; }
try {
const r = cookies.parseCookies(fs.readFileSync(file, 'utf8'));
console.log(` ${path.basename(file)} :`, JSON.stringify(cookies.analyze(r.cookies)), '| avertissements :', JSON.stringify(r.warnings));
} catch (e) {
console.log(` ${path.basename(file)} : erreur ${e.code} ${e.message}`);
}
}
// Migration : les anciens fichiers réels servent de source, la destination est le dossier isolé
cookies.reload();
__glob.COOKIES = realTxt;
__glob.COOKIES_JSON = realJson;
const mig = cookies.migrateLegacy();
const st = cookies.getStatus();
console.log(' migration :', JSON.stringify(mig), '| statut :', JSON.stringify({ present: st.present, loggedIn: st.loggedIn, valid: st.valid, source: st.source, count: st.count, authNames: st.authNames, missing: st.missing, earliestAuthExpiry: st.earliestAuthExpiry }));
check('migration : fichiers d\'origine non modifiés', mtimes() === mtimesBefore);
check('migration : pas de seconde migration', cookies.migrateLegacy().migrated === false);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})();
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const errors = req('src/youtube/errors');
const ID = 'ERROR: [youtube] jNQXAC9IVRw: ';
// Chaque ligne du tableau de research-ytdlp.md §5 (+ variantes réelles)
const CASES = [
// Bot check (apostrophe typographique et droite)
[ID + 'Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies.', 'BOT_CHECK'],
[ID + "Sign in to confirm you're not a bot. This helps protect our community. Learn more", 'BOT_CHECK'],
['ERROR: [youtube] abc: Sign in to confirm youre not a bot', 'BOT_CHECK'],
// IP bloquée
[ID + 'All player responses are invalid. Your IP is likely being blocked by Youtube', 'IP_BLOCKED'],
// Captcha
[ID + 'YouTube is requiring a captcha challenge before playback', 'CAPTCHA'],
// Rate limit
[ID + "This content isn't available, try again later. The current session has been rate-limited by YouTube for up to an hour. It is recommended to use `-t sleep` to add a delay between video requests", 'RATE_LIMITED'],
[ID + 'This content isn’t available, try again later.', 'RATE_LIMITED'],
// 403 au téléchargement
['ERROR: unable to download video data: HTTP Error 403: Forbidden', 'HTTP_403'],
['[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...', 'HTTP_403'],
['ERROR: [download] Giving up after 3 retries: HTTP Error 403: Forbidden', 'HTTP_403'],
// 429
['ERROR: [youtube] abc: Unable to download API page: HTTP Error 429: Too Many Requests', 'HTTP_429'],
// Âge
[ID + 'Sign in to confirm your age. This video may be inappropriate for some users.', 'AGE_RESTRICTED'],
[ID + 'This video is age-restricted and only available on YouTube', 'AGE_RESTRICTED'],
// Connexion / membres
[ID + 'Login details are needed to download this content', 'LOGIN_REQUIRED'],
[ID + "This video is available to this channel's members on level: Member (or any higher level). Join this channel to get access to members-only content like this video, and other exclusive perks.", 'MEMBERS_ONLY'],
[ID + 'Join this channel to get access to members-only content like this video, and other exclusive perks.', 'MEMBERS_ONLY'],
// Privée
[ID + "Private video. Sign in if you've been granted access to this video", 'PRIVATE'],
// Indisponible
[ID + 'Video unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This content isn’t available.', 'UNAVAILABLE'],
[ID + 'This video is unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This video has been removed by the uploader', 'UNAVAILABLE'],
// Géo
[ID + 'The uploader has not made this video available in your country', 'GEO_BLOCKED'],
// PO token (avertissement seul)
['WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with --extractor-args "youtube:po_token=mweb.gvs+XXX".', 'POT_MISSING'],
// SABR
['WARNING: [youtube] jNQXAC9IVRw: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment for your account.', 'SABR'],
['WARNING: [youtube] jNQXAC9IVRw: Some tv client https formats have been skipped as they are missing a url. YouTube may have enabled the SABR-only streaming experiment for the current session.', 'SABR'],
['[debug] [youtube] YouTube is forcing SABR streaming for this client.', 'SABR'],
// Défi JS
['WARNING: [youtube] jNQXAC9IVRw: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed.', 'JS_CHALLENGE'],
['WARNING: [youtube] jNQXAC9IVRw: Signature solving failed: Some formats may be missing.', 'JS_CHALLENGE'],
['WARNING: [youtube] No supported JavaScript runtime could be found. Only deno is enabled by default', 'JS_CHALLENGE'],
// Format
['ERROR: [youtube] jNQXAC9IVRw: Requested format is not available. Use --list-formats for a list of available formats', 'FORMAT_UNAVAILABLE'],
// Page à recharger
[ID + 'The page needs to be reloaded.', 'PAGE_RELOAD'],
// Cookies tournés
['WARNING: [youtube] The provided YouTube account cookies are no longer valid. They have likely been rotated in the browser as a security measure. For tips on how to effectively export YouTube cookies, refer to https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies .', 'COOKIES_ROTATED'],
// Fichier vide
['ERROR: The downloaded file is empty', 'EMPTY_DOWNLOAD'],
// Binaire absent / délai
['spawn yt-dlp ENOENT', 'BINARY_MISSING'],
['ERROR: yt-dlp introuvable (ENOENT)', 'BINARY_MISSING'],
['ERROR: [youtube] abc: Unable to download webpage: <urlopen error timed out> (caused by URLError(TimeoutError(\'timed out\')))', 'TIMEOUT'],
['aucune donnée après 20s', 'TIMEOUT'],
// Informations seules
['WARNING: [youtube] Skipping client "visionos" since it does not support cookies', 'UNKNOWN'],
['ERROR: Login with OAuth is no longer supported', 'UNKNOWN'],
['', 'UNKNOWN'],
];
console.log('# Tableau des messages');
for (const [text, expected] of CASES) {
const r = errors.classify(text);
check(`${expected.padEnd(18)} <- ${text.slice(0, 90)}`, r.cls === expected, r);
}
console.log('# Combinaisons (ligne par ligne)');
{
// L'ancien bug : un bloc contenant [youtube] était ignoré. Ici l'erreur est noyée dans du bruit.
const noisy = [
'[youtube] Extracting URL: https://www.youtube.com/watch?v=jNQXAC9IVRw',
'[youtube] jNQXAC9IVRw: Downloading webpage',
'WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided.',
'ERROR: [youtube] jNQXAC9IVRw: Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication.',
].join('\r\n');
const r = errors.classify(noisy);
check('ERROR prime sur WARNING (BOT_CHECK, indice POT)', r.cls === 'BOT_CHECK' && r.hints.includes('POT_MISSING') && r.retry === 'route', r);
check('message sans préfixe ERROR:', r.message.startsWith('[youtube] jNQXAC9IVRw: Sign in'), r.message);
const rootCause = 'WARNING: [youtube] x: n challenge solving failed: Some formats may be missing.\nERROR: [youtube] x: Requested format is not available. Use --list-formats';
check('format indisponible + défi JS -> JS_CHALLENGE', errors.classify(rootCause).cls === 'JS_CHALLENGE');
const sabrEmpty = 'WARNING: [youtube] x: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment\nERROR: The downloaded file is empty';
check('fichier vide + SABR -> SABR', errors.classify(sabrEmpty).cls === 'SABR');
const rotatedBot = 'WARNING: [youtube] The provided YouTube account cookies are no longer valid.\nERROR: [youtube] x: Sign in to confirm you’re not a bot.';
const rb = errors.classify(rotatedBot);
check('rotation + bot : cls BOT_CHECK, drapeau cookiesRotated', rb.cls === 'BOT_CHECK' && rb.cookiesRotated === true, rb);
const rotatedOnly = errors.classify('WARNING: [youtube] The provided YouTube account cookies are no longer valid.');
check('rotation seule -> COOKIES_ROTATED', rotatedOnly.cls === 'COOKIES_ROTATED' && rotatedOnly.cookiesRotated, rotatedOnly);
const retry403 = '[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...\n[download] Got error: HTTP Error 403: Forbidden. Retrying (2/3)...\nERROR: unable to download video data: HTTP Error 403: Forbidden';
check('403 avec réessais -> HTTP_403 (retry client)', errors.classify(retry403).cls === 'HTTP_403' && errors.classify(retry403).retry === 'client');
check('un WARNING citant "HTTP Error 403" n\'est pas une ligne d\'erreur', errors.isErrorLine('WARNING: [youtube] x: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403.') === false);
check('tableau de lignes accepté', errors.classify(['x', 'ERROR: Private video']).cls === 'PRIVATE');
check('chaque classe a une action', errors.CLASSES.every(c => ['route', 'cookies', 'client', 'none'].includes(errors.RETRY[c])));
}
console.log('# sanitize');
{
const s = errors.sanitize('[debug] Command-line config: [\'--cookies\', \'C:\\\\Temp\\\\chopin-ytc-AbC123\\\\cookies.txt\', \'--proxy\', \'http://user:secret@warp:1080\'] --cookies /tmp/chopin-ytc-x1/cookies.txt Cookie: SID=abcdef; HSID=zzz Authorization: SAPISIDHASH 123_abc SAPISID=VALUE123 __Secure-3PSID=SECRETVALUE po_token=mweb.gvs+AAAAAAAAAAAA');
check('aucun secret', !/secret|abcdef|VALUE123|SECRETVALUE|AAAAAAAAAAAA|123_abc|AbC123|x1\//.test(s), s);
check('proxy masqué', s.includes('http://***@warp:1080'), s);
check('texte normal inchangé', errors.sanitize('ERROR: [youtube] abc: Video unavailable') === 'ERROR: [youtube] abc: Video unavailable');
check('null -> ""', errors.sanitize(null) === '');
}
done();
+257
View File
@@ -0,0 +1,257 @@
// Test du flux Google (OAuth2.js + YoutubeList.js + PlaylistManager.js) avec googleapis, la base de données,
// la configuration, les utilisateurs et loguix simulés : aucun fichier de data/ n'est lu ni écrit, aucun réseau.
const assert = require("assert")
const Module = require("module")
const EventEmitter = require("events")
const path = require("path")
const SRC = require("path").resolve(__dirname, "../../src")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
// --- Simulations ---
const cfg = { id: "client-id", secret: "client-secret", website: "https://chopin.example/" }
const databases = {}
class FakeDatabase {
constructor(name, file, empty) { this.name = name; this.path = file; this.data = JSON.parse(JSON.stringify(empty || {})); this.saves = 0; databases[name] = this }
save() { this.saves++ }
}
const fakeLog = { LogType: class { log() {} warn() {} error() {} }, getInstance: () => ({ log() {}, warn() {}, error() {} }) }
const api = { calls: { playlists: 0, items: 0, videos: 0, channels: 0 }, failWith: null, revoked: [] }
const PLAYLISTS = [
{ id: "PLa", snippet: { title: "Rock", channelTitle: "Moi", channelId: "UCme", thumbnails: { default: { url: "t-a" } } }, contentDetails: { itemCount: 120 } },
{ id: "PLb", snippet: { title: "Calme", channelTitle: "Moi", channelId: "UCme" }, contentDetails: { itemCount: 3 } },
{ id: "PLc", snippet: { title: "Vide", channelTitle: "Moi", channelId: "UCme", thumbnails: null }, contentDetails: { itemCount: 0 } },
]
function itemsFor(listId) {
const count = { PLa: 120, PLb: 3, PLc: 0, LLme: 60 }[listId] ?? 0
return Array.from({ length: count }, (_, i) => {
// LL réutilise les vidéos de PLa (cache des durées partagé)
const vid = (listId === "LLme" ? "PLa" : listId) + "-v" + i
const priv = listId === "PLb" && i === 1
return {
snippet: {
title: priv ? "Private video" : "Titre " + vid,
resourceId: { videoId: vid },
videoOwnerChannelTitle: priv ? undefined : "Artiste",
videoOwnerChannelId: priv ? undefined : "UCart",
thumbnails: { medium: { url: "m-" + vid } }
},
contentDetails: { videoId: vid }
}
})
}
function page(all, token, size) {
const start = token ? Number(token) : 0
return { items: all.slice(start, start + size), nextPageToken: start + size < all.length ? String(start + size) : undefined }
}
function maybeFail() { if (api.failWith) throw api.failWith }
class FakeOAuth2 extends EventEmitter {
constructor(id, secret, uri) { super(); this.args = [id, secret, uri]; this.credentials = null }
generateAuthUrl(opts) { this.lastOpts = opts; return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ ...opts, scope: opts.scope.join(" ") }) }
async getToken(code) {
if (code === "bad") { const e = new Error("invalid_grant"); e.response = { data: { error: "invalid_grant" } }; throw e }
return { tokens: { access_token: "AT-" + code, refresh_token: "RT-" + code, expiry_date: 123, scope: "yt" } }
}
setCredentials(t) { this.credentials = t }
async revokeToken(t) { api.revoked.push(t); return {} }
}
const fakeGoogle = {
google: {
auth: { OAuth2: FakeOAuth2 },
youtube: ({ auth }) => {
assert.ok(auth instanceof FakeOAuth2, "service authentifié")
return {
channels: { list: async () => { api.calls.channels++; return { data: { items: [{ id: "UCme", snippet: { title: "Ma chaîne" }, contentDetails: { relatedPlaylists: { likes: "LLme" } } }] } } } },
playlists: { list: async (p) => {
api.calls.playlists++; maybeFail()
if (p.id) return { data: { items: PLAYLISTS.filter(x => p.id.includes(x.id)) } }
assert.strictEqual(p.mine, true)
return { data: page(PLAYLISTS, p.pageToken, 2) }
} },
playlistItems: { list: async (p) => {
api.calls.items++; maybeFail()
assert.strictEqual(p.maxResults, 50)
return { data: page(itemsFor(p.playlistId), p.pageToken, 50) }
} },
videos: { list: async (p) => {
api.calls.videos++; maybeFail()
assert.ok(p.id.length <= 50, "50 ids max par appel")
return { data: { items: p.id.map((id, i) => ({ id, contentDetails: { duration: "PT" + (i % 5) + "M" + (i % 60) + "S" } })) } }
} },
}
}
}
}
const stubs = {
"googleapis": fakeGoogle,
"loguix": fakeLog,
[norm(SRC + "/utils/Database/Database")]: { Database: FakeDatabase, getDatabase: (n) => databases[n] },
[norm(SRC + "/utils/Database/Configuration")]: { getYoutubeApiClientId: () => cfg.id, getYoutubeApiClientSecret: () => cfg.secret, getWebsiteLink: () => cfg.website },
[norm(SRC + "/server/auth/User")]: { getUserById: (id) => (id ? { identity: { id } } : null) },
[norm(SRC + "/player/Finder")]: { search: async () => null },
[norm(SRC + "/media/SpotifyInformation")]: { getTracks: async (p) => p },
[norm(SRC + "/utils/SafeMetric")]: { toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? Math.floor(n) : 0 } },
}
const origLoad = Module._load
Module._load = function (request, parent, isMain) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const events = []
process.on("GOOGLE_UPDATE", (u) => events.push("GOOGLE_UPDATE:" + u))
process.on("PLAYLISTS_UPDATE", (u) => events.push("PLAYLISTS_UPDATE:" + u))
const OAuth2 = require(SRC + "/playlists/Google/OAuth2.js")
const PM = require(SRC + "/playlists/PlaylistManager.js")
const store = databases.google
assert.ok(store && store.path.endsWith("google.json"), "base google.json")
;(async () => {
// --- Configuration absente : bouton désactivé ---
cfg.id = ""
assert.strictEqual(OAuth2.getStatus("u1").configured, false)
assert.strictEqual(OAuth2.createAuthUrl("u1"), null)
cfg.id = "client-id"
// --- URL de consentement ---
const url = new URL(OAuth2.createAuthUrl("u1"))
const state = url.searchParams.get("state")
assert.strictEqual(url.searchParams.get("prompt"), "consent")
assert.strictEqual(url.searchParams.get("access_type"), "offline")
assert.match(state, /^[0-9a-f]{48}$/)
// --- Liaison : state d'un autre utilisateur / invalide refusé ---
await assert.rejects(OAuth2.linkAccount("u2", "code1", state), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "code1", "nope"), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "", state), { code: "INVALID_PARAMS" })
const status = await OAuth2.linkAccount("u1", "code1", state)
assert.deepStrictEqual([status.linked, status.valid, status.channel.title], [true, true, "Ma chaîne"])
assert.strictEqual(store.data.u1.tokens.refresh_token, "RT-code1")
assert.strictEqual(store.data.u1.likesPlaylistId, "LLme")
await assert.rejects(OAuth2.linkAccount("u1", "code1", state), { code: "INVALID_STATE" }, "state à usage unique")
assert.ok(!JSON.stringify(OAuth2.getStatus("u1")).includes("RT-"), "aucun jeton dans le statut")
// Redirect URI = site + /oauth2callback (slash final retiré)
const client = OAuth2.getClient("u1")
assert.strictEqual(client.args[2], "https://chopin.example/oauth2callback")
assert.strictEqual(OAuth2.getClient("u1"), client, "client en cache")
// --- Jetons rafraîchis par la librairie : persistés, refresh_token conservé ---
client.emit("tokens", { access_token: "AT-new", expiry_date: 999 })
assert.deepStrictEqual([store.data.u1.tokens.access_token, store.data.u1.tokens.refresh_token], ["AT-new", "RT-code1"])
// --- CONFIG_UPDATE : client recréé (identifiants relus) ---
cfg.secret = "secret-2"
process.emit("CONFIG_UPDATE")
const client2 = OAuth2.getClient("u1")
assert.notStrictEqual(client2, client)
assert.strictEqual(client2.args[1], "secret-2")
assert.strictEqual(client2.credentials.access_token, "AT-new")
// --- Synchronisation (+ concurrence) ---
const p1 = PM.syncYoutube("u1", { force: true })
assert.strictEqual(OAuth2.getStatus("u1").syncing, true)
const p2 = await PM.syncYoutube("u1", { force: true })
assert.strictEqual(p2.error, "ALREADY_SYNCING")
const r1 = await p1
assert.strictEqual(r1.ok, true, JSON.stringify(r1))
assert.strictEqual(r1.playlists, 4, "3 playlists + J'aime")
const list = PM.getPlaylistsOfUser("u1")
assert.strictEqual(list.length, 4)
const byYt = Object.fromEntries(list.map(p => [p.youtubeId, p]))
assert.strictEqual(byYt.PLa.songs.length, 120, "pagination 50+50+20")
assert.strictEqual(byYt.PLb.songs.length, 2, "vidéo privée ignorée")
assert.strictEqual(byYt.PLc.songs.length, 0)
assert.strictEqual(byYt.LLme.title, "Vidéos « J'aime »")
for (const p of list) {
assert.strictEqual(typeof p.playlistId, "string")
assert.strictEqual(p.type, "youtube")
assert.strictEqual(p.source, "google")
assert.ok(Number.isFinite(p.duration))
for (const s of p.songs) {
assert.strictEqual(s.type, "youtube")
assert.ok(Number.isFinite(s.duration))
assert.ok(s.readduration)
}
}
assert.strictEqual(new Set(list.map(p => p.playlistId)).size, 4, "identifiants uniques")
assert.strictEqual(byYt.PLa.songs[1].duration, 61, "durée ISO-8601 : PT1M1S")
assert.strictEqual(byYt.PLc.thumbnail, "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png", "miniature absente tolérée")
// 2 pages de playlists ; durées : J'aime (60 → 2 appels), PLa (60 restantes grâce au cache → 2 appels), PLb (1 appel) = 5
assert.strictEqual(api.calls.playlists, 2)
assert.strictEqual(api.calls.videos, 5)
assert.ok(events.includes("PLAYLISTS_UPDATE:u1"))
assert.strictEqual(OAuth2.getStatus("u1").syncing, false)
assert.strictEqual(OAuth2.getStatus("u1").lastSync.ok, true)
// --- Délai entre deux synchronisations, puis resynchronisation : identifiants et ordre conservés ---
assert.strictEqual((await PM.syncYoutube("u1")).error, "COOLDOWN")
const ids = list.map(p => p.playlistId).join(",")
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).ok, true)
assert.strictEqual(PM.getPlaylistsOfUser("u1").map(p => p.playlistId).join(","), ids, "même ordre, mêmes ids")
// --- Lecture par identifiant (chaîne ou nombre) ---
const anyId = PM.getPlaylistsOfUser("u1")[0].playlistId
assert.ok(PM.getPlaylistOfUser("u1", anyId))
assert.ok(PM.getPlaylistOfUser("u1", Number(anyId)))
// --- Rafraîchissement d'une playlist importée via l'API (J'aime) ---
const likesId = PM.getPlaylistsOfUser("u1").find(p => p.youtubeId === "LLme").playlistId
const before = api.calls.items
const refreshed = await PM.refreshPlaylist("u1", likesId)
assert.ok(refreshed && refreshed.playlistId === likesId && refreshed.songs.length === 60)
assert.ok(api.calls.items > before, "rafraîchie via playlistItems.list")
// --- Création classique : identifiant primitif (X1) + copie indépendante (X5) ---
const created = await PM.addPlaylist("u1", "Ma liste", null, "moi", "u1", "av")
assert.strictEqual(typeof created.playlistId, "string")
assert.ok(PM.getPlaylistOfUser("u1", created.playlistId), "retrouvée immédiatement")
const copy = PM.copyPlaylist("u1", "u2", created.playlistId)
assert.ok(copy && copy !== created)
assert.strictEqual(PM.copyPlaylist("u1", "u2", created.playlistId), null, "doublon de titre refusé")
PM.renamePlaylist("u2", copy.playlistId, "Autre")
assert.strictEqual(created.title, "Ma liste", "copie indépendante")
// --- Autorisation révoquée chez Google : compte invalide ---
const err = new Error("invalid_grant"); err.response = { status: 400, data: { error: "invalid_grant" } }
api.failWith = err
const failed = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(failed.error, "AUTH_EXPIRED")
const st = OAuth2.getStatus("u1")
assert.deepStrictEqual([st.linked, st.valid, st.lastSync.ok], [true, false, false])
assert.strictEqual(OAuth2.getClient("u1"), null)
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).error, "NOT_LINKED")
// --- Reconnexion puis quota épuisé ---
const url2 = new URL(OAuth2.createAuthUrl("u1"))
await OAuth2.linkAccount("u1", "code2", url2.searchParams.get("state"))
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "reconnexion")
const q = new Error("quota"); q.errors = [{ reason: "quotaExceeded" }]; q.response = { status: 403, data: {} }
api.failWith = q
const quota = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(quota.error, "QUOTA")
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "quota : compte toujours valide")
// --- Code invalide ---
const url3 = new URL(OAuth2.createAuthUrl("u3"))
await assert.rejects(OAuth2.linkAccount("u3", "bad", url3.searchParams.get("state")), { code: "TOKEN_ERROR" })
// --- Déconnexion : suppression + révocation ---
const res = await OAuth2.unlink("u1")
assert.deepStrictEqual(res, { removed: true, revoked: true })
assert.deepStrictEqual(api.revoked, ["RT-code2"])
assert.strictEqual(store.data.u1, undefined)
assert.strictEqual(OAuth2.getStatus("u1").linked, false)
assert.ok(PM.getPlaylistsOfUser("u1").length >= 4, "playlists importées conservées")
assert.deepStrictEqual(await OAuth2.unlink("u1"), { removed: false, revoked: false })
assert.ok(events.filter(e => e === "GOOGLE_UPDATE:u1").length >= 4)
console.log("google-flow.test.js : OK", JSON.stringify(api.calls))
})().catch((e) => { console.error(e); process.exit(1) })
+84
View File
@@ -0,0 +1,84 @@
// Tests des fonctions pures de backend/src/playlists/Google/Utils.js (aucune dépendance)
const assert = require("assert")
const U = require(require("path").resolve(__dirname, "../../src/playlists/Google/Utils.js"))
;(async () => {
// --- parseIsoDuration ---
const cases = [["PT1H2M3S", 3723], ["PT4M13S", 253], ["PT45S", 45], ["PT0S", 0], ["P0D", 0], ["P1DT2H", 93600],
["PT10M", 600], ["P1W", 604800], ["PT1.5S", 1], ["pt3m", 180], ["P", null], ["PT", null], ["", null],
["1:23", null], [null, null], [123, null], ["P1Y", null], ["PT1H2M3", null]]
for (const [input, expected] of cases) assert.strictEqual(U.parseIsoDuration(input), expected, "parseIsoDuration(" + input + ")")
// --- collectPages : pagination simulée (3 pages de 50 + 1 de 20 = 170) ---
const makeFetcher = (total, size, log) => async (token) => {
log.push(token)
const start = token ? Number(token) : 0
const items = Array.from({ length: Math.min(size, total - start) }, (_, i) => start + i)
const next = start + size < total ? String(start + size) : undefined
return { items, nextPageToken: next }
}
let log = []
let r = await U.collectPages(makeFetcher(170, 50, log), { maxItems: 500 })
assert.strictEqual(r.items.length, 170); assert.strictEqual(r.truncated, false); assert.strictEqual(r.pages, 4)
assert.deepStrictEqual(log, [undefined, "50", "100", "150"])
// limite atteinte au milieu d'une page
log = []
r = await U.collectPages(makeFetcher(1000, 50, log), { maxItems: 120 })
assert.strictEqual(r.items.length, 120); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 3)
assert.deepStrictEqual(r.items.slice(-1), [119])
// limite atteinte pile en fin de page alors que Google annonce encore une page
r = await U.collectPages(makeFetcher(1000, 50, []), { maxItems: 100 })
assert.strictEqual(r.items.length, 100); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 2)
// limite pile sur le total : pas tronqué
r = await U.collectPages(makeFetcher(100, 50, []), { maxItems: 100 })
assert.strictEqual(r.truncated, false); assert.strictEqual(r.items.length, 100)
// jeton répété : pas de boucle infinie
let calls = 0
r = await U.collectPages(async () => { calls++; return { items: [calls], nextPageToken: "SAME" } }, { maxItems: 500 })
assert.strictEqual(calls, 2); assert.strictEqual(r.items.length, 2)
// maxPages
r = await U.collectPages(makeFetcher(1000, 10, []), { maxItems: 500, maxPages: 3 })
assert.strictEqual(r.items.length, 30); assert.strictEqual(r.truncated, true)
// page vide / réponse inattendue
r = await U.collectPages(async () => null)
assert.deepStrictEqual(r.items, [])
// erreur propagée
await assert.rejects(U.collectPages(async () => { throw new Error("boom") }), /boom/)
// --- chunk ---
assert.deepStrictEqual(U.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]])
assert.strictEqual(U.chunk(Array.from({ length: 120 }), 50).length, 3)
assert.deepStrictEqual(U.chunk([], 50), [])
// --- createStateStore (horloge simulée) ---
let now = 1_000_000
const store = U.createStateStore({ ttlMs: 10 * 60 * 1000, maxPerKey: 3, now: () => now })
const s1 = store.create("alice")
assert.match(s1, /^[0-9a-f]{48}$/)
assert.strictEqual(store.consume(s1, "bob"), false, "autre utilisateur refusé")
assert.strictEqual(store.consume(s1, "alice"), true, "propriétaire accepté")
assert.strictEqual(store.consume(s1, "alice"), false, "usage unique")
const s2 = store.create("alice")
now += 10 * 60 * 1000 + 1
assert.strictEqual(store.consume(s2, "alice"), false, "expiré après 10 min")
const s3 = store.create("alice")
now += 9 * 60 * 1000
assert.strictEqual(store.consume(s3, "alice"), true, "valide avant 10 min")
// maxPerKey : les plus anciens sont évincés
const olds = [store.create("carol"), store.create("carol"), store.create("carol"), store.create("carol")]
assert.strictEqual(store.consume(olds[0], "carol"), false, "plus ancien évincé")
assert.strictEqual(store.consume(olds[3], "carol"), true)
store.clear("carol")
assert.strictEqual(store.consume(olds[2], "carol"), false, "clear")
assert.strictEqual(store.consume(undefined, "carol"), false)
assert.strictEqual(store.consume(12, "carol"), false)
// --- pickTokens / bestThumbnail ---
assert.deepStrictEqual(U.pickTokens({ access_token: "a", refresh_token: null, expiry_date: 5, foo: 1 }), { access_token: "a", expiry_date: 5 })
assert.deepStrictEqual(U.pickTokens(null), {})
assert.strictEqual(U.bestThumbnail({ default: { url: "d" }, high: { url: "h" } }), "h")
assert.strictEqual(U.bestThumbnail({}), null)
assert.strictEqual(U.bestThumbnail(undefined), null)
console.log("google-utils.test.js : OK")
})().catch((e) => { console.error(e); process.exit(1) })
+120
View File
@@ -0,0 +1,120 @@
// Tests unitaires de backend/src/utils/Limiter.js (node limiter.test.js)
const assert = require("assert");
const path = require("path").resolve(__dirname, "../../src/utils/Limiter.js");
const { createLimiter, mapLimit, createInflight, createCache } = require(path);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("limiter: concurrence max respectée et résultats corrects", async () => {
const lim = createLimiter(2);
let active = 0, peak = 0;
const res = await Promise.all([1, 2, 3, 4, 5, 6].map(i => lim.run(async () => {
active++; peak = Math.max(peak, active);
await sleep(20);
active--;
return i * 10;
})));
assert.deepStrictEqual(res, [10, 20, 30, 40, 50, 60]);
assert.strictEqual(peak, 2);
assert.strictEqual(lim.active, 0);
assert.strictEqual(lim.pending, 0);
});
test("limiter: erreurs (async et sync) propagées sans bloquer la file", async () => {
const lim = createLimiter(1);
await assert.rejects(lim.run(async () => { throw new Error("a"); }), /a/);
await assert.rejects(lim.run(() => { throw new Error("b"); }), /b/);
assert.strictEqual(await lim.run(() => 42), 42);
});
test("limiter: minIntervalMs espace les démarrages", async () => {
const lim = createLimiter(5, { minIntervalMs: 30 });
const starts = [];
await Promise.all([0, 1, 2, 3].map(() => lim.run(async () => { starts.push(Date.now()); })));
for (let i = 1; i < starts.length; i++) assert.ok(starts[i] - starts[i - 1] >= 25, "écart " + (starts[i] - starts[i - 1]));
});
test("mapLimit: ordre conservé, concurrence bornée, erreurs ignorées", async () => {
let active = 0, peak = 0;
const out = await mapLimit([30, 5, 20, 1, 10], 3, async (ms, i) => {
active++; peak = Math.max(peak, active);
await sleep(ms);
active--;
if (i === 2) throw new Error("x");
return ms;
}, () => null);
assert.deepStrictEqual(out, [30, 5, null, 1, 10]);
assert.strictEqual(peak, 3);
assert.deepStrictEqual(await mapLimit(null, 2, async x => x), []);
});
test("inflight: appels simultanés dédupliqués, libérés ensuite", async () => {
const inf = createInflight();
let calls = 0;
const fn = async () => { calls++; await sleep(10); return "v"; };
const [a, b] = await Promise.all([inf.run("k", fn), inf.run("k", fn)]);
assert.strictEqual(a, "v"); assert.strictEqual(b, "v"); assert.strictEqual(calls, 1);
assert.strictEqual(inf.size, 0);
await inf.run("k", fn);
assert.strictEqual(calls, 2);
await assert.rejects(inf.run("e", async () => { throw new Error("boom"); }));
assert.strictEqual(inf.size, 0);
});
test("cache: TTL, null explicite, expiration", async () => {
const c = createCache({ ttlMs: 40, max: 10 });
c.set("a", 1);
c.set("n", null);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("n"), null);
assert.strictEqual(c.get("absent"), undefined);
c.set("z", 1, 0); // TTL 0 → pas mis en cache
assert.strictEqual(c.get("z"), undefined);
await sleep(60);
assert.strictEqual(c.get("a"), undefined);
assert.strictEqual(c.size, 1); // "n" expiré mais pas encore lu
});
test("cache: taille max et éviction LRU", () => {
const c = createCache({ ttlMs: 10000, max: 3 });
c.set("a", 1); c.set("b", 2); c.set("c", 3);
c.get("a"); // a devient le plus récent
c.set("d", 4);
assert.strictEqual(c.size, 3);
assert.strictEqual(c.get("b"), undefined);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("d"), 4);
});
test("cache.wrap: hit, dédup en cours, null non mis en cache, ttlFor", async () => {
const c = createCache({ ttlMs: 10000, max: 10 });
let calls = 0;
const fn = async () => { calls++; await sleep(10); return { x: 1 }; };
const [a, b] = await Promise.all([c.wrap("k", fn), c.wrap("k", fn)]);
assert.strictEqual(a, b); assert.strictEqual(calls, 1);
await c.wrap("k", fn);
assert.strictEqual(calls, 1);
let nulls = 0;
await c.wrap("n", async () => { nulls++; return null; });
await c.wrap("n", async () => { nulls++; return null; });
assert.strictEqual(nulls, 2);
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
assert.strictEqual(nulls, 3);
await assert.rejects(c.wrap("e", async () => { throw new Error("err"); }));
assert.strictEqual(c.get("e"), undefined);
const s = c.stats();
assert.ok(s.hits >= 2 && s.misses >= 4, JSON.stringify(s));
});
(async () => {
let failed = 0;
for (const t of tests) {
try { await t.fn(); console.log("OK " + t.name); }
catch (e) { failed++; console.log("FAIL " + t.name + "\n " + (e && e.stack || e)); }
}
console.log(`\n${tests.length - failed}/${tests.length} réussis`);
process.exit(failed ? 1 : 0);
})();
+107
View File
@@ -0,0 +1,107 @@
// Test réseau de YoutubeInformation / SpotifyInformation / Finder (cache + limiteur), sans démarrer le bot.
const assert = require("assert");
const path = require("path");
const SRC = require("path").resolve(__dirname, "../../src");
// Logs loguix dans le scratchpad (sinon LogType.error plante : flux non initialisé)
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
// Configuration factice : évite de lire data/config.json (et son process.exit sans token)
const confPath = require.resolve(path.join(SRC, "utils/Database/Configuration.js"));
const fakeConf = { id: "", secret: "" };
require.cache[confPath] = { id: confPath, filename: confPath, loaded: true, exports: {
getSpotifyClientId: () => fakeConf.id,
getSpotifyClientSecret: () => fakeConf.secret,
} };
const yt = require(path.join(SRC, "media/YoutubeInformation.js"));
const spotify = require(path.join(SRC, "media/SpotifyInformation.js"));
const Finder = require(path.join(SRC, "player/Finder.js"));
const { Playlist } = require(path.join(SRC, "playlists/Playlist.js"));
async function timed(label, fn) {
const t = Date.now();
const r = await fn();
const ms = Date.now() - t;
console.log(`${label}: ${ms} ms`);
return [r, ms];
}
(async () => {
// 1) Recherche : 2e appel servi par le cache
const [s1, m1] = await timed("getQuery #1 (réseau)", () => yt.getQuery("Daft Punk Around the World", true));
assert.ok(Array.isArray(s1) && s1.length > 0, "résultats attendus");
const [s2, m2] = await timed("getQuery #2 (cache)", () => yt.getQuery(" daft punk around the world ", false));
assert.ok(s2 && s2.url === s1[0].url, "même premier résultat");
assert.ok(m2 < 20, "le 2e appel doit venir du cache");
assert.notStrictEqual(s2, s1[0], "instances Song distinctes");
// 2) Vidéo par id : 2e appel + getSecondsFromUrl servis par le cache
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const [v1] = await timed("getVideo #1 (réseau)", () => yt.getVideo(url));
assert.ok(v1 && v1.id === "jNQXAC9IVRw" && v1.duration > 0, JSON.stringify(v1));
const [v2, mv2] = await timed("getVideo #2 (cache)", () => yt.getVideo(url));
assert.ok(mv2 < 20 && v2.title === v1.title);
const [secs, ms3] = await timed("getSecondsFromUrl (cache)", () => yt.getSecondsFromUrl("https://youtu.be/jNQXAC9IVRw"));
assert.strictEqual(secs, v1.duration); assert.ok(ms3 < 20);
// 3) Appels simultanés dédupliqués (1 seul scrape)
const [[a, b], md] = await timed("2 recherches simultanées identiques", () => Promise.all([yt.getQuery("lofi hip hop radio"), yt.getQuery("LOFI hip hop radio")]));
assert.ok(a && b && a.url === b.url);
// 4) Durée d'une vidéo issue d'une recherche : déjà en cache
const [secs2, ms4] = await timed("getSecondsFromUrl d'un résultat de recherche", () => yt.getSecondsFromUrl(s1[1].url));
assert.ok(ms4 < 20 && secs2 === s1[1].duration);
// 5) Playlists YouTube (nouvelle mise en page) + avatar ; 2e appel servi par le cache
for (const listUrl of ["https://www.youtube.com/playlist?list=PLdXJrX9OsbOVq2TKvUc3xGUp2Rm8nTUVh", "https://www.youtube.com/watch?v=fcnDmrtj6Sk&list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i"]) {
const [p1] = await timed("getPlaylist #1 (réseau)", () => yt.getPlaylist(listUrl));
assert.ok(p1 && p1.songs.length > 0, "playlist attendue");
const s0 = p1.songs[0];
console.log(" playlist:", p1.title, "| auteur:", p1.author, p1.authorId, "| titres:", p1.songs.length, "| durée:", p1.readduration, "| avatar:", String(p1.authorAvatar).slice(0, 50));
console.log(" 1er titre:", s0.title, "|", s0.author, s0.authorId, "|", s0.readduration, s0.url);
assert.ok(s0.url.startsWith("https://www.youtube.com/watch?v=") && s0.type === "youtube" && s0.duration > 0);
const [p2, mp2] = await timed("getPlaylist #2 (cache)", () => yt.getPlaylist(listUrl));
assert.ok(mp2 < 20 && p2.songs.length === p1.songs.length && p2.authorAvatar === p1.authorAvatar);
const [sec, ms5] = await timed("getSecondsFromUrl d'un titre de playlist", () => yt.getSecondsFromUrl(s0.url));
assert.ok(ms5 < 20 && sec === s0.duration);
}
// Avatar d'une chaîne (en-têtes navigateur + consentement), 2e appel en cache
// 6) getTracks : concurrence 3, ordre conservé, titres invalides ignorés, idempotent
const pl = new Playlist("Test", "https://open.spotify.com/playlist/x");
pl.type = "spotify";
pl.songs = [
{ id: "t1", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 248000 },
null,
{ id: "t2", name: "Bohemian Rhapsody", artists: [{ name: "Queen" }], duration_ms: 354000 },
{ id: "pod", name: "Episode", artists: [] },
{ id: "t3", name: "Billie Jean", artists: [{ name: "Michael Jackson" }], duration_ms: 294000 },
];
const [out] = await timed("getTracks (3 titres)", () => spotify.getTracks(pl));
assert.strictEqual(out, pl);
assert.deepStrictEqual(out.songs.map(s => s.id), ["t1", "t2", "t3"]);
assert.ok(out.songs.every(s => s.type === "youtube" && s.url.startsWith("https://www.youtube.com/watch?v=")));
assert.strictEqual(out.duration, 248 + 354 + 294);
const [, mi] = await timed("getTracks idempotent", () => spotify.getTracks(pl));
assert.ok(mi < 20 && pl.songs.length === 3);
// 7) Finder : playlist Spotify convertie (spotify.getPlaylist simulé), échec total → null
const realGetPlaylist = spotify.getPlaylist;
spotify.getPlaylist = async () => { const p = new Playlist("Sim", "u"); p.type = "spotify"; p.songs = [{ id: "z", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 1000 }]; return p; };
const fp = await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true);
assert.ok(fp instanceof Playlist && fp.songs[0].type === "youtube" && fp.songs[0].url, "Finder doit renvoyer des Song convertis");
const fp2 = await Finder.search("https://open.spotify.com/intl-fr/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", false, "PLAYLIST");
assert.ok(fp2 && fp2.songs[0].url);
spotify.getPlaylist = async () => { const p = new Playlist("Vide", "u"); p.type = "spotify"; p.songs = [null]; return p; };
assert.strictEqual(await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true), null);
spotify.getPlaylist = realGetPlaylist;
console.log("Finder Spotify: OK");
// 8) Spotify sans identifiants : erreur propre (pas d'appel réseau), lecture paresseuse
assert.strictEqual(await spotify.getSong("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"), null);
process.emit("CONFIG_UPDATE");
console.log("Spotify sans identifiants: OK (null)");
console.log("\nTOUS LES TESTS RÉUSSIS");
process.exit(0);
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const plan = req('src/youtube/plan');
const W = 'http://warp:1080';
const ROUTES = { 'warp+direct': [W, null], 'direct': [null], 'warp seul': [W] };
const FB = 'web_safari,tv_downgraded';
const label = (a) => `${a.proxy ? 'W' : 'D'}/${a.identity === 'cookies' ? 'C' : 'A'}${a.clients ? '/fb' : ''}${a.seek ? '/' + a.seek : ''}`;
/**
* Simule getStream : chaque tentative échoue avec la classe donnée (tableau, ou fonction (attempt, i, history))
* okAt : index de la tentative qui réussit ; rotateAt : la tentative i invalide les cookies
*/
function simulate({ routes, mode = 'fallback', cookiesValid = true, cookiesOnProxy = false, fail, okAt = -1, rotateAt = -1, seekMode = null, fallbackClients = FB }) {
const history = [];
const seq = [];
let valid = cookiesValid;
for (let i = 0; i < 10; i++) {
const a = plan.planNext({ routes, mode, cookiesValid: valid, cookiesOnProxy, clients: '', fallbackClients, seekMode, history, maxAttempts: 4 });
if (!a) break;
seq.push(label(a));
if (i === okAt) { seq.push('OK'); break; }
const cls = typeof fail === 'function' ? fail(a, i, history) : (Array.isArray(fail) ? fail[Math.min(i, fail.length - 1)] : fail);
if (i === rotateAt) valid = false;
history.push({ ...a, cls });
}
return seq.join(' > ');
}
function expect(name, input, expected) {
const got = simulate(input);
check(`${name.padEnd(62)} ${got}`, got === expected, { expected });
}
console.log('# Mode never');
expect('never, warp+direct, UNKNOWN', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNKNOWN' }, 'W/A > D/A');
expect('never, warp+direct, BOT_CHECK (jamais de cookies)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('never, warp+direct, HTTP_403 (clients de repli une fois)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'HTTP_403' }, 'W/A > W/A/fb > D/A');
expect('never, direct, LOGIN_REQUIRED (arrêt)', { routes: ROUTES.direct, mode: 'never', fail: 'LOGIN_REQUIRED' }, 'D/A');
expect('never, warp seul, TIMEOUT', { routes: ROUTES['warp seul'], mode: 'never', fail: 'TIMEOUT' }, 'W/A');
expect('never, warp+direct, PRIVATE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'PRIVATE' }, 'W/A');
expect('never, warp+direct, UNAVAILABLE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNAVAILABLE' }, 'W/A');
expect('never, warp+direct, RATE_LIMITED (autre route uniquement)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'RATE_LIMITED' }, 'W/A > D/A');
expect('never, warp+direct, BINARY_MISSING (arrêt)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BINARY_MISSING' }, 'W/A');
console.log('# Mode fallback (cookies valides)');
expect('fallback, warp+direct, BOT_CHECK', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK' }, 'W/A > D/A > D/C');
expect('fallback, warp+direct, IP_BLOCKED, cookiesOnProxy', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'IP_BLOCKED' }, 'W/A > D/A > W/C > D/C');
expect('fallback, direct, CAPTCHA', { routes: ROUTES.direct, fail: 'CAPTCHA' }, 'D/A > D/C');
expect('fallback, warp seul, BOT_CHECK (proxy = seule route)', { routes: ROUTES['warp seul'], fail: 'BOT_CHECK' }, 'W/A > W/C');
expect('fallback, warp+direct, AGE_RESTRICTED (compte directement)', { routes: ROUTES['warp+direct'], fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('fallback, warp+direct, MEMBERS_ONLY', { routes: ROUTES['warp+direct'], fail: 'MEMBERS_ONLY' }, 'W/A > D/C');
expect('fallback, warp+direct, LOGIN_REQUIRED, cookiesOnProxy (refus avec compte = arrêt)', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'LOGIN_REQUIRED' }, 'W/A > W/C');
expect('fallback, warp+direct, UNKNOWN (pas de cookies)', { routes: ROUTES['warp+direct'], fail: 'UNKNOWN' }, 'W/A > D/A');
expect('fallback, warp+direct, GEO_BLOCKED (autre route seulement)', { routes: ROUTES['warp+direct'], fail: 'GEO_BLOCKED' }, 'W/A > D/A');
expect('fallback, direct, GEO_BLOCKED', { routes: ROUTES.direct, fail: 'GEO_BLOCKED' }, 'D/A');
expect('fallback, direct, HTTP_403', { routes: ROUTES.direct, fail: 'HTTP_403' }, 'D/A > D/A/fb');
expect('fallback, direct, SABR', { routes: ROUTES.direct, fail: 'SABR' }, 'D/A > D/A/fb');
expect('fallback, direct, POT_MISSING', { routes: ROUTES.direct, fail: 'POT_MISSING' }, 'D/A > D/A/fb');
expect('fallback, direct, EMPTY_DOWNLOAD', { routes: ROUTES.direct, fail: 'EMPTY_DOWNLOAD' }, 'D/A > D/A/fb');
expect('fallback, direct, FORMAT_UNAVAILABLE, repli désactivé', { routes: ROUTES.direct, fail: 'FORMAT_UNAVAILABLE', fallbackClients: '' }, 'D/A');
expect('fallback, warp+direct, 403 puis BOT_CHECK', { routes: ROUTES['warp+direct'], fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A > W/A/fb > D/A > D/C');
expect('fallback, warp+direct, BOT puis 403 (max 4)', { routes: ROUTES['warp+direct'], fail: ['BOT_CHECK', 'BOT_CHECK', 'HTTP_403'] }, 'W/A > D/A > D/C > D/C/fb');
expect('fallback, warp+direct, réussite à la 2e', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', okAt: 1 }, 'W/A > D/A > OK');
expect('fallback, cookies tournés pendant la tentative avec compte', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', rotateAt: 2 }, 'W/A > D/A > D/C');
expect('fallback, cookies invalides, BOT_CHECK', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('fallback, cookies invalides, AGE_RESTRICTED (arrêt)', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'AGE_RESTRICTED' }, 'W/A');
expect('fallback, compte refusé (AGE avec cookies) -> arrêt', { routes: ROUTES.direct, fail: 'AGE_RESTRICTED' }, 'D/A > D/C');
console.log('# Mode always');
expect('always, warp+direct (pas de compte via WARP)', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'UNKNOWN' }, 'W/A > D/C');
expect('always, warp+direct, cookiesOnProxy', { routes: ROUTES['warp+direct'], mode: 'always', cookiesOnProxy: true, fail: 'UNKNOWN' }, 'W/C > D/C');
expect('always, direct, BOT_CHECK', { routes: ROUTES.direct, mode: 'always', fail: 'BOT_CHECK' }, 'D/C');
expect('always, warp seul (compte autorisé)', { routes: ROUTES['warp seul'], mode: 'always', fail: 'UNKNOWN' }, 'W/C');
expect('always, direct, rotation -> anonyme', { routes: ROUTES.direct, mode: 'always', fail: 'COOKIES_ROTATED', rotateAt: 0 }, 'D/C > D/A');
expect('always, warp+direct, AGE sur WARP anonyme -> compte', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('always, direct, HTTP_403 -> clients de repli avec compte', { routes: ROUTES.direct, mode: 'always', fail: 'HTTP_403' }, 'D/C > D/C/fb');
expect('always sans cookies valides = never', { routes: ROUTES['warp+direct'], mode: 'always', cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('always, PRIVATE', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'PRIVATE' }, 'W/A');
console.log('# Seek');
check('seekModeFor auto 30 -> native', plan.seekModeFor('auto', 30) === 'native');
check('seekModeFor auto 1200 -> native', plan.seekModeFor('auto', 1200) === 'native');
check('seekModeFor auto 1201 -> sections', plan.seekModeFor('auto', 1201) === 'sections');
check('seekModeFor ffmpeg 10 -> sections', plan.seekModeFor('ffmpeg', 10) === 'sections');
check('seekModeFor native 5000 -> native', plan.seekModeFor('native', 5000) === 'native');
check('seekModeFor sans seek -> null', plan.seekModeFor('auto', 0) === null);
expect('sections + HTTP_403 -> natif puis clients de repli', { routes: ROUTES.direct, seekMode: 'sections', fail: 'HTTP_403' }, 'D/A/sections > D/A/native > D/A/fb/native');
expect('sections + 403 sur warp+direct -> la suite en natif', { routes: ROUTES['warp+direct'], seekMode: 'sections', fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A/sections > W/A/native > D/A/native > D/C/native');
expect('natif + UNKNOWN', { routes: ROUTES['warp+direct'], seekMode: 'native', fail: 'UNKNOWN' }, 'W/A/native > D/A/native');
console.log('# Matrice (modes x routes x classes)');
const classes = ['BOT_CHECK', 'IP_BLOCKED', 'CAPTCHA', 'RATE_LIMITED', 'HTTP_403', 'HTTP_429', 'AGE_RESTRICTED', 'LOGIN_REQUIRED', 'MEMBERS_ONLY', 'PRIVATE', 'UNAVAILABLE', 'GEO_BLOCKED', 'POT_MISSING', 'SABR', 'JS_CHALLENGE', 'FORMAT_UNAVAILABLE', 'PAGE_RELOAD', 'COOKIES_ROTATED', 'EMPTY_DOWNLOAD', 'BINARY_MISSING', 'TIMEOUT', 'UNKNOWN'];
let maxLen = 0, dup = 0;
for (const mode of ['never', 'always', 'fallback']) {
for (const [rname, routes] of Object.entries(ROUTES)) {
const row = [];
for (const cls of classes) {
const seq = simulate({ routes, mode, fail: cls });
const parts = seq.split(' > ');
maxLen = Math.max(maxLen, parts.length);
if (new Set(parts).size !== parts.length) dup++;
row.push(`${cls}: ${seq}`);
}
console.log(` [${mode} | ${rname}]`);
for (const r of row) console.log(` ${r}`);
}
}
check('jamais plus de 4 tentatives', maxLen <= 4, maxLen);
check('jamais deux fois la même tentative', dup === 0, dup);
check('pas de compte en mode never', !['warp+direct', 'direct', 'warp seul'].some(r => classes.some(c => simulate({ routes: ROUTES[r], mode: 'never', fail: c }).includes('/C'))));
done();
+227
View File
@@ -0,0 +1,227 @@
// Test de backend/src/server/Server.js avec un vrai serveur socket.io (port local) et des clients socket.io-client.
// Toutes les dépendances (bot Discord, utilisateurs, players, Admin, playlists, Google...) sont simulées :
// aucun bot Discord n'est connecté, aucun fichier de data/ n'est lu ni écrit.
const assert = require("assert")
const Module = require("module")
const path = require("path")
const ROOT = require("path").resolve(__dirname, "../../..")
const SRC = ROOT + "/backend/src"
const { io: ioClient } = require(ROOT + "/frontend/node_modules/socket.io-client")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
const PORT = 45000 + Math.floor(Math.random() * 1000)
const uncaught = []
process.on("uncaughtException", (e) => { uncaught.push(e); console.error("uncaughtException", e) })
process.on("unhandledRejection", (e) => { uncaught.push(e); console.error("unhandledRejection", e) })
// --- Utilisateurs : chaque lecture renvoie un NOUVEL objet (comme loadUsers() après chaque saveUsers()) ---
const raw = {
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
B: { id: "B", tokens: ["tB"], labels: [] },
C: { id: "C", tokens: ["tC"], labels: [] },
}
function makeUser(r) {
if (!r) return null
const labels = [...r.labels]
return {
identity: { id: r.id, username: "user" + r.id, global_name: "User " + r.id, avatar: null },
labels, auth: { ok: true }, tokens: [...r.tokens],
isAdmin: () => labels.includes("ADMIN"),
isFullBanned: () => labels.includes("BAN"),
isBanned: (g) => labels.includes("BAN_" + g),
isMod: (g) => labels.includes("ADMIN") || labels.includes("MOD_" + g),
isDeleted: () => labels.includes("DELETED"),
needUpdate: () => false, justUpdated() {}, clearToken() {},
removeToken(t) { r.tokens = r.tokens.filter(x => x !== t) },
}
}
const usersStub = {
getUserById: (id) => makeUser(raw[id]),
getUserByToken: (t) => makeUser(Object.values(raw).find(r => r.tokens.includes(t))),
setGuildBan: async (id, g) => { const r = raw[id]; const l = "BAN_" + g; r.labels = r.labels.includes(l) ? r.labels.filter(x => x !== l) : [...r.labels, l]; return true },
setGuildMod: async () => true,
deleteAccount: async (id) => { raw[id].labels = ["DELETED"]; raw[id].tokens = [] },
updateIdentity: async () => true,
addUser: async () => null,
}
// --- Bot / players ---
const guilds = new Map([
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }],
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
])
const AllPlayers = new Map()
const seekCalls = []
class FakePlayer {
constructor(guildId) { if (AllPlayers.has(guildId)) return AllPlayers.get(guildId); this.guildId = guildId; this.connected = false; this.queue = { current: { title: "x" } }; AllPlayers.set(guildId, this) }
isConnected() { return this.connected }
getState() { return { v: 2, guildId: this.guildId, status: this.connected ? "playing" : "idle" } }
async setDuration(t) { seekCalls.push(t); return { ok: true } }
async skip() { return "no_music" }
}
let ioRef = null
const adminStub = {
ADMIN_ROOM: "ADMIN", LOGS_ROOM: "ADMIN_LOGS",
init(io) { ioRef = io }, attach() {},
syncUserSockets(userId) {
const user = usersStub.getUserById(userId)
for (const s of ioRef.sockets.sockets.values()) {
if (s.data.userId !== userId) continue
for (const room of [...s.rooms]) if (guilds.has(room) && user.isBanned(room)) s.leave(room)
}
process.emit("USERS_UPDATE")
},
}
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
let reportResult = false
let finderThrows = true
const stubs = {
"loguix": fakeLog,
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v },
[norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} },
[norm(SRC + "/server/auth/User")]: usersStub,
[norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] },
[norm(SRC + "/discord/Bot")]: { isReady: async () => true, getGuilds: () => guilds, getGuildMember: () => null, getMembersVoices: () => new Map(), getChannel: () => null, getGuildRoles: () => [] },
[norm(SRC + "/server/auth/DiscordAuth")]: {},
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
[norm(SRC + "/playlists/PlaylistManager")]: { syncYoutube: async () => ({ ok: true }) },
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
[norm(SRC + "/lyrics/Lyrics")]: {},
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null },
[norm(SRC + "/discord/MediaBase")]: {},
[norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false },
[norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT },
[norm(SRC + "/player/List")]: { List: class { getPrevious() { return [] } } },
[norm(SRC + "/server/Admin")]: adminStub,
[norm(SRC + "/utils/AudioBufferCheck")]: { isAudioFile: () => false },
[norm(SRC + "/player/Song")]: { Song: class {} },
[norm(SRC + "/media/MediaInformation")]: {},
}
const origLoad = Module._load
Module._load = function (request, parent) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const Server = require(SRC + "/server/Server.js")
assert.strictEqual(typeof Server.init, "function", "Server.init exporté")
Server.init()
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const clients = []
function connect(token) {
return new Promise((resolve, reject) => {
const s = ioClient("http://127.0.0.1:" + PORT, { auth: { token }, reconnection: false, transports: ["websocket"] })
clients.push(s)
s.once("/USER/READY", () => resolve(s))
s.once("AUTH_ERROR", (e) => reject(new Error("AUTH_ERROR " + e)))
setTimeout(() => reject(new Error("timeout connexion " + token)), 3000)
})
}
function request(s, event, data) {
return new Promise((resolve, reject) => {
s.once(event, resolve)
s.emit(event, data)
setTimeout(() => reject(new Error("pas de réponse " + event)), 3000)
})
}
function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
;(async () => {
await sleep(100)
const a1 = await connect("tA1"), a2 = await connect("tA2"), b = await connect("tB")
// --- Rooms conservées lors d'un changement de guilde ---
serverSocket(a1).join("ADMIN_LOGS")
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g1"), true)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g2"), true)
const rooms = [...serverSocket(a1).rooms]
assert.ok(rooms.includes("g2") && !rooms.includes("g1"), "guilde précédente quittée : " + rooms)
for (const r of ["user:A", "ADMIN", "ADMIN_LOGS", a1.id]) assert.ok(rooms.includes(r), "room conservée : " + r)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "inconnue"), false)
// --- Présence multi-appareils ---
await request(a1, "/GUILD/JOIN", "g1")
await request(a2, "/GUILD/JOIN", "g1")
await request(b, "/GUILD/JOIN", "g1")
let members = await request(b, "/USERS/LIST")
assert.deepStrictEqual(members.map(m => m.id).sort(), ["A", "B"], "A une seule fois")
a1.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(members.some(m => m.id === "A"), "A toujours présent (2e appareil)")
const info = await request(b, "/GUILD/INFO")
assert.deepStrictEqual(info.members.map(m => m.id), ["A"])
a2.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(!members.some(m => m.id === "A"), "A absent après la dernière déconnexion")
// --- PLAYER_STATE : jamais de création de player, /GUILD/UPDATE seulement au changement de connexion ---
let guildUpdates = 0, playerUpdates = 0
b.on("/GUILD/UPDATE", () => guildUpdates++)
b.on("/PLAYER/UPDATE", () => playerUpdates++)
process.emit("PLAYER_STATE", "g1")
process.emit("PLAYERS_UPDATE")
await sleep(100)
assert.strictEqual(AllPlayers.size, 0, "aucun player créé")
assert.strictEqual(playerUpdates, 1, "état hors-ligne envoyé à la room g1")
const player = new FakePlayer("g1"); player.connected = true
process.emit("PLAYER_STATE", "g1"); await sleep(50)
process.emit("PLAYER_STATE", "g1"); await sleep(50)
assert.strictEqual(guildUpdates, 2, "1re émission + passage à connecté uniquement (" + guildUpdates + ")")
assert.strictEqual((await request(b, "/PLAYER/STATE")).status, "playing")
// --- SEEK : 0 accepté, négatif refusé, refus du player transmis ---
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 0 }), true)
assert.strictEqual(await request(b, "/PLAYER/SEEK", 0), true)
assert.deepStrictEqual(seekCalls, [0, 0])
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: -1 }), false)
assert.strictEqual(await request(b, "/PLAYER/SEEK", {}), false)
player.queue.current = null
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 5 }), false, "pas de musique : false (pas de ReferenceError)")
assert.strictEqual(await request(b, "/PLAYER/FORWARD"), "no_music")
// --- Exception dans un handler : réponse false, process intact ---
assert.strictEqual(await request(b, "/SEARCH", "test"), false)
// --- /REPORT : résultat réel de l'envoi ---
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), false)
reportResult = true
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), true)
// --- /MOD/USERS/BAN (plus de UsersBySocket) + droits relus à chaque requête ---
const c = await connect("tC")
await request(c, "/GUILD/JOIN", "g1")
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "le propriétaire bannit B")
await sleep(50)
assert.ok(!serverSocket(b).rooms.has("g1"), "B a quitté la room g1")
assert.strictEqual(await request(b, "/PLAYER/STATE"), false, "B banni : refus immédiat (utilisateur relu)")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), false)
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "levée du ban")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), true)
// --- Bannissement global en cours de session ---
raw.B.labels.push("BAN")
const authError = new Promise(r => b.once("AUTH_ERROR", r))
b.emit("/USER/INFO")
assert.strictEqual(await authError, "Vous êtes banni du serveur")
await sleep(100)
assert.strictEqual(b.connected, false)
// --- /GOOGLE/STATUS par ack ---
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
assert.strictEqual(status.user, "C")
await sleep(100)
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
console.log("server-socket.test.js : OK")
clients.forEach(s => s.disconnect())
ioRef.close()
setTimeout(() => process.exit(0), 100)
})().catch((e) => { console.error(e); process.exit(1) })
+155
View File
@@ -0,0 +1,155 @@
// Test de bout en bout : vrai yt-dlp, vrai YouTube, sans proxy (WARP non démarré => route directe)
// Usage : node stream.e2e.js [--no-update] [--no-cookies]
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('e2e');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const warp = req('src/utils/WarpProxy');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const args = process.argv.slice(2);
const TARGET = 200 * 1024;
const LONG_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
function procCount(image) {
if (process.platform !== 'win32') return null;
try {
const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${image}`, '/NH'], { encoding: 'utf8', windowsHide: true });
return out.split(/\r?\n/).filter(l => l.toLowerCase().startsWith(image.toLowerCase())).length;
} catch (e) { return null; }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* Lit ~200 Ko puis annule via AbortController
*/
async function readThenAbort(label, seek, { expectRaw = false, url } = {}) {
console.log(`\n# ${label}`);
const controller = new AbortController();
const progress = [];
const t0 = Date.now();
const song = { id: 'e2e', title: 'e2e', url: url || settings.get().testUrl, type: 'youtube' };
const stream = await getStream(song, seek, { signal: controller.signal, guildId: 'e2e-guild', onProgress: (p) => progress.push(p) });
const tStream = Date.now() - t0;
console.log(` getStream -> ${stream ? 'flux' : 'null'} en ${tStream} ms, progression : ${JSON.stringify(progress)}`);
console.log(` activeProcesses pendant la lecture : ${JSON.stringify([...activeProcesses.entries()].map(([g, p]) => ({ g, route: p.route, identity: p.identity, pid: !!p.pid })))}`);
if (!stream) {
console.log(' diagnostics :', JSON.stringify(diagnostics.getRecent(4), null, 0));
return { ok: false };
}
check(`${label} : streamType ${expectRaw ? '"raw"' : 'conteneur'}`, expectRaw ? stream.streamType === 'raw' : stream.streamType === undefined);
const chunks = [];
let total = 0;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 60000);
stream.on('data', (c) => {
if (total >= TARGET) return;
chunks.push(c);
total += c.length;
if (total >= TARGET) { clearTimeout(timer); resolve(); }
});
stream.on('close', () => { clearTimeout(timer); resolve(); });
stream.on('error', () => {});
});
const buf = Buffer.concat(chunks);
console.log(` ${total} octets lus en ${Date.now() - t0} ms`);
check(`${label} : ~200 Ko reçus`, total >= TARGET, total);
if (expectRaw) {
// PCM s16le stéréo : vérifie qu'il y a du signal (pas que des zéros)
let sumSq = 0, n = 0, nonZero = 0;
for (let i = 0; i + 1 < buf.length; i += 2) {
const v = buf.readInt16LE(i);
sumSq += v * v; n++;
if (v !== 0) nonZero++;
}
const rms = Math.sqrt(sumSq / Math.max(1, n));
console.log(` PCM : ${n} échantillons, RMS ${rms.toFixed(1)}, ${(100 * nonZero / Math.max(1, n)).toFixed(1)} % non nuls, ~${(n / 2 / 48000).toFixed(2)} s d'audio`);
check(`${label} : PCM non silencieux`, rms > 10 && nonZero > n / 10);
} else {
// WebM (EBML 1A45DFA3) ou MP4 (ftyp)
const magic = buf.subarray(0, 4).toString('hex');
const isWebm = magic === '1a45dfa3';
const isMp4 = buf.subarray(4, 8).toString() === 'ftyp';
console.log(` conteneur : ${isWebm ? 'WebM' : isMp4 ? 'MP4' : 'inconnu (' + magic + ')'}`);
check(`${label} : conteneur audio reconnu`, isWebm || isMp4);
}
controller.abort();
// Processus tués et copies de cookies supprimées
let waited = 0;
while ((ytdlp.activeCount() > 0 || activeProcesses.size > 0 || cookies.liveCopyCount() > 0) && waited < 5000) {
await sleep(100); waited += 100;
}
console.log(` après abort (${waited} ms) : processus suivis ${ytdlp.activeCount()}, activeProcesses ${activeProcesses.size}, copies de cookies ${cookies.liveCopyCount()}, stream détruit ${stream.destroyed}`);
check(`${label} : tous les processus terminés`, ytdlp.activeCount() === 0 && activeProcesses.size === 0);
check(`${label} : flux détruit`, stream.destroyed === true);
check(`${label} : aucune copie chopin-ytc-* restante`, chopinTmpDirs().length === 0 && cookies.liveCopyCount() === 0, chopinTmpDirs());
return { ok: true };
}
(async () => {
console.log('routes WARP :', JSON.stringify(warp.getRoutes().map(r => r ? 'warp' : 'direct')));
const bin = ytdlp.resolveBinary();
const ff = ytdlp.resolveFfmpeg();
console.log(`yt-dlp : ${bin.source} ${path.relative(BACKEND, bin.path) || bin.path}`);
console.log(`ffmpeg : ${ff?.source} ${ff ? path.relative(BACKEND, ff.path) : '-'}`);
console.log(`version : ${await ytdlp.getVersion({ force: true })}`);
const before = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système avant :', JSON.stringify(before));
if (!args.includes('--no-update')) {
console.log('\n# Mise à jour (yt-dlp -U)');
const up = await ytdlp.update({ reason: 'test e2e' });
console.log(' ', JSON.stringify({ ok: up.ok, updatable: up.updatable, before: up.before, after: up.after, message: up.message }));
console.log(' sortie :', up.output.split('\n').slice(-4).join(' | '));
check('update : version connue après', Boolean(up.after));
}
await readThenAbort('Lecture sans seek (anonyme, directe)', 0);
// testUrl (Me at the zoo) ne dure que 19 s : seek 30 s sur une vidéo plus longue
await readThenAbort('Seek 30 s (natif : yt-dlp -> ffmpeg -> PCM brut)', 30, { expectRaw: true, url: LONG_URL });
console.log('\n# Seek au-delà de la fin (testUrl, 19 s) : un seul essai, sans clients de repli');
const n0 = diagnostics.getRecent(100).length;
const past = await getStream({ id: 'e2e', title: 'e2e', url: settings.get().testUrl, type: 'youtube' }, 30, { guildId: 'e2e-guild' });
const tries = diagnostics.getRecent(100).length - n0;
check('seek après la fin : null en 1 tentative (final)', past === null && tries === 1 && diagnostics.getRecent(1)[0].cls === 'EMPTY_DOWNLOAD', { tries, last: diagnostics.getRecent(1)[0] });
await sleep(2500);
check('seek après la fin : processus terminés', ytdlp.activeCount() === 0, ytdlp.activeCount());
if (!args.includes('--no-cookies')) {
// Anciens cookies (fichiers réels en lecture seule) importés dans le dossier isolé, mode "always"
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
const mig = cookies.migrateLegacy();
console.log('\n# Cookies : migration', JSON.stringify(mig), '-> valides :', cookies.isValid());
if (cookies.isValid()) {
settings.update({ cookiesMode: 'always' }, { username: 'e2e' });
await readThenAbort('Lecture avec compte (mode always, copie temporaire des cookies)', 0);
const st = cookies.getStatus();
console.log(' statut cookies après lecture :', JSON.stringify({ valid: st.valid, invalidatedAt: !!st.invalidatedAt, invalidReason: st.invalidReason }));
}
}
console.log('\n# Diagnostics');
for (const e of diagnostics.getRecent(20).reverse()) {
console.log(` ${e.outcome.padEnd(7)} ${e.route}/${e.identity}${e.clients ? '/' + e.clients : ''}${e.seek ? '/' + e.seek : ''} ${e.ms} ms ${e.cls || ''} ${e.message || ''}`);
}
const s = diagnostics.getSummary();
console.log(' résumé :', JSON.stringify({ ok: s.ok, failed: s.failed, aborted: s.aborted, byClass: s.byClass, consecutiveBlocks: s.consecutiveBlocks }));
await sleep(1000);
console.log(' getStatus().activeProcesses :', youtube.getStatus().activeProcesses);
const after = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système après :', JSON.stringify(after));
check('aucun yt-dlp.exe / ffmpeg.exe orphelin', after.ytdlp === null || (after.ytdlp <= before.ytdlp && after.ffmpeg <= before.ffmpeg), { before, after });
check('aucune copie chopin-ytc-* (fin)', chopinTmpDirs().length === 0, chopinTmpDirs());
fs.rmSync(dataDir, { recursive: true, force: true });
done();
// Timers unref : le process se termine seul
})().catch((e) => { console.error(e); process.exitCode = 1; });
+55
View File
@@ -0,0 +1,55 @@
// Cas limites de getStream : binaire absent, annulation avant les données (avec copie de cookies), URL refusée
const fs = require('fs');
const { SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('edge');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const song = { id: 'edge', title: 'edge', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', type: 'youtube' };
(async () => {
console.log('# URL refusée');
check('URL "--exec=..." refusée', (await getStream({ url: '--exec=calc' })) === null);
console.log('# Binaire absent');
const savedPath = process.env.PATH, savedRoot = __glob.ROOT;
process.env.PATH = '';
__glob.ROOT = SCRATCH + '/';
const bin = ytdlp.resolveBinary({ force: true });
check('resolveBinary -> missing', bin.source === 'missing', bin);
const n0 = diagnostics.getRecent(100).length;
const r1 = await getStream(song, 0, { guildId: 'g' });
const last = diagnostics.getRecent(1)[0];
check('binaire absent : null, 1 tentative, BINARY_MISSING', r1 === null && diagnostics.getRecent(100).length - n0 === 1 && last.cls === 'BINARY_MISSING', last);
process.env.PATH = savedPath;
__glob.ROOT = savedRoot;
check('binaire retrouvé ensuite', ytdlp.resolveBinary({ force: true }).source !== 'missing');
console.log('# Annulation avant les données (avec compte : copie de cookies)');
const T = (...f) => f.join('\t');
cookies.importCookies([T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'LOGIN_INFO', 'fake'), T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'SAPISID', 'fake')].join('\n'));
settings.update({ cookiesMode: 'always' });
const controller = new AbortController();
const p = getStream(song, 0, { signal: controller.signal, guildId: 'g2' });
await sleep(400);
const copiesDuring = cookies.liveCopyCount();
const procDuring = ytdlp.activeCount();
controller.abort();
const r2 = await p;
console.log(` pendant : copies ${copiesDuring}, processus ${procDuring}`);
check('annulation : null', r2 === null);
check('annulation : diagnostics "aborted"', diagnostics.getRecent(1)[0].outcome === 'aborted');
let waited = 0;
while ((ytdlp.activeCount() || cookies.liveCopyCount() || chopinTmpDirs().length) && waited < 5000) { await sleep(100); waited += 100; }
console.log(` après ${waited} ms : processus ${ytdlp.activeCount()}, copies ${cookies.liveCopyCount()}, dossiers ${chopinTmpDirs().length}, activeProcesses ${activeProcesses.size}`);
check('annulation : la copie existait pendant la tentative', copiesDuring === 1 && procDuring >= 1);
check('annulation : processus tués, copie supprimée', ytdlp.activeCount() === 0 && cookies.liveCopyCount() === 0 && chopinTmpDirs().length === 0 && activeProcesses.size === 0);
console.log('# Signal déjà annulé');
const c2 = new AbortController(); c2.abort();
check('signal déjà annulé : null sans processus', (await getStream(song, 0, { signal: c2.signal })) === null && ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+20
View File
@@ -0,0 +1,20 @@
// Détecte les identifiants référencés sans liaison (ReferenceError potentielle) via @babel/traverse
const FE = require("path").resolve(__dirname, "../../../frontend/node_modules") + "/"
const parser = require(FE + "@babel/parser")
const traverse = require(FE + "@babel/traverse").default
const fs = require("fs")
const globals = new Set(Object.getOwnPropertyNames(globalThis).concat(["require","module","exports","__dirname","__filename","process","Buffer","console","setTimeout","clearTimeout","setInterval","clearInterval","setImmediate","URL","fetch","AbortController","structuredClone","undefined","arguments"]))
let bad = 0
for (const file of process.argv.slice(2)) {
const ast = parser.parse(fs.readFileSync(file, "utf8"), { sourceType: "script", allowReturnOutsideFunction: true })
traverse(ast, {
ReferencedIdentifier(p) {
const name = p.node.name
if (p.scope.hasBinding(name, true) || globals.has(name)) return
if (p.parentPath.isMemberExpression({ property: p.node }) && !p.parentPath.node.computed) return
bad++
console.log(file.split("/").pop() + ":" + p.node.loc.start.line + " " + name)
}
})
}
console.log(bad ? bad + " identifiant(s) non lié(s)" : "aucun identifiant non lié")
+5
View File
@@ -0,0 +1,5 @@
const { isolateData, req } = require('./_setup');
isolateData('upd');
const ytdlp = req('src/youtube/ytdlp');
const a = ytdlp.update({ reason: 'test' }), b = ytdlp.update();
a.then(r => console.log('single-flight:', a === b, JSON.stringify({ ok: r.ok, updatable: r.updatable, before: r.before, after: r.after, message: r.message })));