Files
chopin/src/server/Admin.js
T
2026-10-08 10:06:46 +02:00

2179 lines
89 KiB
JavaScript

/**
* [Subsonics Chopin - Backend] - Interface d'administration générale
* Toutes les routes Socket.IO "/ADMIN/*" : vue d'ensemble, logs, comptes, serveurs Discord, players, WARP, YouTube,
* métriques et maintenance. Contrat : réponse {ok:true, data} ou {ok:false, error, message} via l'ack
* (ou socket.emit(name, env) sans ack).
*
* - attach({ io, socket, socketUser }) : enregistre les routes pour un socket authentifié, SANS condition :
* les droits sont revérifiés à chaque appel sur l'utilisateur relu dans users.json (jamais un objet périmé).
* - init(io) : poussées globales vers les rooms "ADMIN" et "ADMIN_LOGS" (appelé une seule fois).
*/
const os = require('os')
const fs = require('fs')
const path = require('path')
const { LogType } = require('loguix')
const users = require('./auth/User')
const session = require('./auth/Session')
const players = require('../player/Player')
const discordBot = require('../discord/Bot')
const serverSettings = require('../discord/ServerSettings')
const playlists = require('../playlists/PlaylistManager')
const guildPlaylists = require('../playlists/GuildPlaylists')
const warp = require('../utils/WarpProxy')
const SafeMetric = require('../utils/SafeMetric')
const LogReader = require('../utils/LogReader')
const maintenance = require('../utils/Maintenance')
const configuration = require('../utils/Database/Configuration')
const { getDatabase } = require('../utils/Database/Database')
const { __glob } = require('../utils/GlobalVars')
const youtube = require('../youtube')
const alog = new LogType("Admin")
const ADMIN_ROOM = "ADMIN"
const LOGS_ROOM = "ADMIN_LOGS"
const OVERVIEW_INTERVAL = 10 * 1000
const WARP_CHECK_TIMEOUT = 10 * 1000
const SNOWFLAKE = /^\d{5,25}$/
// YouTube : attente max de la version de yt-dlp avant de répondre (le sondage continue en arrière-plan)
const YOUTUBE_VERSION_WAIT = 3000
// Import depuis un navigateur : sous le délai de 60 s du client
const YOUTUBE_BROWSER_TIMEOUT = 50 * 1000
const YOUTUBE_RECENT = 50
const COOKIES_MAX_LENGTH = 1000000
const COOKIE_IMPORT_SOURCES = ["paste", "file"]
const YOUTUBE_BROWSERS = ["firefox", "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "safari"]
// Navigateurs Chromium : base de cookies verrouillée / chiffrée sous Windows
const CHROMIUM_BROWSERS = ["chrome", "chromium", "edge", "brave", "opera", "vivaldi"]
const YOUTUBE_TEST_ROUTES = ["auto", "warp", "direct"]
const YOUTUBE_TEST_IDENTITIES = ["auto", "anon", "cookies"]
const YOUTUBE_SETTINGS_MAX_KEYS = 20
// Test des identifiants Spotify (jeton client credentials)
const SPOTIFY_TEST_TIMEOUT = 15 * 1000
// Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger
const YTDLP_PROCESS_POLL = 2000
// Jamais "config" : contient le token du bot et les secrets
const DATABASES = ["Users", "Playlists", "GuildPlaylists", "history", "media", "previous", "server_settings"]
// Playlists partagées : qui peut les modifier (tous les membres / modérateurs)
const SHARED_PLAYLISTS_EDITORS = ["members", "mods"]
const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"]
const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"]
const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"]
let io = null
let initialized = false
let browserImportPending = false
class AdminError extends Error {
/**
* @param {"FORBIDDEN"|"INVALID_PARAMS"|"NOT_FOUND"|"SELF_ACTION"|"TARGET_PROTECTED"|"UNAVAILABLE"|"INTERNAL"} code
* @param {string} [message]
*/
constructor(code, message) {
super(message || code)
this.code = code
}
}
// UTILITAIRES
function safeNum(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function toIso(value) {
if (value === null || value === undefined || value === "") return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function asObject(payload) {
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {}
}
function requireId(value, name = "userId") {
if (typeof value !== "string" || !SNOWFLAKE.test(value)) {
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide`)
}
return value
}
function requireBool(value, name = "value") {
if (typeof value !== "boolean") {
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (booléen attendu)`)
}
return value
}
function requireString(value, name, min, max) {
if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`)
const text = value.trim()
if (text.length < min || text.length > max) {
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (${min} à ${max} caractères)`)
}
return text
}
function optionalString(value, name, max) {
if (value === undefined || value === null) return null
if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`)
const text = value.trim()
if (text.length > max) throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" trop long (${max} caractères max)`)
return text === "" ? null : text
}
function optionalInt(value, name, min, max, fallback) {
if (value === undefined || value === null) return fallback
const n = Number(value)
if (!Number.isInteger(n) || n < min || n > max) {
throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (entier entre ${min} et ${max})`)
}
return n
}
/**
* Les anciens endpoints acceptent un userId en chaîne, les nouveaux un objet {userId}
*/
function userIdFrom(payload) {
if (typeof payload === "string") return requireId(payload)
return requireId(asObject(payload).userId)
}
function describeUser(user) {
return `${user?.identity?.username ?? "inconnu"} (${user?.identity?.id ?? "?"})`
}
/**
* Journalise chaque action mutante
*/
function audit(me, action, target = "") {
alog.warn(`[ADMIN] ${me.identity.username} -> ${action}${target ? " " + target : ""}`)
}
function roomSize(room) {
return io?.sockets.adapter.rooms.get(room)?.size || 0
}
function getClient() {
return discordBot.getClient()
}
function getYoutubeProcesses() {
try {
const map = require('../player/Method/Youtube').activeProcesses
return map instanceof Map ? map : null
} catch (e) {
return null
}
}
function getMediaBase() {
try {
return require('../discord/MediaBase')
} catch (e) {
return null
}
}
function dbData(name, fallback) {
const data = getDatabase(name)?.data
return data === undefined || data === null ? fallback : data
}
/**
* Coalesce les appels : fn est exécutée au plus une fois par délai
*/
function debounce(fn, delay) {
let timer = null
return () => {
if (timer) return
timer = setTimeout(async () => {
timer = null
try {
await fn()
} catch (e) {
alog.error(`Poussée admin impossible : ${e?.stack || e}`)
}
}, delay)
timer.unref?.()
}
}
// TYPES SÉRIALISÉS
function userLite(identity) {
return {
id: identity?.id ?? null,
username: identity?.username ?? null,
global_name: identity?.global_name ?? null,
avatar: identity?.avatar ?? null
}
}
function songLite(song) {
if (!song || typeof song !== "object") return null
const duration = Number(song.duration)
return {
id: song.id ?? null,
title: song.title ?? null,
author: song.author ?? null,
authorId: song.authorId ?? null,
url: song.url ?? null,
thumbnail: song.thumbnail ?? null,
duration: Number.isFinite(duration) && duration > 0 ? duration : null,
readduration: song.readduration ?? null,
type: song.type ?? null
}
}
/**
* Index des sockets authentifiés par utilisateur (socket.data.userId posé par Server.js à la connexion)
* @returns {Map<string, import('socket.io').Socket[]>}
*/
function socketIndex() {
const byUser = new Map()
if (!io) return byUser
for (const socket of io.sockets.sockets.values()) {
const userId = socket.data?.userId
if (!userId) continue
if (!byUser.has(userId)) byUser.set(userId, [])
byUser.get(userId).push(socket)
}
return byUser
}
function socketGuildId(socket) {
const guilds = discordBot.getGuilds()
for (const room of socket.rooms) {
if (guilds.has(room)) return room
}
return null
}
/**
* Données partagées pour construire plusieurs résumés sans tout recalculer
*/
function buildContext() {
const guildsByUser = new Map()
for (const [guildId, guild] of discordBot.getGuilds()) {
for (const memberId of guild.allMembers || []) {
if (!guildsByUser.has(memberId)) guildsByUser.set(memberId, [])
guildsByUser.get(memberId).push(guildId)
}
}
const mediaCounts = new Map()
const media = dbData("media", [])
if (Array.isArray(media)) {
for (const m of media) mediaCounts.set(m?.userId, (mediaCounts.get(m?.userId) || 0) + 1)
}
const registered = new Set(users.getUsers().filter(u => !u.labels?.includes("DELETED")).map(u => u.identity.id))
return {
sockets: socketIndex(),
guildsByUser,
playlists: dbData("Playlists", {}),
history: dbData("history", {}),
mediaCounts,
registered
}
}
function userSummary(user, ctx = buildContext()) {
const id = user.identity.id
const labels = Array.isArray(user.labels) ? [...user.labels] : []
const sockets = ctx.sockets.get(id) || []
const updated = labels.find(l => l.startsWith("UPDATED["))
let currentGuildId = null
for (const socket of sockets) {
currentGuildId = socketGuildId(socket)
if (currentGuildId) break
}
const playlistsOfUser = ctx.playlists?.[id]
const historyOfUser = ctx.history?.[id]
return {
...userLite(user.identity),
labels,
isAdmin: labels.includes("ADMIN"),
isFullBanned: labels.includes("BAN"),
isDeleted: labels.includes("DELETED"),
modGuilds: labels.filter(l => l.startsWith("MOD_")).map(l => l.slice(4)),
bannedGuilds: labels.filter(l => l.startsWith("BAN_")).map(l => l.slice(4)),
banReason: user.banInfo?.reason ?? null,
bannedAt: toIso(user.banInfo?.at),
lastIdentityUpdate: updated ? toIso(updated.slice("UPDATED[".length, -1)) : null,
hasDiscordAuth: !!user.auth,
tokenCount: Array.isArray(user.tokens) ? user.tokens.length : 0,
online: sockets.length > 0,
socketCount: sockets.length,
currentGuildId,
guildIds: ctx.guildsByUser.get(id) || [],
playlistCount: Array.isArray(playlistsOfUser) ? playlistsOfUser.length : 0,
historyCount: Array.isArray(historyOfUser) ? historyOfUser.length : 0,
mediaCount: ctx.mediaCounts.get(id) || 0
}
}
function isConnectedPlayer(player) {
if (!player) return false
try {
if (typeof player.isConnected === "function") return !!player.isConnected()
} catch (e) {
return false
}
return !!player.connection
}
function connectedPlayers() {
return players.getAllPlayers().filter(isConnectedPlayer)
}
/**
* Statut simplifié d'un état de player (v2 : state.status ; ancien format : playerState)
*/
function playerStatus(state) {
if (state?.status) return state.status
if (state?.playing) return "playing"
if (state?.paused) return "paused"
return "idle"
}
/**
* PlayerSummary = état v2 du player (next tronqué à 10) + informations d'administration
*/
function playerSummary(player, { full = false } = {}) {
let state
try {
state = player.getState()
} catch (e) {
alog.error(`Lecture de l'état du player ${player?.guildId} impossible : ${e?.stack || e}`)
return null
}
if (!state) return null
const guildId = state.guildId || player.guildId
const guild = discordBot.getGuilds().get(guildId)
const channelId = state.connection?.channelId ?? state.channelId ?? player.channelId ?? null
let listeners = 0
try {
const channel = channelId ? discordBot.getChannel(guildId, channelId) : null
listeners = channel?.members ? channel.members.filter(m => !m.user?.bot).size : 0
} catch (e) {
listeners = 0
}
const proc = getYoutubeProcesses()?.get(guildId)
const next = Array.isArray(state.next) ? state.next : []
const previous = Array.isArray(state.previous) ? state.previous : []
// Player.lastError n'est jamais effacé : une erreur sur une autre musique que celle en cours de lecture est résolue
const lastErrorStale = !!(state.lastError && state.status === "playing" && state.current?.id
&& state.lastError.songId !== state.current.id)
return {
...state,
guildId,
lastErrorStale,
next: full ? next : next.slice(0, 10),
nextCount: Number.isFinite(state.nextCount) ? state.nextCount : next.length,
// Résumé : 20 derniers titres ; détail complet : tout l'historique renvoyé par getState()
previous: full ? previous : previous.slice(0, 20),
guildName: guild?.name ?? null,
guildIcon: guild?.icon ?? null,
listeners,
webListeners: roomSize(guildId),
ytdlp: proc ? { pid: proc.pid ?? null, route: proc.route ?? null, startedAt: toIso(proc.startedAt) } : null
}
}
function playersList() {
return connectedPlayers().map(p => playerSummary(p)).filter(Boolean)
}
function guildStats(guildId) {
return {
commands: safeNum(SafeMetric.get("numberOfCommands_" + guildId)),
musicPlayed: safeNum(SafeMetric.get("numberOfMusicPlayed_" + guildId)),
secondsPlayed: safeNum(SafeMetric.get("numberOfSecondsPlayed_" + guildId))
}
}
/**
* Copie sérialisable d'une guilde (jamais l'objet partagé de Bot.js, jamais allMembers)
*/
function guildSummary(guildData, ctx = buildContext()) {
const id = guildData.id
const discordGuild = getClient().guilds.cache.get(id)
let missingPermissions = []
try {
if (discordGuild?.members?.me && typeof discordBot.checkRequiredPermission === "function") {
missingPermissions = discordBot.checkRequiredPermission(discordGuild.members.me)
}
} catch (e) {
missingPermissions = []
}
let owner = { id: guildData.owner ?? null }
const ownerMember = discordGuild?.members?.cache.get(guildData.owner)
if (ownerMember?.user) {
owner = {
id: ownerMember.user.id,
username: ownerMember.user.username ?? null,
global_name: ownerMember.user.globalName ?? null,
avatar: ownerMember.user.avatar ?? null
}
}
// Utilisateurs web distincts dans la room de la guilde
const webUsers = new Set()
for (const socketId of io?.sockets.adapter.rooms.get(id) || []) {
const userId = io.sockets.sockets.get(socketId)?.data?.userId
if (userId) webUsers.add(userId)
}
const members = guildData.allMembers || []
const secureRole = serverSettings.getSecureRole(id)
const player = players.AllPlayers.get(id)
return {
id,
name: guildData.name ?? null,
icon: guildData.icon ?? null,
banner: guildData.banner ?? null,
description: guildData.description ?? null,
owner,
memberCount: members.length,
registeredMembers: members.filter(m => ctx.registered.has(m)).length,
webUsersOnline: webUsers.size,
joinedAt: toIso(guildData.joinedAt),
createdAt: toIso(guildData.createdAt),
features: Array.isArray(guildData.features) ? [...guildData.features] : [],
secureRole: secureRole ? { id: secureRole.id ?? null, name: secureRole.name ?? null } : null,
missingPermissions,
isMediaGuild: !!id && id === configuration.getMediaGuildId(),
player: isConnectedPlayer(player) ? playerSummary(player) : null,
stats: guildStats(id),
sharedPlaylists: sharedPlaylistsSummary(id)
}
}
/**
* Option des playlists partagées d'un serveur + nombre de playlists enregistrées (même masquées)
*/
function sharedPlaylistsSummary(guildId) {
const settings = typeof serverSettings.getSharedPlaylists === "function"
? serverSettings.getSharedPlaylists(guildId)
: { enabled: false, editors: "members" }
return {
enabled: !!settings?.enabled,
editors: SHARED_PLAYLISTS_EDITORS.includes(settings?.editors) ? settings.editors : "members",
count: guildPlaylists.countPlaylists(guildId)
}
}
/**
* Résumé d'une playlist pour les listes d'administration (jamais la liste des titres)
*/
function playlistLite(p) {
return {
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
title: p?.title ?? null,
type: p?.type ?? null,
// "google" : synchronisée depuis le compte Google du propriétaire
source: p?.source ?? null,
url: typeof p?.url === "string" ? p.url : null,
thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null,
duration: safeNum(p?.duration),
syncedAt: toIso(p?.syncedAt),
createdAt: toIso(p?.createdAt),
updatedAt: toIso(p?.updatedAt),
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
}
}
function guildsList(search) {
const ctx = buildContext()
let list = [...discordBot.getGuilds().values()]
if (search) {
const s = search.toLowerCase()
list = list.filter(g => (g.name || "").toLowerCase().includes(s) || g.id.includes(s))
}
return list.map(g => guildSummary(g, ctx)).sort((a, b) => (a.name || "").localeCompare(b.name || ""))
}
function onlineUsersPayload() {
const sockets = socketIndex()
const online = []
for (const [userId, list] of sockets) {
const user = users.getUserById(userId)
if (!user) continue
let guildId = null
for (const socket of list) {
guildId = socketGuildId(socket)
if (guildId) break
}
online.push({ ...userLite(user.identity), socketCount: list.length, guildId })
}
const registered = users.getUsers().filter(u => !u.labels?.includes("DELETED")).length
return { online, counts: { registered, online: online.length } }
}
// MÉTRIQUES
function metricsData() {
const all = SafeMetric.getAll()
const raw = all.map(m => ({
name: m.name,
description: m.description ?? m.name,
value: safeNum(m.value),
invalid: !Number.isFinite(Number(m.value))
}))
const byGuildMap = new Map()
const ensureGuild = (guildId) => {
if (!byGuildMap.has(guildId)) {
byGuildMap.set(guildId, {
guildId,
guildName: discordBot.getGuilds().get(guildId)?.name ?? null,
commands: 0,
musicPlayed: 0,
secondsPlayed: 0
})
}
return byGuildMap.get(guildId)
}
const byUser = []
let commands = 0
for (const m of raw) {
if (m.name === "numberOfCommands") commands = m.value
else if (m.name.startsWith("numberOfCommands_")) ensureGuild(m.name.slice("numberOfCommands_".length)).commands = m.value
else if (m.name.startsWith("numberOfMusicPlayed_")) ensureGuild(m.name.slice("numberOfMusicPlayed_".length)).musicPlayed = m.value
else if (m.name.startsWith("numberOfSecondsPlayed_")) ensureGuild(m.name.slice("numberOfSecondsPlayed_".length)).secondsPlayed = m.value
else if (m.name.startsWith("userCommand_")) byUser.push({ key: m.name.slice("userCommand_".length), commands: m.value })
}
const byGuild = [...byGuildMap.values()]
return {
global: {
commands,
musicPlayed: byGuild.reduce((acc, g) => acc + g.musicPlayed, 0),
secondsPlayed: byGuild.reduce((acc, g) => acc + g.secondsPlayed, 0)
},
byGuild: byGuild.sort((a, b) => b.commands - a.commands),
byUser: byUser.sort((a, b) => b.commands - a.commands),
raw
}
}
// YOUTUBE
/**
* Erreurs typées du module youtube -> AdminError (leurs messages sont déjà en français)
*/
function youtubeError(e) {
if (e instanceof AdminError) return e
switch (e?.code) {
case "INVALID_COOKIES":
case "INVALID_PARAMS":
return new AdminError("INVALID_PARAMS", e.message)
case "BUSY":
return new AdminError("UNAVAILABLE", e.message)
default:
return e
}
}
/**
* Relance le sondage de la version de yt-dlp si son cache a expiré, sans bloquer plus de quelques secondes
* (getVersion() répond directement depuis son cache : 6 h si connue, 1 min en cas d'échec)
*/
async function refreshYtdlpVersion() {
let timer
try {
await Promise.race([
youtube.ytdlp.getVersion().catch(() => null),
new Promise((resolve) => {
timer = setTimeout(resolve, YOUTUBE_VERSION_WAIT)
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
}
/**
* État complet de la section YouTube : youtube.getStatus() + tentatives récentes (avec le nom du serveur)
*/
function youtubeStatus() {
const guilds = discordBot.getGuilds()
const recent = youtube.diagnostics.getRecent(YOUTUBE_RECENT).map(entry => ({
...entry,
guildName: entry.guildId ? (guilds.get(entry.guildId)?.name ?? null) : null
}))
return { ...youtube.getStatus(), recent }
}
/**
* Résumé yt-dlp de la vue d'ensemble (clés present / updatedAt conservées pour les anciens clients)
*/
async function youtubeOverview() {
const activeProcesses = getYoutubeProcesses()?.size ?? 0
try {
await refreshYtdlpVersion()
const status = youtube.getStatus()
const cookies = status.cookies || {}
const summary = status.diagnostics || {}
return {
version: status.ytdlp?.version ?? null,
source: status.ytdlp?.source ?? null,
updating: !!status.ytdlp?.updating,
cookies: {
present: !!cookies.present,
loggedIn: !!cookies.loggedIn,
valid: !!cookies.valid,
invalidatedAt: cookies.invalidatedAt ?? null,
invalidReason: cookies.invalidReason ?? null,
importedAt: cookies.importedAt ?? null,
updatedAt: cookies.importedAt ?? null,
earliestAuthExpiry: cookies.earliestAuthExpiry ?? null,
lastCheck: cookies.lastCheck ? { at: cookies.lastCheck.at ?? null, ok: !!cookies.lastCheck.ok } : null
},
cookiesMode: status.settings?.cookiesMode?.value ?? null,
activeProcesses,
lastFailure: summary.lastFailure ? { at: summary.lastFailure.at ?? null, cls: summary.lastFailure.cls ?? null } : null,
recentFailureRate: summary.recentFailureRate ?? null,
recentCount: safeNum(summary.recentCount)
}
} catch (e) {
alog.error(`État YouTube illisible pour la vue d'ensemble : ${e?.stack || e}`)
return { version: null, cookies: { present: false, updatedAt: null }, activeProcesses }
}
}
/**
* Lance la vérification en direct des cookies sans l'attendre (le résultat arrive par "/ADMIN/YOUTUBE/UPDATE")
* @returns {boolean} true si une vérification a été lancée
*/
function startCookieCheck() {
if (!youtube.cookies.getStatus().loggedIn) return false
youtube.check.checkCookies().catch((e) => alog.warn(`Vérification des cookies YouTube impossible : ${e?.message || e}`))
return true
}
/**
* Description d'une modification de réglages pour l'audit (URL du fournisseur de PO token masquée)
*/
function describeSettingsChange(before, after) {
const format = (key, value) => key === "potProviderUrl" ? (youtube.settings.maskUrl(value) || '""') : JSON.stringify(value)
return Object.keys(after)
.filter(key => before[key] !== after[key])
.map(key => `${key}: ${format(key, before[key])} -> ${format(key, after[key])}`)
.join(", ")
}
function isContainer() {
try {
return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv")
} catch (e) {
return false
}
}
/**
* Message d'échec de --cookies-from-browser (dernière ligne ERROR nettoyée + explication)
*/
function browserFailureMessage(browser, result) {
const line = String(result?.stderr || "").split(/\r?\n/).map(l => l.trim()).reverse().find(l => /^ERROR:/i.test(l))
let detail
if (result?.timedOut) detail = "délai dépassé"
else if (line) detail = youtube.errors.sanitize(line.replace(/^ERROR:\s*/i, "")).slice(0, 300)
else detail = `aucun cookie exporté (code ${result?.code ?? "?"})`
let hint = "Cette méthode ne fonctionne que si le bot tourne sur la même machine et la même session que le navigateur."
if (CHROMIUM_BROWSERS.includes(browser) && process.platform === "win32") {
hint += " Sous Windows, Chrome et ses dérivés verrouillent et chiffrent leurs cookies : fermez complètement le navigateur, ou préférez Firefox."
}
return `Lecture des cookies de ${browser} impossible : ${detail}. ${hint}`
}
/**
* Importe les cookies d'un navigateur installé sur la machine du bot (dev / local uniquement) :
* yt-dlp --cookies-from-browser les exporte dans un fichier temporaire, aussitôt importé puis supprimé.
*/
async function importFromBrowser(browser, profile, by) {
if (isContainer()) {
throw new AdminError("UNAVAILABLE", "Le bot tourne dans un conteneur (Docker) : il n'a accès à aucun navigateur. Utilisez l'assistant de connexion (export d'un fichier cookies.txt).")
}
if (browserImportPending) throw new AdminError("UNAVAILABLE", "Un import depuis un navigateur est déjà en cours")
browserImportPending = true
// Préfixe des copies de cookies du module youtube : nettoyé au démarrage en cas de crash
let dir = null
try {
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chopin-ytc-"))
const file = path.join(dir, "cookies.txt")
const spec = profile ? `${browser}:${profile}` : browser
const args = [
...youtube.ytdlp.baseArgs({ proxy: null }),
"--cookies-from-browser", spec,
"--cookies", file,
"--skip-download",
"--", youtube.settings.get().testUrl
]
// yt-dlp écrit le fichier de cookies en se fermant, même si l'extraction de la vidéo échoue
const result = await youtube.ytdlp.run(args, { timeoutMs: YOUTUBE_BROWSER_TIMEOUT })
let text = null
try {
text = await fs.promises.readFile(file, "utf-8")
} catch (e) {
text = null
}
if (!text || !text.trim()) throw new AdminError("UNAVAILABLE", browserFailureMessage(browser, result))
try {
return youtube.cookies.importCookies(text, { source: "browser:" + browser, by })
} catch (e) {
if (e?.code === "INVALID_COOKIES") {
throw new AdminError("INVALID_PARAMS", `${e.message} (êtes-vous connecté à YouTube dans ${browser}${profile ? `, profil « ${profile} »` : ""} ?)`)
}
throw e
}
} finally {
browserImportPending = false
// Le fichier temporaire contient TOUS les cookies du navigateur : supprimé dans tous les cas
if (dir) await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
function adminIdentity(me) {
return { id: me.identity.id, username: me.identity.username }
}
// CONFIGURATION ET INTÉGRATIONS
// Dernier test des identifiants Spotify (oublié dès qu'ils changent)
let spotifyLastTest = null
function websiteBase() {
return String(configuration.getWebsiteLink() || "").replace(/\/+$/, "")
}
/**
* Comptes Google liés, lus dans data/google.json (lecture seule : jamais de jeton renvoyé)
*/
function googleAccountsInfo() {
const data = dbData("google", null)
if (!data || typeof data !== "object" || Array.isArray(data)) return { available: false, linked: 0, invalid: 0 }
let linked = 0
let invalid = 0
for (const record of Object.values(data)) {
if (!record || typeof record !== "object" || !record.tokens) continue
linked++
if (record.invalid) invalid++
}
return { available: true, linked, invalid }
}
function youtubeCookiesSummary() {
try {
const cookies = youtube.cookies.getStatus()
return {
present: !!cookies.present,
loggedIn: !!cookies.loggedIn,
valid: !!cookies.valid,
invalidReason: cookies.invalidReason ?? null,
importedAt: cookies.importedAt ?? null
}
} catch (e) {
return null
}
}
/**
* État des intégrations pour la section Configuration (aucun secret, seulement des indicateurs)
*/
function integrationsStatus() {
const website = websiteBase()
const google = googleAccountsInfo()
const warpStatus = warp.getStatus()
const client = getClient()
return {
website: website || null,
discord: {
ready: !!discordBot.isReady(),
tag: client?.user?.tag ?? null,
// À déclarer dans le portail développeur Discord (OAuth2 > Redirects)
redirectUri: website ? website + "/redirect" : null
},
google: {
configured: !!(configuration.getYoutubeApiClientId() && configuration.getYoutubeApiClientSecret() && website),
clientIdSet: !!configuration.getYoutubeApiClientId(),
clientSecretSet: !!configuration.getYoutubeApiClientSecret(),
// URI de redirection autorisée à déclarer dans la console Google Cloud
redirectUri: website ? website + "/oauth2callback" : null,
accountsAvailable: google.available,
linkedUsers: google.linked,
invalidUsers: google.invalid
},
spotify: {
configured: !!(configuration.getSpotifyClientId() && configuration.getSpotifyClientSecret()),
lastTest: spotifyLastTest
},
warp: {
configured: !!warpStatus.configured,
healthy: !!warpStatus.healthy,
url: warpStatus.url ?? null,
routes: warpStatus.routes || []
},
youtube: { cookies: youtubeCookiesSummary() },
media: mediaStatus(),
report: {
channelSet: !!configuration.getReportChannel(),
contactSet: !!configuration.getReportContact()
}
}
}
function configPayload() {
const config = configuration.getMasked()
return {
config,
restartRequired: Object.values(config).some(entry => entry.pendingRestart),
integrations: integrationsStatus()
}
}
/**
* Demande un jeton client credentials à Spotify avec les identifiants enregistrés
* (instance dédiée : le jeton en cache de SpotifyInformation n'est pas touché)
*/
async function testSpotify() {
const clientId = configuration.getSpotifyClientId()
const clientSecret = configuration.getSpotifyClientSecret()
if (!clientId || !clientSecret) throw new AdminError("UNAVAILABLE", "Identifiants Spotify non configurés")
const SpotifyWebApi = require('spotify-web-api-node')
const api = new SpotifyWebApi({ clientId, clientSecret })
const startedAt = Date.now()
let timer
let result
try {
const data = await Promise.race([
api.clientCredentialsGrant(),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error("délai dépassé")), SPOTIFY_TEST_TIMEOUT)
timer.unref?.()
})
])
const expiresIn = Number(data?.body?.expires_in)
result = { ok: true, message: "Jeton obtenu : identifiants valides", expiresIn: Number.isFinite(expiresIn) ? expiresIn : null }
} catch (e) {
const status = Number(e?.statusCode)
let detail = String(e?.body?.error_description || e?.body?.error || e?.message || e)
// Par précaution : jamais le secret dans un message renvoyé ou journalisé
detail = detail.split(clientSecret).join("***").slice(0, 200)
result = {
ok: false,
message: status === 400 || status === 401 ? `Identifiants refusés par Spotify (${detail})` : `Spotify injoignable : ${detail}`
}
} finally {
clearTimeout(timer)
}
spotifyLastTest = { ...result, at: new Date().toISOString(), ms: Date.now() - startedAt }
return spotifyLastTest
}
// VUE D'ENSEMBLE
async function databasesInfo(withEntries = false) {
const result = []
for (const name of DATABASES) {
const db = getDatabase(name)
if (!db?.path) continue
const info = { name, sizeBytes: 0, modifiedAt: null }
try {
const stat = await fs.promises.stat(db.path)
info.sizeBytes = stat.size
info.modifiedAt = stat.mtime.toISOString()
} catch (e) {
// Fichier absent : taille 0
}
if (withEntries) {
const data = db.data
info.entries = Array.isArray(data) ? data.length : (data && typeof data === "object" ? Object.keys(data).length : 0)
}
result.push(info)
}
return result
}
function packageName() {
try {
return JSON.parse(fs.readFileSync(__glob.PACKAGEINFO, "utf-8")).name || "chopin-backend"
} catch (e) {
return "chopin-backend"
}
}
function mediaStatus() {
const configured = !!(configuration.getMediaGuildId() && configuration.getMediaChannelId())
let connected = false
const mediaBase = getMediaBase()
if (typeof mediaBase?.isConnected === "function") {
connected = !!mediaBase.isConnected()
} else if (configured) {
// À défaut d'accesseur : le salon multimédia est-il visible par le bot ?
try {
connected = !!discordBot.getChannel(configuration.getMediaGuildId(), configuration.getMediaChannelId())
} catch (e) {
connected = false
}
}
return { configured, connected }
}
let cachedPackageName = null
async function buildOverview() {
const client = getClient()
const uptime = process.uptime()
const memory = process.memoryUsage()
// Joueurs
const playerCounts = { connected: 0, playing: 0, paused: 0, idle: 0 }
for (const player of connectedPlayers()) {
playerCounts.connected++
let status = "idle"
try {
status = playerStatus(player.getState())
} catch (e) {
status = "idle"
}
if (status === "playing") playerCounts.playing++
else if (status === "paused") playerCounts.paused++
else playerCounts.idle++
}
// Comptes
const allUsers = users.getUsers()
const userCounts = { registered: 0, admins: 0, fullBanned: 0, deleted: 0 }
for (const user of allUsers) {
const labels = user.labels || []
if (labels.includes("DELETED")) {
userCounts.deleted++
continue
}
userCounts.registered++
if (labels.includes("ADMIN")) userCounts.admins++
if (labels.includes("BAN")) userCounts.fullBanned++
}
// Web
const sockets = socketIndex()
let adminsOnline = 0
for (const userId of sockets.keys()) {
if (users.getUserById(userId)?.isAdmin()) adminsOnline++
}
// Discord
let voiceConnections = 0
try {
voiceConnections = require('@discordjs/voice').getVoiceConnections().size
} catch (e) {
voiceConnections = 0
}
const activity = client?.user?.presence?.activities?.[0]
const metrics = metricsData()
if (!cachedPackageName) cachedPackageName = packageName()
return {
app: {
name: cachedPackageName,
version: __glob.VERSION,
node: process.version,
platform: process.platform,
pid: process.pid,
startedAt: new Date(Date.now() - uptime * 1000).toISOString(),
uptime: Math.floor(uptime),
pm2: process.env.pm_id !== undefined
},
memory: {
rss: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
external: memory.external
},
system: {
loadavg: os.loadavg().map(safeNum),
totalmem: os.totalmem(),
freemem: os.freemem(),
cpus: os.cpus().length,
hostname: os.hostname()
},
discord: {
ready: !!discordBot.isReady(),
id: client?.user?.id ?? null,
tag: client?.user?.tag ?? null,
avatar: client?.user ? client.user.displayAvatarURL() : null,
ping: safeNum(client?.ws?.ping),
readyAt: toIso(client?.readyAt),
guildCount: client?.guilds?.cache?.size ?? 0,
voiceConnections,
activity: activity ? (activity.state || activity.name || null) : null
},
web: {
sockets: io ? io.sockets.sockets.size : 0,
usersOnline: sockets.size,
pendingSessions: typeof session.getSessionCount === "function" ? session.getSessionCount() : 0,
adminsOnline
},
players: playerCounts,
users: userCounts,
warp: warp.getStatus(),
ytdlp: await youtubeOverview(),
media: mediaStatus(),
databases: await databasesInfo(false),
stats: metrics.global,
logs: {
current: LogReader.getCurrentName(),
interrupted: await LogReader.isInterrupted()
}
}
}
// SYNCHRONISATION DES SOCKETS
/**
* Applique les droits actuels de l'utilisateur à tous ses sockets (après chaque mutation de labels) :
* room ADMIN, sortie des guildes bannies, déconnexion si banni globalement ou supprimé.
*/
function syncUserSockets(userId) {
if (!io || !userId) return 0
const user = users.getUserById(userId)
const guilds = discordBot.getGuilds()
let affected = 0
for (const socket of [...io.sockets.sockets.values()]) {
if (socket.data?.userId !== userId) continue
affected++
if (!user || user.labels.includes("DELETED") || user.isFullBanned()) {
socket.emit("AUTH_ERROR", user?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte a été supprimé")
socket.disconnect(true)
continue
}
if (user.isAdmin()) {
socket.join(ADMIN_ROOM)
} else {
socket.leave(ADMIN_ROOM)
socket.leave(LOGS_ROOM)
}
for (const room of [...socket.rooms]) {
if (guilds.has(room) && user.isBanned(room)) socket.leave(room)
}
}
process.emit("USERS_UPDATE")
return affected
}
/**
* Déconnecte toutes les sessions d'un utilisateur
* @returns {number} nombre de sockets déconnectés
*/
function disconnectUser(userId, message) {
if (!io) return 0
let count = 0
for (const socket of [...io.sockets.sockets.values()]) {
if (socket.data?.userId !== userId) continue
socket.emit("AUTH_ERROR", message)
socket.disconnect(true)
count++
}
return count
}
// ACTIONS SUR LES COMPTES
function getTarget(userId) {
const target = users.getUserById(userId)
if (!target) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
return target
}
function freshSummary(userId) {
const user = users.getUserById(userId)
if (!user) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
return userSummary(user)
}
async function setAdminValue(me, userId, value) {
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits")
const target = getTarget(userId)
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
const state = await users.setAdmin(userId, value)
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
audit(me, value ? "promotion admin" : "retrait admin", describeUser(target))
syncUserSockets(userId)
return freshSummary(userId)
}
async function setFullBanValue(me, userId, value, reason) {
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même")
const target = getTarget(userId)
if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni")
const state = await users.setFullBan(userId, value, reason || undefined)
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
audit(me, value ? "bannissement global" : "levée du bannissement global", describeUser(target) + (reason ? ` - raison : ${reason}` : ""))
syncUserSockets(userId)
return freshSummary(userId)
}
async function deleteUserAccount(me, userId, purge) {
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas supprimer votre propre compte ici")
const target = getTarget(userId)
if (target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être supprimé")
// Déconnexion d'abord : les sockets ne doivent plus agir pendant la suppression
disconnectUser(userId, "Votre compte a été supprimé")
const purged = await users.deleteAccount(userId, { purge })
if (purged === null) throw new AdminError("NOT_FOUND", "Utilisateur introuvable")
audit(me, purge ? "suppression du compte (avec purge)" : "suppression du compte", describeUser(target))
syncUserSockets(userId)
return { deleted: true, purged }
}
// ACTIONS SUR LES PLAYERS
function getConnectedPlayer(guildId) {
const player = players.AllPlayers.get(guildId)
if (!isConnectedPlayer(player)) throw new AdminError("NOT_FOUND", "Aucun player connecté sur ce serveur")
return player
}
/**
* @param {boolean} [value] loop / shuffle : état voulu (idempotent) ; absent = bascule (ancien client)
*/
async function runPlayerAction(player, action, value) {
const guildId = player.guildId
switch (action) {
case "pause":
case "resume": {
const value = action === "pause"
let result
if (typeof player.setPaused === "function") {
result = await player.setPaused(value)
} else {
// Ancienne API : pause() bascule, on vérifie l'état avant pour rester idempotent
const status = player.player?.state?.status
const paused = status === "paused" || status === "autopaused"
if (paused !== value) await player.pause()
result = { ok: true, paused: value }
}
if (result && result.ok === false) throw new AdminError("UNAVAILABLE", result.reason || "Action impossible")
break
}
case "skip":
case "previous": {
const result = await player[action]()
if (!result || result === "no_music") throw new AdminError("UNAVAILABLE", "no_music")
break
}
case "stop":
if (typeof player.stop === "function") {
await player.stop()
} else {
player.loop = false
player.queue?.clearNext?.()
player.player?.stop(true)
}
break
case "leave":
await player.leave("admin")
return null
case "loop":
if (value === undefined || !!player.loop !== value) await player.setLoop()
break
case "shuffle":
if (value === undefined || !!player.queue?.shuffle !== value) await player.setShuffle()
break
case "clear":
player.queue.clearNext()
break
}
const current = players.AllPlayers.get(guildId)
return isConnectedPlayer(current) ? playerSummary(current) : null
}
// LOGS TEMPS RÉEL
// Démarrage du suivi en cours : un second abonnement l'attend au lieu de renvoyer un tampon encore vide
let tailStarting = null
async function ensureTail() {
if (tailStarting) return tailStarting
if (LogReader.isTailing()) return
tailStarting = LogReader.startTail(({ name, lines }) => {
if (!io || roomSize(LOGS_ROOM) === 0) return
io.to(LOGS_ROOM).emit("/ADMIN/LOGS/APPEND", { name, lines })
}).finally(() => {
tailStarting = null
})
return tailStarting
}
/**
* Offset du début de la première ligne complète d'une lecture commençant à `start`
* (LogReader renvoie l'offset demandé et ignore la ligne partielle : le client en a besoin pour
* « Charger les lignes plus anciennes » sans perdre la ligne à cheval sur deux blocs)
*/
async function firstLineOffset(name, start, end) {
if (!(start > 0)) return 0
const handle = await fs.promises.open(path.join(__glob.LOGS, name), "r")
try {
const buffer = Buffer.alloc(64 * 1024)
// On part de l'octet précédent : s'il s'agit d'un saut de ligne, start est déjà un début de ligne
let position = start - 1
while (position < end) {
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, end - position), position)
if (!bytesRead) break
const index = buffer.subarray(0, bytesRead).indexOf(0x0a)
if (index !== -1) return position + index + 1
position += bytesRead
}
return end
} finally {
await handle.close()
}
}
// ENREGISTREMENT DES ROUTES
/**
* Enregistre toutes les routes d'administration pour un socket authentifié.
* @param {{io?: import('socket.io').Server, socket: import('socket.io').Socket, socketUser: function|Object}} options
* socketUser : fonction renvoyant l'utilisateur courant du socket (ou l'objet lui-même)
*/
function attach({ io: ioInstance, socket, socketUser }) {
if (!io && ioInstance) io = ioInstance
function getMe() {
const current = typeof socketUser === "function" ? socketUser() : socketUser
const id = current?.identity?.id || socket.data?.userId
return id ? users.getUserById(id) : null
}
/**
* Route d'administration : droits relus à chaque appel, réponse par enveloppe {ok, data|error}
*/
function AdminRequest(name, handler) {
socket.on(name, async (payload, ack) => {
if (typeof payload === "function" && ack === undefined) {
ack = payload
payload = undefined
}
const reply = (env) => {
if (typeof ack === "function") ack(env)
else socket.emit(name, env)
}
const me = getMe()
if (!me || !me.isAdmin() || me.isFullBanned() || me.labels.includes("DELETED")) {
alog.warn(`[ADMIN] Accès refusé à ${name} pour ${describeUser(me)} - Socket : ${socket.id}`)
socket.leave(ADMIN_ROOM)
socket.leave(LOGS_ROOM)
return reply({ ok: false, error: "FORBIDDEN", message: "Accès réservé aux administrateurs" })
}
try {
const data = await handler(payload, me)
reply({ ok: true, data: data === undefined ? null : data })
} catch (e) {
if (e instanceof AdminError) {
return reply({ ok: false, error: e.code, message: e.message })
}
alog.error(`${name} : ${e?.stack || e}`)
reply({ ok: false, error: "INTERNAL", message: String(e?.message || e) })
}
})
}
// 1. VUE D'ENSEMBLE
AdminRequest("/ADMIN/OVERVIEW", async () => buildOverview())
// 2. LOGS
AdminRequest("/ADMIN/LOGS/LIST", async () => LogReader.listLogs())
// Ancien nom : renvoie la liste, sans le contenu (l'ancienne version lisait tout en synchrone)
AdminRequest("/ADMIN/LOGS", async () => LogReader.listLogs())
AdminRequest("/ADMIN/LOGS/READ", async (payload) => {
const data = asObject(payload)
if (!LogReader.isValidName(data.name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide")
const offset = optionalInt(data.offset, "offset", 0, Number.MAX_SAFE_INTEGER, undefined)
const limit = optionalInt(data.limit, "limit", 1, 1048576, 262144)
let levels
if (data.levels !== undefined && data.levels !== null) {
if (!Array.isArray(data.levels) || data.levels.some(l => typeof l !== "string" || !LOG_LEVELS.includes(l.toUpperCase()))) {
throw new AdminError("INVALID_PARAMS", "Paramètre \"levels\" invalide")
}
levels = data.levels.map(l => l.toUpperCase())
}
const type = optionalString(data.type, "type", 64)
const search = optionalString(data.search, "search", 200)
const result = await LogReader.readLog(data.name, { offset, limit, levels, type, search })
if (!result) throw new AdminError("NOT_FOUND", "Fichier de logs introuvable")
let lineStart = result.offset
try {
lineStart = await firstLineOffset(data.name, result.offset, result.nextOffset)
} catch (e) {
lineStart = result.offset
}
return { ...result, firstLineOffset: lineStart }
})
AdminRequest("/ADMIN/LOGS/SUBSCRIBE", async () => {
socket.join(LOGS_ROOM)
await ensureTail()
return { name: LogReader.getCurrentName(), lines: LogReader.getBuffer(300) }
})
AdminRequest("/ADMIN/LOGS/UNSUBSCRIBE", async () => {
socket.leave(LOGS_ROOM)
return true
})
AdminRequest("/ADMIN/LOGS/DELETE", async (payload, me) => {
const name = asObject(payload).name
if (!LogReader.isValidName(name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide")
const result = await LogReader.deleteLog(name)
if (result === "protected") throw new AdminError("TARGET_PROTECTED", "Le fichier de logs courant ne peut pas être supprimé")
if (result === "not_found") throw new AdminError("NOT_FOUND", "Fichier de logs introuvable")
audit(me, "suppression du fichier de logs", name)
return { deleted: name }
})
// 3. COMPTES
AdminRequest("/ADMIN/USERS/LIST", async (payload) => {
const data = asObject(payload)
const search = optionalString(data.search, "search", 100)
const filter = data.filter === undefined || data.filter === null ? "all" : data.filter
if (!USER_FILTERS.includes(filter)) throw new AdminError("INVALID_PARAMS", "Paramètre \"filter\" invalide")
const page = optionalInt(data.page, "page", 1, 100000, 1)
const pageSize = optionalInt(data.pageSize, "pageSize", 1, 200, 50)
const ctx = buildContext()
let list = users.getUsers().map(u => userSummary(u, ctx))
if (search) {
const s = search.toLowerCase()
list = list.filter(u => (u.id || "").includes(s)
|| (u.username || "").toLowerCase().includes(s)
|| (u.global_name || "").toLowerCase().includes(s))
}
switch (filter) {
case "online": list = list.filter(u => u.online); break
case "admin": list = list.filter(u => u.isAdmin); break
case "banned": list = list.filter(u => u.isFullBanned || u.bannedGuilds.length > 0); break
case "deleted": list = list.filter(u => u.isDeleted); break
}
// En ligne d'abord, puis ordre alphabétique
list.sort((a, b) => (b.online - a.online) || (a.global_name || a.username || "").localeCompare(b.global_name || b.username || ""))
const start = (page - 1) * pageSize
return { total: list.length, page, pageSize, items: list.slice(start, start + pageSize) }
})
AdminRequest("/ADMIN/USERS/GET", async (payload) => {
const userId = userIdFrom(payload)
const user = getTarget(userId)
const ctx = buildContext()
const summary = userSummary(user, ctx)
const guilds = []
for (const guildId of summary.guildIds) {
const guild = discordBot.getGuilds().get(guildId)
if (!guild) continue
guilds.push({
id: guildId,
name: guild.name ?? null,
icon: guild.icon ?? null,
isOwner: guild.owner === userId,
isMod: user.isMod(guildId),
isBanned: user.isBanned(guildId)
})
}
const sockets = (ctx.sockets.get(userId) || []).map(s => ({
socketId: s.id,
address: s.handshake?.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() || s.handshake?.address || null,
userAgent: s.handshake?.headers?.["user-agent"] ?? null,
connectedAt: toIso(s.handshake?.issued),
guildId: socketGuildId(s)
}))
const userPlaylists = Array.isArray(ctx.playlists?.[userId]) ? ctx.playlists[userId] : []
const userHistory = Array.isArray(ctx.history?.[userId]) ? ctx.history[userId] : []
const media = dbData("media", [])
return {
...summary,
// Identité Discord complète (jamais auth ni tokens)
identity: { ...user.identity },
guilds,
sockets,
playlists: userPlaylists.map(p => ({
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
title: p?.title ?? null,
type: p?.type ?? null,
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
})),
history: userHistory.map(songLite).filter(Boolean),
media: (Array.isArray(media) ? media : []).filter(m => m?.userId === userId).map(m => ({
id: m.id ?? null,
name: m.name ?? null,
size: safeNum(m.size),
createdAt: toIso(m.createdAt),
url: m.url ?? null
}))
}
})
AdminRequest("/ADMIN/USERS/SET_ADMIN", async (payload, me) => {
const data = asObject(payload)
const userId = requireId(data.userId)
const value = requireBool(data.value)
return setAdminValue(me, userId, value)
})
// Ancien nom : bascule, userId en chaîne
AdminRequest("/ADMIN/USERS/SWITCH_ADMIN", async (payload, me) => {
const userId = userIdFrom(payload)
const target = getTarget(userId)
return setAdminValue(me, userId, !target.isAdmin())
})
AdminRequest("/ADMIN/USERS/SET_FULL_BAN", async (payload, me) => {
const data = asObject(payload)
const userId = requireId(data.userId)
const value = requireBool(data.value)
const reason = optionalString(data.reason, "reason", 500)
return setFullBanValue(me, userId, value, reason)
})
// Ancien nom : bascule, userId en chaîne
AdminRequest("/ADMIN/USERS/FULL_BAN", async (payload, me) => {
const userId = userIdFrom(payload)
const target = getTarget(userId)
return setFullBanValue(me, userId, !target.isFullBanned(), null)
})
AdminRequest("/ADMIN/USERS/SET_GUILD_BAN", async (payload, me) => {
const data = asObject(payload)
const userId = requireId(data.userId)
const guildId = requireId(data.guildId, "guildId")
const value = requireBool(data.value)
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même")
const target = getTarget(userId)
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni")
const state = await users.setGuildBan(userId, guildId, value)
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
audit(me, value ? "bannissement du serveur" : "levée du bannissement du serveur", `${describeUser(target)} sur ${guildId}`)
if (value && io) io.in("user:" + userId).socketsLeave(guildId)
syncUserSockets(userId)
return freshSummary(userId)
})
AdminRequest("/ADMIN/USERS/SET_GUILD_MOD", async (payload, me) => {
const data = asObject(payload)
const userId = requireId(data.userId)
const guildId = requireId(data.guildId, "guildId")
const value = requireBool(data.value)
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits")
const target = getTarget(userId)
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
if (value && (target.isFullBanned() || target.labels.includes("BAN_" + guildId))) {
throw new AdminError("INVALID_PARAMS", "Cet utilisateur est banni : levez d'abord le bannissement")
}
const state = await users.setGuildMod(userId, guildId, value)
if (state === null) throw new AdminError("INTERNAL", "Modification impossible")
audit(me, value ? "nomination modérateur" : "retrait modérateur", `${describeUser(target)} sur ${guildId}`)
syncUserSockets(userId)
return freshSummary(userId)
})
AdminRequest("/ADMIN/USERS/DISCONNECT", async (payload, me) => {
const data = asObject(payload)
const userId = requireId(data.userId)
const revokeTokens = data.revokeTokens === undefined ? false : requireBool(data.revokeTokens, "revokeTokens")
const message = optionalString(data.message, "message", 200)
if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Utilisez la déconnexion de votre compte")
const target = getTarget(userId)
const disconnectedSockets = disconnectUser(userId, message || "Vous avez été déconnecté par un administrateur")
const revokedTokens = revokeTokens ? users.revokeTokens(userId) : 0
audit(me, revokeTokens ? "déconnexion et révocation des sessions" : "déconnexion", `${describeUser(target)} (${disconnectedSockets} socket(s), ${revokedTokens} token(s))`)
process.emit("USERS_UPDATE")
return { disconnectedSockets, revokedTokens }
})
AdminRequest("/ADMIN/USERS/REFRESH", async (payload, me) => {
const userId = userIdFrom(payload)
const target = getTarget(userId)
if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé")
if (!target.auth) throw new AdminError("UNAVAILABLE", "Aucune authentification Discord pour ce compte")
const identity = await users.updateIdentity(userId)
if (!identity) throw new AdminError("UNAVAILABLE", "L'API Discord n'a pas répondu correctement")
users.getUserById(userId)?.justUpdated()
audit(me, "rafraîchissement de l'identité", describeUser(target))
process.emit("USERS_UPDATE")
return freshSummary(userId)
})
AdminRequest("/ADMIN/USERS/DELETE", async (payload, me) => {
const userId = userIdFrom(payload)
const data = asObject(payload)
// Purge uniquement sur demande explicite (l'ancien format "id" seul ne purge plus rien)
const purge = data.purge === undefined || data.purge === null ? false : requireBool(data.purge, "purge")
return deleteUserAccount(me, userId, purge)
})
// 4. SERVEURS DISCORD
AdminRequest("/ADMIN/GUILDS/LIST", async (payload) => {
const search = optionalString(asObject(payload).search, "search", 100)
return guildsList(search)
})
AdminRequest("/ADMIN/GUILDS/GET", async (payload) => {
const guildId = requireId(asObject(payload).guildId, "guildId")
const guildData = discordBot.getGuilds().get(guildId)
if (!guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable")
const ctx = buildContext()
const summary = guildSummary(guildData, ctx)
const discordGuild = getClient().guilds.cache.get(guildId)
const roles = discordBot.getGuildRoles(guildId).sort((a, b) => b.position - a.position)
const members = []
for (const memberId of guildData.allMembers || []) {
const user = users.getUserById(memberId)
if (!user || user.labels.includes("DELETED")) continue
members.push({
...userLite(user.identity),
isOwner: guildData.owner === memberId,
isMod: user.isMod(guildId),
isBanned: user.isBanned(guildId),
isAdmin: user.isAdmin(),
online: ctx.sockets.has(memberId)
})
}
const voiceChannels = []
if (discordGuild) {
for (const channel of discordGuild.channels.cache.values()) {
if (typeof channel.isVoiceBased !== "function" || !channel.isVoiceBased()) continue
voiceChannels.push({
id: channel.id,
name: channel.name,
members: channel.members ? channel.members.filter(m => !m.user?.bot).size : 0
})
}
}
const metrics = SafeMetric.getGuildMetrics(guildId).map(m => ({
name: m.name,
description: m.description ?? m.name,
value: safeNum(m.value),
invalid: !Number.isFinite(Number(m.value))
}))
return { ...summary, roles, members, voiceChannels, metrics }
})
AdminRequest("/ADMIN/GUILDS/LEAVE", async (payload, me) => {
const data = asObject(payload)
const guildId = requireId(data.guildId, "guildId")
if (typeof data.confirmName !== "string") throw new AdminError("INVALID_PARAMS", "Confirmation du nom du serveur requise")
const discordGuild = getClient().guilds.cache.get(guildId)
const guildData = discordBot.getGuilds().get(guildId)
if (!discordGuild && !guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable")
if (guildId === configuration.getMediaGuildId()) {
throw new AdminError("TARGET_PROTECTED", "Le serveur de la base multimédia ne peut pas être quitté")
}
const name = discordGuild?.name ?? guildData?.name
if (data.confirmName.trim() !== String(name ?? "").trim()) throw new AdminError("INVALID_PARAMS", "Le nom de confirmation ne correspond pas au nom du serveur")
if (!discordGuild) throw new AdminError("NOT_FOUND", "Le bot n'est plus sur ce serveur")
const player = players.AllPlayers.get(guildId)
if (isConnectedPlayer(player)) {
try {
await player.leave("admin")
} catch (e) {
alog.error(`Impossible de quitter le salon vocal de ${guildId} : ${e?.message || e}`)
}
}
if (typeof discordBot.clearAutoLeaveTimer === "function") discordBot.clearAutoLeaveTimer(guildId)
try {
await discordGuild.leave()
} catch (e) {
throw new AdminError("UNAVAILABLE", `Discord a refusé le départ du serveur : ${e?.message || e}`)
}
audit(me, "départ du serveur", `${name} (${guildId})`)
// guildDelete retire aussi l'entrée, on le fait tout de suite pour une réponse cohérente
discordBot.getGuilds().delete(guildId)
if (io) {
io.to(guildId).emit("/GUILD/UPDATE")
io.in(guildId).socketsLeave(guildId)
}
process.emit("USERS_UPDATE")
process.emit("GUILDS_UPDATE")
return { left: true, guildId }
})
AdminRequest("/ADMIN/GUILDS/SET_SECURE_ROLE", async (payload, me) => {
const data = asObject(payload)
const guildId = requireId(data.guildId, "guildId")
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
let role = null
if (data.roleId !== null && data.roleId !== undefined) {
const roleId = requireId(data.roleId, "roleId")
const found = discordBot.getGuildRoles(guildId).find(r => r.id === roleId)
if (!found) throw new AdminError("INVALID_PARAMS", "Rôle introuvable sur ce serveur")
// @everyone équivaut à aucun rôle
role = found.name === "@everyone" ? null : { id: found.id, name: found.name, color: found.color, position: found.position }
}
serverSettings.setSecureRole(guildId, role)
audit(me, "rôle sécurisé", `${guildId} -> ${role ? role.name + " (" + role.id + ")" : "aucun"}`)
process.emit("GUILDS_UPDATE")
return { secureRole: role ? { id: role.id, name: role.name } : null }
})
// Playlists partagées d'un serveur : {guildId, enabled?, editors?} (désactiver masque sans rien supprimer)
AdminRequest("/ADMIN/GUILDS/SET_SHARED_PLAYLISTS", async (payload, me) => {
const data = asObject(payload)
const guildId = requireId(data.guildId, "guildId")
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
const patch = {}
if (data.enabled !== undefined) patch.enabled = requireBool(data.enabled, "enabled")
if (data.editors !== undefined) {
if (!SHARED_PLAYLISTS_EDITORS.includes(data.editors)) throw new AdminError("INVALID_PARAMS", "Paramètre \"editors\" invalide (members ou mods)")
patch.editors = data.editors
}
if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée (enabled ou editors)")
const settings = serverSettings.setSharedPlaylists(guildId, patch)
if (!settings) throw new AdminError("INVALID_PARAMS", "Réglage des playlists partagées invalide")
audit(me, "playlists partagées", `${guildId} -> ${settings.enabled ? "activées" : "désactivées"}, modification : ${settings.editors}`)
process.emit("GUILDS_UPDATE")
// Les membres présents sur ce serveur rechargent leurs playlists partagées
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
return { sharedPlaylists: sharedPlaylistsSummary(guildId) }
})
AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => {
if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible")
await discordBot.refreshGuilds()
audit(me, "rafraîchissement des serveurs")
process.emit("USERS_UPDATE")
return guildsList(null)
})
// 5. PLAYERS
AdminRequest("/ADMIN/PLAYERS/LIST", async () => playersList())
AdminRequest("/ADMIN/PLAYERS/GET", async (payload) => {
const guildId = requireId(asObject(payload).guildId, "guildId")
const player = getConnectedPlayer(guildId)
const summary = playerSummary(player, { full: true })
if (!summary) throw new AdminError("UNAVAILABLE", "État du player illisible")
return summary
})
AdminRequest("/ADMIN/PLAYERS/ACTION", async (payload, me) => {
const data = asObject(payload)
const guildId = requireId(data.guildId, "guildId")
if (!PLAYER_ACTIONS.includes(data.action)) throw new AdminError("INVALID_PARAMS", "Action inconnue")
// loop / shuffle : valeur explicite facultative (sinon bascule)
let value
if ((data.action === "loop" || data.action === "shuffle") && data.value !== undefined && data.value !== null) {
value = requireBool(data.value)
}
const player = getConnectedPlayer(guildId)
const guildName = discordBot.getGuilds().get(guildId)?.name ?? guildId
const result = await runPlayerAction(player, data.action, value)
// Journalisé après coup : une action refusée (UNAVAILABLE...) n'apparaît pas comme effectuée
audit(me, `player ${data.action}${value === undefined ? "" : (value ? " (activé)" : " (désactivé)")}`, `sur ${guildName} (${guildId})`)
return result
})
// Ancien nom (corrigé) : états bruts des players connectés
AdminRequest("/ADMIN/PLAYER/GETALLSTATE", async () => {
const states = []
for (const player of connectedPlayers()) {
try {
states.push(player.getState())
} catch (e) {
alog.error(`Lecture de l'état du player ${player.guildId} impossible : ${e?.message || e}`)
}
}
return states
})
// 6. WARP
AdminRequest("/ADMIN/WARP/STATUS", async () => warp.getStatus())
AdminRequest("/ADMIN/WARP/CHECK", async () => {
if (!warp.getStatus().configured) throw new AdminError("UNAVAILABLE", "Aucun proxy WARP configuré")
let timer
try {
await Promise.race([
warp.checkHealth(),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new AdminError("UNAVAILABLE", "Délai de vérification dépassé")), WARP_CHECK_TIMEOUT)
})
])
} finally {
clearTimeout(timer)
}
return warp.getStatus()
})
AdminRequest("/ADMIN/WARP/CONFIG", async (payload, me) => {
const data = asObject(payload)
const patch = {}
for (const key of ["enabled", "url", "fallbackDirect"]) {
if (data[key] !== undefined) patch[key] = data[key]
}
if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée")
let result
try {
// Champs verrouillés par l'environnement, URL invalide... : INVALID_PARAMS
result = await warp.setConfig(patch, { check: false })
} catch (e) {
if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message)
throw e
}
// Jamais l'URL brute dans le journal (identifiants éventuels) : setConfig masque déjà les URL
if (result.changes.length) audit(me, "configuration WARP", result.changes.join(", "))
// Contrôle immédiat du proxy, borné pour répondre avant le délai du client
if (warp.getStatus().configured) {
let timer
try {
await Promise.race([
warp.checkHealth(),
new Promise((resolve) => {
timer = setTimeout(resolve, WARP_CHECK_TIMEOUT)
timer.unref?.()
})
])
} finally {
clearTimeout(timer)
}
}
return { status: warp.getStatus(), changes: result.changes }
})
// 7. MÉTRIQUES
AdminRequest("/ADMIN/METRICS", async () => metricsData())
AdminRequest("/ADMIN/METRICS/REPAIR", async (payload, me) => {
const repaired = SafeMetric.repair()
audit(me, "réparation des métriques", `${repaired} valeur(s) corrigée(s)`)
return { repaired }
})
AdminRequest("/ADMIN/METRICS/DELETE", async (payload, me) => {
const name = requireString(asObject(payload).name, "name", 1, 200)
if (!SafeMetric.remove(name)) throw new AdminError("NOT_FOUND", "Métrique introuvable")
audit(me, "suppression de la métrique", name)
return { deleted: name }
})
// 8. MAINTENANCE
AdminRequest("/ADMIN/MAINTENANCE/RESTART", async (payload, me) => {
// Une chaîne seule reste acceptée (ancien client)
const reason = requireString(typeof payload === "string" ? payload : asObject(payload).reason, "reason", 3, 200)
if (maintenance.isRestarting()) throw new AdminError("UNAVAILABLE", "Un redémarrage est déjà en cours")
audit(me, "redémarrage", `raison : ${reason}`)
const at = new Date().toISOString()
// Après la réponse : annonce "/MAINTENANCE" (via l'événement MAINTENANCE), départ des salons vocaux puis arrêt
setTimeout(() => maintenance.restart(reason), 50)
return { restarting: true, at }
})
AdminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", async (payload, me) => {
const data = asObject(payload)
const message = requireString(data.message, "message", 1, 500)
const level = data.level === undefined || data.level === null ? "info" : data.level
if (level !== "info" && level !== "warn") throw new AdminError("INVALID_PARAMS", "Paramètre \"level\" invalide (info ou warn)")
if (io) io.emit("/ANNOUNCE", { message, level, at: new Date().toISOString() })
audit(me, "annonce globale", `[${level}] ${message}`)
return true
})
AdminRequest("/ADMIN/DATABASES", async () => databasesInfo(true))
AdminRequest("/ADMIN/PLAYLISTS/LIST", async (payload) => {
const data = asObject(payload)
const userId = data.userId === undefined || data.userId === null ? null : requireId(data.userId)
const all = dbData("Playlists", {})
const result = []
for (const ownerId of Object.keys(all)) {
if (userId && ownerId !== userId) continue
const list = Array.isArray(all[ownerId]) ? all[ownerId] : []
if (list.length === 0 && !userId) continue
const owner = users.getUserById(ownerId)
result.push({
userId: ownerId,
username: owner?.identity?.username ?? null,
// Identité légère pour l'affichage (jamais auth ni tokens)
owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null,
playlists: list.map(playlistLite)
})
}
return result
})
// Playlists partagées des serveurs : {guildId?}. Serveurs ayant des playlists (même désactivées ou quittés
// par le bot) ou l'option activée
AdminRequest("/ADMIN/PLAYLISTS/GUILDS", async (payload) => {
const data = asObject(payload)
const filter = data.guildId === undefined || data.guildId === null ? null : requireId(data.guildId, "guildId")
const botGuilds = discordBot.getGuilds()
const ids = new Set(guildPlaylists.listGuildIds())
if (typeof serverSettings.getSharedPlaylists === "function") {
for (const guildId of botGuilds.keys()) {
if (serverSettings.getSharedPlaylists(guildId)?.enabled) ids.add(guildId)
}
}
if (filter) {
ids.clear()
ids.add(filter)
}
const result = []
for (const guildId of ids) {
const list = guildPlaylists.getPlaylists(guildId)
const shared = sharedPlaylistsSummary(guildId)
if (!filter && list.length === 0 && !shared.enabled) continue
const guild = botGuilds.get(guildId)
result.push({
guildId,
guildName: guild?.name ?? null,
guildIcon: guild?.icon ?? null,
settings: { enabled: shared.enabled, editors: shared.editors },
playlists: list.map(p => ({
...playlistLite(p),
createdBy: p?.createdBy ? userLite(p.createdBy) : null
}))
})
}
return result.sort((a, b) => (a.guildName || a.guildId).localeCompare(b.guildName || b.guildId))
})
// {userId, playlistId} : playlist personnelle ; {guildId, playlistId} : playlist partagée d'un serveur
AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => {
const data = asObject(payload)
const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64)
if (data.guildId !== undefined && data.guildId !== null) {
const guildId = requireId(data.guildId, "guildId")
const playlist = guildPlaylists.getPlaylist(guildId, playlistId)
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
if (!guildPlaylists.deletePlaylist(guildId, playlist.playlistId)) throw new AdminError("NOT_FOUND", "Playlist introuvable")
audit(me, "suppression de la playlist partagée", `"${playlist.title}" (${playlistId}) du serveur ${guildId}`)
process.emit("GUILDS_UPDATE")
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
return { deleted: true }
}
const userId = requireId(data.userId)
const list = dbData("Playlists", {})[userId]
const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
if (playlists.removePlaylist(userId, playlist.playlistId) === false) throw new AdminError("NOT_FOUND", "Playlist introuvable")
audit(me, "suppression de la playlist", `"${playlist.title}" (${playlistId}) de ${userId}`)
// Le propriétaire connecté recharge ses playlists
process.emit("PLAYLISTS_UPDATE", userId)
return { deleted: true }
})
// 9. YOUTUBE (cookies, yt-dlp, réglages, diagnostics)
// Jamais de contenu de cookie dans les réponses ni dans les logs : seulement des noms, des compteurs et des dates
AdminRequest("/ADMIN/YOUTUBE/STATUS", async () => {
await refreshYtdlpVersion()
return youtubeStatus()
})
AdminRequest("/ADMIN/YOUTUBE/COOKIES/IMPORT", async (payload, me) => {
const data = asObject(payload)
if (typeof data.content !== "string" || !data.content.trim() || data.content.length > COOKIES_MAX_LENGTH) {
throw new AdminError("INVALID_PARAMS", `Contenu des cookies invalide (1 à ${COOKIES_MAX_LENGTH} caractères)`)
}
const source = data.source === undefined || data.source === null ? "paste" : data.source
if (!COOKIE_IMPORT_SOURCES.includes(source)) throw new AdminError("INVALID_PARAMS", "Paramètre \"source\" invalide (paste ou file)")
let result
try {
result = youtube.cookies.importCookies(data.content, { source, by: adminIdentity(me) })
} catch (e) {
throw youtubeError(e)
}
audit(me, "import des cookies YouTube", `(${source === "file" ? "fichier" : "collage"}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`)
// Vérification en direct en arrière-plan : le client reçoit le résultat par la poussée
const checking = startCookieCheck()
return { status: youtubeStatus(), warnings: result.warnings, checking }
})
AdminRequest("/ADMIN/YOUTUBE/COOKIES/BROWSER", async (payload, me) => {
const data = asObject(payload)
if (!YOUTUBE_BROWSERS.includes(data.browser)) throw new AdminError("INVALID_PARAMS", "Navigateur inconnu")
const profile = optionalString(data.profile, "profile", 200)
// Syntaxe de yt-dlp : NAVIGATEUR[:PROFIL][::CONTENEUR] (pas de retour à la ligne, pas de "::")
if (profile && (/[\r\n\0]/.test(profile) || profile.includes("::"))) throw new AdminError("INVALID_PARAMS", "Paramètre \"profile\" invalide")
let result
try {
result = await importFromBrowser(data.browser, profile, adminIdentity(me))
} catch (e) {
throw youtubeError(e)
}
audit(me, "import des cookies YouTube", `(navigateur ${data.browser}${profile ? ", profil personnalisé" : ""}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`)
const checking = startCookieCheck()
return { status: youtubeStatus(), warnings: result.warnings, checking }
})
AdminRequest("/ADMIN/YOUTUBE/COOKIES/CHECK", async (payload, me) => {
if (!youtube.cookies.getStatus().present) throw new AdminError("UNAVAILABLE", "Aucun cookie YouTube enregistré")
let check
try {
check = await youtube.check.checkCookies()
} catch (e) {
throw youtubeError(e)
}
// L'état des cookies est déjà dans "status"
const { status: _cookieStatus, ...result } = check
audit(me, "vérification des cookies YouTube", `${result.ok ? "réussie" : "échouée"} : ${result.message}`)
return { result, status: youtubeStatus() }
})
AdminRequest("/ADMIN/YOUTUBE/COOKIES/DELETE", async (payload, me) => {
const before = youtube.cookies.getStatus()
try {
youtube.cookies.deleteCookies()
} catch (e) {
throw new AdminError("INTERNAL", `Suppression des cookies impossible : ${e?.message || e}`)
}
audit(me, "suppression des cookies YouTube", before.present ? `(${before.count} cookie(s))` : "(aucun cookie enregistré)")
return youtubeStatus()
})
AdminRequest("/ADMIN/YOUTUBE/SETTINGS", async (payload, me) => {
const patch = asObject(payload).patch
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new AdminError("INVALID_PARAMS", "Paramètre \"patch\" invalide (objet attendu)")
const keys = Object.keys(patch)
if (keys.length === 0) throw new AdminError("INVALID_PARAMS", "Aucun réglage à modifier")
if (keys.length > YOUTUBE_SETTINGS_MAX_KEYS) throw new AdminError("INVALID_PARAMS", "Trop de réglages dans la requête")
const before = youtube.settings.get()
try {
// Clés inconnues, valeurs invalides et clés verrouillées par l'environnement : INVALID_PARAMS
youtube.settings.update(patch, adminIdentity(me))
} catch (e) {
throw youtubeError(e)
}
const changes = describeSettingsChange(before, youtube.settings.get())
if (changes) audit(me, "réglages YouTube", changes)
return youtubeStatus()
})
AdminRequest("/ADMIN/YOUTUBE/TEST", async (payload) => {
const data = asObject(payload)
const url = optionalString(data.url, "url", 500)
if (url) {
let parsed = null
try {
parsed = new URL(url)
} catch (e) {
parsed = null
}
if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(url)) throw new AdminError("INVALID_PARAMS", "Paramètre \"url\" invalide (lien http(s) attendu)")
}
const route = data.route === undefined || data.route === null ? "auto" : data.route
if (!YOUTUBE_TEST_ROUTES.includes(route)) throw new AdminError("INVALID_PARAMS", "Paramètre \"route\" invalide (auto, warp ou direct)")
const identity = data.identity === undefined || data.identity === null ? "auto" : data.identity
if (!YOUTUBE_TEST_IDENTITIES.includes(identity)) throw new AdminError("INVALID_PARAMS", "Paramètre \"identity\" invalide (auto, anon ou cookies)")
// Vide ou absent : clients des réglages
const clients = optionalString(data.clients, "clients", 200)
try {
return await youtube.check.testPlayback({ url: url || undefined, route, identity, clients: clients ?? undefined })
} catch (e) {
throw youtubeError(e)
}
})
AdminRequest("/ADMIN/YOUTUBE/UPDATE", async (payload, me) => {
let result
try {
// Une mise à jour déjà en cours (automatique) est simplement attendue
result = await youtube.ytdlp.update({ reason: `manuelle, par ${me.identity.username}` })
} catch (e) {
throw youtubeError(e)
}
audit(me, "mise à jour de yt-dlp", result?.message || "")
return { result, status: youtubeStatus() }
})
AdminRequest("/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR", async (payload, me) => {
youtube.diagnostics.clear()
audit(me, "effacement des diagnostics YouTube")
return youtubeStatus()
})
// 10. CONFIGURATION (config.json) ET INTÉGRATIONS
// Les secrets ne sont jamais renvoyés ni journalisés : seulement {set, length}
AdminRequest("/ADMIN/CONFIG/GET", async () => configPayload())
AdminRequest("/ADMIN/CONFIG/SET", async (payload, me) => {
const data = asObject(payload)
if (typeof data.key !== "string" || !configuration.getKeys().includes(data.key)) {
throw new AdminError("INVALID_PARAMS", "Clé de configuration inconnue")
}
if (data.value === undefined || data.value === null) throw new AdminError("INVALID_PARAMS", "Paramètre \"value\" manquant")
let result
try {
result = configuration.setValue(data.key, data.value)
} catch (e) {
if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message)
throw e
}
if (result.changed) {
if (data.key.startsWith("api.spotify.")) spotifyLastTest = null
// Clé seulement, jamais la valeur
audit(me, "modification de la configuration", data.key + (result.requiresRestart ? " (redémarrage requis)" : ""))
// Serveur de la base multimédia : protégé dans la liste des serveurs
if (data.key === "media.guildId") process.emit("GUILDS_UPDATE")
}
return { ...configPayload(), result }
})
AdminRequest("/ADMIN/CONFIG/SPOTIFY/TEST", async (payload, me) => {
const result = await testSpotify()
audit(me, "test des identifiants Spotify", result.ok ? "réussi" : "échoué")
return { result, integrations: integrationsStatus() }
})
}
// POUSSÉES GLOBALES
/**
* Initialisation unique : poussées périodiques et événementielles vers les rooms ADMIN / ADMIN_LOGS
* @param {import('socket.io').Server} ioInstance
* @param {Object} [deps] réservé (dépendances supplémentaires)
*/
function init(ioInstance, deps = {}) {
if (initialized) return
initialized = true
io = ioInstance
LogReader.init()
// Vue d'ensemble toutes les 10 s, seulement si un admin regarde
setInterval(async () => {
if (roomSize(ADMIN_ROOM) === 0) return
try {
io.to(ADMIN_ROOM).emit("/ADMIN/OVERVIEW/UPDATE", await buildOverview())
} catch (e) {
alog.error(`Vue d'ensemble admin impossible : ${e?.stack || e}`)
}
}, OVERVIEW_INTERVAL).unref()
// Players : chaque changement d'état (coalescé à 250 ms)
const pushPlayers = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/PLAYERS/UPDATE", playersList())
}, 250)
process.on("PLAYER_STATE", pushPlayers)
process.on("PLAYERS_UPDATE", pushPlayers)
// Processus yt-dlp (PID / route affichés dans les players et la section WARP) : Youtube.js ne déclenche
// pas d'état player à leur démarrage ou à leur fin, on compare donc leur signature toutes les 2 s
let ytdlpSignature = ""
setInterval(() => {
if (roomSize(ADMIN_ROOM) === 0) return
const map = getYoutubeProcesses()
const signature = map ? [...map.entries()].map(([guildId, proc]) => `${guildId}:${proc?.pid}:${proc?.route}`).sort().join("|") : ""
if (signature === ytdlpSignature) return
ytdlpSignature = signature
pushPlayers()
}, YTDLP_PROCESS_POLL).unref()
// Utilisateurs connectés
const pushUsers = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/USERS/UPDATE", onlineUsersPayload())
}, 300)
process.on("USERS_UPDATE", pushUsers)
// Serveurs Discord
const pushGuilds = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/GUILDS/UPDATE", guildsList(null))
}, 500)
process.on("GUILDS_UPDATE", pushGuilds)
// Playlists modifiées (personnelles ou partagées) : la section Playlists ouverte se relit d'elle-même
const changedScopes = new Set()
const pushPlaylists = debounce(() => {
const scopes = [...changedScopes]
changedScopes.clear()
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/PLAYLISTS/UPDATE", { scopes })
}, 800)
process.on("PLAYLISTS_UPDATE", () => { changedScopes.add("personal"); pushPlaylists() })
process.on("GUILD_PLAYLISTS_UPDATE", () => { changedScopes.add("guild"); pushPlaylists() })
// WARP : changement d'état du proxy
process.on("WARP_UPDATE", () => {
if (roomSize(ADMIN_ROOM) === 0) return
try {
io.to(ADMIN_ROOM).emit("/ADMIN/WARP/UPDATE", warp.getStatus())
} catch (e) {
alog.error(`Poussée WARP impossible : ${e?.message || e}`)
}
})
// YouTube : cookies, réglages, mises à jour de yt-dlp, tentatives de lecture (coalescé à 500 ms)
const pushYoutube = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/YOUTUBE/UPDATE", youtubeStatus())
}, 500)
process.on("YOUTUBE_UPDATE", pushYoutube)
// Configuration modifiée (par un autre admin) : version masquée, jamais de secret
const pushConfig = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/CONFIG/UPDATE", configPayload())
}, 300)
process.on("CONFIG_UPDATE", pushConfig)
// Maintenance (redémarrage depuis l'admin ou la commande /restart) : prévient tous les clients
process.on("MAINTENANCE", (payload) => {
try {
io.emit("/MAINTENANCE", {
type: payload?.type || "restart",
reason: payload?.reason ?? null,
at: payload?.at || new Date().toISOString()
})
} catch (e) {
alog.error(`Annonce de maintenance impossible : ${e?.message || e}`)
}
})
// Plus personne n'écoute les logs : on arrête la surveillance du fichier
io.of("/").adapter.on("delete-room", (room) => {
if (room === LOGS_ROOM) LogReader.stopTail()
})
alog.log("Module d'administration initialisé")
}
module.exports = {
init,
attach,
AdminError,
syncUserSockets,
disconnectUser,
ADMIN_ROOM,
LOGS_ROOM
}