Version 2.1.0 - Update UI

This commit is contained in:
raphix committed 2026-10-08 10:06:46 +02:00
1 parent 6017e03eac
commit dcc350241e
18 files changed
+3508 -330

No files matched your search

+121 -15
View File
@@ -19,6 +19,7 @@ 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')
@@ -53,7 +54,9 @@ const SPOTIFY_TEST_TIMEOUT = 15 * 1000
// Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger
const YTDLP_PROCESS_POLL = 2000
// Jamais "config" : contient le token du bot et les secrets
const DATABASES = ["Users", "Playlists", "history", "media", "previous", "server_settings"]
const 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"]
@@ -447,7 +450,42 @@ function guildSummary(guildData, ctx = buildContext()) {
missingPermissions,
isMediaGuild: !!id && id === configuration.getMediaGuildId(),
player: isConnectedPlayer(player) ? playerSummary(player) : null,
stats: guildStats(id)
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
}
}
@@ -1601,6 +1639,27 @@ function attach({ io: ioInstance, socket, socketUser }) {
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()
@@ -1763,27 +1822,63 @@ function attach({ io: ioInstance, socket, socketUser }) {
username: owner?.identity?.username ?? null,
// Identité légère pour l'affichage (jamais auth ni tokens)
owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null,
playlists: list.map(p => ({
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
title: p?.title ?? null,
type: p?.type ?? null,
// "google" : synchronisée depuis le compte Google du propriétaire
source: p?.source ?? null,
url: typeof p?.url === "string" ? p.url : null,
thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null,
duration: safeNum(p?.duration),
syncedAt: toIso(p?.syncedAt),
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
}))
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 userId = requireId(data.userId)
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")
@@ -2016,6 +2111,17 @@ function init(ioInstance, deps = {}) {
}, 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
+498 -86
View File
@@ -17,6 +17,8 @@ const Finder = require("../player/Finder")
const {__glob} = require("../utils/GlobalVars")
const playlists = require("../playlists/PlaylistManager")
const guildPlaylists = require("../playlists/GuildPlaylists")
const playlistUtils = require("../playlists/PlaylistUtils")
const history = require("../playlists/History")
const lyrics = require("../lyrics/Lyrics")
const serverSettings = require("../discord/ServerSettings")
@@ -92,6 +94,104 @@ function notifyGuildUpdate(guildId) {
io.to(rooms).emit("/GUILD/UPDATE")
}
// Playlists partagées : messages par défaut des refus (enveloppe {ok:false, error, message})
const GUILD_PLAYLIST_ERRORS = {
NO_GUILD: "Aucun serveur actif ou accès refusé à ce serveur",
DISABLED: "Les playlists partagées sont désactivées sur ce serveur",
FORBIDDEN: "Vous n'avez pas les droits nécessaires sur les playlists de ce serveur",
NOT_FOUND: "Playlist introuvable",
INVALID_PARAMS: "Paramètres invalides",
LIMIT: "Limite atteinte",
FAILED: "L'opération a échoué",
}
function refusal(error, message) {
return { ok: false, error, message: message || GUILD_PLAYLIST_ERRORS[error] || GUILD_PLAYLIST_ERRORS.FAILED }
}
function payloadObject(data) {
return data !== null && typeof data === "object" && !Array.isArray(data) ? data : {}
}
/**
* Identifiant de playlist : valeur brute (ancien client) ou {id} / {playlistId}
* @returns {string|null}
*/
function playlistIdFrom(data) {
if(typeof data === "string" || typeof data === "number") return playlistUtils.parsePlaylistId(data)
const payload = payloadObject(data)
return playlistUtils.parsePlaylistId(payload.id ?? payload.playlistId)
}
/**
* Création de playlist {name, url?} : nom de 1 à 100 caractères sans lien ; avec un lien (2000 caractères max),
* un nom non vide remplace le titre importé (un nom identique au lien, envoyé par l'ancien client, est ignoré)
* @returns {{name: string|null, url: string|null}|null}
*/
function parseCreateInput(data) {
const payload = payloadObject(data)
const hasUrl = payload.url !== undefined && payload.url !== null && payload.url !== ""
if(hasUrl) {
const url = playlistUtils.parseUrl(payload.url)
if(!url) return null
let name = null
if(typeof payload.name === "string" && payload.name.trim() !== "" && payload.name.trim() !== url) {
name = playlistUtils.parseName(payload.name)
if(!name) return null
} else if(payload.name !== undefined && payload.name !== null && typeof payload.name !== "string") {
return null
}
return { name, url }
}
const name = playlistUtils.parseName(payload.name)
return name ? { name, url: null } : null
}
/**
* Identifiant de musique (chaîne ou nombre non vide)
*/
function songIdFrom(value) {
if(typeof value === "number" && Number.isFinite(value)) return String(value)
if(typeof value === "string" && value !== "" && value.length <= 200) return value
return null
}
/**
* Statistiques publiques d'un serveur (accueil) : nombres finis, positifs, arrondis
*/
function guildStatsOf(guildId) {
const values = new Map()
for(const metric of SafeMetric.getGuildMetrics(guildId) || []) {
if(metric && typeof metric.name === "string") values.set(metric.name, metric.value)
}
const read = (name) => {
const n = Number(SafeMetric.toSafeNumber(values.get(name + "_" + guildId), 0))
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0
}
return {
musicPlayed: read("numberOfMusicPlayed"),
secondsPlayed: read("numberOfSecondsPlayed"),
commands: read("numberOfCommands"),
}
}
/**
* Appel protégé du module de paroles : toute absence, exception ou réponse mal formée devient UNAVAILABLE
*/
async function callLyrics(functionName, ...args) {
const fn = lyrics && typeof lyrics[functionName] === "function" ? lyrics[functionName] : null
if(!fn) return { ok: false, error: "UNAVAILABLE" }
try {
const result = await fn(...args)
if(result && typeof result === "object" && typeof result.ok === "boolean") return result
wlog.warn("Réponse inattendue du module de paroles (" + functionName + ")")
return { ok: false, error: "UNAVAILABLE" }
} catch(e) {
wlog.error("Erreur du module de paroles (" + functionName + ") : " + (e?.stack || e))
return { ok: false, error: "UNAVAILABLE" }
}
}
function init() {
wlog.step.init("server_init", "Initialisation du serveur Socket.IO")
@@ -177,6 +277,16 @@ function init() {
io.to("user:" + userId).emit("/PLAYLISTS/UPDATE")
})
// Playlists partagées d'un serveur (contenu ou réglage modifié) : sockets présents sur ce serveur
process.on("GUILD_PLAYLISTS_UPDATE", (guildId) => {
if(!io || typeof guildId !== "string" || guildId === "") return
try {
io.to(guildId).emit("/GUILD/PLAYLISTS/UPDATE", { guildId })
} catch(e) {
wlog.error("Erreur lors de l'envoi de la mise à jour des playlists partagées : " + (e?.stack || e))
}
})
io.on("connection", async (socket) => {
var socketUser;
var disconnectHandled = false
@@ -384,23 +494,34 @@ function init() {
// PLAYERS
IORequest("/PLAYER/LYRICS", async () => {
if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYER/LYRICS", false)
const player = await verifyPlayerAction(actualGuildId)
if(!player) return IOAnswer("/PLAYER/LYRICS", false)
if(!player.queue?.current) {
wlog.warn("Le player de la guilde : " + actualGuildId + " n'a pas de musique en cours")
IOAnswer("/PLAYER/LYRICS", false)
return
}
const song = player.queue.current
const lyricsData = await lyrics.getLyrics(song.title + " " + song.author)
if(!lyricsData) {
wlog.warn("Aucune lyrics trouvée pour la musique : " + song.title + " de l'artiste : " + song.author)
IOAnswer("/PLAYER/LYRICS", false)
return
}
IOAnswer("/PLAYER/LYRICS", lyricsData)
// PAROLES (LRCLIB, repli lyrics.ovh) : réponses {ok:true, ...} ou {ok:false, error}
// NO_MUSIC | NOT_FOUND | UNAVAILABLE | INVALID_PARAMS, par acquittement ou par l'événement du même nom
// Paroles de la musique en cours sur le serveur actif (jamais de création de player)
IORequest("/PLAYER/LYRICS", async (_, ack) => {
const reply = (value) => answer("/PLAYER/LYRICS", value, ack)
if(!checkUserGuild(socketUser, actualGuildId)) return reply({ ok: false, error: "NO_MUSIC" })
const song = players.AllPlayers.get(actualGuildId)?.queue?.current
if(!song) return reply({ ok: false, error: "NO_MUSIC" })
reply(await callLyrics("getLyricsForSong", song))
})
// Recherche manuelle : {q} ou texte (200 caractères max)
IORequest("/LYRICS/SEARCH", async (data, ack) => {
const reply = (value) => answer("/LYRICS/SEARCH", value, ack)
const raw = typeof data === "string" ? data : payloadObject(data).q
const query = typeof raw === "string" ? raw.trim() : ""
if(query === "" || query.length > 200) return reply({ ok: false, error: "INVALID_PARAMS" })
reply(await callLyrics("searchCandidates", query))
})
// Paroles d'un résultat de recherche : {id} (identifiant LRCLIB, entier positif)
IORequest("/LYRICS/GET", async (data, ack) => {
const reply = (value) => answer("/LYRICS/GET", value, ack)
const raw = typeof data === "number" || typeof data === "string" ? data : payloadObject(data).id
const id = typeof raw === "string" && /^\d{1,15}$/.test(raw.trim()) ? Number(raw.trim()) : raw
if(!Number.isSafeInteger(id) || id <= 0) return reply({ ok: false, error: "INVALID_PARAMS" })
reply(await callLyrics("getById", id))
})
@@ -460,6 +581,9 @@ function init() {
isAdmin: socketUser.isAdmin(),
}
guildData.members = connectedMembers(actualGuildId, guild.owner)
// Option et droits des playlists partagées (le client adapte l'interface sans requête de plus)
const access = guildPlaylistsAccess()
guildData.sharedPlaylists = access ? sharedPlaylistsInfo(access) : { enabled: false, editors: "members", canEdit: false, canManage: false }
IOAnswer("/GUILD/INFO", guildData, true)
}, true)
@@ -469,6 +593,12 @@ function init() {
IOAnswer("/GUILD/LIST", guildData, true)
}, true)
// Statistiques publiques du serveur actif (accueil) : tout membre ayant accès
IORequest("/GUILD/STATS", (_, ack) => {
if(!checkUserGuild(socketUser, actualGuildId)) return answer("/GUILD/STATS", { ok: false, error: "NO_GUILD" }, ack, true)
answer("/GUILD/STATS", { ok: true, ...guildStatsOf(actualGuildId) }, ack, true)
}, true)
// CHECKED : 03/05/2025
IORequest("/PLAYER/STATE", async () => {
const player = await verifyPlayerAction(actualGuildId)
@@ -632,11 +762,33 @@ function init() {
IOAnswer("/SEARCH/PLAYLIST", true)
})
IORequest("/SEARCH/LYRICS", async (name) => {
if(!name) return IOAnswer("/SEARCH/LYRICS", false)
const lyricsData = await lyrics.getLyrics(name)
if(!lyricsData) return IOAnswer("/SEARCH/LYRICS", false)
IOAnswer("/SEARCH/LYRICS", lyricsData)
// Ancienne route : texte libre ou {track, artist?, duration?} → paroles du meilleur résultat
IORequest("/SEARCH/LYRICS", async (data, ack) => {
const reply = (value) => answer("/SEARCH/LYRICS", value, ack)
const invalid = { ok: false, error: "INVALID_PARAMS" }
let query
if(typeof data === "string") {
const text = data.trim()
if(text === "" || text.length > 200) return reply(invalid)
query = text
} else {
const payload = payloadObject(data)
const title = typeof payload.track === "string" ? payload.track.trim() : ""
const author = payload.artist === undefined || payload.artist === null ? "" : payload.artist
if(title === "" || title.length > 200 || typeof author !== "string" || author.length > 200) return reply(invalid)
let duration = 0
if(payload.duration !== undefined && payload.duration !== null) {
duration = Number(payload.duration)
if(!Number.isFinite(duration) || duration < 0 || duration > 86400) return reply(invalid)
}
query = { track: title, artist: author.trim(), duration }
}
// findLyrics : texte libre (« artiste titre ») ou {track, artist, duration}
if(typeof lyrics?.findLyrics === "function") return reply(await callLyrics("findLyrics", query))
const song = typeof query === "string"
? { title: query, author: "", duration: 0 }
: { title: query.track, author: query.artist, duration: query.duration }
reply(await callLyrics("getLyricsForSong", song))
})
// UPLOAD
@@ -734,104 +886,291 @@ function init() {
IOAnswer("/CHANNEL", channel, true)
}, true)
// PLAYLISTS PERSONNELLES
// Réponses inchangées (playlist | false | true) mais par acquittement si le client en fournit un
// (IORequestAck), sinon par l'événement du même nom. Chaque modification est poussée à toutes les
// sessions de l'utilisateur (/PLAYLISTS/UPDATE) pour que ses autres appareils se mettent à jour.
// CHECKED : 30/04/2025
IORequest("/PLAYLISTS/CREATE", async (data) => {
if(!data) return IOAnswer("/PLAYLISTS/CREATE", false)
const {name, url} = data
if(!name || typeof name !== "string") return IOAnswer("/PLAYLISTS/CREATE", false)
const playlist = await playlists.addPlaylist(socketUser.identity.id, name, url, socketUser.identity.username, socketUser.identity.id, socketUser.identity.avatar)
if(!playlist) return IOAnswer("/PLAYLISTS/CREATE", false)
IOAnswer("/PLAYLISTS/CREATE", playlist)
IORequest("/PLAYLISTS/CREATE", async (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/CREATE", value, ack)
const input = parseCreateInput(data)
if(!input) return reply(false)
// Identité figée avant l'import (socketUser est relu par chaque nouvelle requête)
const identity = socketUser.identity
const playlist = await playlists.addPlaylist(identity.id, input.name, input.url, identity.username, identity.id, identity.avatar)
if(!playlist) return reply(false)
personalPlaylistsChanged(identity.id)
reply(playlist)
})
// CHECKED : 30/04/2025
IORequest("/PLAYLISTS/DELETE", (data) => {
if(!data) return IOAnswer("/PLAYLISTS/DELETE", false)
IOAnswer("/PLAYLISTS/DELETE", playlists.removePlaylist(socketUser.identity.id, data))
IORequest("/PLAYLISTS/DELETE", (data, ack) => {
const id = playlistIdFrom(data)
if(!id) return answer("/PLAYLISTS/DELETE", false, ack)
const userId = socketUser.identity.id
const removed = playlists.removePlaylist(userId, id)
if(removed) personalPlaylistsChanged(userId)
answer("/PLAYLISTS/DELETE", removed, ack)
})
// CHECKED : 24/04/2025
IORequest("/PLAYLISTS/LIST", () => {
const playlist = playlists.getPlaylistsOfUser(socketUser.identity.id)
IOAnswer("/PLAYLISTS/LIST", playlist)
IORequest("/PLAYLISTS/LIST", (_, ack) => {
answer("/PLAYLISTS/LIST", playlists.getPlaylistsOfUser(socketUser.identity.id), ack)
})
// Envoie une copie d'une playlist à un utilisateur partageant au moins un serveur
// {playlistId (ou name, ancien nom du champ), toId}
IORequest("/PLAYLISTS/SEND", (data) => {
if(!data) return IOAnswer("/PLAYLISTS/SEND", false)
const toId = data.toId
const playlistId = data.playlistId ?? data.id ?? data.name
if(!playlistId || !toId || typeof toId !== "string") return IOAnswer("/PLAYLISTS/SEND", false)
IORequest("/PLAYLISTS/SEND", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/SEND", value, ack)
const payload = payloadObject(data)
const toId = payload.toId
const playlistId = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id ?? payload.name)
if(!playlistId || !toId || typeof toId !== "string") return reply(false)
const fromId = socketUser.identity.id
if(toId === fromId) return IOAnswer("/PLAYLISTS/SEND", false)
if(toId === fromId) return reply(false)
const toUser = users.getUserById(toId)
if(!toUser || toUser.isDeleted()) return IOAnswer("/PLAYLISTS/SEND", false)
if(!toUser || toUser.isDeleted()) return reply(false)
const shareGuild = [...discordBot.getGuilds().values()].some(g => g.allMembers?.includes(fromId) && g.allMembers?.includes(toId))
if(!shareGuild) return IOAnswer("/PLAYLISTS/SEND", false)
if(!shareGuild) return reply(false)
// copyPlaylist refuse un doublon de titre chez le destinataire
const copy = playlists.copyPlaylist(fromId, toId, playlistId)
if(copy) process.emit("PLAYLISTS_UPDATE", toId)
IOAnswer("/PLAYLISTS/SEND", !!copy)
reply(!!copy)
})
// CHECKED : 30/04/2025
IORequest("/PLAYLISTS/RENAME", (data) => {
if(!data) return IOAnswer("/PLAYLISTS/RENAME", false)
const {id, newName} = data
if(!id || !newName || typeof newName !== "string" || newName.trim() === "" || newName.length > 200) return IOAnswer("/PLAYLISTS/RENAME", false)
var playlist = playlists.getPlaylist(socketUser.identity.id, id)
if(!playlist) return IOAnswer("/PLAYLISTS/RENAME", false)
playlists.renamePlaylist(socketUser.identity.id, id, newName.trim())
playlist = playlists.getPlaylist(socketUser.identity.id, id)
IOAnswer("/PLAYLISTS/RENAME", playlist)
IORequest("/PLAYLISTS/RENAME", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/RENAME", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id)
const newName = playlistUtils.parseName(payload.newName, 200)
if(!id || !newName) return reply(false)
const userId = socketUser.identity.id
const playlist = playlists.renamePlaylist(userId, id, newName)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
// CHECKED : 30/04/2025
IORequest("/PLAYLISTS/ADD_SONG", (data) => {
if(!data) return IOAnswer("/PLAYLISTS/ADD_SONG", false)
const {id, song} = data
if(!id || !song) return IOAnswer("/PLAYLISTS/ADD_SONG", false)
var playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
if(!playlist) return IOAnswer("/PLAYLISTS/ADD_SONG", false)
playlists.addSong(socketUser.identity.id, id, song)
playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
IOAnswer("/PLAYLISTS/ADD_SONG", playlist)
IORequest("/PLAYLISTS/ADD_SONG", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/ADD_SONG", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id || !payload.song) return reply(false)
const userId = socketUser.identity.id
// null : playlist inconnue, musique invalide ou limite de titres atteinte
const playlist = playlists.addSong(userId, id, payload.song)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
// CHECKED : 30/04/2025
IORequest("/PLAYLISTS/REMOVE_SONG", (data) => {
if(!data) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false)
const {id, songId} = data
if(!id || !songId) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false)
var playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
if(!playlist) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false)
// {id, songId, index?} : index désigne l'occurrence exacte quand la musique est en double
IORequest("/PLAYLISTS/REMOVE_SONG", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/REMOVE_SONG", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id)
const songId = songIdFrom(payload.songId)
if(!id || !songId) return reply(false)
const index = playlistUtils.parseIndex(payload.index)
const userId = socketUser.identity.id
const playlist = playlists.removeSong(userId, id, songId, index === null ? undefined : index)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
playlists.removeSong(socketUser.identity.id, id, songId)
playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
// Déplace une musique dans une playlist : {id, from, to}
IORequest("/PLAYLISTS/MOVE_SONG", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/MOVE_SONG", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id)
const from = playlistUtils.parseIndex(payload.from)
const to = playlistUtils.parseIndex(payload.to)
if(!id || from === null || to === null) return reply(false)
const userId = socketUser.identity.id
const playlist = playlists.moveSong(userId, id, from, to)
if(!playlist) return reply(false)
if(from !== to) personalPlaylistsChanged(userId)
reply(playlist)
})
IOAnswer("/PLAYLISTS/REMOVE_SONG", playlist)
// Ordre d'affichage des playlists : {order: [playlistId...]} (identifiants absents conservés à la suite)
IORequest("/PLAYLISTS/REORDER", (data, ack) => {
const order = payloadObject(data).order
if(!Array.isArray(order)) return answer("/PLAYLISTS/REORDER", false, ack)
const userId = socketUser.identity.id
const done = playlists.reorderPlaylists(userId, order)
if(done) personalPlaylistsChanged(userId)
answer("/PLAYLISTS/REORDER", done, ack)
})
// CHECKED : 05/05/2025
IORequest("/PLAYLISTS/PLAY", async (data) => {
if(!data) return IOAnswer("/PLAYLISTS/PLAY", false)
const {name, now} = data
if(!name) return IOAnswer("/PLAYLISTS/PLAY", false)
if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYLISTS/PLAY", false)
const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, name)
if(!playlist) return IOAnswer("/PLAYLISTS/PLAY", false)
// {id, now} (ou {name, now} : ancien nom du champ, qui contenait déjà l'identifiant)
IORequest("/PLAYLISTS/PLAY", async (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/PLAY", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id ?? payload.name)
if(!id) return reply(false)
if(!checkUserGuild(socketUser, actualGuildId)) return reply(false)
const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
if(!playlist) return reply(false)
const player = new Player(actualGuildId)
if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/PLAYLISTS/PLAY", false)
player.readPlaylist(playlist, now)
IOAnswer("/PLAYLISTS/PLAY", true)
if(!connectToPlayer(actualGuildId, player)) return reply(false)
player.readPlaylist(playlist, !!payload.now)
reply(true)
})
IORequest("/PLAYLISTS/REFRESH", async (data) => {
if(!data) return IOAnswer("/PLAYLISTS/REFRESH", false)
const playlist = await playlists.refreshPlaylist(socketUser.identity.id, data)
if(!playlist) return IOAnswer("/PLAYLISTS/REFRESH", false)
IOAnswer("/PLAYLISTS/REFRESH", playlist)
// Identifiant brut ou {id}
IORequest("/PLAYLISTS/REFRESH", async (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/REFRESH", value, ack)
const id = playlistIdFrom(data)
if(!id) return reply(false)
const userId = socketUser.identity.id
const playlist = await playlists.refreshPlaylist(userId, id)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
// PLAYLISTS PARTAGÉES DU SERVEUR ACTIF
// Toujours le serveur actif (actualGuildId, jamais un identifiant du client) et checkUserGuild d'abord.
// Réponses {ok:true, ...} ou {ok:false, error, message} ; error : NO_GUILD, DISABLED, FORBIDDEN,
// NOT_FOUND, INVALID_PARAMS, LIMIT, FAILED. Chaque modification pousse /GUILD/PLAYLISTS/UPDATE {guildId}.
IORequest("/GUILD/PLAYLISTS/LIST", (_, ack) => {
const access = guildPlaylistsAccess()
if(!access) return answer("/GUILD/PLAYLISTS/LIST", refusal("NO_GUILD"), ack, true)
answer("/GUILD/PLAYLISTS/LIST", {
ok: true,
guildId: access.guildId,
settings: { ...access.settings },
canEdit: access.canEdit,
canManage: access.canManage,
// Désactivées : masquées (jamais supprimées), réapparaissent à la réactivation
playlists: access.settings.enabled ? guildPlaylists.getPlaylists(access.guildId) : [],
}, ack, true)
}, true)
GuildPlaylistRequest("/GUILD/PLAYLISTS/CREATE", async (payload, access) => {
const input = parseCreateInput(payload)
if(!input) return refusal("INVALID_PARAMS", "Nom (1 à 100 caractères) ou lien de playlist invalide")
return guildResult(await guildPlaylists.createPlaylist(access.guildId, input, access.user), access.guildId)
}, { edit: true })
// Suppression : créateur de la playlist, modérateur, propriétaire du serveur ou administrateur
GuildPlaylistRequest("/GUILD/PLAYLISTS/DELETE", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
const isCreator = !!playlist.createdBy?.id && playlist.createdBy.id === access.userId
if(!isCreator && !access.isModerator) return refusal("FORBIDDEN", "Seuls le créateur de la playlist et les modérateurs peuvent la supprimer")
if(!guildPlaylists.deletePlaylist(access.guildId, id)) return refusal("NOT_FOUND")
guildPlaylistsChanged(access.guildId)
return { ok: true }
})
GuildPlaylistRequest("/GUILD/PLAYLISTS/RENAME", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const newName = playlistUtils.parseName(payload.newName)
if(!id || !newName) return refusal("INVALID_PARAMS", "Nom invalide (1 à 100 caractères)")
return guildResult(guildPlaylists.renamePlaylist(access.guildId, id, newName, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/ADD_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id || !payload.song) return refusal("INVALID_PARAMS")
return guildResult(guildPlaylists.addSong(access.guildId, id, payload.song, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/REMOVE_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const songId = songIdFrom(payload.songId)
if(!id || !songId) return refusal("INVALID_PARAMS")
const index = playlistUtils.parseIndex(payload.index)
return guildResult(guildPlaylists.removeSong(access.guildId, id, songId, index === null ? undefined : index, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/MOVE_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const from = playlistUtils.parseIndex(payload.from)
const to = playlistUtils.parseIndex(payload.to)
if(!id || from === null || to === null) return refusal("INVALID_PARAMS")
const result = guildPlaylists.moveSong(access.guildId, id, from, to, access.user)
// Déplacement sur place : aucune poussée inutile
if(result?.ok && from === to) return { ok: true, playlist: result.playlist }
return guildResult(result, access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/REORDER", async (payload, access) => {
if(!Array.isArray(payload.order)) return refusal("INVALID_PARAMS")
if(!guildPlaylists.reorder(access.guildId, payload.order)) return refusal("INVALID_PARAMS")
guildPlaylistsChanged(access.guildId)
return { ok: true }
}, { edit: true })
// Playlists importées depuis un lien uniquement
GuildPlaylistRequest("/GUILD/PLAYLISTS/REFRESH", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
return guildResult(await guildPlaylists.refreshPlaylist(access.guildId, id, access.user), access.guildId)
}, { edit: true })
// Lecture : tout membre ayant accès (même logique de connexion que /PLAYLISTS/PLAY)
GuildPlaylistRequest("/GUILD/PLAYLISTS/PLAY", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
const player = new Player(access.guildId)
if(!connectToPlayer(access.guildId, player)) return refusal("FAILED", "Rejoignez d'abord un salon vocal de ce serveur")
player.readPlaylist(playlist, !!payload.now)
return { ok: true }
})
// Partage sur le serveur d'une de MES playlists (copie indépendante) : {playlistId}
GuildPlaylistRequest("/GUILD/PLAYLISTS/IMPORT", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id)
if(!id) return refusal("INVALID_PARAMS")
const personal = playlists.getPlaylistOfUser(access.userId, id)
if(!personal) return refusal("NOT_FOUND")
return guildResult(guildPlaylists.importPlaylist(access.guildId, personal, access.user), access.guildId)
}, { edit: true })
// Copie d'une playlist du serveur dans MES playlists : {id}
GuildPlaylistRequest("/GUILD/PLAYLISTS/COPY", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
if(!playlists.hasRoomForPlaylist(access.userId)) return refusal("LIMIT", "Vous avez atteint la limite de " + playlists.MAX_PLAYLISTS + " playlists : supprimez-en une avant d'en copier une autre")
const copy = playlists.addPlaylistCopy(access.userId, playlist)
if(!copy) return refusal("FAILED", "La copie de la playlist a échoué")
personalPlaylistsChanged(access.userId)
return { ok: true, playlist: copy }
})
// Réglage du serveur actif : {enabled?, editors?} (propriétaire ou administrateur du bot)
IORequest("/OWNER/PLAYLISTS/SETTINGS", (data, ack) => {
const reply = (value) => answer("/OWNER/PLAYLISTS/SETTINGS", value, ack)
if(!checkUserGuild(socketUser, actualGuildId)) return reply(refusal("NO_GUILD"))
const guild = discordBot.getGuilds().get(actualGuildId)
if(!isGuildOwnerOrAdmin(guild)) return reply(refusal("FORBIDDEN", "Réservé au propriétaire du serveur"))
const payload = payloadObject(data)
const patch = {}
if(payload.enabled !== undefined) patch.enabled = payload.enabled
if(payload.editors !== undefined) patch.editors = payload.editors
const settings = serverSettings.setSharedPlaylists(actualGuildId, patch)
if(!settings) return reply(refusal("INVALID_PARAMS", "Réglage invalide (enabled : booléen, editors : \"members\" ou \"mods\")"))
wlog.log("Playlists partagées de la guilde " + actualGuildId + " modifiées par '" + socketUser.identity.username + "'")
guildPlaylistsChanged(actualGuildId)
// Liste des serveurs de l'administration (option affichée)
process.emit("GUILDS_UPDATE")
reply({ ok: true, settings })
})
@@ -900,6 +1239,79 @@ function init() {
return socketUser.isAdmin()
}
/**
* Droits de l'utilisateur sur les playlists partagées du serveur actif (null : pas d'accès au serveur)
* - voir / lire : option activée ; modifier : activée et (tous les membres ou modérateur / propriétaire / admin)
* - gérer l'option : propriétaire ou administrateur du bot
*/
function guildPlaylistsAccess() {
if(!checkUserGuild(socketUser, actualGuildId)) return null
const guild = discordBot.getGuilds().get(actualGuildId)
if(!guild) return null
const settings = serverSettings.getSharedPlaylists(actualGuildId)
const userId = socketUser.identity.id
const isOwner = userId === guild.owner
const isAdmin = socketUser.isAdmin()
const isModerator = isOwner || isAdmin || socketUser.isMod(actualGuildId)
return {
guildId: actualGuildId,
guild,
settings,
userId,
// Identité figée au moment de la requête (createdBy / updatedBy)
user: { ...socketUser.identity },
isModerator,
canEdit: settings.enabled && (settings.editors === "members" || isModerator),
canManage: isOwner || isAdmin,
}
}
function sharedPlaylistsInfo(access) {
return {
enabled: access.settings.enabled,
editors: access.settings.editors,
canEdit: access.canEdit,
canManage: access.canManage,
}
}
/**
* Route des playlists partagées : accès au serveur, option activée et, si edit, droit de modification
* vérifiés avant le handler ; réponse unique (acquittement ou événement), exception → FAILED
*/
function GuildPlaylistRequest(name, handler, { edit = false } = {}) {
IORequest(name, async (data, ack) => {
const reply = (value) => answer(name, value, ack)
try {
const access = guildPlaylistsAccess()
if(!access) return reply(refusal("NO_GUILD"))
if(!access.settings.enabled) return reply(refusal("DISABLED"))
if(edit && !access.canEdit) return reply(refusal("FORBIDDEN", "Seuls les modérateurs peuvent modifier les playlists de ce serveur"))
reply(await handler(payloadObject(data), access))
} catch(e) {
wlog.error("Erreur lors du traitement de " + name + " pour '" + socketUser?.identity?.username + "' : " + (e?.stack || e))
reply(refusal("FAILED"))
}
})
}
function guildPlaylistsChanged(guildId) {
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
}
function personalPlaylistsChanged(userId) {
process.emit("PLAYLISTS_UPDATE", userId)
}
// Résultat de GuildPlaylists → enveloppe de réponse (poussée aux membres présents en cas de succès)
function guildResult(result, guildId) {
if(result?.ok) {
guildPlaylistsChanged(guildId)
return { ok: true, playlist: result.playlist }
}
return refusal(result?.error || "FAILED", result?.message)
}
// CHECKED : 24/04/2025
IORequest("/MOD/USERS/BAN", async (userId) => {
if(!userId || typeof userId !== "string" || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false)