Version 2.1.1 - modification d'une certaine UI
This commit is contained in:
1 parent
dcc350241e
commit
fc71b10294
10 files changed
+1204
-135
No files matched your search
+429
-100
@@ -6,11 +6,39 @@ 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user