diff --git a/src/discord/MediaBase.js b/src/discord/MediaBase.js index 8b8b65f..8dbdc38 100644 --- a/src/discord/MediaBase.js +++ b/src/discord/MediaBase.js @@ -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} 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} 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} 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 -} \ No newline at end of file + getAllMedia, + resolvePlayableUrl, + isConnected, + getInfo, + getStorageLimitBytes, + getStorageStatus, + listEntries, + refreshMedia, + refreshAllMedia, + adminDeleteMedia +} diff --git a/src/media/AudioPrepare.js b/src/media/AudioPrepare.js new file mode 100644 index 0000000..520df4b --- /dev/null +++ b/src/media/AudioPrepare.js @@ -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 +} diff --git a/src/media/DiscordUrl.js b/src/media/DiscordUrl.js new file mode 100644 index 0000000..a142a04 --- /dev/null +++ b/src/media/DiscordUrl.js @@ -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 } diff --git a/src/player/Method/Media.js b/src/player/Method/Media.js index c305f89..2b3e42f 100644 --- a/src/player/Method/Media.js +++ b/src/player/Method/Media.js @@ -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} 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 diff --git a/src/player/Player.js b/src/player/Player.js index e9c5188..c48dc44 100644 --- a/src/player/Player.js +++ b/src/player/Player.js @@ -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) { diff --git a/src/server/Admin.js b/src/server/Admin.js index cfd5399..b3ffc63 100644 --- a/src/server/Admin.js +++ b/src/server/Admin.js @@ -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 diff --git a/src/server/Server.js b/src/server/Server.js index 22b34d2..c4f683b 100644 --- a/src/server/Server.js +++ b/src/server/Server.js @@ -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) diff --git a/tests/dev/README.md b/tests/dev/README.md index a77ae1b..16e2ba2 100644 --- a/tests/dev/README.md +++ b/tests/dev/README.md @@ -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/.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). diff --git a/tests/dev/admin-config.test.js b/tests/dev/admin-config.test.js index 9848c28..c833830 100644 --- a/tests/dev/admin-config.test.js +++ b/tests/dev/admin-config.test.js @@ -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'); diff --git a/tests/dev/media.test.js b/tests/dev/media.test.js new file mode 100644 index 0000000..9f2e1cd --- /dev/null +++ b/tests/dev/media.test.js @@ -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; +});