Version 2.1.0 - Update UI
This commit is contained in:
18 files changed
+3508
-330
No files matched your search
+498
-86
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user