Version 2.0.0 - update of everything
This commit is contained in:
85 files changed
+12750
-1792
No files matched your search
@@ -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
@@ -2,24 +2,50 @@ FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Version du plugin PO token (bgutil) : doit correspondre à l'image du service bgutil-provider (docker-compose.yml)
|
||||
ARG BGUTIL_VERSION=2.0.1
|
||||
|
||||
# 1. Installer les dépendances système requises
|
||||
# python3 : indispensable pour faire tourner yt-dlp
|
||||
# ffmpeg : nécessaire pour certaines conversions audio ou formats spécifiques
|
||||
# curl : pour télécharger yt-dlp
|
||||
# python3 : indispensable pour faire tourner yt-dlp (version zipimport, auto-actualisable avec -U)
|
||||
# ffmpeg : seek YouTube (décodage du flux) et certains formats
|
||||
# curl : pour télécharger yt-dlp et le plugin bgutil
|
||||
RUN apk add --no-cache bash python3 ffmpeg curl
|
||||
|
||||
# 2. Installer yt-dlp (Dernière version officielle)
|
||||
# 2. Installer yt-dlp (dernière version officielle, build zipimport : inclut yt-dlp-ejs pour les défis JS)
|
||||
# On le place dans /usr/local/bin pour qu'il soit accessible globalement dans le PATH
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||
RUN curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||
chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
# 3. Installation des dépendances Node.js
|
||||
# 3. Plugin bgutil : yt-dlp obtient des PO tokens auprès du service bgutil-provider
|
||||
# (/etc/yt-dlp/plugins/ est lu par yt-dlp, les archives .zip y sont acceptées telles quelles)
|
||||
RUN mkdir -p /etc/yt-dlp/plugins && \
|
||||
curl -fL "https://github.com/Brainicism/bgutil-ytdlp-pot-provider/releases/download/${BGUTIL_VERSION}/bgutil-ytdlp-pot-provider.zip" \
|
||||
-o /etc/yt-dlp/plugins/bgutil-ytdlp-pot-provider.zip
|
||||
|
||||
# 4. Installation des dépendances Node.js
|
||||
# youtube-dl-exec téléchargerait un second yt-dlp (build glibc, inutilisable sous Alpine) : inutile, celui du PATH est utilisé
|
||||
ENV YOUTUBE_DL_SKIP_DOWNLOAD=true
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# 4. Copie du reste du code
|
||||
# 5. Copie du reste du code
|
||||
# Voir .dockerignore : node_modules (binaires Windows), data/ (token, cookies, comptes), logs/ et .git
|
||||
# ne sont PAS copiés dans l'image. data/ est fourni par le volume monté sur /app/data (docker-compose.yml) :
|
||||
# config.json doit y être déposé avant le premier démarrage, sinon le bot crée une config vide et s'arrête.
|
||||
# data/youtube/ (cookies YouTube, réglages, cache de yt-dlp) est créé au démarrage dans ce même volume.
|
||||
COPY . .
|
||||
# logs/ n'est plus copié : loguix crée son dossier de façon asynchrone et ouvre son fichier aussitôt,
|
||||
# le dossier doit donc exister au démarrage (même sans volume monté sur /app/logs)
|
||||
RUN mkdir -p /app/logs /app/data
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
# État de santé : route HTTP /health du serveur (port 4000, celui exposé ci-dessus)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:4000/health > /dev/null || exit 1
|
||||
|
||||
# Mise à jour de yt-dlp à chaque démarrage : une version obsolète provoque des erreurs 403
|
||||
CMD ["sh", "-c", "yt-dlp -U || true; npm run start"]
|
||||
# (le bot la refait ensuite toutes les 24 h et après une série d'échecs, voir src/youtube/ytdlp.js)
|
||||
# exec : node remplace sh et devient PID 1 (il reçoit SIGTERM pour un arrêt propre).
|
||||
# Plus de nodemon en production : un crash termine le process et Docker relance le conteneur (restart: unless-stopped).
|
||||
CMD ["sh", "-c", "yt-dlp -U || true; exec node src/main.js"]
|
||||
@@ -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:
|
||||
@@ -25,23 +25,44 @@ services:
|
||||
volumes:
|
||||
- subsonics-warp-data:/var/lib/cloudflare-warp
|
||||
|
||||
# Fournisseur de PO tokens pour yt-dlp (plugin bgutil installé dans l'image du backend)
|
||||
# Aucun port publié : le serveur n'a pas d'authentification, il reste sur le réseau interne.
|
||||
# Il reçoit le --proxy de yt-dlp dans chaque requête : warp:1080 lui est joignable sur ce même réseau.
|
||||
# La version doit correspondre à BGUTIL_VERSION dans le Dockerfile.
|
||||
bgutil-provider:
|
||||
image: brainicism/bgutil-ytdlp-pot-provider:2.0.1
|
||||
container_name: subsonics-bgutil
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
|
||||
subsonics-backend:
|
||||
build:
|
||||
context: . # dossier backend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- BGUTIL_VERSION=2.0.1
|
||||
container_name: subsonics-backend
|
||||
# Init minimal (tini) : node n'est pas PID 1, les enfants orphelins de yt-dlp (node, ffmpeg) sont bien récupérés
|
||||
init: true
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
- WARP_PROXY=http://warp:1080
|
||||
# Si WARP tombe ou se fait bloquer, on retente en connexion directe
|
||||
- WARP_FALLBACK_DIRECT=true
|
||||
# PO tokens (verrouillé dans l'administration tant que la variable est définie)
|
||||
- YTDLP_POT_PROVIDER=http://bgutil-provider:4416
|
||||
depends_on:
|
||||
- warp
|
||||
- bgutil-provider
|
||||
volumes:
|
||||
# Contient aussi data/youtube/ : cookies YouTube, réglages et cache de yt-dlp
|
||||
- subsonics-backend-data:/app/data
|
||||
# Logs persistés (consultables depuis l'interface d'administration)
|
||||
- subsonics-backend-logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
subsonics-backend-data:
|
||||
subsonics-backend-logs:
|
||||
subsonics-warp-data:
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chopin-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Discord Bot for music - Fetching everywhere !",
|
||||
"main": "src/main.js",
|
||||
"nodemonConfig": {
|
||||
|
||||
+59
-13
@@ -2,30 +2,76 @@ const bot = require('./Bot')
|
||||
const dlog = require('loguix').getInstance('Discord')
|
||||
const {ActivityType} = require('discord.js')
|
||||
|
||||
// Regroupement des mises à jour de présence : pas de clignotement entre deux morceaux, pas de rate limit
|
||||
const REFRESH_DELAY = 1500
|
||||
// Statuts où le bot "écoute" quelque chose
|
||||
const ACTIVE_STATUS = ["playing", "loading", "stalled"]
|
||||
|
||||
let refreshTimer = null
|
||||
// Dernière présence envoyée : on n'appelle Discord que si elle change
|
||||
let lastActivity = null
|
||||
|
||||
function getActivity() {
|
||||
const client = bot.getClient()
|
||||
return client.user.presence.activities[0]
|
||||
}
|
||||
|
||||
function applyActivity(text) {
|
||||
const client = bot.getClient()
|
||||
if(!client?.user) return false
|
||||
if(text === lastActivity) return true
|
||||
try {
|
||||
client.user.setActivity(text, {
|
||||
type: ActivityType.Listening
|
||||
});
|
||||
lastActivity = text
|
||||
dlog.log(`Activité mise à jour : ${text}`)
|
||||
return true
|
||||
} catch(e) {
|
||||
dlog.error(`Impossible de mettre à jour l'activité : ${e?.message || e}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set All type of activities
|
||||
|
||||
function setMusicActivity(songName, artistName, imageUrl) {
|
||||
const client = bot.getClient()
|
||||
client.user.setActivity(`${songName} - ${artistName}`,{
|
||||
type: ActivityType.Listening,
|
||||
url: imageUrl
|
||||
});
|
||||
dlog.log(`Activité mise à jour : ${songName} - ${artistName}`)
|
||||
function setMusicActivity(songName, artistName) {
|
||||
applyActivity(`${songName} - ${artistName}`)
|
||||
}
|
||||
|
||||
function idleActivity() {
|
||||
const client = bot.getClient()
|
||||
client.user.setActivity("le silence absolu", {
|
||||
type: ActivityType.Listening
|
||||
applyActivity("le silence absolu")
|
||||
}
|
||||
|
||||
});
|
||||
dlog.log(`Activité mise à jour : rien`)
|
||||
/**
|
||||
* Calcule la présence à partir de TOUS les players (cohérente en multi-serveur)
|
||||
*/
|
||||
function computeActivity() {
|
||||
const { getAllPlayers } = require('../player/Player')
|
||||
const active = getAllPlayers().filter(player => ACTIVE_STATUS.includes(player.status) && player.queue?.current)
|
||||
if(active.length === 0) return "le silence absolu"
|
||||
if(active.length === 1) {
|
||||
const song = active[0].queue.current
|
||||
return `${song.title} - ${song.author}`
|
||||
}
|
||||
return `de la musique sur ${active.length} serveurs`
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande une mise à jour de la présence (debounce de 1,5 s)
|
||||
*/
|
||||
function refresh() {
|
||||
if(refreshTimer) return
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null
|
||||
try {
|
||||
applyActivity(computeActivity())
|
||||
} catch(e) {
|
||||
dlog.error(`Erreur lors du calcul de l'activité : ${e?.message || e}`)
|
||||
}
|
||||
}, REFRESH_DELAY)
|
||||
refreshTimer.unref?.()
|
||||
}
|
||||
|
||||
|
||||
module.exports = {getActivity, setMusicActivity, idleActivity}
|
||||
module.exports = {getActivity, setMusicActivity, idleActivity, refresh}
|
||||
+147
-47
@@ -4,8 +4,8 @@ const path = require("path")
|
||||
const { __glob } = require("../utils/GlobalVars")
|
||||
const { LogType } = require("loguix")
|
||||
const config = require("../utils/Database/Configuration")
|
||||
const metric = require("webmetrik")
|
||||
const { Player } = require("../player/Player")
|
||||
const SafeMetric = require("../utils/SafeMetric")
|
||||
const { AllPlayers } = require("../player/Player")
|
||||
const {refreshAllUserInformation} = require("../server/auth/User")
|
||||
const serverSettings = require("./ServerSettings")
|
||||
const { Embed, EmbedError } = require("./Embed")
|
||||
@@ -75,7 +75,8 @@ function getGuildRoles(guildId) {
|
||||
}
|
||||
|
||||
function getChannel(guildId, channelId) {
|
||||
return client.guilds.cache.get(guildId).channels.cache.get(channelId)
|
||||
// Guilde quittée ou inconnue : undefined plutôt qu'une TypeError
|
||||
return client.guilds.cache.get(guildId)?.channels.cache.get(channelId)
|
||||
}
|
||||
|
||||
function init() {
|
||||
@@ -106,38 +107,53 @@ function init() {
|
||||
|
||||
if(!interaction.isChatInputCommand()) return;
|
||||
|
||||
var numberOfCommands = new metric.Metric("numberOfCommands", "Nombre de commandes éxécutées")
|
||||
numberOfCommands.setValue(numberOfCommands.getValue() + 1)
|
||||
var numberOfCommandsServer = new metric.Metric("numberOfCommands_" + interaction.guild.id, "Nombre de commandes éxécutées sur le serveur : " + interaction.guild.name)
|
||||
numberOfCommandsServer.setValue(numberOfCommandsServer.getValue() + 1)
|
||||
// Compteurs de commandes (SafeMetric ne lève jamais d'exception)
|
||||
SafeMetric.increment("numberOfCommands", 1, "Nombre de commandes éxécutées")
|
||||
if(interaction.guildId) {
|
||||
SafeMetric.increment("numberOfCommands_" + interaction.guildId, 1, "Nombre de commandes éxécutées sur le serveur : " + (interaction.guild?.name ?? interaction.guildId))
|
||||
}
|
||||
|
||||
const command = client.commands.get(interaction.commandName)
|
||||
const roleProtected = await serverSettings.getSecureRole(interaction.guild.id) || false
|
||||
const command = client.commands?.get(interaction.commandName)
|
||||
if(!command) {
|
||||
dlog.warn("Commande inconnue ou pas encore chargée : /" + interaction.commandName)
|
||||
return
|
||||
}
|
||||
var roleProtected = false
|
||||
var havePermission = true;
|
||||
try {
|
||||
roleProtected = interaction.guildId ? (await serverSettings.getSecureRole(interaction.guildId) || false) : false
|
||||
if(roleProtected) {
|
||||
await interaction.member.fetch()
|
||||
if(!interaction.member.roles.cache.has(roleProtected.id)) {
|
||||
havePermission = false;
|
||||
}
|
||||
}
|
||||
} catch(error) {
|
||||
// Vérification impossible : on refuse par sécurité si un rôle est exigé
|
||||
dlog.error("Vérification du rôle sécurisé impossible pour /" + interaction.commandName + " : " + (error?.stack || error))
|
||||
if(roleProtected) havePermission = false
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a metric to count the number of commands executed by each user
|
||||
const userCommand = new metric.Metric("userCommand_" + interaction.member.user.username, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.member.user.username)
|
||||
userCommand.setValue(userCommand.getValue() + 1)
|
||||
SafeMetric.increment("userCommand_" + interaction.user.username, 1, "Nombre de commandes éxécutées par l'utilisateur : " + interaction.user.username)
|
||||
dlog.log(interaction.member.user.username + "-> /" + interaction.commandName)
|
||||
if(havePermission) {
|
||||
command.execute(client, interaction)
|
||||
// await : les erreurs asynchrones de la commande remontent dans ce try/catch
|
||||
await command.execute(client, interaction)
|
||||
} else {
|
||||
const embed = new EmbedError(`L'utilisation du Bot est réservée aux membres ayant le rôle "${roleProtected.name}"`, interaction, true)
|
||||
embed.setTitle("Accès refusé")
|
||||
embed.send()
|
||||
await embed.send()
|
||||
}
|
||||
} catch(error) {
|
||||
|
||||
dlog.error(interaction.member.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE")
|
||||
dlog.error(error)
|
||||
interaction.reply({content:"Erreur lors de l'éxécution de la commande !", ephemeral: true})
|
||||
dlog.error(interaction.user.username + "-> /" + interaction.commandName + " : ERREUR RENCONTRE")
|
||||
dlog.error(error?.stack || error)
|
||||
// Réponse d'erreur selon l'état de l'interaction (déjà différée ou non), sans jamais lever
|
||||
const errorReply = {content:"Erreur lors de l'éxécution de la commande !", ephemeral: true}
|
||||
const replyPromise = (interaction.replied || interaction.deferred) ? interaction.editReply(errorReply) : interaction.reply(errorReply)
|
||||
await Promise.resolve(replyPromise).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -156,10 +172,12 @@ function init() {
|
||||
|
||||
// If a new guild is added, we will add it to the guilds map
|
||||
client.on("guildCreate", async (guild) => {
|
||||
|
||||
// Déploiement des commandes slash sur le nouveau serveur (sinon absentes jusqu'au redémarrage)
|
||||
require("./CommandUpdater").registerGuild(guild.id)
|
||||
await refreshGuilds()
|
||||
glog.log("Guilde ajoutée : " + guild.name + " (" + guild.id + ")")
|
||||
process.emit("USERS_UPDATE")
|
||||
process.emit("GUILDS_UPDATE")
|
||||
|
||||
})
|
||||
|
||||
@@ -168,11 +186,13 @@ function init() {
|
||||
guilds.delete(guild.id)
|
||||
glog.log("Guilde supprimée : " + guild.name + " (" + guild.id + ")")
|
||||
process.emit("USERS_UPDATE")
|
||||
process.emit("GUILDS_UPDATE")
|
||||
})
|
||||
|
||||
client.on('guildUpdate', async () => {
|
||||
await refreshGuilds()
|
||||
process.emit("USERS_UPDATE")
|
||||
process.emit("GUILDS_UPDATE")
|
||||
})
|
||||
|
||||
client.on("voiceStateUpdate", (oldMember, newMember) => {
|
||||
@@ -181,32 +201,14 @@ function init() {
|
||||
channelId: newMember.channelId,
|
||||
})
|
||||
|
||||
const player = new Player(newMember.guild.id)
|
||||
|
||||
if(player.connection && player.channelId) {
|
||||
client.channels.fetch(player.channelId).then(channel => {
|
||||
|
||||
if(channel.members.size <= 1) {
|
||||
|
||||
// If the player is alone in the channel, we will destroy it in 10 minutes
|
||||
// 10 minutes = 600000 ms
|
||||
// 10 second = 10000 ms
|
||||
timers.set(newMember.guild.id, setTimeout(() => {
|
||||
const getPlayer = new Player(newMember.guild.id)
|
||||
if(getPlayer.connection && player.channelId) {
|
||||
getPlayer.leave()
|
||||
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé : " + channel.name)
|
||||
try {
|
||||
// Le bot lui-même a changé de salon (déplacement par un modérateur) ou a été expulsé
|
||||
if(newMember.id === client.user?.id) {
|
||||
AllPlayers.get(newMember.guild.id)?.onBotVoiceState(newMember)
|
||||
}
|
||||
|
||||
}, 600000))
|
||||
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player supprimé dans 10 minutess : " + channel.name)
|
||||
} else {
|
||||
dlog.log("[Automatic Task] Guild Id :" + newMember.guild.id + " - Player n'est pas seul dans le channel : " + channel.name)
|
||||
clearTimeout(timers.get(newMember.guild.id))
|
||||
timers.delete(newMember.guild.id)
|
||||
}
|
||||
})
|
||||
|
||||
checkAutoLeave(newMember.guild.id)
|
||||
} catch(error) {
|
||||
dlog.error("[Automatic Task] Erreur lors de la vérification de l'auto-départ : " + (error?.stack || error))
|
||||
}
|
||||
|
||||
process.emit("VOCAL_UPDATE")
|
||||
@@ -218,17 +220,46 @@ function init() {
|
||||
client.login(config.getToken())
|
||||
}
|
||||
|
||||
// Un seul rafraîchissement à la fois : les appels concurrents (arrivées de membres, guildUpdate...) sont regroupés
|
||||
let refreshing = null
|
||||
let refreshQueued = false
|
||||
|
||||
async function refreshGuilds() {
|
||||
if(refreshing) {
|
||||
refreshQueued = true
|
||||
return refreshing
|
||||
}
|
||||
refreshing = (async () => {
|
||||
do {
|
||||
refreshQueued = false
|
||||
try {
|
||||
await doRefreshGuilds()
|
||||
} catch(error) {
|
||||
glog.step.error("d_refresh_guilds", error?.message || error)
|
||||
dlog.error("Erreur lors du rafraichissement des guildes : " + (error?.stack || error))
|
||||
}
|
||||
} while(refreshQueued)
|
||||
})().finally(() => { refreshing = null })
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function doRefreshGuilds() {
|
||||
glog.step.init("d_refresh_guilds", "Rafraichissement des guildes")
|
||||
await client.guilds.fetch()
|
||||
for(const guild of client.guilds.cache.values()) {
|
||||
try {
|
||||
await guild.members.fetch()
|
||||
} catch(error) {
|
||||
// GuildMembersTimeout : on garde le cache actuel plutôt que d'abandonner toutes les guildes
|
||||
glog.warn("Impossible de récupérer les membres de la guilde : " + guild.name + " (" + guild.id + ") : " + (error?.message || error))
|
||||
}
|
||||
var allMembersOfGuild = guild.members.cache.map(member => member.user.id)
|
||||
const missingPermissions = checkRequiredPermission(guild.members.me)
|
||||
const missingPermissions = guild.members.me ? checkRequiredPermission(guild.members.me) : []
|
||||
if(missingPermissions.length > 0) {
|
||||
dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name)
|
||||
guild.leave()
|
||||
return
|
||||
dlog.error("Le bot n'a pas les permissions nécessaires pour rejoindre la guilde : " + guild.name + " (manquantes : " + missingPermissions.join(", ") + ")")
|
||||
guild.leave().catch(error => dlog.error("Impossible de quitter la guilde " + guild.name + " : " + (error?.message || error)))
|
||||
// continue (et non return) : les guildes suivantes doivent quand même être rafraichies
|
||||
continue
|
||||
}
|
||||
guilds.set(guild.id, {
|
||||
id: guild.id,
|
||||
@@ -258,6 +289,75 @@ function checkRequiredPermission(guildMember) {
|
||||
return requiredPermissions.filter(permission => !guildMember.permissions.has(permission));
|
||||
}
|
||||
|
||||
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles}
|
||||
// Départ automatique quand plus aucun humain n'écoute (10 minutes)
|
||||
const AUTO_LEAVE_DELAY = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Nombre de membres non-bots dans le salon vocal du player (cache des voice states, pas de requête)
|
||||
*/
|
||||
function countListeners(player) {
|
||||
if(!player?.channelId) return 0
|
||||
const channel = client.channels.cache.get(player.channelId)
|
||||
if(!channel?.members) return 0
|
||||
return channel.members.filter(member => !member.user.bot).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme ou annule le départ automatique d'une guilde.
|
||||
* Un seul minuteur par guilde : il n'est jamais écrasé, et il est annulé dès que quelqu'un revient.
|
||||
*/
|
||||
function checkAutoLeave(guildId) {
|
||||
const player = AllPlayers.get(guildId)
|
||||
if(!player || !player.isConnected() || !player.channelId) {
|
||||
// Plus de player : rien à surveiller
|
||||
if(timers.has(guildId)) {
|
||||
clearTimeout(timers.get(guildId))
|
||||
timers.delete(guildId)
|
||||
}
|
||||
player?.setAutoLeaveAt(null)
|
||||
return
|
||||
}
|
||||
|
||||
if(countListeners(player) > 0) {
|
||||
if(timers.has(guildId)) {
|
||||
clearTimeout(timers.get(guildId))
|
||||
timers.delete(guildId)
|
||||
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Départ automatique annulé, quelqu'un écoute dans : " + player.channelName)
|
||||
}
|
||||
player.setAutoLeaveAt(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Déjà programmé : on ne remplace pas le minuteur existant
|
||||
if(timers.has(guildId)) return
|
||||
|
||||
const leaveAt = Date.now() + AUTO_LEAVE_DELAY
|
||||
timers.set(guildId, setTimeout(() => {
|
||||
timers.delete(guildId)
|
||||
const current = AllPlayers.get(guildId)
|
||||
if(!current || !current.isConnected()) return
|
||||
// Revérification au moment du départ
|
||||
if(countListeners(current) > 0) {
|
||||
current.setAutoLeaveAt(null)
|
||||
return
|
||||
}
|
||||
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé : " + current.channelName)
|
||||
current.leave("auto")
|
||||
}, AUTO_LEAVE_DELAY))
|
||||
player.setAutoLeaveAt(leaveAt)
|
||||
dlog.log("[Automatic Task] Guild Id :" + guildId + " - Player supprimé dans 10 minutes : " + player.channelName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule le minuteur d'auto-départ d'une guilde (utilisé quand l'admin fait quitter le serveur au bot)
|
||||
*/
|
||||
function clearAutoLeaveTimer(guildId) {
|
||||
if(typeof timers === "undefined" || !timers.has(guildId)) return false
|
||||
clearTimeout(timers.get(guildId))
|
||||
timers.delete(guildId)
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {init, getClient, getGuilds, getMembersVoices, getChannel, getGuildMembers, getGuildMember, isReady, getGuildRoles, refreshGuilds, checkRequiredPermission, clearAutoLeaveTimer}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ class Command {
|
||||
* @param {Client} client
|
||||
* @param {Interaction} interaction
|
||||
*/
|
||||
this.data = {data: SlashCommand, async execute(client, interaction) {callback(client, interaction)}}
|
||||
// On renvoie la promesse du callback : l'appelant (Bot.js) l'attend et capture ses erreurs
|
||||
this.data = {data: SlashCommand, async execute(client, interaction) {return await callback(client, interaction)}}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,58 @@
|
||||
function init() {
|
||||
|
||||
const bot = require("./Bot")
|
||||
const client = bot.getClient()
|
||||
const { Collection, REST, Routes } = require("discord.js")
|
||||
const fs = require("node:fs")
|
||||
const path = require("path")
|
||||
const {__glob} = require("../utils/GlobalVars")
|
||||
const dlog = require("loguix").getInstance("Discord")
|
||||
const config = require("../utils/Database/Configuration")
|
||||
|
||||
// Commandes au format JSON (envoyées à Discord) et client REST, initialisés par init()
|
||||
var commands = null
|
||||
var rest = null
|
||||
|
||||
function getLog() {
|
||||
return require("loguix").getInstance("Discord")
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
const bot = require("./Bot")
|
||||
const client = bot.getClient()
|
||||
const dlog = getLog()
|
||||
|
||||
client.commands = new Collection()
|
||||
dlog.step.init("d_init", "Initialisation du Bot Discord")
|
||||
dlog.step.init("d_get_commands", "Récupération des commandes en local depuis : " + __glob.COMMANDS)
|
||||
|
||||
const commands = [];
|
||||
commands = [];
|
||||
// Grab all the command files from the commands directory you created earlier
|
||||
const commandsPath = __glob.COMMANDS
|
||||
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||
// Les commandes de développement ("__DEBUG.js", "__TEST.js"...) ne sont chargées qu'en développement :
|
||||
// sinon elles seraient déployées sur toutes les guildes, sans contrôle de permission
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
const commandFiles = fs.readdirSync(commandsPath).filter(file => {
|
||||
if(!file.endsWith('.js')) return false
|
||||
if(file.startsWith("__") && !devMode) {
|
||||
dlog.log("Commande de développement ignorée (NODE_ENV != development) : " + file)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
});
|
||||
|
||||
for (const file of commandFiles) {
|
||||
// Une commande invalide ne doit pas empêcher le chargement des autres
|
||||
try {
|
||||
const cmd = require(commandsPath + path.sep + file);
|
||||
client.commands.set(cmd.command.getName(), cmd.command.getData())
|
||||
|
||||
commands.push(cmd.command.getData().data.toJSON());
|
||||
} catch (error) {
|
||||
dlog.error("Impossible de charger la commande '" + file + "' : " + (error?.stack || error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dlog.step.end("d_get_commands")
|
||||
|
||||
const rest = new REST().setToken(config.getToken());
|
||||
rest = new REST().setToken(config.getToken());
|
||||
|
||||
if(commands.length != 0) {
|
||||
|
||||
@@ -40,14 +64,8 @@ client.commands = new Collection()
|
||||
|
||||
dlog.step.init("d_commands_refresh", `Refreshing ${guilds.length} guilds (/) commands.`);
|
||||
for (const guildId of guilds) {
|
||||
const data = await rest.put(
|
||||
Routes.applicationGuildCommands(client.user.id, guildId),
|
||||
{ body: commands },
|
||||
);
|
||||
|
||||
// Log the guilds where the commands have been refreshed with number of commands
|
||||
dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId).name}"`);
|
||||
|
||||
// Une guilde en erreur (permissions, guilde quittée…) ne bloque pas les suivantes
|
||||
await registerGuild(guildId)
|
||||
}
|
||||
|
||||
dlog.step.end("d_commands_refresh")
|
||||
@@ -71,4 +89,34 @@ client.commands = new Collection()
|
||||
|
||||
}
|
||||
|
||||
module.exports = {init}
|
||||
/**
|
||||
* Déploie les commandes slash sur une guilde (ex : à l'arrivée sur un nouveau serveur).
|
||||
* Ne lève jamais d'exception.
|
||||
* @param {string} guildId
|
||||
* @returns {Promise<boolean>} true si les commandes ont été déployées
|
||||
*/
|
||||
async function registerGuild(guildId) {
|
||||
const dlog = getLog()
|
||||
if(!commands || !rest) {
|
||||
// init() n'a pas encore tourné : il déploiera sur toutes les guildes du cache
|
||||
dlog.warn(`Commandes non chargées, déploiement différé pour la guilde "${guildId}"`)
|
||||
return false
|
||||
}
|
||||
if(commands.length == 0) return false
|
||||
try {
|
||||
const client = require("./Bot").getClient()
|
||||
const data = await rest.put(
|
||||
Routes.applicationGuildCommands(client.user.id, guildId),
|
||||
{ body: commands },
|
||||
);
|
||||
|
||||
// Log the guilds where the commands have been refreshed with number of commands
|
||||
dlog.log(`Refreshed "${data.length}" commands in guild "${guildId} - ${client.guilds.cache.get(guildId)?.name}"`);
|
||||
return true
|
||||
} catch (error) {
|
||||
dlog.error(`Impossible de déployer les commandes sur la guilde "${guildId}" : ` + (error?.stack || error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {init, registerGuild}
|
||||
@@ -1,20 +1,20 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const {Player, AllPlayers} = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
|
||||
const command = new Command("leave", "Quitter le salon vocal", (client, interaction) => {
|
||||
const command = new Command("leave", "Quitter le salon vocal", async (client, interaction) => {
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour arrêter le bot !", interaction)
|
||||
const channel = interaction.member.voice.channel
|
||||
var embed = new Embed(interaction)
|
||||
|
||||
if(AllPlayers.has(channel.guildId)) {
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
if(!player?.connected) {
|
||||
if(!player || !player.isConnected()) {
|
||||
return embed.returnError("Le bot n'est pas connecté à ce salon vocal")
|
||||
}
|
||||
player.leave()
|
||||
|
||||
// Annule aussi un chargement en cours (yt-dlp tué)
|
||||
await player.leave("command")
|
||||
|
||||
embed.setColor(200, 20, 20)
|
||||
embed.setTitle('**Déconnexion**')
|
||||
@@ -23,12 +23,6 @@ const command = new Command("leave", "Quitter le salon vocal", (client, interact
|
||||
|
||||
embed.send()
|
||||
|
||||
} else {
|
||||
|
||||
embed.returnError("Le bot n'est pas connecté à ce salon vocal")
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
module.exports = {command}
|
||||
@@ -1,7 +1,8 @@
|
||||
const {Command} = require('../Command');
|
||||
const {Embed, EmbedError} = require('../Embed');
|
||||
const { Player } = require('../../player/Player');
|
||||
const { getPlayer } = require('../../player/Player');
|
||||
const { Song } = require('../../player/Song');
|
||||
const { checkSong } = require('../../player/SongCheck');
|
||||
const history = require('../../playlists/History');
|
||||
|
||||
const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal", async (client, interaction) => {
|
||||
@@ -20,18 +21,18 @@ const command = new Command("media", "Lire un média MP3/WAV dans un salon vocal
|
||||
const song = new Song()
|
||||
await song.processMedia(media, interaction.user.username)
|
||||
|
||||
const player = new Player(channel.guildId)
|
||||
player.join(channel)
|
||||
|
||||
|
||||
if(!checkSong(song)) return embed.returnError("Ce média ne peut pas être lu")
|
||||
|
||||
const player = getPlayer(channel.guildId)
|
||||
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
|
||||
|
||||
if(now) {
|
||||
player.play(song)
|
||||
// Lecture immédiate : la musique en cours part dans l'historique
|
||||
await player.playNow(song)
|
||||
embed.setTitle('**Lecture immédiate**')
|
||||
|
||||
} else {
|
||||
player.add(song)
|
||||
await player.add(song)
|
||||
embed.setTitle('**Ajout à liste de lecture**')
|
||||
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const { Player } = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
|
||||
const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", (client, interaction) => {
|
||||
const command = new Command("pause", "Mettre en pause / Reprendre la musique en cours", async (client, interaction) => {
|
||||
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour mettre en pause la musique !", interaction)
|
||||
|
||||
const channel = interaction.member.voice.channel
|
||||
const player = new Player(channel.guildId)
|
||||
const result = player.pause()
|
||||
|
||||
|
||||
var embed = new Embed(interaction)
|
||||
embed.setColor(0x03ff2d)
|
||||
|
||||
result.then((pause) => {
|
||||
// Pas de création de player : le bot doit déjà être connecté
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
if(!player || !player.isConnected()) {
|
||||
return embed.returnError("Le bot n'est pas connecté")
|
||||
}
|
||||
|
||||
if(pause == "no_music") {
|
||||
// Résultat réel : {ok, paused, reason}
|
||||
const result = await player.pause()
|
||||
|
||||
if(!result.ok) {
|
||||
embed.returnError("Il n'y a pas de musique en cours de lecture")
|
||||
return
|
||||
}
|
||||
|
||||
} else if(pause) {
|
||||
const loading = player.status === "loading" || player.status === "stalled"
|
||||
|
||||
if(result.paused) {
|
||||
embed.setTitle('Musique en pause')
|
||||
embed.setDescription("La musique a été mise en pause")
|
||||
embed.setDescription(loading ? "La musique sera mise en pause dès le début de la lecture" : "La musique a été mise en pause")
|
||||
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/pause-64.png")
|
||||
|
||||
|
||||
} else {
|
||||
embed.setTitle('Musique reprise')
|
||||
embed.setDescription("La musique a été reprise")
|
||||
embed.setDescription(loading ? "La musique sera lue dès qu'elle sera prête" : "La musique a été reprise")
|
||||
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/play-64.png")
|
||||
}
|
||||
|
||||
embed.send()
|
||||
})
|
||||
|
||||
// Réponse en embed
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { Command } = require("../Command");
|
||||
const { Embed, EmbedError } = require("../Embed");
|
||||
const { Player } = require("../../player/Player");
|
||||
const { getPlayer } = require("../../player/Player");
|
||||
const Finder = require("../../player/Finder");
|
||||
const { checkSong } = require("../../player/SongCheck");
|
||||
const { Playlist } = require("../../playlists/Playlist");
|
||||
const spotify = require("../../media/SpotifyInformation");
|
||||
const history = require("../../playlists/History");
|
||||
@@ -14,12 +15,13 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
|
||||
const channel = interaction.member.voice.channel
|
||||
const now = interaction.options.getBoolean("now") || false
|
||||
const embed = new Embed(interaction)
|
||||
await Finder.search(url.value).then(async (song) => {
|
||||
|
||||
let song = await Finder.search(url.value)
|
||||
if(!song) return embed.returnError("Impossible de trouver la musique à partir du lien donné ou des mots clés donnés")
|
||||
if(!(song instanceof Playlist) && !checkSong(song)) return embed.returnError("Cette musique ne peut pas être lue (source non prise en charge)")
|
||||
|
||||
const player = new Player(channel.guildId)
|
||||
player.join(channel)
|
||||
|
||||
const player = getPlayer(channel.guildId)
|
||||
if(!await player.join(channel)) return embed.returnError("Impossible de rejoindre le salon vocal")
|
||||
|
||||
embed.setColor(0x15e6ed)
|
||||
|
||||
@@ -31,7 +33,7 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
|
||||
embed.addField('**Demandé par : **', interaction.member.user.username,)
|
||||
embed.addField('**Auteur : **', song.author)
|
||||
embed.addField('**Provient de : **', song.type.replace(/^\w/, (c) => c.toUpperCase()))
|
||||
if(!song.type == "spotify") {
|
||||
if(song.type != "spotify") {
|
||||
embed.addField('**Durée : **', song.readduration)
|
||||
}
|
||||
embed.addField('**Lien : **', song.url)
|
||||
@@ -58,34 +60,25 @@ const command = new Command("play", "Jouer une musique à partir d'un lien dans
|
||||
embed.setTitle("Ajoutée à la file d'attente")
|
||||
}
|
||||
|
||||
embed.send()
|
||||
|
||||
if(song instanceof Playlist) {
|
||||
embed.send()
|
||||
if(song.type == "spotify") {
|
||||
song = await spotify.getTracks(song)
|
||||
}
|
||||
player.readPlaylist(song, now)
|
||||
} else {
|
||||
|
||||
|
||||
if(now) {
|
||||
|
||||
player.play(song)
|
||||
|
||||
|
||||
} else {
|
||||
player.add(song)
|
||||
await player.readPlaylist(song, now)
|
||||
return
|
||||
}
|
||||
|
||||
// Lecture immédiate : la musique en cours part dans l'historique ; sinon ajout à la file
|
||||
if(now) {
|
||||
await player.playNow(song)
|
||||
} else {
|
||||
await player.add(song)
|
||||
}
|
||||
embed.send()
|
||||
|
||||
history.addToPersonalHistory(interaction.user.id, song)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
}, [{type: "STRING", name: "url", description: "Recherche / Lien audio (Youtube / Soundclound / Spotify)", required: true},
|
||||
{type:"BOOLEAN", name: "now", description: "Lire le média maintenant", required: false}]
|
||||
)
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const { Player, AllPlayers } = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
|
||||
const command = new Command("previous", "Passe à la musique précédente", (client, interaction) => {
|
||||
const command = new Command("previous", "Passe à la musique précédente", async (client, interaction) => {
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction)
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique précédente !", interaction)
|
||||
|
||||
const channel = interaction.member.voice.channel
|
||||
var embed = new Embed(interaction)
|
||||
|
||||
if(AllPlayers.has(channel.guildId)) {
|
||||
|
||||
const player = new Player(channel.guildId)
|
||||
const result = player.previous()
|
||||
// Pas de création de player : le bot doit déjà être connecté
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
if(!player || !player.isConnected()) {
|
||||
return embed.returnError("Le bot n'est pas connecté")
|
||||
}
|
||||
|
||||
const song = await player.previous()
|
||||
|
||||
// Réponse envoyée dans tous les cas (sinon l'interaction expire)
|
||||
if(song == "no_music" || !song) {
|
||||
return embed.returnError("Il n'y a pas de musique précédemment jouée")
|
||||
}
|
||||
|
||||
embed.setColor(0x15e6ed)
|
||||
|
||||
result.then((song) => {
|
||||
|
||||
if(song == "no_music") {
|
||||
embed.returnError("Il n'y a pas de musique précédemment jouée")
|
||||
|
||||
} else if(song) {
|
||||
|
||||
// Result is a song
|
||||
|
||||
|
||||
embed.setTitle('**Musique précédente !**')
|
||||
embed.setDescription('**Titre : **' + song.title)
|
||||
embed.addField('**Durée : **'+ song.readduration, "")
|
||||
@@ -35,16 +30,6 @@ const command = new Command("previous", "Passe à la musique précédente", (cli
|
||||
embed.setThumbnail(song.thumbnail)
|
||||
|
||||
embed.send()
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
} else {
|
||||
return embed.returnError("Le bot n'est pas connecté", interaction)
|
||||
}
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const { Player, AllPlayers } = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
|
||||
// Nombre de musiques détaillées dans l'embed
|
||||
const MAX_LISTED = 10
|
||||
|
||||
const command = new Command("liste", "Affiche la file d'attente", (client, interaction) => {
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la file d'attente !", interaction)
|
||||
|
||||
const channel = interaction.member.voice.channel
|
||||
var embed = new Embed(interaction)
|
||||
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
if(!player || !player.isConnected()) {
|
||||
return embed.returnError("Le bot n'est pas connecté")
|
||||
}
|
||||
|
||||
if(AllPlayers.has(channel.guildId)) {
|
||||
|
||||
const player = new Player(channel.guildId)
|
||||
const queue = player.queue.getNext()
|
||||
|
||||
|
||||
|
||||
if(queue.length == 0) {
|
||||
embed.returnError("Il n'y a pas de musique en file d'attente")
|
||||
return embed.returnError("Il n'y a pas de musique en file d'attente")
|
||||
}
|
||||
|
||||
} else if(queue.length > 0) {
|
||||
|
||||
// Result is a song
|
||||
embed.setColor(0x15e6ed)
|
||||
embed.setThumbnail("https://www.iconsdb.com/icons/download/white/list-2-64.png")
|
||||
embed.setTitle('**File d\'attente :**')
|
||||
embed.setDescription('**' + queue.length + ' musiques**')
|
||||
queue.forEach((song, index) => {
|
||||
// max 24 fields
|
||||
if(index > 10) return
|
||||
queue.slice(0, MAX_LISTED).forEach((song, index) => {
|
||||
embed.addField(`**${index+1} - ${song.title}**`, `**Durée : **${song.readduration}\n**Artiste : **${song.author}`)
|
||||
})
|
||||
if(queue.length > MAX_LISTED) {
|
||||
embed.addField(`... et ${queue.length - MAX_LISTED} autre(s)`, "")
|
||||
}
|
||||
embed.send()
|
||||
}
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
embed.returnError("Le bot n'est pas connecté")
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = {command}
|
||||
@@ -1,25 +1,37 @@
|
||||
const {Embed} = require("../Embed")
|
||||
const {Command} = require("../Command")
|
||||
const {restart} = require("../../utils/Maintenance")
|
||||
const {restart, isRestarting} = require("../../utils/Maintenance")
|
||||
const users = require("../../server/auth/User")
|
||||
const { LogType } = require("loguix")
|
||||
const clog = new LogType("Maintenance")
|
||||
|
||||
// Nécéssite une raison pour redémarrer le bot
|
||||
|
||||
const command = new Command("restart", "Redémarre le bot", (client, interaction) => {
|
||||
// Check if user is admin from users list
|
||||
const user = users.getUserById(interaction.user.id)
|
||||
if(!user || !user.isAdmin()) {
|
||||
if(!user || !user.isAdmin() || user.isFullBanned()) {
|
||||
interaction.reply({content: "Vous n'êtes pas admin", ephemeral: true})
|
||||
return
|
||||
}
|
||||
const reason = interaction.options.getString("reason")
|
||||
restart(reason)
|
||||
const reason = (interaction.options.getString("reason") || "").trim().slice(0, 200)
|
||||
if(!reason) {
|
||||
interaction.reply({content: "Une raison est nécessaire pour redémarrer le bot", ephemeral: true})
|
||||
return
|
||||
}
|
||||
if(isRestarting()) {
|
||||
interaction.reply({content: "Un redémarrage est déjà en cours", ephemeral: true})
|
||||
return
|
||||
}
|
||||
clog.warn(`[ADMIN] ${interaction.user.username} -> redémarrage depuis Discord : ${reason}`)
|
||||
// On répond d'abord, le redémarrage effectif a lieu après un court délai (voir Maintenance.restart)
|
||||
const embed = new Embed(interaction)
|
||||
embed.setColor(150, 20, 20)
|
||||
embed.setTitle('Redémarrage')
|
||||
embed.setDescription("Veuillez patientez, le bot va redémarrer dans un instant ! :arrows_counterclockwise:")
|
||||
embed.addField('Raison', reason)
|
||||
embed.send()
|
||||
restart(reason)
|
||||
},
|
||||
[{type: "STRING", name: "reason", description: "Raison du redémarrage", required: true}]
|
||||
)
|
||||
|
||||
@@ -1,47 +1,35 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const { Player, AllPlayers } = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
|
||||
const command = new Command("skip", "Passe à la musique suivante", (client, interaction) => {
|
||||
const command = new Command("skip", "Passe à la musique suivante", async (client, interaction) => {
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour passer à la musique suivante !", interaction)
|
||||
|
||||
const channel = interaction.member.voice.channel
|
||||
var embed = new Embed(interaction)
|
||||
|
||||
if(AllPlayers.has(channel.guildId)) {
|
||||
// Pas de création de player : le bot doit déjà être connecté
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
if(!player || !player.isConnected()) {
|
||||
return embed.returnError("Le bot n'est pas connecté")
|
||||
}
|
||||
|
||||
const player = new Player(channel.guildId)
|
||||
const result = player.skip()
|
||||
const song = await player.skip()
|
||||
|
||||
if(song == "no_music" || !song) {
|
||||
return embed.returnError("Il n'y a pas de musique en file d'attente")
|
||||
}
|
||||
|
||||
// Le chargement démarre : l'état (chargement / erreur) est visible sur le site
|
||||
embed.setColor(0x15e6ed)
|
||||
result.then((song) => {
|
||||
|
||||
if(song == "no_music") {
|
||||
embed.returnError("Il n'y a pas de musique en file d'attente", interaction)
|
||||
|
||||
} else if(song) {
|
||||
|
||||
// Result is a song
|
||||
embed.setColor(0x15e6ed)
|
||||
|
||||
embed.setTitle('**Musique suivante !**')
|
||||
embed.setDescription('**Titre : **' + song.title)
|
||||
embed.addField('**Durée : **'+ song.readduration, "")
|
||||
embed.addField("**Artiste : **" + song.author, "")
|
||||
embed.setThumbnail(song.thumbnail)
|
||||
|
||||
|
||||
}
|
||||
|
||||
embed.send()
|
||||
})
|
||||
|
||||
} else {
|
||||
return embed.returnError("Le bot n'est pas connecté", interaction)
|
||||
}
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
const {Command} = require("../Command")
|
||||
const {Embed, EmbedError} = require("../Embed")
|
||||
const {Player} = require("../../player/Player")
|
||||
const { AllPlayers } = require("../../player/Player")
|
||||
const { getReadableDuration } = require("../../utils/TimeConverter")
|
||||
|
||||
// Libellés des statuts du player
|
||||
const STATUS_LABELS = {
|
||||
loading: "Chargement…",
|
||||
playing: "En lecture",
|
||||
paused: "En pause",
|
||||
stalled: "Connexion vocale instable",
|
||||
idle: "Inactif",
|
||||
offline: "Déconnecté",
|
||||
}
|
||||
|
||||
const command = new Command("state", "Affiche la musique en cours", (client, interaction) => {
|
||||
|
||||
if(!interaction.member.voice.channel) return new EmbedError("Vous devez rejoindre un salon vocal pour afficher la musique en cours !", interaction)
|
||||
|
||||
const channel = interaction.member.voice.channel
|
||||
const player = new Player(channel.guildId)
|
||||
const song = player.queue.getCurrent()
|
||||
|
||||
|
||||
|
||||
// Pas de création de player : simple lecture de l'état
|
||||
const player = AllPlayers.get(channel.guildId)
|
||||
const song = player?.queue?.getCurrent()
|
||||
|
||||
if(!song) {
|
||||
var embed = new EmbedError("Il n'y a pas de musique en cours de lecture", interaction)
|
||||
return new EmbedError("Il n'y a pas de musique en cours de lecture", interaction)
|
||||
}
|
||||
|
||||
} else if(song) {
|
||||
var embed = new Embed(interaction)
|
||||
// Result is a song
|
||||
embed.setColor(0x15e6ed)
|
||||
|
||||
embed.setTitle('**Musique en cours :**')
|
||||
embed.setDescription('**Titre : **' + song.title)
|
||||
embed.addField('**Durée : **', song.readduration)
|
||||
const durationMs = player.getDurationMs()
|
||||
const position = getReadableDuration(Math.floor(player.getPositionMs() / 1000))
|
||||
embed.addField('**Position : **', durationMs ? `${position} / ${song.readduration}` : `${position} (durée inconnue / LIVE)`)
|
||||
embed.addField('**Statut : **', STATUS_LABELS[player.status] ?? player.status)
|
||||
embed.addField("**Artiste : **", song.author)
|
||||
embed.setThumbnail(song.thumbnail)
|
||||
|
||||
embed.send()
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
+51
-10
@@ -1,4 +1,27 @@
|
||||
const { EmbedBuilder, ActionRowBuilder } = require("discord.js");
|
||||
const { LogType } = require("loguix");
|
||||
|
||||
const elog = new LogType("Embed")
|
||||
|
||||
// Limites Discord : au-delà, EmbedBuilder lève une exception
|
||||
const MAX_TITLE = 256
|
||||
const MAX_DESCRIPTION = 4096
|
||||
const MAX_FIELD_NAME = 256
|
||||
const MAX_FIELD_VALUE = 1024
|
||||
// Délai maximal d'attente du deferReply avant d'éditer la réponse
|
||||
const DEFER_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* Tronque un texte à la limite Discord (jamais vide : caractère invisible par défaut)
|
||||
* @param {*} value
|
||||
* @param {number} max
|
||||
* @returns {string}
|
||||
*/
|
||||
function clampText(value, max) {
|
||||
const text = String(value ?? '')
|
||||
if(text.length === 0) return ''
|
||||
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
||||
}
|
||||
|
||||
class Embed {
|
||||
fields;
|
||||
@@ -8,8 +31,9 @@ class Embed {
|
||||
this.fields = []
|
||||
this.buttons = []
|
||||
this.isSended = false
|
||||
this.deferPromise = null
|
||||
if(interaction) {
|
||||
interaction.deferReply({ ephemeral: ephemeral }).then(() => {
|
||||
this.deferPromise = interaction.deferReply({ ephemeral: ephemeral }).then(() => {
|
||||
this.isSended = true
|
||||
}).catch(() => {
|
||||
this.isSended = true
|
||||
@@ -20,12 +44,12 @@ class Embed {
|
||||
}
|
||||
|
||||
setTitle(title) {
|
||||
this.embed.setTitle(title)
|
||||
this.embed.setTitle(title == null ? null : clampText(title, MAX_TITLE))
|
||||
return this
|
||||
}
|
||||
|
||||
setDescription(description) {
|
||||
this.embed.setDescription(description)
|
||||
this.embed.setDescription(description == null ? null : clampText(description, MAX_DESCRIPTION))
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -86,7 +110,7 @@ class Embed {
|
||||
|
||||
addField(name, value, inline) {
|
||||
if(!inline) inline = false;
|
||||
this.fields.push({name: name, value: value, inline: inline})
|
||||
this.fields.push({name: clampText(name, MAX_FIELD_NAME), value: clampText(value, MAX_FIELD_VALUE), inline: inline})
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -101,8 +125,8 @@ class Embed {
|
||||
}
|
||||
|
||||
build() {
|
||||
//Add Fields to an object
|
||||
this.embed.addFields(this.fields)
|
||||
//Add Fields to an object (setFields : un second build ne duplique pas les champs)
|
||||
this.embed.setFields(this.fields)
|
||||
if(this.buttons.length > 0) {
|
||||
this.actionRow = new ActionRowBuilder()
|
||||
.addComponents(this.buttons);
|
||||
@@ -110,13 +134,30 @@ class Embed {
|
||||
return this.embed
|
||||
}
|
||||
|
||||
/**
|
||||
* Édite la réponse de l'interaction avec l'embed. Ne lève jamais d'exception.
|
||||
* @returns {Promise<boolean>} true si la réponse a été envoyée
|
||||
*/
|
||||
async send() {
|
||||
// Add a secutiry check to avoid sending an embed if the interaction is not defined and retry one again
|
||||
while(!this.isSended) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
// Sans interaction (embed destiné à un salon, ex : rapport), il n'y a rien à éditer
|
||||
if(!this.interaction) return false
|
||||
// Attente du deferReply, avec un délai maximal (plus de boucle infinie)
|
||||
if(!this.isSended && this.deferPromise) {
|
||||
let timer = null
|
||||
await Promise.race([
|
||||
this.deferPromise,
|
||||
new Promise(resolve => { timer = setTimeout(resolve, DEFER_TIMEOUT) })
|
||||
])
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if(this.ephemeral === undefined) this.ephemeral = false;
|
||||
this.interaction.editReply({ embeds: [this.build()], components: this.buttons.length > 0 ? [this.actionRow] : [] })
|
||||
try {
|
||||
await this.interaction.editReply({ embeds: [this.build()], components: this.buttons.length > 0 ? [this.actionRow] : [] })
|
||||
return true
|
||||
} catch (error) {
|
||||
elog.error("Impossible d'envoyer l'embed de la commande /" + (this.interaction.commandName || "?") + " : " + (error?.message || error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async returnError(message) {
|
||||
|
||||
@@ -23,32 +23,34 @@ if(!config.getMediaGuildId() || !config.getMediaChannelId()) {
|
||||
|
||||
var channel = null
|
||||
|
||||
discordBot.getClient().on("ready", () => {
|
||||
discordBot.getClient().on("ready", async () => {
|
||||
try {
|
||||
channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId())
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
if(!channel) {
|
||||
if(!channel || typeof channel.send !== "function") {
|
||||
channel = null
|
||||
wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.")
|
||||
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.")
|
||||
connected = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Le canal existe : la base est utilisable (comme avant), le message de démarrage n'est qu'informatif
|
||||
connected = true
|
||||
wlog.step.end("init_db")
|
||||
try {
|
||||
const dateTime = new Date()
|
||||
const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' })
|
||||
const time = dateTime.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })
|
||||
const message = `[LOGS] La base de données multimédia a été initialisée le ${date} à ${time}`
|
||||
channel.send(message)
|
||||
// await : un refus de Discord (permissions manquantes…) ne doit pas devenir un rejet non géré
|
||||
await channel.send(message)
|
||||
wlog.log("La base de données multimédia a été initialisée avec succès.")
|
||||
wlog.step.end("init_db")
|
||||
connected = true
|
||||
} catch (e) {
|
||||
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.")
|
||||
wlog.step.error("init_db","Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration.")
|
||||
connected = false
|
||||
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e))
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ class Report {
|
||||
const embed = new Embed()
|
||||
embed.setDescription('**Version : **' + packageJson.version)
|
||||
embed.setTitle("Rapport de : " + provider)
|
||||
// L'embed n'est pas lié à une interaction : on l'envoie nous-mêmes dans le salon (voir send())
|
||||
|
||||
var levelString = null
|
||||
if(level == "bug") {
|
||||
@@ -26,24 +27,36 @@ class Report {
|
||||
}
|
||||
|
||||
embed.addField("Type", levelString)
|
||||
embed.addField("Description", desc)
|
||||
// Champ limité à 1024 caractères par Discord : on tronque la description
|
||||
const description = String(desc ?? "")
|
||||
embed.addField("Description", description.length > 1000 ? description.slice(0, 1000) + "…" : description)
|
||||
this.embed = embed
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie le rapport dans le salon configuré. Ne lève jamais d'exception.
|
||||
* @returns {Promise<boolean>} true si le rapport a été envoyé
|
||||
*/
|
||||
async send() {
|
||||
if(!this.report_channel || this.report_channel == "") {
|
||||
log.getInstance("Discord").error("Pas de channel de rapport configuré")
|
||||
return false
|
||||
} else {
|
||||
try {
|
||||
const channel = await this.client.channels.fetch(this.report_channel)
|
||||
channel.send({embeds: [this.embed.build()]})
|
||||
if(this.report_contact && this.report_contact != "") channel.send({content: "<@" + this.report_contact + ">"})
|
||||
if(!channel || typeof channel.send !== "function") {
|
||||
log.getInstance("Discord").error("Le channel de rapport est introuvable ou n'accepte pas de messages : " + this.report_channel)
|
||||
return false
|
||||
}
|
||||
await channel.send({embeds: [this.embed.build()]})
|
||||
if(this.report_contact && this.report_contact != "") await channel.send({content: "<@" + this.report_contact + ">"})
|
||||
return true
|
||||
} catch (error) {
|
||||
log.getInstance("Discord").error("Impossible d'envoyer le rapport : " + (error?.message || error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,15 @@ const { __glob } = require('../utils/GlobalVars');
|
||||
const { Database } = require('../utils/Database/Database');
|
||||
const ServerDB = new Database("server_settings", __glob.SERVER_DB, {});
|
||||
|
||||
/**
|
||||
* Lecture seule : ne crée jamais d'entrée (un guildId inconnu ou aléatoire ne doit pas provoquer d'écriture disque)
|
||||
* @returns {{id, name}|null}
|
||||
*/
|
||||
function getSecureRole(guildId) {
|
||||
checkKey(guildId);
|
||||
var role = ServerDB.getData()[guildId].secureRole || null;
|
||||
if (typeof guildId !== "string" || guildId === "") return null;
|
||||
const data = ServerDB.getData() || {};
|
||||
if (!Object.prototype.hasOwnProperty.call(data, guildId)) return null;
|
||||
var role = data[guildId]?.secureRole || null;
|
||||
if(role) {
|
||||
if(role.name === "@everyone") {
|
||||
return null;
|
||||
@@ -15,21 +21,29 @@ function getSecureRole(guildId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
function setSecureRole(guildId, roleId) {
|
||||
/**
|
||||
* @param {string} guildId
|
||||
* @param {{id, name}|null} role rôle requis pour utiliser le bot (null = aucun)
|
||||
*/
|
||||
function setSecureRole(guildId, role) {
|
||||
// Identifiant Discord numérique uniquement (pas de clé arbitraire comme "__proto__")
|
||||
if (typeof guildId !== "string" || !/^\d{1,25}$/.test(guildId)) {
|
||||
clog.warn("Impossible de définir un rôle sécurisé sans identifiant de serveur");
|
||||
return false;
|
||||
}
|
||||
checkKey(guildId);
|
||||
ServerDB.getData()[guildId].secureRole = roleId;
|
||||
ServerDB.getData()[guildId].secureRole = role || null;
|
||||
ServerDB.save();
|
||||
clog.log(`Rôle sécurisé du serveur ${guildId} : ${role?.name ?? "aucun"}`);
|
||||
process.emit("USERS_UPDATE")
|
||||
return true;
|
||||
}
|
||||
|
||||
async function checkKey(guildId) {
|
||||
function checkKey(guildId) {
|
||||
const data = ServerDB.getData();
|
||||
if (!data[guildId]) {
|
||||
data[guildId] = { secureRole: null };
|
||||
ServerDB.save();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+11
-7
@@ -2,6 +2,9 @@ const { LogType } = require("loguix");
|
||||
const plog = new LogType('Lyrics');
|
||||
const urls = require('./urls.json');
|
||||
|
||||
// Délai maximal de chaque requête : sans réponse du fournisseur, /PLAYER/LYRICS restait en attente indéfiniment
|
||||
const REQUEST_TIMEOUT = 8000
|
||||
|
||||
// Make sure Url exists and get lyrics for the first item only
|
||||
async function getLyrics(name) {
|
||||
let result = null;
|
||||
@@ -10,7 +13,8 @@ async function getLyrics(name) {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
|
||||
});
|
||||
const searchData = await searchResponse.json();
|
||||
|
||||
@@ -26,18 +30,18 @@ async function getLyrics(name) {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
|
||||
});
|
||||
const lyricsData = await lyricsResponse.json();
|
||||
console.log(lyricsData);
|
||||
if (lyricsData && lyricsData && lyricsData.lyrics) {
|
||||
if (lyricsData && lyricsData.lyrics) {
|
||||
result = lyricsData.lyrics;
|
||||
} else {
|
||||
plog.error('Invalid response structure:', lyricsData);
|
||||
plog.error('Invalid response structure: ' + JSON.stringify(lyricsData).slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
plog.error('Error fetching lyrics data:', error);
|
||||
plog.error('Error fetching lyrics data: ' + (error?.message || error));
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -49,7 +53,7 @@ async function getLyrics(name) {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
plog.error('Error fetching search data:', error);
|
||||
plog.error('Error fetching search data: ' + (error?.message || error));
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
|
||||
+116
-4
@@ -7,19 +7,131 @@
|
||||
|
||||
const { __glob } = require("./utils/GlobalVars")
|
||||
require("loguix").setup(__glob.LOGS, __glob.PACKAGEINFO)
|
||||
setupProcessHandlers()
|
||||
// Métriques : chargement unique et nettoyage de metrics.json (remplace webmetrik)
|
||||
const SafeMetric = require("./utils/SafeMetric")
|
||||
SafeMetric.init()
|
||||
const config = require("./utils/Database/Configuration")
|
||||
const metric = require("webmetrik")
|
||||
metric.setMetricFile(__glob.METRIC_FILE)
|
||||
metric.publishMetrics("8001", "subsonicsMetricsRaph")
|
||||
SafeMetric.publish(8001, process.env.METRICS_KEY || "subsonicsMetricsRaph")
|
||||
|
||||
// SETUP
|
||||
|
||||
setup();
|
||||
setup().catch((error) => {
|
||||
// Démarrage impossible : on quitte pour que Docker relance le conteneur
|
||||
safeLogError("Échec du démarrage : " + (error?.stack || error))
|
||||
shutdown("SETUP_ERROR", 1)
|
||||
});
|
||||
// Fin du chargement synchrone de main.js (voir le handler uncaughtException)
|
||||
mainLoaded = true
|
||||
|
||||
async function setup() {
|
||||
await require("./utils/WarpProxy").start()
|
||||
// Module YouTube : migration des cookies, binaire yt-dlp, mises à jour automatiques (ne bloque jamais le démarrage)
|
||||
await require("./youtube").init()
|
||||
const DiscordBot = require("./discord/Bot")
|
||||
await DiscordBot.init()
|
||||
const Server = require("./server/Server")
|
||||
await Server.init()
|
||||
}
|
||||
|
||||
// GESTION DU PROCESS (erreurs globales et arrêt propre)
|
||||
|
||||
// var (hissée) : vaut undefined tant que le code synchrone de main.js n'est pas allé au bout
|
||||
var mainLoaded
|
||||
var processLog = null
|
||||
function getProcessLog() {
|
||||
if(!processLog) processLog = new (require("loguix").LogType)("Process")
|
||||
return processLog
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise sans jamais lever (l'écriture du log elle-même peut échouer)
|
||||
*/
|
||||
function safeLogError(text, level = "error") {
|
||||
try {
|
||||
getProcessLog()[level](text)
|
||||
} catch (e) {
|
||||
console.error("[Process] - [" + level.toUpperCase() + "] - " + text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handlers globaux : une erreur isolée est journalisée sans tuer le process.
|
||||
* En cas de rafale d'exceptions (état probablement incohérent), on quitte pour que Docker relance.
|
||||
*/
|
||||
function setupProcessHandlers() {
|
||||
// loguix enregistre son propre handler 'uncaughtException' qui FERME son flux de logs :
|
||||
// après la première exception plus rien n'était écrit dans logs/. On le retire.
|
||||
process.removeAllListeners("uncaughtException")
|
||||
|
||||
const MAX_EXCEPTIONS = 10
|
||||
const EXCEPTION_WINDOW = 60 * 1000
|
||||
var exceptionTimes = []
|
||||
var handling = false
|
||||
|
||||
process.on("uncaughtException", (error, origin) => {
|
||||
// Garde anti-réentrance : si journaliser provoque une nouvelle exception, on ne boucle pas
|
||||
if(handling) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
handling = true
|
||||
try {
|
||||
safeLogError("Exception non capturée (" + origin + ") : " + (error?.stack || error))
|
||||
// Erreur pendant le chargement initial (config, modules…) : démarrage impossible, on quitte
|
||||
if(!mainLoaded) {
|
||||
shutdown("STARTUP_EXCEPTION", 1)
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
exceptionTimes = exceptionTimes.filter(t => now - t < EXCEPTION_WINDOW)
|
||||
exceptionTimes.push(now)
|
||||
if(exceptionTimes.length >= MAX_EXCEPTIONS) {
|
||||
safeLogError(`${MAX_EXCEPTIONS} exceptions en moins de ${EXCEPTION_WINDOW / 1000} s : arrêt du process`)
|
||||
shutdown("UNCAUGHT_EXCEPTION", 1)
|
||||
}
|
||||
} finally {
|
||||
handling = false
|
||||
}
|
||||
})
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
safeLogError("Promesse rejetée non gérée : " + (reason?.stack || reason))
|
||||
})
|
||||
|
||||
// Node est PID 1 sous Docker : sans handler, SIGTERM est ignoré et docker stop finit en SIGKILL
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM", 0))
|
||||
process.on("SIGINT", () => shutdown("SIGINT", 0))
|
||||
}
|
||||
|
||||
var shuttingDown = false
|
||||
/**
|
||||
* Arrêt propre : sauvegarde des métriques, déconnexion de Discord, puis sortie
|
||||
* @param {string} reason
|
||||
* @param {number} code
|
||||
*/
|
||||
function shutdown(reason, code) {
|
||||
if(shuttingDown) {
|
||||
// Second signal : sortie immédiate
|
||||
process.exit(code)
|
||||
}
|
||||
shuttingDown = true
|
||||
process.exitCode = code
|
||||
safeLogError("Arrêt du process demandé (" + reason + ")", code === 0 ? "warn" : "error")
|
||||
// Délai maximal : on ne reste jamais bloqué dans l'arrêt
|
||||
setTimeout(() => process.exit(code), 3000)
|
||||
try {
|
||||
require("./utils/SafeMetric").flush()
|
||||
} catch (e) {
|
||||
safeLogError("Sauvegarde des métriques impossible : " + (e?.stack || e))
|
||||
}
|
||||
// Les bases JSON sont écrites de façon synchrone à chaque modification : rien à vider
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
// Uniquement si le bot a déjà été chargé (pas de require qui le démarrerait)
|
||||
const botPath = require.resolve("./discord/Bot")
|
||||
if(require.cache[botPath]) return require("./discord/Bot").getClient().destroy()
|
||||
})
|
||||
.catch((e) => safeLogError("Déconnexion de Discord impossible : " + (e?.stack || e)))
|
||||
.finally(() => process.exit(code))
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
const ffprobe = require('ffprobe');
|
||||
const ffprobeStatic = require('ffprobe-static');
|
||||
const { getReadableDuration } = require('../utils/TimeConverter');
|
||||
const { toSafeDuration } = require('../utils/SafeMetric');
|
||||
const clog = require("loguix").getInstance("Song")
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* ffprobe renvoie la durée sous forme de CHAÎNE ("116.088163") : on la convertit en nombre de secondes.
|
||||
* Sans conversion, les additions de durées deviennent des concaténations (cause des NaN dans les statistiques).
|
||||
* Durée absente ou invalide → 0 (inconnue), pour que le morceau reste jouable.
|
||||
*/
|
||||
function applyDuration(instance, info) {
|
||||
const raw = info?.streams?.[0]?.duration ?? info?.format?.duration;
|
||||
const duration = toSafeDuration(parseFloat(raw));
|
||||
instance.duration = duration;
|
||||
instance.readduration = getReadableDuration(duration);
|
||||
}
|
||||
|
||||
async function getMediaInformation(instance, media, provider) {
|
||||
let tmpFile = null;
|
||||
|
||||
@@ -28,10 +41,7 @@ async function getMediaInformation(instance, media, provider) {
|
||||
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
|
||||
|
||||
// 4. Récupérer les informations
|
||||
if (info.streams?.[0]?.duration_ts) {
|
||||
instance.duration = info.streams[0].duration;
|
||||
instance.readduration = getReadableDuration(instance.duration);
|
||||
}
|
||||
applyDuration(instance, info);
|
||||
|
||||
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
|
||||
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
|
||||
@@ -76,10 +86,7 @@ async function getMediaInformationFromUrl(instance, url) {
|
||||
// 3. Lancer ffprobe sur le fichier local
|
||||
const info = await ffprobe(tmpFile, { path: ffprobeStatic.path });
|
||||
|
||||
if (info.streams?.[0]?.duration_ts) {
|
||||
instance.duration = info.streams[0].duration;
|
||||
instance.readduration = getReadableDuration(instance.duration);
|
||||
}
|
||||
applyDuration(instance, info);
|
||||
|
||||
instance.thumbnail = info.streams?.[0]?.tags?.thumbnail ??
|
||||
"https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
|
||||
|
||||
@@ -4,6 +4,7 @@ const {Song} = require('../player/Song');
|
||||
const {Playlist} = require('../playlists/Playlist');
|
||||
const {Soundcloud} = require('soundcloud.ts')
|
||||
const {getReadableDuration} = require('../utils/TimeConverter');
|
||||
const {toSafeDuration} = require('../utils/SafeMetric');
|
||||
|
||||
const soundcloud = new Soundcloud();
|
||||
|
||||
@@ -22,8 +23,9 @@ async function getTrack(url) {
|
||||
song.url = info.permalink_url;
|
||||
song.thumbnail = info.artwork_url;
|
||||
song.id = info.id;
|
||||
song.duration = info.duration / 1000;
|
||||
song.readduration = getReadableDuration(info.duration / 1000);
|
||||
// Durée SoundCloud en millisecondes ; absente ou invalide → 0 (inconnue)
|
||||
song.duration = toSafeDuration(Number(info.duration) / 1000);
|
||||
song.readduration = getReadableDuration(song.duration);
|
||||
song.type = "soundcloud";
|
||||
|
||||
return song;
|
||||
@@ -56,18 +58,20 @@ async function getPlaylist(url) {
|
||||
playlist.songs = [];
|
||||
playlist.type = "soundcloud";
|
||||
|
||||
for(const track of info.tracks) {
|
||||
for(const track of info.tracks || []) {
|
||||
// Piste incomplète (supprimée / privée) : on l'ignore au lieu de faire échouer toute la playlist
|
||||
if(!track || !track.permalink_url) continue;
|
||||
const song = new Song();
|
||||
song.title = track.title;
|
||||
song.author = track.user.username;
|
||||
song.author = track.user?.username ?? song.author;
|
||||
song.url = track.permalink_url;
|
||||
song.thumbnail = track.artwork_url;
|
||||
song.id = track.id;
|
||||
song.duration = track.duration / 1000;
|
||||
song.readduration = getReadableDuration(track.duration / 1000);
|
||||
song.duration = toSafeDuration(Number(track.duration) / 1000);
|
||||
song.readduration = getReadableDuration(song.duration);
|
||||
song.type = "soundcloud";
|
||||
|
||||
playlist.duration += track.duration / 1000;
|
||||
playlist.duration += song.duration;
|
||||
playlist.songs.push(song);
|
||||
}
|
||||
|
||||
|
||||
+183
-61
@@ -1,26 +1,107 @@
|
||||
const {LogType} = require('loguix');
|
||||
const clog = new LogType("SpotifyInformation");
|
||||
const config = require('../utils/Database/Configuration');
|
||||
const SPOTIFY_CLIENT_ID = config.getSpotifyClientId()
|
||||
const SPOTIFY_CLIENT_SECRET = config.getSpotifyClientSecret()
|
||||
const SpotifyWebApi = require('spotify-web-api-node');
|
||||
const {Playlist} = require('../playlists/Playlist');
|
||||
const {Song} = require('../player/Song');
|
||||
const youtube = require("../media/YoutubeInformation");
|
||||
const {getReadableDuration} = require('../utils/TimeConverter');
|
||||
const {toSafeDuration} = require('../utils/SafeMetric');
|
||||
const {mapLimit} = require('../utils/Limiter');
|
||||
|
||||
const spotifyApi = new SpotifyWebApi({
|
||||
clientId: SPOTIFY_CLIENT_ID,
|
||||
clientSecret: SPOTIFY_CLIENT_SECRET,
|
||||
});
|
||||
const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
|
||||
// Chaque titre déclenche une recherche YouTube : on borne la taille des imports
|
||||
const MAX_TRACKS = 500;
|
||||
// Conversions Spotify → YouTube simultanées (le limiteur YouTube partagé reste le plafond global)
|
||||
const CONVERSION_CONCURRENCY = 3;
|
||||
|
||||
// Identifiants lus au moment de demander un jeton : une modification depuis l'admin s'applique sans redémarrage
|
||||
const spotifyApi = new SpotifyWebApi();
|
||||
|
||||
// Le jeton client est valable une heure : on le garde en cache au lieu d'en redemander un à chaque requête
|
||||
let tokenExpiresAt = 0;
|
||||
// Identifiants ayant servi à obtenir le jeton courant (détecte un changement de configuration)
|
||||
let tokenCredentials = null;
|
||||
// Demande de jeton en cours, partagée par les appels simultanés
|
||||
let tokenPromise = null;
|
||||
|
||||
function resetToken() {
|
||||
tokenExpiresAt = 0;
|
||||
tokenCredentials = null;
|
||||
spotifyApi.resetAccessToken();
|
||||
}
|
||||
|
||||
// Configuration modifiée (admin) : le prochain appel redemandera un jeton avec les nouveaux identifiants
|
||||
process.on("CONFIG_UPDATE", resetToken);
|
||||
|
||||
async function ensureToken() {
|
||||
const clientId = config.getSpotifyClientId();
|
||||
const clientSecret = config.getSpotifyClientSecret();
|
||||
if(!clientId || !clientSecret) {
|
||||
throw new Error("Identifiants Spotify non configurés (api.spotify.clientId / clientSecret)");
|
||||
}
|
||||
const credentials = clientId + ":" + clientSecret;
|
||||
if(tokenCredentials !== null && tokenCredentials !== credentials) resetToken();
|
||||
if(Date.now() < tokenExpiresAt) return;
|
||||
|
||||
if(!tokenPromise) {
|
||||
tokenPromise = (async () => {
|
||||
spotifyApi.setClientId(clientId);
|
||||
spotifyApi.setClientSecret(clientSecret);
|
||||
const data = await spotifyApi.clientCredentialsGrant();
|
||||
spotifyApi.setAccessToken(data.body['access_token']);
|
||||
const expiresIn = Number(data.body['expires_in']) || 3600;
|
||||
// Marge d'une minute avant l'expiration réelle
|
||||
tokenExpiresAt = Date.now() + Math.max(0, expiresIn - 60) * 1000;
|
||||
tokenCredentials = credentials;
|
||||
})().finally(() => { tokenPromise = null; });
|
||||
}
|
||||
await tokenPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un titre Spotify est exploitable s'il a un nom et un artiste.
|
||||
* Les titres supprimés ou indisponibles valent null, les épisodes de podcast n'ont pas d'artistes.
|
||||
*/
|
||||
function isValidTrack(track) {
|
||||
return !!track && typeof track.name === "string" && track.name.length > 0
|
||||
&& Array.isArray(track.artists) && !!track.artists[0]?.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les pages suivantes d'une liste paginée Spotify (playlist ou album)
|
||||
* @param {Object} firstPage Première page ({items, next, total})
|
||||
* @param {(offset: number) => Promise<Object>} fetchPage Renvoie la page commençant à offset
|
||||
* @param {(item: Object) => Object} mapItem Transforme un élément de page en titre
|
||||
*/
|
||||
async function collectAllTracks(firstPage, fetchPage, mapItem) {
|
||||
const tracks = [];
|
||||
let page = firstPage;
|
||||
while (page && Array.isArray(page.items)) {
|
||||
for (const item of page.items) {
|
||||
if (tracks.length >= MAX_TRACKS) break;
|
||||
tracks.push(mapItem(item));
|
||||
}
|
||||
if (!page.next || tracks.length >= MAX_TRACKS || page.items.length === 0) break;
|
||||
try {
|
||||
page = await fetchPage((page.offset ?? 0) + page.items.length);
|
||||
} catch (error) {
|
||||
clog.warn("Impossible de récupérer la suite des titres Spotify, import partiel : " + error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tracks.length >= MAX_TRACKS && page?.total > MAX_TRACKS) {
|
||||
clog.warn(`Import Spotify limité aux ${MAX_TRACKS} premiers titres (sur ${page.total})`);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
async function getSong(url) {
|
||||
try {
|
||||
const data = await spotifyApi.clientCredentialsGrant();
|
||||
spotifyApi.setAccessToken(data.body['access_token']);
|
||||
await ensureToken();
|
||||
|
||||
const parts = url.split('/');
|
||||
const trackId = parts[parts.length - 1];
|
||||
const trackId = parts[parts.length - 1]?.split('?')[0];
|
||||
|
||||
if(!trackId) {
|
||||
clog.error("Impossible de récupérer l'identifiant de la piste Spotify à partir de l'URL");
|
||||
@@ -28,6 +109,11 @@ async function getSong(url) {
|
||||
}
|
||||
const trackInfo = await spotifyApi.getTrack(trackId);
|
||||
|
||||
if(!isValidTrack(trackInfo?.body)) {
|
||||
clog.error("La piste Spotify est indisponible ou incomplète : " + trackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackName = trackInfo.body.name;
|
||||
const artistName = trackInfo.body.artists[0].name;
|
||||
|
||||
@@ -44,9 +130,7 @@ async function getAlbum(url) {
|
||||
|
||||
try {
|
||||
|
||||
|
||||
const creditdata = await spotifyApi.clientCredentialsGrant();
|
||||
spotifyApi.setAccessToken(creditdata.body['access_token']);
|
||||
await ensureToken();
|
||||
|
||||
const parts = url.split('/');
|
||||
const albumId = parts[parts.indexOf('album') + 1].split('?')[0];
|
||||
@@ -63,13 +147,18 @@ async function getAlbum(url) {
|
||||
|
||||
const playlist = new Playlist()
|
||||
playlist.title = info.name;
|
||||
playlist.author = info.artists[0].name;
|
||||
playlist.authorId = info.artists[0].id;
|
||||
playlist.thumbnail = info.images[0].url;
|
||||
playlist.url = info.external_urls.spotify;
|
||||
playlist.author = info.artists?.[0]?.name ?? "Auteur inconnu";
|
||||
playlist.authorId = info.artists?.[0]?.id;
|
||||
playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
|
||||
playlist.url = info.external_urls?.spotify;
|
||||
playlist.id = albumId;
|
||||
playlist.type = "spotify";
|
||||
playlist.songs = info.tracks.items;
|
||||
// Un album ne renvoie que ses 50 premiers titres : on récupère les pages suivantes
|
||||
playlist.songs = await collectAllTracks(
|
||||
info.tracks,
|
||||
async (offset) => (await spotifyApi.getAlbumTracks(albumId, { offset, limit: 50 })).body,
|
||||
(item) => item
|
||||
);
|
||||
|
||||
return playlist;
|
||||
|
||||
@@ -87,8 +176,7 @@ async function getPlaylist(url) {
|
||||
// Get the playlist and return a Playlist Object
|
||||
|
||||
try {
|
||||
const creditdata = await spotifyApi.clientCredentialsGrant();
|
||||
spotifyApi.setAccessToken(creditdata.body['access_token']);
|
||||
await ensureToken();
|
||||
|
||||
const parts = url.split('/');
|
||||
const playlistId = parts[parts.indexOf('playlist') + 1].split('?')[0];
|
||||
@@ -106,74 +194,108 @@ async function getPlaylist(url) {
|
||||
|
||||
const playlist = new Playlist()
|
||||
playlist.title = info.name;
|
||||
playlist.author = info.owner.display_name;
|
||||
playlist.authorId = info.owner.id;
|
||||
playlist.thumbnail = info.images[0].url;
|
||||
playlist.url = info.external_urls.spotify;
|
||||
playlist.author = info.owner?.display_name ?? "Auteur inconnu";
|
||||
playlist.authorId = info.owner?.id;
|
||||
playlist.thumbnail = info.images?.[0]?.url ?? DEFAULT_THUMBNAIL;
|
||||
playlist.url = info.external_urls?.spotify;
|
||||
playlist.id = playlistId;
|
||||
playlist.type = "spotify";
|
||||
|
||||
for(const track of info.tracks.items) {
|
||||
playlist.songs.push(track.track);
|
||||
}
|
||||
// Une playlist ne renvoie que ses 100 premiers titres : on récupère les pages suivantes
|
||||
// (les titres null — supprimés, indisponibles — sont filtrés plus tard par getTracks)
|
||||
playlist.songs = await collectAllTracks(
|
||||
info.tracks,
|
||||
async (offset) => (await spotifyApi.getPlaylistTracks(playlistId, { offset, limit: 100 })).body,
|
||||
(item) => item?.track ?? null
|
||||
);
|
||||
|
||||
return playlist;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
clog.error("Impossible de récupérer les informations de l'album Spotify à partir de l'URL");
|
||||
clog.error("Impossible de récupérer les informations de la playlist Spotify à partir de l'URL");
|
||||
clog.error(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les titres Spotify d'une playlist en musiques YouTube jouables.
|
||||
* Modifie la playlist (songs, duration, readduration) et la renvoie, même en cas d'erreur.
|
||||
* @param {Playlist} playlist
|
||||
* @returns {Promise<Playlist>}
|
||||
*/
|
||||
async function getTracks(playlist) {
|
||||
|
||||
const tracks = playlist.songs
|
||||
playlistSongs = [];
|
||||
for(const track of tracks) {
|
||||
const tracks = Array.isArray(playlist?.songs) ? playlist.songs : [];
|
||||
|
||||
var trackName = track.name;
|
||||
var artistName = track.artists[0].name;
|
||||
var queryForYoutube = `${trackName} - ${artistName}`;
|
||||
|
||||
var urlYoutubeFounded = await youtube.getQuery(queryForYoutube).then(function(songFind) {
|
||||
if(!songFind) return null;
|
||||
return songFind.url;
|
||||
// Concurrence bornée, ordre conservé ; un titre en erreur vaut null au lieu de faire échouer tout l'import
|
||||
const converted = await mapLimit(tracks, CONVERSION_CONCURRENCY, (track) => convertTrack(track, playlist), (error) => {
|
||||
clog.error("Erreur lors de la conversion d'un titre Spotify : " + error);
|
||||
return null;
|
||||
});
|
||||
|
||||
clog.log("URL de la vidéo YouTube trouvée : " + urlYoutubeFounded);
|
||||
// Tableau local : deux imports simultanés ne doivent jamais partager leurs titres
|
||||
const playlistSongs = converted.filter(Boolean);
|
||||
const totalDuration = playlistSongs.reduce((acc, song) => acc + toSafeDuration(song.duration), 0);
|
||||
|
||||
if(!urlYoutubeFounded) {
|
||||
clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube);
|
||||
|
||||
} else {
|
||||
const song = new Song();
|
||||
|
||||
song.title = track.name;
|
||||
song.author = track.artists[0].name;
|
||||
song.url = urlYoutubeFounded;
|
||||
song.thumbnail = playlist.thumbnail;
|
||||
song.id = track.id;
|
||||
song.duration = track.duration_ms / 1000;
|
||||
song.readduration = getReadableDuration(track.duration_ms / 1000);
|
||||
song.type = "youtube";
|
||||
|
||||
playlist.duration += track.duration_ms / 1000;
|
||||
playlistSongs.push(song);
|
||||
if(tracks.length > 0) {
|
||||
clog.log(`Conversion Spotify → YouTube : ${playlistSongs.length}/${tracks.length} titres pour « ${playlist?.title} »`);
|
||||
}
|
||||
|
||||
// When finish do this
|
||||
|
||||
}
|
||||
|
||||
playlist.readduration = getReadableDuration(playlist.duration);
|
||||
if(playlist) {
|
||||
playlist.songs = playlistSongs;
|
||||
|
||||
playlist.duration = totalDuration;
|
||||
playlist.readduration = getReadableDuration(totalDuration);
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un titre Spotify en musique YouTube jouable
|
||||
* @param {Object} track Titre Spotify (ou musique déjà convertie)
|
||||
* @param {Playlist} playlist Playlist d'origine (miniature par défaut)
|
||||
* @returns {Promise<Song|null>} null si le titre est ignoré ou introuvable
|
||||
*/
|
||||
async function convertTrack(track, playlist) {
|
||||
// Titre déjà converti (playlist rafraîchie ou déjà importée) : on le garde tel quel
|
||||
if(track && track.type === "youtube" && track.url) {
|
||||
return track;
|
||||
}
|
||||
|
||||
if(!isValidTrack(track)) {
|
||||
clog.warn("Titre Spotify ignoré (supprimé, indisponible ou épisode de podcast)");
|
||||
return null;
|
||||
}
|
||||
|
||||
const trackName = track.name;
|
||||
const artistName = track.artists[0].name;
|
||||
const queryForYoutube = `${trackName} - ${artistName}`;
|
||||
|
||||
const songFind = await youtube.getQuery(queryForYoutube);
|
||||
const urlYoutubeFounded = songFind ? songFind.url : null;
|
||||
|
||||
if(!urlYoutubeFounded) {
|
||||
clog.error("Impossible de récupérer l'URL de la vidéo YouTube à partir de la requête " + queryForYoutube);
|
||||
return null;
|
||||
}
|
||||
|
||||
const song = new Song();
|
||||
const duration = toSafeDuration(Number(track.duration_ms) / 1000);
|
||||
|
||||
song.title = trackName;
|
||||
song.author = artistName;
|
||||
song.url = urlYoutubeFounded;
|
||||
song.thumbnail = track.album?.images?.[0]?.url ?? playlist?.thumbnail ?? song.thumbnail;
|
||||
song.id = track.id ?? songFind.id;
|
||||
song.duration = duration;
|
||||
song.readduration = getReadableDuration(duration);
|
||||
song.type = "youtube";
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {getSong, getAlbum, getPlaylist, getTracks}
|
||||
+239
-43
@@ -2,9 +2,55 @@ const { LogType } = require('loguix');
|
||||
const clog = new LogType("YoutubeInformation");
|
||||
const { Song } = require('../player/Song');
|
||||
const { Playlist } = require('../playlists/Playlist');
|
||||
const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter');
|
||||
const { getReadableDuration } = require('../utils/TimeConverter');
|
||||
const { toSafeDuration } = require('../utils/SafeMetric');
|
||||
const { createLimiter, createCache } = require('../utils/Limiter');
|
||||
const yts = require("yt-search")
|
||||
const ytfps = require('ytfps');
|
||||
|
||||
// Les métadonnées sont récupérées en scrapant youtube.com depuis la même IP que yt-dlp :
|
||||
// on limite les rafales (concurrence + espacement) et on met les réponses en cache pour éviter les blocages
|
||||
const youtubeLimiter = createLimiter(2, { minIntervalMs: 250 });
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const videoCache = createCache({ ttlMs: 6 * HOUR, max: 2000 }); // videoId → vidéo yt-search
|
||||
const queryCache = createCache({ ttlMs: 30 * MINUTE, max: 500 }); // requête → vidéos yt-search
|
||||
const listCache = createCache({ ttlMs: 10 * MINUTE, max: 100 }); // listId → playlist yt-search
|
||||
const avatarCache = createCache({ ttlMs: 24 * HOUR, max: 1000 }); // URL de chaîne → URL de l'avatar
|
||||
// Un échec de récupération d'avatar est retenu moins longtemps pour retenter plus tard
|
||||
const AVATAR_FAILURE_TTL = HOUR;
|
||||
const AVATAR_TIMEOUT_MS = 8000;
|
||||
|
||||
// En-têtes d'un navigateur de bureau réel ; SOCS/CONSENT évitent la page de consentement (hôte dans l'UE)
|
||||
const BROWSER_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Cookie": "SOCS=CAI; CONSENT=YES+cb",
|
||||
};
|
||||
|
||||
const VIDEO_ID_REGEX = /(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
/**
|
||||
* Appel yt-search passant par le limiteur partagé
|
||||
* @param {Object} options Options yt-search ({query} | {videoId} | {listId})
|
||||
*/
|
||||
function limitedYts(options) {
|
||||
return youtubeLimiter.run(() => yts(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vidéo yt-search par identifiant (cache 6 h, requêtes simultanées dédupliquées)
|
||||
* @param {string} videoId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
function fetchVideo(videoId) {
|
||||
return videoCache.wrap(videoId, async () => (await limitedYts({ videoId })) || null);
|
||||
}
|
||||
|
||||
function normalizeQuery(query) {
|
||||
return query.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
async function getQuery(query, multiple) {
|
||||
if (!query || typeof query !== 'string') {
|
||||
@@ -14,15 +60,26 @@ async function getQuery(query, multiple) {
|
||||
|
||||
try {
|
||||
const limit = multiple ? 25 : 1;
|
||||
const searchResults = await yts({ query: query, limit: limit });
|
||||
const videos = searchResults.videos;
|
||||
// yt-search ignore "limit" (une page de résultats quoi qu'il arrive) :
|
||||
// le même scrape sert donc aux recherches simples et multiples
|
||||
const videos = await queryCache.wrap(normalizeQuery(query), async () => {
|
||||
const searchResults = await limitedYts({ query: query });
|
||||
const found = Array.isArray(searchResults?.videos) ? searchResults.videos : [];
|
||||
// Résultat vide non mis en cache (blocage passager possible)
|
||||
return found.length > 0 ? found : null;
|
||||
});
|
||||
|
||||
if (videos.length === 0) {
|
||||
if (!videos || videos.length === 0) {
|
||||
clog.error("Impossible de récupérer le lien de la vidéo YouTube à partir de la requête");
|
||||
return null;
|
||||
}
|
||||
|
||||
const songs = await Promise.all(videos.map(video => new Song().processYoutubeVideo(video)));
|
||||
// Les résultats contiennent déjà durée / auteur : ils alimentent le cache des vidéos
|
||||
for (const video of videos) {
|
||||
if (video?.videoId && !videoCache.has(video.videoId)) videoCache.set(video.videoId, video);
|
||||
}
|
||||
|
||||
const songs = await Promise.all(videos.slice(0, limit).map(video => new Song().processYoutubeVideo(video)));
|
||||
return multiple ? songs.filter(song => song !== null) : songs[0];
|
||||
} catch (error) {
|
||||
clog.error('Erreur lors de la recherche YouTube: ' + error);
|
||||
@@ -31,15 +88,16 @@ async function getQuery(query, multiple) {
|
||||
}
|
||||
|
||||
async function getVideo(url) {
|
||||
const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/);
|
||||
const videoId = typeof url === "string" ? url.match(VIDEO_ID_REGEX) : null;
|
||||
if (videoId === null) {
|
||||
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const video = await yts({videoId: videoId[1]});
|
||||
const video = await fetchVideo(videoId[1]);
|
||||
if (video) {
|
||||
// Nouvelle instance à chaque appel : la vidéo en cache n'est jamais modifiée par les appelants
|
||||
const songReturn = new Song();
|
||||
await songReturn.processYoutubeVideo(video);
|
||||
return songReturn;
|
||||
@@ -60,24 +118,18 @@ async function getPlaylist(url) {
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// If watch?v= is present in the url with list?=, remove it and the code behind and transform it to playlist?list=
|
||||
var playlistId;
|
||||
|
||||
// Get &list= in the url until the first & or ?
|
||||
|
||||
// Identifiant après list= (jusqu'au premier caractère hors identifiant, & ou ?)
|
||||
let playlistId;
|
||||
if (url.includes("list=")) {
|
||||
playlistId = url.match(/(list=)([a-zA-Z0-9_-]+)/);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (playlistId === null) {
|
||||
if (!playlistId) {
|
||||
clog.error("Impossible de récupérer l'identifiant de la playlist YouTube à partir de l'URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
const playlistInfo = await yts({ listId: playlistId[2] });
|
||||
const playlistInfo = await listCache.wrap(playlistId[2], () => fetchPlaylistInfo(playlistId[2]));
|
||||
|
||||
if (!playlistInfo) {
|
||||
clog.error("Impossible de récupérer la playlist YouTube à partir de l'identifiant");
|
||||
@@ -86,19 +138,22 @@ async function getPlaylist(url) {
|
||||
|
||||
const playlist = new Playlist();
|
||||
playlist.type = "youtube";
|
||||
playlist.author = playlistInfo.author.name;
|
||||
playlist.authorId = playlistInfo.author.url;
|
||||
playlist.authorAvatar = await getYouTubeProfilePicture(playlistInfo.author.url);
|
||||
playlist.author = playlistInfo.author?.name ?? playlist.author;
|
||||
playlist.authorId = playlistInfo.author?.url;
|
||||
// Avatar déjà présent dans la page de la playlist : évite une requête vers la chaîne
|
||||
playlist.authorAvatar = playlistInfo.authorAvatar || await getYouTubeProfilePicture(playlistInfo.author?.url);
|
||||
playlist.title = playlistInfo.title;
|
||||
playlist.thumbnail = playlistInfo.thumbnail;
|
||||
playlist.url = `https://www.youtube.com/playlist?list=${playlistId[2]}`;
|
||||
playlist.id = playlistInfo.listId;
|
||||
playlist.views = playlistInfo.views;
|
||||
|
||||
for (const video of playlistInfo.videos) {
|
||||
for (const video of playlistInfo.videos || []) {
|
||||
if (video?.videoId && !videoCache.has(video.videoId)) videoCache.set(video.videoId, video);
|
||||
const song = new Song();
|
||||
await song.processYoutubeVideo(video);
|
||||
playlist.duration += song.duration;
|
||||
// Live ou durée inconnue → 0 : la somme reste toujours un nombre
|
||||
playlist.duration += toSafeDuration(song.duration);
|
||||
playlist.songs.push(song);
|
||||
}
|
||||
playlist.readduration = getReadableDuration(playlist.duration);
|
||||
@@ -109,16 +164,130 @@ async function getPlaylist(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une playlist au format yt-search ({title, listId, thumbnail, views, author, videos}).
|
||||
* yt-search ne sait plus lire la nouvelle mise en page des playlists (lockupViewModel) :
|
||||
* on lit d'abord la page nous-mêmes (une seule requête), yt-search ne sert que de repli.
|
||||
* @param {string} listId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function fetchPlaylistInfo(listId) {
|
||||
try {
|
||||
const info = await fetchPlaylistPage(listId);
|
||||
if (info) return info;
|
||||
} catch (error) {
|
||||
clog.warn(`Lecture directe de la playlist YouTube ${listId} impossible, repli sur yt-search : ${error?.message || error}`);
|
||||
}
|
||||
return (await limitedYts({ listId })) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt récursivement un objet JSON et renvoie toutes les valeurs de la clé donnée
|
||||
*/
|
||||
function findAllByKey(root, key, out = []) {
|
||||
if (Array.isArray(root)) {
|
||||
for (const item of root) findAllByKey(item, key, out);
|
||||
} else if (root && typeof root === "object") {
|
||||
for (const k of Object.keys(root)) {
|
||||
if (k === key) out.push(root[k]);
|
||||
else findAllByKey(root[k], key, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* "4:01" / "1:02:03" → secondes ; texte non numérique (LIVE, PREMIERE…) → 0
|
||||
*/
|
||||
function parseTimestamp(text) {
|
||||
if (typeof text !== "string" || !/^\d+(:\d{1,2}){0,2}$/.test(text.trim())) return 0;
|
||||
return text.trim().split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL absolue de chaîne à partir d'une commande de navigation YouTube
|
||||
*/
|
||||
function channelUrlFromRuns(textObject) {
|
||||
const endpoint = textObject?.commandRuns?.[0]?.onTap?.innertubeCommand?.browseEndpoint;
|
||||
if (endpoint?.canonicalBaseUrl) return "https://www.youtube.com" + endpoint.canonicalBaseUrl;
|
||||
if (endpoint?.browseId) return "https://www.youtube.com/channel/" + endpoint.browseId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit la page d'une playlist (100 premières vidéos, comme yt-search)
|
||||
* @param {string} listId
|
||||
* @returns {Promise<Object|null>} null si la mise en page n'est pas reconnue
|
||||
*/
|
||||
async function fetchPlaylistPage(listId) {
|
||||
const url = `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}&hl=en&gl=US`;
|
||||
const html = await youtubeLimiter.run(async () => {
|
||||
// Page en anglais : textes ("by …", "… views") et titres non traduits, comme yt-search
|
||||
const res = await fetch(url, {
|
||||
headers: { ...BROWSER_HEADERS, "Accept-Language": "en-US,en;q=0.9" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.text();
|
||||
});
|
||||
const match = html.match(/ytInitialData\s*=\s*({.*?});\s*<\/script>/s);
|
||||
if (!match) return null;
|
||||
const data = JSON.parse(match[1]);
|
||||
|
||||
const videos = [];
|
||||
for (const lockup of findAllByKey(data, "lockupViewModel")) {
|
||||
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO" || !lockup.contentId) continue;
|
||||
const meta = lockup.metadata?.lockupMetadataViewModel;
|
||||
// Vidéos privées / supprimées : présentes dans la liste mais illisibles
|
||||
if (/^\[(private|deleted) video\]$/i.test(meta?.title?.content ?? "")) continue;
|
||||
const authorText = meta?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.[0]?.text;
|
||||
const badge = findAllByKey(lockup.contentImage, "thumbnailBadgeViewModel")[0];
|
||||
const seconds = parseTimestamp(badge?.text);
|
||||
videos.push({
|
||||
title: meta?.title?.content ?? "Aucun titre",
|
||||
videoId: lockup.contentId,
|
||||
listId,
|
||||
thumbnail: `https://i.ytimg.com/vi/${lockup.contentId}/hqdefault.jpg`,
|
||||
duration: { seconds, timestamp: badge?.text ?? "" },
|
||||
author: { name: authorText?.content ?? "Auteur inconnu", url: channelUrlFromRuns(authorText) },
|
||||
});
|
||||
}
|
||||
if (videos.length === 0) return null;
|
||||
|
||||
const header = findAllByKey(data, "pageHeaderViewModel")[0];
|
||||
const headerRows = header?.metadata?.contentMetadataViewModel?.metadataRows || [];
|
||||
const ownerPart = headerRows[0]?.metadataParts?.[0];
|
||||
const ownerStack = ownerPart?.avatarStack?.avatarStackViewModel;
|
||||
const ownerText = ownerPart?.text ?? ownerStack?.text;
|
||||
const viewsText = headerRows.flatMap(row => row.metadataParts || []).map(part => part?.text?.content || "").find(text => /views?$/i.test(text));
|
||||
const thumbnails = data.microformat?.microformatDataRenderer?.thumbnail?.thumbnails;
|
||||
|
||||
return {
|
||||
title: data.metadata?.playlistMetadataRenderer?.title ?? header?.title?.dynamicTextViewModel?.text?.content ?? "Aucun titre",
|
||||
listId,
|
||||
url: `https://www.youtube.com/playlist?list=${listId}`,
|
||||
thumbnail: thumbnails?.[thumbnails.length - 1]?.url ?? videos[0].thumbnail,
|
||||
views: viewsText ? Number(viewsText.replace(/\D/g, "")) || 0 : 0,
|
||||
author: {
|
||||
name: (ownerText?.content ?? "Auteur inconnu").replace(/^by\s+/i, ""),
|
||||
url: channelUrlFromRuns(ownerText),
|
||||
},
|
||||
authorAvatar: ownerStack?.avatars?.[0]?.avatarViewModel?.image?.sources?.[0]?.url ?? null,
|
||||
videos,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSecondsFromUrl(url) {
|
||||
const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/|music\.youtube\.com\/)(?:watch\?v=)?([a-zA-Z0-9_-]{11})/);
|
||||
const videoId = typeof url === "string" ? url.match(VIDEO_ID_REGEX) : null;
|
||||
if (videoId === null) {
|
||||
clog.error("Impossible de récupérer l'identifiant de la vidéo YouTube à partir de l'URL");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const video = await yts({ videoId: videoId[1] });
|
||||
const video = await fetchVideo(videoId[1]);
|
||||
if (video) {
|
||||
return video.duration.seconds;
|
||||
// 0 pour un live ou une durée invalide (null reste réservé à l'échec de récupération)
|
||||
return toSafeDuration(video.duration?.seconds);
|
||||
} else {
|
||||
clog.error("Impossible de récupérer la vidéo YouTube à partir de l'identifiant");
|
||||
return null;
|
||||
@@ -129,36 +298,63 @@ async function getSecondsFromUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getYouTubeProfilePicture(channelUrl) {
|
||||
try {
|
||||
const res = await fetch(channelUrl, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||
}
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
// Match img with yt-spec-avatar-shape__image in class list
|
||||
/**
|
||||
* Extrait l'avatar d'une chaîne depuis le HTML de sa page
|
||||
* @param {string} html
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractAvatar(html) {
|
||||
// Balise img avec la classe yt-spec-avatar-shape__image
|
||||
const imgRegex = /<img[^>]*(?:class="[^"]*\byt-spec-avatar-shape__image\b[^"]*"[^>]*|[^>]*class="[^"]*\byt-spec-avatar-shape__image\b[^"]*")[^>]*src="([^"]+)"/i;
|
||||
const imgMatch = html.match(imgRegex);
|
||||
|
||||
if (imgMatch && imgMatch[1]) {
|
||||
return imgMatch[1];
|
||||
}
|
||||
|
||||
// Fallback: look for avatar in embedded JSON
|
||||
// Sinon : avatar dans le JSON embarqué
|
||||
const jsonRegex = /"avatar":\{"thumbnails":\[\{"url":"(.*?)"/;
|
||||
const match = html.match(jsonRegex);
|
||||
if (match && match[1]) {
|
||||
return match[1].replace(/\\u0026/g, "&"); // Decode \u0026 to &
|
||||
return match[1].replace(/\\u0026/g, "&");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn("Photo non trouvée pour :", channelUrl);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Erreur :", err);
|
||||
/**
|
||||
* Avatar d'une chaîne YouTube (cache 24 h, échecs retenus 1 h)
|
||||
* @param {string} channelUrl URL de la chaîne (https://www.youtube.com/...)
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getYouTubeProfilePicture(channelUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(channelUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Uniquement des pages youtube.com en http(s) (l'URL vient d'un scrape)
|
||||
if (!/^https?:$/.test(parsed.protocol) || !/(^|\.)youtube\.com$/i.test(parsed.hostname) || parsed.pathname.endsWith("/undefined")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return avatarCache.wrap(parsed.href, async () => {
|
||||
try {
|
||||
const html = await youtubeLimiter.run(async () => {
|
||||
const res = await fetch(parsed.href, {
|
||||
headers: BROWSER_HEADERS,
|
||||
signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.text();
|
||||
});
|
||||
const avatar = extractAvatar(html);
|
||||
if (!avatar) clog.warn("Photo de profil introuvable pour la chaîne : " + parsed.href);
|
||||
return avatar;
|
||||
} catch (error) {
|
||||
clog.warn("Impossible de récupérer la photo de profil de la chaîne " + parsed.href + " : " + (error?.message || error));
|
||||
return null;
|
||||
}
|
||||
}, { ttlFor: (value) => value ? 24 * HOUR : AVATAR_FAILURE_TTL });
|
||||
}
|
||||
|
||||
module.exports = { getQuery, getVideo, getPlaylist, getSecondsFromUrl };
|
||||
+48
-7
@@ -1,10 +1,41 @@
|
||||
const {LogType} = require('loguix');
|
||||
const clog = new LogType("Finder");
|
||||
const Resolver = require('../utils/Resolver');
|
||||
const { QueryType } = require('../utils/QueryType');
|
||||
const { Links } = require('../utils/Links');
|
||||
const youtube = require("../media/YoutubeInformation")
|
||||
const spotify = require("../media/SpotifyInformation")
|
||||
const soundcloud = require("../media/SoundcloudInformation")
|
||||
|
||||
// Titres Spotify convertis pour un simple résultat de recherche
|
||||
const SEARCH_PREVIEW_TRACKS = 25
|
||||
|
||||
/**
|
||||
* Récupère une playlist ou un album Spotify et convertit ses titres en musiques YouTube jouables
|
||||
* (les appelants reçoivent des Song et non des titres Spotify bruts, sans url ni type).
|
||||
* @param {"playlist"|"album"} kind
|
||||
* @param {string} url
|
||||
* @param {number} [maxTracks] - 0 = tous ; sinon aperçu limité (recherche) pour éviter une rafale de requêtes YouTube
|
||||
* @returns {Promise<Playlist|null>} null si introuvable, ou si aucun titre n'a pu être converti
|
||||
*/
|
||||
async function getSpotifyCollection(kind, url, maxTracks = 0) {
|
||||
const playlist = kind === "album" ? await spotify.getAlbum(url) : await spotify.getPlaylist(url)
|
||||
if(!playlist) return null
|
||||
if(maxTracks > 0 && Array.isArray(playlist.songs) && playlist.songs.length > maxTracks) {
|
||||
playlist.totalTracks = playlist.songs.length
|
||||
playlist.truncated = true
|
||||
playlist.songs = playlist.songs.slice(0, maxTracks)
|
||||
}
|
||||
const trackCount = Array.isArray(playlist.songs) ? playlist.songs.length : 0
|
||||
// getTracks est idempotent : les appelants qui le rappellent ensuite ne relancent aucune recherche
|
||||
await spotify.getTracks(playlist)
|
||||
if(trackCount > 0 && playlist.songs.length === 0) {
|
||||
// Échec total (YouTube indisponible ou bloqué) : on ne renvoie pas une playlist vide
|
||||
// (un rafraîchissement écraserait sinon la playlist enregistrée)
|
||||
clog.error(`Aucun titre de « ${playlist.title} » n'a pu être converti depuis Spotify`)
|
||||
return null
|
||||
}
|
||||
return playlist
|
||||
}
|
||||
|
||||
async function search(query, multiple, forceType) {
|
||||
if(!query) return null
|
||||
@@ -12,7 +43,9 @@ async function search(query, multiple, forceType) {
|
||||
if(!forceType) forceType = null
|
||||
if(forceType == "PLAYLIST") {
|
||||
if(query.includes("spotify")) {
|
||||
return await spotify.getPlaylist(query)
|
||||
// Lien d'album collé dans "playlist" : même conversion
|
||||
const kind = /\/album\//.test(query) ? "album" : "playlist"
|
||||
return await getSpotifyCollection(kind, query)
|
||||
} else if(query.includes("soundcloud")) {
|
||||
return await soundcloud.getPlaylist(query)
|
||||
} else if(query.includes("youtube")) {
|
||||
@@ -24,10 +57,15 @@ async function search(query, multiple, forceType) {
|
||||
|
||||
if(forceType == "SONG") {
|
||||
if(query.includes("spotify")) {
|
||||
return await spotify.getSong(query)
|
||||
// getSong renvoie "titre - artiste" : on cherche la musique YouTube correspondante
|
||||
const spotifyQuery = await spotify.getSong(query)
|
||||
return spotifyQuery ? await youtube.getQuery(spotifyQuery, multiple) : null
|
||||
} else if(query.includes("soundcloud")) {
|
||||
return await soundcloud.getTrack(query)
|
||||
} else if(query.includes("youtube")) {
|
||||
if(Resolver.getQueryType(query) == QueryType.YOUTUBE_VIDEO) {
|
||||
return await youtube.getVideo(query)
|
||||
}
|
||||
return await youtube.getQuery(query, multiple)
|
||||
} else {
|
||||
return null
|
||||
@@ -36,7 +74,7 @@ async function search(query, multiple, forceType) {
|
||||
|
||||
if(forceType == "ALBUM") {
|
||||
if(query.includes("spotify")) {
|
||||
return await spotify.getAlbum(query)
|
||||
return await getSpotifyCollection("album", query)
|
||||
} else if(query.includes("youtube")) {
|
||||
return await youtube.getQuery(query, multiple)
|
||||
} else {
|
||||
@@ -58,14 +96,17 @@ async function search(query, multiple, forceType) {
|
||||
return await youtube.getPlaylist(query)
|
||||
}
|
||||
if(type == QueryType.SPOTIFY_SONG) {
|
||||
return await youtube.getQuery(await spotify.getSong(query))
|
||||
const spotifyQuery = await spotify.getSong(query)
|
||||
return spotifyQuery ? await youtube.getQuery(spotifyQuery) : null
|
||||
|
||||
}
|
||||
// Recherche (multiple) : simple aperçu ; l'import complet passe par forceType "PLAYLIST"
|
||||
const preview = multiple ? SEARCH_PREVIEW_TRACKS : 0
|
||||
if(type == QueryType.SPOTIFY_ALBUM) {
|
||||
return await spotify.getAlbum(query)
|
||||
return await getSpotifyCollection("album", query, preview)
|
||||
}
|
||||
if(type == QueryType.SPOTIFY_PLAYLIST) {
|
||||
return await spotify.getPlaylist(query)
|
||||
return await getSpotifyCollection("playlist", query, preview)
|
||||
|
||||
}
|
||||
if(type == QueryType.SOUNDCLOUD_TRACK) {
|
||||
|
||||
+114
-56
@@ -7,30 +7,57 @@ const { Song } = require('./Song')
|
||||
|
||||
const AllLists = new Map() // Map<guildId, List>
|
||||
|
||||
// Taille max de l'historique par guilde
|
||||
const MAX_PREVIOUS = 50
|
||||
// Écriture différée de l'historique (une rafale de skips = une seule écriture disque)
|
||||
const SAVE_DELAY = 1000
|
||||
|
||||
class List {
|
||||
next;
|
||||
current;
|
||||
shuffle;
|
||||
guildId;
|
||||
// Appelé à chaque modification (branché par le Player sur markDirty)
|
||||
onChange = null;
|
||||
constructor(guildId) {
|
||||
if(guildId === null) {
|
||||
if(!guildId) {
|
||||
clog.error("Impossible de créer une liste, car guildId est null")
|
||||
return
|
||||
}
|
||||
if(AllLists.has(guildId)) {
|
||||
return AllLists.get(guildId)
|
||||
}
|
||||
// Add PreviousDB.data[this.guildId]
|
||||
if(PreviousDB.data[guildId] === undefined) {
|
||||
PreviousDB.data[guildId] = new Array()
|
||||
savePrevious()
|
||||
}
|
||||
AllLists.set(guildId, this)
|
||||
this.next = new Array();
|
||||
this.current = null;
|
||||
this.shuffle = false;
|
||||
this.guildId = guildId;
|
||||
// Création paresseuse de l'entrée d'historique (voir getPreviousArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signale une modification au Player (émission d'état regroupée)
|
||||
*/
|
||||
changed() {
|
||||
if(typeof this.onChange !== "function") return
|
||||
try {
|
||||
this.onChange()
|
||||
} catch(e) {
|
||||
clog.error("Erreur lors de la notification de modification de la liste : " + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historique de la guilde (créé si absent)
|
||||
* @returns {Array}
|
||||
*/
|
||||
getPreviousArray() {
|
||||
if(!Array.isArray(PreviousDB.data[this.guildId])) {
|
||||
PreviousDB.data[this.guildId] = new Array()
|
||||
}
|
||||
return PreviousDB.data[this.guildId]
|
||||
}
|
||||
|
||||
getNext() {
|
||||
return this.next;
|
||||
}
|
||||
@@ -58,43 +85,49 @@ class List {
|
||||
this.next.splice(randomIndex, 1)
|
||||
|
||||
}
|
||||
this.setCurrent(song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
return song
|
||||
this.setCurrent(song ?? null)
|
||||
return song ?? null
|
||||
}
|
||||
|
||||
|
||||
clearNext() {
|
||||
this.next = new Array();
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
}
|
||||
|
||||
addNextSong(song) {
|
||||
if(!song) return
|
||||
this.next.push(song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
}
|
||||
|
||||
firstNext(song) {
|
||||
if(!song) return
|
||||
this.next.unshift(song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
}
|
||||
|
||||
removeNextByIndex(index) {
|
||||
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
|
||||
this.next.splice(index, 1)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
return true
|
||||
}
|
||||
|
||||
moveSongToUpNext(index) {
|
||||
if(!Number.isInteger(index) || index < 0 || index >= this.next.length) return false
|
||||
const song = this.next[index]
|
||||
this.next.splice(index, 1)
|
||||
this.next.unshift(song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
return true
|
||||
}
|
||||
|
||||
getPrevious() {
|
||||
const previousList = new Array()
|
||||
|
||||
for(const song of PreviousDB.data[this.guildId]) {
|
||||
for(const song of this.getPreviousArray()) {
|
||||
if(!song) continue
|
||||
previousList.push(new Song(song))
|
||||
}
|
||||
return previousList;
|
||||
@@ -102,8 +135,9 @@ class List {
|
||||
}
|
||||
|
||||
getPreviousSong() {
|
||||
if(PreviousDB.data[this.guildId].length > 0) {
|
||||
return new Song(PreviousDB.data[this.guildId][0])
|
||||
const previous = this.getPreviousArray()
|
||||
if(previous.length > 0 && previous[0]) {
|
||||
return new Song(previous[0])
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -111,37 +145,39 @@ class List {
|
||||
}
|
||||
|
||||
previousSong() {
|
||||
const previous = this.getPreviousArray()
|
||||
if(previous.length === 0) return null
|
||||
if(this.current != null) {
|
||||
this.firstNext(this.current)
|
||||
}
|
||||
if(PreviousDB.data[this.guildId].length > 0) {
|
||||
const song = PreviousDB.data[this.guildId][0]
|
||||
const song = previous[0]
|
||||
// Remove the song from the previous list
|
||||
PreviousDB.data[this.guildId].splice(0, 1)
|
||||
previous.splice(0, 1)
|
||||
savePrevious()
|
||||
return new Song(song)
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
this.changed()
|
||||
return song ? new Song(song) : null
|
||||
|
||||
}
|
||||
|
||||
clearPrevious() {
|
||||
PreviousDB.data[this.guildId] = new Array();
|
||||
savePrevious();
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
}
|
||||
|
||||
addPreviousSongToNextByIndex(index) {
|
||||
const song = PreviousDB.data[this.guildId][index]
|
||||
const song = this.getPreviousArray()[index]
|
||||
if(!song) return false
|
||||
this.next.push(song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
return true
|
||||
}
|
||||
|
||||
addPreviousSong(song) {
|
||||
PreviousDB.data[this.guildId].unshift(song)
|
||||
if(!song) return
|
||||
this.getPreviousArray().unshift(song)
|
||||
savePrevious()
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
}
|
||||
|
||||
getCurrent() {
|
||||
@@ -149,71 +185,72 @@ class List {
|
||||
}
|
||||
|
||||
setCurrent(value) {
|
||||
this.current = value;
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.current = value ?? null;
|
||||
this.changed()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clearNext();
|
||||
this.next = new Array();
|
||||
this.current = null
|
||||
this.shuffle = false;
|
||||
AllLists.delete(this.guildId)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
if(AllLists.get(this.guildId) === this) AllLists.delete(this.guildId)
|
||||
this.changed()
|
||||
this.onChange = null
|
||||
}
|
||||
|
||||
setShuffle() {
|
||||
this.shuffle = !this.shuffle;
|
||||
this.changed()
|
||||
}
|
||||
|
||||
isShuffle() {
|
||||
return this.shuffle;
|
||||
}
|
||||
|
||||
// Play the song with the index in the queue and delete it from the queue
|
||||
// Retire et renvoie la musique d'index donné de la file (NEXT), ou la renvoie depuis l'historique (PREVIOUS)
|
||||
playByIndex(index, typelist) {
|
||||
|
||||
var index = data[0]
|
||||
var list = data[1]
|
||||
|
||||
if(typelist == ListType.NEXT) {
|
||||
|
||||
const song = this.next[index]
|
||||
if(!song) return null
|
||||
this.next.splice(index, 1)
|
||||
|
||||
this.changed()
|
||||
return song
|
||||
|
||||
} else if(typelist == ListType.PREVIOUS) {
|
||||
|
||||
const song = PreviousDB.data[this.guildId][index]
|
||||
return song
|
||||
const song = this.getPreviousArray()[index]
|
||||
return song ? new Song(song) : null
|
||||
|
||||
}
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
return null
|
||||
}
|
||||
|
||||
addNextPlaylist(playlist, firstAlreadyPlayed) {
|
||||
if(firstAlreadyPlayed) {
|
||||
playlist.songs.shift()
|
||||
}
|
||||
// Copie : la playlist enregistrée de l'utilisateur ne doit jamais être modifiée
|
||||
const songs = Array.isArray(playlist?.songs) ? playlist.songs.slice(firstAlreadyPlayed ? 1 : 0) : []
|
||||
|
||||
for(const song of playlist.songs) {
|
||||
this.addNextSong(song)
|
||||
for(const song of songs) {
|
||||
if(song) this.next.push(song)
|
||||
}
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
// Une seule notification pour toute la playlist
|
||||
this.changed()
|
||||
|
||||
}
|
||||
|
||||
moveNext(fromIndex, toIndex) {
|
||||
// Check if fromIndex and toIndex are valid
|
||||
if(fromIndex < 0 || fromIndex >= this.next.length + 1 || toIndex < 0 || toIndex >= this.next.length + 1) {
|
||||
if(!Number.isInteger(fromIndex) || !Number.isInteger(toIndex) || fromIndex < 0 || fromIndex >= this.next.length || toIndex < 0 || toIndex > this.next.length) {
|
||||
clog.error("Impossible de déplacer la musique, car l'index est invalide, GuildId : " + this.guildId)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if(fromIndex == toIndex) return;
|
||||
if(fromIndex == toIndex) return true;
|
||||
const song = this.next[fromIndex]
|
||||
this.next.splice(fromIndex, 1)
|
||||
this.next.splice(toIndex, 0, song)
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
this.changed()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -225,15 +262,36 @@ const ListType = {
|
||||
PREVIOUS: "1"
|
||||
}
|
||||
|
||||
function savePrevious() {
|
||||
let saveTimer = null
|
||||
|
||||
function flushPrevious() {
|
||||
if(saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
for(const guildId in PreviousDB.data) {
|
||||
if(PreviousDB.data[guildId].length > 50) {
|
||||
PreviousDB.data[guildId].splice(50, PreviousDB.data[guildId].length - 50)
|
||||
if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
|
||||
PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
|
||||
}
|
||||
}
|
||||
|
||||
PreviousDB.save();
|
||||
}
|
||||
|
||||
function savePrevious() {
|
||||
// L'historique en mémoire est borné immédiatement, l'écriture disque est différée
|
||||
for(const guildId in PreviousDB.data) {
|
||||
if(Array.isArray(PreviousDB.data[guildId]) && PreviousDB.data[guildId].length > MAX_PREVIOUS) {
|
||||
PreviousDB.data[guildId].splice(MAX_PREVIOUS, PreviousDB.data[guildId].length - MAX_PREVIOUS)
|
||||
}
|
||||
}
|
||||
if(saveTimer) return
|
||||
saveTimer = setTimeout(flushPrevious, SAVE_DELAY)
|
||||
saveTimer.unref?.()
|
||||
}
|
||||
|
||||
// Écriture des dernières modifications à l'arrêt du processus
|
||||
process.once("exit", () => {
|
||||
if(saveTimer) flushPrevious()
|
||||
})
|
||||
|
||||
module.exports = { List, ListType }
|
||||
+35
-10
@@ -1,19 +1,44 @@
|
||||
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice');
|
||||
const { Readable } = require('stream')
|
||||
const {LogType} = require('loguix')
|
||||
const clog = new LogType("Media")
|
||||
const plog = require("loguix").getInstance("Player")
|
||||
const ffmpeg = require('fluent-ffmpeg')
|
||||
const { isAllowedUrl } = require('../SongCheck')
|
||||
|
||||
async function getStream(song) {
|
||||
// Les pièces jointes sont lues depuis le début : le seek n'est pas supporté (le player le refuse)
|
||||
const seekable = false
|
||||
|
||||
/**
|
||||
* @param {Object} song
|
||||
* @param {number} seekSec - ignoré (seek non supporté)
|
||||
* @param {{signal?: AbortSignal}} options
|
||||
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
|
||||
*/
|
||||
async function getStream(song, seekSec = 0, { signal } = {}) {
|
||||
if(signal?.aborted) return null
|
||||
// Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF)
|
||||
if(!song || !isAllowedUrl("attachment", song.url)) {
|
||||
clog.error("URL de pièce jointe refusée : " + JSON.stringify(song?.url))
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const stream = await fetch(song.url).then(res => res.body);
|
||||
return stream;
|
||||
const res = await fetch(song.url, { signal })
|
||||
if(!res.ok || !res.body) {
|
||||
clog.error(`Erreur HTTP ${res.status} lors de la lecture de la musique : ${song.title}`)
|
||||
try { await res.body?.cancel() } catch(e) {}
|
||||
return null
|
||||
}
|
||||
// Flux Node (et non flux web) : le player suit la fin réelle du flux
|
||||
const stream = Readable.fromWeb(res.body)
|
||||
if(signal?.aborted) {
|
||||
stream.destroy()
|
||||
return null
|
||||
}
|
||||
return stream
|
||||
} catch(e) {
|
||||
if(signal?.aborted) return null
|
||||
clog.error("Erreur lors de la lecture de la musique : " + song.title)
|
||||
clog.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = {getStream}
|
||||
module.exports = {getStream, seekable}
|
||||
@@ -1,25 +1,43 @@
|
||||
const {createAudioResource, VoiceConnectionStatus, createAudioPlayer, StreamType} = require('@discordjs/voice');
|
||||
const {LogType} = require('loguix')
|
||||
const clog = new LogType("Soundcloud-Stream")
|
||||
const {Soundcloud} = require('soundcloud.ts')
|
||||
const ffmpeg = require('fluent-ffmpeg')
|
||||
const { isAllowedUrl } = require('../SongCheck')
|
||||
|
||||
const soundcloud = new Soundcloud();
|
||||
|
||||
async function getStream(song) {
|
||||
// Le flux SoundCloud est lu depuis le début : le seek n'est pas supporté (le player le refuse)
|
||||
const seekable = false
|
||||
|
||||
/**
|
||||
* @param {Object} song
|
||||
* @param {number} seekSec - ignoré (seek non supporté)
|
||||
* @param {{signal?: AbortSignal}} options
|
||||
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
|
||||
*/
|
||||
async function getStream(song, seekSec = 0, { signal } = {}) {
|
||||
if(signal?.aborted) return null
|
||||
if(!song || !isAllowedUrl("soundcloud", song.url)) {
|
||||
clog.error("URL SoundCloud refusée : " + JSON.stringify(song?.url))
|
||||
return null
|
||||
}
|
||||
try {
|
||||
|
||||
var stream = await soundcloud.util.streamTrack(song.url)
|
||||
const stream = await soundcloud.util.streamTrack(song.url)
|
||||
if(!stream) {
|
||||
clog.error("Aucun flux renvoyé par SoundCloud pour : " + song.title)
|
||||
return null
|
||||
}
|
||||
// Chargement remplacé entre-temps (skip, leave...) : on libère le flux
|
||||
if(signal?.aborted) {
|
||||
stream.destroy?.()
|
||||
return null
|
||||
}
|
||||
signal?.addEventListener('abort', () => stream.destroy?.(), { once: true })
|
||||
return stream
|
||||
|
||||
|
||||
|
||||
} catch(e) {
|
||||
clog.error("Erreur lors de la récupération du stream : " + song.title)
|
||||
clog.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = {getStream}
|
||||
module.exports = {getStream, seekable}
|
||||
+421
-171
@@ -1,228 +1,478 @@
|
||||
const { LogType } = require('loguix');
|
||||
const clog = new LogType("Youtube-Stream");
|
||||
const { spawn, exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const { PassThrough } = require('stream');
|
||||
const { __glob } = require('../../utils/GlobalVars');
|
||||
const warp = require('../../utils/WarpProxy');
|
||||
const { cookies, settings, ytdlp, errors, diagnostics, plan } = require('../../youtube');
|
||||
|
||||
// Erreurs liées à l'IP / la session : une autre route a des chances de passer
|
||||
const RETRYABLE_ERRORS = /HTTP Error 403|403: Forbidden|HTTP Error 429|Sign in to confirm/i;
|
||||
// Délai max avant de recevoir les premières données audio
|
||||
const FIRST_DATA_TIMEOUT = 30 * 1000;
|
||||
|
||||
// Variable globale pour stocker le processus actif
|
||||
let currentYtProcess = null;
|
||||
// Incrémenté à chaque appel de getStream, permet de savoir si un appel a été remplacé par un plus récent
|
||||
let generation = 0;
|
||||
// Opus d'abord (pas de transcodage côté Discord), puis le meilleur audio, puis n'importe quoi
|
||||
const FORMAT = "bestaudio[acodec=opus]/bestaudio/best";
|
||||
// Délai max avant de recevoir les premières données audio (par tentative)
|
||||
const FIRST_DATA_TIMEOUT = 20 * 1000;
|
||||
// Seek natif : ffmpeg doit décoder tout le début avant de produire du son (marge par seconde sautée)
|
||||
const NATIVE_SEEK_MS_PER_SECOND = 50;
|
||||
// Plus de nouvelle tentative au-delà de ce délai total
|
||||
const GLOBAL_BUDGET = 60 * 1000;
|
||||
const MAX_ATTEMPTS = 4;
|
||||
// Tampon entre yt-dlp et le player (~1 Mo, soit plusieurs dizaines de secondes d'audio) : absorbe les coupures réseau
|
||||
const STREAM_BUFFER = 1 << 20;
|
||||
// Lignes d'erreur / avertissement gardées par processus (classement + logs)
|
||||
const MAX_STDERR_LINES = 40;
|
||||
// Échecs liés au contenu : la route n'y est pour rien (pas de revérification de WARP)
|
||||
const CONTENT_CLASSES = new Set(["PRIVATE", "UNAVAILABLE", "AGE_RESTRICTED", "LOGIN_REQUIRED", "MEMBERS_ONLY", "BINARY_MISSING"]);
|
||||
|
||||
/**
|
||||
* Tue un processus yt-dlp et ses enfants (ffmpeg)
|
||||
* Processus yt-dlp actifs par guilde (pour l'administration).
|
||||
* Plus aucun état global partagé entre guildes : chaque appel de getStream est indépendant.
|
||||
* @type {Map<string, {pid: number, route: "warp"|"direct", identity: "anon"|"cookies", songId: string|null, startedAt: number}>}
|
||||
*/
|
||||
const activeProcesses = new Map();
|
||||
|
||||
let binaryMissingLogged = false;
|
||||
|
||||
/**
|
||||
* Tue un processus yt-dlp et ses enfants (ffmpeg), puis attend sa fin réelle (2 s max)
|
||||
*/
|
||||
function killProcess(proc) {
|
||||
return new Promise((resolve) => {
|
||||
if (!proc || proc.exitCode !== null) return resolve();
|
||||
|
||||
const pid = proc.pid;
|
||||
clog.log(`[YT-DLP] Nettoyage violent du processus PID: ${pid}`);
|
||||
|
||||
// Détection de l'OS pour utiliser la bonne commande de kill
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
// Sur Windows, taskkill /T (Tree) /F (Force) est nécessaire pour tuer les enfants (ffmpeg)
|
||||
try {
|
||||
exec(`taskkill /pid ${pid} /T /F`, () => {
|
||||
// Peu importe l'erreur (ex: processus déjà mort), on considère que c'est fini
|
||||
resolve();
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback si taskkill échoue
|
||||
try { proc.kill('SIGKILL'); } catch (e2) {}
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
// Sur Linux/Mac, on tente de tuer le groupe de processus
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch (e) {
|
||||
// Fallback si le group kill échoue
|
||||
try { proc.kill('SIGKILL'); } catch (e2) {}
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tue le processus yt-dlp en cours proprement et attend sa fin réelle.
|
||||
* Cela garantit qu'aucun flux ne se chevauche.
|
||||
*/
|
||||
async function killCurrentProcess() {
|
||||
const proc = currentYtProcess;
|
||||
currentYtProcess = null;
|
||||
await killProcess(proc);
|
||||
return ytdlp.killTree(proc);
|
||||
}
|
||||
|
||||
function routeLabel(proxy) {
|
||||
return proxy ? `WARP (${proxy})` : "directe";
|
||||
return proxy ? "WARP" : "directe";
|
||||
}
|
||||
|
||||
function attemptLabel(attempt) {
|
||||
const parts = [`route ${routeLabel(attempt.proxy)}`, attempt.identity === "cookies" ? "avec compte" : "sans compte"];
|
||||
if (attempt.clients) parts.push(`clients ${attempt.clients}`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance yt-dlp sur une route donnée et attend les premières données.
|
||||
* Résout { stream } en cas de succès, ou { stream: null, reason, retryable } si la route a échoué avant le début du flux.
|
||||
* Refuse toute URL qui ne commence pas par http(s):// (une valeur comme "--exec=..." serait lue comme une option)
|
||||
*/
|
||||
function runAttempt(song, seekTime, proxy) {
|
||||
return new Promise((resolve, reject) => {
|
||||
clog.log(`[YT-DLP] Lancement pour : ${song.url} (Début: ${seekTime}s, Route: ${routeLabel(proxy)})`);
|
||||
function isSafeUrl(url) {
|
||||
return typeof url === 'string' && /^https?:\/\/[^\s]+$/i.test(url);
|
||||
}
|
||||
|
||||
const ytArgs = [
|
||||
song.url,
|
||||
/**
|
||||
* Lance une tentative (yt-dlp, + ffmpeg pour le seek natif) et attend les premières données.
|
||||
* Résout { stream } en cas de succès, ou { stream: null, cls, message, aborted? } si la tentative a échoué avant le début du flux.
|
||||
* @param {Object} song
|
||||
* @param {number} seekTime - secondes entières
|
||||
* @param {{proxy: string|null, identity: "anon"|"cookies", clients: string, seek: "native"|"sections"|null}} attempt
|
||||
* @param {{signal?: AbortSignal, guildId?: string}} options
|
||||
*/
|
||||
async function runAttempt(song, seekTime, attempt, { signal, guildId } = {}) {
|
||||
if (signal?.aborted) return { stream: null, aborted: true, cls: null, message: "annulé" };
|
||||
|
||||
// Copie privée des cookies : yt-dlp réécrit son fichier en sortant
|
||||
let copy = null;
|
||||
if (attempt.identity === "cookies") {
|
||||
try {
|
||||
copy = await cookies.acquireCopy();
|
||||
} catch (e) {
|
||||
return { stream: null, cls: "UNKNOWN", message: `Copie des cookies impossible : ${e?.message || e}` };
|
||||
}
|
||||
if (!copy) return { stream: null, cls: "UNKNOWN", message: "Aucun cookie disponible" };
|
||||
if (signal?.aborted) {
|
||||
copy.release();
|
||||
return { stream: null, aborted: true, cls: null, message: "annulé" };
|
||||
}
|
||||
}
|
||||
|
||||
let ffmpegPath = null;
|
||||
if (attempt.seek === "native") {
|
||||
const ffmpeg = ytdlp.resolveFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
copy?.release();
|
||||
return { stream: null, cls: "BINARY_MISSING", message: "ffmpeg introuvable : impossible de reprendre en cours de morceau" };
|
||||
}
|
||||
ffmpegPath = ffmpeg.path;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = [
|
||||
...ytdlp.baseArgs({ proxy: attempt.proxy, cookiesPath: copy?.path, playerClients: attempt.clients }),
|
||||
'-f', FORMAT,
|
||||
'--buffer-size', '1M',
|
||||
'-o', '-',
|
||||
|
||||
// Sélecteur large : Audio pur OU Vidéo (ffmpeg se débrouillera)
|
||||
'-f', 'bestaudio/best',
|
||||
'--buffer-size', '16K',
|
||||
'--js-runtimes', 'node',
|
||||
];
|
||||
// Seek par ffmpeg côté yt-dlp (longs sauts uniquement : lecture non bornée, plus exposée aux 403)
|
||||
if (attempt.seek === "sections") args.push('--download-sections', `*${seekTime}-inf`);
|
||||
// L'URL vient en dernier, après "--" : elle ne peut jamais être interprétée comme une option
|
||||
args.push('--', song.url);
|
||||
|
||||
// --- GESTION DU TIMECODE (SEEK) ---
|
||||
if (seekTime && seekTime > 0) {
|
||||
ytArgs.push('--download-sections', `*${Math.round(seekTime)}-inf`);
|
||||
}
|
||||
|
||||
// --- GESTION DES COOKIES ---
|
||||
if (__glob.COOKIES && fs.existsSync(__glob.COOKIES)) {
|
||||
ytArgs.push('--cookies', __glob.COOKIES);
|
||||
ytArgs.push('--no-cache-dir');
|
||||
}
|
||||
|
||||
// --- GESTION DU PROXY WARP ---
|
||||
if (proxy) {
|
||||
ytArgs.push('--proxy', proxy);
|
||||
}
|
||||
|
||||
// Lancement du nouveau processus
|
||||
const yt = spawn('yt-dlp', ytArgs);
|
||||
|
||||
// On met à jour la variable globale tout de suite
|
||||
currentYtProcess = yt;
|
||||
|
||||
// Flux renvoyé au player : il ne reçoit rien tant qu'on ne sait pas si la route fonctionne
|
||||
const out = new PassThrough();
|
||||
let errorLogs = "";
|
||||
const route = attempt.proxy ? "warp" : "direct";
|
||||
const out = new PassThrough({ highWaterMark: STREAM_BUFFER });
|
||||
const important = [];
|
||||
let settled = false;
|
||||
// Arrêt volontaire (skip, seek, leave, échec de tentative) : la sortie n'est pas une anomalie
|
||||
let killedByUs = false;
|
||||
let ytClosed = false;
|
||||
let ytCode = null;
|
||||
let ffClosed = !ffmpegPath;
|
||||
let ytStarted = false;
|
||||
let timer = null;
|
||||
let yt;
|
||||
let ff = null;
|
||||
|
||||
const fail = (reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
yt.stdout.unpipe(out);
|
||||
out.destroy();
|
||||
if (currentYtProcess === yt) currentYtProcess = null;
|
||||
killProcess(yt);
|
||||
resolve({
|
||||
stream: null,
|
||||
reason,
|
||||
// Via WARP, tout échec justifie un essai en direct ; en direct, seulement les erreurs d'IP / session
|
||||
retryable: Boolean(proxy) || RETRYABLE_ERRORS.test(errorLogs)
|
||||
});
|
||||
const releaseCopy = () => copy?.release();
|
||||
// Seek natif : yt-dlp a tout envoyé sans erreur et ffmpeg n'a rien produit
|
||||
const failPastEnd = () => fail("EMPTY_DOWNLOAD", `position de départ (${seekTime}s) au-delà de la fin du morceau`, { final: true });
|
||||
|
||||
const kill = () => {
|
||||
if (killedByUs) return;
|
||||
killedByUs = true;
|
||||
if (yt) ytdlp.killTree(yt);
|
||||
if (ff) ytdlp.killTree(ff);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => fail(`aucune donnée après ${FIRST_DATA_TIMEOUT / 1000}s`), FIRST_DATA_TIMEOUT);
|
||||
const cleanupListener = () => {
|
||||
if (ytClosed && ffClosed) signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
yt.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
console.log(msg);
|
||||
if (!msg.includes('[download]') && !msg.includes('[youtube]')) {
|
||||
errorLogs += msg;
|
||||
const fail = (cls, message, extra = {}) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
(ff ? ff.stdout : yt?.stdout)?.unpipe(out);
|
||||
out.destroy();
|
||||
kill();
|
||||
resolve({ stream: null, cls, message, ...extra });
|
||||
};
|
||||
|
||||
const succeed = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stream: out, route });
|
||||
};
|
||||
|
||||
// Annulation par le player (nouveau chargement, seek, leave) : avant ou après le début du flux
|
||||
const onAbort = () => {
|
||||
if (!settled) {
|
||||
fail(null, "annulé", { aborted: true });
|
||||
} else {
|
||||
kill();
|
||||
out.destroy();
|
||||
}
|
||||
// On n'attend pas la fin du processus pour changer de route
|
||||
if (!settled && RETRYABLE_ERRORS.test(errorLogs)) {
|
||||
fail(errorLogs.trim().split('\n').pop());
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
// --- Analyse de stderr ligne par ligne (les erreurs [youtube] ne sont plus perdues) ---
|
||||
const handleLine = (raw) => {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
if (/cookies are no longer valid/i.test(line) && attempt.identity === "cookies"
|
||||
&& copy?.importedAt === cookies.getStatus().importedAt) {
|
||||
cookies.markInvalid("YouTube a refusé les cookies pendant la lecture (rotation)");
|
||||
}
|
||||
// Informations et progression ignorées
|
||||
if (!/^(ERROR|WARNING):/i.test(line) && !/HTTP Error|Got error|Giving up/i.test(line)) return;
|
||||
important.push(errors.sanitize(line));
|
||||
if (important.length > MAX_STDERR_LINES) important.shift();
|
||||
// Erreur avant toute donnée : inutile d'attendre la fin des réessais de yt-dlp
|
||||
if (!settled && (/^ERROR:/i.test(line) || (!/^WARNING:/i.test(line) && /HTTP Error 403/i.test(line)))) {
|
||||
const c = errors.classify(important);
|
||||
fail(c.cls, c.message);
|
||||
}
|
||||
};
|
||||
const lineReader = (onLine) => {
|
||||
let partial = "";
|
||||
return {
|
||||
push: (chunk) => {
|
||||
partial += chunk.toString();
|
||||
const lines = partial.split(/\r\n|\n|\r/);
|
||||
partial = lines.pop();
|
||||
if (partial.length > 16384) partial = partial.slice(-16384);
|
||||
for (const l of lines) onLine(l);
|
||||
},
|
||||
flush: () => {
|
||||
if (partial) onLine(partial);
|
||||
partial = "";
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// --- yt-dlp ---
|
||||
try {
|
||||
yt = ytdlp.spawnYtdlp(args);
|
||||
} catch (e) {
|
||||
releaseCopy();
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
settled = true;
|
||||
return resolve({ stream: null, cls: e?.code === 'ENOENT' ? "BINARY_MISSING" : "UNKNOWN", message: `Lancement de yt-dlp impossible : ${e?.message || e}` });
|
||||
}
|
||||
yt.stdin?.end();
|
||||
if (guildId && yt.pid) {
|
||||
activeProcesses.set(guildId, { pid: yt.pid, route, identity: attempt.identity, songId: song.id ?? null, startedAt: Date.now() });
|
||||
}
|
||||
const forgetProcess = () => {
|
||||
if (guildId && activeProcesses.get(guildId)?.pid === yt.pid) activeProcesses.delete(guildId);
|
||||
};
|
||||
|
||||
const ytLines = lineReader(handleLine);
|
||||
yt.stderr.on('data', ytLines.push);
|
||||
yt.stderr.on('end', ytLines.flush);
|
||||
|
||||
// --- ffmpeg (seek natif) : yt-dlp télécharge depuis le début, ffmpeg décode et jette jusqu'à la position ---
|
||||
let ffErrors = [];
|
||||
if (ffmpegPath) {
|
||||
try {
|
||||
ff = ytdlp.spawnTracked(ffmpegPath, [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-i', 'pipe:0',
|
||||
// -ss APRÈS -i : décodage puis abandon jusqu'à la position (précis, sans requête HTTP)
|
||||
'-ss', String(seekTime),
|
||||
'-vn', '-f', 's16le', '-ar', '48000', '-ac', '2',
|
||||
'pipe:1'
|
||||
]);
|
||||
} catch (e) {
|
||||
ff = null;
|
||||
ffClosed = true;
|
||||
// yt-dlp est déjà lancé : copie des cookies et suivi libérés à sa fin
|
||||
const cleanup = () => { ytClosed = true; releaseCopy(); forgetProcess(); cleanupListener(); };
|
||||
yt.once('close', cleanup);
|
||||
yt.once('error', cleanup);
|
||||
fail("BINARY_MISSING", `Lancement de ffmpeg impossible : ${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
// Le player lit du PCM brut (StreamType.Raw) : pas de second ffmpeg côté @discordjs/voice
|
||||
out.streamType = "raw";
|
||||
const ffLines = lineReader((l) => {
|
||||
const line = l.trim();
|
||||
if (!line) return;
|
||||
ffErrors.push(errors.sanitize(line));
|
||||
if (ffErrors.length > 10) ffErrors.shift();
|
||||
});
|
||||
ff.stderr.on('data', ffLines.push);
|
||||
ff.stderr.on('end', ffLines.flush);
|
||||
// ffmpeg tué ou fermé : l'écriture vers son entrée échoue (EPIPE), sans conséquence
|
||||
ff.stdin.on('error', () => {});
|
||||
yt.stdout.pipe(ff.stdin);
|
||||
|
||||
ff.on('error', (err) => {
|
||||
ffClosed = true;
|
||||
cleanupListener();
|
||||
if (!settled) {
|
||||
fail(err.code === 'ENOENT' ? "BINARY_MISSING" : "UNKNOWN", err.code === 'ENOENT' ? "ffmpeg introuvable" : `Erreur ffmpeg : ${err.message}`);
|
||||
} else {
|
||||
out.abnormalEnd = true;
|
||||
out.destroy();
|
||||
kill();
|
||||
}
|
||||
});
|
||||
ff.on('close', (code) => {
|
||||
ffClosed = true;
|
||||
cleanupListener();
|
||||
if (!settled) {
|
||||
const detail = ffErrors.length ? ffErrors[ffErrors.length - 1] : "";
|
||||
// yt-dlp a tout envoyé sans erreur et ffmpeg n'a rien produit : la position est après la fin du morceau
|
||||
// (si yt-dlp n'a pas encore fini, sa fermeture tranchera)
|
||||
if (code === 0 && !ytClosed) return;
|
||||
if (code === 0 && ytCode === 0) failPastEnd();
|
||||
else if (code === 0) fail("EMPTY_DOWNLOAD", `ffmpeg n'a produit aucun son${detail ? ` : ${detail}` : ''}`);
|
||||
else fail("UNKNOWN", `ffmpeg arrêté (code ${code})${detail ? ` : ${detail}` : ''}`);
|
||||
return;
|
||||
}
|
||||
if (!killedByUs && code !== 0) {
|
||||
out.abnormalEnd = true;
|
||||
out.exitCode = code;
|
||||
clog.warn(`[FFMPEG] Arrêt code ${code} en cours de lecture${ffErrors.length ? ` : ${ffErrors[ffErrors.length - 1]}` : ''}`);
|
||||
}
|
||||
});
|
||||
ff.stdout.on('error', () => kill());
|
||||
ff.stdout.once('data', succeed);
|
||||
ff.stdout.pipe(out);
|
||||
} else {
|
||||
yt.stdout.once('data', succeed);
|
||||
yt.stdout.pipe(out);
|
||||
}
|
||||
|
||||
// Délai pour les premières données : yt-dlp d'abord, puis (seek natif) le décodage par ffmpeg
|
||||
timer = setTimeout(() => fail("TIMEOUT", `aucune donnée après ${FIRST_DATA_TIMEOUT / 1000}s`), FIRST_DATA_TIMEOUT);
|
||||
yt.stdout.once('data', () => {
|
||||
ytStarted = true;
|
||||
if (!ffmpegPath || settled) return;
|
||||
clearTimeout(timer);
|
||||
const decodeMs = FIRST_DATA_TIMEOUT + seekTime * NATIVE_SEEK_MS_PER_SECOND;
|
||||
timer = setTimeout(() => fail("TIMEOUT", `aucun son après ${Math.round(decodeMs / 1000)}s de décodage (seek ${seekTime}s)`), decodeMs);
|
||||
});
|
||||
|
||||
yt.on('error', (err) => {
|
||||
clog.error("[YT-DLP] Erreur au lancement.", err);
|
||||
// Si c'est ce processus qui est en cours, on le clean
|
||||
if (currentYtProcess === yt) currentYtProcess = null;
|
||||
clearTimeout(timeout);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
ytClosed = true;
|
||||
releaseCopy();
|
||||
forgetProcess();
|
||||
cleanupListener();
|
||||
if (err.code === 'ENOENT') {
|
||||
// Binaire installé entre-temps : on le recherchera au prochain lancement
|
||||
ytdlp.resolveBinary({ force: true });
|
||||
fail("BINARY_MISSING", "yt-dlp introuvable (installez yt-dlp ou définissez YTDLP_PATH)");
|
||||
} else {
|
||||
fail("UNKNOWN", `Lancement de yt-dlp impossible : ${err.message}`);
|
||||
}
|
||||
if (ff) ytdlp.killTree(ff);
|
||||
});
|
||||
|
||||
yt.on('close', (code) => {
|
||||
// Nettoyage de la référence globale si c'est bien nous
|
||||
if (currentYtProcess === yt) currentYtProcess = null;
|
||||
ytClosed = true;
|
||||
ytCode = code;
|
||||
// yt-dlp a fini d'écrire sa copie des cookies : on peut la supprimer
|
||||
releaseCopy();
|
||||
forgetProcess();
|
||||
cleanupListener();
|
||||
|
||||
if (code !== 0 && code !== null && code !== 143 && code !== 137) { // 137 = SIGKILL
|
||||
clog.warn(`[YT-DLP] Arrêt code ${code}. Logs : ${errorLogs}`);
|
||||
if (!settled) {
|
||||
// Seek natif : yt-dlp peut avoir tout téléchargé avant que ffmpeg n'atteigne la position
|
||||
if (ffmpegPath && code === 0 && ytStarted) {
|
||||
if (ffClosed && ff?.exitCode === 0) failPastEnd();
|
||||
return;
|
||||
}
|
||||
const c = errors.classify(important);
|
||||
if (c.cls === "UNKNOWN" && code === 0) fail("EMPTY_DOWNLOAD", "yt-dlp s'est arrêté sans envoyer de données");
|
||||
else fail(c.cls, c.cls === "UNKNOWN" ? `arrêt code ${code} avant toute donnée${important.length ? ` : ${c.message}` : ''}` : c.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Le flux avait commencé : une sortie en erreur (403 en cours de route, coupure...) n'est pas une fin de morceau.
|
||||
// Le player lit ce marqueur à la fin de la ressource pour reprendre depuis la position courante.
|
||||
if (!killedByUs && code !== 0) {
|
||||
out.abnormalEnd = true;
|
||||
out.exitCode = code;
|
||||
const c = errors.classify(important);
|
||||
clog.warn(`[YT-DLP] Arrêt code ${code} en cours de lecture (${attemptLabel(attempt)}) : ${c.cls} - ${c.message}`);
|
||||
}
|
||||
fail(`arrêt code ${code} avant toute donnée`);
|
||||
});
|
||||
|
||||
// --- SÉCURITÉ ANTI-OVERRIDE SUR LE FLUX ---
|
||||
// Si le stream se ferme (le bot quitte le vocal ou skip), on tue yt-dlp
|
||||
// Si le flux se ferme (le bot quitte le vocal, skip, seek), on tue yt-dlp, ffmpeg et leurs enfants
|
||||
out.on('close', () => {
|
||||
if (yt.exitCode === null && !yt.killed) yt.kill();
|
||||
// Fin naturelle du tube : les processus sortent d'eux-mêmes. Les tuer ici masquerait
|
||||
// une sortie en erreur (abnormalEnd) et le player passerait au morceau suivant au lieu de reprendre.
|
||||
if (out.readableEnded) return;
|
||||
const ytAlive = yt.exitCode === null && yt.signalCode === null;
|
||||
const ffAlive = ff && ff.exitCode === null && ff.signalCode === null;
|
||||
if (ytAlive || ffAlive) kill();
|
||||
});
|
||||
|
||||
yt.stdout.on('error', () => {
|
||||
if (!yt.killed) yt.kill();
|
||||
});
|
||||
|
||||
yt.stdout.once('data', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({ stream: out });
|
||||
});
|
||||
|
||||
yt.stdout.pipe(out);
|
||||
yt.stdout.on('error', () => kill());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} song - L'objet contenant l'URL
|
||||
* @param {number} seekTime - Le temps de démarrage en secondes (par défaut 0)
|
||||
* @param {{signal?: AbortSignal, onProgress?: function({route: string, attempt: number, identity: string}), guildId?: string}} options
|
||||
* @returns {Promise<import('stream').Readable|null>} le flux (streamType "raw" = PCM s16le 48 kHz stéréo), ou null en cas d'échec / d'annulation
|
||||
*/
|
||||
async function getStream(song, seekTime = 0) {
|
||||
// Incrémenté avant le kill pour que l'appel précédent sache tout de suite qu'il est remplacé
|
||||
const myGeneration = ++generation;
|
||||
async function getStream(song, seekTime = 0, { signal, onProgress, guildId } = {}) {
|
||||
if (!song || !isSafeUrl(song.url)) {
|
||||
clog.error(`[YT-DLP] URL refusée : ${JSON.stringify(song?.url)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ÉTAPE 1 : On s'assure que l'ancien processus est BIEN mort avant de faire quoi que ce soit.
|
||||
await killCurrentProcess();
|
||||
|
||||
// ÉTAPE 2 : On essaie chaque route (WARP puis directe, selon l'état du proxy)
|
||||
const seek = Number.isFinite(Number(seekTime)) && Number(seekTime) > 0 ? Math.floor(Number(seekTime)) : 0;
|
||||
const s = settings.get();
|
||||
// Routes figées pour toute la lecture (WARP puis directe, selon l'état du proxy)
|
||||
const routes = warp.getRoutes();
|
||||
const seekMode = plan.seekModeFor(s.seekStrategy, seek);
|
||||
const started = Date.now();
|
||||
const history = [];
|
||||
let last = null;
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
// Un nouvel appel (skip, seek...) a pris la main : on n'envoie rien au player
|
||||
if (myGeneration !== generation) return null;
|
||||
|
||||
const proxy = routes[i];
|
||||
const result = await runAttempt(song, seekTime, proxy);
|
||||
|
||||
if (result.stream) return result.stream;
|
||||
if (myGeneration !== generation) return null;
|
||||
|
||||
if (proxy) warp.reportFailure();
|
||||
|
||||
const next = routes[i + 1];
|
||||
if (!result.retryable || next === undefined) {
|
||||
clog.error(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason}`);
|
||||
while (true) {
|
||||
// Un nouvel appel (skip, seek, leave...) a pris la main : on n'envoie rien au player
|
||||
if (signal?.aborted) return null;
|
||||
if (history.length > 0 && Date.now() - started > GLOBAL_BUDGET) {
|
||||
clog.warn(`[YT-DLP] Délai global de ${GLOBAL_BUDGET / 1000}s dépassé, abandon`);
|
||||
break;
|
||||
}
|
||||
clog.warn(`[YT-DLP] Échec via la route ${routeLabel(proxy)} : ${result.reason} - Nouvelle tentative via la route ${routeLabel(next)}`);
|
||||
|
||||
const attempt = plan.planNext({
|
||||
routes,
|
||||
mode: s.cookiesMode,
|
||||
cookiesValid: cookies.isValid(),
|
||||
cookiesOnProxy: s.cookiesOnProxy,
|
||||
clients: s.playerClients,
|
||||
fallbackClients: s.fallbackPlayerClients,
|
||||
seekMode,
|
||||
history,
|
||||
maxAttempts: MAX_ATTEMPTS
|
||||
});
|
||||
if (!attempt) break;
|
||||
|
||||
const n = history.length + 1;
|
||||
const route = attempt.proxy ? "warp" : "direct";
|
||||
try {
|
||||
onProgress?.({ route, attempt: n, identity: attempt.identity });
|
||||
} catch (e) {
|
||||
clog.warn(`[YT-DLP] Erreur dans onProgress : ${e?.message || e}`);
|
||||
}
|
||||
const seekLabel = attempt.seek ? `, début ${seek}s (${attempt.seek === "native" ? "natif" : "ffmpeg"})` : "";
|
||||
clog.log(`[YT-DLP] Tentative ${n} : ${song.url} (${attemptLabel(attempt)}${seekLabel}${n > 1 ? `, ${attempt.reason}` : ""}${guildId ? `, guilde ${guildId}` : ''})`);
|
||||
|
||||
const t0 = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await runAttempt(song, seek, attempt, { signal, guildId });
|
||||
} catch (e) {
|
||||
result = { stream: null, cls: "UNKNOWN", message: e?.message || String(e) };
|
||||
}
|
||||
const entry = {
|
||||
guildId: guildId ?? null,
|
||||
songId: song.id ?? null,
|
||||
title: song.title ?? null,
|
||||
route,
|
||||
identity: attempt.identity,
|
||||
clients: attempt.clients,
|
||||
seek: attempt.seek,
|
||||
ms: Date.now() - t0
|
||||
};
|
||||
|
||||
if (result.stream) {
|
||||
// Annulé pendant la dernière étape : on ne renvoie jamais un flux périmé
|
||||
if (signal?.aborted) {
|
||||
result.stream.destroy();
|
||||
diagnostics.record({ ...entry, outcome: "aborted" });
|
||||
return null;
|
||||
}
|
||||
diagnostics.record({ ...entry, outcome: "ok" });
|
||||
binaryMissingLogged = false;
|
||||
if (n > 1) clog.log(`[YT-DLP] Flux obtenu à la tentative ${n} (${attemptLabel(attempt)})`);
|
||||
return result.stream;
|
||||
}
|
||||
if (result.aborted || signal?.aborted) {
|
||||
diagnostics.record({ ...entry, outcome: "aborted" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flux vide : le player passe en Idle et enchaîne sur la musique suivante
|
||||
const empty = new PassThrough();
|
||||
empty.end();
|
||||
return empty;
|
||||
const cls = result.cls || "UNKNOWN";
|
||||
diagnostics.record({ ...entry, outcome: "failed", cls, message: result.message });
|
||||
last = { cls, message: result.message };
|
||||
// Échec qui ne dépend ni de la route ni du client (seek après la fin) : inutile de réessayer
|
||||
if (result.final) {
|
||||
history.push({ ...attempt, cls });
|
||||
break;
|
||||
}
|
||||
|
||||
module.exports = { getStream };
|
||||
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, activeProcesses, killProcess };
|
||||
+894
-254
File diff suppressed because it is too large.
Load diff
+11
-5
@@ -3,6 +3,7 @@ const {LogType} = require('loguix')
|
||||
const clog = new LogType("Song")
|
||||
const MediaInformation = require('../media/MediaInformation')
|
||||
const { getReadableDuration, getSecondsDuration } = require('../utils/TimeConverter');
|
||||
const { toSafeDuration } = require('../utils/SafeMetric');
|
||||
|
||||
class Song {
|
||||
title = "Aucun titre";
|
||||
@@ -11,7 +12,8 @@ class Song {
|
||||
authorId;
|
||||
url;
|
||||
thumbnail = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png" ;
|
||||
duration;
|
||||
// Durée en secondes : toujours un nombre, 0 = live ou inconnue
|
||||
duration = 0;
|
||||
readduration;
|
||||
form = "SONG";
|
||||
type;
|
||||
@@ -25,9 +27,10 @@ class Song {
|
||||
this.author = properties.author ?? this.author
|
||||
this.url = properties.url ?? this.url
|
||||
this.thumbnail = properties.thumbnail ?? this.thumbnail
|
||||
this.duration = properties.duration ?? this.duration
|
||||
this.readduration = properties.readduration ?? this.readduration
|
||||
this.type = properties.type ?? this.type
|
||||
// Durée normalisée (chaîne, NaN, négatif ou aberrant => 0) : ne fausse plus les statistiques
|
||||
this.duration = toSafeDuration(properties.duration)
|
||||
// Jamais de confiance dans le readduration reçu : recalculé depuis la durée normalisée
|
||||
this.readduration = getReadableDuration(this.duration)
|
||||
this.authorId = properties.authorId ?? this.authorId
|
||||
|
||||
}
|
||||
@@ -44,6 +47,8 @@ class Song {
|
||||
|
||||
// In face, duration is null, get the metadata of the file to get the duration
|
||||
await MediaInformation.getMediaInformation(this, media)
|
||||
this.duration = toSafeDuration(this.duration)
|
||||
this.readduration = getReadableDuration(this.duration)
|
||||
|
||||
} else {
|
||||
clog.error("Impossible de traiter le média")
|
||||
@@ -60,7 +65,8 @@ class Song {
|
||||
this.type = "youtube"
|
||||
this.id = video.videoId
|
||||
|
||||
this.duration = video.duration.seconds
|
||||
// 0 pour les lives (affichés en LIVE côté client)
|
||||
this.duration = toSafeDuration(video?.duration?.seconds)
|
||||
this.readduration = getReadableDuration(this.duration)
|
||||
return this
|
||||
}
|
||||
|
||||
+91
-25
@@ -2,43 +2,109 @@ const {LogType} = require("loguix")
|
||||
const {Song} = require("./Song")
|
||||
const slog = new LogType("SongCheck")
|
||||
|
||||
// Types de sources que le player sait lire
|
||||
const KNOWN_TYPES = ["youtube", "soundcloud", "attachment"]
|
||||
|
||||
// Hôtes autorisés par type : le serveur ne contacte jamais une URL arbitraire fournie par un client (SSRF)
|
||||
const ALLOWED_HOSTS = {
|
||||
youtube: (host) => host === "youtu.be" || host === "youtube.com" || host.endsWith(".youtube.com"),
|
||||
soundcloud: (host) => host === "soundcloud.com" || host.endsWith(".soundcloud.com"),
|
||||
attachment: (host) => host === "cdn.discordapp.com" || host === "media.discordapp.net",
|
||||
}
|
||||
|
||||
// Bornes des champs texte (un titre de 100 Mo bloquerait chaque sauvegarde)
|
||||
const MAX_TEXT = 200
|
||||
const MAX_URL = 2000
|
||||
|
||||
/**
|
||||
* Vérifie qu'une URL est en http(s) et pointe vers un hôte autorisé pour le type donné
|
||||
* @param {string} type
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAllowedUrl(type, url) {
|
||||
if(typeof url !== "string" || url.length > MAX_URL) return false
|
||||
if(!/^https?:\/\//i.test(url)) return false
|
||||
const check = ALLOWED_HOSTS[type]
|
||||
if(!check) return false
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if(parsed.protocol !== "http:" && parsed.protocol !== "https:") return false
|
||||
if(parsed.username || parsed.password) return false
|
||||
return check(parsed.hostname.toLowerCase())
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clampText(value, max) {
|
||||
if(value === undefined || value === null) return undefined
|
||||
if(typeof value !== "string" && typeof value !== "number") return undefined
|
||||
const text = String(value)
|
||||
return text.length > max ? text.slice(0, max) : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une musique peut être lue : type connu et URL autorisée pour ce type.
|
||||
* Reste tolérant pour les anciennes entrées (titre, auteur ou durée manquants : valeurs par défaut).
|
||||
* @param {Object} song
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkSong(song) {
|
||||
if(!(song instanceof Song)) {
|
||||
slog.error("La musique n'est pas une instance de la classe Song")
|
||||
// Check if the song is valid and if it has all the required properties
|
||||
if(song.title && song.id && song.author && song.url && song.duration && song.readduration && song.type) {
|
||||
slog.log("Acceptation de la musique : " + song.title)
|
||||
return true
|
||||
} else {
|
||||
if(!song || typeof song !== "object") {
|
||||
slog.error("La musique n'est pas valide")
|
||||
return false
|
||||
}
|
||||
if(!song.type || !KNOWN_TYPES.includes(song.type)) {
|
||||
slog.error("La musique n'a pas de type valide : " + JSON.stringify(song.type))
|
||||
return false
|
||||
}
|
||||
if(!song.url) {
|
||||
slog.error("La musique n'a pas d'url")
|
||||
return false
|
||||
}
|
||||
if(!song.title) {
|
||||
slog.error("La musique n'a pas de titre")
|
||||
if(!isAllowedUrl(song.type, song.url)) {
|
||||
slog.error("L'url de la musique n'est pas autorisée pour le type " + song.type + " : " + String(song.url).slice(0, 200))
|
||||
return false
|
||||
}
|
||||
if(!song.author) {
|
||||
slog.error("La musique n'a pas d'auteur")
|
||||
return false
|
||||
}
|
||||
if(song.duration == null) {
|
||||
slog.error("La musique n'a pas de durée")
|
||||
return false
|
||||
}
|
||||
if(song.readduration == null) {
|
||||
slog.error("La musique n'a pas de durée lisible")
|
||||
return false
|
||||
}
|
||||
if(!song.type) {
|
||||
slog.error("La musique n'a pas de type")
|
||||
return false
|
||||
if(!(song instanceof Song)) {
|
||||
// Objet brut (base de données, playlist, client) : accepté, il sera normalisé par sanitizeSong
|
||||
slog.log("Acceptation de la musique : " + String(song.title).slice(0, MAX_TEXT))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {checkSong}
|
||||
/**
|
||||
* Construit une instance de Song propre à partir de données non fiables (client, playlists.json, historique).
|
||||
* Durée normalisée (0 = live / inconnue), textes bornés, miniature en http(s) uniquement.
|
||||
* @param {Object|string} raw
|
||||
* @returns {Song|null} null si la musique ne peut pas être lue
|
||||
*/
|
||||
function sanitizeSong(raw) {
|
||||
let data = raw
|
||||
if(typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch(e) {
|
||||
slog.error("Musique reçue illisible (JSON invalide)")
|
||||
return null
|
||||
}
|
||||
}
|
||||
if(!data || typeof data !== "object" || Array.isArray(data)) return null
|
||||
if(!checkSong(data)) return null
|
||||
|
||||
const song = new Song({
|
||||
type: data.type,
|
||||
url: data.url,
|
||||
title: clampText(data.title, MAX_TEXT),
|
||||
id: clampText(data.id, MAX_TEXT),
|
||||
author: clampText(data.author, MAX_TEXT),
|
||||
authorId: clampText(data.authorId, MAX_URL),
|
||||
thumbnail: typeof data.thumbnail === "string" && data.thumbnail.length <= MAX_URL && /^https?:\/\//i.test(data.thumbnail) ? data.thumbnail : undefined,
|
||||
duration: data.duration,
|
||||
})
|
||||
if(typeof data.createdAt === "string") song.createdAt = data.createdAt.slice(0, 64)
|
||||
return song
|
||||
}
|
||||
|
||||
module.exports = {checkSong, sanitizeSong, isAllowedUrl, KNOWN_TYPES}
|
||||
+362
-50
@@ -3,85 +3,397 @@ const alog = new LogType("GoogleOAuth2");
|
||||
const { google } = require('googleapis');
|
||||
const config = require("../../utils/Database/Configuration");
|
||||
const Users = require('../../server/auth/User');
|
||||
const store = require('./TokenStore');
|
||||
const { createStateStore, pickTokens, bestThumbnail } = require('./Utils');
|
||||
|
||||
const clientId = config.getYoutubeApiClientId();
|
||||
const clientSecret = config.getYoutubeApiClientSecret();
|
||||
const redirectUri = config.getWebsiteLink() + "/oauth2callback";
|
||||
const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
|
||||
// Durée de validité d'une demande de connexion (state OAuth)
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
// Délai max d'un appel aux serveurs Google (échange du code, révocation, API)
|
||||
const REQUEST_TIMEOUT_MS = 20 * 1000;
|
||||
|
||||
const states = createStateStore({ ttlMs: STATE_TTL_MS, maxPerKey: 5 });
|
||||
// userId -> OAuth2Client avec les jetons enregistrés (recréé si la configuration change)
|
||||
const clients = new Map();
|
||||
// Utilisateurs dont la synchronisation est en cours
|
||||
const syncing = new Set();
|
||||
|
||||
const oAuth2Map = new Map();
|
||||
|
||||
function createAuthUrl(userId) {
|
||||
if(!checkCredientials()) return null;
|
||||
var oAuth2Client;
|
||||
const user = Users.getUserById(userId);
|
||||
if (!user) {
|
||||
alog.error(`User with ID ${userId} not found.`);
|
||||
return null;
|
||||
/**
|
||||
* Erreur typée de l'intégration Google (message en français, affichable à l'utilisateur)
|
||||
*/
|
||||
class GoogleError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "GoogleError";
|
||||
this.code = code;
|
||||
}
|
||||
if (!clientId || !clientSecret) {
|
||||
alog.error("YouTube API client ID or secret is not set in the configuration.");
|
||||
} else {
|
||||
oAuth2Client = new google.auth.OAuth2(
|
||||
}
|
||||
|
||||
// --- Configuration (lue à chaque appel : elle peut changer à chaud) ---
|
||||
|
||||
function getConfig() {
|
||||
const clientId = config.getYoutubeApiClientId() || "";
|
||||
const clientSecret = config.getYoutubeApiClientSecret() || "";
|
||||
const website = String(config.getWebsiteLink() || "").replace(/\/+$/, "");
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
);
|
||||
|
||||
alog.log("Google OAuth2 client initialized successfully.");
|
||||
redirectUri: website ? website + "/oauth2callback" : ""
|
||||
};
|
||||
}
|
||||
|
||||
if (!oAuth2Client) {
|
||||
alog.error("OAuth2 client is not initialized. Please check your configuration.");
|
||||
function isConfigured() {
|
||||
const { clientId, clientSecret, redirectUri } = getConfig();
|
||||
return !!(clientId && clientSecret && redirectUri);
|
||||
}
|
||||
|
||||
function newClient() {
|
||||
const { clientId, clientSecret, redirectUri } = getConfig();
|
||||
if (!clientId || !clientSecret || !redirectUri) return null;
|
||||
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
||||
}
|
||||
|
||||
function dropClient(userId) {
|
||||
const client = clients.get(userId);
|
||||
if (client) client.removeAllListeners("tokens");
|
||||
clients.delete(userId);
|
||||
}
|
||||
|
||||
function clearClients() {
|
||||
for (const userId of [...clients.keys()]) dropClient(userId);
|
||||
}
|
||||
|
||||
// Identifiants modifiés depuis l'administration : les clients en cache utilisent les anciens
|
||||
process.on("CONFIG_UPDATE", () => {
|
||||
if (clients.size > 0) alog.log("Configuration modifiée : réinitialisation des clients Google OAuth2");
|
||||
clearClients();
|
||||
});
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Object.assign(new Error(`Délai dépassé (${label})`), { code: "ETIMEDOUT" })), ms);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function emitUpdate(userId) {
|
||||
try {
|
||||
process.emit("GOOGLE_UPDATE", userId);
|
||||
} catch (e) {
|
||||
alog.error("Erreur lors de l'émission de GOOGLE_UPDATE : " + (e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Erreurs de l'API ---
|
||||
|
||||
/**
|
||||
* Traduit une erreur googleapis / gaxios en GoogleError (message français, jamais de jeton)
|
||||
* @param {*} error
|
||||
* @returns {GoogleError}
|
||||
*/
|
||||
function describeApiError(error) {
|
||||
if (error instanceof GoogleError) return error;
|
||||
const status = error?.response?.status ?? error?.status ?? (Number.isInteger(error?.code) ? error.code : null);
|
||||
const data = error?.response?.data;
|
||||
const oauthError = typeof data?.error === "string" ? data.error : null;
|
||||
const reason = error?.errors?.[0]?.reason || data?.error?.errors?.[0]?.reason || null;
|
||||
const message = String(error?.message || "");
|
||||
|
||||
if (oauthError === "invalid_grant" || /invalid_grant/i.test(message)) {
|
||||
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google a expiré ou a été révoquée : reconnectez votre compte YouTube");
|
||||
}
|
||||
if (oauthError === "invalid_client" || oauthError === "unauthorized_client" || /invalid_client|unauthorized_client/i.test(message)) {
|
||||
return new GoogleError("CONFIG_ERROR", "Les identifiants de l'application Google sont invalides (configuration du bot)");
|
||||
}
|
||||
if (oauthError === "redirect_uri_mismatch" || /redirect_uri_mismatch/i.test(message)) {
|
||||
return new GoogleError("CONFIG_ERROR", "L'URI de redirection n'est pas autorisée dans la console Google (attendu : <site>/oauth2callback)");
|
||||
}
|
||||
if (["quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded"].includes(reason)) {
|
||||
return new GoogleError("QUOTA", "Quota de l'API YouTube dépassé, réessayez plus tard");
|
||||
}
|
||||
if (reason === "playlistNotFound" || status === 404) {
|
||||
return new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
|
||||
}
|
||||
if (status === 401) {
|
||||
return new GoogleError("AUTH_EXPIRED", "L'autorisation Google n'est plus valide : reconnectez votre compte YouTube");
|
||||
}
|
||||
if (status === 403) {
|
||||
return new GoogleError("FORBIDDEN", "Accès refusé par l'API YouTube" + (reason ? " (" + reason + ")" : ""));
|
||||
}
|
||||
if (["ENOTFOUND", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code) || /timeout|Délai dépassé/i.test(message)) {
|
||||
return new GoogleError("NETWORK", "Impossible de joindre les serveurs Google, réessayez plus tard");
|
||||
}
|
||||
return new GoogleError("API_ERROR", "Erreur de l'API YouTube" + (status ? " (HTTP " + status + ")" : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise une erreur d'API et marque le compte comme invalide si l'autorisation est perdue
|
||||
* @param {string} userId
|
||||
* @param {*} error
|
||||
* @returns {GoogleError}
|
||||
*/
|
||||
function handleApiError(userId, error) {
|
||||
const described = describeApiError(error);
|
||||
alog.error(`Erreur Google pour l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`);
|
||||
if (described.code === "AUTH_EXPIRED") markInvalid(userId, described.message);
|
||||
return described;
|
||||
}
|
||||
|
||||
function markInvalid(userId, reason) {
|
||||
dropClient(userId);
|
||||
const changed = store.update(userId, (record) => {
|
||||
record.invalid = true;
|
||||
record.invalidReason = reason || "Autorisation invalide";
|
||||
});
|
||||
if (changed) {
|
||||
alog.warn(`Compte Google de l'utilisateur ${userId} marqué comme invalide : ${reason}`);
|
||||
emitUpdate(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Connexion du compte ---
|
||||
|
||||
/**
|
||||
* Génère l'URL de consentement Google pour l'utilisateur (state aléatoire lié à son id, valable 10 min)
|
||||
* @param {string} userId
|
||||
* @returns {string|null} URL, ou null si l'intégration n'est pas configurée
|
||||
*/
|
||||
function createAuthUrl(userId) {
|
||||
if (!checkCredientials()) return null;
|
||||
const user = Users.getUserById(userId);
|
||||
if (!user) {
|
||||
alog.error(`Utilisateur ${userId} introuvable.`);
|
||||
return null;
|
||||
}
|
||||
oAuth2Map.set(userId, oAuth2Client);
|
||||
alog.log(`OAuth2 client created for user ${userId}.`);
|
||||
return oAuth2Client.generateAuthUrl({
|
||||
const client = newClient();
|
||||
if (!client) return null;
|
||||
const state = states.create(userId);
|
||||
alog.log(`Demande de connexion Google créée pour l'utilisateur ${userId}.`);
|
||||
return client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
// Le consentement explicite garantit l'envoi d'un refresh_token à chaque liaison
|
||||
prompt: 'consent',
|
||||
include_granted_scopes: true,
|
||||
scope: SCOPES,
|
||||
state
|
||||
});
|
||||
}
|
||||
|
||||
async function getAuthorization(userId, code) {
|
||||
if(!checkCredientials()) return null;
|
||||
/**
|
||||
* Lie le compte Google de l'utilisateur à partir du code renvoyé par Google.
|
||||
* @param {string} userId
|
||||
* @param {string} code
|
||||
* @param {string} state
|
||||
* @returns {Promise<object>} statut du compte (voir getStatus)
|
||||
* @throws {GoogleError}
|
||||
*/
|
||||
async function linkAccount(userId, code, state) {
|
||||
if (!isConfigured()) throw new GoogleError("NOT_CONFIGURED", "La connexion YouTube n'est pas configurée sur ce bot");
|
||||
if (typeof code !== "string" || code.length === 0 || code.length > 2048) {
|
||||
throw new GoogleError("INVALID_PARAMS", "Code d'autorisation Google manquant ou invalide");
|
||||
}
|
||||
if (!states.consume(state, userId)) {
|
||||
throw new GoogleError("INVALID_STATE", "La demande de connexion a expiré ou n'est pas valide, veuillez réessayer");
|
||||
}
|
||||
const client = newClient();
|
||||
let tokens;
|
||||
try {
|
||||
const user = Users.getUserById(userId);
|
||||
if (!user) {
|
||||
alog.error(`User with ID ${userId} not found.`);
|
||||
return null;
|
||||
}
|
||||
oAuth2Client = oAuth2Map.get(userId);
|
||||
if (!oAuth2Client) {
|
||||
alog.error(`OAuth2 client for user ${userId} not found. Please create an OAuth2 client first.`);
|
||||
return null;
|
||||
}
|
||||
const { tokens } = await oAuth2Client.getToken(code);
|
||||
oAuth2Client.setCredentials(tokens);
|
||||
alog.log(`OAuth2 client credentials set for user ${userId}.`);
|
||||
return oAuth2Client;
|
||||
const response = await withTimeout(client.getToken(code), REQUEST_TIMEOUT_MS, "échange du code");
|
||||
tokens = pickTokens(response.tokens);
|
||||
} catch (error) {
|
||||
alog.error(`Error during OAuth2 authorization for user ${userId}:`, error);
|
||||
return null;
|
||||
const described = describeApiError(error);
|
||||
alog.error(`Échec de l'échange du code Google pour l'utilisateur ${userId} : ${error?.message || error}`);
|
||||
// Code expiré / déjà utilisé : message dédié
|
||||
if (described.code === "AUTH_EXPIRED") throw new GoogleError("TOKEN_ERROR", "Le code d'autorisation Google a expiré, veuillez réessayer");
|
||||
throw described;
|
||||
}
|
||||
if (!tokens.access_token) throw new GoogleError("TOKEN_ERROR", "Google n'a renvoyé aucun jeton d'accès");
|
||||
|
||||
const previous = store.get(userId);
|
||||
// Google ne renvoie le refresh_token qu'au premier consentement : on garde l'ancien si besoin
|
||||
if (!tokens.refresh_token && previous?.tokens?.refresh_token) tokens.refresh_token = previous.tokens.refresh_token;
|
||||
if (!tokens.refresh_token) alog.warn(`Aucun refresh_token reçu pour l'utilisateur ${userId} : la liaison expirera avec le jeton d'accès`);
|
||||
client.setCredentials(tokens);
|
||||
|
||||
// Chaîne YouTube du compte (titre affiché, playlist "J'aime")
|
||||
let channel = null;
|
||||
let likesPlaylistId = null;
|
||||
try {
|
||||
const youtube = google.youtube({ version: 'v3', auth: client, timeout: REQUEST_TIMEOUT_MS });
|
||||
const response = await youtube.channels.list({ part: ['snippet', 'contentDetails'], mine: true, maxResults: 1 });
|
||||
const item = response?.data?.items?.[0];
|
||||
if (item) {
|
||||
channel = {
|
||||
id: item.id,
|
||||
title: item.snippet?.title || null,
|
||||
thumbnail: bestThumbnail(item.snippet?.thumbnails)
|
||||
};
|
||||
likesPlaylistId = item.contentDetails?.relatedPlaylists?.likes || null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Non bloquant : un compte Google sans chaîne YouTube reste utilisable
|
||||
alog.warn(`Impossible de lire la chaîne YouTube de l'utilisateur ${userId} : ${error?.message || error}`);
|
||||
}
|
||||
|
||||
dropClient(userId);
|
||||
store.set(userId, {
|
||||
tokens,
|
||||
linkedAt: new Date().toISOString(),
|
||||
channel,
|
||||
likesPlaylistId,
|
||||
lastSync: previous?.lastSync || null,
|
||||
invalid: false,
|
||||
invalidReason: null
|
||||
});
|
||||
attachClient(userId, client);
|
||||
alog.log(`Compte Google lié pour l'utilisateur ${userId}` + (channel?.title ? ` (chaîne : ${channel.title})` : ""));
|
||||
emitUpdate(userId);
|
||||
return getStatus(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un client en cache et persiste les jetons rafraîchis par la librairie
|
||||
*/
|
||||
function attachClient(userId, client) {
|
||||
client.on('tokens', (fresh) => {
|
||||
const picked = pickTokens(fresh);
|
||||
// Compte délié entre-temps : rien à enregistrer
|
||||
store.update(userId, (record) => {
|
||||
record.tokens = { ...(record.tokens || {}), ...picked };
|
||||
});
|
||||
});
|
||||
clients.set(userId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client OAuth2 authentifié de l'utilisateur (jetons persistés), ou null si le compte n'est pas (ou plus) lié
|
||||
* @param {string} userId
|
||||
* @returns {import('google-auth-library').OAuth2Client|null}
|
||||
*/
|
||||
function getClient(userId) {
|
||||
const record = store.get(userId);
|
||||
if (!record?.tokens || record.invalid) return null;
|
||||
if (clients.has(userId)) return clients.get(userId);
|
||||
const client = newClient();
|
||||
if (!client) return null;
|
||||
client.setCredentials(record.tokens);
|
||||
return attachClient(userId, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Délie le compte Google : révocation chez Google (au mieux) puis suppression des jetons
|
||||
* @param {string} userId
|
||||
* @param {{revoke?: boolean}} [options]
|
||||
* @returns {Promise<{removed: boolean, revoked: boolean}>}
|
||||
*/
|
||||
async function unlink(userId, { revoke = true } = {}) {
|
||||
const record = store.get(userId);
|
||||
dropClient(userId);
|
||||
states.clear(userId);
|
||||
if (!record) return { removed: false, revoked: false };
|
||||
const token = record.tokens?.refresh_token || record.tokens?.access_token;
|
||||
// Suppression locale d'abord : même si Google ne répond pas, plus rien n'est conservé
|
||||
store.remove(userId);
|
||||
emitUpdate(userId);
|
||||
let revoked = false;
|
||||
if (revoke && token) {
|
||||
try {
|
||||
// La révocation ne nécessite pas les identifiants de l'application
|
||||
await withTimeout(new google.auth.OAuth2().revokeToken(token), REQUEST_TIMEOUT_MS, "révocation");
|
||||
revoked = true;
|
||||
} catch (error) {
|
||||
alog.warn(`Révocation du jeton Google impossible pour l'utilisateur ${userId} : ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
alog.log(`Compte Google délié pour l'utilisateur ${userId}` + (revoked ? " (jeton révoqué)" : ""));
|
||||
return { removed: true, revoked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Statut du compte Google de l'utilisateur (jamais de jeton)
|
||||
* @param {string} userId
|
||||
*/
|
||||
function getStatus(userId) {
|
||||
const record = store.get(userId);
|
||||
const linked = !!record?.tokens;
|
||||
return {
|
||||
configured: isConfigured(),
|
||||
linked,
|
||||
valid: linked && !record.invalid,
|
||||
invalidReason: linked && record.invalid ? (record.invalidReason || null) : null,
|
||||
channel: linked ? (record.channel || null) : null,
|
||||
linkedAt: linked ? (record.linkedAt || null) : null,
|
||||
lastSync: linked ? (record.lastSync || null) : null,
|
||||
syncing: syncing.has(userId)
|
||||
};
|
||||
}
|
||||
|
||||
function isLinked(userId) {
|
||||
const record = store.get(userId);
|
||||
return !!(record?.tokens && !record.invalid);
|
||||
}
|
||||
|
||||
function getRecord(userId) {
|
||||
return store.get(userId);
|
||||
}
|
||||
|
||||
function setSyncing(userId, value) {
|
||||
if (value) syncing.add(userId); else syncing.delete(userId);
|
||||
emitUpdate(userId);
|
||||
}
|
||||
|
||||
function isSyncing(userId) {
|
||||
return syncing.has(userId);
|
||||
}
|
||||
|
||||
function setLastSync(userId, lastSync) {
|
||||
store.update(userId, (record) => {
|
||||
record.lastSync = lastSync;
|
||||
});
|
||||
}
|
||||
|
||||
// Compatibilité : ancien nom (renvoie le client authentifié ou null)
|
||||
async function getAuthorization(userId, code, state) {
|
||||
try {
|
||||
await linkAccount(userId, code, state);
|
||||
return getClient(userId);
|
||||
} catch (error) {
|
||||
alog.error(`Erreur lors de l'autorisation OAuth2 de l'utilisateur ${userId} : ` + (error?.message || error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkCredientials() {
|
||||
if (!clientId || !clientSecret) {
|
||||
alog.error("YouTube API client ID or secret is not set in the configuration.");
|
||||
if (!isConfigured()) {
|
||||
alog.warn("Identifiants de l'API YouTube (clientId / clientSecret) ou lien du site absents de la configuration.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCOPES,
|
||||
GoogleError,
|
||||
isConfigured,
|
||||
createAuthUrl,
|
||||
linkAccount,
|
||||
getAuthorization,
|
||||
getOAuth2Client: (userId) => oAuth2Map.get(userId),
|
||||
oAuth2Map
|
||||
getClient,
|
||||
getOAuth2Client: getClient,
|
||||
unlink,
|
||||
getStatus,
|
||||
isLinked,
|
||||
getRecord,
|
||||
setSyncing,
|
||||
isSyncing,
|
||||
setLastSync,
|
||||
describeApiError,
|
||||
handleApiError,
|
||||
markInvalid,
|
||||
REQUEST_TIMEOUT_MS
|
||||
};
|
||||
|
||||
const SCOPES = ['https://www.googleapis.com/auth/youtube.readonly'];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,60 +3,245 @@ const { LogType } = require('loguix');
|
||||
const alog = new LogType("YoutubeAPI");
|
||||
|
||||
const OAuth2 = require('./OAuth2');
|
||||
const Users = require('../../server/auth/User');
|
||||
const { GoogleError } = OAuth2;
|
||||
const { collectPages, chunk, parseIsoDuration, bestThumbnail } = require('./Utils');
|
||||
|
||||
async function getYoutubePlaylists(userId) {
|
||||
const user = Users.getUserById(userId);
|
||||
if (!user) {
|
||||
alog.error(`User with ID ${userId} not found.`);
|
||||
return null;
|
||||
// Limites d'une synchronisation (quota YouTube : 1 unité par page de 50)
|
||||
const MAX_PLAYLISTS = 50;
|
||||
const MAX_ITEMS_PER_PLAYLIST = 500;
|
||||
const PAGE_SIZE = 50;
|
||||
const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png";
|
||||
// Titre de la playlist "J'aime" (Google renvoie "Liked videos" ou rien)
|
||||
const LIKES_TITLE = "Vidéos « J'aime »";
|
||||
|
||||
/**
|
||||
* Service YouTube authentifié pour l'utilisateur
|
||||
* @param {string} userId
|
||||
* @throws {GoogleError} NOT_LINKED
|
||||
*/
|
||||
function getService(userId) {
|
||||
const auth = OAuth2.getClient(userId);
|
||||
if (!auth) throw new GoogleError("NOT_LINKED", "Aucun compte YouTube valide n'est lié");
|
||||
return google.youtube({ version: 'v3', auth, timeout: OAuth2.REQUEST_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
const oAuth2Client = OAuth2.getOAuth2Client(userId);
|
||||
if (!oAuth2Client) {
|
||||
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
|
||||
|
||||
/**
|
||||
* Exécute un appel à l'API ; toute erreur est traduite (et le compte marqué invalide si besoin)
|
||||
*/
|
||||
async function call(userId, fn) {
|
||||
try {
|
||||
const response = await youtube.playlists.list({
|
||||
part: 'snippet,contentDetails',
|
||||
mine: true,
|
||||
maxResults: 50
|
||||
});
|
||||
alog.log(`Retrieved playlists for user ${userId}.`);
|
||||
return response.data.items;
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
alog.error(`Error retrieving playlists for user ${userId}:`, error);
|
||||
return null;
|
||||
throw OAuth2.handleApiError(userId, error);
|
||||
}
|
||||
}
|
||||
|
||||
function getYoutubePlaylistSongs(playlistId, userId) {
|
||||
const user = Users.getUserById(userId);
|
||||
if (!user) {
|
||||
alog.error(`User with ID ${userId} not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const oAuth2Client = OAuth2.getOAuth2Client(userId);
|
||||
if (!oAuth2Client) {
|
||||
alog.error(`OAuth2 client for user ${userId} not found. Please authenticate first.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
|
||||
|
||||
return youtube.playlistItems.list({
|
||||
part: 'snippet',
|
||||
playlistId: playlistId,
|
||||
maxResults: 50
|
||||
/**
|
||||
* Playlists de l'utilisateur (paginées, limitées à MAX_PLAYLISTS)
|
||||
* @param {string} userId
|
||||
* @param {{max?: number}} [options]
|
||||
* @returns {Promise<Array>} éléments bruts de playlists.list
|
||||
*/
|
||||
async function getYoutubePlaylists(userId, { max = MAX_PLAYLISTS } = {}) {
|
||||
const youtube = getService(userId);
|
||||
const { items, truncated } = await collectPages((pageToken) => call(userId, async () => {
|
||||
const response = await youtube.playlists.list({
|
||||
part: ['snippet', 'contentDetails'],
|
||||
mine: true,
|
||||
maxResults: PAGE_SIZE,
|
||||
pageToken
|
||||
});
|
||||
return response.data;
|
||||
}), { maxItems: max });
|
||||
if (truncated) alog.warn(`Playlists YouTube de l'utilisateur ${userId} limitées à ${max}`);
|
||||
alog.log(`${items.length} playlist(s) YouTube récupérée(s) pour l'utilisateur ${userId}.`);
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Informations d'une playlist par son id YouTube
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
async function getPlaylistInfo(userId, youtubeId) {
|
||||
const youtube = getService(userId);
|
||||
const response = await call(userId, () => youtube.playlists.list({
|
||||
part: ['snippet', 'contentDetails'],
|
||||
id: [youtubeId],
|
||||
maxResults: 1
|
||||
}));
|
||||
return response?.data?.items?.[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Éléments d'une playlist (paginés, limités à MAX_ITEMS_PER_PLAYLIST)
|
||||
* @param {string} youtubeId
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{items: Array, truncated: boolean}>}
|
||||
*/
|
||||
async function getPlaylistItems(userId, youtubeId, { max = MAX_ITEMS_PER_PLAYLIST } = {}) {
|
||||
const youtube = getService(userId);
|
||||
return collectPages((pageToken) => call(userId, async () => {
|
||||
const response = await youtube.playlistItems.list({
|
||||
part: ['snippet', 'contentDetails'],
|
||||
playlistId: youtubeId,
|
||||
maxResults: PAGE_SIZE,
|
||||
pageToken
|
||||
});
|
||||
return response.data;
|
||||
}), { maxItems: max });
|
||||
}
|
||||
|
||||
// Compatibilité : ancienne signature (playlistId, userId) → { data: { items } }
|
||||
async function getYoutubePlaylistSongs(playlistId, userId) {
|
||||
const { items } = await getPlaylistItems(userId, playlistId);
|
||||
return { data: { items } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Durées des vidéos par paquets de 50 ids (1 unité de quota par appel)
|
||||
* @param {string} userId
|
||||
* @param {string[]} ids
|
||||
* @param {Map<string, number>} [cache] durées déjà connues (partagées entre playlists)
|
||||
* @returns {Promise<Map<string, number>>} id -> secondes (absent si inconnue)
|
||||
*/
|
||||
async function getVideoDurations(userId, ids, cache = new Map()) {
|
||||
const youtube = getService(userId);
|
||||
const missing = [...new Set(ids.filter(id => id && !cache.has(id)))];
|
||||
for (const ids50 of chunk(missing, 50)) {
|
||||
const response = await call(userId, () => youtube.videos.list({
|
||||
part: ['contentDetails'],
|
||||
id: ids50,
|
||||
maxResults: 50
|
||||
}));
|
||||
for (const video of response?.data?.items || []) {
|
||||
const seconds = parseIsoDuration(video?.contentDetails?.duration);
|
||||
if (seconds !== null) cache.set(video.id, seconds);
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit les éléments de playlistItems.list en musiques jouables (vidéos privées / supprimées ignorées)
|
||||
* @param {Array} items
|
||||
* @param {Map<string, number>} durations
|
||||
*/
|
||||
function toSongs(items, durations) {
|
||||
const songs = [];
|
||||
const seen = new Set();
|
||||
for (const item of items) {
|
||||
const snippet = item?.snippet;
|
||||
const videoId = item?.contentDetails?.videoId || snippet?.resourceId?.videoId;
|
||||
if (!videoId || seen.has(videoId)) continue;
|
||||
// Vidéo privée ou supprimée : aucune chaîne propriétaire
|
||||
if (!snippet?.videoOwnerChannelId) continue;
|
||||
seen.add(videoId);
|
||||
songs.push({
|
||||
id: videoId,
|
||||
title: snippet.title || "Aucun titre",
|
||||
author: snippet.videoOwnerChannelTitle || "Auteur inconnu",
|
||||
authorId: `https://www.youtube.com/channel/${snippet.videoOwnerChannelId}`,
|
||||
url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
thumbnail: bestThumbnail(snippet.thumbnails) || DEFAULT_THUMBNAIL,
|
||||
duration: durations.get(videoId) ?? 0,
|
||||
type: "youtube"
|
||||
});
|
||||
}
|
||||
return songs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlist complète (métadonnées + musiques + durées) au format neutre utilisé par PlaylistManager
|
||||
* @param {string} userId
|
||||
* @param {object} info élément playlists.list, ou {id, snippet} construit pour la playlist "J'aime"
|
||||
* @param {Map<string, number>} cache durées partagées
|
||||
*/
|
||||
async function buildPlaylist(userId, info, cache) {
|
||||
const { items, truncated } = await getPlaylistItems(userId, info.id);
|
||||
const ids = items.map(item => item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId).filter(Boolean);
|
||||
await getVideoDurations(userId, ids, cache);
|
||||
const songs = toSongs(items, cache);
|
||||
if (truncated) alog.warn(`Playlist YouTube ${info.id} limitée à ${MAX_ITEMS_PER_PLAYLIST} éléments`);
|
||||
return {
|
||||
youtubeId: info.id,
|
||||
title: info.snippet?.title || "Playlist YouTube",
|
||||
description: info.snippet?.description || "",
|
||||
author: info.snippet?.channelTitle || "Auteur inconnu",
|
||||
authorId: info.snippet?.channelId ? `https://www.youtube.com/channel/${info.snippet.channelId}` : null,
|
||||
thumbnail: bestThumbnail(info.snippet?.thumbnails) || songs[0]?.thumbnail || DEFAULT_THUMBNAIL,
|
||||
url: `https://www.youtube.com/playlist?list=${info.id}`,
|
||||
songs,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
|
||||
function likesInfo(userId) {
|
||||
const record = OAuth2.getRecord(userId);
|
||||
if (!record?.likesPlaylistId) return null;
|
||||
return {
|
||||
id: record.likesPlaylistId,
|
||||
snippet: {
|
||||
title: LIKES_TITLE,
|
||||
description: "Vidéos aimées sur YouTube",
|
||||
channelTitle: record.channel?.title || null,
|
||||
channelId: record.channel?.id || null,
|
||||
thumbnails: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les playlists de l'utilisateur (et ses « J'aime ») avec leurs musiques.
|
||||
* Une playlist en erreur est ignorée ; une perte d'autorisation ou un quota épuisé interrompt tout.
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{playlists: Array, skipped: number}>}
|
||||
*/
|
||||
async function fetchLibrary(userId) {
|
||||
const infos = await getYoutubePlaylists(userId);
|
||||
const likes = likesInfo(userId);
|
||||
if (likes && !infos.some(info => info.id === likes.id)) infos.unshift(likes);
|
||||
|
||||
const cache = new Map();
|
||||
const playlists = [];
|
||||
let skipped = 0;
|
||||
for (const info of infos) {
|
||||
if (!info?.id) continue;
|
||||
try {
|
||||
playlists.push(await buildPlaylist(userId, info, cache));
|
||||
} catch (error) {
|
||||
if (["AUTH_EXPIRED", "QUOTA", "CONFIG_ERROR", "NOT_LINKED"].includes(error?.code)) throw error;
|
||||
skipped++;
|
||||
alog.warn(`Playlist YouTube ${info.id} ignorée : ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
return { playlists, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une seule playlist (rafraîchissement d'une playlist importée)
|
||||
* @param {string} userId
|
||||
* @param {string} youtubeId
|
||||
* @returns {Promise<object>}
|
||||
* @throws {GoogleError}
|
||||
*/
|
||||
async function fetchPlaylist(userId, youtubeId) {
|
||||
if (typeof youtubeId !== "string" || !/^[\w-]{2,64}$/.test(youtubeId)) {
|
||||
throw new GoogleError("INVALID_PARAMS", "Identifiant de playlist YouTube invalide");
|
||||
}
|
||||
const likes = likesInfo(userId);
|
||||
let info = likes && likes.id === youtubeId ? likes : await getPlaylistInfo(userId, youtubeId);
|
||||
if (!info) throw new GoogleError("NOT_FOUND", "Playlist introuvable sur YouTube (supprimée ou privée)");
|
||||
return buildPlaylist(userId, info, new Map());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_PLAYLISTS,
|
||||
MAX_ITEMS_PER_PLAYLIST,
|
||||
getYoutubePlaylists,
|
||||
getYoutubePlaylistSongs
|
||||
getYoutubePlaylistSongs,
|
||||
getPlaylistItems,
|
||||
getVideoDurations,
|
||||
fetchLibrary,
|
||||
fetchPlaylist,
|
||||
toSongs
|
||||
};
|
||||
@@ -2,9 +2,44 @@ const {LogType} = require("loguix")
|
||||
const hlog = new LogType("PersonalHistory")
|
||||
const {__glob} = require("../utils/GlobalVars")
|
||||
const { Database } = require("../utils/Database/Database")
|
||||
const { getReadableDuration } = require("../utils/TimeConverter")
|
||||
const { toSafeNumber, toSafeDuration } = require("../utils/SafeMetric")
|
||||
const historyDb = new Database("history", __glob.HISTORY_DB, {})
|
||||
historyDb.load()
|
||||
|
||||
/**
|
||||
* Copie de l'entrée avec une durée toujours numérique (jamais NaN, chaîne ou négative).
|
||||
* Une playlist peut dépasser 24 h : seule une musique est bornée par toSafeDuration.
|
||||
* @param {Object} entry
|
||||
* @returns {Object}
|
||||
*/
|
||||
function sanitizeEntry(entry) {
|
||||
if (!entry || typeof entry !== "object" || !("duration" in entry)) return entry
|
||||
const isPlaylist = entry.form === "PLAYLIST"
|
||||
const duration = isPlaylist ? Math.max(0, toSafeNumber(entry.duration, 0)) : toSafeDuration(entry.duration)
|
||||
return { ...entry, duration, readduration: getReadableDuration(duration) }
|
||||
}
|
||||
|
||||
// Au démarrage : répare les durées corrompues déjà enregistrées
|
||||
try {
|
||||
let repaired = 0
|
||||
for (const userId of Object.keys(historyDb.data || {})) {
|
||||
const entries = historyDb.data[userId]
|
||||
if (!Array.isArray(entries)) continue
|
||||
historyDb.data[userId] = entries.map(entry => {
|
||||
const clean = sanitizeEntry(entry)
|
||||
if (clean !== entry && (clean.duration !== entry.duration || clean.readduration !== entry.readduration)) repaired++
|
||||
return clean
|
||||
})
|
||||
}
|
||||
if (repaired > 0) {
|
||||
hlog.warn(`Durées corrigées pour ${repaired} entrée(s) de l'historique`)
|
||||
historyDb.save()
|
||||
}
|
||||
} catch (e) {
|
||||
hlog.error("Erreur lors de la vérification des durées de l'historique : " + e)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @returns {Array<Object>}
|
||||
@@ -40,7 +75,7 @@ function addToPersonalHistory(userId, entry) {
|
||||
if (history.length >= 25) {
|
||||
history.shift();
|
||||
}
|
||||
history.push(entry)
|
||||
history.push(sanitizeEntry(entry))
|
||||
historyDb.save();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
const { getReadableDuration } = require("../utils/TimeConverter");
|
||||
const { toSafeDuration } = require("../utils/SafeMetric");
|
||||
|
||||
/**
|
||||
* Somme sûre des durées d'une liste de musiques (durée absente, chaîne ou NaN → 0)
|
||||
* @param {Array<Object>} songs
|
||||
* @returns {number}
|
||||
*/
|
||||
function computeDuration(songs) {
|
||||
if (!Array.isArray(songs)) return 0;
|
||||
return songs.reduce((acc, song) => acc + toSafeDuration(song?.duration), 0);
|
||||
}
|
||||
|
||||
class Playlist {
|
||||
title = "Aucun titre";
|
||||
@@ -23,7 +34,7 @@ class Playlist {
|
||||
this.thumbnail = thumbnail;
|
||||
// Make the some of durations of the songs
|
||||
if(this.songs.length > 0) {
|
||||
this.duration = this.songs.reduce((acc, song) => acc + song.duration, 0);
|
||||
this.duration = computeDuration(this.songs);
|
||||
this.readduration = getReadableDuration(this.duration);
|
||||
}
|
||||
this.description = description;
|
||||
@@ -33,5 +44,5 @@ class Playlist {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {Playlist};
|
||||
module.exports = {Playlist, computeDuration};
|
||||
|
||||
+283
-136
@@ -1,22 +1,110 @@
|
||||
const {Database} = require('../utils/Database/Database');
|
||||
const {__glob} = require('../utils/GlobalVars');
|
||||
|
||||
const {Playlist} = require('./Playlist');
|
||||
const {Playlist, computeDuration} = require('./Playlist');
|
||||
const {LogType} = require('loguix');
|
||||
const clog = new LogType("PlaylistManager");
|
||||
const Finder = require('../player/Finder');
|
||||
const spotify = require('../media/SpotifyInformation');
|
||||
const { getYoutubePlaylistSongs } = require('./Google/YoutubeList');
|
||||
const { auth } = require('googleapis/build/src/apis/abusiveexperiencereport');
|
||||
const YoutubeList = require('./Google/YoutubeList');
|
||||
const GoogleOAuth2 = require('./Google/OAuth2');
|
||||
const { getReadableDuration } = require('../utils/TimeConverter');
|
||||
const { getSecondsFromUrl } = require('../media/YoutubeInformation');
|
||||
const { toSafeDuration } = require('../utils/SafeMetric');
|
||||
const songCheck = require('../player/SongCheck');
|
||||
|
||||
// Délai minimal entre deux synchronisations YouTube réussies d'un même utilisateur (quota de l'API)
|
||||
const SYNC_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {});
|
||||
|
||||
/**
|
||||
* Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...)
|
||||
*/
|
||||
function sameId(a, b) {
|
||||
if (a === undefined || a === null || b === undefined || b === null) return false;
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de l'utilisateur
|
||||
* (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation)
|
||||
* @param {Array} userPlaylists
|
||||
* @returns {string}
|
||||
*/
|
||||
function newPlaylistId(userPlaylists) {
|
||||
let candidate = Date.now();
|
||||
const used = new Set((userPlaylists || []).map(p => String(p?.playlistId)));
|
||||
while (used.has(String(candidate))) candidate++;
|
||||
return String(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible.
|
||||
* Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée.
|
||||
* @returns {boolean} true si la musique a été modifiée
|
||||
*/
|
||||
function sanitizeSongDuration(song) {
|
||||
if (!song || typeof song !== "object") return false;
|
||||
const duration = toSafeDuration(song.duration);
|
||||
const readduration = getReadableDuration(duration);
|
||||
if (song.duration === duration && song.readduration === readduration) return false;
|
||||
song.duration = duration;
|
||||
song.readduration = readduration;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne)
|
||||
* @returns {boolean} true si la playlist a été modifiée
|
||||
*/
|
||||
function refreshPlaylistDuration(playlist) {
|
||||
if (!playlist || typeof playlist !== "object") return false;
|
||||
if (!Array.isArray(playlist.songs)) playlist.songs = [];
|
||||
let changed = false;
|
||||
for (const song of playlist.songs) {
|
||||
if (sanitizeSongDuration(song)) changed = true;
|
||||
}
|
||||
const duration = computeDuration(playlist.songs);
|
||||
const readduration = getReadableDuration(duration);
|
||||
if (playlist.duration !== duration || playlist.readduration !== readduration) {
|
||||
playlist.duration = duration;
|
||||
playlist.readduration = readduration;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN)
|
||||
// et donne un identifiant (chaîne) aux playlists qui n'en ont pas (anciens imports YouTube)
|
||||
try {
|
||||
let repaired = 0;
|
||||
let identified = 0;
|
||||
for (const userId of Object.keys(playlistDB.data || {})) {
|
||||
const userPlaylists = playlistDB.data[userId];
|
||||
if (!Array.isArray(userPlaylists)) continue;
|
||||
for (const playlist of userPlaylists) {
|
||||
if (refreshPlaylistDuration(playlist)) repaired++;
|
||||
if (playlist && typeof playlist === "object") {
|
||||
if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") {
|
||||
playlist.playlistId = newPlaylistId(userPlaylists);
|
||||
identified++;
|
||||
} else if (typeof playlist.playlistId !== "string") {
|
||||
playlist.playlistId = String(playlist.playlistId);
|
||||
identified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`);
|
||||
if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`);
|
||||
if (repaired > 0 || identified > 0) playlistDB.save();
|
||||
} catch (e) {
|
||||
clog.error("Erreur lors de la vérification des playlists : " + e);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @returns {Array<Playlist>}
|
||||
* @description Renvoie la liste des playlists de l'utilisateur
|
||||
*/
|
||||
@@ -40,7 +128,7 @@ function getPlaylistsOfUser(id) {
|
||||
*/
|
||||
function getPlaylistOfUser(id, playlistId) {
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
const playlist = playlists.find(p => sameId(p.playlistId, playlistId));
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
@@ -65,16 +153,20 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
|
||||
if(playlistFounded instanceof Playlist) {
|
||||
playlist = playlistFounded;
|
||||
}
|
||||
if(playlist.type === "spotify") {
|
||||
playlist.songs = await spotify.getTracks(playlist);
|
||||
if(!failed && playlist.type === "spotify") {
|
||||
// getTracks remplace playlist.songs et renvoie la playlist elle-même
|
||||
// (ne pas l'assigner à playlist.songs : référence circulaire impossible à sauvegarder)
|
||||
await spotify.getTracks(playlist);
|
||||
}
|
||||
})
|
||||
}
|
||||
playlist.playlistId = new String(Date.now());
|
||||
if(failed) {
|
||||
clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
refreshPlaylistDuration(playlist);
|
||||
// Chaîne primitive : un objet String ne serait jamais égal (===) à l'identifiant envoyé par le client
|
||||
playlist.playlistId = newPlaylistId(playlists);
|
||||
|
||||
playlists.push(playlist);
|
||||
playlistDB.save();
|
||||
@@ -84,55 +176,52 @@ async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
|
||||
|
||||
function removePlaylist(id, playlistId) {
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const index = playlists.findIndex(p => p.playlistId === playlistId);
|
||||
const index = playlists.findIndex(p => sameId(p.playlistId, playlistId));
|
||||
if (index === -1) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
playlists.splice(index, 1);
|
||||
playlistDB.save();
|
||||
clog.log(`Suppression de la playlist ${playlistId} pour l'utilisateur ${id}`);
|
||||
return true;
|
||||
}
|
||||
function getPlaylist(id, playlistId) {
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
return playlist;
|
||||
return getPlaylistOfUser(id, playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copie une playlist vers un autre utilisateur (copie indépendante, nouvel identifiant).
|
||||
* Refusée si le destinataire possède déjà une playlist du même titre.
|
||||
* @returns {Playlist|null} la copie, ou null
|
||||
*/
|
||||
function copyPlaylist(fromId, toId, playlistId) {
|
||||
const playlists = getPlaylistsOfUser(fromId);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${fromId}`);
|
||||
return null;
|
||||
}
|
||||
const playlist = getPlaylistOfUser(fromId, playlistId);
|
||||
if (!playlist) return null;
|
||||
const toPlaylists = getPlaylistsOfUser(toId);
|
||||
// Check if the playlist already exists in the target user
|
||||
// Doublon : même titre chez le destinataire (Playlist n'a pas de champ "name")
|
||||
if (toPlaylists.find(p => p.title === playlist.title)) {
|
||||
clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`);
|
||||
return null;
|
||||
}
|
||||
toPlaylists.push(playlist);
|
||||
// Copie profonde : un même objet partagé entre deux utilisateurs serait modifié des deux côtés
|
||||
const copy = JSON.parse(JSON.stringify(playlist));
|
||||
copy.playlistId = newPlaylistId(toPlaylists);
|
||||
// Une playlist synchronisée appartient au compte Google de l'expéditeur : la copie devient une playlist classique
|
||||
delete copy.source;
|
||||
toPlaylists.push(copy);
|
||||
playlistDB.save();
|
||||
clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`);
|
||||
|
||||
return false;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function renamePlaylist(id, playlistId, newName) {
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
const playlist = getPlaylistOfUser(id, playlistId);
|
||||
if (!playlist) return null;
|
||||
playlist.title = newName;
|
||||
playlistDB.save();
|
||||
clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
function addSong(id, playlistId, song) {
|
||||
@@ -149,121 +238,157 @@ function addSong(id, playlistId, song) {
|
||||
clog.error(`La chanson ${song} n'est pas valide`);
|
||||
return null;
|
||||
}
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
// Check the integrity of the song
|
||||
if (!song.id || !song.title || !song.url) {
|
||||
clog.error(`La chanson ${song.title} n'est pas valide`);
|
||||
const playlist = getPlaylistOfUser(id, playlistId);
|
||||
if (!playlist) return null;
|
||||
// Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement
|
||||
song = songCheck.sanitizeSong(song);
|
||||
if (!song || !song.id || !song.title) {
|
||||
clog.error(`La chanson reçue n'est pas valide`);
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(playlist.songs)) playlist.songs = [];
|
||||
playlist.songs.push(song);
|
||||
// Recalculate the songs duration and readduration
|
||||
playlist.duration += song.duration;
|
||||
playlist.readduration = getReadableDuration(playlist.duration);
|
||||
// Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue)
|
||||
refreshPlaylistDuration(playlist);
|
||||
playlistDB.save();
|
||||
clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
function removeSong(id, playlistId, songId) {
|
||||
const playlists = getPlaylistsOfUser(id);
|
||||
const playlist = playlists.find(p => p.playlistId === playlistId);
|
||||
if (!playlist) {
|
||||
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
const playlist = getPlaylistOfUser(id, playlistId);
|
||||
if (!playlist) return null;
|
||||
if (!Array.isArray(playlist.songs)) playlist.songs = [];
|
||||
const index = playlist.songs.findIndex(s => s.id === songId);
|
||||
if (index === -1) {
|
||||
clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
playlist.duration -= playlist.songs[index].duration;
|
||||
playlist.songs.splice(index, 1);
|
||||
playlist.readduration = getReadableDuration(playlist.duration);
|
||||
// Recalcul complet plutôt qu'une soustraction (durée manquante → NaN, résultat négatif...)
|
||||
refreshPlaylistDuration(playlist);
|
||||
playlistDB.save();
|
||||
clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
async function processYoutubeData(userId, data) {
|
||||
if (!data || data.length === 0) {
|
||||
// --- YOUTUBE (compte Google lié) ---
|
||||
|
||||
/**
|
||||
* Construit une playlist enregistrable à partir des données neutres de YoutubeList
|
||||
* @param {object} data résultat de YoutubeList.buildPlaylist
|
||||
* @param {string} playlistId identifiant local (conservé d'une synchronisation à l'autre)
|
||||
* @returns {Playlist}
|
||||
*/
|
||||
function playlistFromYoutube(data, playlistId) {
|
||||
const playlist = new Playlist(data.title, data.url, data.author, data.authorId, data.songs, data.thumbnail, null, null, data.description || "Aucune description disponible");
|
||||
playlist.id = data.youtubeId;
|
||||
playlist.youtubeId = data.youtubeId;
|
||||
// "google" : rafraîchie via l'API YouTube avec le compte du propriétaire (playlists privées, "J'aime")
|
||||
playlist.source = "google";
|
||||
playlist.type = "youtube";
|
||||
playlist.playlistId = playlistId;
|
||||
playlist.syncedAt = new Date().toISOString();
|
||||
refreshPlaylistDuration(playlist);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index d'une playlist déjà importée correspondant à une playlist YouTube
|
||||
*/
|
||||
function findImportedIndex(userPlaylists, youtubeId) {
|
||||
const synced = userPlaylists.findIndex(p => p?.source === "google" && p.youtubeId === youtubeId);
|
||||
if (synced !== -1) return synced;
|
||||
// Anciens imports (sans "source") : id = identifiant YouTube
|
||||
return userPlaylists.findIndex(p => p?.type === "youtube" && !p.source && p.id === youtubeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur.
|
||||
* Les playlists déjà importées gardent leur identifiant local et leur position.
|
||||
* @param {string} userId
|
||||
* @param {Array} data
|
||||
* @returns {Array<Playlist>}
|
||||
*/
|
||||
function processYoutubeData(userId, data) {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const playlists = [];
|
||||
for (const item of data) {
|
||||
if (item.snippet && item.contentDetails) {
|
||||
const playlist = new Playlist();
|
||||
playlist.id = item.id;
|
||||
playlist.title = item.snippet.title;
|
||||
playlist.url = `https://www.youtube.com/playlist?list=${item.id}`;
|
||||
playlist.description = item.snippet.description || "Aucune description disponible";
|
||||
playlist.author = item.snippet.channelTitle;
|
||||
playlist.thumbnail = item.snippet.thumbnails.default.url;
|
||||
playlist.authorId = `https://www.youtube.com/channel/${item.snippet.channelId}`;
|
||||
playlist.songs = []; // You can fetch songs later if needed
|
||||
await getYoutubePlaylistSongs(item.id, userId).then(songsData => {
|
||||
if (songsData && songsData.data && songsData.data.items) {
|
||||
playlist.songs = songsData.data.items.map(song => ({
|
||||
id: song.snippet.resourceId.videoId,
|
||||
title: song.snippet.title,
|
||||
author: song.snippet.videoOwnerChannelTitle,
|
||||
authorId: `https://www.youtube.com/channel/${song.snippet.videoOwnerChannelId}`,
|
||||
url: `https://www.youtube.com/watch?v=${song.snippet.resourceId.videoId}`,
|
||||
thumbnail: song.snippet?.thumbnails?.default?.url || "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png",
|
||||
}));
|
||||
// Add readduration for every items in songs
|
||||
|
||||
} else {
|
||||
clog.warn(`Aucune chanson trouvée pour la playlist ${item.id}`);
|
||||
}
|
||||
}).catch(err => {
|
||||
clog.error(`Erreur lors de la récupération des chansons pour la playlist ${item.id}:`, err);
|
||||
});
|
||||
for (const song of playlist.songs) {
|
||||
// If authorId is not defined, delete the song
|
||||
if (song.authorId == "https://www.youtube.com/channel/undefined") {
|
||||
clog.warn(`L'auteur de la chanson ${song.title} (${song.id}) n'est pas défini. Suppression de la chanson.`);
|
||||
playlist.songs.splice(playlist.songs.indexOf(song), 1);
|
||||
continue; // Skip this song
|
||||
}
|
||||
song.duration = await getSecondsFromUrl(song.url);
|
||||
if (song.duration === null) {
|
||||
clog.warn(`Impossible de récupérer la durée de la chanson ${song.title} (${song.id})`);
|
||||
song.duration = 0; // Set to 0 if duration cannot be fetched
|
||||
} else {
|
||||
song.readduration = getReadableDuration(song.duration);
|
||||
playlist.duration += song.duration; // Initialize duration if not set
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
playlist.readduration = getReadableDuration(playlist.duration);
|
||||
playlist.type = "youtube";
|
||||
playlists.push(playlist);
|
||||
} else {
|
||||
clog.warn(`Données YouTube manquantes pour l'élément ${item.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
clog.log(`Traitement des données YouTube pour l'utilisateur ${userId} terminé. Nombre de playlists trouvées : ${playlists.length}`);
|
||||
// Save the playlists to the user's playlist collection
|
||||
const userPlaylists = getPlaylistsOfUser(userId);
|
||||
// Remove existing playlists with the same IDs to avoid duplicates
|
||||
for (const playlist of playlists) {
|
||||
const existingIndex = userPlaylists.findIndex(p => p.id === playlist.id);
|
||||
if (existingIndex !== -1) {
|
||||
userPlaylists.splice(existingIndex, 1); // Remove existing playlist with the same ID
|
||||
const result = [];
|
||||
for (const item of data) {
|
||||
if (!item?.youtubeId) continue;
|
||||
const index = findImportedIndex(userPlaylists, item.youtubeId);
|
||||
if (index !== -1) {
|
||||
const playlist = playlistFromYoutube(item, userPlaylists[index].playlistId || newPlaylistId(userPlaylists));
|
||||
userPlaylists.splice(index, 1, playlist);
|
||||
result.push(playlist);
|
||||
} else {
|
||||
const playlist = playlistFromYoutube(item, newPlaylistId(userPlaylists));
|
||||
userPlaylists.push(playlist);
|
||||
result.push(playlist);
|
||||
}
|
||||
}
|
||||
userPlaylists.push(...playlists);
|
||||
playlistDB.save();
|
||||
clog.log(`Playlists ajoutées pour l'utilisateur ${userId}. Nombre total de playlists : ${userPlaylists.length}`);
|
||||
return playlists;
|
||||
clog.log(`Playlists YouTube importées pour l'utilisateur ${userId} : ${result.length}. Nombre total de playlists : ${userPlaylists.length}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise toutes les playlists YouTube du compte Google lié (une seule à la fois par utilisateur).
|
||||
* @param {string} userId
|
||||
* @param {{force?: boolean}} [options] force : ignore le délai entre deux synchronisations
|
||||
* @returns {Promise<{ok: boolean, error?: string, message?: string, playlists?: number, songs?: number, skipped?: number}>}
|
||||
*/
|
||||
async function syncYoutube(userId, { force = false } = {}) {
|
||||
if (!GoogleOAuth2.isLinked(userId)) {
|
||||
return { ok: false, error: "NOT_LINKED", message: "Aucun compte YouTube valide n'est lié" };
|
||||
}
|
||||
if (GoogleOAuth2.isSyncing(userId)) {
|
||||
return { ok: false, error: "ALREADY_SYNCING", message: "Une synchronisation est déjà en cours" };
|
||||
}
|
||||
const lastSync = GoogleOAuth2.getRecord(userId)?.lastSync;
|
||||
const lastAt = lastSync?.at ? Date.parse(lastSync.at) : NaN;
|
||||
if (!force && lastSync?.ok && Number.isFinite(lastAt) && Date.now() - lastAt < SYNC_COOLDOWN_MS) {
|
||||
return { ok: false, error: "COOLDOWN", message: "Synchronisation effectuée il y a moins d'une minute, réessayez plus tard" };
|
||||
}
|
||||
|
||||
// Marqué avant le premier await : deux demandes simultanées ne lancent qu'une synchronisation
|
||||
GoogleOAuth2.setSyncing(userId, true);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
clog.log(`Synchronisation YouTube de l'utilisateur ${userId}...`);
|
||||
const { playlists, skipped } = await YoutubeList.fetchLibrary(userId);
|
||||
const imported = processYoutubeData(userId, playlists);
|
||||
const songs = imported.reduce((acc, p) => acc + (p.songs?.length || 0), 0);
|
||||
GoogleOAuth2.setLastSync(userId, {
|
||||
at: new Date().toISOString(),
|
||||
ok: true,
|
||||
playlists: imported.length,
|
||||
songs,
|
||||
skipped,
|
||||
message: skipped > 0 ? `${skipped} playlist(s) ignorée(s)` : null
|
||||
});
|
||||
clog.log(`Synchronisation YouTube terminée pour l'utilisateur ${userId} : ${imported.length} playlist(s), ${songs} musique(s) en ${Date.now() - startedAt} ms`);
|
||||
process.emit("PLAYLISTS_UPDATE", userId);
|
||||
return { ok: true, playlists: imported.length, songs, skipped };
|
||||
} catch (error) {
|
||||
const described = GoogleOAuth2.describeApiError(error);
|
||||
clog.error(`Échec de la synchronisation YouTube de l'utilisateur ${userId} : ${described.code} - ${error?.message || error}`);
|
||||
GoogleOAuth2.setLastSync(userId, {
|
||||
at: new Date().toISOString(),
|
||||
ok: false,
|
||||
playlists: 0,
|
||||
songs: 0,
|
||||
skipped: 0,
|
||||
message: described.message
|
||||
});
|
||||
return { ok: false, error: described.code, message: described.message };
|
||||
} finally {
|
||||
GoogleOAuth2.setSyncing(userId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserPlaylists(userId) {
|
||||
@@ -277,39 +402,60 @@ async function deleteUserPlaylists(userId) {
|
||||
}
|
||||
|
||||
async function refreshPlaylist(userId, playlistId) {
|
||||
var playlist = getPlaylistOfUser(userId, playlistId);
|
||||
if (!playlist) {
|
||||
const current = getPlaylistOfUser(userId, playlistId);
|
||||
if (!current) {
|
||||
clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId} avec l'ID ${playlistId}`);
|
||||
return null;
|
||||
}
|
||||
const localId = current.playlistId;
|
||||
var playlist = current;
|
||||
let failed = false;
|
||||
// If playlistHasUrl, refresh the playlist
|
||||
if (playlist.url) {
|
||||
await Finder.search(playlist.url, false, "PLAYLIST").then(async (playlistFounded) => {
|
||||
|
||||
if (current.source === "google" && current.youtubeId && GoogleOAuth2.isLinked(userId)) {
|
||||
// Playlist importée : l'API (compte du propriétaire) voit aussi les playlists privées et "J'aime"
|
||||
try {
|
||||
const data = await YoutubeList.fetchPlaylist(userId, current.youtubeId);
|
||||
playlist = playlistFromYoutube(data, localId);
|
||||
} catch (error) {
|
||||
clog.warn(`Échec de la mise à jour via l'API YouTube de la playlist ${playlistId} : ${error?.message || error}`);
|
||||
failed = true;
|
||||
}
|
||||
} else if (current.url) {
|
||||
// Sinon : recherche publique par l'URL (les playlists privées échouent)
|
||||
await Finder.search(current.url, false, "PLAYLIST").then(async (playlistFounded) => {
|
||||
if(!playlistFounded) {
|
||||
failed = true;
|
||||
}
|
||||
if(playlistFounded instanceof Playlist) {
|
||||
playlist = playlistFounded;
|
||||
}
|
||||
if(playlist.type === "spotify") {
|
||||
playlist.songs = await spotify.getTracks(playlist);
|
||||
if(!failed && playlist.type === "spotify") {
|
||||
// getTracks remplace playlist.songs et renvoie la playlist elle-même
|
||||
await spotify.getTracks(playlist);
|
||||
}
|
||||
})
|
||||
// Une playlist importée rafraîchie publiquement reste rattachée au compte Google
|
||||
if (!failed && current.source === "google" && playlist !== current) {
|
||||
playlist.source = "google";
|
||||
playlist.youtubeId = current.youtubeId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if(failed) {
|
||||
clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
playlist.playlistId = playlistId;
|
||||
refreshPlaylistDuration(playlist);
|
||||
playlist.playlistId = localId;
|
||||
const playlists = getPlaylistsOfUser(userId);
|
||||
// Remove the older one
|
||||
// Push at the same index
|
||||
const existingIndex = playlists.findIndex(p => p.playlistId === playlistId);
|
||||
// Remplacement à la même position
|
||||
const existingIndex = playlists.findIndex(p => sameId(p.playlistId, localId));
|
||||
if (existingIndex === -1) {
|
||||
// Supprimée entre-temps : splice(-1) aurait remplacé la dernière playlist de l'utilisateur
|
||||
clog.warn(`La playlist ${playlistId} a été supprimée pendant sa mise à jour pour l'utilisateur ${userId}`);
|
||||
return null;
|
||||
}
|
||||
playlists.splice(existingIndex, 1, playlist);
|
||||
playlistDB.save();
|
||||
return playlist;
|
||||
@@ -326,6 +472,7 @@ module.exports = {
|
||||
addSong,
|
||||
removeSong,
|
||||
processYoutubeData,
|
||||
syncYoutube,
|
||||
deleteUserPlaylists,
|
||||
refreshPlaylist
|
||||
}
|
||||
+2072
File diff suppressed because it is too large.
Load diff
+471
-385
File diff suppressed because it is too large.
Load diff
@@ -2,10 +2,34 @@ const { LogType } = require('loguix');
|
||||
const dlog = new LogType("DiscordAuth");
|
||||
const { getWebsiteLink, getClientSecret } = require('../../utils/Database/Configuration');
|
||||
|
||||
// Délai maximal d'une requête vers l'API Discord
|
||||
const REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
/**
|
||||
* Appelle l'API Discord et renvoie le JSON, en levant une erreur si le statut HTTP n'est pas 2xx
|
||||
* (429 / 401 renvoient un JSON {message, retry_after} qui ne doit jamais être pris pour un utilisateur)
|
||||
*/
|
||||
async function discordFetch(url, options = {}) {
|
||||
const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT) });
|
||||
let body = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch (e) {
|
||||
body = null;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const detail = body?.error_description || body?.error || body?.message || resp.statusText;
|
||||
const error = new Error(`HTTP ${resp.status} : ${detail}`);
|
||||
error.status = resp.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{auth, identity}|"ACCESS_TOKEN_ERROR"|"USER_INFO_ERROR">}
|
||||
*/
|
||||
async function getDiscordUser(sessionId, auth_code) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const discordBot = require("../../discord/Bot")
|
||||
const discordBotClient = discordBot.getClient()
|
||||
dlog.step.init("discord_auth_" + sessionId, "Authentification Discord de la session :" + sessionId);
|
||||
@@ -18,109 +42,97 @@ async function getDiscordUser(sessionId, auth_code) {
|
||||
params.append("grant_type", "authorization_code");
|
||||
params.append("code", auth_code);
|
||||
params.append("redirect_uri", getWebsiteLink() + "/redirect");
|
||||
params.append("scope", "identify guilds");
|
||||
|
||||
fetch("https://discord.com/api/oauth2/token", {
|
||||
let accessToken;
|
||||
try {
|
||||
accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
}).then(accessTokenResp => accessTokenResp.json()).then(accessToken => {
|
||||
if (accessToken.error) {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + accessToken.error + " : " + accessToken.error_description);
|
||||
resolve("ACCESS_TOKEN_ERROR");
|
||||
return;
|
||||
});
|
||||
if (!accessToken?.access_token || accessToken.error) {
|
||||
throw new Error((accessToken?.error || "Réponse invalide") + " : " + (accessToken?.error_description || ""));
|
||||
}
|
||||
} catch (err) {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err.message);
|
||||
return "ACCESS_TOKEN_ERROR";
|
||||
}
|
||||
|
||||
|
||||
dlog.log("Récupération réussi du token d'accès Discord associé à la session : " + sessionId);
|
||||
|
||||
fetch("https://discord.com/api/users/@me", {
|
||||
// Le scope demandé par le site est "identify" : l'appel /users/@me/guilds était toujours refusé et inutilisé
|
||||
try {
|
||||
const user = await discordFetch("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
|
||||
},
|
||||
}).then(userResp => userResp.json()).then(user => {
|
||||
});
|
||||
if (!user?.id) throw new Error("Identité Discord sans identifiant");
|
||||
dlog.log("Récupération réussi des informations de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")");
|
||||
// Get the guilds of the user
|
||||
fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: {
|
||||
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
|
||||
},
|
||||
}).then(guildsResp => guildsResp.json()).then(guilds => {
|
||||
dlog.log("Récupération réussi des guildes de l'utilisateur Discord associé à la session : " + sessionId + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")");
|
||||
dlog.step.end("discord_auth_" + sessionId)
|
||||
const userData = {
|
||||
return {
|
||||
auth: accessToken,
|
||||
identity: user,
|
||||
guilds: guilds,
|
||||
};
|
||||
} catch (err) {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err.message);
|
||||
return "USER_INFO_ERROR";
|
||||
}
|
||||
resolve(userData);
|
||||
}).catch(err => {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des guildes de l'utilisateur Discord" + " avec le nom d'utilisateur : " + user.username + " (" + user.id + ")" + " associé à la session : " + sessionId + " : " + err);
|
||||
resolve("GUILDS_ERROR");
|
||||
});
|
||||
|
||||
}).catch(err => {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération des informations de l'utilisateur Discord associé à la session : " + sessionId + " : " + err);
|
||||
resolve( "USER_INFO_ERROR");
|
||||
})
|
||||
|
||||
}).catch(err => {
|
||||
dlog.step.error("discord_auth_" + sessionId, "Erreur lors de la récupération du token d'accès Discord associé à la session : " + sessionId + " : " + err);
|
||||
resolve("ACCESS_TOKEN_ERROR");
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function refreshToken(refresh_token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
/**
|
||||
* @returns {Promise<Object|null>} nouveau token d'accès, null en cas d'échec
|
||||
*/
|
||||
async function refreshToken(refresh_token) {
|
||||
if (!refresh_token) return null;
|
||||
const discordBot = require("../../discord/Bot")
|
||||
const params = new URLSearchParams();
|
||||
params.append("client_id", discordBot.getClient().user.id);
|
||||
params.append("client_secret", getClientSecret());
|
||||
params.append("grant_type", "refresh_token");
|
||||
params.append("refresh_token", refresh_token);
|
||||
params.append("scope", "identify guilds");
|
||||
|
||||
fetch("https://discord.com/api/oauth2/token", {
|
||||
try {
|
||||
const accessToken = await discordFetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
}).then(accessTokenResp => accessTokenResp.json()).then(accessToken => {
|
||||
if (accessToken.error) {
|
||||
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken.error + " : " + accessToken.error_description);
|
||||
resolve(null);
|
||||
return;
|
||||
});
|
||||
if (!accessToken?.access_token || accessToken.error) {
|
||||
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + accessToken?.error + " : " + accessToken?.error_description);
|
||||
return null;
|
||||
}
|
||||
return accessToken;
|
||||
} catch (err) {
|
||||
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err.message);
|
||||
return null;
|
||||
}
|
||||
resolve(accessToken);
|
||||
}).catch(err => {
|
||||
dlog.error("Erreur lors de la récupération du token d'accès Discord : " + err);
|
||||
resolve(null);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function getUserIdentity(accessToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch("https://discord.com/api/users/@me", {
|
||||
/**
|
||||
* @returns {Promise<Object|null>} identité Discord (/users/@me), null en cas d'échec
|
||||
*/
|
||||
async function getUserIdentity(accessToken) {
|
||||
if (!accessToken?.access_token) return null;
|
||||
try {
|
||||
const user = await discordFetch("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
authorization: `${accessToken.token_type} ${accessToken.access_token}`,
|
||||
},
|
||||
}).then(userResp => userResp.json()).then(user => {
|
||||
if (user.error) {
|
||||
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + user.error + " : " + user.error_description);
|
||||
resolve(null);
|
||||
return;
|
||||
});
|
||||
if (!user?.id) {
|
||||
dlog.error("Réponse de Discord sans identifiant utilisateur");
|
||||
return null;
|
||||
}
|
||||
resolve(user);
|
||||
}).catch(err => {
|
||||
dlog.error("Erreur lors de la récupération des informations de l'utilisateur Discord : " + err);
|
||||
resolve(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}
|
||||
@@ -2,32 +2,69 @@ const { LogType } = require('loguix');
|
||||
const { generateSessionId } = require('../../utils/TokenManager');
|
||||
const clog = new LogType("Session");
|
||||
|
||||
const sessions = new Array();
|
||||
// Une session OAuth n'est utile que le temps de la redirection Discord : on l'expire après 10 minutes
|
||||
const SESSION_TTL = 10 * 60 * 1000;
|
||||
const CLEAN_INTERVAL = 60 * 1000;
|
||||
|
||||
// sessionId -> date de création (ms)
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* Supprime les sessions expirées (OAuth jamais terminé)
|
||||
*/
|
||||
function purgeExpired() {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [sessionId, createdAt] of sessions) {
|
||||
if (now - createdAt > SESSION_TTL) {
|
||||
sessions.delete(sessionId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) clog.log(`${removed} session(s) expirée(s) supprimée(s).`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
setInterval(purgeExpired, CLEAN_INTERVAL).unref();
|
||||
|
||||
function checkSession(sessionId) {
|
||||
return sessions.includes(sessionId);
|
||||
if (typeof sessionId !== "string") return false;
|
||||
const createdAt = sessions.get(sessionId);
|
||||
if (createdAt === undefined) return false;
|
||||
if (Date.now() - createdAt > SESSION_TTL) {
|
||||
sessions.delete(sessionId);
|
||||
clog.warn(`Session ${sessionId} expirée.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function addSession() {
|
||||
purgeExpired();
|
||||
const sessionId = generateSessionId();
|
||||
if (checkSession(sessionId)) {
|
||||
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`);
|
||||
if (sessions.has(sessionId)) {
|
||||
clog.warn(`La session ${sessionId} existe déjà, génération d'un nouvel identifiant.`);
|
||||
return addSession(); // Recursively generate a new session ID if it already exists
|
||||
}
|
||||
sessions.push(sessionId);
|
||||
sessions.set(sessionId, Date.now());
|
||||
clog.log(`Nouvelle session ${sessionId} ajoutée.`);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function removeSession(sessionId) {
|
||||
const index = sessions.indexOf(sessionId);
|
||||
if (index > -1) {
|
||||
sessions.splice(index, 1);
|
||||
if (sessions.delete(sessionId)) {
|
||||
clog.log(`Suppression de la session ${sessionId}.`);
|
||||
} else {
|
||||
clog.warn(`Session ${sessionId} non trouvée dans la liste des sessions.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {checkSession, addSession, removeSession};
|
||||
/**
|
||||
* Nombre de sessions OAuth en attente (vue d'ensemble de l'administration)
|
||||
*/
|
||||
function getSessionCount() {
|
||||
purgeExpired();
|
||||
return sessions.size;
|
||||
}
|
||||
|
||||
module.exports = {checkSession, addSession, removeSession, getSessionCount};
|
||||
+205
-59
@@ -4,53 +4,86 @@ const { generateToken } = require('../../utils/TokenManager');
|
||||
const { LogType } = require('loguix');
|
||||
const clog = new LogType("User");
|
||||
const discordAuth = require('./DiscordAuth');
|
||||
const e = require('cors');
|
||||
|
||||
const UserDB = new Database("Users", __glob.USERFILE, []);
|
||||
var userList = new Array();
|
||||
|
||||
/**
|
||||
* Ajoute ou retire un label. value === undefined : bascule (ancien comportement).
|
||||
* @returns {boolean} présence du label après l'opération
|
||||
*/
|
||||
function applyLabel(labels, label, value) {
|
||||
const has = labels.includes(label);
|
||||
const wanted = value === undefined ? !has : !!value;
|
||||
if (wanted && !has) labels.push(label);
|
||||
if (!wanted && has) {
|
||||
// Retire toutes les occurrences (doublons éventuels dans users.json)
|
||||
for (let i = labels.length - 1; i >= 0; i--) {
|
||||
if (labels[i] === label) labels.splice(i, 1);
|
||||
}
|
||||
}
|
||||
return wanted;
|
||||
}
|
||||
|
||||
class User {
|
||||
auth;
|
||||
identity;
|
||||
tokens;
|
||||
labels;
|
||||
constructor(auth, identity, tokens, labels) {
|
||||
banInfo;
|
||||
constructor(auth, identity, tokens, labels, banInfo) {
|
||||
this.auth = auth;
|
||||
this.identity = identity;
|
||||
this.tokens = tokens;
|
||||
this.labels = labels;
|
||||
this.tokens = tokens || [];
|
||||
this.labels = labels || [];
|
||||
// Raison et date du bannissement global : {reason, at} ou null
|
||||
this.banInfo = banInfo || null;
|
||||
}
|
||||
|
||||
setAdmin() {
|
||||
/**
|
||||
* @param {boolean} [value] true = admin, false = retiré, undefined = bascule
|
||||
* @returns {boolean|null} nouvel état
|
||||
*/
|
||||
setAdmin(value) {
|
||||
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
|
||||
if (!userInUserList) {
|
||||
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
|
||||
return null;
|
||||
}
|
||||
if (userInUserList.labels.includes("ADMIN")) {
|
||||
userInUserList.labels.splice(userInUserList.labels.indexOf("ADMIN"), 1);
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`);
|
||||
} else {
|
||||
userInUserList.labels.push("ADMIN");
|
||||
const wasAdmin = userInUserList.labels.includes("ADMIN");
|
||||
const isAdmin = applyLabel(userInUserList.labels, "ADMIN", value);
|
||||
if (wasAdmin === isAdmin) return isAdmin;
|
||||
if (isAdmin) {
|
||||
clog.log(`L'utilisateur ${this.identity.username} est maintenant admin.`);
|
||||
} else {
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus admin.`);
|
||||
}
|
||||
saveUsers()
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
setBan(guildId) {
|
||||
/**
|
||||
* @param {string} guildId
|
||||
* @param {boolean} [value] undefined = bascule
|
||||
* @returns {boolean|null} nouvel état
|
||||
*/
|
||||
setBan(guildId, value) {
|
||||
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
|
||||
if (!userInUserList) {
|
||||
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
|
||||
return null;
|
||||
}
|
||||
const banLabel = `BAN_${guildId}`;
|
||||
if (userInUserList.labels.includes(banLabel)) {
|
||||
userInUserList.labels.splice(userInUserList.labels.indexOf(banLabel), 1);
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`);
|
||||
} else {
|
||||
userInUserList.labels.push(banLabel);
|
||||
const wasBanned = userInUserList.labels.includes(banLabel);
|
||||
const banned = applyLabel(userInUserList.labels, banLabel, value);
|
||||
if (wasBanned === banned) return banned;
|
||||
if (banned) {
|
||||
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni du serveur ${guildId}.`);
|
||||
} else {
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni du serveur ${guildId}.`);
|
||||
}
|
||||
saveUsers()
|
||||
return banned;
|
||||
}
|
||||
|
||||
createToken() {
|
||||
@@ -118,20 +151,36 @@ class User {
|
||||
|
||||
}
|
||||
|
||||
setFullBan() {
|
||||
/**
|
||||
* Bannissement global
|
||||
* @param {boolean} [value] undefined = bascule
|
||||
* @param {string} [reason]
|
||||
* @returns {boolean|null} nouvel état
|
||||
*/
|
||||
setFullBan(value, reason) {
|
||||
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
|
||||
if (!userInUserList) {
|
||||
clog.warn(`Utilisateur ${this.identity.username} non trouvé dans la liste des utilisateurs.`);
|
||||
return null;
|
||||
}
|
||||
if (userInUserList.labels.find(label => label == "BAN")) {
|
||||
userInUserList.labels.splice(userInUserList.labels.indexOf("BAN"), 1);
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`);
|
||||
const wasBanned = userInUserList.labels.includes("BAN");
|
||||
const banned = applyLabel(userInUserList.labels, "BAN", value);
|
||||
if (banned) {
|
||||
if (!wasBanned || reason) {
|
||||
userInUserList.banInfo = { reason: reason ? String(reason).slice(0, 500) : null, at: new Date().toISOString() };
|
||||
}
|
||||
} else {
|
||||
userInUserList.labels.push("BAN");
|
||||
userInUserList.banInfo = null;
|
||||
}
|
||||
if (wasBanned !== banned) {
|
||||
if (banned) {
|
||||
clog.log(`L'utilisateur ${this.identity.username} est maintenant banni.`);
|
||||
} else {
|
||||
clog.log(`L'utilisateur ${this.identity.username} n'est plus banni.`);
|
||||
}
|
||||
}
|
||||
saveUsers()
|
||||
return banned;
|
||||
}
|
||||
|
||||
isBanned(guildId) {
|
||||
@@ -148,6 +197,9 @@ class User {
|
||||
const modLabel = `MOD_${guildId}`;
|
||||
return this.labels.includes(modLabel) || this.isAdmin();
|
||||
}
|
||||
isDeleted() {
|
||||
return this.labels.includes("DELETED");
|
||||
}
|
||||
|
||||
justUpdated() {
|
||||
const userInUserList = userList.find(user => user.identity.id === this.identity.id);
|
||||
@@ -187,13 +239,19 @@ class User {
|
||||
async function refreshAllUserInformation() {
|
||||
await loadUsers();
|
||||
clog.log("Récupération des informations de tous les utilisateurs...");
|
||||
for (const user of userList) {
|
||||
await updateCredientials(user.identity.id);
|
||||
// Une seule sauvegarde à la fin plutôt qu'une réécriture complète du fichier par utilisateur
|
||||
for (const user of [...userList]) {
|
||||
if (!user.auth) continue;
|
||||
await updateCredientials(user.identity.id, false);
|
||||
}
|
||||
saveUsers();
|
||||
}
|
||||
|
||||
async function updateCredientials(id) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {boolean} [save=true] false pour regrouper les sauvegardes (rafraîchissement global)
|
||||
*/
|
||||
async function updateCredientials(id, save = true) {
|
||||
const user = getUserById(id);
|
||||
if (!user) {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
@@ -223,7 +281,7 @@ async function updateCredientials(id) {
|
||||
else {
|
||||
clog.warn(`Aucune authentification trouvée pour l'utilisateur ${user.identity.username} (${user.identity.id})`);
|
||||
}
|
||||
saveUsers();
|
||||
if (save) saveUsers();
|
||||
return user.auth;
|
||||
}
|
||||
|
||||
@@ -277,6 +335,11 @@ async function updateIdentity(id) {
|
||||
* @returns {User} user
|
||||
*/
|
||||
async function addUser(auth, identity) {
|
||||
// Une réponse d'erreur de Discord (429, 401...) n'a pas d'id : on ne crée jamais d'utilisateur fantôme
|
||||
if (!identity || typeof identity.id !== "string" || identity.id === "") {
|
||||
clog.error("Ajout d'un utilisateur refusé : identité Discord invalide");
|
||||
return null;
|
||||
}
|
||||
// Check if the user already exists
|
||||
const existingUser = userList.find(user => user.identity.id === identity.id);
|
||||
if (existingUser) {
|
||||
@@ -320,12 +383,12 @@ function removeToken(token) {
|
||||
if (index > -1) {
|
||||
user.tokens.splice(index, 1);
|
||||
saveUsers();
|
||||
clog.log(`Token ${token} supprimé pour l'utilisateur ${user.identity.username}.`);
|
||||
clog.log(`Token supprimé pour l'utilisateur ${user.identity.username}.`);
|
||||
} else {
|
||||
clog.warn(`Token ${token} non trouvé pour l'utilisateur ${user.identity.username}.`);
|
||||
clog.warn(`Token non trouvé pour l'utilisateur ${user.identity.username}.`);
|
||||
}
|
||||
} else {
|
||||
clog.warn(`Utilisateur avec le token "${token}" non trouvé.`);
|
||||
clog.warn(`Utilisateur introuvable pour le token fourni.`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -336,7 +399,7 @@ function addToken(id) {
|
||||
const token = generateToken();
|
||||
user.tokens.push(token);
|
||||
saveUsers();
|
||||
clog.log(`Token "${token}" ajouté pour l'utilisateur ${user.identity.username}.`);
|
||||
clog.log(`Token ajouté pour l'utilisateur ${user.identity.username}.`);
|
||||
return token;
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
@@ -392,64 +455,98 @@ function getSimpleUser(id) {
|
||||
|
||||
// SET LABELS
|
||||
|
||||
async function setAdmin(id) {
|
||||
/**
|
||||
* Toutes les fonctions de labels acceptent une valeur explicite (idempotent) ;
|
||||
* sans valeur, elles basculent l'état comme avant (compatibilité).
|
||||
* @returns {Promise<boolean|null>} nouvel état, null si l'utilisateur est introuvable ou protégé
|
||||
*/
|
||||
async function setAdmin(id, value) {
|
||||
const user = getUserById(id);
|
||||
if (user) {
|
||||
await user.setAdmin();
|
||||
await saveUsers();
|
||||
return user.setAdmin(value);
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setGuildMod(id, guildId) {
|
||||
async function setGuildMod(id, guildId, value) {
|
||||
const user = getUserById(id);
|
||||
if (user) {
|
||||
const modLabel = `MOD_${guildId}`;
|
||||
if (user.labels.includes(modLabel)) {
|
||||
await user.labels.splice(user.labels.indexOf(modLabel), 1);
|
||||
clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`);
|
||||
} else {
|
||||
await user.labels.push(modLabel);
|
||||
const wasMod = user.labels.includes(modLabel);
|
||||
const isMod = applyLabel(user.labels, modLabel, value);
|
||||
if (wasMod !== isMod) {
|
||||
if (isMod) {
|
||||
clog.log(`L'utilisateur ${user.identity.username} est maintenant modérateur du serveur ${guildId}.`);
|
||||
} else {
|
||||
clog.log(`L'utilisateur ${user.identity.username} n'est plus modérateur du serveur ${guildId}.`);
|
||||
}
|
||||
await saveUsers();
|
||||
saveUsers();
|
||||
}
|
||||
return isMod;
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setGuildBan(id, guildId) {
|
||||
async function setGuildBan(id, guildId, value) {
|
||||
const user = getUserById(id);
|
||||
if (user) {
|
||||
if(user.isAdmin()) {
|
||||
if(user.isAdmin() && (value === undefined ? !user.isBanned(guildId) : value)) {
|
||||
clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await user.setBan(guildId);
|
||||
await saveUsers();
|
||||
return user.setBan(guildId, value);
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setFullBan(id) {
|
||||
async function setFullBan(id, value, reason) {
|
||||
const user = getUserById(id);
|
||||
if (user) {
|
||||
if(user.isAdmin()) {
|
||||
if(user.isAdmin() && (value === undefined ? !user.isFullBanned() : value)) {
|
||||
clog.warn(`L'utilisateur ${user.identity.username} est admin, il ne peut pas être banni.`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await user.setFullBan();
|
||||
await saveUsers();
|
||||
return user.setFullBan(value, reason);
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAccount(id) {
|
||||
/**
|
||||
* Révoque toutes les sessions web (tokens) d'un utilisateur
|
||||
* @returns {number} nombre de tokens révoqués
|
||||
*/
|
||||
function revokeTokens(id) {
|
||||
const user = getUserById(id);
|
||||
if (user) {
|
||||
if (!user) {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return 0;
|
||||
}
|
||||
const count = user.tokens?.length || 0;
|
||||
if (count > 0) user.clearToken();
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression douce : garde l'entrée et les labels BAN (un banni ne retrouve pas un compte vierge),
|
||||
* ajoute DELETED et efface tokens, authentification et identité détaillée.
|
||||
* @param {string} id
|
||||
* @param {{purge?: boolean}} [options] purge : supprime aussi playlists, historique et médias
|
||||
* @returns {Promise<{playlists: number, history: number, media: number}|null>} éléments purgés, null si introuvable
|
||||
*/
|
||||
async function deleteAccount(id, { purge = false } = {}) {
|
||||
const user = getUserById(id);
|
||||
if (!user) {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
return null;
|
||||
}
|
||||
user.labels = user.labels.filter(label => label.includes('BAN'));
|
||||
user.labels.push('DELETED'); // Add a deleted label
|
||||
user.tokens = []; // Clear tokens
|
||||
@@ -457,9 +554,52 @@ function deleteAccount(id) {
|
||||
user.identity = { id: user.identity.id, username: user.identity.username }; // Keep only identity information
|
||||
saveUsers();
|
||||
clog.log(`Suppression du compte de l'utilisateur ${user.identity.username}.`);
|
||||
} else {
|
||||
clog.warn(`Utilisateur ${id} non trouvé.`);
|
||||
|
||||
// Jetons Google (identifiants, pas des données) : toujours supprimés, révocation chez Google sans attendre
|
||||
try {
|
||||
// Chargement différé : OAuth2.js dépend de ce module
|
||||
require('../../playlists/Google/OAuth2').unlink(id, { revoke: true }).catch((e) => {
|
||||
clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`);
|
||||
});
|
||||
} catch (e) {
|
||||
clog.error(`Suppression du compte Google de ${id} impossible : ${e?.message || e}`);
|
||||
}
|
||||
|
||||
const purged = { playlists: 0, history: 0, media: 0 };
|
||||
if (!purge) return purged;
|
||||
|
||||
// Chargement différé : évite les dépendances circulaires au démarrage
|
||||
const { getDatabase } = require('../../utils/Database/Database');
|
||||
try {
|
||||
const playlistDB = getDatabase("Playlists");
|
||||
purged.playlists = playlistDB?.data?.[id]?.length || 0;
|
||||
await require('../../playlists/PlaylistManager').deleteUserPlaylists(id);
|
||||
} catch (e) {
|
||||
clog.error(`Purge des playlists de ${id} impossible : ${e.message}`);
|
||||
}
|
||||
try {
|
||||
const historyDB = getDatabase("history");
|
||||
purged.history = historyDB?.data?.[id]?.length || 0;
|
||||
if (historyDB?.data?.[id]) {
|
||||
delete historyDB.data[id];
|
||||
historyDB.save();
|
||||
}
|
||||
} catch (e) {
|
||||
clog.error(`Purge de l'historique de ${id} impossible : ${e.message}`);
|
||||
}
|
||||
try {
|
||||
const mediaDB = getDatabase("media");
|
||||
if (Array.isArray(mediaDB?.data)) {
|
||||
const before = mediaDB.data.length;
|
||||
mediaDB.data = mediaDB.data.filter(m => m.userId !== id);
|
||||
purged.media = before - mediaDB.data.length;
|
||||
if (purged.media > 0) mediaDB.save();
|
||||
}
|
||||
} catch (e) {
|
||||
clog.error(`Purge des médias de ${id} impossible : ${e.message}`);
|
||||
}
|
||||
clog.log(`Purge des données de l'utilisateur ${id} : ${purged.playlists} playlist(s), ${purged.history} entrée(s) d'historique, ${purged.media} média(s).`);
|
||||
return purged;
|
||||
}
|
||||
|
||||
// USERS DB
|
||||
@@ -468,12 +608,15 @@ function loadUsers() {
|
||||
UserDB.load()
|
||||
userList = new Array();
|
||||
for (const user of UserDB.getData()) {
|
||||
if(!user?.identity?.id) {
|
||||
clog.warn("Entrée utilisateur sans identifiant ignorée dans users.json.");
|
||||
continue;
|
||||
}
|
||||
if(user?.labels?.includes("DELETED")) {
|
||||
clog.log(`Utilisateur ${user.identity.id} marqué comme supprimé, ignoré.`);
|
||||
userList.push(new User(null, user.identity, [], user.labels));
|
||||
userList.push(new User(null, user.identity, [], user.labels, user.banInfo));
|
||||
continue; // Skip deleted users
|
||||
}
|
||||
userList.push(new User(user.auth, user.identity, user.tokens, user.labels));
|
||||
userList.push(new User(user.auth, user.identity, user.tokens, user.labels, user.banInfo));
|
||||
}
|
||||
clog.log(`Chargement de ${userList.length} utilisateurs.`);
|
||||
return userList;
|
||||
@@ -481,12 +624,14 @@ function loadUsers() {
|
||||
|
||||
function saveUsers() {
|
||||
UserDB.data = userList.map(user => {
|
||||
return {
|
||||
const data = {
|
||||
auth: user.auth,
|
||||
identity: user.identity,
|
||||
tokens: user.tokens,
|
||||
labels: user.labels,
|
||||
};
|
||||
if (user.banInfo) data.banInfo = user.banInfo;
|
||||
return data;
|
||||
});
|
||||
UserDB.save()
|
||||
clog.log(`Sauvegarde de ${userList.length} utilisateurs.`);
|
||||
@@ -503,8 +648,8 @@ function clearNeedUpdateForUsers() {
|
||||
}
|
||||
|
||||
|
||||
module.exports = {User}
|
||||
module.exports = {
|
||||
User,
|
||||
addUser,
|
||||
setFullBan,
|
||||
removeUser,
|
||||
@@ -522,5 +667,6 @@ module.exports = {
|
||||
refreshAllUserInformation,
|
||||
updateIdentity,
|
||||
clearNeedUpdateForUsers,
|
||||
deleteAccount
|
||||
deleteAccount,
|
||||
revokeTokens
|
||||
};
|
||||
@@ -2,7 +2,6 @@ const {Database} = require("./Database")
|
||||
const {__glob} = require("../GlobalVars")
|
||||
const {LogType} = require("loguix")
|
||||
const path = require("path")
|
||||
const { get } = require("http")
|
||||
|
||||
const clog = new LogType("Configuration")
|
||||
|
||||
@@ -31,33 +30,194 @@ const config = new Database("config", __glob.DATA + path.sep + "config.json", {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Clés modifiables depuis l'administration (liste blanche : aucune autre clé n'est accessible).
|
||||
* - type : snowflake | url | port | text
|
||||
* - secret : la valeur n'est jamais renvoyée (seulement {set, length}) ni journalisée
|
||||
* - optional : la chaîne vide est acceptée (efface la valeur)
|
||||
* - requiresRestart : la valeur n'est lue qu'au démarrage (ou à la connexion du bot)
|
||||
*/
|
||||
const KEYS = {
|
||||
"token": { type: "text", secret: true, optional: false, requiresRestart: true, max: 200 },
|
||||
// Lu à chaque connexion OAuth Discord (DiscordAuth.js) : pris en compte à chaud
|
||||
"client_secret": { type: "text", secret: true, optional: false, requiresRestart: false, max: 200 },
|
||||
"website": { type: "url", secret: false, optional: true, requiresRestart: false, max: 300 },
|
||||
"server_port": { type: "port", secret: false, optional: false, requiresRestart: true },
|
||||
"report.channel": { type: "snowflake", secret: false, optional: true, requiresRestart: false },
|
||||
"report.contact": { type: "snowflake", secret: false, optional: true, requiresRestart: false },
|
||||
// MediaBase résout le salon une seule fois, quand le bot se connecte à Discord
|
||||
"media.guildId": { type: "snowflake", secret: false, optional: true, requiresRestart: true },
|
||||
"media.channelId": { type: "snowflake", secret: false, optional: true, requiresRestart: true },
|
||||
"api.youtube.clientId": { type: "text", secret: false, optional: true, requiresRestart: false, max: 300 },
|
||||
"api.youtube.clientSecret": { type: "text", secret: true, optional: true, requiresRestart: false, max: 300 },
|
||||
"api.spotify.clientId": { type: "text", secret: false, optional: true, requiresRestart: false, max: 200 },
|
||||
"api.spotify.clientSecret": { type: "text", secret: true, optional: true, requiresRestart: false, max: 200 }
|
||||
}
|
||||
|
||||
const SNOWFLAKE = /^\d{5,25}$/
|
||||
|
||||
function invalid(message) {
|
||||
return Object.assign(new Error(message), { code: "INVALID_PARAMS" })
|
||||
}
|
||||
|
||||
function readPath(key) {
|
||||
let node = config.data
|
||||
for (const part of key.split(".")) {
|
||||
if (node === null || typeof node !== "object") return undefined
|
||||
node = node[part]
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function writePath(key, value) {
|
||||
const parts = key.split(".")
|
||||
let node = config.data
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
// Fichier édité à la main sans la section : on la recrée
|
||||
if (node[part] === null || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {}
|
||||
node = node[part]
|
||||
}
|
||||
node[parts[parts.length - 1]] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et normalise une valeur pour une clé de la liste blanche (fonction pure).
|
||||
* @returns {string|number} valeur à enregistrer
|
||||
* @throws {Error} code INVALID_PARAMS, message en français
|
||||
*/
|
||||
function validateValue(key, value) {
|
||||
const def = KEYS[key]
|
||||
if (!def) throw invalid(`Clé de configuration inconnue : "${key}"`)
|
||||
|
||||
if (def.type === "port") {
|
||||
const n = typeof value === "string" && value.trim() !== "" ? Number(value.trim()) : value
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) throw invalid("Port invalide (entier entre 1 et 65535)")
|
||||
return n
|
||||
}
|
||||
|
||||
if (typeof value !== "string") throw invalid(`Valeur invalide pour "${key}" (texte attendu)`)
|
||||
const text = value.trim()
|
||||
if (text === "") {
|
||||
if (!def.optional) throw invalid(`"${key}" ne peut pas être vide`)
|
||||
return ""
|
||||
}
|
||||
if (/[\r\n\0]/.test(text)) throw invalid(`Valeur invalide pour "${key}" (caractères interdits)`)
|
||||
|
||||
switch (def.type) {
|
||||
case "snowflake":
|
||||
if (!SNOWFLAKE.test(text)) throw invalid(`Identifiant Discord invalide pour "${key}" (5 à 25 chiffres)`)
|
||||
return text
|
||||
case "url": {
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = new URL(text)
|
||||
} catch (e) {
|
||||
parsed = null
|
||||
}
|
||||
if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(text)) throw invalid("Adresse du site invalide (lien http(s) attendu)")
|
||||
if (parsed.username || parsed.password) throw invalid("L'adresse du site ne doit pas contenir d'identifiants")
|
||||
if (parsed.search || parsed.hash) throw invalid("L'adresse du site ne doit contenir ni paramètres (?) ni ancre (#)")
|
||||
if (text.length > def.max) throw invalid(`Adresse du site trop longue (${def.max} caractères max)`)
|
||||
// Sans "/" final : les modules ajoutent "/redirect" et "/oauth2callback"
|
||||
return (parsed.origin + parsed.pathname).replace(/\/+$/, "")
|
||||
}
|
||||
default:
|
||||
// Jetons et identifiants d'API ne contiennent jamais d'espace (copier-coller incomplet)
|
||||
if (/\s/.test(text)) throw invalid(`Valeur invalide pour "${key}" (espaces interdits)`)
|
||||
if (text.length > def.max) throw invalid(`Valeur trop longue pour "${key}" (${def.max} caractères max)`)
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// Valeurs lues au démarrage : une clé « redémarrage requis » modifiée depuis est signalée en attente
|
||||
const bootValues = {}
|
||||
for (const [key, def] of Object.entries(KEYS)) {
|
||||
if (def.requiresRestart) bootValues[key] = readPath(key)
|
||||
}
|
||||
|
||||
function isPendingRestart(key) {
|
||||
return !!KEYS[key]?.requiresRestart && readPath(key) !== bootValues[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration pour l'administration : jamais la valeur d'un secret, seulement {set, length}
|
||||
*/
|
||||
function getMasked() {
|
||||
const result = {}
|
||||
for (const [key, def] of Object.entries(KEYS)) {
|
||||
const raw = readPath(key)
|
||||
const entry = {
|
||||
type: def.type,
|
||||
secret: def.secret,
|
||||
optional: def.optional,
|
||||
requiresRestart: def.requiresRestart,
|
||||
pendingRestart: isPendingRestart(key)
|
||||
}
|
||||
if (def.secret) {
|
||||
const text = typeof raw === "string" ? raw : ""
|
||||
entry.set = text.length > 0
|
||||
entry.length = text.length
|
||||
} else {
|
||||
entry.value = raw === undefined || raw === null ? (def.type === "port" ? null : "") : raw
|
||||
}
|
||||
result[key] = entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie une clé de la liste blanche, enregistre config.json puis prévient les modules (CONFIG_UPDATE)
|
||||
* @returns {{key: string, changed: boolean, requiresRestart: boolean, pendingRestart: boolean}}
|
||||
*/
|
||||
function setValue(key, value) {
|
||||
const normalized = validateValue(key, value)
|
||||
const def = KEYS[key]
|
||||
const changed = readPath(key) !== normalized
|
||||
if (changed) {
|
||||
writePath(key, normalized)
|
||||
config.save()
|
||||
// Jamais la valeur dans les logs (secrets)
|
||||
clog.log(`Configuration modifiée : ${key}` + (def.requiresRestart ? " (redémarrage requis)" : ""))
|
||||
try {
|
||||
process.emit("CONFIG_UPDATE", { key })
|
||||
} catch (e) {
|
||||
clog.error(`Notification de la modification de configuration impossible : ${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
return { key, changed, requiresRestart: def.requiresRestart, pendingRestart: isPendingRestart(key) }
|
||||
}
|
||||
|
||||
function getKeys() {
|
||||
return Object.keys(KEYS)
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return config.data.token
|
||||
}
|
||||
|
||||
function getReportChannel() {
|
||||
return config.data.report.channel
|
||||
return config.data.report?.channel
|
||||
}
|
||||
|
||||
function getReportContact() {
|
||||
return config.data.report.contact
|
||||
return config.data.report?.contact
|
||||
}
|
||||
|
||||
function getYoutubeApiClientId() {
|
||||
return config.data.api.youtube.clientId
|
||||
return config.data.api?.youtube?.clientId
|
||||
}
|
||||
|
||||
function getYoutubeApiClientSecret() {
|
||||
return config.data.api.youtube.clientSecret
|
||||
return config.data.api?.youtube?.clientSecret
|
||||
}
|
||||
|
||||
function getSpotifyClientId() {
|
||||
return config.data.api.spotify.clientId
|
||||
return config.data.api?.spotify?.clientId
|
||||
}
|
||||
|
||||
function getSpotifyClientSecret() {
|
||||
|
||||
return config.data.api.spotify.clientSecret
|
||||
return config.data.api?.spotify?.clientSecret
|
||||
}
|
||||
|
||||
function getWebsiteLink() {
|
||||
@@ -73,15 +233,16 @@ function getClientSecret() {
|
||||
}
|
||||
|
||||
function getMediaGuildId() {
|
||||
return config.data.media.guildId
|
||||
return config.data.media?.guildId
|
||||
}
|
||||
|
||||
function getMediaChannelId() {
|
||||
return config.data.media.channelId
|
||||
return config.data.media?.channelId
|
||||
}
|
||||
|
||||
if(getToken() == "") {
|
||||
clog.error("Impossible de démarrer sans token valide")
|
||||
if(!getToken()) {
|
||||
// Sous Docker, data/ n'est pas dans l'image : config.json doit être déposé dans le volume monté sur /app/data
|
||||
clog.error("Impossible de démarrer sans token valide : renseignez 'token' dans " + __glob.DATA + path.sep + "config.json")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -97,6 +258,11 @@ module.exports = {
|
||||
getWebsiteLink,
|
||||
getPort,
|
||||
getMediaGuildId,
|
||||
getMediaChannelId
|
||||
getMediaChannelId,
|
||||
// Administration
|
||||
getMasked,
|
||||
setValue,
|
||||
validateValue,
|
||||
getKeys
|
||||
|
||||
}
|
||||
+116
-20
@@ -11,31 +11,68 @@ function getDatabase(name) {
|
||||
return AllDatabases[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture atomique : fichier temporaire puis renommage, pour ne jamais laisser
|
||||
* un fichier tronqué si le process est tué pendant l'écriture.
|
||||
* Sous Windows / OneDrive, le renommage peut échouer (fichier verrouillé) : on retente
|
||||
* puis on se replie sur une écriture directe.
|
||||
* @param {string} filePath
|
||||
* @param {string} content
|
||||
*/
|
||||
function writeFileAtomic(filePath, content) {
|
||||
const tmp = filePath + ".tmp"
|
||||
// Le temporaire reprend les droits du fichier existant (ex : google.json en 600, jamais lisible même brièvement)
|
||||
let mode
|
||||
try { mode = fs.statSync(filePath).mode & 0o777 } catch(e) { /* nouveau fichier : droits par défaut */ }
|
||||
fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : undefined)
|
||||
let lastError = null
|
||||
for(let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmp, filePath)
|
||||
return
|
||||
} catch(e) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
// Repli : écriture directe (non atomique) puis nettoyage du temporaire
|
||||
clog.warn(`Renommage atomique impossible pour '${filePath}' (${lastError?.code || lastError}), écriture directe`)
|
||||
fs.writeFileSync(filePath, content)
|
||||
try { fs.unlinkSync(tmp) } catch(e) { /* temporaire déjà absent */ }
|
||||
}
|
||||
|
||||
class Database {
|
||||
data;
|
||||
path;
|
||||
name;
|
||||
empty;
|
||||
constructor(name, path, empty) {
|
||||
if(name == undefined || path == undefined) throw clog.error("Impossible de créer une base de données sans nom ou sans chemin")
|
||||
if(name == undefined || path == undefined) {
|
||||
clog.error("Impossible de créer une base de données sans nom ou sans chemin")
|
||||
throw new Error("Impossible de créer une base de données sans nom ou sans chemin")
|
||||
}
|
||||
|
||||
clog.log(`Enregistrement de la base de données '${name}' contenu dans ${path}`)
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.data = {};
|
||||
this.loaded = false;
|
||||
if(empty) this.empty = empty; else this.empty = {}
|
||||
this.data = this.getEmpty();
|
||||
this.load()
|
||||
AllDatabases[name] = this
|
||||
}
|
||||
|
||||
// Copie profonde de la valeur par défaut (jamais partagée avec this.data)
|
||||
getEmpty() {
|
||||
return JSON.parse(JSON.stringify(this.empty))
|
||||
}
|
||||
|
||||
load() {
|
||||
clog.log(`Chargement de la base de données '${this.name}'`)
|
||||
try {
|
||||
this.create()
|
||||
this.update()
|
||||
} catch(e) {
|
||||
clog.error(`Erreur lors du chargement de la base de données '${this.name}'`)
|
||||
clog.error(e)
|
||||
clog.error(e?.stack || e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -44,43 +81,102 @@ class Database {
|
||||
try {
|
||||
if(!fs.existsSync(this.path)) {
|
||||
clog.warn(`Le fichier de la base de données '${this.name}' n'existe pas, création du fichier`)
|
||||
fs.writeFileSync(this.path, JSON.stringify(this.empty, null, 2))
|
||||
fs.mkdirSync(path.dirname(this.path), { recursive: true })
|
||||
writeFileAtomic(this.path, JSON.stringify(this.empty, null, 2))
|
||||
}
|
||||
} catch(e) {
|
||||
clog.error(`Erreur lors de la création de la base de données '${this.name}'`)
|
||||
clog.error(e)
|
||||
clog.error(e?.stack || e)
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
try {
|
||||
clog.log(`Sauvegarde de la base de données '${this.name}'`)
|
||||
fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2))
|
||||
|
||||
const content = JSON.stringify(this.data, null, 2)
|
||||
if(content === undefined) throw new Error("Données non sérialisables")
|
||||
writeFileAtomic(this.path, content)
|
||||
} catch(e) {
|
||||
clog.error(`Erreur lors de la sauvegarde de la base de données '${this.name}'`)
|
||||
clog.error(e)
|
||||
clog.error(e?.stack || e)
|
||||
}
|
||||
|
||||
// Assure that the database is up to date and reloaded
|
||||
this.update()
|
||||
// La mémoire fait foi : plus de relecture du fichier après la sauvegarde.
|
||||
// (Relire remplaçait this.data par un nouvel objet : les références détenues
|
||||
// ailleurs devenaient orphelines et des modifications concurrentes étaient perdues.)
|
||||
// Pour recharger explicitement depuis le disque, appeler load() ou update().
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que la donnée lue a le même type que la valeur par défaut (tableau / objet)
|
||||
* @param {*} parsed
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidShape(parsed) {
|
||||
if(parsed === null || typeof parsed !== "object") return false
|
||||
return Array.isArray(parsed) === Array.isArray(this.empty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Met de côté un fichier illisible sous le nom <fichier>.corrupted-<timestamp>
|
||||
* @param {string} filePath
|
||||
*/
|
||||
quarantine(filePath) {
|
||||
const target = filePath + ".corrupted-" + Date.now()
|
||||
try {
|
||||
fs.renameSync(filePath, target)
|
||||
} catch(e) {
|
||||
// Renommage refusé (Windows / OneDrive) : copie à la place
|
||||
fs.copyFileSync(filePath, target)
|
||||
}
|
||||
clog.warn(`Fichier corrompu de la base de données '${this.name}' sauvegardé sous : ${target}`)
|
||||
}
|
||||
|
||||
update() {
|
||||
let rawdata
|
||||
try {
|
||||
clog.log(`Mise à jour de la base de données '${this.name}'`)
|
||||
let rawdata = fs.readFileSync(this.path);
|
||||
this.data = JSON.parse(rawdata);
|
||||
clog.log(`Base de donnée '${this.name}' chargée avec succès`)
|
||||
|
||||
rawdata = fs.readFileSync(this.path, "utf-8");
|
||||
} catch(e) {
|
||||
clog.error(`Erreur lors de la mise à jour de la base de données '${this.name}'`)
|
||||
clog.error(e)
|
||||
|
||||
// Erreur d'accès (verrou, permission…) : on garde les données en mémoire, sans rien écraser
|
||||
clog.error(`Erreur lors de la lecture de la base de données '${this.name}'`)
|
||||
clog.error(e?.stack || e)
|
||||
return
|
||||
}
|
||||
|
||||
let parsed
|
||||
let valid = false
|
||||
try {
|
||||
// Retrait d'un éventuel BOM UTF-8 (fichier édité à la main sous Windows)
|
||||
parsed = JSON.parse(rawdata.replace(/^/, ""));
|
||||
valid = this.isValidShape(parsed)
|
||||
if(!valid) clog.error(`Contenu inattendu dans la base de données '${this.name}' (type ${Array.isArray(parsed) ? "tableau" : typeof parsed} au lieu de ${Array.isArray(this.empty) ? "tableau" : "objet"})`)
|
||||
} catch(e) {
|
||||
clog.error(`Fichier JSON invalide pour la base de données '${this.name}' : ${e?.message || e}`)
|
||||
}
|
||||
|
||||
if(valid) {
|
||||
this.data = parsed;
|
||||
this.loaded = true
|
||||
return
|
||||
}
|
||||
|
||||
// Fichier corrompu : on le met de côté au lieu de l'écraser plus tard avec une base vide
|
||||
try {
|
||||
this.quarantine(this.path)
|
||||
} catch(e) {
|
||||
clog.error(`Impossible de sauvegarder le fichier corrompu de la base de données '${this.name}', aucune réécriture`)
|
||||
clog.error(e?.stack || e)
|
||||
return
|
||||
}
|
||||
|
||||
if(this.loaded) {
|
||||
// Données déjà chargées en mémoire : elles font foi, on réécrit un fichier sain
|
||||
clog.warn(`Base de données '${this.name}' : réécriture depuis les données en mémoire`)
|
||||
} else {
|
||||
clog.warn(`Base de données '${this.name}' : réinitialisation avec la valeur par défaut`)
|
||||
this.data = this.getEmpty()
|
||||
this.loaded = true
|
||||
}
|
||||
this.save()
|
||||
}
|
||||
|
||||
getData() {
|
||||
|
||||
+10
-2
@@ -4,7 +4,7 @@ const version = JSON.parse(require('fs').readFileSync(root + path.sep + "package
|
||||
|
||||
const __glob = {
|
||||
PACKAGEINFO: root + path.sep + "package.json",
|
||||
ROOT: root + + path.sep,
|
||||
ROOT: root + path.sep,
|
||||
SRC: root + path.sep + "src",
|
||||
LOGS: root + path.sep + "logs",
|
||||
DATA: root + path.sep + "data",
|
||||
@@ -19,7 +19,15 @@ const __glob = {
|
||||
VERSION: version,
|
||||
CHANGELOG_PATH: root + path.sep + "CHANGELOG.html",
|
||||
COOKIES: root + path.sep + "data" + path.sep + "cookies.txt",
|
||||
PROXY: root + path.sep + "data" + path.sep + "proxy.json"
|
||||
PROXY: root + path.sep + "data" + path.sep + "proxy.json",
|
||||
// Ancien export JSON des cookies (navigateur) : importé une seule fois par le module youtube
|
||||
COOKIES_JSON: root + path.sep + "data" + path.sep + "cookies.json",
|
||||
// Module youtube : cookies maîtres, état, réglages et cache persistant de yt-dlp
|
||||
YOUTUBE_DIR: root + path.sep + "data" + path.sep + "youtube",
|
||||
YOUTUBE_COOKIES: root + path.sep + "data" + path.sep + "youtube" + path.sep + "cookies.txt",
|
||||
YOUTUBE_STATE: root + path.sep + "data" + path.sep + "youtube" + path.sep + "state.json",
|
||||
YOUTUBE_SETTINGS: root + path.sep + "data" + path.sep + "youtube" + path.sep + "settings.json",
|
||||
YOUTUBE_CACHE: root + path.sep + "data" + path.sep + "youtube" + path.sep + "cache"
|
||||
}
|
||||
|
||||
module.exports = {__glob}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,11 +1,96 @@
|
||||
const pm2 = require("pm2")
|
||||
const { LogType } = require("loguix")
|
||||
const clog = new LogType("Maintenance")
|
||||
|
||||
function restart(reason) {
|
||||
clog.warn("Redémarrage du serveur Subsonics")
|
||||
clog.warn(`Reason: ${reason}`)
|
||||
pm2.restart("Subsonics")
|
||||
// Délai entre l'annonce et l'arrêt : laisse le temps de répondre aux clients et de quitter les salons vocaux
|
||||
const RESTART_DELAY = 1000
|
||||
|
||||
let restarting = false
|
||||
|
||||
function isRestarting() {
|
||||
return restarting
|
||||
}
|
||||
|
||||
module.exports = {restart}
|
||||
/**
|
||||
* Sauvegarde ce qui peut l'être avant l'arrêt (métriques en mémoire, salons vocaux)
|
||||
*/
|
||||
function prepareShutdown(reason) {
|
||||
try {
|
||||
require("./SafeMetric").flush()
|
||||
} catch (e) {
|
||||
clog.error(`Sauvegarde des métriques impossible avant le redémarrage : ${e.message}`)
|
||||
}
|
||||
try {
|
||||
const players = require("../player/Player")
|
||||
for (const player of players.getAllPlayers()) {
|
||||
if (player.isConnected?.()) Promise.resolve(player.leave("restart")).catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
clog.error(`Impossible de quitter les salons vocaux avant le redémarrage : ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function exitProcess() {
|
||||
clog.warn("Arrêt du processus, il sera relancé par le superviseur (Docker restart: unless-stopped)")
|
||||
// Laisse le temps à loguix d'écrire les dernières lignes
|
||||
setTimeout(() => process.exit(0), 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redémarre le bot.
|
||||
* - sous pm2 (process.env.pm_id défini) : pm2.restart du processus courant
|
||||
* - sinon : process.exit(0), Docker relance le conteneur (restart: unless-stopped).
|
||||
* Attention : nodemon (npm start en local) ne relance pas après un arrêt propre.
|
||||
* @param {string} reason
|
||||
* @returns {boolean} false si un redémarrage est déjà en cours
|
||||
*/
|
||||
function restart(reason) {
|
||||
if (restarting) {
|
||||
clog.warn("Redémarrage déjà en cours, demande ignorée")
|
||||
return false
|
||||
}
|
||||
restarting = true
|
||||
const at = new Date().toISOString()
|
||||
clog.warn("Redémarrage du serveur Subsonics")
|
||||
clog.warn(`Raison : ${reason}`)
|
||||
|
||||
// Prévient les clients web (relayé par le module Admin vers "/MAINTENANCE")
|
||||
try {
|
||||
process.emit("MAINTENANCE", { type: "restart", reason: reason ? String(reason) : null, at })
|
||||
} catch (e) {
|
||||
clog.error(`Notification de maintenance impossible : ${e.message}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
prepareShutdown(reason)
|
||||
if (process.env.pm_id !== undefined) {
|
||||
let pm2
|
||||
try {
|
||||
pm2 = require("pm2")
|
||||
} catch (e) {
|
||||
clog.error(`pm2 indisponible : ${e.message}`)
|
||||
return exitProcess()
|
||||
}
|
||||
pm2.connect((err) => {
|
||||
if (err) {
|
||||
clog.error(`Connexion à pm2 impossible : ${err.message || err}`)
|
||||
return exitProcess()
|
||||
}
|
||||
const target = process.env.name || process.env.pm_id
|
||||
clog.warn(`Redémarrage via pm2 du processus : ${target}`)
|
||||
pm2.restart(target, (err2) => {
|
||||
if (err2) {
|
||||
clog.error(`Redémarrage pm2 impossible : ${err2.message || err2}`)
|
||||
pm2.disconnect()
|
||||
return exitProcess()
|
||||
}
|
||||
pm2.disconnect()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
exitProcess()
|
||||
}
|
||||
}, RESTART_DELAY)
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {restart, isRestarting}
|
||||
@@ -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
|
||||
};
|
||||
+52
-36
@@ -1,46 +1,62 @@
|
||||
/**
|
||||
* Conversion de durées. Toute entrée invalide (NaN, Infinity, undefined, chaîne non numérique, négatif)
|
||||
* est ramenée à 0 : on n'affiche jamais "NaN". Même format que frontend/src/utils/TimeConverter.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une valeur en nombre de secondes fini et >= 0 (sinon 0).
|
||||
* Les chaînes ne sont acceptées que si elles sont strictement numériques ("116.088163" oui, "117290116.0881631..." non).
|
||||
*/
|
||||
function toSeconds(duration) {
|
||||
let value = NaN
|
||||
if (typeof duration === "number") {
|
||||
value = duration
|
||||
} else if (typeof duration === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(duration)) {
|
||||
value = Number(duration)
|
||||
}
|
||||
if (!Number.isFinite(value) || value < 0) return 0
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function pad(number) {
|
||||
return String(number).padStart(2, "0")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} duration Durée en secondes
|
||||
* @returns {string} "3m05s" ou "1h02m03s" ("0m00s" si la durée est invalide)
|
||||
*/
|
||||
function getReadableDuration(duration) {
|
||||
var max = ""
|
||||
duration *= 1000;
|
||||
const total = toSeconds(duration)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
|
||||
const maxhours = Math.floor(duration / 3600000);
|
||||
|
||||
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60);
|
||||
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60);
|
||||
|
||||
|
||||
if (maxsec < 10) {
|
||||
maxsec = `0${maxsec}`;
|
||||
if (hours > 0) {
|
||||
return `${hours}h${pad(minutes)}m${pad(seconds)}s`
|
||||
}
|
||||
|
||||
|
||||
if(maxhours != 0) {
|
||||
|
||||
if (maxmin < 10) {
|
||||
maxmin = `0${maxmin}`;
|
||||
}
|
||||
|
||||
|
||||
max = maxhours + "h" + maxmin + "m" + maxsec
|
||||
} else {
|
||||
max = maxmin + "m" + maxsec + "s"
|
||||
|
||||
}
|
||||
|
||||
return max
|
||||
return `${minutes}m${pad(seconds)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} duration Durée au format hh:mm:ss, mm:ss ou ss
|
||||
* @returns {number} Secondes (0 si le format est invalide)
|
||||
*/
|
||||
function getSecondsDuration(duration) {
|
||||
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss
|
||||
var durationArray = duration.split(":");
|
||||
var seconds = 0;
|
||||
if(durationArray.length == 3) {
|
||||
seconds = parseInt(durationArray[0]) * 3600 + parseInt(durationArray[1]) * 60 + parseInt(durationArray[2]);
|
||||
} else if(durationArray.length == 2) {
|
||||
seconds = parseInt(durationArray[0]) * 60 + parseInt(durationArray[1]);
|
||||
} else {
|
||||
seconds = parseInt(durationArray[0]);
|
||||
if (typeof duration === "number") return toSeconds(duration)
|
||||
if (typeof duration !== "string" || duration.trim() === "") return 0
|
||||
|
||||
const parts = duration.trim().split(":").map(part => parseInt(part, 10))
|
||||
if (parts.length > 3 || parts.some(part => !Number.isFinite(part) || part < 0)) return 0
|
||||
|
||||
// On lit de droite à gauche : secondes, minutes, heures
|
||||
let seconds = 0
|
||||
let multiplier = 1
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
seconds += parts[i] * multiplier
|
||||
multiplier *= 60
|
||||
}
|
||||
return seconds;
|
||||
return seconds
|
||||
}
|
||||
|
||||
module.exports = {getReadableDuration, getSecondsDuration}
|
||||
+282
-24
@@ -2,11 +2,14 @@ const { LogType } = require('loguix');
|
||||
const clog = new LogType("WARP-Proxy");
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { __glob } = require('./GlobalVars');
|
||||
|
||||
const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace";
|
||||
const CHECK_INTERVAL = 60 * 1000;
|
||||
const CHECK_TIMEOUT = 8 * 1000;
|
||||
const URL_MAX_LENGTH = 500;
|
||||
const ALLOWED_PROTOCOLS = ["http:", "https:", "socks5:", "socks5h:"];
|
||||
|
||||
const state = {
|
||||
url: null,
|
||||
@@ -20,20 +23,42 @@ const state = {
|
||||
|
||||
let started = false;
|
||||
let pendingCheck = null;
|
||||
let interval = null;
|
||||
// Incrémenté à chaque changement de configuration : le résultat d'un contrôle lancé avant est ignoré
|
||||
let generation = 0;
|
||||
|
||||
/**
|
||||
* Contenu de data/proxy.json ({} si absent ou illisible)
|
||||
*/
|
||||
function readFileConfig({ quiet = false } = {}) {
|
||||
if (!fs.existsSync(__glob.PROXY)) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(__glob.PROXY, "utf-8").replace(/^\uFEFF/, ""));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (e) {
|
||||
if (!quiet) clog.error(`Impossible de lire ${__glob.PROXY} : ${e.message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs imposés par l'environnement (non modifiables depuis l'administration)
|
||||
*/
|
||||
function getLocks() {
|
||||
const urlLocked = !!process.env.WARP_PROXY;
|
||||
return {
|
||||
url: urlLocked,
|
||||
enabled: urlLocked,
|
||||
fallbackDirect: process.env.WARP_FALLBACK_DIRECT !== undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Priorité : variable d'environnement WARP_PROXY, puis data/proxy.json.
|
||||
* Format de proxy.json : { "enabled": true, "url": "http://127.0.0.1:1080", "fallbackDirect": true }
|
||||
*/
|
||||
function loadConfig() {
|
||||
let fileConfig = {};
|
||||
if (fs.existsSync(__glob.PROXY)) {
|
||||
try {
|
||||
fileConfig = JSON.parse(fs.readFileSync(__glob.PROXY, "utf-8"));
|
||||
} catch (e) {
|
||||
clog.error(`Impossible de lire ${__glob.PROXY} : ${e.message}`);
|
||||
}
|
||||
}
|
||||
const fileConfig = readFileConfig();
|
||||
|
||||
const url = process.env.WARP_PROXY || (fileConfig.enabled !== false ? fileConfig.url : null) || null;
|
||||
const fallbackDirect = process.env.WARP_FALLBACK_DIRECT !== undefined
|
||||
@@ -73,7 +98,7 @@ async function fetchTrace(proxyUrl) {
|
||||
*/
|
||||
function tcpProbe(host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host, port: Number(port) });
|
||||
const socket = net.connect({ host, port: Number(port) || 1080 });
|
||||
socket.setTimeout(CHECK_TIMEOUT);
|
||||
socket.once('connect', () => { socket.destroy(); resolve(); });
|
||||
socket.once('timeout', () => { socket.destroy(); reject(new Error("Délai de connexion dépassé")); });
|
||||
@@ -82,48 +107,80 @@ function tcpProbe(host, port) {
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
const gen = generation;
|
||||
const url = state.url;
|
||||
const wasHealthy = state.healthy;
|
||||
const firstCheck = state.lastCheck === null;
|
||||
|
||||
const result = { healthy: false, warp: state.warp, ip: state.ip, lastError: null };
|
||||
try {
|
||||
const parsed = new URL(state.url);
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol.startsWith("http")) {
|
||||
const trace = await fetchTrace(state.url);
|
||||
state.warp = trace.warp;
|
||||
state.ip = trace.ip;
|
||||
const trace = await fetchTrace(url);
|
||||
result.warp = trace.warp;
|
||||
result.ip = trace.ip;
|
||||
if (trace.warp !== "on" && trace.warp !== "plus") {
|
||||
throw new Error(`Proxy joignable mais le trafic ne passe pas par WARP (warp=${trace.warp})`);
|
||||
}
|
||||
} else {
|
||||
await tcpProbe(parsed.hostname, parsed.port);
|
||||
}
|
||||
state.healthy = true;
|
||||
state.lastError = null;
|
||||
result.healthy = true;
|
||||
} catch (e) {
|
||||
state.healthy = false;
|
||||
state.lastError = e.cause?.message || e.message;
|
||||
result.healthy = false;
|
||||
result.lastError = e.cause?.message || e.message;
|
||||
}
|
||||
|
||||
// Configuration modifiée pendant le contrôle : ce résultat concerne l'ancien proxy
|
||||
if (gen !== generation || url !== state.url) return state.healthy;
|
||||
|
||||
state.healthy = result.healthy;
|
||||
state.warp = result.warp;
|
||||
state.ip = result.ip;
|
||||
state.lastError = result.lastError;
|
||||
state.lastCheck = Date.now();
|
||||
|
||||
if (state.healthy && (!wasHealthy || firstCheck)) {
|
||||
clog.log(`Proxy WARP opérationnel (${state.url}) - IP de sortie : ${state.ip ?? "inconnue"}`);
|
||||
clog.log(`Proxy WARP opérationnel (${maskUrl(state.url)}) - IP de sortie : ${state.ip ?? "inconnue"}`);
|
||||
}
|
||||
if (!state.healthy && (wasHealthy || firstCheck)) {
|
||||
const fallback = state.fallbackDirect ? "connexion directe utilisée en attendant" : "aucun repli configuré";
|
||||
clog.warn(`Proxy WARP indisponible (${state.url}) : ${state.lastError} - ${fallback}`);
|
||||
clog.warn(`Proxy WARP indisponible (${maskUrl(state.url)}) : ${state.lastError} - ${fallback}`);
|
||||
}
|
||||
|
||||
// Prévient l'interface d'administration quand l'état change (et au premier contrôle)
|
||||
if (firstCheck || wasHealthy !== state.healthy) notifyUpdate();
|
||||
|
||||
return state.healthy;
|
||||
}
|
||||
|
||||
function notifyUpdate() {
|
||||
try {
|
||||
process.emit("WARP_UPDATE", getStatus());
|
||||
} catch (e) {
|
||||
clog.error(`Notification de l'état WARP impossible : ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkHealth() {
|
||||
if (!state.url) return Promise.resolve(false);
|
||||
if (!pendingCheck) {
|
||||
pendingCheck = runCheck().finally(() => { pendingCheck = null; });
|
||||
const check = runCheck().finally(() => {
|
||||
// Ne pas effacer un contrôle plus récent (lancé après un changement de configuration)
|
||||
if (pendingCheck === check) pendingCheck = null;
|
||||
});
|
||||
pendingCheck = check;
|
||||
}
|
||||
return pendingCheck;
|
||||
}
|
||||
|
||||
function ensureInterval() {
|
||||
if (interval) return;
|
||||
// Sans proxy, checkHealth() ne fait rien : l'intervalle peut rester en place
|
||||
interval = setInterval(checkHealth, CHECK_INTERVAL);
|
||||
interval.unref();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
@@ -138,7 +195,154 @@ async function start() {
|
||||
}
|
||||
|
||||
await checkHealth();
|
||||
setInterval(checkHealth, CHECK_INTERVAL).unref();
|
||||
ensureInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une modification de configuration (fonction pure, sans lecture ni écriture).
|
||||
* @param {{enabled?: boolean, url?: string|null, fallbackDirect?: boolean}} patch
|
||||
* @param {{url: boolean, enabled: boolean, fallbackDirect: boolean}} [locks]
|
||||
* @returns {{enabled?: boolean, url?: string|null, fallbackDirect?: boolean}} patch normalisé
|
||||
* @throws {Error} code INVALID_PARAMS, message en français
|
||||
*/
|
||||
function validateConfigPatch(patch, locks = getLocks()) {
|
||||
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_PARAMS" });
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw invalid("Configuration invalide (objet attendu)");
|
||||
const allowed = ["enabled", "url", "fallbackDirect"];
|
||||
const unknown = Object.keys(patch).filter(key => !allowed.includes(key));
|
||||
if (unknown.length) throw invalid(`Champ inconnu : ${unknown.join(", ")}`);
|
||||
|
||||
const result = {};
|
||||
for (const key of allowed) {
|
||||
if (patch[key] === undefined) continue;
|
||||
if (locks[key]) {
|
||||
throw invalid(`"${key}" est défini par une variable d'environnement (${key === "fallbackDirect" ? "WARP_FALLBACK_DIRECT" : "WARP_PROXY"}) et ne peut pas être modifié ici`);
|
||||
}
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
if (typeof patch.enabled !== "boolean") throw invalid("\"enabled\" invalide (booléen attendu)");
|
||||
result.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.fallbackDirect !== undefined) {
|
||||
if (typeof patch.fallbackDirect !== "boolean") throw invalid("\"fallbackDirect\" invalide (booléen attendu)");
|
||||
result.fallbackDirect = patch.fallbackDirect;
|
||||
}
|
||||
if (patch.url !== undefined) {
|
||||
if (patch.url === null || (typeof patch.url === "string" && patch.url.trim() === "")) {
|
||||
result.url = null;
|
||||
} else {
|
||||
if (typeof patch.url !== "string") throw invalid("URL du proxy invalide (texte attendu)");
|
||||
const text = patch.url.trim();
|
||||
if (text.length > URL_MAX_LENGTH) throw invalid(`URL du proxy trop longue (${URL_MAX_LENGTH} caractères max)`);
|
||||
if (/\s/.test(text)) throw invalid("URL du proxy invalide (espaces interdits)");
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = new URL(text);
|
||||
} catch (e) {
|
||||
parsed = null;
|
||||
}
|
||||
if (!parsed || !ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
|
||||
throw invalid("URL du proxy invalide (http://, https://, socks5:// ou socks5h://hôte:port attendu)");
|
||||
}
|
||||
if (!parsed.hostname) throw invalid("URL du proxy invalide (hôte manquant)");
|
||||
if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
|
||||
throw invalid("URL du proxy invalide (ni chemin, ni paramètres, ni ancre)");
|
||||
}
|
||||
if (parsed.protocol.startsWith("socks") && !parsed.port) throw invalid("URL du proxy invalide (port obligatoire pour SOCKS)");
|
||||
result.url = text.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
if (result.enabled === true && result.url === null) throw invalid("Impossible d'activer le proxy sans URL");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture atomique de data/proxy.json (contient d'éventuels identifiants : droits 600 si possible)
|
||||
*/
|
||||
function writeFileConfig(content) {
|
||||
fs.mkdirSync(path.dirname(__glob.PROXY), { recursive: true });
|
||||
const tmp = __glob.PROXY + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(content, null, 2), { mode: 0o600 });
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmp, __glob.PROXY);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
// Windows / OneDrive : renommage parfois refusé (fichier verrouillé), écriture directe en repli
|
||||
fs.writeFileSync(__glob.PROXY, JSON.stringify(content, null, 2), { mode: 0o600 });
|
||||
try { fs.unlinkSync(tmp); } catch (e) { /* temporaire déjà absent */ }
|
||||
}
|
||||
try { fs.chmodSync(__glob.PROXY, 0o600); } catch (e) { /* sans effet sous Windows */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit la configuration et l'applique immédiatement (état de santé remis à zéro si l'URL change)
|
||||
*/
|
||||
function reload() {
|
||||
const config = loadConfig();
|
||||
const urlChanged = config.url !== state.url;
|
||||
const fallbackChanged = config.fallbackDirect !== state.fallbackDirect;
|
||||
state.fallbackDirect = config.fallbackDirect;
|
||||
if (urlChanged) {
|
||||
generation++;
|
||||
pendingCheck = null;
|
||||
state.url = config.url;
|
||||
state.healthy = false;
|
||||
state.warp = null;
|
||||
state.ip = null;
|
||||
state.lastCheck = null;
|
||||
state.lastError = null;
|
||||
}
|
||||
started = true;
|
||||
if (state.url) ensureInterval();
|
||||
if (urlChanged || fallbackChanged) notifyUpdate();
|
||||
return { urlChanged, fallbackChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie data/proxy.json depuis l'administration, recharge la configuration et revérifie aussitôt le proxy.
|
||||
* url absente = URL actuelle du fichier conservée (permet de changer les options sans renvoyer d'identifiants).
|
||||
* @param {{check?: boolean}} [options] check:false = sans attendre le contrôle (l'appelant le lance lui-même)
|
||||
* @returns {Promise<{status: Object, changes: string[]}>}
|
||||
*/
|
||||
async function setConfig(patch, { check = true } = {}) {
|
||||
const normalized = validateConfigPatch(patch);
|
||||
const before = readFileConfig();
|
||||
const next = {
|
||||
enabled: before.enabled !== false,
|
||||
url: typeof before.url === "string" && before.url ? before.url : null,
|
||||
fallbackDirect: before.fallbackDirect !== false
|
||||
};
|
||||
if (normalized.url !== undefined) next.url = normalized.url;
|
||||
if (normalized.enabled !== undefined) next.enabled = normalized.enabled;
|
||||
if (normalized.fallbackDirect !== undefined) next.fallbackDirect = normalized.fallbackDirect;
|
||||
if (next.enabled && !next.url) {
|
||||
if (normalized.enabled === true) throw Object.assign(new Error("Impossible d'activer le proxy sans URL"), { code: "INVALID_PARAMS" });
|
||||
// URL effacée : le proxy est désactivé
|
||||
next.enabled = false;
|
||||
}
|
||||
|
||||
// Description des changements (URL masquée : jamais d'identifiants)
|
||||
const changes = [];
|
||||
if ((before.enabled !== false) !== next.enabled) changes.push(`activé: ${before.enabled !== false} -> ${next.enabled}`);
|
||||
if ((before.url || null) !== next.url) changes.push(`url: ${maskUrl(before.url) || "aucune"} -> ${maskUrl(next.url) || "aucune"}`);
|
||||
if ((before.fallbackDirect !== false) !== next.fallbackDirect) changes.push(`repli direct: ${before.fallbackDirect !== false} -> ${next.fallbackDirect}`);
|
||||
|
||||
if (changes.length) {
|
||||
// Les champs inconnus du fichier (ajoutés à la main) sont conservés
|
||||
writeFileConfig({ ...before, ...next });
|
||||
clog.log(`Configuration du proxy modifiée depuis l'administration : ${changes.join(", ")}`);
|
||||
}
|
||||
const { urlChanged } = reload();
|
||||
// Contrôle immédiat du (nouveau) proxy : l'interface reçoit un état à jour dans la réponse
|
||||
if (check && state.url && (urlChanged || changes.length)) await checkHealth();
|
||||
return { status: getStatus(), changes };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,8 +361,62 @@ function reportFailure() {
|
||||
checkHealth();
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return { ...state };
|
||||
/**
|
||||
* Masque les identifiants éventuels de l'URL du proxy (http://user:pass@hote:port -> http://***@hote:port)
|
||||
*/
|
||||
function maskUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.username || parsed.password) {
|
||||
return `${parsed.protocol}//***@${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
// URL illisible : on ne renvoie rien qui pourrait contenir un secret
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus };
|
||||
function hasCredentials(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return !!(parsed.username || parsed.password);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* État du proxy pour l'interface d'administration (jamais l'URL brute)
|
||||
*/
|
||||
function getStatus() {
|
||||
// Lu à chaque appel (vue d'ensemble toutes les 10 s) : sans journaliser un fichier illisible à chaque fois
|
||||
const fileConfig = readFileConfig({ quiet: true });
|
||||
const locks = getLocks();
|
||||
const fileUrl = typeof fileConfig.url === "string" && fileConfig.url ? fileConfig.url : null;
|
||||
return {
|
||||
configured: !!state.url,
|
||||
url: maskUrl(state.url),
|
||||
fallbackDirect: state.fallbackDirect,
|
||||
healthy: state.healthy,
|
||||
warp: state.warp ?? null,
|
||||
ip: state.ip ?? null,
|
||||
lastCheck: state.lastCheck ? new Date(state.lastCheck).toISOString() : null,
|
||||
lastError: state.lastError ?? null,
|
||||
routes: getRoutes().map(route => (route ? "warp" : "direct")),
|
||||
checkIntervalMs: CHECK_INTERVAL,
|
||||
// Édition depuis l'administration
|
||||
source: process.env.WARP_PROXY ? "env" : (fileUrl ? "file" : "none"),
|
||||
locked: locks,
|
||||
file: {
|
||||
enabled: fileConfig.enabled !== false && !!fileUrl,
|
||||
url: maskUrl(fileUrl),
|
||||
hasCredentials: hasCredentials(fileUrl),
|
||||
fallbackDirect: fileConfig.fallbackDirect !== false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { start, getRoutes, reportFailure, checkHealth, getStatus, maskUrl, setConfig, validateConfigPatch, reload };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -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).
|
||||
@@ -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 };
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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; });
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
@@ -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) })
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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é")
|
||||
@@ -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 })));
|
||||
Reference in new issue
Block a user