Version 2.1.1 - modification d'une certaine UI

This commit is contained in:
raphix committed 2026-10-08 21:47:31 +02:00
1 parent dcc350241e
commit fc71b10294
10 files changed
+1208 -139

No files matched your search

+433 -104
View File
@@ -1,16 +1,44 @@
const {LogType} = require("loguix")
const config = require("../utils/Database/Configuration")
const config = require("../utils/Database/Configuration")
const wlog = new LogType("MediaBase")
const { Database } = require("../utils/Database/Database")
const {__glob} = require("../utils/GlobalVars")
const { AttachmentBuilder } = require("discord.js")
const { Song } = require("../player/Song")
const { getMediaInformationFromUrl } = require("../media/MediaInformation")
const audioPrepare = require("../media/AudioPrepare")
const discordUrl = require("../media/DiscordUrl")
const { createLimiter, createInflight, mapLimit } = require("../utils/Limiter")
const discordBot = require("./Bot")
/*
* Stockage des fichiers envoyés : chaque fichier est converti en Ogg/Opus puis posté dans un salon Discord privé
* (config media.guildId / media.channelId). Le lien du CDN expire au bout de ~24 h : on conserve l'identifiant
* du message et on renouvelle le lien à la demande (API refresh-urls, sinon relecture du message).
* Entrée de data/media.json : {id (message), channelId, url, thumbnail, name, title, author, album, duration,
* size, originalSize, bitrate, format, createdAt, userId, uploaderName, missing?}
*/
const MiB = 1024 * 1024
// Limite d'envoi des bots selon le niveau de boost du serveur de stockage (par fichier)
const TIER_LIMITS = { 0: 10 * MiB, 1: 10 * MiB, 2: 50 * MiB, 3: 100 * MiB }
// Fichier d'origine accepté (socket.io est limité à 300 Mo par message)
const MAX_INPUT_BYTES = 250 * MiB
const DEFAULT_THUMBNAIL = "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png"
// Codes d'erreur de l'API Discord
const UNKNOWN_CHANNEL = 10003
const UNKNOWN_MESSAGE = 10008
const ENTITY_TOO_LARGE = 40005
const REFRESH_BATCH = 50
var connected = false
var mediaDB = new Database("media", __glob.MEDIA_DB, [])
var channel = null
// Conversion coûteuse en CPU : une à la fois
const transcodeLimiter = createLimiter(1)
// Un seul renouvellement simultané par fichier
const refreshInflight = createInflight()
wlog.step.init("init_db", "Initialisation de la base de données multimédia")
@@ -19,142 +47,443 @@ if(!config.getMediaGuildId() || !config.getMediaChannelId()) {
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.")
}
var channel = null
function resolveChannel() {
let found = null
try {
found = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId())
} catch (e) {
found = null
}
if(!found || typeof found.send !== "function") {
channel = null
connected = false
return false
}
channel = found
connected = true
return true
}
discordBot.getClient().on("ready", async () => {
try {
channel = discordBot.getChannel(config.getMediaGuildId(), config.getMediaChannelId())
} catch (e) {
}
if(!channel || typeof channel.send !== "function") {
channel = null
if(!resolveChannel()) {
wlog.warn("Le canal multimédia n'existe pas, vérifiez le fichier de configuration.")
wlog.step.error("init_db","Impossible d'initialiser la base de données multimédia, vérifiez le fichier de configuration.")
connected = false
return
}
// Le canal existe : la base est utilisable (comme avant), le message de démarrage n'est qu'informatif
connected = true
wlog.step.end("init_db")
try {
const dateTime = new Date()
const date = dateTime.toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris' })
const time = dateTime.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })
const message = `[LOGS] La base de données multimédia a été initialisée le ${date} à ${time}`
// await : un refus de Discord (permissions manquantes…) ne doit pas devenir un rejet non géré
await channel.send(message)
wlog.log("La base de données multimédia a été initialisée avec succès.")
} catch (e) {
wlog.error("Impossible d'envoyer un message au canal multimédia, vérifiez le fichier de configuration : " + (e?.message || e))
}
wlog.log(`Base de données multimédia prête (limite d'envoi : ${Math.round(getStorageLimitBytes() / MiB)} Mo par fichier).`)
})
// Salon de stockage modifié depuis l'administration : pris en compte sans redémarrage
process.on("CONFIG_UPDATE", (change) => {
const key = change?.key
if(key && !String(key).startsWith("media")) return
if(!discordBot.getClient().isReady?.()) return
resolveChannel()
})
// SEND FILE TO DISCORD AND GET THE URL ID
function isConnected() {
return connected && !!channel
}
async function postMedia(file, userId) {
if(!connected) {
wlog.error("La base de données multimédia n'est pas connectée, impossible d'envoyer le fichier.")
return null
function save() {
mediaDB.save()
// Section « Médias » de l'administration (poussée regroupée côté Admin)
process.emit("MEDIA_UPDATE")
}
function fail(error, message) {
return { ok: false, error, message }
}
/**
* Limite d'envoi du salon de stockage. MEDIA_UPLOAD_LIMIT_MB permet de la forcer si Discord la change.
*/
function getStorageLimitBytes() {
const forced = Number(process.env.MEDIA_UPLOAD_LIMIT_MB)
if(Number.isFinite(forced) && forced >= 1 && forced <= 500) return Math.floor(forced * MiB)
const tier = channel?.guild?.premiumTier
return TIER_LIMITS[tier] ?? TIER_LIMITS[0]
}
function getInfo() {
const limit = getStorageLimitBytes()
return {
available: isConnected(),
storageLimitBytes: limit,
maxInputBytes: MAX_INPUT_BYTES,
maxDurationSec: audioPrepare.maxDurationFor(limit),
preferredBitrate: audioPrepare.PREFERRED_BITRATE,
formats: ["mp3", "wav", "ogg", "opus", "flac", "m4a", "aac", "webm"]
}
}
function toBuffer(data) {
if(Buffer.isBuffer(data)) return data
if(data instanceof ArrayBuffer) return Buffer.from(data)
if(ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
return null
}
function findEntry(id) {
if(id === undefined || id === null) return null
return mediaDB.data.find(m => String(m.id) === String(id)) || null
}
function toSong(entry) {
const song = new Song({
type: "attachment",
id: String(entry.id),
title: entry.title || audioPrepare.titleFromName(entry.name),
author: entry.author || entry.uploaderName || "Auteur inconnu",
url: entry.url,
thumbnail: entry.thumbnail || DEFAULT_THUMBNAIL,
duration: entry.duration
})
song.createdAt = entry.createdAt
return song
}
// Pièce jointe audio / pochette d'un message de stockage
function pickAttachments(message) {
const list = [...(message?.attachments?.values?.() || [])]
const audio = list.find(a => /\.(ogg|opus|mp3|wav|flac|m4a|aac|webm)$/i.test(a.name || "")) || list[0] || null
const cover = list.find(a => a !== audio && /\.(jpe?g|png|webp)$/i.test(a.name || "")) || null
return { audio, cover }
}
async function fetchStorageChannel(channelId) {
if(!channelId || String(channelId) === String(channel?.id)) return channel
try {
const attachment = new AttachmentBuilder(file.file)
attachment.setName(file.name) // Set the name of the file
attachment.setDescription("Fichier envoyé par Subsonics Chopin - Raphix")
const message = await channel.send({ files: [attachment] })
const url = message.attachments.first().url
wlog.log(`Fichier envoyé avec succès : ${url}`)
// add the file to the database
mediaDB.data.push({
id: message.id,
url: url,
name: file.name,
size: file.size,
createdAt: new Date().toISOString(),
userId: userId
})
mediaDB.save()
return url
} catch (error) {
wlog.error(`Erreur lors de l'envoi du fichier : ${error.message}`)
return null
return await discordBot.getClient().channels.fetch(channelId)
} catch (e) {
if(e?.code === UNKNOWN_CHANNEL) return null
throw e
}
}
/**
* Renouvelle des liens Discord en un appel (jusqu'à 50) ; renvoie Map(base du lien → lien frais).
*/
async function refreshUrls(urls) {
const result = new Map()
const unique = [...new Set(urls.filter(u => discordUrl.isDiscordCdn(u)))]
for(let i = 0; i < unique.length; i += REFRESH_BATCH) {
const batch = unique.slice(i, i + REFRESH_BATCH)
try {
const res = await discordBot.getClient().rest.post("/attachments/refresh-urls", { body: { attachment_urls: batch } })
for(const item of res?.refreshed_urls || []) {
if(item?.original && item?.refreshed) result.set(discordUrl.baseOf(item.original), item.refreshed)
}
} catch (e) {
wlog.warn("Renouvellement groupé des liens Discord impossible : " + (e?.message || e))
}
}
return result
}
async function refreshAttachmentUrl(url) {
const map = await refreshUrls([url])
return map.get(discordUrl.baseOf(url)) || null
}
function stale(entry) {
return discordUrl.needsRefresh(entry.url) || (!!entry.thumbnail && discordUrl.needsRefresh(entry.thumbnail))
}
/**
* Relit le message de stockage (source de vérité) : liens frais, ou fichier marqué manquant s'il a été supprimé.
* @returns {Promise<Object|null>} l'entrée à jour, ou null si le fichier n'existe plus
*/
function refreshFromMessage(entry) {
return refreshInflight.run(String(entry.id), async () => {
try {
const target = await fetchStorageChannel(entry.channelId)
if(!target?.messages) throw Object.assign(new Error("Salon de stockage inaccessible"), { code: UNKNOWN_CHANNEL })
const message = await target.messages.fetch({ message: String(entry.id), force: true })
const { audio, cover } = pickAttachments(message)
if(!audio?.url) throw Object.assign(new Error("Pièce jointe absente"), { code: UNKNOWN_MESSAGE })
entry.url = audio.url
entry.thumbnail = cover?.url ?? entry.thumbnail ?? null
if(!entry.channelId) entry.channelId = target.id
delete entry.missing
save()
return entry
} catch (e) {
if(e?.code === UNKNOWN_MESSAGE || e?.code === UNKNOWN_CHANNEL) {
// Message supprimé sur Discord : on garde la trace mais le fichier n'est plus proposé
wlog.warn(`Fichier ${entry.id} introuvable sur Discord : marqué comme manquant.`)
entry.missing = true
save()
return null
}
wlog.warn(`Impossible de renouveler le lien du fichier ${entry.id} : ${e?.message || e}`)
return entry
}
})
}
/**
* Garantit des liens valides pour une entrée.
* @param {boolean} force relit le message même si le lien semble encore valide (après un refus du CDN)
*/
async function refreshEntry(entry, force = false) {
if(!entry || entry.missing) return null
if(!force && !stale(entry)) return entry
if(!force) {
const map = await refreshUrls([entry.url, entry.thumbnail].filter(Boolean))
const freshAudio = map.get(discordUrl.baseOf(entry.url))
if(freshAudio) {
entry.url = freshAudio
if(entry.thumbnail) entry.thumbnail = map.get(discordUrl.baseOf(entry.thumbnail)) || entry.thumbnail
save()
return entry
}
}
return refreshFromMessage(entry)
}
/**
* Renouvelle les liens d'une liste d'entrées : un appel groupé, puis relecture des messages pour les restants.
*/
async function refreshEntries(entries) {
const targets = entries.filter(e => !e.missing && stale(e))
if(targets.length === 0) return
const map = await refreshUrls(targets.flatMap(e => [e.url, e.thumbnail].filter(Boolean)))
const remaining = []
for(const entry of targets) {
const freshAudio = map.get(discordUrl.baseOf(entry.url))
if(!freshAudio) {
remaining.push(entry)
continue
}
entry.url = freshAudio
if(entry.thumbnail) entry.thumbnail = map.get(discordUrl.baseOf(entry.thumbnail)) || entry.thumbnail
}
if(remaining.length < targets.length) save()
await mapLimit(remaining, 2, (entry) => refreshFromMessage(entry), () => null)
}
// Anciennes entrées (avant la conversion Opus) : durée lue une seule fois puis mémorisée
async function backfillMetadata(entry) {
if(entry.metaChecked || entry.duration > 0 || entry.missing) return
const probe = new Song()
const ok = await getMediaInformationFromUrl(probe, entry.url)
entry.metaChecked = true
if(ok) {
if(probe.duration > 0) entry.duration = probe.duration
if(!entry.title && probe.title && probe.title !== "Titre inconnu") entry.title = probe.title
if(!entry.author && probe.author && probe.author !== "Auteur inconnu") entry.author = probe.author
}
save()
}
/**
* Convertit puis stocke un fichier.
* @param {{name: string, file: Buffer|ArrayBuffer}} file
* @param {{id: string, username?: string}} user
* @returns {Promise<{ok: true, song: Song, bitrate: number, size: number} | {ok: false, error: string, message: string}>}
*/
async function postMedia(file, user) {
if(!isConnected()) {
wlog.error("La base de données multimédia n'est pas connectée, impossible d'envoyer le fichier.")
return fail("UNAVAILABLE", "Le stockage des fichiers n'est pas configuré sur ce bot.")
}
const userId = typeof user === "string" ? user : user?.id
if(!userId) return fail("INVALID_PARAMS", "Utilisateur inconnu.")
const name = typeof file?.name === "string" ? file.name.trim().slice(0, 200) : ""
const buffer = toBuffer(file?.file)
if(!name || !buffer || buffer.length === 0) return fail("INVALID_FILE", "Fichier vide ou illisible.")
if(buffer.length > MAX_INPUT_BYTES) return fail("TOO_LARGE", `Fichier trop volumineux (${Math.round(MAX_INPUT_BYTES / MiB)} Mo maximum).`)
const prepared = await transcodeLimiter.run(() => audioPrepare.prepare(buffer, name, { limitBytes: getStorageLimitBytes() }))
if(!prepared.ok) {
wlog.warn(`Fichier « ${name} » refusé : ${prepared.error}`)
return prepared
}
const files = [new AttachmentBuilder(prepared.audio, { name: prepared.audioName, description: prepared.meta.title })]
if(prepared.cover) files.push(new AttachmentBuilder(prepared.cover, { name: prepared.coverName }))
let message
try {
message = await channel.send({
content: `🎵 ${prepared.meta.title} · envoyé par ${user?.username || userId} (${userId})`,
files,
allowedMentions: { parse: [] }
})
} catch (error) {
if(error?.code === ENTITY_TOO_LARGE || error?.status === 413) {
return fail("TOO_LARGE", "Discord a refusé le fichier : il dépasse la limite d'envoi du salon de stockage.")
}
wlog.error(`Erreur lors de l'envoi du fichier : ${error?.message || error}`)
return fail("FAILED", "L'envoi vers le stockage a échoué.")
}
const { audio, cover } = pickAttachments(message)
if(!audio?.url) return fail("FAILED", "Le stockage n'a pas renvoyé de lien.")
const entry = {
id: message.id,
channelId: message.channelId ?? channel.id,
url: audio.url,
thumbnail: cover?.url ?? null,
name,
title: prepared.meta.title,
author: prepared.meta.artist,
album: prepared.meta.album,
duration: prepared.meta.duration,
size: audio.size ?? prepared.audio.length,
originalSize: buffer.length,
bitrate: prepared.bitrate,
format: "opus",
createdAt: new Date().toISOString(),
userId,
uploaderName: user?.username || null
}
mediaDB.data.push(entry)
save()
wlog.log(`Fichier stocké : « ${name} » (${Math.round(prepared.bitrate / 1000)} kb/s, ${(entry.size / MiB).toFixed(1)} Mo)`)
return { ok: true, song: toSong(entry), bitrate: prepared.bitrate, size: entry.size }
}
async function getAllMedia(userId) {
if(!connected) {
wlog.error("La base de données multimédia n'est pas connectée, impossible de récupérer les fichiers.")
return []
const entries = mediaDB.data.filter(m => m.userId === userId && !m.missing)
if(isConnected()) {
await refreshEntries(entries)
await mapLimit(entries, 2, (entry) => backfillMetadata(entry), () => null)
}
const allSongs = mediaDB.data.filter(m => m.userId === userId)
const songs = []
for(const songDB of allSongs) {
const song = new Song()
const information = await getMediaInformationFromUrl(song, songDB.url)
if(!information) {
mediaDB.data = mediaDB.data.filter(m => m.id !== songDB.id)
mediaDB.save()
continue
}
song.type = "attachment"
song.author = songDB.author
song.createdAt = songDB.createdAt
song.author = songDB.userId
song.title = songDB.name
song.id = songDB.id
song.url = songDB.url
songs.push(song)
}
return songs
// Les fichiers ne sont plus jamais supprimés de la base parce qu'un lien a expiré
return entries
.filter(m => !m.missing)
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")))
.map(toSong)
}
async function getMedia(id) {
if(!connected) {
wlog.error("La base de données multimédia n'est pas connectée, impossible de récupérer le fichier.")
return null
}
const media = mediaDB.data.find(m => m.id === id)
if(!media) {
const entry = findEntry(id)
if(!entry) {
wlog.error(`Aucun média trouvé avec l'ID : ${id}`)
return null
}
const fresh = await refreshEntry(entry)
return fresh ? fresh.url : null
}
/**
* Lien lisible pour un morceau « attachment » : fichier du stockage (relu par son message) ou pièce jointe
* quelconque (commande /media, anciens morceaux de playlists) renouvelée via l'API Discord.
* @returns {Promise<{url: string, format: string|null}|null>}
*/
async function resolvePlayableUrl(song, { force = false } = {}) {
const entry = findEntry(song?.id)
if(entry) {
const fresh = await refreshEntry(entry, force)
return fresh ? { url: fresh.url, format: fresh.format || null } : null
}
const url = song?.url
if(!discordUrl.isDiscordCdn(url)) return url ? { url, format: null } : null
if(force || discordUrl.needsRefresh(url)) {
const fresh = await refreshAttachmentUrl(url)
if(fresh) return { url: fresh, format: null }
}
return { url, format: null }
}
async function deleteMedia(data, userId) {
const id = typeof data === "object" ? data?.id : data
const mediaIndex = mediaDB.data.findIndex(m => String(m.id) === String(id) && m.userId === userId)
if(mediaIndex === -1) {
wlog.error(`Aucun média trouvé avec l'ID : ${id} pour l'utilisateur : ${userId}`)
return false
}
const [entry] = mediaDB.data.splice(mediaIndex, 1)
save()
// Libère aussi le message de stockage (sans bloquer si Discord refuse)
try {
return media.url
} catch (error) {
wlog.error(`Erreur lors de la récupération du média : ${error.message}`)
return null
const target = await fetchStorageChannel(entry.channelId)
await target?.messages?.delete(String(entry.id))
} catch (e) {
wlog.warn(`Message de stockage ${entry.id} non supprimé : ${e?.message || e}`)
}
return true
}
// ----- Administration -----
/**
* État du salon de stockage (jamais de secret : identifiants Discord publics et limites).
*/
function getStorageStatus() {
const guild = channel?.guild || null
return {
configured: !!(config.getMediaGuildId() && config.getMediaChannelId()),
connected: isConnected(),
guildId: config.getMediaGuildId() || null,
guildName: guild?.name ?? null,
channelId: config.getMediaChannelId() || null,
channelName: channel?.name ?? null,
premiumTier: typeof guild?.premiumTier === "number" ? guild.premiumTier : null,
limitForced: Number.isFinite(Number(process.env.MEDIA_UPLOAD_LIMIT_MB)) && !!process.env.MEDIA_UPLOAD_LIMIT_MB,
...getInfo()
}
}
function deleteMedia(data, userId) {
if(!connected) {
wlog.error("La base de données multimédia n'est pas connectée, impossible de supprimer le fichier.")
return false
}
// Copie des entrées : l'administration ne modifie jamais la base directement
function listEntries() {
return mediaDB.data.map(entry => ({ ...entry }))
}
const mediaIndex = mediaDB.data.findIndex(m => m.id === data.id && m.userId === userId)
if(mediaIndex === -1) {
wlog.error(`Aucun média trouvé avec l'ID : ${data.id} pour l'utilisateur : ${userId}`)
return false
}
/**
* Renouvelle les liens d'un fichier en relisant son message (détecte aussi un message supprimé).
* @returns {Promise<Object|null|undefined>} l'entrée à jour, null si le fichier est manquant, undefined si inconnu
*/
async function refreshMedia(id) {
const entry = findEntry(id)
if(!entry) return undefined
if(!isConnected()) throw Object.assign(new Error("Stockage non connecté"), { code: "UNAVAILABLE" })
// Un fichier marqué manquant peut avoir été restauré (salon recréé, permissions rendues) : on revérifie
delete entry.missing
const fresh = await refreshFromMessage(entry)
return fresh ? { ...fresh } : null
}
mediaDB.data.splice(mediaIndex, 1)
mediaDB.save()
return true
/**
* Renouvelle tous les liens expirés ou proches de l'échéance.
*/
async function refreshAllMedia() {
if(!isConnected()) throw Object.assign(new Error("Stockage non connecté"), { code: "UNAVAILABLE" })
const targets = mediaDB.data.filter(e => !e.missing && stale(e))
await refreshEntries(targets)
return {
checked: targets.length,
refreshed: targets.filter(e => !e.missing && !stale(e)).length,
missing: targets.filter(e => e.missing).length
}
}
/**
* Suppression par un administrateur (quel que soit le propriétaire), message de stockage compris.
* @returns {Promise<Object|null>} l'entrée supprimée, ou null si inconnue
*/
async function adminDeleteMedia(id) {
const entry = findEntry(id)
if(!entry) return null
const ok = await deleteMedia({ id: entry.id }, entry.userId)
return ok ? entry : null
}
module.exports = {
postMedia,
getMedia,
deleteMedia,
getAllMedia
}
getAllMedia,
resolvePlayableUrl,
isConnected,
getInfo,
getStorageLimitBytes,
getStorageStatus,
listEntries,
refreshMedia,
refreshAllMedia,
adminDeleteMedia
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Préparation des fichiers audio envoyés par les utilisateurs avant stockage :
* analyse (ffprobe), conversion en Ogg/Opus (format natif de la voix Discord : lu sans réencodage)
* avec un débit choisi pour tenir dans la limite d'envoi du salon de stockage, extraction de la pochette.
*/
const { spawn } = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')
const ffmpegPath = require('ffmpeg-static')
const ffprobeStatic = require('ffprobe-static')
const KBPS = 1000
// La voix Discord est diffusée à 64–96 kb/s : inutile de stocker plus
const PREFERRED_BITRATE = 96 * KBPS
// En dessous, la musique se dégrade nettement : au-delà de cette durée, le fichier est refusé
const MIN_BITRATE = 32 * KBPS
// Débit pour une durée inconnue (vérifié après conversion)
const UNKNOWN_DURATION_BITRATE = 64 * KBPS
// Marge pour l'enveloppe Ogg et les écarts du débit variable
const SIZE_MARGIN = 0.92
const COVER_MAX_SIZE = 512
const PROBE_TIMEOUT = 30 * 1000
const TRANSCODE_TIMEOUT = 4 * 60 * 1000
const MAX_TEXT = 200
function fail(error, message) {
return { ok: false, error, message }
}
/**
* Lance un exécutable et récupère sa sortie ; rejette en cas de code non nul ou de délai dépassé.
*/
function run(bin, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { windowsHide: true })
const out = []
let err = ""
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
child.kill("SIGKILL")
reject(Object.assign(new Error("Délai dépassé"), { code: "TIMEOUT" }))
}, timeoutMs)
child.stdout.on("data", (chunk) => out.push(chunk))
child.stderr.on("data", (chunk) => { if (err.length < 8000) err += chunk.toString() })
child.on("error", (e) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(e)
})
child.on("close", (code) => {
if (settled) return
settled = true
clearTimeout(timer)
if (code === 0) resolve(Buffer.concat(out))
else reject(Object.assign(new Error(err.trim().split(/\r?\n/).slice(-3).join(" | ") || `Code ${code}`), { code: "EXIT" }))
})
})
}
function cleanText(value) {
if (typeof value !== "string") return null
const text = value.replace(/[\u0000-\u001f]/g, " ").trim().slice(0, MAX_TEXT)
return text || null
}
// Les étiquettes peuvent être en majuscules (Vorbis) ou en minuscules (ID3 vu par ffmpeg)
function readTag(tags, name) {
if (!tags || typeof tags !== "object") return null
for (const key of Object.keys(tags)) {
if (key.toLowerCase() === name) return cleanText(tags[key])
}
return null
}
/**
* Nom de fichier sûr pour Discord (sans chemin ni caractères spéciaux), extension imposée.
*/
function safeFileName(name, extension) {
const base = path.basename(String(name || "audio")).replace(/\.[^.]{1,5}$/, "")
const cleaned = base.normalize("NFKD").replace(/[̀-ͯ]/g, "")
.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, "_").replace(/_+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "").slice(0, 80)
return `${cleaned || "audio"}.${extension}`
}
/**
* Titre lisible tiré du nom de fichier (« Mon_titre-final.mp3 » → « Mon titre-final »).
*/
function titleFromName(name) {
const base = path.basename(String(name || "")).replace(/\.[^.]{1,5}$/, "").replace(/_+/g, " ").trim()
return cleanText(base) || "Fichier audio"
}
/**
* Débit (b/s) pour qu'une durée donnée tienne dans le budget, ou null si c'est impossible au débit minimal.
* @param {number} durationSec
* @param {number} budgetBytes
*/
function planBitrate(durationSec, budgetBytes) {
if (!(budgetBytes > 0)) return null
if (!(durationSec > 0)) return UNKNOWN_DURATION_BITRATE
const fit = Math.floor((budgetBytes * 8 * SIZE_MARGIN) / durationSec)
if (fit < MIN_BITRATE) return null
// Arrondi au kb/s inférieur, plafonné au débit utile
return Math.min(PREFERRED_BITRATE, Math.floor(fit / KBPS) * KBPS)
}
/**
* Durée maximale (s) acceptée pour une limite d'envoi donnée.
*/
function maxDurationFor(limitBytes) {
return Math.floor((limitBytes * 8 * SIZE_MARGIN) / MIN_BITRATE)
}
async function probe(file) {
const raw = await run(ffprobeStatic.path, [
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", file
], PROBE_TIMEOUT)
return JSON.parse(raw.toString("utf8"))
}
function describe(info) {
const streams = Array.isArray(info?.streams) ? info.streams : []
const audio = streams.find(s => s.codec_type === "audio")
if (!audio) return null
const cover = streams.find(s => s.codec_type === "video" && (s.disposition?.attached_pic === 1 || ["mjpeg", "png", "bmp"].includes(s.codec_name)))
const duration = parseFloat(info?.format?.duration ?? audio.duration)
const tags = { ...(info?.format?.tags || {}), ...(audio.tags || {}) }
return {
codec: audio.codec_name || null,
container: info?.format?.format_name || null,
duration: Number.isFinite(duration) && duration > 0 ? duration : 0,
title: readTag(tags, "title"),
artist: readTag(tags, "artist") || readTag(tags, "album_artist"),
album: readTag(tags, "album"),
hasCover: !!cover
}
}
async function transcode(input, output, bitrate) {
await run(ffmpegPath, [
"-hide_banner", "-loglevel", "error", "-y",
"-i", input,
"-map", "0:a:0", "-vn", "-sn", "-dn",
"-map_metadata", "-1",
// VBR contraint : la taille finale reste proche de la cible (le VBR libre la dépasse de 15 % et plus)
"-c:a", "libopus", "-b:a", String(bitrate), "-vbr", "constrained",
"-compression_level", "10", "-application", "audio",
"-ar", "48000", "-ac", "2",
"-f", "ogg", output
], TRANSCODE_TIMEOUT)
return fs.promises.readFile(output)
}
async function extractCover(input, output) {
try {
await run(ffmpegPath, [
"-hide_banner", "-loglevel", "error", "-y",
"-i", input, "-map", "0:v:0", "-frames:v", "1",
"-vf", `scale='min(${COVER_MAX_SIZE},iw)':-2`, "-q:v", "4",
output
], PROBE_TIMEOUT)
const buffer = await fs.promises.readFile(output)
return buffer.length > 0 ? buffer : null
} catch (e) {
// Pochette illisible : le fichier reste utilisable sans elle
return null
}
}
/**
* Analyse et convertit un fichier audio pour le stockage.
* @param {Buffer} buffer contenu du fichier d'origine
* @param {string} name nom d'origine
* @param {{limitBytes: number}} options limite d'envoi du salon de stockage (par fichier)
* @returns {Promise<{ok: true, audio: Buffer, audioName: string, cover: Buffer|null, coverName: string|null,
* meta: {title: string, artist: string|null, album: string|null, duration: number, sourceCodec: string|null},
* bitrate: number} | {ok: false, error: string, message: string}>}
*/
async function prepare(buffer, name, { limitBytes } = {}) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return fail("INVALID_FILE", "Fichier vide ou illisible.")
if (!(limitBytes > 0)) return fail("UNAVAILABLE", "Limite d'envoi inconnue.")
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chopin-media-"))
try {
const input = path.join(dir, "input")
await fs.promises.writeFile(input, buffer)
let info
try {
info = describe(await probe(input))
} catch (e) {
info = null
}
if (!info) return fail("INVALID_FILE", "Ce fichier ne contient pas de piste audio lisible.")
// La pochette part dans le même message : elle est retirée du budget
const cover = info.hasCover ? await extractCover(input, path.join(dir, "cover.jpg")) : null
const budget = limitBytes - (cover ? cover.length : 0)
let bitrate = planBitrate(info.duration, budget)
if (!bitrate) {
const minutes = Math.floor(maxDurationFor(limitBytes) / 60)
return fail("TOO_LONG", `Fichier trop long pour le stockage (environ ${minutes} minutes maximum).`)
}
const output = path.join(dir, "output.ogg")
let audio
try {
audio = await transcode(input, output, bitrate)
// Cible dépassée (enveloppe, durée mal annoncée) : nouveaux essais au débit corrigé
for (let attempt = 0; attempt < 2 && audio.length > budget; attempt++) {
bitrate = Math.floor(bitrate * (budget / audio.length) * 0.95 / KBPS) * KBPS
if (bitrate < MIN_BITRATE) break
audio = await transcode(input, output, bitrate)
}
} catch (e) {
return fail("FAILED", e?.code === "TIMEOUT" ? "La conversion a pris trop de temps." : "La conversion du fichier a échoué.")
}
if (audio.length === 0) return fail("FAILED", "La conversion n'a produit aucun son.")
if (audio.length > budget) return fail("TOO_LONG", "Fichier trop long pour le stockage.")
return {
ok: true,
audio,
audioName: safeFileName(name, "ogg"),
cover,
coverName: cover ? "cover.jpg" : null,
meta: {
title: info.title || titleFromName(name),
artist: info.artist,
album: info.album,
duration: info.duration,
sourceCodec: info.codec
},
bitrate
}
} finally {
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
module.exports = {
prepare,
planBitrate,
maxDurationFor,
safeFileName,
titleFromName,
PREFERRED_BITRATE,
MIN_BITRATE
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Liens du CDN Discord : depuis 2024 ils sont signés (paramètres ex/is/hm) et expirent au bout de ~24 h.
* ex = date d'expiration en secondes Unix, en hexadécimal.
*/
const CDN_HOSTS = new Set(["cdn.discordapp.com", "media.discordapp.net"])
// Renouvelé un peu avant l'échéance : une lecture longue ne doit pas tomber sur un lien mort
const DEFAULT_MARGIN = 60 * 60 * 1000
function parse(url) {
if (typeof url !== "string") return null
try {
return new URL(url)
} catch (e) {
return null
}
}
function isDiscordCdn(url) {
const parsed = parse(url)
return !!parsed && parsed.protocol === "https:" && CDN_HOSTS.has(parsed.hostname)
}
/**
* @returns {number|null} date d'expiration (ms) ou null si le lien n'est pas signé
*/
function getExpiry(url) {
const parsed = parse(url)
const ex = parsed?.searchParams.get("ex")
if (!ex || !/^[0-9a-f]{1,12}$/i.test(ex)) return null
return parseInt(ex, 16) * 1000
}
/**
* Faut-il renouveler ce lien ? (lien Discord non signé, expiré ou proche de l'échéance)
*/
function needsRefresh(url, marginMs = DEFAULT_MARGIN, now = Date.now()) {
if (!isDiscordCdn(url)) return false
const expiry = getExpiry(url)
if (expiry === null) return true
return expiry - now < marginMs
}
/**
* Identifiant stable d'une pièce jointe (chemin sans signature), pour comparer deux versions d'un lien.
*/
function baseOf(url) {
const parsed = parse(url)
return parsed ? `${parsed.hostname}${parsed.pathname}` : null
}
module.exports = { isDiscordCdn, getExpiry, needsRefresh, baseOf, CDN_HOSTS }
+36 -10
View File
@@ -6,6 +6,25 @@ const { isAllowedUrl } = require('../SongCheck')
// Les pièces jointes sont lues depuis le début : le seek n'est pas supporté (le player le refuse)
const seekable = false
// Chargé à la demande : MediaBase dépend du bot Discord, lui-même chargé après le player
function getMediaBase() {
return require('../../discord/MediaBase')
}
async function open(url, signal) {
// Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF)
if(!isAllowedUrl("attachment", url)) {
clog.error("URL de pièce jointe refusée : " + JSON.stringify(url))
return { res: null, status: 0 }
}
const res = await fetch(url, { signal })
if(!res.ok || !res.body) {
try { await res.body?.cancel() } catch(e) {}
return { res: null, status: res.status }
}
return { res, status: res.status }
}
/**
* @param {Object} song
* @param {number} seekSec - ignoré (seek non supporté)
@@ -13,21 +32,28 @@ const seekable = false
* @returns {Promise<import('stream').Readable|null>} le flux, ou null en cas d'échec / d'annulation
*/
async function getStream(song, seekSec = 0, { signal } = {}) {
if(signal?.aborted) return null
// Uniquement le CDN Discord : jamais de requête serveur vers une URL arbitraire (SSRF)
if(!song || !isAllowedUrl("attachment", song.url)) {
clog.error("URL de pièce jointe refusée : " + JSON.stringify(song?.url))
return null
}
if(signal?.aborted || !song) return null
try {
const res = await fetch(song.url, { signal })
if(!res.ok || !res.body) {
clog.error(`Erreur HTTP ${res.status} lors de la lecture de la musique : ${song.title}`)
try { await res.body?.cancel() } catch(e) {}
// Les liens du CDN Discord expirent (~24 h) : lien renouvelé avant la lecture
let target = await getMediaBase().resolvePlayableUrl(song)
if(!target) {
clog.error(`Fichier introuvable dans le stockage : ${song.title}`)
return null
}
let { res, status } = await open(target.url, signal)
// Lien refusé malgré tout (expiré entre-temps, révoqué) : une seconde tentative avec un lien relu
if(!res && (status === 403 || status === 404 || status === 410) && !signal?.aborted) {
target = await getMediaBase().resolvePlayableUrl(song, { force: true })
if(target) ({ res, status } = await open(target.url, signal))
}
if(!res) {
clog.error(`Erreur HTTP ${status} lors de la lecture de la musique : ${song.title}`)
return null
}
// Flux Node (et non flux web) : le player suit la fin réelle du flux
const stream = Readable.fromWeb(res.body)
// Fichiers convertis par le stockage : Ogg/Opus transmis tel quel à Discord, sans ffmpeg
if(target.format === "opus") stream.streamType = "ogg/opus"
if(signal?.aborted) {
stream.destroy()
return null
+5 -2
View File
@@ -642,8 +642,11 @@ class Player {
let resource
try {
resource = createAudioResource(stream, {
// PCM brut déjà décodé (seek natif YouTube) ou flux conteneur quelconque
inputType: stream.streamType === "raw" ? StreamType.Raw : StreamType.Arbitrary,
// PCM brut déjà décodé (seek natif YouTube), Ogg/Opus transmis sans réencodage (fichiers stockés)
// ou flux conteneur quelconque (décodé par ffmpeg)
inputType: stream.streamType === "raw" ? StreamType.Raw
: stream.streamType === "ogg/opus" ? StreamType.OggOpus
: StreamType.Arbitrary,
metadata: { token: token, songId: safe.id ?? null, source: stream, sourceEnded: false, played: false, counted: false }
});
} catch(e) {
+103 -1
View File
@@ -28,6 +28,7 @@ const configuration = require('../utils/Database/Configuration')
const { getDatabase } = require('../utils/Database/Database')
const { __glob } = require('../utils/GlobalVars')
const youtube = require('../youtube')
const discordUrl = require('../media/DiscordUrl')
const alog = new LogType("Admin")
@@ -472,6 +473,57 @@ function sharedPlaylistsSummary(guildId) {
/**
* Résumé d'une playlist pour les listes d'administration (jamais la liste des titres)
*/
/**
* Nombre de playlists (personnelles et partagées) contenant chaque fichier envoyé : Map(id → nombre)
*/
function mediaReferences() {
const counts = new Map()
const scan = (list) => {
for (const playlist of Array.isArray(list) ? list : []) {
const ids = new Set()
for (const song of Array.isArray(playlist?.songs) ? playlist.songs : []) {
if (song?.type === "attachment" && song.id != null) ids.add(String(song.id))
}
for (const id of ids) counts.set(id, (counts.get(id) || 0) + 1)
}
}
for (const list of Object.values(dbData("Playlists", {}))) scan(list)
for (const list of Object.values(dbData("GuildPlaylists", {}))) scan(list)
return counts
}
function cdnUrl(value) {
return discordUrl.isDiscordCdn(value) ? value : null
}
function mediaItem(entry, refs) {
const owner = entry?.userId ? users.getUserById(entry.userId) : null
const expiresAt = discordUrl.getExpiry(entry?.url)
return {
id: entry?.id != null ? String(entry.id) : null,
name: entry?.name ?? null,
title: entry?.title ?? null,
author: entry?.author ?? null,
album: entry?.album ?? null,
duration: safeNum(entry?.duration),
size: safeNum(entry?.size),
originalSize: safeNum(entry?.originalSize),
bitrate: safeNum(entry?.bitrate),
// "legacy" : envoyé avant la conversion Opus (fichier d'origine, décodé par ffmpeg à la lecture)
format: entry?.format || "legacy",
createdAt: toIso(entry?.createdAt),
userId: entry?.userId ?? null,
uploader: owner ? userLite(owner.identity) : (entry?.userId ? { id: entry.userId, username: entry.uploaderName ?? null, global_name: null, avatar: null } : null),
channelId: entry?.channelId ?? null,
url: cdnUrl(entry?.url),
thumbnail: cdnUrl(entry?.thumbnail),
// null : lien non signé (ancien format), à renouveler
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
missing: entry?.missing === true,
playlists: refs?.get(String(entry?.id)) || 0
}
}
function playlistLite(p) {
return {
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
@@ -922,7 +974,10 @@ function mediaStatus() {
connected = false
}
}
return { configured, connected }
// Compteurs pour la vue d'ensemble et la pastille de navigation (lecture de la base, sans requête Discord)
const entries = dbData("media", [])
const list = Array.isArray(entries) ? entries : []
return { configured, connected, files: list.filter(m => !m?.missing).length, missing: list.filter(m => m?.missing).length }
}
let cachedPackageName = null
@@ -1889,6 +1944,46 @@ function attach({ io: ioInstance, socket, socketUser }) {
return { deleted: true }
})
// MÉDIAS (fichiers envoyés, stockés dans le salon Discord multimédia)
// Les liens du CDN expirent : la liste montre leur échéance sans les renouveler (action explicite)
AdminRequest("/ADMIN/MEDIA/LIST", async () => {
const mediaBase = getMediaBase()
const refs = mediaReferences()
return {
status: mediaBase.getStorageStatus(),
items: mediaBase.listEntries().map(entry => mediaItem(entry, refs))
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")))
}
})
// {id} : relit le message de ce fichier ; sans id : renouvelle tous les liens expirés ou proches de l'échéance
AdminRequest("/ADMIN/MEDIA/REFRESH", async (payload, me) => {
const data = asObject(payload)
const mediaBase = getMediaBase()
if (!mediaBase.isConnected()) throw new AdminError("UNAVAILABLE", "Le salon de stockage n'est pas connecté")
if (data.id !== undefined && data.id !== null) {
const id = requireId(String(data.id), "id")
const fresh = await mediaBase.refreshMedia(id)
if (fresh === undefined) throw new AdminError("NOT_FOUND", "Fichier introuvable")
audit(me, "renouvellement du lien du média", id)
const entry = mediaBase.listEntries().find(e => String(e.id) === id)
return { item: entry ? mediaItem(entry, mediaReferences()) : null }
}
const result = await mediaBase.refreshAllMedia()
audit(me, "renouvellement des liens des médias", `${result.refreshed}/${result.checked}`)
return result
})
AdminRequest("/ADMIN/MEDIA/DELETE", async (payload, me) => {
const data = asObject(payload)
const id = requireId(data.id === undefined || data.id === null ? data.id : String(data.id), "id")
const removed = await getMediaBase().adminDeleteMedia(id)
if (!removed) throw new AdminError("NOT_FOUND", "Fichier introuvable")
audit(me, "suppression du média", `"${removed.title || removed.name}" (${id}) de ${removed.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
@@ -2122,6 +2217,13 @@ function init(ioInstance, deps = {}) {
process.on("PLAYLISTS_UPDATE", () => { changedScopes.add("personal"); pushPlaylists() })
process.on("GUILD_PLAYLISTS_UPDATE", () => { changedScopes.add("guild"); pushPlaylists() })
// Médias : envoi, suppression, liens renouvelés (la section ouverte se relit d'elle-même)
const pushMedia = debounce(() => {
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/MEDIA/UPDATE")
}, 1000)
process.on("MEDIA_UPDATE", pushMedia)
// WARP : changement d'état du proxy
process.on("WARP_UPDATE", () => {
if (roomSize(ADMIN_ROOM) === 0) return
+20 -20
View File
@@ -28,7 +28,6 @@ const googleApis = require("../playlists/Google/OAuth2")
const configuration = require("../utils/Database/Configuration")
const { List } = require('../player/List')
const Admin = require("./Admin")
const { isAudioFile } = require('../utils/AudioBufferCheck')
const { Song } = require('../player/Song')
const { getMediaInformationFromUrl } = require('../media/MediaInformation')
@@ -793,31 +792,32 @@ function init() {
// UPLOAD
// CHECKED : 29/05/2025
// Fichiers convertis en Ogg/Opus puis stockés dans le salon Discord multimédia (voir MediaBase)
// Réponses par ack (ou même nom d'événement) : {ok:true, ...} | {ok:false, error, message}
IORequest("/UPLOAD/FILE", async (data) => {
if(!data) return IOAnswer("/UPLOAD/FILE", false)
if(!data.name) return IOAnswer("/UPLOAD/FILE", false)
const file = data.file
// Check wav or mp3
if(isAudioFile(file) == false) {
wlog.warn("Le fichier envoyé n'est pas un fichier audio valide (MP3/WAV)")
return IOAnswer("/UPLOAD/FILE", false)
IORequest("/UPLOAD/INFO", (_, ack) => {
answer("/UPLOAD/INFO", { ok: true, ...mediaBase.getInfo() }, ack, true)
}, true)
IORequest("/UPLOAD/FILE", async (data, ack) => {
const reply = (value) => answer("/UPLOAD/FILE", value, ack)
if(!data || typeof data !== "object" || typeof data.name !== "string" || !data.name.trim()) {
return reply({ ok: false, error: "INVALID_PARAMS", message: "Fichier manquant." })
}
const url = await mediaBase.postMedia(data, socketUser.identity.id)
if(!url) return IOAnswer("/UPLOAD/FILE", "TOOHIGH")
IOAnswer("/UPLOAD/FILE", {"url": url, "name": data.name})
// Le contenu est vérifié par ffprobe pendant la conversion (piste audio lisible)
reply(await mediaBase.postMedia({ name: data.name, file: data.file }, {
id: socketUser.identity.id,
username: socketUser.identity.username
}))
})
IORequest("/UPLOAD/FILES", async () => {
const files = await mediaBase.getAllMedia(socketUser.identity.id)
IOAnswer("/UPLOAD/FILES", files)
IORequest("/UPLOAD/FILES", async (_, ack) => {
answer("/UPLOAD/FILES", await mediaBase.getAllMedia(socketUser.identity.id), ack)
})
IORequest("/UPLOAD/FILE/DELETE", (data) => {
if(!data) return IOAnswer("/UPLOAD/FILE/DELETE", false)
mediaBase.deleteMedia(data, socketUser.identity.id)
IOAnswer("/UPLOAD/FILE/DELETE", true)
IORequest("/UPLOAD/FILE/DELETE", async (data, ack) => {
if(!data) return answer("/UPLOAD/FILE/DELETE", false, ack)
answer("/UPLOAD/FILE/DELETE", await mediaBase.deleteMedia(data, socketUser.identity.id), ack)
})
// GOOGLE API (compte YouTube lié : import et synchronisation des playlists)
+1 -1
View File
@@ -3,7 +3,7 @@
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé)
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé), `media.test.js` (conversion Opus avec ffmpeg-static, liens Discord expirants, MediaBase avec un faux Discord)
- Réseau (YouTube / GitHub / LRCLIB réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`, `lyrics.live.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
+67 -1
View File
@@ -79,6 +79,39 @@ const fakePlayer = {
const AllPlayers = new Map([['222222', fakePlayer]]);
const removed = [];
// Stockage des médias simulé (MediaBase) : une entrée Opus valide, une ancienne entrée manquante sur Discord
const HOUR = 3600 * 1000;
const hexSec = (ms) => Math.floor(ms / 1000).toString(16);
const cdnLink = (name, expiresAt) => `https://cdn.discordapp.com/attachments/1/2/${name}?ex=${hexSec(expiresAt)}&is=${hexSec(expiresAt - 24 * HOUR)}&hm=abcdef&`;
let mediaConnected = true;
const mediaCalls = { refresh: [], all: 0, deleted: [] };
const mediaEntries = [
{ id: '900000001', name: 'a.mp3', title: 'Titre A', author: 'Artiste', duration: 200, size: 3000000, originalSize: 8000000, bitrate: 96000,
format: 'opus', createdAt: '2026-10-08T10:00:00.000Z', userId: '333333', channelId: '777', url: cdnLink('a.ogg', Date.now() + 3 * HOUR), thumbnail: null },
{ id: '900000002', name: 'b.mp3', size: 5000000, createdAt: '2026-01-01T10:00:00.000Z', userId: '555555', uploaderName: 'parti',
url: 'https://cdn.discordapp.com/attachments/1/3/b.mp3', missing: true }
];
const fakeMediaBase = {
isConnected: () => mediaConnected,
getStorageStatus: () => ({ configured: true, connected: mediaConnected, guildId: '1', channelId: '2', channelName: 'stockage', premiumTier: 0,
storageLimitBytes: 10 * 1024 * 1024, maxInputBytes: 250 * 1024 * 1024, maxDurationSec: 2411, preferredBitrate: 96000 }),
listEntries: () => mediaEntries.map(e => ({ ...e })),
async refreshMedia(id) {
mediaCalls.refresh.push(id);
const entry = mediaEntries.find(e => e.id === id);
if (!entry) return undefined;
entry.url = cdnLink('a.ogg', Date.now() + 23 * HOUR);
return { ...entry };
},
async refreshAllMedia() { mediaCalls.all++; return { checked: 1, refreshed: 1, missing: 0 }; },
async adminDeleteMedia(id) {
const index = mediaEntries.findIndex(e => e.id === id);
if (index === -1) return null;
mediaCalls.deleted.push(id);
return mediaEntries.splice(index, 1)[0];
}
};
const stubs = {
[norm(SRC + '/server/auth/User')]: {
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
@@ -91,7 +124,7 @@ const stubs = {
getChannel: () => null
},
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/discord/MediaBase')]: fakeMediaBase,
[norm(SRC + '/playlists/PlaylistManager')]: {
removePlaylist(userId, playlistId) {
const list = getDatabase('Playlists').data[userId] || [];
@@ -343,6 +376,39 @@ process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
console.log('# Médias');
// Un titre du fichier A dans une playlist : compté dans "playlists"
playlistsDb.data['333333'][0].songs.push({ id: '900000001', type: 'attachment' });
r = await call('/ADMIN/MEDIA/LIST', {});
const mediaA = r.ok ? r.data.items.find(i => i.id === '900000001') : null;
const mediaB = r.ok ? r.data.items.find(i => i.id === '900000002') : null;
check('LIST : état du stockage + 2 fichiers', r.ok && r.data.status?.channelName === 'stockage' && r.data.items.length === 2, r);
check('LIST : expiration lue depuis le lien', mediaA && Math.abs(Date.parse(mediaA.expiresAt) - (Date.now() + 3 * HOUR)) < 2000, mediaA?.expiresAt);
check('LIST : propriétaire résolu et référence en playlist', mediaA?.uploader?.username === 'proprio' && mediaA.playlists === 1 && mediaA.format === 'opus', mediaA);
check('LIST : ancien fichier manquant, sans signature, propriétaire supprimé', mediaB?.missing === true && mediaB.expiresAt === null && mediaB.format === 'legacy'
&& mediaB.uploader?.username === 'parti' && mediaB.playlists === 0, mediaB);
check('LIST : plus récent en premier', r.ok && r.data.items[0].id === '900000001');
r = await call('/ADMIN/MEDIA/REFRESH', { id: '900000001' });
check('REFRESH {id} : lien renouvelé', r.ok && mediaCalls.refresh.includes('900000001') && Date.parse(r.data.item.expiresAt) > Date.now() + 20 * HOUR, r);
r = await call('/ADMIN/MEDIA/REFRESH', { id: '123456789' });
check('REFRESH id inconnu -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND', r);
r = await call('/ADMIN/MEDIA/REFRESH', { id: '../x' });
check('REFRESH id invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/MEDIA/REFRESH', {});
check('REFRESH global', r.ok && mediaCalls.all === 1 && r.data.refreshed === 1, r);
mediaConnected = false;
r = await call('/ADMIN/MEDIA/REFRESH', {});
check('REFRESH hors connexion -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
mediaConnected = true;
r = await call('/ADMIN/MEDIA/DELETE', { id: '900000002' });
check('DELETE : supprimé', r.ok && r.data.deleted === true && mediaCalls.deleted.includes('900000002') && mediaEntries.length === 1, r);
r = await call('/ADMIN/MEDIA/DELETE', { id: '900000002' });
check('DELETE déjà supprimé -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND', r);
r = await call('/ADMIN/MEDIA/DELETE', {});
check('DELETE sans id -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/OVERVIEW', {});
check('vue d\'ensemble : compteurs médias présents', r.ok && typeof r.data.media?.files === 'number' && typeof r.data.media?.missing === 'number', r.ok ? r.data.media : r);
console.log('# Journaux');
await sleep(300);
const logDir = path.join(__dirname, 'logs');
+237
View File
@@ -0,0 +1,237 @@
// Stockage des fichiers envoyés : conversion Opus (ffmpeg-static réel), liens Discord expirants,
// MediaBase avec un faux client Discord. Dossier data temporaire : jamais backend/data.
const { BACKEND, SCRATCH, __glob, check, done } = require('./_setup');
const path = require('path');
const fs = require('fs');
const { EventEmitter } = require('events');
const { spawnSync } = require('child_process');
const DATA = fs.mkdtempSync(path.join(SCRATCH, 'media-'));
__glob.MEDIA_DB = path.join(DATA, 'media.json');
__glob.METRIC_FILE = path.join(DATA, 'metrics.json');
const src = (rel) => path.join(BACKEND, 'src', rel);
const ffmpeg = require(path.join(BACKEND, 'node_modules', 'ffmpeg-static'));
const discordUrl = require(src('media/DiscordUrl'));
const audioPrepare = require(src('media/AudioPrepare'));
const HOUR = 3600 * 1000;
const hex = (ms) => Math.floor(ms / 1000).toString(16);
const cdn = (name, expiresAt, n = 0) => `https://cdn.discordapp.com/attachments/111/${222 + n}/${name}?ex=${hex(expiresAt)}&is=${hex(expiresAt - 24 * HOUR)}&hm=${'ab'.repeat(8)}${n}&`;
function makeFixture(file, args) {
const res = spawnSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', ...args, file], { windowsHide: true });
if (res.status !== 0) throw new Error('ffmpeg fixture: ' + res.stderr.toString());
return fs.readFileSync(file);
}
(async () => {
console.log('DiscordUrl');
const now = Date.now();
const fresh = cdn('a.ogg', now + 20 * HOUR);
check('lien CDN reconnu', discordUrl.isDiscordCdn(fresh));
check('lien non Discord ignoré', !discordUrl.isDiscordCdn('https://example.com/a.ogg') && !discordUrl.needsRefresh('https://example.com/a.ogg'));
check('http refusé', !discordUrl.isDiscordCdn('http://cdn.discordapp.com/a.ogg'));
check('expiration lue (hex)', Math.abs(discordUrl.getExpiry(fresh) - (now + 20 * HOUR)) < 1000);
check('lien valide : pas de renouvellement', !discordUrl.needsRefresh(fresh, HOUR, now));
check('lien proche de l\'échéance : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now + 30 * 60 * 1000), HOUR, now));
check('lien expiré : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now - HOUR), HOUR, now));
check('lien non signé : renouvellement', discordUrl.needsRefresh('https://cdn.discordapp.com/attachments/1/2/a.ogg'));
check('base identique malgré la signature', discordUrl.baseOf(fresh) === discordUrl.baseOf(cdn('a.ogg', now + 5 * HOUR)));
console.log('Planification du débit');
const MiB = 1024 * 1024;
check('court : débit préféré (96k)', audioPrepare.planBitrate(180, 10 * MiB) === 96000);
const long = audioPrepare.planBitrate(20 * 60, 10 * MiB);
check('20 min dans 10 Mo : débit réduit ≥ 32k', long >= 32000 && long < 96000 && long % 1000 === 0, long);
check('trop long : refusé', audioPrepare.planBitrate(60 * 60, 10 * MiB) === null);
check('durée inconnue : 64k', audioPrepare.planBitrate(0, 10 * MiB) === 64000);
check('durée max ~38 min pour 10 Mo', Math.abs(audioPrepare.maxDurationFor(10 * MiB) - 2411) < 5, audioPrepare.maxDurationFor(10 * MiB));
check('nom de fichier sûr', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg') === 'Ete_mon_titre.ogg', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg'));
check('titre depuis le nom', audioPrepare.titleFromName('Mon_titre_final.wav') === 'Mon titre final');
console.log('Conversion (ffmpeg réel)');
const cover = path.join(DATA, 'cover.png');
makeFixture(cover, ['-f', 'lavfi', '-i', 'color=c=red:s=64x64', '-frames:v', '1']);
const mp3 = makeFixture(path.join(DATA, 'tagged.mp3'), [
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=20', '-i', cover,
'-map', '0:a', '-map', '1:v', '-c:a', 'libmp3lame', '-b:a', '192k', '-c:v', 'mjpeg',
'-disposition:v', 'attached_pic', '-id3v2_version', '3',
'-metadata', 'title=Titre test', '-metadata', 'artist=Artiste test'
]);
const wav = makeFixture(path.join(DATA, 'plain.wav'), ['-f', 'lavfi', '-i', 'sine=frequency=220:duration=8']);
const ok = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 10 * MiB });
check('MP3 converti', ok.ok === true, ok);
check('sortie Ogg/Opus', ok.audio?.subarray(0, 4).toString() === 'OggS' && ok.audio.includes(Buffer.from('OpusHead')));
check('étiquettes lues', ok.meta?.title === 'Titre test' && ok.meta?.artist === 'Artiste test', ok.meta);
check('durée ~20 s', Math.abs((ok.meta?.duration || 0) - 20) < 0.5, ok.meta?.duration);
check('pochette extraite (JPEG)', ok.cover?.subarray(0, 2).toString('hex') === 'ffd8' && ok.coverName === 'cover.jpg');
check('débit 96k', ok.bitrate === 96000, ok.bitrate);
check('nom .ogg', ok.audioName === 'tagged.ogg');
check('plus léger que l\'original', ok.audio.length < mp3.length, [ok.audio.length, mp3.length]);
const plain = await audioPrepare.prepare(wav, 'Ma prise.wav', { limitBytes: 10 * MiB });
check('WAV sans étiquettes : titre du nom', plain.ok && plain.meta.title === 'Ma prise' && plain.meta.artist === null && plain.cover === null, plain.meta);
const tight = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 150 * 1024 });
check('budget serré : débit abaissé et respecté', tight.ok && tight.bitrate < 96000 && tight.audio.length + (tight.cover?.length || 0) <= 150 * 1024, tight.ok ? [tight.bitrate, tight.audio.length] : tight);
const tooLong = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 40 * 1024 });
check('trop long pour la limite : TOO_LONG', tooLong.ok === false && tooLong.error === 'TOO_LONG', tooLong);
const garbage = await audioPrepare.prepare(Buffer.from('ceci n\'est pas un fichier audio'.repeat(50)), 'faux.mp3', { limitBytes: 10 * MiB });
check('fichier non audio : INVALID_FILE', garbage.ok === false && garbage.error === 'INVALID_FILE', garbage);
const empty = await audioPrepare.prepare(Buffer.alloc(0), 'vide.mp3', { limitBytes: 10 * MiB });
check('fichier vide : INVALID_FILE', empty.error === 'INVALID_FILE');
check('aucun dossier temporaire laissé', fs.readdirSync(require('os').tmpdir()).filter(e => e.startsWith('chopin-media-')).length === 0);
console.log('MediaBase (faux Discord)');
const { Collection } = require(path.join(BACKEND, 'node_modules', 'discord.js'));
let messageSeq = 900;
const messages = new Map();
const calls = { refresh: 0, fetch: 0, delete: 0 };
let refreshMode = 'ok';
let nextExpiry = now + 20 * HOUR;
function buildMessage(id, files) {
const attachments = new Collection();
files.forEach((f, i) => attachments.set(String(i), { name: f.name, url: cdn(f.name, nextExpiry, Number(id) + i), size: f.size }));
return { id, channelId: 'chan', attachments };
}
const channel = {
id: 'chan',
guild: { premiumTier: 0 },
send: async (opts) => {
const id = String(++messageSeq);
const files = opts.files.map(a => ({ name: a.name, size: a.attachment.length }));
messages.set(id, files);
return buildMessage(id, files);
},
messages: {
fetch: async ({ message }) => {
calls.fetch++;
if (!messages.has(message)) throw Object.assign(new Error('Unknown Message'), { code: 10008 });
return buildMessage(message, messages.get(message));
},
delete: async (id) => { calls.delete++; messages.delete(id); }
}
};
const client = new EventEmitter();
client.isReady = () => true;
client.channels = { fetch: async () => channel };
client.rest = {
post: async (route, { body }) => {
calls.refresh++;
if (route !== '/attachments/refresh-urls') throw new Error('route inattendue');
if (refreshMode === 'fail') throw new Error('refusé');
return { refreshed_urls: body.attachment_urls.map(u => ({ original: u, refreshed: u.replace(/ex=[0-9a-f]+/, 'ex=' + hex(now + 23 * HOUR)) })) };
}
};
const stub = (rel, exports) => {
const file = require.resolve(src(rel));
require.cache[file] = { id: file, filename: file, loaded: true, exports };
};
stub('discord/Bot', { getClient: () => client, getChannel: () => channel });
stub('utils/Database/Configuration', { getMediaGuildId: () => '1', getMediaChannelId: () => '2' });
const mediaBase = require(src('discord/MediaBase'));
check('non connecté avant ready', mediaBase.isConnected() === false);
const refused = await mediaBase.postMedia({ name: 'a.mp3', file: mp3 }, { id: 'u1' });
check('envoi refusé tant que le stockage est indisponible', refused.ok === false && refused.error === 'UNAVAILABLE');
client.emit('ready');
await new Promise(r => setImmediate(r));
check('connecté après ready', mediaBase.isConnected() === true);
const info = mediaBase.getInfo();
check('limite 10 Mo (serveur non boosté)', info.storageLimitBytes === 10 * MiB && info.available === true && info.maxDurationSec > 30 * 60, info);
channel.guild.premiumTier = 3;
check('limite 100 Mo au niveau 3', mediaBase.getStorageLimitBytes() === 100 * MiB);
channel.guild.premiumTier = 0;
const posted = await mediaBase.postMedia({ name: 'tagged.mp3', file: mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.length) }, { id: 'u1', username: 'alice' });
check('envoi OK (ArrayBuffer accepté)', posted.ok === true, posted);
check('morceau renvoyé', posted.song?.type === 'attachment' && posted.song.title === 'Titre test' && posted.song.author === 'Artiste test' && Math.round(posted.song.duration) === 20, posted.song);
check('pochette en miniature', /cover\.jpg/.test(posted.song?.thumbnail || ''));
const stored = JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
check('entrée persistée avec identifiant du message', stored.length === 1 && stored[0].id === posted.song.id && stored[0].format === 'opus' && stored[0].userId === 'u1' && stored[0].channelId === 'chan', stored[0]);
const badFile = await mediaBase.postMedia({ name: 'faux.mp3', file: Buffer.from('nope'.repeat(100)) }, { id: 'u1' });
check('fichier invalide refusé sans message Discord', badFile.ok === false && badFile.error === 'INVALID_FILE' && messages.size === 1);
const noName = await mediaBase.postMedia({ name: '', file: mp3 }, { id: 'u1' });
check('nom manquant refusé', noName.ok === false);
let list = await mediaBase.getAllMedia('u1');
check('liste : 1 fichier, aucun renouvellement inutile', list.length === 1 && calls.refresh === 0 && calls.fetch === 0, calls);
check('autre utilisateur : liste vide', (await mediaBase.getAllMedia('u2')).length === 0);
// Lien expiré : renouvellement groupé, l'entrée n'est plus supprimée comme avant
const db = () => JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
const entry = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[0];
entry.url = cdn('tagged.ogg', now - HOUR, 900);
entry.thumbnail = cdn('cover.jpg', now - HOUR, 901);
list = await mediaBase.getAllMedia('u1');
check('lien expiré renouvelé via refresh-urls', list.length === 1 && calls.refresh === 1 && !discordUrl.needsRefresh(list[0].url) && !discordUrl.needsRefresh(list[0].thumbnail), calls);
check('renouvellement persisté', !discordUrl.needsRefresh(db()[0].url));
// refresh-urls indisponible : relecture du message
refreshMode = 'fail';
entry.url = cdn('tagged.ogg', now - HOUR, 900);
list = await mediaBase.getAllMedia('u1');
check('repli : relecture du message', list.length === 1 && calls.fetch === 1 && !discordUrl.needsRefresh(list[0].url), calls);
// Lecture : lien frais + format Opus (transmis sans ffmpeg)
entry.url = cdn('tagged.ogg', now - HOUR, 900);
const playable = await mediaBase.resolvePlayableUrl({ id: posted.song.id, url: 'https://cdn.discordapp.com/vieux' });
check('lecture : lien frais et format opus', playable && playable.format === 'opus' && !discordUrl.needsRefresh(playable.url), playable);
const forced = await mediaBase.resolvePlayableUrl({ id: posted.song.id }, { force: true });
check('lecture forcée : message relu', forced && calls.fetch === 3, calls);
refreshMode = 'ok';
const foreign = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: cdn('x.mp3', now - HOUR, 5) });
check('pièce jointe hors stockage renouvelée', foreign && foreign.format === null && !discordUrl.needsRefresh(foreign.url), foreign);
const external = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: 'https://example.com/a.mp3' });
check('lien non Discord inchangé', external?.url === 'https://example.com/a.mp3');
// Message supprimé sur Discord : masqué, mais l'entrée reste en base
const second = await mediaBase.postMedia({ name: 'plain.wav', file: wav }, { id: 'u1', username: 'alice' });
check('second envoi OK', second.ok === true && second.song.title === 'plain');
messages.delete(second.song.id);
const entry2 = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[1];
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'fail';
list = await mediaBase.getAllMedia('u1');
check('message supprimé : fichier masqué', list.length === 1 && list[0].id === posted.song.id, list.map(s => s.id));
check('entrée conservée et marquée manquante', db().length === 2 && db()[1].missing === true);
check('lecture d\'un fichier manquant : null', (await mediaBase.resolvePlayableUrl({ id: second.song.id })) === null);
// Suppression
check('suppression par un autre utilisateur refusée', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u2')) === false);
check('suppression OK', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u1')) === true && calls.delete === 1 && !messages.has(posted.song.id));
check('base mise à jour', db().length === 1 && db()[0].id === second.song.id);
console.log('Administration');
let pushes = 0;
process.on('MEDIA_UPDATE', () => { pushes++; });
const status = mediaBase.getStorageStatus();
check('état du stockage', status.connected === true && status.configured === true && status.premiumTier === 0 && status.storageLimitBytes === 10 * MiB, status);
const copy = mediaBase.listEntries();
copy[0].title = 'modifié';
check('listEntries renvoie une copie', mediaBase.listEntries()[0].title !== 'modifié');
check('fichier inconnu : undefined', (await mediaBase.refreshMedia('123')) === undefined);
check('fichier toujours absent de Discord : null', (await mediaBase.refreshMedia(second.song.id)) === null && db()[0].missing === true);
// Message rétabli (salon restauré) : la vérification manuelle lève le marquage
messages.set(second.song.id, [{ name: 'plain.ogg', size: 1000 }]);
const restored = await mediaBase.refreshMedia(second.song.id);
check('fichier rétabli : marquage levé', restored && !restored.missing && !db()[0].missing && !discordUrl.needsRefresh(restored.url), restored);
check('poussée MEDIA_UPDATE à chaque enregistrement', pushes >= 2, pushes);
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'ok';
const all = await mediaBase.refreshAllMedia();
check('renouvellement global', all.checked === 1 && all.refreshed === 1 && all.missing === 0, all);
const none = await mediaBase.refreshAllMedia();
check('rien à renouveler', none.checked === 0);
const removedByAdmin = await mediaBase.adminDeleteMedia(second.song.id);
check('suppression admin quel que soit le propriétaire', removedByAdmin?.userId === 'u1' && db().length === 0 && !messages.has(second.song.id));
check('suppression admin d\'un inconnu : null', (await mediaBase.adminDeleteMedia(second.song.id)) === null);
done();
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});